diff --git a/admin-frontend-vue/src/pages/account/GroupEditorDialog.vue b/admin-frontend-vue/src/pages/account/GroupEditorDialog.vue new file mode 100644 index 00000000..2365a116 --- /dev/null +++ b/admin-frontend-vue/src/pages/account/GroupEditorDialog.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/admin-frontend-vue/src/pages/account/GroupsPage.vue b/admin-frontend-vue/src/pages/account/GroupsPage.vue index f59b9753..a62d3929 100644 --- a/admin-frontend-vue/src/pages/account/GroupsPage.vue +++ b/admin-frontend-vue/src/pages/account/GroupsPage.vue @@ -1,12 +1,25 @@ @@ -27,8 +63,9 @@ onMounted(loadGroups)

数据权限分组

-

管理店铺数据访问分组和组员范围。

+

管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。

+ 新建分组
@@ -50,13 +87,36 @@ onMounted(loadGroups) - - - - - + + + + + + + + + + + + + + @@ -75,4 +135,15 @@ onMounted(loadGroups) font-weight: 600; line-height: 1.2; } +.member-tag { + margin: 2px 4px 2px 0; +} +.member-more { + font-size: 12px; + color: var(--admin-muted); +} +.member-none { + color: var(--admin-muted); + font-size: 13px; +} diff --git a/admin-frontend-vue/src/pages/account/shop-group-api.ts b/admin-frontend-vue/src/pages/account/shop-group-api.ts index 6fd5d680..a6eb79ab 100644 --- a/admin-frontend-vue/src/pages/account/shop-group-api.ts +++ b/admin-frontend-vue/src/pages/account/shop-group-api.ts @@ -1,6 +1,13 @@ -/** 数据权限分组加载适配(任务 59):GET /api/admin/shop-manages/groups → 分组列表。 */ +/** 数据权限分组 API 适配(任务 59;module 13 task 253 扩展 CRUD)。 */ import { http } from '@/api/http' -import { parseShopGroupList, type ShopGroupItem } from './shop-group-model' +import { unwrap } from '@/api/envelope' +import { + parseShopGroupItem, + parseShopGroupList, + toShopGroupCreatePayload, + toShopGroupUpdatePayload, + type ShopGroupItem, +} from './shop-group-model' export const SHOP_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups' @@ -8,3 +15,25 @@ export async function fetchShopGroups(): Promise { const { data } = await http.get(SHOP_GROUPS_ENDPOINT) return parseShopGroupList(data) } + +/** 新建分组:POST /api/admin/shop-manages/groups(创建人 = 组长 = 当前操作者)。 */ +export async function createShopGroup(name: string, memberUserIds: number[], createdById: number): Promise { + const { data } = await http.post(SHOP_GROUPS_ENDPOINT, toShopGroupCreatePayload(name, memberUserIds, createdById)) + const created = parseShopGroupItem(unwrap(data)) + if (!created) throw new Error('创建分组响应异常:缺少分组数据') + return created +} + +/** 更新分组:PUT /api/admin/shop-manages/groups/{id}。 */ +export async function updateShopGroup(id: number, name: string, memberUserIds: number[]): Promise { + const { data } = await http.put(`${SHOP_GROUPS_ENDPOINT}/${id}`, toShopGroupUpdatePayload(name, memberUserIds)) + const updated = parseShopGroupItem(unwrap(data)) + if (!updated) throw new Error('更新分组响应异常:缺少分组数据') + return updated +} + +/** 删除分组:DELETE /api/admin/shop-manages/groups/{id}。 */ +export async function deleteShopGroup(id: number): Promise { + const { data } = await http.delete(`${SHOP_GROUPS_ENDPOINT}/${id}`) + unwrap(data) +} diff --git a/admin-frontend-vue/src/pages/account/shop-group-model.ts b/admin-frontend-vue/src/pages/account/shop-group-model.ts index 17a7d1aa..dd4bb565 100644 --- a/admin-frontend-vue/src/pages/account/shop-group-model.ts +++ b/admin-frontend-vue/src/pages/account/shop-group-model.ts @@ -1,13 +1,20 @@ -/** 数据权限分组列表模型(任务 59):解析 Java ShopManageGroupItemVo + 摘要统计,纯逻辑。 */ +/** 数据权限分组列表模型(任务 59;module 13 task 253 扩展编辑能力):解析 VO + 摘要 + 提交载荷。 */ import { unwrap } from '../../api/envelope.ts' export interface ShopGroupItem { id: number name: string leaderUsername: string + leaderUserId?: number memberCount: number memberUsernames: string[] + memberUserIds?: number[] createdAt?: string + updatedAt?: string +} + +export interface ShopGroupFormErrors { + groupName?: string } function text(value: unknown): string { @@ -27,6 +34,9 @@ export function parseShopGroupItem(raw: unknown): ShopGroupItem | null { const names = Array.isArray(record.memberUsernames) ? record.memberUsernames.map((v) => text(v)).filter(Boolean) : [] + const memberIds = Array.isArray(record.memberUserIds) + ? record.memberUserIds.map((v) => numberOrNull(v)).filter((v): v is number => v !== null && v > 0) + : [] const count = numberOrNull(record.memberCount) const item: ShopGroupItem = { id, @@ -35,8 +45,13 @@ export function parseShopGroupItem(raw: unknown): ShopGroupItem | null { memberCount: count === null ? names.length : count, memberUsernames: names, } + const leaderId = numberOrNull(record.leaderUserId ?? record.leader_user_id) + if (leaderId !== null && leaderId > 0) item.leaderUserId = leaderId + if (memberIds.length) item.memberUserIds = memberIds const createdAt = text(record.createdAt ?? record.created_at) if (createdAt) item.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) item.updatedAt = updatedAt return item } @@ -55,3 +70,34 @@ export function shopGroupSummary(groups: ShopGroupItem[]): { groupCount: number; for (const group of groups) memberTotal += group.memberCount || 0 return { groupCount: groups.length, memberTotal } } + +/** 分组名必填校验(镜像后端 NotBlank 文案「请输入分组名称」)。 */ +export function validateShopGroupForm(name: string): ShopGroupFormErrors { + const errors: ShopGroupFormErrors = {} + if (!text(name)) errors.groupName = '请输入分组名称' + return errors +} + +/** 创建载荷:groupName + 成员 + 创建人(= 组长,当前操作者)。 */ +export function toShopGroupCreatePayload( + name: string, + memberUserIds: number[], + createdById: number, +): { groupName: string; memberUserIds: number[]; createdById: number } { + return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds), createdById } +} + +/** 更新载荷:groupName + 成员(组长/创建人不可改)。 */ +export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]): { groupName: string; memberUserIds: number[] } { + return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) } +} + +function dedupeIds(ids: unknown[]): number[] { + if (!Array.isArray(ids)) return [] + const set = new Set() + for (const v of ids) { + const id = numberOrNull(v) + if (id !== null && id > 0) set.add(id) + } + return [...set].sort((a, b) => a - b) +} diff --git a/admin-frontend-vue/tests/task-253.test.ts b/admin-frontend-vue/tests/task-253.test.ts new file mode 100644 index 00000000..f21ec7a5 --- /dev/null +++ b/admin-frontend-vue/tests/task-253.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { validateShopGroupForm } from '../src/pages/account/shop-group-model.ts' + +// module 13 task 253:数据权限分组页面对齐(新建/编辑/删除 + 组员展示 + 组长锁定)。 + +test('test_task_253_group_list_normal_primary_path', () => { + const page = readSource('src/pages/account/GroupsPage.vue') + assert.match(page, /新建分组/, '需有新建分组入口') + assert.match(page, /GroupEditorDialog/, '需挂载分组编辑器') + assert.match(page, /组员/, '列表需含组员列') + assert.match(page, /memberUsernames/, '组员列读 memberUsernames') +}) + +test('test_task_253_group_list_normal_variant_input', () => { + // 组长不可改、仅组长/超管可编辑删除。 + const page = readSource('src/pages/account/GroupsPage.vue') + assert.match(page, /canEditOf/, '需有可编辑判定') + assert.match(page, /leaderUserId/, '判定依赖组长 leaderUserId') + const editor = readSource('src/pages/account/GroupEditorDialog.vue') + assert.match(editor, /组长不可更改|组长(创建人)/, '组长语义可见') +}) + +test('test_task_253_group_list_normal_repeated_operation_is_idempotent', () => { + // 分组名校验幂等。 + assert.deepEqual(validateShopGroupForm(''), { groupName: '请输入分组名称' }) + assert.deepEqual(validateShopGroupForm(' '), { groupName: '请输入分组名称' }) + assert.deepEqual(validateShopGroupForm('A组'), {}) +}) + +test('test_task_253_group_list_boundary_empty_input', () => { + const page = readSource('src/pages/account/GroupsPage.vue') + assert.match(page, /删除成功/, '删除成功提示') + assert.match(page, /openAdminConfirm/, '删除确认走统一确认组件') + const editor = readSource('src/pages/account/GroupEditorDialog.vue') + assert.match(editor, /更新成功/, '更新成功提示') + assert.match(editor, /创建成功/, '创建成功提示') +}) + +test('test_task_253_group_list_boundary_single_item', () => { + // 单组:无组员显示“无”,超员折叠 +N。 + const page = readSource('src/pages/account/GroupsPage.vue') + assert.match(page, /member-none|无/, '无组员占位') + assert.match(page, /slice\(0,\s*10\)/, '超员折叠') +}) + +test('test_task_253_group_list_boundary_limit_or_missing_field', () => { + // 边界上限:编辑器含 分组名称/组长(创建人)/组员 选择。 + const editor = readSource('src/pages/account/GroupEditorDialog.vue') + assert.match(editor, /groupName/, '含分组名称字段') + assert.match(editor, /memberIds/, '含组员选择') + assert.match(editor, /fetchUserList/, '成员候选来自用户列表') +}) + +test('test_task_253_group_list_invalid_input_rejected', () => { + // 异常:空名不可保存。 + const editor = readSource('src/pages/account/GroupEditorDialog.vue') + assert.match(editor, /validateShopGroupForm/, '保存前走分组名校验') +}) + +test('test_task_253_group_list_dependency_failure_returns_actionable_message', () => { + // 依赖:CRUD 走 /api/admin/shop-manages/groups;模型解析 member/leader ids。 + const api = readSource('src/pages/account/shop-group-api.ts') + assert.match(api, /shop-manages\/groups/, '分组端点真实') + assert.match(api, /deleteShopGroup/, '含删除适配') + const model = readSource('src/pages/account/shop-group-model.ts') + assert.match(model, /leaderUserId/, '模型需解析组长 id') + assert.match(model, /memberUserIds/, '模型需解析成员 id') +})