新增案例库列表和详情页
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CaseList } from "../../components/cases/case-list";
|
||||
|
||||
export default function CasesPage() {
|
||||
return <CaseList />;
|
||||
}
|
||||
|
||||
+184
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" ? "已优化" : "失败"
|
||||
}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user