feat: scope blacklists and paginate phone segments
This commit is contained in:
+12
-4
@@ -593,6 +593,13 @@ export type PagedResponse<T> = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type CursorPage<T> = {
|
||||
items: T[];
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export type EnterpriseApplication = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -957,11 +964,12 @@ export const adminApi = {
|
||||
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
|
||||
listEnterpriseBlacklist: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
|
||||
createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
|
||||
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
|
||||
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'),
|
||||
listPhoneSegments: (query: { keyword?: string; cursor?: string; pageSize?: number } = {}) =>
|
||||
request<CursorPage<DictionaryItem>>(withQuery('/admin/dictionaries/phone-segments', query)),
|
||||
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'),
|
||||
@@ -1057,7 +1065,7 @@ export const clientApi = {
|
||||
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
previewImport: (body: { content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem, type TenantOption } from '@/api/adminApi';
|
||||
import { adminApi, type DictionaryItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
|
||||
type EnterpriseBlacklistItem = DictionaryItem & {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
tenant?: TenantOption;
|
||||
application?: EnterpriseApplication;
|
||||
phoneNumber?: string;
|
||||
reason?: string | null;
|
||||
};
|
||||
@@ -13,18 +15,27 @@ type EnterpriseBlacklistItem = DictionaryItem & {
|
||||
export function AdminEnterpriseBlacklistPage() {
|
||||
const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [filterTenantId, setFilterTenantId] = useState('');
|
||||
const [filterApplicationId, setFilterApplicationId] = useState('');
|
||||
const [formTenantId, setFormTenantId] = useState('');
|
||||
const [formApplicationId, setFormApplicationId] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listEnterpriseBlacklist({ keyword }), adminApi.listTenants()])
|
||||
.then(([blacklist, tenantItems]) => {
|
||||
Promise.all([
|
||||
adminApi.listEnterpriseBlacklist({ tenantId: filterTenantId || undefined, applicationId: filterApplicationId || undefined, keyword }),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
])
|
||||
.then(([blacklist, tenantItems, applicationItems]) => {
|
||||
setItems(blacklist as EnterpriseBlacklistItem[]);
|
||||
setTenants(tenantItems);
|
||||
setApplications(applicationItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业黑名单加载失败'));
|
||||
@@ -35,12 +46,15 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.tenant?.name, item.phoneNumber, item.reason, item.status].join(' ');
|
||||
const text = [item.tenant?.name, item.application?.name, item.phoneNumber, item.reason, item.status].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
const filterApplications = applications.filter((application) => application.tenantId === filterTenantId && application.status !== 'deleted');
|
||||
const modalApplications = applications.filter((application) => application.tenantId === formTenantId && application.status !== 'deleted');
|
||||
|
||||
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
|
||||
{ key: 'application', title: '应用名称', width: '180px', render: (record) => <span>{record.application?.name ?? record.applicationId}</span> },
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
|
||||
@@ -59,9 +73,10 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
], []);
|
||||
|
||||
function addItem() {
|
||||
adminApi.createEnterpriseBlacklist({ tenantId, phoneNumber: phone, reason, status: 'active' })
|
||||
adminApi.createEnterpriseBlacklist({ tenantId: formTenantId, applicationId: formApplicationId, phoneNumber: phone, reason, status: 'active' })
|
||||
.then(() => {
|
||||
setTenantId('');
|
||||
setFormTenantId('');
|
||||
setFormApplicationId('');
|
||||
setPhone('');
|
||||
setReason('');
|
||||
setModalOpen(false);
|
||||
@@ -85,13 +100,25 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、手机号或原因"
|
||||
placeholder="搜索企业、应用、手机号或原因"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => { setFilterTenantId(event.target.value); setFilterApplicationId(''); }}
|
||||
options={[{ label: '全部企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={filterTenantId}
|
||||
/>
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setFilterApplicationId(event.target.value)}
|
||||
options={[{ label: filterTenantId ? '全部应用' : '请先选择企业', value: '' }, ...filterApplications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={filterApplicationId}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setFilterTenantId(''); setFilterApplicationId(''); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +130,7 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!tenantId || !phone} onClick={addItem}>确认添加</Button>
|
||||
<Button disabled={!formTenantId || !formApplicationId || !phone} onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
@@ -113,9 +140,15 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<div className="admin-security-form">
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
onChange={(event) => { setFormTenantId(event.target.value); setFormApplicationId(''); }}
|
||||
options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={tenantId}
|
||||
value={formTenantId}
|
||||
/>
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setFormApplicationId(event.target.value)}
|
||||
options={[{ label: formTenantId ? '请选择应用' : '请先选择企业', value: '' }, ...modalApplications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={formApplicationId}
|
||||
/>
|
||||
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
type PhoneSegment = DictionaryItem & {
|
||||
@@ -18,6 +18,7 @@ type CarrierRule = DictionaryItem & {
|
||||
};
|
||||
|
||||
export function AdminPhoneSegmentsPage() {
|
||||
const pageSize = 20;
|
||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||
@@ -33,25 +34,48 @@ export function AdminPhoneSegmentsPage() {
|
||||
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 [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()])
|
||||
.then(([segmentItems, ruleItems]) => {
|
||||
setSegments(segmentItems as PhoneSegment[]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setSegmentQuery(keyword.trim());
|
||||
setPage(1);
|
||||
setPageCursors([undefined]);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
|
||||
adminApi.listPhoneCarrierRules(),
|
||||
])
|
||||
.then(([segmentPage, ruleItems]) => {
|
||||
if (cancelled) return;
|
||||
setSegments(segmentPage.items as PhoneSegment[]);
|
||||
setHasMore(segmentPage.hasMore);
|
||||
setNextCursor(segmentPage.nextCursor);
|
||||
setRules(ruleItems as CarrierRule[]);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredSegments = useMemo(
|
||||
() => segments.filter((segment) => [segment.prefix, segment.carrier, segment.province, segment.city].some((value) => String(value ?? '').includes(keyword))),
|
||||
[keyword, segments],
|
||||
);
|
||||
.catch((failure: Error) => {
|
||||
if (!cancelled) setError(failure.message || '手机号段加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, pageCursors, reloadKey, segmentQuery]);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
|
||||
@@ -65,7 +89,9 @@ export function AdminPhoneSegmentsPage() {
|
||||
setProvince('');
|
||||
setCity('');
|
||||
setCreating(false);
|
||||
loadData();
|
||||
setPage(1);
|
||||
setPageCursors([undefined]);
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
||||
}
|
||||
@@ -76,7 +102,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
setRulePattern('');
|
||||
setRuleRemark('');
|
||||
setCreatingRule(false);
|
||||
loadData();
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
|
||||
}
|
||||
@@ -106,23 +132,6 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="phone-segment-overview">
|
||||
<section>
|
||||
<span><Smartphone size={20} /></span>
|
||||
<div>
|
||||
<strong>{segments.length}</strong>
|
||||
<p>手机号段记录</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<span><RadioTower size={20} /></span>
|
||||
<div>
|
||||
<strong>{rules.length}</strong>
|
||||
<p>运营商区分规则</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar phone-segment-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
|
||||
@@ -135,8 +144,31 @@ export function AdminPhoneSegmentsPage() {
|
||||
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{ label: `手机号段 ${segments.length}`, value: 'segments', content: <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> },
|
||||
{ label: `运营商区分规则 ${rules.length}`, value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
|
||||
{
|
||||
label: '手机号段',
|
||||
value: 'segments',
|
||||
content: (
|
||||
<>
|
||||
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
|
||||
<Pagination
|
||||
page={page}
|
||||
previousDisabled={page <= 1 || loading}
|
||||
nextDisabled={!hasMore || loading}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
onNext={() => {
|
||||
if (!nextCursor) return;
|
||||
setPageCursors((current) => {
|
||||
const updated = [...current];
|
||||
updated[page] = nextCursor;
|
||||
return updated;
|
||||
});
|
||||
setPage((current) => current + 1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ label: '运营商区分规则', value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function ClientSendPage() {
|
||||
try {
|
||||
const content = await file.text();
|
||||
const preview = await clientApi.previewImport({
|
||||
applicationId: applicationId || undefined,
|
||||
content,
|
||||
fileName: file.name,
|
||||
delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
|
||||
|
||||
@@ -8,7 +8,7 @@ type QueryPanelProps = {
|
||||
};
|
||||
|
||||
type PaginationProps = {
|
||||
total: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
previousDisabled?: boolean;
|
||||
nextDisabled?: boolean;
|
||||
@@ -42,7 +42,7 @@ export function Pagination({
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
<div className="ui-pagination">
|
||||
<span>显示 {total} 条记录</span>
|
||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||
<div>
|
||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">{page}</Button>
|
||||
|
||||
Reference in New Issue
Block a user