fix(account): close create modal on success and make cookies optional

- AccountList.createAccount 成功后关闭创建弹窗(此前只提示不关闭)
- Cookies 前端改为可选:留空代表扫码登录场景,提交时不携带该字段
- 后端 validAccount 允许空凭据(此前 http.ParseCookie("") 直接拒绝)
- CreateAccount 空凭据时跳过 keyring 写入,不产生空值记录;回滚补偿仅在实际写入过凭据时执行

验证:go test ./... / go vet ./... / 双端构建通过;web vitest 66 用例通过(新增无 cookies 创建+弹窗关闭用例);npm run build 通过
This commit is contained in:
2026-09-02 17:00:19 +08:00
parent f921a2e835
commit 1e92e16ed3
4 changed files with 872 additions and 283 deletions
+549 -185
View File
@@ -1,269 +1,560 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { useDataProvider, useList, useOne } from '@refinedev/core'
import { 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, Modal, PageHeader,
PageState, Select, StatusPill, Textarea, conflictMessage,
} from './lib/ui.jsx'
import { useTitle } from './lib/hooks.js'
Alert,
Button,
Card,
CardContent,
ConfirmDialog,
DetailList,
Field,
Input,
Modal,
PageHeader,
PageState,
Select,
StatusPill,
Textarea,
conflictMessage,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
const createInitial = { name: '', platform: '', platform_account_key: '', tags: '', cookies: '' }
const createInitial = {
name: "",
platform: "",
platform_account_key: "",
tags: "",
cookies: "",
};
const platforms = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'wechat-official', label: '公众号' },
{ value: 'kuaishou', label: '快手' },
]
{ 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: '环境状态未知',
}
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 }
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 }
? "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 }
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} />
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() && form.cookies.trim()
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 created = await onSubmit({
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.split(/[,,]/).map(tag => tag.trim()).filter(Boolean),
cookies: form.cookies.trim(),
})
if (created) setForm(createInitial)
tags: form.tags
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
};
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
if (form.cookies.trim()) data.cookies = form.cookies.trim();
const created = await onSubmit(data);
if (created) setForm(createInitial);
}
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>
</>}>
<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}
{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
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
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
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="多个标签用逗号分隔">
<Input id="account-tags" value={form.tags} onChange={event => update('tags', event.target.value)} placeholder="如:主账号,直播" />
<Input
id="account-tags"
value={form.tags}
onChange={(event) => update("tags", event.target.value)}
placeholder="如:主账号,直播"
/>
</Field>
<Field id="account-cookies" label="Cookies" required className="sm:col-span-2" helper="仅支持浏览器 Cookie Header 格式,如 name=value; token=value">
<Textarea id="account-cookies" required maxLength={8192} rows={3} value={form.cookies} onChange={event => update('cookies', event.target.value)} />
<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 AccountCard({ account, binding, bindingsError }) {
const readiness = accountReadiness(account, binding, bindingsError)
const readiness = accountReadiness(account, binding, bindingsError);
return (
<Card>
<CardContent className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<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} · {account.platform}</p>
<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} · {account.platform}
</p>
</div>
<ReadinessPill readiness={readiness} paused={account.runtime_status === 'paused'} />
<ReadinessPill
readiness={readiness}
paused={account.runtime_status === "paused"}
/>
</div>
<div className="text-sm">
<span className="text-muted">授权:</span>{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}
<span className="text-muted"> · 账号:</span>{account.runtime_status === 'active' ? '启用' : '暂停'}
<span className="text-muted">授权:</span>
{account.authorization_status === "authorized" ? "已授权" : "已撤销"}
<span className="text-muted"> · 账号:</span>
{account.runtime_status === "active" ? "启用" : "暂停"}
</div>
<div className="text-sm">
<span className="text-muted">固定资源:</span>
{bindingsError ? '状态未知(环境数据不可用)' : (
<span className="anywhere">{binding?.name || '未绑定运行环境'} · {binding?.network_exit_id || '未绑定出口'}</span>
{bindingsError ? (
"状态未知(环境数据不可用)"
) : (
<span className="anywhere">
{binding?.name || "未绑定运行环境"} ·{" "}
{binding?.network_exit_id || "未绑定出口"}
</span>
)}
</div>
<Link to={`/accounts/${account.id}`} className="inline-flex text-sm font-medium text-primary hover:underline">查看账号 →</Link>
<Link
to={`/accounts/${account.id}`}
className="inline-flex text-sm font-medium text-primary hover:underline"
>
查看账号 →
</Link>
</CardContent>
</Card>
)
);
}
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 ?? []
const { result: bindingResult, query: bindingsQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
const bindings = bindingResult.data ?? []
const bindingsError = bindingsQuery.error
const refetchBindings = bindingsQuery.refetch
const bindingByAccount = useMemo(() => new Map(bindings.filter(item => item.account_id).map(item => [item.account_id, item])), [bindings])
useTitle('CreatorHub · 社媒账号')
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 ?? [];
const { result: bindingResult, query: bindingsQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const bindings = bindingResult.data ?? [];
const bindingsError = bindingsQuery.error;
const refetchBindings = bindingsQuery.refetch;
const bindingByAccount = useMemo(
() =>
new Map(
bindings
.filter((item) => item.account_id)
.map((item) => [item.account_id, item]),
),
[bindings],
);
useTitle("CreatorHub · 社媒账号");
async function createAccount(data) {
setBusy(true)
setCreateError(null)
setBusy(true);
setCreateError(null);
try {
await dataProvider.create({ resource: 'accounts', variables: data })
await query.refetch()
setNotice({ variant: 'success', text: '账号已创建;绑定健康出口和运行环境后方可恢复。' })
return true
await dataProvider.create({ resource: "accounts", variables: data });
await query.refetch();
setNotice({
variant: "success",
text: "账号已创建;绑定健康出口和运行环境后方可恢复。",
});
setCreateOpen(false);
return true;
} catch (reason) {
setCreateError(reason)
return false
setCreateError(reason);
return false;
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<div>
<PageHeader icon="ri-account-circle-line" title="社媒账号" description="管理账号授权、暂停状态与固定运行资源">
<Button variant="primary" icon="ri-add-line" onClick={() => setCreateOpen(true)}>创建账号</Button>
<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}
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ''}:无法读取运行环境绑定与 readiness,状态暂时未知。
<Button size="sm" onClick={() => refetchBindings()}>重试环境状态</Button>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
<PageState pending={isPending} error={error} empty={accounts.length === 0} emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。">
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""}
:无法读取运行环境绑定与 readiness,状态暂时未知。
<Button size="sm" onClick={() => refetchBindings()}>
重试环境状态
</Button>
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={accounts.length === 0}
emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{accounts.map(account => <AccountCard key={account.id} account={account} binding={bindingByAccount.get(account.id)} bindingsError={!!bindingsError} />)}
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
binding={bindingByAccount.get(account.id)}
bindingsError={!!bindingsError}
/>
))}
</div>
</PageState>
<AccountCreateModal open={createOpen} onClose={() => { setCreateOpen(false); setCreateError(null) }} onSubmit={createAccount} busy={busy} error={createError} />
<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 · 账号详情')
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)
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 任务没有自动恢复。' })
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) })
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setActionBusy(false)
setActionBusy(false);
}
}
async function createDraft(event) {
event.preventDefault()
if (!draftText.trim() || !readiness?.ready) return
setDraftBusy(true)
setNotice(null)
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} 已创建,请进入只读快照核对。` })
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) })
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setDraftBusy(false)
setDraftBusy(false);
}
}
if (isPending) return <PageState pending />
if (error || !account) return <Alert variant="destructive">{error?.message || '账号不存在'}</Alert>
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
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}
{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>
<Button size="sm" onClick={() => refetchBrowsers()}>
重试环境状态
</Button>
</Alert>
) : null}
<Alert variant="warning" className="mb-6">暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。</Alert>
<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('、') || '无'],
]} />
<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>
<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>
@@ -271,21 +562,48 @@ export function AccountDetail() {
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">固定资源</h2>
{browsersError ? (
<p className="text-sm text-muted">运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。</p>
<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>
<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>
<p className="text-sm text-muted">
尚未绑定运行环境。账号暂停且资源就绪后,可在运行环境页创建绑定。
</p>
<Link
to="/browsers"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
前往运行环境
</Link>
</>
)}
</CardContent>
@@ -293,24 +611,62 @@ export function AccountDetail() {
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">文本草稿</h2>
<p className="text-sm text-muted">仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。</p>
<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
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>
<Button
variant="primary"
type="submit"
busy={draftBusy}
busyText="创建中…"
disabled={!readiness?.ready || !draftText.trim()}
>
创建草稿
</Button>
</form>
<p className="text-sm text-muted">尚无草稿时无需处理;创建后请进入草稿核对。</p>
<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">
{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>
<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>
<Link
to={`/drafts/${draft.id}`}
className="shrink-0 text-sm font-medium text-primary hover:underline"
>
核对草稿
</Link>
</div>
</div>
))}
@@ -319,9 +675,17 @@ export function AccountDetail() {
</CardContent>
</Card>
</div>
<ConfirmDialog open={pauseOpen} onClose={() => setPauseOpen(false)} onConfirm={() => { setPauseOpen(false); runAction('pause') }}
title="暂停账号" confirmLabel="确认暂停"
body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`} />
<ConfirmDialog
open={pauseOpen}
onClose={() => setPauseOpen(false)}
onConfirm={() => {
setPauseOpen(false);
runAction("pause");
}}
title="暂停账号"
confirmLabel="确认暂停"
body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}
/>
</div>
)
);
}