feat: 搭建阶段 0 与阶段 1 基础工程
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
platformCatalogMap,
|
||||
type CandidateRecord,
|
||||
type PlatformId
|
||||
} from "@cross-ai/domain";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams
|
||||
} from "react-router-dom";
|
||||
|
||||
import { PlatformIdentity, PlatformStatusPill, TaskStatusPill } from "./components/StatusPill";
|
||||
import { TaskContextHeader } from "./components/TaskContextHeader";
|
||||
import { TaskSpine } from "./components/TaskSpine";
|
||||
import {
|
||||
confirmTask,
|
||||
createTask,
|
||||
getHistoryTasks,
|
||||
getPlatformReadiness,
|
||||
getTask,
|
||||
getTaskCandidates,
|
||||
getTaskReport,
|
||||
preparePlatform
|
||||
} from "./lib/api";
|
||||
|
||||
function Layout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<p className="eyebrow">Workspace</p>
|
||||
<h1 className="sidebar__title">跨平台商品分析</h1>
|
||||
<p className="sidebar__copy">研究台、证据板、任务主线。</p>
|
||||
</div>
|
||||
<nav className="sidebar__nav">
|
||||
<a href="/tasks/new">新建任务</a>
|
||||
<a href="/history">历史任务</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="main-content">{props.children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskPage() {
|
||||
const navigate = useNavigate();
|
||||
const readinessQuery = useQuery({
|
||||
queryKey: ["platform-readiness"],
|
||||
queryFn: getPlatformReadiness
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [perLinkLimit, setPerLinkLimit] = useState(100);
|
||||
const [taskTotalLimit, setTaskTotalLimit] = useState(500);
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: createTask,
|
||||
onSuccess: ({ task }) => {
|
||||
navigate(`/tasks/${task.taskId}/confirm`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel hero-panel">
|
||||
<div className="hero-panel__copy">
|
||||
<p className="eyebrow">Task Composer</p>
|
||||
<h2>从一个商品描述开始,先召回,再确认,再生成报告。</h2>
|
||||
<p>
|
||||
当前先落地 Phase 0 / 1 基线:统一工作台、共享状态模型、候选确认和结构化报告壳层。
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="task-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createTaskMutation.mutate({
|
||||
query,
|
||||
perLinkLimit,
|
||||
taskTotalLimit
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>商品关键词 / 描述</span>
|
||||
<textarea
|
||||
name="query"
|
||||
placeholder="例如:iPhone 15 Pro、DJI Pocket 3、某品牌蓝牙耳机"
|
||||
required
|
||||
rows={4}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>单链接评论上限</span>
|
||||
<input
|
||||
min={10}
|
||||
max={200}
|
||||
type="number"
|
||||
value={perLinkLimit}
|
||||
onChange={(event) => setPerLinkLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>任务评论总上限</span>
|
||||
<input
|
||||
min={50}
|
||||
max={500}
|
||||
type="number"
|
||||
value={taskTotalLimit}
|
||||
onChange={(event) => setTaskTotalLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-button" disabled={createTaskMutation.isPending} type="submit">
|
||||
创建任务
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="page-grid">
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Platform Readiness</p>
|
||||
<h3>当前平台预检查基线</h3>
|
||||
<div className="stack">
|
||||
{readinessQuery.data?.platforms.map((platform) => (
|
||||
<article key={platform.platform} className="readiness-card">
|
||||
<div className="readiness-card__header">
|
||||
<PlatformIdentity platform={platform.platform} />
|
||||
<PlatformStatusPill
|
||||
platformLabel={platformCatalogMap[platform.platform].label}
|
||||
status={platform.ready ? "Completed" : "SearchBlocked"}
|
||||
/>
|
||||
</div>
|
||||
<p>{platformCatalogMap[platform.platform].description}</p>
|
||||
<a
|
||||
className="text-link"
|
||||
href={`/sessions/${platform.platform}/prepare?from=/tasks/new`}
|
||||
>
|
||||
进入会话准备
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-panel">
|
||||
<p className="eyebrow">Scope Reminder</p>
|
||||
<h3>P0 当前不做什么</h3>
|
||||
<ul className="list">
|
||||
<li>不做自动绕过风控。</li>
|
||||
<li>不做无人工确认的同款判断。</li>
|
||||
<li>当前工作台只覆盖天猫、京东。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateCard(props: {
|
||||
candidate: CandidateRecord;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="candidate-card">
|
||||
<div className="candidate-card__media">
|
||||
<img alt={props.candidate.title} src={props.candidate.imageUrl} />
|
||||
</div>
|
||||
<div className="candidate-card__content">
|
||||
<div className="candidate-card__topline">
|
||||
<input checked={props.checked} onChange={props.onToggle} type="checkbox" />
|
||||
<strong>{props.candidate.title}</strong>
|
||||
</div>
|
||||
<p>{props.candidate.storeName}</p>
|
||||
<div className="candidate-card__meta">
|
||||
<span>{props.candidate.priceLabel}</span>
|
||||
<span>{props.candidate.specLabel}</span>
|
||||
<span>{props.candidate.salesHint}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ["task-candidates", taskId],
|
||||
queryFn: () => getTaskCandidates(taskId),
|
||||
enabled: Boolean(taskId)
|
||||
});
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
|
||||
const groupedSelections = useMemo(() => {
|
||||
const groups: Array<{ platform: PlatformId; candidateIds: string[] }> = [];
|
||||
const candidates = candidatesQuery.data?.candidates;
|
||||
if (!candidates) {
|
||||
return groups;
|
||||
}
|
||||
(Object.keys(candidates) as PlatformId[]).forEach((platform) => {
|
||||
const candidateIds = candidates[platform]
|
||||
.filter((candidate) => selected[candidate.candidateId])
|
||||
.map((candidate) => candidate.candidateId);
|
||||
if (candidateIds.length > 0) {
|
||||
groups.push({ platform, candidateIds });
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}, [candidatesQuery.data, selected]);
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: () => confirmTask(taskId, { selections: groupedSelections }),
|
||||
onSuccess: ({ task }) => {
|
||||
if (task.taskStatus === "NoSelection") {
|
||||
navigate("/history");
|
||||
return;
|
||||
}
|
||||
navigate(`/tasks/${task.taskId}/run`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!taskQuery.data || !candidatesQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
const candidates = candidatesQuery.data.candidates;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="confirmation" />
|
||||
<section className="page-grid">
|
||||
{(Object.keys(candidates) as PlatformId[]).map((platform) => (
|
||||
<article key={platform} className="page-panel">
|
||||
<div className="candidate-section__header">
|
||||
<div>
|
||||
<p className="eyebrow">Candidate Board</p>
|
||||
<h3>{platformCatalogMap[platform].label}</h3>
|
||||
</div>
|
||||
<PlatformStatusPill
|
||||
status={
|
||||
task.platformRuns.find((run) => run.platform === platform)?.status ??
|
||||
"Pending"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{candidates[platform].length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>
|
||||
{task.platformRuns.find((run) => run.platform === platform)?.reason ??
|
||||
"当前没有候选结果。"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack">
|
||||
{candidates[platform].map((candidate) => (
|
||||
<CandidateCard
|
||||
key={candidate.candidateId}
|
||||
candidate={candidate}
|
||||
checked={Boolean(selected[candidate.candidateId])}
|
||||
onToggle={() =>
|
||||
setSelected((current) => ({
|
||||
...current,
|
||||
[candidate.candidateId]: !current[candidate.candidateId]
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<div className="sticky-actions">
|
||||
<div>
|
||||
<p className="eyebrow">Selection Basket</p>
|
||||
<strong>
|
||||
已选{" "}
|
||||
{groupedSelections.reduce(
|
||||
(sum, group) => sum + group.candidateIds.length,
|
||||
0
|
||||
)}{" "}
|
||||
个链接
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={confirmMutation.isPending}
|
||||
onClick={() => confirmMutation.mutate()}
|
||||
>
|
||||
提交确认
|
||||
</button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function RunPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
|
||||
if (!taskQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="execution" />
|
||||
<section className="page-grid">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Task Status</p>
|
||||
<h3>当前阶段:{task.taskStage}</h3>
|
||||
<div className="stack">
|
||||
{task.platformRuns.map((run) => (
|
||||
<div key={run.platform} className="platform-run-panel">
|
||||
<div className="platform-run-panel__header">
|
||||
<PlatformIdentity platform={run.platform} />
|
||||
<PlatformStatusPill status={run.status} />
|
||||
</div>
|
||||
<p>{run.reason ?? "当前平台已进入主线处理。"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Live Event Feed</p>
|
||||
<div aria-live="polite" className="event-feed">
|
||||
{task.events.map((event) => (
|
||||
<div key={event.eventId} className="event-row">
|
||||
<span>{new Date(event.createdAt).toLocaleTimeString("zh-CN")}</span>
|
||||
<p>{event.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{task.defaultReportVersion ? (
|
||||
<a
|
||||
className="primary-button primary-button--link"
|
||||
href={`/tasks/${task.taskId}/report`}
|
||||
>
|
||||
查看报告
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const version = searchParams.get("version");
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
const reportQuery = useQuery({
|
||||
queryKey: ["report", taskId, version],
|
||||
queryFn: () => getTaskReport(taskId, version ? Number(version) : undefined)
|
||||
});
|
||||
|
||||
if (!taskQuery.data || !reportQuery.data) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="page-panel">加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const task = taskQuery.data.task;
|
||||
const report = reportQuery.data.report;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<TaskContextHeader task={task} />
|
||||
<TaskSpine current="report" />
|
||||
<section className="report-grid">
|
||||
<article className="page-panel report-hero">
|
||||
<p className="eyebrow">Report Summary</p>
|
||||
<div className="report-hero__topline">
|
||||
<h2>{report.summary.headline}</h2>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="metrics-grid">
|
||||
<MetricCard label="平台数" value={String(report.product_snapshot.platform_count)} />
|
||||
<MetricCard
|
||||
label="确认链接"
|
||||
value={String(report.product_snapshot.selected_link_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="评论样本"
|
||||
value={String(report.product_snapshot.review_sample_count)}
|
||||
/>
|
||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||
</div>
|
||||
<div className="stack">
|
||||
{report.summary.key_points.map((point) => (
|
||||
<p key={point} className="inline-note">
|
||||
{point}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Quality Flags</p>
|
||||
<h3>当前覆盖范围</h3>
|
||||
<ul className="list">
|
||||
{report.summary.limitations.map((limitation) => (
|
||||
<li key={limitation}>{limitation}</li>
|
||||
))}
|
||||
<li>
|
||||
阻塞平台:
|
||||
{report.quality_flags.blocked_platforms.length > 0
|
||||
? report.quality_flags.blocked_platforms
|
||||
.map((platform) => platformCatalogMap[platform].label)
|
||||
.join("、")
|
||||
: "无"}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="page-grid">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Platform Insights</p>
|
||||
<div className="stack">
|
||||
{report.platform_insights.map((insight) => (
|
||||
<div key={insight.platform} className="platform-run-panel">
|
||||
<div className="platform-run-panel__header">
|
||||
<PlatformIdentity platform={insight.platform} />
|
||||
<span className="status-pill status-pill--info">
|
||||
{insight.execution_status}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
已确认 {insight.selected_link_count} 个链接
|
||||
{insight.price_range
|
||||
? `,价格区间 ¥${insight.price_range.min} - ¥${insight.price_range.max}`
|
||||
: ",当前无价格区间。"}
|
||||
</p>
|
||||
<div className="stack stack--dense">
|
||||
{insight.selling_points.concat(insight.negative_themes).map((card) => (
|
||||
<div key={card.card_id} className="insight-card">
|
||||
<strong>{card.title}</strong>
|
||||
<p>{card.statement}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Evidence Index</p>
|
||||
<div className="stack stack--dense">
|
||||
{report.evidence_index.map((evidence) => (
|
||||
<div key={evidence.evidence_id} className="evidence-card">
|
||||
<strong>{evidence.evidence_id}</strong>
|
||||
<p>{evidence.snippet}</p>
|
||||
<a
|
||||
className="text-link"
|
||||
href={evidence.source_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看来源
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["history"],
|
||||
queryFn: getHistoryTasks
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel">
|
||||
<p className="eyebrow">History</p>
|
||||
<h2>任务账本</h2>
|
||||
<div className="stack">
|
||||
{historyQuery.data?.tasks.map((task) => (
|
||||
<article key={task.taskId} className="history-card">
|
||||
<div className="history-card__topline">
|
||||
<div>
|
||||
<strong>{task.query}</strong>
|
||||
<p>{new Date(task.updatedAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<TaskStatusPill status={task.taskStatus} />
|
||||
</div>
|
||||
<div className="history-card__actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={
|
||||
task.hasReport
|
||||
? `/tasks/${task.taskId}/report`
|
||||
: `/tasks/${task.taskId}/run`
|
||||
}
|
||||
>
|
||||
{task.hasReport ? "查看报告" : "查看任务"}
|
||||
</a>
|
||||
{task.defaultReportVersion ? (
|
||||
<span>默认版本 v{task.defaultReportVersion}</span>
|
||||
) : (
|
||||
<span>No report</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)) ?? <div className="empty-state">还没有任务,先创建第一条分析任务。</div>}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionPreparePage() {
|
||||
const navigate = useNavigate();
|
||||
const { platform = "tmall" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from") ?? "/tasks/new";
|
||||
const prepareMutation = useMutation({
|
||||
mutationFn: () => preparePlatform(platform as PlatformId),
|
||||
onSuccess: () => {
|
||||
navigate(from);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<section className="page-panel session-panel">
|
||||
<p className="eyebrow">Session Console</p>
|
||||
<h2>{platformCatalogMap[platform as PlatformId].label} 会话准备</h2>
|
||||
<p>{platformCatalogMap[platform as PlatformId].recoveryHint}</p>
|
||||
<div className="session-placeholder">
|
||||
<div className="session-placeholder__viewport">Remote Browser Viewport</div>
|
||||
<div className="session-placeholder__sidebar">
|
||||
<strong>当前模式:prepare</strong>
|
||||
<p>
|
||||
本轮实现先用按钮模拟会话预热,后续替换为真正的远程浏览器接管。
|
||||
</p>
|
||||
<button className="primary-button" onClick={() => prepareMutation.mutate()}>
|
||||
标记预热完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Navigate replace to="/history" />} path="/" />
|
||||
<Route element={<NewTaskPage />} path="/tasks/new" />
|
||||
<Route element={<ConfirmPage />} path="/tasks/:taskId/confirm" />
|
||||
<Route element={<RunPage />} path="/tasks/:taskId/run" />
|
||||
<Route element={<ReportPage />} path="/tasks/:taskId/report" />
|
||||
<Route element={<HistoryPage />} path="/history" />
|
||||
<Route element={<SessionPreparePage />} path="/sessions/:platform/prepare" />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
platformCatalogMap,
|
||||
platformStatusToneMap,
|
||||
taskStatusToneMap,
|
||||
type PlatformStatus,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
|
||||
type Tone =
|
||||
| "neutral"
|
||||
| "info"
|
||||
| "progress"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "blocked"
|
||||
| "danger"
|
||||
| "empty";
|
||||
|
||||
function toneClassName(tone: Tone) {
|
||||
return `status-pill status-pill--${tone}`;
|
||||
}
|
||||
|
||||
export function TaskStatusPill(props: { status: TaskStatus }) {
|
||||
return <span className={toneClassName(taskStatusToneMap[props.status])}>{props.status}</span>;
|
||||
}
|
||||
|
||||
export function PlatformStatusPill(props: {
|
||||
platformLabel?: string;
|
||||
status: PlatformStatus;
|
||||
}) {
|
||||
return (
|
||||
<span className={toneClassName(platformStatusToneMap[props.status])}>
|
||||
{props.platformLabel ? `${props.platformLabel} · ` : ""}
|
||||
{props.status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformIdentity(props: { platform: "tmall" | "jd" }) {
|
||||
const platform = platformCatalogMap[props.platform];
|
||||
return (
|
||||
<span className="platform-identity">
|
||||
<span className="platform-identity__badge">{platform.shortLabel}</span>
|
||||
{platform.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TaskRecord } from "@cross-ai/domain";
|
||||
|
||||
import { TaskStatusPill } from "./StatusPill";
|
||||
|
||||
export function TaskContextHeader(props: { task: TaskRecord }) {
|
||||
return (
|
||||
<header className="task-context-header">
|
||||
<div>
|
||||
<p className="eyebrow">当前任务</p>
|
||||
<h1>{props.task.query}</h1>
|
||||
</div>
|
||||
<div className="task-context-header__meta">
|
||||
<TaskStatusPill status={props.task.taskStatus} />
|
||||
<span>{new Date(props.task.updatedAt).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TaskSpine } from "./TaskSpine";
|
||||
|
||||
describe("TaskSpine", () => {
|
||||
it("renders the four task steps and highlights the current one", () => {
|
||||
render(<TaskSpine current="execution" />);
|
||||
|
||||
expect(screen.getByText("输入")).toBeInTheDocument();
|
||||
expect(screen.getByText("确认")).toBeInTheDocument();
|
||||
expect(screen.getByText("执行")).toBeInTheDocument();
|
||||
expect(screen.getByText("报告")).toBeInTheDocument();
|
||||
expect(screen.getByText("执行").closest(".task-spine__item")).toHaveClass(
|
||||
"task-spine__item--active"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { taskSpine } from "@cross-ai/domain";
|
||||
|
||||
export function TaskSpine(props: {
|
||||
current: (typeof taskSpine)[number]["id"];
|
||||
}) {
|
||||
return (
|
||||
<div aria-label="任务主线" className="task-spine">
|
||||
{taskSpine.map((step, index) => {
|
||||
const active = step.id === props.current;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`task-spine__item ${active ? "task-spine__item--active" : ""}`}
|
||||
>
|
||||
<span className="task-spine__index">{index + 1}</span>
|
||||
<span>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,471 @@
|
||||
:root {
|
||||
--bg-canvas: #f2eee6;
|
||||
--bg-surface: #fbf8f2;
|
||||
--bg-elevated: #ffffff;
|
||||
--text-primary: #1f2a30;
|
||||
--text-secondary: #5b6971;
|
||||
--line-subtle: #d7d0c4;
|
||||
--brand-primary: #146c6e;
|
||||
--brand-primary-soft: #d9efeb;
|
||||
--accent-amber: #8c5a16;
|
||||
--success: #2e7d5b;
|
||||
--warning: #9a4b24;
|
||||
--blocked: #9e3f22;
|
||||
--danger: #b63e2f;
|
||||
--info: #2f6d8a;
|
||||
color: var(--text-primary);
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 108, 110, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f6f1e8 0%, var(--bg-canvas) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 32px 24px;
|
||||
border-right: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(251, 248, 242, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar__title {
|
||||
margin: 8px 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar__copy {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar__nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar__nav a {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 40px rgba(31, 42, 48, 0.05);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-panel__copy h2 {
|
||||
margin: 6px 0 14px;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-form,
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stack--dense {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field textarea,
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line-subtle);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-grid,
|
||||
.page-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-grid {
|
||||
grid-template-columns: 1.4fr 0.8fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
.candidate-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
border-radius: 18px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
.metric-card,
|
||||
.insight-card,
|
||||
.evidence-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.readiness-card__header,
|
||||
.platform-run-panel__header,
|
||||
.history-card__topline,
|
||||
.candidate-section__header,
|
||||
.report-hero__topline,
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-context-header {
|
||||
padding: 20px 0 0;
|
||||
}
|
||||
|
||||
.task-context-header h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.task-context-header__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-spine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-spine__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.64);
|
||||
border: 1px solid rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.task-spine__item--active {
|
||||
background: var(--brand-primary-soft);
|
||||
border-color: rgba(20, 108, 110, 0.28);
|
||||
}
|
||||
|
||||
.task-spine__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pill--neutral {
|
||||
background: rgba(31, 42, 48, 0.08);
|
||||
}
|
||||
|
||||
.status-pill--info {
|
||||
background: rgba(47, 109, 138, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status-pill--progress {
|
||||
background: rgba(20, 108, 110, 0.12);
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
background: rgba(46, 125, 91, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill--warning {
|
||||
background: rgba(154, 75, 36, 0.12);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-pill--blocked {
|
||||
background: rgba(158, 63, 34, 0.12);
|
||||
color: var(--blocked);
|
||||
}
|
||||
|
||||
.status-pill--danger {
|
||||
background: rgba(182, 62, 47, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-pill--empty {
|
||||
background: rgba(140, 90, 22, 0.12);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.platform-identity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-identity__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary-soft);
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.candidate-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.candidate-card__content {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-feed {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inline-note {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.history-card__actions,
|
||||
.sticky-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sticky-actions {
|
||||
position: sticky;
|
||||
bottom: 24px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 20px;
|
||||
background: rgba(31, 42, 48, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.session-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-placeholder__viewport,
|
||||
.session-placeholder__sidebar {
|
||||
min-height: 320px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(31, 42, 48, 0.18);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.session-placeholder__viewport {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(140, 90, 22, 0.08);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.hero-panel,
|
||||
.page-grid,
|
||||
.report-grid,
|
||||
.field-grid,
|
||||
.metrics-grid,
|
||||
.session-placeholder {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
Reference in New Issue
Block a user