fix: complete Douyin release remediation

This commit is contained in:
2026-09-16 01:51:14 +08:00
parent 977e541fef
commit 9095920a5b
56 changed files with 4530 additions and 518 deletions
+24 -2
View File
@@ -16,6 +16,7 @@ import {
Select,
StatusPill,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
@@ -62,7 +63,7 @@ const createInitial = {
image_version: "",
account_id: "",
network_exit_id: "",
seed: "1000",
seed: "",
platform: "",
platform_version: "",
brand: "",
@@ -75,6 +76,17 @@ const createInitial = {
};
// 高级字段空值不提交,收敛到 seed 驱动。
function nextFingerprintSeed(runtimes) {
const used = new Set(
runtimes
.map((runtime) => Number(runtime.fingerprint?.seed))
.filter((seed) => Number.isInteger(seed) && seed > 0),
);
let seed = 1000;
while (used.has(seed)) seed += 1;
return String(seed);
}
function fingerprintPayload(form) {
const payload = { seed: Number(form.seed) };
const advanced = {
@@ -424,9 +436,12 @@ export function BrowserCreatePage() {
const [advancedOpen, setAdvancedOpen] = useState(false);
const [submitBusy, setSubmitBusy] = useState(false);
const [submitError, setSubmitError] = useState("");
const { query: runtimesQuery } = useList({ resource: "browsers" });
const { result: runtimesResult, query: runtimesQuery } = useList({
resource: "browsers",
});
const { result: gatewayResult } = useList({ resource: "gateways" });
const gateways = gatewayResult.data ?? [];
const runtimes = runtimesResult.data ?? [];
const { result: imageResult } = useList({ resource: "browser-images" });
const images = imageResult.data ?? [];
const { result: accountResult } = useList({ resource: "accounts" });
@@ -467,6 +482,13 @@ export function BrowserCreatePage() {
if (form.account_id === "" && pausedAccounts[0]?.id)
update("account_id", pausedAccounts[0].id);
}, [pausedAccounts, form.account_id]);
useEffect(() => {
if (form.seed === "" && runtimesResult.data !== undefined)
update("seed", nextFingerprintSeed(runtimes));
}, [form.seed, runtimes, runtimesResult.data]);
useUnsavedChanges(
Boolean(form.name || form.alias || form.seed || advancedOpen),
);
const seed = Number(form.seed);
const valid =
+12
View File
@@ -15,6 +15,7 @@ import {
Textarea,
dateTime,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
const emptyProfile = {
@@ -171,6 +172,17 @@ export function CreatorAccountsPage() {
() => profiles.find((item) => item.id === selectedID),
[profiles, selectedID],
);
useUnsavedChanges(
Boolean(
selected &&
JSON.stringify(form) !== JSON.stringify(profileForm(selected)),
) ||
Boolean(
strategyForm.execution_account_id ||
strategyForm.candidate_texts ||
editingStrategyID,
),
);
const choose = (profile) => {
const dirty =
selected &&
+265 -30
View File
@@ -14,6 +14,7 @@ import {
Textarea,
dateTime,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
const platformOptions = [
@@ -41,6 +42,9 @@ export function CreatorCompetitorsPage() {
const [minLikes, setMinLikes] = useState("");
const [minComments, setMinComments] = useState("");
const [minShares, setMinShares] = useState("");
const [publishedAtStatus, setPublishedAtStatus] = useState("");
const [publishedAfter, setPublishedAfter] = useState("");
const [publishedBefore, setPublishedBefore] = useState("");
const [pending, setPending] = useState(true);
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState(null);
@@ -53,6 +57,9 @@ export function CreatorCompetitorsPage() {
const [rewriteRequirement, setRewriteRequirement] = useState("");
const [rewriteTitle, setRewriteTitle] = useState("");
const [rewriteScript, setRewriteScript] = useState("");
const [workDetail, setWorkDetail] = useState(null);
const [workMetrics, setWorkMetrics] = useState([]);
const [workDetailPending, setWorkDetailPending] = useState(false);
const load = async () => {
setPending(true);
@@ -66,6 +73,21 @@ export function CreatorCompetitorsPage() {
filters.push({ field: "min_comments", value: Number(minComments) });
if (minShares !== "")
filters.push({ field: "min_shares", value: Number(minShares) });
if (publishedAtStatus)
filters.push({
field: "published_at_status",
value: publishedAtStatus,
});
if (publishedAfter)
filters.push({
field: "published_after",
value: `${publishedAfter}T00:00:00Z`,
});
if (publishedBefore)
filters.push({
field: "published_before",
value: `${publishedBefore}T23:59:59Z`,
});
const [competitorResult, workResult] = await Promise.all([
dataProvider.getList({
resource: "creator-competitors",
@@ -88,10 +110,27 @@ export function CreatorCompetitorsPage() {
};
useEffect(() => {
load();
}, [platform, minLikes, minComments, minShares, workPage]);
}, [
platform,
minLikes,
minComments,
minShares,
publishedAtStatus,
publishedAfter,
publishedBefore,
workPage,
]);
useEffect(() => {
setWorkPage(1);
}, [platform, minLikes, minComments, minShares]);
}, [
platform,
minLikes,
minComments,
minShares,
publishedAtStatus,
publishedAfter,
publishedBefore,
]);
useEffect(() => {
dataProvider
.getList({ resource: "creator-accounts" })
@@ -107,7 +146,8 @@ export function CreatorCompetitorsPage() {
setPreview(null);
setPreviewConfirmed(false);
};
const parseHomepage = () => {
const parseHomepage = async () => {
setBusy(true);
try {
const parsed = new URL(form.homepage_url);
const parts = parsed.pathname.split("/").filter(Boolean);
@@ -115,14 +155,12 @@ export function CreatorCompetitorsPage() {
const allowed = isXiaohongshu
? parsed.protocol === "https:" &&
parsed.hostname === "www.xiaohongshu.com"
: parsed.hostname === "www.douyin.com";
let candidate = parts.at(-1) || "";
if (
isXiaohongshu &&
!(parts.length === 3 && parts[0] === "user" && parts[1] === "profile")
) {
candidate = "";
}
: parsed.protocol === "https:" && parsed.hostname === "www.douyin.com";
const validAccountRoute = isXiaohongshu
? parts.length === 3 && parts[0] === "user" && parts[1] === "profile"
: (parts.length === 2 && parts[0] === "user") ||
(parts.length === 4 && parts[0] === "user" && parts[1] === "profile");
const candidate = validAccountRoute ? parts.at(-1) || "" : "";
if (
!allowed ||
!candidate ||
@@ -132,19 +170,54 @@ export function CreatorCompetitorsPage() {
"链接不是受支持的平台主页格式,请人工填写平台返回的稳定标识",
);
}
const next = { ...form, platform_account_key: candidate };
setForm(next);
setPreview(next);
if (isXiaohongshu) {
const next = { ...form, platform_account_key: candidate };
setForm(next);
setPreview(next);
setPreviewConfirmed(false);
setNotice({
variant: "info",
text: `已解析候选标识 ${candidate},请核对平台返回值后确认。`,
});
return;
}
const account = accounts.find(
(value) =>
value.platform === "douyin" &&
value.authorization_status === "authorized" &&
(value.business_status === "normal" ||
value.business_status === "muted") &&
value.login_status === "logged_in",
);
if (!account) throw new Error("没有可用于校验抖音主页的已登录账号");
const result = await dataProvider.create({
resource: "creator-competitor-preview",
variables: {
...form,
platform_account_key: candidate,
account_id: account.id,
},
});
const verified = result.data;
setForm((value) => ({
...value,
platform_account_key: verified.platform_account_key,
nickname: verified.nickname || value.nickname,
homepage_url: verified.homepage_url || value.homepage_url,
}));
setPreview(verified);
setPreviewConfirmed(false);
setNotice({
variant: "info",
text: `已解析候选标识 ${candidate},请核对平台返回值后确认。`,
text: `已通过抖音账号核验 ${verified.nickname || verified.platform_account_key},请确认后加入监测。`,
});
} catch (parseError) {
setNotice({
variant: "warning",
text: parseError.message || "主页链接解析失败",
});
} finally {
setBusy(false);
}
};
const create = async (event) => {
@@ -158,7 +231,12 @@ export function CreatorCompetitorsPage() {
try {
const result = await dataProvider.create({
resource: "creator-competitors",
variables: form,
variables: {
...form,
...(form.platform === "douyin" && preview?.account_id
? { account_id: preview.account_id }
: {}),
},
});
setCompetitors((items) => [result.data, ...items]);
setForm((value) => ({
@@ -234,6 +312,27 @@ export function CreatorCompetitorsPage() {
setBusy(false);
}
};
const openWorkDetail = async (workID) => {
setWorkDetailPending(true);
setNotice(null);
try {
const [detail, metrics] = await Promise.all([
dataProvider.creatorGet(`/creator/works/${encodeURIComponent(workID)}`),
dataProvider.creatorGet(
`/creator/works/${encodeURIComponent(workID)}/metrics`,
),
]);
setWorkDetail(detail);
setWorkMetrics(metrics || []);
} catch (actionError) {
setNotice({
variant: "destructive",
text: conflictMessage(actionError, "作品详情读取失败"),
});
} finally {
setWorkDetailPending(false);
}
};
const selectMaterial = async (workID) => {
const dirty =
material &&
@@ -275,13 +374,16 @@ export function CreatorCompetitorsPage() {
`/creator/works/${encodeURIComponent(material.work_id)}/material/process`,
);
setMaterial(result);
const failedStep =
result.failed_step ||
["download", "audio", "transcription"].find(
(step) => result[`${step}_status`] === "failed",
);
setNotice({
variant:
result.transcription_status === "failed" ? "warning" : "success",
text:
result.transcription_status === "failed"
? "素材已处理,但转写供应商尚未配置。"
: "素材处理完成。",
variant: failedStep ? "warning" : "success",
text: failedStep
? `素材处理未完成(${failedStep}):${result.failure_reason || "请查看步骤状态后重试。"}`
: "素材处理完成。",
});
} catch (actionError) {
setNotice({
@@ -338,6 +440,20 @@ export function CreatorCompetitorsPage() {
setMaterialPending(false);
}
};
const materialDirty =
material &&
(rewriteRequirement !== (material.rewrite_requirement || "") ||
rewriteTitle !== (material.generated_title || "") ||
rewriteScript !== (material.generated_script || ""));
useUnsavedChanges(
Boolean(
form.platform_account_key ||
form.nickname ||
form.homepage_url ||
materialDirty,
),
);
const saveRewrite = async () => {
if (!material) return;
setMaterialPending(true);
@@ -559,6 +675,36 @@ export function CreatorCompetitorsPage() {
onChange={(event) => setMinShares(event.target.value)}
/>
</Field>
<Field id="work-status-filter" label="发布时间状态">
<Select
id="work-status-filter"
value={publishedAtStatus}
onChange={(event) => setPublishedAtStatus(event.target.value)}
emptyOption={{ value: "", label: "全部状态" }}
options={[
{ value: "verified", label: "已核验" },
{ value: "future", label: "未来时间" },
{ value: "unverified", label: "待核验" },
{ value: "invalid", label: "无效时间" },
]}
/>
</Field>
<Field id="work-published-after" label="发布时间从">
<Input
id="work-published-after"
type="date"
value={publishedAfter}
onChange={(event) => setPublishedAfter(event.target.value)}
/>
</Field>
<Field id="work-published-before" label="发布时间至">
<Input
id="work-published-before"
type="date"
value={publishedBefore}
onChange={(event) => setPublishedBefore(event.target.value)}
/>
</Field>
</div>
{accountError ? (
<Alert variant="warning" className="mb-4">
@@ -590,9 +736,14 @@ export function CreatorCompetitorsPage() {
{works.map((work) => (
<tr key={work.id}>
<td className="max-w-[280px] px-3 py-3">
<p className="truncate font-medium">
<button
type="button"
className="truncate text-left font-medium text-primary hover:underline"
onClick={() => openWorkDetail(work.id)}
disabled={workDetailPending}
>
{work.title || "无标题"}
</p>
</button>
<p className="mt-1 truncate text-xs text-muted">
{work.author_name || work.source_id} ·{" "}
{work.published_at_status}
@@ -613,12 +764,22 @@ export function CreatorCompetitorsPage() {
: work.metric_stop_reason || "—"}
</td>
<td className="px-3 py-3 text-right">
<Button
size="sm"
onClick={() => selectMaterial(work.id)}
>
选择素材
</Button>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openWorkDetail(work.id)}
busy={workDetailPending}
>
详情
</Button>
<Button
size="sm"
onClick={() => selectMaterial(work.id)}
>
选择素材
</Button>
</div>
</td>
</tr>
))}
@@ -649,6 +810,80 @@ export function CreatorCompetitorsPage() {
</Button>
</div>
</div>
{workDetail ? (
<Card className="mt-5 border-primary/20">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold">作品详情</h2>
<p className="mt-1 text-xs text-muted">
{workDetail.work_key || workDetail.id}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => setWorkDetail(null)}
>
关闭
</Button>
</div>
{workDetail.cover_url ? (
<img
src={workDetail.cover_url}
alt="作品封面"
className="max-h-64 rounded border border-hairline object-contain"
/>
) : null}
<p className="whitespace-pre-wrap text-sm">
{workDetail.body || workDetail.title || "暂无正文"}
</p>
<div className="flex flex-wrap gap-3 text-xs text-muted">
<span>来源:{workDetail.source_type || "—"}</span>
<span>状态:{workDetail.published_at_status || "—"}</span>
{workDetail.original_url ? (
<a
className="text-primary hover:underline"
href={workDetail.original_url}
target="_blank"
rel="noreferrer"
>
打开原文
</a>
) : null}
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="border-b border-hairline text-muted">
<tr>
<th className="px-2 py-2">采集时间</th>
<th className="px-2 py-2">点赞</th>
<th className="px-2 py-2">评论</th>
<th className="px-2 py-2">转发</th>
</tr>
</thead>
<tbody>
{workMetrics.map((point) => (
<tr
key={point.collected_at}
className="border-b border-hairline last:border-0"
>
<td className="px-2 py-2">
{dateTime(point.collected_at)}
</td>
<td className="px-2 py-2">{point.likes ?? "—"}</td>
<td className="px-2 py-2">
{point.comments_count ?? "—"}
</td>
<td className="px-2 py-2">{point.shares ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
) : null}
{material ? (
<div className="mt-5 space-y-4 border-t border-hairline pt-5">
<div>
+86 -3
View File
@@ -67,6 +67,7 @@ function provider(overrides = {}) {
create: vi.fn().mockResolvedValue({
data: {
id: "competitor-a",
account_id: "account-a",
platform: "douyin",
platform_account_key: "sec-b",
homepage_url: "https://www.douyin.com/user/sec-b",
@@ -120,6 +121,71 @@ describe("creator pages", () => {
);
});
it("verifies account identity and toggles big-account mode", async () => {
const dataProvider = provider({
creatorAction: vi.fn((path) =>
Promise.resolve(
path.endsWith("/verify")
? { ...profile, login_status: "logged_in" }
: { ...profile, big_account: true },
),
),
});
renderPage(<CreatorAccountsPage />, dataProvider);
expect(
await screen.findByRole("button", { name: "开启大号模式" }),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "核验浏览器身份" }));
await waitFor(() =>
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
"/creator/accounts/account-a/verify",
),
);
fireEvent.click(screen.getByRole("button", { name: "开启大号模式" }));
await waitFor(() =>
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
"/creator/accounts/account-a/big-account",
{ enabled: true },
),
);
expect(await screen.findByText("已开启大号模式。")).toBeTruthy();
});
it("shows retryable account and strategy loading failures", async () => {
const dataProvider = provider({
getList: vi.fn().mockRejectedValue(new Error("accounts offline")),
creatorGet: vi.fn().mockRejectedValue(new Error("creator offline")),
});
renderPage(<CreatorAccountsPage />, dataProvider);
expect(await screen.findByText("accounts offline")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() => expect(dataProvider.getList).toHaveBeenCalledTimes(2));
});
it("shows settings save conflicts", async () => {
const dataProvider = provider({
creatorUpdate: vi.fn().mockRejectedValue({
status: 409,
body: { reason: "version changed" },
}),
});
renderPage(<CreatorSettingsPage />, dataProvider);
expect(await screen.findByText("采集设置")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
expect(await screen.findByText("冲突(409):设置保存失败")).toBeTruthy();
});
it("saves settings successfully", async () => {
const dataProvider = provider({
creatorUpdate: vi.fn().mockResolvedValue({}),
});
renderPage(<CreatorSettingsPage />, dataProvider);
expect(await screen.findByText("采集设置")).toBeTruthy();
fireEvent.click(screen.getByLabelText("已完成 AI 配置审批"));
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
expect(await screen.findByText("采集与 AI 配置已保存。")).toBeTruthy();
});
it("does not render stale data using another tab's shape", async () => {
const dataProvider = provider({
getList: vi.fn(({ resource }) =>
@@ -183,21 +249,38 @@ describe("creator pages", () => {
it("requires preview confirmation before importing a competitor", async () => {
const dataProvider = provider();
renderPage(<CreatorCompetitorsPage />, dataProvider);
await waitFor(() =>
expect(dataProvider.getList).toHaveBeenCalledWith(
expect.objectContaining({ resource: "creator-accounts" }),
),
);
fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), {
target: { value: "https://www.douyin.com/user/sec-b" },
});
fireEvent.click(screen.getByRole("button", { name: "解析链接预览" }));
expect(await screen.findByText("导入预览")).toBeTruthy();
expect(dataProvider.create).not.toHaveBeenCalled();
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith(
expect.objectContaining({
resource: "creator-competitor-preview",
variables: expect.objectContaining({
account_id: "account-a",
platform: "douyin",
platform_account_key: "sec-b",
}),
}),
),
);
fireEvent.click(screen.getByRole("button", { name: "确认预览内容" }));
fireEvent.click(screen.getByRole("button", { name: "加入监测" }));
await waitFor(() => expect(dataProvider.create).toHaveBeenCalled());
expect(dataProvider.create).toHaveBeenCalledWith(
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledTimes(2));
expect(dataProvider.create).toHaveBeenLastCalledWith(
expect.objectContaining({
resource: "creator-competitors",
variables: expect.objectContaining({
platform: "douyin",
platform_account_key: "sec-b",
account_id: "account-a",
}),
}),
);
+9
View File
@@ -10,6 +10,7 @@ import {
PageHeader,
PageState,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
const editableFields = [
@@ -48,6 +49,7 @@ const defaults = {
export function CreatorSettingsPage() {
const dataProvider = useDataProvider()("default");
const [form, setForm] = useState(defaults);
const [savedForm, setSavedForm] = useState(defaults);
const [pending, setPending] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
@@ -59,6 +61,7 @@ export function CreatorSettingsPage() {
try {
const result = await dataProvider.creatorGet("/creator/settings");
setForm((value) => ({ ...value, ...editableSettings(result) }));
setSavedForm((value) => ({ ...value, ...editableSettings(result) }));
} catch (loadError) {
setError(loadError);
} finally {
@@ -96,6 +99,7 @@ export function CreatorSettingsPage() {
editableSettings(form),
);
setForm((value) => ({ ...value, ...editableSettings(result) }));
setSavedForm((value) => ({ ...value, ...editableSettings(result) }));
setNotice({ variant: "success", text: "采集与 AI 配置已保存。" });
} catch (saveError) {
setNotice({
@@ -107,6 +111,11 @@ export function CreatorSettingsPage() {
}
};
useUnsavedChanges(
JSON.stringify(editableSettings(form)) !==
JSON.stringify(editableSettings(savedForm)),
);
return (
<>
<PageHeader
+115 -21
View File
@@ -15,6 +15,7 @@ import {
Textarea,
dateTime,
conflictMessage,
useUnsavedChanges,
} from "./lib/ui.jsx";
const tabs = [
@@ -28,7 +29,9 @@ const tabs = [
function parseKeywords(value) {
if (!value.trim()) return [];
const values = value.split(",").map((item) => item.trim());
const values = value
.split(",")
.map((item) => item.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ""));
if (values.some((item) => !item)) throw new Error("关键词不能为空");
return values;
}
@@ -143,6 +146,7 @@ export function CreatorWorkbenchPage() {
ai_requirement: "",
});
const [editingRuleID, setEditingRuleID] = useState("");
const [replyMode, setReplyMode] = useState("comment");
const [reply, setReply] = useState({
account_id: "",
text: "",
@@ -158,6 +162,11 @@ export function CreatorWorkbenchPage() {
const visibleData = dataTab === tab ? data : [];
const visibleConversations =
conversationAccountID === dmAccountID ? conversations : [];
const replyTarget =
visibleData.find((item) => item.id === reply.target_comment_id) ||
visibleData.find((item) => item.comment?.id === reply.target_comment_id)
?.comment;
useUnsavedChanges(Boolean(reply.text.trim()));
const load = async () => {
const sequence = ++loadSequence.current;
@@ -494,7 +503,8 @@ export function CreatorWorkbenchPage() {
setBusy(false);
}
};
const requestReply = (comment) => {
const requestReply = (comment, mode = "comment") => {
setReplyMode(mode);
const draft = readDraft(replyDraftKey(comment.id));
setReply({
account_id: draft?.account_id || "",
@@ -505,7 +515,10 @@ export function CreatorWorkbenchPage() {
});
setNotice({
variant: "info",
text: "请填写发送账号和文案,再进行逐次确认。",
text:
mode === "dm"
? "请填写发送账号和私信文案,再进行逐次确认。"
: "请填写发送账号和文案,再进行逐次确认。",
});
};
const askReplyConfirm = (comment) => {
@@ -539,11 +552,13 @@ export function CreatorWorkbenchPage() {
const created = await dataProvider.creatorCreate("/creator/operations", {
idempotency_key: reply.operation_key,
source: "manual",
action: "reply_comment",
action: replyMode === "dm" ? "dm" : "reply_comment",
platform: confirm.comment.platform,
account_id: reply.account_id,
target_uid: reply.target_uid,
target_comment_id: reply.target_comment_id,
...(replyMode === "comment"
? { target_comment_id: reply.target_comment_id }
: {}),
text: reply.text,
});
const result = await dataProvider.creatorAction(
@@ -654,7 +669,7 @@ export function CreatorWorkbenchPage() {
emptyText="暂无一级评论。"
onRetry={load}
>
{reply.target_comment_id ? (
{reply.target_comment_id && replyTarget ? (
<Card className="mb-4">
<CardContent className="grid gap-3 p-4 md:grid-cols-[minmax(180px,0.6fr)_minmax(0,1.4fr)_auto]">
<Field id="reply-account" label="发送账号" required>
@@ -670,11 +685,7 @@ export function CreatorWorkbenchPage() {
}
options={accounts
.filter(
(account) =>
account.platform ===
visibleData.find(
(item) => item.id === reply.target_comment_id,
)?.platform,
(account) => account.platform === replyTarget.platform,
)
.map((account) => ({
value: account.id,
@@ -683,7 +694,11 @@ export function CreatorWorkbenchPage() {
placeholder="选择同平台账号"
/>
</Field>
<Field id="reply-text" label="人工回复文案" required>
<Field
id="reply-text"
label={replyMode === "dm" ? "人工私信文案" : "人工回复文案"}
required
>
<Input
id="reply-text"
value={reply.text}
@@ -699,13 +714,7 @@ export function CreatorWorkbenchPage() {
<div className="flex items-end">
<Button
variant="primary"
onClick={() =>
askReplyConfirm(
visibleData.find(
(item) => item.id === reply.target_comment_id,
),
)
}
onClick={() => askReplyConfirm(replyTarget)}
>
确认内容
</Button>
@@ -773,6 +782,13 @@ export function CreatorWorkbenchPage() {
<Button size="sm" onClick={() => requestReply(comment)}>
人工回复
</Button>
<Button
size="sm"
variant="outline"
onClick={() => requestReply(comment, "dm")}
>
人工私信
</Button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Select
@@ -812,6 +828,62 @@ export function CreatorWorkbenchPage() {
emptyText="暂无命中线索。"
onRetry={load}
>
{reply.target_comment_id && replyTarget ? (
<Card className="mb-4">
<CardContent className="grid gap-3 p-4 md:grid-cols-[minmax(180px,0.6fr)_minmax(0,1.4fr)_auto]">
<Field id="lead-reply-account" label="发送账号" required>
<Select
id="lead-reply-account"
value={reply.account_id}
onChange={(event) =>
setReply((value) => ({
...value,
account_id: event.target.value,
operation_key: "",
}))
}
options={accounts
.filter(
(account) => account.platform === replyTarget.platform,
)
.map((account) => ({
value: account.id,
label: account.name || account.id,
}))}
placeholder="选择同平台账号"
/>
</Field>
<Field
id="lead-reply-text"
label={replyMode === "dm" ? "人工私信文案" : "人工回复文案"}
required
>
<Input
id="lead-reply-text"
value={reply.text}
onChange={(event) =>
setReply((value) => ({
...value,
text: event.target.value,
operation_key: "",
}))
}
/>
</Field>
<div className="flex items-end">
<Button
variant="primary"
onClick={() => askReplyConfirm(replyTarget)}
>
确认内容
</Button>
</div>
<p className="text-xs text-muted md:col-span-2">
目标 UID:{reply.target_uid || "不可用,无法发送"}
</p>
</CardContent>
</Card>
) : null}
<div className="space-y-3">
{visibleData.map((lead) => (
<Card key={lead.comment.id}>
@@ -825,9 +897,16 @@ export function CreatorWorkbenchPage() {
<StatusPill tone="success" label="线索" />
<Button
size="sm"
onClick={() => requestReply(lead.comment)}
onClick={() => requestReply(lead.comment, "comment")}
>
人工联系
人工回复
</Button>
<Button
size="sm"
variant="outline"
onClick={() => requestReply(lead.comment, "dm")}
>
人工私信
</Button>
</div>
</div>
@@ -835,6 +914,21 @@ export function CreatorWorkbenchPage() {
<p className="mt-2 text-xs text-muted">
规则:{lead.rule_ids.join("、")}
</p>
<div className="mt-2 space-y-1 text-xs text-muted">
{lead.results?.length ? (
lead.results.map((result) => (
<div key={`${lead.comment.id}-${result.rule_id}`}>
<span className="font-medium">{result.status}</span>
{result.reason ? `:${result.reason}` : ""}
{result.matched_keywords?.length
? `(关键词:${result.matched_keywords.join("、")})`
: ""}
</div>
))
) : (
<span>尚未分析</span>
)}
</div>
</CardContent>
</Card>
))}
+142 -14
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
@@ -59,8 +59,8 @@ function ExitHealthPill({ exit }) {
);
}
function ExitActions({ exit, busy, onAction }) {
const disabled = busy || exit.health_status === "disabled";
function ExitActions({ exit, busy, onAction, onEdit, onDelete }) {
const disabled = busy;
return (
<div className="flex flex-wrap gap-2">
<Button
@@ -71,21 +71,65 @@ function ExitActions({ exit, busy, onAction }) {
>
检测
</Button>
{exit.health_status === "disabled" ? (
<Button
size="sm"
icon="ri-checkbox-circle-line"
disabled={disabled}
onClick={() => onAction(exit, "enable")}
>
启用
</Button>
) : (
<Button
size="sm"
variant="danger"
icon="ri-forbid-line"
disabled={disabled}
onClick={() => onAction(exit, "disable")}
>
停用
</Button>
)}
<Button
size="sm"
variant="danger"
icon="ri-forbid-line"
variant="outline"
disabled={disabled}
onClick={() => onAction(exit, "disable")}
onClick={() => onEdit(exit)}
>
停用
编辑
</Button>
<Button
size="sm"
variant="outline"
disabled={disabled}
onClick={() => onDelete(exit)}
>
删除
</Button>
</div>
);
}
function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState(createInitial);
function ExitCreateModal({
open,
onClose,
onSubmit,
busy,
error,
initialValue = createInitial,
title = "创建网络出口",
submitLabel = "创建网络出口",
}) {
const [form, setForm] = useState({ ...createInitial, ...initialValue });
useEffect(() => {
if (open)
setForm({
...createInitial,
...initialValue,
port: initialValue.port ? String(initialValue.port) : "",
});
}, [open, initialValue]);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const port = Number(form.port);
@@ -119,7 +163,7 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
<Modal
open={open}
onClose={onClose}
title="创建网络出口"
title={title}
labelledBy="exit-create-title"
size="lg"
footer={
@@ -135,7 +179,7 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
busyText="创建中…"
disabled={!valid}
>
创建网络出口
{submitLabel}
</Button>
</>
}
@@ -240,7 +284,15 @@ function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
);
}
function ExitTable({ exits, accountsByExit, bindingsError, busy, onAction }) {
function ExitTable({
exits,
accountsByExit,
bindingsError,
busy,
onAction,
onEdit,
onDelete,
}) {
const columns = [
{
header: "出口",
@@ -313,7 +365,13 @@ function ExitTable({ exits, accountsByExit, bindingsError, busy, onAction }) {
width: "8%",
align: "right",
render: (exit) => (
<ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} />
<ExitActions
exit={exit}
busy={busy === exit.id}
onAction={onAction}
onEdit={onEdit}
onDelete={onDelete}
/>
),
},
];
@@ -334,6 +392,8 @@ export function NetworkExitList() {
const [createError, setCreateError] = useState(null);
const [notice, setNotice] = useState(null);
const [disabling, setDisabling] = useState(null);
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const { result, query } = useList({ resource: "network-exits" });
const error = query.error;
const isPending = query.isPending;
@@ -377,6 +437,51 @@ export function NetworkExitList() {
}
}
async function updateExit(exit, data) {
setBusy(`edit:${exit.id}`);
setNotice(null);
try {
await dataProvider.update({
resource: "network-exits",
id: exit.id,
variables: data,
});
await query.refetch();
setEditing(null);
setNotice({
variant: "success",
text: "网络出口已更新,需重新检测后才能使用。",
});
return true;
} catch (reason) {
setNotice({
variant: "destructive",
text: conflictMessage(reason, "出口当前不能修改。"),
});
return false;
} finally {
setBusy("");
}
}
async function deleteExit(exit) {
setBusy(`delete:${exit.id}`);
setNotice(null);
try {
await dataProvider.delete({ resource: "network-exits", id: exit.id });
await query.refetch();
setDeleting(null);
setNotice({ variant: "success", text: "网络出口已删除。" });
} catch (reason) {
setNotice({
variant: "destructive",
text: conflictMessage(reason, "出口仍有绑定或运行实例,不能删除。"),
});
} finally {
setBusy("");
}
}
async function runAction(exit, action) {
setBusy(exit.id);
setNotice(null);
@@ -388,7 +493,9 @@ export function NetworkExitList() {
text:
action === "check"
? "出口检测完成。"
: "出口已停用;绑定账号不会自动恢复。",
: action === "enable"
? "出口已启用;重新检测通过后才能绑定。"
: "出口已停用;绑定账号不会自动恢复。",
});
} catch (reason) {
setNotice({
@@ -451,6 +558,8 @@ export function NetworkExitList() {
onAction={(exit, action) =>
action === "disable" ? setDisabling(exit) : runAction(exit, action)
}
onEdit={(exit) => setEditing(exit)}
onDelete={(exit) => setDeleting(exit)}
/>
</PageState>
<ExitCreateModal
@@ -463,6 +572,25 @@ export function NetworkExitList() {
busy={busy === "create"}
error={createError}
/>
<ExitCreateModal
open={editing !== null}
onClose={() => setEditing(null)}
onSubmit={(data) => updateExit(editing, data)}
busy={busy === `edit:${editing?.id}`}
error={null}
initialValue={editing || createInitial}
title="编辑网络出口"
submitLabel="保存网络出口"
/>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={() => deleteExit(deleting)}
title="删除网络出口"
confirmLabel="确认删除"
busy={busy === `delete:${deleting?.id}`}
body={`删除网络出口 ${deleting?.id}?有绑定或运行实例时会拒绝。`}
/>
<ConfirmDialog
open={disabling !== null}
onClose={() => setDisabling(null)}
+16 -12
View File
@@ -57,6 +57,7 @@ const resourcePaths = {
"network-exits": "/network-exits",
"creator-accounts": "/creator/accounts",
"creator-competitors": "/creator/competitors",
"creator-competitor-preview": "/creator/competitors/preview",
"creator-works": "/creator/works",
"creator-comments": "/creator/comments",
"creator-leads": "/creator/leads",
@@ -107,10 +108,7 @@ export const dataProvider = {
data: data.map((record, index) => withID(record, index)),
total: records.total ?? data.length,
};
if (
!Array.isArray(records) &&
Object.prototype.hasOwnProperty.call(records, "has_next")
) {
if (!Array.isArray(records) && Object.hasOwn(records, "has_next")) {
result.hasNext = Boolean(records.has_next);
}
return result;
@@ -230,7 +228,7 @@ export const dataProvider = {
);
},
async networkExitAction(id, action) {
if (action !== "check" && action !== "disable")
if (!["check", "disable", "enable"].includes(action))
throw new Error(`未知网络出口操作: ${action}`);
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, {
method: "POST",
@@ -275,17 +273,23 @@ export const dataProvider = {
onStatus?.("connected");
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (frame) => {
if (frame.split(/\r\n|\n|\r/).some((line) => line.startsWith("data:")))
onMessage(frame);
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
let boundary;
while ((boundary = buffer.indexOf("\\n\\n")) >= 0) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (frame.split(/\\r?\\n/).some((line) => line.startsWith("data:")))
onMessage(frame);
let match;
while ((match = buffer.match(/\r\n\r\n|\n\n|\r\r/))) {
dispatch(buffer.slice(0, match.index));
buffer = buffer.slice(match.index + match[0].length);
}
if (done) {
if (buffer.trim()) dispatch(buffer);
onStatus?.("disconnected");
return;
}
if (done) return;
}
},
creatorUpdate(path, data) {
+304 -138
View File
@@ -1,173 +1,339 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { dataProvider } from './dataProvider'
import { afterEach, describe, expect, it, vi } from "vitest";
import { dataProvider } from "./dataProvider";
afterEach(() => vi.unstubAllGlobals())
afterEach(() => vi.unstubAllGlobals());
const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
describe("dataProvider", () => {
it("maps the existing browser list into refine records", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify([{ name: "account-a", state: "running" }]),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
describe('dataProvider', () => {
it('maps the existing browser list into refine records', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ name: 'account-a', state: 'running' }]), { status: 200 }))
vi.stubGlobal('fetch', fetch)
await expect(dataProvider.getList({ resource: 'browsers' })).resolves.toEqual({
data: [{ id: 'account-a', name: 'account-a', state: 'running' }],
await expect(
dataProvider.getList({ resource: "browsers" }),
).resolves.toEqual({
data: [{ id: "account-a", name: "account-a", state: "running" }],
total: 1,
})
expect(fetch).toHaveBeenCalledWith('/api/browsers', { headers: {} })
})
});
expect(fetch).toHaveBeenCalledWith("/api/browsers", { headers: {} });
});
it('attaches Basic credentials from localStorage', async () => {
localStorage.setItem('creatorhub.auth', 'user:pass')
const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
it("attaches Basic credentials from localStorage", async () => {
localStorage.setItem("creatorhub.auth", "user:pass");
const fetch = vi
.fn()
.mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.getList({ resource: 'browsers' })
expect(fetch).toHaveBeenCalledWith('/api/browsers', { headers: { Authorization: 'Basic dXNlcjpwYXNz' } })
localStorage.removeItem('creatorhub.auth')
})
await dataProvider.getList({ resource: "browsers" });
expect(fetch).toHaveBeenCalledWith("/api/browsers", {
headers: { Authorization: "Basic dXNlcjpwYXNz" },
});
localStorage.removeItem("creatorhub.auth");
});
it('clears credentials and redirects to login on 401', async () => {
localStorage.setItem('creatorhub.auth', 'user:pass')
const fetch = vi.fn().mockResolvedValue(new Response('', { status: 401 }))
vi.stubGlobal('fetch', fetch)
it("clears credentials and redirects to login on 401", async () => {
localStorage.setItem("creatorhub.auth", "user:pass");
const fetch = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getList({ resource: 'browsers' })).rejects.toMatchObject({ status: 401 })
expect(localStorage.getItem('creatorhub.auth')).toBeNull()
expect(location.hash).toBe('#/login')
})
await expect(
dataProvider.getList({ resource: "browsers" }),
).rejects.toMatchObject({ status: 401 });
expect(localStorage.getItem("creatorhub.auth")).toBeNull();
expect(location.hash).toBe("#/login");
});
it.each([
['accounts', '/api/phase-a/accounts'],
['network-exits', '/api/network-exits'],
])('loads %s for the environment create contract', async (resource, path) => {
const fetch = vi.fn().mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
["accounts", "/api/phase-a/accounts"],
["network-exits", "/api/network-exits"],
])("loads %s for the environment create contract", async (resource, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response('[{"id":"record-1"}]', { status: 200 }));
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getList({ resource })).resolves.toMatchObject({ data: [{ id: 'record-1' }] })
expect(fetch).toHaveBeenCalledWith(path, { headers: {} })
})
await expect(dataProvider.getList({ resource })).resolves.toMatchObject({
data: [{ id: "record-1" }],
});
expect(fetch).toHaveBeenCalledWith(path, { headers: {} });
});
it.each([
['accounts', 'account-a', '/api/phase-a/accounts/account-a'],
['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'],
['browsers', 'environment-one', '/api/browsers/environment-one'],
['drafts', 'draft/one', '/api/phase-a/drafts/draft%2Fone'],
['confirmations', 'confirmation/one', '/api/phase-a/confirmations/confirmation%2Fone'],
['tasks', 'task/one', '/api/phase-a/tasks/task%2Fone'],
['attempts', 'attempt/one', '/api/phase-a/attempts/attempt%2Fone'],
])('loads %s detail through its stable API path', async (resource, id, path) => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }))
vi.stubGlobal('fetch', fetch)
["accounts", "account-a", "/api/phase-a/accounts/account-a"],
["network-exits", "exit/one", "/api/network-exits/exit%2Fone"],
["browsers", "environment-one", "/api/browsers/environment-one"],
["drafts", "draft/one", "/api/phase-a/drafts/draft%2Fone"],
[
"confirmations",
"confirmation/one",
"/api/phase-a/confirmations/confirmation%2Fone",
],
["tasks", "task/one", "/api/phase-a/tasks/task%2Fone"],
["attempts", "attempt/one", "/api/phase-a/attempts/attempt%2Fone"],
])("loads %s detail through its stable API path", async (resource, id, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }));
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getOne({ resource, id })).resolves.toEqual({ data: { id } })
expect(fetch).toHaveBeenCalledWith(path, { headers: {} })
})
await expect(dataProvider.getOne({ resource, id })).resolves.toEqual({
data: { id },
});
expect(fetch).toHaveBeenCalledWith(path, { headers: {} });
});
it('creates an account without a client-generated technical id', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('{"id":"account-generated"}', { status: 201 }))
vi.stubGlobal('fetch', fetch)
const data = { name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: ['主账号'], cookies: 'sessionid=value' }
it("creates an account without a client-generated technical id", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response('{"id":"account-generated"}', { status: 201 }),
);
vi.stubGlobal("fetch", fetch);
const data = {
name: "店铺一号",
platform: "douyin",
platform_account_key: "shop-a",
tags: ["主账号"],
cookies: "sessionid=value",
};
await expect(dataProvider.create({ resource: 'accounts', variables: data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } })
expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' }))
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(data)
})
await expect(
dataProvider.create({ resource: "accounts", variables: data }),
).resolves.toEqual({ data: { ...data, id: "account-generated" } });
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/accounts",
expect.objectContaining({ method: "POST" }),
);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(data);
});
it('filters drafts by account through the server list contract', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
it("filters drafts by account through the server list contract", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response("[]", { status: 200 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.getList({ resource: 'drafts', filters: [{ field: 'account_id', value: 'account-a' }] })
expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', { headers: {} })
})
await dataProvider.getList({
resource: "drafts",
filters: [{ field: "account_id", value: "account-a" }],
});
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/drafts?account_id=account-a",
{ headers: {} },
);
});
it('maps paginated audit filters without exposing a CRUD mutation', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('{"data":[{"id":7,"event_type":"task_verified"}],"total":31}', { status: 200 }))
vi.stubGlobal('fetch', fetch)
it("maps paginated audit filters without exposing a CRUD mutation", async () => {
const fetch = vi
.fn()
.mockResolvedValue(
new Response(
'{"data":[{"id":7,"event_type":"task_verified"}],"total":31}',
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetch);
await expect(dataProvider.getList({ resource: 'audit',
pagination: { currentPage: 2, pageSize: 25 },
filters: [
{ field: 'task_id', value: 'task-a' }, { field: 'attempt_id', value: 'attempt-a' },
{ field: 'browser_env_alias', value: 'env-a' }, { field: 'network_exit_id', value: 'exit-a' },
await expect(
dataProvider.getList({
resource: "audit",
pagination: { currentPage: 2, pageSize: 25 },
filters: [
{ field: "task_id", value: "task-a" },
{ field: "attempt_id", value: "attempt-a" },
{ field: "browser_env_alias", value: "env-a" },
{ field: "network_exit_id", value: "exit-a" },
],
}),
).resolves.toEqual({
data: [{ id: 7, event_type: "task_verified" }],
total: 31,
});
expect(fetch).toHaveBeenCalledWith(
"/api/phase-a/audit?task_id=task-a&attempt_id=attempt-a&browser_env_alias=env-a&network_exit_id=exit-a&page=2&page_size=25",
{ headers: {} },
);
});
it("keeps generated draft, confirmation and enqueue identifiers out of user input", async () => {
const fetch = vi
.fn()
.mockImplementation(() =>
Promise.resolve(new Response('{"id":"generated"}', { status: 201 })),
);
vi.stubGlobal("fetch", fetch);
await dataProvider.createDraft("account-a", "hello");
await dataProvider.confirmDraft("draft-a", 2, 3);
await dataProvider.enqueueConfirmation("confirmation-a");
expect(
fetch.mock.calls.map(([path, options]) => [
path,
JSON.parse(options.body),
]),
).toEqual([
["/api/phase-a/drafts", { account_id: "account-a", content: "hello" }],
[
"/api/phase-a/confirmations",
{ draft_id: "draft-a", account_version: 2, draft_version: 3 },
],
})).resolves.toEqual({
data: [{ id: 7, event_type: 'task_verified' }], total: 31,
})
expect(fetch).toHaveBeenCalledWith('/api/phase-a/audit?task_id=task-a&attempt_id=attempt-a&browser_env_alias=env-a&network_exit_id=exit-a&page=2&page_size=25', { headers: {} })
})
it('keeps generated draft, confirmation and enqueue identifiers out of user input', async () => {
const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('{"id":"generated"}', { status: 201 })))
vi.stubGlobal('fetch', fetch)
await dataProvider.createDraft('account-a', 'hello')
await dataProvider.confirmDraft('draft-a', 2, 3)
await dataProvider.enqueueConfirmation('confirmation-a')
expect(fetch.mock.calls.map(([path, options]) => [path, JSON.parse(options.body)])).toEqual([
['/api/phase-a/drafts', { account_id: 'account-a', content: 'hello' }],
['/api/phase-a/confirmations', { draft_id: 'draft-a', account_version: 2, draft_version: 3 }],
['/api/phase-a/tasks', { confirmation_id: 'confirmation-a' }],
])
})
["/api/phase-a/tasks", { confirmation_id: "confirmation-a" }],
]);
});
it.each([
['start', '/api/browsers/account-a/start', 'POST'],
['stop', '/api/browsers/account-a/stop', 'POST'],
['recycle', '/api/browsers/account-a', 'DELETE'],
])('keeps %s as an explicit domain action', async (action, path, method) => {
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetch)
["start", "/api/browsers/account-a/start", "POST"],
["stop", "/api/browsers/account-a/stop", "POST"],
["recycle", "/api/browsers/account-a", "DELETE"],
])("keeps %s as an explicit domain action", async (action, path, method) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.browserAction('account-a', action)
expect(fetch).toHaveBeenCalledWith(path, expect.objectContaining({ method }))
})
await dataProvider.browserAction("account-a", action);
expect(fetch).toHaveBeenCalledWith(
path,
expect.objectContaining({ method }),
);
});
it('sends upgrade with the version payload', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetch)
it("sends upgrade with the version payload", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.browserAction('account-a', 'upgrade', { version: '144.0.0.1' })
expect(fetch).toHaveBeenCalledWith('/api/browsers/account-a/upgrade', expect.objectContaining({ method: 'POST', body: '{"version":"144.0.0.1"}' }))
})
await dataProvider.browserAction("account-a", "upgrade", {
version: "144.0.0.1",
});
expect(fetch).toHaveBeenCalledWith(
"/api/browsers/account-a/upgrade",
expect.objectContaining({
method: "POST",
body: '{"version":"144.0.0.1"}',
}),
);
});
it('preserves API error messages and status codes', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: '网关不可用' }), { status: 502 })))
it("preserves API error messages and status codes", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: "网关不可用" }), {
status: 502,
}),
),
);
await expect(dataProvider.getList({ resource: 'browsers' })).rejects.toMatchObject({ message: '网关不可用', status: 502 })
})
it.each([409, 503])('preserves structured action errors for status %s', async status => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'blocked', reason_code: 'network_exit_unhealthy' }), { status })))
await expect(dataProvider.accountAction('account-a', 'resume')).rejects.toMatchObject({
message: 'blocked', status, body: { error: 'blocked', reason_code: 'network_exit_unhealthy' },
})
})
await expect(
dataProvider.getList({ resource: "browsers" }),
).rejects.toMatchObject({ message: "网关不可用", status: 502 });
});
it.each([
['pause', '/api/phase-a/accounts/account-a/pause'],
['resume', '/api/phase-a/accounts/account-a/resume'],
])('sends explicit account action %s', async (action, path) => {
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetch)
409, 503,
])("preserves structured action errors for status %s", async (status) => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({
error: "blocked",
reason_code: "network_exit_unhealthy",
}),
{ status },
),
),
);
await dataProvider.accountAction('account-a', action)
expect(fetch).toHaveBeenCalledWith(path, { method: 'POST', headers: {} })
})
await expect(
dataProvider.accountAction("account-a", "resume"),
).rejects.toMatchObject({
message: "blocked",
status,
body: { error: "blocked", reason_code: "network_exit_unhealthy" },
});
});
it('keeps verification and recovery as separate task actions', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
vi.stubGlobal('fetch', fetch)
it.each([
["pause", "/api/phase-a/accounts/account-a/pause"],
["resume", "/api/phase-a/accounts/account-a/resume"],
])("sends explicit account action %s", async (action, path) => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.taskAction('task/a', 'verify', { result: 'not_executed' })
await dataProvider.taskAction('task/a', 'resume')
await dataProvider.accountAction("account-a", action);
expect(fetch).toHaveBeenCalledWith(path, { method: "POST", headers: {} });
});
it("keeps verification and recovery as separate task actions", async () => {
const fetch = vi
.fn()
.mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetch);
await dataProvider.taskAction("task/a", "verify", {
result: "not_executed",
});
await dataProvider.taskAction("task/a", "resume");
expect(fetch.mock.calls).toEqual([
['/api/phase-a/tasks/task%2Fa/verify', expect.objectContaining({ method: 'POST', body: '{"result":"not_executed"}' })],
['/api/phase-a/tasks/task%2Fa/resume', { method: 'POST', headers: {} }],
])
})
})
[
"/api/phase-a/tasks/task%2Fa/verify",
expect.objectContaining({
method: "POST",
body: '{"result":"not_executed"}',
}),
],
["/api/phase-a/tasks/task%2Fa/resume", { method: "POST", headers: {} }],
]);
});
});
describe("creatorSubscribe SSE framing", () => {
it("keeps split UTF-8 messages and accepts CRLF framing", async () => {
const encoder = new TextEncoder();
const chunks = [
'data: {"text":"北',
'京"}\r\n\r',
'\ndata: {"text":"ok"}\n\n',
];
const body = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
});
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve({ status: 200, ok: true, body })),
);
const messages = [];
const statuses = [];
await dataProvider.creatorSubscribe(
"/creator/updates",
(frame) => messages.push(frame),
new AbortController().signal,
(status) => statuses.push(status),
);
expect(messages).toEqual(['data: {"text":"北京"}', 'data: {"text":"ok"}']);
expect(statuses).toEqual(["connected", "disconnected"]);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { conflictError, displayError, useTitle } from "./hooks";
describe("hooks helpers", () => {
afterEach(() => {
document.title = "";
});
it("returns backend messages and fallback text", () => {
expect(displayError({ message: "backend failed" })).toBe("backend failed");
expect(displayError({})).toBe("操作失败");
});
it("formats conflict and unavailable errors", () => {
expect(
conflictError(
{ status: 409, body: { reason: "version changed" } },
"retry",
),
).toBe("冲突(409):version changed");
expect(conflictError({ status: 503, message: "offline" }, "retry")).toBe(
"资源未就绪(503):offline",
);
expect(conflictError({ status: 400, message: "bad input" }, "retry")).toBe(
"bad input",
);
expect(conflictError({ status: 409, body: {} }, "retry")).toBe(
"冲突(409):retry",
);
});
it("updates the document title", () => {
function TitleProbe() {
useTitle("CreatorHub 测试");
return null;
}
render(<TitleProbe />);
expect(document.title).toBe("CreatorHub 测试");
});
});
+23 -1
View File
@@ -20,6 +20,29 @@ export function conflictMessage(error, fallback) {
export const dateTime = (value) => new Date(value).toLocaleString("zh-CN");
export function useUnsavedChanges(dirty) {
useEffect(() => {
if (!dirty) return undefined;
const beforeUnload = (event) => {
event.preventDefault();
event.returnValue = "";
};
const click = (event) => {
const anchor = event.target.closest?.("a[href]");
if (!anchor || !anchor.getAttribute("href")?.startsWith("#/")) return;
if (window.confirm("当前内容尚未保存,确定离开吗?")) return;
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
return () => {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
};
}, [dirty]);
}
/**
* 页面加载/错误/空态的统一外壳。
*/
@@ -186,7 +209,6 @@ export function Field({
className,
}) {
const helpId = helper || error ? `${id}-help` : undefined;
const describedBy = error ? `${id}-error` : helpId;
const control = children;
return (
<div className={cn("min-w-0 space-y-1.5", className)}>
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
isolate: false, // ponytail: one worker avoids 90s startup stalls; restore isolation if tests leak state.
coverage: {
provider: "v8",
reporter: ["text"],
reporter: ["text", "json-summary", "json"],
thresholds: { lines: 65 },
},
},