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
@@ -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) =>
`${channel.name} ${channel.code} ${channel.sendRegion ?? '全国'}`.toLocaleLowerCase().includes(query),
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(