Files
creator-hub/web/src/AccountsPage.jsx
T

1009 lines
32 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
Alert,
Button,
Card,
CardContent,
ConfirmDialog,
DetailList,
Field,
Input,
PageHeader,
PageState,
Modal,
Select,
StatusPill,
Table,
TagInput,
Textarea,
conflictMessage,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
const createInitial = {
name: "",
platform: "",
platform_account_key: "",
tags: [],
cookies: "",
};
const platforms = [
{ value: "douyin", label: "抖音" },
{ value: "xiaohongshu", label: "小红书" },
{ value: "wechat-official", label: "公众号" },
{ value: "kuaishou", label: "快手" },
];
const reasonText = {
account_revoked: "授权已撤销",
account_paused: "账号已暂停",
binding_missing: "未绑定运行环境",
network_exit_missing: "未绑定固定出口",
network_exit_unhealthy: "固定出口不健康",
runtime_stop_pending: "运行环境停止结果待确认",
runtime_missing: "运行环境未启动",
runtime_active: "仍有活动运行实例",
account_conflict: "账号状态已变化,请刷新后重试",
environment_unavailable: "环境状态未知",
};
// 账号可恢复性判定:授权、绑定、出口健康、账号暂停四层都要看。
export function accountReadiness(account, binding, bindingsError = false) {
if (account.authorization_status !== "authorized")
return {
label: "授权已撤销",
reason: "account_revoked",
canResume: false,
ready: false,
};
if (bindingsError)
return {
label: "环境状态未知",
reason: "environment_unavailable",
canResume: false,
ready: false,
};
if (!binding)
return {
label: "未绑定运行环境",
reason: "binding_missing",
canResume: false,
ready: false,
};
const blocked = binding.cleanup_pending
? "runtime_stop_pending"
: binding.network_exit_id && binding.network_exit_health !== "healthy"
? "network_exit_unhealthy"
: "";
if (blocked)
return {
label: reasonText[blocked],
reason: blocked,
canResume: false,
ready: false,
};
if (account.runtime_status === "paused") {
const stopped = !binding.runtime_instance_id;
return {
label: stopped ? "资源就绪,可恢复" : "等待运行环境停止",
reason: stopped ? "" : "runtime_stop_pending",
canResume: stopped,
ready: false,
};
}
const schedule = binding.schedule_block_reason || "";
return {
label: schedule
? reasonText[schedule] || `不可调度:${schedule}`
: "可调度",
reason: schedule,
canResume: false,
ready: !schedule,
};
}
function ReadinessPill({ readiness, paused }) {
const tone = readiness.ready
? "success"
: paused && readiness.canResume
? "warning"
: "danger";
return <StatusPill tone={tone} label={readiness.label} />;
}
function AccountCreateForm({ onSubmit, busy, error }) {
const [form, setForm] = useState(createInitial);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const valid =
form.name.trim() && form.platform && form.platform_account_key.trim();
async function submit(event) {
event.preventDefault();
if (!valid) return;
const data = {
name: form.name.trim(),
platform: form.platform.trim(),
platform_account_key: form.platform_account_key.trim(),
tags: form.tags,
};
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
if (form.cookies.trim()) data.cookies = form.cookies.trim();
await onSubmit(data);
}
return (
<form id="account-create-form" onSubmit={submit} noValidate>
{error ? (
<Alert variant="destructive" className="mb-4">
{conflictMessage(error, "该平台的账号 ID 已存在;表单内容已保留。")}
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field
id="account-name"
label="账号名称"
required
helper="展示名称,支持中文"
>
<Input
id="account-name"
required
maxLength={128}
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="如:店铺一号"
/>
</Field>
<Field
id="account-platform"
label="平台类型"
required
error={form.platform === "" ? "请选择平台" : undefined}
helper="账号所属平台"
>
<Select
id="account-platform"
value={form.platform}
invalid={form.platform === ""}
onChange={(event) => update("platform", event.target.value)}
options={platforms}
placeholder="选择平台"
/>
</Field>
<Field
id="account-key"
label="账号 ID"
required
helper="平台内唯一标识,同平台不可重复"
>
<Input
id="account-key"
required
maxLength={128}
value={form.platform_account_key}
onChange={(event) =>
update("platform_account_key", event.target.value)
}
/>
</Field>
<Field
id="account-tags"
label="TAGS"
helper="回车或逗号确认,退格删除末位标签"
>
<TagInput
id="account-tags"
value={form.tags}
onChange={(tags) => update("tags", tags)}
placeholder="如:主账号"
/>
</Field>
<Field
id="account-cookies"
label="Cookies"
className="sm:col-span-2"
helper="可选。留空则创建后扫码登录;仅支持浏览器 Cookie Header 格式,如 name=value; token=value"
>
<Textarea
id="account-cookies"
maxLength={8192}
rows={3}
value={form.cookies}
onChange={(event) => update("cookies", event.target.value)}
placeholder="可选,如:sessionid=value; token=value"
/>
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button as={Link} to="/accounts" disabled={busy}>
取消
</Button>
<Button
variant="primary"
type="submit"
busy={busy}
busyText="创建中…"
disabled={!valid}
>
创建账号
</Button>
</div>
</form>
);
}
function platformLabel(value) {
return platforms.find((platform) => platform.value === value)?.label || value;
}
export function AccountCreatePage() {
const navigate = useNavigate();
const dataProvider = useDataProvider()("default");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useTitle("CreatorHub · 创建社媒账号");
async function createAccount(data) {
setBusy(true);
setError(null);
try {
const result = await dataProvider.create({
resource: "accounts",
variables: data,
});
const id = result.data?.id;
if (!id) throw new Error("创建账号未返回账号 ID");
navigate(`/accounts/${encodeURIComponent(id)}/edit`);
} catch (reason) {
setError(reason);
} finally {
setBusy(false);
}
}
return (
<div>
<PageHeader
icon="ri-account-circle-line"
title="创建社媒账号"
description="创建账号后,再在编辑页配置登录身份与账号策略。"
>
<Button as={Link} to="/accounts" icon="ri-arrow-left-line">
返回社媒账号
</Button>
</PageHeader>
<Card>
<CardContent>
<AccountCreateForm onSubmit={createAccount} busy={busy} error={error} />
</CardContent>
</Card>
</div>
);
}
function isCollectionAccount(account) {
return (
account.authorization_status === "authorized" &&
["normal", "muted"].includes(account.business_status) &&
account.login_status === "logged_in"
);
}
function AccountTags({ tags }) {
const values = Array.isArray(tags) ? tags : [];
return values.length ? (
<div className="flex flex-wrap gap-1">
{values.map((tag) => (
<span
key={tag}
className="rounded bg-[#eef3ff] px-1.5 py-0.5 text-xs text-primary"
>
{tag}
</span>
))}
</div>
) : (
<span className="text-xs text-muted">未设置</span>
);
}
export function AccountList() {
const dataProvider = useDataProvider()("default");
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);
const [actionBusy, setActionBusy] = useState("");
const [tagTarget, setTagTarget] = useState(null);
const [tagDraft, setTagDraft] = useState([]);
const [tagBusy, setTagBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState("");
useTitle("CreatorHub · 账号管理");
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 ?? []);
} catch (loadError) {
setError(loadError);
} finally {
setPending(false);
}
};
useEffect(() => {
load();
}, [dataProvider]);
const rows = useMemo(
() => [
...accounts.map((account) => ({
...account,
source_type: "owned",
tags: account.tags ?? [],
})),
...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;
async function syncCompetitor(competitor) {
setActionBusy(competitor.id);
setNotice(null);
try {
const result = await dataProvider.getList({ resource: "creator-accounts" });
const account = (result.data ?? []).find(
(candidate) =>
candidate.platform === competitor.platform &&
isCollectionAccount(candidate),
);
if (!account) {
throw new Error(
`没有可用于同步${platformLabel(competitor.platform)}监测账号的已登录自有账号。`,
);
}
await dataProvider.creatorAction(
`/creator/competitors/${encodeURIComponent(competitor.id)}/sync`,
{ account_id: account.id },
);
setNotice({ variant: "success", text: "监测账号同步任务已提交。" });
await load();
} catch (actionError) {
setNotice({
variant: "destructive",
text: conflictMessage(actionError, "监测账号同步失败"),
});
} finally {
setActionBusy("");
}
}
async function toggleCompetitor(competitor) {
setActionBusy(competitor.id);
setNotice(null);
try {
await dataProvider.creatorAction(
`/creator/competitors/${encodeURIComponent(competitor.id)}/${competitor.enabled ? "pause" : "resume"}`,
);
setNotice({
variant: "success",
text: competitor.enabled ? "监测账号已暂停。" : "监测账号已恢复。",
});
await load();
} catch (actionError) {
setNotice({
variant: "destructive",
text: conflictMessage(actionError, "监测账号状态更新失败"),
});
} finally {
setActionBusy("");
}
}
async function deleteAccount(row) {
const label = row.source_type === "owned" ? "自有账号" : "监测账号";
if (!window.confirm(`确定删除${label}“${row.name}”吗?相关专属资源也会被清理。`)) {
return;
}
const busyID = `${row.source_type}:${row.id}`;
setDeleteBusy(busyID);
setNotice(null);
try {
await dataProvider.deleteOne({
resource: row.source_type === "owned" ? "accounts" : "creator-competitors",
id: row.id,
});
setNotice({ variant: "success", text: `${label}已删除。` });
await load();
} catch (deleteError) {
setNotice({
variant: "destructive",
text: conflictMessage(deleteError, `${label}删除失败`),
});
} finally {
setDeleteBusy("");
}
}
function openTags(row) {
setNotice(null);
setTagTarget(row);
setTagDraft(Array.isArray(row.tags) ? row.tags : []);
}
async function saveTags() {
if (!tagTarget) return;
setTagBusy(true);
setNotice(null);
try {
const result =
tagTarget.source_type === "owned"
? await dataProvider.creatorUpdate(
`/creator/accounts/${encodeURIComponent(tagTarget.id)}/tags`,
{ tags: tagDraft },
)
: await dataProvider.creatorUpdate(
`/creator/competitors/${encodeURIComponent(tagTarget.id)}`,
{ tags: tagDraft },
);
const tags = result.tags;
if (tagTarget.source_type === "owned") {
setAccounts((current) =>
current.map((account) =>
account.id === tagTarget.id ? { ...account, tags } : account,
),
);
} else {
setCompetitors((current) =>
current.map((account) =>
account.id === tagTarget.id ? { ...account, tags } : account,
),
);
}
setTagTarget(null);
setNotice({ variant: "success", text: "标签已保存。" });
} catch (saveError) {
setNotice({
variant: "destructive",
text: conflictMessage(saveError, "标签保存失败"),
});
} finally {
setTagBusy(false);
}
}
return (
<div>
<PageHeader
icon="ri-account-circle-line"
title="账号管理"
description="统一管理自有账号与监测账号,按账号类型筛选。"
>
<Button as={Link} to="/creator/competitors">
添加监测账号
</Button>
<Button
as={Link}
to="/accounts/new"
variant="primary"
icon="ri-add-line"
>
创建自有账号
</Button>
</PageHeader>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
<Card>
<CardContent>
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<Field id="account-source-filter" label="账号类型">
<Select
id="account-source-filter"
value={sourceFilter}
onChange={(event) => setSourceFilter(event.target.value)}
emptyOption={{ value: "", label: "全部账号" }}
options={[
{ value: "owned", label: "自有" },
{ value: "monitoring", label: "监测" },
]}
/>
</Field>
<p className="text-xs text-muted">
自有 {accounts.length} 个 · 监测 {competitors.length} 个 · 当前显示{" "}
{visibleRows.length} 个
</p>
</div>
<PageState
pending={pending}
error={error}
empty={visibleRows.length === 0}
emptyText={
sourceFilter ? "暂无符合筛选条件的账号。" : "暂无账号。"
}
onRetry={load}
>
<Table
columns={[
{
header: "账号",
width: "24%",
render: (account) => (
<div className="min-w-0">
{account.source_type === "owned" ? (
<Link
to={`/accounts/${account.id}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{account.name}
</Link>
) : (
<span className="anywhere font-semibold text-ink">
{account.name}
</span>
)}
<p className="anywhere text-xs text-muted">
{account.platform_account_key}
</p>
</div>
),
},
{
header: "类型",
width: "12%",
render: (account) => (
<StatusPill
tone={
account.source_type === "owned" ? "success" : "warning"
}
label={account.source_type === "owned" ? "自有" : "监测"}
/>
),
},
{
header: "平台",
width: "12%",
render: (account) => platformLabel(account.platform),
},
{
header: "标签",
width: "18%",
render: (account) => <AccountTags tags={account.tags} />,
},
{
header: "状态",
width: "16%",
render: (account) =>
account.source_type === "owned" ? (
<div className="space-y-1">
<StatusPill
tone={
account.runtime_status === "active"
? "success"
: "neutral"
}
label={
account.runtime_status === "active" ? "启用" : "暂停"
}
/>
<p className="text-xs text-muted">
{account.authorization_status === "authorized"
? "已授权"
: "已撤销"}
</p>
</div>
) : (
<StatusPill
tone={account.enabled ? "success" : "neutral"}
label={account.enabled ? "监测中" : "已暂停"}
/>
),
},
{
header: "操作",
width: "18%",
render: (account) => (
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => openTags(account)}>
编辑标签
</Button>
{account.source_type === "owned" ? (
<>
<Button
size="sm"
as={Link}
to={`/accounts/${account.id}/edit`}
>
编辑账号
</Button>
<Button
size="sm"
as={Link}
to={`/accounts/${account.id}`}
>
查看账号
</Button>
</>
) : (
<>
<Button
size="sm"
onClick={() => syncCompetitor(account)}
busy={actionBusy === account.id}
>
同步
</Button>
<Button
size="sm"
onClick={() => toggleCompetitor(account)}
busy={actionBusy === account.id}
>
{account.enabled ? "暂停" : "恢复"}
</Button>
</>
)}
<Button
size="sm"
onClick={() => deleteAccount(account)}
busy={deleteBusy === `${account.source_type}:${account.id}`}
>
删除
</Button>
</div>
),
},
]}
rows={visibleRows}
rowKey={(account) => `${account.source_type}:${account.id}`}
minWidth="960px"
/>
</PageState>
</CardContent>
</Card>
<Modal
open={Boolean(tagTarget)}
onClose={() => {
if (!tagBusy) setTagTarget(null);
}}
title={`编辑标签 · ${tagTarget?.name || "账号"}`}
labelledBy="account-tags-title"
footer={
<>
<Button type="button" onClick={() => setTagTarget(null)} disabled={tagBusy}>
取消
</Button>
<Button type="button" variant="primary" onClick={saveTags} busy={tagBusy}>
保存标签
</Button>
</>
}
>
<Field
id="account-tags-editor"
label="标签"
helper="回车或逗号确认,最多 20 个标签,每个最多 32 个字符。"
>
<TagInput
id="account-tags-editor"
value={tagDraft}
onChange={setTagDraft}
placeholder="如:主账号"
/>
</Field>
</Modal>
</div>
);
}
export function AccountDetail() {
const { id } = useParams();
const navigate = useNavigate();
const dataProvider = useDataProvider()("default");
const [pauseOpen, setPauseOpen] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [notice, setNotice] = useState(null);
const [draftText, setDraftText] = useState("");
const [draftBusy, setDraftBusy] = useState(false);
const {
result: account,
error,
isPending,
query,
} = useOne({ resource: "accounts", id, queryOptions: { retry: false } });
const { result: bindingResult, query: browsersQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const browsers = bindingResult.data ?? [];
const browsersError = browsersQuery.error;
const refetchBrowsers = browsersQuery.refetch;
const { result: draftResult, query: draftQuery } = useList({
resource: "drafts",
filters: [{ field: "account_id", value: id }],
queryOptions: { retry: false },
});
const drafts = draftResult.data ?? [];
const refetchDrafts = draftQuery.refetch;
const binding = browsersError
? undefined
: browsers.find((item) => item.account_id === id);
const readiness = account
? accountReadiness(account, binding, !!browsersError)
: null;
useTitle("CreatorHub · 账号详情");
async function runAction(action) {
setActionBusy(true);
setNotice(null);
try {
await dataProvider.accountAction(account.id, action);
await Promise.all([query.refetch(), refetchBrowsers()]);
setNotice({
variant: "success",
text:
action === "pause"
? "账号已暂停;既有 hold 任务需后续逐条核验。"
: "账号已恢复;既有 hold 任务没有自动恢复。",
});
} catch (reason) {
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setActionBusy(false);
}
}
async function createDraft(event) {
event.preventDefault();
if (!draftText.trim() || !readiness?.ready) return;
setDraftBusy(true);
setNotice(null);
try {
const draft = await dataProvider.createDraft(account.id, draftText);
await refetchDrafts();
setDraftText("");
setNotice({
variant: "success",
text: `草稿版本 ${draft.version} 已创建,请进入只读快照核对。`,
});
} catch (reason) {
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setDraftBusy(false);
}
}
if (isPending) return <PageState pending />;
if (error || !account)
return (
<Alert variant="destructive">{error?.message || "账号不存在"}</Alert>
);
return (
<div>
<PageHeader
title={<span className="anywhere">{account.name}</span>}
description={
<span className="anywhere">
{account.platform_account_key} · {account.platform}
</span>
}
>
<Button as={Link} to={`/accounts/${account.id}/edit`}>
编辑账号
</Button>
<Button icon="ri-arrow-left-line" onClick={() => navigate("/accounts")}>
返回社媒账号
</Button>
</PageHeader>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
{browsersError ? (
<Alert variant="destructive" className="mb-4">
环境不可用:运行环境与网络出口状态未知,依赖资源状态的操作暂不可用。
<Button size="sm" onClick={() => refetchBrowsers()}>
重试环境状态
</Button>
</Alert>
) : null}
<Alert variant="warning" className="mb-6">
暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有
hold。
</Alert>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">账号状态</h2>
<ReadinessPill
readiness={readiness}
paused={account.runtime_status === "paused"}
/>
<DetailList
rows={[
[
"授权",
account.authorization_status === "authorized"
? "已授权"
: "已撤销",
],
[
"运行",
`${account.runtime_status === "active" ? "启用" : "暂停"} · 版本 ${account.version}`,
],
["TAGS", account.tags?.join("、") || "无"],
]}
/>
<div className="flex flex-wrap gap-3 pt-1">
<Button
variant="danger"
disabled={actionBusy || account.runtime_status === "paused"}
onClick={() => setPauseOpen(true)}
>
暂停账号
</Button>
<Button
variant="primary"
disabled={actionBusy || !readiness.canResume}
busy={actionBusy}
onClick={() => runAction("resume")}
>
恢复账号
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">固定资源</h2>
{browsersError ? (
<p className="text-sm text-muted">
运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。
</p>
) : binding ? (
<>
<DetailList
rows={[
["运行环境", `${binding.name}(${binding.alias})`],
[
"网络出口",
binding.network_exit_id
? `${binding.network_exit_id} · ${binding.network_exit_health || "未知状态"}`
: "当前机器直连",
],
["绑定版本", binding.binding_version],
[
"不可调度原因",
binding.schedule_block_reason
? reasonText[binding.schedule_block_reason] ||
binding.schedule_block_reason
: "无",
],
]}
/>
<Link
to="/browsers"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
前往运行环境
</Link>
</>
) : (
<>
<p className="text-sm text-muted">
尚未绑定运行环境。账号暂停且资源就绪后,可在运行环境页创建绑定。
</p>
<Link
to="/browsers"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
前往运行环境
</Link>
</>
)}
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">文本草稿</h2>
<p className="text-sm text-muted">
仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。
</p>
<form onSubmit={createDraft} className="space-y-3">
<Field
id="draft-content"
label="草稿内容"
helper={
readiness?.ready
? "创建后进入只读快照核对并显式确认"
: `资源未就绪:${readiness?.label || "正在加载"}`
}
>
<Textarea
id="draft-content"
rows={4}
value={draftText}
onChange={(event) => setDraftText(event.target.value)}
disabled={!readiness?.ready || draftBusy}
/>
</Field>
<Button
variant="primary"
type="submit"
busy={draftBusy}
busyText="创建中…"
disabled={!readiness?.ready || !draftText.trim()}
>
创建草稿
</Button>
</form>
<p className="text-sm text-muted">
尚无草稿时无需处理;创建后请进入草稿核对。
</p>
{drafts.length > 0 ? (
<div className="space-y-3">
{drafts.map((draft) => (
<div
key={draft.id}
className="rounded-md border border-hairline p-3"
>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">
草稿版本 {draft.version}
</p>
<p className="anywhere mt-0.5 text-xs text-muted line-clamp-2">
{draft.content}
</p>
</div>
<Link
to={`/drafts/${draft.id}`}
className="shrink-0 text-sm font-medium text-primary hover:underline"
>
核对草稿
</Link>
</div>
</div>
))}
</div>
) : null}
</CardContent>
</Card>
</div>
<ConfirmDialog
open={pauseOpen}
onClose={() => setPauseOpen(false)}
onConfirm={() => {
setPauseOpen(false);
runAction("pause");
}}
title="暂停账号"
confirmLabel="确认暂停"
body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}
/>
</div>
);
}