server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物

This commit is contained in:
Qiufeng
2026-08-21 10:53:32 +08:00
parent 0daa9782c9
commit 3585c39bab
103 changed files with 5842 additions and 3197 deletions
+2 -2
View File
@@ -11,8 +11,8 @@
"build:site": "vite build --mode site",
"site:preview": "npm run build && cp -r dist _site",
"preview": "vite preview --mode test",
"test": "echo \"no test specified,work in process\"",
"test:coverage": "echo \"no test specified,work in process\"",
"test": "node ../../tools/web-smoke.mjs",
"test:coverage": "node ../../tools/web-smoke.mjs",
"lint": "eslint ./src --ext ts,tsx",
"lint:fix": "eslint ./src --ext ts,tsx --fix",
"prepare": "husky install"
@@ -1,9 +1,11 @@
import React, { memo } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Badge, Dropdown, Space } from 'tdesign-react';
import { Button, Badge, Dropdown, Space, Tag } from 'tdesign-react';
import { Icon, MailIcon, PoweroffIcon, UserCircleIcon } from 'tdesign-icons-react';
import { useAppDispatch, useAppSelector } from 'modules/store';
import { logout, selectUser } from 'modules/user';
import { disconnectWS, onWS } from 'utils/ws';
import request from 'utils/request';
import Style from './HeaderIcon.module.less';
const { DropdownMenu, DropdownItem } = Dropdown;
@@ -13,6 +15,26 @@ export default memo(() => {
const navigate = useNavigate();
const userState = useAppSelector(selectUser);
const nickname = userState.userInfo.nickname || userState.userInfo.email || '未登录';
const [wsOnline, setWsOnline] = useState(false);
const [unread, setUnread] = useState(0);
useEffect(() => {
const offConnection = onWS('connection.status', (payload) => setWsOnline(Boolean(payload?.online)));
const loadNotifications = async () => {
try {
const res = await request.get('/notifications');
setUnread(Number(res.data.unread || 0));
} catch (e) {
setUnread(0);
}
};
loadNotifications();
const offNotification = onWS('notification.new', loadNotifications);
return () => {
offConnection();
offNotification();
};
}, []);
const clickHandler = (data: any) => {
if (data.value === 1) {
@@ -22,13 +44,17 @@ export default memo(() => {
const handleLogout = async () => {
await dispatch(logout());
disconnectWS();
navigate('/login/index');
};
return (
<Space align='center'>
<Badge className={Style.badge} count={0} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} />
<Tag theme={wsOnline ? 'success' : 'warning'} variant='light'>
{wsOnline ? '实时连接' : '连接重试中'}
</Tag>
<Badge className={Style.badge} count={unread} dot={false} maxCount={99} shape='circle' showZero={false} size='medium'>
<Button className={Style.menuIcon} shape='square' size='large' variant='text' icon={<MailIcon />} onClick={() => navigate('/notification/list')} />
</Badge>
<Dropdown trigger={'click'} onClick={clickHandler}>
<Button variant='text' className={Style.dropdown}>
+139 -51
View File
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, Select, Space, Table, Tag } from 'tdesign-react';
import { Button, Card, Dialog, Form, FormInstanceFunctions, Input, MessagePlugin, Popconfirm, QRCode, Select, Space, Table, Tag, Textarea } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
import { PLATFORM_LABEL, PLATFORM_OPTIONS } from 'constants/platforms';
import { useAppSelector } from 'modules/store';
import { selectUser } from 'modules/user';
const { FormItem } = Form;
@@ -15,14 +17,11 @@ interface IAccount {
avatarUrl: string;
status: string;
health: number;
agentDeviceId: number;
ipProfile: string;
lastActiveAt?: string | null;
lastError: string;
}
interface IDevice { id: number; name: string; status: string; revoked: boolean; }
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
unbound: { label: '未绑定', theme: 'default' },
binding: { label: '绑定中', theme: 'warning' },
@@ -32,12 +31,20 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
};
export default function AccountList() {
const userState = useAppSelector(selectUser);
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
const canAdmin = ['owner', 'admin'].includes(userState.memberRole || '');
const [list, setList] = useState<IAccount[]>([]);
const [devices, setDevices] = useState<IDevice[]>([]);
const [loading, setLoading] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
const [bindAccount, setBindAccount] = useState<IAccount | null>(null);
const [bindTip, setBindTip] = useState('');
const [bindChallenge, setBindChallenge] = useState<{ id: number; qrUrl: string; qrToken: string; status: string; prompt: string } | null>(null);
const [credentialsTarget, setCredentialsTarget] = useState<IAccount | null>(null);
const [credentialsValue, setCredentialsValue] = useState('');
const [credentialsSaving, setCredentialsSaving] = useState(false);
const [checkingId, setCheckingId] = useState(0);
const bindAccountRef = useRef<IAccount | null>(null);
const formRef = useRef<FormInstanceFunctions>();
const load = useCallback(async () => {
@@ -52,34 +59,31 @@ export default function AccountList() {
}
}, []);
const loadDevices = useCallback(async () => {
try {
const res = await request.get('/agent/devices');
setDevices(((res.data.list || []) as IDevice[]).filter((d) => d.status === 'online' && !d.revoked));
} catch (e) {
setDevices([]);
}
}, []);
useEffect(() => {
load();
const off = onWS('challenge.status', (p) => {
if (p?.status === 'solved') {
const current = bindAccountRef.current;
if (p?.accountId && current?.id === p.accountId) {
setBindTip(p.prompt || (p.status === 'scanned' ? '已扫码,请在手机上确认登录' : p.status === 'pending' ? '等待扫码' : '登录状态已更新'));
}
if (p?.status === 'solved' && current?.id === p.accountId) {
MessagePlugin.success('账号绑定成功');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
load();
}
});
return off;
const offAccount = onWS('account.status', () => load());
return () => { off(); offAccount(); };
}, [load]);
const openCreate = () => {
loadDevices();
setCreateVisible(true);
};
const create = async () => {
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { platform: string; agentDeviceId: number; remark: string };
const value = (formRef.current?.getFieldsValue?.(true) || {}) as { platform: string; remark: string };
if (!value.platform) {
MessagePlugin.warning('请选择平台');
return;
@@ -87,7 +91,6 @@ export default function AccountList() {
try {
await request.post('/accounts', {
platform: value.platform,
agentDeviceId: value.agentDeviceId || 0,
remark: value.remark || '',
});
MessagePlugin.success('账号已添加');
@@ -100,13 +103,66 @@ export default function AccountList() {
const bind = async (row: IAccount) => {
setBindAccount(row);
bindAccountRef.current = row;
setBindTip('');
try {
const res = await request.post('/accounts/' + row.id + '/bind');
setBindTip('已发起绑定,等待客户端扫码(挑战 #' + res.data.challengeId + ')...');
setBindChallenge({
id: res.data.challengeId,
qrUrl: res.data.qrUrl || '',
qrToken: res.data.qrToken || '',
status: 'active',
prompt: res.data.prompt || '等待扫码',
});
setBindTip(res.data.prompt || '等待扫码');
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '发起绑定失败');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
}
};
const unbind = async (row: IAccount) => {
try {
await request.post('/accounts/' + row.id + '/unbind');
MessagePlugin.success('账号已解绑');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '解绑失败');
}
};
const importCredentials = async () => {
if (!credentialsTarget || !credentialsValue.trim()) {
MessagePlugin.warning('请输入平台 Cookie 凭据');
return;
}
setCredentialsSaving(true);
try {
await request.post('/accounts/' + credentialsTarget.id + '/credentials', { cookies: credentialsValue.trim() });
MessagePlugin.success('凭据已加密保存,账号已恢复可用');
setCredentialsTarget(null);
setCredentialsValue('');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '凭据导入失败');
} finally {
setCredentialsSaving(false);
}
};
const checkAccount = async (row: IAccount) => {
setCheckingId(row.id);
try {
await request.post('/accounts/' + row.id + '/check');
MessagePlugin.success('账号凭据有效');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '账号检查失败');
load();
} finally {
setCheckingId(0);
}
};
@@ -120,8 +176,6 @@ export default function AccountList() {
}
};
const deviceName = (id: number) => devices.find((d) => d.id === id)?.name || ('设备 #' + id);
const columns = [
{
colKey: 'platform',
@@ -149,12 +203,6 @@ export default function AccountList() {
return <Tag theme={st.theme as any}>{st.label}</Tag>;
},
},
{
colKey: 'agentDeviceId',
title: '执行设备',
width: 140,
cell: ({ row }: { row: IAccount }) => <span>{row.agentDeviceId ? deviceName(row.agentDeviceId) : '-'}</span>,
},
{
colKey: 'lastActiveAt',
title: '最后活跃',
@@ -166,39 +214,49 @@ export default function AccountList() {
{
colKey: 'op',
title: '操作',
width: 140,
width: 230,
cell: ({ row }: { row: IAccount }) => (
<Space size={0}>
{(row.status === 'unbound' || row.status === 'expired') && (
{canOperate && (row.status === 'unbound' || row.status === 'expired' || row.status === 'active') && (
<Button size='small' variant='text' theme='primary' onClick={() => bind(row)}>
绑定
{row.status === 'active' ? '重新登录' : '登录'}
</Button>
)}
{row.status === 'unbound' && (
{canOperate && (row.status === 'unbound' || row.status === 'expired') && (
<Button size='small' variant='text' onClick={() => { setCredentialsTarget(row); setCredentialsValue(''); }}>
导入凭据
</Button>
)}
{canOperate && row.status === 'active' && (
<Button size='small' variant='text' loading={checkingId === row.id} onClick={() => checkAccount(row)}>
检查
</Button>
)}
{canAdmin && row.status === 'unbound' && (
<Popconfirm content='确认删除该账号?' onConfirm={() => remove(row)}>
<Button size='small' variant='text' theme='danger'>
删除
</Button>
</Popconfirm>
)}
{canAdmin && (row.status === 'active' || row.status === 'binding' || row.status === 'expired') && (
<Popconfirm content='解绑后需要重新登录,确认?' onConfirm={() => unbind(row)}>
<Button size='small' variant='text' theme='danger'>
解绑
</Button>
</Popconfirm>
)}
</Space>
),
},
];
const deviceOptions = [
{ label: '暂不指定(稍后选择)', value: 0 },
...devices.map((d) => ({ label: d.name + '(在线)', value: d.id })),
];
return (
<Card
title='账号台账'
actions={
<Space>
<Button theme='primary' onClick={openCreate}>
添加账号
</Button>
{canOperate && <Button theme='primary' onClick={openCreate}>添加账号</Button>}
<Button variant='outline' onClick={load}>
刷新
</Button>
@@ -206,7 +264,7 @@ export default function AccountList() {
}
>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>凭据只保存在客户端本机保险库,服务器零凭据;账号名称/头像由客户端绑定后自动获取。</div>
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>当前为 Web-only 本地模式:登录挑战和平台浏览器 profile 在运行服务器的设备上完成,不需要客户端配对。</div>
<Dialog
header='添加平台账号'
visible={createVisible}
@@ -226,9 +284,6 @@ export default function AccountList() {
<FormItem label='平台' name='platform' rules={[{ required: true, message: '请选择平台', type: 'error' }]}>
<Select options={PLATFORM_OPTIONS} placeholder='选择平台' />
</FormItem>
<FormItem label='执行设备' name='agentDeviceId' initialData={0}>
<Select options={deviceOptions} placeholder={devices.length ? '选择在线设备' : '无在线设备'} />
</FormItem>
<FormItem label='备注' name='remark'>
<Input placeholder='选填,如「品牌主号」「投放账号」' />
</FormItem>
@@ -237,17 +292,50 @@ export default function AccountList() {
<Dialog
header={'绑定 ' + (bindAccount ? (bindAccount.remark || PLATFORM_LABEL[bindAccount.platform] || '') : '')}
visible={!!bindAccount}
onClose={() => setBindAccount(null)}
onClose={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}
footer={null}
>
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<div style={{ marginBottom: 12 }}>📱 {bindTip || '正在发起绑定...'}</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 12 }}>请在客户端设备上完成扫码/验证,完成后本窗口自动关闭。</div>
<Button variant='outline' onClick={() => setBindAccount(null)}>
稍后处理(可在挑战中心继续)
</Button>
<div style={{ padding: '8px 0 16px', textAlign: 'center' }}>
{bindChallenge?.qrUrl && !bindChallenge.qrUrl.startsWith('mock://') && (
<QRCode value={bindChallenge.qrUrl} size={220} />
)}
<div style={{ margin: '14px 0 6px', fontWeight: 600 }}>{bindTip || '正在发起登录...'}</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 14 }}>
二维码由当前服务器生成并轮询状态,不需要客户端配对码。
</div>
{bindChallenge?.qrUrl?.startsWith('mock://') && (
<Button theme='primary' onClick={async () => {
if (!bindChallenge) return;
try {
await request.post('/challenges/' + bindChallenge.id + '/solve', { value: 'web-mock-login' });
MessagePlugin.success('本地模拟登录已完成');
setBindAccount(null);
bindAccountRef.current = null;
setBindChallenge(null);
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '模拟登录失败');
}
}}>完成本地模拟登录</Button>
)}
<div style={{ marginTop: 12 }}>
<Button variant='outline' onClick={() => { setBindAccount(null); bindAccountRef.current = null; setBindChallenge(null); }}>
稍后处理
</Button>
</div>
</div>
</Dialog>
<Dialog
header={'导入 ' + (credentialsTarget ? (credentialsTarget.remark || PLATFORM_LABEL[credentialsTarget.platform] || '') : '') + ' 凭据'}
visible={!!credentialsTarget}
onClose={() => { setCredentialsTarget(null); setCredentialsValue(''); }}
confirmBtn={<Button theme='primary' loading={credentialsSaving} onClick={importCredentials}>加密保存</Button>}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 10 }}>
仅支持已接入真实适配器的平台。凭据只写入当前服务器的加密目录,不会显示在列表、日志或响应中。
</div>
<Textarea value={credentialsValue} onChange={(v) => setCredentialsValue(v as string)} rows={5} placeholder='粘贴平台 Cookie 字符串' />
</Dialog>
</Card>
);
}
-100
View File
@@ -1,100 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, MessagePlugin, Popconfirm, Table, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
interface IAgent {
id: number;
name: string;
workspaceName: string;
os: string;
version: string;
status: string;
revoked: boolean;
lastSeenAt?: string | null;
}
export default function AdminAgent() {
const [list, setList] = useState<IAgent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/admin/agents?page=' + page + '&size=20');
setList((res.data.list || []) as IAgent[]);
setTotal(res.data.total || 0);
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '加载失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
load();
}, [load]);
const revoke = async (a: IAgent) => {
try {
await request.put('/admin/agents/' + a.id + '/revoke');
MessagePlugin.success('已强制吊销');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '操作失败');
}
};
const columns = [
{ colKey: 'id', title: 'ID', width: 70 },
{ colKey: 'name', title: '设备名', ellipsis: true },
{ colKey: 'workspaceName', title: '所属租户', ellipsis: true },
{ colKey: 'os', title: '系统', width: 90 },
{ colKey: 'version', title: '版本', width: 110 },
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IAgent }) => {
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
},
},
{
colKey: 'lastSeenAt',
title: '最后活跃',
width: 160,
cell: ({ row }: { row: IAgent }) => (
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
),
},
{
colKey: 'op',
title: '操作',
width: 110,
cell: ({ row }: { row: IAgent }) =>
row.revoked ? null : (
<Popconfirm content='强制吊销后设备立即下线?' onConfirm={() => revoke(row)}>
<Button size='small' variant='text' theme='danger'>
强制吊销
</Button>
</Popconfirm>
),
},
];
return (
<Card title='Agent 总览' subtitle='全局设备:在线/版本/强制吊销'>
<Table
data={list}
columns={columns}
rowKey='id'
loading={loading}
hover
pagination={{ current: page, total, pageSize: 20, onChange: (p) => setPage(p.current) }}
/>
</Card>
);
}
+41 -50
View File
@@ -43,12 +43,13 @@ export const globalErrorCodes: ApiErrorCode[] = [
{ code: 2003, desc: '刷新令牌无效或已失效' },
{ code: 2004, desc: '已是该工作空间成员' },
{ code: 2005, desc: '邀请链接无效或已过期' },
{ code: 2006, desc: '配对码无效或已过期' },
{ code: 3001, desc: '绑定中/已绑定账号不可删除' },
{ code: 3002, desc: '账号已绑定' },
{ code: 3003, desc: '挑战已结束(不可重发/挂起)' },
{ code: 3004, desc: '下载链接已过期' },
{ code: 3005, desc: '任务当前状态不允许该操作' },
{ code: 3006, desc: '当前模式或平台不支持该操作' },
{ code: 2007, desc: '平台适配器或外部服务失败' },
{ code: 5000, desc: '服务内部错误' },
];
@@ -102,6 +103,11 @@ export const apiGroups: ApiGroup[] = [
params: [{ name: 'refreshToken', type: 'string', required: false, desc: '要吊销的 refreshToken' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/logout -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"refreshToken":"<refresh-token>"}'`,
},
{
method: 'POST', path: '/auth/password', summary: '修改当前用户密码', auth: 'Bearer', status: '200 / 400 / 401',
params: [{ name: 'currentPassword', type: 'string', required: true, desc: '当前密码' }, { name: 'newPassword', type: 'string', required: true, desc: '新密码,至少 8 位' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/auth/password -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"currentPassword":"old-pass","newPassword":"new-pass-2026"}'`,
},
{
method: 'GET',
path: '/auth/me',
@@ -169,7 +175,7 @@ export const apiGroups: ApiGroup[] = [
{
key: 'account',
title: '账号台账 /accounts',
desc: '平台账号登记与绑定(凭据仅在客户端本机,服务器零凭据)',
desc: '平台账号登记与网页本机登录;凭据和浏览器 profile 由当前服务器本机执行器管理',
apis: [
{
method: 'GET', path: '/accounts', summary: '账号列表(platform/status 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
@@ -180,7 +186,7 @@ export const apiGroups: ApiGroup[] = [
method: 'POST', path: '/accounts', summary: '新增账号(初始 unbound)', auth: 'Bearer', status: '200 / 400 / 401',
params: [
{ name: 'platform', type: 'string', required: true, desc: 'douyin/kuaishou/xiaohongshu/bilibili' },
{ name: 'accountName', type: 'string', required: true, desc: '账号名称' },
{ name: 'accountName', type: 'string', required: false, desc: '账号名称,可在网页登录后同步' },
{ name: 'avatarUrl', type: 'string', required: false, desc: '头像 URL' },
{ name: 'ipProfile', type: 'string', required: false, desc: 'IP 画像标识(一账号固定 IP)' },
],
@@ -196,9 +202,22 @@ export const apiGroups: ApiGroup[] = [
curl: `curl -X DELETE http://127.0.0.1:8090/api/v1/accounts/1 -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/bind', summary: '发起绑定 → 创建挑战 → 推送给在线设备扫码', auth: 'Bearer', status: '200 / 400 / 401 / 404 / 409(3002)',
method: 'POST', path: '/accounts/:id/bind', summary: '发起网页登录挑战(QR/验证码)', auth: 'Bearer', status: '200 / 400 / 401 / 404',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/bind -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/credentials', summary: '导入平台 Cookie(服务端加密保存)', auth: 'Bearer(owner/admin/operator)', status: '200 / 400 / 401 / 409(3006)',
params: [{ name: 'cookies', type: 'string', required: true, desc: '平台 Cookie 字符串,不会回显' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/credentials -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"cookies":"SESSDATA=...; bili_jct=...; DedeUserID=..."}'`,
},
{
method: 'POST', path: '/accounts/:id/check', summary: '调用平台适配器检查凭据', auth: 'Bearer(owner/admin/operator)', status: '200 / 409(3006) / 502(2007)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/check -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/accounts/:id/unbind', summary: '解绑并清理本机登录状态', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/accounts/1/unbind -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
@@ -246,7 +265,7 @@ export const apiGroups: ApiGroup[] = [
{ name: 'tags', type: 'string[]', required: false, desc: '话题标签' },
{ name: 'accountIds', type: 'number[]', required: true, desc: '账号 ID 数组(至少 1 个)' },
{ name: 'materialIds', type: 'number[]', required: true, desc: '素材 ID 数组(至少 1 个)' },
{ name: 'scheduleAt', type: 'number', required: false, desc: '定时发布时间 unix 毫秒' },
{ name: 'scheduleAt', type: 'number', required: false, desc: '定时发布时间 unix 毫秒,必须晚于当前时间' },
{ name: 'priority', type: 'number', required: false, desc: '优先级 1-10(默认 5)' },
],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"title":"新品发布","accountIds":[1],"materialIds":[1]}'`,
@@ -268,7 +287,7 @@ export const apiGroups: ApiGroup[] = [
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/submit -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/tasks/:id/approve', summary: '审核通过(→queued,在线设备立即下发)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
method: 'POST', path: '/tasks/:id/approve', summary: '审核通过(→queued,本机 Web 执行器立即处理)', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/approve -H 'Authorization: Bearer <accessToken>'`,
},
{
@@ -288,6 +307,10 @@ export const apiGroups: ApiGroup[] = [
method: 'POST', path: '/tasks/:id/cancel', summary: '取消任务', auth: 'Bearer', status: '200 / 401 / 404 / 409(3005)',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/tasks/1/cancel -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'GET', path: '/tasks/:id/events', summary: '任务状态时间线和执行日志', auth: 'Bearer', status: '200 / 401 / 404',
curl: `curl http://127.0.0.1:8090/api/v1/tasks/1/events -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
@@ -336,7 +359,7 @@ export const apiGroups: ApiGroup[] = [
{
key: 'audit',
title: '审计 /audit-logs',
desc: '操作审计(登录/登出/增删改/审核/挑战/设备,自动埋点)',
desc: '操作审计(登录/登出/增删改/审核/挑战,自动埋点)',
apis: [
{
method: 'GET', path: '/audit-logs', summary: '审计日志(userId/action 筛选 + 分页)', auth: 'Bearer', status: '200 / 401',
@@ -344,37 +367,13 @@ export const apiGroups: ApiGroup[] = [
},
],
},
{
key: 'agent',
title: '设备 /agent',
desc: '客户端设备配对与吊销(Ed25519 公钥注册)',
apis: [
{
method: 'POST', path: '/agent/pair-code', summary: '生成配对码(6 位,5 分钟一次性)', auth: 'Bearer', status: '200 / 401',
curl: `curl -X POST http://127.0.0.1:8090/api/v1/agent/pair-code -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'POST', path: '/agent/pair', summary: '设备配对(凭码注册公钥)', auth: '无(凭配对码)', status: '200 / 400 / 410(2006)',
params: [{ name: 'code', type: 'string', required: true, desc: '配对码' }, { name: 'deviceName', type: 'string', required: true, desc: '设备名' }, { name: 'publicKey', type: 'string', required: true, desc: 'Ed25519 公钥 base64' }],
curl: `curl -X POST http://127.0.0.1:8090/api/v1/agent/pair -H 'Content-Type: application/json' -d '{"code":"AB12CD","deviceName":"PC-01","publicKey":"<ed25519-pub-b64>"}'`,
},
{
method: 'GET', path: '/agent/devices', summary: '设备列表(在线状态)', auth: 'Bearer', status: '200 / 401',
curl: `curl http://127.0.0.1:8090/api/v1/agent/devices -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'PUT', path: '/agent/devices/:id/revoke', summary: '吊销设备并强制下线', auth: 'Bearer(owner/admin)', status: '200 / 401 / 403 / 404',
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/agent/devices/1/revoke -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
key: 'admin',
title: '平台管理 /admin',
desc: '仅平台管理员(role=admin)可用',
apis: [
{
method: 'GET', path: '/admin/tenants', summary: '租户列表(owner 邮箱/成员数/设备数)', auth: 'Bearer(admin)', status: '200 / 401 / 403(1003)',
method: 'GET', path: '/admin/tenants', summary: '租户列表(owner 邮箱/成员数)', auth: 'Bearer(admin)', status: '200 / 401 / 403(1003)',
curl: `curl 'http://127.0.0.1:8090/api/v1/admin/tenants' -H 'Authorization: Bearer <accessToken>'`,
},
{
@@ -382,14 +381,6 @@ export const apiGroups: ApiGroup[] = [
params: [{ name: 'status', type: 'string', required: true, desc: 'active/suspended' }],
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/admin/tenants/1/status -H 'Authorization: Bearer <accessToken>' -H 'Content-Type: application/json' -d '{"status":"suspended"}'`,
},
{
method: 'GET', path: '/admin/agents', summary: '全局 Agent 总览(含所属租户)', auth: 'Bearer(admin)', status: '200 / 401 / 403',
curl: `curl http://127.0.0.1:8090/api/v1/admin/agents -H 'Authorization: Bearer <accessToken>'`,
},
{
method: 'PUT', path: '/admin/agents/:id/revoke', summary: '全局强制吊销设备', auth: 'Bearer(admin)', status: '200 / 401 / 403 / 404',
curl: `curl -X PUT http://127.0.0.1:8090/api/v1/admin/agents/1/revoke -H 'Authorization: Bearer <accessToken>'`,
},
],
},
{
@@ -403,23 +394,23 @@ export const apiGroups: ApiGroup[] = [
},
],
},
{
key: 'runtime',
title: '运行状态 /runtime/status',
desc: '当前执行模式、已注册适配器、数据库和 Redis 状态',
apis: [{ method: 'GET', path: '/runtime/status', summary: '读取当前网页执行器状态', auth: 'Bearer', status: '200 / 401', curl: `curl http://127.0.0.1:8090/api/v1/runtime/status -H 'Authorization: Bearer <accessToken>'` }],
},
{
key: 'ws',
title: 'WSS 双通道 /ws',
desc: '实时通道:Agent 设备签名 / 浏览器 JWT(WebSocket)',
title: '浏览器实时通道 /ws',
desc: '网页端 Browser WebSocket,断开后 REST 兜底',
apis: [
{
method: 'GET', path: '/ws/agent', summary: 'Agent 通道(Ed25519 hello 验签;消息 hello/heartbeat/task.push/task.ack/task.result/challenge)', auth: 'Ed25519 设备签名', status: '101 升级 / 4408 拒绝',
curl: `# 需客户端代码连接(fake-agent / client-core);首条消息 hello:{deviceId,nonce,ts,sig=ed25519(nonce|ts)}`,
note: '任务下发延迟实测约 28ms(硬指标 <300ms)',
},
{
method: 'GET', path: '/ws/browser', summary: '浏览器通道(?token=JWT,只读订阅 task.status/agent.status/challenge.status)', auth: 'JWT(query token)', status: '101 升级 / 401',
curl: `# 前端 utils/ws.ts 自动连接;地址 ws://127.0.0.1:3003/ws/browser?token=<JWT>`,
method: 'GET', path: '/ws/browser', summary: '浏览器通道(?token=JWT,订阅 task.status/challenge.status/notification.new)', auth: 'JWT(query token)', status: '101 升级 / 401',
curl: `# 前端 utils/ws.ts 自动连接;地址 ws://127.0.0.1:3013/ws/browser?token=<JWT>`,
},
],
},
];
export default apiGroups;
@@ -10,7 +10,6 @@ interface ITenant {
plan: string;
status: string;
memberCount: number;
deviceCount: number;
createdAt: string;
}
@@ -53,7 +52,6 @@ export default function AdminTenant() {
{ colKey: 'ownerEmail', title: 'Owner 邮箱', ellipsis: true },
{ colKey: 'plan', title: '套餐', width: 90 },
{ colKey: 'memberCount', title: '成员', width: 80 },
{ colKey: 'deviceCount', title: '设备', width: 80 },
{
colKey: 'status',
title: '状态',
-1
View File
@@ -71,7 +71,6 @@ export default function AuditList() {
{ label: '账号绑定', value: 'account.bind' },
{ label: '素材', value: 'material.upload' },
{ label: '成员', value: 'member.invite' },
{ label: '设备', value: 'agent.revoke' },
]}
style={{ width: 160 }}
/>
+15 -7
View File
@@ -17,7 +17,7 @@ interface IChallenge {
createdAt: string;
}
const KIND_LABEL: Record<string, string> = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
const KIND_LABEL: Record<string, string> = { qr: '扫码', sms: '短信验证码', confirm: '应用确认', captcha: '图形验证码', pending: '等待验证' };
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
active: { label: '待处理', theme: 'warning' },
solved: { label: '已完成', theme: 'success' },
@@ -92,9 +92,16 @@ export default function ChallengeList() {
cell: ({ row }: { row: IChallenge }) => (
<Space size={0}>
{row.kind === 'qr' && row.status === 'active' && (
<Button size='small' variant='text' theme='primary' onClick={() => setQrTarget(row)}>
显示二维码
</Button>
<>
<Button size='small' variant='text' theme='primary' onClick={() => setQrTarget(row)}>
显示二维码
</Button>
{row.qrUrl?.startsWith('mock://') && (
<Button size='small' variant='text' onClick={() => act(row, 'solve', { value: 'web-mock-login' })}>
模拟完成
</Button>
)}
</>
)}
{(row.kind === 'sms' || row.kind === 'captcha') && row.status === 'active' && (
<Button size='small' variant='text' theme='primary' onClick={() => setSolveTarget(row)}>
@@ -107,7 +114,7 @@ export default function ChallengeList() {
</Button>
)}
{row.status === 'active' && (
<Popconfirm content='挂起后客户端停止等待?' onConfirm={() => act(row, 'suspend')}>
<Popconfirm content='挂起后将停止等待本次登录,确认?' onConfirm={() => act(row, 'suspend')}>
<Button size='small' variant='text' theme='warning'>
挂起
</Button>
@@ -119,7 +126,7 @@ export default function ChallengeList() {
];
return (
<Card title='挑战中心' subtitle='二次验证兜底:扫码/验证码/APP 确认;超时自动挂起,可一键重发'>
<Card title='挑战中心' subtitle='二次验证兜底:扫码/验证码/应用确认;超时自动挂起,可一键重发'>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<Dialog
header='扫码验证(本地渲染,不截图传输)'
@@ -128,8 +135,9 @@ export default function ChallengeList() {
footer={null}
>
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<QRCode value={qrTarget?.qrToken || qrTarget?.qrUrl || 'pending'} size={220} />
<QRCode value={qrTarget?.qrUrl?.startsWith('mock://') ? qrTarget.qrUrl : (qrTarget?.qrUrl || qrTarget?.qrToken || 'pending')} size={220} />
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>{qrTarget?.prompt}</div>
<div style={{ fontSize: 12, color: '#999', marginTop: 6 }}>二维码只需在此页面扫码;客户端配对码不参与平台登录。</div>
</div>
</Dialog>
<Dialog
+19 -38
View File
@@ -18,8 +18,8 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
};
interface ITask { id: number; title: string; status: string; scheduleAt?: string | null; createdAt: string; publishedAt?: string | null; }
interface IDevice { id: number; name: string; status: string; version: string; revoked: boolean; lastSeenAt?: string | null; }
interface IChallenge { id: number; status: string; }
interface IRuntime { executorMode: string; adapters: string[]; db: boolean; redis: boolean; }
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
@@ -32,17 +32,11 @@ function StatCard({ label, value, hint }: { label: string; value: string; hint?:
}
export default function DashboardBase() {
const [devices, setDevices] = useState<IDevice[]>([]);
const [tasks, setTasks] = useState<ITask[]>([]);
const [challenges, setChallenges] = useState<IChallenge[]>([]);
const [runtime, setRuntime] = useState<IRuntime | null>(null);
const load = useCallback(async () => {
try {
const d = await request.get('/agent/devices');
setDevices((d.data.list || []) as IDevice[]);
} catch (e) {
setDevices([]);
}
try {
const t = await request.get('/tasks?size=100');
setTasks((t.data.list || []) as ITask[]);
@@ -55,24 +49,27 @@ export default function DashboardBase() {
} catch (e) {
setChallenges([]);
}
try {
const r = await request.get('/runtime/status');
setRuntime(r.data as IRuntime);
} catch (e) {
setRuntime(null);
}
}, []);
useEffect(() => {
load();
const offTask = onWS('task.status', () => load());
const offAgent = onWS('agent.status', () => load());
const offChallenge = onWS('challenge.status', () => load());
return () => {
offTask();
offAgent();
offChallenge();
};
}, [load]);
const onlineDevices = devices.filter((d) => d.status === 'online' && !d.revoked).length;
const activeChallenges = challenges.filter((c) => c.status === 'active').length;
const pendingReview = tasks.filter((t) => t.status === 'pending_review').length;
const todaySuccess = tasks.filter((t) => t.status === 'success' && t.publishedAt && dayjs(t.publishedAt).isSame(dayjs(), 'day')).length;
const runningTasks = tasks.filter((t) => t.status === 'running' || t.status === 'dispatched' || t.status === 'queued').length;
const recentTasks = tasks.slice(0, 8);
const taskColumns = [
@@ -101,33 +98,15 @@ export default function DashboardBase() {
},
];
const deviceColumns = [
{ colKey: 'name', title: '设备名', ellipsis: true },
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IDevice }) => {
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
},
},
{ colKey: 'version', title: '版本', width: 120 },
{
colKey: 'lastSeenAt',
title: '最后活跃',
width: 160,
cell: ({ row }: { row: IDevice }) => (
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
),
},
];
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6} xl={3}>
<StatCard label='Agent 在线' value={onlineDevices + ' / ' + devices.length} hint='在线设备 / 全部设备' />
<StatCard
label='本机执行器'
value={runtime?.executorMode === 'web' ? 'Web 真实模式' : 'Mock 联调模式'}
hint={runtime ? `适配器:${runtime.adapters.join('、') || '无'}` : '状态读取中'}
/>
</Col>
<Col xs={12} sm={6} xl={3}>
<StatCard label='待处理挑战' value={String(activeChallenges)} hint='扫码/验证码等二次验证' />
@@ -136,14 +115,16 @@ export default function DashboardBase() {
<StatCard label='待审核任务' value={String(pendingReview)} hint='审核中心待处理' />
</Col>
<Col xs={12} sm={6} xl={3}>
<StatCard label='今日发布成功' value={String(todaySuccess)} hint='今日已完成发布' />
<StatCard label='执行中任务' value={String(runningTasks)} hint='排队/运行中的任务' />
</Col>
</Row>
<Card title='最近任务' subtitle='实时状态推送' style={{ marginTop: 16 }}>
<Table data={recentTasks} columns={taskColumns} rowKey='id' hover />
</Card>
<Card title='Agent 设备' style={{ marginTop: 16 }}>
<Table data={devices} columns={deviceColumns} rowKey='id' hover />
<Card title='执行器说明' style={{ marginTop: 16 }}>
<div style={{ color: 'var(--td-text-color-secondary)', lineHeight: 1.8 }}>
当前为 Web-only 本地模式:任务由服务器进程排队和执行,不需要客户端配对。{runtime?.executorMode === 'web' ? '已注册平台会走真实适配器。' : '当前为自动化联调模式,结果不会代表真实平台发布。'}
</div>
</Card>
</div>
);
@@ -5,6 +5,7 @@ import { BrowseIcon, BrowseOffIcon, LockOnIcon, MailIcon } from 'tdesign-icons-r
import classnames from 'classnames';
import { useAppDispatch } from 'modules/store';
import { login } from 'modules/user';
import { connectWS } from 'utils/ws';
import Style from './index.module.less';
@@ -21,6 +22,7 @@ export default function Login() {
try {
const formValue = (formRef.current?.getFieldsValue?.(true) || {}) as { email: string; password: string };
await dispatch(login(formValue)).unwrap();
connectWS();
MessagePlugin.success('登录成功');
navigate('/dashboard/base');
} catch (err: any) {
@@ -5,6 +5,7 @@ import { LockOnIcon, MailIcon, BrowseOffIcon, BrowseIcon, UserIcon } from 'tdesi
import classnames from 'classnames';
import { useAppDispatch } from 'modules/store';
import { register } from 'modules/user';
import { connectWS } from 'utils/ws';
import Style from './index.module.less';
@@ -31,6 +32,7 @@ export default function Register() {
try {
const nickname = formValue.nickname || formValue.email.split('@')[0];
await dispatch(register({ email: formValue.email, password: formValue.password, nickname })).unwrap();
connectWS();
MessagePlugin.success('注册成功,已自动登录');
navigate('/dashboard/base');
} catch (err: any) {
+29 -1
View File
@@ -27,6 +27,7 @@ export default function MaterialList() {
const [kind, setKind] = useState('');
const [group, setGroup] = useState('');
const [urlDialog, setUrlDialog] = useState<{ visible: boolean; url: string }>({ visible: false, url: '' });
const [preview, setPreview] = useState<{ visible: boolean; url: string; kind: string; name: string }>({ visible: false, url: '', kind: '', name: '' });
const load = useCallback(async () => {
setLoading(true);
@@ -76,6 +77,15 @@ export default function MaterialList() {
}
};
const openPreview = async (m: IMaterial) => {
try {
const res = await request.get('/materials/' + m.id + '/url');
setPreview({ visible: true, url: (res.data.url || '') + '?inline=1', kind: m.kind, name: m.name });
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '预览链接生成失败');
}
};
const copyURL = async () => {
try {
await navigator.clipboard.writeText(urlDialog.url);
@@ -124,9 +134,12 @@ export default function MaterialList() {
{
colKey: 'op',
title: '操作',
width: 160,
width: 220,
cell: ({ row }: { row: IMaterial }) => (
<Space>
<Button size='small' variant='text' onClick={() => openPreview(row)}>
预览
</Button>
<Button size='small' variant='text' theme='primary' onClick={() => getURL(row)}>
获取直链
</Button>
@@ -181,6 +194,21 @@ export default function MaterialList() {
</Button>
</Space>
</Dialog>
<Dialog
header={'预览 · ' + preview.name}
visible={preview.visible}
onClose={() => setPreview({ visible: false, url: '', kind: '', name: '' })}
footer={null}
>
<div style={{ textAlign: 'center', minHeight: 120 }}>
{preview.kind === 'video' ? (
<video controls src={preview.url} style={{ maxWidth: '100%', maxHeight: 420 }} />
) : (
<img src={preview.url} alt={preview.name} style={{ maxWidth: '100%', maxHeight: 420, objectFit: 'contain' }} />
)}
<div style={{ fontSize: 12, color: '#888', marginTop: 8 }}>预览链接为一次性链接,关闭后需重新生成。</div>
</div>
</Dialog>
</Card>
);
}
@@ -35,8 +35,12 @@ export default function NotificationList() {
useEffect(() => {
load();
const off = onWS('task.status', () => load());
return off;
const offTask = onWS('task.status', () => load());
const offNotification = onWS('notification.new', () => load());
return () => {
offTask();
offNotification();
};
}, [load]);
const markRead = async (n: INotification) => {
+78 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, Calendar, MessagePlugin, Tag } from 'tdesign-react';
import { Button, Card, Calendar, DatePicker, Drawer, MessagePlugin, Space, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
@@ -7,6 +7,10 @@ interface ITask {
id: number;
title: string;
status: string;
content?: string;
tags?: string;
accountIds?: string;
materialIds?: string;
scheduleAt?: string | null;
}
@@ -15,6 +19,9 @@ const THEME: Record<string, string> = { success: 'success', failed: 'danger', ca
export default function CalendarPage() {
const [tasks, setTasks] = useState<ITask[]>([]);
const [map, setMap] = useState<Record<string, ITask[]>>({});
const [selected, setSelected] = useState<ITask | null>(null);
const [scheduleAt, setScheduleAt] = useState('');
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
@@ -38,6 +45,55 @@ export default function CalendarPage() {
load();
}, [load]);
const openTask = async (task: ITask) => {
try {
const res = await request.get('/tasks/' + task.id);
const row = res.data as ITask;
setSelected(row);
setScheduleAt(row.scheduleAt || '');
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '任务详情加载失败');
}
};
const saveSchedule = async (nextSchedule = scheduleAt) => {
if (!selected || !['draft', 'rejected'].includes(selected.status)) return;
const parseIDs = (raw?: string): number[] => {
try {
const value = JSON.parse(raw || '[]');
return Array.isArray(value) ? value.map(Number).filter((id) => Number.isFinite(id)) : [];
} catch {
return [];
}
};
const parseTags = (raw?: string): string[] => {
try {
const value = JSON.parse(raw || '[]');
return Array.isArray(value) ? value.map(String) : [];
} catch {
return [];
}
};
setSaving(true);
try {
await request.put('/tasks/' + selected.id, {
title: selected.title,
content: selected.content || '',
tags: parseTags(selected.tags),
accountIds: parseIDs(selected.accountIds),
materialIds: parseIDs(selected.materialIds),
scheduleAt: nextSchedule ? dayjs(nextSchedule).valueOf() : null,
});
MessagePlugin.success(nextSchedule ? '定时已更新' : '已改为立即发布');
setSelected(null);
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '定时更新失败');
} finally {
setSaving(false);
}
};
return (
<Card title='排期日历' subtitle='按计划发布时间查看任务'>
<Calendar
@@ -50,7 +106,7 @@ export default function CalendarPage() {
<div>{dayNum}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{list.slice(0, 3).map((t) => (
<Tag key={t.id} size='small' theme={(THEME[t.status] || 'primary') as any} variant='light'>
<Tag key={t.id} size='small' theme={(THEME[t.status] || 'primary') as any} variant='light' onClick={() => openTask(t)}>
{t.title.slice(0, 6)}
</Tag>
))}
@@ -61,6 +117,26 @@ export default function CalendarPage() {
}}
/>
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>共 {tasks.filter((t) => t.scheduleAt).length} 个定时任务;立即发布的任务不在此展示。</div>
<Drawer header={selected ? `任务 #${selected.id} 排期` : '任务排期'} visible={!!selected} onClose={() => setSelected(null)} size='420px'>
{selected && (
<div style={{ lineHeight: 2 }}>
<div><b>标题</b>:{selected.title}</div>
<div><b>状态</b>:{selected.status}</div>
{['draft', 'rejected'].includes(selected.status) ? (
<>
<div style={{ marginTop: 12, marginBottom: 6 }}>计划发布时间</div>
<DatePicker enableTimePicker value={scheduleAt} onChange={(v) => setScheduleAt(v as string)} clearable style={{ width: '100%' }} />
<Space style={{ marginTop: 16 }}>
<Button theme='primary' loading={saving} onClick={() => saveSchedule()}>保存排期</Button>
<Button variant='outline' loading={saving} onClick={() => { setScheduleAt(''); saveSchedule(''); }}>改为立即发布</Button>
</Space>
</>
) : (
<div style={{ marginTop: 12, color: '#888' }}>只有草稿或驳回任务可以编辑排期。</div>
)}
</div>
)}
</Drawer>
</Card>
);
}
+35 -5
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, DatePicker, Form, Input, MessagePlugin, Radio, Space, Steps, Table, Tag, TagInput, Textarea } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
@@ -12,6 +12,8 @@ interface IAccount { id: number; platform: string; accountName: string; remark:
export default function PublishNew() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editingID = searchParams.get('taskId');
const [step, setStep] = useState(0);
const [materials, setMaterials] = useState<IMaterial[]>([]);
const [accounts, setAccounts] = useState<IAccount[]>([]);
@@ -39,6 +41,28 @@ export default function PublishNew() {
loadData();
}, [loadData]);
useEffect(() => {
if (!editingID) return;
const parse = <T,>(raw: string | undefined, fallback: T[]): T[] => {
try {
const value = JSON.parse(raw || '[]');
return Array.isArray(value) ? value as T[] : fallback;
} catch {
return fallback;
}
};
request.get('/tasks/' + editingID).then((res) => {
const task = res.data;
setTitle(task.title || '');
setContent(task.content || '');
setTags(parse<string>(task.tags, []));
setSelAccs(parse<number>(task.accountIds, []));
setSelMats(parse<number>(task.materialIds, []));
setScheduleMode(task.scheduleAt ? 'scheduled' : 'now');
setScheduleAt(task.scheduleAt || '');
}).catch((e: any) => MessagePlugin.error(e?.response?.data?.message || '草稿加载失败'));
}, [editingID]);
const submit = async () => {
if (!title.trim()) {
MessagePlugin.warning('请填写标题');
@@ -46,7 +70,7 @@ export default function PublishNew() {
}
setSubmitting(true);
try {
await request.post('/tasks', {
const payload = {
title: title.trim(),
content,
tags,
@@ -54,8 +78,14 @@ export default function PublishNew() {
materialIds: selMats.map(Number),
scheduleAt: scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).valueOf() : null,
priority: 5,
});
MessagePlugin.success('草稿已创建,可在任务列表提交审核');
};
if (editingID) {
await request.put('/tasks/' + editingID, payload);
MessagePlugin.success('草稿已更新,可在任务列表提交审核');
} else {
await request.post('/tasks', payload);
MessagePlugin.success('草稿已创建,可在任务列表提交审核');
}
navigate('/publish/tasks');
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '创建失败');
@@ -159,7 +189,7 @@ export default function PublishNew() {
<div><b>标题</b>:{title}</div>
<div><b>标签</b>:{tags.join('、') || '-'}</div>
<div><b>时间</b>:{scheduleMode === 'scheduled' && scheduleAt ? dayjs(scheduleAt).format('YYYY-MM-DD HH:mm') : '审核通过后立即'}</div>
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>提交后为草稿状态,需在任务列表提交审核;审核通过后自动下发客户端执行。</div>
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>提交后为草稿状态,需在任务列表提交审核;审核通过后由本机 Web 执行器排队处理。</div>
</div>
)}
<div style={{ marginTop: 24 }}>
+51 -7
View File
@@ -4,6 +4,8 @@ import { Button, Card, Dialog, Drawer, Input, MessagePlugin, Select, Space, Tabl
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
import { useAppSelector } from 'modules/store';
import { selectUser } from 'modules/user';
interface ITask {
id: number;
@@ -22,6 +24,14 @@ interface ITask {
tags: string;
}
interface ITaskEvent {
id: number;
type: string;
status: string;
message: string;
createdAt: string;
}
const STATUS_MAP: Record<string, { label: string; theme: string }> = {
draft: { label: '草稿', theme: 'default' },
pending_review: { label: '待审核', theme: 'warning' },
@@ -37,6 +47,9 @@ const STATUS_MAP: Record<string, { label: string; theme: string }> = {
export default function Tasks() {
const navigate = useNavigate();
const userState = useAppSelector(selectUser);
const canOperate = ['owner', 'admin', 'operator'].includes(userState.memberRole || '');
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
const [list, setList] = useState<ITask[]>([]);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState('');
@@ -44,6 +57,7 @@ export default function Tasks() {
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
const [rejectNote, setRejectNote] = useState('');
const [total, setTotal] = useState(0);
const [events, setEvents] = useState<ITaskEvent[]>([]);
const load = useCallback(async () => {
setLoading(true);
@@ -84,9 +98,25 @@ export default function Tasks() {
}
};
const openDetail = async (task: ITask) => {
setDetail(task);
try {
const res = await request.get('/tasks/' + task.id + '/events');
setEvents((res.data.list || []) as ITaskEvent[]);
} catch (e) {
setEvents([]);
MessagePlugin.error('任务时间线加载失败');
}
};
const renderActions = (row: ITask) => {
const btns: React.ReactNode[] = [];
if (row.status === 'draft') {
if (canOperate && row.status === 'draft') {
btns.push(
<Button key='edit' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
编辑
</Button>,
);
btns.push(
<Button key='submit' size='small' variant='text' theme='primary' onClick={() => act(row, 'submit')}>
提交审核
@@ -98,7 +128,7 @@ export default function Tasks() {
</Button>,
);
}
if (row.status === 'pending_review') {
if (canReview && row.status === 'pending_review') {
btns.push(
<Button key='approve' size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>
通过
@@ -110,21 +140,26 @@ export default function Tasks() {
</Button>,
);
}
if (row.status === 'rejected') {
if (canOperate && row.status === 'rejected') {
btns.push(
<Button key='edit-rejected' size='small' variant='text' theme='primary' onClick={() => navigate('/publish/new?taskId=' + row.id)}>
编辑
</Button>,
);
btns.push(
<Button key='resubmit' size='small' variant='text' theme='primary' onClick={() => act(row, 'resubmit')}>
重新提交
</Button>,
);
}
if (row.status === 'failed') {
if (canOperate && row.status === 'failed') {
btns.push(
<Button key='retry' size='small' variant='text' theme='primary' onClick={() => act(row, 'retry')}>
重试
</Button>,
);
}
if (['queued', 'dispatched', 'running', 'suspended', 'pending_review', 'draft', 'rejected'].includes(row.status)) {
if (canOperate && ['queued', 'dispatched', 'running', 'suspended', 'pending_review', 'draft', 'rejected'].includes(row.status)) {
btns.push(
<Button key='cancel' size='small' variant='text' onClick={() => act(row, 'cancel')}>
取消
@@ -132,7 +167,7 @@ export default function Tasks() {
);
}
btns.push(
<Button key='detail' size='small' variant='text' onClick={() => setDetail(row)}>
<Button key='detail' size='small' variant='text' onClick={() => openDetail(row)}>
详情
</Button>,
);
@@ -192,7 +227,7 @@ export default function Tasks() {
<Drawer
header={'任务 #' + (detail?.id || '') + ' 详情'}
visible={!!detail}
onClose={() => setDetail(null)}
onClose={() => { setDetail(null); setEvents([]); }}
size='420px'
>
{detail && (
@@ -206,6 +241,15 @@ export default function Tasks() {
<div><b>驳回批注</b>:{detail.reviewNote || '-'}</div>
<div><b>发布链接</b>:{detail.publishedUrls === 'null' || !detail.publishedUrls ? '-' : detail.publishedUrls}</div>
<div><b>创建时间</b>:{dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm')}</div>
<div style={{ marginTop: 16, fontWeight: 600 }}>状态时间线</div>
{events.length === 0 ? (
<div style={{ color: '#888' }}>暂无事件</div>
) : events.map((event) => (
<div key={event.id} style={{ borderLeft: '2px solid var(--td-brand-color)', paddingLeft: 10, marginTop: 8 }}>
<div>{event.message}</div>
<div style={{ color: '#888', fontSize: 12 }}>{dayjs(event.createdAt).format('MM-DD HH:mm:ss')} · {event.status}</div>
</div>
))}
</div>
)}
</Drawer>
+18 -9
View File
@@ -3,6 +3,8 @@ import { Button, Card, Dialog, Drawer, MessagePlugin, Space, Table, Tag, Textare
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
import { useAppSelector } from 'modules/store';
import { selectUser } from 'modules/user';
interface ITask {
id: number;
@@ -16,12 +18,23 @@ interface ITask {
}
export default function ReviewList() {
const userState = useAppSelector(selectUser);
const canReview = ['owner', 'admin', 'reviewer'].includes(userState.memberRole || '');
const [list, setList] = useState<ITask[]>([]);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<ITask | null>(null);
const [rejectTarget, setRejectTarget] = useState<ITask | null>(null);
const [note, setNote] = useState('');
const countIDs = (raw: string) => {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed.length : 0;
} catch {
return 0;
}
};
const load = useCallback(async () => {
setLoading(true);
try {
@@ -68,19 +81,15 @@ export default function ReviewList() {
<Button size='small' variant='text' onClick={() => setPreview(row)}>
预览
</Button>
<Button size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>
通过
</Button>
<Button size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>
驳回
</Button>
{canReview && <Button size='small' variant='text' theme='success' onClick={() => act(row, 'approve')}>通过</Button>}
{canReview && <Button size='small' variant='text' theme='danger' onClick={() => setRejectTarget(row)}>驳回</Button>}
</Space>
),
},
];
return (
<Card title='审核中心' subtitle='待审核任务(通过后自动下发客户端执行)'>
<Card title='审核中心' subtitle='待审核任务(通过后由本机 Web 执行器处理)'>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<Drawer header={'任务 #' + (preview?.id || '') + ' 预览'} visible={!!preview} onClose={() => setPreview(null)} size='420px'>
{preview && (
@@ -88,8 +97,8 @@ export default function ReviewList() {
<div><b>标题</b>:{preview.title}</div>
<div><b>内容</b>:{preview.content || '-'}</div>
<div><b>标签</b>:{preview.tags || '-'}</div>
<div><b>账号数</b>:{(preview.accountIds || '[]').length > 2 ? JSON.parse(preview.accountIds).length : 0}</div>
<div><b>素材数</b>:{(preview.materialIds || '[]').length > 2 ? JSON.parse(preview.materialIds).length : 0}</div>
<div><b>账号数</b>:{countIDs(preview.accountIds)}</div>
<div><b>素材数</b>:{countIDs(preview.materialIds)}</div>
</div>
)}
</Drawer>
@@ -1,30 +0,0 @@
import React from 'react';
import { Card, Switch, Tag } from 'tdesign-react';
const CHANNELS = [
{ name: '企业微信机器人', desc: '任务失败/挑战待处理时推送到企业微信群' },
{ name: '钉钉机器人', desc: '任务失败/挑战待处理时推送到钉钉群' },
{ name: '邮件通知', desc: '审核结果/任务结果发送到成员邮箱' },
];
export default function SettingChannel() {
return (
<Card
title='通知渠道'
actions={<Tag theme='warning' variant='light'>二期接入</Tag>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{CHANNELS.map((ch) => (
<div key={ch.name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch disabled value={false} />
<div>
<div style={{ fontSize: 14 }}>{ch.name}</div>
<div style={{ fontSize: 13, color: '#888' }}>{ch.desc}</div>
</div>
</div>
))}
</div>
<div style={{ marginTop: 16, fontSize: 13, color: '#888' }}>一期站内通知已全量可用;企微/钉钉/邮件渠道在二期接入。</div>
</Card>
);
}
-107
View File
@@ -1,107 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, MessagePlugin, Popconfirm, Table, Tag } from 'tdesign-react';
import dayjs from 'dayjs';
import request from 'utils/request';
import { onWS } from 'utils/ws';
interface IDevice {
id: number;
name: string;
os: string;
version: string;
status: string;
ip: string;
revoked: boolean;
lastSeenAt?: string | null;
pairedAt?: string;
}
export default function SettingDevice() {
const [list, setList] = useState<IDevice[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request.get('/agent/devices');
setList((res.data.list || []) as IDevice[]);
} catch (e) {
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const off = onWS('agent.status', () => load());
return off;
}, [load]);
const revoke = async (d: IDevice) => {
try {
await request.put('/agent/devices/' + d.id + '/revoke');
MessagePlugin.success('已吊销,设备将被强制下线');
load();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '吊销失败');
}
};
const columns = [
{ colKey: 'name', title: '设备名', ellipsis: true },
{
colKey: 'status',
title: '状态',
width: 100,
cell: ({ row }: { row: IDevice }) => {
if (row.revoked) return <Tag theme='danger'>已吊销</Tag>;
return row.status === 'online' ? <Tag theme='success'>在线</Tag> : <Tag theme='default'>离线</Tag>;
},
},
{ colKey: 'os', title: '系统', width: 100 },
{ colKey: 'version', title: '版本', width: 100 },
{ colKey: 'ip', title: 'IP', width: 140 },
{
colKey: 'pairedAt',
title: '配对时间',
width: 160,
cell: ({ row }: { row: IDevice }) => <span>{row.pairedAt ? dayjs(row.pairedAt).format('YYYY-MM-DD HH:mm') : '-'}</span>,
},
{
colKey: 'lastSeenAt',
title: '最后活跃',
width: 160,
cell: ({ row }: { row: IDevice }) => (
<span>{row.lastSeenAt ? dayjs(row.lastSeenAt).format('MM-DD HH:mm:ss') : '-'}</span>
),
},
{
colKey: 'op',
title: '操作',
width: 100,
cell: ({ row }: { row: IDevice }) =>
row.revoked ? null : (
<Popconfirm content='吊销后设备立即下线且无法再连接,确认?' onConfirm={() => revoke(row)}>
<Button size='small' variant='text' theme='danger'>
吊销
</Button>
</Popconfirm>
),
},
];
return (
<Card
title='Agent 设备'
actions={
<Button variant='outline' onClick={load}>
刷新
</Button>
}
>
<Table data={list} columns={columns} rowKey='id' loading={loading} hover />
<div style={{ marginTop: 12, fontSize: 13, color: '#888' }}>配对:客户端安装后生成配对码(设置页留位,D9 客户端 UI 提供),在此页可吊销设备。</div>
</Card>
);
}
+24 -3
View File
@@ -16,6 +16,7 @@ export default function SettingProfile() {
const [workspaces, setWorkspaces] = useState<IWorkspace[]>([]);
const [name, setName] = useState('');
const [saving, setSaving] = useState(false);
const [switching, setSwitching] = useState(0);
const load = useCallback(async () => {
try {
@@ -52,6 +53,21 @@ export default function SettingProfile() {
}
};
const switchWorkspace = async (workspaceId: number) => {
if (workspaceId === userState.workspaceId) return;
setSwitching(workspaceId);
try {
const res = await request.post('/auth/switch-workspace/' + workspaceId);
localStorage.setItem('ep_token', res.data.accessToken);
localStorage.setItem('ep_refresh', res.data.refreshToken);
window.location.reload();
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '切换工作空间失败');
} finally {
setSwitching(0);
}
};
return (
<Space direction='vertical' style={{ width: '100%' }} size={16}>
<Card title='工作区资料'>
@@ -75,9 +91,14 @@ export default function SettingProfile() {
<Card title='我的工作空间'>
<div style={{ fontSize: 14, lineHeight: 2 }}>
{workspaces.map((w) => (
<div key={w.id}>
{w.name}({w.plan}){w.id === userState.workspaceId ? ' ← 当前' : ''}
</div>
<Space key={w.id}>
<span>{w.name}({w.plan}){w.id === userState.workspaceId ? ' ← 当前' : ''}</span>
{w.id !== userState.workspaceId && (
<Button size='small' variant='text' loading={switching === w.id} onClick={() => switchWorkspace(w.id)}>
切换
</Button>
)}
</Space>
))}
</div>
</Card>
+28 -11
View File
@@ -1,9 +1,30 @@
import React from 'react';
import { Card, Input, Switch, Tag } from 'tdesign-react';
import React, { useState } from 'react';
import { Button, Card, Input, MessagePlugin, Space, Switch, Tag } from 'tdesign-react';
import request from 'utils/request';
export default function SettingSecurity() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [saving, setSaving] = useState(false);
const changePassword = async () => {
if (newPassword.length < 8) {
MessagePlugin.warning('新密码至少 8 位');
return;
}
setSaving(true);
try {
await request.post('/auth/password', { currentPassword, newPassword });
MessagePlugin.success('密码已更新,请重新登录');
setCurrentPassword('');
setNewPassword('');
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '密码更新失败');
} finally {
setSaving(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Space direction='vertical' style={{ width: '100%' }} size={16}>
<Card title='两步验证(2FA)'>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 14 }}>
<Switch disabled value={false} />
@@ -16,15 +37,11 @@ export default function SettingSecurity() {
</Card>
<Card title='修改密码'>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 420 }}>
<Input type='password' placeholder='当前密码' disabled />
<Input type='password' placeholder='新密码(至少 8 位)' disabled />
<div>
<Tag theme='warning' variant='light'>
二期开放
</Tag>
</div>
<Input type='password' value={currentPassword} onChange={(v) => setCurrentPassword(v as string)} placeholder='当前密码' />
<Input type='password' value={newPassword} onChange={(v) => setNewPassword(v as string)} placeholder='新密码(至少 8 位)' />
<Button theme='primary' loading={saving} onClick={changePassword}>更新密码</Button>
</div>
</Card>
</div>
</Space>
);
}
+1 -6
View File
@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { ShopIcon, DesktopIcon, RootListIcon } from 'tdesign-icons-react';
import { ShopIcon } from 'tdesign-icons-react';
import { IRouter } from '../index';
const admin: IRouter[] = [
@@ -12,11 +12,6 @@ const admin: IRouter[] = [
Component: lazy(() => import('pages/Admin/Tenant')),
meta: { title: '租户管理' },
},
{
path: 'agent',
Component: lazy(() => import('pages/Admin/Agent')),
meta: { title: 'Agent 总览', Icon: DesktopIcon },
},
{
path: 'docs',
Component: lazy(() => import('pages/Admin/Docs')),
-10
View File
@@ -126,16 +126,6 @@ const workspace: IRouter[] = [
Component: lazy(() => import('pages/Setting/Security')),
meta: { title: '安全' },
},
{
path: 'channel',
Component: lazy(() => import('pages/Setting/Channel')),
meta: { title: '通知渠道' },
},
{
path: 'device',
Component: lazy(() => import('pages/Setting/Device')),
meta: { title: 'Agent 设备' },
},
],
},
{
+3 -1
View File
@@ -54,7 +54,9 @@ instance.interceptors.response.use(
if (data.code === SUCCESS_CODE) {
return data;
}
return Promise.reject(data);
const businessError = new Error(data.message || '请求失败') as Error & { response?: { data: unknown } };
businessError.response = { data };
return Promise.reject(businessError);
}
return Promise.reject(response?.data);
},
+18
View File
@@ -5,10 +5,16 @@ let stopped = false;
type Listener = (payload: any) => void;
const listeners = new Map<string, Set<Listener>>();
function emit(type: string, payload: any) {
listeners.get(type)?.forEach((fn) => fn(payload));
}
export function connectWS() {
stopped = false;
if (stopped) return;
const token = localStorage.getItem('ep_token');
if (!token) return;
if (sock && (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING)) return;
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = proto + '://' + window.location.host + '/ws/browser?token=' + encodeURIComponent(token);
try {
@@ -17,6 +23,7 @@ export function connectWS() {
scheduleRetry();
return;
}
sock.onopen = () => emit('connection.status', { online: true });
sock.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
@@ -28,6 +35,7 @@ export function connectWS() {
};
sock.onclose = () => {
sock = null;
emit('connection.status', { online: false });
if (localStorage.getItem('ep_token')) scheduleRetry();
};
sock.onerror = () => {
@@ -49,3 +57,13 @@ export function onWS(type: string, fn: Listener): () => void {
set.add(fn);
return () => set.delete(fn);
}
export function disconnectWS() {
stopped = true;
if (retryTimer) {
window.clearTimeout(retryTimer);
retryTimer = null;
}
sock?.close();
sock = null;
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default (params) => {
},
server: {
host: '0.0.0.0',
port: 3003,
port: 3013,
proxy: {
'/api': {
target: 'http://127.0.0.1:8090',