fix: complete Douyin release remediation
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user