668 lines
21 KiB
React
668 lines
21 KiB
React
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>
|
||
);
|
||
}
|