完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)

这个提交包含在:
2026-07-18 15:46:14 +08:00
父节点 58c8decef9
当前提交 5d0a5f9a38
共修改 134 个文件,包含 10790 行新增和 1413 行删除
@@ -0,0 +1,99 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Textarea } from "../ui/textarea";
type AttachmentDeleteConfirmDialogProps = {
open: boolean;
targetName?: string;
title?: string;
description?: ReactNode;
pending?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (reason: string) => void | Promise<void>;
};
export function AttachmentDeleteConfirmDialog({
open,
targetName,
title = "确认删除附件?",
description,
pending = false,
onOpenChange,
onConfirm,
}: AttachmentDeleteConfirmDialogProps) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!open) {
setReason("");
setError("");
}
}, [open]);
const confirm = async () => {
const normalizedReason = reason.trim();
if (normalizedReason.length < 2) {
setError("请填写至少 2 个字的删除理由");
return;
}
await onConfirm(normalizedReason);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md p-5" data-testid="attachment-delete-confirm-dialog">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description ?? (
<>
{targetName ? `附件:${targetName}。` : "该附件"}
删除后将从当前业务单据移除,且无法在 OA 内恢复。
</>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-2">
<Textarea
aria-label="删除理由"
value={reason}
rows={3}
maxLength={200}
placeholder="例如:重复上传、文件有误,需重新提供"
onChange={(event) => {
setReason(event.target.value);
if (error) setError("");
}}
/>
<div className="flex items-center justify-between text-xs">
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "对象、后果和理由会随删除操作留痕。"}</span>
<span className="text-zinc-400">{reason.trim().length}/200</span>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" disabled={pending} onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
variant="destructive"
disabled={pending}
data-testid="attachment-delete-confirm-submit"
onClick={() => void confirm()}
>
{pending ? "删除中..." : "确认删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,159 @@
import {
createContext,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useBlocker } from "react-router-dom";
import { ConfirmDialog } from "./ConfirmDialog";
type GuardRegistration = {
dirty: boolean;
label: string;
};
type UnsavedChangesContextValue = {
register: (id: string, registration: GuardRegistration | null) => void;
runGuarded: (action: () => void) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: PropsWithChildren) {
const registrationsRef = useRef(new Map<string, GuardRegistration>());
const pendingActionRef = useRef<(() => void) | null>(null);
const allowNextNavigationRef = useRef(false);
const confirmingRef = useRef(false);
const [revision, setRevision] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const dirtyRegistrations = useMemo(
() => [...registrationsRef.current.values()].filter((registration) => registration.dirty),
[revision],
);
const hasDirtyChanges = dirtyRegistrations.length > 0;
const dirtyLabel = dirtyRegistrations[0]?.label || "当前表单";
const hasDirtyChangesRef = useRef(hasDirtyChanges);
useEffect(() => {
hasDirtyChangesRef.current = hasDirtyChanges;
}, [hasDirtyChanges]);
const shouldBlock = useCallback(
() => hasDirtyChangesRef.current && !allowNextNavigationRef.current,
[],
);
const blocker = useBlocker(shouldBlock);
useEffect(() => {
document.documentElement.dataset.unsavedChanges = hasDirtyChanges ? "true" : "false";
return () => {
delete document.documentElement.dataset.unsavedChanges;
};
}, [hasDirtyChanges]);
const register = useCallback((id: string, registration: GuardRegistration | null) => {
if (registration) {
registrationsRef.current.set(id, registration);
} else {
registrationsRef.current.delete(id);
}
setRevision((current) => current + 1);
}, []);
const runGuarded = useCallback((action: () => void) => {
if (!hasDirtyChangesRef.current) {
action();
return;
}
pendingActionRef.current = action;
setDialogOpen(true);
}, []);
useEffect(() => {
if (blocker.state === "blocked") {
setDialogOpen(true);
}
}, [blocker.state]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasDirtyChangesRef.current) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, []);
const value = useMemo(() => ({ register, runGuarded }), [register, runGuarded]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
<ConfirmDialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (open) return;
if (confirmingRef.current) {
confirmingRef.current = false;
return;
}
pendingActionRef.current = null;
if (blocker.state === "blocked") blocker.reset();
}}
title="放弃未保存的修改?"
description={`${dirtyLabel}尚未保存。离开后,本次修改不会保留。`}
confirmLabel="放弃并离开"
cancelLabel="继续编辑"
variant="destructive"
testId="unsaved-changes-dialog"
confirmTestId="unsaved-changes-discard"
cancelTestId="unsaved-changes-continue"
onConfirm={() => {
confirmingRef.current = true;
const action = pendingActionRef.current;
pendingActionRef.current = null;
if (blocker.state === "blocked") {
blocker.proceed();
return;
}
if (!action) return;
allowNextNavigationRef.current = true;
try {
action();
} finally {
queueMicrotask(() => {
allowNextNavigationRef.current = false;
});
}
}}
/>
</UnsavedChangesContext.Provider>
);
}
export function useUnsavedChangesGuard(dirty: boolean, label: string) {
const context = useContext(UnsavedChangesContext);
const id = useId();
if (!context) {
throw new Error("useUnsavedChangesGuard 必须在 UnsavedChangesProvider 内使用");
}
useLayoutEffect(() => {
context.register(id, { dirty, label });
return () => context.register(id, null);
}, [context, dirty, id, label]);
return context.runGuarded;
}
+12 -4
查看文件
@@ -33,8 +33,8 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
@@ -65,6 +65,12 @@ const MANAGEMENT_ACTIVE_PATHS = [
"/audit-logs",
];
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
function hasPaymentReviewRole(roleCodes: string[], permissions: string[]) {
return roleCodes.some((roleCode) => PAYMENT_REVIEWER_ROLE_CODES.includes(roleCode)) &&
hasAnyPermission(permissions, ["payment.batch.review"]);
}
type ResolvedNavItem = {
key?: string;
@@ -142,13 +148,15 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig
}
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
const financeOnlyPaths = new Set(["/invoice-pool"]);
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
const mapItems = (paths: string[]) =>
NAV_ITEMS.filter((item) => paths.includes(item.to))
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
.filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin"))
.filter((item) => !paymentPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || hasPaymentReviewRole(roleCodes, permissions))
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
.filter((item) => paths === BACKUP_NAV_ORDER || !backupOnlyPaths.has(item.to))
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
@@ -90,7 +90,17 @@ export function ActionToolbar({ className, children }: WorkspaceChildrenProps) {
}
export function PrimaryTable({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("table-container ops-table-focus p-0", className)}>{children}</section>;
return (
<section
className={cn("table-container ops-table-focus max-w-full p-0", className)}
role="region"
aria-label="数据表格,可横向滚动"
tabIndex={0}
data-testid="business-table-scroll"
>
{children}
</section>
);
}
export function WorkspaceEmptyState({