feat: 简化通道组顺序调整并过滤不可选通道

This commit is contained in:
hectorzhao
2026-09-06 15:54:27 +08:00
parent bd920f76b0
commit 69e3d7368d
12 changed files with 220 additions and 142 deletions
@@ -0,0 +1,50 @@
import { selectChannelCandidate } from './send-chain.helpers';
const connected = [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }];
const member = (channelId: string, priority: number, isBackup = false) => ({
channelId,
priority,
isBackup,
weight: isBackup ? 1 : 100,
carrier: 'mobile',
province: null,
channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected },
});
describe('channel group displayed order and retry selection', () => {
it.each([
[member('last', 30), member('first', 10, true), member('middle', 20)],
[member('last', 3), member('first', 1, true), member('middle', 2)],
[member('last', 2147483647), member('first', -10, true), member('middle', 0)],
])('consumes ascending slots even when backup flags, weights and input order differ: %j', (...items) => {
const excluded = new Set<string>();
const selected: string[] = [];
for (let attempt = 0; attempt <= items.length; attempt += 1) {
const next = selectChannelCandidate(items, {
carrier: 'mobile',
forceNational: true,
excludedChannelIds: excluded,
approvedChannelIds: new Set(items.map((item) => item.channelId)),
routingKey: 'order-verification',
});
if (!next) break;
selected.push(next.channelId);
excluded.add(next.channelId);
}
expect(selected).toEqual(['first', 'middle', 'last']);
});
it('skips ineligible slots without changing the order of remaining eligible channels', () => {
const items = [member('offline', 10), member('unreported', 20), member('next', 30, true), member('last', 40)];
items[0].channel.connectionStates = [];
const options = {
carrier: 'mobile',
forceNational: true,
excludedChannelIds: new Set<string>(),
approvedChannelIds: new Set(['offline', 'next', 'last']),
};
expect(selectChannelCandidate(items, options)?.channelId).toBe('next');
options.excludedChannelIds.add('next');
expect(selectChannelCandidate(items, options)?.channelId).toBe('last');
});
});
@@ -1,5 +1,15 @@
# 通道组编辑交互优化方案
## 2026-09-06 顺序交互调整
本节替代下文优先级输入、拖拽及不可选项展示的旧交互。此次仅授权修改并本地提交,历史发布授权不延续到本轮。
- 编辑页只展示顺序序号;全国通道仅通过上移/下移调整,撤销恢复最近一次排序。撤销与保存修改使用相同紧凑按钮宽度。
- 添加/编辑弹窗不提供优先级或顺序输入;编辑保留当前位置,新增全国通道始终追加到末尾。普通历史优先级槽位保留;达到PostgreSQL整数上限而不能追加时,仅在当前草稿按原相对顺序压紧为连续整数后追加,保存前摘要提示顺序,失败保留草稿。
- 待选列表只显示符合运营商、地区且未占用、未删除的通道;编辑当前成员时允许保留自身。搜索在可选集合内生效,省网先选省份,再呈现匹配项。沿用原规则:暂时断连和停用状态如允许配置仍真实标注,发送资格由后端校验,不把“可配置”说成“可发送”。
- API继续提交真实priority字段,不变更表或补发算法。全国列表按priority升序;后端selectChannelCandidate在剔除已尝试、未报备及不可用通道后,选择最小priority。因此符合资格的全国通道按页面顺序首次尝试和补发,weight/isBackup不会覆盖不同priority的顺序。省网匹配优先、失败转全国等规则保持,不把不匹配省份当作顺序中的可用通道。
- 验收覆盖首尾移动、撤销、追加/替换/保存失败、历史负数与最大整数、候选过滤及三尺寸按钮宽度;通过真实API/PostgreSQL只读配置和无副作用选路函数核对,不发送短信或保存现有业务通道组。单元隔离异常与真实环境结果分开记录。
日期:2026-09-05。适用通道组新建与修改,用户已授权实施、提交推送及双环境发布。沿用TC-ADMIN-004路由语义,不改变真实通道价格、发送或计费规则。
- 修正历史任意合法整数优先级回显;重复通道、归一化后重复省份及全国重复优先级明确报错,绝不删除或覆盖原行。弹窗确认只更新草稿。
@@ -2208,3 +2208,12 @@
- 签名清退预警移至报备任务提醒,数量使用现有真实今日未读且未抑制汇总;不再计入预警中心。原侧栏入口保留,审核待办统计不变。
- 报备任务提醒新增“报备进度提醒”禁用按钮和“待开发”标签,不提供跳转、虚构数量或假成功。后续真实进度能力需另行设计后端,本轮不实现。
- 预警中心继续汇总安全检测与封禁、系统监控告警。三个一级按钮和弹层在1600×1000、1366×768、390×844均应完整可用。
## 通道组顺序交互调整(2026-09-06)
本节替代2026-09-05通道组交互中优先级输入、拖拽和不可选通道展示要求,设计见[channel-group-interaction-plan-20260905.md](channel-group-interaction-plan-20260905.md)同日调整节。
- 编辑页仅显示顺序序号,不显示优先级;撤销排序与保存修改宽度一致(当前160px)。
- 添加/编辑通道弹窗不允许设置顺序或优先级;新增全国通道固定追加末尾,编辑保留当前位置,调整只通过编辑页上移/下移,保留撤销功能。
- 待选通道隐藏已占用、不支持运营商/地区、已删除的项;编辑当前成员可保留自身,搜索只在可选集合中进行。保留原可配置的停用/暂断连状态及真实提示,不等同发送资格。
- 符合发送资格的全国通道按列表顺序首次尝试和补发,跳过已尝试、未报备和不可用通道;省网优先及失败转全国规则保持。优先级仅作为API兼容字段,整数上限时保持相对顺序压紧当前草稿后追加,不将新增项插到前面。
+15
View File
@@ -5130,3 +5130,18 @@ npm run verify:phase8
| TC-REPORT-NOTICE-006 | 检查无通知配置的客户端壳层 | 不新增运营端报备提醒;不得为占位功能读取或制造客户端数据 |
真实正数清退、失败/权限和客户端登录需有授权夹具/账号才可记为真实验收;本轮现有测试环境清退数为0,正数与失败分支使用单元测试隔离覆盖。
## 通道组顺序与候选过滤用例(2026-09-06)
本节替代TC-CHANNEL-GROUP-EDITOR-001的拖拽、002的优先级输入以及003的禁用项展示预期;保留原保存失败、部分创建重试、草稿保护及权限边界。
| 编号 | 场景与步骤 | 预期结果 |
|---|---|---|
| TC-CHANNEL-ORDER-001 | 打开历史10/20顺序的通道组,上移第二条、下移第一条、撤销 | 仅显示1/2等顺序,无优先级或拖拽入口;顺序、预览及保存参数一致,首尾按钮正确禁用;撤销恢复最近一次排序 |
| TC-CHANNEL-ORDER-002 | 添加全国通道,编辑已有成员,再保存或模拟保存失败 | 弹窗无顺序/优先级控件;新增永远末尾、编辑保留位置,API提交升序唯一priority;旧成员weight/isBackup保留,失败保留草稿 |
| TC-CHANNEL-ORDER-003 | 历史负数、乱序返回、最大整数2147483647下追加 | 按原升序追加;上限耗尽时仅草稿按相对顺序压紧为连续整数,全部可持久化,不修改原对象或使新增项插队 |
| TC-CHANNEL-ORDER-004 | 添加时包含已占用、其他运营商、已删除、地区不匹配通道;切换省份及搜索 | 不可选项完全不在待选列表;编辑自身可保留;省网先选省份再显示匹配项;显示真实可选数、无结果和无可添加通道空态 |
| TC-CHANNEL-ORDER-005 | 以列表对应priority构造组成员,顺次排除已尝试通道调用无副作用后端选择函数 | 选择顺序与全国列表一致;主备/权重不覆盖不同priority;离线、未报备项跳过;此测试不发送或补发短信 |
| TC-CHANNEL-ORDER-006 | 三尺寸首次进入、刷新、上移/下移/撤销、添加、搜索空态和跨路由切换 | 撤销和保存按钮宽度相同且紧凑;表格/弹窗可操作,无新增裁切或控制台异常;真实API验收不保存业务配置 |
真实短信补发、真实保存业务通道组及真实权限/故障注入未授权时仍标记未执行;不能把纯函数、模拟保存或构建通过当作发送链投递验收。
+12
View File
@@ -4593,3 +4593,15 @@ git diff --check
- 数据与账户:用户授权新建并保留`codex_qa_admin`ID `cmtphgp340000yrle7v11jemc`platform_admin、active),安全入口见[部署文档持久验收管理员节](production-deployment.md#测试环境持久验收管理员2026-09-06)。2026-09-06 15:32左右PostgreSQL短信记录仍119509,三条短信Stream的consumer/pending/last-delivered/entries-read/lag与验收前一致(pending/lag均0)。账号与受限凭据按要求保留,浏览器测试正常logout;未发送、补发、重投、重新入队短信或更改余额、通道、客户配置。
- 证据:本机`%TEMP%/cmpp-report-notice-20260906`的before.json/png、after.json、reporting-1600/1366/390.png、browser.mjs、build.log。浏览器脚本只在内存读取安全凭据,不保存会话凭证或密码到报告。
- 边界:真实清退正数、真实故障/权限注入和客户端账号登录未执行,正数/失败/无配置由隔离单元测试覆盖,不冒充真实验收;未为造数据改变历史消息状态。纯前端入口调整未重跑后端/Gateway全量测试与构建。仅授权本地提交,不推送、不部署测试或预生产;既有文档改动保持未提交。
## 2026-09-06 通道组顺序与候选过滤调整(本地实现)
- 起始main/HEAD为`bd920f7`,远端main重新核验为`442dda711d5c9f778f3f76fd6d8fd69f14414ce6`,本地领先1个提交,暂存空;原8份修改文档、3份未跟踪文档继续保护,本轮仅精确提交功能用例和本节增量。测试环境SSH回读部署标记442dda7,未访问预生产。
- 只读复现:真实测试API的电信本地压测组`cmsvlitxw002pgdleix0hs04d`,PostgreSQL成员顺序为备用通道priority10、主通道priority20weight均100;原添加弹窗仍有优先级输入,最初6项全部不可选却完整展示。旧nextAvailablePriority在Int上限时选择更小空位,可能导致新增通道插队。顺序撤销按钮是grid子项而默认拉满;保存按钮既有min-width为160px。
- 修改:编辑页仅显示顺序,移除拖拽和弹窗优先级输入;新增通过appendNationalRoute在末尾分配顺序,上限时仅草稿按原相对顺序压紧,编辑成员保留位置。撤销与保存共用局部160px宽度类。候选列表先过滤不可选项,再搜索并显示可选总数;省份选项不从删除/不兼容/其他已占用通道产生。既有允许配置的停用/暂断连通道仍真实标注。未改历史CSS模块、import顺序或Stylelint例外。
- 补发一致性:只读核对SendRetryService以attemptedChannelIds排除已尝试通道,并调用selectChannelForMessage;其selectChannelCandidate按资格过滤后取最小priority。全国顺序与后端一致,主备/权重只在同priority内影响选择,前端/后端均校验全国priority唯一;省网匹配优先与失败转全国保持。本轮无API运行时代码、数据库结构或队列改动,新增后端4项顺序回归覆盖乱序输入、历史负数/大整数、备用在前及不可用通道跳过。
- 自动验证:定向前端3套24项、后端2套13项通过;最终前端全量18套95项、API全量56套624项通过。前端TypeScript、前后端生产构建、format:check、变更ESLint、quality:verify、Stylelint、CSS治理及15项测试、bundle:verify通过;保留既有Chart包体提示,入口gzip108.26KiB低于250KiB预算。当前shell无npm,使用已有Node执行package.json等价脚本;未新增依赖。实现时Array.at不符合现有TS lib,已改兼容数组索引。
- 真实浏览器:Browser插件入口不可用,沿用前端测试技能与已有Playwright/Edge。用持久codex_qa_admin正常验证码登录,本地生产预览4173代理测试环境真实API1600×1000、1366×768、390×844均完成首次进入/刷新、上移、撤销、新建草稿连续添加末尾、已加入项隐藏、搜索空态及路由切换,撤销/保存计算宽度均160pxconsole warning/error、pageerror、失败HTTP响应均0。截图复查列表仅顺序,窄屏弹窗及页脚可用。所有通道组写请求设置拦截,本轮只在前端草稿添加,不点击最终业务保存。账户保留并正常logout。
- 动态环境:浏览器验收期间其他操作新增了一个真实停用通道,旧候选快照因此不再匹配;改为用当前页面实际收到的API响应核对过滤集合,未删除或改写该通道。只读数据库短信记录119509;三条短信Stream在核验时pending/lag均0且last-delivered/entries-read与本轮开始一致。未发送、补发、重投或重新入队短信,未修改余额、通道或客户配置。
- 证据目录:本机`%TEMP%/cmpp-channel-order-20260906`内before.png、after.json、editor-1600/1366/390.png、choices-1600/1366/390.png、前后浏览器脚本、build.log和api-tests.log。首轮浏览器脚本修正了API路径/定位器及草稿beforeunload确认处理;不把脚本超时当作功能通过。图片、响应报告不含密码,认证文件沿用部署文档受限入口。
- 验收边界:未真实保存现有通道组、未执行短信补发;参数映射/保存失败用组件隔离测试,补发顺序用真实代码、当前PostgreSQL/API顺序及无副作用策略测试交叉核对。无真实省网可选夹具,地区过滤和历史缺失项由组件测试覆盖;不冒充端到端投递或真实权限异常验收。仅本地提交,不推送、不部署测试或预生产。
+5 -1
View File
@@ -11,7 +11,6 @@
display: flex;
align-items: center;
gap: 4px;
cursor: grab;
}
.channel-group-editor .channel-group-editor__preview {
@@ -27,3 +26,8 @@
color: var(--color-text-secondary, #6b7280);
overflow-wrap: anywhere;
}
.channel-group-editor .channel-group-editor__save-size {
justify-self: start;
width: 160px;
}
@@ -96,17 +96,17 @@ describe('channel group editor interactions', () => {
const user = userEvent.setup();
renderEditor();
await screen.findByDisplayValue('验收通道组');
expect(nationalRows()[0]).toHaveTextContent('1 / 10');
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
expect(nationalRows()[0]).toHaveTextContent('1');
expect(nationalRows()[1]).toHaveTextContent('2');
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()[0]).toHaveTextContent('1');
expect(nationalRows()[1]).toHaveTextContent('通道A');
expect(nationalRows()[1]).toHaveTextContent('2 / 20');
expect(nationalRows()[1]).toHaveTextContent('2');
expect(screen.getByRole('status')).toHaveTextContent('调整 2');
expect(adminApi.updateChannelGroup).not.toHaveBeenCalled();
@@ -116,29 +116,38 @@ describe('channel group editor interactions', () => {
expect(screen.getByRole('button', { name: '保存修改' })).toBeDisabled();
});
it('shows historical priority in the editor and rejects a duplicate without replacing either route', async () => {
it('only appends new channels and preserves the position when editing a member', async () => {
const user = userEvent.setup();
renderEditor();
await screen.findByDisplayValue('验收通道组');
expect(screen.queryByText('顺序 / 优先级')).not.toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: '顺序' })).toBeVisible();
expect(document.querySelector('[draggable="true"]')).toBeNull();
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');
let dialog = screen.getByRole('dialog', { name: '编辑通道' });
expect(within(dialog).queryByRole('spinbutton')).not.toBeInTheDocument();
expect(within(dialog).queryByRole('radio', { name: /CH-B/ })).not.toBeInTheDocument();
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');
expect(nationalRows()[0]).toHaveTextContent('通道A');
await user.click(screen.getByRole('button', { name: '添加全国通道' }));
dialog = screen.getByRole('dialog', { name: '添加通道' });
expect(within(dialog).queryByRole('spinbutton')).not.toBeInTheDocument();
expect(within(dialog).getAllByRole('radio')).toHaveLength(1);
await user.click(within(dialog).getByRole('radio', { name: /CH-C/ }));
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');
expect(nationalRows().map((row) => row.querySelector('strong')?.textContent)).toEqual(['通道A', '通道B', '通道C']);
await user.click(screen.getByRole('button', { name: '上移通道C' }));
await submitSave(user);
expect(adminApi.updateChannelGroup).toHaveBeenCalledWith(
'group-existing',
expect.objectContaining({
items: [
expect.objectContaining({ channelId: 'channel-A', priority: 10 }),
expect.objectContaining({ channelId: 'channel-C', priority: 20 }),
expect.objectContaining({ channelId: 'channel-B', priority: 30, weight: 2, isBackup: true }),
],
}),
);
});
it('keeps changed order and input after a failed save and preserves historical metadata on retry', async () => {
+27 -40
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, GripVertical, Pencil, Plus, Trash2, Undo2 } from 'lucide-react';
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { formatRateAmount, MONEY_UNITS_PER_YUAN } from '@/utils/currency';
@@ -7,9 +7,8 @@ import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '
import { RouteConfigModal, type RouteModalState } from './channel-groups/RouteConfigModal';
import {
validateRoutes,
validateRouteCandidate,
moveNationalRoute,
nextAvailablePriority,
appendNationalRoute,
summarizeRouteChanges,
type ProvinceRoute,
type NationalRoute,
@@ -82,7 +81,6 @@ function ChannelGroupEditor() {
const [modal, setModal] = useState<RouteModalState | null>(null);
const [confirmSave, setConfirmSave] = useState(false);
const [undoOrder, setUndoOrder] = useState<NationalRoute[] | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const createdId = useRef<string | null>(null);
const bypassLeave = useRef(false);
const dirty = Boolean(baseline && JSON.stringify(draft) !== JSON.stringify(baseline));
@@ -127,21 +125,21 @@ function ChannelGroupEditor() {
change({ nationalRoutes: moveNationalRoute(draft.nationalRoutes, sourceId, targetId) });
}
function saveRoute(route: ProvinceRoute | NationalRoute) {
const message = validateRouteCandidate(route, draft.provinceRoutes, draft.nationalRoutes);
if (message) return message;
if ('province' in route)
change({
provinceRoutes: draft.provinceRoutes.some((x) => x.id === route.id)
const provinceRoutes =
'province' in route
? draft.provinceRoutes.some((x) => x.id === route.id)
? draft.provinceRoutes.map((x) => (x.id === route.id ? route : x))
: [...draft.provinceRoutes, route],
});
else
change({
nationalRoutes: (draft.nationalRoutes.some((x) => x.id === route.id)
? draft.nationalRoutes.map((x) => (x.id === route.id ? route : x))
: [...draft.nationalRoutes, route]
).sort((a, b) => a.priority - b.priority),
});
: [...draft.provinceRoutes, route]
: draft.provinceRoutes;
const nationalRoutes =
'priority' in route
? draft.nationalRoutes.some((x) => x.id === route.id)
? draft.nationalRoutes.map((x) => (x.id === route.id ? { ...route, priority: x.priority } : x))
: appendNationalRoute(draft.nationalRoutes, route)
: draft.nationalRoutes;
const message = validateRoutes(provinceRoutes, nationalRoutes);
if (message) return message;
change({ provinceRoutes, nationalRoutes });
setUndoOrder(null);
setModal(null);
return null;
@@ -221,26 +219,12 @@ function ChannelGroupEditor() {
];
const nationalColumns: TableColumn<NationalRoute>[] = [
{
key: 'priority',
title: '顺序 / 优先级',
key: 'order',
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>
<div className="channel-group-editor__order">
<span>{index + 1}</span>
<Button
aria-label={'上移' + channelById.get(route.channelId)?.name}
disabled={saving || index === 0}
@@ -442,11 +426,11 @@ function ChannelGroupEditor() {
</section>
<section className="surface channel-group-form-section">
<h2></h2>
<p className="muted">使</p>
<p className="muted">使</p>
<div className="channel-group-editor__preview">{orderText}</div>
<Button
className="channel-group-editor__save-size"
disabled={!undoOrder}
icon={<Undo2 size={14} />}
onClick={() => {
if (undoOrder) change({ nationalRoutes: undoOrder });
setUndoOrder(null);
@@ -477,7 +461,11 @@ function ChannelGroupEditor() {
{baseChanged ? ' · 基础设置已修改' : ''}
</div>
<div className="channel-group-form-footer">
<Button disabled={saving || loading || !baseline || !dirty} onClick={() => void saveGroup()}>
<Button
className="channel-group-editor__save-size"
disabled={saving || loading || !baseline || !dirty}
onClick={() => void saveGroup()}
>
{saving ? '保存中...' : '保存修改'}
</Button>
<Button
@@ -536,7 +524,6 @@ function ChannelGroupEditor() {
carrier={draft.carrier}
modal={modal}
occupiedChannelIds={[...draft.provinceRoutes, ...draft.nationalRoutes].map((x) => x.channelId)}
nextPriority={nextAvailablePriority(draft.nationalRoutes)}
onClose={() => setModal(null)}
onSubmit={saveRoute}
/>
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { 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';
@@ -30,12 +30,11 @@ describe('RouteConfigModal', () => {
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.queryByRole('spinbutton')).not.toBeInTheDocument();
expect(screen.getByText('0.0325')).toBeVisible();
expect(screen.getByText('暂无连接回写')).toBeVisible();
expect(screen.getByRole('radio')).toBeEnabled();
@@ -43,7 +42,7 @@ describe('RouteConfigModal', () => {
expect(onSubmit).toHaveBeenCalledWith({ id: 'route-1', channelId: 'current', priority: 20 });
});
it('shows unavailable reasons and keeps the selected channel while searching', async () => {
it('hides unavailable channels and keeps the selected channel while searching', async () => {
const channels = [
channel('available'),
channel('occupied'),
@@ -57,21 +56,18 @@ describe('RouteConfigModal', () => {
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.queryByRole('radio', { name: new RegExp(`CODE-${id}`) })).not.toBeInTheDocument();
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.queryAllByRole('radio')).toHaveLength(0);
expect(screen.getByText('没有匹配的可选通道,请调整搜索条件')).toBeVisible();
expect(screen.getByText('已选:通道availableCODE-available')).toBeVisible();
});
@@ -82,18 +78,16 @@ describe('RouteConfigModal', () => {
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.queryByRole('radio')).not.toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('原通道不存在,请重新选择');
});
it('retains form values after parent conflict rejection and rejects a fractional priority', async () => {
it('retains selected values after parent conflict rejection without offering priority input', async () => {
const onSubmit = vi.fn(() => '同一通道组内全国通道优先级不能重复');
const onClose = vi.fn();
render(
@@ -102,7 +96,6 @@ describe('RouteConfigModal', () => {
carrier="mobile"
modal={{ type: 'national', mode: 'create' }}
occupiedChannelIds={[]}
nextPriority={20}
onClose={onClose}
onSubmit={onSubmit}
/>,
@@ -112,14 +105,8 @@ describe('RouteConfigModal', () => {
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(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
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 () => {
@@ -130,7 +117,6 @@ describe('RouteConfigModal', () => {
carrier="mobile"
modal={{ type: 'national', mode: 'create' }}
occupiedChannelIds={[]}
nextPriority={10}
onClose={onClose}
onSubmit={() => null}
/>,
@@ -3,7 +3,7 @@ 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 { normalizeRegion, type NationalRoute, type ProvinceRoute } from './model';
import './RouteConfigModal.css';
type Carrier = 'mobile' | 'unicom' | 'telecom';
@@ -46,7 +46,6 @@ export function RouteConfigModal({
carrier,
modal,
occupiedChannelIds,
nextPriority,
onClose,
onSubmit,
}: {
@@ -54,26 +53,25 @@ export function RouteConfigModal({
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 dirty = province !== (provinceRoute?.province ?? '') || channelId !== (modal.route?.channelId ?? '');
const provinces = channels
.filter((channel) => isCarrierCompatible(channel, carrier))
.filter(
(channel) =>
channel.status !== 'deleted' &&
isCarrierCompatible(channel, carrier) &&
(channel.id === modal.route?.channelId || !occupiedChannelIds.includes(channel.id)),
)
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国'));
if (provinceRoute?.province) provinces.push(provinceRoute.province);
@@ -96,10 +94,11 @@ export function RouteConfigModal({
}
const query = keyword.trim().toLocaleLowerCase();
const visibleChannels = channels.filter((channel) =>
const visibleChannels = channels.filter(
(channel) =>
!unavailableReason(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) {
@@ -115,14 +114,10 @@ export function RouteConfigModal({
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 };
: { id: nationalRoute?.id ?? `n-${selectionName}`, priority: nationalRoute?.priority ?? 0, channelId };
setError(onSubmit(route) ?? '');
}
@@ -156,22 +151,7 @@ export function RouteConfigModal({
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="数值越小越先使用;失败补发会跳到下一优先级。"
/>
)}
) : null}
<Input
label="搜索通道"
onChange={(event) => setKeyword(event.target.value)}
@@ -184,9 +164,7 @@ export function RouteConfigModal({
<span></span>
</p>
<fieldset className="channel-route-editor__choices">
<legend>
· {visibleChannels.length} {availableCount}
</legend>
<legend> · {visibleChannels.length} </legend>
{visibleChannels.map((channel) => {
const reason = unavailableReason(channel);
const connection = connectionLabel(channel);
@@ -235,7 +213,11 @@ export function RouteConfigModal({
})}
{visibleChannels.length === 0 ? (
<p className="channel-route-editor__empty">
{channels.length ? '没有匹配的通道,请调整搜索条件' : '暂无通道数据'}
{modal.type === 'province' && !province
? '请先选择省份'
: query
? '没有匹配的可选通道,请调整搜索条件'
: '暂无可添加的通道'}
</p>
) : null}
</fieldset>
+24 -8
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
isValidPriority,
moveNationalRoute,
nextAvailablePriority,
appendNationalRoute,
normalizeRegion,
summarizeRouteChanges,
validateRouteCandidate,
@@ -93,14 +93,30 @@ describe('national route movement', () => {
});
});
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);
describe('appending national routes', () => {
it('appends after historical slots regardless of the candidate priority', () => {
expect(appendNationalRoute([], national('c', -100))).toEqual([national('c', 10)]);
expect(appendNationalRoute([national('b', 20), national('a', 10)], national('c', -100))).toEqual([
national('a', 10),
national('b', 20),
national('c', 30),
]);
expect(appendNationalRoute([national('a', -20)], national('c', 0))).toEqual([
national('a', -20),
national('c', -10),
]);
});
it('stays inside the PostgreSQL integer range when the highest slot is occupied', () => {
expect(nextAvailablePriority([national('a', 2147483647), national('b', 2147483637)])).toBe(2147483627);
it('never prepends when the PostgreSQL integer ceiling is reached', () => {
const routes = [national('a', 2147483637), national('b', 2147483647)];
const original = structuredClone(routes);
const added = appendNationalRoute(routes, national('c', 0));
expect(added).toEqual([national('a', 1), national('b', 2), national('c', 3)]);
expect(routes).toEqual(original);
expect(validateRoutes([], added)).toBeNull();
expect(appendNationalRoute([national('a', 2147483646)], national('b', 0))).toEqual([
national('a', 2147483646),
national('b', 2147483647),
]);
});
});
+9 -11
View File
@@ -35,18 +35,16 @@ export function isValidPriority(priority: number) {
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;
export function appendNationalRoute(routes: readonly NationalRoute[], route: NationalRoute): NationalRoute[] {
const ordered = [...routes].sort((left, right) => left.priority - right.priority);
const highest = ordered[ordered.length - 1]?.priority;
if (highest === undefined) return [{ ...route, priority: 10 }];
if (highest < 2147483647) {
return [...ordered, { ...route, priority: highest + Math.min(10, 2147483647 - highest) }];
}
return 0;
// An exhausted Int range must never insert a new member before existing ones.
// Compact this draft only, preserving relative order and all member metadata.
return [...ordered, route].map((item, index) => ({ ...item, priority: index + 1 }));
}
export function validateRoutes(