feat: 优化通道组编辑交互
This commit is contained in:
@@ -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: '选择通道 通道C(CH-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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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('已选:通道available(CODE-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>
|
||||
);
|
||||
}
|
||||
@@ -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) }]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user