From 1e05a643e5398973bd32953fc95ed5c3aa2b0daf Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sat, 5 Sep 2026 22:55:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=80=9A=E9=81=93?= =?UTF-8?q?=E7=BB=84=E7=BC=96=E8=BE=91=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...channel-group-interaction-plan-20260905.md | 10 + .../first-version-development-requirements.md | 8 + docs/system-functional-test-cases.md | 12 + docs/testing-progress.md | 8 + src/apps/admin/AdminChannelGroupFormPage.css | 29 + .../admin/AdminChannelGroupFormPage.test.tsx | 261 +++++ src/apps/admin/AdminChannelGroupFormPage.tsx | 958 +++++++++--------- .../admin/channel-groups/RouteConfigModal.css | 140 +++ .../channel-groups/RouteConfigModal.test.tsx | 145 +++ .../admin/channel-groups/RouteConfigModal.tsx | 263 +++++ src/apps/admin/channel-groups/model.test.ts | 160 +++ src/apps/admin/channel-groups/model.ts | 155 +++ .../admin/channel-groups/useUnsavedChanges.ts | 53 + tools/quality/css-ownership.json | 12 + 14 files changed, 1752 insertions(+), 462 deletions(-) create mode 100644 docs/channel-group-interaction-plan-20260905.md create mode 100644 src/apps/admin/AdminChannelGroupFormPage.css create mode 100644 src/apps/admin/AdminChannelGroupFormPage.test.tsx create mode 100644 src/apps/admin/channel-groups/RouteConfigModal.css create mode 100644 src/apps/admin/channel-groups/RouteConfigModal.test.tsx create mode 100644 src/apps/admin/channel-groups/RouteConfigModal.tsx create mode 100644 src/apps/admin/channel-groups/model.test.ts create mode 100644 src/apps/admin/channel-groups/model.ts create mode 100644 src/apps/admin/channel-groups/useUnsavedChanges.ts diff --git a/docs/channel-group-interaction-plan-20260905.md b/docs/channel-group-interaction-plan-20260905.md new file mode 100644 index 0000000..cea388b --- /dev/null +++ b/docs/channel-group-interaction-plan-20260905.md @@ -0,0 +1,10 @@ +# 通道组编辑交互优化方案 + +日期:2026-09-05。适用通道组新建与修改,用户已授权实施、提交推送及双环境发布。沿用TC-ADMIN-004路由语义,不改变真实通道价格、发送或计费规则。 + +- 修正历史任意合法整数优先级回显;重复通道、归一化后重复省份及全国重复优先级明确报错,绝不删除或覆盖原行。弹窗确认只更新草稿。 +- 全国顺序支持上移、下移和拖拽;移动仅交换已有优先级槽位(如10/20),保留数值集合;新增通道建议下一个空闲优先级。提供明确顺序预览及撤销最近一次排序。 +- 通道选择支持名称/编号/地区搜索,展示成本及真实连接状态,禁用项说明原因;活动但暂时断连的通道仍可配置,保留历史停用通道且不误称连接正常。 +- 保存前展示新增、删除、调整以及基础设置变更摘要;未保存离开、刷新或后退需提示;失败保留输入,新建第二步失败保存已创建ID以便重试。请求沿用真实API,已有组status/description和成员weight/isBackup保持,不改数据模型。 +- 验收:纯函数冲突/排序/摘要测试,组件交互及前端回归、TypeScript、生产构建、CSS门禁;真实API的三尺寸浏览器查询、选择、排序、撤销及退出。禁止在验收中保存既有真实通道组或发送短信。 +- 发布:测试先行;预生产已确认发布完整最新版本,包含尚未发布的WPS异步解析、1项兼容migration及新解析Worker。独立恢复点与存储保护核验后再切换,不执行会改写无关配置的全量初始化脚本。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index e9f13c1..807ed7c 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2191,3 +2191,11 @@ - 运营端右上角新增“签名导入待审”,按签名导入资料中 pending_review 的明细条数计入待审核总数,独立于正式签名待审;点击进入签名审核的导入页签。统计遵守批次tenantId范围,invalid/approved/rejected明细不计入;导入提交及审核成功后立即刷新,不预先改计数。 - 短信通道组新建/编辑的省网、全国列表展示成本价格(元/条),使用通道现有unitPrice及10000单位/元换算,保留4位小数;零成本显示0.0000,缺失通道/价格显示“—”。本次不改变通道选择、路由优先级和保存行为。 + +### 2026-09-05 通道组编辑交互优化 + +- 新建和修改通道组均使用草稿编辑:添加、编辑、删除、排序只改变当前页面草稿,最终确认保存时才调用真实接口。保存前展示基础设置、成员新增/删除和顺序调整摘要;保存失败保留输入。新建时若组已创建但成员保存失败,重试不得重复创建通道组。 +- 全国通道支持上移、下移和拖拽,移动时交换已有合法优先级槽位并保留历史优先级集合;支持撤销最近一次排序。历史任意 PostgreSQL `integer` 范围内优先级均可回显和编辑,新增项使用下一个未占用优先级。 +- 重复通道、归一化后重复省份及全国重复优先级必须明确阻止,不得静默覆盖已有成员。保存时保留已有组的状态、描述以及成员权重和主备属性。 +- 通道选择支持按名称、编号和地区搜索,直接展示成本价格、适用地区、运营商和真实连接回写。已删除、运营商不匹配、地区不匹配或已在本组配置的通道不可选择并说明原因;活动但暂时断连的通道允许配置。 +- 页面加载失败时禁止编辑和保存;存在未保存修改时,返回、路由切换、浏览器后退或刷新须提示。桌面、常用笔记本和 390px 窄屏均应可完成上述交互,且不得产生页面级横向溢出。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index e2e255b..70224b9 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5103,3 +5103,15 @@ npm run verify:phase8 | TC-CHANNEL-COST-001 | 新建/编辑通道组,查看省网和全国列表,分别使用非零、零及缺失价格 | 成本价格按现有单位换算为元/条并保留4位;零显示0.0000,缺失显示—;三尺寸表格内部滚动,无整页溢出;不修改价格和路由 | 上述通知口径增加签名导入这一类审核任务,替代TC-ADMIN-NOTICE-0715-09中“仅五类”的数量限定;下游投递告警仍不计入待审核任务。 + +## TC-CHANNEL-GROUP-EDITOR-20260905 通道组编辑交互 + +| 用例ID | 场景 | 预期 | +| --- | --- | --- | +| TC-CHANNEL-GROUP-EDITOR-001 | 打开含历史优先级10/20、权重和主备设置的通道组并上移、下移或拖拽全国通道 | 页面交换现有优先级槽位、顺序预览同步变化;撤销恢复原顺序,权重和主备属性保持 | +| TC-CHANNEL-GROUP-EDITOR-002 | 新增或编辑时选择重复通道、重复省份或重复全国优先级 | 页面明确显示冲突并保持原有行,不静默替换、删除或覆盖 | +| TC-CHANNEL-GROUP-EDITOR-003 | 在通道选择弹窗按名称、编号或地区搜索 | 结果来自真实通道API并展示成本、地区、运营商和连接回写;不可选项显示具体原因,活动但暂时断连的通道仍可选择 | +| TC-CHANNEL-GROUP-EDITOR-004 | 修改基础设置、成员或顺序后点击保存 | 保存前确认弹窗展示新增、删除、调整及基础设置摘要;取消不调用写接口,确认后才按草稿统一保存 | +| TC-CHANNEL-GROUP-EDITOR-005 | 新建通道组时创建成功但成员保存失败,随后重试 | 页面保留草稿和已创建组ID,只重试后续保存,不重复创建通道组 | +| TC-CHANNEL-GROUP-EDITOR-006 | 加载接口失败,或有未保存修改时返回、跨路由、后退和刷新 | 加载失败时编辑与保存不可用并显示真实错误;未保存离开均提示,取消后保留草稿 | +| TC-CHANNEL-GROUP-EDITOR-007 | 在1600×1000、1366×768和390×844执行排序、撤销、搜索、冲突校验和保存预览 | 三尺寸均可操作且无页面级横向溢出;控制台无新增错误;验收不保存真实通道组、不修改通道配置、不发送短信 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index ec974e1..d14888f 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4564,3 +4564,11 @@ git diff --check - 后续交互建议(未实施):现有历史优先级10/20与编辑下拉1—5不兼容;同优先级/同省份会过滤原行。建议先修正历史值回显和冲突处理,再加入全国路由上下移动/拖拽、明确顺序预览、可搜索通道选择及未保存提示;与本次成本展示分开实施。 - 提交前补充:签名审核页页签由URL直接派生,修正已停留在该页时通知只改query而未切换页签的问题;新增同路由query切换回归测试通过。该文件另有HEAD已存在的1条preserve-manual-memoization错误和1条依赖警告,未扩展整改。 - 最终前端全量14套66项通过,最终TypeScript/Vite生产构建通过;本轮共13个目标文件精确暂存,已有文档段落保持未暂存。 + +## 2026-09-05 通道组编辑交互优化(发布前验证) + +- 按用户确认方案完成通道组新建/修改交互:草稿编辑、历史优先级兼容、显式冲突校验、全国通道上移/下移/拖拽与撤销、顺序预览、可搜索通道选择、成本/地区/运营商/真实连接回写、不可选原因、保存摘要和未保存离开保护。保存继续使用真实API,保留既有组状态/描述及成员权重/主备属性;新建部分失败后以已创建ID重试,避免重复创建。 +- 新增页面及弹窗私有CSS,由各自TSX直接导入并登记所有权;未重建global.css、未向14个历史兼容模块追加规则、未增加`!important`或宽泛标签选择器,也未改变domains/index.css顺序。 +- 自动验证:通道组纯函数、选择弹窗和整页交互定向3套24项通过;前端全量17套90项、API全量55套620项通过。format:check、Stylelint、CSS治理15项、TypeScript/Vite生产构建、API构建、安全、部署契约和包体积门禁通过;既有Chart包体提示不影响构建。 +- 候选页面通过测试环境真实API/PostgreSQL只读验收,所有业务写请求由浏览器路由拦截。1600×1000、1366×768、390×844均完成排序/撤销、重复优先级拦截、搜索空态、保存前预览和离开取消;无页面级横向溢出,pageerror和console error为0。验收未保存真实通道组、未修改成本或通道配置、未发送/补发/重投/重新入队短信。 +- 测试与预生产发布前已分别建立独立恢复点并核验运行版本、服务、数据库迁移及存储保护。预生产从旧版本发布完整最新版本,预计新增1项向后兼容报备解析迁移及独立解析Worker;实际提交、推送、双环境切换和发布后真实验收结果在完成后追加。 diff --git a/src/apps/admin/AdminChannelGroupFormPage.css b/src/apps/admin/AdminChannelGroupFormPage.css new file mode 100644 index 0000000..a0bfcdb --- /dev/null +++ b/src/apps/admin/AdminChannelGroupFormPage.css @@ -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; +} diff --git a/src/apps/admin/AdminChannelGroupFormPage.test.tsx b/src/apps/admin/AdminChannelGroupFormPage.test.tsx new file mode 100644 index 0000000..b5b9a5f --- /dev/null +++ b/src/apps/admin/AdminChannelGroupFormPage.test.tsx @@ -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( + + + } /> + 通道组列表} /> + + , + ); +} + +function nationalRows() { + return within(screen.getAllByRole('table')[1]).getAllByRole('row').slice(1); +} + +async function submitSave(user: ReturnType) { + 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((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( + + 切换通道组 + + } /> + 通道组列表} /> + + , + ); + 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(); + }); +}); diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 1121130..494b6de 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -1,508 +1,542 @@ -import { useEffect, useMemo, useState } from 'react'; -import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowDown, ArrowUp, GripVertical, Pencil, Plus, Trash2, Undo2 } 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'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui'; +import { RouteConfigModal, type RouteModalState } from './channel-groups/RouteConfigModal'; +import { + validateRoutes, + validateRouteCandidate, + moveNationalRoute, + nextAvailablePriority, + summarizeRouteChanges, + type ProvinceRoute, + type NationalRoute, +} from './channel-groups/model'; +import { useUnsavedChanges } from './channel-groups/useUnsavedChanges'; +import './AdminChannelGroupFormPage.css'; type Carrier = 'mobile' | 'unicom' | 'telecom'; -type ChannelStatus = 'normal' | 'stopped'; -type ProvinceRoute = { - id: string; - province: string; - channelId: string; - status: ChannelStatus; -}; -type NationalRoute = { - id: string; - priority: number; - channelId: string; - status: ChannelStatus; -}; -type RouteModalState = { - type: 'province' | 'national'; - mode: 'create' | 'edit'; - route?: ProvinceRoute | NationalRoute; -}; - -const priorityOptions = [ - { label: '请选择', value: '' }, - { label: '1', value: '1' }, - { label: '2', value: '2' }, - { label: '3', value: '3' }, - { label: '4', value: '4' }, - { label: '5', value: '5' }, -]; - -const carrierLabels: Record = { - mobile: '移动', - unicom: '联通', - telecom: '电信', -}; - -const statusLabels: Record = { - normal: '链接正常', - stopped: '通道停用', -}; - -const statusTones: Record = { - normal: 'success', - stopped: 'neutral', -}; - -function normalizeRegion(region?: string | null) { - return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); -} - -function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) { - return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier; -} - -function getChannelStatus(channel?: AdminChannel): ChannelStatus { - if (channel?.status !== 'active') return 'stopped'; - return (channel.connectionStates ?? []).some((connection) => - connection.status === 'connected' - && connection.desiredConnections > 0 - && connection.currentConnections > 0 - ) ? 'normal' : 'stopped'; -} - -function StatusTag({ status }: { status: ChannelStatus }) { - return {statusLabels[status]}; -} - -function RouteConfigModal({ - channels, - carrier, - modal, - occupiedChannelIds, - onClose, - onSubmit, -}: { - channels: AdminChannel[]; +type Draft = { + name: string; carrier: Carrier; - modal: RouteModalState; - occupiedChannelIds: string[]; - onClose: () => void; - onSubmit: (route: ProvinceRoute | NationalRoute) => void; -}) { - const provinceRoute = modal.type === 'province' ? modal.route as ProvinceRoute | undefined : undefined; - const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined; - const [province, setProvince] = useState(provinceRoute?.province ?? ''); - const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : ''); - const [channelId, setChannelId] = useState(modal.route?.channelId ?? ''); - const [error, setError] = useState(''); - - const provinceOptions = [ - { label: '请选择省份', value: '' }, - ...Array.from(new Set(channels - .filter((channel) => isCarrierCompatible(channel, carrier)) - .map((channel) => channel.sendRegion) - .filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')), - )).sort().map((region) => ({ label: region, value: region })), - ]; - - const selectableChannels = channels.filter((channel) => { - if (!isCarrierCompatible(channel, carrier)) return false; - if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false; - if (modal.type === 'province' && province) { - return normalizeRegion(channel.sendRegion) === normalizeRegion(province); - } - return true; - }); - const channelOptions = [ - { label: '请选择', value: '' }, - ...selectableChannels.map((channel) => ({ - label: `${channel.name}(${channel.code}) / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`, - value: channel.id, - })), - ]; - - function submit() { - if (!channelId) { - setError('请选择可用通道'); - return; - } - const channel = channels.find((item) => item.id === channelId); - if (modal.type === 'province') { - if (!province) { - setError('请选择省份'); - return; - } - onSubmit({ - id: provinceRoute?.id ?? `p-${Date.now()}`, - province, - channelId, - status: getChannelStatus(channel), - }); - return; - } - - if (!priority) { - setError('请选择优先级'); - return; - } - onSubmit({ - id: nationalRoute?.id ?? `n-${Date.now()}`, - priority: Number(priority), - channelId, - status: getChannelStatus(channel), - }); - } - - return ( - - - - - )} - onClose={onClose} - open - size="xl" - title={( -
- -

