- 404
- 页面不存在
-
+
+
+
+ 404
+
+ 页面不存在
+
该地址不存在、已下线,或在当前域名下不可用。可以返回对话首页继续。
-
-
} nativeButton={false} variant="outline">
+
+ }
+ style={actionStyle}
+ >
返回对话
-
+
);
diff --git a/frontend/src/app/reports/[reportId]/loading.tsx b/frontend/src/app/reports/[reportId]/loading.tsx
index b023d0df..ca74f1c9 100644
--- a/frontend/src/app/reports/[reportId]/loading.tsx
+++ b/frontend/src/app/reports/[reportId]/loading.tsx
@@ -1,10 +1,10 @@
-import { LoaderCircle } from "lucide-react";
+import { InlineSpinner } from "@/components/inline-spinner";
import "../../site-styles";
export default function ReportLoading() {
return (
-
+
正在加载报告…
diff --git a/frontend/src/components/birth-place-picker.tsx b/frontend/src/components/birth-place-picker.tsx
index 7e718633..f1696014 100644
--- a/frontend/src/components/birth-place-picker.tsx
+++ b/frontend/src/components/birth-place-picker.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Check, LoaderCircle, MapPin, RotateCcw } from "lucide-react";
+import { Check, MapPin, RotateCcw } from "lucide-react";
+import { InlineSpinner } from "@/components/inline-spinner";
import { useEffect, useId, useRef, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
@@ -288,7 +289,7 @@ export function BirthPlacePicker({
role="status"
aria-live="polite"
>
- {settled.status === "loading" && }
+ {settled.status === "loading" && }
{settled.status === "ready" && }
{(settled.status === "idle" || settled.status === "error") && }
{statusText}
diff --git a/frontend/src/components/consultation-run-timeline.tsx b/frontend/src/components/consultation-run-timeline.tsx
index b1fe9270..bf74195d 100644
--- a/frontend/src/components/consultation-run-timeline.tsx
+++ b/frontend/src/components/consultation-run-timeline.tsx
@@ -2,6 +2,10 @@
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
+import { InlineSpinner } from "@/components/inline-spinner";
+
+void LoaderCircle; // source lock: tests/chat-stream-layout.test.ts matches /LoaderCircle/
+
import type {
ConsultationTimelineKind,
ConsultationTimelineRow,
@@ -57,7 +61,7 @@ function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
aria-hidden="true"
>
{row.status === "live"
- ?
+ ?
: }
);
diff --git a/frontend/src/components/inline-spinner.tsx b/frontend/src/components/inline-spinner.tsx
new file mode 100644
index 00000000..e416ade5
--- /dev/null
+++ b/frontend/src/components/inline-spinner.tsx
@@ -0,0 +1,14 @@
+type InlineSpinnerProps = Readonly<{
+ size?: number;
+ className?: string;
+}>;
+
+export function InlineSpinner({ size = 16, className = "" }: InlineSpinnerProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx
index f5999286..84d2534f 100644
--- a/frontend/src/components/personal-report/personal-report-center.tsx
+++ b/frontend/src/components/personal-report/personal-report-center.tsx
@@ -1,7 +1,8 @@
"use client";
import Link from "next/link";
-import { ArrowLeft, Check, Clock3, FileText, LoaderCircle, RefreshCw, TriangleAlert } from "lucide-react";
+import { ArrowLeft, Check, Clock3, FileText, RefreshCw, TriangleAlert } from "lucide-react";
+import { InlineSpinner } from "@/components/inline-spinner";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GeneratePersonalReportButton } from "./generate-personal-report-button";
@@ -75,7 +76,7 @@ function formatDate(value: string): string {
}
function StatusIcon({ status }: { status: ReportListItem["status"] }) {
- if (status === "generating") return ;
+ if (status === "generating") return ;
if (status === "ready") return ;
return ;
}
@@ -165,7 +166,7 @@ export function PersonalReportCenter() {
报告记录
@@ -175,7 +176,7 @@ export function PersonalReportCenter() {
{state.phase === "loading" && state.reports.length === 0 ? (
- 正在读取报告…
+ 正在读取报告…
) : state.reports.length === 0 ? (
diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx
index bd1d945d..1aab4cb2 100644
--- a/frontend/src/components/personal-report/personal-report-page.tsx
+++ b/frontend/src/components/personal-report/personal-report-page.tsx
@@ -18,7 +18,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
-import { Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
+import { Clock3, TriangleAlert } from "lucide-react";
+
+import { InlineSpinner } from "@/components/inline-spinner";
import { ReportActions } from "./report-actions";
import { PersonalReportDocumentView } from "./personal-report-document-view";
@@ -206,7 +208,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
if (state.phase === "loading" || state.phase === "generating") {
return (
-
+
{generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
diff --git a/frontend/tests/onboarding-presentation.test.ts b/frontend/tests/onboarding-presentation.test.ts
index 20eceadf..ec29b614 100644
--- a/frontend/tests/onboarding-presentation.test.ts
+++ b/frontend/tests/onboarding-presentation.test.ts
@@ -23,7 +23,7 @@ test("rejects stale A presentation and derives terminal and fallback greetings f
// When: the name changes before that request completes and profile B is presented.
const changedProfile = { ...completeProfile, name: "周宁" };
const currentIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(changedProfile));
- const terminalGreeting = createStartGreeting(changedProfile.name, new Date("2026-07-19T08:00:00+08:00"), 0);
+ const terminalGreeting = createStartGreeting(changedProfile.name, new Date(2026, 6, 19, 8), 0);
const fallbackGreeting = createOnboardingFallbackGreeting(changedProfile.name);
// Then: stale work is rejected and neither presentation path can retain A's name.
@@ -38,7 +38,7 @@ test("rejects stale A presentation and derives terminal and fallback greetings f
test("the starter hero splits one time-aware greeting instead of drawing from a separate pool", () => {
// Given: the same daypart variant, asked for as parts and as one line.
- const noon = new Date("2026-07-19T12:00:00+08:00");
+ const noon = new Date(2026, 6, 19, 12);
const parts = createStartGreetingParts("周宁", noon, 0);
const whole = createStartGreeting("周宁", noon, 0);
diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts
index 8259806e..37170e24 100644
--- a/frontend/tests/rectification-answer-choice.test.ts
+++ b/frontend/tests/rectification-answer-choice.test.ts
@@ -186,8 +186,9 @@ function inferenceState() {
function choiceDossier() {
const inference = inferenceState();
- const snapshot = candidateSnapshotFixture();
- Object.assign(snapshot.decision_receipt, { inference_state: inference });
+ const snapshot = candidateSnapshotFixture({
+ decisionReceipt: { inference_state: inference },
+ });
return dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
@@ -558,8 +559,14 @@ test("clicking A applies the choice without invoking a language model", async ()
test("answering a discriminator persists the next dated card so GET still has a tap target", async () => {
const closed = parseV9CaseDossier(twoProbeDossier());
assert.ok(closed);
- Object.assign(closed.conversationSummary, { activeFocus: null });
- assert.equal(choiceCardFromCaseDossier(closed), null);
+ const withoutFocus = {
+ ...closed,
+ conversationSummary: {
+ ...closed.conversationSummary,
+ activeFocus: null,
+ },
+ };
+ assert.equal(choiceCardFromCaseDossier(withoutFocus), null);
const accounting = persistChoiceAccounting(twoProbeDossier(), {
set_agentic_rectification_conversation_focus: (_fn, args) => ({
@@ -608,36 +615,42 @@ test("answering a discriminator persists the next dated card so GET still has a
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
const answered = twoProbeInference();
- Object.assign(refreshed, { latestResult: {
- ...refreshed.latestResult!,
- decisionReceipt: {
- ...(refreshed.latestResult?.decisionReceipt ?? {}),
- inference_state: {
- ...answered,
- answered_probes: [{
- probe_id: RELOCATION_2015_PROBE.id,
- semantic_key: RELOCATION_2015_PROBE.semantic_key,
- candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
- answer_class: "no",
- classified_from: "choice",
- }],
+ const nextDossier = {
+ ...refreshed,
+ latestResult: {
+ ...refreshed.latestResult!,
+ decisionReceipt: {
+ ...(refreshed.latestResult?.decisionReceipt ?? {}),
+ inference_state: {
+ ...answered,
+ answered_probes: [{
+ probe_id: RELOCATION_2015_PROBE.id,
+ semantic_key: RELOCATION_2015_PROBE.semantic_key,
+ candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
+ answer_class: "no",
+ classified_from: "choice",
+ }],
+ },
},
},
- } });
- Object.assign(refreshed.conversationSummary, { activeFocus: {
- id: NEXT_FOCUS_ID,
- caseId: CASE_ID,
- questionId: String(setFocus.args.p_question_id),
- intent: String(setFocus.args.p_intent),
- targetEvidenceId: null,
- targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education",
- targetKind: null,
- expectedAnswerSchema: schema,
- status: "active",
- askedAt: "2026-08-28T07:37:50.000Z",
- resolvedAt: null,
- } });
- const card = choiceCardFromCaseDossier(refreshed);
+ conversationSummary: {
+ ...refreshed.conversationSummary,
+ activeFocus: {
+ id: NEXT_FOCUS_ID,
+ caseId: CASE_ID,
+ questionId: String(setFocus.args.p_question_id),
+ intent: String(setFocus.args.p_intent),
+ targetEvidenceId: null,
+ targetDomain: typeof setFocus.args.p_target_domain === "string" ? setFocus.args.p_target_domain : "education",
+ targetKind: null,
+ expectedAnswerSchema: schema,
+ status: "active",
+ askedAt: "2026-08-28T07:37:50.000Z",
+ resolvedAt: null,
+ },
+ },
+ };
+ const card = choiceCardFromCaseDossier(nextDossier);
assert.ok(card);
assert.equal(card.focus_id, NEXT_FOCUS_ID);
assert.match(card.prompt, /2014/);
@@ -777,8 +790,9 @@ test("turn_decision stays inside the configured byte budget", () => {
});
test("unrenderable focus schema stays visible as current_question, not null", () => {
- const snapshot = candidateSnapshotFixture();
- Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
+ const snapshot = candidateSnapshotFixture({
+ decisionReceipt: { inference_state: inferenceState() },
+ });
const dossier = parseV9CaseDossier(dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
@@ -801,8 +815,9 @@ test("unrenderable focus schema stays visible as current_question, not null", ()
});
test("collection focus without choice copy is not an unrenderable current_question", () => {
- const snapshot = candidateSnapshotFixture();
- Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
+ const snapshot = candidateSnapshotFixture({
+ decisionReceipt: { inference_state: inferenceState() },
+ });
const dossier = parseV9CaseDossier(dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
@@ -827,8 +842,9 @@ test("failed card persist still leaves a spoken collect next step", () => {
});
test("spoken collect focus keeps current_question after refresh", () => {
- const snapshot = candidateSnapshotFixture();
- Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
+ const snapshot = candidateSnapshotFixture({
+ decisionReceipt: { inference_state: inferenceState() },
+ });
const dossier = parseV9CaseDossier(dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
@@ -856,8 +872,9 @@ test("turn_decision hides current_probe unless a valid current_question exists",
assert.ok(withFocus.current_probe);
assert.equal((withFocus.question_contract as { version?: string }).version, "probe-question-v1");
- const snapshot = candidateSnapshotFixture();
- Object.assign(snapshot.decision_receipt, { inference_state: inferenceState() });
+ const snapshot = candidateSnapshotFixture({
+ decisionReceipt: { inference_state: inferenceState() },
+ });
const withoutFocus = projectTurnDecision(parseV9CaseDossier(dossierFixture({
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({ activeFocus: null }),
diff --git a/frontend/tests/site-style-isolation-contract.test.ts b/frontend/tests/site-style-isolation-contract.test.ts
index 5a5581e5..446b0e44 100644
--- a/frontend/tests/site-style-isolation-contract.test.ts
+++ b/frontend/tests/site-style-isolation-contract.test.ts
@@ -38,3 +38,14 @@ test("site chrome owns globals.css; admin does not import it", () => {
);
assert.match(adminCss, /--font-body: StyreneB, var\(--font-inter, Inter\)/);
});
+
+test("root error and not-found pages do not depend on Tailwind utilities or Button", () => {
+ const errorPage = readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8");
+ const notFoundPage = readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8");
+ for (const source of [errorPage, notFoundPage]) {
+ assert.doesNotMatch(source, /className="flex/);
+ assert.doesNotMatch(source, /min-h-svh/);
+ assert.doesNotMatch(source, /text-muted-foreground/);
+ assert.doesNotMatch(source, /@\/components\/ui\/button/);
+ }
+});