forked from wangziqi/gongxue-base
feat: add platform tenant detail audit console
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user