feat: add platform staff management

This commit is contained in:
Codex
2026-06-30 08:28:44 +08:00
parent 780feee4f8
commit 4f64b7aed8
21 changed files with 1205 additions and 25 deletions

View File

@@ -219,6 +219,74 @@
color: #be123c;
}
.platform-mini-button.active {
border-color: #1d4ed8;
background: #eff6ff;
color: #1d4ed8;
}
.platform-permission-panel {
display: flex;
flex-direction: column;
gap: 16px;
margin: 8px 0 16px;
padding: 18px;
border: 1px solid #dbe4f0;
border-radius: 8px;
background: #fff;
}
.platform-permission-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.platform-permission-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.platform-chip-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.platform-chip-list.compact {
margin-top: 14px;
}
.platform-chip {
min-width: 0;
max-width: 100%;
min-height: 48px;
padding: 0 14px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-size: 20px;
font-weight: 680;
line-height: 48px;
}
.platform-chip.active {
border-color: #1d4ed8;
background: #eff6ff;
color: #1d4ed8;
}
.platform-chip.readonly {
display: inline-flex;
align-items: center;
max-width: 100%;
color: #475569;
line-height: 1.25;
}
.platform-error {
display: block;
margin-top: 12px;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '平台员工',
});

View File

@@ -0,0 +1,359 @@
import { useEffect, useMemo, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
loadPlatformPermissions,
loadPlatformStaff,
updatePlatformStaffStatus,
upsertPlatformStaff,
type PlatformPermissionCatalogItem,
type PlatformPermissionSummary,
type PlatformStaffItem,
} from '@/services/platformAdmin';
import '../platform.css';
type StaffForm = {
id: string;
authUserId: string;
username: string;
name: string;
email: string;
phone: string;
avatarUrl: string;
status: string;
platformPermissions: Record<string, true>;
};
const emptyForm: StaffForm = {
id: '',
authUserId: '',
username: '',
name: '',
email: '',
phone: '',
avatarUrl: '',
status: 'active',
platformPermissions: {},
};
function dateText(value?: string | null) {
return value ? String(value).slice(0, 19).replace('T', ' ') : '-';
}
function permissionKeys(value?: Record<string, unknown> | null) {
return Object.entries(value || {})
.filter(([, enabled]) => enabled === true)
.map(([key]) => key)
.sort();
}
function permissionLabel(item: PlatformPermissionCatalogItem) {
return item.label || item.key;
}
function groupLabel(group?: string | null) {
const labels: Record<string, string> = {
overview: '概览',
staff: '员工',
tenant: '租户',
billing: '账务',
usage: '用量',
audit: '审计',
question_bank: '公共题库',
};
return labels[group || ''] || group || '其他';
}
function fromStaff(item: PlatformStaffItem): StaffForm {
const platformPermissions: Record<string, true> = {};
for (const key of permissionKeys(item.platformPermissions)) {
platformPermissions[key] = true;
}
return {
id: item.id || '',
authUserId: item.authUserId || '',
username: item.username || '',
name: item.name || '',
email: item.email || '',
phone: item.phone || '',
avatarUrl: item.avatarUrl || '',
status: item.status || 'active',
platformPermissions,
};
}
export default function PlatformStaffPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [staff, setStaff] = useState<PlatformStaffItem[]>([]);
const [permissionSummary, setPermissionSummary] = useState<PlatformPermissionSummary | null>(null);
const [form, setForm] = useState<StaffForm>(emptyForm);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const catalog = permissionSummary?.catalog || [];
const effective = permissionSummary?.effective || {};
const canWrite = effective['platform:staff:write'] === true || effective['*'] === true;
const canChangeStatus = effective['platform:staff:status'] === true || effective['*'] === true;
const groupedCatalog = useMemo(() => {
const groups: Array<{ group: string; items: PlatformPermissionCatalogItem[] }> = [];
const byGroup = new Map<string, PlatformPermissionCatalogItem[]>();
for (const item of catalog) {
const group = item.group || 'other';
byGroup.set(group, [...(byGroup.get(group) || []), item]);
}
for (const [group, items] of byGroup.entries()) {
groups.push({ group, items });
}
return groups;
}, [catalog]);
function reload(nextStatus = status, nextKeyword = keyword) {
setError('');
loadPlatformStaff({ q: nextKeyword || undefined, status: nextStatus || undefined, limit: 120 })
.then(payload => setStaff(payload.items || []))
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台员工加载失败'));
}
useEffect(() => {
Promise.all([
loadPlatformPermissions().catch(() => ({ item: null })),
loadPlatformStaff({ limit: 120 }).catch(() => ({ items: [] })),
]).then(([permissionPayload, staffPayload]) => {
setPermissionSummary(permissionPayload.item || null);
setStaff(staffPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台员工页面加载失败'));
}, []);
function chooseStatus(nextStatus: string) {
setStatus(nextStatus);
reload(nextStatus, keyword);
}
function updateForm(key: keyof StaffForm, value: string) {
setForm(current => ({ ...current, [key]: value }));
}
function togglePermission(key: string) {
setForm(current => {
const nextPermissions = { ...current.platformPermissions };
if (nextPermissions[key]) {
delete nextPermissions[key];
} else {
nextPermissions[key] = true;
}
return { ...current, platformPermissions: nextPermissions };
});
}
function toggleSuperPermission() {
togglePermission('*');
}
function editStaff(item: PlatformStaffItem) {
setForm(fromStaff(item));
}
function resetForm() {
setForm(emptyForm);
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
}
async function submitStaff() {
setError('');
if (!canWrite) {
setError('当前账号没有 platform:staff:write 权限。');
return;
}
if (!form.name.trim()) {
setError('平台员工必须填写姓名。');
return;
}
if (!form.authUserId.trim()) {
setError('平台员工必须绑定 Supabase Auth 用户 ID生产环境不允许悬空账号。');
return;
}
const keys = permissionKeys(form.platformPermissions);
if (!keys.length) {
setError('平台员工至少需要配置一个平台权限。');
return;
}
const ok = await confirm(
form.id ? '更新平台员工' : '创建平台员工',
form.platformPermissions['*']
? '该员工将拥有平台超级权限,请确认这是必要授权。'
: `确认保存员工 ${form.name.trim()}${keys.length} 个权限点?`,
);
if (!ok) return;
setBusy('save');
try {
const payload = await upsertPlatformStaff({
id: form.id || undefined,
authUserId: form.authUserId.trim(),
username: form.username.trim() || undefined,
name: form.name.trim(),
email: form.email.trim() || undefined,
phone: form.phone.trim() || undefined,
avatarUrl: form.avatarUrl.trim() || undefined,
status: form.status || 'active',
platformPermissions: form.platformPermissions,
});
Taro.showToast({ title: '已保存', icon: 'success' });
if (payload.item) setForm(fromStaff(payload.item));
reload(status, keyword);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '平台员工保存失败');
} finally {
setBusy('');
}
}
async function submitStatus(item: PlatformStaffItem, nextStatus: 'active' | 'disabled') {
setError('');
if (!canChangeStatus) {
setError('当前账号没有 platform:staff:status 权限。');
return;
}
const ok = await confirm(
nextStatus === 'disabled' ? '禁用平台员工' : '恢复平台员工',
nextStatus === 'disabled'
? `确认禁用 ${item.name || item.username || item.id}?后端会默认撤销迁移期 sessionSupabase JWT 也会因 status=disabled 被拒绝。`
: `确认恢复 ${item.name || item.username || item.id} 的平台后台访问?`,
);
if (!ok) return;
setBusy(`status-${item.id}-${nextStatus}`);
try {
await updatePlatformStaffStatus({
staffId: item.id,
status: nextStatus,
reason: nextStatus === 'disabled' ? 'platform admin disabled from staff page' : 'platform admin restored from staff page',
revokeSessions: nextStatus === 'disabled',
});
Taro.showToast({ title: nextStatus === 'disabled' ? '已禁用' : '已恢复', icon: 'success' });
reload(status, keyword);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '员工状态更新失败');
} finally {
setBusy('');
}
}
const activeCount = staff.filter(item => item.status === 'active').length;
const disabledCount = staff.filter(item => item.status === 'disabled').length;
const selectedPermissions = permissionKeys(form.platformPermissions);
return (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Staff</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'> Supabase Auth </Text>
</View>
<View className='platform-actions'>
<Input className='platform-input' placeholder='姓名、用户名、手机号、邮箱' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
<Button className='platform-button primary' onClick={() => reload(status, keyword)}></Button>
<Button className='platform-button' onClick={resetForm}></Button>
</View>
<View className='platform-tabs'>
{[
{ label: '全部', value: '' },
{ label: 'active', value: 'active' },
{ label: 'disabled', value: 'disabled' },
].map(item => (
<Button key={item.label} className={`platform-button ${status === item.value ? 'active' : ''}`} onClick={() => chooseStatus(item.value)}>{item.label}</Button>
))}
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(staff.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(activeCount)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(disabledCount)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(selectedPermissions.length)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'>{form.id ? '编辑平台员工' : '创建平台员工'}</Text>
<View className='platform-form'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='编辑已有员工时自动填充' value={form.id} onInput={event => updateForm('id', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'>Supabase Auth ID</Text><Input className='platform-input' placeholder='auth.users.id' value={form.authUserId} onInput={event => updateForm('authUserId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='platform_operator' value={form.username} onInput={event => updateForm('username', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='员工姓名' value={form.name} onInput={event => updateForm('name', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='name@example.com' value={form.email} onInput={event => updateForm('email', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='13800138000' value={form.phone} onInput={event => updateForm('phone', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / disabled' value={form.status} onInput={event => updateForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'> URL</Text><Input className='platform-input' placeholder='可选' value={form.avatarUrl} onInput={event => updateForm('avatarUrl', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-permission-panel'>
<View className='platform-permission-header'>
<Text className='platform-row-main'></Text>
<Button className={`platform-mini-button ${form.platformPermissions['*'] ? 'active' : ''}`} onClick={toggleSuperPermission}> *</Button>
</View>
{groupedCatalog.map(group => (
<View className='platform-permission-group' key={group.group}>
<Text className='platform-field-label'>{groupLabel(group.group)}</Text>
<View className='platform-chip-list'>
{group.items.map(item => (
<Button
key={item.key}
className={`platform-chip ${form.platformPermissions[item.key] ? 'active' : ''}`}
onClick={() => togglePermission(item.key)}
>
{permissionLabel(item)}
</Button>
))}
</View>
</View>
))}
{!catalog.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'save'} disabled={!canWrite} onClick={submitStaff}></Button>
<Button className='platform-button' onClick={resetForm}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{staff.map(item => {
const keys = permissionKeys(item.platformPermissions);
return (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.name || item.username || item.id}</Text>
<Text className='platform-row-meta'>{item.username || '-'} · {item.status || '-'} · {item.email || '-'} · {item.phone || '-'}</Text>
<Text className='platform-row-meta'>Auth {item.authUserId || '未绑定'} · {dateText(item.lastSeenAt)} · {dateText(item.createdAt)}</Text>
<View className='platform-chip-list compact'>
{keys.slice(0, 10).map(key => <Text className='platform-chip readonly' key={key}>{key}</Text>)}
{keys.length > 10 ? <Text className='platform-chip readonly'>+{keys.length - 10}</Text> : null}
{!keys.length ? <Text className='platform-chip readonly'></Text> : null}
</View>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => editStaff(item)}></Button>
{item.status === 'disabled' ? (
<Button className='platform-mini-button' disabled={!canChangeStatus} loading={busy === `status-${item.id}-active`} onClick={() => submitStatus(item, 'active')}></Button>
) : (
<Button className='platform-mini-button danger' disabled={!canChangeStatus} loading={busy === `status-${item.id}-disabled`} onClick={() => submitStatus(item, 'disabled')}></Button>
)}
</View>
</View>
);
})}
</View>
{!staff.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -91,6 +91,7 @@ export default function PlatformWorkbenchPage() {
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
];
async function exportAuditLogs() {
@@ -161,6 +162,7 @@ export default function PlatformWorkbenchPage() {
<Button className='platform-button primary' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/tenants/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/question-banks/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/staff/index' })}></Button>
</View>
<View className='platform-section'>