{modal.mode === 'edit' ? '编辑通道' : '添加通道'}

{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}

-
- )} - > -
- {modal.type === 'province' ? ( - setPriority(event.target.value)} options={priorityOptions} value={priority} /> -
- - 失败补发会跳往下一优先级的通道,不会在同优先级的通道去补发 -
- - )} - setGroupName(event.target.value)} placeholder="请输入通道组名称" value={groupName} /> -
- * 运营商 - {(Object.keys(carrierLabels) as Carrier[]).map((item) => ( - - ))} -
-
- * 失败补发 - -
-
- 补发时间上限 - setRetryLimitHours(event.target.value)} - suffix="小时" - type="number" - value={retryLimitHours} - /> - setRetryLimitMinutes(event.target.value)} - suffix="分钟" - type="number" - value={retryLimitMinutes} - /> - 后端限制 1 分钟到 72 小时;默认 12 小时 0 分钟。 -
-
- - -
-

省网分流配置

- - - - -
-

全国通道配置

-
- - - -
- - + ) : null} +
+
+

基础设置

+
+ change({ name: e.target.value })} /> +
+ * 运营商 + {(Object.keys(carrierLabels) as Carrier[]).map((x) => ( + + ))} +
+
+ * 失败补发 + +
+
+ 补发时间上限 + change({ hours: e.target.value })} + /> + change({ minutes: e.target.value })} + /> + 1分钟至72小时。 +
+
+
+
+

