feat: integrate analytics and fragment receipt improvements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
|
||||
import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
|
||||
|
||||
// Review, risk and billing mutations keep their original URLs, payloads and
|
||||
// response types behind one governance boundary.
|
||||
@@ -14,10 +14,12 @@ export const adminGovernanceApi = {
|
||||
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
|
||||
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
||||
listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword) params.set('keyword', query.keyword);
|
||||
if (query.status && query.status !== 'all') params.set('status', query.status);
|
||||
if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom);
|
||||
if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo);
|
||||
const suffix = params.toString() ? `?${params}` : '';
|
||||
return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`);
|
||||
},
|
||||
@@ -35,7 +37,7 @@ export const adminGovernanceApi = {
|
||||
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
|
||||
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
|
||||
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
|
||||
@@ -47,7 +49,7 @@ export const adminGovernanceApi = {
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) =>
|
||||
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
|
||||
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
|
||||
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
|
||||
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
|
||||
@@ -71,10 +73,12 @@ export const adminGovernanceApi = {
|
||||
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
|
||||
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => {
|
||||
listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword) params.set('keyword', query.keyword);
|
||||
if (query.status && query.status !== 'all') params.set('status', query.status);
|
||||
if (query.submittedAtFrom) params.set('submittedAtFrom', query.submittedAtFrom);
|
||||
if (query.submittedAtTo) params.set('submittedAtTo', query.submittedAtTo);
|
||||
const suffix = params.toString() ? `?${params}` : '';
|
||||
return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`);
|
||||
},
|
||||
@@ -87,7 +91,7 @@ export const adminGovernanceApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
|
||||
listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
|
||||
listRiskRules: (applicationId?: string) =>
|
||||
request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })),
|
||||
createRiskRule: (body: {
|
||||
@@ -187,6 +191,16 @@ export const adminGovernanceApi = {
|
||||
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
||||
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
|
||||
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
|
||||
createDrainageDetectionRule: (body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
|
||||
request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateDrainageDetectionRule: (id: string, body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
|
||||
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') =>
|
||||
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
testDrainageDetectionRule: (body: { content: string; rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'> }) =>
|
||||
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
|
||||
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
||||
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -37,9 +37,9 @@ export const adminOperationsApi = {
|
||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
|
||||
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
|
||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||
|
||||
@@ -94,3 +94,29 @@ export type RiskTaskMessagePage = {
|
||||
};
|
||||
|
||||
export type BatchTaskMessagePage = RiskTaskMessagePage;
|
||||
|
||||
export type DrainageDetectionRule = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: 'url' | 'mobile' | 'landline';
|
||||
pattern: string;
|
||||
flags: string;
|
||||
priority: number;
|
||||
status: 'active' | 'inactive';
|
||||
description?: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DrainageDetectionResult = {
|
||||
hasDrainageContent: boolean;
|
||||
drainageDetection: {
|
||||
matches: Array<{ ruleCode: string; ruleName: string; category: string; text: string; normalizedText: string; start: number; end: number }>;
|
||||
categories: string[];
|
||||
truncated: boolean;
|
||||
};
|
||||
drainageDetectionVersion: string;
|
||||
drainageEvaluatedAt: string;
|
||||
};
|
||||
|
||||
@@ -52,6 +52,10 @@ export type SignatureChannelCarrierQualityStat = {
|
||||
averageArrivalMs?: number | null;
|
||||
};
|
||||
|
||||
export type SignatureChannelCarrierDrainageQualityStat = SignatureChannelCarrierQualityStat & {
|
||||
drainageState: 'with' | 'without' | 'unknown';
|
||||
};
|
||||
|
||||
export type SignatureCarrierBusinessQualityStat = {
|
||||
signatureId: string;
|
||||
carrier: string;
|
||||
@@ -78,6 +82,7 @@ export type SignatureChannelQualityItem = {
|
||||
channelSubmitTotal: number;
|
||||
carrierOverview: SignatureCarrierBusinessQualityStat[];
|
||||
breakdowns: SignatureChannelCarrierQualityStat[];
|
||||
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
|
||||
};
|
||||
|
||||
export type SignatureChannelQualityResponse = {
|
||||
@@ -108,6 +113,7 @@ export type SendQualityResponse = {
|
||||
summary: DailySendSummary;
|
||||
channels: ChannelQualityStat[];
|
||||
signatures: SignatureQualityStat[];
|
||||
drainageSignatures: SignatureQualityStat[];
|
||||
applications: ApplicationQualityStat[];
|
||||
};
|
||||
|
||||
@@ -122,6 +128,14 @@ export type SmsMessageRecord = {
|
||||
carrier?: string | null;
|
||||
province?: string | null;
|
||||
content: string;
|
||||
hasDrainageContent?: boolean | null;
|
||||
drainageDetection?: {
|
||||
matches?: Array<{ category: string; text: string; normalizedText: string; start: number; end: number }>;
|
||||
categories?: string[];
|
||||
truncated?: boolean;
|
||||
} | null;
|
||||
drainageDetectionVersion?: string | null;
|
||||
drainageEvaluatedAt?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
billingUnits: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
adminApi,
|
||||
type SendQualityResponse,
|
||||
type SignatureChannelCarrierQualityStat,
|
||||
type SignatureChannelCarrierDrainageQualityStat,
|
||||
type SignatureChannelQualityItem,
|
||||
type SignatureChannelQualityResponse,
|
||||
} from '@/api/adminApi';
|
||||
@@ -290,6 +291,7 @@ function SignatureQualityDrawer({
|
||||
item: SignatureChannelQualityItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall');
|
||||
const carriers = item.carrierOverview
|
||||
.map((carrier) => ({
|
||||
...carrier,
|
||||
@@ -360,8 +362,9 @@ function SignatureQualityDrawer({
|
||||
<div className="signature-quality-section__heading">
|
||||
<div>
|
||||
<h3>通道 × 运营商矩阵</h3>
|
||||
<p>“—”表示所选日期没有该通道与运营商组合的真实提交,并不等同于通道不支持该运营商。</p>
|
||||
<p>{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}“—”表示所选日期没有真实提交。</p>
|
||||
</div>
|
||||
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}>整体统计</Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}>按引流切分</Button></div>
|
||||
</div>
|
||||
<div className="signature-quality-matrix">
|
||||
<table>
|
||||
@@ -379,9 +382,14 @@ function SignatureQualityDrawer({
|
||||
const metric = item.breakdowns.find((entry) => (
|
||||
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
|
||||
));
|
||||
const drainageMetrics = item.drainageBreakdowns.filter((entry) => (
|
||||
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
|
||||
));
|
||||
return (
|
||||
<td key={carrier}>
|
||||
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty">—</span>}
|
||||
{matrixMode === 'overall'
|
||||
? metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty">—</span>
|
||||
: drainageMetrics.length ? <DrainageMatrixMetrics metrics={drainageMetrics} /> : <span className="signature-quality-matrix__empty">—</span>}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -423,6 +431,14 @@ function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) {
|
||||
const labels = { with: '含引流', without: '不含引流', unknown: '未检测' };
|
||||
return <div className="signature-quality-matrix__drainage">{(['with', 'without', 'unknown'] as const).map((state) => {
|
||||
const metric = metrics.find((item) => item.drainageState === state);
|
||||
return metric ? <div key={state}><b>{labels[state]}</b><MatrixMetric metric={metric} /></div> : null;
|
||||
})}</div>;
|
||||
}
|
||||
|
||||
function QualityRate({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="signature-quality-rate">
|
||||
|
||||
@@ -317,7 +317,7 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<div className="surface ui-filter-row">
|
||||
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
<Select
|
||||
@@ -362,7 +362,7 @@ export function AdminDownstreamDeliveriesPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -309,7 +309,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<div className="surface ui-filter-row">
|
||||
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<DateRangeInput label="最近更新时间" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
<Select
|
||||
@@ -358,7 +358,7 @@ export function AdminDownstreamRecoveryStatusesPage() {
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type FileRef, type SmsDrainageInfo } from '@/api/adminApi';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
|
||||
|
||||
@@ -54,18 +54,24 @@ export function AdminDrainageAuditPage() {
|
||||
const [items, setItems] = useState<SmsDrainageInfo[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [detail, setDetail] = useState<SmsDrainageInfo>();
|
||||
const [rejectTarget, setRejectTarget] = useState<SmsDrainageInfo>();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listDrainageInfos({ keyword, status: status === 'all' ? undefined : status })
|
||||
adminApi.listDrainageInfos({
|
||||
keyword,
|
||||
status: status === 'all' ? undefined : status,
|
||||
submittedAtFrom: submittedDateRange.start,
|
||||
submittedAtTo: submittedDateRange.end,
|
||||
})
|
||||
.then((records) => { setItems(records); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '引流信息审核列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [keyword, status]);
|
||||
useEffect(loadData, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
async function approve(item: SmsDrainageInfo) {
|
||||
try { await adminApi.approveDrainageInfo(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核通过失败'); }
|
||||
@@ -89,7 +95,7 @@ export function AdminDrainageAuditPage() {
|
||||
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1>引流信息审核</h1></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Tabs items={[
|
||||
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="drainage" /> },
|
||||
]} />
|
||||
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FlaskConical, Pencil, Plus, Power, PowerOff, Search } from 'lucide-react';
|
||||
import { adminApi, type DrainageDetectionResult, type DrainageDetectionRule } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type RuleDraft = Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
const emptyDraft: RuleDraft = { code: '', name: '', category: 'url', pattern: '', flags: 'giu', priority: 100, status: 'active', description: '' };
|
||||
const categoryOptions = [
|
||||
{ label: 'URL / 域名', value: 'url' }, { label: '手机号码', value: 'mobile' }, { label: '固定电话', value: 'landline' },
|
||||
];
|
||||
const categoryLabels = { url: 'URL / 域名', mobile: '手机号码', landline: '固定电话' };
|
||||
|
||||
export function AdminDrainageDetectionRulesPage() {
|
||||
const [rules, setRules] = useState<DrainageDetectionRule[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [draft, setDraft] = useState<RuleDraft>(emptyDraft);
|
||||
const [editingId, setEditingId] = useState<string>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [testContent, setTestContent] = useState('');
|
||||
const [testResult, setTestResult] = useState<DrainageDetectionResult>();
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load(search = keyword) {
|
||||
adminApi.listDrainageDetectionRules({ keyword: search.trim() || undefined })
|
||||
.then((items) => { setRules(items); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '引流识别规则加载失败'));
|
||||
}
|
||||
useEffect(() => { load(''); }, []);
|
||||
|
||||
function openEditor(rule?: DrainageDetectionRule) {
|
||||
setEditingId(rule?.id);
|
||||
setDraft(rule ? { code: rule.code, name: rule.name, category: rule.category, pattern: rule.pattern, flags: rule.flags, priority: rule.priority, status: rule.status, description: rule.description } : emptyDraft);
|
||||
setTestContent(''); setTestResult(undefined); setOpen(true);
|
||||
}
|
||||
async function save() {
|
||||
try {
|
||||
if (editingId) await adminApi.updateDrainageDetectionRule(editingId, draft);
|
||||
else await adminApi.createDrainageDetectionRule(draft);
|
||||
setOpen(false); load();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '规则保存失败'); }
|
||||
}
|
||||
async function test() {
|
||||
try { setTestResult(await adminApi.testDrainageDetectionRule({ content: testContent, rule: draft })); setError(''); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '规则测试失败'); }
|
||||
}
|
||||
async function toggle(rule: DrainageDetectionRule) {
|
||||
try { await adminApi.changeDrainageDetectionRuleStatus(rule.id, rule.status === 'active' ? 'inactive' : 'active'); load(); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '规则状态修改失败'); }
|
||||
}
|
||||
|
||||
return <section className="page-stack admin-system-page">
|
||||
<div className="page-heading"><div><Breadcrumb items={['系统管理', '引流识别规则']} /><h1>引流识别规则</h1><p>配置 URL、手机号码和固定电话识别规则。规则只用于记录与统计,不会拦截发送。</p></div><Button icon={<Plus size={16} />} onClick={() => openEditor()}>新增规则</Button></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-system-toolbar"><Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索编码、名称或说明" prefix={<Search size={16} />} value={keyword} /><div className="admin-system-toolbar__actions"><Button onClick={() => load()}>查询</Button><Button onClick={() => { setKeyword(''); load(''); }} variant="ghost">重置</Button></div></div>
|
||||
<div className="surface ui-table-wrap"><table className="ui-table"><thead><tr><th>规则</th><th>类型</th><th>优先级</th><th>版本</th><th>状态</th><th>表达式</th><th>操作</th></tr></thead><tbody>{rules.map((rule) => <tr key={rule.id}><td><strong>{rule.name}</strong><br /><small>{rule.code}</small></td><td>{categoryLabels[rule.category]}</td><td>{rule.priority}</td><td>v{rule.version}</td><td><Tag tone={rule.status === 'active' ? 'success' : 'neutral'}>{rule.status === 'active' ? '启用' : '停用'}</Tag></td><td><code title={rule.pattern}>{rule.pattern}</code></td><td><div className="ui-table__actions"><Button icon={<Pencil size={14} />} onClick={() => openEditor(rule)} size="sm" variant="ghost">编辑</Button><Button icon={rule.status === 'active' ? <PowerOff size={14} /> : <Power size={14} />} onClick={() => void toggle(rule)} size="sm" variant="ghost">{rule.status === 'active' ? '停用' : '启用'}</Button></div></td></tr>)}</tbody></table>{rules.length === 0 ? <div className="ui-table__empty">暂无识别规则</div> : null}</div>
|
||||
<Modal footer={<><Button onClick={() => setOpen(false)} variant="ghost">取消</Button><Button disabled={!draft.code || !draft.name || !draft.pattern} onClick={() => void save()}>保存规则</Button></>} onClose={() => setOpen(false)} open={open} title={editingId ? '编辑引流识别规则' : '新增引流识别规则'}>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="规则编码" onChange={(event) => setDraft({ ...draft, code: event.target.value })} value={draft.code} />
|
||||
<Input label="规则名称" onChange={(event) => setDraft({ ...draft, name: event.target.value })} value={draft.name} />
|
||||
<Select label="识别类型" onChange={(event) => setDraft({ ...draft, category: event.target.value as RuleDraft['category'] })} options={categoryOptions} value={draft.category} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setDraft({ ...draft, priority: Number(event.target.value) || 100 })} type="number" value={String(draft.priority)} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="识别表达式" onChange={(event) => setDraft({ ...draft, pattern: event.target.value })} rows={4} value={draft.pattern} />
|
||||
<Input label="表达式标志" onChange={(event) => setDraft({ ...draft, flags: event.target.value })} value={draft.flags} />
|
||||
<Textarea label="说明" onChange={(event) => setDraft({ ...draft, description: event.target.value })} rows={3} value={draft.description ?? ''} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="测试短信内容" onChange={(event) => setTestContent(event.target.value)} rows={3} value={testContent} />
|
||||
<div><Button disabled={!testContent || !draft.pattern} icon={<FlaskConical size={15} />} onClick={() => void test()} size="sm" variant="secondary">测试当前规则</Button>{testResult ? <p>{testResult.hasDrainageContent ? `命中:${testResult.drainageDetection.matches.map((item) => item.text).join('、')}` : '未命中引流信息'}</p> : null}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
@@ -73,12 +73,13 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
|
||||
export function AdminEnterpriseAuditPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [records, setRecords] = useState<EnterpriseAuditRecord[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listEnterpriseCertifications({ keyword, status })
|
||||
adminApi.listEnterpriseCertifications({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end })
|
||||
.then((items) => {
|
||||
setRecords(items.map(mapCertification));
|
||||
setError('');
|
||||
@@ -87,7 +88,7 @@ export function AdminEnterpriseAuditPage() {
|
||||
setRecords([]);
|
||||
setError(failure.message || '企业认证审核数据加载失败');
|
||||
});
|
||||
}, [keyword, status]);
|
||||
}, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
@@ -141,12 +142,13 @@ export function AdminEnterpriseAuditPage() {
|
||||
<Breadcrumb items={['审核中心', '企业认证审核']} />
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--enterprise">
|
||||
<div className="ui-filter-row">
|
||||
<Input label="企业名称/信用代码" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入企业名称或统一社会信用代码" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,8 +156,8 @@ export function AdminHome() {
|
||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
|
||||
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}秒` },
|
||||
];
|
||||
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
|
||||
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
|
||||
const plainSignatureQuality = quality?.signatures ?? [];
|
||||
const drainageSignatureQuality = quality?.drainageSignatures ?? [];
|
||||
const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||
const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||
const pagedPlainSignatureQuality = plainSignatureQuality
|
||||
@@ -233,12 +233,12 @@ export function AdminHome() {
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计 - 不含引流</h2>
|
||||
<p className="muted">按签名汇总当天真实发送、回执和平均到达时长。</p>
|
||||
<h2>今日签名发送统计</h2>
|
||||
<p className="muted">按签名汇总当天全部真实发送,不区分是否包含引流信息。</p>
|
||||
</div>
|
||||
<Tag tone="info">{plainSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
|
||||
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无签名发送记录" rowKey="id" />
|
||||
<Pagination
|
||||
total={plainSignatureQuality.length}
|
||||
page={plainSignaturePage}
|
||||
@@ -254,7 +254,7 @@ export function AdminHome() {
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计 - 含引流</h2>
|
||||
<p className="muted">独立展示关联引流信息的签名发送效果。</p>
|
||||
<p className="muted">只统计短信内容识别为含引流信息的发送效果。</p>
|
||||
</div>
|
||||
<Tag tone="accent">{drainageSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
|
||||
@@ -105,11 +105,11 @@ export function AdminRiskRulesPage() {
|
||||
|
||||
useEffect(load, [applicationId]);
|
||||
|
||||
function loadFrequencyHits(page = hitPage) {
|
||||
function loadFrequencyHits(page = hitPage, filters = { phoneNumber: hitPhone, status: hitStatus }) {
|
||||
adminApi.listPhoneFrequencyHits({
|
||||
applicationId: applicationId || undefined,
|
||||
phoneNumber: hitPhone.trim() || undefined,
|
||||
status: hitStatus || undefined,
|
||||
phoneNumber: filters.phoneNumber.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
@@ -124,10 +124,10 @@ export function AdminRiskRulesPage() {
|
||||
loadFrequencyHits(1);
|
||||
}, [applicationId]);
|
||||
|
||||
function loadWhitelist(page = whitelistPage) {
|
||||
function loadWhitelist(page = whitelistPage, filters = { phoneNumber: whitelistPhone, status: whitelistStatus }) {
|
||||
adminApi.listPhoneFrequencyWhitelist({
|
||||
phoneNumber: whitelistPhone.trim() || undefined,
|
||||
status: whitelistStatus || undefined,
|
||||
phoneNumber: filters.phoneNumber.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
page,
|
||||
pageSize: 20,
|
||||
}).then((result) => {
|
||||
@@ -325,7 +325,7 @@ export function AdminRiskRulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
<div className="surface sms-audit-filter">
|
||||
<div className="surface sms-audit-filter ui-filter-row">
|
||||
<Select
|
||||
label="查看范围"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
@@ -348,7 +348,7 @@ export function AdminRiskRulesPage() {
|
||||
<Button icon={<Plus size={16} />} onClick={() => setWhitelistEditor({ phoneNumber: '', reason: '', remark: '', status: 'active' })}>新增白名单</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-audit-filter">
|
||||
<div className="sms-audit-filter ui-filter-row">
|
||||
<Input label="手机号码" onChange={(event) => setWhitelistPhone(event.target.value)} placeholder="输入完整或部分号码" value={whitelistPhone} />
|
||||
<Select
|
||||
label="白名单状态"
|
||||
@@ -361,8 +361,9 @@ export function AdminRiskRulesPage() {
|
||||
]}
|
||||
value={whitelistStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => loadWhitelist(1)}>查询</Button>
|
||||
<Button onClick={() => { setWhitelistPhone(''); setWhitelistStatus(''); loadWhitelist(1, { phoneNumber: '', status: '' }); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={whitelistColumns} data={whitelist} emptyText="暂无号码频控白名单" pagination={false} rowKey="id" />
|
||||
@@ -382,7 +383,7 @@ export function AdminRiskRulesPage() {
|
||||
<div><h2>号码频次触发记录</h2><p>按企业应用和号码隔离计数;周期到期自动重新计数,人工解除会立即清零当前周期并保留审计记录。</p></div>
|
||||
<Tag tone="warning">{hitTotal} 条</Tag>
|
||||
</div>
|
||||
<div className="sms-audit-filter">
|
||||
<div className="sms-audit-filter ui-filter-row">
|
||||
<Input label="手机号码" onChange={(event) => setHitPhone(event.target.value)} placeholder="输入完整或部分号码" value={hitPhone} />
|
||||
<Select
|
||||
label="记录状态"
|
||||
@@ -395,8 +396,9 @@ export function AdminRiskRulesPage() {
|
||||
]}
|
||||
value={hitStatus}
|
||||
/>
|
||||
<div className="admin-task-filter__actions">
|
||||
<div className="admin-task-filter__actions ui-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { setHitPage(1); loadFrequencyHits(1); }}>查询</Button>
|
||||
<Button onClick={() => { setHitPhone(''); setHitStatus('active'); setHitPage(1); loadFrequencyHits(1, { phoneNumber: '', status: 'active' }); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={hitColumns} data={frequencyHits} emptyText="暂无号码频次触发记录" pagination={false} rowKey="id" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, FileActions, Input, Modal, RiskAction, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportImportAuditPanel } from './ReportImportAuditPanel';
|
||||
|
||||
@@ -71,18 +71,24 @@ export function AdminSignatureAuditPage() {
|
||||
const [items, setItems] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [detail, setDetail] = useState<ClientSmsSignature>();
|
||||
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.listEnterpriseSignatures({ keyword, status: status === 'all' ? undefined : status })
|
||||
adminApi.listEnterpriseSignatures({
|
||||
keyword,
|
||||
status: status === 'all' ? undefined : status,
|
||||
submittedAtFrom: submittedDateRange.start,
|
||||
submittedAtTo: submittedDateRange.end,
|
||||
})
|
||||
.then((records) => { setItems(records); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [keyword, status]);
|
||||
useEffect(loadData, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
||||
|
||||
@@ -104,7 +110,7 @@ export function AdminSignatureAuditPage() {
|
||||
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1>短信签名审核</h1></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<Tabs items={[
|
||||
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
|
||||
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> },
|
||||
]} />
|
||||
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Check, Eye, Search, X } from 'lucide-react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
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 { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -38,7 +38,7 @@ export function AdminSmsAuditPage() {
|
||||
const [records, setRecords] = useState<RiskReviewTask[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending_review');
|
||||
const [date, setDate] = useState('');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [error, setError] = useState('');
|
||||
const [approveTarget, setApproveTarget] = useState<RiskReviewTask | 'batch' | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<RiskReviewTask | 'batch' | null>(null);
|
||||
@@ -56,7 +56,11 @@ export function AdminSmsAuditPage() {
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status })
|
||||
adminApi.listRiskReviewTasks({
|
||||
status: status === 'all' ? undefined : status,
|
||||
submittedAtFrom: submittedDateRange.start,
|
||||
submittedAtTo: submittedDateRange.end,
|
||||
})
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setSelectedIds((current) => current.filter((id) => items.some((item) => item.id === id && item.status === 'pending_review')));
|
||||
@@ -67,16 +71,14 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [status]);
|
||||
}, [status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || [record.taskNo, record.content, record.reviewReason, record.rejectReason].join(' ').includes(keyword);
|
||||
const relevantDate = record.status === 'pending_review' ? record.createdAt : record.reviewedAt ?? record.createdAt;
|
||||
const matchesDate = !date || relevantDate.startsWith(date);
|
||||
return matchesKeyword && matchesDate;
|
||||
return matchesKeyword;
|
||||
}),
|
||||
[date, keyword, records],
|
||||
[keyword, records],
|
||||
);
|
||||
|
||||
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
|
||||
@@ -192,7 +194,7 @@ export function AdminSmsAuditPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface sms-audit-filter">
|
||||
<h2>筛选条件</h2>
|
||||
<div className="audit-filter-grid audit-filter-grid--sms">
|
||||
<div className="ui-filter-row">
|
||||
<Select
|
||||
label="审核状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
@@ -205,10 +207,10 @@ export function AdminSmsAuditPage() {
|
||||
value={status}
|
||||
/>
|
||||
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
|
||||
<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">
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setDate(''); setStatus('pending_review'); }} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setSubmittedDateRange({}); setStatus('pending_review'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sms-bulk-actions">
|
||||
@@ -252,10 +254,10 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
{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">
|
||||
<div className="ui-filter-row">
|
||||
<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 className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPhonePage(1); loadPhones(phoneTarget, 1, phonePageSize); }}>查询</Button></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
|
||||
@@ -20,6 +20,7 @@ export function AdminSmsRecordsPage() {
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [hasDrainage, setHasDrainage] = useState('all');
|
||||
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
|
||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||
const [segmentLoading, setSegmentLoading] = useState(false);
|
||||
@@ -41,6 +42,7 @@ export function AdminSmsRecordsPage() {
|
||||
queuedAtFrom: dateRange.start,
|
||||
queuedAtTo: dateRange.end,
|
||||
status: status === 'all' ? undefined : status,
|
||||
hasDrainage: hasDrainage === 'all' ? undefined : hasDrainage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ export function AdminSmsRecordsPage() {
|
||||
setChannelKeyword('');
|
||||
setCarrier('all');
|
||||
setStatus('all');
|
||||
setHasDrainage('all');
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
|
||||
}
|
||||
@@ -151,6 +154,7 @@ export function AdminSmsRecordsPage() {
|
||||
dateRange={dateRange}
|
||||
enterprise={enterprise}
|
||||
enterpriseOptions={enterpriseOptions}
|
||||
hasDrainage={hasDrainage}
|
||||
phoneKeyword={phoneKeyword}
|
||||
status={status}
|
||||
onApplicationChange={setApplication}
|
||||
@@ -162,6 +166,7 @@ export function AdminSmsRecordsPage() {
|
||||
setEnterprise(value);
|
||||
setApplication('all');
|
||||
}}
|
||||
onHasDrainageChange={setHasDrainage}
|
||||
onPhoneKeywordChange={setPhoneKeyword}
|
||||
onQuery={() => {
|
||||
if (page !== 1) setPage(1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, Search, X } from 'lucide-react';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, Input, Modal, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { AuditReviewInfo, Breadcrumb, Button, DateRangeInput, Input, Modal, RiskAction, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -22,13 +22,14 @@ export function AdminTemplateAuditPage() {
|
||||
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('pending');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [detail, setDetail] = useState<SmsTemplateAudit>();
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listTemplateAudits({ keyword, status })
|
||||
adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end })
|
||||
.then(setAudits)
|
||||
.catch(() => setAudits([]));
|
||||
}, [keyword, status]);
|
||||
}, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
async function rejectTemplate(id: string) {
|
||||
const updated = await adminApi.rejectTemplate(id);
|
||||
@@ -57,7 +58,7 @@ export function AdminTemplateAuditPage() {
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button>
|
||||
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
|
||||
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end }).then(setAudits)} targetId={record.id} targetType="template" />
|
||||
<Button
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
@@ -71,7 +72,7 @@ export function AdminTemplateAuditPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[keyword, status],
|
||||
[keyword, status, submittedDateRange.end, submittedDateRange.start],
|
||||
);
|
||||
const templateAudits = audits;
|
||||
|
||||
@@ -83,12 +84,13 @@ export function AdminTemplateAuditPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<div className="ui-filter-row">
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用或模板内容" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +258,7 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar admin-user-toolbar">
|
||||
<div className="admin-user-filter-grid">
|
||||
<div className="admin-user-filter-grid ui-filter-row">
|
||||
<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
|
||||
@@ -279,10 +279,10 @@ export function AdminUsersPage() {
|
||||
options={[{ label: '全部状态', value: '' }, { label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={filters.status}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-system-toolbar__actions">
|
||||
<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 className="admin-system-toolbar__actions ui-filter-actions">
|
||||
<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>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm">新增用户</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ReportImportReviewBatch, type ReportImportReviewItem } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -38,6 +38,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
const pageSize = 20;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [detail, setDetail] = useState<ReportImportReviewBatch>();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
@@ -50,6 +51,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
startAt: submittedDateRange.start,
|
||||
endAt: submittedDateRange.end,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
}).then((result) => {
|
||||
@@ -61,7 +64,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
|
||||
useEffect(() => {
|
||||
load(page);
|
||||
}, [page, pageSize, reportType, status]);
|
||||
}, [page, pageSize, reportType, status, submittedDateRange.end, submittedDateRange.start]);
|
||||
|
||||
const pendingItems = detail?.items.filter((item) => item.status === 'pending_review') ?? [];
|
||||
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((item) => selected.has(item.id));
|
||||
@@ -81,6 +84,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
reportType,
|
||||
status: status === 'all' ? undefined : status,
|
||||
keyword: keyword.trim() || undefined,
|
||||
startAt: submittedDateRange.start,
|
||||
endAt: submittedDateRange.end,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
@@ -126,7 +131,7 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
return <div className="page-stack">
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<div className="ui-filter-row">
|
||||
<Input label="批次号/文件名" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索导入批次或文件" value={keyword} />
|
||||
<Select label="批次状态" onChange={(event) => { setStatus(event.target.value); setPage(1); }} options={[
|
||||
{ label: '全部状态', value: 'all' },
|
||||
@@ -136,7 +141,8 @@ export function ReportImportAuditPanel({ reportType }: { reportType: 'signature'
|
||||
{ label: '部分通过', value: 'partially_approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
]} value={status} />
|
||||
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}>查询</Button></div>
|
||||
<DateRangeInput label="提交时间" onChange={(value) => { setSubmittedDateRange(value); setPage(1); }} value={submittedDateRange} />
|
||||
<div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); load(1); }}>查询</Button><Button onClick={() => { setKeyword(''); setStatus('all'); setSubmittedDateRange({}); setPage(1); }} variant="ghost">重置</Button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface"><Table columns={columns} data={data.items} emptyText="暂无导入审核批次" pagination={false} rowKey="id" /></div>
|
||||
|
||||
@@ -78,6 +78,39 @@
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.admin-sms-record-card .admin-sms-record-content.is-drainage {
|
||||
background: color-mix(in srgb, #f59e0b 13%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border));
|
||||
}
|
||||
|
||||
.admin-sms-record-content mark {
|
||||
background: color-mix(in srgb, #f59e0b 32%, transparent);
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.admin-sms-record-drainage-badge {
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-left: var(--space-3);
|
||||
padding: 1px var(--space-2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-sms-record-drainage-badge.is-yes {
|
||||
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.admin-sms-record-drainage-badge.is-no,
|
||||
.admin-sms-record-drainage-badge.is-unknown {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
|
||||
@@ -18,6 +18,7 @@ type SmsRecordFilterProps = {
|
||||
dateRange: DateRangeValue;
|
||||
enterprise: string;
|
||||
enterpriseOptions: SelectOption[];
|
||||
hasDrainage: string;
|
||||
phoneKeyword: string;
|
||||
status: string;
|
||||
onApplicationChange: (value: string) => void;
|
||||
@@ -26,6 +27,7 @@ type SmsRecordFilterProps = {
|
||||
onContentKeywordChange: (value: string) => void;
|
||||
onDateRangeChange: (value: DateRangeValue) => void;
|
||||
onEnterpriseChange: (value: string) => void;
|
||||
onHasDrainageChange: (value: string) => void;
|
||||
onPhoneKeywordChange: (value: string) => void;
|
||||
onQuery: () => void;
|
||||
onReset: () => void;
|
||||
@@ -48,6 +50,13 @@ const statusOptions = [
|
||||
{ label: '送达失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const drainageOptions = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '含引流', value: 'true' },
|
||||
{ label: '不含引流', value: 'false' },
|
||||
{ label: '未检测', value: 'unknown' },
|
||||
];
|
||||
|
||||
export function SmsRecordFilter({
|
||||
application,
|
||||
applicationOptions,
|
||||
@@ -57,6 +66,7 @@ export function SmsRecordFilter({
|
||||
dateRange,
|
||||
enterprise,
|
||||
enterpriseOptions,
|
||||
hasDrainage,
|
||||
phoneKeyword,
|
||||
status,
|
||||
onApplicationChange,
|
||||
@@ -65,6 +75,7 @@ export function SmsRecordFilter({
|
||||
onContentKeywordChange,
|
||||
onDateRangeChange,
|
||||
onEnterpriseChange,
|
||||
onHasDrainageChange,
|
||||
onPhoneKeywordChange,
|
||||
onQuery,
|
||||
onReset,
|
||||
@@ -80,6 +91,7 @@ export function SmsRecordFilter({
|
||||
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => onChannelKeywordChange(event.target.value)} value={channelKeyword} />
|
||||
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
|
||||
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={onQuery}>查询</Button>
|
||||
<Button onClick={onReset} variant="ghost">重置</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SmsMessageRecord } from '@/api/adminApi';
|
||||
import { Button, Pagination } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
@@ -22,6 +23,23 @@ function StatusLine({ record }: { record: SmsMessageRecord }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageContent({ record }: { record: SmsMessageRecord }) {
|
||||
const ranges = (record.drainageDetection?.matches ?? [])
|
||||
.filter((match) => Number.isInteger(match.start) && Number.isInteger(match.end) && match.start >= 0 && match.end > match.start && match.end <= record.content.length)
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
.filter((match, index, items) => index === 0 || match.start >= items[index - 1].end);
|
||||
if (!record.hasDrainageContent || ranges.length === 0) return <>{record.content}</>;
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((range, index) => {
|
||||
if (range.start > cursor) parts.push(record.content.slice(cursor, range.start));
|
||||
parts.push(<mark key={`${range.start}-${range.end}-${index}`}>{record.content.slice(range.start, range.end)}</mark>);
|
||||
cursor = range.end;
|
||||
});
|
||||
if (cursor < record.content.length) parts.push(record.content.slice(cursor));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
type SmsRecordListProps = {
|
||||
currentPage: number;
|
||||
loading: boolean;
|
||||
@@ -59,7 +77,12 @@ export function SmsRecordList({
|
||||
<StatusLine record={record} />
|
||||
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
|
||||
</header>
|
||||
<p className="admin-sms-record-content">{record.content}</p>
|
||||
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
|
||||
<DrainageContent record={record} />
|
||||
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
|
||||
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
|
||||
</span>
|
||||
</p>
|
||||
<div className="admin-sms-record-card__meta">
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / ¥{formatCents(record.amountCents)}</strong><small>{record.content.length} 字</small></div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type MessageFilters = {
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
status?: string;
|
||||
hasDrainage?: string;
|
||||
};
|
||||
|
||||
export type TenantOption = {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ClientHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-grid dashboard-grid--four">
|
||||
<div className="surface metric-card metric-card--featured">
|
||||
<span>可用发送额度</span>
|
||||
<strong>¥{formatAmount(availableBalance)}</strong>
|
||||
@@ -109,6 +109,11 @@ export function ClientHome() {
|
||||
<strong>{(dashboard?.today.sent ?? 0).toLocaleString('zh-CN')}</strong>
|
||||
<small>成功率 {dashboard?.today.successRate ?? 0}%</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日消费金额</span>
|
||||
<strong>¥{formatCents(dashboard?.today.spendCents)}</strong>
|
||||
<small>企业今日实际消费</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日返还金额</span>
|
||||
<strong>¥{formatCents(todayRefundCents)}</strong>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type DateRangeValue = {
|
||||
};
|
||||
|
||||
type DateRangeInputProps = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
@@ -65,7 +66,7 @@ function getCalendarDays(viewDate: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
export function DateRangeInput({ label, value, onChange }: DateRangeInputProps) {
|
||||
export function DateRangeInput({ className = '', label, value, onChange }: DateRangeInputProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(() => parseDate(value.start) ?? new Date());
|
||||
const startDate = parseDate(value.start);
|
||||
@@ -112,7 +113,7 @@ export function DateRangeInput({ label, value, onChange }: DateRangeInputProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-field ui-date-range-field">
|
||||
<div className={['ui-field', 'ui-date-range-field', className].filter(Boolean).join(' ')}>
|
||||
{label ? <span className="ui-field__label">{label}</span> : null}
|
||||
<button
|
||||
className={['ui-date-range-trigger', open ? 'ui-date-range-trigger--open' : '', value.start || value.end ? 'ui-date-range-trigger--filled' : ''].filter(Boolean).join(' ')}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ReceiptText,
|
||||
ClipboardList,
|
||||
Send,
|
||||
ScanSearch,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
@@ -182,6 +183,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||||
{ label: '报备字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||
{ label: '引流识别规则', to: '/admin/drainage-detection-rules', icon: ScanSearch },
|
||||
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
|
||||
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
|
||||
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
|
||||
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
|
||||
import { AdminDrainageDetectionRulesPage } from '@/apps/admin/AdminDrainageDetectionRulesPage';
|
||||
import { AdminDownstreamDeliveriesPage } from '@/apps/admin/AdminDownstreamDeliveriesPage';
|
||||
import { AdminDownstreamRecoveryStatusesPage } from '@/apps/admin/AdminDownstreamRecoveryStatusesPage';
|
||||
import { AdminEnterpriseApplicationsPage } from '@/apps/admin/AdminEnterpriseApplicationsPage';
|
||||
@@ -137,6 +138,7 @@ export function AppRoutes() {
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
|
||||
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
|
||||
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
|
||||
<Route path="system-logs" element={<AdminSystemLogsPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
|
||||
@@ -801,8 +801,41 @@
|
||||
}
|
||||
|
||||
.ui-query-actions .ui-button {
|
||||
flex: 0 0 88px;
|
||||
width: 88px;
|
||||
flex: 0 0 var(--query-action-width);
|
||||
width: var(--query-action-width);
|
||||
}
|
||||
|
||||
/* Shared list-filter sizing keeps ordinary controls compact and leaves enough
|
||||
room for a complete date range without page-specific width overrides. */
|
||||
.ui-filter-row {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.ui-filter-row > .ui-field,
|
||||
.ui-filter-field {
|
||||
flex: 0 1 var(--query-control-width);
|
||||
width: min(100%, var(--query-control-width));
|
||||
}
|
||||
|
||||
.ui-filter-row > .ui-date-range-field,
|
||||
.ui-filter-field--date-range {
|
||||
flex-basis: var(--query-date-range-width);
|
||||
width: min(100%, var(--query-date-range-width));
|
||||
}
|
||||
|
||||
.ui-filter-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ui-filter-actions .ui-button {
|
||||
flex: 0 0 var(--query-action-width);
|
||||
width: var(--query-action-width);
|
||||
}
|
||||
|
||||
.ui-pagination {
|
||||
@@ -1569,6 +1602,24 @@
|
||||
.ui-modal__guard { padding: var(--space-5); }
|
||||
.ui-modal__guard footer { display: grid; }
|
||||
|
||||
.ui-filter-row > .ui-field,
|
||||
.ui-filter-field,
|
||||
.ui-filter-row > .ui-date-range-field,
|
||||
.ui-filter-field--date-range {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-filter-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-filter-actions .ui-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-query-panel__grid,
|
||||
.ui-detail-info-grid,
|
||||
.ui-detail-progress-stats,
|
||||
@@ -1583,6 +1634,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.ui-filter-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.ui-table-wrap {
|
||||
background: transparent;
|
||||
|
||||
+36
-6
@@ -132,6 +132,16 @@
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-grid--four {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard-grid--four {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@@ -6849,6 +6859,30 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage > div {
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 58px 1fr;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage > div:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__drainage b {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__rate {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -8026,14 +8060,11 @@
|
||||
}
|
||||
|
||||
.admin-user-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-user-filter-grid {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-user-toolbar > .ui-button {
|
||||
@@ -8235,7 +8266,6 @@
|
||||
.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 {
|
||||
|
||||
@@ -91,6 +91,9 @@
|
||||
--control-height-md: 38px;
|
||||
--control-height-lg: 44px;
|
||||
--control-padding-x: 12px;
|
||||
--query-control-width: 220px;
|
||||
--query-date-range-width: 320px;
|
||||
--query-action-width: 88px;
|
||||
--z-dropdown: 1000;
|
||||
--z-modal: 1100;
|
||||
--z-toast: 1200;
|
||||
|
||||
Reference in New Issue
Block a user