feat: 补充报告评论集合与评论详情证据
This commit is contained in:
+362
-39
@@ -9,6 +9,7 @@ import {
|
||||
type TaskRecord,
|
||||
type TaskStatus
|
||||
} from "@cross-ai/domain";
|
||||
import type { ReportSnapshot } from "@cross-ai/report-schema";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -52,6 +53,12 @@ import {
|
||||
updateJdSessionManagerConfig
|
||||
} from "./lib/api";
|
||||
|
||||
type ReportEvidence = ReportSnapshot["evidence_index"][number];
|
||||
type EvidenceReviewDetail = NonNullable<ReportEvidence["review_detail"]>;
|
||||
type ReportReviewCollection = NonNullable<ReportSnapshot["review_collections"]>[number];
|
||||
type ReportReviewCollectionComment = ReportReviewCollection["comments"][number];
|
||||
type ReviewDetailLike = EvidenceReviewDetail | ReportReviewCollectionComment;
|
||||
|
||||
function Layout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -188,6 +195,42 @@ function formatTimestamp(timestamp?: string) {
|
||||
return new Date(timestamp).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function getEvidenceSourceLabel(sourceType: ReportEvidence["source_type"]) {
|
||||
return sourceType === "review" ? "评论证据" : "商品证据";
|
||||
}
|
||||
|
||||
function formatReviewScore(score?: string | null) {
|
||||
return score ? `${score} 分` : "未评分";
|
||||
}
|
||||
|
||||
function getReviewMediaSummary(reviewDetail: ReviewDetailLike) {
|
||||
const pictureCount = reviewDetail.picture_urls?.length ?? 0;
|
||||
const videoCount = reviewDetail.video_urls?.length ?? 0;
|
||||
const appendPictureCount = reviewDetail.append_picture_urls?.length ?? 0;
|
||||
const parts = [
|
||||
pictureCount > 0 ? `图片 ${pictureCount}` : null,
|
||||
videoCount > 0 ? `视频 ${videoCount}` : null,
|
||||
appendPictureCount > 0 ? `追评图片 ${appendPictureCount}` : null
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
function getReviewSampleBucketLabel(
|
||||
bucket?: ReportReviewCollectionComment["sample_bucket"] | null
|
||||
) {
|
||||
switch (bucket) {
|
||||
case "latest":
|
||||
return "最新样本";
|
||||
case "hot":
|
||||
return "热门样本";
|
||||
case "negative":
|
||||
return "负向样本";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchRequirementLabel(
|
||||
searchRequirement: SessionReadinessRecord["searchRequirement"]
|
||||
) {
|
||||
@@ -595,9 +638,9 @@ function CandidateCard(props: {
|
||||
}
|
||||
|
||||
function ConfirmPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
useTaskLiveSync(taskId, { includeCandidates: true });
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
@@ -682,27 +725,9 @@ function ConfirmPage() {
|
||||
<p>{platformRun?.reason ?? "当前没有候选结果。"}</p>
|
||||
{platformRun?.status === "SearchBlocked" ? (
|
||||
isOpsManagedPlatform(platform) ? (
|
||||
<>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
|
||||
>
|
||||
去运维恢复
|
||||
</a>
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||
onClick={() => retryMutation.mutate(platform)}
|
||||
type="button"
|
||||
>
|
||||
恢复后重试候选
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
className="text-link"
|
||||
@@ -712,6 +737,24 @@ function ConfirmPage() {
|
||||
</a>
|
||||
)
|
||||
) : null}
|
||||
{platformRun?.status === "SearchBlocked" && isOpsManagedPlatform(platform) ? (
|
||||
<div className="panel-actions">
|
||||
<a
|
||||
className="text-link"
|
||||
href={buildOpsSessionManagerHref(platform, `/tasks/${taskId}/confirm`)}
|
||||
>
|
||||
去运维恢复
|
||||
</a>
|
||||
<button
|
||||
className="ghost-button"
|
||||
disabled={retryMutation.isPending && retryMutation.variables === platform}
|
||||
onClick={() => retryMutation.mutate(platform)}
|
||||
type="button"
|
||||
>
|
||||
恢复后重试候选
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{platformRun?.status === "Failed" ? (
|
||||
<div className="panel-actions">
|
||||
<button
|
||||
@@ -768,13 +811,14 @@ function ConfirmPage() {
|
||||
}
|
||||
|
||||
function RunPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { taskId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
useTaskLiveSync(taskId);
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
queryFn: () => getTask(taskId)
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
|
||||
onSuccess: async () => {
|
||||
@@ -868,11 +912,110 @@ function RunPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string }) {
|
||||
function MetricCard(props: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
ariaLabel?: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const className = props.onClick ? "metric-card metric-card--button" : "metric-card";
|
||||
|
||||
if (props.onClick) {
|
||||
return (
|
||||
<button
|
||||
aria-expanded={props.expanded}
|
||||
aria-label={props.ariaLabel}
|
||||
className={className}
|
||||
onClick={props.onClick}
|
||||
type="button"
|
||||
>
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
{props.hint ? <small>{props.hint}</small> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<div className={className}>
|
||||
<span>{props.label}</span>
|
||||
<strong>{props.value}</strong>
|
||||
{props.hint ? <small>{props.hint}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewDetailPanel(props: {
|
||||
detail: ReviewDetailLike;
|
||||
reviewRef?: string | null;
|
||||
bucketLabel?: string | null;
|
||||
}) {
|
||||
const { detail, reviewRef, bucketLabel } = props;
|
||||
const mediaSummary = getReviewMediaSummary(detail);
|
||||
|
||||
return (
|
||||
<div className="evidence-review-detail">
|
||||
<div className="evidence-review-detail__meta">
|
||||
{bucketLabel ? <span className="inline-note">{bucketLabel}</span> : null}
|
||||
{reviewRef ? <span className="inline-note inline-note--subtle">#{reviewRef}</span> : null}
|
||||
{detail.author_label ? (
|
||||
<span className="inline-note inline-note--subtle">{detail.author_label}</span>
|
||||
) : null}
|
||||
<span className="inline-note inline-note--subtle">{formatReviewScore(detail.score)}</span>
|
||||
{detail.created_at ? (
|
||||
<span className="inline-note inline-note--subtle">{detail.created_at}</span>
|
||||
) : null}
|
||||
{detail.sku_labels && detail.sku_labels.length > 0 ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{detail.sku_labels.join(" / ")}
|
||||
</span>
|
||||
) : null}
|
||||
{detail.like_count ? (
|
||||
<span className="inline-note inline-note--subtle">点赞 {detail.like_count}</span>
|
||||
) : null}
|
||||
{mediaSummary ? (
|
||||
<span className="inline-note inline-note--subtle">{mediaSummary}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="stack stack--dense">
|
||||
<strong>评论正文</strong>
|
||||
<p>{detail.content}</p>
|
||||
</div>
|
||||
{detail.append_content ? (
|
||||
<div className="stack stack--dense">
|
||||
<strong>追评</strong>
|
||||
<p>{detail.append_content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.reply ? (
|
||||
<div className="stack stack--dense">
|
||||
<strong>商家回复</strong>
|
||||
<p>{detail.reply}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewCollectionComments(props: { collection: ReportReviewCollection }) {
|
||||
const { collection } = props;
|
||||
|
||||
if (collection.comments.length === 0) {
|
||||
return <p className="inline-note inline-note--subtle">当前链接没有保留抓取评论。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="review-comment-list">
|
||||
{collection.comments.map((comment) => (
|
||||
<ReviewDetailPanel
|
||||
key={comment.review_ref}
|
||||
bucketLabel={getReviewSampleBucketLabel(comment.sample_bucket)}
|
||||
detail={comment}
|
||||
reviewRef={comment.review_ref}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -881,6 +1024,11 @@ function ReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { taskId = "" } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [expandedEvidenceId, setExpandedEvidenceId] = useState<string | null>(null);
|
||||
const [expandedCollectionEvidenceId, setExpandedCollectionEvidenceId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [showAllReviewCollections, setShowAllReviewCollections] = useState(false);
|
||||
const version = searchParams.get("version");
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ["task", taskId],
|
||||
@@ -890,6 +1038,13 @@ function ReportPage() {
|
||||
queryKey: ["report", taskId, version],
|
||||
queryFn: () => getTaskReport(taskId, version ? Number(version) : undefined)
|
||||
});
|
||||
const reportId = reportQuery.data?.report.report_id;
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedEvidenceId(null);
|
||||
setExpandedCollectionEvidenceId(null);
|
||||
setShowAllReviewCollections(false);
|
||||
}, [reportId]);
|
||||
|
||||
if (!taskQuery.data || !reportQuery.data) {
|
||||
return (
|
||||
@@ -905,6 +1060,14 @@ function ReportPage() {
|
||||
version && Number.isFinite(Number(version))
|
||||
? Number(version)
|
||||
: task.defaultReportVersion ?? report.report_version;
|
||||
const reviewCollections = report.review_collections ?? [];
|
||||
const reviewCollectionsByProductEvidenceId = new Map(
|
||||
reviewCollections.map((collection) => [collection.product_evidence_id, collection] as const)
|
||||
);
|
||||
const totalCapturedReviewCount = reviewCollections.reduce(
|
||||
(sum, collection) => sum + collection.review_count,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -926,6 +1089,14 @@ function ReportPage() {
|
||||
<MetricCard
|
||||
label="评论样本"
|
||||
value={String(report.product_snapshot.review_sample_count)}
|
||||
{...(reviewCollections.length > 0
|
||||
? {
|
||||
ariaLabel: showAllReviewCollections ? "收起全部抓取评论" : "查看全部抓取评论",
|
||||
expanded: showAllReviewCollections,
|
||||
hint: showAllReviewCollections ? "收起全部抓取评论" : "点击查看全部抓取评论",
|
||||
onClick: () => setShowAllReviewCollections((current) => !current)
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
<MetricCard label="报告版本" value={`v${report.report_version}`} />
|
||||
</div>
|
||||
@@ -982,6 +1153,81 @@ function ReportPage() {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{reviewCollections.length > 0 ? (
|
||||
<section className="page-panel">
|
||||
<div className="candidate-section__header">
|
||||
<div>
|
||||
<p className="eyebrow">Review Collections</p>
|
||||
<h3>按链接查看全部抓取评论</h3>
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<span className="inline-note inline-note--subtle">
|
||||
共保留 {totalCapturedReviewCount} 条抓取评论
|
||||
</span>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setShowAllReviewCollections((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{showAllReviewCollections ? "收起全部抓取评论" : "展开全部抓取评论"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showAllReviewCollections ? (
|
||||
<div className="stack stack--dense">
|
||||
{reviewCollections.map((collection) => (
|
||||
<div key={collection.collection_id} className="evidence-card">
|
||||
<div className="evidence-card__header">
|
||||
<strong>{collection.title}</strong>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
已抓取 {collection.review_count} 条评论
|
||||
</span>
|
||||
</div>
|
||||
<div className="evidence-card__meta">
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{platformCatalogMap[collection.platform].label}
|
||||
</span>
|
||||
{collection.store_name ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
店铺 {collection.store_name}
|
||||
</span>
|
||||
) : null}
|
||||
{collection.price_label ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
价格 {collection.price_label}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{formatTimestamp(collection.captured_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="evidence-card__actions">
|
||||
{collection.sampled_review_refs.length > 0 ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
已抽样 {collection.sampled_review_refs.length} 条进入报告样本
|
||||
</span>
|
||||
) : null}
|
||||
<a
|
||||
className="text-link"
|
||||
href={collection.source_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看来源
|
||||
</a>
|
||||
</div>
|
||||
<ReviewCollectionComments collection={collection} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="inline-note inline-note--subtle">
|
||||
点击上方“评论样本”卡或右侧按钮,可展开全部抓取评论。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="page-grid">
|
||||
<article className="page-panel">
|
||||
<p className="eyebrow">Platform Insights</p>
|
||||
@@ -1016,20 +1262,97 @@ function ReportPage() {
|
||||
<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>
|
||||
))}
|
||||
{report.evidence_index.map((evidence) => {
|
||||
const reviewDetail = evidence.review_detail;
|
||||
const linkedReviewCollection =
|
||||
evidence.source_type === "product"
|
||||
? reviewCollectionsByProductEvidenceId.get(evidence.evidence_id) ?? null
|
||||
: null;
|
||||
const isCollectionExpanded =
|
||||
expandedCollectionEvidenceId === evidence.evidence_id;
|
||||
const isExpanded = expandedEvidenceId === evidence.evidence_id;
|
||||
|
||||
return (
|
||||
<div key={evidence.evidence_id} className="evidence-card">
|
||||
<div className="evidence-card__header">
|
||||
<strong>{evidence.evidence_id}</strong>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{formatTimestamp(evidence.captured_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="evidence-card__meta">
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{platformCatalogMap[evidence.platform].label}
|
||||
</span>
|
||||
<span className="inline-note inline-note--subtle">
|
||||
{getEvidenceSourceLabel(evidence.source_type)}
|
||||
</span>
|
||||
{evidence.review_ref ? (
|
||||
<span className="inline-note inline-note--subtle">#{evidence.review_ref}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{evidence.snippet}</p>
|
||||
<div className="evidence-card__actions">
|
||||
{linkedReviewCollection ? (
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() =>
|
||||
setExpandedCollectionEvidenceId((current) =>
|
||||
current === evidence.evidence_id ? null : evidence.evidence_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isCollectionExpanded
|
||||
? "收起该链接评论"
|
||||
: `查看该链接评论(${linkedReviewCollection.review_count})`}
|
||||
</button>
|
||||
) : null}
|
||||
{reviewDetail ? (
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() =>
|
||||
setExpandedEvidenceId((current) =>
|
||||
current === evidence.evidence_id ? null : evidence.evidence_id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? "收起样本评论" : "查看样本评论"}
|
||||
</button>
|
||||
) : evidence.review_ref ? (
|
||||
<span className="inline-note inline-note--subtle">旧版报告未保留评论正文</span>
|
||||
) : null}
|
||||
<a
|
||||
className="text-link"
|
||||
href={evidence.source_url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看来源
|
||||
</a>
|
||||
</div>
|
||||
{isCollectionExpanded && linkedReviewCollection ? (
|
||||
<div className="stack stack--dense">
|
||||
<div className="evidence-card__meta">
|
||||
<span className="inline-note inline-note--subtle">
|
||||
该链接共抓取 {linkedReviewCollection.review_count} 条评论
|
||||
</span>
|
||||
{linkedReviewCollection.sampled_review_refs.length > 0 ? (
|
||||
<span className="inline-note inline-note--subtle">
|
||||
已抽样 {linkedReviewCollection.sampled_review_refs.length} 条进入报告样本
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ReviewCollectionComments collection={linkedReviewCollection} />
|
||||
</div>
|
||||
) : null}
|
||||
{isExpanded && reviewDetail ? (
|
||||
<ReviewDetailPanel detail={reviewDetail} reviewRef={evidence.review_ref} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/api", () => ({
|
||||
cancelJdQrLogin: vi.fn(),
|
||||
cancelTmallQrLogin: vi.fn(),
|
||||
clearJdManagedSession: vi.fn(),
|
||||
clearJdSessionManagerConfig: vi.fn(),
|
||||
clearPlatformSession: vi.fn(),
|
||||
clearTmallManagedSession: vi.fn(),
|
||||
clearTmallSessionManagerConfig: vi.fn(),
|
||||
confirmTask: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
createTaskEventsSource: vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})),
|
||||
deleteTask: vi.fn(),
|
||||
getHistoryTasks: vi.fn(),
|
||||
getJdKeywordPreview: vi.fn(),
|
||||
getJdLiveSession: vi.fn(),
|
||||
getJdQrLoginState: vi.fn(),
|
||||
getJdSessionManager: vi.fn(),
|
||||
getPlatformReadiness: vi.fn(),
|
||||
getPlatformSession: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getTaskCandidates: vi.fn(),
|
||||
getTaskReport: vi.fn(),
|
||||
getTmallLiveSession: vi.fn(),
|
||||
getTmallQrLoginState: vi.fn(),
|
||||
getTmallSessionManager: vi.fn(),
|
||||
importJdManagedSession: vi.fn(),
|
||||
importTmallManagedSession: vi.fn(),
|
||||
preparePlatform: vi.fn(),
|
||||
resumeJdQrLoginManualRecovery: vi.fn(),
|
||||
retryTaskPlatform: vi.fn(),
|
||||
runJdSessionManagerHealthCheck: vi.fn(),
|
||||
runJdSessionManagerRecovery: vi.fn(),
|
||||
runTmallSessionManagerHealthCheck: vi.fn(),
|
||||
startJdQrLogin: vi.fn(),
|
||||
startTmallQrLogin: vi.fn(),
|
||||
updateJdSessionManagerConfig: vi.fn(),
|
||||
updateTmallSessionManagerConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import { App } from "./App";
|
||||
import { getTask, getTaskReport } from "./lib/api";
|
||||
|
||||
function renderWithProviders(node: ReactNode, initialEntries: string[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{node}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReportPage", () => {
|
||||
const taskId = "task-report";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTask).mockResolvedValue({
|
||||
task: {
|
||||
taskId,
|
||||
query: "Nintendo Switch 2",
|
||||
createdAt: "2026-04-02T11:40:00.000Z",
|
||||
updatedAt: "2026-04-02T12:00:00.000Z",
|
||||
perLinkLimit: 100,
|
||||
taskTotalLimit: 500,
|
||||
taskStatus: "Completed",
|
||||
taskStage: "publish",
|
||||
platformRuns: [
|
||||
{
|
||||
platform: "jd",
|
||||
searchRequirement: "required",
|
||||
status: "Completed",
|
||||
candidateCount: 1,
|
||||
selectedCandidateIds: ["jd-1"],
|
||||
lastUpdatedAt: "2026-04-02T11:58:00.000Z"
|
||||
}
|
||||
],
|
||||
platformCandidates: {
|
||||
tmall: [],
|
||||
jd: []
|
||||
},
|
||||
events: [],
|
||||
reportVersions: [1],
|
||||
defaultReportVersion: 1,
|
||||
latestSuccessfulReportVersion: 1
|
||||
}
|
||||
} as any);
|
||||
|
||||
vi.mocked(getTaskReport).mockResolvedValue({
|
||||
report: {
|
||||
report_id: "report-1",
|
||||
report_version: 1,
|
||||
task_id: taskId,
|
||||
generated_at: "2026-04-02T12:00:00.000Z",
|
||||
task_status: "Completed",
|
||||
summary: {
|
||||
headline: "报告已包含真实评论证据",
|
||||
key_points: ["评论样本已写入报告证据,可在站内查看。"],
|
||||
limitations: []
|
||||
},
|
||||
product_snapshot: {
|
||||
query: "Nintendo Switch 2",
|
||||
normalized_product_name: "Nintendo Switch 2",
|
||||
platform_count: 1,
|
||||
selected_link_count: 1,
|
||||
review_sample_count: 5,
|
||||
analysis_time_range: {
|
||||
start: "2026-04-02T11:40:00.000Z",
|
||||
end: "2026-04-02T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
platform_insights: [
|
||||
{
|
||||
platform: "jd",
|
||||
execution_status: "completed",
|
||||
selected_link_count: 1,
|
||||
price_range: null,
|
||||
selling_points: [],
|
||||
positive_themes: [],
|
||||
negative_themes: [],
|
||||
store_diff_notes: []
|
||||
}
|
||||
],
|
||||
cross_platform_insights: [],
|
||||
recommendations: [],
|
||||
evidence_index: [
|
||||
{
|
||||
evidence_id: "evidence-product-1",
|
||||
platform: "jd",
|
||||
source_type: "product",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
review_ref: null,
|
||||
snippet: "Nintendo Switch 2 | 店铺 京东自营 | 价格 ¥2999 | 库存 有货",
|
||||
captured_at: "2026-04-02T11:58:00.000Z"
|
||||
},
|
||||
{
|
||||
evidence_id: "evidence-review-1",
|
||||
platform: "jd",
|
||||
source_type: "review",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
review_ref: "comment-1",
|
||||
review_detail: {
|
||||
content: "这是一条抓取到的完整评论内容。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "PLUS会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "9",
|
||||
reply: "感谢支持。",
|
||||
append_content: "追评:一周后依然满意。",
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
snippet: "样本 latest | 评分 5 | PLUS会员 | 这是一条抓取到的完整评论内容。",
|
||||
captured_at: "2026-04-02T11:59:00.000Z"
|
||||
}
|
||||
],
|
||||
review_collections: [
|
||||
{
|
||||
collection_id: "review-collection-jd-1",
|
||||
candidate_id: "jd-1",
|
||||
product_evidence_id: "evidence-product-1",
|
||||
platform: "jd",
|
||||
source_url: "https://item.jd.com/100068388533.html",
|
||||
title: "Nintendo Switch 2",
|
||||
store_name: "京东自营",
|
||||
price_label: "¥2999",
|
||||
captured_at: "2026-04-02T11:59:00.000Z",
|
||||
review_count: 2,
|
||||
sampled_review_refs: ["comment-1"],
|
||||
comments: [
|
||||
{
|
||||
review_ref: "comment-1",
|
||||
sample_bucket: "latest",
|
||||
content: "这是一条抓取到的完整评论内容。",
|
||||
score: "5",
|
||||
created_at: "2026-04-02",
|
||||
author_label: "PLUS会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "9",
|
||||
reply: "感谢支持。",
|
||||
append_content: "追评:一周后依然满意。",
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
},
|
||||
{
|
||||
review_ref: "comment-2",
|
||||
sample_bucket: null,
|
||||
content: "第二条抓取评论,主要提到续航稳定。",
|
||||
score: "4",
|
||||
created_at: "2026-04-01",
|
||||
author_label: "普通会员",
|
||||
sku_labels: ["标准版"],
|
||||
like_count: "2",
|
||||
reply: null,
|
||||
append_content: null,
|
||||
picture_urls: [],
|
||||
video_urls: [],
|
||||
append_picture_urls: []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
quality_flags: {
|
||||
sample_insufficient: false,
|
||||
partial_platform_failure: false,
|
||||
blocked_platforms: [],
|
||||
failed_platforms: []
|
||||
}
|
||||
}
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("expands all captured comments from the review sample metric", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||
|
||||
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看全部抓取评论" }));
|
||||
|
||||
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||
expect(screen.getByText("共保留 2 条抓取评论")).toBeInTheDocument();
|
||||
expect(screen.getByText("已抽样 1 条进入报告样本")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands per-link comments and sampled review details inside the evidence index", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<App />, [`/tasks/${taskId}/report`]);
|
||||
|
||||
expect(await screen.findByText("评论样本已写入报告证据,可在站内查看。")).toBeInTheDocument();
|
||||
expect(screen.queryByText("第二条抓取评论,主要提到续航稳定。")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看该链接评论(2)" }));
|
||||
|
||||
expect(await screen.findByText("第二条抓取评论,主要提到续航稳定。")).toBeInTheDocument();
|
||||
expect(screen.getByText("该链接共抓取 2 条评论")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("评论正文")).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看样本评论" }));
|
||||
|
||||
expect(await screen.findAllByText("评论正文")).toHaveLength(3);
|
||||
expect(screen.getAllByText("追评")).toHaveLength(2);
|
||||
expect(screen.getAllByText("商家回复")).toHaveLength(2);
|
||||
expect(screen.getAllByText("点赞 9")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+439
-346
@@ -1,39 +1,39 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
: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);
|
||||
@@ -56,85 +56,85 @@ a {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
.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;
|
||||
min-height: 100vh;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
.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,
|
||||
.field select {
|
||||
@@ -151,39 +151,39 @@ a {
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
|
||||
.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;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -216,12 +216,12 @@ a {
|
||||
.primary-button--link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
.text-link {
|
||||
color: var(--brand-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.readiness-card,
|
||||
.platform-run-panel,
|
||||
.history-card,
|
||||
@@ -244,18 +244,18 @@ a {
|
||||
.mini-task-link {
|
||||
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;
|
||||
.task-context-header,
|
||||
.sticky-actions,
|
||||
.history-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -285,6 +285,39 @@ a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.evidence-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.evidence-card p,
|
||||
.evidence-review-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.evidence-card__header,
|
||||
.evidence-card__meta,
|
||||
.evidence-card__actions,
|
||||
.evidence-review-detail__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evidence-card__header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.evidence-review-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(20, 108, 110, 0.16);
|
||||
background: rgba(20, 108, 110, 0.06);
|
||||
}
|
||||
|
||||
.mini-task-link__topline {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
@@ -296,169 +329,169 @@ a {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
.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;
|
||||
@@ -470,12 +503,24 @@ a {
|
||||
background: rgba(31, 42, 48, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-card--button {
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metric-card--button:hover {
|
||||
border-color: rgba(20, 108, 110, 0.24);
|
||||
box-shadow: 0 12px 24px rgba(20, 108, 110, 0.08);
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
@@ -485,6 +530,17 @@ a {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.metric-card small {
|
||||
color: var(--brand-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.review-comment-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.history-card__actions,
|
||||
.sticky-actions {
|
||||
margin-top: 16px;
|
||||
@@ -583,32 +639,32 @@ a {
|
||||
.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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -621,6 +677,39 @@ a {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.qr-login-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.qr-login-card__preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 248px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
|
||||
border: 1px solid rgba(20, 108, 110, 0.14);
|
||||
}
|
||||
|
||||
.qr-login-card__preview img {
|
||||
display: block;
|
||||
width: min(100%, 240px);
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.qr-login-card__empty {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -725,15 +814,15 @@ a {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.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,
|
||||
@@ -748,6 +837,10 @@ a {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qr-login-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user