Files
EveryPublish/sau_web_react/src/pages/Account/index.tsx
T
Qiufeng fd944b08bd feat: EveryPublish Web 管理端重构与全平台发布接入
- 新增 React + TDesign 管理端(sau_web_react),替代历史 Vue 前端
- 后端接入 11 平台登录/发布(视频+图文),check_cookie 扩展全平台
- 异步发布任务 + 实时进度监控 + 发布日志
- Open API 开放体系(API Key 鉴权 + 7 个开放接口 + API 文档页)
- 账号单/批量校验与批量删除
- 全局配置(登录方式 + 发布静默模式)
- 统一浏览器为系统 Chrome,品牌文案统一为 EveryPublish
2026-08-21 14:41:47 +08:00

448 lines
15 KiB
TypeScript

import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Table, Tag, Button, Input, Select, Dialog, MessagePlugin, DialogPlugin, Loading, Progress } from 'tdesign-react';
import { SearchIcon, AddIcon, RefreshIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { loadAccounts, selectAccounts, selectAccountLoading } from 'modules/account';
import {
deleteAccount,
updateAccount,
downloadCookie,
uploadCookie,
getLoginSSEUrl,
checkAccounts,
getCheckStatus,
deleteAccounts,
IAccount,
ICheckTask,
} from 'services/account';
import { PLATFORMS, WEB_PLATFORMS, IPlatform } from 'configs/platforms';
import CommonStyle from 'styles/common.module.less';
import style from './index.module.less';
const platformOptions = [
{ label: '全部平台', value: 'all' },
...PLATFORMS.map((p) => ({ label: p.name, value: String(p.type) })),
];
const statusTag = (status: number) => {
if (status === 1) return <Tag theme='success' variant='light'>正常</Tag>;
if (status === -1) return <Tag theme='warning' variant='light'>验证中</Tag>;
return <Tag theme='danger' variant='light'>异常</Tag>;
};
const Account = () => {
const dispatch = useAppDispatch();
const accounts = useAppSelector(selectAccounts);
const loading = useAppSelector(selectAccountLoading);
const [filterPlatform, setFilterPlatform] = useState<string>('all');
const [searchKeyword, setSearchKeyword] = useState('');
const [addVisible, setAddVisible] = useState(false);
const [editVisible, setEditVisible] = useState(false);
const [editTarget, setEditTarget] = useState<IAccount | null>(null);
const [editPlatform, setEditPlatform] = useState<number>(0);
const [editName, setEditName] = useState('');
// 登录态
const [loginPlatform, setLoginPlatform] = useState<number>(WEB_PLATFORMS[0]?.type || 1);
const [loginName, setLoginName] = useState('');
const [qrSrc, setQrSrc] = useState('');
const [loginStatus, setLoginStatus] = useState<'idle' | 'pending' | 'success' | 'fail'>('idle');
const [loginHint, setLoginHint] = useState('');
const esRef = useRef<EventSource | null>(null);
// 多选 + 校验任务
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
const [checkDialogVisible, setCheckDialogVisible] = useState(false);
const [checkTask, setCheckTask] = useState<ICheckTask | null>(null);
const checkPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
dispatch(loadAccounts());
}, [dispatch]);
useEffect(() => {
return () => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
};
}, []);
const filtered = useMemo(() => {
return accounts.filter((a) => {
const matchPlatform = filterPlatform === 'all' || String(a.type) === filterPlatform;
const kw = searchKeyword.trim().toLowerCase();
const matchKw = !kw || a.name.toLowerCase().includes(kw) || a.platform.includes(kw);
return matchPlatform && matchKw;
});
}, [accounts, filterPlatform, searchKeyword]);
const refresh = () => dispatch(loadAccounts());
const startCheck = async (ids: number[]) => {
if (!ids.length) {
MessagePlugin.warning('请先选择要校验的账号');
return;
}
try {
const res = await checkAccounts(ids);
if (!res.taskId) {
MessagePlugin.error('未返回校验任务 ID');
return;
}
setCheckDialogVisible(true);
pollCheck(res.taskId);
} catch {
MessagePlugin.error('提交校验任务失败');
}
};
const pollCheck = (taskId: string) => {
if (checkPollRef.current) clearInterval(checkPollRef.current);
const poll = async () => {
try {
const task = await getCheckStatus(taskId);
setCheckTask(task);
if (task && (task.status === 'done' || task.status === 'error')) {
if (checkPollRef.current) {
clearInterval(checkPollRef.current);
checkPollRef.current = null;
}
MessagePlugin.success('校验完成');
refresh();
}
} catch {
// 轮询失败继续
}
};
poll();
checkPollRef.current = setInterval(poll, 2000);
};
const batchDelete = () => {
if (!selectedRowKeys.length) {
MessagePlugin.warning('请先选择账号');
return;
}
DialogPlugin.confirm({
header: '批量删除',
body: '确定删除选中的 ' + selectedRowKeys.length + ' 个账号?对应 Cookie 文件也会被删除。',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteAccounts(selectedRowKeys as number[]);
MessagePlugin.success('删除成功');
setSelectedRowKeys([]);
refresh();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const onDelete = (row: IAccount) => {
DialogPlugin.confirm({
header: '删除账号',
body: '确定删除账号「' + row.name + '」?对应 Cookie 文件也会被删除。',
confirmBtn: { content: '删除', theme: 'danger' },
onConfirm: async () => {
try {
await deleteAccount(row.id);
MessagePlugin.success('删除成功');
refresh();
} catch {
MessagePlugin.error('删除失败');
}
},
});
};
const openEdit = (row: IAccount) => {
setEditTarget(row);
setEditPlatform(row.type);
setEditName(row.name);
setEditVisible(true);
};
const submitEdit = async () => {
if (!editTarget) return;
try {
await updateAccount({ id: editTarget.id, type: editPlatform, userName: editName });
MessagePlugin.success('更新成功');
setEditVisible(false);
refresh();
} catch {
MessagePlugin.error('更新失败');
}
};
const onUploadCookie = (row: IAccount) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files && input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('id', String(row.id));
formData.append('platform', String(row.type));
try {
await uploadCookie(formData);
MessagePlugin.success('Cookie 上传成功');
refresh();
} catch {
MessagePlugin.error('Cookie 上传失败');
}
};
input.click();
};
const startLogin = () => {
if (!loginName.trim()) {
MessagePlugin.warning('请输入账号名称');
return;
}
if (esRef.current) esRef.current.close();
setQrSrc('');
setLoginHint('');
setLoginStatus('pending');
const url = getLoginSSEUrl(loginPlatform, loginName.trim());
const es = new EventSource(url);
esRef.current = es;
es.onmessage = (e) => {
const msg = e.data;
if (msg === '200') {
setLoginStatus('success');
es.close();
esRef.current = null;
MessagePlugin.success('登录成功');
setAddVisible(false);
setLoginStatus('idle');
refresh();
} else if (msg === '500') {
setLoginStatus('fail');
es.close();
esRef.current = null;
MessagePlugin.error('登录失败');
} else if (msg && msg.startsWith('data:image')) {
setQrSrc(msg);
setLoginHint('');
} else if (msg && msg !== '') {
setLoginHint(msg);
}
};
es.onerror = () => {
if (esRef.current && loginStatus !== 'pending') {
es.close();
esRef.current = null;
}
};
};
const columns = [
{ colKey: 'row-select', type: 'multiple' as const, width: 50 },
{ colKey: 'name', title: '账号名称', width: 170, ellipsis: true },
{
colKey: 'platform',
title: '平台',
width: 140,
cell: ({ row }: { row: IAccount }) => {
const p = PLATFORMS.find((x) => x.type === row.type);
return <Tag theme={p?.theme || 'default'} variant='light'>{row.platform}</Tag>;
},
},
{
colKey: 'status',
title: '状态',
width: 120,
cell: ({ row }: { row: IAccount }) => (
<span style={{ cursor: 'pointer' }} onClick={() => startCheck([row.id])} title='点击校验该账号 Cookie'>
{statusTag(row.status)}
</span>
),
},
{
colKey: 'op',
title: '操作',
width: 320,
cell: ({ row }: { row: IAccount }) => (
<div className={style.opCell}>
<Button variant='text' theme='primary' onClick={() => startCheck([row.id])}>校验</Button>
<Button variant='text' theme='primary' onClick={() => openEdit(row)}>编辑</Button>
<Button variant='text' theme='primary' onClick={() => downloadCookie(row.filePath)}>下载Cookie</Button>
<Button variant='text' theme='primary' onClick={() => onUploadCookie(row)}>上传Cookie</Button>
<Button variant='text' theme='danger' onClick={() => onDelete(row)}>删除</Button>
</div>
),
},
];
const selectedPlatform = PLATFORMS.find((p) => p.type === loginPlatform) as IPlatform | undefined;
const loginSupported = selectedPlatform?.web === true;
return (
<div className={CommonStyle.pageWithPadding}>
<div className={style.toolbar}>
<div className={style.toolbarLeft}>
<Button icon={<AddIcon />} onClick={() => setAddVisible(true)}>添加账号</Button>
<Button icon={<RefreshIcon />} variant='outline' onClick={refresh} loading={loading}>刷新</Button>
<Button variant='outline' onClick={() => startCheck(selectedRowKeys as number[])}>校验所选</Button>
<Button variant='outline' theme='danger' onClick={batchDelete}>批量删除</Button>
</div>
<div className={style.toolbarRight}>
<Select
value={filterPlatform}
onChange={(v) => setFilterPlatform(String(v))}
options={platformOptions}
style={{ width: 160 }}
/>
<Input
value={searchKeyword}
onChange={setSearchKeyword}
placeholder='搜索账号名称'
prefixIcon={<SearchIcon />}
clearable
style={{ width: 240 }}
/>
</div>
</div>
<div className={style.tableWrap}>
<Table rowKey='id' data={filtered} columns={columns} loading={loading} hover empty='暂无账号数据' selectedRowKeys={selectedRowKeys} onSelectChange={(v) => setSelectedRowKeys(v)} />
</div>
{/* 添加账号(扫码登录) */}
<Dialog
header='添加账号'
visible={addVisible}
onClose={() => {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
setLoginStatus('idle');
setQrSrc('');
setLoginHint('');
setAddVisible(false);
}}
footer={false}
width={460}
>
<div className={style.loginForm}>
<div className={style.formItem}>
<label>平台</label>
<Select
value={loginPlatform}
onChange={(v) => {
setLoginPlatform(Number(v));
setQrSrc('');
setLoginHint('');
setLoginStatus('idle');
}}
options={PLATFORMS.map((p) => ({ label: p.name + (p.web ? '' : '(待接入)'), value: p.type }))}
style={{ width: '100%' }}
/>
</div>
<div className={style.formItem}>
<label>账号名称</label>
<Input value={loginName} onChange={setLoginName} placeholder='用于区分账号,如 creator1' />
</div>
{!loginSupported ? (
<p className={style.warnText}>
该平台登录暂未接入 Web 后端(已接入 CLI 主线)。可通过「上传 Cookie」方式导入账号。
</p>
) : (
<>
{loginStatus === 'idle' && (
<Button block theme='primary' onClick={startLogin} disabled={!loginName.trim()}>生成登录二维码</Button>
)}
{loginStatus === 'pending' && (
<div className={style.qrBox}>
{qrSrc ? (
<img src={qrSrc} alt='登录二维码' className={style.qrImg} />
) : loginHint ? (
<p className={style.warnText}>{loginHint}</p>
) : (
<div className={style.qrLoading}><Loading text='正在生成二维码...' /></div>
)}
{qrSrc && <p className={style.qrTip}>请使用对应平台 App 扫码登录</p>}
</div>
)}
{loginStatus === 'fail' && (
<div className={style.qrBox}>
<p className={style.warnText}>登录失败,请重试。</p>
<Button block theme='primary' onClick={startLogin}>重新生成二维码</Button>
</div>
)}
</>
)}
</div>
</Dialog>
{/* 编辑账号 */}
<Dialog
header='编辑账号'
visible={editVisible}
onClose={() => setEditVisible(false)}
onConfirm={submitEdit}
confirmBtn={{ content: '保存' }}
cancelBtn={{ content: '取消' }}
width={420}
>
<div className={style.loginForm}>
<div className={style.formItem}>
<label>平台</label>
<Select
value={editPlatform}
onChange={(v) => setEditPlatform(Number(v))}
options={PLATFORMS.map((p) => ({ label: p.name, value: p.type }))}
style={{ width: '100%' }}
/>
</div>
<div className={style.formItem}>
<label>账号名称</label>
<Input value={editName} onChange={setEditName} />
</div>
</div>
</Dialog>
{/* Cookie 校验进度 */}
<Dialog
header='Cookie 校验进度'
visible={checkDialogVisible}
onClose={() => setCheckDialogVisible(false)}
footer={false}
width={520}
>
{checkTask ? (
<div className={style.checkBox}>
<div className={style.checkHead}>
<span>进度:{checkTask.done} / {checkTask.total}</span>
{checkTask.status === 'done' && <Tag theme='success' variant='light'>完成</Tag>}
{checkTask.status === 'error' && <Tag theme='danger' variant='light'>出错</Tag>}
</div>
<Progress percentage={checkTask.total ? Math.round((checkTask.done / checkTask.total) * 100) : 0} theme='line' />
{checkTask.error && <p className={style.warnText}>{checkTask.error}</p>}
<div className={style.checkLogs}>
{checkTask.logs.length === 0 ? (
<p className={style.warnText}>校验中...</p>
) : (
checkTask.logs.map((log, i) => <div key={i} className={style.checkLogLine}>{log}</div>)
)}
</div>
</div>
) : (
<p className={style.warnText}>正在提交校验任务...</p>
)}
</Dialog>
</div>
);
};
export default memo(Account);