forked from wangziqi/gongxue-base
feat: add platform staff management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
apps/taro/src/pages/platform-admin/staff/index.config.ts
Normal file
3
apps/taro/src/pages/platform-admin/staff/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '平台员工',
|
||||
});
|
||||
359
apps/taro/src/pages/platform-admin/staff/index.tsx
Normal file
359
apps/taro/src/pages/platform-admin/staff/index.tsx
Normal 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}?后端会默认撤销迁移期 session,Supabase 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>
|
||||
);
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user