feat: split account management

This commit is contained in:
2026-09-21 16:44:09 +08:00
parent 86a332997e
commit 4f736cba21
25 changed files with 1246 additions and 630 deletions
+7 -3
View File
@@ -23,7 +23,9 @@ const menu = [
{
section: '运营',
items: [
{ to: '/accounts', icon: 'ri-account-circle-line', label: '账号管理' },
{ to: '/accounts/import', icon: 'ri-upload-cloud-2-line', label: '账号导入' },
{ to: '/accounts', icon: 'ri-account-circle-line', label: '我的账号' },
{ to: '/accounts/monitoring', icon: 'ri-eye-line', label: '监控账号' },
{ to: '/creator/competitors', icon: 'ri-line-chart-line', label: '竞品分析' },
{ to: '/creator/workbench', icon: 'ri-chat-3-line', label: '运营工作台' },
{ to: '/creator/settings', icon: 'ri-settings-3-line', label: '采集设置' },
@@ -113,11 +115,13 @@ function AppSidebar({ pathname }) {
}
const pageMetadataRules = [
{ test: (path) => path === '/accounts', title: '账号管理', subtitle: '统一管理自有账号与监测账号,按账号类型筛选。' },
{ test: (path) => path === '/accounts/import', title: '账号导入', subtitle: '管理分享链接解析任务,解析成功后自动加入监控账号。' },
{ test: (path) => path === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
{ test: (path) => path === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
{ test: (path) => path === '/accounts/new', title: '创建社媒账号', subtitle: '创建账号后,再在编辑页配置登录身份与账号策略。' },
{ test: (path) => path.endsWith('/edit') && path.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (path) => path.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
{ test: (path) => path === '/creator/competitors', title: '竞品分析', subtitle: '粘贴作品分享内容,自动识别作者并加入监听队列。' },
{ test: (path) => path === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
{ test: (path) => path === '/creator/workbench', title: '运营工作台', subtitle: '评论、线索、私信与写操作均保留来源和明确结果。' },
{ test: (path) => path === '/creator/settings', title: '采集设置', subtitle: '统一配置采集窗口、指标采集和已批准的服务。' },
{ test: (path) => path === '/competitors' || path.startsWith('/competitors/'), title: '竞品分析', subtitle: '粘贴作品分享内容,自动识别作者并加入监听队列。' },
+13 -12
View File
@@ -18,7 +18,7 @@ afterEach(() => {
});
describe("CreatorHubLayout", () => {
it("exposes one merged account-management menu item", () => {
it("exposes three account-management menu items", () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({ matches: true }),
@@ -33,17 +33,18 @@ describe("CreatorHubLayout", () => {
</MemoryRouter>,
);
expect(
screen.getByRole("link", { name: "账号管理" }).getAttribute("href"),
).toBe("/accounts");
expect(screen.queryByRole("link", { name: "竞品账号" })).toBeNull();
expect(screen.queryByRole("link", { name: "账号策略" })).toBeNull();
expect(screen.queryByRole("link", { name: "浏览器版本" })).toBeNull();
expect(screen.getByRole("heading", { name: "账号管理" })).toBeTruthy();
expect(
screen.getByText("统一管理自有账号与监测账号,按账号类型筛选。"),
).toBeTruthy();
expect(screen.getByRole("link", { name: "账号管理" }).querySelector("i"))
expect(screen.getByRole("link", { name: "账号导入" }).getAttribute("href")).toBe(
"/accounts/import",
);
expect(screen.getByRole("link", { name: "我的账号" }).getAttribute("href")).toBe(
"/accounts",
);
expect(screen.getByRole("link", { name: "监控账号" }).getAttribute("href")).toBe(
"/accounts/monitoring",
);
expect(screen.getByRole("heading", { name: "我的账号" })).toBeTruthy();
expect(screen.getByText("创建和管理自己维护的账号。")).toBeTruthy();
expect(screen.getByRole("link", { name: "我的账号" }).querySelector("i"))
.not.toBeNull();
});
@@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import { useDataProvider } from "../../shared/hooks/dataHooks.js";
import { Alert } from "../../components/ui/alert";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { Field, PageHeader, PageState, StatusPill, TagInput, conflictMessage, dateTime } from "../../shared/ui/ui.jsx";
import { useUnsavedChanges } from "../../shared/hooks/hooks.js";
const platformOptions = [
{ value: "douyin", label: "抖音" },
{ value: "xiaohongshu", label: "小红书" },
];
const shareJobStatus = {
queued: { label: "待解析", tone: "warning" },
processing: { label: "解析中", tone: "warning" },
succeeded: { label: "已加入监控", tone: "success" },
failed: { label: "解析失败", tone: "danger" },
};
const platformLabel = (value) =>
platformOptions.find((option) => option.value === value)?.label || value || "—";
const shareURLPattern = /https?:\/\/[^\s<>"'`]+/giu;
const trailingShareURLPunctuation = /[,。;!?、)》)】\]}>,.?!:;]+$/u;
export function extractShareURL(value) {
const candidates = String(value || "")
.match(shareURLPattern)
?.map((candidate) => candidate.replace(trailingShareURLPunctuation, ""))
.filter(Boolean);
return candidates?.find((candidate) => platformForShareURL(candidate)) || candidates?.[0] || "";
}
export function platformForShareURL(value) {
try {
const hostname = new URL(value).hostname.toLowerCase();
if (["www.douyin.com", "v.douyin.com"].includes(hostname)) {
return "douyin";
}
if (["www.xiaohongshu.com", "xhslink.com", "www.xhslink.com"].includes(hostname)) {
return "xiaohongshu";
}
} catch {
return "";
}
return "";
}
export function AccountImportPage() {
const dataProvider = useDataProvider()("default");
const [createOpen, setCreateOpen] = useState(false);
const [shareText, setShareText] = useState("");
const [shareTags, setShareTags] = useState([]);
const [shareJobs, setShareJobs] = useState([]);
const [shareJobsPending, setShareJobsPending] = useState(true);
const [shareJobsError, setShareJobsError] = useState(null);
const [shareJobActionID, setShareJobActionID] = useState(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(null);
const loadShareJobs = async () => {
setShareJobsPending(true);
setShareJobsError(null);
try {
const result = await dataProvider.getList({
resource: "creator-competitor-share-jobs",
filters: [],
});
setShareJobs(result.data ?? []);
} catch (loadError) {
setShareJobsError(loadError);
} finally {
setShareJobsPending(false);
}
};
useEffect(() => {
loadShareJobs();
}, [dataProvider]);
const extractedShareURL = extractShareURL(shareText);
const sharePlatform = platformForShareURL(extractedShareURL);
const closeCreate = () => {
if (busy) return;
setCreateOpen(false);
setShareText("");
setShareTags([]);
setNotice(null);
};
const create = async () => {
if (!extractedShareURL) {
setNotice({ variant: "default", text: "请粘贴包含分享链接的内容。" });
return;
}
if (!sharePlatform) {
setNotice({ variant: "default", text: "链接不是支持的平台分享链接。" });
return;
}
setBusy(true);
setNotice(null);
try {
const variables = {
platform: sharePlatform,
share_url: extractedShareURL,
};
if (shareTags.length) variables.tags = shareTags;
await dataProvider.create({
resource: "creator-competitor-share-jobs",
variables,
});
await loadShareJobs();
setCreateOpen(false);
setShareText("");
setShareTags([]);
setNotice({ variant: "default", text: "分享链接已加入作者解析队列。" });
} catch (createError) {
setNotice({
variant: "destructive",
text: conflictMessage(createError, "分享链接入队失败"),
});
} finally {
setBusy(false);
}
};
const retryShareJob = async (jobID) => {
setShareJobActionID(jobID);
setNotice(null);
try {
await dataProvider.creatorAction(
`/creator/competitor-share-jobs/${encodeURIComponent(jobID)}/retry`,
);
await loadShareJobs();
setNotice({ variant: "default", text: "任务已重新加入解析队列。" });
} catch (retryError) {
setNotice({
variant: "destructive",
text: conflictMessage(retryError, "任务重新入队失败"),
});
} finally {
setShareJobActionID(null);
}
};
useUnsavedChanges(Boolean(shareText || shareTags.length));
return (
<>
<PageHeader
icon="ri-upload-cloud-2-line"
title="账号导入"
description="管理分享链接解析任务,解析成功后自动加入监控账号。"
>
<Button
variant="default"
onClick={() => {
setNotice(null);
setCreateOpen(true);
}}
>
<i className="ri-add-line" aria-hidden="true" />
新建导入任务
</Button>
</PageHeader>
{notice && !createOpen ? (
<Alert variant={notice.variant === "destructive" ? "destructive" : "default"} className="mb-5">
{notice.text}
</Alert>
) : null}
<Dialog open={createOpen} onOpenChange={(nextOpen) => { if (!nextOpen) closeCreate(); }}>
<DialogContent>
<DialogHeader><DialogTitle>创建账号导入任务</DialogTitle></DialogHeader>
<div className="space-y-4">
{notice && createOpen ? <Alert variant={notice.variant === "destructive" ? "destructive" : "default"}>{notice.text}</Alert> : null}
<Field
id="account-import-share-content"
label="分享内容或链接"
required
helper="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。"
>
<Textarea
id="account-import-share-content"
value={shareText}
onChange={(event) => {
setShareText(event.target.value);
setNotice(null);
}}
placeholder="粘贴抖音或小红书的分享内容"
required
/>
</Field>
{shareText ? (
extractedShareURL ? (
<p className="break-all text-xs text-muted-foreground">
已提取链接:{extractedShareURL}
{sharePlatform
? ` · ${sharePlatform === "douyin" ? "抖音" : "小红书"}`
: " · 待识别平台"}
</p>
) : (
<p className="text-xs text-destructive">未找到可用的 http(s) 分享链接。</p>
)
) : null}
<Field
id="account-import-tags"
label="TAGS"
helper="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。"
>
<TagInput
id="account-import-tags"
value={shareTags}
onChange={setShareTags}
placeholder="如:重点监控"
/>
</Field>
<p className="text-xs text-muted-foreground">
提交后立即进入独立解析任务列表,后台最多自动尝试 3 次;失败后可查看原因并重新入队。
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeCreate} disabled={busy}>取消</Button>
<Button type="button" variant="default" disabled={!extractedShareURL || !sharePlatform || busy} onClick={create}>
{busy ? "加入中…" : "加入解析队列"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card>
<CardContent>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold">分享链接解析任务</h2>
<p className="mt-1 text-sm text-muted-foreground">
待处理和失败的分享链接独立展示;成功后才会进入竞品作者监控。
</p>
</div>
<Button size="sm" variant="outline" onClick={loadShareJobs} disabled={shareJobsPending}>刷新</Button>
</div>
<PageState
pending={shareJobsPending}
error={shareJobsError}
empty={!shareJobs.length}
emptyText="暂无分享链接解析任务。"
onRetry={loadShareJobs}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>分享链接</TableHead>
<TableHead>平台</TableHead>
<TableHead>状态</TableHead>
<TableHead>尝试次数</TableHead>
<TableHead>失败原因</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shareJobs.map((job) => {
const status = shareJobStatus[job.status] || { label: job.status || "未知", tone: "neutral" };
return (
<TableRow key={job.id}>
<TableCell className="max-w-[280px]">
<a className="block truncate text-primary hover:underline" href={job.share_url} target="_blank" rel="noreferrer">
{job.share_url}
</a>
<p className="mt-1 text-xs text-muted-foreground">{dateTime(job.created_at)}</p>
</TableCell>
<TableCell>{platformLabel(job.platform)}</TableCell>
<TableCell><StatusPill tone={status.tone} label={status.label} /></TableCell>
<TableCell className="text-xs text-muted-foreground">{job.attempts} / 3</TableCell>
<TableCell className="max-w-[320px] text-xs text-destructive">{job.failure_reason || "—"}</TableCell>
<TableCell className="text-right">
{job.status === "failed" ? (
<Button size="sm" variant="outline" onClick={() => retryShareJob(job.id)} disabled={shareJobActionID === job.id}>
{shareJobActionID === job.id ? "入队中…" : "重新入队"}
</Button>
) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</PageState>
</CardContent>
</Card>
</>
);
}
+58 -63
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "../../shared/router.jsx";
import { useDataProvider, useList, useOne } from "../../shared/hooks/dataHooks.js";
import { Alert } from "../../components/ui/alert";
@@ -302,11 +302,11 @@ function AccountTags({ tags }) {
);
}
export function AccountList() {
function AccountManagementList({ mode }) {
const dataProvider = useDataProvider()("default");
const isMonitoring = mode === "monitoring";
const [accounts, setAccounts] = useState([]);
const [competitors, setCompetitors] = useState([]);
const [sourceFilter, setSourceFilter] = useState("");
const [pending, setPending] = useState(true);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
@@ -315,18 +315,19 @@ export function AccountList() {
const [tagDraft, setTagDraft] = useState([]);
const [tagBusy, setTagBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState("");
useTitle("CreatorHub · 账号管理");
useTitle(`CreatorHub · ${isMonitoring ? "监控账号" : "我的账号"}`);
const load = async () => {
setPending(true);
setError(null);
try {
const [accountResult, competitorResult] = await Promise.all([
dataProvider.getList({ resource: "accounts" }),
dataProvider.getList({ resource: "creator-competitors" }),
]);
setAccounts(accountResult.data ?? []);
setCompetitors(competitorResult.data ?? []);
const resource = isMonitoring ? "creator-competitors" : "accounts";
const result = await dataProvider.getList({ resource });
if (isMonitoring) {
setCompetitors(result.data ?? []);
} else {
setAccounts(result.data ?? []);
}
} catch (loadError) {
setError(loadError);
} finally {
@@ -336,27 +337,24 @@ export function AccountList() {
useEffect(() => {
load();
}, [dataProvider]);
}, [dataProvider, isMonitoring]);
const rows = useMemo(
() => [
...accounts.map((account) => ({
...account,
source_type: "owned",
tags: account.tags ?? [],
})),
...competitors.map((account) => ({
const rows = isMonitoring
? competitors.map((account) => ({
...account,
name: account.nickname || account.platform_account_key,
source_type: "monitoring",
tags: account.tags ?? [],
})),
],
[accounts, competitors],
);
const visibleRows = sourceFilter
? rows.filter((account) => account.source_type === sourceFilter)
: rows;
}))
: accounts.map((account) => ({
...account,
source_type: "owned",
tags: account.tags ?? [],
}));
const title = isMonitoring ? "监控账号" : "我的账号";
const description = isMonitoring
? "管理需要持续跟踪的竞品账号及采集状态。"
: "创建和管理自己维护的账号。";
async function syncCompetitor(competitor) {
setActionBusy(competitor.id);
@@ -370,7 +368,7 @@ export function AccountList() {
);
if (!account) {
throw new Error(
`没有可用于同步${platformLabel(competitor.platform)}监测账号的已登录自有账号。`,
`没有可用于同步${platformLabel(competitor.platform)}监控账号的已登录自有账号。`,
);
}
await dataProvider.creatorAction(
@@ -486,19 +484,25 @@ export function AccountList() {
return (
<div>
<PageHeader
icon="ri-account-circle-line"
title="账号管理"
description="统一管理自有账号与监测账号,按账号类型筛选。"
icon={isMonitoring ? "ri-eye-line" : "ri-account-circle-line"}
title={title}
description={description}
>
<Button asChild>
<Link to="/creator/competitors">添加监测账号</Link>
</Button>
<Button asChild variant="default">
<Link to="/accounts/new">
<i className="ri-add-line" aria-hidden="true" />
创建自有账号
</Link>
</Button>
{isMonitoring ? (
<Button asChild>
<Link to="/accounts/import">
<i className="ri-upload-cloud-2-line" aria-hidden="true" />
导入竞品账号
</Link>
</Button>
) : (
<Button asChild variant="default">
<Link to="/accounts/new">
<i className="ri-add-line" aria-hidden="true" />
创建账号
</Link>
</Button>
)}
</PageHeader>
{notice ? (
<Alert variant={notice.variant === "destructive" ? "destructive" : "default"} className="mb-4">
@@ -507,36 +511,22 @@ export function AccountList() {
) : null}
<Card>
<CardContent>
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<Field id="account-source-filter" label="账号类型">
<Select value={sourceFilter || "__all__"} onValueChange={(value) => setSourceFilter(value === "__all__" ? "" : value)}>
<SelectTrigger id="account-source-filter"><SelectValue placeholder="全部账号" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__">全部账号</SelectItem>
<SelectItem value="owned">自有</SelectItem>
<SelectItem value="monitoring">监测</SelectItem>
</SelectContent>
</Select>
</Field>
<p className="text-xs text-muted-foreground">
自有 {accounts.length} 个 · 监测 {competitors.length} 个 · 当前显示{" "}
{visibleRows.length} 个
<div className="mb-4 flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
共 {rows.length} 个{isMonitoring ? "监控" : "自有"}账号
</p>
</div>
<PageState
pending={pending}
error={error}
empty={visibleRows.length === 0}
emptyText={
sourceFilter ? "暂无符合筛选条件的账号。" : "暂无账号。"
}
empty={rows.length === 0}
emptyText={isMonitoring ? "暂无监控账号。" : "暂无自有账号。"}
onRetry={load}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>账号</TableHead>
<TableHead>类型</TableHead>
<TableHead>平台</TableHead>
<TableHead>标签</TableHead>
<TableHead>状态</TableHead>
@@ -544,7 +534,7 @@ export function AccountList() {
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((account) => (
{rows.map((account) => (
<TableRow key={`${account.source_type}:${account.id}`}>
<TableCell>
<div className="min-w-0">
@@ -558,9 +548,6 @@ export function AccountList() {
<p className="anywhere text-xs text-muted-foreground">{account.platform_account_key}</p>
</div>
</TableCell>
<TableCell>
<StatusPill tone={account.source_type === "owned" ? "success" : "warning"} label={account.source_type === "owned" ? "自有" : "监测"} />
</TableCell>
<TableCell>{platformLabel(account.platform)}</TableCell>
<TableCell><AccountTags tags={account.tags} /></TableCell>
<TableCell>
@@ -570,7 +557,7 @@ export function AccountList() {
<p className="text-xs text-muted-foreground">{account.authorization_status === "authorized" ? "已授权" : "已撤销"}</p>
</div>
) : (
<StatusPill tone={account.enabled ? "success" : "neutral"} label={account.enabled ? "监测中" : "已暂停"} />
<StatusPill tone={account.enabled ? "success" : "neutral"} label={account.enabled ? "监控中" : "已暂停"} />
)}
</TableCell>
<TableCell className="text-right">
@@ -622,6 +609,14 @@ export function AccountList() {
);
}
export function AccountList() {
return <AccountManagementList mode="owned" />;
}
export function MonitoringAccountList() {
return <AccountManagementList mode="monitoring" />;
}
function BindingContent({ browsersError, binding }) {
if (browsersError) {
return (
+36 -16
View File
@@ -13,6 +13,7 @@ import {
AccountCreatePage,
AccountDetail,
AccountList,
MonitoringAccountList,
accountReadiness,
} from "./AccountsPage";
@@ -94,6 +95,18 @@ function renderList(dataProvider) {
);
}
function renderMonitoringList(dataProvider) {
return render(
<QueryClientProvider client={queryClient()}>
<TestDataProvider dataProvider={dataProvider}>
<MemoryRouter>
<MonitoringAccountList />
</MemoryRouter>
</TestDataProvider>
</QueryClientProvider>,
);
}
function renderDetail(dataProvider) {
return render(
<QueryClientProvider client={queryClient()}>
@@ -247,7 +260,24 @@ describe("AccountList", () => {
).toHaveLength(0);
});
it("deletes owned and monitoring accounts through their own resources", async () => {
it("deletes owned accounts through the accounts resource", async () => {
const dataProvider = provider({
deleteOne: vi.fn().mockResolvedValue({ data: {} }),
});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderList(dataProvider);
expect(await screen.findByText("店铺一号")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "删除" }));
await waitFor(() =>
expect(dataProvider.deleteOne).toHaveBeenCalledWith({
resource: "accounts",
id: "account-a",
}),
);
});
it("deletes monitoring accounts through the competitor resource", async () => {
const competitor = {
id: "competitor-a",
nickname: "竞品账号",
@@ -258,27 +288,17 @@ describe("AccountList", () => {
};
const dataProvider = provider({
getList: vi.fn(({ resource }) =>
resource === "accounts"
? { data: [account], total: 1 }
: resource === "creator-competitors"
? { data: [competitor], total: 1 }
: { data: [], total: 0 },
resource === "creator-competitors"
? { data: [competitor], total: 1 }
: { data: [], total: 0 },
),
deleteOne: vi.fn().mockResolvedValue({ data: {} }),
});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderList(dataProvider);
renderMonitoringList(dataProvider);
expect(await screen.findByText("竞品账号")).toBeTruthy();
const deleteButtons = screen.getAllByRole("button", { name: "删除" });
fireEvent.click(deleteButtons[0]);
await waitFor(() =>
expect(dataProvider.deleteOne).toHaveBeenCalledWith({
resource: "accounts",
id: "account-a",
}),
);
fireEvent.click(screen.getAllByRole("button", { name: "删除" })[1]);
fireEvent.click(screen.getByRole("button", { name: "删除" }));
await waitFor(() =>
expect(dataProvider.deleteOne).toHaveBeenCalledWith({
resource: "creator-competitors",
+72 -114
View File
@@ -10,8 +10,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TestDataProvider } from "../../test/providerCompat.jsx";
import { MemoryRouter, Route, Routes } from "../../test/routerCompat.jsx";
import { AccountEditPage } from "../accounts/AccountEditPage";
import { AccountList } from "../accounts/AccountsPage";
import { CreatorCompetitorsPage } from "./competitors/CreatorCompetitorsPage";
import { AccountImportPage } from "../accounts/AccountImportPage";
import { AccountList, MonitoringAccountList } from "../accounts/AccountsPage";
import { CreatorSettingsPage } from "./settings/CreatorSettingsPage";
import { CreatorWorkbenchPage } from "./workbench/CreatorWorkbenchPage";
@@ -284,6 +284,7 @@ describe("creator pages", () => {
});
renderPage(<CreatorSettingsPage />, dataProvider);
await screen.findByRole("button", { name: "保存设置" });
fireEvent.click(screen.getByRole("tab", { name: "AI 服务" }));
fireEvent.click(screen.getByLabelText("已完成 AI 配置审批"));
fireEvent.click(screen.getByRole("button", { name: "保存设置" }));
expect(await screen.findByText("采集与 AI 配置已保存。")).toBeTruthy();
@@ -338,44 +339,47 @@ describe("creator pages", () => {
expect(await screen.findByText("线索用户", { exact: false })).toBeTruthy();
});
it("opens share-link monitoring and shows the works table", async () => {
it("opens account import and keeps competitor analysis focused on works", async () => {
const dataProvider = provider();
renderPage(<CreatorCompetitorsPage />, dataProvider);
expect(await screen.findByRole("button", { name: "添加监听" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "添加监听" }));
renderPage(<AccountImportPage />, dataProvider);
expect(await screen.findByRole("button", { name: "新建导入任务" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "新建导入任务" }));
expect(await screen.findByLabelText("分享内容或链接", { exact: false })).toBeTruthy();
expect(await screen.findByText("暂无符合条件的作品。")).toBeTruthy();
expect(screen.queryByRole("heading", { name: "已监测账号" })).toBeNull();
expect(await screen.findByText("暂无分享链接解析任务。")).toBeTruthy();
expect(screen.queryByRole("heading", { name: "竞品分析" })).toBeNull();
expect(dataProvider.create).not.toHaveBeenCalled();
});
it("shows the environment reason when competitor preview is unavailable", async () => {
it("shows share-job failure reasons and supports requeue", async () => {
const job = {
id: "share-job-failed",
platform: "douyin",
share_url: "https://v.douyin.com/share-a/",
status: "failed",
attempts: 3,
failure_reason: "环境不可用:gateway unavailable",
created_at: "2026-01-01T00:00:00Z",
};
const dataProvider = provider({
create: vi.fn().mockRejectedValue({
status: 503,
message: "creator capability is unavailable",
body: {
error: "creator capability is unavailable",
reason:
"creator capability is unavailable: start account environment: gateway unavailable",
},
}),
});
renderPage(<CreatorCompetitorsPage />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "添加监听" }));
fireEvent.change(
screen.getByLabelText("分享内容或链接", { exact: false }),
{ target: { value: "https://v.douyin.com/share-a/" } },
);
fireEvent.click(screen.getByRole("button", { name: "解析并预览作者" }));
expect(
await screen.findByText(
"环境不可用(503):creator capability is unavailable: start account environment: gateway unavailable",
getList: vi.fn(({ resource }) =>
Promise.resolve({
data: resource === "creator-competitor-share-jobs" ? [job] : [],
total: resource === "creator-competitor-share-jobs" ? 1 : 0,
}),
),
).toBeTruthy();
creatorAction: vi.fn().mockResolvedValue({ ...job, status: "queued", attempts: 0 }),
});
renderPage(<AccountImportPage />, dataProvider);
expect(await screen.findByText("环境不可用:gateway unavailable")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "重新入队" }));
await waitFor(() =>
expect(dataProvider.creatorAction).toHaveBeenCalledWith(
"/creator/competitor-share-jobs/share-job-failed/retry",
),
);
});
it("merges owned and monitored accounts and filters by source", async () => {
it("separates owned and monitored accounts by page", async () => {
const owned = {
id: "account-a",
name: "自有账号",
@@ -397,29 +401,18 @@ describe("creator pages", () => {
const dataProvider = provider({
getList: vi.fn(({ resource }) =>
Promise.resolve({
data:
resource === "accounts"
? [owned]
: resource === "creator-competitors"
? [competitor]
: resource === "creator-accounts"
? [profile]
: [],
total:
resource === "accounts" || resource === "creator-competitors"
? 1
: 0,
data: resource === "accounts" ? [owned] : resource === "creator-competitors" ? [competitor] : [],
total: resource === "accounts" || resource === "creator-competitors" ? 1 : 0,
}),
),
});
renderPage(<AccountList />, dataProvider);
await screen.findByText("自有账号");
expect(await screen.findByText("自有账号")).toBeTruthy();
expect(screen.getByText("监测作者")).toBeTruthy();
fireEvent.click(screen.getByRole("combobox", { name: "账号类型" }));
fireEvent.click(await screen.findByRole("option", { name: "监测" }));
expect(screen.queryByText("监测作者")).toBeNull();
cleanup();
renderPage(<MonitoringAccountList />, dataProvider);
expect(await screen.findByText("监测作者")).toBeTruthy();
expect(screen.queryByText("自有账号")).toBeNull();
expect(screen.getByText("监测作者")).toBeTruthy();
});
it("saves tags for a monitored account", async () => {
@@ -443,7 +436,7 @@ describe("creator pages", () => {
tags: ["重点监测"],
}),
});
renderPage(<AccountList />, dataProvider);
renderPage(<MonitoringAccountList />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "编辑标签" }));
const input = screen.getByRole("textbox", { name: "标签" });
fireEvent.change(input, { target: { value: "重点监测" } });
@@ -480,37 +473,24 @@ describe("creator pages", () => {
}),
),
});
renderPage(<AccountList />, dataProvider);
renderPage(<MonitoringAccountList />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "同步" }));
const warning = await screen.findByRole("alert");
expect(warning.textContent).toContain("没有可用于同步抖音监测账号的已登录自有账号");
expect(warning.textContent).toContain("没有可用于同步抖音监控账号的已登录自有账号");
});
it("extracts a share URL and requires author confirmation before queuing", async () => {
it("extracts a share URL and queues it without synchronous author lookup", async () => {
const dataProvider = provider({
create: vi.fn(({ resource, variables }) =>
Promise.resolve({
data:
resource === "creator-competitor-preview"
? {
platform: variables.platform,
platform_account_key: "sec-b",
nickname: "竞品作者",
homepage_url: "https://www.douyin.com/user/sec-b",
}
: {
id: "competitor-a",
account_id: "account-a",
platform: "douyin",
platform_account_key: "sec-b",
homepage_url: "https://www.douyin.com/user/sec-b",
enabled: true,
},
}),
),
create: vi.fn().mockResolvedValue({
data: {
id: "share-job-a",
status: "queued",
attempts: 0,
},
}),
});
renderPage(<CreatorCompetitorsPage />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "添加监听" }));
renderPage(<AccountImportPage />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "新建导入任务" }));
fireEvent.change(screen.getByLabelText("分享内容或链接", { exact: false }), {
target: {
value:
@@ -525,30 +505,20 @@ describe("creator pages", () => {
/已提取链接:https:\/\/v\.douyin\.com\/9CdUScDai0U\//,
),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "解析并预览作者" }));
expect(await screen.findByText("作者预览")).toBeTruthy();
expect(dataProvider.create).toHaveBeenCalledWith(
expect.objectContaining({
resource: "creator-competitor-preview",
variables: {
platform: "douyin",
share_url: "https://v.douyin.com/9CdUScDai0U/",
},
}),
);
fireEvent.click(screen.getByRole("button", { name: "确认作者并加入队列" }));
fireEvent.click(screen.getByRole("button", { name: "确认加入监听" }));
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledTimes(2));
expect(dataProvider.create).toHaveBeenLastCalledWith(
expect.objectContaining({
resource: "creator-competitors",
variables: {
platform: "douyin",
share_url: "https://v.douyin.com/9CdUScDai0U/",
tags: ["重点监测"],
},
}),
fireEvent.click(screen.getByRole("button", { name: "加入解析队列" }));
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith(
expect.objectContaining({
resource: "creator-competitor-share-jobs",
variables: {
platform: "douyin",
share_url: "https://v.douyin.com/9CdUScDai0U/",
tags: ["重点监测"],
},
}),
),
);
expect(dataProvider.create).toHaveBeenCalledTimes(1);
});
it("detects Xiaohongshu from a pasted share link", async () => {
@@ -562,22 +532,10 @@ describe("creator pages", () => {
total: resource === "creator-accounts" ? 1 : 0,
}),
),
create: vi.fn(({ resource, variables }) =>
Promise.resolve({
data:
resource === "creator-competitor-preview"
? {
platform: variables.platform,
platform_account_key: "xhs-b",
nickname: "小红书作者",
homepage_url: "https://www.xiaohongshu.com/user/profile/xhs-b",
}
: { id: "competitor-xhs", ...variables, enabled: true },
}),
),
create: vi.fn().mockResolvedValue({ data: { id: "share-job-xhs", status: "queued" } }),
});
renderPage(<CreatorCompetitorsPage />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "添加监听" }));
renderPage(<AccountImportPage />, dataProvider);
fireEvent.click(await screen.findByRole("button", { name: "新建导入任务" }));
fireEvent.change(screen.getByLabelText("分享内容或链接", { exact: false }), {
target: {
value: "小红书分享 https://www.xiaohongshu.com/explore/note-a?xsec_token=t",
@@ -586,11 +544,11 @@ describe("creator pages", () => {
expect(
await screen.findByText(/已提取链接:https:\/\/www\.xiaohongshu\.com\/explore\/note-a/),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "解析并预览作者" }));
fireEvent.click(screen.getByRole("button", { name: "加入解析队列" }));
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith(
expect.objectContaining({
resource: "creator-competitor-preview",
resource: "creator-competitor-share-jobs",
variables: expect.objectContaining({
platform: "xiaohongshu",
share_url: "https://www.xiaohongshu.com/explore/note-a?xsec_token=t",
@@ -3,12 +3,11 @@ import { useDataProvider } from "../../../shared/hooks/dataHooks.js";
import { Alert } from "../../../components/ui/alert";
import { Button } from "../../../components/ui/button";
import { Card, CardContent } from "../../../components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../components/ui/table";
import { Textarea } from "../../../components/ui/textarea";
import { Field, PageHeader, PageState, StatusPill, TagInput, dateTime, conflictMessage } from "../../../shared/ui/ui.jsx";
import { Field, PageHeader, PageState, StatusPill, dateTime, conflictMessage } from "../../../shared/ui/ui.jsx";
import { useUnsavedChanges } from "../../../shared/hooks/hooks.js";
const platformOptions = [
@@ -16,38 +15,6 @@ const platformOptions = [
{ value: "xiaohongshu", label: "小红书" },
];
const shareURLPattern = /https?:\/\/[^\s<>"'`]+/giu;
const trailingShareURLPunctuation = /[,。;!?、)》)】\]}>,.?!:;]+$/u;
export function extractShareURL(value) {
const candidates = String(value || "")
.match(shareURLPattern)
?.map((candidate) => candidate.replace(trailingShareURLPunctuation, ""))
.filter(Boolean);
return candidates?.find((candidate) => platformForShareURL(candidate)) || candidates?.[0] || "";
}
export function platformForShareURL(value) {
try {
const hostname = new URL(value).hostname.toLowerCase();
if (["www.douyin.com", "v.douyin.com"].includes(hostname)) {
return "douyin";
}
if (
[
"www.xiaohongshu.com",
"xhslink.com",
"www.xhslink.com",
].includes(hostname)
) {
return "xiaohongshu";
}
} catch {
return "";
}
return "";
}
export function CreatorCompetitorsPage() {
const dataProvider = useDataProvider()("default");
const [works, setWorks] = useState([]);
@@ -57,9 +24,6 @@ export function CreatorCompetitorsPage() {
hasNext: false,
});
const [platform, setPlatform] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [shareText, setShareText] = useState("");
const [shareTags, setShareTags] = useState([]);
const [minLikes, setMinLikes] = useState("");
const [minComments, setMinComments] = useState("");
const [minShares, setMinShares] = useState("");
@@ -67,9 +31,6 @@ export function CreatorCompetitorsPage() {
const [publishedAfter, setPublishedAfter] = useState("");
const [publishedBefore, setPublishedBefore] = useState("");
const [pending, setPending] = useState(true);
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState(null);
const [previewConfirmed, setPreviewConfirmed] = useState(false);
const [error, setError] = useState(null);
const [notice, setNotice] = useState(null);
const [material, setMaterial] = useState(null);
@@ -144,84 +105,6 @@ export function CreatorCompetitorsPage() {
publishedAfter,
publishedBefore,
]);
const extractedShareURL = extractShareURL(shareText);
const sharePlatform = platformForShareURL(extractedShareURL);
const closeCreate = () => {
if (busy) return;
setCreateOpen(false);
setShareText("");
setShareTags([]);
setPreview(null);
setNotice(null);
setPreviewConfirmed(false);
};
const parseShare = async () => {
if (!extractedShareURL) {
setNotice({ variant: "default", text: "请粘贴包含分享链接的内容。" });
return;
}
if (!sharePlatform) {
setNotice({ variant: "default", text: "链接不是支持的平台分享链接。" });
return;
}
setBusy(true);
setNotice(null);
try {
const result = await dataProvider.create({
resource: "creator-competitor-preview",
variables: {
platform: sharePlatform,
share_url: extractedShareURL,
},
});
const verified = result.data;
setPreview({ ...verified, share_url: extractedShareURL });
setPreviewConfirmed(false);
setNotice({
variant: "default",
text: `已识别${sharePlatform === "douyin" ? "抖音" : "小红书"}作者 ${verified.nickname || verified.platform_account_key},请确认后加入监测。`,
});
} catch (parseError) {
setNotice({
variant: "destructive",
text: conflictMessage(parseError, "分享内容解析失败"),
});
} finally {
setBusy(false);
}
};
const create = async () => {
if (!preview || !previewConfirmed) {
setNotice({ variant: "default", text: "请先解析并确认导入预览。" });
return;
}
setBusy(true);
setNotice(null);
try {
const variables = {
platform: preview.platform,
share_url: preview.share_url || extractedShareURL,
};
if (shareTags.length) variables.tags = shareTags;
await dataProvider.create({
resource: "creator-competitors",
variables,
});
setCreateOpen(false);
setShareText("");
setShareTags([]);
setPreview(null);
setPreviewConfirmed(false);
setNotice({ variant: "default", text: "相关账号已加入监听队列。" });
} catch (createError) {
setNotice({
variant: "destructive",
text: conflictMessage(createError, "竞品账号加入失败"),
});
} finally {
setBusy(false);
}
};
const openWorkDetail = async (workID) => {
setWorkDetailPending(true);
setNotice(null);
@@ -355,7 +238,7 @@ export function CreatorCompetitorsPage() {
(rewriteRequirement !== (material.rewrite_requirement || "") ||
rewriteTitle !== (material.generated_title || "") ||
rewriteScript !== (material.generated_script || ""));
useUnsavedChanges(Boolean(shareText || shareTags.length || materialDirty));
useUnsavedChanges(Boolean(materialDirty));
const saveRewrite = async () => {
if (!material) return;
@@ -388,102 +271,13 @@ export function CreatorCompetitorsPage() {
<PageHeader
icon="ri-line-chart-line"
title="竞品分析"
description="粘贴作品分享内容,自动识别作者并加入监听队列。"
>
<Button
variant="default"
onClick={() => {
setNotice(null);
setCreateOpen(true);
}}
>
添加监听
</Button>
</PageHeader>
{notice && !createOpen ? (
description="查看竞品作品与指标,分享链接导入请前往账号导入。"
/>
{notice ? (
<Alert variant={notice.variant === "destructive" ? "destructive" : "default"} className="mb-5">
{notice.text}
</Alert>
) : null}
<Dialog open={createOpen} onOpenChange={(nextOpen) => { if (!nextOpen) closeCreate() }}>
<DialogContent>
<DialogHeader><DialogTitle>添加竞品监听</DialogTitle></DialogHeader>
<div className="space-y-4">
{notice && createOpen ? <Alert variant={notice.variant === "destructive" ? "destructive" : "default"}>{notice.text}</Alert> : null}
<Field
id="competitor-share-content"
label="分享内容或链接"
required
helper="可直接粘贴平台分享文案,系统会自动提取其中的链接并识别作者。"
>
<Textarea
id="competitor-share-content"
value={shareText}
onChange={(event) => {
setShareText(event.target.value);
setNotice(null);
setPreview(null);
setPreviewConfirmed(false);
}}
placeholder="粘贴抖音或小红书的分享内容"
required
/>
</Field>
{shareText ? (
extractedShareURL ? (
<p className="break-all text-xs text-muted-foreground">
已提取链接:{extractedShareURL}
{sharePlatform
? ` · ${sharePlatform === "douyin" ? "抖音" : "小红书"}`
: " · 待识别平台"}
</p>
) : (
<p className="text-xs text-destructive">未找到可用的 http(s) 分享链接。</p>
)
) : null}
<Field
id="competitor-tags"
label="TAGS"
helper="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。"
>
<TagInput
id="competitor-tags"
value={shareTags}
onChange={setShareTags}
placeholder="如:重点监测"
/>
</Field>
<Button type="button" variant="outline" onClick={parseShare} disabled={busy}>
{busy ? "解析中…" : "解析并预览作者"}
</Button>
{preview ? (
<div className="rounded-md border border-border bg-muted p-3 text-sm">
<p className="font-medium">作者预览</p>
<p className="mt-1 text-muted-foreground">
{preview.platform === "douyin" ? "抖音" : "小红书"} ·{" "}
{preview.nickname || preview.platform_account_key}
</p>
<p className="mt-1 break-all text-xs text-muted-foreground">
账号标识:{preview.platform_account_key}
</p>
<Button
type="button"
className="mt-3"
size="sm"
variant={previewConfirmed ? "outline" : "default"}
onClick={() => setPreviewConfirmed((value) => !value)}
>
{previewConfirmed ? "已确认作者" : "确认作者并加入队列"}
</Button>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeCreate} disabled={busy}>取消</Button>
<Button type="button" variant="default" disabled={!previewConfirmed || busy} onClick={create}>{busy ? "加入中…" : "确认加入监听"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="w-full">
<Card>
<CardContent>
@@ -5,6 +5,7 @@ import { Button } from "../../../components/ui/button";
import { Card, CardContent } from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import { cn } from "../../../lib/utils";
import { Field, PageHeader, PageState, conflictMessage } from "../../../shared/ui/ui.jsx";
import { useUnsavedChanges } from "../../../shared/hooks/hooks.js";
@@ -23,6 +24,38 @@ const editableFields = [
"transcription_configured",
];
const numericFields = new Set([
"lookback_days",
"new_work_interval_seconds",
"metric_initial_interval_seconds",
"metric_multiplier",
"metric_max_interval_seconds",
"metric_age_seconds",
]);
const settingTabs = [
{
id: "collection",
label: "采集策略",
description: "配置首次回溯和新作品采集节奏。",
},
{
id: "metrics",
label: "指标采集",
description: "配置作品指标的首次采集、递增间隔和停止条件。",
},
{
id: "ai",
label: "AI 服务",
description: "维护已批准的 AI 服务和模型。",
},
{
id: "transcription",
label: "转写服务",
description: "维护已批准的音频转写服务和模型。",
},
];
const editableSettings = (value) =>
Object.fromEntries(editableFields.map((field) => [field, value[field]]));
@@ -45,6 +78,7 @@ export function CreatorSettingsPage() {
const dataProvider = useDataProvider()("default");
const [form, setForm] = useState(defaults);
const [savedForm, setSavedForm] = useState(defaults);
const [activeTab, setActiveTab] = useState(settingTabs[0].id);
const [pending, setPending] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
@@ -63,27 +97,22 @@ export function CreatorSettingsPage() {
setPending(false);
}
};
useEffect(() => {
load();
}, []);
const change = (field) => (event) => {
const value = event.target.value;
setForm((current) => ({
...current,
[field]: [
"lookback_days",
"new_work_interval_seconds",
"metric_initial_interval_seconds",
"metric_multiplier",
"metric_max_interval_seconds",
"metric_age_seconds",
].includes(field)
? Number(value)
: value,
[field]: numericFields.has(field) ? Number(value) : value,
}));
};
const toggle = (field) => () =>
setForm((current) => ({ ...current, [field]: !current[field] }));
const save = async (event) => {
event.preventDefault();
setBusy(true);
@@ -116,126 +145,195 @@ export function CreatorSettingsPage() {
<PageHeader
icon="ri-settings-3-line"
title="采集设置"
description="统一配置 UTC 采集窗口、指数指标采集和已批准的 AI/转写服务。"
description="按配置类别管理 UTC 采集窗口、指标采集和已批准的服务。"
/>
<PageState pending={pending} error={error} empty={false} onRetry={load}>
<Card>
<CardContent>
<form onSubmit={save} className="grid gap-5 md:grid-cols-2">
<Field
id="setting-lookback"
label="竞品回溯天数"
helper="首次回溯默认 30 天;不会猜测缺失发布时间。"
>
<Input
id="setting-lookback"
type="number"
min="1"
value={form.lookback_days}
onChange={change("lookback_days")}
/>
</Field>
<Field id="setting-new-work" label="新作品/一级评论间隔(秒)">
<Input
id="setting-new-work"
type="number"
min="1"
value={form.new_work_interval_seconds}
onChange={change("new_work_interval_seconds")}
/>
</Field>
<Field id="setting-metric-initial" label="指标首次间隔(秒)">
<Input
id="setting-metric-initial"
type="number"
min="1"
value={form.metric_initial_interval_seconds}
onChange={change("metric_initial_interval_seconds")}
/>
</Field>
<Field id="setting-metric-multiplier" label="指标间隔倍数">
<Input
id="setting-metric-multiplier"
type="number"
min="1.01"
step="0.01"
value={form.metric_multiplier}
onChange={change("metric_multiplier")}
/>
</Field>
<Field id="setting-metric-max" label="指标最大间隔(秒)">
<Input
id="setting-metric-max"
type="number"
min={form.metric_initial_interval_seconds}
value={form.metric_max_interval_seconds}
onChange={change("metric_max_interval_seconds")}
/>
</Field>
<Field id="setting-metric-age" label="指标监测最长时长(秒)">
<Input
id="setting-metric-age"
type="number"
min="1"
value={form.metric_age_seconds}
onChange={change("metric_age_seconds")}
/>
</Field>
<div className="md:col-span-2 border-t border-border pt-5">
<h2 className="mb-4 text-base font-semibold">AI 服务</h2>
<div className="grid gap-4 md:grid-cols-2">
<Field id="setting-ai-provider" label="AI Provider">
<Input
id="setting-ai-provider"
value={form.ai_provider}
onChange={change("ai_provider")}
/>
</Field>
<Field id="setting-ai-model" label="AI Model">
<Input
id="setting-ai-model"
value={form.ai_model}
onChange={change("ai_model")}
/>
</Field>
<form onSubmit={save}>
<div className="mb-6 border-b border-border">
<div
role="tablist"
aria-label="采集设置配置分类"
className="flex gap-1 overflow-x-auto"
>
{settingTabs.map((tab) => {
const selected = activeTab === tab.id;
return (
<button
key={tab.id}
id={`settings-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`settings-panel-${tab.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
className={cn(
"relative shrink-0 px-3 py-2.5 text-sm font-medium transition-colors",
"after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full",
selected
? "text-foreground after:bg-primary"
: "text-muted-foreground hover:text-foreground after:bg-transparent",
)}
>
{tab.label}
</button>
);
})}
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox checked={form.ai_configured} onCheckedChange={toggle("ai_configured")} />
</div>
<div
id="settings-panel-collection"
role="tabpanel"
aria-labelledby="settings-tab-collection"
hidden={activeTab !== "collection"}
tabIndex={0}
className="grid gap-5 md:grid-cols-2"
>
<Field
id="setting-lookback"
label="竞品回溯天数"
helper="首次回溯默认 30 天;不会猜测缺失发布时间。"
>
<Input
id="setting-lookback"
type="number"
min="1"
value={form.lookback_days}
onChange={change("lookback_days")}
/>
</Field>
<Field id="setting-new-work" label="新作品/一级评论间隔(秒)">
<Input
id="setting-new-work"
type="number"
min="1"
value={form.new_work_interval_seconds}
onChange={change("new_work_interval_seconds")}
/>
</Field>
</div>
<div
id="settings-panel-metrics"
role="tabpanel"
aria-labelledby="settings-tab-metrics"
hidden={activeTab !== "metrics"}
tabIndex={0}
className="grid gap-5 md:grid-cols-2"
>
<Field id="setting-metric-initial" label="指标首次间隔(秒)">
<Input
id="setting-metric-initial"
type="number"
min="1"
value={form.metric_initial_interval_seconds}
onChange={change("metric_initial_interval_seconds")}
/>
</Field>
<Field id="setting-metric-multiplier" label="指标间隔倍数">
<Input
id="setting-metric-multiplier"
type="number"
min="1.01"
step="0.01"
value={form.metric_multiplier}
onChange={change("metric_multiplier")}
/>
</Field>
<Field id="setting-metric-max" label="指标最大间隔(秒)">
<Input
id="setting-metric-max"
type="number"
min={form.metric_initial_interval_seconds}
value={form.metric_max_interval_seconds}
onChange={change("metric_max_interval_seconds")}
/>
</Field>
<Field id="setting-metric-age" label="指标监测最长时长(秒)">
<Input
id="setting-metric-age"
type="number"
min="1"
value={form.metric_age_seconds}
onChange={change("metric_age_seconds")}
/>
</Field>
</div>
<div
id="settings-panel-ai"
role="tabpanel"
aria-labelledby="settings-tab-ai"
hidden={activeTab !== "ai"}
tabIndex={0}
className="grid gap-5 md:grid-cols-2"
>
<Field id="setting-ai-provider" label="AI Provider">
<Input
id="setting-ai-provider"
value={form.ai_provider}
onChange={change("ai_provider")}
/>
</Field>
<Field id="setting-ai-model" label="AI Model">
<Input
id="setting-ai-model"
value={form.ai_model}
onChange={change("ai_model")}
/>
</Field>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<Checkbox
checked={form.ai_configured}
onCheckedChange={toggle("ai_configured")}
/>
已完成 AI 配置审批
</label>
</div>
<div className="md:col-span-2 border-t border-border pt-5">
<h2 className="mb-4 text-base font-semibold">转写服务</h2>
<div className="grid gap-4 md:grid-cols-2">
<Field
<div
id="settings-panel-transcription"
role="tabpanel"
aria-labelledby="settings-tab-transcription"
hidden={activeTab !== "transcription"}
tabIndex={0}
className="grid gap-5 md:grid-cols-2"
>
<Field id="setting-transcription-provider" label="转写 Provider">
<Input
id="setting-transcription-provider"
label="转写 Provider"
>
<Input
id="setting-transcription-provider"
value={form.transcription_provider}
onChange={change("transcription_provider")}
/>
</Field>
<Field id="setting-transcription-model" label="转写 Model">
<Input
id="setting-transcription-model"
value={form.transcription_model}
onChange={change("transcription_model")}
/>
</Field>
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox checked={form.transcription_configured} onCheckedChange={toggle("transcription_configured")} />
value={form.transcription_provider}
onChange={change("transcription_provider")}
/>
</Field>
<Field id="setting-transcription-model" label="转写 Model">
<Input
id="setting-transcription-model"
value={form.transcription_model}
onChange={change("transcription_model")}
/>
</Field>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<Checkbox
checked={form.transcription_configured}
onCheckedChange={toggle("transcription_configured")}
/>
已完成转写配置审批
</label>
</div>
{notice ? (
<Alert variant={notice.variant === "destructive" ? "destructive" : "default"} className="md:col-span-2">
<Alert
variant={notice.variant === "destructive" ? "destructive" : "default"}
className="mt-6"
>
{notice.text}
</Alert>
) : null}
<div className="flex justify-end md:col-span-2">
<div className="mt-6 flex justify-end">
<Button type="submit" variant="default" disabled={busy}>
{busy ? "保存中…" : "保存设置"}
</Button>
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { AccountImportPage } from '@/features/accounts/AccountImportPage'
export const Route = createFileRoute('/_app/accounts/import')({ component: AccountImportPage })
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { MonitoringAccountList } from '@/features/accounts/AccountsPage'
export const Route = createFileRoute('/_app/accounts/monitoring')({ component: MonitoringAccountList })
+3 -1
View File
@@ -58,7 +58,7 @@ const resourcePaths = {
"network-exits": "/network-exits",
"creator-accounts": "/creator/accounts",
"creator-competitors": "/creator/competitors",
"creator-competitor-preview": "/creator/competitors/preview",
"creator-competitor-share-jobs": "/creator/competitor-share-jobs",
"creator-works": "/creator/works",
"creator-comments": "/creator/comments",
"creator-leads": "/creator/leads",
@@ -128,6 +128,7 @@ export const dataProvider = {
"attempts",
"creator-accounts",
"creator-competitors",
"creator-competitor-share-jobs",
"creator-works",
"creator-comments",
"creator-rules",
@@ -299,6 +300,7 @@ function filterKeys(resource) {
confirmations: ["draft_id"],
tasks: ["account_id", "draft_id", "state"],
"creator-competitors": ["platform"],
"creator-competitor-share-jobs": ["platform", "status"],
"creator-works": [
"platform",
"source_id",