948 lines
42 KiB
JavaScript
948 lines
42 KiB
JavaScript
/**
|
||
* TalkingQ Platform - RBAC Permission Management Page
|
||
*/
|
||
|
||
registerPage('rbac', function(container) {
|
||
const DEFAULT_ROLE_COMPANIES = getDefaultCompanyIds();
|
||
|
||
const PERMISSION_CATALOG = [
|
||
{ key: 'dashboard', label: '数据仪表盘', desc: '查看公司维度统计数据' },
|
||
{ key: 'agent-dev', label: '配置应用', desc: '配置智能体模型、Prompt、技能和运行体验' },
|
||
{ key: 'agent-manage', label: '应用管理', desc: '创建、发布、回收应用' },
|
||
{ key: 'session', label: '会话分析', desc: '查看设备会话和热门话题' },
|
||
{ key: 'badcase', label: 'Bad Case 管理', desc: '标注、处理和关闭问题案例' },
|
||
{ key: 'device', label: '设备管理', desc: '查看和维护公司设备' },
|
||
{ key: 'ota', label: 'OTA 升级', desc: '上传固件和创建升级任务' },
|
||
{ key: 'rbac', label: '权限管理', desc: '管理用户、角色和授权范围' },
|
||
{ key: 'settings', label: '系统设置', desc: '管理 API Key 和平台配置' },
|
||
];
|
||
|
||
const DEFAULT_ROLE_CONFIGS = [
|
||
{
|
||
id: 'admin',
|
||
name: 'Admin',
|
||
desc: '超级管理员,拥有平台全部权限和全部公司数据访问权',
|
||
color: '#722ed1',
|
||
companyScope: 'all',
|
||
permissions: PERMISSION_CATALOG.map(item => item.key),
|
||
locked: true,
|
||
},
|
||
{
|
||
id: 'worker',
|
||
name: 'Worker',
|
||
desc: '内部运营 / 研发人员,可配置应用并处理运营问题',
|
||
color: '#1890ff',
|
||
companyScope: 'all',
|
||
permissions: ['dashboard', 'agent-dev', 'agent-manage', 'session', 'badcase', 'device', 'ota'],
|
||
locked: false,
|
||
},
|
||
{
|
||
id: 'client',
|
||
name: 'Client',
|
||
desc: 'B 端客户,只能访问自己公司的客户可见数据',
|
||
color: '#52c41a',
|
||
companyScope: 'single',
|
||
permissions: ['dashboard', 'session', 'device'],
|
||
locked: true,
|
||
},
|
||
];
|
||
|
||
const DEFAULT_USERS = [
|
||
{ id: 'u001', displayName: '张明华', loginName: 'zhangmh@talkingq.com', status: 'active', lastLogin: '2026-05-12 14:10', role: 'admin', remark: '超级管理员' },
|
||
{ id: 'u002', displayName: '李思远', loginName: 'lisiy@talkingq.com', status: 'active', lastLogin: '2026-05-12 11:30', role: 'worker', remark: '运营人员' },
|
||
{ id: 'u003', displayName: '王晓燕', loginName: 'wangxy@partner.com', status: 'active', lastLogin: '2026-05-11 09:00', role: 'client', companyId: 'company_a', allowedCompanies: ['company_a'], remark: '智慧星科技-客户' },
|
||
{ id: 'u004', displayName: '刘亚楠', loginName: 'liuyn@partner.com', status: 'inactive', lastLogin: '2026-04-28 16:00', role: 'client', companyId: 'company_b', allowedCompanies: ['company_b'], remark: '未来玩具厂-客户' },
|
||
{ id: 'u005', displayName: '陈志强', loginName: 'chenzq@talkingq.com', status: 'active', lastLogin: '2026-05-10 09:15', role: 'worker', remark: '开发人员' },
|
||
];
|
||
|
||
const DEFAULT_AUDIT_LOGS = [
|
||
{ id: 'a001', time: '2026-05-12 14:22:05', user: '张明华', action: '登录', target: '系统', ip: '192.168.1.10', ok: true },
|
||
{ id: 'a002', time: '2026-05-12 14:18:30', user: '张明华', action: '修改 Prompt', target: '小Q故事机器人 v2.3', ip: '192.168.1.10', ok: true },
|
||
{ id: 'a003', time: '2026-05-12 13:55:12', user: '李思远', action: '关闭 Bad Case', target: 'BC-2026-004', ip: '10.0.2.15', ok: true },
|
||
{ id: 'a004', time: '2026-05-12 11:30:00', user: 'system', action: '自动触发内容安全', target: 'sess-3f7a', ip: 'internal', ok: false },
|
||
{ id: 'a005', time: '2026-05-12 09:15:42', user: '张明华', action: '创建 OTA 任务', target: 'OTA-2026-002', ip: '192.168.1.10', ok: true },
|
||
];
|
||
|
||
let activeTab = window.__rbacActiveTab || 'users';
|
||
let roleConfigs = window.__rbacRoleConfigs || cloneRoleConfigs(DEFAULT_ROLE_CONFIGS);
|
||
let users = loadUsers();
|
||
let auditLogs = window.__rbacAuditLogs || DEFAULT_AUDIT_LOGS.map(item => ({ ...item }));
|
||
|
||
window.__rbacRoleConfigs = roleConfigs;
|
||
window.__rbacUsers = users;
|
||
window.__rbacAuditLogs = auditLogs;
|
||
window.__lookupRbacUserByLoginName = function(loginName) {
|
||
const normalized = String(loginName || '').trim().toLowerCase();
|
||
return users.find(user => user.loginName === normalized) || null;
|
||
};
|
||
window.__getRoleConfig = function(role) {
|
||
return getRoleConfig(role);
|
||
};
|
||
|
||
function getDefaultCompanyIds() {
|
||
const ids = (window.companies || [])
|
||
.filter(company => company.id !== 'all')
|
||
.map(company => company.id);
|
||
return ids.length ? ids : ['company_a', 'company_b', 'company_c'];
|
||
}
|
||
|
||
function cloneRoleConfigs(list) {
|
||
return list.map(role => ({
|
||
...role,
|
||
permissions: [...role.permissions],
|
||
allowedCompanyIds: role.allowedCompanyIds ? [...role.allowedCompanyIds] : DEFAULT_ROLE_COMPANIES.slice(),
|
||
}));
|
||
}
|
||
|
||
function cloneUsers(list) {
|
||
return list.map(item => ({
|
||
...item,
|
||
allowedCompanies: Array.isArray(item.allowedCompanies) ? [...item.allowedCompanies] : item.allowedCompanies,
|
||
permissions: Array.isArray(item.permissions) ? [...item.permissions] : item.permissions,
|
||
}));
|
||
}
|
||
|
||
function loadUsers() {
|
||
try {
|
||
localStorage.removeItem('talkingq_rbac_users_v1');
|
||
if (Array.isArray(window.__rbacUsers) && window.__rbacUsers.length) {
|
||
return window.__rbacUsers.map((user, index) => normalizeUser(user, index));
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to reset legacy RBAC storage', error);
|
||
}
|
||
return cloneUsers(DEFAULT_USERS).map((user, index) => normalizeUser(user, index));
|
||
}
|
||
|
||
function saveUsers(nextUsers = users) {
|
||
users = nextUsers;
|
||
window.__rbacUsers = users;
|
||
}
|
||
|
||
function saveRoles(nextRoles = roleConfigs) {
|
||
roleConfigs = nextRoles;
|
||
window.__rbacRoleConfigs = roleConfigs;
|
||
}
|
||
|
||
function saveAudit(nextLogs = auditLogs) {
|
||
auditLogs = nextLogs.slice(0, 80);
|
||
window.__rbacAuditLogs = auditLogs;
|
||
}
|
||
|
||
function normalizeRole(role) {
|
||
const raw = String(role || '').toLowerCase();
|
||
if (raw === 'admin' || raw === 'administrator') return 'admin';
|
||
if (raw === 'worker' || raw === 'employee') return 'worker';
|
||
if (raw === 'client' || raw === 'customer') return 'client';
|
||
return 'worker';
|
||
}
|
||
|
||
function inferRoleFromRemark(remark) {
|
||
const text = String(remark || '');
|
||
if (text.includes('超级管理员')) return 'admin';
|
||
if (text.includes('客户')) return 'client';
|
||
return 'worker';
|
||
}
|
||
|
||
function getRoleConfig(role) {
|
||
const roleId = normalizeRole(role);
|
||
return roleConfigs.find(item => item.id === roleId) || roleConfigs.find(item => item.id === 'worker');
|
||
}
|
||
|
||
function getRoleDefaultPermissions(role) {
|
||
return [...(getRoleConfig(role)?.permissions || [])];
|
||
}
|
||
|
||
function normalizeAllowedCompanies(value, companyId, role) {
|
||
if (role !== 'client') return DEFAULT_ROLE_COMPANIES.slice();
|
||
if (Array.isArray(value) && value.length) return value;
|
||
return companyId ? [companyId] : [];
|
||
}
|
||
|
||
function normalizeUser(user, index = 0) {
|
||
const role = normalizeRole(user.role || inferRoleFromRemark(user.remark));
|
||
const fallbackCompanyId = DEFAULT_ROLE_COMPANIES[index % DEFAULT_ROLE_COMPANIES.length];
|
||
const companyId = role === 'client'
|
||
? (user.companyId || user.allowedCompanies?.[0] || fallbackCompanyId)
|
||
: null;
|
||
return {
|
||
id: user.id || `u${String(index + 1).padStart(3, '0')}`,
|
||
displayName: user.displayName || user.loginName || '未命名用户',
|
||
loginName: String(user.loginName || '').trim().toLowerCase(),
|
||
status: user.status || 'active',
|
||
lastLogin: user.lastLogin || '-',
|
||
role,
|
||
companyId,
|
||
allowedCompanies: normalizeAllowedCompanies(user.allowedCompanies, companyId, role),
|
||
permissions: Array.isArray(user.permissions) && user.permissions.length
|
||
? [...user.permissions]
|
||
: getRoleDefaultPermissions(role),
|
||
remark: user.remark || (role === 'client' ? `${getCompanyName(companyId)}-客户` : role === 'admin' ? '超级管理员' : '运营人员'),
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function getNowText() {
|
||
const now = new Date();
|
||
const pad = value => String(value).padStart(2, '0');
|
||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
||
}
|
||
|
||
function getCompanyName(companyId) {
|
||
return window.__getCompanyNameById ? window.__getCompanyNameById(companyId) : companyId;
|
||
}
|
||
|
||
function getCompanyOptionsHtml(selectedCompanyId = '') {
|
||
const companies = (window.companies || []).filter(company => company.id !== 'all');
|
||
return companies.map(company => `
|
||
<option value="${escapeHtml(company.id)}" ${company.id === selectedCompanyId ? 'selected' : ''}>${escapeHtml(company.name)}</option>
|
||
`).join('');
|
||
}
|
||
|
||
function renderCompanyCheckboxes(selectedIds = DEFAULT_ROLE_COMPANIES) {
|
||
const selected = new Set(selectedIds);
|
||
const companies = (window.companies || []).filter(company => company.id !== 'all');
|
||
return companies.map(company => `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:8px 0;cursor:pointer">
|
||
<input type="checkbox" class="rbac-company-checkbox" value="${escapeHtml(company.id)}" ${selected.has(company.id) ? 'checked' : ''} style="accent-color:var(--primary)">
|
||
<span>${escapeHtml(company.name)}</span>
|
||
</label>
|
||
`).join('');
|
||
}
|
||
|
||
function getUserById(userId) {
|
||
return users.find(user => user.id === userId);
|
||
}
|
||
|
||
function getUserRoleLabel(user) {
|
||
if (user.role === 'admin') return '管理员';
|
||
if (user.role === 'client') return '客户';
|
||
return '运营人员';
|
||
}
|
||
|
||
function getCompanyScopeText(user) {
|
||
if (user.role !== 'client') return '全部公司';
|
||
return user.companyId ? getCompanyName(user.companyId) : '未绑定公司';
|
||
}
|
||
|
||
function getStatusLabel(status) {
|
||
return status === 'inactive' ? '已禁用' : '启用中';
|
||
}
|
||
|
||
function getStatusBadge(status) {
|
||
return status === 'inactive' ? 'badge-default' : 'badge-success';
|
||
}
|
||
|
||
function getActorName() {
|
||
try {
|
||
const user = JSON.parse(sessionStorage.getItem('talkingq_user') || 'null');
|
||
return user?.displayName || user?.username || '张明华';
|
||
} catch (error) {
|
||
return '张明华';
|
||
}
|
||
}
|
||
|
||
function addAuditLog(action, target, ok = true) {
|
||
saveAudit([
|
||
{
|
||
id: `a${Date.now()}`,
|
||
time: getNowText(),
|
||
user: getActorName(),
|
||
action,
|
||
target,
|
||
ip: 'local-demo',
|
||
ok,
|
||
},
|
||
...auditLogs,
|
||
]);
|
||
}
|
||
|
||
function getPermissionLabel(key) {
|
||
return PERMISSION_CATALOG.find(item => item.key === key)?.label || key;
|
||
}
|
||
|
||
function renderPermissionBadges(permissionKeys) {
|
||
const keys = permissionKeys.length ? permissionKeys : [];
|
||
const shown = keys.slice(0, 8);
|
||
return `
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px">
|
||
${shown.map(key => `<span class="badge badge-default" style="font-size:11px">${escapeHtml(getPermissionLabel(key))}</span>`).join('')}
|
||
${keys.length > shown.length ? `<span class="badge badge-default" style="font-size:11px">+${keys.length - shown.length}</span>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPermissionCheckboxes(roleId, selectedKeys, editable = true) {
|
||
const role = getRoleConfig(roleId);
|
||
const selected = new Set(selectedKeys || role.permissions);
|
||
const locked = role.id === 'admin' || role.id === 'client' || !editable;
|
||
return PERMISSION_CATALOG.map(permission => {
|
||
const checked = role.id === 'admin' ? true : selected.has(permission.key);
|
||
const disabled = locked ? 'disabled' : '';
|
||
return `
|
||
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--border);cursor:${disabled ? 'default' : 'pointer'};opacity:${disabled ? '.78' : '1'}">
|
||
<input type="checkbox" class="rbac-permission-checkbox" value="${permission.key}" ${checked ? 'checked' : ''} ${disabled} style="margin-top:3px;accent-color:var(--primary)">
|
||
<span>
|
||
<span style="display:block;font-size:13px;font-weight:600;color:var(--text-primary)">${escapeHtml(permission.label)}</span>
|
||
<span style="display:block;font-size:12px;color:var(--text-muted);margin-top:2px">${escapeHtml(permission.desc)}</span>
|
||
</span>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function collectCheckedValues(root, selector) {
|
||
return [...root.querySelectorAll(selector)]
|
||
.filter(input => input.checked)
|
||
.map(input => input.value);
|
||
}
|
||
|
||
function isLastActiveAdmin(userId) {
|
||
const activeAdmins = users.filter(user => user.role === 'admin' && user.status === 'active');
|
||
return activeAdmins.length <= 1 && activeAdmins.some(user => user.id === userId);
|
||
}
|
||
|
||
function renderPage() {
|
||
activeTab = activeTab || 'users';
|
||
window.__rbacActiveTab = activeTab;
|
||
container.innerHTML = `
|
||
<div class="page-header">
|
||
<div>
|
||
<div class="page-title">权限管理 (RBAC)</div>
|
||
<div class="page-desc">基于角色的访问控制,管理用户权限和公司数据隔离</div>
|
||
</div>
|
||
<div class="page-actions">
|
||
<button class="btn btn-primary" id="addUserBtn">
|
||
<i data-lucide="user-plus"></i> 添加用户
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs" id="rbacTabs">
|
||
<div class="tab-item ${activeTab === 'users' ? 'active' : ''}" data-tab="users">用户管理</div>
|
||
<div class="tab-item ${activeTab === 'roles' ? 'active' : ''}" data-tab="roles">角色配置</div>
|
||
<div class="tab-item ${activeTab === 'audit' ? 'active' : ''}" data-tab="audit">审计日志</div>
|
||
</div>
|
||
|
||
<div class="tab-content ${activeTab === 'users' ? 'active' : ''}" id="tab-users">${renderUsersTab()}</div>
|
||
<div class="tab-content ${activeTab === 'roles' ? 'active' : ''}" id="tab-roles">${renderRolesTab()}</div>
|
||
<div class="tab-content ${activeTab === 'audit' ? 'active' : ''}" id="tab-audit">${renderAuditTab()}</div>
|
||
`;
|
||
|
||
bindEvents();
|
||
lucide.createIcons({ elements: [container] });
|
||
}
|
||
|
||
function renderUsersTab() {
|
||
return `
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div class="table-wrapper">
|
||
<table class="rbac-table">
|
||
<thead>
|
||
<tr>
|
||
<th>显示名称</th>
|
||
<th>登录名称</th>
|
||
<th>用户类型</th>
|
||
<th>数据范围</th>
|
||
<th>状态</th>
|
||
<th>备注</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${users.map(user => renderUserRow(user)).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderUserRow(user) {
|
||
const role = getRoleConfig(user.role);
|
||
const safeName = escapeHtml(user.displayName);
|
||
return `
|
||
<tr>
|
||
<td>
|
||
<div class="user-cell">
|
||
<span class="user-display-name">${safeName}</span>
|
||
${user.status === 'active'
|
||
? '<span class="status-indicator online" title="启用中"></span>'
|
||
: '<span class="status-indicator offline" title="已禁用"></span>'}
|
||
</div>
|
||
</td>
|
||
<td class="login-name-cell">${escapeHtml(user.loginName)}</td>
|
||
<td>
|
||
<span class="badge" style="background:${role.color}20;color:${role.color};border:1px solid ${role.color}40">${escapeHtml(getUserRoleLabel(user))}</span>
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-secondary)">${escapeHtml(getCompanyScopeText(user))}</td>
|
||
<td><span class="badge ${getStatusBadge(user.status)}">${getStatusLabel(user.status)}</span></td>
|
||
<td class="remark-cell">${escapeHtml(user.remark || '-')}</td>
|
||
<td>
|
||
<div class="action-links">
|
||
<button class="action-link green" onclick="editUser('${user.id}')">编辑</button>
|
||
<span class="action-divider">|</span>
|
||
<button class="action-link primary" onclick="managePermissions('${user.id}')">权限管理</button>
|
||
<span class="action-divider">|</span>
|
||
<button class="action-link" onclick="toggleUserStatus('${user.id}')">${user.status === 'active' ? '禁用' : '启用'}</button>
|
||
<span class="action-divider">|</span>
|
||
<button class="action-link danger" onclick="deleteUser('${user.id}')">删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function renderRolesTab() {
|
||
return `
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:16px">
|
||
${roleConfigs.map(role => `
|
||
<div class="card role-card" style="border-top:3px solid ${role.color}">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||
<span style="font-size:16px;font-weight:700;color:${role.color}">${escapeHtml(role.name)}</span>
|
||
<span class="badge badge-default" style="font-size:11px">${role.id === 'admin' ? '全部' : role.permissions.length} 项权限</span>
|
||
</div>
|
||
<div style="font-size:13px;color:var(--text-muted);margin-bottom:14px;line-height:1.6">${escapeHtml(role.desc)}</div>
|
||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">可访问功能:</div>
|
||
${renderPermissionBadges(role.id === 'admin' ? PERMISSION_CATALOG.map(item => item.key) : role.permissions)}
|
||
<div style="margin-top:14px;display:flex;gap:8px">
|
||
<button class="btn btn-outline btn-sm" style="flex:1" onclick="editRolePermissions('${role.id}')">编辑权限</button>
|
||
<button class="btn btn-ghost btn-sm" onclick="viewRoleUsers('${role.id}')">查看用户</button>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="api-note" style="margin-top:16px">
|
||
<i data-lucide="info"></i>
|
||
<span>RBAC 接口预留:<code>GET /api/v1/rbac/roles</code>、<code>PUT /api/v1/rbac/roles/{id}</code>、<code>POST /api/v1/rbac/users/assign-role</code></span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderAuditTab() {
|
||
return `
|
||
<div class="card" style="padding:0;overflow:hidden">
|
||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||
<div>
|
||
<div style="font-size:14px;font-weight:700">审计日志</div>
|
||
<div style="font-size:12px;color:var(--text-muted);margin-top:2px">记录权限、角色、用户状态等关键变更</div>
|
||
</div>
|
||
<button class="btn btn-outline btn-sm" id="refreshAuditBtn"><i data-lucide="refresh-cw"></i> 刷新日志</button>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table>
|
||
<thead><tr><th>时间</th><th>操作人</th><th>操作类型</th><th>目标</th><th>IP 地址</th><th>结果</th></tr></thead>
|
||
<tbody>
|
||
${auditLogs.map(log => `
|
||
<tr>
|
||
<td style="font-family:monospace;font-size:12px">${escapeHtml(log.time)}</td>
|
||
<td>${escapeHtml(log.user)}</td>
|
||
<td>${escapeHtml(log.action)}</td>
|
||
<td style="font-size:12px;color:var(--text-muted)">${escapeHtml(log.target)}</td>
|
||
<td style="font-family:monospace;font-size:12px">${escapeHtml(log.ip)}</td>
|
||
<td><span class="badge ${log.ok ? 'badge-success' : 'badge-danger'}">${log.ok ? '成功' : '告警'}</span></td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function bindEvents() {
|
||
container.querySelectorAll('#rbacTabs .tab-item').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
activeTab = tab.dataset.tab;
|
||
renderPage();
|
||
});
|
||
});
|
||
|
||
container.querySelector('#addUserBtn')?.addEventListener('click', openAddUserModal);
|
||
container.querySelector('#refreshAuditBtn')?.addEventListener('click', () => {
|
||
addAuditLog('刷新审计日志', '权限管理 (RBAC)');
|
||
activeTab = 'audit';
|
||
renderPage();
|
||
Toast.success('审计日志已刷新');
|
||
});
|
||
}
|
||
|
||
function openAddUserModal() {
|
||
const modal = createModal({
|
||
title: '添加用户',
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">显示名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="newUserDisplayName" placeholder="用户显示名称">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">登录名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="newUserLoginName" placeholder="username@talkingq.com">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">角色<span class="required">*</span></label>
|
||
<select class="form-control" id="newUserRole">
|
||
<option value="">请选择角色</option>
|
||
<option value="admin">Admin - 超级管理员</option>
|
||
<option value="worker">Worker - 运营人员</option>
|
||
<option value="client">Client - B端客户</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">状态</label>
|
||
<select class="form-control" id="newUserStatus">
|
||
<option value="active">启用</option>
|
||
<option value="inactive">禁用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="newCompanyGroup" style="display:none">
|
||
<label class="form-label">所属公司<span class="required">*</span></label>
|
||
<select class="form-control" id="newUserCompany">
|
||
<option value="">请选择公司</option>
|
||
${getCompanyOptionsHtml()}
|
||
</select>
|
||
<div class="form-hint">Client 角色必须绑定一个公司,登录后仅能查看该公司客户可见内容。</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">初始密码<span class="required">*</span></label>
|
||
<input type="password" class="form-control" id="newUserPassword" placeholder="设置初始密码">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">备注</label>
|
||
<input type="text" class="form-control" id="newUserRemark" placeholder="可选">
|
||
</div>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
const displayName = modal.overlay.querySelector('#newUserDisplayName')?.value.trim();
|
||
const loginName = modal.overlay.querySelector('#newUserLoginName')?.value.trim().toLowerCase();
|
||
const role = modal.overlay.querySelector('#newUserRole')?.value;
|
||
const status = modal.overlay.querySelector('#newUserStatus')?.value || 'active';
|
||
const companyId = modal.overlay.querySelector('#newUserCompany')?.value;
|
||
const password = modal.overlay.querySelector('#newUserPassword')?.value;
|
||
const remark = modal.overlay.querySelector('#newUserRemark')?.value.trim();
|
||
|
||
if (!displayName || !loginName || !role || !password) {
|
||
Toast.warning('请填写用户必填信息');
|
||
return false;
|
||
}
|
||
if (users.some(user => user.loginName === loginName)) {
|
||
Toast.warning('登录名称已存在');
|
||
return false;
|
||
}
|
||
if (role === 'client' && !companyId) {
|
||
Toast.warning('Client 角色必须选择所属公司');
|
||
return false;
|
||
}
|
||
|
||
const nextUser = normalizeUser({
|
||
id: `u${Date.now()}`,
|
||
displayName,
|
||
loginName,
|
||
role,
|
||
status,
|
||
companyId: role === 'client' ? companyId : null,
|
||
allowedCompanies: role === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(),
|
||
remark: remark || (role === 'client' ? `${getCompanyName(companyId)}-客户` : role === 'admin' ? '超级管理员' : '运营人员'),
|
||
}, users.length);
|
||
|
||
saveUsers([nextUser, ...users]);
|
||
addAuditLog('创建用户', `${nextUser.displayName} / ${getUserRoleLabel(nextUser)}`);
|
||
try {
|
||
await window.API?.rbac?.createUser?.({ displayName, loginName, role, companyId, status });
|
||
} catch (error) {
|
||
console.warn('Mock create user failed:', error);
|
||
}
|
||
Toast.success('用户创建成功');
|
||
activeTab = 'users';
|
||
renderPage();
|
||
},
|
||
confirmText: '创建用户',
|
||
});
|
||
|
||
bindRoleCompanyToggle(modal.overlay, '#newUserRole', '#newCompanyGroup', '#newUserCompany');
|
||
}
|
||
|
||
function bindRoleCompanyToggle(root, roleSelector, groupSelector, companySelector) {
|
||
const roleSelect = root.querySelector(roleSelector);
|
||
const group = root.querySelector(groupSelector);
|
||
const companySelect = root.querySelector(companySelector);
|
||
const sync = () => {
|
||
const isClient = roleSelect?.value === 'client';
|
||
if (group) group.style.display = isClient ? '' : 'none';
|
||
if (companySelect) companySelect.disabled = !isClient;
|
||
};
|
||
roleSelect?.addEventListener('change', sync);
|
||
sync();
|
||
}
|
||
|
||
window.editUser = function(userId) {
|
||
const user = getUserById(userId);
|
||
if (!user) return;
|
||
|
||
const modal = createModal({
|
||
title: '编辑用户',
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">显示名称<span class="required">*</span></label>
|
||
<input type="text" class="form-control" id="editUserDisplayName" value="${escapeHtml(user.displayName)}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">登录名称</label>
|
||
<input type="text" class="form-control" value="${escapeHtml(user.loginName)}" disabled style="background:#f5f5f5">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">角色</label>
|
||
<select class="form-control" id="editUserRole">
|
||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin - 超级管理员</option>
|
||
<option value="worker" ${user.role === 'worker' ? 'selected' : ''}>Worker - 运营人员</option>
|
||
<option value="client" ${user.role === 'client' ? 'selected' : ''}>Client - B端客户</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">状态</label>
|
||
<select class="form-control" id="editUserStatus">
|
||
<option value="active" ${user.status === 'active' ? 'selected' : ''}>启用</option>
|
||
<option value="inactive" ${user.status === 'inactive' ? 'selected' : ''}>禁用</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="editCompanyGroup" style="${user.role === 'client' ? '' : 'display:none'}">
|
||
<label class="form-label">所属公司<span class="required">*</span></label>
|
||
<select class="form-control" id="editUserCompany">
|
||
<option value="">请选择公司</option>
|
||
${getCompanyOptionsHtml(user.companyId)}
|
||
</select>
|
||
<div class="form-hint">Client 角色登录后只显示该公司客户可见内容。</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">备注</label>
|
||
<input type="text" class="form-control" id="editUserRemark" value="${escapeHtml(user.remark === '-' ? '' : user.remark)}">
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
const displayName = modal.overlay.querySelector('#editUserDisplayName')?.value.trim();
|
||
const role = modal.overlay.querySelector('#editUserRole')?.value || 'worker';
|
||
const status = modal.overlay.querySelector('#editUserStatus')?.value || 'active';
|
||
const companyId = modal.overlay.querySelector('#editUserCompany')?.value;
|
||
const remark = modal.overlay.querySelector('#editUserRemark')?.value.trim();
|
||
|
||
if (!displayName) {
|
||
Toast.warning('请填写显示名称');
|
||
return false;
|
||
}
|
||
if (role === 'client' && !companyId) {
|
||
Toast.warning('Client 角色必须选择所属公司');
|
||
return false;
|
||
}
|
||
if (status === 'inactive' && isLastActiveAdmin(user.id)) {
|
||
Toast.warning('至少需要保留一个启用中的管理员');
|
||
return false;
|
||
}
|
||
|
||
const nextUser = normalizeUser({
|
||
...user,
|
||
displayName,
|
||
role,
|
||
status,
|
||
companyId: role === 'client' ? companyId : null,
|
||
allowedCompanies: role === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(),
|
||
permissions: role === user.role ? user.permissions : getRoleDefaultPermissions(role),
|
||
remark: remark || (role === 'client' ? `${getCompanyName(companyId)}-客户` : role === 'admin' ? '超级管理员' : '运营人员'),
|
||
});
|
||
saveUsers(users.map(item => item.id === user.id ? nextUser : item));
|
||
addAuditLog('编辑用户', `${nextUser.displayName} / ${getUserRoleLabel(nextUser)}`);
|
||
try {
|
||
await window.API?.rbac?.updateUser?.(user.id, nextUser);
|
||
} catch (error) {
|
||
console.warn('Mock update user failed:', error);
|
||
}
|
||
Toast.success(`用户 ${nextUser.displayName} 信息已更新`);
|
||
activeTab = 'users';
|
||
renderPage();
|
||
},
|
||
confirmText: '保存',
|
||
});
|
||
|
||
bindRoleCompanyToggle(modal.overlay, '#editUserRole', '#editCompanyGroup', '#editUserCompany');
|
||
};
|
||
|
||
window.managePermissions = function(userId) {
|
||
const user = getUserById(userId);
|
||
if (!user) return;
|
||
|
||
const modal = createModal({
|
||
title: `权限管理 - ${escapeHtml(user.displayName)}`,
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">当前角色</label>
|
||
<select class="form-control" id="permRoleSelect">
|
||
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin - 超级管理员</option>
|
||
<option value="worker" ${user.role === 'worker' ? 'selected' : ''}>Worker - 运营人员</option>
|
||
<option value="client" ${user.role === 'client' ? 'selected' : ''}>Client - B端客户</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="permCompanyGroup" style="${user.role === 'client' ? '' : 'display:none'}">
|
||
<label class="form-label">所属公司<span class="required">*</span></label>
|
||
<select class="form-control" id="permCompanySelect">
|
||
<option value="">请选择公司</option>
|
||
${getCompanyOptionsHtml(user.companyId)}
|
||
</select>
|
||
<div class="form-hint">Client 角色仅可访问所选公司。</div>
|
||
</div>
|
||
</div>
|
||
<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px">
|
||
<div style="font-size:13px;font-weight:700;margin-bottom:8px">细粒度权限</div>
|
||
<div id="permCheckboxGrid"></div>
|
||
</div>
|
||
<div class="api-note" style="margin-top:12px">
|
||
<i data-lucide="info"></i>
|
||
<span>后端接口预留:<code>POST /api/v1/rbac/users/assign-role</code>、<code>PUT /api/v1/rbac/users/{id}/permissions</code></span>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
const nextRole = modal.overlay.querySelector('#permRoleSelect')?.value || user.role;
|
||
const companyId = modal.overlay.querySelector('#permCompanySelect')?.value;
|
||
if (nextRole === 'client' && !companyId) {
|
||
Toast.warning('Client 角色必须选择所属公司');
|
||
return false;
|
||
}
|
||
const role = getRoleConfig(nextRole);
|
||
const permissions = role.locked
|
||
? getRoleDefaultPermissions(nextRole)
|
||
: collectCheckedValues(modal.overlay, '.rbac-permission-checkbox');
|
||
if (!permissions.length) {
|
||
Toast.warning('请至少保留一项权限');
|
||
return false;
|
||
}
|
||
|
||
const nextUser = normalizeUser({
|
||
...user,
|
||
role: nextRole,
|
||
companyId: nextRole === 'client' ? companyId : null,
|
||
allowedCompanies: nextRole === 'client' ? [companyId] : DEFAULT_ROLE_COMPANIES.slice(),
|
||
permissions,
|
||
remark: nextRole === 'client' ? `${getCompanyName(companyId)}-客户` : nextRole === 'admin' ? '超级管理员' : '运营人员',
|
||
});
|
||
saveUsers(users.map(item => item.id === user.id ? nextUser : item));
|
||
addAuditLog('更新用户权限', `${nextUser.displayName} / ${getUserRoleLabel(nextUser)}`);
|
||
try {
|
||
await window.API?.rbac?.assignRole?.(user.id, nextRole);
|
||
await window.API?.rbac?.updateUser?.(user.id, nextUser);
|
||
} catch (error) {
|
||
console.warn('Mock update permissions failed:', error);
|
||
}
|
||
Toast.success(`${nextUser.displayName} 的权限已更新`);
|
||
activeTab = 'users';
|
||
renderPage();
|
||
},
|
||
confirmText: '保存权限',
|
||
});
|
||
|
||
const roleSelect = modal.overlay.querySelector('#permRoleSelect');
|
||
const companyGroup = modal.overlay.querySelector('#permCompanyGroup');
|
||
const companySelect = modal.overlay.querySelector('#permCompanySelect');
|
||
const grid = modal.overlay.querySelector('#permCheckboxGrid');
|
||
const sync = () => {
|
||
const roleId = roleSelect?.value || user.role;
|
||
const role = getRoleConfig(roleId);
|
||
if (companyGroup) companyGroup.style.display = roleId === 'client' ? '' : 'none';
|
||
if (companySelect) companySelect.disabled = roleId !== 'client';
|
||
if (grid) grid.innerHTML = renderPermissionCheckboxes(roleId, roleId === user.role ? user.permissions : role.permissions, !role.locked);
|
||
lucide.createIcons({ elements: [modal.overlay] });
|
||
};
|
||
roleSelect?.addEventListener('change', sync);
|
||
sync();
|
||
};
|
||
|
||
window.toggleUserStatus = function(userId) {
|
||
const user = getUserById(userId);
|
||
if (!user) return;
|
||
const nextStatus = user.status === 'active' ? 'inactive' : 'active';
|
||
createModal({
|
||
title: `${nextStatus === 'active' ? '启用' : '禁用'}用户`,
|
||
content: `
|
||
<div style="text-align:center;padding:18px 0">
|
||
<i data-lucide="${nextStatus === 'active' ? 'user-check' : 'user-x'}" style="width:44px;height:44px;color:${nextStatus === 'active' ? 'var(--success)' : 'var(--warning)'};margin-bottom:14px"></i>
|
||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认${nextStatus === 'active' ? '启用' : '禁用'} ${escapeHtml(user.displayName)}?</p>
|
||
<p style="font-size:13px;color:var(--text-muted)">${nextStatus === 'active' ? '启用后用户可重新登录平台。' : '禁用后用户将无法登录平台。'}</p>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
if (nextStatus === 'inactive' && isLastActiveAdmin(user.id)) {
|
||
Toast.warning('至少需要保留一个启用中的管理员');
|
||
return false;
|
||
}
|
||
const nextUser = { ...user, status: nextStatus };
|
||
saveUsers(users.map(item => item.id === user.id ? nextUser : item));
|
||
addAuditLog(nextStatus === 'active' ? '启用用户' : '禁用用户', user.displayName);
|
||
try {
|
||
await window.API?.rbac?.updateUser?.(user.id, { status: nextStatus });
|
||
} catch (error) {
|
||
console.warn('Mock update status failed:', error);
|
||
}
|
||
Toast.success(`用户 ${user.displayName} 已${nextStatus === 'active' ? '启用' : '禁用'}`);
|
||
renderPage();
|
||
},
|
||
confirmText: nextStatus === 'active' ? '确认启用' : '确认禁用',
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
window.deleteUser = function(userId) {
|
||
const user = getUserById(userId);
|
||
if (!user) return;
|
||
|
||
createModal({
|
||
title: '确认删除',
|
||
content: `
|
||
<div style="text-align:center;padding:20px 0">
|
||
<i data-lucide="alert-triangle" style="width:48px;height:48px;color:var(--warning);margin-bottom:16px"></i>
|
||
<p style="font-size:15px;font-weight:600;margin-bottom:8px">确认删除用户 ${escapeHtml(user.displayName)}?</p>
|
||
<p style="font-size:13px;color:var(--text-muted)">此操作不可撤销,删除后该用户将无法登录系统。</p>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
if (isLastActiveAdmin(user.id)) {
|
||
Toast.warning('至少需要保留一个启用中的管理员');
|
||
return false;
|
||
}
|
||
saveUsers(users.filter(item => item.id !== user.id));
|
||
addAuditLog('删除用户', user.displayName);
|
||
try {
|
||
await window.API?.rbac?.deleteUser?.(user.id);
|
||
} catch (error) {
|
||
console.warn('Mock delete user failed:', error);
|
||
}
|
||
Toast.success(`用户 ${user.displayName} 已删除`);
|
||
activeTab = 'users';
|
||
renderPage();
|
||
},
|
||
confirmText: '确认删除',
|
||
cancelText: '取消',
|
||
});
|
||
lucide.createIcons();
|
||
};
|
||
|
||
window.editRolePermissions = function(roleId) {
|
||
const role = getRoleConfig(roleId);
|
||
if (!role) return;
|
||
const modal = createModal({
|
||
title: `编辑 ${escapeHtml(role.name)} 角色权限`,
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div style="margin-bottom:14px">
|
||
<div style="font-weight:700;margin-bottom:6px">角色说明</div>
|
||
<div style="font-size:13px;color:var(--text-muted);line-height:1.6">${escapeHtml(role.desc)}</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">功能权限</label>
|
||
<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:0 12px" id="rolePermissionGrid">
|
||
${renderPermissionCheckboxes(role.id, role.permissions, !role.locked)}
|
||
</div>
|
||
${role.locked ? '<div class="form-hint">Admin / Client 为平台内置角色,功能范围固定,只能通过用户授权控制公司范围。</div>' : ''}
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">可分配公司范围</label>
|
||
<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 12px">
|
||
${role.id === 'admin'
|
||
? '<div style="font-size:13px;color:var(--success);padding:10px 0">Admin 默认可访问全部公司。</div>'
|
||
: renderCompanyCheckboxes(role.allowedCompanyIds || DEFAULT_ROLE_COMPANIES)}
|
||
</div>
|
||
<div class="form-hint">这里控制该角色可被分配到哪些公司;Client 用户仍需在用户权限中选择具体所属公司。</div>
|
||
</div>
|
||
<div class="api-note">
|
||
<i data-lucide="info"></i>
|
||
<span>后端接口预留:<code>PUT /api/v1/rbac/roles/${escapeHtml(role.id)}</code></span>
|
||
</div>`,
|
||
onConfirm: async () => {
|
||
const permissions = role.locked
|
||
? getRoleDefaultPermissions(role.id)
|
||
: collectCheckedValues(modal.overlay, '.rbac-permission-checkbox');
|
||
const allowedCompanyIds = role.id === 'admin'
|
||
? DEFAULT_ROLE_COMPANIES.slice()
|
||
: collectCheckedValues(modal.overlay, '.rbac-company-checkbox');
|
||
|
||
if (!permissions.length) {
|
||
Toast.warning('请至少保留一项权限');
|
||
return false;
|
||
}
|
||
if (role.id !== 'admin' && !allowedCompanyIds.length) {
|
||
Toast.warning('请至少选择一个可分配公司');
|
||
return false;
|
||
}
|
||
|
||
const nextRole = {
|
||
...role,
|
||
permissions,
|
||
allowedCompanyIds,
|
||
};
|
||
saveRoles(roleConfigs.map(item => item.id === role.id ? nextRole : item));
|
||
addAuditLog('编辑角色权限', role.name);
|
||
try {
|
||
await window.API?.rbac?.updateRole?.(role.id, nextRole);
|
||
} catch (error) {
|
||
console.warn('Mock update role failed:', error);
|
||
}
|
||
Toast.success(`${role.name} 角色权限已更新`);
|
||
activeTab = 'roles';
|
||
renderPage();
|
||
},
|
||
confirmText: '保存',
|
||
});
|
||
lucide.createIcons({ elements: [modal.overlay] });
|
||
};
|
||
|
||
window.viewRoleUsers = function(roleId) {
|
||
const role = getRoleConfig(roleId);
|
||
if (!role) return;
|
||
const roleUsers = users.filter(user => user.role === role.id);
|
||
createModal({
|
||
title: `${escapeHtml(role.name)} 角色下的用户`,
|
||
size: 'modal-lg',
|
||
content: `
|
||
<div style="margin-bottom:12px;font-size:13px;color:var(--text-muted)">共 ${roleUsers.length} 个用户</div>
|
||
<div style="max-height:340px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm)">
|
||
${roleUsers.length ? roleUsers.map(user => `
|
||
<div style="display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center;padding:12px;border-bottom:1px solid var(--border)">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<div>
|
||
<div style="font-weight:600">${escapeHtml(user.displayName)}</div>
|
||
<div style="font-size:12px;color:var(--text-muted)">${escapeHtml(user.loginName)}</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span class="badge ${getStatusBadge(user.status)}">${getStatusLabel(user.status)}</span>
|
||
<span class="badge badge-default">${escapeHtml(getCompanyScopeText(user))}</span>
|
||
</div>
|
||
</div>
|
||
`).join('') : `
|
||
<div style="padding:32px;text-align:center;color:var(--text-muted)">
|
||
<i data-lucide="users" style="width:32px;height:32px;margin-bottom:8px"></i>
|
||
<div>暂无用户使用该角色</div>
|
||
</div>
|
||
`}
|
||
</div>`,
|
||
confirmText: '关闭',
|
||
showCancel: false,
|
||
});
|
||
};
|
||
|
||
renderPage();
|
||
});
|