diff --git a/docs/tasks/P29_INTAKE_LINK_COPY_RELIABILITY.md b/docs/tasks/P29_INTAKE_LINK_COPY_RELIABILITY.md new file mode 100644 index 0000000..f905071 --- /dev/null +++ b/docs/tasks/P29_INTAKE_LINK_COPY_RELIABILITY.md @@ -0,0 +1,31 @@ +# P29:填写链接复制可靠性 + +## 背景与目标 + +后台创建填写链接时,原文只在创建响应中返回。列表接口按安全约束返回 `fill_path: null`,当前页面的定时刷新、可见性刷新或较早请求晚到会覆盖创建结果,导致“复制/打开”入口消失。目标是在不持久化 Token 原文的前提下,保证当前页面内可以可靠复制刚生成的链接。 + +## 允许修改范围 + +- `frontend/src/features/intake/AdminIntakePage.tsx` +- `frontend/src/features/intake/AdminIntakePage.test.tsx` +- 本任务文档 + +## 禁止修改范围 + +- 后端接口、数据库、迁移和 Token 哈希规则 +- 真实 Relay、真实填写链接和客户数据 +- `.env`、密钥、Token、Cookie、日志和浏览器数据 + +## 已确定实现要求 + +- 创建成功后自动复制完整链接,并显示明确成功反馈。 +- 自动复制失败时保留“打开/复制”入口并提示手动复制。 +- 仅在 React 页面生命周期内缓存创建响应;不得写入浏览器存储或后端。 +- 后台刷新需要合并服务器状态与页面内原文;较早 GET 晚到不得删除新链接或恢复已关闭状态。 +- 链接关闭、过期或已提交后清除页面内原文;页面刷新后不可恢复。 + +## 验收标准与测试 + +- 自动复制成功、剪贴板失败降级、旧 GET 晚到、30 秒刷新、关闭后清理均有前端测试。 +- 运行 `npm test -- --run src/features/intake/AdminIntakePage.test.tsx`、`npm run typecheck`、`npm run lint`、`npm run build`、`npm run build:public`。 +- 返回修改摘要、测试结果、Git 分支/提交/远端 SHA、PR 与 CI 状态;不得自动合并。 diff --git a/frontend/src/features/intake/AdminIntakePage.test.tsx b/frontend/src/features/intake/AdminIntakePage.test.tsx index 4123d5a..2d273f6 100644 --- a/frontend/src/features/intake/AdminIntakePage.test.tsx +++ b/frontend/src/features/intake/AdminIntakePage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { editableDraft } from "./constants"; @@ -92,6 +92,12 @@ function renderPage() { return render(); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { resolve = next; }); + return { promise, resolve }; +} + beforeEach(() => { vi.resetAllMocks(); apiMocks.listIntakeTokens.mockResolvedValue({ items: [token], total: 1 }); @@ -203,7 +209,7 @@ it("copies the public note into the chosen review destinations without changing expect(detail.payload.customer.notes).toBeNull(); }); -it("creates a link and copies the browser-origin URL", async () => { +it("creates a link, copies it automatically, and keeps manual controls", async () => { renderPage(); await screen.findByText("链接 #7"); @@ -212,11 +218,104 @@ it("creates a link and copies the browser-origin URL", async () => { await waitFor(() => expect(apiMocks.createIntakeToken).toHaveBeenCalledWith(21)); expect(await screen.findByText("链接 #8")).toBeInTheDocument(); expect(screen.getByText(/新链接仅本次可查看/)).toBeInTheDocument(); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/f/P10-test-token`, + )); + expect(screen.getByRole("status")).toHaveTextContent("链接 #8 已生成并复制到剪贴板"); + const newestLink = within(screen.getByText("链接 #8").closest("li")!); + expect(newestLink.getByRole("link", { name: "打开" })).toHaveAttribute("href", "/f/P10-test-token"); + expect(newestLink.getByRole("button", { name: "已复制" })).toBeInTheDocument(); +}); + +it("keeps manual copy available when automatic clipboard access fails", async () => { + const writeText = vi.mocked(navigator.clipboard.writeText); + writeText.mockRejectedValueOnce(new Error("clipboard blocked")).mockResolvedValueOnce(undefined); + renderPage(); + await screen.findByText("链接 #7"); + + fireEvent.click(screen.getByRole("button", { name: "生成链接" })); + expect(await screen.findByRole("status")).toHaveTextContent("自动复制失败"); + const newestLink = within(screen.getByText("链接 #8").closest("li")!); + expect(newestLink.getByRole("link", { name: "打开" })).toBeInTheDocument(); + fireEvent.click(newestLink.getByRole("button", { name: "复制" })); - fireEvent.click(screen.getAllByRole("button", { name: "复制" })[0]); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2)); + expect(screen.getByRole("status")).toHaveTextContent("链接 #8 已复制到剪贴板"); +}); + +it("keeps a newly created link when an older initial list request finishes later", async () => { + const initialTokens = deferred<{ items: IntakeTokenRead[]; total: number }>(); + apiMocks.listIntakeTokens.mockReturnValueOnce(initialTokens.promise); + renderPage(); + + fireEvent.click(screen.getByRole("button", { name: "生成链接" })); await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith( `${window.location.origin}/f/P10-test-token`, )); + initialTokens.resolve({ items: [token], total: 1 }); + + expect(await screen.findByText("链接 #8")).toBeInTheDocument(); + const newestLink = within(screen.getByText("链接 #8").closest("li")!); + expect(newestLink.getByRole("link", { name: "打开" })).toBeInTheDocument(); + expect(newestLink.getByRole("button", { name: "已复制" })).toBeInTheDocument(); +}); + +it("preserves a new link across the 30-second background refresh", async () => { + vi.useFakeTimers(); + const created = { ...token, id: 8, fill_path: "/f/P10-test-token", submitted_at: null, submission_status: null }; + apiMocks.listIntakeTokens + .mockResolvedValueOnce({ items: [token], total: 1 }) + .mockResolvedValue({ items: [{ ...created, fill_path: null }, token], total: 2 }); + const view = renderPage(); + try { + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + fireEvent.click(screen.getByRole("button", { name: "生成链接" })); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/f/P10-test-token`, + ); + + await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); + + expect(apiMocks.listIntakeTokens).toHaveBeenCalledTimes(2); + const newestLink = within(screen.getByText("链接 #8").closest("li")!); + expect(newestLink.getByRole("link", { name: "打开" })).toBeInTheDocument(); + expect(newestLink.getByRole("button", { name: "复制" })).toBeInTheDocument(); + } finally { + view.unmount(); + vi.useRealTimers(); + } +}); + +it("clears the raw link when closed and does not revive it from an older refresh", async () => { + const created = { ...token, id: 8, fill_path: "/f/P10-test-token", submitted_at: null, submission_status: null }; + const staleTokens = deferred<{ items: IntakeTokenRead[]; total: number }>(); + apiMocks.listIntakeTokens + .mockResolvedValueOnce({ items: [token], total: 1 }) + .mockReturnValueOnce(staleTokens.promise); + apiMocks.updateIntakeToken.mockResolvedValue({ ...created, status: "disabled", fill_path: null }); + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + renderPage(); + try { + await screen.findByText("链接 #7"); + fireEvent.click(screen.getByRole("button", { name: "生成链接" })); + expect(await screen.findByText("链接 #8")).toBeInTheDocument(); + + document.dispatchEvent(new Event("visibilitychange")); + await waitFor(() => expect(apiMocks.listIntakeTokens).toHaveBeenCalledTimes(2)); + fireEvent.click(within(screen.getByText("链接 #8").closest("li")!).getByRole("button", { name: "关闭" })); + await waitFor(() => expect(apiMocks.updateIntakeToken).toHaveBeenCalledWith(8, "disabled", revision)); + staleTokens.resolve({ items: [{ ...created, fill_path: null }, token], total: 2 }); + + await waitFor(() => { + const newestLink = within(screen.getByText("链接 #8").closest("li")!); + expect(newestLink.getByText("已关闭")).toBeInTheDocument(); + expect(newestLink.queryByRole("link", { name: "打开" })).not.toBeInTheDocument(); + expect(newestLink.queryByRole("button", { name: "复制" })).not.toBeInTheDocument(); + }); + } finally { + visibility.mockRestore(); + } }); it("saves a review and archives an order only through explicit admin actions", async () => { diff --git a/frontend/src/features/intake/AdminIntakePage.tsx b/frontend/src/features/intake/AdminIntakePage.tsx index 00895aa..dc91f4b 100644 --- a/frontend/src/features/intake/AdminIntakePage.tsx +++ b/frontend/src/features/intake/AdminIntakePage.tsx @@ -50,6 +50,46 @@ const tokenStatusLabels: Record = { expired: "已过期", }; +interface LinkCopyNotice { + tone: "success" | "warning"; + message: string; +} + +function tokenCanExposeRawLink(token: IntakeTokenRead): boolean { + return token.status === "active" && token.submitted_at === null; +} + +function newestTokensFirst(left: IntakeTokenRead, right: IntakeTokenRead): number { + return right.created_at.localeCompare(left.created_at) || right.id - left.id; +} + +function mergeListedTokens( + listed: IntakeTokenRead[], + current: IntakeTokenRead[], + preserved: Map, + preferCurrent: boolean, +): IntakeTokenRead[] { + const currentById = new Map(current.map((token) => [token.id, token])); + const listedIds = new Set(listed.map((token) => token.id)); + const merged = listed.map((listedToken) => { + const token = preferCurrent ? currentById.get(listedToken.id) ?? listedToken : listedToken; + if (!tokenCanExposeRawLink(token)) { + preserved.delete(token.id); + return token.fill_path === null ? token : { ...token, fill_path: null }; + } + const fillPath = preserved.get(token.id)?.fill_path ?? token.fill_path; + return fillPath ? { ...token, fill_path: fillPath } : token; + }); + const missing = (preferCurrent ? current : [...preserved.values()]) + .filter((token) => !listedIds.has(token.id)) + .filter((token) => { + if (tokenCanExposeRawLink(token)) return true; + preserved.delete(token.id); + return preferCurrent; + }); + return [...merged, ...missing].sort(newestTokensFirst); +} + const submissionStatusLabels: Record = { draft: "草稿", submitted: "待审核", @@ -271,16 +311,20 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { const [action, setAction] = useState(null); const [confirmingDecision, setConfirmingDecision] = useState(null); const [copiedId, setCopiedId] = useState(null); + const [linkCopyNotice, setLinkCopyNotice] = useState(null); const [linksExpanded, setLinksExpanded] = useState(false); const [showRemoved, setShowRemoved] = useState(false); const [error, setError] = useState(null); const decisionKeys = useRef(new Map()); + const preservedTokensRef = useRef(new Map()); + const tokenMutationEpochRef = useRef(0); const selectedIdRef = useRef(null); const showRemovedRef = useRef(false); useEffect(() => { selectedIdRef.current = selectedId; }, [selectedId]); const refresh = useCallback(async (silent = false) => { + const tokenMutationEpoch = tokenMutationEpochRef.current; if (!silent) setLoading(true); setError(null); try { @@ -288,7 +332,12 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { listIntakeTokens(), listIntakeSubmissions(), ]); - setTokens(tokenResponse.items); + setTokens((current) => mergeListedTokens( + tokenResponse.items, + current, + preservedTokensRef.current, + tokenMutationEpoch !== tokenMutationEpochRef.current, + )); setSubmissions(submissionResponse.items); setLoadedOnce(true); const matchingSubmissions = submissionResponse.items.filter( @@ -351,13 +400,42 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { }; }, [selectedId]); + async function copyTokenToClipboard(token: IntakeTokenRead): Promise { + if (!token.fill_path) return false; + try { + await navigator.clipboard.writeText(new URL(token.fill_path, window.location.origin).toString()); + setCopiedId(token.id); + window.setTimeout(() => setCopiedId(null), 1800); + return true; + } catch { + return false; + } + } + async function handleCreate(event: FormEvent) { event.preventDefault(); setAction("create"); setError(null); + setLinkCopyNotice(null); try { const created = await createIntakeToken(expiresInDays); - setTokens((current) => [created, ...current]); + tokenMutationEpochRef.current += 1; + if (created.fill_path && tokenCanExposeRawLink(created)) { + preservedTokensRef.current.set(created.id, created); + } + setTokens((current) => mergeListedTokens( + [created, ...current.filter((token) => token.id !== created.id)], + current, + preservedTokensRef.current, + false, + )); + const copied = await copyTokenToClipboard(created); + setLinkCopyNotice({ + tone: copied ? "success" : "warning", + message: copied + ? `链接 #${created.id} 已生成并复制到剪贴板。` + : `链接 #${created.id} 已生成,但自动复制失败。请点击下方“复制”按钮。`, + }); } catch (cause) { setError(cause instanceof Error ? cause.message : "填写链接生成失败,请重试。"); } finally { @@ -367,16 +445,16 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { async function handleCopy(token: IntakeTokenRead) { if (!token.fill_path) { - setError("该链接原文已不再保存。如未妥善留存,请关闭旧链接并重新生成。"); + setLinkCopyNotice({ tone: "warning", message: "该链接原文已不再保存。如未妥善留存,请关闭旧链接并重新生成。" }); return; } - try { - await navigator.clipboard.writeText(new URL(token.fill_path, window.location.origin).toString()); - setCopiedId(token.id); - window.setTimeout(() => setCopiedId(null), 1800); - } catch { - setError("复制失败,请打开链接后手工复制浏览器地址。"); - } + const copied = await copyTokenToClipboard(token); + setLinkCopyNotice({ + tone: copied ? "success" : "warning", + message: copied + ? `链接 #${token.id} 已复制到剪贴板。` + : "复制失败,请打开链接后手工复制浏览器地址。", + }); } async function handleTokenStatus(token: IntakeTokenRead) { @@ -385,6 +463,10 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { setError(null); try { const updated = await updateIntakeToken(token.id, next, token.revision); + tokenMutationEpochRef.current += 1; + preservedTokensRef.current.delete(token.id); + if (copiedId === token.id) setCopiedId(null); + setLinkCopyNotice(null); setTokens((current) => current.map((item) => item.id === token.id ? updated : item)); } catch (cause) { setError(cause instanceof Error ? cause.message : "链接状态修改失败,请刷新后重试。"); @@ -442,8 +524,14 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) { setSubmissions((current) => replaceSubmission(current, updated)); setConfirmingDecision(null); decisionKeys.current.delete(keyName); + const tokenMutationEpoch = ++tokenMutationEpochRef.current; const tokenResponse = await listIntakeTokens(); - setTokens(tokenResponse.items); + setTokens((current) => mergeListedTokens( + tokenResponse.items, + current, + preservedTokensRef.current, + tokenMutationEpoch !== tokenMutationEpochRef.current, + )); } catch (cause) { setError(cause instanceof Error ? cause.message : "审核动作失败;请保留当前页面并重试。"); try { @@ -537,6 +625,7 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {