feat: 简化通道组顺序调整并过滤不可选通道
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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('已选:通道available(CODE-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>
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user