新增普通文案优化标签页

This commit is contained in:
czj
2026-07-02 14:38:39 +08:00
parent 3919887a8d
commit bf7be12dad
4 changed files with 330 additions and 31 deletions
+36
View File
@@ -120,6 +120,22 @@ h3 {
min-width: min(16rem, 100%);
}
.app-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.app-tabs button {
background: #ffffff;
color: #172033;
}
.app-tabs button.active-tab {
background: #172033;
color: #ffffff;
}
.workflow-grid {
align-items: start;
display: grid;
@@ -539,8 +555,28 @@ h3 {
padding: 0.75rem 0.75rem 0.75rem 1.4rem;
}
.copy-optimizer-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
.copy-result {
background: #f6f7f9;
border: 1px solid #e5e9f0;
border-radius: 6px;
color: #172033;
margin: 0;
min-height: 10rem;
overflow: auto;
padding: 0.8rem;
white-space: pre-wrap;
}
@media (max-width: 900px) {
.workflow-grid,
.copy-optimizer-grid,
.two-col,
.fact-summary-grid,
.calibration-metrics {
+57 -31
View File
@@ -11,6 +11,7 @@ import { OptimizedPreview } from "../components/optimized-preview";
import { PerformanceCalibrationPanel } from "../components/performance-calibration-panel";
import { ProgressPanel } from "../components/progress-panel";
import { QaReportPanel } from "../components/qa-report-panel";
import { RenweiCopyOptimizerPanel } from "../components/renwei-copy-optimizer-panel";
import type {
OptimizationFactCard,
OptimizedArticle,
@@ -45,6 +46,7 @@ interface TimingSummary {
}
export default function Home() {
const [activeTab, setActiveTab] = useState<"geo" | "copy">("geo");
const [input, setInput] = useState(initialInput);
const [jobId, setJobId] = useState<string | null>(null);
const [factCard, setFactCard] = useState<OptimizationFactCard | null>(null);
@@ -177,39 +179,63 @@ export default function Home() {
/>
</label>
</header>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={null}
/>
<div className="workflow-grid">
<ArticleInputForm
isSubmitting={busyAction === "optimize"}
value={input}
onChange={setInput}
onSubmit={optimize}
/>
<FactCardEditor
factCard={factCard}
isSaving={busyAction === "optimize"}
onChange={setFactCard}
/>
<OptimizedPreview
activityText={streamActivity}
article={optimizedArticle}
draftArticle={draftArticle}
isStreaming={busyAction === "optimize"}
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
<PerformanceCalibrationPanel
<nav className="app-tabs" aria-label="功能标签">
<button
className={activeTab === "geo" ? "active-tab" : undefined}
onClick={() => setActiveTab("geo")}
type="button"
>
GEO
</button>
<button
className={activeTab === "copy" ? "active-tab" : undefined}
onClick={() => setActiveTab("copy")}
type="button"
>
</button>
</nav>
{activeTab === "geo" ? (
<>
<ProgressPanel
action={busyAction}
elapsedSeconds={elapsedSeconds}
lastTiming={lastTiming}
liveProgress={null}
/>
<div className="workflow-grid">
<ArticleInputForm
isSubmitting={busyAction === "optimize"}
value={input}
onChange={setInput}
onSubmit={optimize}
/>
<FactCardEditor
factCard={factCard}
isSaving={busyAction === "optimize"}
onChange={setFactCard}
/>
<OptimizedPreview
activityText={streamActivity}
article={optimizedArticle}
draftArticle={draftArticle}
isStreaming={busyAction === "optimize"}
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
<PerformanceCalibrationPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
</>
) : (
<RenweiCopyOptimizerPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
)}
</main>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useState, type FormEvent } from "react";
import type {
CopyOptimizationIntensity,
CopyOptimizationResult,
} from "../lib/domain/types";
interface RenweiCopyOptimizerPanelProps {
apiAccessKey: string;
}
interface CopyOptimizeResponse {
result?: CopyOptimizationResult;
error?: string;
}
const intensityOptions: Array<{
value: CopyOptimizationIntensity;
label: string;
}> = [
{ value: "light", label: "轻微整理" },
{ value: "medium", label: "适度润色" },
{ value: "conversational", label: "更口语自然" },
];
export function RenweiCopyOptimizerPanel({
apiAccessKey,
}: RenweiCopyOptimizerPanelProps) {
const [sourceText, setSourceText] = useState("");
const [goal, setGoal] = useState("保留原意,减少 AI 味");
const [intensity, setIntensity] =
useState<CopyOptimizationIntensity>("light");
const [userInstructions, setUserInstructions] = useState("");
const [result, setResult] = useState<CopyOptimizationResult | null>(null);
const [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setMessage("");
setResult(null);
try {
const response = await fetch("/api/copy/renwei-optimize", {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
source_text: sourceText,
goal,
intensity,
user_instructions: userInstructions,
}),
});
const body = (await response.json()) as CopyOptimizeResponse;
if (!response.ok || !body.result) {
throw new Error(body.error ?? "文案优化失败");
}
setResult(body.result);
setMessage("文案优化完成。");
} catch (error) {
setMessage(error instanceof Error ? error.message : "文案优化失败");
} finally {
setIsSubmitting(false);
}
}
async function copyResult() {
if (!result?.optimized_text) return;
await navigator.clipboard.writeText(result.optimized_text);
setMessage("已复制优化结果。");
}
return (
<section className="copy-optimizer-grid">
<form className="panel stack" onSubmit={submit}>
<div className="panel-heading">
<span></span>
<button disabled={isSubmitting} type="submit">
{isSubmitting ? "优化中..." : "优化文案"}
</button>
</div>
<label>
<span></span>
<textarea
className="body-input"
required
value={sourceText}
onChange={(event) => setSourceText(event.target.value)}
/>
</label>
<label>
<span></span>
<input value={goal} onChange={(event) => setGoal(event.target.value)} />
</label>
<label>
<span></span>
<select
value={intensity}
onChange={(event) =>
setIntensity(event.target.value as CopyOptimizationIntensity)
}
>
{intensityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span></span>
<textarea
value={userInstructions}
onChange={(event) => setUserInstructions(event.target.value)}
/>
</label>
{message ? <p className="status-text">{message}</p> : null}
</form>
<section className="panel stack">
<div className="panel-heading">
<span></span>
<button disabled={!result} onClick={copyResult} type="button">
</button>
</div>
{result ? (
<>
<pre className="copy-result">{result.optimized_text}</pre>
<section className="stack">
<h3></h3>
<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>
</section>
<section className="stack">
<h3>AI </h3>
<ul className="qa-list">
{result.ai_taste_checks.map((check) => (
<li className="qa-item" key={check.rule_id}>
<p>
<span className={`status-pill ${check.status}`}>
{check.status}
</span>
</p>
<small>{check.evidence}</small>
{check.suggestion ? <small>{check.suggestion}</small> : null}
</li>
))}
</ul>
</section>
{result.warnings.length > 0 ? (
<ul className="calibration-observations">
{result.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
) : null}
</>
) : (
<p className="empty-panel"></p>
)}
</section>
</section>
);
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
test("普通文案优化标签页可以生成文案结果", async ({ page }) => {
await page.route("**/api/copy/renwei-optimize", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
result: {
optimized_text: "我观察到大家越来越难进入心流了。",
change_notes: [
{
original: "我观察到大家越来越难进入心流",
revised: "我观察到大家越来越难进入心流了。",
reason: "补足句尾语气,让句子自然收住。",
confidence: "confident",
revertible: false,
},
],
ai_taste_checks: [
{
rule_id: "promotion_tone",
status: "pass",
evidence: "没有新增宣传腔。",
suggestion: "",
},
],
warnings: [],
},
}),
});
});
await page.goto("/");
await expect(
page.getByRole("heading", { name: "GEO 智能文章优化器" }),
).toBeVisible();
await expect(page.getByText("文章输入")).toBeVisible();
await page.getByRole("button", { name: "普通文案优化" }).click();
await expect(page.getByLabel("原始文案")).toBeVisible();
await page.getByLabel("访问密钥").fill("local-dev-key");
await page.getByLabel("原始文案").fill("我观察到大家越来越难进入心流");
await page.getByRole("button", { name: "优化文案" }).click();
await expect(page.getByText("优化后文案")).toBeVisible();
await expect(page.locator(".copy-result")).toContainText(
"我观察到大家越来越难进入心流了。",
);
await expect(page.getByText("改动说明")).toBeVisible();
await expect(page.getByText("AI 味检查")).toBeVisible();
});