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 }) {
生成填写链接
+ {linkCopyNotice ? {linkCopyNotice.message}
: null}