省网分流配置

+
+ + +
+

全国通道配置

+

顺序靠前的通道优先使用;拖动顺序单元格或上下移动,保存后生效。

+
{orderText}
+ +
+ + + +
+ {dirty ? '有未保存修改' : '当前配置未修改'} · 新增 {changes.added.length} · 移除 {changes.removed.length} · 调整{' '} + {changes.updated.length} + {baseChanged ? ' · 基础设置已修改' : ''}
- +
+ + +
+ { + if (!saving) setConfirmSave(false); + }} + footer={ + <> + + + + } + > +
+

+ 新增 {changes.added.length} 条,移除 {changes.removed.length} 条,调整 {changes.updated.length} 条。 +

+ {baseChanged ? ( +

+ 基础设置已修改:{draft.name} / {carrierLabels[draft.carrier]} /{' '} + {draft.retryEnabled ? '启用补发' : '关闭补发'} / {draft.hours}小时{draft.minutes}分钟 +

+ ) : null} + {changes.added.map((x) => ( +

新增:{routeName(x.channelId)}

+ ))} + {changes.removed.map((x) => ( +

移除:{routeName(x.channelId)}

+ ))} +

全国顺序:{orderText}

+

确认后统一保存;失败保留本次输入。

+
+
{modal ? ( route.channelId), - ...nationalRoutes.map((route) => route.channelId), - ]} + occupiedChannelIds={[...draft.provinceRoutes, ...draft.nationalRoutes].map((x) => x.channelId)} + nextPriority={nextAvailablePriority(draft.nationalRoutes)} onClose={() => setModal(null)} onSubmit={saveRoute} /> diff --git a/src/apps/admin/channel-groups/RouteConfigModal.css b/src/apps/admin/channel-groups/RouteConfigModal.css new file mode 100644 index 0000000..ca65f33 --- /dev/null +++ b/src/apps/admin/channel-groups/RouteConfigModal.css @@ -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; + } +} diff --git a/src/apps/admin/channel-groups/RouteConfigModal.test.tsx b/src/apps/admin/channel-groups/RouteConfigModal.test.tsx new file mode 100644 index 0000000..3b341a0 --- /dev/null +++ b/src/apps/admin/channel-groups/RouteConfigModal.test.tsx @@ -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 => ({ + 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( + , + ); + 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( + 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( + 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( + , + ); + 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( + 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(); + }); +}); diff --git a/src/apps/admin/channel-groups/RouteConfigModal.tsx b/src/apps/admin/channel-groups/RouteConfigModal.tsx new file mode 100644 index 0000000..4b2e7b6 --- /dev/null +++ b/src/apps/admin/channel-groups/RouteConfigModal.tsx @@ -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 = { 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 ( + ( + <> + + + + )} + onClose={onClose} + open + size="xl" + title={modal.mode === 'edit' ? '编辑通道' : '添加通道'} + > +
+ {modal.type === 'province' ? ( + { + setPriority(event.target.value); + setError(''); + }} + value={priority} + hint="数值越小越先使用;失败补发会跳到下一优先级。" + /> + )} + setKeyword(event.target.value)} + placeholder="输入通道名称、编号或地区" + prefix={
+
+ ); +} diff --git a/src/apps/admin/channel-groups/model.test.ts b/src/apps/admin/channel-groups/model.test.ts new file mode 100644 index 0000000..e213ef0 --- /dev/null +++ b/src/apps/admin/channel-groups/model.test.ts @@ -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) }]); + }); +}); diff --git a/src/apps/admin/channel-groups/model.ts b/src/apps/admin/channel-groups/model.ts new file mode 100644 index 0000000..9a99e46 --- /dev/null +++ b/src/apps/admin/channel-groups/model.ts @@ -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(); + for (const route of [...provinceRoutes, ...nationalRoutes]) { + if (!route.channelId) return '请选择通道'; + if (channels.has(route.channelId)) return '通道组内不能重复配置同一通道'; + channels.add(route.channelId); + } + + const provinces = new Set(); + 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(); + 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( + 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) => + [...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]), + }; +} diff --git a/src/apps/admin/channel-groups/useUnsavedChanges.ts b/src/apps/admin/channel-groups/useUnsavedChanges.ts new file mode 100644 index 0000000..a4ec075 --- /dev/null +++ b/src/apps/admin/channel-groups/useUnsavedChanges.ts @@ -0,0 +1,53 @@ +import { useEffect, type RefObject } from 'react'; + +export function useUnsavedChanges(dirty: boolean, bypass: RefObject) { + 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]); +} diff --git a/tools/quality/css-ownership.json b/tools/quality/css-ownership.json index 76fe848..8e7f37e 100644 --- a/tools/quality/css-ownership.json +++ b/tools/quality/css-ownership.json @@ -239,6 +239,18 @@ "stylelintLegacy": true, "reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。", "removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。" + }, + { + "file": "src/apps/admin/AdminChannelGroupFormPage.css", + "owners": ["src/apps/admin/AdminChannelGroupFormPage.tsx"], + "stylelintLegacy": false, + "roots": ["channel-group-editor"] + }, + { + "file": "src/apps/admin/channel-groups/RouteConfigModal.css", + "owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"], + "stylelintLegacy": false, + "roots": ["channel-route-editor"] } ] }