feat: strengthen risk controls and review workflows
This commit is contained in:
+80
-8
@@ -988,6 +988,36 @@ export type RiskReviewTask = {
|
||||
_count?: { messageRecords: number };
|
||||
};
|
||||
|
||||
export type RiskRuleItem = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
code: 'MAX_PHONES_PER_TASK' | 'NON_WORKING_MARKETING_BULK' | 'TASK_CREATE_FREQUENCY';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
metric: string;
|
||||
thresholdValue: number;
|
||||
action: 'block' | 'manual_review';
|
||||
status: 'active' | 'inactive';
|
||||
priority: number;
|
||||
config?: { startTime?: string; endTime?: string; timeZone?: string } | null;
|
||||
application?: { id: string; name: string; tenantId: string; tenant?: { id: string; name: string } } | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type RiskTaskMessagePage = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
province?: string | null;
|
||||
carrier?: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type TenantAccount = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -1144,10 +1174,13 @@ export type EnterpriseApplication = {
|
||||
name: string;
|
||||
scene?: string | null;
|
||||
status: string;
|
||||
disablingAt?: string | null;
|
||||
autoDisableAt?: string | null;
|
||||
disableReason?: string | null;
|
||||
deactivation?: ApplicationDeactivationPreview | null;
|
||||
dailyLimit?: number | null;
|
||||
customerUnitPrice?: number | null;
|
||||
queuePriority?: 'normal' | 'priority' | string | null;
|
||||
maxPhonesPerTask?: number | null;
|
||||
templateMismatchMode?: string | null;
|
||||
downstreamReceiptRetryEnabled?: boolean | null;
|
||||
downstreamUplinkRetryEnabled?: boolean | null;
|
||||
@@ -1284,6 +1317,20 @@ export type DownstreamDeliveryRecord = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ApplicationDeactivationPreview = {
|
||||
status: string;
|
||||
reason?: string | null;
|
||||
disablingAt?: string | null;
|
||||
autoDisableAt?: string | null;
|
||||
awaitingSupplierReceipt: number;
|
||||
waitingToSend: number;
|
||||
awaitingClientAck: number;
|
||||
retryableFailures: number;
|
||||
pendingUplinks: number;
|
||||
activeConnections: number;
|
||||
totalOutstanding: number;
|
||||
};
|
||||
|
||||
export type BatchRequeueResponse = {
|
||||
total: number;
|
||||
successCount: number;
|
||||
@@ -1463,7 +1510,8 @@ export const adminApi = {
|
||||
changeTenantStatus: (id: string, status: string) =>
|
||||
request<TenantOption>(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
deleteTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`, { method: 'DELETE' }),
|
||||
listUsers: (query: { tenantId?: string; roleCode?: string } = {}) => request<ManagedUser[]>(withQuery('/admin/users', query)),
|
||||
listUsers: (query: { tenantId?: string; roleCode?: string; displayName?: string; login?: string; status?: string } = {}) =>
|
||||
request<ManagedUser[]>(withQuery('/admin/users', query)),
|
||||
createUser: (body: UserPayload) => request<ManagedUser>('/admin/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateUser: (id: string, body: Omit<UserPayload, 'password'>) => request<ManagedUser>(`/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeUserStatus: (id: string, status: string, operatorId?: string) =>
|
||||
@@ -1491,14 +1539,16 @@ export const adminApi = {
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||
getApplicationDeactivationPreview: (id: string) =>
|
||||
request<ApplicationDeactivationPreview>(`/admin/enterprise-applications/${id}/deactivation-preview`),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string, force = false) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status, reason }),
|
||||
body: JSON.stringify({ status, reason, force }),
|
||||
}),
|
||||
listApplicationConnections: (applicationId: string) =>
|
||||
request<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
|
||||
@@ -1706,6 +1756,26 @@ export const adminApi = {
|
||||
batchRequeueDownstreamDeliveries: (ids: string[]) =>
|
||||
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }),
|
||||
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
|
||||
listRiskRules: (applicationId?: string) =>
|
||||
request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })),
|
||||
createRiskRule: (body: {
|
||||
applicationId?: string;
|
||||
code: RiskRuleItem['code'];
|
||||
thresholdValue: number;
|
||||
action: RiskRuleItem['action'];
|
||||
status: RiskRuleItem['status'];
|
||||
priority?: number;
|
||||
config?: RiskRuleItem['config'];
|
||||
}) => request<RiskRuleItem>('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateRiskRule: (id: string, body: {
|
||||
thresholdValue?: number;
|
||||
action?: RiskRuleItem['action'];
|
||||
status?: RiskRuleItem['status'];
|
||||
priority?: number;
|
||||
config?: RiskRuleItem['config'];
|
||||
}) => request<RiskRuleItem>(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<RiskTaskMessagePage>(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)),
|
||||
approveRiskReviewTask: (id: string, reason?: string) =>
|
||||
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }),
|
||||
rejectRiskReviewTask: (id: string, reason?: string) =>
|
||||
@@ -1778,8 +1848,10 @@ export const clientApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listUsers: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser[]>('/client/users', { tenantId }),
|
||||
listUsers: (
|
||||
query: { displayName?: string; login?: string; status?: string } = {},
|
||||
tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID,
|
||||
) => request<ManagedUser[]>(withQuery('/client/users', query), { tenantId }),
|
||||
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type ApplicationCmppParams, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
|
||||
import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
@@ -14,7 +14,9 @@ type SmsApp = {
|
||||
name: string;
|
||||
enterprise: string;
|
||||
appId: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
deactivation?: ApplicationDeactivationPreview | null;
|
||||
sentToday: number;
|
||||
deliveryRate: number;
|
||||
unitPrice: number;
|
||||
@@ -51,8 +53,22 @@ type CmppConnection = {
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
function enabledTag(enabled: boolean) {
|
||||
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
|
||||
function applicationStatusTag(app: SmsApp) {
|
||||
if (app.status === 'disabling') {
|
||||
const detail = app.deactivation;
|
||||
const title = [
|
||||
detail?.reason || '等待未完成回执清算',
|
||||
`等待供应商回执:${detail?.awaitingSupplierReceipt ?? 0}条`,
|
||||
`等待推送:${detail?.waitingToSend ?? 0}条`,
|
||||
`等待客户端确认:${detail?.awaitingClientAck ?? 0}条`,
|
||||
`可重试失败:${detail?.retryableFailures ?? 0}条`,
|
||||
`待推送上行:${detail?.pendingUplinks ?? 0}条`,
|
||||
`进入停用中:${formatDateTime(detail?.disablingAt)}`,
|
||||
`自动停用时间:${formatDateTime(detail?.autoDisableAt)}`,
|
||||
].join('\n');
|
||||
return <span aria-label={title} className="application-status-detail" tabIndex={0} title={title}><Tag tone="warning">停用中</Tag></span>;
|
||||
}
|
||||
return <Tag tone={app.status === 'active' ? 'success' : 'neutral'}>{app.status === 'active' ? '启用' : '停用'}</Tag>;
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||||
@@ -73,6 +89,51 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
|
||||
);
|
||||
}
|
||||
|
||||
function DeactivateApplicationModal({
|
||||
app,
|
||||
preview,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
preview: ApplicationDeactivationPreview;
|
||||
onCancel: () => void;
|
||||
onConfirm: (mode: 'wait' | 'force') => void;
|
||||
}) {
|
||||
const hasOutstanding = preview.totalOutstanding > 0;
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
{hasOutstanding ? <Button onClick={() => onConfirm('force')} variant="danger">强制停用并断开连接</Button> : null}
|
||||
<Button onClick={() => onConfirm(hasOutstanding ? 'wait' : 'force')} variant={hasOutstanding ? 'warning' : 'primary'}>
|
||||
{hasOutstanding ? '等待回执后停用' : '确认停用'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title={`停用应用“${app.name}”`}
|
||||
>
|
||||
{hasOutstanding ? (
|
||||
<div className="section-stack">
|
||||
<p className="admin-confirm-text">该应用还有 {preview.totalOutstanding} 项回执或上行投递义务尚未清算。</p>
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>等待供应商回执</span><strong>{preview.awaitingSupplierReceipt}</strong></div>
|
||||
<div><span>等待推送</span><strong>{preview.waitingToSend}</strong></div>
|
||||
<div><span>等待客户端确认</span><strong>{preview.awaitingClientAck}</strong></div>
|
||||
<div><span>可重试失败</span><strong>{preview.retryableFailures}</strong></div>
|
||||
<div><span>待推送上行</span><strong>{preview.pendingUplinks}</strong></div>
|
||||
<div><span>当前CMPP连接</span><strong>{preview.activeConnections}</strong></div>
|
||||
</div>
|
||||
<p className="form-hint">“等待回执后停用”会立即停止接收新短信,清算完成后自动停用;最长等待72小时。“强制停用”会立即断开全部连接并放弃剩余投递。</p>
|
||||
</div>
|
||||
) : <p className="admin-confirm-text">该应用没有待清算数据,将立即停用并断开全部 CMPP 客户端连接。</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApplicationModal({
|
||||
tenants,
|
||||
loading,
|
||||
@@ -272,10 +333,11 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { action: 'toggle'; id: string; name: string; enabled: boolean }
|
||||
| { action: 'enable'; id: string; name: string }
|
||||
| { action: 'delete'; id: string; name: string }
|
||||
| null
|
||||
>(null);
|
||||
const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null);
|
||||
|
||||
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) {
|
||||
try {
|
||||
@@ -312,11 +374,33 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmToggle(id: string) {
|
||||
const app = smsApps.find((item) => item.id === id);
|
||||
if (app) {
|
||||
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
|
||||
async function confirmEnable(id: string) {
|
||||
await adminApi.changeApplicationStatus(id, 'active', '运营端恢复启用企业应用');
|
||||
await loadSmsApps();
|
||||
}
|
||||
|
||||
async function openDeactivate(app: SmsApp) {
|
||||
try {
|
||||
setDeactivateAction({ app, preview: await adminApi.getApplicationDeactivationPreview(app.id) });
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '停用影响检查失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeactivate(mode: 'wait' | 'force') {
|
||||
if (!deactivateAction) return;
|
||||
try {
|
||||
await adminApi.changeApplicationStatus(
|
||||
deactivateAction.app.id,
|
||||
mode === 'wait' ? 'disabling' : 'disabled',
|
||||
mode === 'wait' ? '运营端选择等待回执后停用' : '运营端选择强制停用并放弃剩余回执',
|
||||
mode === 'force',
|
||||
);
|
||||
setDeactivateAction(null);
|
||||
await loadSmsApps();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业应用停用失败');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +413,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
if (confirmAction.action === 'toggle') {
|
||||
await confirmToggle(confirmAction.id);
|
||||
if (confirmAction.action === 'enable') {
|
||||
await confirmEnable(confirmAction.id);
|
||||
} else {
|
||||
await confirmDelete(confirmAction.id);
|
||||
}
|
||||
@@ -360,7 +444,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const filteredSmsApps = useMemo(
|
||||
() => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword))
|
||||
&& (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword))
|
||||
&& (appliedStatus === 'all' || (appliedStatus === 'active' ? item.enabled : !item.enabled))),
|
||||
&& (appliedStatus === 'all' || item.status === appliedStatus)),
|
||||
[appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps],
|
||||
);
|
||||
|
||||
@@ -390,7 +474,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) },
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: (record) => applicationStatusTag(record) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
@@ -399,8 +483,8 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
render: (record) => (
|
||||
<div className="table-actions enterprise-app-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
<Button onClick={() => { if (record.status === 'active') void openDeactivate(record); else setConfirmAction({ action: 'enable', id: record.id, name: record.name }); }} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{record.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
@@ -436,7 +520,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用中', value: 'disabling' }, { label: '停用', value: 'disabled' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-split-filter__actions">
|
||||
@@ -461,11 +545,19 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
danger={confirmAction.action === 'delete'}
|
||||
message={confirmAction.action === 'delete'
|
||||
? `确认删除应用“${confirmAction.name}”吗?`
|
||||
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
|
||||
: `确认启用应用“${confirmAction.name}”吗?`}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
onConfirm={() => { void runConfirmedAction(); }}
|
||||
/>
|
||||
) : null}
|
||||
{deactivateAction ? (
|
||||
<DeactivateApplicationModal
|
||||
app={deactivateAction.app}
|
||||
onCancel={() => setDeactivateAction(null)}
|
||||
onConfirm={(mode) => { void confirmDeactivate(mode); }}
|
||||
preview={deactivateAction.preview}
|
||||
/>
|
||||
) : null}
|
||||
{addModalOpen ? (
|
||||
<AddApplicationModal
|
||||
loading={tenantsLoading}
|
||||
@@ -496,7 +588,9 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||
name: application.name,
|
||||
enterprise: application.tenant?.name ?? application.tenantId,
|
||||
appId: application.id,
|
||||
status: application.status,
|
||||
enabled: application.status === 'active',
|
||||
deactivation: application.deactivation,
|
||||
sentToday: application.sentToday ?? 0,
|
||||
deliveryRate: application.deliveryRate ?? 0,
|
||||
unitPrice: moneyUnitsToYuan(application.customerUnitPrice),
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pencil, Plus, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type EnterpriseApplication,
|
||||
type RiskRuleItem,
|
||||
} from '@/api/adminApi';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
const definitions: Array<{ code: RiskRuleItem['code']; label: string; unit: string }> = [
|
||||
{ code: 'MAX_PHONES_PER_TASK', label: '单任务最大号码数', unit: '个号码' },
|
||||
{ code: 'NON_WORKING_MARKETING_BULK', label: '非工作时间大批量营销发送', unit: '个号码' },
|
||||
{ code: 'TASK_CREATE_FREQUENCY', label: '10分钟客户端任务创建频控', unit: '个任务' },
|
||||
];
|
||||
|
||||
type EditorState = {
|
||||
id?: string;
|
||||
applicationId: string;
|
||||
code: RiskRuleItem['code'];
|
||||
thresholdValue: string;
|
||||
action: RiskRuleItem['action'];
|
||||
status: RiskRuleItem['status'];
|
||||
priority: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
function editorFromRule(rule?: RiskRuleItem): EditorState {
|
||||
return {
|
||||
id: rule?.id,
|
||||
applicationId: rule?.applicationId ?? '',
|
||||
code: rule?.code ?? 'MAX_PHONES_PER_TASK',
|
||||
thresholdValue: String(rule?.thresholdValue ?? 100000),
|
||||
action: rule?.action ?? 'block',
|
||||
status: rule?.status ?? 'active',
|
||||
priority: String(rule?.priority ?? 100),
|
||||
startTime: rule?.config?.startTime ?? '21:00',
|
||||
endTime: rule?.config?.endTime ?? '08:00',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminRiskRulesPage() {
|
||||
const [rules, setRules] = useState<RiskRuleItem[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [editor, setEditor] = useState<EditorState | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
adminApi.listRiskRules(applicationId || undefined),
|
||||
applications.length === 0 ? adminApi.listEnterpriseApplications() : Promise.resolve(applications),
|
||||
]).then(([nextRules, nextApplications]) => {
|
||||
setRules(nextRules);
|
||||
setApplications(nextApplications);
|
||||
setError('');
|
||||
}).catch((failure: Error) => setError(failure.message || '风控规则加载失败'));
|
||||
}
|
||||
|
||||
useEffect(load, [applicationId]);
|
||||
|
||||
const existingCodes = useMemo(
|
||||
() => new Set(rules.filter((rule) => rule.applicationId === editor?.applicationId).map((rule) => rule.code)),
|
||||
[editor?.applicationId, rules],
|
||||
);
|
||||
|
||||
async function save() {
|
||||
if (!editor) return;
|
||||
if (!editor.id && !editor.applicationId) {
|
||||
setError('请选择企业应用');
|
||||
return;
|
||||
}
|
||||
const thresholdValue = Number(editor.thresholdValue);
|
||||
if (!Number.isFinite(thresholdValue) || thresholdValue < 0) {
|
||||
setError('阈值必须是大于等于0的数字');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
const body = {
|
||||
thresholdValue,
|
||||
action: editor.action,
|
||||
status: editor.status,
|
||||
priority: Number(editor.priority) || 100,
|
||||
config: editor.code === 'NON_WORKING_MARKETING_BULK'
|
||||
? { startTime: editor.startTime, endTime: editor.endTime, timeZone: 'Asia/Shanghai' }
|
||||
: undefined,
|
||||
};
|
||||
try {
|
||||
if (editor.id) {
|
||||
await adminApi.updateRiskRule(editor.id, body);
|
||||
} else {
|
||||
await adminApi.createRiskRule({
|
||||
...body,
|
||||
applicationId: editor.applicationId || undefined,
|
||||
code: editor.code,
|
||||
});
|
||||
}
|
||||
setEditor(null);
|
||||
load();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '风控规则保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<RiskRuleItem>> = [
|
||||
{ key: 'name', title: '规则名称', render: (rule) => <div><strong>{rule.name}</strong><small className="table-subline">{rule.description}</small></div> },
|
||||
{ key: 'scope', title: '适用范围', render: (rule) => rule.application ? <div><strong>{rule.application.name}</strong><small className="table-subline">{rule.application.tenant?.name ?? '-'}</small></div> : <Tag tone="info">全局默认</Tag> },
|
||||
{ key: 'threshold', title: '阈值', width: '150px', render: (rule) => `${rule.thresholdValue.toLocaleString('zh-CN')} ${definitions.find((item) => item.code === rule.code)?.unit ?? ''}` },
|
||||
{ key: 'time', title: '生效时间', width: '180px', render: (rule) => {
|
||||
if (rule.code !== 'NON_WORKING_MARKETING_BULK') return '-';
|
||||
const start = rule.config?.startTime ?? '21:00';
|
||||
const end = rule.config?.endTime ?? '08:00';
|
||||
return `${start}–${start > end ? '次日' : ''}${end}`;
|
||||
} },
|
||||
{ key: 'action', title: '处理动作', width: '120px', render: (rule) => <Tag tone={rule.action === 'block' ? 'danger' : 'warning'}>{rule.action === 'block' ? '直接拒绝' : '人工审核'}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '100px', render: (rule) => <Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag> },
|
||||
{ key: 'priority', title: '优先级', width: '90px', render: (rule) => rule.priority },
|
||||
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (rule) => <Button icon={<Pencil size={15} />} onClick={() => setEditor(editorFromRule(rule))} size="sm" variant="ghost">编辑</Button> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['风控管理', '风控规则']} /><h1>风控规则</h1><p>维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。</p></div>
|
||||
<div className="page-heading__actions">
|
||||
<Button icon={<RefreshCw size={16} />} onClick={load} variant="ghost">刷新</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setEditor(editorFromRule())}>新增应用覆盖</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="surface sms-audit-filter">
|
||||
<Select
|
||||
label="查看范围"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部全局规则', value: '' },
|
||||
...applications.map((application) => ({
|
||||
label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`,
|
||||
value: application.id,
|
||||
})),
|
||||
]}
|
||||
value={applicationId}
|
||||
/>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={rules} emptyText="暂无风控规则" rowKey="id" /></div>
|
||||
{editor ? <Modal
|
||||
footer={<><Button disabled={saving} onClick={() => setEditor(null)} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中…' : '保存'}</Button></>}
|
||||
onClose={() => setEditor(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={editor.id ? '编辑风控规则' : '新增企业应用级覆盖'}
|
||||
>
|
||||
<div className="form-grid">
|
||||
{!editor.id ? <Select
|
||||
label="企业应用"
|
||||
onChange={(event) => setEditor({ ...editor, applicationId: event.target.value })}
|
||||
options={[{ label: '请选择企业应用', value: '' }, ...applications.map((application) => ({ label: `${application.tenant?.name ?? '未命名企业'} · ${application.name}`, value: application.id }))]}
|
||||
value={editor.applicationId}
|
||||
/> : null}
|
||||
{!editor.id ? <Select
|
||||
label="规则"
|
||||
onChange={(event) => {
|
||||
const code = event.target.value as RiskRuleItem['code'];
|
||||
const globalRule = rules.find((rule) => !rule.applicationId && rule.code === code);
|
||||
setEditor({ ...editor, code, thresholdValue: String(globalRule?.thresholdValue ?? editor.thresholdValue), action: globalRule?.action ?? editor.action });
|
||||
}}
|
||||
options={definitions.filter((item) => !existingCodes.has(item.code) || item.code === editor.code).map((item) => ({ label: item.label, value: item.code }))}
|
||||
value={editor.code}
|
||||
/> : <Input disabled label="规则" value={definitions.find((item) => item.code === editor.code)?.label ?? editor.code} />}
|
||||
<Input label={`阈值(${definitions.find((item) => item.code === editor.code)?.unit ?? ''})`} min="0" onChange={(event) => setEditor({ ...editor, thresholdValue: event.target.value })} type="number" value={editor.thresholdValue} />
|
||||
<Select label="处理动作" onChange={(event) => setEditor({ ...editor, action: event.target.value as RiskRuleItem['action'] })} options={[{ label: '直接拒绝', value: 'block' }, { label: '进入人工审核', value: 'manual_review' }]} value={editor.action} />
|
||||
<Select label="状态" onChange={(event) => setEditor({ ...editor, status: event.target.value as RiskRuleItem['status'] })} options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'inactive' }]} value={editor.status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setEditor({ ...editor, priority: event.target.value })} type="number" value={editor.priority} />
|
||||
{editor.code === 'NON_WORKING_MARKETING_BULK' ? <>
|
||||
<Input label="非工作时间开始" onChange={(event) => setEditor({ ...editor, startTime: event.target.value })} type="time" value={editor.startTime} />
|
||||
<Input label="非工作时间结束" onChange={(event) => setEditor({ ...editor, endTime: event.target.value })} type="time" value={editor.endTime} />
|
||||
</> : null}
|
||||
</div>
|
||||
</Modal> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
||||
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
|
||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
|
||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||
@@ -82,7 +81,6 @@ export function AdminSmsApplicationFormPage() {
|
||||
setInterfaceEnabled(true);
|
||||
setInterfaceType('cmpp20');
|
||||
setCmppMaxConnections('1');
|
||||
setPhoneDailyLimit('10000');
|
||||
setMismatchPolicy('manual_review');
|
||||
setDownstreamReceiptRetryEnabled(true);
|
||||
setDownstreamUplinkRetryEnabled(true);
|
||||
@@ -156,7 +154,6 @@ export function AdminSmsApplicationFormPage() {
|
||||
setInterfaceEnabled(application.interfaceEnabled !== false);
|
||||
setInterfaceType('cmpp20');
|
||||
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
|
||||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||||
setDownstreamReceiptRetryEnabled(application.downstreamReceiptRetryEnabled !== false);
|
||||
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
|
||||
@@ -223,7 +220,6 @@ export function AdminSmsApplicationFormPage() {
|
||||
interfaceEnabled,
|
||||
interfaceType,
|
||||
cmppMaxConnections: Number(cmppMaxConnections) || 1,
|
||||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||||
templateMismatchMode: mismatchPolicy,
|
||||
downstreamReceiptRetryEnabled,
|
||||
downstreamUplinkRetryEnabled,
|
||||
@@ -305,7 +301,6 @@ export function AdminSmsApplicationFormPage() {
|
||||
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} />
|
||||
<Select
|
||||
label="不符合模板的短信"
|
||||
onChange={(event) => setMismatchPolicy(event.target.value)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Check, Info, Search, X } from 'lucide-react';
|
||||
import { adminApi, type RiskReviewTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending_review: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
approved: '人工通过',
|
||||
rejected: '人工驳回',
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'warning' | 'success' | 'danger'> = {
|
||||
@@ -20,6 +20,20 @@ function sourceLabel(sourceType?: string) {
|
||||
return sourceType === 'cmpp_template_mismatch' ? 'CMPP模板不匹配聚合' : '风控审核';
|
||||
}
|
||||
|
||||
function messageStatusLabel(status: string) {
|
||||
return {
|
||||
pending_review: '待人工审核',
|
||||
queued: '已入队',
|
||||
scheduled: '等待定时发送',
|
||||
submitted: '供应商已受理',
|
||||
delivered: '送达成功',
|
||||
submit_failed: '提交失败',
|
||||
failed: '回执失败',
|
||||
rejected: '已拒绝',
|
||||
timeout: '超时',
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
export function AdminSmsAuditPage() {
|
||||
const [records, setRecords] = useState<RiskReviewTask[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
@@ -31,6 +45,11 @@ export function AdminSmsAuditPage() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detailTarget, setDetailTarget] = useState<RiskReviewTask | null>(null);
|
||||
const [phoneTarget, setPhoneTarget] = useState<RiskReviewTask | null>(null);
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [phonePage, setPhonePage] = useState(1);
|
||||
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||
const [phoneData, setPhoneData] = useState<RiskTaskMessagePage>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
|
||||
function refreshAuditCount() {
|
||||
window.dispatchEvent(new Event('cmpp-audit-count-refresh'));
|
||||
@@ -53,12 +72,24 @@ export function AdminSmsAuditPage() {
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
|
||||
const matchesDate = !date || record.createdAt.startsWith(date);
|
||||
const relevantDate = record.status === 'pending_review' ? record.createdAt : record.reviewedAt ?? record.createdAt;
|
||||
const matchesDate = !date || relevantDate.startsWith(date);
|
||||
return matchesKeyword && matchesDate;
|
||||
}),
|
||||
[date, keyword, records],
|
||||
);
|
||||
|
||||
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
|
||||
if (!target) return;
|
||||
adminApi.listRiskReviewTaskMessages(target.id, { phone: phoneKeyword || undefined, page, pageSize })
|
||||
.then(setPhoneData)
|
||||
.catch((failure: Error) => setError(failure.message || '审核号码列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (phoneTarget) loadPhones(phoneTarget, phonePage, phonePageSize);
|
||||
}, [phoneTarget, phonePage, phonePageSize]);
|
||||
|
||||
async function approveRecord(record: RiskReviewTask) {
|
||||
await adminApi.approveRiskReviewTask(record.id, '运营审核通过');
|
||||
setApproveTarget(null);
|
||||
@@ -104,7 +135,7 @@ export function AdminSmsAuditPage() {
|
||||
},
|
||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
|
||||
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表</button> },
|
||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join(';') ?? '-' },
|
||||
{
|
||||
@@ -143,13 +174,13 @@ export function AdminSmsAuditPage() {
|
||||
options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending_review' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
{ label: '人工通过', value: 'approved' },
|
||||
{ label: '人工驳回', value: 'rejected' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
|
||||
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<Input label={status === 'pending_review' ? '提交日期' : '审核日期'} onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost">重置</Button>
|
||||
@@ -191,6 +222,37 @@ export function AdminSmsAuditPage() {
|
||||
</div>
|
||||
</Modal> : null}
|
||||
|
||||
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · ${phoneTarget.taskNo}`}>
|
||||
<div className="page-stack">
|
||||
<div className="audit-filter-grid">
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
|
||||
<Select label="每页条数" onChange={(event) => { setPhonePageSize(Number(event.target.value)); setPhonePage(1); }} options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }, { label: '50条/页', value: '50' }]} value={String(phonePageSize)} />
|
||||
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}>查询</Button></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier || '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'pending_review' ? 'warning' : item.status === 'failed' || item.status === 'submit_failed' ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={phoneData.items}
|
||||
emptyText="暂无号码记录"
|
||||
rowKey="id"
|
||||
/>
|
||||
<Pagination
|
||||
nextDisabled={phonePage * phonePageSize >= phoneData.total}
|
||||
onNext={() => setPhonePage((current) => current + 1)}
|
||||
onPageChange={setPhonePage}
|
||||
onPrevious={() => setPhonePage((current) => Math.max(1, current - 1))}
|
||||
page={phonePage}
|
||||
previousDisabled={phonePage <= 1}
|
||||
total={phoneData.total}
|
||||
totalPages={Math.max(1, Math.ceil(phoneData.total / phonePageSize))}
|
||||
/>
|
||||
</div>
|
||||
</Modal> : null}
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
|
||||
@@ -21,6 +21,14 @@ type ConfirmAction = {
|
||||
user: ManagedUser;
|
||||
};
|
||||
|
||||
type UserFilters = {
|
||||
displayName: string;
|
||||
login: string;
|
||||
tenantId: string;
|
||||
roleCode: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const emptyForm: UserForm = {
|
||||
tenantId: '',
|
||||
displayName: '',
|
||||
@@ -37,6 +45,14 @@ const roleLabel: Record<string, string> = {
|
||||
enterprise_admin: '企业管理员',
|
||||
};
|
||||
|
||||
const emptyFilters: UserFilters = {
|
||||
displayName: '',
|
||||
login: '',
|
||||
tenantId: '',
|
||||
roleCode: '',
|
||||
status: '',
|
||||
};
|
||||
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
const roleCode = user?.roles[0]?.role.code === 'enterprise_admin' ? 'enterprise_admin' : 'platform_admin';
|
||||
return user ? {
|
||||
@@ -62,8 +78,8 @@ export function AdminUsersPage() {
|
||||
const session = readSession('admin');
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||||
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
|
||||
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
@@ -71,35 +87,63 @@ export function AdminUsersPage() {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showInitialPassword, setShowInitialPassword] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [confirmError, setConfirmError] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [querying, setQuerying] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
|
||||
setUsers(nextUsers);
|
||||
setTenants(nextTenants);
|
||||
async function loadUsers(query: UserFilters = appliedFilters) {
|
||||
setUsers(await adminApi.listUsers(query));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
void Promise.all([adminApi.listUsers(), adminApi.listTenants()])
|
||||
.then(([nextUsers, nextTenants]) => {
|
||||
setUsers(nextUsers);
|
||||
setTenants(nextTenants);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, []);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const value = appliedKeyword.trim().toLowerCase();
|
||||
return users.filter((user) => {
|
||||
const target = `${user.displayName} ${user.username} ${user.email ?? ''} ${user.phone ?? ''} ${user.tenant?.name ?? ''} ${roleLabel[user.roles[0]?.role.code] ?? ''}`.toLowerCase();
|
||||
return !value || target.includes(value);
|
||||
});
|
||||
}, [appliedKeyword, users]);
|
||||
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function queryUsers(nextFilters = filters) {
|
||||
const next = {
|
||||
...nextFilters,
|
||||
displayName: nextFilters.displayName.trim(),
|
||||
login: nextFilters.login.trim(),
|
||||
};
|
||||
setQuerying(true);
|
||||
setError('');
|
||||
try {
|
||||
await loadUsers(next);
|
||||
setAppliedFilters(next);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '查询用户失败');
|
||||
} finally {
|
||||
setQuerying(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirm(action: ConfirmAction) {
|
||||
setConfirmError('');
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
|
||||
setFormError('');
|
||||
setShowInitialPassword(false);
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
function openEdit(user: ManagedUser) {
|
||||
setForm(toForm(user));
|
||||
setFormError('');
|
||||
setEditingUser(user);
|
||||
}
|
||||
|
||||
@@ -115,11 +159,11 @@ export function AdminUsersPage() {
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6) || (form.roleCode === 'enterprise_admin' && !form.tenantId)) {
|
||||
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
|
||||
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setFormError('');
|
||||
const body: UserPayload = {
|
||||
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
|
||||
username: form.username || form.email || form.phone,
|
||||
@@ -138,9 +182,9 @@ export function AdminUsersPage() {
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
await load();
|
||||
await loadUsers();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||
setFormError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -148,13 +192,28 @@ export function AdminUsersPage() {
|
||||
|
||||
async function runConfirm() {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === 'delete') {
|
||||
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
|
||||
} else {
|
||||
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
|
||||
setConfirming(true);
|
||||
setConfirmError('');
|
||||
try {
|
||||
if (confirmAction.type === 'delete') {
|
||||
await adminApi.deleteUser(confirmAction.user.id, session?.user.id);
|
||||
} else {
|
||||
await adminApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id);
|
||||
}
|
||||
} catch (failure) {
|
||||
const detail = failure instanceof Error ? failure.message : '用户操作失败';
|
||||
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
setConfirmAction(null);
|
||||
await load();
|
||||
try {
|
||||
await loadUsers();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '用户列表刷新失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
@@ -180,10 +239,10 @@ export function AdminUsersPage() {
|
||||
<div className="admin-system-actions">
|
||||
<Button onClick={() => openEdit(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
<Button onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -199,16 +258,37 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar admin-user-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="admin-user-filter-grid">
|
||||
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
|
||||
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
|
||||
<Select
|
||||
label="所属企业"
|
||||
onChange={(event) => updateFilter('tenantId', event.target.value)}
|
||||
options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]}
|
||||
value={filters.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="用户角色"
|
||||
onChange={(event) => updateFilter('roleCode', event.target.value)}
|
||||
options={[{ label: '全部角色', value: '' }, { label: '平台管理员', value: 'platform_admin' }, { label: '企业管理员', value: 'enterprise_admin' }]}
|
||||
value={filters.roleCode}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => updateFilter('status', event.target.value)}
|
||||
options={[{ label: '全部状态', value: '' }, { label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={filters.status}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-system-toolbar__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost">重置</Button>
|
||||
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
|
||||
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm">新增用户</Button>
|
||||
</div>
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface admin-system-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{(creating || editingUser) ? (
|
||||
@@ -263,6 +343,7 @@ export function AdminUsersPage() {
|
||||
<label><input checked={form.status === 'disabled'} onChange={() => updateField('status', 'disabled')} type="radio" />禁用</label>
|
||||
</div>
|
||||
</div>
|
||||
{formError ? <p className="form-error admin-app-form-row--wide" role="alert">{formError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
@@ -276,8 +357,9 @@ export function AdminUsersPage() {
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="ghost">取消</Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
.client-user-filter {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.client-user-filter__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.client-user-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.client-user-filter__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.client-user-filter__actions .ui-button {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.client-users-table-card .client-user-actions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -20,6 +20,12 @@ type ConfirmAction = {
|
||||
user: ManagedUser;
|
||||
};
|
||||
|
||||
type UserFilters = {
|
||||
displayName: string;
|
||||
login: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const emptyForm: UserForm = {
|
||||
displayName: '',
|
||||
username: '',
|
||||
@@ -29,6 +35,12 @@ const emptyForm: UserForm = {
|
||||
password: '',
|
||||
};
|
||||
|
||||
const emptyFilters: UserFilters = {
|
||||
displayName: '',
|
||||
login: '',
|
||||
status: '',
|
||||
};
|
||||
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
return user ? {
|
||||
displayName: user.displayName,
|
||||
@@ -44,35 +56,63 @@ export function ClientUsersPage() {
|
||||
const session = readSession('client');
|
||||
const tenantId = session?.user.tenantId ?? undefined;
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
|
||||
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [confirmError, setConfirmError] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [querying, setQuerying] = useState(false);
|
||||
|
||||
async function load() {
|
||||
async function loadUsers(query: UserFilters = appliedFilters) {
|
||||
if (!tenantId) return;
|
||||
setUsers(await clientApi.listUsers(tenantId));
|
||||
setUsers(await clientApi.listUsers(query, tenantId));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
if (!tenantId) return;
|
||||
void clientApi.listUsers({}, tenantId)
|
||||
.then(setUsers)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, [tenantId]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const value = keyword.trim().toLowerCase();
|
||||
return users.filter((item) => {
|
||||
const target = `${item.displayName} ${item.email ?? ''} ${item.phone ?? ''}`.toLowerCase();
|
||||
return !value || target.includes(value);
|
||||
});
|
||||
}, [keyword, users]);
|
||||
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function queryUsers(nextFilters = filters) {
|
||||
const next = {
|
||||
...nextFilters,
|
||||
displayName: nextFilters.displayName.trim(),
|
||||
login: nextFilters.login.trim(),
|
||||
};
|
||||
setQuerying(true);
|
||||
setError('');
|
||||
try {
|
||||
await loadUsers(next);
|
||||
setAppliedFilters(next);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '查询用户失败');
|
||||
} finally {
|
||||
setQuerying(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirm(action: ConfirmAction) {
|
||||
setConfirmError('');
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
function openEditor(user?: ManagedUser) {
|
||||
setForm(toForm(user));
|
||||
setFormError('');
|
||||
setEditingUser(user ?? null);
|
||||
setCreating(!user);
|
||||
}
|
||||
@@ -83,10 +123,11 @@ export function ClientUsersPage() {
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
|
||||
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
|
||||
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError('');
|
||||
const body: UserPayload = {
|
||||
displayName: form.displayName,
|
||||
username: form.username || form.email || form.phone,
|
||||
@@ -104,9 +145,9 @@ export function ClientUsersPage() {
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
await load();
|
||||
await loadUsers();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||
setFormError(failure instanceof Error ? failure.message : '用户保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -114,13 +155,28 @@ export function ClientUsersPage() {
|
||||
|
||||
async function runConfirm() {
|
||||
if (!confirmAction) return;
|
||||
if (confirmAction.type === 'delete') {
|
||||
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||
} else {
|
||||
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||
setConfirming(true);
|
||||
setConfirmError('');
|
||||
try {
|
||||
if (confirmAction.type === 'delete') {
|
||||
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||
} else {
|
||||
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||
}
|
||||
} catch (failure) {
|
||||
const detail = failure instanceof Error ? failure.message : '用户操作失败';
|
||||
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
setConfirmAction(null);
|
||||
await load();
|
||||
try {
|
||||
await loadUsers();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '用户列表刷新失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
@@ -145,8 +201,8 @@ export function ClientUsersPage() {
|
||||
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -162,12 +218,23 @@ export function ClientUsersPage() {
|
||||
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">添加用户</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-filter-row">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="surface system-filter-row client-user-filter">
|
||||
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
|
||||
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => updateFilter('status', event.target.value)}
|
||||
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={filters.status}
|
||||
/>
|
||||
<div className="client-user-filter__actions">
|
||||
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
|
||||
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface system-table-card client-users-table-card">
|
||||
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
|
||||
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{(creating || editingUser) ? (
|
||||
@@ -185,6 +252,7 @@ export function ClientUsersPage() {
|
||||
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
{formError ? <p className="form-error" role="alert">{formError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
@@ -198,8 +266,9 @@ export function ClientUsersPage() {
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button></>} onClose={() => setConfirmAction(null)} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -113,6 +113,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
items: [
|
||||
{ label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck },
|
||||
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||||
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AdminReportTasksPage } from '@/apps/admin/AdminReportTasksPage';
|
||||
import { AdminReportMaterialsPage } from '@/apps/admin/AdminReportMaterialsPage';
|
||||
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
|
||||
import { AdminRiskRulesPage } from '@/apps/admin/AdminRiskRulesPage';
|
||||
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
|
||||
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
@@ -109,6 +110,7 @@ export function AppRoutes() {
|
||||
<Route path="drainage-audits" element={<AdminDrainageAuditPage />} />
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="risk-rules" element={<AdminRiskRulesPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
|
||||
<Route path="report-records" element={<AdminReportRecordsPage />} />
|
||||
|
||||
+34
-1
@@ -834,6 +834,31 @@ h3 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.page-heading__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.table-subline {
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
font-weight: var(--font-weight-regular);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-selected);
|
||||
font-size: var(--font-size-sm);
|
||||
@@ -10148,7 +10173,14 @@ h3 {
|
||||
}
|
||||
|
||||
.admin-user-toolbar {
|
||||
grid-template-columns: minmax(360px, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.admin-user-filter-grid {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.admin-user-toolbar > .ui-button {
|
||||
@@ -10387,6 +10419,7 @@ h3 {
|
||||
.admin-drainage-toolbar { grid-template-columns: 1fr; }
|
||||
.admin-drainage-section__heading { align-items: stretch; flex-direction: column; }
|
||||
.admin-user-toolbar { grid-template-columns: 1fr; }
|
||||
.admin-user-filter-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-drainage-actions .ui-button--ghost {
|
||||
|
||||
Reference in New Issue
Block a user