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

@@ -56,6 +56,7 @@ export async function findUserBySessionToken(token: string) {
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
and u.status = 'active'
and s.revoked_at is null
and s.expires_at > now()
limit 1
@@ -142,6 +143,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
from public.platform_users u
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
where u.auth_user_id = $1::uuid
and u.status = 'active'
and u.primary_role = 'platform_admin'
and ($2::uuid is null or exists (
select 1
@@ -175,6 +177,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id
where u.auth_user_id = $1::uuid
and u.status = 'active'
and tm.status = 'active'
and tm.tenant_id = $2::uuid
order by case

View File

@@ -19,6 +19,7 @@ import {
platformPermissionsRoute,
platformPlansRoute,
platformQuestionBanksRoute,
platformStaffRoute,
processOverdueInvoicesRoute,
questionBankGrantsRoute,
recordUsageRoute,
@@ -27,8 +28,10 @@ import {
tenantInvoicesRoute,
tenantsRoute,
tenantUsageRoute,
updatePlatformStaffStatusRoute,
updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute,
upsertPlatformStaffRoute,
upsertPlatformAuditNotificationChannelRoute,
upsertPlatformDunningNotificationChannelRoute,
upsertQuestionBankGrantRoute,
@@ -37,6 +40,9 @@ import {
export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/permissions', platformPermissionsRoute],
['GET', '/api/platform-admin/staff', platformStaffRoute],
['PUT', '/api/platform-admin/staff', upsertPlatformStaffRoute],
['PATCH', '/api/platform-admin/staff/status', updatePlatformStaffStatusRoute],
['GET', '/api/platform-admin/overview', platformOverviewRoute],
['GET', '/api/platform-admin/plans', platformPlansRoute],
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],

View File

@@ -45,11 +45,15 @@ function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function booleanValue(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max);
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
@@ -57,9 +61,13 @@ const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'f
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
const PLATFORM_STAFF_STATUSES = new Set(['active', 'disabled']);
const PLATFORM_PERMISSION_CATALOG = [
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' },
{ key: 'platform:staff:read', group: 'staff', label: '查看平台员工' },
{ key: 'platform:staff:write', group: 'staff', label: '创建/编辑平台员工' },
{ key: 'platform:staff:status', group: 'staff', label: '启停平台员工' },
{ key: 'platform:tenant:read', group: 'tenant', label: '查看租户' },
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
{ key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' },
@@ -113,6 +121,18 @@ function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unkn
const redactAuditAlertValue = redactAuditExportValue;
type PlatformStaffPublicRow = Record<string, unknown> & {
rawProfile?: unknown;
};
function platformStaffPublicRow<T extends PlatformStaffPublicRow>(item: T): T {
if (!Object.prototype.hasOwnProperty.call(item, 'rawProfile')) return item;
return {
...item,
rawProfile: redactAuditExportValue(item.rawProfile),
};
}
function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8');
return {
@@ -498,6 +518,67 @@ function grantStatusFrom(value: string) {
return status;
}
function platformStaffStatusFrom(value: string, fallback = 'active') {
const status = value || fallback;
if (!PLATFORM_STAFF_STATUSES.has(status)) {
throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
}
return status;
}
function normalizeOptionalEmail(value: string) {
if (!value) return null;
const email = value.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
throw new HttpError(400, 'email is invalid', 'INVALID_EMAIL');
}
return email;
}
function normalizeOptionalPhone(value: string) {
if (!value) return null;
const phone = value.replace(/\s+/g, '');
if (!/^\+?[0-9-]{6,32}$/.test(phone)) {
throw new HttpError(400, 'phone is invalid', 'INVALID_PHONE');
}
return phone;
}
function normalizePlatformUsername(value: string, fallback: string) {
const username = (value || fallback).trim();
if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) {
throw new HttpError(400, 'username is invalid', 'INVALID_USERNAME');
}
return username;
}
function allowedPlatformPermissionKeys() {
return new Set<string>(PLATFORM_PERMISSION_CATALOG.map(item => item.key));
}
function normalizePlatformPermissions(value: unknown) {
const input = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const allowed = allowedPlatformPermissionKeys();
const output: Record<string, true> = {};
for (const [key, enabled] of Object.entries(input)) {
if (enabled !== true) continue;
const permission = key.trim();
const domainWildcard = /^platform:[a-z_]+:\*$/.test(permission);
const validWildcard = domainWildcard && [...allowed].some(item => item.startsWith(permission.slice(0, -1)));
if (permission !== '*' && !allowed.has(permission) && !validWildcard) {
throw new HttpError(400, `Platform permission ${permission} is invalid`, 'INVALID_PLATFORM_PERMISSION');
}
output[permission] = true;
}
return output;
}
function platformPermissionKeys(permissions: Record<string, unknown>) {
return Object.keys(permissions).filter(key => permissions[key] === true).sort();
}
function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) {
if (permissions['*'] === true) return true;
if (permissions[permission] === true) return true;
@@ -528,6 +609,239 @@ export async function platformPermissionsRoute(ctx: RequestContext) {
};
}
export async function platformStaffRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:staff:read');
const status = listQuery(ctx, 'status');
if (status && !PLATFORM_STAFF_STATUSES.has(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query<PlatformStaffPublicRow>(
`
select id, auth_user_id as "authUserId", username, email::text, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", status, platform_permissions as "platformPermissions",
raw_profile as "rawProfile", last_seen_at as "lastSeenAt",
created_at as "createdAt", updated_at as "updatedAt"
from public.platform_users
where primary_role = 'platform_admin'
and ($1::text = '' or status = $1)
and (
$2::text = ''
or coalesce(username, '') ilike '%' || $2 || '%'
or coalesce(name, '') ilike '%' || $2 || '%'
or coalesce(phone, '') ilike '%' || $2 || '%'
or coalesce(email::text, '') ilike '%' || $2 || '%'
)
order by status asc, created_at desc
limit $3
`,
[status, q, limit],
);
return { items: items.map(platformStaffPublicRow) };
}
export async function upsertPlatformStaffRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:staff:write');
const body = await readJsonBody(ctx);
const staffId = optionalString(body, 'id');
if (staffId && !UUID_RE.test(staffId)) throw new HttpError(400, 'id is invalid', 'INVALID_UUID');
const authUserId = requiredString(body, 'authUserId');
if (!UUID_RE.test(authUserId)) throw new HttpError(400, 'authUserId is invalid', 'INVALID_UUID');
const email = normalizeOptionalEmail(optionalString(body, 'email'));
const phone = normalizeOptionalPhone(optionalString(body, 'phone'));
const username = normalizePlatformUsername(optionalString(body, 'username'), email || phone || `platform_${Date.now().toString(36)}`);
const name = requiredString(body, 'name');
const avatarUrl = optionalString(body, 'avatarUrl') || null;
const status = platformStaffStatusFrom(optionalString(body, 'status'), 'active');
const permissions = normalizePlatformPermissions(body.platformPermissions);
if (status === 'active' && platformPermissionKeys(permissions).length === 0) {
throw new HttpError(400, 'Active platform staff must have at least one permission', 'PLATFORM_PERMISSION_EMPTY');
}
const metadata = objectValue(body.metadata);
const session = currentSessionFromContext(ctx);
const item = await transaction(async client => {
let targetStaffId = staffId || null;
const authUser = await client.query(
`
select id
from auth.users
where id = $1::uuid
limit 1
`,
[authUserId],
);
if (authUser.rowCount === 0) {
throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND');
}
const existingByAuth = await client.query(
`
select id, primary_role as "primaryRole"
from public.platform_users
where auth_user_id = $1::uuid
for update
`,
[authUserId],
);
const existing = existingByAuth.rows[0];
if (existing) {
if (staffId && existing.id !== staffId) {
throw new HttpError(409, 'authUserId is already bound to another platform user', 'AUTH_USER_ALREADY_BOUND');
}
if (existing.primaryRole !== 'platform_admin') {
throw new HttpError(409, 'authUserId is already bound to a non-platform account', 'AUTH_USER_ALREADY_BOUND');
}
targetStaffId = existing.id;
}
if (targetStaffId) {
const existingById = await client.query(
`
select id, auth_user_id as "authUserId", primary_role as "primaryRole"
from public.platform_users
where id = $1::uuid
for update
`,
[targetStaffId],
);
const existing = existingById.rows[0];
if (existing && existing.primaryRole !== 'platform_admin') {
throw new HttpError(409, 'Platform staff id is already used by a non-platform account', 'PLATFORM_USER_ROLE_CONFLICT');
}
if (session?.id === targetStaffId && status !== 'active') {
throw new HttpError(400, 'Current platform admin cannot disable itself', 'CANNOT_DISABLE_SELF');
}
if (session?.id === targetStaffId && existing.authUserId && existing.authUserId !== authUserId) {
throw new HttpError(400, 'Current platform admin cannot change its own Auth binding', 'CANNOT_REBIND_SELF');
}
if (session?.id === targetStaffId && permissions['*'] !== true) {
throw new HttpError(400, 'Current platform admin cannot remove its own super permission', 'CANNOT_DOWNGRADE_SELF');
}
}
const result = await client.query(
`
insert into public.platform_users (
id, auth_user_id, username, email, phone, name, avatar_url,
primary_role, status, platform_permissions, raw_profile
)
values (
coalesce($1::uuid, gen_random_uuid()), $2::uuid, $3, $4::citext, $5, $6, $7,
'platform_admin', $8, $9::jsonb,
jsonb_strip_nulls(coalesce($10::jsonb, '{}'::jsonb) || jsonb_build_object(
'source', 'platform-admin:staff',
'managedByPlatform', true
))
)
on conflict (id)
do update set auth_user_id = coalesce(excluded.auth_user_id, public.platform_users.auth_user_id),
username = excluded.username,
email = excluded.email,
phone = excluded.phone,
name = excluded.name,
avatar_url = excluded.avatar_url,
primary_role = 'platform_admin',
status = excluded.status,
platform_permissions = excluded.platform_permissions,
raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile),
updated_at = now()
returning id, auth_user_id as "authUserId", username, email::text, phone, name,
avatar_url as "avatarUrl", primary_role as "primaryRole", status,
platform_permissions as "platformPermissions", raw_profile as "rawProfile",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
targetStaffId,
authUserId || null,
username,
email,
phone,
name,
avatarUrl,
status,
JSON.stringify(permissions),
JSON.stringify(metadata),
],
);
const saved = result.rows[0];
await recordPlatformAudit(client, ctx, 'platform.staff.upserted', 'platform_user', saved.id, {
username,
name,
status,
authUserBound: Boolean(authUserId),
emailSet: Boolean(email),
phoneSet: Boolean(phone),
permissionKeys: platformPermissionKeys(permissions),
});
return platformStaffPublicRow(saved);
});
return { item };
}
export async function updatePlatformStaffStatusRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:staff:status');
const body = await readJsonBody(ctx);
const staffId = requiredString(body, 'staffId');
if (!UUID_RE.test(staffId)) throw new HttpError(400, 'staffId is invalid', 'INVALID_UUID');
const status = platformStaffStatusFrom(optionalString(body, 'status'));
const reason = optionalString(body, 'reason') || null;
const revokeSessions = booleanValue(body.revokeSessions, true);
const session = currentSessionFromContext(ctx);
if (session?.id === staffId && status !== 'active') {
throw new HttpError(400, 'Current platform admin cannot disable itself', 'CANNOT_DISABLE_SELF');
}
const item = await transaction(async client => {
const result = await client.query(
`
update public.platform_users
set status = $2,
raw_profile = jsonb_strip_nulls(raw_profile || jsonb_build_object(
'platformStatusReason', $3::text,
'platformStatusUpdatedAt', now()
)),
updated_at = now()
where id = $1
and primary_role = 'platform_admin'
returning id, auth_user_id as "authUserId", username, email::text, phone, name,
primary_role as "primaryRole", status, platform_permissions as "platformPermissions",
updated_at as "updatedAt"
`,
[staffId, status, reason],
);
const saved = result.rows[0];
if (!saved) throw new HttpError(404, 'Platform staff not found', 'PLATFORM_STAFF_NOT_FOUND');
if (status === 'disabled' && revokeSessions) {
await client.query(
`
update app_private.auth_sessions
set revoked_at = now()
where user_id = $1 and revoked_at is null
`,
[staffId],
);
}
await recordPlatformAudit(client, ctx, 'platform.staff.status_updated', 'platform_user', staffId, {
status,
reasonSet: Boolean(reason),
revokeSessions,
});
return saved;
});
return { item };
}
export async function platformOverviewRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:overview:read');

View File

@@ -28,6 +28,7 @@ export default defineAppConfig({
'pages/platform-admin/tenants/index',
'pages/platform-admin/billing/index',
'pages/platform-admin/question-banks/index',
'pages/platform-admin/staff/index',
],
window: {
backgroundTextStyle: 'light',

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'>

View File

@@ -38,6 +38,23 @@ export interface PlatformPermissionSummary {
catalog?: PlatformPermissionCatalogItem[];
}
export interface PlatformStaffItem {
id: string;
authUserId?: string | null;
username?: string | null;
email?: string | null;
phone?: string | null;
name?: string | null;
avatarUrl?: string | null;
primaryRole?: string | null;
status?: string | null;
platformPermissions?: Record<string, unknown> | null;
rawProfile?: Record<string, unknown> | null;
lastSeenAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformSaasPlan {
id: string;
code: string;
@@ -478,6 +495,26 @@ export interface UpsertPlatformQuestionBankGrantInput {
expiresAt?: string;
}
export interface UpsertPlatformStaffInput {
id?: string;
authUserId?: string;
username?: string;
email?: string;
phone?: string;
name: string;
avatarUrl?: string;
status?: string;
platformPermissions?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface UpdatePlatformStaffStatusInput {
staffId: string;
status: 'active' | 'disabled';
reason?: string;
revokeSessions?: boolean;
}
export async function loadPlatformOverview() {
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null });
}
@@ -486,6 +523,29 @@ export async function loadPlatformPermissions() {
return apiRequest<{ item?: PlatformPermissionSummary }>('/api/platform-admin/permissions', { tenantId: null });
}
export async function loadPlatformStaff(query: { q?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformStaffItem[] }>('/api/platform-admin/staff', {
query: { ...query, limit: query.limit || 80 },
tenantId: null,
});
}
export async function upsertPlatformStaff(input: UpsertPlatformStaffInput) {
return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff', {
method: 'PUT',
body: input,
tenantId: null,
});
}
export async function updatePlatformStaffStatus(input: UpdatePlatformStaffStatusInput) {
return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff/status', {
method: 'PATCH',
body: input,
tenantId: null,
});
}
export async function loadPlatformPlans(includeArchived = false) {
return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', {
query: { includeArchived },