Files
lislgosms/src/apps/admin/AdminPhoneSegmentsPage.tsx
T

338 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Database, ListFilter, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type PhoneSegment = DictionaryItem & {
prefix?: string;
carrier?: string;
province?: string | null;
city?: string | null;
};
type CarrierRule = DictionaryItem & {
carrier?: string;
pattern?: string;
priority?: number;
remark?: string | null;
};
const carrierTone: Record<string, 'success' | 'info' | 'warning' | 'neutral'> = {
中国移动: 'success',
中国联通: 'info',
中国电信: 'warning',
};
const ruleCarrierMeta: Record<string, { label: string; tone: 'success' | 'info' | 'warning' | 'neutral' }> = {
mobile: { label: '中国移动', tone: 'success' },
unicom: { label: '中国联通', tone: 'info' },
telecom: { label: '中国电信', tone: 'warning' },
};
type PhoneSegmentSummaryProps = {
icon: ReactNode;
label: string;
description: string;
total: number;
};
function PhoneSegmentSummary({ icon, label, description, total }: PhoneSegmentSummaryProps) {
return (
<section className="phone-segment-summary" aria-label={label}>
<span className="phone-segment-summary__icon">{icon}</span>
<div className="phone-segment-summary__value">
<span>{label}</span>
<strong>{total.toLocaleString('zh-CN')}</strong>
</div>
<p>{description}</p>
</section>
);
}
export function AdminPhoneSegmentsPage() {
const pageSize = 25;
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [segmentTotal, setSegmentTotal] = useState(0);
const [rules, setRules] = useState<CarrierRule[]>([]);
const [ruleTotal, setRuleTotal] = useState(0);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
const [keyword, setKeyword] = useState('');
const [creating, setCreating] = useState(false);
const [creatingRule, setCreatingRule] = useState(false);
const [prefix, setPrefix] = useState('');
const [carrier, setCarrier] = useState('中国移动');
const [province, setProvince] = useState('');
const [city, setCity] = useState('');
const [ruleCarrier, setRuleCarrier] = useState('mobile');
const [rulePattern, setRulePattern] = useState('');
const [rulePriority, setRulePriority] = useState('100');
const [ruleRemark, setRuleRemark] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [segmentQuery, setSegmentQuery] = useState('');
const [page, setPage] = useState(1);
const [rulePage, setRulePage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
const [deleteTarget, setDeleteTarget] = useState<PhoneSegment | null>(null);
const [ruleDeleteTarget, setRuleDeleteTarget] = useState<CarrierRule | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }),
adminApi.listPhoneCarrierRules({ keyword: segmentQuery || undefined, page: rulePage, pageSize }),
])
.then(([segmentPage, ruleResponse]) => {
if (cancelled) return;
setSegments(segmentPage.items as PhoneSegment[]);
setSegmentTotal(segmentPage.total);
setRules(ruleResponse.items as CarrierRule[]);
setRuleTotal(ruleResponse.total);
setError('');
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '手机号段加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [page, reloadKey, rulePage, segmentQuery]);
const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize));
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
function createSegment() {
adminApi.createPhoneSegment({ prefix, carrier, province, city })
.then(() => {
setPrefix('');
setProvince('');
setCity('');
setCreating(false);
setPage(1);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
}
function createCarrierRule() {
adminApi.createPhoneCarrierRule({ carrier: ruleCarrier, pattern: rulePattern, priority: Number(rulePriority) || 100, remark: ruleRemark })
.then(() => {
setRulePattern('');
setRuleRemark('');
setCreatingRule(false);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
}
function query() {
setSegmentQuery(keyword.trim());
setPage(1);
setRulePage(1);
}
function reset() {
setKeyword('');
setSegmentQuery('');
setPage(1);
setRulePage(1);
}
function deleteSegment() {
if (!deleteTarget) return;
adminApi.deletePhoneSegment(deleteTarget.id)
.then(() => {
setDeleteTarget(null);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '手机号段删除失败'));
}
function deleteCarrierRule() {
if (!ruleDeleteTarget) return;
adminApi.deletePhoneCarrierRule(ruleDeleteTarget.id)
.then(() => {
setRuleDeleteTarget(null);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '运营商区分规则删除失败'));
}
const columns = useMemo<Array<TableColumn<PhoneSegment>>>(() => [
{ key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => <strong className="phone-segment-prefix">{record.prefix}</strong> },
{ key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? <Tag tone={carrierTone[record.carrier] ?? 'neutral'}>{record.carrier}</Tag> : '-' },
{ key: 'province', title: '省份', width: '110px', render: (record) => record.province ?? '-' },
{ key: 'city', title: '城市', width: '110px', render: (record) => record.city ?? '-' },
{ key: 'createdAt', title: '创建时间', width: '170px', render: (record) => formatDateTime(record.createdAt) },
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="ghost">删除</Button> },
], []);
const ruleColumns = useMemo<Array<TableColumn<CarrierRule>>>(() => [
{ key: 'carrier', title: '运营商', width: '140px', render: (record) => { const meta = ruleCarrierMeta[record.carrier ?? '']; return meta ? <Tag tone={meta.tone}>{meta.label}</Tag> : record.carrier ?? '-'; } },
{ key: 'pattern', title: '号码前缀正则', render: (record) => <strong>{record.pattern}</strong> },
{ key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 },
{ key: 'remark', title: '备注', render: (record) => record.remark ?? '-' },
{ key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => <Button className="phone-segment-delete" icon={<Trash2 size={14} />} onClick={() => setRuleDeleteTarget(record)} size="sm" variant="ghost">删除</Button> },
], []);
const queryPanel = (
<div className="phone-segment-query">
<Input aria-label="关键词" onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') query(); }} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<div className="phone-segment-query__actions">
<Button icon={<Search size={16} />} onClick={query}>查询</Button>
<Button icon={<RotateCcw size={16} />} onClick={reset} variant="ghost">重置</Button>
</div>
</div>
);
return (
<section className="page-stack admin-system-page phone-segment-workbench">
<div className="page-heading">
<div className="phone-segment-heading">
<Breadcrumb items={['系统管理', '手机号段库']} />
<p>维护号码前七位归属,用于运营商识别与路由判断</p>
</div>
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
{activeTab === 'segments' ? '新增号段' : '新增规则'}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-system-table-card phone-segment-panel">
<Tabs
className="phone-segment-workbench__tabs"
onChange={(value) => {
setActiveTab(value as 'segments' | 'rules');
}}
value={activeTab}
items={[
{
label: '手机号段',
value: 'segments',
content: (
<div className="phone-segment-tab-content">
<PhoneSegmentSummary description="当前库中可查询的号码前七位记录" icon={<Database size={22} />} label="已收录手机号段" total={segmentTotal} />
{queryPanel}
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
<Pagination
page={page}
previousDisabled={page <= 1 || loading}
nextDisabled={page >= segmentTotalPages || loading}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(segmentTotalPages, current + 1))}
onPageChange={setPage}
total={segmentTotal}
totalPages={segmentTotalPages}
/>
</div>
),
},
{
label: '运营商区分规则',
value: 'rules',
content: (
<div className="phone-segment-tab-content">
<PhoneSegmentSummary description="按优先级匹配号码前缀的识别规则" icon={<ListFilter size={22} />} label="运营商识别规则" total={ruleTotal} />
{queryPanel}
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
<Pagination
page={rulePage}
total={ruleTotal}
totalPages={ruleTotalPages}
previousDisabled={rulePage <= 1 || loading}
nextDisabled={rulePage >= ruleTotalPages || loading}
onPrevious={() => setRulePage((current) => Math.max(1, current - 1))}
onNext={() => setRulePage((current) => Math.min(ruleTotalPages, current + 1))}
onPageChange={setRulePage}
/>
</div>
),
},
]}
/>
</div>
<Modal
footer={(
<>
<Button onClick={() => setCreating(false)} variant="ghost">取消</Button>
<Button disabled={!prefix} onClick={createSegment}>保存</Button>
</>
)}
onClose={() => setCreating(false)}
open={creating}
title="新增手机号段"
>
<div className="admin-system-modal-form">
<Input label="手机号段" maxLength={7} onChange={(event) => setPrefix(event.target.value)} placeholder="手机号码前7位" value={prefix} />
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '中国移动', value: '中国移动' },
{ label: '中国联通', value: '中国联通' },
{ label: '中国电信', value: '中国电信' },
]}
value={carrier}
/>
<Input label="省份" onChange={(event) => setProvince(event.target.value)} value={province} />
<Input label="城市" onChange={(event) => setCity(event.target.value)} value={city} />
</div>
</Modal>
<Modal
footer={(
<>
<Button onClick={() => setCreatingRule(false)} variant="ghost">取消</Button>
<Button disabled={!rulePattern} onClick={createCarrierRule}>保存</Button>
</>
)}
onClose={() => setCreatingRule(false)}
open={creatingRule}
title="新增运营商区分规则"
>
<div className="admin-system-modal-form">
<Select
label="运营商"
onChange={(event) => setRuleCarrier(event.target.value)}
options={[
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={ruleCarrier}
/>
<Input label="号码前缀正则" onChange={(event) => setRulePattern(event.target.value)} placeholder="例如 ^13[4-9]" value={rulePattern} />
<Input label="优先级" onChange={(event) => setRulePriority(event.target.value)} value={rulePriority} />
<Input label="备注" onChange={(event) => setRuleRemark(event.target.value)} value={ruleRemark} />
</div>
</Modal>
{deleteTarget ? (
<Modal
footer={<><Button onClick={() => setDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteSegment} variant="danger">确认删除</Button></>}
onClose={() => setDeleteTarget(null)}
open
title="删除手机号段"
>
<p>确认删除手机号段“{deleteTarget.prefix}”吗?删除后号码归属识别将不再使用该记录。</p>
</Modal>
) : null}
{ruleDeleteTarget ? (
<Modal
footer={<><Button onClick={() => setRuleDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteCarrierRule} variant="danger">确认删除</Button></>}
onClose={() => setRuleDeleteTarget(null)}
open
title="删除运营商区分规则"
>
<p>确认删除规则“{ruleDeleteTarget.pattern}”吗?删除后运营商识别将不再使用该规则。</p>
</Modal>
) : null}
</section>
);
}