Files
TalkingQ_Dashboard/scripts/rbac.js
“yuanmengy” 69785c214f first commit
2026-06-10 17:31:01 +08:00

948 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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();
});