forked from wangziqi/gongxue-base
feat: add platform admin operations
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { Input } from '@tarojs/components';
|
||||
import {
|
||||
confirmPlatformInvoicePayment,
|
||||
createPlatformInvoiceFromSubscription,
|
||||
createPlatformSubscription,
|
||||
loadPlatformInvoices,
|
||||
loadPlatformPlans,
|
||||
loadPlatformUsage,
|
||||
recordPlatformUsage,
|
||||
type PlatformInvoiceItem,
|
||||
type PlatformSaasPlan,
|
||||
type PlatformUsageItem,
|
||||
@@ -15,11 +21,46 @@ function money(cents: unknown, currency = 'CNY') {
|
||||
return `${symbol}${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function todayText() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function centsFromYuan(value: string) {
|
||||
const amount = Number(value || 0);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return 0;
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
export default function PlatformBillingPage() {
|
||||
const [status, setStatus] = useState('');
|
||||
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
|
||||
const [invoices, setInvoices] = useState<PlatformInvoiceItem[]>([]);
|
||||
const [usage, setUsage] = useState<PlatformUsageItem[]>([]);
|
||||
const [subscriptionForm, setSubscriptionForm] = useState({
|
||||
tenantId: '',
|
||||
planCode: '',
|
||||
status: 'active',
|
||||
amountYuan: '',
|
||||
});
|
||||
const [invoiceForm, setInvoiceForm] = useState({
|
||||
tenantId: '',
|
||||
dueDate: '',
|
||||
note: '',
|
||||
});
|
||||
const [paymentForm, setPaymentForm] = useState({
|
||||
tenantId: '',
|
||||
invoiceId: '',
|
||||
amountYuan: '',
|
||||
providerTradeNo: '',
|
||||
});
|
||||
const [usageForm, setUsageForm] = useState({
|
||||
tenantId: '',
|
||||
metricKey: 'students',
|
||||
metricValue: '',
|
||||
periodStart: todayText(),
|
||||
periodEnd: todayText(),
|
||||
});
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload(nextStatus = status) {
|
||||
@@ -28,7 +69,9 @@ export default function PlatformBillingPage() {
|
||||
loadPlatformInvoices({ status: nextStatus || undefined, limit: 100 }),
|
||||
loadPlatformUsage({ limit: 80 }).catch(() => ({ items: [] })),
|
||||
]).then(([planPayload, invoicePayload, usagePayload]) => {
|
||||
setPlans(planPayload.items || []);
|
||||
const nextPlans = planPayload.items || [];
|
||||
setPlans(nextPlans);
|
||||
setSubscriptionForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || '' }));
|
||||
setInvoices(invoicePayload.items || []);
|
||||
setUsage(usagePayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '账务数据加载失败'));
|
||||
@@ -46,6 +89,132 @@ export default function PlatformBillingPage() {
|
||||
const unpaidCents = invoices.reduce((sum, item) => sum + Number(item.balanceCents || 0), 0);
|
||||
const paidCents = invoices.reduce((sum, item) => sum + Number(item.paidCents || 0), 0);
|
||||
|
||||
function updateSubscriptionForm(key: keyof typeof subscriptionForm, value: string) {
|
||||
setSubscriptionForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateInvoiceForm(key: keyof typeof invoiceForm, value: string) {
|
||||
setInvoiceForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updatePaymentForm(key: keyof typeof paymentForm, value: string) {
|
||||
setPaymentForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateUsageForm(key: keyof typeof usageForm, value: string) {
|
||||
setUsageForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
}
|
||||
|
||||
async function submitSubscription() {
|
||||
setError('');
|
||||
if (!subscriptionForm.tenantId || !subscriptionForm.planCode) {
|
||||
setError('开通订阅需要填写租户 ID 和套餐编码。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('开通订阅', `确认给租户 ${subscriptionForm.tenantId} 开通 ${subscriptionForm.planCode}?`);
|
||||
if (!ok) return;
|
||||
setBusy('subscription');
|
||||
try {
|
||||
await createPlatformSubscription({
|
||||
tenantId: subscriptionForm.tenantId,
|
||||
planCode: subscriptionForm.planCode,
|
||||
status: subscriptionForm.status || 'active',
|
||||
amountCents: subscriptionForm.amountYuan ? centsFromYuan(subscriptionForm.amountYuan) : undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已开通', icon: 'success' });
|
||||
reload(status);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '订阅开通失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInvoiceFromSubscription() {
|
||||
setError('');
|
||||
if (!invoiceForm.tenantId) {
|
||||
setError('生成账单需要填写租户 ID。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('生成账单', `确认按租户 ${invoiceForm.tenantId} 最新订阅生成服务费账单?`);
|
||||
if (!ok) return;
|
||||
setBusy('invoice');
|
||||
try {
|
||||
await createPlatformInvoiceFromSubscription({
|
||||
tenantId: invoiceForm.tenantId,
|
||||
status: 'issued',
|
||||
dueDate: invoiceForm.dueDate || undefined,
|
||||
note: invoiceForm.note.trim() || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已生成', icon: 'success' });
|
||||
reload(status);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '账单生成失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPaymentConfirm() {
|
||||
setError('');
|
||||
const amountCents = centsFromYuan(paymentForm.amountYuan);
|
||||
if (!paymentForm.tenantId || !paymentForm.invoiceId || amountCents <= 0) {
|
||||
setError('确认收款需要填写租户 ID、账单 ID 和正数金额。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('确认收款', `确认登记线下收款 ${money(amountCents)}?该动作会影响租户账务状态。`);
|
||||
if (!ok) return;
|
||||
setBusy('payment');
|
||||
try {
|
||||
await confirmPlatformInvoicePayment({
|
||||
tenantId: paymentForm.tenantId,
|
||||
invoiceId: paymentForm.invoiceId,
|
||||
amountCents,
|
||||
provider: 'manual',
|
||||
method: 'manual',
|
||||
providerTradeNo: paymentForm.providerTradeNo.trim() || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已确认', icon: 'success' });
|
||||
reload(status);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '收款确认失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUsageRecord() {
|
||||
setError('');
|
||||
const metricValue = Number(usageForm.metricValue || 0);
|
||||
if (!usageForm.tenantId || !usageForm.metricKey || !usageForm.periodStart || !usageForm.periodEnd || metricValue < 0) {
|
||||
setError('记录用量需要填写租户、指标、数值和账期。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('记录用量', `确认写入 ${usageForm.metricKey} = ${metricValue} 的账期用量?`);
|
||||
if (!ok) return;
|
||||
setBusy('usage');
|
||||
try {
|
||||
await recordPlatformUsage({
|
||||
tenantId: usageForm.tenantId,
|
||||
metricKey: usageForm.metricKey,
|
||||
metricValue,
|
||||
periodStart: usageForm.periodStart,
|
||||
periodEnd: usageForm.periodEnd,
|
||||
});
|
||||
Taro.showToast({ title: '已记录', icon: 'success' });
|
||||
reload(status);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '用量记录失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='platform-page'>
|
||||
<View className='platform-shell'>
|
||||
@@ -73,6 +242,52 @@ export default function PlatformBillingPage() {
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>用量记录</Text><Text className='platform-metric-value'>{String(usage.length)}</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={subscriptionForm.tenantId} onInput={event => updateSubscriptionForm('tenantId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>套餐编码</Text><Input className='platform-input' placeholder={plans[0]?.code || 'plan_code'} value={subscriptionForm.planCode} onInput={event => updateSubscriptionForm('planCode', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>订阅状态</Text><Input className='platform-input' placeholder='active / trial' value={subscriptionForm.status} onInput={event => updateSubscriptionForm('status', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>订阅金额</Text><Input className='platform-input' placeholder='元,可留空用套餐价' type='digit' value={subscriptionForm.amountYuan} onInput={event => updateSubscriptionForm('amountYuan', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'subscription'} onClick={submitSubscription}>开通订阅</Button>
|
||||
</View>
|
||||
|
||||
<View className='platform-form compact'>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>租户 ID</Text><Input className='platform-input' placeholder='tenantId' value={invoiceForm.tenantId} onInput={event => updateInvoiceForm('tenantId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>到期日</Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={invoiceForm.dueDate} onInput={event => updateInvoiceForm('dueDate', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>备注</Text><Input className='platform-input' placeholder='账单说明,可选' value={invoiceForm.note} onInput={event => updateInvoiceForm('note', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'invoice'} onClick={submitInvoiceFromSubscription}>生成订阅账单</Button>
|
||||
</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={paymentForm.tenantId} onInput={event => updatePaymentForm('tenantId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>账单 ID</Text><Input className='platform-input' placeholder='invoiceId' value={paymentForm.invoiceId} onInput={event => updatePaymentForm('invoiceId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>收款金额</Text><Input className='platform-input' placeholder='元' type='digit' value={paymentForm.amountYuan} onInput={event => updatePaymentForm('amountYuan', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>流水号</Text><Input className='platform-input' placeholder='可选' value={paymentForm.providerTradeNo} onInput={event => updatePaymentForm('providerTradeNo', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'payment'} onClick={submitPaymentConfirm}>确认收款</Button>
|
||||
</View>
|
||||
|
||||
<View className='platform-form compact'>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>租户 ID</Text><Input className='platform-input' placeholder='tenantId' value={usageForm.tenantId} onInput={event => updateUsageForm('tenantId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>指标</Text><Input className='platform-input' placeholder='students / questions / storage_gb' value={usageForm.metricKey} onInput={event => updateUsageForm('metricKey', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>数值</Text><Input className='platform-input' placeholder='0' type='digit' value={usageForm.metricValue} onInput={event => updateUsageForm('metricValue', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>开始</Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={usageForm.periodStart} onInput={event => updateUsageForm('periodStart', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>结束</Text><Input className='platform-input' placeholder='YYYY-MM-DD' value={usageForm.periodEnd} onInput={event => updateUsageForm('periodEnd', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'usage'} onClick={submitUsageRecord}>记录用量</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>SaaS 套餐</Text>
|
||||
<View className='platform-list'>
|
||||
@@ -96,6 +311,19 @@ export default function PlatformBillingPage() {
|
||||
<Text className='platform-row-meta'>{item.tenantName || item.tenantSlug || item.tenantId} · {item.invoiceType || 'invoice'} · {item.status || '-'}</Text>
|
||||
<Text className='platform-row-meta'>总额 {money(item.totalCents, item.currency || 'CNY')} · 已收 {money(item.paidCents, item.currency || 'CNY')} · 余额 {money(item.balanceCents, item.currency || 'CNY')}</Text>
|
||||
<Text className='platform-row-meta'>到期 {item.dueDate ? String(item.dueDate).slice(0, 10) : '-'}</Text>
|
||||
<View className='platform-row-actions'>
|
||||
<Button className='platform-mini-button' onClick={() => {
|
||||
setPaymentForm(current => ({
|
||||
...current,
|
||||
tenantId: item.tenantId,
|
||||
invoiceId: item.id,
|
||||
amountYuan: Number(item.balanceCents || 0) > 0 ? String(Number(item.balanceCents || 0) / 100) : current.amountYuan,
|
||||
}));
|
||||
setInvoiceForm(current => ({ ...current, tenantId: item.tenantId }));
|
||||
setSubscriptionForm(current => ({ ...current, tenantId: item.tenantId }));
|
||||
setUsageForm(current => ({ ...current, tenantId: item.tenantId }));
|
||||
}}>选择</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -167,6 +167,58 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.platform-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.platform-form.compact {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.platform-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-field.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.platform-field-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #475569;
|
||||
font-size: 20px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.platform-row-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.platform-mini-button {
|
||||
min-width: 116px;
|
||||
height: 54px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #1e3a8a;
|
||||
font-size: 21px;
|
||||
font-weight: 720;
|
||||
line-height: 54px;
|
||||
}
|
||||
|
||||
.platform-mini-button.danger {
|
||||
border-color: #fecdd3;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.platform-error {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
loadPlatformQuestionBankGrants,
|
||||
loadPlatformQuestionBanks,
|
||||
upsertPlatformQuestionBankGrant,
|
||||
type PlatformQuestionBankGrant,
|
||||
type PlatformQuestionBankItem,
|
||||
} from '@/services/platformAdmin';
|
||||
@@ -23,6 +25,17 @@ export default function PlatformQuestionBanksPage() {
|
||||
const [includeTenantBanks, setIncludeTenantBanks] = useState(false);
|
||||
const [banks, setBanks] = useState<PlatformQuestionBankItem[]>([]);
|
||||
const [grants, setGrants] = useState<PlatformQuestionBankGrant[]>([]);
|
||||
const [grantForm, setGrantForm] = useState({
|
||||
id: '',
|
||||
sourceQuestionBankId: '',
|
||||
grantScope: 'plans',
|
||||
allowedPlanCodes: '',
|
||||
allowedTenantIds: '',
|
||||
status: 'active',
|
||||
startsAt: '',
|
||||
expiresAt: '',
|
||||
});
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload(nextKeyword = keyword, nextIncludeTenantBanks = includeTenantBanks) {
|
||||
@@ -45,6 +58,62 @@ export default function PlatformQuestionBanksPage() {
|
||||
reload(keyword, nextValue);
|
||||
}
|
||||
|
||||
function updateGrantForm(key: keyof typeof grantForm, value: string) {
|
||||
setGrantForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function commaList(value: string) {
|
||||
return value.split(',').map(item => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
setError('');
|
||||
if (!grantForm.sourceQuestionBankId) {
|
||||
setError('请先选择平台公共题库。');
|
||||
return;
|
||||
}
|
||||
const allowedPlanCodes = commaList(grantForm.allowedPlanCodes);
|
||||
const allowedTenantIds = commaList(grantForm.allowedTenantIds);
|
||||
if (grantForm.grantScope === 'plans' && !allowedPlanCodes.length) {
|
||||
setError('按套餐授权时必须填写套餐编码。');
|
||||
return;
|
||||
}
|
||||
if (grantForm.grantScope === 'tenants' && !allowedTenantIds.length) {
|
||||
setError('按租户授权时必须填写租户 ID。');
|
||||
return;
|
||||
}
|
||||
if (grantForm.grantScope === 'mixed' && !allowedPlanCodes.length && !allowedTenantIds.length) {
|
||||
setError('混合授权至少需要填写套餐编码或租户 ID。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('更新题库授权', '确认更新公共题库披露规则?租户后台可见范围会受该规则影响。');
|
||||
if (!ok) return;
|
||||
setBusy('grant');
|
||||
try {
|
||||
await upsertPlatformQuestionBankGrant({
|
||||
id: grantForm.id || undefined,
|
||||
sourceQuestionBankId: grantForm.sourceQuestionBankId,
|
||||
grantScope: grantForm.grantScope,
|
||||
allowedPlanCodes,
|
||||
allowedTenantIds,
|
||||
status: grantForm.status || 'active',
|
||||
startsAt: grantForm.startsAt || undefined,
|
||||
expiresAt: grantForm.expiresAt || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已保存', icon: 'success' });
|
||||
reload(keyword, includeTenantBanks);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '授权保存失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='platform-page'>
|
||||
<View className='platform-shell'>
|
||||
@@ -67,6 +136,23 @@ export default function PlatformQuestionBanksPage() {
|
||||
<View className='platform-metric'><Text className='platform-metric-label'>活跃授权</Text><Text className='platform-metric-value'>{String(grants.filter(item => item.status === 'active').length)}</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='留空则新增' value={grantForm.id} onInput={event => updateGrantForm('id', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>题库 ID</Text><Input className='platform-input' placeholder='sourceQuestionBankId' value={grantForm.sourceQuestionBankId} onInput={event => updateGrantForm('sourceQuestionBankId', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>范围</Text><Input className='platform-input' placeholder='plans / tenants / mixed / all_active_tenants' value={grantForm.grantScope} onInput={event => updateGrantForm('grantScope', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>状态</Text><Input className='platform-input' placeholder='active / disabled' value={grantForm.status} onInput={event => updateGrantForm('status', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>套餐编码</Text><Input className='platform-input' placeholder='starter_yearly,regional_yearly' value={grantForm.allowedPlanCodes} onInput={event => updateGrantForm('allowedPlanCodes', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field wide'><Text className='platform-field-label'>租户 ID</Text><Input className='platform-input' placeholder='多个用英文逗号分隔' value={grantForm.allowedTenantIds} onInput={event => updateGrantForm('allowedTenantIds', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>开始</Text><Input className='platform-input' placeholder='YYYY-MM-DD,可选' value={grantForm.startsAt} onInput={event => updateGrantForm('startsAt', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>结束</Text><Input className='platform-input' placeholder='YYYY-MM-DD,可选' value={grantForm.expiresAt} onInput={event => updateGrantForm('expiresAt', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'grant'} onClick={submitGrant}>保存授权</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>公共题库</Text>
|
||||
<View className='platform-list'>
|
||||
@@ -75,6 +161,9 @@ export default function PlatformQuestionBanksPage() {
|
||||
<Text className='platform-row-main'>{item.name}</Text>
|
||||
<Text className='platform-row-meta'>{item.regionName || '通用'} · {item.sourceScope || 'tenant'} · {item.status || '-'} · {String(item.questionCount || 0)} 题</Text>
|
||||
<Text className='platform-row-meta'>来源租户 {item.tenantName || item.tenantSlug || item.tenantId || '-'}</Text>
|
||||
<View className='platform-row-actions'>
|
||||
<Button className='platform-mini-button' onClick={() => setGrantForm(current => ({ ...current, sourceQuestionBankId: item.id }))}>选择题库</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -89,6 +178,28 @@ export default function PlatformQuestionBanksPage() {
|
||||
<Text className='platform-row-main'>{item.sourceQuestionBankName || item.sourceQuestionBankId || item.id}</Text>
|
||||
<Text className='platform-row-meta'>{item.sourceRegionName || '通用'} · {item.grantScope || 'plans'} · {targetText(item)} · {item.status || '-'}</Text>
|
||||
<Text className='platform-row-meta'>有效期 {item.startsAt ? String(item.startsAt).slice(0, 10) : '-'} 至 {item.expiresAt ? String(item.expiresAt).slice(0, 10) : '长期'}</Text>
|
||||
<View className='platform-row-actions'>
|
||||
<Button className='platform-mini-button' onClick={() => setGrantForm({
|
||||
id: item.id,
|
||||
sourceQuestionBankId: item.sourceQuestionBankId || '',
|
||||
grantScope: item.grantScope || 'plans',
|
||||
allowedPlanCodes: (item.allowedPlanCodes || []).join(','),
|
||||
allowedTenantIds: (item.allowedTenantIds || []).join(','),
|
||||
status: item.status || 'active',
|
||||
startsAt: item.startsAt ? String(item.startsAt).slice(0, 10) : '',
|
||||
expiresAt: item.expiresAt ? String(item.expiresAt).slice(0, 10) : '',
|
||||
})}>编辑</Button>
|
||||
<Button className='platform-mini-button danger' onClick={() => setGrantForm({
|
||||
id: item.id,
|
||||
sourceQuestionBankId: item.sourceQuestionBankId || '',
|
||||
grantScope: item.grantScope || 'plans',
|
||||
allowedPlanCodes: (item.allowedPlanCodes || []).join(','),
|
||||
allowedTenantIds: (item.allowedTenantIds || []).join(','),
|
||||
status: 'disabled',
|
||||
startsAt: item.startsAt ? String(item.startsAt).slice(0, 10) : '',
|
||||
expiresAt: item.expiresAt ? String(item.expiresAt).slice(0, 10) : '',
|
||||
})}>准备禁用</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { loadPlatformTenants, type PlatformTenantItem } from '@/services/platformAdmin';
|
||||
import {
|
||||
createPlatformTenant,
|
||||
loadPlatformPlans,
|
||||
loadPlatformTenants,
|
||||
updatePlatformTenantStatus,
|
||||
type PlatformSaasPlan,
|
||||
type PlatformTenantItem,
|
||||
} from '@/services/platformAdmin';
|
||||
import '../platform.css';
|
||||
|
||||
function money(cents: unknown) {
|
||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function centsFromYuan(value: string) {
|
||||
const amount = Number(value || 0);
|
||||
if (!Number.isFinite(amount) || amount < 0) return 0;
|
||||
return Math.round(amount * 100);
|
||||
}
|
||||
|
||||
export default function PlatformTenantsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [plans, setPlans] = useState<PlatformSaasPlan[]>([]);
|
||||
const [tenants, setTenants] = useState<PlatformTenantItem[]>([]);
|
||||
const [tenantForm, setTenantForm] = useState({
|
||||
slug: '',
|
||||
name: '',
|
||||
legalName: '',
|
||||
brandName: '',
|
||||
primaryHost: '',
|
||||
planCode: '',
|
||||
billingStatus: 'trial',
|
||||
amountYuan: '',
|
||||
});
|
||||
const [statusForm, setStatusForm] = useState({
|
||||
tenantId: '',
|
||||
status: 'active',
|
||||
billingStatus: '',
|
||||
reason: '',
|
||||
});
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload(nextStatus = status, nextKeyword = keyword) {
|
||||
@@ -21,6 +53,11 @@ export default function PlatformTenantsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
reload('', '');
|
||||
loadPlatformPlans().then(payload => {
|
||||
const nextPlans = payload.items || [];
|
||||
setPlans(nextPlans);
|
||||
setTenantForm(current => ({ ...current, planCode: current.planCode || nextPlans[0]?.code || 'starter_yearly' }));
|
||||
}).catch(() => setPlans([]));
|
||||
}, []);
|
||||
|
||||
function chooseStatus(nextStatus: string) {
|
||||
@@ -28,6 +65,89 @@ export default function PlatformTenantsPage() {
|
||||
reload(nextStatus, keyword);
|
||||
}
|
||||
|
||||
function updateTenantForm(key: keyof typeof tenantForm, value: string) {
|
||||
setTenantForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateStatusForm(key: keyof typeof statusForm, value: string) {
|
||||
setStatusForm(current => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function confirm(title: string, content: string) {
|
||||
const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' });
|
||||
return result.confirm;
|
||||
}
|
||||
|
||||
async function submitCreateTenant() {
|
||||
setError('');
|
||||
const slug = tenantForm.slug.trim();
|
||||
const name = tenantForm.name.trim();
|
||||
if (!slug || !name) {
|
||||
setError('创建租户需要填写 slug 和租户名称。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('创建租户', `确认创建租户 ${name},并绑定套餐 ${tenantForm.planCode || 'starter_yearly'}?`);
|
||||
if (!ok) return;
|
||||
setBusy('create');
|
||||
try {
|
||||
await createPlatformTenant({
|
||||
slug,
|
||||
name,
|
||||
legalName: tenantForm.legalName.trim() || undefined,
|
||||
brandName: tenantForm.brandName.trim() || name,
|
||||
shortName: tenantForm.brandName.trim() || name,
|
||||
primaryHost: tenantForm.primaryHost.trim() || undefined,
|
||||
planCode: tenantForm.planCode || plans[0]?.code || 'starter_yearly',
|
||||
billingStatus: tenantForm.billingStatus || 'trial',
|
||||
amountCents: tenantForm.amountYuan ? centsFromYuan(tenantForm.amountYuan) : undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已创建', icon: 'success' });
|
||||
setTenantForm(current => ({
|
||||
...current,
|
||||
slug: '',
|
||||
name: '',
|
||||
legalName: '',
|
||||
brandName: '',
|
||||
primaryHost: '',
|
||||
amountYuan: '',
|
||||
}));
|
||||
reload(status, keyword);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '创建租户失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitStatusChange() {
|
||||
setError('');
|
||||
if (!statusForm.tenantId) {
|
||||
setError('请先从租户列表选择要操作的租户。');
|
||||
return;
|
||||
}
|
||||
if (!statusForm.status && !statusForm.billingStatus) {
|
||||
setError('至少需要选择一个租户状态或账务状态。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm('变更租户状态', '该操作会影响租户后台和学生端访问,请确认已完成线下沟通或风控检查。');
|
||||
if (!ok) return;
|
||||
setBusy('status');
|
||||
try {
|
||||
await updatePlatformTenantStatus({
|
||||
tenantId: statusForm.tenantId,
|
||||
status: statusForm.status || undefined,
|
||||
billingStatus: statusForm.billingStatus || undefined,
|
||||
reason: statusForm.reason.trim() || undefined,
|
||||
});
|
||||
Taro.showToast({ title: '已更新', icon: 'success' });
|
||||
reload(status, keyword);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='platform-page'>
|
||||
<View className='platform-shell'>
|
||||
@@ -59,6 +179,36 @@ export default function PlatformTenantsPage() {
|
||||
<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>
|
||||
|
||||
<View className='platform-section'>
|
||||
<Text className='platform-section-title'>创建租户</Text>
|
||||
<View className='platform-form'>
|
||||
<View className='platform-field'><Text className='platform-field-label'>slug</Text><Input className='platform-input' placeholder='tenant-slug' value={tenantForm.slug} onInput={event => updateTenantForm('slug', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>租户名称</Text><Input className='platform-input' placeholder='合作商名称' value={tenantForm.name} onInput={event => updateTenantForm('name', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>公司名称</Text><Input className='platform-input' placeholder='开票主体,可选' value={tenantForm.legalName} onInput={event => updateTenantForm('legalName', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>品牌名称</Text><Input className='platform-input' placeholder='前台展示品牌,可选' value={tenantForm.brandName} onInput={event => updateTenantForm('brandName', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>主域名</Text><Input className='platform-input' placeholder='tiku.example.com,可选' value={tenantForm.primaryHost} onInput={event => updateTenantForm('primaryHost', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>套餐编码</Text><Input className='platform-input' placeholder={plans[0]?.code || 'starter_yearly'} value={tenantForm.planCode} onInput={event => updateTenantForm('planCode', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>账务状态</Text><Input className='platform-input' placeholder='trial / active' value={tenantForm.billingStatus} onInput={event => updateTenantForm('billingStatus', String(event.detail.value || ''))} /></View>
|
||||
<View className='platform-field'><Text className='platform-field-label'>首期金额</Text><Input className='platform-input' placeholder='元,可留空用套餐默认价' type='digit' value={tenantForm.amountYuan} onInput={event => updateTenantForm('amountYuan', String(event.detail.value || ''))} /></View>
|
||||
</View>
|
||||
<View className='platform-actions'>
|
||||
<Button className='platform-button primary' loading={busy === 'create'} onClick={submitCreateTenant}>创建租户</Button>
|
||||
</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'>
|
||||
@@ -68,6 +218,10 @@ export default function PlatformTenantsPage() {
|
||||
<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'>未收余额 {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>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -113,6 +113,73 @@ export interface PlatformQuestionBankGrant {
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatePlatformTenantInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
legalName?: string;
|
||||
brandName?: string;
|
||||
shortName?: string;
|
||||
primaryHost?: string;
|
||||
planCode?: string;
|
||||
status?: string;
|
||||
billingStatus?: string;
|
||||
amountCents?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformTenantStatusInput {
|
||||
tenantId: string;
|
||||
status?: string;
|
||||
billingStatus?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CreatePlatformSubscriptionInput {
|
||||
tenantId: string;
|
||||
planCode: string;
|
||||
status?: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
amountCents?: number;
|
||||
}
|
||||
|
||||
export interface CreatePlatformInvoiceFromSubscriptionInput {
|
||||
tenantId: string;
|
||||
subscriptionId?: string;
|
||||
status?: string;
|
||||
dueDate?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmPlatformInvoicePaymentInput {
|
||||
tenantId: string;
|
||||
invoiceId: string;
|
||||
amountCents: number;
|
||||
provider?: string;
|
||||
method?: string;
|
||||
providerTradeNo?: string;
|
||||
}
|
||||
|
||||
export interface RecordPlatformUsageInput {
|
||||
tenantId: string;
|
||||
metricKey: string;
|
||||
metricValue: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
export interface UpsertPlatformQuestionBankGrantInput {
|
||||
id?: string;
|
||||
sourceQuestionBankId: string;
|
||||
grantScope: string;
|
||||
allowedPlanCodes?: string[];
|
||||
allowedTenantIds?: string[];
|
||||
allowedRegionIds?: string[];
|
||||
allowedSubjectIds?: string[];
|
||||
status?: string;
|
||||
startsAt?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export async function loadPlatformOverview() {
|
||||
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null });
|
||||
}
|
||||
@@ -158,3 +225,59 @@ export async function loadPlatformQuestionBankGrants(query: { questionBankId?: s
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformTenant(input: CreatePlatformTenantInput) {
|
||||
return apiRequest<{ item?: PlatformTenantItem }>('/api/platform-admin/tenants', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePlatformTenantStatus(input: UpdatePlatformTenantStatusInput) {
|
||||
return apiRequest<{ item?: PlatformTenantItem }>('/api/platform-admin/tenants/status', {
|
||||
method: 'PATCH',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformSubscription(input: CreatePlatformSubscriptionInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/subscriptions', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlatformInvoiceFromSubscription(input: CreatePlatformInvoiceFromSubscriptionInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/from-subscription', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmPlatformInvoicePayment(input: ConfirmPlatformInvoicePaymentInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/invoices/payments/manual-confirm', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordPlatformUsage(input: RecordPlatformUsageInput) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/platform-admin/usage', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertPlatformQuestionBankGrant(input: UpsertPlatformQuestionBankGrantInput) {
|
||||
return apiRequest<{ item?: PlatformQuestionBankGrant }>('/api/platform-admin/question-bank-grants', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user