feat: add platform tenant detail audit console

This commit is contained in:
Codex
2026-06-30 04:38:36 +08:00
parent e62653025c
commit d5d32e84b3
16 changed files with 688 additions and 111 deletions

View File

@@ -5,6 +5,7 @@ import {
createSubscriptionRoute,
createTenantInvoiceFromSubscriptionRoute,
createTenantRoute,
platformAuditLogsRoute,
platformOverviewRoute,
platformPlansRoute,
platformQuestionBanksRoute,
@@ -30,6 +31,7 @@ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
['PATCH', '/api/platform-admin/tenants/status', updateTenantStatusRoute],
['PUT', '/api/platform-admin/tenants/billing-profile', upsertBillingProfileRoute],
['GET', '/api/platform-admin/audit-logs', platformAuditLogsRoute],
['POST', '/api/platform-admin/subscriptions', createSubscriptionRoute],
['GET', '/api/platform-admin/invoices', tenantInvoicesRoute],
['POST', '/api/platform-admin/invoices', createInvoiceRoute],

View File

@@ -1,4 +1,6 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import type pg from 'pg';
import { currentSessionFromContext } from '../../core/auth-context.js';
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import {
intParam,
optionalStringArray,
@@ -37,6 +39,54 @@ function optionalUuidArray(body: Record<string, unknown>, key: string) {
return optionalStringArray(body, key).filter(Boolean);
}
function billingInvoiceTypeFrom(value: string) {
const invoiceType = value || 'none';
if (!['none', 'normal_vat', 'special_vat'].includes(invoiceType)) {
throw new HttpError(400, 'invoiceType is invalid', 'INVALID_INVOICE_TYPE');
}
return invoiceType;
}
function platformAuditDetails(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
function requestIp(ctx: RequestContext) {
const forwarded = getHeader(ctx.req, 'x-forwarded-for').split(',')[0]?.trim();
return forwarded || ctx.req.socket.remoteAddress || null;
}
async function recordPlatformAudit(
client: pg.PoolClient,
ctx: RequestContext,
action: string,
targetType: string,
targetId: string | null,
details: Record<string, unknown> = {},
tenantId: string | null = null,
) {
const session = currentSessionFromContext(ctx);
await client.query(
`
insert into public.audit_logs (
tenant_id, actor_user_id, action, target_type, target_id,
details, ip_address, user_agent
)
values ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7, $8)
`,
[
tenantId || null,
session?.id || null,
action,
targetType,
targetId,
platformAuditDetails(details),
requestIp(ctx),
getHeader(ctx.req, 'user-agent') || null,
],
);
}
function grantScopeFrom(value: string) {
const scope = value || 'plans';
if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) {
@@ -417,7 +467,8 @@ export async function tenantDetailRoute(ctx: RequestContext) {
bp.billing_name as "billingName", bp.tax_id as "taxId",
bp.contact_name as "contactName", bp.contact_phone as "contactPhone",
bp.contact_email as "contactEmail", bp.invoice_title as "invoiceTitle",
bp.invoice_type as "invoiceType"
bp.invoice_type as "invoiceType", bp.billing_address as "billingAddress",
bp.bank_name as "bankName", bp.bank_account_masked as "bankAccountMasked"
from public.tenants t
left join public.tenant_branding b on b.tenant_id = t.id
left join public.tenant_billing_profiles bp on bp.tenant_id = t.id
@@ -454,7 +505,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
),
query(
`
select id, invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
select id, tenant_id as "tenantId", invoice_no as "invoiceNo", invoice_type as "invoiceType", status,
total_cents as "totalCents", paid_cents as "paidCents",
balance_cents as "balanceCents", due_date as "dueDate",
issued_at as "issuedAt", paid_at as "paidAt", created_at as "createdAt"
@@ -467,7 +518,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
),
query(
`
select metric_key as "metricKey", metric_value as "metricValue",
select id, tenant_id as "tenantId", metric_key as "metricKey", metric_value as "metricValue",
period_start as "periodStart", period_end as "periodEnd", metadata,
created_at as "createdAt"
from public.tenant_usage_records
@@ -482,6 +533,63 @@ export async function tenantDetailRoute(ctx: RequestContext) {
return { item: { tenant, domains, subscriptions, invoices, usage } };
}
export async function platformAuditLogsRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
const tenantId = listQuery(ctx, 'tenantId');
const action = listQuery(ctx, 'action');
const targetType = listQuery(ctx, 'targetType');
const actorUserId = listQuery(ctx, 'actorUserId');
const q = listQuery(ctx, 'q');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [];
const filters: string[] = [];
if (tenantId) {
params.push(tenantId);
filters.push(`al.tenant_id = $${params.length}::uuid`);
}
if (action) {
params.push(`${action}%`);
filters.push(`al.action ilike $${params.length}`);
}
if (targetType) {
params.push(targetType);
filters.push(`al.target_type = $${params.length}`);
}
if (actorUserId) {
params.push(actorUserId);
filters.push(`al.actor_user_id = $${params.length}::uuid`);
}
if (q) {
params.push(`%${q}%`);
filters.push(`(al.action ilike $${params.length} or al.target_type ilike $${params.length} or al.target_id ilike $${params.length})`);
}
params.push(limit);
const items = await query(
`
select al.id, al.tenant_id as "tenantId", t.slug::text as "tenantSlug",
t.name as "tenantName", al.actor_user_id as "actorUserId",
u.username as "actorUsername", u.name as "actorName",
u.phone as "actorPhone", al.action,
al.target_type as "targetType", al.target_id as "targetId",
al.details, al.ip_address as "ipAddress", al.user_agent as "userAgent",
al.created_at as "createdAt"
from public.audit_logs al
left join public.tenants t on t.id = al.tenant_id
left join public.platform_users u on u.id = al.actor_user_id
${filters.length ? `where ${filters.join(' and ')}` : ''}
order by al.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function createTenantRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx);
@@ -497,6 +605,7 @@ export async function createTenantRoute(ctx: RequestContext) {
const legalName = optionalString(body, 'legalName') || null;
const primaryHost = optionalString(body, 'primaryHost');
const billing = body.billing && typeof body.billing === 'object' ? (body.billing as Record<string, unknown>) : {};
const billingInvoiceType = billingInvoiceTypeFrom(typeof billing.invoiceType === 'string' ? billing.invoiceType.trim() : '');
const metadata = body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {};
const item = await transaction(async client => {
@@ -561,7 +670,7 @@ export async function createTenantRoute(ctx: RequestContext) {
typeof billing.contactEmail === 'string' ? billing.contactEmail : null,
typeof billing.billingAddress === 'string' ? billing.billingAddress : null,
typeof billing.invoiceTitle === 'string' ? billing.invoiceTitle : legalName || name,
typeof billing.invoiceType === 'string' ? billing.invoiceType : 'none',
billingInvoiceType,
jsonBodyValue(billing.metadata),
],
);
@@ -605,6 +714,15 @@ export async function createTenantRoute(ctx: RequestContext) {
],
);
await recordPlatformAudit(client, ctx, 'platform.tenant.created', 'tenant', tenant.id, {
slug,
name,
legalName,
planCode: plan.code,
billingStatus,
primaryHost: primaryHost ? normalizeHost(primaryHost) : null,
}, tenant.id);
return tenant;
});
@@ -620,21 +738,30 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
const billingStatus = optionalString(body, 'billingStatus');
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
const item = await queryOne(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
const item = await transaction(async client => {
const result = await client.query(
`
update public.tenants
set status = coalesce(nullif($2, ''), status),
billing_status = coalesce(nullif($3, ''), billing_status),
metadata = metadata || $4::jsonb,
updated_at = now()
where id = $1
returning id, slug::text, name, status, billing_status as "billingStatus",
metadata, updated_at as "updatedAt"
`,
[tenantId, status, billingStatus, jsonBodyValue({ statusReason: optionalString(body, 'reason') || null })],
);
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
status: status || null,
billingStatus: billingStatus || null,
reason: optionalString(body, 'reason') || null,
}, tenantId);
return result.rows[0];
});
if (!item) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
return { item };
}
@@ -643,49 +770,63 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
const invoiceType = billingInvoiceTypeFrom(optionalString(body, 'invoiceType'));
const item = await queryOne(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
optionalString(body, 'invoiceType') || 'none',
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.tenant_billing_profiles (
tenant_id, billing_name, tax_id, contact_name, contact_phone, contact_email,
billing_address, invoice_title, invoice_type, bank_name, bank_account_masked, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (tenant_id)
do update set billing_name = excluded.billing_name,
tax_id = excluded.tax_id,
contact_name = excluded.contact_name,
contact_phone = excluded.contact_phone,
contact_email = excluded.contact_email,
billing_address = excluded.billing_address,
invoice_title = excluded.invoice_title,
invoice_type = excluded.invoice_type,
bank_name = excluded.bank_name,
bank_account_masked = excluded.bank_account_masked,
metadata = excluded.metadata,
updated_at = now()
returning tenant_id as "tenantId", billing_name as "billingName", tax_id as "taxId",
contact_name as "contactName", contact_phone as "contactPhone",
contact_email as "contactEmail", billing_address as "billingAddress",
invoice_title as "invoiceTitle", invoice_type as "invoiceType",
bank_name as "bankName", bank_account_masked as "bankAccountMasked",
metadata, updated_at as "updatedAt"
`,
[
tenantId,
optionalString(body, 'billingName') || null,
optionalString(body, 'taxId') || null,
optionalString(body, 'contactName') || null,
optionalString(body, 'contactPhone') || null,
optionalString(body, 'contactEmail') || null,
optionalString(body, 'billingAddress') || null,
optionalString(body, 'invoiceTitle') || null,
invoiceType,
optionalString(body, 'bankName') || null,
optionalString(body, 'bankAccountMasked') || null,
jsonBodyValue(body.metadata),
],
);
await recordPlatformAudit(client, ctx, 'platform.tenant.billing_profile_upserted', 'tenant_billing_profile', tenantId, {
billingName: optionalString(body, 'billingName') || null,
contactName: optionalString(body, 'contactName') || null,
contactPhoneSet: Boolean(optionalString(body, 'contactPhone')),
contactEmailSet: Boolean(optionalString(body, 'contactEmail')),
invoiceType,
bankAccountMaskedSet: Boolean(optionalString(body, 'bankAccountMasked')),
}, tenantId);
return result.rows[0];
});
return { item };
}

View File

@@ -3,10 +3,15 @@ import Taro from '@tarojs/taro';
import { Button, Input, Text, View } from '@tarojs/components';
import {
createPlatformTenant,
loadPlatformAuditLogs,
loadPlatformPlans,
loadPlatformTenantDetail,
loadPlatformTenants,
updatePlatformTenantStatus,
upsertPlatformTenantBillingProfile,
type PlatformAuditLogItem,
type PlatformSaasPlan,
type PlatformTenantDetail,
type PlatformTenantItem,
} from '@/services/platformAdmin';
import '../platform.css';
@@ -21,11 +26,41 @@ function centsFromYuan(value: string) {
return Math.round(amount * 100);
}
function dateText(value?: string | null) {
return value ? String(value).slice(0, 10) : '-';
}
function auditDetailsText(item: PlatformAuditLogItem) {
try {
const text = JSON.stringify(item.details || {});
return text.length > 160 ? `${text.slice(0, 160)}...` : text;
} catch {
return '{}';
}
}
const emptyBillingForm = {
tenantId: '',
billingName: '',
taxId: '',
contactName: '',
contactPhone: '',
contactEmail: '',
billingAddress: '',
invoiceTitle: '',
invoiceType: 'none',
bankName: '',
bankAccountMasked: '',
};
export default function PlatformTenantsPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [detail, setDetail] = useState<PlatformTenantDetail | null>(null);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [tenantForm, setTenantForm] = useState({
slug: '',
name: '',
@@ -42,6 +77,7 @@ export default function PlatformTenantsPage() {
billingStatus: '',
reason: '',
});
const [billingForm, setBillingForm] = useState(emptyBillingForm);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -51,6 +87,43 @@ export default function PlatformTenantsPage() {
.catch(nextError => setError(nextError instanceof Error ? nextError.message : '租户列表加载失败'));
}
function fillBillingForm(nextDetail: PlatformTenantDetail | null, fallbackTenantId = '') {
const tenant = nextDetail?.tenant;
setBillingForm({
tenantId: tenant?.id || fallbackTenantId,
billingName: tenant?.billingName || '',
taxId: tenant?.taxId || '',
contactName: tenant?.contactName || '',
contactPhone: tenant?.contactPhone || '',
contactEmail: tenant?.contactEmail || '',
billingAddress: tenant?.billingAddress || '',
invoiceTitle: tenant?.invoiceTitle || '',
invoiceType: tenant?.invoiceType || 'none',
bankName: tenant?.bankName || '',
bankAccountMasked: tenant?.bankAccountMasked || '',
});
}
async function loadDetail(tenantId: string) {
if (!tenantId) return;
setError('');
setBusy('detail');
try {
const [detailPayload, auditPayload] = await Promise.all([
loadPlatformTenantDetail(tenantId),
loadPlatformAuditLogs({ tenantId, limit: 30 }).catch(() => ({ items: [] })),
]);
const nextDetail = detailPayload.item || null;
setDetail(nextDetail);
fillBillingForm(nextDetail, tenantId);
setAuditLogs(auditPayload.items || []);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '租户详情加载失败');
} finally {
setBusy('');
}
}
useEffect(() => {
reload('', '');
loadPlatformPlans().then(payload => {
@@ -73,6 +146,22 @@ export default function PlatformTenantsPage() {
setStatusForm(current => ({ ...current, [key]: value }));
}
function updateBillingForm(key: keyof typeof billingForm, value: string) {
setBillingForm(current => ({ ...current, [key]: value }));
}
function chooseTenant(item: PlatformTenantItem) {
setSelectedTenantId(item.id);
setStatusForm(current => ({
...current,
tenantId: item.id,
status: item.status || 'active',
billingStatus: item.billingStatus || '',
}));
fillBillingForm(null, item.id);
loadDetail(item.id);
}
async function confirm(title: string, content: string) {
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
return result.confirm;
@@ -90,7 +179,7 @@ export default function PlatformTenantsPage() {
if (!ok) return;
setBusy('create');
try {
await createPlatformTenant({
const payload = await createPlatformTenant({
slug,
name,
legalName: tenantForm.legalName.trim() || undefined,
@@ -112,6 +201,10 @@ export default function PlatformTenantsPage() {
amountYuan: '',
}));
reload(status, keyword);
if (payload.item?.id) {
setSelectedTenantId(payload.item.id);
await loadDetail(payload.item.id);
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '创建租户失败');
} finally {
@@ -141,6 +234,7 @@ export default function PlatformTenantsPage() {
});
Taro.showToast({ title: '已更新', icon: 'success' });
reload(status, keyword);
await loadDetail(statusForm.tenantId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
} finally {
@@ -148,13 +242,47 @@ export default function PlatformTenantsPage() {
}
}
async function submitBillingProfile() {
setError('');
if (!billingForm.tenantId) {
setError('请先选择租户,再维护账务资料。');
return;
}
const ok = await confirm('保存账务资料', '确认更新该租户的开票和收款联系资料?');
if (!ok) return;
setBusy('billing');
try {
await upsertPlatformTenantBillingProfile({
tenantId: billingForm.tenantId,
billingName: billingForm.billingName.trim() || undefined,
taxId: billingForm.taxId.trim() || undefined,
contactName: billingForm.contactName.trim() || undefined,
contactPhone: billingForm.contactPhone.trim() || undefined,
contactEmail: billingForm.contactEmail.trim() || undefined,
billingAddress: billingForm.billingAddress.trim() || undefined,
invoiceTitle: billingForm.invoiceTitle.trim() || undefined,
invoiceType: billingForm.invoiceType.trim() || 'none',
bankName: billingForm.bankName.trim() || undefined,
bankAccountMasked: billingForm.bankAccountMasked.trim() || undefined,
});
Taro.showToast({ title: '已保存', icon: 'success' });
await loadDetail(billingForm.tenantId);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '账务资料保存失败');
} finally {
setBusy('');
}
}
const selectedTenant = detail?.tenant;
return (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Tenants</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'> SaaS API</Text>
<Text className='platform-subtitle'> SaaS </Text>
</View>
<View className='platform-actions'>
@@ -176,7 +304,7 @@ export default function PlatformTenantsPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => Number(item.openBalanceCents || 0) > 0).length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.billingStatus === 'trial').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(tenants.filter(item => item.status === 'suspended').length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant ? selectedTenant.slug : '-'}</Text></View>
</View>
<View className='platform-section'>
@@ -196,19 +324,6 @@ export default function PlatformTenantsPage() {
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='从列表选择或粘贴 tenantId' value={statusForm.tenantId} onInput={event => updateStatusForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / suspended' value={statusForm.status} onInput={event => updateStatusForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active / past_due' value={statusForm.billingStatus} onInput={event => updateStatusForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='内部审计备注' value={statusForm.reason} onInput={event => updateStatusForm('reason', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'status'} onClick={submitStatusChange}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
@@ -216,17 +331,114 @@ export default function PlatformTenantsPage() {
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.brandName || item.name}</Text>
<Text className='platform-row-meta'>{item.slug} · {item.legalName || '未填公司'} · {item.status || '-'} · {item.billingStatus || '-'}</Text>
<Text className='platform-row-meta'> {item.planCode || '未订阅'} · {item.subscriptionStatus || '-'} · {item.subscriptionExpiresAt ? String(item.subscriptionExpiresAt).slice(0, 10) : '-'}</Text>
<Text className='platform-row-meta'> {item.planCode || '未订阅'} · {item.subscriptionStatus || '-'} · {dateText(item.subscriptionExpiresAt)}</Text>
<Text className='platform-row-meta'> {money(item.openBalanceCents)}</Text>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => setStatusForm(current => ({ ...current, tenantId: item.id, status: item.status || 'active', billingStatus: item.billingStatus || '' }))}></Button>
<Button className='platform-mini-button danger' onClick={() => setStatusForm({ tenantId: item.id, status: 'suspended', billingStatus: item.billingStatus || '', reason: 'platform manual suspend' })}></Button>
<Button className='platform-mini-button' loading={busy === 'detail' && selectedTenantId === item.id} onClick={() => chooseTenant(item)}></Button>
<Button className='platform-mini-button danger' onClick={() => {
setSelectedTenantId(item.id);
setStatusForm({ tenantId: item.id, status: 'suspended', billingStatus: item.billingStatus || '', reason: 'platform manual suspend' });
fillBillingForm(null, item.id);
}}></Button>
</View>
</View>
))}
</View>
{!tenants.length ? <View className='platform-empty'></View> : null}
</View>
{selectedTenant ? (
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.brandName || selectedTenant.name}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.status || '-'}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.billingStatus || '-'}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{selectedTenant.legalName || '-'}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form compact'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='tenantId' value={statusForm.tenantId} onInput={event => updateStatusForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / suspended' value={statusForm.status} onInput={event => updateStatusForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='trial / active / past_due' value={statusForm.billingStatus} onInput={event => updateStatusForm('billingStatus', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='内部审计备注' value={statusForm.reason} onInput={event => updateStatusForm('reason', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'status'} onClick={submitStatusChange}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-form'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' value={billingForm.tenantId} onInput={event => updateBillingForm('tenantId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='公司或个人抬头' value={billingForm.billingName} onInput={event => updateBillingForm('billingName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='可选' value={billingForm.taxId} onInput={event => updateBillingForm('taxId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactName} onInput={event => updateBillingForm('contactName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactPhone} onInput={event => updateBillingForm('contactPhone', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.contactEmail} onInput={event => updateBillingForm('contactEmail', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='none / normal_vat / special_vat' value={billingForm.invoiceType} onInput={event => updateBillingForm('invoiceType', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.invoiceTitle} onInput={event => updateBillingForm('invoiceTitle', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.billingAddress} onInput={event => updateBillingForm('billingAddress', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' value={billingForm.bankName} onInput={event => updateBillingForm('bankName', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='仅保存掩码' value={billingForm.bankAccountMasked} onInput={event => updateBillingForm('bankAccountMasked', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'billing'} onClick={submitBillingProfile}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'> / / / </Text>
<View className='platform-list'>
{(detail?.domains || []).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.host || item.id}</Text>
<Text className='platform-row-meta'>{item.domainType || 'domain'} · {item.status || '-'} · {item.isPrimary ? 'primary' : 'secondary'} · {dateText(item.verifiedAt)}</Text>
</View>
))}
{(detail?.subscriptions || []).slice(0, 3).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.planCode || item.id}</Text>
<Text className='platform-row-meta'>{item.status || '-'} · {item.billingCycle || '-'} · {money(item.amountCents)} · {dateText(item.startsAt)} {dateText(item.expiresAt)}</Text>
</View>
))}
{(detail?.invoices || []).slice(0, 4).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.invoiceNo || item.id}</Text>
<Text className='platform-row-meta'>{item.status || '-'} · {money(item.totalCents)} · {money(item.paidCents)} · {money(item.balanceCents)} · {dateText(item.dueDate)}</Text>
</View>
))}
{(detail?.usage || []).slice(0, 6).map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.metricKey || 'metric'}</Text>
<Text className='platform-row-meta'>{String(item.metricValue || 0)} · {dateText(item.periodStart)} {dateText(item.periodEnd)}</Text>
</View>
))}
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{auditLogs.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.action || '-'}</Text>
<Text className='platform-row-meta'>{dateText(item.createdAt)} · {item.actorName || item.actorUsername || item.actorPhone || 'system'} · {item.targetType || '-'} · {item.targetId || '-'}</Text>
<Text className='platform-row-meta'>{auditDetailsText(item)}</Text>
</View>
))}
</View>
{!auditLogs.length ? <View className='platform-empty'></View> : null}
</View>
</View>
) : (
<View className='platform-empty'></View>
)}
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -2,11 +2,13 @@ import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components';
import {
loadPlatformAuditLogs,
loadPlatformInvoices,
loadPlatformOverview,
loadPlatformQuestionBankGrants,
loadPlatformQuestionBanks,
loadPlatformTenants,
type PlatformAuditLogItem,
type PlatformInvoiceItem,
type PlatformOverview,
type PlatformQuestionBankGrant,
@@ -23,6 +25,7 @@ export default function PlatformWorkbenchPage() {
const [overview, setOverview] = useState<PlatformOverview | null>(null);
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
const [auditLogs, setAuditLogs] = useState<PlatformAuditLogItem[]>([]);
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
const [error, setError] = useState('');
@@ -34,12 +37,14 @@ export default function PlatformWorkbenchPage() {
loadPlatformInvoices({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformQuestionBanks({ limit: 6 }).catch(() => ({ items: [] })),
loadPlatformQuestionBankGrants({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload]) => {
loadPlatformAuditLogs({ limit: 6 }).catch(() => ({ items: [] })),
]).then(([overviewPayload, tenantPayload, invoicePayload, bankPayload, grantPayload, auditPayload]) => {
setOverview(overviewPayload.item || null);
setTenants(tenantPayload.items || []);
setInvoices(invoicePayload.items || []);
setBanks(bankPayload.items || []);
setGrants(grantPayload.items || []);
setAuditLogs(auditPayload.items || []);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台后台加载失败'));
}, []);
@@ -105,6 +110,18 @@ export default function PlatformWorkbenchPage() {
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(overview?.billing?.overdueInvoices || 0)}</Text></View>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{auditLogs.map(item => (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.action || '-'}</Text>
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || '平台'} · {item.actorName || item.actorUsername || 'system'} · {item.targetType || '-'} · {String(item.createdAt || '').slice(0, 19).replace('T', ' ')}</Text>
</View>
))}
</View>
{!auditLogs.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>

View File

@@ -54,6 +54,60 @@ export interface PlatformTenantItem {
openBalanceCents?: number | string | null;
}
export interface PlatformTenantBillingProfile {
tenantId?: string | null;
billingName?: string | null;
taxId?: string | null;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
billingAddress?: string | null;
invoiceTitle?: string | null;
invoiceType?: string | null;
bankName?: string | null;
bankAccountMasked?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface PlatformTenantDetailTenant extends PlatformTenantItem, PlatformTenantBillingProfile {
shortName?: string | null;
serviceWechat?: string | null;
ownerUserId?: string | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformTenantDomain {
id: string;
host?: string | null;
domainType?: string | null;
status?: string | null;
isPrimary?: boolean | null;
verifiedAt?: string | null;
createdAt?: string | null;
}
export interface PlatformTenantSubscription {
id: string;
planCode?: string | null;
status?: string | null;
startsAt?: string | null;
expiresAt?: string | null;
billingCycle?: string | null;
amountCents?: number | string | null;
metadata?: Record<string, unknown> | null;
createdAt?: string | null;
}
export interface PlatformTenantDetail {
tenant?: PlatformTenantDetailTenant | null;
domains?: PlatformTenantDomain[];
subscriptions?: PlatformTenantSubscription[];
invoices?: PlatformInvoiceItem[];
usage?: PlatformUsageItem[];
}
export interface PlatformInvoiceItem {
id: string;
tenantId: string;
@@ -113,6 +167,24 @@ export interface PlatformQuestionBankGrant {
expiresAt?: string | null;
}
export interface PlatformAuditLogItem {
id: string;
tenantId?: string | null;
tenantSlug?: string | null;
tenantName?: string | null;
actorUserId?: string | null;
actorUsername?: string | null;
actorName?: string | null;
actorPhone?: string | null;
action?: string | null;
targetType?: string | null;
targetId?: string | null;
details?: Record<string, unknown> | null;
ipAddress?: string | null;
userAgent?: string | null;
createdAt?: string | null;
}
export interface CreatePlatformTenantInput {
slug: string;
name: string;
@@ -133,6 +205,10 @@ export interface UpdatePlatformTenantStatusInput {
reason?: string;
}
export interface UpsertPlatformTenantBillingProfileInput extends PlatformTenantBillingProfile {
tenantId: string;
}
export interface CreatePlatformSubscriptionInput {
tenantId: string;
planCode: string;
@@ -198,6 +274,20 @@ export async function loadPlatformTenants(query: { q?: string; status?: string;
});
}
export async function loadPlatformTenantDetail(tenantId: string) {
return apiRequest<{ item?: PlatformTenantDetail }>('/api/platform-admin/tenants/detail', {
query: { tenantId },
tenantId: null,
});
}
export async function loadPlatformAuditLogs(query: { tenantId?: string; action?: string; targetType?: string; actorUserId?: string; q?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformAuditLogItem[] }>('/api/platform-admin/audit-logs', {
query: { ...query, limit: query.limit || 100 },
tenantId: null,
});
}
export async function loadPlatformInvoices(query: { tenantId?: string; status?: string; limit?: number } = {}) {
return apiRequest<{ items?: PlatformInvoiceItem[] }>('/api/platform-admin/invoices', {
query: { ...query, limit: query.limit || 80 },
@@ -242,6 +332,14 @@ export async function updatePlatformTenantStatus(input: UpdatePlatformTenantStat
});
}
export async function upsertPlatformTenantBillingProfile(input: UpsertPlatformTenantBillingProfileInput) {
return apiRequest<{ item?: PlatformTenantBillingProfile }>('/api/platform-admin/tenants/billing-profile', {
method: 'PUT',
body: input,
tenantId: null,
});
}
export async function createPlatformSubscription(input: CreatePlatformSubscriptionInput) {
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/subscriptions', {
method: 'POST',