新增案例库列表和详情页

This commit is contained in:
czj
2026-07-08 13:59:26 +08:00
parent 1f382c280c
commit c9ca93faa0
13 changed files with 1323 additions and 10 deletions
+11
View File
@@ -0,0 +1,11 @@
import { CaseDetail } from "../../../components/cases/case-detail";
export default async function CaseDetailPage({
params,
}: {
params: Promise<{ caseId: string }>;
}) {
const { caseId } = await params;
return <CaseDetail caseId={caseId} />;
}
+6
View File
@@ -0,0 +1,6 @@
import { CaseList } from "../../components/cases/case-list";
export default function CasesPage() {
return <CaseList />;
}
+184 -1
View File
@@ -116,10 +116,28 @@ h3 {
color: #586174;
}
.topbar-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: flex-end;
}
.api-key-field {
min-width: min(16rem, 100%);
}
.text-link {
color: #2f6fdd;
font-weight: 800;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
.app-tabs {
display: flex;
flex-wrap: wrap;
@@ -574,15 +592,180 @@ h3 {
white-space: pre-wrap;
}
.case-toolbar {
align-items: end;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(16rem, 1fr) 12rem 12rem;
}
.case-table {
border: 1px solid #dce2eb;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.case-row {
align-items: center;
color: #172033;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(16rem, 1.45fr) 7rem minmax(8rem, 0.9fr) minmax(7rem, 0.75fr) minmax(7rem, 0.8fr) 6rem 7rem;
min-height: 4.25rem;
padding: 0.75rem 0.9rem;
text-decoration: none;
}
.case-row + .case-row {
border-top: 1px solid #e5e9f0;
}
.case-row:hover {
background: #f8fafc;
}
.case-row-heading {
background: #f6f7f9;
color: #586174;
font-size: 0.76rem;
font-weight: 800;
min-height: auto;
text-transform: uppercase;
}
.case-row span,
.case-row strong,
.case-row small {
min-width: 0;
overflow-wrap: anywhere;
}
.case-row strong {
display: block;
}
.case-row small {
color: #586174;
display: block;
font-size: 0.78rem;
margin-top: 0.2rem;
}
.case-detail-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(17rem, 0.35fr) minmax(0, 1fr);
}
.case-meta-grid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.case-meta-grid div {
background: #f6f7f9;
border: 1px solid #e5e9f0;
border-radius: 6px;
display: grid;
gap: 0.22rem;
min-width: 0;
padding: 0.55rem;
}
.case-meta-grid span {
color: #667085;
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
}
.case-meta-grid strong {
color: #172033;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.case-version-list,
.case-publication-list,
.case-publication-form {
display: grid;
gap: 0.6rem;
}
.case-version-item {
background: #ffffff;
border-color: #dce2eb;
color: #172033;
display: grid;
gap: 0.25rem;
min-height: 4rem;
padding: 0.65rem;
text-align: left;
white-space: normal;
}
.case-version-item.selected {
border-color: #172033;
box-shadow: 0 0 0 2px rgba(23, 32, 51, 0.08);
}
.case-version-item small {
color: #586174;
}
.case-json-preview,
.case-markdown-preview {
background: #f6f7f9;
border: 1px solid #e5e9f0;
border-radius: 6px;
margin: 0;
overflow: auto;
padding: 0.8rem;
white-space: pre-wrap;
}
.case-publication-list {
list-style: none;
margin: 0;
padding: 0;
}
.case-publication-list li {
border: 1px solid #e5e9f0;
border-radius: 6px;
display: grid;
gap: 0.25rem;
padding: 0.65rem;
}
.case-publication-list small {
color: #586174;
}
@media (max-width: 900px) {
.workflow-grid,
.copy-optimizer-grid,
.two-col,
.fact-summary-grid,
.calibration-metrics {
.calibration-metrics,
.case-toolbar,
.case-detail-grid,
.case-meta-grid {
grid-template-columns: 1fr;
}
.case-row,
.case-row-heading {
grid-template-columns: 1fr;
}
.case-row-heading {
display: none;
}
.input-panel {
grid-row: auto;
}
+15 -9
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import {
@@ -169,15 +170,20 @@ export default function Home() {
<h1>GEO </h1>
{message && <p>{message}</p>}
</div>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<div className="topbar-actions">
<Link className="text-link" href="/cases">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
</div>
</header>
<nav className="app-tabs" aria-label="功能标签">
<button
+24
View File
@@ -0,0 +1,24 @@
export const apiAccessStorageKey = "geo-api-access-key";
export function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}
export function readStoredApiAccessKey() {
if (typeof window === "undefined") return "";
return window.sessionStorage.getItem(apiAccessStorageKey) ?? "";
}
export function storeApiAccessKey(value: string) {
if (typeof window === "undefined") return;
if (value) {
window.sessionStorage.setItem(apiAccessStorageKey, value);
} else {
window.sessionStorage.removeItem(apiAccessStorageKey);
}
}
@@ -0,0 +1,149 @@
"use client";
import type {
ArticleCaseInputPayload,
ArticleResultVersionPayload,
OptimizationResultVersion,
} from "../../lib/cases/types";
interface ArticleCaseDetailProps {
input: unknown;
version: OptimizationResultVersion | null;
}
const platformLabels: Record<string, string> = {
official_site: "官网文章",
media_article: "媒体稿",
comparison_review: "对比评测",
recommendation_list: "推荐榜单",
};
export function ArticleCaseDetail({ input, version }: ArticleCaseDetailProps) {
const payload = isArticleInput(input) ? input : null;
const result = isArticleResult(version?.payload) ? version.payload : null;
return (
<>
<section className="panel stack">
<div className="panel-heading"></div>
{payload ? (
<>
<div className="case-meta-grid">
<MetaItem label="标题" value={payload.source_title || "未填写"} />
<MetaItem
label="目标平台"
value={platformLabels[payload.publish_platform] ?? payload.publish_platform}
/>
<MetaItem
label="补充要求"
value={payload.user_instructions || "无"}
/>
<MetaItem
label="图片输入"
value={
payload.image_inputs.length > 0
? `${payload.image_inputs.length}`
: "无"
}
/>
</div>
<pre className="copy-result">{payload.source_body}</pre>
{payload.image_inputs.length > 0 ? (
<div className="tag-row">
{payload.image_inputs.map((item, index) => (
<span className="tag" key={`${item.content}-${index}`}>
{item.type === "link" ? "链接" : "描述"}{item.content}
</span>
))}
</div>
) : null}
</>
) : (
<p className="empty-panel"></p>
)}
</section>
{payload?.fact_card ? (
<section className="panel stack">
<div className="panel-heading"></div>
<pre className="case-json-preview">
{JSON.stringify(payload.fact_card, null, 2)}
</pre>
</section>
) : null}
<section className="panel stack">
<div className="panel-heading">稿</div>
{result ? (
<>
<article className="preview">
<h2>{result.article.title}</h2>
<p>{result.article.summary}</p>
<pre className="case-markdown-preview">
{result.article.body_markdown}
</pre>
</article>
<section className="stack">
<h3></h3>
<div className="qa-list">
{result.qa_report.checks.map((check) => (
<div className="qa-item" key={check.rule_id}>
<div className="qa-title-row">
<strong>{check.rule_id}</strong>
<span className={`status-pill ${check.status}`}>
{check.status}
</span>
</div>
<p>{check.reason}</p>
<small>{check.evidence}</small>
</div>
))}
</div>
</section>
<section className="stack">
<h3></h3>
<div className="export-row">
{Object.entries(result.export_paths).map(([name, path]) => (
<a className="download-link" href={path} key={name}>
{name}
</a>
))}
</div>
</section>
</>
) : (
<p className="empty-panel">
{version?.error_summary ?? "当前版本没有优化稿。"}
</p>
)}
</section>
</>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function isArticleInput(value: unknown): value is ArticleCaseInputPayload {
return (
value !== null &&
typeof value === "object" &&
"source_body" in value &&
"publish_platform" in value
);
}
function isArticleResult(value: unknown): value is ArticleResultVersionPayload {
return (
value !== null &&
typeof value === "object" &&
"article" in value &&
"qa_report" in value
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import type {
OptimizationCase,
OptimizationCaseDetail,
} from "../../lib/cases/types";
import {
apiHeaders,
readStoredApiAccessKey,
storeApiAccessKey,
} from "./api-client";
import { ArticleCaseDetail } from "./article-case-detail";
import { CasePublicationPanel } from "./case-publication-panel";
import { formatDate } from "./case-format";
import { CaseResultVersionList } from "./case-result-version-list";
import { HumanCopyCaseDetail } from "./human-copy-case-detail";
interface CaseDetailProps {
caseId: string;
}
type CaseDetailResponse = Partial<OptimizationCaseDetail> & {
error?: string;
};
const caseTypeLabels: Record<OptimizationCase["case_type"], string> = {
article: "文章优化案例",
human_copy: "人味文案优化案例",
};
const statusLabels: Record<OptimizationCase["status"], string> = {
running: "运行中",
optimized: "已优化",
failed: "失败",
archived: "已归档",
};
export function CaseDetail({ caseId }: CaseDetailProps) {
const [detail, setDetail] = useState<OptimizationCaseDetail | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
const [message, setMessage] = useState("");
useEffect(() => {
let isCurrent = true;
fetch(`/api/cases/${caseId}`, { headers: apiHeaders(apiAccessKey) })
.then((response) => response.json() as Promise<CaseDetailResponse>)
.then((body: CaseDetailResponse) => {
if (!isCurrent) return;
if (body.error || !body.case || !body.versions) {
setMessage(body.error ?? "案例加载失败");
setDetail(null);
return;
}
const nextDetail = {
case: body.case,
input: body.input ?? null,
versions: body.versions,
};
setDetail(nextDetail);
setSelectedVersionId(
body.case.latest_result_version_id ??
body.versions[0]?.id ??
null,
);
setMessage("");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "案例加载失败");
setDetail(null);
});
return () => {
isCurrent = false;
};
}, [apiAccessKey, caseId]);
const selectedVersion = useMemo(() => {
if (!detail) return null;
return (
detail.versions.find((version) => version.id === selectedVersionId) ??
detail.versions[0] ??
null
);
}, [detail, selectedVersionId]);
function updateApiAccessKey(value: string) {
setApiAccessKey(value);
storeApiAccessKey(value);
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1>{detail?.case.title ?? "案例详情"}</h1>
{message ? <p>{message}</p> : detail ? <p>{detail.case.summary}</p> : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/cases">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => updateApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
</div>
</header>
{detail ? (
<>
<section className="panel compact-panel stack">
<div className="case-meta-grid">
<MetaItem label="类型" value={caseTypeLabels[detail.case.case_type]} />
<MetaItem label="状态" value={statusLabels[detail.case.status]} />
<MetaItem label="客户" value={detail.case.customer_name || "未填写"} />
<MetaItem label="品牌" value={detail.case.brand_name || "未填写"} />
<MetaItem
label="项目标签"
value={
detail.case.project_tags.length > 0
? detail.case.project_tags.join("、")
: "未标记"
}
/>
<MetaItem
label="发布目标"
value={detail.case.publish_target || "未指定"}
/>
<MetaItem label="版本" value={`v${detail.case.latest_version_number ?? 0}`} />
<MetaItem label="更新" value={formatDate(detail.case.updated_at)} />
</div>
{detail.case.notes ? <p>{detail.case.notes}</p> : null}
</section>
{detail.case.last_error_summary ? (
<section className="panel compact-panel stack">
<div className="panel-heading"></div>
<p className="status-text">{detail.case.last_error_stage}</p>
<p>{detail.case.last_error_summary}</p>
</section>
) : null}
<section className="case-detail-grid">
<CaseResultVersionList
selectedVersionId={selectedVersion?.id ?? null}
versions={detail.versions}
onSelect={setSelectedVersionId}
/>
<section className="stack">
{detail.case.case_type === "article" ? (
<ArticleCaseDetail
input={detail.input?.payload ?? null}
version={selectedVersion}
/>
) : (
<HumanCopyCaseDetail
input={detail.input?.payload ?? null}
version={selectedVersion}
/>
)}
<CasePublicationPanel
apiAccessKey={apiAccessKey}
caseId={detail.case.id}
publishTarget={detail.case.publish_target}
versionId={selectedVersion?.id ?? null}
/>
</section>
</section>
</>
) : (
<section className="panel empty-panel"></section>
)}
</main>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { OptimizationResultVersion } from "../../lib/cases/types";
export function formatDate(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function versionLabel(version: OptimizationResultVersion) {
return `v${version.version} · ${
version.status === "optimized" ? "已优化" : "失败"
}`;
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import type { OptimizationCase } from "../../lib/cases/types";
import {
apiHeaders,
readStoredApiAccessKey,
storeApiAccessKey,
} from "./api-client";
interface CaseListResponse {
cases?: OptimizationCase[];
error?: string;
}
const caseTypeLabels: Record<OptimizationCase["case_type"], string> = {
article: "文章优化",
human_copy: "人味文案",
};
const statusLabels: Record<OptimizationCase["status"], string> = {
running: "运行中",
optimized: "已优化",
failed: "失败",
archived: "已归档",
};
export function CaseList() {
const [cases, setCases] = useState<OptimizationCase[]>([]);
const [caseType, setCaseType] = useState("");
const [status, setStatus] = useState("");
const [query, setQuery] = useState("");
const [message, setMessage] = useState("");
const [apiAccessKey, setApiAccessKey] = useState(readStoredApiAccessKey);
const search = useMemo(() => {
const params = new URLSearchParams();
if (caseType) params.set("case_type", caseType);
if (status) params.set("status", status);
if (query) params.set("q", query);
return params.toString();
}, [caseType, status, query]);
useEffect(() => {
let isCurrent = true;
fetch(`/api/cases${search ? `?${search}` : ""}`, {
headers: apiHeaders(apiAccessKey),
})
.then((response) => response.json() as Promise<CaseListResponse>)
.then((body: CaseListResponse) => {
if (!isCurrent) return;
setCases(body.cases ?? []);
setMessage(body.error ?? "");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "案例加载失败");
});
return () => {
isCurrent = false;
};
}, [apiAccessKey, search]);
function updateApiAccessKey(value: string) {
setApiAccessKey(value);
storeApiAccessKey(value);
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1></h1>
{message ? <p>{message}</p> : null}
</div>
<div className="topbar-actions">
<Link className="text-link" href="/">
</Link>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => updateApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
</div>
</header>
<section className="case-toolbar" aria-label="案例筛选">
<input
aria-label="关键词"
placeholder="搜索标题、客户、品牌、备注"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<select
aria-label="案例类型"
value={caseType}
onChange={(event) => setCaseType(event.target.value)}
>
<option value=""></option>
<option value="article"></option>
<option value="human_copy"></option>
</select>
<select
aria-label="状态"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value=""></option>
<option value="running"></option>
<option value="optimized"></option>
<option value="failed"></option>
</select>
</section>
<section className="case-table" aria-label="案例列表">
<div className="case-row case-row-heading">
<span>/</span>
<span></span>
<span>/</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{cases.map((item) => (
<Link className="case-row" href={`/cases/${item.id}`} key={item.id}>
<span>
<strong>{item.title}</strong>
<small>{item.summary}</small>
</span>
<span>{caseTypeLabels[item.case_type]}</span>
<span>
{[item.customer_name, item.brand_name].filter(Boolean).join(" / ") ||
"未填写"}
</span>
<span>
{item.project_tags.length > 0
? item.project_tags.join("、")
: "未标记"}
</span>
<span>{item.publish_target || "未指定"}</span>
<span>
<span className={`status-pill ${statusClass(item.status)}`}>
{statusLabels[item.status]}
</span>
</span>
<span>{formatDate(item.updated_at)}</span>
</Link>
))}
{cases.length === 0 ? (
<p className="empty-panel"></p>
) : null}
</section>
</main>
);
}
function formatDate(value: string) {
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function statusClass(status: OptimizationCase["status"]) {
if (status === "failed") return "fail";
if (status === "running") return "warn";
return "pass";
}
@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import type { PublicationRecord } from "../../lib/calibration/types";
import { apiHeaders } from "./api-client";
import { formatDate } from "./case-format";
interface CasePublicationPanelProps {
apiAccessKey: string;
caseId: string;
versionId: string | null;
publishTarget: string;
}
interface PublicationListResponse {
publications?: PublicationRecord[];
error?: string;
}
interface PublicationCreateResponse {
publication?: PublicationRecord;
error?: string;
}
interface PerformanceResponse {
snapshot?: { metrics: Record<string, number> };
calibrationEvent?: { observations: string[]; recommended_action: string } | null;
error?: string;
}
export function CasePublicationPanel({
apiAccessKey,
caseId,
versionId,
publishTarget,
}: CasePublicationPanelProps) {
const [publications, setPublications] = useState<PublicationRecord[]>([]);
const [url, setUrl] = useState("");
const [publishedAt, setPublishedAt] = useState(() =>
new Date().toISOString().slice(0, 16),
);
const [notes, setNotes] = useState("");
const [publicationId, setPublicationId] = useState<string | null>(null);
const [views, setViews] = useState("");
const [clicks, setClicks] = useState("");
const [inquiries, setInquiries] = useState("");
const [feedbackSummary, setFeedbackSummary] = useState("");
const [message, setMessage] = useState("");
const [observations, setObservations] = useState<string[]>([]);
useEffect(() => {
if (!versionId) {
return;
}
let isCurrent = true;
fetch(`/api/cases/${caseId}/versions/${versionId}/publications`, {
headers: apiHeaders(apiAccessKey),
})
.then((response) => response.json() as Promise<PublicationListResponse>)
.then((body: PublicationListResponse) => {
if (!isCurrent) return;
setPublications(body.publications ?? []);
setMessage(body.error ?? "");
})
.catch((error) => {
if (!isCurrent) return;
setMessage(error instanceof Error ? error.message : "发布记录加载失败");
});
return () => {
isCurrent = false;
};
}, [apiAccessKey, caseId, versionId]);
async function registerPublication() {
if (!versionId || !url || !publishedAt) return;
setMessage("");
const response = await fetch(
`/api/cases/${caseId}/versions/${versionId}/publications`,
{
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
publish_target: publishTarget || "未指定",
url,
published_at: new Date(publishedAt).toISOString(),
notes,
}),
},
);
const body = (await response.json()) as PublicationCreateResponse;
if (!response.ok || !body.publication) {
setMessage(body.error ?? "发布记录保存失败");
return;
}
setPublicationId(body.publication.id);
setPublications((items) => [body.publication!, ...items]);
setMessage("发布记录已保存。");
}
async function recordPerformance() {
if (!publicationId) return;
setMessage("");
const response = await fetch(`/api/publications/${publicationId}/performance`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
window_label: "T+7d",
views,
clicks,
inquiries,
feedback_summary: feedbackSummary,
}),
});
const body = (await response.json()) as PerformanceResponse;
if (!response.ok || !body.snapshot) {
setMessage(body.error ?? "表现数据保存失败");
return;
}
setObservations(body.calibrationEvent?.observations ?? []);
setMessage(
body.calibrationEvent?.recommended_action ?? "表现数据已保存。",
);
}
return (
<section className="panel stack">
<div className="panel-heading"></div>
{versionId && publications.length > 0 ? (
<ul className="case-publication-list">
{publications.map((publication) => (
<li key={publication.id}>
<a href={publication.url}>{publication.url}</a>
<small>
{publication.publish_target} · {formatDate(publication.published_at)}
</small>
{publication.notes ? <small>{publication.notes}</small> : null}
</li>
))}
</ul>
) : (
<p className="empty-panel"></p>
)}
<div className="case-publication-form">
<label>
<span></span>
<input
disabled={!versionId}
placeholder="https://example.com/article"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={!versionId}
type="datetime-local"
value={publishedAt}
onChange={(event) => setPublishedAt(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={!versionId}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</label>
<button disabled={!versionId || !url || !publishedAt} onClick={registerPublication} type="button">
</button>
</div>
<div className="calibration-metrics">
<label>
<span>/</span>
<input
min="0"
type="number"
value={views}
onChange={(event) => setViews(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={clicks}
onChange={(event) => setClicks(event.target.value)}
/>
</label>
<label>
<span></span>
<input
min="0"
type="number"
value={inquiries}
onChange={(event) => setInquiries(event.target.value)}
/>
</label>
</div>
<label>
<span></span>
<textarea
value={feedbackSummary}
onChange={(event) => setFeedbackSummary(event.target.value)}
/>
</label>
<button disabled={!publicationId} onClick={recordPerformance} type="button">
</button>
{message ? <p className="status-text">{message}</p> : null}
{observations.length > 0 ? (
<ul className="calibration-observations">
{observations.map((observation) => (
<li key={observation}>{observation}</li>
))}
</ul>
) : null}
</section>
);
}
@@ -0,0 +1,44 @@
"use client";
import type { OptimizationResultVersion } from "../../lib/cases/types";
import { formatDate, versionLabel } from "./case-format";
interface CaseResultVersionListProps {
versions: OptimizationResultVersion[];
selectedVersionId: string | null;
onSelect: (versionId: string) => void;
}
export function CaseResultVersionList({
versions,
selectedVersionId,
onSelect,
}: CaseResultVersionListProps) {
return (
<section className="panel stack">
<div className="panel-heading"></div>
{versions.length > 0 ? (
<div className="case-version-list">
{versions.map((version) => (
<button
className={
version.id === selectedVersionId
? "case-version-item selected"
: "case-version-item"
}
key={version.id}
onClick={() => onSelect(version.id)}
type="button"
>
<strong>{versionLabel(version)}</strong>
<small>{version.result_summary || version.error_summary || "暂无摘要"}</small>
<small>{formatDate(version.created_at)}</small>
</button>
))}
</div>
) : (
<p className="empty-panel"></p>
)}
</section>
);
}
@@ -0,0 +1,141 @@
"use client";
import type {
HumanCopyCaseInputPayload,
HumanCopyResultVersionPayload,
OptimizationResultVersion,
} from "../../lib/cases/types";
interface HumanCopyCaseDetailProps {
input: unknown;
version: OptimizationResultVersion | null;
}
const intensityLabels: Record<string, string> = {
light: "轻微整理",
medium: "适度润色",
conversational: "更口语自然",
};
export function HumanCopyCaseDetail({
input,
version,
}: HumanCopyCaseDetailProps) {
const payload = isHumanCopyInput(input) ? input : null;
const result = isHumanCopyResult(version?.payload) ? version.payload : null;
return (
<>
<section className="panel stack">
<div className="panel-heading"></div>
{payload ? (
<>
<pre className="copy-result">{payload.source_text}</pre>
<div className="case-meta-grid">
<MetaItem label="优化目标" value={payload.goal} />
<MetaItem
label="修改强度"
value={intensityLabels[payload.intensity] ?? payload.intensity}
/>
<MetaItem
label="发布目标"
value={payload.publish_target || "未指定"}
/>
<MetaItem
label="补充要求"
value={payload.user_instructions || "无"}
/>
</div>
</>
) : (
<p className="empty-panel"></p>
)}
</section>
<section className="panel stack">
<div className="panel-heading"></div>
{result ? (
<>
<pre className="copy-result">{result.optimized_text}</pre>
<section className="stack">
<h3></h3>
{result.change_notes.length > 0 ? (
<ul className="qa-list">
{result.change_notes.map((note, index) => (
<li className="qa-item" key={`${note.original}-${index}`}>
<p>{note.reason}</p>
<small>{note.original}</small>
<small>{note.revised}</small>
{note.revertible ? <em></em> : null}
</li>
))}
</ul>
) : (
<p className="empty-panel"></p>
)}
</section>
<section className="stack">
<h3>AI </h3>
{result.ai_taste_checks.length > 0 ? (
<ul className="qa-list">
{result.ai_taste_checks.map((check) => (
<li className="qa-item" key={check.rule_id}>
<div className="qa-title-row">
<strong>{check.rule_id}</strong>
<span className={`status-pill ${check.status}`}>
{check.status}
</span>
</div>
<small>{check.evidence}</small>
{check.suggestion ? <small>{check.suggestion}</small> : null}
</li>
))}
</ul>
) : (
<p className="empty-panel"> AI </p>
)}
</section>
{result.warnings.length > 0 ? (
<ul className="calibration-observations">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
) : null}
</>
) : (
<p className="empty-panel">
{version?.error_summary ?? "当前版本没有优化结果。"}
</p>
)}
</section>
</>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function isHumanCopyInput(value: unknown): value is HumanCopyCaseInputPayload {
return (
value !== null &&
typeof value === "object" &&
"source_text" in value &&
"goal" in value
);
}
function isHumanCopyResult(value: unknown): value is HumanCopyResultVersionPayload {
return (
value !== null &&
typeof value === "object" &&
"optimized_text" in value &&
"change_notes" in value
);
}
+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from "@playwright/test";
test("案例库列表和人味文案详情可查看", async ({ page }) => {
await page.route("**/api/cases", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
cases: [
{
id: "case_copy_1",
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原始文案摘要",
status: "optimized",
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "",
publish_target: "朋友圈",
source_excerpt: "原始文案摘要",
result_excerpt: "优化后文案摘要",
latest_result_version_id: "ver_copy_1",
latest_version_number: 1,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:05:00.000Z",
},
],
}),
});
});
await page.route("**/api/cases/case_copy_1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
case: {
id: "case_copy_1",
case_type: "human_copy",
title: "人味文案优化:朋友圈",
summary: "原始文案摘要",
status: "optimized",
customer_name: "客户A",
brand_name: "品牌B",
project_tags: ["朋友圈"],
notes: "",
publish_target: "朋友圈",
source_excerpt: "原始文案摘要",
result_excerpt: "优化后文案摘要",
latest_result_version_id: "ver_copy_1",
latest_version_number: 1,
last_error_stage: null,
last_error_summary: null,
archived_at: null,
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:05:00.000Z",
},
input: {
case_id: "case_copy_1",
case_type: "human_copy",
article_job_id: null,
payload: {
source_text: "原始文案",
goal: "自然一点",
intensity: "light",
user_instructions: "",
publish_target: "朋友圈",
},
created_at: "2026-07-08T10:00:00.000Z",
updated_at: "2026-07-08T10:00:00.000Z",
},
versions: [
{
id: "ver_copy_1",
case_id: "case_copy_1",
case_type: "human_copy",
version: 1,
status: "optimized",
article_job_id: null,
article_revision: null,
result_summary: "优化后文案摘要",
payload: {
optimized_text: "优化后文案",
change_notes: [
{
original: "原始文案",
revised: "优化后文案",
reason: "减少书面腔。",
confidence: "confident",
revertible: false,
},
],
ai_taste_checks: [
{
rule_id: "promotion_tone",
status: "pass",
evidence: "没有新增宣传腔。",
suggestion: "",
},
],
warnings: [],
},
process_summary: [],
llm_audit_summary: [],
error_stage: null,
error_summary: null,
created_at: "2026-07-08T10:05:00.000Z",
},
],
}),
});
});
await page.route(
"**/api/cases/case_copy_1/versions/ver_copy_1/publications",
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ publications: [] }),
});
},
);
await page.goto("/cases");
await expect(page.getByRole("heading", { name: "案例库" })).toBeVisible();
await expect(page.getByText("人味文案优化:朋友圈")).toBeVisible();
await page.getByRole("link", { name: "人味文案优化:朋友圈" }).click();
await expect(
page.locator(".copy-result").filter({ hasText: "优化后文案" }),
).toBeVisible();
await expect(page.getByText("AI 味检查")).toBeVisible();
await expect(page.getByText("发布表现")).toBeVisible();
});