“单设备登录(同端互斥)”功能和后台全局用户配置
This commit is contained in:
Vendored
+134
-134
File diff suppressed because one or more lines are too long
Vendored
+36
-36
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DDKKooWp.js"></script>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DyfcSj-n.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -216,6 +216,10 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
|
||||
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
|
||||
}
|
||||
|
||||
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
|
||||
}
|
||||
|
||||
export async function getModelConfigs(): Promise<ModelConfig[]> {
|
||||
return api.get('/admin/model-configs');
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ const AdminSettings: React.FC = () => {
|
||||
if (!data.some(c => c.key === 'llm_media_as_base64')) {
|
||||
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
|
||||
}
|
||||
// 确保 single_device_login_enabled 配置存在
|
||||
if (!data.some(c => c.key === 'single_device_login_enabled')) {
|
||||
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
|
||||
}
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, any> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
@@ -154,6 +158,26 @@ const AdminSettings: React.FC = () => {
|
||||
message.success('已移除登录背景视频');
|
||||
};
|
||||
|
||||
const handleToggleSingleDevice = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||
} else {
|
||||
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
|
||||
config = res;
|
||||
}
|
||||
setConfigs(prev => {
|
||||
const exists = prev.some(c => c.key === 'single_device_login_enabled');
|
||||
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||
return [...prev, config!];
|
||||
});
|
||||
message.success(`已${checked ? '开启' : '关闭'}单设备登录限制`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBase64 = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||
@@ -493,6 +517,32 @@ const AdminSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 单设备登录限制 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
登录安全
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong>单设备登录限制</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
开启后同一账号仅允许在一台同类型设备登录(手机和电脑可同时登录)
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
|
||||
onChange={handleToggleSingleDevice}
|
||||
checkedChildren="已启用"
|
||||
unCheckedChildren="已禁用"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
adminDeductCredits,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
saveUserResourceCapacity,
|
||||
toggleUserStatus,
|
||||
updateFrontendUserKind,
|
||||
updateSingleDeviceLoginOverride,
|
||||
updateUserTeam,
|
||||
updateSystemConfig,
|
||||
updateUserMenus,
|
||||
@@ -84,6 +85,10 @@ const AdminUsers: React.FC = () => {
|
||||
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
||||
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
|
||||
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
|
||||
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
|
||||
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
|
||||
const [capacityLoading, setCapacityLoading] = useState(false);
|
||||
const [capacitySaving, setCapacitySaving] = useState(false);
|
||||
const [teamSaving, setTeamSaving] = useState(false);
|
||||
@@ -136,6 +141,9 @@ const AdminUsers: React.FC = () => {
|
||||
const formValues: Record<string, string> = {};
|
||||
credit.forEach(c => { formValues[c.key] = c.value; });
|
||||
configForm.setFieldsValue(formValues);
|
||||
// 获取全局单设备登录开关状态
|
||||
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
|
||||
} catch { /* auth error handled by client */ }
|
||||
};
|
||||
loadCreditConfigs();
|
||||
@@ -431,6 +439,20 @@ const AdminUsers: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
|
||||
try {
|
||||
setSingleDeviceSaving(true);
|
||||
await updateSingleDeviceLoginOverride(user.id, value);
|
||||
message.success('已更新单设备登录设置');
|
||||
setSingleDeviceModal({ open: false, user: null });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '设置失败');
|
||||
} finally {
|
||||
setSingleDeviceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAdminTab = activeTab === 'admin';
|
||||
|
||||
const columns = [
|
||||
@@ -591,6 +613,15 @@ const AdminUsers: React.FC = () => {
|
||||
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
|
||||
重置密码
|
||||
</Button>
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<SafetyOutlined />}
|
||||
onClick={() => {
|
||||
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
|
||||
setSingleDeviceModal({ open: true, user: r });
|
||||
}}>
|
||||
单设备登录
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
|
||||
onConfirm={() => handleToggleStatus(r)}
|
||||
@@ -1084,6 +1115,51 @@ const AdminUsers: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={<Space><SafetyOutlined />单设备登录设置 - {singleDeviceModal.user?.username}</Space>}
|
||||
open={singleDeviceModal.open}
|
||||
onOk={() => {
|
||||
if (singleDeviceModal.user) {
|
||||
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
|
||||
okText="保存" cancelText="取消" width={420}
|
||||
confirmLoading={singleDeviceSaving}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
当前全局设置:<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={singleDeviceValue}
|
||||
onChange={e => setSingleDeviceValue(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value={null}>
|
||||
<Typography.Text>跟随全局</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
({globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'})
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value={true}>
|
||||
<Typography.Text>强制启用</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
(该用户始终启用单设备登录,不受全局影响)
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value={false}>
|
||||
<Typography.Text>强制禁用</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
(该用户不受单设备登录限制,可多设备同时登录)
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -197,6 +197,7 @@ export interface AdminUser {
|
||||
allowedMenus?: string[] | null;
|
||||
resourceCapacity?: ResourceCapacityUsage | null;
|
||||
privatePortraitAssetLimit: number;
|
||||
singleDeviceLoginOverride?: boolean | null;
|
||||
}
|
||||
|
||||
export interface DailyCredit {
|
||||
|
||||
Reference in New Issue
Block a user