338 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|