1009 lines
32 KiB
React
1009 lines
32 KiB
React
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>
|
||
);
|
||
}
|