新增普通文案优化标签页
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user