feat: harden platform workflows and UI governance
This commit is contained in:
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
@@ -42,7 +42,7 @@ type ChannelModalState = {
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'delete' | 'copy';
|
||||
type: 'toggle' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
@@ -532,11 +532,6 @@ export function AdminChannelsPage() {
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function deleteChannel(id: string) {
|
||||
await adminApi.deleteChannel(id, '运营端删除通道');
|
||||
setChannels((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
await adminApi.copyChannel(channel.id);
|
||||
loadChannels();
|
||||
@@ -563,10 +558,6 @@ export function AdminChannelsPage() {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'delete') {
|
||||
void deleteChannel(confirmAction.channel.id);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
@@ -574,17 +565,13 @@ export function AdminChannelsPage() {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'delete'
|
||||
? '确认删除通道'
|
||||
: confirmAction?.type === 'copy'
|
||||
const confirmTitle = confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'delete'
|
||||
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
|
||||
: confirmAction?.type === 'copy'
|
||||
const confirmDescription = confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
@@ -651,7 +638,7 @@ export function AdminChannelsPage() {
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
||||
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -691,7 +678,7 @@ export function AdminChannelsPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button>
|
||||
<Button onClick={submitConfirmAction}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
type AdminCustomersPageProps = {
|
||||
basePath?: string;
|
||||
@@ -11,11 +11,6 @@ type AdminCustomersPageProps = {
|
||||
|
||||
type CustomerRow = TenantManagementRow;
|
||||
|
||||
type RechargeForm = {
|
||||
amount: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
||||
@@ -24,13 +19,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
);
|
||||
}
|
||||
|
||||
function emptyRechargeForm(): RechargeForm {
|
||||
return {
|
||||
amount: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<CustomerRow[]>([]);
|
||||
@@ -39,9 +27,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const [filters, setFilters] = useState({ name: '', status: 'all' });
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
||||
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
|
||||
const [rechargeError, setRechargeError] = useState('');
|
||||
const [recharging, setRecharging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -108,37 +93,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
|
||||
function openRechargeModal(record: CustomerRow) {
|
||||
setRechargeTarget(record);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
|
||||
setRechargeForm((current) => ({ ...current, [key]: value }));
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
|
||||
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
|
||||
return;
|
||||
}
|
||||
setRecharging(true);
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: rechargeTarget.id,
|
||||
amountCents: yuanToMoneyUnits(rechargeForm.amount),
|
||||
remark: rechargeForm.remark,
|
||||
});
|
||||
setRechargeTarget(null);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
|
||||
} finally {
|
||||
setRecharging(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
@@ -191,28 +145,19 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
onConfirm={submitConfirmAction}
|
||||
/>
|
||||
) : null}
|
||||
{rechargeTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost">取消</Button>
|
||||
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setRechargeTarget(null)}
|
||||
open
|
||||
size="md"
|
||||
title="企业人工充值"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
||||
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
||||
</div>
|
||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
<ManualRechargeDialog
|
||||
initialTargetId={rechargeTarget?.id}
|
||||
lockTarget
|
||||
onClose={() => setRechargeTarget(null)}
|
||||
onCompleted={loadData}
|
||||
open={Boolean(rechargeTarget)}
|
||||
targets={rechargeTarget ? [{
|
||||
id: rechargeTarget.id,
|
||||
name: rechargeTarget.name,
|
||||
code: rechargeTarget.code,
|
||||
balanceCents: rechargeTarget.account?.balanceCents ?? 0,
|
||||
}] : []}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
manual_requeueing: 'info',
|
||||
awaiting_ack: 'info',
|
||||
delivered: 'success',
|
||||
failed: 'danger',
|
||||
@@ -14,6 +15,7 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
manual_requeueing: '人工重投处理中',
|
||||
awaiting_ack: '等待客户端确认',
|
||||
delivered: '客户端已确认',
|
||||
failed: '投递失败',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { displayFileName } from '@/utils/fileName';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -563,7 +563,7 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
export function AdminEnterpriseSignaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||
@@ -672,11 +672,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
if (deleteTarget.kind === 'signature') {
|
||||
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
|
||||
} else {
|
||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||
}
|
||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
@@ -708,7 +704,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
|
||||
type TemplateFormState = {
|
||||
@@ -99,6 +99,8 @@ function TemplateFormModal({
|
||||
category: item?.category ?? '行业通知',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const initialForm = useRef(form).current;
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => (
|
||||
signature.tenantId === form.tenantId
|
||||
@@ -146,9 +148,10 @@ function TemplateFormModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
dirty={dirty}
|
||||
footer={({ requestClose }) => (
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -277,7 +280,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
export function AdminEnterpriseTemplatesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
@@ -363,15 +365,6 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
<div className="page-heading">
|
||||
@@ -442,7 +435,7 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
<div className="admin-enterprise-template-row__actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost">预览</Button>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger">删除</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -475,13 +468,6 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
/>
|
||||
) : null}
|
||||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type ManualRechargeForm = {
|
||||
tenantId: string;
|
||||
amount: string;
|
||||
remark: string;
|
||||
};
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
@@ -26,27 +20,26 @@ function RemarkCell({ value }: { value?: string }) {
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [manualError, setManualError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [nextTenants, nextRecords] = await Promise.all([
|
||||
const [nextTenants, nextAccounts, nextRecords] = await Promise.all([
|
||||
adminApi.listTenants(),
|
||||
adminApi.listAccounts(),
|
||||
adminApi.listManualRecharges(),
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setAccounts(nextAccounts);
|
||||
setRecords(nextRecords);
|
||||
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||
setRecords([]);
|
||||
@@ -84,35 +77,6 @@ export function AdminRechargeRecordsPage() {
|
||||
setDateRange({});
|
||||
}
|
||||
|
||||
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
setManualError('');
|
||||
}
|
||||
|
||||
async function submitManualRecharge() {
|
||||
const amount = Number(form.amount);
|
||||
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
|
||||
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setManualError('');
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: form.tenantId,
|
||||
amountCents: yuanToMoneyUnits(form.amount),
|
||||
remark: form.remark,
|
||||
});
|
||||
await loadData();
|
||||
setManualOpen(false);
|
||||
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
|
||||
} catch (failure) {
|
||||
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-recharge-page">
|
||||
<div className="page-heading">
|
||||
@@ -181,33 +145,18 @@ export function AdminRechargeRecordsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{manualOpen ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setManualOpen(false)}
|
||||
open
|
||||
size="md"
|
||||
title="企业人工充值"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Select
|
||||
label="企业名称"
|
||||
onChange={(event) => updateForm('tenantId', event.target.value)}
|
||||
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
{manualError ? <p className="form-error">{manualError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
<ManualRechargeDialog
|
||||
initialTargetId={tenants.find((tenant) => tenant.status !== 'deleted')?.id}
|
||||
onClose={() => setManualOpen(false)}
|
||||
onCompleted={loadData}
|
||||
open={manualOpen}
|
||||
targets={tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
code: tenant.code,
|
||||
balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
|
||||
@@ -17,38 +17,68 @@ export function AdminReportMaterialsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preflightBusy, setPreflightBusy] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
|
||||
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
|
||||
const [operationKey, setOperationKey] = useState('');
|
||||
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
||||
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
|
||||
.then(async ([pendingItems, batchItems]) => {
|
||||
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
|
||||
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
|
||||
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
|
||||
setPoolEligibility(eligibilityMap);
|
||||
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [reportType]);
|
||||
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
||||
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
|
||||
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
|
||||
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
|
||||
|
||||
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
|
||||
async function beginCreateBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
|
||||
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
|
||||
setOperationKey(`report-batch:${crypto.randomUUID()}`);
|
||||
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
|
||||
finally { setPreflightBusy(false); }
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
||||
setBusy(true); setError(''); setMessage('');
|
||||
try {
|
||||
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
|
||||
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
|
||||
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
|
||||
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <section className="page-stack report-material-page">
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;人工勾选后一次生成所有关联通道的任务和 XLSX 文件。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size})`}</Button></div></div>
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;生成前会重新检查应用、路由、通道字段、资料版本和重复批次。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size})`}</Button></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
||||
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本</span><span>变更时间</span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本 / 资格</span><span>变更时间</span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join(';')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
||||
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
||||
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}>关闭</Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">取消</Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
|
||||
<div className="report-batch-preflight">{preflightBusy ? <p role="status">正在核对资料版本、应用路由、通道字段与历史批次…</p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} />可生成 {preflight.eligibleTargetCount} 个资料通道组合</span><span><AlertTriangle size={17} />跳过 {preflight.skippedTargetCount} 个组合</span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join(';')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join(';')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>报备批次 {batchResult.batchNo} 已处理</strong><span>成功 {batchResult.result.successCount} · 跳过 {batchResult.result.skippedCount} · 失败 {batchResult.result.failedCount}</span><span>操作单号:{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
|
||||
</Modal>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function toBatchItem(item: ReportMaterialPendingItem) {
|
||||
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
@@ -84,10 +84,6 @@ export function AdminSignatureAuditPage() {
|
||||
|
||||
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
||||
|
||||
async function approve(item: ClientSmsSignature) {
|
||||
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!rejectTarget || !reason.trim()) return;
|
||||
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
|
||||
@@ -99,7 +95,7 @@ export function AdminSignatureAuditPage() {
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><Button disabled={!canReviewSignature(record)} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success">通过</Button><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={loadData} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
], []);
|
||||
|
||||
return <section className="page-stack admin-template-audit-page">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -99,7 +99,7 @@ export function AdminSystemLogsPage() {
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -29,10 +29,8 @@ export function AdminTemplateAuditPage() {
|
||||
.catch(() => setAudits([]));
|
||||
}, [keyword, status]);
|
||||
|
||||
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveTemplate(id)
|
||||
: await adminApi.rejectTemplate(id);
|
||||
async function rejectTemplate(id: string) {
|
||||
const updated = await adminApi.rejectTemplate(id);
|
||||
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
||||
}
|
||||
|
||||
@@ -58,19 +56,11 @@ export function AdminTemplateAuditPage() {
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => void reviewTemplate(record.id, 'approved')}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
|
||||
<Button
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => void reviewTemplate(record.id, 'rejected')}
|
||||
onClick={() => void rejectTemplate(record.id)}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
@@ -80,7 +70,7 @@ export function AdminTemplateAuditPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[keyword, status],
|
||||
);
|
||||
const templateAudits = audits;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function generateInitialPassword() {
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const session = readSession();
|
||||
const session = readSession('admin');
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
Reference in New Issue
Block a user