feat: 优化通道组编辑交互

This commit is contained in:
hectorzhao
2026-09-05 22:55:09 +08:00
parent 839dba8d9b
commit 1e05a643e5
14 changed files with 1752 additions and 462 deletions
@@ -0,0 +1,10 @@
# 通道组编辑交互优化方案
日期:2026-09-05。适用通道组新建与修改,用户已授权实施、提交推送及双环境发布。沿用TC-ADMIN-004路由语义,不改变真实通道价格、发送或计费规则。
- 修正历史任意合法整数优先级回显;重复通道、归一化后重复省份及全国重复优先级明确报错,绝不删除或覆盖原行。弹窗确认只更新草稿。
- 全国顺序支持上移、下移和拖拽;移动仅交换已有优先级槽位(如10/20),保留数值集合;新增通道建议下一个空闲优先级。提供明确顺序预览及撤销最近一次排序。
- 通道选择支持名称/编号/地区搜索,展示成本及真实连接状态,禁用项说明原因;活动但暂时断连的通道仍可配置,保留历史停用通道且不误称连接正常。
- 保存前展示新增、删除、调整以及基础设置变更摘要;未保存离开、刷新或后退需提示;失败保留输入,新建第二步失败保存已创建ID以便重试。请求沿用真实API,已有组status/description和成员weight/isBackup保持,不改数据模型。
- 验收:纯函数冲突/排序/摘要测试,组件交互及前端回归、TypeScript、生产构建、CSS门禁;真实API的三尺寸浏览器查询、选择、排序、撤销及退出。禁止在验收中保存既有真实通道组或发送短信。
- 发布:测试先行;预生产已确认发布完整最新版本,包含尚未发布的WPS异步解析、1项兼容migration及新解析Worker。独立恢复点与存储保护核验后再切换,不执行会改写无关配置的全量初始化脚本。
@@ -2191,3 +2191,11 @@
- 运营端右上角新增“签名导入待审”,按签名导入资料中 pending_review 的明细条数计入待审核总数,独立于正式签名待审;点击进入签名审核的导入页签。统计遵守批次tenantId范围,invalid/approved/rejected明细不计入;导入提交及审核成功后立即刷新,不预先改计数。 - 运营端右上角新增“签名导入待审”,按签名导入资料中 pending_review 的明细条数计入待审核总数,独立于正式签名待审;点击进入签名审核的导入页签。统计遵守批次tenantId范围,invalid/approved/rejected明细不计入;导入提交及审核成功后立即刷新,不预先改计数。
- 短信通道组新建/编辑的省网、全国列表展示成本价格(元/条),使用通道现有unitPrice及10000单位/元换算,保留4位小数;零成本显示0.0000,缺失通道/价格显示“—”。本次不改变通道选择、路由优先级和保存行为。 - 短信通道组新建/编辑的省网、全国列表展示成本价格(元/条),使用通道现有unitPrice及10000单位/元换算,保留4位小数;零成本显示0.0000,缺失通道/价格显示“—”。本次不改变通道选择、路由优先级和保存行为。
### 2026-09-05 通道组编辑交互优化
- 新建和修改通道组均使用草稿编辑:添加、编辑、删除、排序只改变当前页面草稿,最终确认保存时才调用真实接口。保存前展示基础设置、成员新增/删除和顺序调整摘要;保存失败保留输入。新建时若组已创建但成员保存失败,重试不得重复创建通道组。
- 全国通道支持上移、下移和拖拽,移动时交换已有合法优先级槽位并保留历史优先级集合;支持撤销最近一次排序。历史任意 PostgreSQL `integer` 范围内优先级均可回显和编辑,新增项使用下一个未占用优先级。
- 重复通道、归一化后重复省份及全国重复优先级必须明确阻止,不得静默覆盖已有成员。保存时保留已有组的状态、描述以及成员权重和主备属性。
- 通道选择支持按名称、编号和地区搜索,直接展示成本价格、适用地区、运营商和真实连接回写。已删除、运营商不匹配、地区不匹配或已在本组配置的通道不可选择并说明原因;活动但暂时断连的通道允许配置。
- 页面加载失败时禁止编辑和保存;存在未保存修改时,返回、路由切换、浏览器后退或刷新须提示。桌面、常用笔记本和 390px 窄屏均应可完成上述交互,且不得产生页面级横向溢出。
+12
View File
@@ -5103,3 +5103,15 @@ npm run verify:phase8
| TC-CHANNEL-COST-001 | 新建/编辑通道组,查看省网和全国列表,分别使用非零、零及缺失价格 | 成本价格按现有单位换算为元/条并保留4位;零显示0.0000,缺失显示—;三尺寸表格内部滚动,无整页溢出;不修改价格和路由 | | TC-CHANNEL-COST-001 | 新建/编辑通道组,查看省网和全国列表,分别使用非零、零及缺失价格 | 成本价格按现有单位换算为元/条并保留4位;零显示0.0000,缺失显示—;三尺寸表格内部滚动,无整页溢出;不修改价格和路由 |
上述通知口径增加签名导入这一类审核任务,替代TC-ADMIN-NOTICE-0715-09中“仅五类”的数量限定;下游投递告警仍不计入待审核任务。 上述通知口径增加签名导入这一类审核任务,替代TC-ADMIN-NOTICE-0715-09中“仅五类”的数量限定;下游投递告警仍不计入待审核任务。
## TC-CHANNEL-GROUP-EDITOR-20260905 通道组编辑交互
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-CHANNEL-GROUP-EDITOR-001 | 打开含历史优先级10/20、权重和主备设置的通道组并上移、下移或拖拽全国通道 | 页面交换现有优先级槽位、顺序预览同步变化;撤销恢复原顺序,权重和主备属性保持 |
| TC-CHANNEL-GROUP-EDITOR-002 | 新增或编辑时选择重复通道、重复省份或重复全国优先级 | 页面明确显示冲突并保持原有行,不静默替换、删除或覆盖 |
| TC-CHANNEL-GROUP-EDITOR-003 | 在通道选择弹窗按名称、编号或地区搜索 | 结果来自真实通道API并展示成本、地区、运营商和连接回写;不可选项显示具体原因,活动但暂时断连的通道仍可选择 |
| TC-CHANNEL-GROUP-EDITOR-004 | 修改基础设置、成员或顺序后点击保存 | 保存前确认弹窗展示新增、删除、调整及基础设置摘要;取消不调用写接口,确认后才按草稿统一保存 |
| TC-CHANNEL-GROUP-EDITOR-005 | 新建通道组时创建成功但成员保存失败,随后重试 | 页面保留草稿和已创建组ID,只重试后续保存,不重复创建通道组 |
| TC-CHANNEL-GROUP-EDITOR-006 | 加载接口失败,或有未保存修改时返回、跨路由、后退和刷新 | 加载失败时编辑与保存不可用并显示真实错误;未保存离开均提示,取消后保留草稿 |
| TC-CHANNEL-GROUP-EDITOR-007 | 在1600×1000、1366×768和390×844执行排序、撤销、搜索、冲突校验和保存预览 | 三尺寸均可操作且无页面级横向溢出;控制台无新增错误;验收不保存真实通道组、不修改通道配置、不发送短信 |
+8
View File
@@ -4564,3 +4564,11 @@ git diff --check
- 后续交互建议(未实施):现有历史优先级10/20与编辑下拉1—5不兼容;同优先级/同省份会过滤原行。建议先修正历史值回显和冲突处理,再加入全国路由上下移动/拖拽、明确顺序预览、可搜索通道选择及未保存提示;与本次成本展示分开实施。 - 后续交互建议(未实施):现有历史优先级10/20与编辑下拉1—5不兼容;同优先级/同省份会过滤原行。建议先修正历史值回显和冲突处理,再加入全国路由上下移动/拖拽、明确顺序预览、可搜索通道选择及未保存提示;与本次成本展示分开实施。
- 提交前补充:签名审核页页签由URL直接派生,修正已停留在该页时通知只改query而未切换页签的问题;新增同路由query切换回归测试通过。该文件另有HEAD已存在的1条preserve-manual-memoization错误和1条依赖警告,未扩展整改。 - 提交前补充:签名审核页页签由URL直接派生,修正已停留在该页时通知只改query而未切换页签的问题;新增同路由query切换回归测试通过。该文件另有HEAD已存在的1条preserve-manual-memoization错误和1条依赖警告,未扩展整改。
- 最终前端全量14套66项通过,最终TypeScript/Vite生产构建通过;本轮共13个目标文件精确暂存,已有文档段落保持未暂存。 - 最终前端全量14套66项通过,最终TypeScript/Vite生产构建通过;本轮共13个目标文件精确暂存,已有文档段落保持未暂存。
## 2026-09-05 通道组编辑交互优化(发布前验证)
- 按用户确认方案完成通道组新建/修改交互:草稿编辑、历史优先级兼容、显式冲突校验、全国通道上移/下移/拖拽与撤销、顺序预览、可搜索通道选择、成本/地区/运营商/真实连接回写、不可选原因、保存摘要和未保存离开保护。保存继续使用真实API,保留既有组状态/描述及成员权重/主备属性;新建部分失败后以已创建ID重试,避免重复创建。
- 新增页面及弹窗私有CSS,由各自TSX直接导入并登记所有权;未重建global.css、未向14个历史兼容模块追加规则、未增加`!important`或宽泛标签选择器,也未改变domains/index.css顺序。
- 自动验证:通道组纯函数、选择弹窗和整页交互定向3套24项通过;前端全量17套90项、API全量55套620项通过。format:check、Stylelint、CSS治理15项、TypeScript/Vite生产构建、API构建、安全、部署契约和包体积门禁通过;既有Chart包体提示不影响构建。
- 候选页面通过测试环境真实API/PostgreSQL只读验收,所有业务写请求由浏览器路由拦截。1600×1000、1366×768、390×844均完成排序/撤销、重复优先级拦截、搜索空态、保存前预览和离开取消;无页面级横向溢出,pageerror和console error为0。验收未保存真实通道组、未修改成本或通道配置、未发送/补发/重投/重新入队短信。
- 测试与预生产发布前已分别建立独立恢复点并核验运行版本、服务、数据库迁移及存储保护。预生产从旧版本发布完整最新版本,预计新增1项向后兼容报备解析迁移及独立解析Worker;实际提交、推送、双环境切换和发布后真实验收结果在完成后追加。
@@ -0,0 +1,29 @@
.channel-group-editor .channel-group-editor__fields {
display: grid;
gap: 20px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.channel-group-editor .channel-group-editor__order {
display: flex;
align-items: center;
gap: 4px;
cursor: grab;
}
.channel-group-editor .channel-group-editor__preview {
margin: 12px 0;
padding: 12px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
overflow-wrap: anywhere;
}
.channel-group-editor .channel-group-editor__summary {
padding: 12px;
color: var(--color-text-secondary, #6b7280);
overflow-wrap: anywhere;
}
@@ -0,0 +1,261 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { AdminChannelGroupFormPage } from './AdminChannelGroupFormPage';
vi.mock('@/api/adminApi', () => ({
adminApi: {
listChannels: vi.fn(),
listChannelGroups: vi.fn(),
createChannelGroup: vi.fn(),
updateChannelGroup: vi.fn(),
},
}));
const channels: AdminChannel[] = ['A', 'B', 'C'].map((name) => ({
id: `channel-${name}`,
code: `CH-${name}`,
name: `通道${name}`,
carrier: 'mobile',
carriers: ['mobile'],
sendRegion: '全国',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
account: 'test-account',
srcId: '10690000',
rateLimitPerSecond: 10,
unitPrice: 321,
status: 'active',
connectionStates: [],
}));
const group: ChannelGroup = {
id: 'group-existing',
code: 'CG-EXISTING',
name: '验收通道组',
carrier: 'mobile',
status: 'disabled',
retryEnabled: true,
retryTimeLimitMinutes: 750,
items: [
{
id: 'route-A',
groupId: 'group-existing',
channelId: 'channel-A',
carrier: 'mobile',
priority: 10,
weight: 3,
isBackup: false,
},
{
id: 'route-B',
groupId: 'group-existing',
channelId: 'channel-B',
carrier: 'mobile',
priority: 20,
weight: 2,
isBackup: true,
},
],
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(adminApi.listChannels).mockResolvedValue(structuredClone(channels));
vi.mocked(adminApi.listChannelGroups).mockResolvedValue([structuredClone(group)]);
vi.mocked(adminApi.updateChannelGroup).mockResolvedValue(structuredClone(group));
});
afterEach(() => vi.restoreAllMocks());
function renderEditor(id = group.id) {
return render(
<MemoryRouter initialEntries={[`/admin/channel-groups/${id}`]}>
<Routes>
<Route path="/admin/channel-groups/:groupId" element={<AdminChannelGroupFormPage />} />
<Route path="/admin/channel-groups" element={<h1></h1>} />
</Routes>
</MemoryRouter>,
);
}
function nationalRows() {
return within(screen.getAllByRole('table')[1]).getAllByRole('row').slice(1);
}
async function submitSave(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: '保存修改' }));
const dialog = await screen.findByRole('dialog', { name: '确认保存通道组' });
await user.click(within(dialog).getByRole('button', { name: '确认保存' }));
}
describe('channel group editor interactions', () => {
it('keeps historical priority slots when moving routes and can undo before saving', async () => {
const user = userEvent.setup();
renderEditor();
await screen.findByDisplayValue('验收通道组');
expect(nationalRows()[0]).toHaveTextContent('1 / 10');
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
expect(screen.getByRole('button', { name: '上移通道A' })).toBeDisabled();
expect(screen.getByRole('button', { name: '下移通道B' })).toBeDisabled();
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
await user.click(screen.getByRole('button', { name: '上移通道B' }));
expect(nationalRows()[0]).toHaveTextContent('通道B');
expect(nationalRows()[0]).toHaveTextContent('1 / 10');
expect(nationalRows()[1]).toHaveTextContent('通道A');
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
expect(screen.getByRole('status')).toHaveTextContent('调整 2');
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '撤销排序' }));
expect(nationalRows()[0]).toHaveTextContent('通道A');
expect(nationalRows()[1]).toHaveTextContent('通道B');
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
});
it('shows historical priority in the editor and rejects a duplicate without replacing either route', async () => {
const user = userEvent.setup();
renderEditor();
await screen.findByDisplayValue('验收通道组');
await user.click(within(nationalRows()[0]).getByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog', { name: '编辑通道' });
const priority = within(dialog).getByRole('spinbutton', { name: /优先级/ });
expect(priority).toHaveValue(10);
await user.clear(priority);
await user.type(priority, '20');
await user.click(within(dialog).getByRole('button', { name: '确认' }));
expect(within(dialog).getByRole('alert')).toHaveTextContent('全国通道优先级不能重复');
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
await user.clear(priority);
await user.type(priority, '30');
await user.click(within(dialog).getByRole('button', { name: '确认' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(nationalRows()).toHaveLength(2);
expect(nationalRows()[0]).toHaveTextContent('通道B');
expect(nationalRows()[0]).toHaveTextContent('1 / 20');
expect(nationalRows()[1]).toHaveTextContent('通道A');
expect(nationalRows()[1]).toHaveTextContent('2 / 30');
});
it('keeps changed order and input after a failed save and preserves historical metadata on retry', async () => {
const user = userEvent.setup();
vi.mocked(adminApi.updateChannelGroup).mockRejectedValueOnce(new Error('保存服务暂不可用'));
renderEditor();
await screen.findByDisplayValue('验收通道组');
const name = screen.getByRole('textbox', { name: /通道组名称/ });
await user.clear(name);
await user.type(name, '保留本次输入');
await user.click(screen.getByRole('button', { name: '上移通道B' }));
await submitSave(user);
expect(await screen.findByRole('alert')).toHaveTextContent('保存服务暂不可用');
expect(name).toHaveValue('保留本次输入');
expect(nationalRows()[0]).toHaveTextContent('通道B');
expect(screen.getByRole('status')).toHaveTextContent('有未保存修改');
expect(screen.queryByRole('heading', { name: '通道组列表' })).not.toBeInTheDocument();
expect(adminApi.updateChannelGroup).toHaveBeenLastCalledWith(
'group-existing',
expect.objectContaining({
name: '保留本次输入',
status: 'disabled',
retryTimeLimitMinutes: 750,
items: [
expect.objectContaining({ channelId: 'channel-B', priority: 10, weight: 2, isBackup: true }),
expect.objectContaining({ channelId: 'channel-A', priority: 20, weight: 3, isBackup: false }),
],
}),
);
await submitSave(user);
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
expect(adminApi.updateChannelGroup).toHaveBeenCalledTimes(2);
expect(adminApi.createChannelGroup).not.toHaveBeenCalled();
});
it('retries a partially created group without creating a second empty group', async () => {
const user = userEvent.setup();
vi.mocked(adminApi.createChannelGroup).mockResolvedValue({ ...group, id: 'created-once', items: [] });
vi.mocked(adminApi.updateChannelGroup).mockRejectedValueOnce(new Error('成员保存失败'));
renderEditor('new');
const name = screen.getByRole('textbox', { name: /通道组名称/ });
await waitFor(() => expect(name).toBeEnabled());
await user.type(name, '新增草稿');
await user.click(screen.getByRole('button', { name: '添加全国通道' }));
const dialog = screen.getByRole('dialog', { name: '添加通道' });
await user.click(within(dialog).getByRole('radio', { name: '选择通道 通道CCH-C' }));
await user.click(within(dialog).getByRole('button', { name: '确认' }));
await submitSave(user);
expect(await screen.findByRole('alert')).toHaveTextContent('成员保存失败');
expect(name).toHaveValue('新增草稿');
expect(nationalRows()[0]).toHaveTextContent('通道C');
await submitSave(user);
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
expect(adminApi.createChannelGroup).toHaveBeenCalledTimes(1);
expect(adminApi.updateChannelGroup).toHaveBeenCalledTimes(2);
for (const call of vi.mocked(adminApi.updateChannelGroup).mock.calls) {
expect(call[0]).toBe('created-once');
expect(call[1].items).toEqual([expect.objectContaining({ channelId: 'channel-C', priority: 10 })]);
}
});
it('disables editing and saving after a load error until real data is successfully reloaded', async () => {
const user = userEvent.setup();
vi.mocked(adminApi.listChannels).mockRejectedValueOnce(new Error('通道读取失败'));
renderEditor();
expect(await screen.findByRole('alert')).toHaveTextContent('通道读取失败');
expect(screen.getByRole('textbox', { name: /通道组名称/ })).toBeDisabled();
expect(screen.getByRole('button', { name: '添加全国通道' })).toBeDisabled();
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
expect(adminApi.createChannelGroup).not.toHaveBeenCalled();
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '重新加载' }));
expect(await screen.findByDisplayValue('验收通道组')).toBeEnabled();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(nationalRows()).toHaveLength(2);
});
it('does not let an old draft save into a new group while a cross-route load is pending', async () => {
const user = userEvent.setup();
const otherGroup = { ...group, id: 'group-other', name: '另一个通道组', items: [] };
let resolveChannels!: (value: AdminChannel[]) => void;
const pendingChannels = new Promise<AdminChannel[]>((resolve) => {
resolveChannels = resolve;
});
vi.mocked(adminApi.listChannels)
.mockResolvedValueOnce(structuredClone(channels))
.mockReturnValueOnce(pendingChannels);
vi.mocked(adminApi.listChannelGroups).mockResolvedValue([group, otherGroup]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<MemoryRouter initialEntries={['/admin/channel-groups/group-existing']}>
<Link to="/admin/channel-groups/group-other"></Link>
<Routes>
<Route path="/admin/channel-groups/:groupId" element={<AdminChannelGroupFormPage />} />
<Route path="/admin/channel-groups" element={<h1></h1>} />
</Routes>
</MemoryRouter>,
);
const oldName = await screen.findByDisplayValue('验收通道组');
await user.clear(oldName);
await user.type(oldName, '旧组未保存草稿');
await user.click(screen.getByRole('link', { name: '切换通道组' }));
expect(await screen.findByText('正在加载真实通道组配置...')).toBeVisible();
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
expect(screen.getByRole('textbox', { name: /通道组名称/ })).toBeDisabled();
expect(screen.queryByDisplayValue('旧组未保存草稿')).not.toBeInTheDocument();
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
await act(async () => resolveChannels(structuredClone(channels)));
const newName = await screen.findByDisplayValue('另一个通道组');
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
await user.type(newName, '已修改');
await submitSave(user);
expect(adminApi.updateChannelGroup).toHaveBeenCalledWith(
'group-other',
expect.objectContaining({ name: '另一个通道组已修改', items: [] }),
);
expect(await screen.findByRole('heading', { name: '通道组列表' })).toBeVisible();
});
});
+459 -425
View File
@@ -1,508 +1,542 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, GripVertical, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency'; import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { RouteConfigModal, type RouteModalState } from './channel-groups/RouteConfigModal';
import {
validateRoutes,
validateRouteCandidate,
moveNationalRoute,
nextAvailablePriority,
summarizeRouteChanges,
type ProvinceRoute,
type NationalRoute,
} from './channel-groups/model';
import { useUnsavedChanges } from './channel-groups/useUnsavedChanges';
import './AdminChannelGroupFormPage.css';
type Carrier = 'mobile' | 'unicom' | 'telecom'; type Carrier = 'mobile' | 'unicom' | 'telecom';
type ChannelStatus = 'normal' | 'stopped'; type Draft = {
type ProvinceRoute = { name: string;
id: string;
province: string;
channelId: string;
status: ChannelStatus;
};
type NationalRoute = {
id: string;
priority: number;
channelId: string;
status: ChannelStatus;
};
type RouteModalState = {
type: 'province' | 'national';
mode: 'create' | 'edit';
route?: ProvinceRoute | NationalRoute;
};
const priorityOptions = [
{ label: '请选择', value: '' },
{ label: '1', value: '1' },
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '5', value: '5' },
];
const carrierLabels: Record<Carrier, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
};
const statusLabels: Record<ChannelStatus, string> = {
normal: '链接正常',
stopped: '通道停用',
};
const statusTones: Record<ChannelStatus, 'success' | 'neutral'> = {
normal: 'success',
stopped: 'neutral',
};
function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
}
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
}
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
if (channel?.status !== 'active') return 'stopped';
return (channel.connectionStates ?? []).some((connection) =>
connection.status === 'connected'
&& connection.desiredConnections > 0
&& connection.currentConnections > 0
) ? 'normal' : 'stopped';
}
function StatusTag({ status }: { status: ChannelStatus }) {
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
}
function RouteConfigModal({
channels,
carrier,
modal,
occupiedChannelIds,
onClose,
onSubmit,
}: {
channels: AdminChannel[];
carrier: Carrier; carrier: Carrier;
modal: RouteModalState; retryEnabled: boolean;
occupiedChannelIds: string[]; hours: string;
onClose: () => void; minutes: string;
onSubmit: (route: ProvinceRoute | NationalRoute) => void; provinceRoutes: ProvinceRoute[];
}) { nationalRoutes: NationalRoute[];
const provinceRoute = modal.type === 'province' ? modal.route as ProvinceRoute | undefined : undefined; };
const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined; const emptyDraft: Draft = {
const [province, setProvince] = useState(provinceRoute?.province ?? ''); name: '',
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : ''); carrier: 'mobile',
const [channelId, setChannelId] = useState(modal.route?.channelId ?? ''); retryEnabled: true,
const [error, setError] = useState(''); hours: '12',
minutes: '0',
const provinceOptions = [ provinceRoutes: [],
{ label: '请选择省份', value: '' }, nationalRoutes: [],
...Array.from(new Set(channels };
.filter((channel) => isCarrierCompatible(channel, carrier)) const carrierLabels: Record<Carrier, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
.map((channel) => channel.sendRegion) function channelStatus(channel?: AdminChannel) {
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')), if (channel?.status !== 'active') return '通道停用';
)).sort().map((region) => ({ label: region, value: region })), return (channel.connectionStates ?? []).some(
]; (x) => x.status === 'connected' && x.currentConnections > 0 && x.desiredConnections > 0,
)
const selectableChannels = channels.filter((channel) => { ? '连接正常'
if (!isCarrierCompatible(channel, carrier)) return false; : '暂未连接';
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
if (modal.type === 'province' && province) {
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
} }
return true; function fromGroup(group?: ChannelGroup): Draft {
}); const minutes = group?.retryTimeLimitMinutes ?? (group?.retryTimeLimitHours ?? 12) * 60;
const channelOptions = [ return {
{ label: '请选择', value: '' }, name: group?.name ?? '',
...selectableChannels.map((channel) => ({ carrier: group?.carrier ?? 'mobile',
label: `${channel.name}${channel.code} / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`, retryEnabled: group?.retryEnabled ?? true,
value: channel.id, hours: String(Math.floor(minutes / 60)),
})), minutes: String(minutes % 60),
]; provinceRoutes: (group?.items ?? [])
.filter((x) => x.province)
function submit() { .map((x) => ({ id: x.id, channelId: x.channelId, province: x.province! })),
if (!channelId) { nationalRoutes: (group?.items ?? [])
setError('请选择可用通道'); .filter((x) => !x.province)
return; .map((x) => ({ id: x.id, channelId: x.channelId, priority: x.priority }))
.sort((a, b) => a.priority - b.priority),
};
} }
const channel = channels.find((item) => item.id === channelId);
if (modal.type === 'province') {
if (!province) {
setError('请选择省份');
return;
}
onSubmit({
id: provinceRoute?.id ?? `p-${Date.now()}`,
province,
channelId,
status: getChannelStatus(channel),
});
return;
}
if (!priority) {
setError('请选择优先级');
return;
}
onSubmit({
id: nationalRoute?.id ?? `n-${Date.now()}`,
priority: Number(priority),
channelId,
status: getChannelStatus(channel),
});
}
return (
<Modal
footer={(
<>
<Button onClick={submit}></Button>
<Button onClick={onClose} variant="ghost"></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={(
<div className="channel-route-modal__title">
<span><RadioTower size={20} /></span>
<div><h2>{modal.mode === 'edit' ? '编辑通道' : '添加通道'}</h2><p>{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}</p></div>
</div>
)}
>
<div className="channel-route-modal">
{modal.type === 'province' ? (
<Select label="* 选择省份" onChange={(event) => { setProvince(event.target.value); setChannelId(''); }} options={provinceOptions} value={province} />
) : (
<>
<Select label="* 优先级" onChange={(event) => setPriority(event.target.value)} options={priorityOptions} value={priority} />
<div className="channel-route-modal__note">
<Info size={17} />
<span></span>
</div>
</>
)}
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
{selectableChannels.length === 0 ? <p className="channel-route-modal__empty"></p> : null}
{channelId ? (() => {
const selected = channels.find((channel) => channel.id === channelId);
if (!selected) return null;
const status = getChannelStatus(selected);
return (
<div className="channel-route-modal__selected">
<CheckCircle2 size={18} />
<div>
<strong>{selected.name}</strong>
<span>{selected.code} · {selected.sendRegion ?? '全国'} · {statusLabels[status]}</span>
</div>
</div>
);
})() : null}
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>
);
}
export function AdminChannelGroupFormPage() { export function AdminChannelGroupFormPage() {
const navigate = useNavigate();
const { groupId } = useParams(); const { groupId } = useParams();
return <ChannelGroupEditor key={groupId ?? 'new'} />;
}
function ChannelGroupEditor() {
const { groupId } = useParams();
const navigate = useNavigate();
const editing = Boolean(groupId && groupId !== 'new'); const editing = Boolean(groupId && groupId !== 'new');
const [channels, setChannels] = useState<AdminChannel[]>([]); const [channels, setChannels] = useState<AdminChannel[]>([]);
const [groupName, setGroupName] = useState(''); const [loadedGroup, setLoadedGroup] = useState<ChannelGroup>();
const [carrier, setCarrier] = useState<Carrier>('mobile'); const [draft, setDraft] = useState<Draft>(emptyDraft);
const [retryEnabled, setRetryEnabled] = useState(true); const [baseline, setBaseline] = useState<Draft>();
const [retryLimitHours, setRetryLimitHours] = useState('12');
const [retryLimitMinutes, setRetryLimitMinutes] = useState('0');
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
const [modal, setModal] = useState<RouteModalState | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loadVersion, setLoadVersion] = useState(0);
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]); const [modal, setModal] = useState<RouteModalState | null>(null);
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [ const [confirmSave, setConfirmSave] = useState(false);
{ key: 'province', title: '省份', width: '160px', render: (route) => route.province }, const [undoOrder, setUndoOrder] = useState<NationalRoute[] | null>(null);
{ const [dragId, setDragId] = useState<string | null>(null);
key: 'channel', const createdId = useRef<string | null>(null);
title: '通道名称', const bypassLeave = useRef(false);
width: '280px', const dirty = Boolean(baseline && JSON.stringify(draft) !== JSON.stringify(baseline));
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道', useUnsavedChanges(dirty, bypassLeave);
}, const channelById = useMemo(() => new Map(channels.map((x) => [x.id, x])), [channels]);
{ const changes = summarizeRouteChanges(baseline ?? emptyDraft, draft);
key: 'cost', const baseChanged =
title: '成本价格(元/条)', baseline &&
width: '180px', ['name', 'carrier', 'retryEnabled', 'hours', 'minutes'].some(
render: (route) => { (k) => draft[k as keyof Draft] !== baseline[k as keyof Draft],
const price = channelById.get(route.channelId)?.unitPrice; );
return price == null ? '—' : formatRateAmount(price / MONEY_UNITS_PER_YUAN); function change(patch: Partial<Draft>) {
}, setDraft((current) => ({ ...current, ...patch }));
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'province', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
{ key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority },
{
key: 'channel',
title: '通道名称',
width: '280px',
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
},
{
key: 'cost',
title: '成本价格(元/条)',
width: '180px',
render: (route) => {
const price = channelById.get(route.channelId)?.unitPrice;
return price == null ? '—' : formatRateAmount(price / MONEY_UNITS_PER_YUAN);
},
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'national', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
function applyGroup(group: ChannelGroup) {
setGroupName(group.name);
setCarrier(group.carrier);
setRetryEnabled(group.retryEnabled ?? true);
const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
setRetryLimitHours(String(Math.floor(retryMinutes / 60)));
setRetryLimitMinutes(String(retryMinutes % 60));
setProvinceRoutes((group.items ?? [])
.filter((item) => item.province)
.map((item) => ({
id: item.id,
province: item.province ?? '',
channelId: item.channelId,
status: getChannelStatus(item.channel),
})));
setNationalRoutes((group.items ?? [])
.filter((item) => !item.province)
.map((item) => ({
id: item.id,
priority: item.priority,
channelId: item.channelId,
status: getChannelStatus(item.channel),
}))
.sort((a, b) => a.priority - b.priority));
} }
function loadData() {
setLoading(true);
Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
.then(([channelItems, groups]) => {
setChannels(channelItems.filter((channel) => channel.status !== 'deleted'));
if (editing && groupId) {
const group = groups.find((item) => item.id === groupId);
if (!group) throw new Error('通道组不存在或已被删除');
applyGroup(group);
}
setError('');
})
.catch((reason: Error) => setError(reason.message || '通道组表单加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => { useEffect(() => {
loadData(); let cancelled = false;
}, [groupId]); Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
.then(([items, groups]) => {
if (cancelled) return;
const group = editing ? groups.find((x) => x.id === groupId) : undefined;
if (editing && !group) throw new Error('通道组不存在或已被删除');
const initial = fromGroup(group);
setChannels(items);
setLoadedGroup(group);
setDraft(initial);
setBaseline(initial);
setError('');
setLoading(false);
})
.catch((reason: Error) => {
if (!cancelled) {
setError(reason.message || '加载失败');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [editing, groupId, loadVersion]);
function moveRoute(sourceId: string, targetId: string) {
setUndoOrder(draft.nationalRoutes);
change({ nationalRoutes: moveNationalRoute(draft.nationalRoutes, sourceId, targetId) });
}
function saveRoute(route: ProvinceRoute | NationalRoute) { function saveRoute(route: ProvinceRoute | NationalRoute) {
if (modal?.type === 'province') { const message = validateRouteCandidate(route, draft.provinceRoutes, draft.nationalRoutes);
const nextRoute = route as ProvinceRoute; if (message) return message;
setProvinceRoutes((current) => { if ('province' in route)
const withoutSameProvince = current.filter((item) => item.id === nextRoute.id || item.province !== nextRoute.province); change({
const exists = withoutSameProvince.some((item) => item.id === nextRoute.id); provinceRoutes: draft.provinceRoutes.some((x) => x.id === route.id)
return exists ? withoutSameProvince.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSameProvince, nextRoute]; ? draft.provinceRoutes.map((x) => (x.id === route.id ? route : x))
: [...draft.provinceRoutes, route],
}); });
} else { else
const nextRoute = route as NationalRoute; change({
setNationalRoutes((current) => { nationalRoutes: (draft.nationalRoutes.some((x) => x.id === route.id)
const withoutSamePriority = current.filter((item) => item.id === nextRoute.id || item.priority !== nextRoute.priority); ? draft.nationalRoutes.map((x) => (x.id === route.id ? route : x))
const exists = withoutSamePriority.some((item) => item.id === nextRoute.id); : [...draft.nationalRoutes, route]
const next = exists ? withoutSamePriority.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSamePriority, nextRoute]; ).sort((a, b) => a.priority - b.priority),
return [...next].sort((a, b) => a.priority - b.priority);
}); });
} setUndoOrder(null);
setModal(null); setModal(null);
return null;
} }
function channelColumns<T extends ProvinceRoute | NationalRoute>(): TableColumn<T>[] {
function buildItems() {
return [ return [
...provinceRoutes.map((route) => ({ {
channelId: route.channelId, key: 'channel',
carrier, title: '通道名称 / 编号',
province: route.province, width: '250px',
priority: 100, render: (route) => (
})), <div>
...nationalRoutes.map((route) => ({ <strong>{channelById.get(route.channelId)?.name ?? '已删除通道'}</strong>
channelId: route.channelId, <div className="muted">{channelById.get(route.channelId)?.code ?? route.channelId}</div>
carrier, </div>
priority: route.priority, ),
})), },
{
key: 'cost',
title: '成本价格(元/条)',
width: '170px',
render: (route) => {
const price = channelById.get(route.channelId)?.unitPrice;
return price == null ? '—' : formatRateAmount(price / MONEY_UNITS_PER_YUAN);
},
},
{
key: 'status',
title: '通道状态',
width: '130px',
render: (route) => {
const status = channelStatus(channelById.get(route.channelId));
return <Tag tone={status === '连接正常' ? 'success' : 'neutral'}>{status}</Tag>;
},
},
{
key: 'actions',
title: '操作',
width: '150px',
render: (route) => (
<div className="channel-group-row-actions">
<Button
size="sm"
icon={<Pencil size={14} />}
variant="ghost"
onClick={() =>
setModal(
'province' in route
? { type: 'province', mode: 'edit', route }
: { type: 'national', mode: 'edit', route },
)
}
>
</Button>
<Button
size="sm"
icon={<Trash2 size={14} />}
variant="ghost"
onClick={() => {
if ('province' in route)
change({ provinceRoutes: draft.provinceRoutes.filter((x) => x.id !== route.id) });
else change({ nationalRoutes: draft.nationalRoutes.filter((x) => x.id !== route.id) });
setUndoOrder(null);
}}
>
</Button>
</div>
),
},
]; ];
} }
const provinceColumns: TableColumn<ProvinceRoute>[] = [
function saveGroup() { { key: 'province', title: '省份', width: '140px', render: (x) => x.province },
if (!groupName.trim()) { ...channelColumns<ProvinceRoute>(),
];
const nationalColumns: TableColumn<NationalRoute>[] = [
{
key: 'priority',
title: '顺序 / 优先级',
width: '190px',
render: (route, index) => (
<div
className="channel-group-editor__order"
draggable={!saving}
onDragStart={() => setDragId(route.id)}
onDragEnd={() => setDragId(null)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
if (dragId && dragId !== route.id) moveRoute(dragId, route.id);
setDragId(null);
}}
>
<GripVertical aria-label="拖动调整顺序" size={16} />
<span>
{index + 1} / {route.priority}
</span>
<Button
aria-label={'上移' + channelById.get(route.channelId)?.name}
disabled={saving || index === 0}
size="sm"
onClick={() => moveRoute(route.id, draft.nationalRoutes[index - 1].id)}
>
<ArrowUp size={14} />
</Button>
<Button
aria-label={'下移' + channelById.get(route.channelId)?.name}
disabled={saving || index === draft.nationalRoutes.length - 1}
size="sm"
onClick={() => moveRoute(route.id, draft.nationalRoutes[index + 1].id)}
>
<ArrowDown size={14} />
</Button>
</div>
),
},
...channelColumns<NationalRoute>(),
];
async function saveGroup(confirmed = false) {
if (saving) return;
const routeError = validateRoutes(draft.provinceRoutes, draft.nationalRoutes);
if (routeError) {
setError(routeError);
return;
}
if (!draft.name.trim()) {
setError('请输入通道组名称'); setError('请输入通道组名称');
return; return;
} }
const retryHours = Number(retryLimitHours); const hours = Number(draft.hours),
const retryMinutes = Number(retryLimitMinutes); minutes = Number(draft.minutes),
if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) { total = hours * 60 + minutes;
setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数'); if (
!draft.hours.trim() ||
!draft.minutes.trim() ||
!Number.isInteger(hours) ||
hours < 0 ||
hours > 72 ||
!Number.isInteger(minutes) ||
minutes < 0 ||
minutes > 59 ||
total < 1 ||
total > 4320
) {
setError('补发时间需为1分钟至72小时,小时和分钟必须为整数');
return; return;
} }
const retryTimeLimitMinutes = retryHours * 60 + retryMinutes; if (!confirmed) {
if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) { setError('');
setError('补发时间上限需大于 0 分钟且不超过 72 小时'); setConfirmSave(true);
return; return;
} }
const original = (id: string) => loadedGroup?.items?.find((x) => x.id === id);
const payload = { const payload = {
name: groupName.trim(), name: draft.name.trim(),
carrier, carrier: draft.carrier,
status: 'active', status: loadedGroup?.status ?? 'active',
retryEnabled, description: loadedGroup?.description ?? undefined,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), retryEnabled: draft.retryEnabled,
retryTimeLimitMinutes, retryTimeLimitHours: Math.ceil(total / 60),
items: buildItems(), retryTimeLimitMinutes: total,
items: [
...draft.provinceRoutes.map((x) => ({
channelId: x.channelId,
carrier: draft.carrier,
province: x.province,
priority: original(x.id)?.priority ?? 100,
weight: original(x.id)?.weight,
isBackup: original(x.id)?.isBackup,
})),
...draft.nationalRoutes.map((x) => ({
channelId: x.channelId,
carrier: draft.carrier,
priority: x.priority,
weight: original(x.id)?.weight,
isBackup: original(x.id)?.isBackup,
})),
],
}; };
setSaving(true); setSaving(true);
setError(''); setError('');
const request = editing && groupId try {
? adminApi.updateChannelGroup(groupId, payload) let targetId = editing ? groupId : createdId.current;
: adminApi.createChannelGroup({ if (!targetId) {
code: `CG-${Date.now()}`, const group = await adminApi.createChannelGroup({
code: 'CG-' + Date.now(),
name: payload.name, name: payload.name,
carrier, carrier: draft.carrier,
status: 'active', status: payload.status,
retryEnabled, retryEnabled: draft.retryEnabled,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60), retryTimeLimitHours: payload.retryTimeLimitHours,
retryTimeLimitMinutes, retryTimeLimitMinutes: total,
}).then((group) => adminApi.updateChannelGroup(group.id, payload)); });
targetId = group.id;
request createdId.current = targetId;
.then(() => navigate('/admin/channel-groups'))
.catch((reason: Error) => setError(reason.message || '通道组保存失败'))
.finally(() => setSaving(false));
} }
await adminApi.updateChannelGroup(targetId, payload);
bypassLeave.current = true;
navigate('/admin/channel-groups');
} catch (reason) {
setError(reason instanceof Error ? reason.message : '保存失败');
setConfirmSave(false);
} finally {
setSaving(false);
}
}
const routeName = (id: string) => channelById.get(id)?.name ?? id;
const orderText = draft.nationalRoutes.map((x) => routeName(x.channelId)).join(' → ') || '尚未配置';
return ( return (
<div className="page-stack channel-group-form-page"> <div className="page-stack channel-group-form-page channel-group-editor">
<div className="page-heading"> <div className="page-heading">
<div>
<Breadcrumb items={[editing ? '编辑短信通道组' : '添加短信通道组']} /> <Breadcrumb items={[editing ? '编辑短信通道组' : '添加短信通道组']} />
</div> </div>
</div>
{loading ? <p className="muted">...</p> : null} {loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null} {error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
{!loading && !baseline ? (
<Button
onClick={() => {
setLoading(true);
setLoadVersion((x) => x + 1);
}}
>
</Button>
) : null}
<fieldset disabled={loading || saving || !baseline} className="channel-group-editor__fields">
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<div className="channel-group-base-form"> <div className="channel-group-base-form">
<Input label="* 通道组名称" onChange={(event) => setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} /> <Input label="* 通道组名称" value={draft.name} onChange={(e) => change({ name: e.target.value })} />
<div className="channel-group-radio-row"> <div className="channel-group-radio-row">
<span>* </span> <span>* </span>
{(Object.keys(carrierLabels) as Carrier[]).map((item) => ( {(Object.keys(carrierLabels) as Carrier[]).map((x) => (
<label key={item}> <label key={x}>
<input checked={carrier === item} onChange={() => setCarrier(item)} type="radio" /> <input type="radio" checked={draft.carrier === x} onChange={() => change({ carrier: x })} />
{carrierLabels[item]} {carrierLabels[x]}
</label> </label>
))} ))}
</div> </div>
<div className="channel-group-switch-row"> <div className="channel-group-switch-row">
<span>* </span> <span>* </span>
<button aria-pressed={retryEnabled} className={retryEnabled ? 'is-on' : ''} onClick={() => setRetryEnabled((current) => !current)} type="button"> <button
aria-label="失败补发"
aria-pressed={draft.retryEnabled}
className={draft.retryEnabled ? 'is-on' : ''}
onClick={() => change({ retryEnabled: !draft.retryEnabled })}
type="button"
>
<i /> <i />
</button> </button>
</div> </div>
<div className="channel-group-retry-limit"> <div className="channel-group-retry-limit">
<span></span> <span></span>
<Input <Input
disabled={!retryEnabled} disabled={!draft.retryEnabled}
max="72"
min="0"
onChange={(event) => setRetryLimitHours(event.target.value)}
suffix="小时"
type="number" type="number"
value={retryLimitHours} min="0"
max="72"
suffix="小时"
value={draft.hours}
onChange={(e) => change({ hours: e.target.value })}
/> />
<Input <Input
disabled={!retryEnabled} disabled={!draft.retryEnabled}
max="59"
min="0"
onChange={(event) => setRetryLimitMinutes(event.target.value)}
suffix="分钟"
type="number" type="number"
value={retryLimitMinutes} min="0"
max="59"
suffix="分钟"
value={draft.minutes}
onChange={(e) => change({ minutes: e.target.value })}
/> />
<small> 1 72 12 0 </small> <small>172</small>
</div> </div>
</div> </div>
</section> </section>
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" pagination={false} rowKey="id" /> <Table
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost"> columns={provinceColumns}
data={draft.provinceRoutes}
emptyText="暂无省网通道"
rowKey="id"
pagination={false}
/>
<Button
icon={<Plus size={16} />}
onClick={() => setModal({ type: 'province', mode: 'create' })}
variant="ghost"
>
</Button> </Button>
</section> </section>
<section className="surface channel-group-form-section"> <section className="surface channel-group-form-section">
<h2></h2> <h2></h2>
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" pagination={false} rowKey="id" /> <p className="muted">使</p>
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost"> <div className="channel-group-editor__preview">{orderText}</div>
<Button
disabled={!undoOrder}
icon={<Undo2 size={14} />}
onClick={() => {
if (undoOrder) change({ nationalRoutes: undoOrder });
setUndoOrder(null);
}}
variant="ghost"
>
</Button>
<Table
columns={nationalColumns}
data={draft.nationalRoutes}
emptyText="暂无全国通道"
rowKey="id"
pagination={false}
/>
<Button
icon={<Plus size={16} />}
onClick={() => setModal({ type: 'national', mode: 'create' })}
variant="ghost"
>
</Button> </Button>
</section> </section>
</fieldset>
<div className="channel-group-form-footer"> <div className="channel-group-editor__summary" role="status">
<Button disabled={saving || loading} onClick={saveGroup}>{saving ? '保存中...' : '确认'}</Button> {dirty ? '有未保存修改' : '当前配置未修改'} · {changes.added.length} · {changes.removed.length} · {' '}
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost"></Button> {changes.updated.length}
{baseChanged ? ' · 基础设置已修改' : ''}
</div> </div>
<div className="channel-group-form-footer">
<Button disabled={saving || loading || !baseline || !dirty} onClick={() => void saveGroup()}>
{saving ? '保存中...' : '保存修改'}
</Button>
<Button
disabled={saving}
variant="ghost"
onClick={() => {
if (!dirty || window.confirm('有未保存的修改,确认离开?')) {
bypassLeave.current = true;
navigate('/admin/channel-groups');
}
}}
>
</Button>
</div>
<Modal
open={confirmSave}
title="确认保存通道组"
onClose={() => {
if (!saving) setConfirmSave(false);
}}
footer={
<>
<Button disabled={saving} onClick={() => setConfirmSave(false)} variant="ghost">
</Button>
<Button disabled={saving} onClick={() => void saveGroup(true)}>
{saving ? '保存中...' : '确认保存'}
</Button>
</>
}
>
<div className="channel-group-editor__summary">
<p>
{changes.added.length} {changes.removed.length} {changes.updated.length}
</p>
{baseChanged ? (
<p>
{draft.name} / {carrierLabels[draft.carrier]} /{' '}
{draft.retryEnabled ? '启用补发' : '关闭补发'} / {draft.hours}{draft.minutes}
</p>
) : null}
{changes.added.map((x) => (
<p key={'add' + x.channelId}>{routeName(x.channelId)}</p>
))}
{changes.removed.map((x) => (
<p key={'remove' + x.channelId}>{routeName(x.channelId)}</p>
))}
<p>{orderText}</p>
<p></p>
</div>
</Modal>
{modal ? ( {modal ? (
<RouteConfigModal <RouteConfigModal
carrier={carrier}
channels={channels} channels={channels}
carrier={draft.carrier}
modal={modal} modal={modal}
occupiedChannelIds={[ occupiedChannelIds={[...draft.provinceRoutes, ...draft.nationalRoutes].map((x) => x.channelId)}
...provinceRoutes.map((route) => route.channelId), nextPriority={nextAvailablePriority(draft.nationalRoutes)}
...nationalRoutes.map((route) => route.channelId),
]}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onSubmit={saveRoute} onSubmit={saveRoute}
/> />
@@ -0,0 +1,140 @@
.channel-route-editor .channel-route-editor__content {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.channel-route-editor .channel-route-editor__note,
.channel-route-editor .channel-route-editor__selected {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.channel-route-editor .channel-route-editor__note svg,
.channel-route-editor .channel-route-editor__selected svg {
flex-shrink: 0;
margin-top: 2px;
}
.channel-route-editor .channel-route-editor__choices {
display: grid;
min-width: 0;
gap: var(--space-2);
padding: 0;
margin: 0;
border: 0;
}
.channel-route-editor .channel-route-editor__choices legend {
padding: 0 0 var(--space-3);
font-weight: var(--font-weight-semibold);
}
.channel-route-editor .channel-route-editor__choice {
display: grid;
grid-template-columns: 20px minmax(160px, 1.5fr) minmax(135px, 1fr) minmax(75px, 0.6fr) minmax(130px, 1fr);
align-items: center;
gap: var(--space-3);
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
cursor: pointer;
}
.channel-route-editor .channel-route-editor__choice:hover,
.channel-route-editor .channel-route-editor__choice:focus-within {
border-color: var(--color-selected);
}
.channel-route-editor .channel-route-editor__choice.is-selected {
border-color: var(--color-selected);
background: var(--color-selected-soft);
}
.channel-route-editor .channel-route-editor__choice.is-disabled {
background: var(--color-bg-subtle);
cursor: not-allowed;
}
.channel-route-editor .channel-route-editor__choice input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--color-selected);
}
.channel-route-editor .channel-route-editor__identity,
.channel-route-editor .channel-route-editor__facts,
.channel-route-editor .channel-route-editor__connection {
display: grid;
justify-items: start;
min-width: 0;
gap: var(--space-1);
overflow-wrap: anywhere;
}
.channel-route-editor .channel-route-editor__identity strong {
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
.channel-route-editor .channel-route-editor__code,
.channel-route-editor .channel-route-editor__facts,
.channel-route-editor .channel-route-editor__reason {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.channel-route-editor .channel-route-editor__facts strong {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
}
.channel-route-editor .channel-route-editor__carriers {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.channel-route-editor .channel-route-editor__selected {
color: var(--color-selected);
overflow-wrap: anywhere;
}
.channel-route-editor .channel-route-editor__empty {
padding: var(--space-6);
margin: 0;
color: var(--color-text-muted);
text-align: center;
}
.channel-route-editor .channel-route-editor__error {
margin: 0;
color: var(--color-danger);
}
@media (width <= 760px) {
.channel-route-editor .channel-route-editor__choice {
grid-template-columns: 20px minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
}
.channel-route-editor .channel-route-editor__identity {
grid-column: 2 / -1;
}
.channel-route-editor .channel-route-editor__facts:nth-child(3) {
grid-column: 2;
}
.channel-route-editor .channel-route-editor__connection {
grid-column: 2 / -1;
}
}
@@ -0,0 +1,145 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { AdminChannel } from '@/api/adminApi';
import { RouteConfigModal } from './RouteConfigModal';
const channel = (id: string, overrides: Partial<AdminChannel> = {}): AdminChannel => ({
id,
name: `通道${id}`,
code: `CODE-${id}`,
carriers: ['mobile'],
sendRegion: '广东省',
gatewayHost: '127.0.0.1',
gatewayPort: 7890,
account: 'test',
srcId: '106',
rateLimitPerSecond: 1,
unitPrice: 325,
status: 'active',
...overrides,
});
describe('RouteConfigModal', () => {
it('keeps historical priority and exposes costs while permitting a disconnected active channel', async () => {
const onSubmit = vi.fn(() => null);
const current = channel('current', { connectionStates: [] });
render(
<RouteConfigModal
channels={[current]}
carrier="mobile"
modal={{ type: 'national', mode: 'edit', route: { id: 'route-1', channelId: current.id, priority: 20 } }}
occupiedChannelIds={[current.id]}
nextPriority={30}
onClose={vi.fn()}
onSubmit={onSubmit}
/>,
);
expect(screen.getByRole('spinbutton', { name: /优先级/ })).toHaveValue(20);
expect(screen.getByText('0.0325')).toBeVisible();
expect(screen.getByText('暂无连接回写')).toBeVisible();
expect(screen.getByRole('radio')).toBeEnabled();
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
expect(onSubmit).toHaveBeenCalledWith({ id: 'route-1', channelId: 'current', priority: 20 });
});
it('shows unavailable reasons and keeps the selected channel while searching', async () => {
const channels = [
channel('available'),
channel('occupied'),
channel('deleted', { status: 'deleted' }),
channel('telecom', { carriers: ['telecom'] }),
channel('disabled', { status: 'disabled' }),
];
render(
<RouteConfigModal
channels={channels}
carrier="mobile"
modal={{ type: 'national', mode: 'create' }}
occupiedChannelIds={['occupied']}
nextPriority={30}
onClose={vi.fn()}
onSubmit={() => null}
/>,
);
for (const id of ['occupied', 'deleted', 'telecom'])
expect(screen.getByRole('radio', { name: new RegExp(`CODE-${id}`) })).toBeDisabled();
expect(screen.getByText('已在当前通道组中配置')).toBeVisible();
expect(screen.getByText('通道已删除')).toBeVisible();
expect(screen.getByText('不支持移动')).toBeVisible();
expect(screen.getByText('已停用')).toBeVisible();
expect(screen.getByRole('radio', { name: /CODE-disabled/ })).toBeEnabled();
await userEvent.click(screen.getByRole('radio', { name: /CODE-available/ }));
await userEvent.type(screen.getByRole('textbox', { name: '搜索通道' }), 'CODE-telecom');
expect(screen.getAllByRole('radio')).toHaveLength(1);
expect(screen.getByText('已选:通道availableCODE-available')).toBeVisible();
});
it('disables region mismatches but preserves a historical province option', () => {
render(
<RouteConfigModal
channels={[channel('other', { sendRegion: '江苏省' })]}
carrier="mobile"
modal={{ type: 'province', mode: 'edit', route: { id: 'p-1', channelId: 'missing', province: '浙江省' } }}
occupiedChannelIds={[]}
nextPriority={10}
onClose={vi.fn()}
onSubmit={() => null}
/>,
);
expect(screen.getByText('浙江省')).toBeVisible();
expect(screen.getByRole('radio')).toBeDisabled();
expect(screen.getByText('通道地区与所选省份不匹配')).toBeVisible();
expect(screen.getByRole('alert')).toHaveTextContent('原通道不存在,请重新选择');
});
it('retains form values after parent conflict rejection and rejects a fractional priority', async () => {
const onSubmit = vi.fn(() => '同一通道组内全国通道优先级不能重复');
const onClose = vi.fn();
render(
<RouteConfigModal
channels={[channel('1')]}
carrier="mobile"
modal={{ type: 'national', mode: 'create' }}
occupiedChannelIds={[]}
nextPriority={20}
onClose={onClose}
onSubmit={onSubmit}
/>,
);
await userEvent.click(screen.getByRole('radio'));
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
expect(screen.getByRole('alert')).toHaveTextContent('优先级不能重复');
expect(screen.getByRole('radio')).toBeChecked();
expect(onClose).not.toHaveBeenCalled();
const input = screen.getByRole('spinbutton', { name: /优先级/ });
fireEvent.change(input, { target: { value: '1.5' } });
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
expect(screen.getByRole('alert')).toHaveTextContent('整数');
expect(onSubmit).toHaveBeenCalledTimes(1);
fireEvent.change(input, { target: { value: '-10' } });
await userEvent.click(screen.getByRole('button', { name: /^确认$/ }));
expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ priority: -10 }));
});
it('uses the shared unsaved guard when dismissing a changed selection', async () => {
const onClose = vi.fn();
render(
<RouteConfigModal
channels={[channel('1')]}
carrier="mobile"
modal={{ type: 'national', mode: 'create' }}
occupiedChannelIds={[]}
nextPriority={10}
onClose={onClose}
onSubmit={() => null}
/>,
);
await userEvent.click(screen.getByRole('radio'));
await userEvent.click(screen.getByRole('button', { name: /^取消$/ }));
expect(screen.getByRole('alertdialog')).toHaveTextContent('放弃未保存的修改');
expect(onClose).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole('button', { name: '继续编辑' }));
expect(screen.getByRole('radio')).toBeChecked();
});
});
@@ -0,0 +1,263 @@
import { useId, useState } from 'react';
import { CheckCircle2, Info, Search } from 'lucide-react';
import type { AdminChannel } from '@/api/adminApi';
import { Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
import { isValidPriority, normalizeRegion, type NationalRoute, type ProvinceRoute } from './model';
import './RouteConfigModal.css';
type Carrier = 'mobile' | 'unicom' | 'telecom';
export type RouteModalState = {
type: 'province' | 'national';
mode: 'create' | 'edit';
route?: ProvinceRoute | NationalRoute;
};
const carrierLabels: Record<Carrier, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) {
return channel.carriers?.length
? channel.carriers.includes(carrier)
: !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier;
}
function connectionLabel(channel: AdminChannel) {
if (channel.status === 'deleted') return { text: '已删除', tone: 'neutral' as const };
if (channel.status !== 'active') return { text: '已停用', tone: 'neutral' as const };
const states = channel.connectionStates ?? [];
const connected = states.filter(
(state) => state.status === 'connected' && state.desiredConnections > 0 && state.currentConnections > 0,
);
if (connected.length) {
return {
text: `已连接 · ${connected.reduce((sum, state) => sum + state.currentConnections, 0)}`,
tone: 'success' as const,
};
}
if (!states.length) return { text: '暂无连接回写', tone: 'neutral' as const };
return {
text: states.some((state) => state.status === 'connecting') ? '连接中' : '未连接',
tone: 'warning' as const,
};
}
export function RouteConfigModal({
channels,
carrier,
modal,
occupiedChannelIds,
nextPriority,
onClose,
onSubmit,
}: {
channels: AdminChannel[];
carrier: Carrier;
modal: RouteModalState;
occupiedChannelIds: string[];
nextPriority: number;
onClose: () => void;
onSubmit: (route: ProvinceRoute | NationalRoute) => string | null;
}) {
const provinceRoute = modal.type === 'province' ? (modal.route as ProvinceRoute | undefined) : undefined;
const nationalRoute = modal.type === 'national' ? (modal.route as NationalRoute | undefined) : undefined;
const initialPriority = String(nationalRoute?.priority ?? nextPriority);
const [province, setProvince] = useState(provinceRoute?.province ?? '');
const [priority, setPriority] = useState(initialPriority);
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const selectionName = useId();
const selected = channels.find((channel) => channel.id === channelId);
const dirty =
province !== (provinceRoute?.province ?? '') ||
priority !== initialPriority ||
channelId !== (modal.route?.channelId ?? '');
const provinces = channels
.filter((channel) => isCarrierCompatible(channel, carrier))
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国'));
if (provinceRoute?.province) provinces.push(provinceRoute.province);
const provinceOptions = [
{ label: '请选择省份', value: '' },
...Array.from(new Set(provinces))
.sort()
.map((region) => ({ label: region, value: region })),
];
function unavailableReason(channel: AdminChannel) {
if (channel.status === 'deleted') return '通道已删除';
if (!isCarrierCompatible(channel, carrier)) return `不支持${carrierLabels[carrier]}`;
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return '已在当前通道组中配置';
if (modal.type === 'province') {
if (!province) return '请先选择省份';
if (normalizeRegion(channel.sendRegion) !== normalizeRegion(province)) return '通道地区与所选省份不匹配';
}
return '';
}
const query = keyword.trim().toLocaleLowerCase();
const visibleChannels = channels.filter((channel) =>
`${channel.name} ${channel.code} ${channel.sendRegion ?? '全国'}`.toLocaleLowerCase().includes(query),
);
const availableCount = visibleChannels.filter((channel) => !unavailableReason(channel)).length;
function submit() {
if (modal.type === 'province' && !province) {
setError('请选择省份');
return;
}
if (!selected) {
setError(channelId ? '原通道不存在,请重新选择' : '请选择通道');
return;
}
const reason = unavailableReason(selected);
if (reason) {
setError(reason);
return;
}
if (modal.type === 'national' && (!priority.trim() || !isValidPriority(Number(priority)))) {
setError('优先级需为 -2147483648 到 2147483647 的整数');
return;
}
const route =
modal.type === 'province'
? { id: provinceRoute?.id ?? `p-${selectionName}`, province, channelId }
: { id: nationalRoute?.id ?? `n-${selectionName}`, priority: Number(priority), channelId };
setError(onSubmit(route) ?? '');
}
return (
<Modal
className="channel-route-editor"
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost">
</Button>
<Button onClick={submit}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
>
<div className="channel-route-editor__content">
{modal.type === 'province' ? (
<Select
label="选择省份"
required
onChange={(event) => {
setProvince(event.target.value);
setChannelId('');
setError('');
}}
options={provinceOptions}
value={province}
/>
) : (
<Input
label="优先级"
required
type="number"
step="1"
min="-2147483648"
max="2147483647"
onChange={(event) => {
setPriority(event.target.value);
setError('');
}}
value={priority}
hint="数值越小越先使用;失败补发会跳到下一优先级。"
/>
)}
<Input
label="搜索通道"
onChange={(event) => setKeyword(event.target.value)}
placeholder="输入通道名称、编号或地区"
prefix={<Search aria-hidden="true" size={16} />}
value={keyword}
/>
<p className="channel-route-editor__note">
<Info aria-hidden="true" size={16} />
<span></span>
</p>
<fieldset className="channel-route-editor__choices">
<legend>
· {visibleChannels.length} {availableCount}
</legend>
{visibleChannels.map((channel) => {
const reason = unavailableReason(channel);
const connection = connectionLabel(channel);
return (
<label
className={`channel-route-editor__choice${channelId === channel.id ? ' is-selected' : ''}${reason ? ' is-disabled' : ''}`}
key={channel.id}
>
<input
aria-label={`选择通道 ${channel.name}${channel.code}`}
checked={channelId === channel.id}
disabled={Boolean(reason)}
name={selectionName}
onChange={() => {
setChannelId(channel.id);
setError('');
}}
type="radio"
value={channel.id}
/>
<span className="channel-route-editor__identity">
<strong>{channel.name}</strong>
<span className="channel-route-editor__code">{channel.code}</span>
<span className="channel-route-editor__carriers">
{(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).map((item) => (
<CarrierTag carrier={item} key={item} />
))}
</span>
</span>
<span className="channel-route-editor__facts">
<span>/</span>
<strong>
{channel.unitPrice == null ? '—' : formatRateAmount(channel.unitPrice / MONEY_UNITS_PER_YUAN)}
</strong>
</span>
<span className="channel-route-editor__facts">
<span></span>
<strong>{channel.sendRegion || '全国'}</strong>
</span>
<span className="channel-route-editor__connection">
<Tag tone={connection.tone}>{connection.text}</Tag>
{reason ? <span className="channel-route-editor__reason">{reason}</span> : null}
</span>
</label>
);
})}
{visibleChannels.length === 0 ? (
<p className="channel-route-editor__empty">
{channels.length ? '没有匹配的通道,请调整搜索条件' : '暂无通道数据'}
</p>
) : null}
</fieldset>
{selected ? (
<p className="channel-route-editor__selected">
<CheckCircle2 aria-hidden="true" size={17} />
<span>
{selected.name}{selected.code}
</span>
</p>
) : null}
{channelId && !selected ? (
<p className="channel-route-editor__error" role="alert">
</p>
) : null}
{error ? (
<p className="channel-route-editor__error" role="alert">
{error}
</p>
) : null}
</div>
</Modal>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import {
isValidPriority,
moveNationalRoute,
nextAvailablePriority,
normalizeRegion,
summarizeRouteChanges,
validateRouteCandidate,
validateRoutes,
type NationalRoute,
type ProvinceRoute,
} from './model';
const national = (id: string, priority: number): NationalRoute => ({
id,
channelId: `channel-${id}`,
priority,
});
const province = (id: string, region: string): ProvinceRoute => ({
id,
channelId: `channel-${id}`,
province: region,
});
describe('channel group route validation', () => {
it('uses the same province aliases as the backend contract', () => {
expect(normalizeRegion(' 广西壮族自治区 ')).toBe('广西');
expect(normalizeRegion('新疆维吾尔自治区')).toBe('新疆');
expect(normalizeRegion('北京市')).toBe('北京');
expect(normalizeRegion(null)).toBe('');
expect(validateRoutes([province('a', '山东'), province('b', '山东省')], [])).toContain('同一省份');
});
it('rejects duplicate channels across provincial and national routes', () => {
expect(validateRoutes([province('a', '山东省')], [national('a', 10)])).toContain('同一通道');
expect(validateRoutes([], [national('a', 10), national('b', 10)])).toContain('优先级不能重复');
});
it('allows historical priorities and editing the current route without removing another row', () => {
const routes = [national('a', 10), national('b', 20)];
const snapshot = structuredClone(routes);
expect(validateRoutes([], routes)).toBeNull();
expect(validateRouteCandidate(national('a', 10), [], routes)).toBeNull();
expect(validateRouteCandidate(national('a', 20), [], routes)).toContain('优先级不能重复');
expect(routes).toEqual(snapshot);
expect(validateRouteCandidate(province('a', '山东'), [province('a', '山东省')], [])).toBeNull();
});
it('rejects incomplete rows and priority values PostgreSQL cannot persist', () => {
expect(validateRoutes([{ ...province('a', '山东'), channelId: '' }], [])).toBe('请选择通道');
expect(validateRoutes([province('a', '全国')], [])).toContain('省份');
expect(validateRoutes([province('a', '')], [])).toContain('省份');
for (const priority of [NaN, Infinity, 1.5, 2147483648, -2147483649]) {
expect(isValidPriority(priority)).toBe(false);
expect(validateRoutes([], [national('a', priority)])).toContain('整数');
}
for (const priority of [-2147483648, -10, 0, 10, 20, 2147483647]) {
expect(isValidPriority(priority)).toBe(true);
}
});
});
describe('national route movement', () => {
it('moves down and up by identity while preserving historical priority slots and route metadata', () => {
const routes = [
{ ...national('a', 10), status: 'normal' },
{ ...national('b', 20), status: 'stopped' },
{ ...national('c', 100), status: 'normal' },
];
const original = structuredClone(routes);
const moved = moveNationalRoute(routes, 'a', 'c');
expect(moved.map(({ id, priority, status }) => ({ id, priority, status }))).toEqual([
{ id: 'b', priority: 10, status: 'stopped' },
{ id: 'c', priority: 20, status: 'normal' },
{ id: 'a', priority: 100, status: 'normal' },
]);
expect(moveNationalRoute(moved, 'a', 'b')).toEqual(original);
expect(routes).toEqual(original);
});
it('uses priority order even if an API array arrives unsorted', () => {
const moved = moveNationalRoute([national('b', 20), national('a', 10)], 'b', 'a');
expect(moved).toEqual([national('b', 10), national('a', 20)]);
});
it('leaves stale drag targets and invalid duplicate priorities unchanged', () => {
const routes = [national('a', 10), national('b', 20)];
expect(moveNationalRoute(routes, 'missing', 'a')).toEqual(routes);
expect(moveNationalRoute(routes, 'a', 'missing')).toEqual(routes);
expect(moveNationalRoute(routes, 'a', 'a')).toEqual(routes);
const invalid = [national('a', 10), national('b', 10)];
expect(moveNationalRoute(invalid, 'a', 'b')).toEqual(invalid);
});
});
describe('next available priority', () => {
it('starts at ten and advances from normal historical priorities', () => {
expect(nextAvailablePriority([])).toBe(10);
expect(nextAvailablePriority([national('a', 10), national('b', 20)])).toBe(30);
});
it('stays inside the PostgreSQL integer range when the highest slot is occupied', () => {
expect(nextAvailablePriority([national('a', 2147483647), national('b', 2147483637)])).toBe(2147483627);
});
});
describe('channel group save summary', () => {
it('reports additions, removals, route changes and national relative order separately', () => {
const before = {
provinceRoutes: [province('p', '山东'), province('q', '河南')],
nationalRoutes: [national('a', 10), national('b', 20)],
};
const after = {
provinceRoutes: [province('p', '河北'), province('r', '北京')],
nationalRoutes: moveNationalRoute(before.nationalRoutes, 'b', 'a'),
};
const summary = summarizeRouteChanges(before, after);
expect(summary.added).toEqual([province('r', '北京')]);
expect(summary.removed).toEqual([province('q', '河南')]);
expect(summary.updated.map((change) => change.after.channelId)).toEqual(['channel-p', 'channel-b', 'channel-a']);
expect(summary.orderChanged).toBe(true);
});
it('ignores recreated member IDs, connection metadata and equivalent province aliases', () => {
const summary = summarizeRouteChanges(
{ provinceRoutes: [province('p', '广西')], nationalRoutes: [national('a', 10)] },
{
provinceRoutes: [{ ...province('p', '广西壮族自治区'), id: 'new-p' }],
nationalRoutes: [{ ...national('a', 10), id: 'new-a' }],
},
);
expect(summary).toEqual({ added: [], removed: [], updated: [], orderChanged: false });
});
it('does not claim a reorder for adding or removing a channel without changing surviving order', () => {
const summary = summarizeRouteChanges(
{
provinceRoutes: [],
nationalRoutes: [national('a', 10), national('b', 20), national('c', 30)],
},
{
provinceRoutes: [],
nationalRoutes: [national('a', 10), national('d', 15), national('c', 30)],
},
);
expect(summary.added).toHaveLength(1);
expect(summary.removed).toHaveLength(1);
expect(summary.orderChanged).toBe(false);
});
it('reports moving the same channel from province to national as a configuration change', () => {
const summary = summarizeRouteChanges(
{ provinceRoutes: [province('a', '山东')], nationalRoutes: [] },
{ provinceRoutes: [], nationalRoutes: [national('a', 10)] },
);
expect(summary.added).toEqual([]);
expect(summary.removed).toEqual([]);
expect(summary.updated).toEqual([{ before: province('a', '山东'), after: national('a', 10) }]);
});
});
+155
View File
@@ -0,0 +1,155 @@
export type ProvinceRoute = {
id: string;
channelId: string;
province: string;
};
export type NationalRoute = {
id: string;
channelId: string;
priority: number;
};
export type Route = ProvinceRoute | NationalRoute;
export type RouteConfiguration = {
provinceRoutes: readonly ProvinceRoute[];
nationalRoutes: readonly NationalRoute[];
};
export type RouteChangeSummary = {
added: Route[];
removed: Route[];
updated: Array<{ before: Route; after: Route }>;
orderChanged: boolean;
};
export function normalizeRegion(region?: string | null) {
return String(region ?? '')
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
.trim();
}
export function isValidPriority(priority: number) {
// Match PostgreSQL Int without narrowing the existing API's historical values.
return Number.isInteger(priority) && priority >= -2147483648 && priority <= 2147483647;
}
export function nextAvailablePriority(routes: readonly NationalRoute[]) {
const used = new Set(routes.map((route) => route.priority));
if (!used.size) return 10;
const highest = Math.max(...used);
if (highest <= 2147483637 && !used.has(highest + 10)) return highest + 10;
const lowest = Math.min(...used);
if (lowest >= -2147483638 && !used.has(lowest - 10)) return lowest - 10;
// Among routes.length + 1 consecutive integers, at least one is unused.
for (let candidate = 0; candidate <= routes.length; candidate += 1) {
if (!used.has(candidate)) return candidate;
}
return 0;
}
export function validateRoutes(
provinceRoutes: readonly ProvinceRoute[],
nationalRoutes: readonly NationalRoute[],
): string | null {
const channels = new Set<string>();
for (const route of [...provinceRoutes, ...nationalRoutes]) {
if (!route.channelId) return '请选择通道';
if (channels.has(route.channelId)) return '通道组内不能重复配置同一通道';
channels.add(route.channelId);
}
const provinces = new Set<string>();
for (const route of provinceRoutes) {
const province = normalizeRegion(route.province);
if (!province || province === '全国') return '请选择省网路由的省份';
if (provinces.has(province)) return '同一通道组内同一省份只能配置一个通道';
provinces.add(province);
}
const priorities = new Set<number>();
for (const route of nationalRoutes) {
if (!isValidPriority(route.priority)) return '优先级必须为 -2147483648 到 2147483647 的整数';
if (priorities.has(route.priority)) return '同一通道组内全国通道优先级不能重复';
priorities.add(route.priority);
}
return null;
}
export function validateRouteCandidate(
candidate: Route,
provinceRoutes: readonly ProvinceRoute[],
nationalRoutes: readonly NationalRoute[],
): string | null {
const provinces = provinceRoutes.filter((route) => route.id !== candidate.id);
const nationals = nationalRoutes.filter((route) => route.id !== candidate.id);
if ('province' in candidate) provinces.push(candidate);
else nationals.push(candidate);
return validateRoutes(provinces, nationals);
}
export function moveNationalRoute<T extends NationalRoute>(
routes: readonly T[],
sourceId: string,
targetId: string,
): T[] {
const ordered = [...routes].sort((left, right) => left.priority - right.priority);
const sourceIndex = ordered.findIndex((route) => route.id === sourceId);
const targetIndex = ordered.findIndex((route) => route.id === targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return [...routes];
if (validateRoutes([], ordered)) return [...routes];
// A move changes which channel occupies each existing priority slot. It must
// not rewrite historical values such as 10/20 to an unrelated 1/2 sequence.
const priorities = ordered.map((route) => route.priority);
const [moved] = ordered.splice(sourceIndex, 1);
ordered.splice(targetIndex, 0, moved);
return ordered.map((route, index) => ({ ...route, priority: priorities[index] }));
}
function configuredRoutes(configuration: RouteConfiguration): Route[] {
return [
...configuration.provinceRoutes,
...[...configuration.nationalRoutes].sort((left, right) => left.priority - right.priority),
];
}
function routeChanged(before: Route, after: Route) {
if ('province' in before && 'province' in after) {
return normalizeRegion(before.province) !== normalizeRegion(after.province);
}
if ('priority' in before && 'priority' in after) return before.priority !== after.priority;
return true;
}
export function summarizeRouteChanges(before: RouteConfiguration, after: RouteConfiguration): RouteChangeSummary {
const beforeRoutes = configuredRoutes(before);
const afterRoutes = configuredRoutes(after);
// The API recreates member IDs on save; the unique channel is the stable
// business identity for changes to its province or national priority.
const beforeByChannel = new Map(beforeRoutes.map((route) => [route.channelId, route]));
const afterByChannel = new Map(afterRoutes.map((route) => [route.channelId, route]));
const updated: RouteChangeSummary['updated'] = [];
for (const route of afterRoutes) {
const previous = beforeByChannel.get(route.channelId);
if (previous && routeChanged(previous, route)) updated.push({ before: previous, after: route });
}
const beforeNationalChannels = new Set(before.nationalRoutes.map((route) => route.channelId));
const afterNationalChannels = new Set(after.nationalRoutes.map((route) => route.channelId));
const commonOrder = (routes: readonly NationalRoute[], other: Set<string>) =>
[...routes]
.sort((left, right) => left.priority - right.priority)
.filter((route) => other.has(route.channelId))
.map((route) => route.channelId);
const beforeOrder = commonOrder(before.nationalRoutes, afterNationalChannels);
const afterOrder = commonOrder(after.nationalRoutes, beforeNationalChannels);
return {
added: afterRoutes.filter((route) => !beforeByChannel.has(route.channelId)),
removed: beforeRoutes.filter((route) => !afterByChannel.has(route.channelId)),
updated,
orderChanged: beforeOrder.some((channelId, index) => channelId !== afterOrder[index]),
};
}
@@ -0,0 +1,53 @@
import { useEffect, type RefObject } from 'react';
export function useUnsavedChanges(dirty: boolean, bypass: RefObject<boolean>) {
useEffect(() => {
if (!dirty) return;
let currentIndex = window.history.state?.idx as number | undefined;
const confirmLeave = () => bypass.current || window.confirm('有未保存的修改,确认离开?');
const beforeUnload = (event: BeforeUnloadEvent) => {
if (bypass.current) return;
event.preventDefault();
event.returnValue = '';
};
const click = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (
!(link instanceof HTMLAnchorElement) ||
link.target === '_blank' ||
link.hasAttribute('download') ||
link.href === window.location.href
)
return;
if (!confirmLeave()) {
event.preventDefault();
event.stopPropagation();
}
};
const pop = (event: PopStateEvent) => {
const nextIndex = event.state?.idx as number | undefined;
if (nextIndex === currentIndex) return;
if (!confirmLeave() && typeof currentIndex === 'number' && typeof nextIndex === 'number') {
event.stopImmediatePropagation();
window.history.go(currentIndex - nextIndex);
} else currentIndex = nextIndex;
};
window.addEventListener('beforeunload', beforeUnload);
document.addEventListener('click', click, true);
window.addEventListener('popstate', pop, true);
return () => {
window.removeEventListener('beforeunload', beforeUnload);
document.removeEventListener('click', click, true);
window.removeEventListener('popstate', pop, true);
};
}, [dirty, bypass]);
}
+12
View File
@@ -239,6 +239,18 @@
"stylelintLegacy": true, "stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/AdminChannelGroupFormPage.css",
"owners": ["src/apps/admin/AdminChannelGroupFormPage.tsx"],
"stylelintLegacy": false,
"roots": ["channel-group-editor"]
},
{
"file": "src/apps/admin/channel-groups/RouteConfigModal.css",
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
"stylelintLegacy": false,
"roots": ["channel-route-editor"]
} }
] ]
} }