server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '状态',
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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')),
|
||||
|
||||
@@ -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 设备' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user