比较提交

...
6 次代码提交
作者 SHA1 备注 提交日期
wangsli 1a2c3b786e R69 忽略本地视觉复核截图产物
ci-minimal / check-minimal (push) Has been cancelled
ci-e2e / e2e (push) Has been cancelled
2026-04-26 21:09:27 +08:00
wangsli 87316044d1 R69 完成业务验收与会话稳定性修复 2026-04-26 21:02:00 +08:00
wangsli 63acc2db92 R67 A2-A6:视觉验收状态回填 2026-04-26 19:02:03 +08:00
wangsli 46981380a0 R68 A1-A6:Portal 首页信息架构与对齐优化 2026-04-26 15:37:03 +08:00
wangsli a08db8590a R67 A1-A6:官网风格前端 UI 刷新 2026-04-26 14:07:11 +08:00
wangsli a15bbcbadb R66 A1-A6:小团队试点收敛与上线验收 2026-04-26 13:24:24 +08:00
共修改 58 个文件,包含 1271 行新增和 338 行删除
+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
+25
查看文件
@@ -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 天,记录非阻断体验问题与流程缺口。
+3
查看文件
@@ -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);
+61 -6
查看文件
@@ -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>
+5 -3
查看文件
@@ -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}
+11 -11
查看文件
@@ -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>
+9 -6
查看文件
@@ -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>
+17 -17
查看文件
@@ -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>
);
}
+6 -4
查看文件
@@ -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>
);
+5 -5
查看文件
@@ -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",
+3 -3
查看文件
@@ -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);
}
}
+3 -2
查看文件
@@ -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 />
+9 -9
查看文件
@@ -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>
+2 -2
查看文件
@@ -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>
))}
+7 -1
查看文件
@@ -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>
);
+14
查看文件
@@ -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() {
+9 -7
查看文件
@@ -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 {
+28 -4
查看文件
@@ -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",
+4 -4
查看文件
@@ -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();
});
+1
查看文件
@@ -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();
+12 -6
查看文件
@@ -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;
+2 -1
查看文件
@@ -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) {
+4 -2
查看文件
@@ -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`
+2 -1
查看文件
@@ -1,6 +1,6 @@
# 小团队试点 4 周观测模板
> 建议由管理员或老板每周五填写一次,用于 `R17-6` 最终验收留痕。
> 建议由管理员或老板每周五填写一次,用于 R66 小团队试点验收留痕。
## 基本信息
- 工作区模式:`small_team_internal`
@@ -33,6 +33,7 @@
## 下周调整
- 是否需要调整提醒频率:
- 是否需要继续隐藏或降级某模块:
- 是否需要恢复考勤、采购、差旅、预算、AP、ERP、AI 等备用模块:
- 是否需要补充培训或说明:
## 结论
+31 -3
查看文件
@@ -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` 补充:
- 多平台重复录入次数、表格替代率、外部在岗表和离职交接表是否停用。
- 仍靠聊天或表格补流程的案例。
- 本周阻塞、下周是否继续隐藏或恢复备用模块。
- 是否达到试点预期,以及是否可进入上线验收。
+50
查看文件
@@ -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 等备用模块:
- 是否需要补充培训、公告提醒或管理员代配置:
- 是否达到试点预期:是 / 否
+18
查看文件
@@ -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`。
+1
查看文件
@@ -7,6 +7,7 @@
- `报销`:继续提交和跟踪报销。
- `周报`:每周写一次三段式周报。
- `待办`:记录和跟进个人事项。
- `项目`:查看自己的任务、里程碑、风险和交付记录。
## 2. 第一次登录后建议先做三件事
1. 检查个人资料是否正确。
+7
查看文件
@@ -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`
+17
查看文件
@@ -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:小团队试点收敛与上线验收。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 结构校验通过。
3 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 通过;待试点账号人工复核。
4 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 周循环说明齐全。
5 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 通过;实际备份恢复演练待试点环境执行。
6 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),需依赖升级合同评审。
7 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:小团队试点收敛与上线验收。
+7
查看文件
@@ -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;截图复核与人工视觉验收已通过。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 与水声科技感。
3 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 通过;员工、管理者、管理员三类账号视觉复核已通过。
4 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 通过;员工、管理者、管理员三类账号视觉复核已通过。
5 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 通过;员工、管理者、管理员三类账号视觉复核已通过。
6 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 首页信息架构。
7 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 已通过;最终状态核对后提交。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 人以下小团队首页优先今日待办、快捷动作、团队状态和少量动态,其余内容进入第二阅读层。
3 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 覆盖桌面与移动首屏。
4 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 中覆盖。
5 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 通过,备用模块默认折叠逻辑保持。
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 通过。
7 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 改动。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 缺陷,先记录再单独方案,不直接扩大范围。
3 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 体积/循环依赖警告。回滚路径:静态门禁不改代码。
4 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 默认范围通过;部分可选域按环境默认跳过。回滚路径:回归执行不改代码。
5 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 不改业务代码。
6 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 修复。回滚路径:验收状态回填可单独回滚。
7 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 边界通过;未修改权限模型。回滚路径:验收状态回填可单独回滚。
8 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 可单独回滚。
9 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 相关改动。
10 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 改动。
+14 -1
查看文件
@@ -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
+123 -35
查看文件
@@ -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`;
}
+1 -1
查看文件
@@ -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",
+6
查看文件
@@ -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");
});
});