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

668 lines
21 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 { 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,
Modal,
PageHeader,
PageState,
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 AccountCreateModal({ open, onClose, 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();
const created = await onSubmit(data);
if (created) {
setForm(createInitial);
onClose();
}
}
return (
<Modal
open={open}
onClose={onClose}
title="创建社媒账号"
labelledBy="account-create-title"
size="lg"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
type="submit"
form="account-create-form"
busy={busy}
busyText="创建中…"
disabled={!valid}
>
创建账号
</Button>
</>
}
>
<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>
</form>
</Modal>
);
}
function platformLabel(value) {
return platforms.find((platform) => platform.value === value)?.label || value;
}
export function AccountList() {
const [createOpen, setCreateOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [createError, setCreateError] = useState(null);
const [notice, setNotice] = useState(null);
const dataProvider = useDataProvider()("default");
const { result, query } = useList({ resource: "accounts" });
const error = query.error;
const isPending = query.isPending;
const accounts = result.data ?? [];
useTitle("CreatorHub · 社媒账号");
async function createAccount(data) {
setBusy(true);
setCreateError(null);
try {
await dataProvider.create({ resource: "accounts", variables: data });
await query.refetch();
setNotice({
variant: "success",
text: "账号已创建;绑定健康出口和运行环境后方可恢复。",
});
return true;
} catch (reason) {
setCreateError(reason);
return false;
} finally {
setBusy(false);
}
}
return (
<div>
<PageHeader
icon="ri-account-circle-line"
title="社媒账号"
description="管理账号授权、暂停状态与固定运行资源"
>
<Button
variant="primary"
icon="ri-add-line"
onClick={() => setCreateOpen(true)}
>
创建账号
</Button>
</PageHeader>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={accounts.length === 0}
emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。"
>
<Table
columns={[
{
header: "账号",
width: "32%",
render: (account) => (
<div className="min-w-0">
<Link
to={`/accounts/${account.id}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{account.name}
</Link>
<p className="anywhere text-xs text-muted">
{account.platform_account_key}
</p>
</div>
),
},
{
header: "平台",
width: "16%",
render: (account) => platformLabel(account.platform),
},
{
header: "授权",
width: "16%",
render: (account) =>
account.authorization_status === "authorized"
? "已授权"
: "已撤销",
},
{
header: "账号状态",
width: "20%",
render: (account) =>
account.runtime_status === "active" ? "启用" : "暂停",
},
{
header: "操作",
width: "16%",
render: (account) => (
<Button size="sm" as={Link} to={`/accounts/${account.id}`}>
查看账号
</Button>
),
},
]}
rows={accounts}
rowKey={(account) => account.id}
minWidth="560px"
/>
</PageState>
<AccountCreateModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createAccount}
busy={busy}
error={createError}
/>
</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 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>
);
}