feat: integrate analytics and fragment receipt improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user