This commit is contained in:
sjy
2026-08-13 17:57:38 +08:00
39 changed files with 2397 additions and 364 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </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"> <link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head> </head>
<body> <body>
+4
View File
@@ -45,6 +45,8 @@ import AdminApiKeys from './pages/AdminApiKeys';
import AdminApiModelPricings from './pages/AdminApiModelPricings'; import AdminApiModelPricings from './pages/AdminApiModelPricings';
import AdminApiUsage from './pages/AdminApiUsage'; import AdminApiUsage from './pages/AdminApiUsage';
import AdminInvoices from './pages/AdminInvoices'; import AdminInvoices from './pages/AdminInvoices';
import AdminBankTransactions from './pages/AdminBankTransactions';
import AdminScheduledTasks from './pages/AdminScheduledTasks';
import { useAdminStore } from './store'; import { useAdminStore } from './store';
@@ -112,6 +114,8 @@ const App = () => {
<Route path="api-model-pricings" element={<AdminApiModelPricings />} /> <Route path="api-model-pricings" element={<AdminApiModelPricings />} />
<Route path="api-usage" element={<AdminApiUsage />} /> <Route path="api-usage" element={<AdminApiUsage />} />
<Route path="invoices" element={<AdminInvoices />} /> <Route path="invoices" element={<AdminInvoices />} />
<Route path="bank-transactions" element={<AdminBankTransactions />} />
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
<Route path="notifications" element={<AdminNotificationManager />} /> <Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} /> <Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
+101
View File
@@ -18,6 +18,7 @@ import type {
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct, VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
BankAccount, ScheduledTask,
} from '../types'; } from '../types';
import type { 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 }); 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[]> { export async function getModelConfigs(): Promise<ModelConfig[]> {
return api.get('/admin/model-configs'); 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(地址)、methodGET/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;
+249 -3
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import { 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'; } from '@ant-design/icons';
import { import {
createSystemConfig, createSystemConfig,
@@ -14,8 +14,12 @@ import {
uploadLogo, uploadLogo,
uploadPdf, uploadPdf,
uploadLoginVideo, uploadLoginVideo,
listBankAccounts,
createBankAccount,
updateBankAccount,
deleteBankAccount,
} from '../api'; } from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types'; import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [ const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' }, { value: 'MB', label: 'MB1024 × 1024 字节)' },
@@ -30,10 +34,116 @@ const AdminSettings: React.FC = () => {
const [uploading, setUploading] = useState(''); const [uploading, setUploading] = useState('');
const [form] = Form.useForm(); 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(() => { useEffect(() => {
load(); 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 () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -45,6 +155,14 @@ const AdminSettings: React.FC = () => {
if (!data.some(c => c.key === 'llm_media_as_base64')) { 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 编码' }); 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); setConfigs(data);
const formValues: Record<string, any> = {}; const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; }); data.forEach(c => { formValues[c.key] = c.value; });
@@ -154,6 +272,26 @@ const AdminSettings: React.FC = () => {
message.success('已移除登录背景视频'); 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) => { const handleToggleBase64 = async (checked: boolean) => {
try { try {
let config = configs.find(c => c.key === 'llm_media_as_base64'); 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'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), '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('user_') && c.key.includes('credits')),
'系统对接': configs.filter(c => c.key.startsWith('oa_')),
'其他配置': configs.filter(c => c.key === 'operation_manual'), '其他配置': configs.filter(c => c.key === 'operation_manual'),
}; };
@@ -389,6 +528,52 @@ const AdminSettings: React.FC = () => {
</Form> </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', key: 'other',
label: '其他配置', label: '其他配置',
@@ -493,6 +678,32 @@ const AdminSettings: React.FC = () => {
</div> </div>
</div> </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> </Form>
), ),
}, },
@@ -525,6 +736,41 @@ const AdminSettings: React.FC = () => {
</Button> </Button>
</div> </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> </div>
); );
}; };
+78 -2
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { 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'; } from 'antd';
import { 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'; } from '@ant-design/icons';
import { import {
adminDeductCredits, adminDeductCredits,
@@ -23,6 +23,7 @@ import {
saveUserResourceCapacity, saveUserResourceCapacity,
toggleUserStatus, toggleUserStatus,
updateFrontendUserKind, updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam, updateUserTeam,
updateSystemConfig, updateSystemConfig,
updateUserMenus, 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 [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 [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 [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 [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false); const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false); const [teamSaving, setTeamSaving] = useState(false);
@@ -136,6 +141,9 @@ const AdminUsers: React.FC = () => {
const formValues: Record<string, string> = {}; const formValues: Record<string, string> = {};
credit.forEach(c => { formValues[c.key] = c.value; }); credit.forEach(c => { formValues[c.key] = c.value; });
configForm.setFieldsValue(formValues); configForm.setFieldsValue(formValues);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} catch { /* auth error handled by client */ } } catch { /* auth error handled by client */ }
}; };
loadCreditConfigs(); 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 isAdminTab = activeTab === 'admin';
const columns = [ const columns = [
@@ -591,6 +613,15 @@ const AdminUsers: React.FC = () => {
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}> onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
</Button> </Button>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
</Button>
)}
<Popconfirm <Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'} title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)} onConfirm={() => handleToggleStatus(r)}
@@ -1084,6 +1115,51 @@ const AdminUsers: React.FC = () => {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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> </div>
); );
}; };
+32
View File
@@ -197,6 +197,7 @@ export interface AdminUser {
allowedMenus?: string[] | null; allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null; resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number; privatePortraitAssetLimit: number;
singleDeviceLoginOverride?: boolean | null;
} }
export interface DailyCredit { export interface DailyCredit {
@@ -1609,3 +1610,34 @@ export interface InvoiceDetail {
updatedAt: string | null; updatedAt: string | null;
orders: InvoiceOrder[]; 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
View File
@@ -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"]
+173
View File
@@ -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 "已禁用"}
+4
View File
@@ -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_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.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.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 = APIRouter()
router.include_router(video_prompt_schema_config_router) 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_keys_admin_router)
router.include_router(api_model_pricings_admin_router) router.include_router(api_model_pricings_admin_router)
router.include_router(vp_v3_quota_admin_router) router.include_router(vp_v3_quota_admin_router)
router.include_router(bank_admin_router)
router.include_router(scheduled_tasks_admin_router)
+39
View File
@@ -399,6 +399,45 @@ async def update_user_admin_status(
return {"message": "ok"} 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) @router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind( async def update_user_frontend_kind(
user_id: str, user_id: str,
+43 -17
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8)) 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,7 +11,9 @@ from app.dependencies import (
get_current_user, get_current_user,
get_current_user_allow_password_pending, get_current_user_allow_password_pending,
get_db, get_db,
security,
) )
from fastapi.security import HTTPAuthorizationCredentials
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
@@ -31,6 +33,7 @@ from app.services.auth import (
hash_password, hash_password,
verify_password, verify_password,
) )
from app.utils.device import detect_device_type
from app.services.sms import verify_sms_code from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel 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) user.credits = round(user.credits, 2)
# 递增 token_version — 单设备登录(全局互斥),使旧 token 全部失效 # 按设备类型递增对应版本号 — 单设备登录(同端互斥)
user.token_version = (user.token_version or 0) + 1 if device_type == "mobile":
token = create_access_token(user.id, remember_me, user.token_version) 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 { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -156,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
summary="客户端密码登录", summary="客户端密码登录",
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。", 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) _validate_captcha_if_needed(req.captcha_token)
user = await authenticate_user(db, req.username, req.password) 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) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST) user.last_login_at = datetime.now(CST)
await db.flush() 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( @router.post(
@@ -184,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端短信验证码登录", summary="客户端短信验证码登录",
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。", 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") ok = await verify_sms_code(req.phone, req.code, "login")
if not ok: if not ok:
raise HTTPException( raise HTTPException(
@@ -207,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST) user.last_login_at = datetime.now(CST)
await db.flush() 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( @router.post(
@@ -215,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端手机号短信注册", summary="客户端手机号短信注册",
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。", 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") ok = await verify_sms_code(req.phone, req.code, "register")
if not ok: if not ok:
raise HTTPException( raise HTTPException(
@@ -248,9 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await _assign_default_frontend_menus(db, user) await _assign_default_frontend_menus(db, user)
user.credits = round(user.credits, 2) user.credits = round(user.credits, 2)
# 递增 token_version — 单设备登录 # 按设备类型递增对应版本号
user.token_version = (user.token_version or 0) + 1 device_type = detect_device_type(request.headers.get("user-agent"))
token = create_access_token(user.id, False, 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, False, version, device_type)
return { return {
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
@@ -261,11 +277,20 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
current_user: User = Depends(get_current_user_allow_password_pending), current_user: User = Depends(get_current_user_allow_password_pending),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# 递增 token_version — 使当前 token 失效,实现主动退出后不可再用 # 按设备类型递增对应版本号 — 使当前 token 失效
current_user.token_version = (current_user.token_version or 0) + 1 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() await db.flush()
return {"message": "ok"} return {"message": "ok"}
@@ -392,7 +417,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
@router.post("/admin-login") @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.""" """Admin-only login endpoint."""
user = await authenticate_user(db, req.username, req.password) user = await authenticate_user(db, req.username, req.password)
if not user: 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) user.last_login_at = datetime.now(CST)
await db.flush() 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) user.credits = round(user.credits, 2)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)} return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
+4
View File
@@ -131,6 +131,10 @@ class Settings(BaseSettings):
CAPTCHA_ENABLED: bool = True CAPTCHA_ENABLED: bool = True
# 银行交易查询接口配置
BANK_API_BASE: str = ""
BANK_API_KEY: str = ""
BASE_URL: str = "" BASE_URL: str = ""
CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS: list[str] = ["*"]
+24 -6
View File
@@ -7,6 +7,7 @@ from app.models.base import async_session
from app.models.user import User from app.models.user import User
from app.services.auth import decode_access_token, user_must_set_password 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.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) security = HTTPBearer(auto_error=False)
@@ -42,6 +43,7 @@ async def get_current_user_allow_password_pending(
user_id = payload.get("sub") user_id = payload.get("sub")
token_version = payload.get("ver", 0) token_version = payload.get("ver", 0)
device_type = payload.get("dev", "pc")
# Skip captcha tokens # Skip captcha tokens
if user_id and user_id.startswith("captcha:"): if user_id and user_id.startswith("captcha:"):
@@ -58,12 +60,28 @@ async def get_current_user_allow_password_pending(
detail="账号不存在或已禁用", detail="账号不存在或已禁用",
) )
# 单设备登录校验 — token 版本号不匹配说明已被踢出 # 单设备登录校验 — 根据全局开关 + 用户级覆盖决定是否启用
if token_version != user.token_version: override = getattr(user, "single_device_login_override", None)
raise HTTPException( if override is True:
status_code=status.HTTP_401_UNAUTHORIZED, enabled = True
detail="账号已在其他设备登录,请重新登录", 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="账号已在其他设备登录,请重新登录",
)
attach_credit_snapshot(user, await get_available_credits(db, user.id)) attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user return user
+3
View File
@@ -43,6 +43,8 @@ from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, Ho
from app.models.contact_request import ContactRequest from app.models.contact_request import ContactRequest
from app.models.invoice import Invoice, InvoiceOrder from app.models.invoice import Invoice, InvoiceOrder
from app.models.invoice_header import InvoiceHeader 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.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -68,4 +70,5 @@ __all__ = [
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink", "ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing", "ApiModelPricing",
"Invoice", "InvoiceOrder", "InvoiceHeader", "Invoice", "InvoiceOrder", "InvoiceHeader",
"BankAccount", "ScheduledTask",
] ]
+18
View File
@@ -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")
+10 -2
View File
@@ -38,10 +38,18 @@ class User(Base, TimestampMixin):
Integer, default=50, server_default="50", nullable=False Integer, default=50, server_default="50", nullable=False
) )
# Token 版本号 — 每次登录/退出时递增,用于实现单设备登录(全局互斥) # 按设备类型分别管理 Token 版本号 — 单设备登录(同端互斥)
token_version: Mapped[int] = mapped_column( pc_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False 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 @property
def credits(self) -> float: def credits(self) -> float:
+1
View File
@@ -66,6 +66,7 @@ class AdminUserOut(BaseModel):
allowed_menus: list | None = None allowed_menus: list | None = None
resource_capacity: ResourceCapacityUsageOut | None = None resource_capacity: ResourceCapacityUsageOut | None = None
private_portrait_asset_limit: int = 50 private_portrait_asset_limit: int = 50
single_device_login_override: bool | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+7 -2
View File
@@ -22,10 +22,15 @@ def verify_password(plain: str, hashed: str | None) -> bool:
return False 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 minutes = settings.JWT_EXPIRE_REMEMBER_MINUTES if remember_me else settings.JWT_EXPIRE_MINUTES
expire = datetime.now(timezone.utc) + timedelta(minutes=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) 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"]
+76
View File
@@ -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
+80
View File
@@ -40,6 +40,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.api_generation_tasks", "app.tasks.api_generation_tasks",
"app.tasks.api_recovery_tasks", "app.tasks.api_recovery_tasks",
"app.tasks.api_upscale_tasks", "app.tasks.api_upscale_tasks",
"app.tasks.scheduled_tasks",
) )
@@ -292,6 +293,85 @@ else:
celery_app = None 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: async def _try_acquire_startup_recovery_lock() -> bool:
"""任意 worker 启动时都可尝试抢恢复投递锁,避免依赖 hostname 命名。""" """任意 worker 启动时都可尝试抢恢复投递锁,避免依赖 hostname 命名。"""
from app.services.redis_registry_service import redis_acquire_lock from app.services.redis_registry_service import redis_acquire_lock
+138
View File
@@ -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())
+16
View File
@@ -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"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-DSYnuUvx.css">
</head> </head>
<body> <body>
+1
View File
@@ -117,6 +117,7 @@ if (!res.ok) {
// 检测"被踢出"错误 — 单设备登录互斥 // 检测"被踢出"错误 — 单设备登录互斥
if (res.status === 401 && typeof parsed?.detail === 'string' && parsed.detail.includes('其他设备登录')) { if (res.status === 401 && typeof parsed?.detail === 'string' && parsed.detail.includes('其他设备登录')) {
window.dispatchEvent(new CustomEvent('kicked-out', { detail: { message: parsed.detail } })); window.dispatchEvent(new CustomEvent('kicked-out', { detail: { message: parsed.detail } }));
throw new Error(parsed.detail); // 阻止后续自动跳转,由通知按钮处理
} }
if (res.status === 401 && !options.skipAuthRedirect) { if (res.status === 401 && !options.skipAuthRedirect) {
clearToken(); clearToken();
@@ -65,7 +65,6 @@ import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRecha
import type { CreditProduct, CreditProductCatalog } from '../../types'; import type { CreditProduct, CreditProductCatalog } from '../../types';
import NotificationPopup from '../NotificationPopup'; import NotificationPopup from '../NotificationPopup';
import ActivityBanner from './ActivityBanner'; import ActivityBanner from './ActivityBanner';
import KickedOutModal from '../KickedOutModal';
import './AppLayout.css'; import './AppLayout.css';
@@ -530,7 +529,6 @@ const AppLayout: React.FC = () => {
}); });
const [siteInfoLoading, setSiteInfoLoading] = useState(!localStorage.getItem('siteInfo')); const [siteInfoLoading, setSiteInfoLoading] = useState(!localStorage.getItem('siteInfo'));
const [bannerVisible, setBannerVisible] = useState(false); const [bannerVisible, setBannerVisible] = useState(false);
const [kickedOutVisible, setKickedOutVisible] = useState(false);
// 资源存储容量(从 getUser().resource_capacity 获取) // 资源存储容量(从 getUser().resource_capacity 获取)
const [resourceCapacity, setResourceCapacity] = useState<{ const [resourceCapacity, setResourceCapacity] = useState<{
@@ -546,12 +544,15 @@ const AppLayout: React.FC = () => {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [operationManualUrl, setOperationManualUrl] = useState(''); const [operationManualUrl, setOperationManualUrl] = useState('');
// 监听"被踢出"事件 — 单设备登录互斥 // 监听"被踢出"事件 — 单设备登录互斥,跳转登录页并带上标识
useEffect(() => { useEffect(() => {
const handler = () => setKickedOutVisible(true); const handleKickedOut = () => {
window.addEventListener('kicked-out', handler); localStorage.removeItem('token');
return () => window.removeEventListener('kicked-out', handler); navigate('/login?kicked_out=1');
}, []); };
window.addEventListener('kicked-out', handleKickedOut);
return () => window.removeEventListener('kicked-out', handleKickedOut);
}, [navigate]);
useEffect(() => { useEffect(() => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
@@ -1123,7 +1124,6 @@ const AppLayout: React.FC = () => {
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<ActivityBanner onVisibilityChange={(v) => setBannerVisible(v)} /> <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 style={{ display: 'flex', position: 'relative', padding: 4, gap: 12, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div className="desktop-sidebar" style={{ <div className="desktop-sidebar" style={{
width: sidebarW, position: 'sticky', top: 4, alignSelf: 'flex-start', zIndex: 100, width: sidebarW, position: 'sticky', top: 4, alignSelf: 'flex-start', zIndex: 100,
+11
View File
@@ -74,6 +74,17 @@ const LoginPage: React.FC = () => {
} }
}, [tabParam]); }, [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 = () => { const goToRedirect = () => {
if (redirect) { if (redirect) {
try { try {