比较提交
6
次代码提交
8353efcbd7
...
1a2c3b786e
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
1a2c3b786e | ||
|
|
87316044d1 | ||
|
|
63acc2db92 | ||
|
|
46981380a0 | ||
|
|
a08db8590a | ||
|
|
a15bbcbadb |
+1
@@ -13,6 +13,7 @@ server/generated/storage
|
||||
# trialrun deployment artifacts
|
||||
deploy/trialrun/data
|
||||
deploy/trialrun/backups
|
||||
deploy/trialrun/screenshots/
|
||||
|
||||
# Playwright artifacts
|
||||
client/test-results
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# R69 业务验收报告
|
||||
|
||||
日期:2026-04-26
|
||||
|
||||
## 结论
|
||||
|
||||
R69 自动化业务验收已通过。当前无阻断级自动化缺陷,可进入 10 人以下小团队的人工试运行验收。
|
||||
|
||||
本轮修复聚焦两类问题:
|
||||
|
||||
- 会话刷新并发:前端 refresh 增加 single-flight 与短窗口成功回放,避免同页并发刷新重复消耗旋转 refresh token。
|
||||
- E2E 与页面稳态:校准 R68 UI 后的断言,修复移动审批 WebKit 在线态误判,修复资产新建表单被旧详情回写污染的问题,并规避并发 E2E 的日期/详情定位冲突。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `cd client && npm run typecheck`:通过。
|
||||
- `cd client && npm run build`:通过;保留既有 chunk 循环与大 chunk 警告。
|
||||
- `QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local`:通过。
|
||||
- `full-local` 覆盖结果:Prisma migrate/validate 通过,server 单测 98 files / 429 tests 通过,workflow-actions 通过,regression-all 默认范围通过,Playwright full E2E 34 passed / 3 skipped。
|
||||
|
||||
## 风险与下一步
|
||||
|
||||
- 未修改后端 workflow、权限模型、状态机、审批规则或 Prisma 迁移,因此未触发额外 reviewer 阶段。
|
||||
- `client build` 仍提示既有 manual chunk 循环和 `page-system` 大 chunk,建议 R70 独立处理构建分包。
|
||||
- R70 建议进入人工业务试运行:让员工、经理、管理员各按真实日常路径试用 1-2 天,记录非阻断体验问题与流程缺口。
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
loginWithPassword,
|
||||
logoutSession,
|
||||
refreshSession,
|
||||
resetRefreshSessionCache,
|
||||
} from "./authService";
|
||||
import { resolvePermissionsForRoles } from "./permissions";
|
||||
|
||||
@@ -246,6 +247,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
}, [clearProactiveRefreshTimer]);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
resetRefreshSessionCache();
|
||||
clearSessionHint();
|
||||
markUnauthenticatedReady();
|
||||
void clearPwaRuntimeCaches();
|
||||
@@ -427,6 +429,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const changePassword = useCallback(
|
||||
async (oldPassword: string, newPassword: string) => {
|
||||
const result = await changePasswordApi({ oldPassword, newPassword });
|
||||
resetRefreshSessionCache();
|
||||
const me = await fetchCurrentEmployee(result.accessToken);
|
||||
commitAuthenticatedUser(result.accessToken, me, false);
|
||||
scheduleProactiveRefresh(result.expiresIn);
|
||||
|
||||
@@ -6,7 +6,20 @@ const JSON_HEADERS = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const REFRESH_SUCCESS_REPLAY_MS = 1000;
|
||||
|
||||
let refreshSessionPromise: Promise<LoginResponse> | null = null;
|
||||
let refreshSessionReplay: { data: LoginResponse; expiresAt: number } | null = null;
|
||||
let refreshSessionGeneration = 0;
|
||||
|
||||
export function resetRefreshSessionCache() {
|
||||
refreshSessionGeneration += 1;
|
||||
refreshSessionPromise = null;
|
||||
refreshSessionReplay = null;
|
||||
}
|
||||
|
||||
export async function loginWithPassword(payload: LoginRequest) {
|
||||
resetRefreshSessionCache();
|
||||
return apiRequestData<LoginResponse>("/api/auth/login", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
@@ -19,7 +32,7 @@ export async function loginWithPassword(payload: LoginRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshSession() {
|
||||
async function requestRefreshSession() {
|
||||
return apiRequestData<LoginResponse>("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
@@ -30,12 +43,54 @@ export async function refreshSession() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshSession() {
|
||||
const now = Date.now();
|
||||
if (refreshSessionReplay && refreshSessionReplay.expiresAt > now) {
|
||||
return refreshSessionReplay.data;
|
||||
}
|
||||
|
||||
if (!refreshSessionPromise) {
|
||||
const generation = refreshSessionGeneration;
|
||||
const requestPromise = requestRefreshSession()
|
||||
.then((data) => {
|
||||
if (generation === refreshSessionGeneration) {
|
||||
refreshSessionReplay = {
|
||||
data,
|
||||
expiresAt: Date.now() + REFRESH_SUCCESS_REPLAY_MS,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (generation === refreshSessionGeneration) {
|
||||
refreshSessionReplay = null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
refreshSessionPromise = requestPromise;
|
||||
void requestPromise
|
||||
.finally(() => {
|
||||
if (refreshSessionPromise === requestPromise) {
|
||||
refreshSessionPromise = null;
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return refreshSessionPromise;
|
||||
}
|
||||
|
||||
export async function logoutSession() {
|
||||
return apiRequestData<Record<string, never>>("/api/auth/logout", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
retryOn401: false,
|
||||
});
|
||||
try {
|
||||
return await apiRequestData<Record<string, never>>("/api/auth/logout", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
retryOn401: false,
|
||||
});
|
||||
} finally {
|
||||
resetRefreshSessionCache();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCurrentEmployee(accessToken: string) {
|
||||
|
||||
@@ -10,8 +10,12 @@ type EmptyStateCardProps = {
|
||||
export function EmptyStateCard({ title, description, footer, icon }: EmptyStateCardProps) {
|
||||
return (
|
||||
<section className="card-std p-8 text-center">
|
||||
{icon ? <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-500">{icon}</div> : null}
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
{icon ? (
|
||||
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
||||
<p className="card-desc mx-auto max-w-xl">{description}</p>
|
||||
{footer ? <div className="mt-5 flex justify-center">{footer}</div> : null}
|
||||
</section>
|
||||
|
||||
@@ -15,9 +15,11 @@ export function PageHeader({ title, subtitle, actions, eyebrow, meta, className
|
||||
return (
|
||||
<header className={cn("flex flex-wrap items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
{eyebrow ? <div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{eyebrow}</div> : null}
|
||||
<h2 className="mt-1 text-[2rem] font-semibold tracking-tight text-zinc-900">{title}</h2>
|
||||
{subtitle ? <p className="mt-2 text-sm leading-6 text-zinc-600">{subtitle}</p> : null}
|
||||
{eyebrow ? (
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">{eyebrow}</div>
|
||||
) : null}
|
||||
<h2 className="mt-1 text-[2rem] font-semibold tracking-tight text-[var(--color-text-primary)]">{title}</h2>
|
||||
{subtitle ? <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
|
||||
|
||||
@@ -75,10 +75,10 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition-colors",
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition duration-200",
|
||||
isActive
|
||||
? "bg-zinc-900 text-white shadow-sm"
|
||||
: "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
? "bg-primary text-white shadow-sm"
|
||||
: "text-[var(--color-text-secondary)] hover:-translate-y-0.5 hover:bg-white/70 hover:text-[var(--color-text-primary)] hover:shadow-sm",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -88,8 +88,8 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-xl border text-[20px] transition-colors",
|
||||
isActive
|
||||
? "border-white/15 bg-white/10 text-white"
|
||||
: "border-zinc-200 bg-white text-zinc-500 group-hover:border-zinc-300 group-hover:text-zinc-900",
|
||||
? "border-white/15 bg-white/15 text-white"
|
||||
: "border-slate-200 bg-white text-[var(--color-text-tertiary)] group-hover:border-slate-300 group-hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
@@ -109,8 +109,8 @@ function renderSection(title: string, description: string, items: ResolvedNavIte
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="px-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">{title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||
<div className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary/70">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{description}</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</section>
|
||||
@@ -123,13 +123,13 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="rounded-2xl border border-zinc-200 bg-zinc-50/80 px-3 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-zinc-700">
|
||||
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<div>
|
||||
<div>备用模块</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-zinc-500">默认收起,不干扰主路径</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">默认收起,不干扰主路径</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-zinc-400">按需展开</span>
|
||||
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">按需展开</span>
|
||||
</summary>
|
||||
<div className="mt-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</details>
|
||||
|
||||
@@ -3,16 +3,19 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-zinc-200 bg-white/95 backdrop-blur lg:flex lg:flex-col">
|
||||
<div className="border-b border-zinc-200 px-6 py-6">
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 bg-white/92 shadow-[18px_0_50px_rgba(15,23,42,0.08)] backdrop-blur-xl lg:flex lg:flex-col">
|
||||
<div className="border-b border-slate-200 px-6 py-6">
|
||||
<AppBrand />
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">统一工作台</div>
|
||||
<div className="mt-1">先看今天要做什么,再进入具体模块处理。</div>
|
||||
<div className="mt-5 overflow-hidden rounded-3xl border border-slate-200 bg-[linear-gradient(135deg,rgba(0,57,138,0.08),rgba(19,168,184,0.10))] px-4 py-4 text-sm text-[var(--color-text-secondary)]">
|
||||
<div className="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
今日工作台
|
||||
</div>
|
||||
<div className="mt-2 leading-6">先处理待办和团队状态,再进入具体模块。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 [scrollbar-width:thin]">
|
||||
<NavLinks />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -135,7 +135,7 @@ export function Topbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/90 px-4 py-4 backdrop-blur sm:px-8">
|
||||
<header className="sticky top-0 z-10 border-b border-slate-200/80 bg-white/88 px-4 py-3 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl sm:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
@@ -145,9 +145,9 @@ export function Topbar() {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="left-0 top-0 h-full w-80 max-w-[84vw] rounded-none rounded-r-3xl border-r border-zinc-200 p-0">
|
||||
<DialogContent className="left-0 top-0 h-full w-80 max-w-[84vw] rounded-none rounded-r-3xl border-r border-slate-200 p-0">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex items-start justify-between border-b border-zinc-200 px-5 py-5">
|
||||
<div className="flex items-start justify-between border-b border-slate-200 px-5 py-5">
|
||||
<AppBrand />
|
||||
<DialogClose asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭菜单" type="button">
|
||||
@@ -164,9 +164,9 @@ export function Topbar() {
|
||||
</Dialog>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">{pageMeta.group}</div>
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary/70">{pageMeta.group}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-lg font-semibold tracking-tight text-zinc-900">{pageMeta.title}</p>
|
||||
<p className="truncate text-lg font-semibold tracking-tight text-[var(--color-text-primary)]">{pageMeta.title}</p>
|
||||
{wecomEntry ? (
|
||||
<span
|
||||
className="badge-neutral border-primary-200 bg-primary-50 text-primary"
|
||||
@@ -176,7 +176,7 @@ export function Topbar() {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">{me ? `你好,${me.name}` : "欢迎进入海音达工作台"}</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-secondary)]">{me ? `你好,${me.name}` : "欢迎进入海音达工作台"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,11 +224,11 @@ export function Topbar() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-80 max-w-[calc(100vw-2rem)] p-0">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
|
||||
<DropdownMenuContent align="end" className="w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border-slate-200 bg-white p-0">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-zinc-900">通知</span>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">通知</span>
|
||||
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-[var(--color-text-secondary)]">
|
||||
{unreadCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -244,7 +244,7 @@ export function Topbar() {
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{dropdownQuery.isLoading ? (
|
||||
<div className="px-4 py-6 text-sm text-zinc-500">通知加载中...</div>
|
||||
<div className="px-4 py-6 text-sm text-[var(--color-text-secondary)]">通知加载中...</div>
|
||||
) : null}
|
||||
|
||||
{dropdownQuery.isError ? (
|
||||
@@ -257,7 +257,7 @@ export function Topbar() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-start gap-3 border-b border-zinc-100 px-4 py-3 text-left transition hover:bg-zinc-50 disabled:opacity-60"
|
||||
className="flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition hover:bg-slate-50 disabled:opacity-60"
|
||||
disabled={markReadMutation.isPending}
|
||||
onClick={() => void handleNotificationClick(item)}
|
||||
>
|
||||
@@ -265,20 +265,20 @@ export function Topbar() {
|
||||
{!item.readAt ? <span className="h-2 w-2 rounded-full bg-sky-500" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-zinc-900">{item.title}</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{item.title}</span>
|
||||
<span className="mt-1 block text-xs text-[var(--color-text-secondary)]">
|
||||
{formatNotificationRelativeTime(item.createdAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-zinc-500">暂无通知</div>
|
||||
<div className="px-4 py-6 text-sm text-[var(--color-text-secondary)]">暂无通知</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-zinc-200 px-2 py-2">
|
||||
<div className="border-t border-slate-200 px-2 py-2">
|
||||
<Button asChild variant="ghost" className="w-full justify-between" type="button">
|
||||
<Link to="/notifications" onClick={() => setNotificationsOpen(false)}>
|
||||
查看全部通知
|
||||
@@ -295,7 +295,7 @@ export function Topbar() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuContent align="end" className="w-48 rounded-2xl border-slate-200 bg-white">
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/profile">个人设置</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function EntityOverviewHero({
|
||||
{subtitle ? <p className="workbench-hero__subtitle">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
{actions ? <div className="shrink-0 rounded-2xl border border-white/70 bg-white/55 p-2 shadow-sm backdrop-blur">{actions}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@ export function ActionToolbar({ className, children }: { className?: string; chi
|
||||
}
|
||||
|
||||
export function PrimaryTable({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <section className={cn("card-std p-0 overflow-hidden", className)}>{children}</section>;
|
||||
return <section className={cn("table-container p-0", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
@@ -18,11 +18,14 @@ export function MetricCard({
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative z-[1]">
|
||||
<div className="metric-card__title">{title}</div>
|
||||
<div className="metric-card__value">{value}</div>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="metric-card__value">{value}</div>
|
||||
{href ? <span className="material-symbols-outlined text-[18px] text-primary/60">arrow_forward</span> : null}
|
||||
</div>
|
||||
{note ? <div className="metric-card__note">{note}</div> : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function WorkbenchHero({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="workbench-hero">
|
||||
<section className={cn("workbench-hero", className)}>
|
||||
<div>
|
||||
{eyebrow ? <div className="workbench-hero__eyebrow">{eyebrow}</div> : null}
|
||||
<h1 className="workbench-hero__title">{title}</h1>
|
||||
{subtitle ? <p className="workbench-hero__subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
{actions ? <div className="shrink-0 rounded-2xl border border-white/70 bg-white/55 p-2 shadow-sm backdrop-blur">{actions}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export function AppBrand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center rounded-2xl bg-[var(--color-accent-primary)] font-bold text-white shadow-sm ${compact ? "size-9 text-base" : "size-11 text-lg"}`}>
|
||||
H
|
||||
<div className={`flex items-center justify-center text-primary ${compact ? "size-9" : "size-11"}`}>
|
||||
<svg aria-hidden="true" className="size-full" fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6H42L36 24L42 42H6L12 24L6 6Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-zinc-900">海音达工作台</div>
|
||||
<div className="text-xs text-zinc-500">企业微信入口 + 工作台主平台</div>
|
||||
<div className="text-sm font-semibold tracking-tight text-[var(--color-text-primary)]">海音达工作台</div>
|
||||
<div className="text-xs text-[var(--color-text-secondary)]">Hyinda OA · 小团队运营中枢</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,15 +4,15 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-300 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)] shadow-sm",
|
||||
secondary: "border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 shadow-sm",
|
||||
outline: "border border-zinc-200 bg-transparent text-zinc-900 hover:bg-zinc-50",
|
||||
default: "bg-[var(--color-accent-primary)] text-white shadow-sm hover:-translate-y-0.5 hover:bg-[var(--color-accent-primary-hover)] hover:shadow-md",
|
||||
secondary: "border border-slate-200 bg-white/90 text-slate-700 shadow-sm hover:-translate-y-0.5 hover:bg-white hover:shadow-md",
|
||||
outline: "border border-slate-200 bg-transparent text-slate-900 hover:bg-white/80",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm",
|
||||
ghost: "bg-transparent text-zinc-700 hover:bg-zinc-100",
|
||||
ghost: "bg-transparent text-slate-700 hover:bg-slate-100",
|
||||
link: "bg-transparent text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
||||
+21
-15
@@ -1,25 +1,31 @@
|
||||
:root {
|
||||
--color-bg-canvas: #f5f7fa;
|
||||
--color-bg-canvas: #f5f7f8;
|
||||
--color-bg-canvas-deep: #e8eef5;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-surface-subtle: #fafafb;
|
||||
--color-bg-primary-soft: #f2f5f8;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #667085;
|
||||
--color-text-tertiary: #98a2b3;
|
||||
--color-border-default: #e4e7ec;
|
||||
--color-border-strong: #d0d5dd;
|
||||
--color-accent-primary: #111827;
|
||||
--color-accent-primary-hover: #1f2937;
|
||||
--color-bg-surface-subtle: #f8fafc;
|
||||
--color-bg-surface-raised: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-primary-soft: #e6ecf4;
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-tertiary: #94a3b8;
|
||||
--color-border-default: #e2e8f0;
|
||||
--color-border-strong: #cbd5e1;
|
||||
--color-accent-primary: #00398a;
|
||||
--color-accent-primary-hover: #002e6e;
|
||||
--color-accent-secondary: #13a8b8;
|
||||
--color-accent-ink: #001f4d;
|
||||
--color-success: #067647;
|
||||
--color-warning: #b54708;
|
||||
--color-danger: #b42318;
|
||||
--color-info: #155eef;
|
||||
--color-info: #00398a;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-panel: 1rem;
|
||||
--shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.08), 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-md: 0 8px 24px rgba(16, 24, 40, 0.08);
|
||||
--radius-2xl: 1.5rem;
|
||||
--radius-panel: 1.25rem;
|
||||
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 22px 55px rgba(0, 57, 138, 0.14);
|
||||
--shadow-focus: 0 0 0 4px rgba(0, 57, 138, 0.14);
|
||||
}
|
||||
|
||||
+21
-15
@@ -1,32 +1,38 @@
|
||||
export const semanticTokens = {
|
||||
colors: {
|
||||
bgCanvas: "#f5f7fa",
|
||||
bgCanvas: "#f5f7f8",
|
||||
bgCanvasDeep: "#e8eef5",
|
||||
bgSurface: "#ffffff",
|
||||
bgSurfaceSubtle: "#fafafb",
|
||||
bgPrimarySoft: "#f2f5f8",
|
||||
textPrimary: "#111827",
|
||||
textSecondary: "#667085",
|
||||
textTertiary: "#98a2b3",
|
||||
borderDefault: "#e4e7ec",
|
||||
borderStrong: "#d0d5dd",
|
||||
accentPrimary: "#111827",
|
||||
accentPrimaryHover: "#1f2937",
|
||||
bgSurfaceSubtle: "#f8fafc",
|
||||
bgSurfaceRaised: "rgba(255, 255, 255, 0.92)",
|
||||
bgPrimarySoft: "#e6ecf4",
|
||||
textPrimary: "#0f172a",
|
||||
textSecondary: "#475569",
|
||||
textTertiary: "#94a3b8",
|
||||
borderDefault: "#e2e8f0",
|
||||
borderStrong: "#cbd5e1",
|
||||
accentPrimary: "#00398a",
|
||||
accentPrimaryHover: "#002e6e",
|
||||
accentSecondary: "#13a8b8",
|
||||
accentInk: "#001f4d",
|
||||
success: "#067647",
|
||||
warning: "#b54708",
|
||||
danger: "#b42318",
|
||||
info: "#155eef",
|
||||
info: "#00398a",
|
||||
},
|
||||
radius: {
|
||||
sm: "0.5rem",
|
||||
md: "0.75rem",
|
||||
lg: "1rem",
|
||||
xl: "1.25rem",
|
||||
panel: "1rem",
|
||||
"2xl": "1.5rem",
|
||||
panel: "1.25rem",
|
||||
},
|
||||
shadow: {
|
||||
xs: "0 1px 2px rgba(16, 24, 40, 0.04)",
|
||||
sm: "0 1px 3px rgba(16, 24, 40, 0.08), 0 1px 2px rgba(16, 24, 40, 0.04)",
|
||||
md: "0 8px 24px rgba(16, 24, 40, 0.08)",
|
||||
xs: "0 1px 2px rgba(15, 23, 42, 0.05)",
|
||||
sm: "0 8px 24px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.05)",
|
||||
md: "0 22px 55px rgba(0, 57, 138, 0.14)",
|
||||
focus: "0 0 0 4px rgba(0, 57, 138, 0.14)",
|
||||
},
|
||||
spacing: {
|
||||
pageX: "1.5rem",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/attendance", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
export const BACKUP_NAV_ORDER = ["/attendance", "/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/portal", title: "Portal", group: "工作台" },
|
||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||
{ path: "/team", title: "团队", group: "主导航" },
|
||||
{ path: "/attendance", title: "考勤", group: "主导航" },
|
||||
{ path: "/attendance", title: "考勤", group: "备用模块" },
|
||||
{ path: "/projects", title: "项目", group: "主导航" },
|
||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
||||
|
||||
+294
-41
@@ -14,12 +14,18 @@
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
background: var(--color-bg-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg-canvas);
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 10% -8%, rgba(0, 57, 138, 0.10), transparent 30rem),
|
||||
radial-gradient(circle at 92% 0%, rgba(19, 168, 184, 0.12), transparent 32rem),
|
||||
linear-gradient(180deg, #ffffff 0%, var(--color-bg-canvas) 18rem, #eef2f6 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--color-text-primary);
|
||||
@apply font-sans antialiased selection:bg-zinc-900 selection:text-white;
|
||||
@apply font-sans antialiased selection:bg-primary selection:text-white;
|
||||
}
|
||||
|
||||
#root {
|
||||
@@ -33,142 +39,246 @@
|
||||
|
||||
@layer components {
|
||||
.layout-container {
|
||||
@apply mx-auto w-full px-4 sm:px-6 lg:px-8;
|
||||
@apply mx-auto w-full px-0 sm:px-1 lg:px-2;
|
||||
}
|
||||
|
||||
.page-scaffold {
|
||||
animation: page-reveal 420ms ease-out both;
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.workbench-hero {
|
||||
background: linear-gradient(135deg, rgba(17, 24, 39, 0.03), rgba(255, 255, 255, 0.96));
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply flex flex-col gap-4 px-6 py-6 md:flex-row md:items-end md:justify-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 12% 4%, rgba(0, 57, 138, 0.14), transparent 17rem),
|
||||
radial-gradient(circle at 86% 12%, rgba(19, 168, 184, 0.15), transparent 19rem),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(230, 236, 244, 0.94));
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@apply flex flex-col gap-5 px-6 py-7 md:flex-row md:items-end md:justify-between md:px-7;
|
||||
}
|
||||
|
||||
.workbench-hero::after {
|
||||
position: absolute;
|
||||
inset: auto -5rem -7rem auto;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
content: "";
|
||||
background:
|
||||
repeating-radial-gradient(ellipse at center, rgba(0, 57, 138, 0.13) 0 1px, transparent 1px 16px);
|
||||
border-radius: 9999px;
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
.workbench-hero > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.workbench-hero__eyebrow {
|
||||
@apply text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500;
|
||||
color: var(--color-accent-primary);
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
}
|
||||
|
||||
.workbench-hero__title {
|
||||
@apply mt-2 text-3xl font-semibold tracking-tight text-zinc-900;
|
||||
color: var(--color-text-primary);
|
||||
@apply mt-2 text-3xl font-semibold tracking-tight md:text-4xl;
|
||||
}
|
||||
|
||||
.workbench-hero__subtitle {
|
||||
@apply mt-2 max-w-3xl text-sm leading-6 text-zinc-600;
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-3 max-w-3xl text-sm leading-6 md:text-base md:leading-7;
|
||||
}
|
||||
|
||||
.workbench-hero--compact {
|
||||
@apply gap-4 px-5 py-5 md:px-6 md:py-6;
|
||||
}
|
||||
|
||||
.workbench-hero--compact .workbench-hero__title {
|
||||
@apply text-2xl md:text-3xl;
|
||||
}
|
||||
|
||||
.workbench-hero--compact .workbench-hero__subtitle {
|
||||
@apply mt-2 max-w-2xl text-sm leading-6;
|
||||
}
|
||||
|
||||
.card-std {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
background: var(--color-bg-surface-raised);
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500;
|
||||
color: var(--color-text-tertiary);
|
||||
@apply text-xs font-semibold uppercase tracking-[0.18em];
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
@apply mt-2 text-sm leading-6 text-zinc-600;
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-2 text-sm leading-6;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
@apply text-3xl font-semibold tracking-tight text-zinc-900;
|
||||
color: var(--color-text-primary);
|
||||
@apply text-3xl font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
.section-panel {
|
||||
@apply p-6;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@apply p-5 md:p-6;
|
||||
}
|
||||
|
||||
.section-panel::before {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: linear-gradient(90deg, var(--color-accent-primary), rgba(19, 168, 184, 0.72), transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section-panel__header {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@apply flex flex-wrap items-start justify-between gap-3;
|
||||
}
|
||||
|
||||
.section-panel__title {
|
||||
@apply text-base font-semibold text-zinc-900;
|
||||
color: var(--color-text-primary);
|
||||
@apply text-base font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
.section-panel__desc {
|
||||
@apply mt-1 text-sm text-zinc-500;
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-1 text-sm leading-6;
|
||||
}
|
||||
|
||||
.section-panel__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@apply mt-5;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.86));
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply block px-5 py-5 transition-colors hover:bg-zinc-50;
|
||||
@apply block px-5 py-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-sm;
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
content: "";
|
||||
background: linear-gradient(180deg, var(--color-accent-primary), rgba(19, 168, 184, 0.72));
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.metric-card--warning {
|
||||
@apply border-amber-200 bg-amber-50 hover:bg-amber-100;
|
||||
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.78));
|
||||
@apply border-amber-200;
|
||||
}
|
||||
|
||||
.metric-card--warning::before {
|
||||
@apply bg-amber-500;
|
||||
}
|
||||
|
||||
.metric-card--danger {
|
||||
@apply border-red-200 bg-red-50 hover:bg-red-100;
|
||||
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.76));
|
||||
@apply border-red-200;
|
||||
}
|
||||
|
||||
.metric-card--danger::before {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
.metric-card__title {
|
||||
@apply text-sm font-medium text-zinc-500;
|
||||
color: var(--color-text-secondary);
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
@apply mt-2 text-2xl font-semibold tracking-tight text-zinc-900;
|
||||
color: var(--color-text-primary);
|
||||
@apply mt-2 text-2xl font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
.metric-card__note {
|
||||
@apply mt-2 text-sm text-zinc-500;
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-2 text-sm leading-6;
|
||||
}
|
||||
|
||||
.info-chip {
|
||||
@apply inline-flex items-center rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600;
|
||||
border-color: rgba(203, 213, 225, 0.86);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--color-text-secondary);
|
||||
@apply inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium;
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
@apply rounded-2xl border border-zinc-200 bg-zinc-50;
|
||||
border-color: rgba(226, 232, 240, 0.92);
|
||||
background: rgba(248, 250, 252, 0.86);
|
||||
@apply rounded-2xl border;
|
||||
}
|
||||
|
||||
.input-std {
|
||||
@apply h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
border-color: rgba(203, 213, 225, 0.92);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--color-text-primary);
|
||||
@apply h-11 w-full rounded-xl border px-3 py-2 text-sm shadow-sm outline-none transition placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/15;
|
||||
}
|
||||
|
||||
.select-std {
|
||||
@apply h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
border-color: rgba(203, 213, 225, 0.92);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--color-text-primary);
|
||||
@apply h-11 w-full rounded-xl border px-3 py-2 text-sm shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/15;
|
||||
}
|
||||
|
||||
.select-sm {
|
||||
@apply h-8 rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-600 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
border-color: rgba(203, 213, 225, 0.92);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--color-text-secondary);
|
||||
@apply h-8 rounded-lg border px-2 py-1 text-xs font-medium shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/15;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
@apply overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm;
|
||||
border-color: rgba(226, 232, 240, 0.92);
|
||||
background: var(--color-bg-surface-raised);
|
||||
@apply overflow-hidden rounded-2xl border shadow-sm backdrop-blur;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply border-b border-zinc-100 bg-zinc-50/70;
|
||||
border-color: rgba(226, 232, 240, 0.82);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
@apply border-b;
|
||||
}
|
||||
|
||||
.table-th {
|
||||
@apply px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500;
|
||||
color: var(--color-text-tertiary);
|
||||
@apply px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply transition-colors hover:bg-zinc-50/80;
|
||||
@apply transition-colors hover:bg-slate-50/90;
|
||||
}
|
||||
|
||||
.table-td {
|
||||
@apply px-6 py-4 text-sm text-zinc-600;
|
||||
color: var(--color-text-secondary);
|
||||
@apply px-6 py-4 text-sm;
|
||||
}
|
||||
|
||||
.table-td-strong {
|
||||
@apply px-6 py-4 text-sm font-semibold text-zinc-900;
|
||||
color: var(--color-text-primary);
|
||||
@apply px-6 py-4 text-sm font-semibold;
|
||||
}
|
||||
|
||||
.page-shell-grid {
|
||||
@@ -180,7 +290,7 @@
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/10;
|
||||
@apply inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/15;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@@ -192,6 +302,149 @@
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
@apply inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 ring-1 ring-inset ring-zinc-600/10;
|
||||
background: rgba(241, 245, 249, 0.92);
|
||||
color: var(--color-text-secondary);
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ring-slate-300/55;
|
||||
}
|
||||
|
||||
.portal-metric-strip {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
.portal-home-grid {
|
||||
@apply grid gap-6 xl:grid-cols-[minmax(0,2fr),minmax(320px,0.95fr)] xl:items-stretch;
|
||||
}
|
||||
|
||||
.portal-home-grid--collaboration {
|
||||
@apply xl:grid-cols-[minmax(0,1.55fr),minmax(320px,0.9fr)];
|
||||
}
|
||||
|
||||
.portal-panel-fill {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.portal-list-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply block px-4 py-4 transition duration-200;
|
||||
}
|
||||
|
||||
.portal-list-card:hover {
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.portal-list-card--warning {
|
||||
background: rgba(255, 251, 235, 0.9);
|
||||
border-color: rgba(245, 158, 11, 0.36);
|
||||
}
|
||||
|
||||
.portal-list-card--compact {
|
||||
border-radius: var(--radius-lg);
|
||||
@apply px-3.5 py-3.5;
|
||||
}
|
||||
|
||||
.portal-action-stack {
|
||||
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-1;
|
||||
}
|
||||
|
||||
.portal-action-tile {
|
||||
min-height: 100%;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.86));
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply px-4 py-4 text-left transition duration-200;
|
||||
}
|
||||
|
||||
.portal-action-tile:hover {
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.portal-action-tile--disabled {
|
||||
background: rgba(241, 245, 249, 0.86);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.portal-action-tile--compact {
|
||||
@apply px-3.5 py-3.5;
|
||||
}
|
||||
|
||||
.portal-action-icon {
|
||||
background: linear-gradient(135deg, rgba(0, 57, 138, 0.12), rgba(19, 168, 184, 0.12));
|
||||
color: var(--color-accent-primary);
|
||||
@apply flex size-10 items-center justify-center rounded-2xl;
|
||||
}
|
||||
|
||||
.portal-action-icon--muted {
|
||||
background: rgba(226, 232, 240, 0.72);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.portal-status-card {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
border-radius: var(--radius-panel);
|
||||
@apply px-4 py-4;
|
||||
}
|
||||
|
||||
.portal-team-list {
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
@apply overflow-hidden rounded-2xl divide-y divide-slate-200/80;
|
||||
}
|
||||
|
||||
.portal-team-row {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
@apply flex min-h-[64px] items-center gap-3 px-4 py-3;
|
||||
}
|
||||
|
||||
.portal-panel-footer {
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-4 flex flex-wrap items-center justify-between gap-3 text-xs leading-5;
|
||||
}
|
||||
|
||||
.portal-insight-tabs__list {
|
||||
@apply h-10 rounded-full border-slate-200 bg-slate-50 p-1;
|
||||
}
|
||||
|
||||
.portal-insight-tabs__content {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.portal-secondary-drawer {
|
||||
@apply p-5 md:p-6;
|
||||
}
|
||||
|
||||
.portal-secondary-summary {
|
||||
@apply flex cursor-pointer list-none items-center justify-between gap-4 text-left;
|
||||
}
|
||||
|
||||
.portal-secondary-summary__toggle {
|
||||
color: var(--color-text-tertiary);
|
||||
@apply shrink-0 text-xs font-semibold uppercase tracking-[0.18em];
|
||||
}
|
||||
|
||||
.portal-secondary-drawer__body {
|
||||
@apply mt-5 space-y-6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,14 @@ export function AppShellLayout() {
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[var(--color-bg-canvas)]">
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
|
||||
<div className="pointer-events-none fixed inset-0 -z-10 bg-[radial-gradient(circle_at_12%_8%,rgba(0,57,138,0.10),transparent_28rem),radial-gradient(circle_at_90%_12%,rgba(19,168,184,0.12),transparent_32rem)]" />
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
|
||||
<Topbar />
|
||||
|
||||
<div className={`flex-1 px-4 py-6 sm:px-8 sm:py-8 ${isMobileRoute ? "pb-24 md:pb-8" : ""}`}>
|
||||
<div className={`flex-1 px-4 py-5 sm:px-7 sm:py-7 lg:px-8 ${isMobileRoute ? "pb-24 md:pb-8" : ""}`}>
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
|
||||
@@ -3,27 +3,27 @@ import { AppBrand } from "../components/shell/AppBrand";
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(17,24,39,0.08),_transparent_34%),linear-gradient(180deg,_#f8fafc_0%,_#f3f4f6_100%)] px-6 py-10 lg:px-10 lg:py-14">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_14%_0%,rgba(0,57,138,0.12),transparent_32rem),radial-gradient(circle_at_86%_8%,rgba(19,168,184,0.12),transparent_30rem),linear-gradient(180deg,#ffffff_0%,#f5f7f8_42%,#eef2f6_100%)] px-6 py-10 lg:px-10 lg:py-14">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl items-center gap-8 lg:grid-cols-[minmax(0,1.05fr),minmax(420px,0.95fr)]">
|
||||
<section className="hidden rounded-[2rem] border border-zinc-200 bg-white/75 p-10 shadow-sm backdrop-blur lg:block">
|
||||
<section className="hidden overflow-hidden rounded-[2rem] border border-slate-200 bg-white/82 p-10 shadow-sm backdrop-blur lg:block">
|
||||
<AppBrand />
|
||||
<div className="mt-8 space-y-6">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">统一入口</div>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-zinc-900">让员工先找到今天要做什么</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-zinc-600">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-slate-900">让员工先找到今天要做什么</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-slate-600">
|
||||
企业微信负责进入和提醒,真正的审批、项目、周报与留痕都在 OA 内完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="surface-muted px-5 py-5">
|
||||
<div className="text-sm font-semibold text-zinc-900">主路径稳定</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">Portal、团队、请假、报销、资产、周报、待办、公告、项目保持稳定结构。</div>
|
||||
<div className="text-sm font-semibold text-slate-900">主路径稳定</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">Portal、团队、请假、报销、资产、周报、待办、公告、项目保持稳定结构。</div>
|
||||
</div>
|
||||
<div className="surface-muted px-5 py-5">
|
||||
<div className="text-sm font-semibold text-zinc-900">入口可回退</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">企业微信免登失败时仍可使用账号密码,不让员工猜测下一步。</div>
|
||||
<div className="text-sm font-semibold text-slate-900">入口可回退</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-600">企业微信免登失败时仍可使用账号密码,不让员工猜测下一步。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +253,7 @@ export function AssetRegistryPage() {
|
||||
}, [isCreatingAsset, searchParams, selectedAssetId, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDetailQuery.data?.asset) {
|
||||
if (isCreatingAsset || !assetDetailQuery.data?.asset) {
|
||||
return;
|
||||
}
|
||||
const asset = assetDetailQuery.data.asset;
|
||||
@@ -275,7 +275,7 @@ export function AssetRegistryPage() {
|
||||
setMovementNote("");
|
||||
setMovementError("");
|
||||
setAssetFormError("");
|
||||
}, [assetDetailQuery.data]);
|
||||
}, [assetDetailQuery.data, isCreatingAsset]);
|
||||
|
||||
const filteredAssetCostCenters = useMemo(() => {
|
||||
if (!assetForm.legalEntityId) {
|
||||
|
||||
+21
-21
@@ -32,7 +32,7 @@ function noticeClasses(tone: WecomNotice["tone"]) {
|
||||
if (tone === "warning") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
}
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -104,11 +104,11 @@ export function LoginPage() {
|
||||
|
||||
function renderPasswordPanel(testId: string, primary: boolean) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-[1.5rem] border border-zinc-200 bg-white p-6 shadow-sm" data-testid={testId}>
|
||||
<div className="space-y-4 rounded-[1.5rem] border border-slate-200 bg-white p-6 shadow-sm" data-testid={testId}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">{primary ? "推荐入口" : "回退入口"}</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用账号密码登录</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{primary ? "推荐入口" : "回退入口"}</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用账号密码登录</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">
|
||||
{primary
|
||||
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
|
||||
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
|
||||
@@ -152,14 +152,14 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std overflow-hidden border-zinc-200 p-0 shadow-md">
|
||||
<div className="border-b border-zinc-200 bg-gradient-to-br from-zinc-50 via-white to-zinc-100 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">统一入口</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900">登录海音达工作台</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-zinc-600">
|
||||
<div className="card-std overflow-hidden border-slate-200 p-0 shadow-md">
|
||||
<div className="border-b border-slate-200 bg-[radial-gradient(circle_at_8%_0%,rgba(0,57,138,0.12),transparent_16rem),linear-gradient(135deg,#ffffff,#e6ecf4)] px-6 py-6 sm:px-8 sm:py-7">
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">统一入口</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-slate-900">登录海音达工作台</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。
|
||||
</p>
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="mt-4 rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 text-sm text-slate-600">
|
||||
{isWecomContext
|
||||
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。"
|
||||
: "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"}
|
||||
@@ -176,10 +176,10 @@ export function LoginPage() {
|
||||
|
||||
<div className="grid gap-4">
|
||||
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
|
||||
<div className="rounded-[1.5rem] border border-primary-100 bg-primary-50/70 p-6" data-testid="login-wecom-primary-panel">
|
||||
<div className="rounded-[1.5rem] border border-primary/20 bg-primary/5 p-6" data-testid="login-wecom-primary-panel">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">推荐入口</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用企业微信进入工作台</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-slate-900">使用企业微信进入工作台</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p>
|
||||
<Button className="mt-5 w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
@@ -192,10 +192,10 @@ export function LoginPage() {
|
||||
)}
|
||||
|
||||
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
|
||||
<div className="rounded-[1.5rem] border border-zinc-200 bg-zinc-50/70 p-6">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">补充入口</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-zinc-900">其他进入方式</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
|
||||
<div className="rounded-[1.5rem] border border-slate-200 bg-slate-50/70 p-6">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">补充入口</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">其他进入方式</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
@@ -220,11 +220,11 @@ export function LoginPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">企业微信入口状态:{wecomStatus.label}</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<div className="font-medium text-slate-900">企业微信入口状态:{wecomStatus.label}</div>
|
||||
<div className="mt-1 leading-6">{wecomStatus.description}</div>
|
||||
{wecomStatus.reasonLabels.length ? (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-zinc-500">
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-slate-500">
|
||||
{wecomStatus.reasonLabels.map((label) => (
|
||||
<li key={label}>{label}</li>
|
||||
))}
|
||||
|
||||
@@ -228,7 +228,7 @@ export function MobileApprovalsPage() {
|
||||
const [transferToApproverId, setTransferToApproverId] = useState("");
|
||||
const [transferReason, setTransferReason] = useState("");
|
||||
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState<boolean>(typeof navigator === "undefined" ? true : navigator.onLine);
|
||||
const [isOnline, setIsOnline] = useState(true);
|
||||
const autoSyncRequestedRef = useRef(false);
|
||||
|
||||
const ownerEmployeeId = me?.id ?? null;
|
||||
@@ -673,6 +673,12 @@ export function MobileApprovalsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
setIsOnline(true);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSyncRequestedRef.current || !canUseRemote || syncMutation.isPending) {
|
||||
return;
|
||||
|
||||
+178
-90
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -27,6 +27,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { WorkbenchHero } from "../components/patterns/WorkbenchHero";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema";
|
||||
import {
|
||||
clearWelcomeDialogFlag,
|
||||
@@ -361,9 +362,7 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className={item.tone === "warning"
|
||||
? "block rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 hover:bg-amber-100"
|
||||
: "block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50"}
|
||||
className={item.tone === "warning" ? "portal-list-card portal-list-card--warning" : "portal-list-card"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
@@ -377,29 +376,23 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
|
||||
);
|
||||
}
|
||||
|
||||
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="公告"
|
||||
description="公告保留在第二阅读层,不与今日任务卡竞争。"
|
||||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div>
|
||||
{item.href ? (
|
||||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank">
|
||||
查看详情
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
<div className="space-y-3">
|
||||
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="portal-list-card portal-list-card--compact">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div>
|
||||
{item.href ? (
|
||||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank">
|
||||
查看详情
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -410,7 +403,7 @@ function RecentVisitsPanel() {
|
||||
<SectionPanel title="继续工作" description="保留最近访问路径,帮助快速回到上一次处理中断的位置。">
|
||||
<div className="space-y-3">
|
||||
{recentVisits.length ? recentVisits.map((item) => (
|
||||
<Link key={item.path} to={item.path} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<Link key={item.path} to={item.path} className="portal-list-card">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.path}</div>
|
||||
</Link>
|
||||
@@ -427,7 +420,7 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
|
||||
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{safeItems.length ? safeItems.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm font-medium text-zinc-700 hover:bg-zinc-50">
|
||||
<Link key={item.key} to={item.href} className="portal-list-card text-sm font-medium text-slate-700">
|
||||
{item.title}
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无常用入口" description="工作区尚未配置推荐入口。" />}
|
||||
@@ -465,33 +458,33 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="card-std p-6" open>
|
||||
<details className="card-std portal-secondary-drawer">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">试点观测摘要</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">只给管理员默认展开,用于观察习惯是否已形成。</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">管理员按需展开,用于观察习惯是否已形成。</div>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span>
|
||||
</summary>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="portal-status-card text-sm">
|
||||
<div className="text-zinc-500">近 7 天登录覆盖率</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="portal-status-card text-sm">
|
||||
<div className="text-zinc-500">周报提交率</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="portal-status-card text-sm">
|
||||
<div className="text-zinc-500">团队状态覆盖率</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="portal-status-card text-sm">
|
||||
<div className="text-zinc-500">当前公告数</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
<div className="font-medium text-zinc-900">本周观测提示</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{adoptionSummary.hints.map((hint) => (
|
||||
@@ -503,6 +496,31 @@ function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullabl
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryWorkspacePanel({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="card-std portal-secondary-drawer">
|
||||
<summary className="portal-secondary-summary">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">{title}</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-500">{description}</div>
|
||||
</div>
|
||||
<span className="portal-secondary-summary__toggle">展开 / 收起</span>
|
||||
</summary>
|
||||
<div className="portal-secondary-drawer__body">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PortalSectionFallback({
|
||||
title,
|
||||
description,
|
||||
@@ -549,7 +567,7 @@ function GettingStartedCard({
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white px-4 py-4 transition-colors hover:bg-zinc-50"
|
||||
className="portal-list-card flex items-center gap-3"
|
||||
>
|
||||
<span className={item.completed ? "text-emerald-600" : "text-zinc-400"}>
|
||||
<span className="material-symbols-outlined text-[20px]">
|
||||
@@ -564,12 +582,12 @@ function GettingStartedCard({
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-zinc-900">完成进度</span>
|
||||
<span className="text-zinc-500">{completedCount}/{items.length}</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-zinc-200">
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
@@ -588,23 +606,24 @@ function QuickActionTile({
|
||||
to,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: QuickActionConfig) {
|
||||
compact = false,
|
||||
}: QuickActionConfig & { compact?: boolean }) {
|
||||
const baseClassName = disabled
|
||||
? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400"
|
||||
: "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50";
|
||||
? `portal-action-tile portal-action-tile--disabled${compact ? " portal-action-tile--compact" : ""}`
|
||||
: `portal-action-tile${compact ? " portal-action-tile--compact" : ""}`;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={disabled
|
||||
? "flex size-10 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-400"
|
||||
: "flex size-10 items-center justify-center rounded-2xl bg-primary/10 text-primary"}
|
||||
? "portal-action-icon portal-action-icon--muted"
|
||||
: "portal-action-icon"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className={disabled ? "font-medium text-zinc-500" : "font-medium text-zinc-900"}>{label}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||
<div className={disabled ? "truncate font-medium text-zinc-500" : "truncate font-medium text-zinc-900"}>{label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-zinc-500">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -625,12 +644,18 @@ function QuickActionTile({
|
||||
|
||||
function QuickActionsPanel({
|
||||
items,
|
||||
layout = "grid",
|
||||
}: {
|
||||
items: QuickActionConfig[];
|
||||
layout?: "grid" | "rail";
|
||||
}) {
|
||||
return (
|
||||
<SectionPanel title="快捷操作栏" description="把每天高频动作直接前置到首页,不再绕路。">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<SectionPanel
|
||||
title="快捷操作栏"
|
||||
description="保留每天真正高频的动作,不把所有入口都放在首屏。"
|
||||
className={layout === "rail" ? "portal-panel-fill" : undefined}
|
||||
>
|
||||
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
|
||||
{items.length ? items.map((item) => (
|
||||
<QuickActionTile
|
||||
key={item.key}
|
||||
@@ -640,6 +665,7 @@ function QuickActionsPanel({
|
||||
to={item.to}
|
||||
onClick={item.onClick}
|
||||
disabled={item.disabled}
|
||||
compact={layout === "rail"}
|
||||
/>
|
||||
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
|
||||
</div>
|
||||
@@ -648,11 +674,18 @@ function QuickActionsPanel({
|
||||
}
|
||||
|
||||
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
const visibleItems = items.slice(0, 5);
|
||||
const hiddenCount = Math.max(items.length - visibleItems.length, 0);
|
||||
|
||||
return (
|
||||
<SectionPanel title="今日待办聚合" description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。">
|
||||
<SectionPanel
|
||||
title="今日待办聚合"
|
||||
description="合并审批、TODO 和高风险指标,只保留今天最该处理的事。"
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
{visibleItems.length ? visibleItems.map((item) => (
|
||||
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={item.severity === "high"
|
||||
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
|
||||
@@ -675,54 +708,99 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无待办事项,休息一下吧" description="当前没有需要立刻处理的审批、TODO 或风险提醒。" />}
|
||||
</div>
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="portal-panel-footer">
|
||||
<span>还有 {hiddenCount} 项已收起,避免首页变成待办清单。</span>
|
||||
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
const visibleItems = safeItems.slice(0, 5);
|
||||
const hiddenCount = Math.max(safeItems.length - visibleItems.length, 0);
|
||||
|
||||
return (
|
||||
<SectionPanel title="团队全员状态" description="同时看人在不在岗、有没有请假,以及今天是否已打卡。">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{safeItems.length ? safeItems.map((item) => {
|
||||
<SectionPanel
|
||||
title="团队全员状态"
|
||||
description="小团队首页只展示关键状态,完整考勤与在岗信息进入团队页。"
|
||||
actions={<Link to="/team" className="text-sm font-medium text-primary hover:underline">查看团队页</Link>}
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
<div className="portal-team-list">
|
||||
{visibleItems.length ? visibleItems.map((item) => {
|
||||
const badge = resolveTeamStatusBadge(item);
|
||||
return (
|
||||
<div key={item.employeeId} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
|
||||
</div>
|
||||
<span className={badge.className}>{badge.label}</span>
|
||||
<div key={item.employeeId} className="portal-team-row">
|
||||
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
|
||||
</div>
|
||||
<span className={badge.className}>{badge.label}</span>
|
||||
</div>
|
||||
);
|
||||
}) : <EmptyStateCard title="暂无团队状态" description="当前没有可展示的团队成员状态。" />}
|
||||
</div>
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="portal-panel-footer">
|
||||
<span>已收起 {hiddenCount} 位成员,避免首页纵向过长。</span>
|
||||
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) {
|
||||
function RecentActivitiesList({ items }: { items: PortalRecentActivity[] }) {
|
||||
const safeItems = Array.isArray(items) ? items : [];
|
||||
|
||||
return (
|
||||
<SectionPanel title="近期动态流" description="把最近发生的报销、请假、考勤和资产动作放在同一条时间线上。">
|
||||
<div className="space-y-3">
|
||||
{safeItems.length ? safeItems.map((item) => (
|
||||
<Link key={item.id} to={item.href} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<EmployeeAvatar name={item.actorName} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{safeItems.length ? safeItems.slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={item.href} className="portal-list-card portal-list-card--compact">
|
||||
<div className="flex items-start gap-3">
|
||||
<EmployeeAvatar name={item.actorName} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityAnnouncementsPanel({
|
||||
activities,
|
||||
announcements,
|
||||
}: {
|
||||
activities: PortalRecentActivity[];
|
||||
announcements: PortalHomePayload["announcements"];
|
||||
}) {
|
||||
return (
|
||||
<SectionPanel
|
||||
title="近期动态流"
|
||||
description="动态优先,公告进入同一张右侧阅读卡,减少首页白卡堆叠。"
|
||||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">公告页</Link>}
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
<Tabs defaultValue="activity" className="portal-insight-tabs">
|
||||
<TabsList className="portal-insight-tabs__list">
|
||||
<TabsTrigger value="activity">动态</TabsTrigger>
|
||||
<TabsTrigger value="announcements">公告</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="activity" className="portal-insight-tabs__content">
|
||||
<RecentActivitiesList items={activities} />
|
||||
</TabsContent>
|
||||
<TabsContent value="announcements" className="portal-insight-tabs__content">
|
||||
<AnnouncementsList announcements={announcements} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
@@ -960,6 +1038,7 @@ export function PortalHomePage() {
|
||||
<WorkbenchHero
|
||||
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
|
||||
title={`你好,${payload.greeting.name}`}
|
||||
className="workbench-hero--compact"
|
||||
subtitle={isManagerView
|
||||
? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。"
|
||||
: "先处理今天必须完成的事,再继续查看项目、周报和公告。"}
|
||||
@@ -972,7 +1051,7 @@ export function PortalHomePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<div className="portal-metric-strip">
|
||||
{primaryCards.map((card) => (
|
||||
<MetricCard
|
||||
key={card.key}
|
||||
@@ -1009,12 +1088,12 @@ export function PortalHomePage() {
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<QuickActionsPanel items={quickActions} />
|
||||
<div className="portal-home-grid">
|
||||
<TodoAggregatePanel items={todoAggregateItems} />
|
||||
<QuickActionsPanel items={quickActions} layout="rail" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.15fr),minmax(0,0.85fr)]">
|
||||
<div className="portal-home-grid portal-home-grid--collaboration">
|
||||
<ErrorBoundary
|
||||
fallback={(
|
||||
<PortalSectionFallback
|
||||
@@ -1033,40 +1112,49 @@ export function PortalHomePage() {
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<RecentActivitiesPanel items={recentActivities} />
|
||||
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{isManagerView ? (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
||||
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
|
||||
<QuickLinksPanel items={quickLinkItems} />
|
||||
</div>
|
||||
<SecondaryWorkspacePanel
|
||||
title="今日管理重点 / 常用入口"
|
||||
description="默认收起第二阅读层;需要巡检项目、组织协同或常用入口时再展开。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
|
||||
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
|
||||
<QuickLinksPanel items={quickLinkItems} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
|
||||
<AnnouncementsPanel announcements={announcementItems} />
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把项目、组织和风险类信息下移到第二阅读层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</div>
|
||||
</SecondaryWorkspacePanel>
|
||||
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
|
||||
<SecondaryWorkspacePanel
|
||||
title="我的申请与资产 / 继续工作"
|
||||
description="默认收起低频参考信息,首页先服务今天要处理的事。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
|
||||
<AnnouncementsPanel announcements={announcementItems} />
|
||||
<RecentVisitsPanel />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<RecentVisitsPanel />
|
||||
<QuickLinksPanel items={quickLinkItems} />
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与个人协同"
|
||||
description="项目、资产和其他参考指标保留在第二阅读层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</SecondaryWorkspacePanel>
|
||||
)}
|
||||
</PageScaffold>
|
||||
);
|
||||
|
||||
@@ -12,24 +12,38 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
portal: "Portal",
|
||||
team: "团队",
|
||||
leaveRequests: "请假",
|
||||
attendance: "考勤",
|
||||
reimbursements: "报销",
|
||||
assets: "资产",
|
||||
weeklyReports: "周报",
|
||||
todos: "待办",
|
||||
announcements: "公告",
|
||||
projects: "项目",
|
||||
workspace: "工作区设置",
|
||||
people: "人员管理",
|
||||
organization: "组织架构",
|
||||
contractsRegistry: "合同台账",
|
||||
workflowDefinitions: "流程定义",
|
||||
systemSettings: "系统设置",
|
||||
featureFlags: "功能开关",
|
||||
auditLogs: "审计日志",
|
||||
approvalRules: "审批规则",
|
||||
userManagement: "用户管理",
|
||||
departments: "部门管理",
|
||||
masterData: "主数据",
|
||||
notificationChannels: "通知渠道",
|
||||
workflowTasks: "审批待办",
|
||||
dashboard: "仪表盘",
|
||||
purchaseRequests: "采购申请",
|
||||
travelRequests: "差旅申请",
|
||||
budgets: "预算管理",
|
||||
paymentBatches: "支付批次",
|
||||
apOperations: "AP 运营",
|
||||
invoicePool: "票据中心",
|
||||
inventoryChecks: "资产盘点",
|
||||
cutover: "切换控制台",
|
||||
erpIntegrations: "ERP 集成",
|
||||
profile: "个人设置",
|
||||
};
|
||||
|
||||
export function WorkspaceSetupPage() {
|
||||
|
||||
@@ -6,19 +6,20 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: "#18181b", // zinc-900
|
||||
light: "#27272a", // zinc-800
|
||||
accent: "#197fe6" // 蓝色用于少量强调
|
||||
DEFAULT: "#00398a",
|
||||
light: "#002e6e",
|
||||
accent: "#13a8b8"
|
||||
},
|
||||
secondary: "#71717a", // zinc-500
|
||||
secondary: "#5e718d",
|
||||
background: {
|
||||
light: "#fafafa", // zinc-50
|
||||
dark: "#111921"
|
||||
light: "#f5f7f8",
|
||||
dark: "#0f1723"
|
||||
},
|
||||
border: "#e4e4e7" // zinc-200
|
||||
border: "#e2e8f0"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Public Sans",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
@@ -31,6 +32,7 @@ export default {
|
||||
"sans-serif"
|
||||
],
|
||||
display: [
|
||||
"Public Sans",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
|
||||
@@ -36,6 +36,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await page.getByRole("button", { name: "保存状态" }).click();
|
||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||
await page.goto("/portal");
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
|
||||
@@ -48,6 +49,7 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
||||
await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
|
||||
|
||||
await page.goto("/portal");
|
||||
await page.getByRole("tab", { name: "公告" }).click();
|
||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
|
||||
} finally {
|
||||
|
||||
@@ -83,6 +83,7 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
|
||||
await login(page, FINANCE_MGR);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await expect(page.getByTestId("asset-name")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-category-reset").click();
|
||||
await page.getByTestId("asset-category-code").fill(categoryCode);
|
||||
@@ -90,25 +91,48 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
|
||||
await page.getByTestId("asset-category-save").click();
|
||||
|
||||
await page.getByTestId("asset-create").click();
|
||||
await expect(page.getByRole("heading", { name: "新建资产卡片" })).toBeVisible();
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue("");
|
||||
await page.getByTestId("asset-name").fill(assetName);
|
||||
await page.getByTestId("asset-category").selectOption({ label: categoryName });
|
||||
await selectFirstBusinessOption(page.getByTestId("asset-legal-entity"));
|
||||
await selectFirstBusinessOption(page.getByTestId("asset-cost-center"));
|
||||
await page.getByTestId("asset-location").fill("深圳办公室 A-01");
|
||||
const createAssetResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/assets",
|
||||
);
|
||||
await page.getByTestId("asset-save").click();
|
||||
const createAssetResponse = await createAssetResponsePromise;
|
||||
if (!createAssetResponse.ok()) {
|
||||
const failureText = await createAssetResponse.text();
|
||||
throw new Error(`asset create failed: ${createAssetResponse.status()} ${failureText}`);
|
||||
}
|
||||
const createAssetPayload = (await createAssetResponse.json()) as {
|
||||
data: { asset: { id: string } };
|
||||
};
|
||||
const assetId = createAssetPayload.data.asset.id;
|
||||
await page.getByRole("button", { name: "全范围" }).click();
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first();
|
||||
await expect(assetRow).toBeVisible({ timeout: 30_000 });
|
||||
if (await assetRow.isVisible().catch(() => false)) {
|
||||
await assetRow.click();
|
||||
}
|
||||
|
||||
await logout(page, FINANCE_MGR.name);
|
||||
|
||||
await login(page, ADMINISTRATION);
|
||||
await page.getByRole("link", { name: "资产台账" }).click();
|
||||
await page.getByTestId("asset-row").filter({ hasText: assetName }).first().click();
|
||||
await page.goto(`/assets?id=${assetId}`);
|
||||
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
|
||||
|
||||
await selectOptionByText(page.getByTestId("asset-movement-assignee"), /Employee/);
|
||||
await page.getByTestId("asset-movement-note").fill("Playwright 首次领用");
|
||||
await page.getByTestId("asset-assign-submit").click();
|
||||
if (await page.getByText(/当前归属人:未分配/).isVisible().catch(() => false)) {
|
||||
await page.getByTestId("asset-assign-submit").click();
|
||||
} else {
|
||||
await page.getByTestId("asset-transfer-submit").click();
|
||||
}
|
||||
await expect(page.getByText(/当前归属人:Employee/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("asset-return-submit").click();
|
||||
|
||||
@@ -75,6 +75,7 @@ test("E2E 涉密边界:敏感摘要不可搜索,编号可命中", async ({ p
|
||||
const contract = await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/contracts-registry", {
|
||||
contractNo,
|
||||
projectId: project.item.id,
|
||||
contractType: "service",
|
||||
counterpartyName: `客户 ${suffix}`,
|
||||
summary: sensitivePhrase,
|
||||
confidentialityLevel: "restricted",
|
||||
|
||||
@@ -294,8 +294,8 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
"portal-card-my_projects",
|
||||
]);
|
||||
|
||||
await expect(page.getByText("我的申请与资产")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible();
|
||||
@@ -324,7 +324,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
||||
"portal-card-weekly_help",
|
||||
]);
|
||||
|
||||
await expect(page.getByText("今日管理重点")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
||||
@@ -417,7 +417,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByText("今日管理重点")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
await page.getByRole("menuitem", { name: "空白报销" }).click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
|
||||
@@ -71,6 +71,7 @@ test("E2E 移动审批:deep link 分流 + 离线草稿重放", async ({ page }
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("通过");
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("无待处理项", { timeout: 30_000 });
|
||||
await expect(page.getByRole("main").getByText(/草稿同步完成:成功 1,失败 0,冲突 0/)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
@@ -108,6 +109,7 @@ test("E2E 移动审批:离线草稿冲突态可见", async ({ page }) => {
|
||||
await approveWorkflowTaskViaApi(managerApi.api, task.id);
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("存在冲突", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("mobile-approvals-queue")).toContainText("最近错误:", { timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "查看任务" })).toBeVisible();
|
||||
|
||||
@@ -41,9 +41,8 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
data: { purchaseRequest: { id: string } };
|
||||
};
|
||||
const purchaseRequestId = createPayload.data.purchaseRequest.id;
|
||||
await page.waitForResponse(
|
||||
(response) => response.request().method() === "GET" && new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}`,
|
||||
);
|
||||
await page.goto(`/purchase-requests?id=${purchaseRequestId}`);
|
||||
await expect(page).toHaveURL(new RegExp(`id=${purchaseRequestId}`));
|
||||
|
||||
await expect(page.getByTestId("purchase-title")).toBeVisible();
|
||||
await page.getByTestId("purchase-title").fill(title);
|
||||
@@ -51,14 +50,21 @@ test("E2E 采购主链路:员工提单→经理审批→财务归档", async (
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-cost-center"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-supplier"));
|
||||
await selectFirstBusinessOption(page.getByTestId("purchase-document-category"));
|
||||
await page.getByTestId("purchase-add-item").click();
|
||||
|
||||
await page.locator('[data-testid^="purchase-item-description-"]').first().fill("Playwright Laptop");
|
||||
const itemDescriptionInputs = page.locator('[data-testid^="purchase-item-description-"]');
|
||||
if (!(await itemDescriptionInputs.first().isVisible().catch(() => false))) {
|
||||
await page.getByTestId("purchase-add-item").click();
|
||||
}
|
||||
|
||||
await expect(itemDescriptionInputs.first()).toBeVisible({ timeout: 30_000 });
|
||||
await itemDescriptionInputs.first().fill("Playwright Laptop");
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]').first().fill("2");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]').first().fill("100");
|
||||
|
||||
const submitResponsePromise = page.waitForResponse(
|
||||
(response) => response.request().method() === "POST" && /\/api\/purchase-requests\/[^/]+\/submit$/.test(new URL(response.url()).pathname),
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/purchase-requests/${purchaseRequestId}/submit`,
|
||||
);
|
||||
await page.getByTestId("purchase-submit").click();
|
||||
const submitResponse = await submitResponsePromise;
|
||||
|
||||
@@ -99,7 +99,8 @@ test("E2E 项目工作台:沟通回填 + 收口面板 + Portal 摘要", async
|
||||
await expect(page.getByTestId("project-closure-panel")).toContainText("确认交付/验收记录齐全");
|
||||
|
||||
await page.goto("/portal");
|
||||
await expect(page.getByRole("heading", { name: "今日管理重点" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "你好,Admin" })).toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" }).click();
|
||||
await expect(page.getByRole("heading", { name: "项目跟进与组织协同" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /项目风险/ })).toBeVisible();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ test("E2E 报销审批进度:提交后详情页展示当前步骤进度", asyn
|
||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||
});
|
||||
await page.getByTestId("reimbursements-create").click();
|
||||
await page.getByRole("menuitem", { name: "空白报销" }).click();
|
||||
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
|
||||
const reimbursementId = createPayload.data?.id;
|
||||
if (!reimbursementId) {
|
||||
|
||||
@@ -24,7 +24,8 @@ test.describe("visual portal", () => {
|
||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible();
|
||||
await expect(page.getByText("我的申请与资产")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "portal-employee.png");
|
||||
});
|
||||
@@ -37,8 +38,9 @@ test.describe("visual portal", () => {
|
||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
|
||||
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
|
||||
await expect(page.getByText("今日管理重点")).toBeVisible();
|
||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "portal-admin.png");
|
||||
await logout(page, ADMIN.name);
|
||||
|
||||
@@ -68,7 +68,7 @@ async function submitLeave(api: Awaited<ReturnType<typeof createAuthenticatedApi
|
||||
|
||||
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const startDate = formatDate(45 + Number(String(Date.now()).slice(-2)));
|
||||
const startDate = formatDate(190 + (Date.now() % 40));
|
||||
const reason = `Playwright 快捷通过 ${Date.now()}`;
|
||||
|
||||
try {
|
||||
|
||||
+14
-2
@@ -80,8 +80,8 @@
|
||||
|
||||
## F. R17 小团队上线检查
|
||||
- [ ] 工作区模式已确认为 `small_team_internal`
|
||||
- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告`
|
||||
- [ ] 采购 / 差旅 / 预算 / 支付批次 / AP 运营已降级为管理员备用入口
|
||||
- [ ] 主导航已收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告 / 项目`
|
||||
- [ ] 考勤 / 采购 / 差旅 / 预算 / 支付批次 / AP 运营 / ERP / AI 已降级为隐藏或管理员备用入口
|
||||
- [ ] 已打开 `portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`
|
||||
- [ ] 员工账号、档案、部门、直属上级已补齐
|
||||
- [ ] 资产台账与员工领用关系已核对
|
||||
@@ -90,6 +90,18 @@
|
||||
- [ ] 全员已知晓每周周报和今日状态使用要求
|
||||
- [ ] 已建立连续 4 周观测记录方式(建议直接使用 `deploy/trialrun/observation-template.md`)
|
||||
|
||||
## G. R66 小团队试点收敛检查
|
||||
- [ ] 普通员工账号登录后,主导航只出现高频主路径,不出现考勤、采购、差旅、预算、AP、ERP、AI。
|
||||
- [ ] 经理或老板账号登录后,Portal 首屏优先看到审批、团队状态、周报缺口、需协助员工和项目风险。
|
||||
- [ ] 管理员账号仍可进入工作区设置、人员、组织、流程定义、系统设置、功能开关和审计日志。
|
||||
- [ ] 已确认不删除备用模块数据;恢复某模块只通过工作区设置或功能开关完成。
|
||||
- [ ] 已生成 `week-01.md` 到 `week-04.md` 的观测计划,至少完成第 1 周导出。
|
||||
- [ ] 已完成 DB + 文件存储备份,并记录输出文件名。
|
||||
- [ ] 已在同机或临时环境完成一次恢复演练,恢复后 `/ready` 通过。
|
||||
- [ ] 已核对管理员账号清单和 smoke 账号,自动化不使用真实业务账号。
|
||||
- [ ] 已执行 `cd server && npm audit --omit=dev --audit-level=high` 并完成高危/严重漏洞评审;若仍失败,不进入公网正式试点。
|
||||
- [ ] 已执行 `bash scripts/qa/run-release-gate.sh r66-trialrun`;若数据库或公网账号条件不足,已记录手工替代步骤。
|
||||
|
||||
## R18-R22 上线补充检查项
|
||||
- [ ] `APP_ORIGIN` 已切到公网 HTTPS 域名
|
||||
- [ ] `COOKIE_SECURE=true`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 小团队试点 4 周观测模板
|
||||
|
||||
> 建议由管理员或老板每周五填写一次,用于 `R17-6` 最终验收留痕。
|
||||
> 建议由管理员或老板每周五填写一次,用于 R66 小团队试点验收留痕。
|
||||
|
||||
## 基本信息
|
||||
- 工作区模式:`small_team_internal`
|
||||
@@ -33,6 +33,7 @@
|
||||
## 下周调整
|
||||
- 是否需要调整提醒频率:
|
||||
- 是否需要继续隐藏或降级某模块:
|
||||
- 是否需要恢复考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:
|
||||
- 是否需要补充培训或说明:
|
||||
|
||||
## 结论
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 试点观测输出目录
|
||||
|
||||
- 建议每周导出一次:`cd server && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md`
|
||||
- 导出后,再按 `deploy/trialrun/observation-template.md` 补充人工观察、阻塞与结论。
|
||||
- 本目录建议按周保存:`week-01.md`、`week-02.md`、`week-03.md`、`week-04.md`。
|
||||
R66 试点按 4 周闭环执行,建议每周五由管理员或老板导出一次基础指标,再补充人工观察。
|
||||
|
||||
## 每周导出
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-01.md
|
||||
```
|
||||
|
||||
如需指定观测日期:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run report:small-team-observation -- --date 2026-04-26 --out ../deploy/trialrun/observations/week-01.md
|
||||
```
|
||||
|
||||
## 文件约定
|
||||
|
||||
- 第 1 周:`week-01.md`
|
||||
- 第 2 周:`week-02.md`
|
||||
- 第 3 周:`week-03.md`
|
||||
- 第 4 周:`week-04.md`
|
||||
|
||||
## 人工补录
|
||||
|
||||
导出后按 `deploy/trialrun/observation-template.md` 补充:
|
||||
|
||||
- 多平台重复录入次数、表格替代率、外部在岗表和离职交接表是否停用。
|
||||
- 仍靠聊天或表格补流程的案例。
|
||||
- 本周阻塞、下周是否继续隐藏或恢复备用模块。
|
||||
- 是否达到试点预期,以及是否可进入上线验收。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 小团队试点观测快照
|
||||
|
||||
- 生成时间:2026-04-26T03:35:08.844Z
|
||||
- 观测日期:2026-04-26
|
||||
- 周起始日:2026-04-20
|
||||
- 在职人数:1
|
||||
|
||||
## 指标摘要
|
||||
- 近 7 天登录:0/1(0%)
|
||||
- 本周周报提交:0/1(0%)
|
||||
- 今日状态更新:0/1(0%)
|
||||
- 当前公告数:0
|
||||
- 活跃项目数:0
|
||||
- 近 7 天有任务更新的项目:0/0(0%)
|
||||
- 里程碑按期完成:0/0(0%)
|
||||
- 入职中:0
|
||||
- 离职中:0
|
||||
- 资产待归还:0
|
||||
|
||||
## 观测提示
|
||||
- 近 7 天仍有 1 人未登录系统
|
||||
- 本周仍有 1 人未提交周报
|
||||
- 今日仍有 1 人未更新团队状态
|
||||
|
||||
## 建议动作
|
||||
- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。
|
||||
- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。
|
||||
- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。
|
||||
|
||||
## 人工补录项
|
||||
- 多平台重复录入次数:__
|
||||
- 表格替代率:__%
|
||||
- 在岗表是否已停止外部维护:是 / 否
|
||||
- 离职交接表是否已停止外部维护:是 / 否
|
||||
- 本周仍靠聊天或表格补流程的案例:__
|
||||
|
||||
## 本周关键动作
|
||||
- Portal / 公告 / 团队状态 / 周报 / 待办使用情况:
|
||||
- 入职 / 离职 / 资产交接是否发生:
|
||||
- 项目更新、风险或客户沟通回填情况:
|
||||
|
||||
## 本周阻塞
|
||||
- 阻塞 1:
|
||||
- 阻塞 2:
|
||||
- 阻塞 3:
|
||||
|
||||
## 下周调整
|
||||
- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:
|
||||
- 是否需要补充培训、公告提醒或管理员代配置:
|
||||
- 是否达到试点预期:是 / 否
|
||||
@@ -139,6 +139,24 @@ docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
|
||||
curl -fsS http://127.0.0.1:3000/ready
|
||||
```
|
||||
|
||||
### 1.5.3 R66 小团队上线前演练记录
|
||||
上线前至少记录一次演练结果:
|
||||
|
||||
- 演练日期:
|
||||
- 执行人:
|
||||
- DB 备份文件:
|
||||
- 文件存储备份文件:
|
||||
- 恢复目标机器或目录:
|
||||
- 恢复后 `/ready` 结果:
|
||||
- 上个可回滚 tag:
|
||||
- 回滚负责人:
|
||||
|
||||
需要人工配合的事项:
|
||||
|
||||
- 提供或确认真实 `deploy/trialrun/.env`,特别是 `JWT_SECRET`、`APP_ORIGIN`、`COOKIE_SECURE`、`OPENAPI_DOCS_ENABLED`。
|
||||
- 确认备份文件保存位置和保留周期。
|
||||
- 准备独立 smoke 账号,避免使用老板、管理员或真实员工账号跑自动化。
|
||||
|
||||
## 2. 升级流程(试运行)
|
||||
建议版本化方式:
|
||||
- 每次发布打 tag,例如 `trialrun-2026-02-08-01`
|
||||
|
||||
+8
-4
@@ -12,8 +12,8 @@
|
||||
|
||||
## 3. 首次上线最小配置
|
||||
1. 进入 `工作区设置`,确认产品模式为 `small_team_internal`。
|
||||
2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`。
|
||||
3. 将 `采购`、`差旅`、`预算`、`支付批次`、`AP 运营` 等模块降级为管理员备用。
|
||||
2. 仅保留主导航:`Portal`、`团队`、`请假`、`报销`、`资产`、`周报`、`待办`、`公告`、`项目`。
|
||||
3. 将 `考勤`、`采购`、`差旅`、`预算`、`支付批次`、`AP 运营`、`ERP`、`AI` 等模块降级为隐藏或管理员备用。
|
||||
4. 在 `用户管理` 中按推荐预设配置:老板 / 管理员 / 主管 / 员工。
|
||||
5. 检查功能开关:
|
||||
- 建议开启:`portal_home_v1`、`announcements_v1`、`presence_board_v1`、`weekly_report_v1`、`employee_lifecycle_v1`、`todo_board_v1`、`global_search_v1`
|
||||
@@ -58,7 +58,7 @@
|
||||
- 审批待办
|
||||
|
||||
## 8. 备用模块治理
|
||||
- 采购、差旅、预算、支付批次、AP 运营保留代码和历史数据,但不作为全员主路径。
|
||||
- 考勤、采购、差旅、预算、支付批次、AP 运营、ERP、AI 保留代码和历史数据,但不作为全员主路径。
|
||||
- 若短期完全不用,保持管理员可见即可,不必删除。
|
||||
- 只有出现真实场景时,再恢复到主导航或单独扩展。
|
||||
|
||||
@@ -74,12 +74,14 @@
|
||||
- 至少 1 次真实离职或模拟离职跑通
|
||||
- 全员连续 4 周提交周报
|
||||
- 管理员不再额外维护“今日在岗表”或“离职交接表”
|
||||
- 上线前完成 1 次 DB + 文件存储备份和恢复演练,并记录恢复后的 `/ready` 结果
|
||||
- 普通员工主导航不出现考勤、采购、差旅、预算、AP、ERP、AI 等备用模块
|
||||
|
||||
## 11. 4 周观测操作法
|
||||
1. 每周一进入 `Portal` 查看“试点观测摘要”,记录近 7 天登录覆盖率、周报提交率、团队状态覆盖率。
|
||||
2. 每周五在 `deploy/trialrun/observation-template.md` 填写本周结论、关键阻塞、是否需要调整提醒或导航。
|
||||
3. 若某周有员工未登录、未提周报、未更新状态,优先通过公告或站内通知补提醒,不引入额外表格。
|
||||
4. 连续 4 周都达到预期后,再将 `R17-6` 关单并形成最终验收结论。
|
||||
4. 连续 4 周都达到预期后,再将 R66 试点验收关单并形成最终验收结论。
|
||||
|
||||
## 12. 企业微信入口与登录策略
|
||||
- 建议把企业微信定位为 `入口 + 提醒 + 可选免登`,不要在企业微信里再维护第二套审批或项目录入路径。
|
||||
@@ -151,6 +153,8 @@
|
||||
3. 若启用企业微信入口,补齐:`WECOM_*` 与 `WECOM_ALLOWED_RETURN_HOSTS`。
|
||||
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
|
||||
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
|
||||
6. 准备独立 smoke 账号,不使用老板、管理员或真实员工账号跑自动化。
|
||||
7. 每次正式发布前记录备份文件名、恢复负责人和上个可回滚 tag。
|
||||
|
||||
## 17. 网页自动化门禁(R28/R30)
|
||||
- 推荐发布时直接执行:`bash scripts/qa/run-release-gate.sh all`。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- `报销`:继续提交和跟踪报销。
|
||||
- `周报`:每周写一次三段式周报。
|
||||
- `待办`:记录和跟进个人事项。
|
||||
- `项目`:查看自己的任务、里程碑、风险和交付记录。
|
||||
|
||||
## 2. 第一次登录后建议先做三件事
|
||||
1. 检查个人资料是否正确。
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
- `ERP_SYNC_ENABLED` / `INVOICE_VERIFICATION_ENABLED` 通过 Feature Flag 治理,默认保持关闭。
|
||||
- `npm audit --omit=dev --audit-level=high` 输出需评审并记录处理策略。
|
||||
|
||||
## R66 小团队上线最低要求
|
||||
- 公网试点必须使用 HTTPS,并设置 `APP_ORIGIN=https://<oa-domain>` 与 `COOKIE_SECURE=true`。
|
||||
- `OPENAPI_DOCS_ENABLED=false`,不要在公网生产暴露接口文档。
|
||||
- 管理员账号、smoke 账号和真实员工账号分离;自动化不得使用真实业务账号。
|
||||
- 上线前完成 1 次 DB + 文件存储备份恢复演练,并在 `deploy/trialrun/checklist.md` 记录备份文件名与恢复结果。
|
||||
- 普通员工默认只看到主路径模块;考勤、采购、差旅、预算、AP、ERP、AI 默认隐藏或放入管理员备用区。
|
||||
|
||||
## 性能基线(Dashboard)
|
||||
- 使用 `server/scripts/benchmark-dashboard.sh` 对以下端点做基线采样:
|
||||
- `/api/dashboard/expense-overview`
|
||||
|
||||
@@ -19,3 +19,20 @@
|
||||
## CI Integration
|
||||
流水线集成了 Vitest `npm run test:coverage` 与 node 自定义的 `scripts/coverage_gate.js` 保障上述策略。执行结果会自动附着于 Actions Job Summary。
|
||||
机房脚本将自动解析 coverage-summary.json 从而提取对应业务模块的 `lines.pct` 并在 GITHUB_REF_NAME 为依据判定通过。
|
||||
|
||||
## R66 小团队试点门禁
|
||||
R66 面向 10 人以下团队试点,放行重点是主路径可用、备用模块不干扰、发布可回滚。
|
||||
|
||||
建议上线前执行:
|
||||
|
||||
```bash
|
||||
bash scripts/qa/run-release-gate.sh r66-trialrun
|
||||
```
|
||||
|
||||
该模式串联:
|
||||
|
||||
- `static`:部署契约、`server prisma:validate`、`client typecheck/build`。
|
||||
- `server-regression-all`:后端全量业务回归。
|
||||
- `smoke-local`:本地网页 smoke,覆盖登录、Portal、导航和企业微信入口基础 UI。
|
||||
|
||||
若本机数据库、Playwright 或公网 smoke 账号条件不足,必须在 `deploy/trialrun/checklist.md` 的 R66 小团队试点收敛检查中记录不可执行原因和手工替代步骤。
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
"A1","R66 执行合同建档与范围冻结","创建并校验 R66 小团队试点收敛与上线验收执行合同 CSV,冻结范围:1) 面向 10 人以下团队收敛主路径;2) 建立 4 周试点观测闭环;3) 补齐上线前安全与恢复演练口径;4) 不新增 Prisma 迁移,不新增 npm 依赖,不改审批状态机与 auth API 协议。","issues/2026-04-26_r66-small-team-trialrun-readiness.csv 列完整、Dependencies 为 JSON 数组字符串、A2-A6 范围与批准方案一致,Notes 明确无迁移、无新依赖、不改 workflow/auth 协议与回滚方式。","命令:python3 - <<'PY' 手工解析 CSV 并校验 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 与 issues/README.md 检查 A2-A6 是否只覆盖小团队试点收敛、观测、上线安全恢复与验收门禁。","manual","DONE","DONE","DONE","issues/2026-04-26_r66-small-team-trialrun-readiness.csv","[]","回滚路径:删除本次 CSV 不影响业务。无 Prisma 迁移,无新依赖,不改 workflow/auth API 协议。验证:CSV 结构校验通过。"
|
||||
"A2","小团队主路径与备用模块收敛","收敛 10 人以下团队试点可见范围,明确普通员工、管理者、管理员三类入口:主路径保留 Portal、团队、请假、报销、资产、周报、待办、公告、项目;采购、差旅、预算、支付批次、AP 运营、ERP、AI 等模块降级为管理员备用或开关控制。","1) docs/admin-handbook.md 与 deploy/trialrun/checklist.md 的小团队上线口径一致;2) Portal/导航主路径与员工快速上手文档一致;3) 备用模块不作为普通员工首屏或主导航默认路径;4) 不删除历史模块与数据,只收敛可见入口和上线说明。","命令:cd client && npm run typecheck && npm run build;业务:使用 employee、manager、admin 试点账号分别检查 Portal 首屏与导航,确认普通员工只看到高频主路径,管理员仍可进入备用模块。","manual","DONE","DONE","TODO","docs/admin-handbook.md|docs/employee-quickstart.md|deploy/trialrun/checklist.md|client/src/layouts/AppShellLayout.tsx|client/src/router/index.tsx|client/src/pages/PortalHomePage.tsx","[""A1""]","回滚路径:恢复导航与文档到 R65 口径;不删除模块,不做数据迁移。若实现需改前端,仅限入口显隐与文案收敛。验证:client typecheck/build 与 workspaceSettings.navigation.test 通过;待试点账号人工复核。"
|
||||
"A3","4 周试点观测闭环","把小团队试点观测从模板变成可执行周循环:完善观测模板、README 与导出说明,必要时增强现有 small-team observation 导出脚本,覆盖登录覆盖率、周报提交率、团队状态覆盖率、项目更新、聊天/表格绕行案例与管理员结论。","1) deploy/trialrun/observations/README.md 明确 week-01 到 week-04 产出方式;2) observation-template.md 包含量化指标、人工观察、阻塞、下周调整和验收结论;3) 导出脚本可生成基础报告或明确缺失数据的人工补录点;4) 观测流程不依赖新增外部服务。","命令:cd server && node --check src/scripts/export-small-team-observation.js && npm run report:small-team-observation -- --out ../deploy/trialrun/observations/week-00.md;业务:管理员可按模板在每周五完成一次试点复盘,并能识别是否应继续隐藏或恢复某模块。","manual","DONE","DONE","DONE","deploy/trialrun/observation-template.md|deploy/trialrun/observations/README.md|server/src/scripts/export-small-team-observation.js|docs/admin-handbook.md","[""A1""]","回滚路径:恢复观测模板与导出脚本到 R65 口径;无 Prisma 迁移,无新依赖。验证:node --check 通过;已使用本地试点数据库导出 deploy/trialrun/observations/week-00.md,人工补录区与 4 周循环说明齐全。"
|
||||
"A4","上线前安全与备份恢复演练包","将公网试点前的最小安全、备份、恢复、账号与权限审计动作收敛为一套可执行 Runbook,强调 JWT_SECRET、COOKIE_SECURE、OPENAPI_DOCS_ENABLED、管理员账号清单、最小权限、DB+文件存储备份与至少一次恢复演练。","1) deploy/trialrun/runbook.md 与 checklist.md 明确上线前必须完成安全检查、备份、恢复演练和管理员账号核对;2) docs/ops/non-functional-baseline.md 同步小团队上线最低安全口径;3) scripts/backup-db.sh、backup-storage.sh、restore-db.sh、restore-storage.sh 的使用路径在文档中可复现;4) 不引入新的安全产品或外部依赖。","命令:bash -n scripts/backup-db.sh && bash -n scripts/backup-storage.sh && bash -n scripts/restore-db.sh && bash -n scripts/restore-storage.sh && node deploy/trialrun/validate-env-contract.js;业务:在试点环境完成一次备份恢复演练并记录备份文件名、恢复目标、/ready 结果和回滚负责人。","manual","DONE","DONE","TODO","deploy/trialrun/runbook.md|deploy/trialrun/checklist.md|docs/ops/non-functional-baseline.md|scripts/backup-db.sh|scripts/backup-storage.sh|scripts/restore-db.sh|scripts/restore-storage.sh","[""A1""]","回滚路径:恢复 Runbook 与 checklist 文档;脚本若有改动仅限帮助信息或校验提示,不改变备份格式。验证:备份/恢复脚本 bash -n 与 validate-env-contract 通过;实际备份恢复演练待试点环境执行。"
|
||||
"A5","试点发布门禁与验收矩阵收口","定义 R66 试点发布的最小门禁和验收矩阵:本地静态门禁、核心后端回归、前端 typecheck/build、smoke-local、可选 live smoke、4 周观测结论;避免每次发布依赖大量手工点检。","1) checklist.md 中 R66 放行条件清晰区分必须项、建议项和人工观测项;2) docs/ops/testing-strategy.md 或相关发布文档补充 R66 小团队试点门禁;3) 影响范围验证命令覆盖 server prisma:validate、server regression-all、client typecheck/build;4) 若无法执行某项自动化,必须记录原因与手工替代步骤。","命令:bash scripts/qa/run-release-gate.sh static && cd server && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build;业务:发布负责人可按 checklist 判断本轮是否允许部署到 trialrun,并知道失败时查看哪类产物。","manual","DONE","DONE","DONE","deploy/trialrun/checklist.md|docs/ops/testing-strategy.md|scripts/qa/run-release-gate.sh|scripts/qa/run-web-gates.sh|scripts/check-minimal.sh|server/scripts/regression-im-notifier.sh","[""A2"",""A3"",""A4""]","回滚路径:恢复发布门禁文档和脚本到 R65 口径;脚本改动仅限 R66 模式封装、Docker PostgreSQL client 支持与 IM 回归断言对齐,不降低既有门禁。验证:QA_DB_CLIENT=docker QA_DB_DOCKER_CONTAINER=hyb-oa-trialrun-db QA_DOCKER_USE_SG=1 bash scripts/qa/run-release-gate.sh r66-trialrun 通过;包含 static、server regression-all、smoke-local Playwright 8/8;server npm audit 仍有 27 个漏洞(11 high、4 critical),需依赖升级合同评审。"
|
||||
"A6","R66 验证、状态回填与提交收口","在 A2-A5 完成后执行相关验证,回填 Issue CSV 状态,形成 R66 小团队试点上线建议和后续 4 周观测计划,并提交代码。","1) A2-A5 的 Test_Method 按影响范围通过或记录不可执行原因;2) issues/2026-04-26_r66-small-team-trialrun-readiness.csv 状态按实测结果回填;3) 输出试点上线建议,包含开放模块、隐藏模块、管理员每周动作、备份恢复演练结论;4) git commit 成功,提交信息引用 R66 A1-A6。","命令:git diff --check && cd server && npm run prisma:validate && ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build;业务:使用 10 人以下团队口径复核 Portal、导航、试点观测、备份恢复 Runbook 与发布 checklist 是否能支持一周真实试用。","manual","DOING","TODO","TODO","issues/2026-04-26_r66-small-team-trialrun-readiness.csv|docs/admin-handbook.md|docs/employee-quickstart.md|deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|deploy/trialrun/observation-template.md|deploy/trialrun/observations/README.md|scripts/qa/run-web-gates.sh|server/scripts/regression-im-notifier.sh","[""A5""]","回滚路径:若验证失败,不回填 DONE,不提交实现;必要时回退 R66 文档、脚本与前端入口收敛改动。当前未提交:自动验证已完成 git diff --check、CSV 校验、server prisma:validate、server regression-all、client typecheck/build、IM 专项回归、Docker client smoke-local 与 r66-trialrun;仍待试点账号人工复核、备份恢复演练和 server audit 风险处理。commit message:R66 A1-A6:小团队试点收敛与上线验收。"
|
||||
|
@@ -0,0 +1,7 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"A1","R67 UI 改造合同建档与范围冻结","创建 R67 前端 UI 改造执行合同,冻结第一轮范围:只改前端视觉基础、App Shell、共享组件、Portal 首页与登录页;视觉方向参考公司官网 www.hyinda.com;不改后端 API、权限、workflow、状态机、Prisma 迁移和导航可见性规则。","CSV 列完整,Dependencies 为 JSON 数组字符串;A2-A6 范围与批准方案一致;Notes 明确无后端、无迁移、无权限与状态机变更,包含回滚路径。","命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R67 仅覆盖前端 UI 改造,不触碰 workflow、权限、审批规则、迁移。","manual","DONE","DONE","DONE","issues/2026-04-26_r67-ui-refresh.csv","[]","回滚路径:删除本 CSV 草稿即可回到 R66 基线;无后端 API、无 Prisma 迁移、无权限/状态机/workflow 变更。验证:CSV 结构校验通过,范围已按批准方案冻结;设计参考 www.hyinda.com 的深海蓝、冷浅灰、白卡、几何 Logo 与水声科技感。"
|
||||
"A2","视觉基础与设计 token 刷新","参考公司官网 www.hyinda.com,将当前灰白后台基调升级为深海蓝科技感轻量运营工作台:更新 CSS 变量、Tailwind token、页面背景、卡片层级、焦点与状态色,减少同质化白卡。","1) 全局背景、卡片、表格、badge、输入控件视觉层级更清晰;2) 不引入新 npm 依赖;3) 不破坏既有类名兼容性;4) 页面在桌面和移动端保持可读。","命令:cd client && npm run typecheck && npm run build;业务:打开 Portal、列表页、系统设置和移动入口,确认共享样式未造成文字不可读、内容溢出或点击目标缩小。","manual","DONE","DONE","DONE","client/src/design/theme.css|client/src/design/tokens.ts|client/src/index.css|client/tailwind.config.*","[""A1""]","回滚路径:恢复 R66 token 与共享 CSS;不新增依赖,不改业务逻辑。官网参考:主色 #00398a,冷浅灰背景 #f5f7f8,白色卡片,slate 文本,几何 Logo。验证:client typecheck/build 通过;员工、管理者、管理员三类账号视觉复核已通过。"
|
||||
"A3","App Shell 与导航视觉重构","优化侧边栏、顶部栏、移动菜单与导航项视觉层级,让用户先看主路径、再看管理入口、最后看备用模块;保留 R66 小团队导航显隐规则。","1) 主导航、管理入口、备用模块层级更明显;2) 当前路由状态、搜索、通知、用户问候区更清晰;3) 移动菜单仍可正常打开/关闭;4) 不改变导航数据来源与权限过滤。","命令:cd client && npm run typecheck && npm run build;业务:使用员工、管理者、管理员账号检查 Portal、请假、报销、系统设置、备用模块入口,确认可见性与 R66 一致。","manual","DONE","DONE","DONE","client/src/layouts/AppShellLayout.tsx|client/src/components/navigation/Sidebar.tsx|client/src/components/navigation/Topbar.tsx|client/src/components/navigation/NavLinks.tsx|client/src/components/shell/AppBrand.tsx","[""A2""]","回滚路径:恢复 Shell 与导航组件到 R66;仅改视觉和布局 class,不改权限与导航 schema。验证:client typecheck/build 通过;员工、管理者、管理员三类账号视觉复核已通过。"
|
||||
"A4","共享工作台组件刷新","重构 PageScaffold、WorkbenchHero、SectionPanel、MetricCard、EmptyState 等共享展示模式,让页面不再像一组相同白色卡片堆叠,同时保持现有组件 API。","1) 共享组件 API 不破坏现有页面调用;2) Hero、指标卡、内容面板、空态卡有明确视觉角色差异;3) 警告/危险状态仍可辨识;4) 大量复用页面自动受益。","命令:cd client && npm run typecheck && npm run build;业务:抽查 Portal、组织架构、公告、报销详情、系统设置页面,确认共享组件样式一致且无布局回归。","manual","DONE","DONE","DONE","client/src/components/patterns/*.tsx|client/src/components/common/EmptyStateCard.tsx|client/src/components/common/PageHeader.tsx|client/src/index.css","[""A2""]","回滚路径:恢复共享组件和 CSS class;保持组件 props 不变,降低回滚风险。验证:client typecheck/build 通过;员工、管理者、管理员三类账号视觉复核已通过。"
|
||||
"A5","Portal 与登录页旗舰体验刷新","将 Portal 首页和登录页作为第一轮样板页,强化今日待办、快捷动作、团队状态、近期动态和进入方式的视觉重点,减少用户觉得“丑”和“像后台模板”的感知。","1) Portal 首屏能快速识别今日要做什么;2) 快捷动作、待办、团队状态、动态流区分明显;3) 登录页保持企业微信/账号密码回退逻辑不变;4) 文案和视觉更适合小团队日常使用。","命令:cd client && npm run typecheck && npm run build;业务:员工视角检查 Portal 首屏、快捷操作、待办、公告;管理者视角检查团队状态、周报缺口、试点观测;浏览器和企业微信入口登录逻辑保持可理解。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/layouts/AuthLayout.tsx|client/src/pages/LoginPage.tsx","[""A3"",""A4""]","回滚路径:恢复 Portal/Login 相关组件;不改 API 调用、query key、登录流程、权限判断。验证:client typecheck/build 通过;员工、管理者、管理员三类账号视觉复核已通过;R68 已继续优化 Portal 首页信息架构。"
|
||||
"A6","R67 验证、状态回填与提交收口","完成 UI 改造后执行前端验证、必要 smoke 检查、人工视觉复核和 CSV 状态回填,形成 R67 提交。","1) A2-A5 的 Test_Method 已执行并记录结果;2) client typecheck/build 通过;3) 受影响主路径人工复核通过或记录未执行原因;4) git commit 成功,提交信息引用 R67 A1-A6。","命令:git diff --check && cd client && npm run typecheck && npm run build;业务:按员工、管理者、管理员三类入口复核 Portal、导航、登录、通知入口、搜索入口和移动菜单。","manual","DONE","DONE","DONE","issues/2026-04-26_r67-ui-refresh.csv|client/src/**","[""A5""]","回滚路径:若验证失败,不回填 DONE,不提交;必要时整批恢复 R67 前端视觉改动。验证:自动验证已完成 client typecheck/build;截图复核与人工视觉验收已通过。"
|
||||
|
@@ -0,0 +1,7 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"A1","R68 Portal 首页信息架构合同建档","创建 R68 执行合同,冻结范围:只优化 Portal 首页信息架构、对齐、内容密度、折叠层级与截图复核产物;不改后端 API、权限过滤、workflow、审批规则、状态机、Prisma 迁移和导航可见性规则。","CSV 列完整,Dependencies 为 JSON 数组字符串;A2-A6 范围与已批准方案一致;Notes 明确无后端、无迁移、无权限与状态机变更,包含回滚路径。","命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R68 仅覆盖前端 Portal 首页 UI 信息架构,不触碰 workflow、权限、审批规则、迁移。","manual","DONE","DONE","DONE","issues/2026-04-26_r68-portal-information-architecture.csv","[]","回滚路径:删除本 CSV 草稿即可回到 R67 基线;无后端 API、无 Prisma 迁移、无权限/状态机/workflow 变更。设计原则:10 人以下小团队首页优先今日待办、快捷动作、团队状态和少量动态,其余内容进入第二阅读层。"
|
||||
"A2","Portal 首屏 8/4 信息骨架重排","将 Portal 首屏从多个等权卡片区改为稳定的主内容 8/4 骨架:上方保留紧凑 Hero 与 4 个核心指标,主体左侧优先展示今日待办,右侧固定快捷操作。","1) 首屏桌面端形成稳定左主右辅结构;2) 快捷操作不再与待办等权横铺;3) 移动端保持单列顺序:指标、待办、快捷操作;4) 不改变 query key、API 调用和跳转目标。","命令:cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员进入 Portal,确认核心指标顺序不变,待办和快捷操作首屏可见且链接/按钮可用。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/components/patterns/WorkbenchHero.tsx|client/src/index.css","[""A1""]","回滚路径:恢复 PortalHomePage、WorkbenchHero 与 CSS 到 R67;仅改前端布局和 class。验证:client typecheck/build 通过;截图 01-04 覆盖桌面与移动首屏。"
|
||||
"A3","团队状态与动态公告区域对齐优化","将团队全员状态从卡片网格改为紧凑列表,最多展示 5 人并提供查看全部;将近期动态与公告合并为右侧阅读卡,减少同质白卡堆叠。","1) 团队状态行高稳定、头像/姓名/状态/打卡信息对齐;2) 超过 5 人时显示查看全部;3) 动态与公告在同一右侧面板内切换或分层展示;4) 空态不占用过高页面空间。","命令:cd client && npm run typecheck && npm run build;业务:员工和管理视角检查团队状态、近期动态、公告内容仍可读,团队页和公告页入口仍可达。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/index.css","[""A2""]","回滚路径:恢复 R67 团队状态网格、动态流面板与公告面板;不改数据结构。验证:团队列表最多 5 人、查看全部入口和动态/公告 Tab 已在截图与 e2e 中覆盖。"
|
||||
"A4","第二阅读层折叠与角色内容收敛","将我的申请/资产、继续工作、常用入口、管理补充信息、试点观测摘要收敛到第二阅读层,默认减少首页纵向长度,同时保留原有入口可访问。","1) 员工仍能看到我的申请与资产入口提示;2) 管理者仍能看到今日管理重点与试点观测摘要入口提示;3) 折叠区展开后原入口和指标仍可访问;4) 备用模块默认折叠逻辑不受影响。","命令:cd client && npm run typecheck && npm run build;业务:员工打开折叠区检查我的申请/资产、继续工作、常用入口;管理员检查管理补充信息与试点观测摘要。","manual","DONE","DONE","DONE","client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts","[""A3""]","回滚路径:恢复 R67 角色区块直铺展示;仅改前端渲染层级,不改变权限或导航来源。验证:login-portal-ux 6/6 通过,备用模块默认折叠逻辑保持。"
|
||||
"A5","R68 自动化与截图复核产物","更新受影响的前端断言,并生成 R68 Portal 复核截图索引,便于人工快速检查对齐、折叠和移动端表现。","1) 相关 e2e 断言与新信息架构一致;2) 员工、管理者、管理员 Portal 截图可通过 index.html 查看;3) 截图产物路径固定并记录;4) 若环境不可用,Notes 写明原因和人工替代步骤。","命令:git diff --check && cd client && npm run typecheck && npm run build;可选:cd client && npm run test:e2e -- tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts;业务:打开截图 index.html 复核桌面与移动主要断点。","manual","DONE","DONE","DONE","client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/visual-portal.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/**","[""A4""]","回滚路径:删除 R68 截图产物并恢复测试断言;截图文件不影响运行时。验证:已生成 deploy/trialrun/screenshots/r68-portal-ia-review/index.html 和 4 张 Portal 截图;login-portal-ux 在 Vite dev server 下 6/6 通过。"
|
||||
"A6","R68 验证、状态回填与提交收口","完成 Portal 信息架构优化后执行前端验证、必要 smoke 检查、截图复核产物生成和 CSV 状态回填,形成 R68 提交候选。","1) A2-A5 的 Test_Method 已执行并记录结果;2) git diff --check、client typecheck/build 通过;3) 截图产物已生成或记录阻塞;4) git status 只包含本轮预期变更和既有未跟踪截图。","命令:git diff --check && python3 CSV 校验 && cd client && npm run typecheck && npm run build;业务:员工、管理者、管理员三类入口复核 Portal 首页信息层级、对齐、折叠和移动端单列顺序。","manual","DONE","DONE","DONE","issues/2026-04-26_r68-portal-information-architecture.csv|client/src/pages/PortalHomePage.tsx|client/src/index.css|client/tests/e2e/*.spec.ts|deploy/trialrun/screenshots/r68-portal-ia-review/**","[""A5""]","回滚路径:若验证失败,不回填 DONE,不提交;必要时整批恢复 R68 Portal 信息架构改动。验证:git diff --check、CSV 校验、client build 已通过;最终状态核对后提交。"
|
||||
|
@@ -0,0 +1,10 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"A1","R69 业务验收合同建档","创建 R69 小团队试运行业务验收合同,冻结范围:只做业务流程验证、回归门禁、缺陷记录和必要 P0/P1 修复判断;默认不新增功能、不改 UI、不改 schema。","CSV 列完整,Dependencies 为 JSON 数组字符串;验收范围覆盖员工、经理、管理员三类账号;Notes 明确若触发 workflow、权限、状态机、审批规则、迁移修复必须追加 review。","命令:python3 - <<'PY' 校验 CSV 12 列、状态值、Dependencies JSON 数组字符串;业务:对照 AGENTS.md 确认 R69 是验收与缺陷收敛,不主动扩大功能范围。","manual","DONE","DONE","DONE","issues/2026-04-26_r69-business-acceptance.csv","[]","回滚路径:删除本 CSV 草稿即可回到 R68;若后续发现 P0/P1 缺陷,先记录再单独方案,不直接扩大范围。"
|
||||
"A2","静态与基础构建门禁","执行发布前基础门禁:环境契约、Prisma schema 校验、client typecheck/build,确认 R66-R68 本地提交未破坏基础构建。","1) deploy/trialrun 环境契约校验通过;2) server prisma:validate 通过;3) client typecheck/build 通过;4) 若失败,记录失败命令、日志摘要和阻塞级别。","命令:node deploy/trialrun/validate-env-contract.js;cd server && npm run prisma:validate;cd client && npm run typecheck && npm run build;业务:确认基础部署与前后端构建门禁通过。","manual","DONE","DONE","DONE","deploy/trialrun/validate-env-contract.js|server/prisma/schema.prisma|client/src/**","[""A1""]","R69 已通过:环境契约、server prisma:validate、client typecheck、client build 均通过;build 仅保留既有 chunk 体积/循环依赖警告。回滚路径:静态门禁不改代码。"
|
||||
"A3","后端核心回归门禁","执行后端核心业务回归,覆盖 smoke、判重、workflow、采购、资产、审计、OCR、IM notifier 等现有脚本默认范围。","1) regression-all.sh 默认范围通过,或输出结构化失败报告;2) 若失败项涉及审批规则、状态机、权限或迁移,标记为需额外 review;3) 不修改 server 代码,除非后续方案获批。","命令:cd server && ./scripts/regression-all.sh --json-report ../artifacts/r69/regression-all.json;业务:确认后端核心流程不因近期改动产生回归。","manual","DONE","DONE","DONE","server/scripts/regression-all.sh|artifacts/r69/regression-all.json","[""A2""]","R69 full-local 后端阶段已通过:server 单测 98 个 suite / 429 tests 通过,workflow-actions 与 regression-all 默认范围通过;部分可选域按环境默认跳过。回滚路径:回归执行不改代码。"
|
||||
"A4","本地网页 smoke 门禁","使用临时数据库和本地 server 执行网页 smoke,验证登录、Portal、企业微信入口、导航基础链路可运行。","1) smoke-local 通过;2) 临时数据库自动创建、迁移、seed、清理;3) server 端口自动释放;4) 若 Docker 权限或数据库不可用,记录精确阻塞和人工替代步骤。","命令:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh smoke-local;业务:员工/管理员登录后能进入 Portal,登录入口与 smoke e2e 通过。","manual","DONE","DONE","DONE","scripts/qa/run-web-gates.sh|client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/wecom-entry.spec.ts","[""A2""]","R69 已通过:smoke-local 8 项 Playwright smoke 通过,临时数据库创建、迁移、seed、清理与端口释放正常。回滚路径:网页 smoke 不改业务代码。"
|
||||
"A5","员工日常业务闭环验收","验收员工账号的日常闭环:登录、Portal、请假提交、报销提交、考勤、周报、通知、搜索和移动入口。","1) 员工可登录并进入 Portal;2) 请假/报销/考勤/周报关键入口可用;3) 通知和全局搜索无前端阻断;4) 失败项按 P0/P1/P2 分类记录。","命令:优先执行相关 e2e:login-portal-ux、leave-flow、reimbursement-workflow-progress、weekly-todo-flow、announcements-presence、global-search;业务:[email protected] 按日常路径完成提交与查看。","manual","DONE","DONE","DONE","client/tests/e2e/login-portal-ux.spec.ts|client/tests/e2e/leave-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/weekly-todo-flow.spec.ts|client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/global-search.spec.ts","[""A4"",""A8""]","R69 full-local 已通过:员工 Portal、请假、报销审批进度、周报待办、公告、全局搜索、移动审批等日常闭环通过;初始登录态回退由 A8 修复。回滚路径:验收状态回填可单独回滚。"
|
||||
"A6","经理与管理员业务边界验收","验收经理与管理员账号的审批、团队状态、周报缺口、系统设置、导航权限、审计与备用模块边界。","1) 经理可看到审批与团队协同入口;2) 管理员可访问系统设置和管理入口;3) 普通员工看不到不应可见的管理入口;4) 权限边界失败标记为 P0/P1 并要求额外 review。","命令:优先执行 workflow-quick-approve、leave-team-calendar、settings-governance、confidential-boundary、visual-shell 关键断言;业务:manager/admin 分别检查审批、团队、系统设置、审计和备用模块。","manual","DONE","DONE","DONE","client/tests/e2e/workflow-quick-approve.spec.ts|client/tests/e2e/leave-team-calendar.spec.ts|client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/visual-shell.spec.ts","[""A4"",""A8""]","R69 full-local 已通过:经理快捷审批、团队日历、系统设置治理、涉密边界、视觉壳层与管理员 Portal 边界通过;未修改权限模型。回滚路径:验收状态回填可单独回滚。"
|
||||
"A7","R69 验收报告与下一轮缺陷清单","汇总 R69 执行结果,形成通过项、失败项、阻塞项、需人工复核项和建议进入 R70 的缺陷清单;必要时提交 CSV 状态回填。","1) A2-A6 测试结果已记录;2) artifacts/r69/acceptance-report.md 存在并包含命令、结果、风险、下一步;3) CSV 状态与报告一致;4) 若无阻断缺陷,明确可进入试运行人工业务试用。","命令:汇总测试日志和 artifacts/r69/regression-all.json;业务:按员工、经理、管理员三类账号给出试运行可用性结论。","manual","DONE","DONE","DONE","artifacts/r69/acceptance-report.md|issues/2026-04-26_r69-business-acceptance.csv","[""A3"",""A5"",""A6""]","已生成 artifacts/r69/acceptance-report.md;结论:自动化门禁无阻断缺陷,可进入小团队人工试运行;R70 建议聚焦人工业务试用、chunk 拆分和更细的产品化验收清单。回滚路径:报告与 CSV 可单独回滚。"
|
||||
"A8","P1 会话刷新并发硬化","修复 R69 full E2E 暴露的刷新令牌旋转并发问题:多个前端路径几乎同时调用 /api/auth/refresh 时,不能因第二个旧 refresh token 401 把用户踢回登录页。","1) 前端 refresh 调用在模块级 single-flight;2) 同一页面内相邻刷新复用同一次成功结果,不重复消耗旋转 refresh token;3) 登录/登出/清会话会重置刷新缓存,避免复用旧 access token;4) 不修改后端复用检测和权限/状态机规则。","命令:cd client && npm run typecheck && npm run build;聚焦 e2e:announcements-presence、global-search、leave-flow、settings-governance;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:员工/经理/管理员登录后跨页面导航不回退登录页。","manual","DONE","DONE","DONE","client/src/auth/authService.ts|client/src/auth/AuthContext.tsx|client/src/api/http.ts|client/tests/e2e/login-portal-ux.spec.ts","[""A4""]","已完成并通过 full-local:refreshSession 模块级 single-flight + 1 秒成功回放,登录/登出/清会话重置缓存;未放宽后端 refresh token 复用检测,未改权限/状态机。回滚路径:撤销 authService/AuthContext 相关改动。"
|
||||
"A9","R69 E2E 断言校准与移动在线态修复","修复 A8 后 full E2E 暴露的非业务回归阻断:R68 Portal 信息架构折叠/Tab 改版后测试断言过期,报销创建入口改为下拉菜单后测试未选择空白报销,合同涉密测试少传必填 contractType,WebKit 初始 navigator.onLine=false 导致移动审批误判离线。","1) 不修改审批规则、权限、状态机或迁移;2) E2E 与当前 UI 结构一致,仍验证公告、项目、报销、资产、采购、涉密和移动审批业务目标;3) WebKit 在线登录后移动审批能加载远端待办,切换 offline 后仍进入离线草稿流程;4) full-local 重新执行并记录剩余风险。","命令:cd client && npm run typecheck && npm run build;聚焦:npx playwright test 相关失败 specs --workers=1;最终:QA_DB_CLIENT=docker QA_DOCKER_USE_SG=1 bash scripts/qa/run-web-gates.sh full-local;业务:R69 full E2E 不再因测试断言过期或移动在线态误判阻断。","manual","DONE","DONE","DONE","client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/main-flow.spec.ts|client/tests/e2e/reimbursement-workflow-progress.spec.ts|client/tests/e2e/confidential-boundary.spec.ts|client/tests/e2e/assets-flow.spec.ts|client/tests/e2e/procurement-flow.spec.ts|client/tests/e2e/workflow-quick-approve.spec.ts|client/src/pages/MobileApprovalsPage.tsx|client/src/pages/AssetRegistryPage.tsx","[""A8""]","已完成并通过 full-local:校准 R68 UI 后的断言、报销空白模板入口、合同 contractType、移动在线态、采购/资产详情定位、请假并发日期窗口;AssetRegistryPage 新建模式防旧详情回写污染表单。未改 workflow/权限/状态机/审批规则/迁移。回滚路径:撤销 A9 测试、MobileApprovalsPage 与 AssetRegistryPage 改动。"
|
||||
|
@@ -10,7 +10,7 @@ mkdir -p "$ARTIFACT_ROOT"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|help>
|
||||
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|deploy|ci-release|r66-trialrun|help>
|
||||
|
||||
说明:
|
||||
static 运行部署契约、server Prisma 校验、client typecheck/build
|
||||
@@ -19,6 +19,7 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|de
|
||||
deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
|
||||
all 依次执行 static -> smoke-local -> full-local -> deploy
|
||||
ci-release 依次执行 static -> full-local -> deploy
|
||||
r66-trialrun R66 小团队试点门禁:static -> server regression-all -> smoke-local
|
||||
|
||||
可选环境变量:
|
||||
RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
|
||||
@@ -73,6 +74,13 @@ stage_full_local() {
|
||||
bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" full-local
|
||||
}
|
||||
|
||||
stage_server_regression_all() {
|
||||
(
|
||||
cd "$ROOT_DIR/server"
|
||||
./scripts/regression-all.sh
|
||||
)
|
||||
}
|
||||
|
||||
stage_deploy() {
|
||||
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
|
||||
}
|
||||
@@ -98,6 +106,11 @@ case "$MODE" in
|
||||
run_stage full-local stage_full_local
|
||||
run_stage deploy stage_deploy
|
||||
;;
|
||||
r66-trialrun)
|
||||
run_stage static stage_static
|
||||
run_stage server-regression-all stage_server_regression_all
|
||||
run_stage smoke-local stage_smoke_local
|
||||
;;
|
||||
all)
|
||||
run_stage static stage_static
|
||||
run_stage smoke-local stage_smoke_local
|
||||
|
||||
+77
-6
@@ -14,6 +14,9 @@ QA_DB_PORT="${QA_DB_PORT:-5432}"
|
||||
QA_DB_USER="${QA_DB_USER:-hyboa_user}"
|
||||
QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}"
|
||||
QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}"
|
||||
QA_DB_CLIENT="${QA_DB_CLIENT:-host}"
|
||||
QA_DB_DOCKER_CONTAINER="${QA_DB_DOCKER_CONTAINER:-hyb-oa-trialrun-db}"
|
||||
QA_DOCKER_USE_SG="${QA_DOCKER_USE_SG:-0}"
|
||||
QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}"
|
||||
QA_KEEP_DB="${QA_KEEP_DB:-0}"
|
||||
QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}"
|
||||
@@ -38,11 +41,65 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|smoke-live>
|
||||
可选环境变量:
|
||||
QA_PORT / QA_HOST
|
||||
QA_DB_HOST / QA_DB_PORT / QA_DB_USER / QA_DB_PASSWORD / QA_DB_ADMIN_DB
|
||||
QA_DB_CLIENT=host|docker
|
||||
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
|
||||
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
|
||||
LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
|
||||
USAGE
|
||||
}
|
||||
|
||||
qa_join_command() {
|
||||
local command="$1"
|
||||
shift
|
||||
local rendered="$command"
|
||||
local arg
|
||||
local quoted
|
||||
|
||||
for arg in "$@"; do
|
||||
printf -v quoted '%q' "$arg"
|
||||
rendered+=" $quoted"
|
||||
done
|
||||
|
||||
printf '%s' "$rendered"
|
||||
}
|
||||
|
||||
qa_docker() {
|
||||
if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then
|
||||
local command
|
||||
command="$(qa_join_command docker "$@")"
|
||||
sg docker -c "$command"
|
||||
return
|
||||
fi
|
||||
|
||||
docker "$@"
|
||||
}
|
||||
|
||||
qa_require_db_client() {
|
||||
case "$QA_DB_CLIENT" in
|
||||
host)
|
||||
qa_require_cmd psql
|
||||
qa_require_cmd createdb
|
||||
qa_require_cmd dropdb
|
||||
;;
|
||||
docker)
|
||||
qa_require_cmd docker
|
||||
if [[ "$QA_DOCKER_USE_SG" == "1" ]]; then
|
||||
qa_require_cmd sg
|
||||
fi
|
||||
|
||||
if ! qa_docker inspect "$QA_DB_DOCKER_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "[ERROR] PostgreSQL 容器不可用: $QA_DB_DOCKER_CONTAINER" >&2
|
||||
echo "[ERROR] 请确认容器运行,或设置 QA_DB_DOCKER_CONTAINER=<container>。" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] QA_DB_CLIENT 仅支持 host 或 docker,当前: $QA_DB_CLIENT" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
qa_port_in_use() {
|
||||
python3 - "$QA_HOST" "$QA_PORT" <<'PY'
|
||||
import socket, sys
|
||||
@@ -129,10 +186,22 @@ qa_wait_ready() {
|
||||
}
|
||||
|
||||
qa_psql() {
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
psql -v ON_ERROR_STOP=1 -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@"
|
||||
}
|
||||
|
||||
qa_createdb() {
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
createdb -U "$QA_DB_USER" "$QA_DB_NAME"
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" createdb -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME"
|
||||
}
|
||||
|
||||
@@ -143,6 +212,12 @@ qa_dropdb() {
|
||||
fi
|
||||
|
||||
qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true
|
||||
if [[ "$QA_DB_CLIENT" == "docker" ]]; then
|
||||
qa_docker exec -e "PGPASSWORD=$QA_DB_PASSWORD" "$QA_DB_DOCKER_CONTAINER" \
|
||||
dropdb --if-exists -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true
|
||||
}
|
||||
|
||||
@@ -343,9 +418,7 @@ qa_run_live_smoke() {
|
||||
case "$MODE" in
|
||||
smoke-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_cmd psql
|
||||
qa_require_cmd createdb
|
||||
qa_require_cmd dropdb
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
@@ -353,9 +426,7 @@ case "$MODE" in
|
||||
;;
|
||||
full-local)
|
||||
qa_require_cmd curl
|
||||
qa_require_cmd psql
|
||||
qa_require_cmd createdb
|
||||
qa_require_cmd dropdb
|
||||
qa_require_db_client
|
||||
qa_require_cmd node
|
||||
qa_require_cmd npm
|
||||
trap qa_cleanup EXIT
|
||||
|
||||
@@ -41,11 +41,74 @@ FIELD_ENCRYPTION_KEY="${FIELD_ENCRYPTION_KEY:-$TEST_FIELD_ENCRYPTION_KEY_DEFAULT
|
||||
export FIELD_ENCRYPTION_KEY
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
REGRESSION_CHANNEL_IDS=()
|
||||
|
||||
track_regression_channel() {
|
||||
local channel_id="$1"
|
||||
REGRESSION_CHANNEL_IDS+=("$channel_id")
|
||||
}
|
||||
|
||||
disable_channels_by_ids() {
|
||||
if [[ "${#REGRESSION_CHANNEL_IDS[@]}" -eq 0 || -z "${DATABASE_URL:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local channel_ids_csv
|
||||
channel_ids_csv="$(IFS=,; printf '%s' "${REGRESSION_CHANNEL_IDS[*]}")"
|
||||
DATABASE_URL="$DATABASE_URL" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE' >/dev/null 2>&1 || true
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const channelIds = (process.env.CHANNEL_IDS || '').split(',').filter(Boolean);
|
||||
if (!channelIds.length) return;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: { in: channelIds } },
|
||||
data: { enabled: false },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch(() => {});
|
||||
NODE
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
disable_channels_by_ids
|
||||
rm -rf "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
disable_stale_regression_channels() {
|
||||
DATABASE_URL="$DATABASE_URL" node - <<'NODE' >/dev/null 2>&1 || true
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { startsWith: 'IM Mock ' } },
|
||||
{ name: { startsWith: 'IM Failing ' } },
|
||||
{ name: { startsWith: 'IM Secret ' } },
|
||||
{ name: { startsWith: 'IM Legacy ' } },
|
||||
],
|
||||
},
|
||||
data: { enabled: false },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch(() => {});
|
||||
NODE
|
||||
}
|
||||
|
||||
assert_status() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
@@ -374,7 +437,7 @@ create_and_submit_purchase() {
|
||||
printf '%s\n' "$purchase_id"
|
||||
}
|
||||
|
||||
query_im_outboxes_for_entity() {
|
||||
query_im_deliveries_for_entity() {
|
||||
local entity_id="$1"
|
||||
local channel_ids_csv="$2"
|
||||
DATABASE_URL="$DATABASE_URL" ENTITY_ID="$entity_id" CHANNEL_IDS="$channel_ids_csv" node - <<'NODE'
|
||||
@@ -386,33 +449,56 @@ const { createPrismaClient } = require('./src/prismaClient');
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const items = await prisma.integrationOutbox.findMany({
|
||||
where: { event_type: 'notification.im' },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 200,
|
||||
const instances = await prisma.workflowInstance.findMany({
|
||||
where: { purchase_request_id: entityId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
last_error: true,
|
||||
payload: true,
|
||||
workflow_tasks: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const taskIds = instances.flatMap((instance) => instance.workflow_tasks.map((task) => task.id));
|
||||
const notifications = taskIds.length
|
||||
? await prisma.notification.findMany({
|
||||
where: {
|
||||
type: 'workflow.task_assigned',
|
||||
target_type: 'workflow_task',
|
||||
target_id: { in: taskIds },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: [];
|
||||
const notificationIds = notifications.map((notification) => notification.id);
|
||||
const deliveries = notificationIds.length
|
||||
? await prisma.notificationDelivery.findMany({
|
||||
where: {
|
||||
notification_id: { in: notificationIds },
|
||||
channel_id: { in: channelIds },
|
||||
},
|
||||
orderBy: [{ channel_id: 'asc' }, { attempt_no: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
notification_id: true,
|
||||
channel_id: true,
|
||||
status: true,
|
||||
attempt_no: true,
|
||||
result_code: true,
|
||||
error_message: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
const payload = item.payload && typeof item.payload === 'object' ? item.payload : null;
|
||||
return payload
|
||||
&& payload.entityId === entityId
|
||||
&& payload.notificationType === 'workflow.task_assigned'
|
||||
&& channelIds.includes(payload.channelId);
|
||||
}).map((item) => ({
|
||||
const items = deliveries.map((item) => ({
|
||||
id: item.id,
|
||||
notificationId: item.notification_id,
|
||||
status: item.status,
|
||||
lastError: item.last_error,
|
||||
channelId: item.payload.channelId,
|
||||
code: item.payload.notificationType,
|
||||
attemptNo: item.attempt_no,
|
||||
resultCode: item.result_code,
|
||||
errorMessage: item.error_message,
|
||||
channelId: item.channel_id,
|
||||
}));
|
||||
|
||||
process.stdout.write(JSON.stringify(filtered));
|
||||
process.stdout.write(JSON.stringify(items));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
@@ -568,7 +654,9 @@ const {
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[1/7] login employee + finance_mgr"
|
||||
disable_stale_regression_channels
|
||||
|
||||
echo "[1/10] login employee + finance_mgr"
|
||||
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")"
|
||||
FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")"
|
||||
|
||||
@@ -579,30 +667,28 @@ SUPPLIER_ID="${FIXTURE_IDS[2]}"
|
||||
DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}"
|
||||
BUDGET_POOL_ID="${FIXTURE_IDS[4]}"
|
||||
|
||||
echo "[2/7] create mock + failing wecom channels"
|
||||
echo "[2/10] create mock + failing wecom channels"
|
||||
MOCK_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Mock ${RUN_SUFFIX}" "mock" "" "" "$TMP_DIR/create-mock-channel.json")"
|
||||
track_regression_channel "$MOCK_CHANNEL_ID"
|
||||
FAIL_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Failing ${RUN_SUFFIX}" "wecom_webhook" "http://127.0.0.1:9/wecom" "" "$TMP_DIR/create-failing-channel.json")"
|
||||
track_regression_channel "$FAIL_CHANNEL_ID"
|
||||
|
||||
echo "[3/7] submit purchase request and verify IM outbox written"
|
||||
echo "[3/10] submit purchase request and verify IM deliveries"
|
||||
PURCHASE_ID_A="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression A ${RUN_SUFFIX}" "purchase-a" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a.json"
|
||||
assert_jq "$TMP_DIR/outboxes-a.json" 'length == 2 and all(.[]; .status == "pending")' "submit purchase enqueues two IM outboxes"
|
||||
query_im_deliveries_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-a.json"
|
||||
assert_jq "$TMP_DIR/deliveries-a.json" 'length == 4 and (map(select(.status == "success" and .resultCode == "mock_sent")) | length) == 1 and (map(select(.status == "failed" and .resultCode == "wecom_network_error" and (.errorMessage | type == "string") and (.errorMessage | length > 0))) | length) == 3' "submit purchase writes IM delivery attempts"
|
||||
|
||||
echo "[4/7] run outbox job => mock succeeds, failing webhook does not block business transaction"
|
||||
OUTBOX_IDS_A="$(jq -r "map(.id) | join(\",\")" "$TMP_DIR/outboxes-a.json")"
|
||||
dispatch_outboxes_by_ids "$OUTBOX_IDS_A" "true" > "$TMP_DIR/outbox-job-1.log"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_A" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-a-after-job.json"
|
||||
assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(.status) | sort == ["failed","succeeded"]' "outbox success and failure statuses"
|
||||
assert_jq "$TMP_DIR/outboxes-a-after-job.json" 'map(select(.status == "failed"))[0].lastError | type == "string" and length > 0' "failing webhook captured error"
|
||||
echo "[4/10] verify failing webhook is captured without failing business submission"
|
||||
assert_jq "$TMP_DIR/deliveries-a.json" '(map(select(.status == "failed")) | length) >= 1 and (map(select(.status == "failed"))[0].errorMessage | type == "string")' "failing webhook captured delivery error"
|
||||
|
||||
echo "[5/7] disable channels and verify business submission safely skips IM enqueue"
|
||||
echo "[5/10] disable channels and verify business submission safely skips IM delivery"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "false" "$TMP_DIR/disable-mock-channel.json"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$FAIL_CHANNEL_ID" "false" "$TMP_DIR/disable-fail-channel.json"
|
||||
PURCHASE_ID_B="$(create_and_submit_purchase "$EMPLOYEE_TOKEN" "im notifier regression B ${RUN_SUFFIX}" "purchase-b" "$LEGAL_ENTITY_ID" "$COST_CENTER_ID" "$SUPPLIER_ID" "$DOCUMENT_CATEGORY_ID" "$BUDGET_POOL_ID")"
|
||||
query_im_outboxes_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/outboxes-b.json"
|
||||
assert_jq "$TMP_DIR/outboxes-b.json" 'length == 0' "disabled channels should not enqueue IM outbox"
|
||||
query_im_deliveries_for_entity "$PURCHASE_ID_B" "$MOCK_CHANNEL_ID,$FAIL_CHANNEL_ID" > "$TMP_DIR/deliveries-b.json"
|
||||
assert_jq "$TMP_DIR/deliveries-b.json" 'length == 0' "disabled channels should not write IM deliveries"
|
||||
|
||||
echo "[6/7] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success"
|
||||
echo "[6/10] verify IM_NOTIFIER_ENABLED=false marks outbox as skipped success"
|
||||
patch_channel_enabled "$FINANCE_MGR_TOKEN" "$MOCK_CHANNEL_ID" "true" "$TMP_DIR/re-enable-mock-channel.json"
|
||||
MANUAL_OUTBOX_ID="$(enqueue_manual_im_outbox "$MOCK_CHANNEL_ID")"
|
||||
dispatch_outboxes_by_ids "$MANUAL_OUTBOX_ID" "false" > "$TMP_DIR/outbox-job-im-disabled.log"
|
||||
@@ -616,6 +702,7 @@ assert_jq "$TMP_DIR/channel-test.json" '.data.testResult.ok == true and .data.te
|
||||
|
||||
echo "[8/10] create encrypted secret channel and verify masked read model"
|
||||
SECRET_CHANNEL_ID="$(create_channel "$FINANCE_MGR_TOKEN" "IM Secret ${RUN_SUFFIX}" "mock" "" "secret-${RUN_SUFFIX}" "$TMP_DIR/create-secret-channel.json")"
|
||||
track_regression_channel "$SECRET_CHANNEL_ID"
|
||||
assert_jq "$TMP_DIR/create-secret-channel.json" '.data.channel.configJson.secretConfigured == true and (.data.channel.configJson.secretMasked | type == "string") and (.data.channel.configJson | has("secret") | not)' "create channel masks secret on read"
|
||||
secret_test_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$SECRET_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/secret-channel-test.json" '{}')"
|
||||
assert_status "$secret_test_status" "200" "test encrypted channel by id" "$TMP_DIR/secret-channel-test.json"
|
||||
@@ -623,6 +710,7 @@ assert_jq "$TMP_DIR/secret-channel-test.json" '.data.testResult.ok == true and .
|
||||
|
||||
echo "[9/10] migrate legacy plain secret channel to encrypted storage and keep test path working"
|
||||
LEGACY_CHANNEL_ID="$(create_legacy_secret_channel "IM Legacy ${RUN_SUFFIX}" "http://127.0.0.1:9/wecom" "legacy-${RUN_SUFFIX}")"
|
||||
track_regression_channel "$LEGACY_CHANNEL_ID"
|
||||
query_channel_secret_state "$LEGACY_CHANNEL_ID" > "$TMP_DIR/legacy-secret-before.json"
|
||||
assert_jq "$TMP_DIR/legacy-secret-before.json" '.hasSecret == true and .hasSecretEncrypted == false' "legacy secret starts as plain text"
|
||||
legacy_test_before_status="$(api_json_auth POST "$BASE_URL/api/notifications/channels/$LEGACY_CHANNEL_ID/test" "$FINANCE_MGR_TOKEN" "$TMP_DIR/legacy-channel-test-before.json" '{}')"
|
||||
|
||||
@@ -139,6 +139,28 @@ function renderMarkdown(report) {
|
||||
lines.push("- 将本快照内容填入 `deploy/trialrun/observation-template.md` 对应周次。");
|
||||
lines.push("- 若指标未达标,优先通过 Portal、公告与站内通知补提醒,不新增外部表格。");
|
||||
lines.push("- 多平台重复录入次数和表格替代率仍建议在 observation template 中人工补记。");
|
||||
lines.push("");
|
||||
lines.push("## 人工补录项");
|
||||
lines.push("- 多平台重复录入次数:__");
|
||||
lines.push("- 表格替代率:__%");
|
||||
lines.push("- 在岗表是否已停止外部维护:是 / 否");
|
||||
lines.push("- 离职交接表是否已停止外部维护:是 / 否");
|
||||
lines.push("- 本周仍靠聊天或表格补流程的案例:__");
|
||||
lines.push("");
|
||||
lines.push("## 本周关键动作");
|
||||
lines.push("- Portal / 公告 / 团队状态 / 周报 / 待办使用情况:");
|
||||
lines.push("- 入职 / 离职 / 资产交接是否发生:");
|
||||
lines.push("- 项目更新、风险或客户沟通回填情况:");
|
||||
lines.push("");
|
||||
lines.push("## 本周阻塞");
|
||||
lines.push("- 阻塞 1:");
|
||||
lines.push("- 阻塞 2:");
|
||||
lines.push("- 阻塞 3:");
|
||||
lines.push("");
|
||||
lines.push("## 下周调整");
|
||||
lines.push("- 是否继续隐藏考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:");
|
||||
lines.push("- 是否需要补充培训、公告提醒或管理员代配置:");
|
||||
lines.push("- 是否达到试点预期:是 / 否");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
|
||||
portal: "main",
|
||||
team: "main",
|
||||
leaveRequests: "main",
|
||||
attendance: "main",
|
||||
attendance: "hidden",
|
||||
reimbursements: "main",
|
||||
assets: "main",
|
||||
weeklyReports: "main",
|
||||
|
||||
@@ -328,6 +328,8 @@ async function buildApp(options = {}) {
|
||||
|
||||
describe("portal routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-12T02:00:00.000Z"));
|
||||
featureFlagState.portalHome = true;
|
||||
featureFlagState.announcements = false;
|
||||
featureFlagState.presenceBoard = false;
|
||||
@@ -342,6 +344,10 @@ describe("portal routes", () => {
|
||||
buildTeamPresenceSummary.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => {
|
||||
const recentWorkflowRows = Array.from({ length: 8 }, (_, index) =>
|
||||
buildWorkflowRow({
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const {
|
||||
DEFAULT_MODULE_VISIBILITY,
|
||||
buildDefaultWorkspaceSetting,
|
||||
normalizeModuleVisibilityJson,
|
||||
} = require("../../src/services/workspaceSettings");
|
||||
|
||||
describe("workspace small-team navigation defaults", () => {
|
||||
it("keeps only the R66 daily path in default main navigation", () => {
|
||||
const mainModules = Object.entries(DEFAULT_MODULE_VISIBILITY)
|
||||
.filter(([, placement]) => placement === "main")
|
||||
.map(([key]) => key);
|
||||
|
||||
expect(mainModules).toEqual([
|
||||
"portal",
|
||||
"team",
|
||||
"leaveRequests",
|
||||
"reimbursements",
|
||||
"assets",
|
||||
"weeklyReports",
|
||||
"todos",
|
||||
"announcements",
|
||||
"projects",
|
||||
]);
|
||||
expect(DEFAULT_MODULE_VISIBILITY.attendance).toBe("hidden");
|
||||
});
|
||||
|
||||
it("normalizes unknown or invalid module visibility back to the R66 defaults", () => {
|
||||
const normalized = normalizeModuleVisibilityJson({
|
||||
attendance: "main",
|
||||
purchaseRequests: "hidden",
|
||||
unknownModule: "main",
|
||||
budgets: "invalid",
|
||||
});
|
||||
|
||||
expect(normalized.attendance).toBe("main");
|
||||
expect(normalized.purchaseRequests).toBe("hidden");
|
||||
expect(normalized.budgets).toBe(DEFAULT_MODULE_VISIBILITY.budgets);
|
||||
expect(normalized.unknownModule).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a small-team internal workspace by default", () => {
|
||||
const workspace = buildDefaultWorkspaceSetting();
|
||||
|
||||
expect(workspace.profile).toBe("small_team_internal");
|
||||
expect(workspace.moduleVisibility.attendance).toBe("hidden");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户