- 新增 React + TDesign 管理端(sau_web_react),替代历史 Vue 前端 - 后端接入 11 平台登录/发布(视频+图文),check_cookie 扩展全平台 - 异步发布任务 + 实时进度监控 + 发布日志 - Open API 开放体系(API Key 鉴权 + 7 个开放接口 + API 文档页) - 账号单/批量校验与批量删除 - 全局配置(登录方式 + 发布静默模式) - 统一浏览器为系统 Chrome,品牌文案统一为 EveryPublish
448 lines
15 KiB
TypeScript
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);
|