新增发布表现校准界面

This commit is contained in:
Codex
2026-06-24 11:16:15 +08:00
parent dafbe2e2e9
commit ed176b9fac
3 changed files with 239 additions and 1 deletions
+18 -1
View File
@@ -295,9 +295,26 @@ h3 {
padding: 0.25rem 0.55rem;
}
.calibration-metrics {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.calibration-observations {
border: 1px solid #e5e9f0;
border-radius: 8px;
color: #586174;
display: grid;
gap: 0.45rem;
margin: 0;
padding: 0.75rem 0.75rem 0.75rem 1.4rem;
}
@media (max-width: 900px) {
.workflow-grid,
.two-col {
.two-col,
.calibration-metrics {
grid-template-columns: 1fr;
}
+7
View File
@@ -11,6 +11,7 @@ import {
toConfirmedFactCard,
} from "../components/fact-card-editor";
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 type {
@@ -270,6 +271,12 @@ export default function Home() {
jobId={jobId}
/>
<QaReportPanel report={qaReport} />
<PerformanceCalibrationPanel
apiAccessKey={apiAccessKey}
jobId={jobId}
key={`${jobId ?? "no-job"}-${optimizedArticle?.revision ?? "no-revision"}`}
optimizedRevision={optimizedArticle?.revision ?? null}
/>
</div>
</main>
);
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import type { PublishPlatform } from "../lib/domain/types";
interface PerformanceCalibrationPanelProps {
apiAccessKey: string;
jobId: string | null;
optimizedRevision: number | null;
}
interface ScoreResponse {
scoringRun?: { id: string; composite_score: number; rationale: string };
error?: string;
}
interface PublicationResponse {
publication?: { id: string; url: string };
error?: string;
}
interface PerformanceResponse {
snapshot?: { metrics: Record<string, number> };
calibrationEvent?: { observations: string[]; recommended_action: string };
error?: string;
}
export function PerformanceCalibrationPanel({
apiAccessKey,
jobId,
optimizedRevision,
}: PerformanceCalibrationPanelProps) {
const [platform, setPlatform] = useState<PublishPlatform>("official_site");
const [url, setUrl] = useState("");
const [publishedAt, setPublishedAt] = useState(() =>
new Date().toISOString().slice(0, 16),
);
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[]>([]);
const disabled = !jobId || !optimizedRevision;
async function scoreRevision() {
if (!jobId) return;
setMessage("");
const response = await fetch(`/api/jobs/${jobId}/calibration/score`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
});
const body = (await response.json()) as ScoreResponse;
if (!response.ok || !body.scoringRun) {
setMessage(body.error ?? "评分失败");
return;
}
setMessage(
`校准评分 ${body.scoringRun.composite_score}/10${body.scoringRun.rationale}`,
);
}
async function registerPublication() {
if (!jobId || !publishedAt) return;
setMessage("");
const response = await fetch(`/api/jobs/${jobId}/publications`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
body: JSON.stringify({
platform,
url,
published_at: new Date(publishedAt).toISOString(),
notes: "",
}),
});
const body = (await response.json()) as PublicationResponse;
if (!response.ok || !body.publication) {
setMessage(body.error ?? "发布记录保存失败");
return;
}
setPublicationId(body.publication.id);
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.calibrationEvent) {
setMessage(body.error ?? "表现数据保存失败");
return;
}
setObservations(body.calibrationEvent.observations);
setMessage(body.calibrationEvent.recommended_action);
}
return (
<section className="panel stack">
<div className="panel-heading">
<span></span>
<button disabled={disabled} onClick={scoreRevision} type="button">
</button>
</div>
<label>
<span></span>
<select
disabled={disabled}
value={platform}
onChange={(event) => setPlatform(event.target.value as PublishPlatform)}
>
<option value="official_site"></option>
<option value="media_article">稿</option>
<option value="comparison_review"></option>
<option value="recommendation_list"></option>
</select>
</label>
<label>
<span></span>
<input
disabled={disabled}
placeholder="https://example.com/article"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
</label>
<label>
<span></span>
<input
disabled={disabled}
type="datetime-local"
value={publishedAt}
onChange={(event) => setPublishedAt(event.target.value)}
/>
</label>
<button
disabled={disabled || !url || !publishedAt}
onClick={registerPublication}
type="button"
>
</button>
<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>
);
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}