Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
Vendored
+143
-134
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DDKKooWp.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DRV5getO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -45,6 +45,8 @@ import AdminApiKeys from './pages/AdminApiKeys';
|
||||
import AdminApiModelPricings from './pages/AdminApiModelPricings';
|
||||
import AdminApiUsage from './pages/AdminApiUsage';
|
||||
import AdminInvoices from './pages/AdminInvoices';
|
||||
import AdminBankTransactions from './pages/AdminBankTransactions';
|
||||
import AdminScheduledTasks from './pages/AdminScheduledTasks';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -112,6 +114,8 @@ const App = () => {
|
||||
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
|
||||
<Route path="api-usage" element={<AdminApiUsage />} />
|
||||
<Route path="invoices" element={<AdminInvoices />} />
|
||||
<Route path="bank-transactions" element={<AdminBankTransactions />} />
|
||||
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
|
||||
BankAccount, ScheduledTask,
|
||||
} from '../types';
|
||||
|
||||
import type {
|
||||
@@ -216,6 +217,106 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
|
||||
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
|
||||
}
|
||||
|
||||
export async function queryBankTransactions(params: {
|
||||
accountId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
dcFlag?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ items: any[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('account_id', params.accountId);
|
||||
qs.set('start_date', params.startDate);
|
||||
qs.set('end_date', params.endDate);
|
||||
if (params.dcFlag !== undefined) qs.set('dc_flag', String(params.dcFlag));
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
return api.get(`/admin/bank/transactions?${qs.toString()}`);
|
||||
}
|
||||
|
||||
// ── Bank Account Management ────────────────────────────────
|
||||
|
||||
export async function listBankAccounts(): Promise<{ items: BankAccount[] }> {
|
||||
return api.get('/admin/bank/accounts');
|
||||
}
|
||||
|
||||
export async function createBankAccount(payload: {
|
||||
account_name: string;
|
||||
bank_name: string;
|
||||
account_no: string;
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
description?: string;
|
||||
}): Promise<{ id: string; message: string }> {
|
||||
return api.post('/admin/bank/accounts', payload);
|
||||
}
|
||||
|
||||
export async function updateBankAccount(
|
||||
accountId: string,
|
||||
payload: Partial<{
|
||||
account_name: string;
|
||||
bank_name: string;
|
||||
account_no: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
description: string;
|
||||
}>,
|
||||
): Promise<{ message: string }> {
|
||||
return api.put(`/admin/bank/accounts/${accountId}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteBankAccount(accountId: string): Promise<{ message: string }> {
|
||||
await api.delete(`/admin/bank/accounts/${accountId}`);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
// ── Scheduled Tasks ────────────────────────────────────────
|
||||
|
||||
export async function listScheduledTasks(): Promise<{ items: ScheduledTask[] }> {
|
||||
return api.get('/admin/scheduled-tasks');
|
||||
}
|
||||
|
||||
export async function createScheduledTask(payload: {
|
||||
name: string;
|
||||
task_type: 'external_api' | 'internal_method';
|
||||
schedule: string;
|
||||
config?: Record<string, unknown> | string;
|
||||
is_active?: boolean;
|
||||
}): Promise<{ id: string; message: string }> {
|
||||
return api.post('/admin/scheduled-tasks', payload);
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
task_type: 'external_api' | 'internal_method';
|
||||
schedule: string;
|
||||
config: Record<string, unknown> | string;
|
||||
is_active: boolean;
|
||||
}>,
|
||||
): Promise<{ message: string }> {
|
||||
return api.put(`/admin/scheduled-tasks/${taskId}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(taskId: string): Promise<{ message: string }> {
|
||||
await api.delete(`/admin/scheduled-tasks/${taskId}`);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
export async function runScheduledTask(taskId: string): Promise<{ message: string }> {
|
||||
return api.post(`/admin/scheduled-tasks/${taskId}/run`, {});
|
||||
}
|
||||
|
||||
export async function toggleScheduledTask(taskId: string): Promise<{ is_active: boolean; message: string }> {
|
||||
return api.post(`/admin/scheduled-tasks/${taskId}/toggle`, {});
|
||||
}
|
||||
|
||||
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
|
||||
}
|
||||
|
||||
export async function getModelConfigs(): Promise<ModelConfig[]> {
|
||||
return api.get('/admin/model-configs');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
|
||||
import { BankOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { queryBankTransactions, listBankAccounts } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import type { BankAccount } from '../types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface TransactionRecord {
|
||||
counterAcctNo?: string;
|
||||
cnterName?: string;
|
||||
cnterBankName?: string;
|
||||
acctNo?: string;
|
||||
transAmt?: number | string;
|
||||
dcFlag?: number;
|
||||
dcFlagLabel?: string;
|
||||
transTimeStr?: string;
|
||||
digestCode?: string;
|
||||
remark?: string;
|
||||
balance?: number | string;
|
||||
tellerSeqno?: string;
|
||||
transferId?: string;
|
||||
}
|
||||
|
||||
const AdminBankTransactions: React.FC = () => {
|
||||
const [data, setData] = useState<TransactionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
// 银行账户
|
||||
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
|
||||
|
||||
// 搜索条件
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
|
||||
const [dcFlag, setDcFlag] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const res = await listBankAccounts();
|
||||
setAccounts(res.items || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载银行账户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!selectedAccountId || !dateRange[0] || !dateRange[1]) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await queryBankTransactions({
|
||||
accountId: selectedAccountId,
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
dcFlag,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setData(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAccountId, dateRange, dcFlag, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccountId && dateRange[0] && dateRange[1]) {
|
||||
fetchData();
|
||||
}
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!selectedAccountId) {
|
||||
message.warning('请选择银行账户');
|
||||
return;
|
||||
}
|
||||
if (!dateRange[0] || !dateRange[1]) {
|
||||
message.warning('请选择交易时间区间');
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '收款账号',
|
||||
dataIndex: 'counterAcctNo',
|
||||
width: 180,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
dataIndex: 'cnterName',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '收款开户行',
|
||||
dataIndex: 'cnterBankName',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '我方付款账号',
|
||||
dataIndex: 'acctNo',
|
||||
width: 180,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '打款金额',
|
||||
dataIndex: 'transAmt',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '借贷方向',
|
||||
dataIndex: 'dcFlagLabel',
|
||||
width: 100,
|
||||
render: (v: string, r: TransactionRecord) => v || (r.dcFlag === 0 ? '借/出金' : r.dcFlag === 1 ? '贷/入金' : '-'),
|
||||
},
|
||||
{
|
||||
title: '打款时间',
|
||||
dataIndex: 'transTimeStr',
|
||||
width: 160,
|
||||
render: (v: string) => v ? formatDate(v) : '-',
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'digestCode',
|
||||
width: 100,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '用途/备注',
|
||||
dataIndex: 'remark',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '账户余额',
|
||||
dataIndex: 'balance',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '流水号',
|
||||
dataIndex: 'tellerSeqno',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<BankOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>银行交易查询</Typography.Title>
|
||||
<Typography.Text type="secondary">查询银行账户交易流水记录</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="选择银行账户"
|
||||
value={selectedAccountId}
|
||||
onChange={setSelectedAccountId}
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
options={accounts.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.bank_name} - ${a.account_no} (${a.account_name})`,
|
||||
}))}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(dates, dateStrings) => {
|
||||
if (Array.isArray(dateStrings)) {
|
||||
setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="借贷方向"
|
||||
value={dcFlag}
|
||||
onChange={setDcFlag}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value={0}>借/出金</Select.Option>
|
||||
<Select.Option value={1}>贷/入金</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{selectedAccount && (
|
||||
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 8, fontSize: 13, color: '#64748b' }}>
|
||||
<strong>当前查询账户:</strong>{selectedAccount.bank_name} | {selectedAccount.account_no} | {selectedAccount.account_name}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Table
|
||||
rowKey={(r, i) => `${r.tellerSeqno || ''}-${r.transferId || ''}-${i}`}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBankTransactions;
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listScheduledTasks,
|
||||
createScheduledTask,
|
||||
updateScheduledTask,
|
||||
deleteScheduledTask,
|
||||
runScheduledTask,
|
||||
toggleScheduledTask,
|
||||
} from '../api';
|
||||
import type { ScheduledTask } from '../types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS = [
|
||||
{ value: 'external_api', label: '外部接口调用' },
|
||||
{ value: 'internal_method', label: '内部方法执行' },
|
||||
];
|
||||
|
||||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
external_api: '外部接口',
|
||||
internal_method: '内部方法',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
success: { label: '成功', color: 'green' },
|
||||
error: { label: '失败', color: 'red' },
|
||||
};
|
||||
|
||||
const AdminScheduledTasks: React.FC = () => {
|
||||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listScheduledTasks();
|
||||
setData(res.items || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openModal = (task?: ScheduledTask) => {
|
||||
if (task) {
|
||||
setEditing(task);
|
||||
let configStr = '';
|
||||
if (task.config) {
|
||||
try {
|
||||
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
|
||||
} catch {
|
||||
configStr = task.config;
|
||||
}
|
||||
}
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
task_type: task.task_type,
|
||||
schedule: task.schedule,
|
||||
config: configStr,
|
||||
is_active: task.is_active,
|
||||
});
|
||||
} else {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, task_type: 'external_api' });
|
||||
}
|
||||
setActiveTab('basic');
|
||||
setModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
let configVal = values.config;
|
||||
if (configVal && typeof configVal === 'string') {
|
||||
try {
|
||||
configVal = JSON.stringify(JSON.parse(configVal));
|
||||
} catch {
|
||||
message.warning('配置 JSON 格式不合法,将按原样保存');
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
if (editing) {
|
||||
await updateScheduledTask(editing.id, { ...values, config: configVal });
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createScheduledTask({ ...values, config: configVal });
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModal(false);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
try {
|
||||
await deleteScheduledTask(taskId);
|
||||
message.success('删除成功');
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (taskId: string) => {
|
||||
try {
|
||||
await runScheduledTask(taskId);
|
||||
message.success('任务已提交执行');
|
||||
setTimeout(fetchData, 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (task: ScheduledTask) => {
|
||||
try {
|
||||
const res = await toggleScheduledTask(task.id);
|
||||
message.success(res.message);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const taskType = Form.useWatch('task_type', form);
|
||||
|
||||
const columns = [
|
||||
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'task_type',
|
||||
width: 100,
|
||||
render: (v: string) => <Tag color={v === 'external_api' ? 'blue' : 'purple'}>{TASK_TYPE_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后执行',
|
||||
dataIndex: 'last_run_at',
|
||||
width: 160,
|
||||
render: (v: string, r: ScheduledTask) => {
|
||||
if (!v) return '-';
|
||||
const status = r.last_status ? STATUS_LABELS[r.last_status] : null;
|
||||
return (
|
||||
<span>
|
||||
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
|
||||
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}>执行</Button>
|
||||
<Button type="link" size="small" icon={record.is_active ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const scheduleHelp = (
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||
<div>• 纯数字:间隔秒数(如 60 = 每 60 秒执行一次)</div>
|
||||
<div>• Cron 表达式(5 字段):分 时 日 月 周</div>
|
||||
<div>• 示例:<code>* * * * *</code> = 每分钟 | <code>0 * * * *</code> = 每小时</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>定时任务管理</Typography.Title>
|
||||
<Typography.Text type="secondary">自定义定时执行外部接口调用或内部方法</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
|
||||
新增任务
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editing ? '编辑定时任务' : '新增定时任务'}
|
||||
open={modal}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModal(false)}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本设置',
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
|
||||
<Input placeholder="例如:每小时同步银行交易" />
|
||||
</Form.Item>
|
||||
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
|
||||
<Select options={SCHEDULE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="schedule"
|
||||
label="调度表达式"
|
||||
rules={[{ required: true, message: '请输入调度表达式' }]}
|
||||
extra={scheduleHelp}
|
||||
>
|
||||
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: '任务配置',
|
||||
children: (
|
||||
<Form.Item
|
||||
name="config"
|
||||
label="配置 JSON"
|
||||
extra={
|
||||
taskType === 'external_api'
|
||||
? '字段:url(地址)、method(GET/POST/PUT/DELETE)、headers(对象)、payload(对象/参数)、timeout(秒,默认 30)'
|
||||
: '字段:module(模块路径,如 app.services.xxx)、function(函数名)、args(参数数组)'
|
||||
}
|
||||
>
|
||||
<TextArea
|
||||
rows={8}
|
||||
placeholder={taskType === 'external_api'
|
||||
? '{\n "url": "https://api.example.com/data",\n "method": "POST",\n "headers": {},\n "payload": {}\n}'
|
||||
: '{\n "module": "app.services.report",\n "function": "generate_daily_report",\n "args": []\n}'
|
||||
}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminScheduledTasks;
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Tabs, Typography, Upload, Table, Tag, Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined, BankOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
createSystemConfig,
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
uploadLogo,
|
||||
uploadPdf,
|
||||
uploadLoginVideo,
|
||||
listBankAccounts,
|
||||
createBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
} from '../api';
|
||||
import type { ResourceCapacityUnit, SystemConfig } from '../types';
|
||||
import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
|
||||
|
||||
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
|
||||
{ value: 'MB', label: 'MB(1024 × 1024 字节)' },
|
||||
@@ -30,10 +34,116 @@ const AdminSettings: React.FC = () => {
|
||||
const [uploading, setUploading] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 银行账户管理
|
||||
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false);
|
||||
const [accountModal, setAccountModal] = useState(false);
|
||||
const [accountEditing, setAccountEditing] = useState<BankAccount | null>(null);
|
||||
const [accountForm] = Form.useForm();
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadBankAccounts();
|
||||
}, []);
|
||||
|
||||
const loadBankAccounts = async () => {
|
||||
setLoadingAccounts(true);
|
||||
try {
|
||||
const res = await listBankAccounts();
|
||||
setBankAccounts(res.items || []);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载银行账户失败');
|
||||
} finally {
|
||||
setLoadingAccounts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAccountModal = (account?: BankAccount) => {
|
||||
if (account) {
|
||||
setAccountEditing(account);
|
||||
accountForm.setFieldsValue({
|
||||
account_name: account.account_name,
|
||||
bank_name: account.bank_name,
|
||||
account_no: account.account_no,
|
||||
is_active: account.is_active,
|
||||
is_default: account.is_default,
|
||||
description: account.description,
|
||||
});
|
||||
} else {
|
||||
setAccountEditing(null);
|
||||
accountForm.resetFields();
|
||||
accountForm.setFieldsValue({ is_active: true, is_default: false });
|
||||
}
|
||||
setAccountModal(true);
|
||||
};
|
||||
|
||||
const handleAccountSave = async () => {
|
||||
try {
|
||||
const values = await accountForm.validateFields();
|
||||
setAccountSaving(true);
|
||||
if (accountEditing) {
|
||||
await updateBankAccount(accountEditing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createBankAccount(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setAccountModal(false);
|
||||
await loadBankAccounts();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setAccountSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountDelete = async (accountId: string) => {
|
||||
try {
|
||||
await deleteBankAccount(accountId);
|
||||
message.success('删除成功');
|
||||
await loadBankAccounts();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const accountColumns = [
|
||||
{ title: '账户名称', dataIndex: 'account_name', width: 150 },
|
||||
{ title: '开户银行', dataIndex: 'bank_name', width: 150 },
|
||||
{
|
||||
title: '银行账号',
|
||||
dataIndex: 'account_no',
|
||||
width: 180,
|
||||
render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'is_default',
|
||||
width: 80,
|
||||
render: (v: boolean) => v && <Tag color="blue">默认</Tag>,
|
||||
},
|
||||
{ title: '备注', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: any, record: BankAccount) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openAccountModal(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该账户?" onConfirm={() => handleAccountDelete(record.id)} okText="确定" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -45,6 +155,14 @@ const AdminSettings: React.FC = () => {
|
||||
if (!data.some(c => c.key === 'llm_media_as_base64')) {
|
||||
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
|
||||
}
|
||||
// 确保 single_device_login_enabled 配置存在
|
||||
if (!data.some(c => c.key === 'single_device_login_enabled')) {
|
||||
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
|
||||
}
|
||||
// 确保 OA 地址配置存在
|
||||
if (!data.some(c => c.key === 'oa_url')) {
|
||||
data.push({ id: 'cfg_oa_url', key: 'oa_url', value: '', description: 'OA系统地址' });
|
||||
}
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, any> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
@@ -154,6 +272,26 @@ const AdminSettings: React.FC = () => {
|
||||
message.success('已移除登录背景视频');
|
||||
};
|
||||
|
||||
const handleToggleSingleDevice = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||
} else {
|
||||
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
|
||||
config = res;
|
||||
}
|
||||
setConfigs(prev => {
|
||||
const exists = prev.some(c => c.key === 'single_device_login_enabled');
|
||||
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||
return [...prev, config!];
|
||||
});
|
||||
message.success(`已${checked ? '开启' : '关闭'}单设备登录限制`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBase64 = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||
@@ -179,6 +317,7 @@ const AdminSettings: React.FC = () => {
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||
'系统对接': configs.filter(c => c.key.startsWith('oa_')),
|
||||
'其他配置': configs.filter(c => c.key === 'operation_manual'),
|
||||
};
|
||||
|
||||
@@ -389,6 +528,52 @@ const AdminSettings: React.FC = () => {
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'integration',
|
||||
label: '系统对接',
|
||||
children: (
|
||||
<div>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
系统对接
|
||||
</Typography.Text>
|
||||
{groupedConfigs['系统对接']?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* 银行账户管理 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
<BankOutlined style={{ marginRight: 8, color: '#6366f1' }} />
|
||||
银行账户管理
|
||||
</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => openAccountModal()}>
|
||||
新增账户
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={bankAccounts}
|
||||
loading={loadingAccounts}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
columns={accountColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: '其他配置',
|
||||
@@ -493,6 +678,32 @@ const AdminSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 单设备登录限制 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
登录安全
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong>单设备登录限制</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
开启后同一账号仅允许在一台同类型设备登录(手机和电脑可同时登录)
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
|
||||
onChange={handleToggleSingleDevice}
|
||||
checkedChildren="已启用"
|
||||
unCheckedChildren="已禁用"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -525,6 +736,41 @@ const AdminSettings: React.FC = () => {
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 银行账户新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={accountEditing ? '编辑银行账户' : '新增银行账户'}
|
||||
open={accountModal}
|
||||
onOk={handleAccountSave}
|
||||
onCancel={() => setAccountModal(false)}
|
||||
confirmLoading={accountSaving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="account_name" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]}>
|
||||
<Input placeholder="例如:民众普康科技有限公司" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]}>
|
||||
<Input placeholder="例如:中国工商银行北京分行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="account_no" label="银行账号" rules={[{ required: true, message: '请输入银行账号' }]}>
|
||||
<Input placeholder="银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选备注信息" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_default" label="设为默认" valuePropName="checked">
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
adminDeductCredits,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
saveUserResourceCapacity,
|
||||
toggleUserStatus,
|
||||
updateFrontendUserKind,
|
||||
updateSingleDeviceLoginOverride,
|
||||
updateUserTeam,
|
||||
updateSystemConfig,
|
||||
updateUserMenus,
|
||||
@@ -84,6 +85,10 @@ const AdminUsers: React.FC = () => {
|
||||
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
||||
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
|
||||
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
|
||||
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
|
||||
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
|
||||
const [capacityLoading, setCapacityLoading] = useState(false);
|
||||
const [capacitySaving, setCapacitySaving] = useState(false);
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
@@ -136,6 +141,9 @@ const AdminUsers: React.FC = () => {
|
||||
const formValues: Record<string, string> = {};
|
||||
credit.forEach(c => { formValues[c.key] = c.value; });
|
||||
configForm.setFieldsValue(formValues);
|
||||
// 获取全局单设备登录开关状态
|
||||
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
|
||||
} catch { /* auth error handled by client */ }
|
||||
};
|
||||
loadCreditConfigs();
|
||||
@@ -431,6 +439,20 @@ const AdminUsers: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
|
||||
try {
|
||||
setSingleDeviceSaving(true);
|
||||
await updateSingleDeviceLoginOverride(user.id, value);
|
||||
message.success('已更新单设备登录设置');
|
||||
setSingleDeviceModal({ open: false, user: null });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '设置失败');
|
||||
} finally {
|
||||
setSingleDeviceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAdminTab = activeTab === 'admin';
|
||||
|
||||
const columns = [
|
||||
@@ -591,6 +613,15 @@ const AdminUsers: React.FC = () => {
|
||||
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
|
||||
重置密码
|
||||
</Button>
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<SafetyOutlined />}
|
||||
onClick={() => {
|
||||
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
|
||||
setSingleDeviceModal({ open: true, user: r });
|
||||
}}>
|
||||
单设备登录
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
|
||||
onConfirm={() => handleToggleStatus(r)}
|
||||
@@ -1084,6 +1115,51 @@ const AdminUsers: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><SafetyOutlined />单设备登录设置 - {singleDeviceModal.user?.username}</Space>}
|
||||
open={singleDeviceModal.open}
|
||||
onOk={() => {
|
||||
if (singleDeviceModal.user) {
|
||||
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
|
||||
okText="保存" cancelText="取消" width={420}
|
||||
confirmLoading={singleDeviceSaving}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前全局设置:<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={singleDeviceValue}
|
||||
onChange={e => setSingleDeviceValue(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value={null}>
|
||||
<Typography.Text>跟随全局</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
({globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'})
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value={true}>
|
||||
<Typography.Text>强制启用</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
(该用户始终启用单设备登录,不受全局影响)
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value={false}>
|
||||
<Typography.Text>强制禁用</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
(该用户不受单设备登录限制,可多设备同时登录)
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface AdminUser {
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitAssetLimit: number;
|
||||
singleDeviceLoginOverride?: boolean | null;
|
||||
}
|
||||
|
||||
export interface DailyCredit {
|
||||
@@ -1609,3 +1610,34 @@ export interface InvoiceDetail {
|
||||
updatedAt: string | null;
|
||||
orders: InvoiceOrder[];
|
||||
}
|
||||
|
||||
// ── Bank Account ───────────────────────────────────────
|
||||
|
||||
export interface BankAccount {
|
||||
id: string;
|
||||
account_name: string;
|
||||
bank_name: string;
|
||||
account_no: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
description?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
// ── Scheduled Task ─────────────────────────────────────
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
task_type: 'external_api' | 'internal_method';
|
||||
schedule: string;
|
||||
config?: string | null;
|
||||
is_active: boolean;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
last_error?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminbanktransactions.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminscheduledtasks.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""单设备登录拆分设备类型 + 用户级覆盖
|
||||
|
||||
Revision ID: 20260813_split_device_type
|
||||
Revises: 20260813_token_version
|
||||
Create Date: 2026-08-13 12:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20260813_split_device_type'
|
||||
down_revision = '20260813_token_version'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 拆分为按设备类型管理
|
||||
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.drop_column('users', 'token_version')
|
||||
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
|
||||
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.drop_column('users', 'single_device_login_override')
|
||||
op.drop_column('users', 'mobile_token_version')
|
||||
op.drop_column('users', 'pc_token_version')
|
||||
@@ -0,0 +1,53 @@
|
||||
"""银行账户与定时任务
|
||||
|
||||
Revision ID: 20260813_bank_scheduled
|
||||
Revises: 20260813_split_device_type
|
||||
Create Date: 2026-08-13 16:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20260813_bank_scheduled'
|
||||
down_revision = '20260813_split_device_type'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 创建银行账户表
|
||||
op.create_table(
|
||||
'bank_accounts',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('account_name', sa.String(128), nullable=False, comment='账户名称'),
|
||||
sa.Column('bank_name', sa.String(128), nullable=False, comment='开户银行'),
|
||||
sa.Column('account_no', sa.String(64), unique=True, nullable=False, comment='银行账号'),
|
||||
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
|
||||
sa.Column('is_default', sa.Boolean, default=False, comment='是否默认账户'),
|
||||
sa.Column('description', sa.String(256), nullable=True, comment='备注'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
|
||||
)
|
||||
|
||||
# 创建定时任务表
|
||||
op.create_table(
|
||||
'scheduled_tasks',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('name', sa.String(128), nullable=False, comment='任务名称'),
|
||||
sa.Column('task_type', sa.String(32), nullable=False, comment='类型: external_api / internal_method'),
|
||||
sa.Column('schedule', sa.String(128), nullable=False, comment='Cron 表达式或间隔秒数'),
|
||||
sa.Column('config', sa.Text, nullable=True, comment='任务配置 JSON'),
|
||||
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
|
||||
sa.Column('last_run_at', sa.String(64), nullable=True, comment='最后执行时间 ISO'),
|
||||
sa.Column('last_status', sa.String(16), nullable=True, comment='最后执行状态'),
|
||||
sa.Column('last_error', sa.Text, nullable=True, comment='最后执行错误信息'),
|
||||
sa.Column('created_by', sa.String(32), nullable=True, comment='创建者管理员 ID'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# 删除新表
|
||||
op.drop_table('scheduled_tasks')
|
||||
op.drop_table('bank_accounts')
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.admin_api.bank.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""银行账户管理与交易查询后台路由。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.user import User
|
||||
from app.services.bank.service import query_transactions_with_log
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/admin/bank", tags=["admin-bank"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 银行账户 CRUD
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出所有银行账户。"""
|
||||
result = await db.execute(select(BankAccount).order_by(BankAccount.is_default.desc(), BankAccount.created_at.desc()))
|
||||
accounts = result.scalars().all()
|
||||
return {"items": [
|
||||
{
|
||||
"id": a.id,
|
||||
"account_name": a.account_name,
|
||||
"bank_name": a.bank_name,
|
||||
"account_no": a.account_no,
|
||||
"is_active": a.is_active,
|
||||
"is_default": a.is_default,
|
||||
"description": a.description,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
for a in accounts
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/accounts")
|
||||
async def create_account(
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""新增银行账户。"""
|
||||
account_name = (body.get("account_name") or "").strip()
|
||||
bank_name = (body.get("bank_name") or "").strip()
|
||||
account_no = (body.get("account_no") or "").strip()
|
||||
if not account_name or not bank_name or not account_no:
|
||||
raise HTTPException(status_code=400, detail="账户名称、开户银行、银行账号不能为空")
|
||||
|
||||
# 检查账号唯一性
|
||||
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == account_no))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="该银行账号已存在")
|
||||
|
||||
is_default = bool(body.get("is_default", False))
|
||||
# 如果设为默认,取消其他默认
|
||||
if is_default:
|
||||
await db.execute(
|
||||
BankAccount.__table__.update().where(BankAccount.is_default.is_(True)).values(is_default=False)
|
||||
)
|
||||
|
||||
account = BankAccount(
|
||||
id=generate_id(),
|
||||
account_name=account_name,
|
||||
bank_name=bank_name,
|
||||
account_no=account_no,
|
||||
is_active=bool(body.get("is_active", True)),
|
||||
is_default=is_default,
|
||||
description=body.get("description"),
|
||||
)
|
||||
db.add(account)
|
||||
await db.commit()
|
||||
return {"id": account.id, "message": "创建成功"}
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}")
|
||||
async def update_account(
|
||||
account_id: str = Path(..., description="账户 ID"),
|
||||
body: dict = ...,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""编辑银行账户。"""
|
||||
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="账户不存在")
|
||||
|
||||
if "account_name" in body:
|
||||
account.account_name = str(body["account_name"]).strip()
|
||||
if "bank_name" in body:
|
||||
account.bank_name = str(body["bank_name"]).strip()
|
||||
if "account_no" in body:
|
||||
new_no = str(body["account_no"]).strip()
|
||||
if new_no != account.account_no:
|
||||
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == new_no))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="该银行账号已存在")
|
||||
account.account_no = new_no
|
||||
if "is_active" in body:
|
||||
account.is_active = bool(body["is_active"])
|
||||
if "description" in body:
|
||||
account.description = body.get("description")
|
||||
|
||||
if body.get("is_default"):
|
||||
await db.execute(
|
||||
BankAccount.__table__.update()
|
||||
.where(BankAccount.is_default.is_(True))
|
||||
.where(BankAccount.id != account_id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
account.is_default = True
|
||||
|
||||
await db.commit()
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
async def delete_account(
|
||||
account_id: str = Path(..., description="账户 ID"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除银行账户。"""
|
||||
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="账户不存在")
|
||||
await db.delete(account)
|
||||
await db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 银行交易查询
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
async def list_transactions(
|
||||
account_id: str = Query(..., description="银行账户 ID"),
|
||||
start_date: str = Query(..., description="开始日期 (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="结束日期 (YYYY-MM-DD)"),
|
||||
dc_flag: int | None = Query(None, description="借贷方向: 0-借/出金, 1-贷/入金, 不传返回全部"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询银行交易流水(带接口请求记录到文件日志)。"""
|
||||
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=404, detail="银行账户不存在")
|
||||
|
||||
data = await query_transactions_with_log(
|
||||
admin.id,
|
||||
acct_no=account.account_no,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
dc_flag=dc_flag,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.admin_api.scheduled_tasks.routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""定时任务管理后台路由。"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
from app.models.user import User
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/admin/scheduled-tasks", tags=["admin-scheduled-tasks"])
|
||||
|
||||
|
||||
def _task_to_dict(task: ScheduledTask) -> dict:
|
||||
return {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"task_type": task.task_type,
|
||||
"schedule": task.schedule,
|
||||
"config": task.config,
|
||||
"is_active": task.is_active,
|
||||
"last_run_at": task.last_run_at,
|
||||
"last_status": task.last_status,
|
||||
"last_error": task.last_error,
|
||||
"created_by": task.created_by,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出所有定时任务。"""
|
||||
result = await db.execute(select(ScheduledTask).order_by(ScheduledTask.created_at.desc()))
|
||||
tasks = result.scalars().all()
|
||||
return {"items": [_task_to_dict(t) for t in tasks]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_task(
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建定时任务。"""
|
||||
name = (body.get("name") or "").strip()
|
||||
task_type = (body.get("task_type") or "").strip()
|
||||
schedule = (body.get("schedule") or "").strip()
|
||||
if not name or not task_type or not schedule:
|
||||
raise HTTPException(status_code=400, detail="任务名称、类型、调度表达式不能为空")
|
||||
if task_type not in ("external_api", "internal_method"):
|
||||
raise HTTPException(status_code=400, detail="任务类型必须为 external_api 或 internal_method")
|
||||
|
||||
config = body.get("config")
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config, ensure_ascii=False)
|
||||
elif isinstance(config, str):
|
||||
# 验证 JSON 合法性
|
||||
try:
|
||||
json.loads(config)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
|
||||
|
||||
task = ScheduledTask(
|
||||
id=generate_id(),
|
||||
name=name,
|
||||
task_type=task_type,
|
||||
schedule=schedule,
|
||||
config=config,
|
||||
is_active=bool(body.get("is_active", True)),
|
||||
created_by=admin.id,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
return {"id": task.id, "message": "创建成功"}
|
||||
|
||||
|
||||
@router.put("/{task_id}")
|
||||
async def update_task(
|
||||
task_id: str = Path(..., description="任务 ID"),
|
||||
body: dict = ...,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新定时任务。"""
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if "name" in body:
|
||||
task.name = str(body["name"]).strip()
|
||||
if "task_type" in body:
|
||||
t = body["task_type"]
|
||||
if t not in ("external_api", "internal_method"):
|
||||
raise HTTPException(status_code=400, detail="任务类型必须为 external_api 或 internal_method")
|
||||
task.task_type = t
|
||||
if "schedule" in body:
|
||||
task.schedule = str(body["schedule"]).strip()
|
||||
if "is_active" in body:
|
||||
task.is_active = bool(body["is_active"])
|
||||
if "config" in body:
|
||||
config = body["config"]
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config, ensure_ascii=False)
|
||||
elif isinstance(config, str):
|
||||
try:
|
||||
json.loads(config)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
|
||||
task.config = config
|
||||
|
||||
await db.commit()
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_task(
|
||||
task_id: str = Path(..., description="任务 ID"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除定时任务。"""
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
await db.delete(task)
|
||||
await db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{task_id}/run")
|
||||
async def run_task(
|
||||
task_id: str = Path(..., description="任务 ID"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""手动执行一次定时任务。"""
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
from app.tasks.scheduled_tasks import execute_scheduled_task
|
||||
|
||||
execute_scheduled_task.apply_async(args=[task_id])
|
||||
return {"message": "任务已提交执行"}
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle")
|
||||
async def toggle_task(
|
||||
task_id: str = Path(..., description="任务 ID"),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""启用/禁用定时任务。"""
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
task.is_active = not task.is_active
|
||||
await db.commit()
|
||||
return {"is_active": task.is_active, "message": "已启用" if task.is_active else "已禁用"}
|
||||
@@ -15,6 +15,8 @@ from app.api.admin.contact import router as admin_contact_router
|
||||
from app.admin_api.api_keys import router as api_keys_admin_router
|
||||
from app.admin_api.api_model_pricings import router as api_model_pricings_admin_router
|
||||
from app.admin_api.vp_v3_quota import router as vp_v3_quota_admin_router
|
||||
from app.admin_api.bank import router as bank_admin_router
|
||||
from app.admin_api.scheduled_tasks import router as scheduled_tasks_admin_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(video_prompt_schema_config_router)
|
||||
@@ -32,3 +34,5 @@ router.include_router(admin_contact_router)
|
||||
router.include_router(api_keys_admin_router)
|
||||
router.include_router(api_model_pricings_admin_router)
|
||||
router.include_router(vp_v3_quota_admin_router)
|
||||
router.include_router(bank_admin_router)
|
||||
router.include_router(scheduled_tasks_admin_router)
|
||||
|
||||
@@ -399,6 +399,45 @@ async def update_user_admin_status(
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/single-device-login-override")
|
||||
async def update_single_device_login_override(
|
||||
user_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置用户级单设备登录覆盖。
|
||||
|
||||
body.override: True=强制启用, False=强制禁用, None=跟随全局
|
||||
"""
|
||||
override = body.get("override")
|
||||
if override is not None:
|
||||
override = bool(override)
|
||||
# None 表示跟随全局
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(single_device_login_override=override)
|
||||
)
|
||||
await db.flush()
|
||||
label = "跟随全局" if override is None else ("强制启用" if override else "强制禁用")
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"单设备登录设置: {label}",
|
||||
"PUT",
|
||||
f"/admin/users/{user_id}/single-device-login-override",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"override": override,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
|
||||
async def update_user_frontend_kind(
|
||||
user_id: str,
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -11,7 +11,9 @@ from app.dependencies import (
|
||||
get_current_user,
|
||||
get_current_user_allow_password_pending,
|
||||
get_db,
|
||||
security,
|
||||
)
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
@@ -31,6 +33,7 @@ from app.services.auth import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from app.utils.device import detect_device_type
|
||||
from app.services.sms import verify_sms_code
|
||||
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
||||
@@ -59,11 +62,16 @@ def _validate_captcha_if_needed(captcha_token: str | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _token_response(user: User, remember_me: bool = False) -> dict:
|
||||
def _token_response(user: User, remember_me: bool = False, device_type: str = "pc") -> dict:
|
||||
user.credits = round(user.credits, 2)
|
||||
# 递增 token_version — 单设备登录(全局互斥),使旧 token 全部失效
|
||||
user.token_version = (user.token_version or 0) + 1
|
||||
token = create_access_token(user.id, remember_me, user.token_version)
|
||||
# 按设备类型递增对应版本号 — 单设备登录(同端互斥)
|
||||
if device_type == "mobile":
|
||||
user.mobile_token_version = (user.mobile_token_version or 0) + 1
|
||||
version = user.mobile_token_version
|
||||
else:
|
||||
user.pc_token_version = (user.pc_token_version or 0) + 1
|
||||
version = user.pc_token_version
|
||||
token = create_access_token(user.id, remember_me, version, device_type)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -156,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||
summary="客户端密码登录",
|
||||
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。",
|
||||
)
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
_validate_captcha_if_needed(req.captcha_token)
|
||||
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
@@ -176,7 +184,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _handle_daily_login_credits(db, user)
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
return _token_response(user, req.remember_me, device_type)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -184,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
summary="客户端短信验证码登录",
|
||||
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。",
|
||||
)
|
||||
async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def sms_login(req: SmsLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "login")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
@@ -207,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _handle_daily_login_credits(db, user)
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
return _token_response(user, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
return _token_response(user, req.remember_me, device_type)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -215,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
summary="客户端手机号短信注册",
|
||||
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。",
|
||||
)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def register(req: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
ok = await verify_sms_code(req.phone, req.code, "register")
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
@@ -248,9 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
await _assign_default_frontend_menus(db, user)
|
||||
|
||||
user.credits = round(user.credits, 2)
|
||||
# 递增 token_version — 单设备登录
|
||||
user.token_version = (user.token_version or 0) + 1
|
||||
token = create_access_token(user.id, False, user.token_version)
|
||||
# 按设备类型递增对应版本号
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
if device_type == "mobile":
|
||||
user.mobile_token_version = (user.mobile_token_version or 0) + 1
|
||||
version = user.mobile_token_version
|
||||
else:
|
||||
user.pc_token_version = (user.pc_token_version or 0) + 1
|
||||
version = user.pc_token_version
|
||||
token = create_access_token(user.id, False, version, device_type)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
@@ -261,11 +277,20 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# 递增 token_version — 使当前 token 失效,实现主动退出后不可再用
|
||||
current_user.token_version = (current_user.token_version or 0) + 1
|
||||
# 按设备类型递增对应版本号 — 使当前 token 失效
|
||||
device_type = "pc"
|
||||
if credentials:
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload:
|
||||
device_type = payload.get("dev", "pc")
|
||||
if device_type == "mobile":
|
||||
current_user.mobile_token_version = (current_user.mobile_token_version or 0) + 1
|
||||
else:
|
||||
current_user.pc_token_version = (current_user.pc_token_version or 0) + 1
|
||||
await db.flush()
|
||||
return {"message": "ok"}
|
||||
|
||||
@@ -392,7 +417,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/admin-login")
|
||||
async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
async def admin_login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Admin-only login endpoint."""
|
||||
user = await authenticate_user(db, req.username, req.password)
|
||||
if not user:
|
||||
@@ -410,6 +435,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
user.last_login_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
|
||||
token = create_access_token(user.id, req.remember_me)
|
||||
device_type = detect_device_type(request.headers.get("user-agent"))
|
||||
token = create_access_token(user.id, req.remember_me, 0, device_type)
|
||||
user.credits = round(user.credits, 2)
|
||||
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
|
||||
|
||||
@@ -131,6 +131,10 @@ class Settings(BaseSettings):
|
||||
|
||||
CAPTCHA_ENABLED: bool = True
|
||||
|
||||
# 银行交易查询接口配置
|
||||
BANK_API_BASE: str = ""
|
||||
BANK_API_KEY: str = ""
|
||||
|
||||
BASE_URL: str = ""
|
||||
|
||||
CORS_ORIGINS: list[str] = ["*"]
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.models.base import async_session
|
||||
from app.models.user import User
|
||||
from app.services.auth import decode_access_token, user_must_set_password
|
||||
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
|
||||
from app.services.system_config_cache import get_system_config_value
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -42,6 +43,7 @@ async def get_current_user_allow_password_pending(
|
||||
|
||||
user_id = payload.get("sub")
|
||||
token_version = payload.get("ver", 0)
|
||||
device_type = payload.get("dev", "pc")
|
||||
|
||||
# Skip captcha tokens
|
||||
if user_id and user_id.startswith("captcha:"):
|
||||
@@ -58,8 +60,24 @@ async def get_current_user_allow_password_pending(
|
||||
detail="账号不存在或已禁用",
|
||||
)
|
||||
|
||||
# 单设备登录校验 — token 版本号不匹配说明已被踢出
|
||||
if token_version != user.token_version:
|
||||
# 单设备登录校验 — 根据全局开关 + 用户级覆盖决定是否启用
|
||||
override = getattr(user, "single_device_login_override", None)
|
||||
if override is True:
|
||||
enabled = True
|
||||
elif override is False:
|
||||
enabled = False
|
||||
else:
|
||||
# 跟随全局设置
|
||||
config_val = await get_system_config_value(db, "single_device_login_enabled")
|
||||
enabled = config_val is not None and config_val.lower() in ("true", "1", "yes")
|
||||
|
||||
if enabled:
|
||||
# 按设备类型比对对应版本号
|
||||
if device_type == "mobile":
|
||||
current_version = user.mobile_token_version
|
||||
else:
|
||||
current_version = user.pc_token_version
|
||||
if token_version != current_version:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="账号已在其他设备登录,请重新登录",
|
||||
|
||||
@@ -43,6 +43,8 @@ from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, Ho
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.invoice import Invoice, InvoiceOrder
|
||||
from app.models.invoice_header import InvoiceHeader
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
|
||||
|
||||
@@ -68,4 +70,5 @@ __all__ = [
|
||||
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
|
||||
"ApiModelPricing",
|
||||
"Invoice", "InvoiceOrder", "InvoiceHeader",
|
||||
"BankAccount", "ScheduledTask",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""银行账户信息模型。"""
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class BankAccount(Base, TimestampMixin):
|
||||
__tablename__ = "bank_accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
account_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="账户名称")
|
||||
bank_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="开户银行")
|
||||
account_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="银行账号")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否默认账户")
|
||||
description: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="备注")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""定时任务配置模型。"""
|
||||
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class ScheduledTask(Base, TimestampMixin):
|
||||
__tablename__ = "scheduled_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="任务名称")
|
||||
task_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="类型: external_api / internal_method")
|
||||
schedule: Mapped[str] = mapped_column(String(128), nullable=False, comment="Cron 表达式或间隔秒数")
|
||||
config: Mapped[str | None] = mapped_column(Text, nullable=True, comment="任务配置 JSON")
|
||||
# external_api config: {url, method, headers, payload}
|
||||
# internal_method config: {module, function, args}
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
|
||||
last_run_at: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="最后执行时间 ISO")
|
||||
last_status: Mapped[str | None] = mapped_column(String(16), nullable=True, comment="最后执行状态: success / error")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="最后执行错误信息")
|
||||
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="创建者管理员 ID")
|
||||
@@ -38,10 +38,18 @@ class User(Base, TimestampMixin):
|
||||
Integer, default=50, server_default="50", nullable=False
|
||||
)
|
||||
|
||||
# Token 版本号 — 每次登录/退出时递增,用于实现单设备登录(全局互斥)
|
||||
token_version: Mapped[int] = mapped_column(
|
||||
# 按设备类型分别管理 Token 版本号 — 单设备登录(同端互斥)
|
||||
pc_token_version: Mapped[int] = mapped_column(
|
||||
Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
mobile_token_version: Mapped[int] = mapped_column(
|
||||
Integer, default=0, server_default="0", nullable=False
|
||||
)
|
||||
|
||||
# 单设备登录用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
|
||||
single_device_login_override: Mapped[bool | None] = mapped_column(
|
||||
Boolean, nullable=True
|
||||
)
|
||||
|
||||
@property
|
||||
def credits(self) -> float:
|
||||
|
||||
@@ -66,6 +66,7 @@ class AdminUserOut(BaseModel):
|
||||
allowed_menus: list | None = None
|
||||
resource_capacity: ResourceCapacityUsageOut | None = None
|
||||
private_portrait_asset_limit: int = 50
|
||||
single_device_login_override: bool | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -22,10 +22,15 @@ def verify_password(plain: str, hashed: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: str, remember_me: bool = False, token_version: int = 0) -> str:
|
||||
def create_access_token(
|
||||
user_id: str,
|
||||
remember_me: bool = False,
|
||||
token_version: int = 0,
|
||||
device_type: str = "pc",
|
||||
) -> str:
|
||||
minutes = settings.JWT_EXPIRE_REMEMBER_MINUTES if remember_me else settings.JWT_EXPIRE_MINUTES
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
payload = {"sub": user_id, "exp": expire, "ver": token_version}
|
||||
payload = {"sub": user_id, "exp": expire, "ver": token_version, "dev": device_type}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from app.services.bank.client import query_bank_transactions, BankApiError
|
||||
from app.services.bank.service import query_transactions_with_log
|
||||
|
||||
__all__ = ["query_bank_transactions", "BankApiError", "query_transactions_with_log"]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""银行交易查询 API 客户端。"""
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class BankApiError(RuntimeError):
|
||||
"""银行接口错误。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
retryable: bool = False,
|
||||
http_status: int | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.retryable = retryable
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
async def query_bank_transactions(
|
||||
acct_no: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
dc_flag: int | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict:
|
||||
"""查询银行交易流水。
|
||||
|
||||
调用外部银行接口,按账号和日期范围查询交易记录。
|
||||
|
||||
:param acct_no: 我方银行账号
|
||||
:param start_date: 开始日期 (YYYY-MM-DD)
|
||||
:param end_date: 结束日期 (YYYY-MM-DD)
|
||||
:param dc_flag: 借贷方向 (0-借/出金, 1-贷/入金, None-全部)
|
||||
:param page: 页码
|
||||
:param page_size: 每页数量 (最大 100)
|
||||
:return: 接口返回的原始数据
|
||||
"""
|
||||
base = (settings.BANK_API_BASE or "").rstrip("/")
|
||||
if not base:
|
||||
raise BankApiError("银行接口地址未配置 (BANK_API_BASE)", retryable=False)
|
||||
|
||||
url = f"{base}/api/v1/internal/get-blank-transfer-acct-time"
|
||||
headers = {
|
||||
"api-key": settings.BANK_API_KEY or "",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict = {
|
||||
"acct_no": acct_no,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"page": page,
|
||||
"page_size": min(page_size, 100),
|
||||
}
|
||||
if dc_flag is not None:
|
||||
payload["dc_flag"] = dc_flag
|
||||
|
||||
timeout = httpx.Timeout(30.0)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise BankApiError(
|
||||
f"银行接口返回错误: HTTP {e.response.status_code}",
|
||||
retryable=e.response.status_code >= 500,
|
||||
http_status=e.response.status_code,
|
||||
) from e
|
||||
except httpx.TimeoutException as e:
|
||||
raise BankApiError("银行接口请求超时", retryable=True) from e
|
||||
except httpx.RequestError as e:
|
||||
raise BankApiError(f"银行接口请求失败: {e}", retryable=True) from e
|
||||
@@ -0,0 +1,50 @@
|
||||
"""银行接口请求文件日志记录器。
|
||||
|
||||
使用 get_logger 模式,日志输出到 log/bank_api/bank_api-YYYY-MM-DD.log。
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
bank_api_logger = get_logger("bank_api", "bank_api")
|
||||
|
||||
|
||||
def log_bank_api_request(
|
||||
acct_no: str,
|
||||
request_params: dict | None,
|
||||
response_data: dict | None,
|
||||
status_code: int | None,
|
||||
is_success: bool,
|
||||
error_msg: str | None = None,
|
||||
admin_id: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> None:
|
||||
"""记录银行接口请求到日志文件。
|
||||
|
||||
:param acct_no: 查询的银行账号
|
||||
:param request_params: 请求参数
|
||||
:param response_data: 响应数据
|
||||
:param status_code: HTTP 状态码
|
||||
:param is_success: 是否成功
|
||||
:param error_msg: 错误信息
|
||||
:param admin_id: 操作管理员 ID
|
||||
:param duration_ms: 请求耗时(毫秒)
|
||||
"""
|
||||
log_data = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"acct_no": acct_no,
|
||||
"request_params": request_params,
|
||||
"response_data": response_data,
|
||||
"status_code": status_code,
|
||||
"is_success": is_success,
|
||||
"error_msg": error_msg,
|
||||
"admin_id": admin_id,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
line = json.dumps(log_data, ensure_ascii=False, default=str)
|
||||
if is_success:
|
||||
bank_api_logger.info(line)
|
||||
else:
|
||||
bank_api_logger.error(line)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""银行交易查询服务 — 接口请求日志写入文件。"""
|
||||
|
||||
import time
|
||||
|
||||
from app.services.bank.client import query_bank_transactions, BankApiError
|
||||
from app.services.bank.file_logger import log_bank_api_request
|
||||
|
||||
|
||||
async def query_transactions_with_log(
|
||||
admin_id: str,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""查询银行流水并记录接口日志到文件。
|
||||
|
||||
:param admin_id: 操作管理员 ID
|
||||
:param kwargs: 透传给 query_bank_transactions 的参数
|
||||
:return: 接口返回的原始数据
|
||||
"""
|
||||
acct_no = kwargs.get("acct_no", "")
|
||||
request_params = {k: v for k, v in kwargs.items()}
|
||||
start_time = time.monotonic()
|
||||
try:
|
||||
result = await query_bank_transactions(**kwargs)
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
log_bank_api_request(
|
||||
acct_no=acct_no,
|
||||
request_params=request_params,
|
||||
response_data=result,
|
||||
status_code=200,
|
||||
is_success=True,
|
||||
admin_id=admin_id,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
return result
|
||||
except BankApiError as e:
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
log_bank_api_request(
|
||||
acct_no=acct_no,
|
||||
request_params=request_params,
|
||||
response_data=None,
|
||||
status_code=e.http_status,
|
||||
is_success=False,
|
||||
error_msg=str(e),
|
||||
admin_id=admin_id,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
raise
|
||||
@@ -40,6 +40,7 @@ CELERY_TASK_IMPORTS = (
|
||||
"app.tasks.api_generation_tasks",
|
||||
"app.tasks.api_recovery_tasks",
|
||||
"app.tasks.api_upscale_tasks",
|
||||
"app.tasks.scheduled_tasks",
|
||||
)
|
||||
|
||||
|
||||
@@ -292,6 +293,85 @@ else:
|
||||
celery_app = None
|
||||
|
||||
|
||||
def _parse_schedule_to_celery(schedule_str: str):
|
||||
"""将 schedule 字符串解析为 Celery 可识别的调度值。
|
||||
|
||||
- 纯数字:视为间隔秒数(返回 int)
|
||||
- cron 表达式 (5 字段空格分隔):返回 crontab 对象
|
||||
"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
s = (schedule_str or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
# 纯数字 → 间隔秒数
|
||||
if s.isdigit():
|
||||
return int(s)
|
||||
# cron 表达式 (分 时 日 月 周)
|
||||
parts = s.split()
|
||||
if len(parts) == 5:
|
||||
try:
|
||||
return crontab(
|
||||
minute=parts[0],
|
||||
hour=parts[1],
|
||||
day_of_month=parts[2],
|
||||
month_of_year=parts[3],
|
||||
day_of_week=parts[4],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("解析 cron 表达式失败: %s", s)
|
||||
return None
|
||||
logger.warning("无法解析 schedule 表达式: %s", s)
|
||||
return None
|
||||
|
||||
|
||||
@celery_app.on_after_configure.connect # type: ignore
|
||||
def _setup_dynamic_beat_tasks(sender, **kwargs):
|
||||
"""从数据库加载活跃定时任务并注册到 Beat 调度。
|
||||
|
||||
通过 @celery_app.on_after_configure.connect 在 Celery 配置完成后执行,
|
||||
适用于 Worker 和 Beat 启动场景。
|
||||
"""
|
||||
if celery_app is None:
|
||||
return
|
||||
|
||||
async def _load():
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ScheduledTask).where(ScheduledTask.is_active.is_(True))
|
||||
)
|
||||
tasks = result.scalars().all()
|
||||
return tasks
|
||||
|
||||
try:
|
||||
active_tasks = run_async(_load())
|
||||
except Exception:
|
||||
logger.exception("加载定时任务失败,跳过动态 Beat 注册")
|
||||
return
|
||||
|
||||
for task in active_tasks:
|
||||
schedule_val = _parse_schedule_to_celery(task.schedule)
|
||||
if schedule_val is None:
|
||||
logger.warning("定时任务 %s schedule 无效,跳过注册: %s", task.id, task.schedule)
|
||||
continue
|
||||
beat_key = f"dynamic-scheduled-task-{task.id}"
|
||||
sender.conf.beat_schedule[beat_key] = {
|
||||
"task": "execute_scheduled_task",
|
||||
"schedule": schedule_val,
|
||||
"args": (task.id,),
|
||||
"options": {"queue": RECOVERY_QUEUE},
|
||||
}
|
||||
logger.info(
|
||||
"动态注册定时任务到 Beat: %s (%s) schedule=%s",
|
||||
task.name, task.id, task.schedule,
|
||||
)
|
||||
|
||||
|
||||
async def _try_acquire_startup_recovery_lock() -> bool:
|
||||
"""任意 worker 启动时都可尝试抢恢复投递锁,避免依赖 hostname 命名。"""
|
||||
from app.services.redis_registry_service import redis_acquire_lock
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""定时任务执行器。
|
||||
|
||||
支持两种任务类型:
|
||||
- external_api: 调用外部 HTTP 接口
|
||||
- internal_method: 动态导入并执行内部函数
|
||||
|
||||
任务在 Celery Worker 中执行,通过 run_async 桥接异步操作。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.scheduled_task import ScheduledTask
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
|
||||
def _update_task_status(task_id: str, status: str, error_msg: str | None = None) -> None:
|
||||
"""更新任务最后执行状态。"""
|
||||
|
||||
async def _do():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
task.last_run_at = datetime.now(timezone.utc).isoformat()
|
||||
task.last_status = status
|
||||
task.last_error = error_msg
|
||||
await db.commit()
|
||||
|
||||
run_async(_do())
|
||||
|
||||
|
||||
def _execute_external_api(config: str | None) -> dict:
|
||||
"""执行外部 API 调用。"""
|
||||
cfg = json.loads(config or "{}")
|
||||
url = cfg.get("url", "").strip()
|
||||
method = cfg.get("method", "GET").upper()
|
||||
headers = cfg.get("headers") or {}
|
||||
payload = cfg.get("payload")
|
||||
timeout_val = float(cfg.get("timeout", 30))
|
||||
|
||||
if not url:
|
||||
raise ValueError("外部接口地址 (url) 未配置")
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(timeout_val, connect=10.0)) as client:
|
||||
if method == "GET":
|
||||
resp = client.get(url, headers=headers, params=payload)
|
||||
elif method == "DELETE":
|
||||
resp = client.delete(url, headers=headers, params=payload)
|
||||
else:
|
||||
resp = client.request(method, url, headers=headers, json=payload)
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
resp.raise_for_status()
|
||||
return {
|
||||
"status_code": resp.status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"body": resp.text[:2000],
|
||||
}
|
||||
except httpx.HTTPError as e:
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
raise RuntimeError(f"外部接口请求失败: {e} (耗时 {duration_ms}ms)") from e
|
||||
|
||||
|
||||
def _execute_internal_method(config: str | None) -> dict:
|
||||
"""执行内部方法调用。"""
|
||||
cfg = json.loads(config or "{}")
|
||||
module_path = cfg.get("module", "").strip()
|
||||
function_name = cfg.get("function", "").strip()
|
||||
args = cfg.get("args") or []
|
||||
|
||||
if not module_path or not function_name:
|
||||
raise ValueError("内部方法需要指定 module 和 function")
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
func = getattr(module, function_name, None)
|
||||
if func is None or not callable(func):
|
||||
raise ValueError(f"模块 {module_path} 中不存在可调用函数 {function_name}")
|
||||
|
||||
start = time.monotonic()
|
||||
result = func(*args)
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
return {
|
||||
"duration_ms": duration_ms,
|
||||
"result": str(result)[:1000] if result is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(name="execute_scheduled_task", bind=True, ignore_result=True) # type: ignore[call-arg]
|
||||
def execute_scheduled_task(self, task_id: str):
|
||||
"""执行定时任务(Celery 任务入口)。
|
||||
|
||||
通过 run_async 桥接到异步上下文读取任务配置并执行。
|
||||
"""
|
||||
|
||||
async def _run():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
logger.warning("定时任务不存在: %s", task_id)
|
||||
return
|
||||
if not task.is_active:
|
||||
logger.info("定时任务已禁用,跳过执行: %s", task_id)
|
||||
return
|
||||
|
||||
task_config = task.config
|
||||
task_type = task.task_type
|
||||
|
||||
try:
|
||||
if task_type == "external_api":
|
||||
exec_result = _execute_external_api(task_config)
|
||||
elif task_type == "internal_method":
|
||||
exec_result = _execute_internal_method(task_config)
|
||||
else:
|
||||
raise ValueError(f"未知的任务类型: {task_type}")
|
||||
|
||||
_update_task_status(task_id, "success")
|
||||
logger.info("定时任务执行成功: %s (%s) -> %s", task_id, task_type, exec_result)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
_update_task_status(task_id, "error", error_msg)
|
||||
logger.exception("定时任务执行失败: %s (%s)", task_id, task_type)
|
||||
|
||||
run_async(_run())
|
||||
@@ -0,0 +1,16 @@
|
||||
"""设备类型检测工具。"""
|
||||
|
||||
|
||||
def detect_device_type(user_agent: str | None) -> str:
|
||||
"""根据 User-Agent 判断设备类型:'pc' 或 'mobile'。
|
||||
|
||||
返回 'mobile' 表示手机/平板等移动设备,返回 'pc' 表示桌面设备或无法识别。
|
||||
"""
|
||||
if not user_agent:
|
||||
return "pc"
|
||||
ua = user_agent.lower()
|
||||
mobile_keywords = [
|
||||
"mobile", "android", "iphone", "ipad", "ipod",
|
||||
"windows phone", "blackberry", "opera mini", "opera mobi",
|
||||
]
|
||||
return "mobile" if any(kw in ua for kw in mobile_keywords) else "pc"
|
||||
+117
-117
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -27,7 +27,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>民众智创</title>
|
||||
<script type="module" crossorigin src="/assets/index-CGl8RZCw.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-8FIfOCqA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -117,6 +117,7 @@ if (!res.ok) {
|
||||
// 检测"被踢出"错误 — 单设备登录互斥
|
||||
if (res.status === 401 && typeof parsed?.detail === 'string' && parsed.detail.includes('其他设备登录')) {
|
||||
window.dispatchEvent(new CustomEvent('kicked-out', { detail: { message: parsed.detail } }));
|
||||
throw new Error(parsed.detail); // 阻止后续自动跳转,由通知按钮处理
|
||||
}
|
||||
if (res.status === 401 && !options.skipAuthRedirect) {
|
||||
clearToken();
|
||||
|
||||
@@ -65,7 +65,6 @@ import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRecha
|
||||
import type { CreditProduct, CreditProductCatalog } from '../../types';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import ActivityBanner from './ActivityBanner';
|
||||
import KickedOutModal from '../KickedOutModal';
|
||||
import './AppLayout.css';
|
||||
|
||||
|
||||
@@ -530,7 +529,6 @@ const AppLayout: React.FC = () => {
|
||||
});
|
||||
const [siteInfoLoading, setSiteInfoLoading] = useState(!localStorage.getItem('siteInfo'));
|
||||
const [bannerVisible, setBannerVisible] = useState(false);
|
||||
const [kickedOutVisible, setKickedOutVisible] = useState(false);
|
||||
|
||||
// 资源存储容量(从 getUser().resource_capacity 获取)
|
||||
const [resourceCapacity, setResourceCapacity] = useState<{
|
||||
@@ -546,12 +544,15 @@ const AppLayout: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [operationManualUrl, setOperationManualUrl] = useState('');
|
||||
|
||||
// 监听"被踢出"事件 — 单设备登录互斥
|
||||
// 监听"被踢出"事件 — 单设备登录互斥,跳转登录页并带上标识
|
||||
useEffect(() => {
|
||||
const handler = () => setKickedOutVisible(true);
|
||||
window.addEventListener('kicked-out', handler);
|
||||
return () => window.removeEventListener('kicked-out', handler);
|
||||
}, []);
|
||||
const handleKickedOut = () => {
|
||||
localStorage.removeItem('token');
|
||||
navigate('/login?kicked_out=1');
|
||||
};
|
||||
window.addEventListener('kicked-out', handleKickedOut);
|
||||
return () => window.removeEventListener('kicked-out', handleKickedOut);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo().then(info => {
|
||||
@@ -1123,7 +1124,6 @@ const AppLayout: React.FC = () => {
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<ActivityBanner onVisibilityChange={(v) => setBannerVisible(v)} />
|
||||
<KickedOutModal visible={kickedOutVisible} />
|
||||
<div style={{ display: 'flex', position: 'relative', padding: 4, gap: 12, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className="desktop-sidebar" style={{
|
||||
width: sidebarW, position: 'sticky', top: 4, alignSelf: 'flex-start', zIndex: 100,
|
||||
|
||||
@@ -74,6 +74,17 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
}, [tabParam]);
|
||||
|
||||
// 单设备登录互斥 — 被踢出后显示提示
|
||||
useEffect(() => {
|
||||
if (searchParams.get('kicked_out') === '1') {
|
||||
message.warning('您的账号已在其他设备登录,请重新登录');
|
||||
// 清除参数,避免刷新重复提示
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('kicked_out');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const goToRedirect = () => {
|
||||
if (redirect) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user