Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/tasks/P29_INTAKE_LINK_COPY_RELIABILITY.md
Original file line number Diff line number Diff line change
@@ -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 状态;不得自动合并。
105 changes: 102 additions & 3 deletions frontend/src/features/intake/AdminIntakePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -92,6 +92,12 @@ function renderPage() {
return render(<MemoryRouter><AdminIntakePage /></MemoryRouter>);
}

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => { resolve = next; });
return { promise, resolve };
}

beforeEach(() => {
vi.resetAllMocks();
apiMocks.listIntakeTokens.mockResolvedValue({ items: [token], total: 1 });
Expand Down Expand Up @@ -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");

Expand All @@ -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 () => {
Expand Down
111 changes: 100 additions & 11 deletions frontend/src/features/intake/AdminIntakePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,46 @@ const tokenStatusLabels: Record<FormTokenStatus, string> = {
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<number, IntakeTokenRead>,
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<FormSubmissionStatus, string> = {
draft: "草稿",
submitted: "待审核",
Expand Down Expand Up @@ -271,24 +311,33 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
const [action, setAction] = useState<string | null>(null);
const [confirmingDecision, setConfirmingDecision] = useState<IntakeDecisionMode | null>(null);
const [copiedId, setCopiedId] = useState<number | null>(null);
const [linkCopyNotice, setLinkCopyNotice] = useState<LinkCopyNotice | null>(null);
const [linksExpanded, setLinksExpanded] = useState(false);
const [showRemoved, setShowRemoved] = useState(false);
const [error, setError] = useState<string | null>(null);
const decisionKeys = useRef(new Map<string, string>());
const preservedTokensRef = useRef(new Map<number, IntakeTokenRead>());
const tokenMutationEpochRef = useRef(0);
const selectedIdRef = useRef<number | null>(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 {
const [tokenResponse, submissionResponse] = await Promise.all([
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(
Expand Down Expand Up @@ -351,13 +400,42 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
};
}, [selectedId]);

async function copyTokenToClipboard(token: IntakeTokenRead): Promise<boolean> {
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<HTMLFormElement>) {
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 {
Expand All @@ -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) {
Expand All @@ -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 : "链接状态修改失败,请刷新后重试。");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -537,6 +625,7 @@ export function IntakeWorkspace({ embedded = false }: { embedded?: boolean }) {
<section id="links" className="cc-surface p-5" aria-labelledby="new-link-title">
<h2 id="new-link-title" className="flex items-center gap-2 font-semibold"><Link2 size={17} />生成填写链接</h2>
<form className="mt-4 flex flex-wrap items-end gap-3" onSubmit={handleCreate}><label className="text-sm font-medium text-slate-700">有效天数<input className="mt-1.5 block min-h-10 w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm" type="number" min={1} max={90} value={expiresInDays} onChange={(event) => setExpiresInDays(Number(event.target.value))} required /></label><button type="submit" className="cc-button cc-button--primary" disabled={action !== null}>{action === "create" ? <LoaderCircle className="animate-spin" size={15} /> : <Plus size={15} />}生成链接</button></form>
{linkCopyNotice ? <div className={`mt-4 flex items-start gap-2 rounded-lg border px-3 py-2 text-sm ${linkCopyNotice.tone === "success" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-amber-200 bg-amber-50 text-amber-800"}`} role="status"><ClipboardCopy className="mt-0.5 shrink-0" size={15} /><span>{linkCopyNotice.message}</span></div> : null}
</section>

<section className="cc-surface overflow-hidden" aria-labelledby="links-title">
Expand Down