feat: split account management
This commit is contained in:
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user