task-253(admin.html观感对齐): 数据权限分组页对齐(新建/编辑/删除 + 组员 chips + 组长锁定)

This commit is contained in:
2026-09-05 21:21:49 +08:00
parent 8b4955d9b5
commit f765b3ce67
5 changed files with 367 additions and 10 deletions
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import type { ShopGroupItem } from './shop-group-model'
import { validateShopGroupForm } from './shop-group-model'
import { createShopGroup, updateShopGroup } from './shop-group-api'
import { fetchUserList } from '@/api/users'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import { useAdminSessionStore } from '@/stores/admin-session'
const props = defineProps<{ modelValue: boolean; group: ShopGroupItem | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const session = useAdminSessionStore()
const groupName = ref('')
const memberIds = ref<number[]>([])
const leaderId = ref<number | null>(null)
const errorText = ref('')
const busy = ref(false)
const users = ref<AdminUser[]>([])
function isAdminUser(u: AdminUser): boolean {
const role = (u.role || '').toLowerCase()
return role === 'admin' || role === 'super_admin' || u.isAdmin === true
}
const adminCandidates = ref<AdminUser[]>([])
const memberCandidates = ref<AdminUser[]>([])
watch(
() => props.modelValue,
(open) => {
if (!open) return
groupName.value = ''
memberIds.value = []
leaderId.value = null
errorText.value = ''
if (props.group) {
groupName.value = props.group.name
memberIds.value = props.group.memberUserIds ?? []
leaderId.value = props.group.leaderUserId ?? null
}
},
)
async function loadCandidates(): Promise<void> {
try {
const page = await fetchUserList({ page: 1, pageSize: 200 })
users.value = page.items
adminCandidates.value = page.items.filter(isAdminUser)
memberCandidates.value = page.items.filter((u) => !isAdminUser(u))
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
const errs = validateShopGroupForm(groupName.value)
errorText.value = errs.groupName || ''
if (errorText.value) return
busy.value = true
try {
if (props.group) {
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
showAdminFeedback('更新成功', 'success')
} else {
const operatorId = session.user?.id ?? 0
if (!operatorId) {
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
return
}
const createdBy = leaderId.value && leaderId.value > 0 ? leaderId.value : operatorId
await createShopGroup(groupName.value, memberIds.value, createdBy)
showAdminFeedback('创建成功', 'success')
}
emit('saved')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
onMounted(loadCandidates)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="group ? '编辑分组' : '新建分组'"
width="600px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组名称" :error="errorText || undefined">
<el-input v-model="groupName" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item v-if="!group && operatorSuper" label="组长创建人">
<el-select v-model="leaderId" clearable filterable placeholder="默认为当前登录管理员" style="width: 100%">
<el-option v-for="u in adminCandidates" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item v-else-if="group" label="组长">
<el-input :model-value="group.leaderUsername" readonly />
</el-form-item>
<el-form-item label="组员">
<el-select
v-model="memberIds"
multiple
filterable
collapse-tags
placeholder="选择组员普通账号"
style="width: 100%"
>
<el-option v-for="u in memberCandidates" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
<div class="candidate-hint">组员范围:当前可选普通账号(共 {{ memberCandidates.length }} 名)</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.candidate-hint {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
</style>
@@ -1,12 +1,25 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchShopGroups } from './shop-group-api'
import { useAdminSessionStore } from '@/stores/admin-session'
import { openAdminConfirm } from '@/components/admin-confirm'
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
import GroupEditorDialog from './GroupEditorDialog.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<ShopGroupItem[]>([])
const editorVisible = ref(false)
const editingGroup = ref<ShopGroupItem | null>(null)
const summary = computed(() => shopGroupSummary(rows.value))
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
function canEditOf(group: ShopGroupItem): boolean {
if (isSuperAdmin.value) return true
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
}
async function loadGroups() {
loading.value = true
@@ -19,6 +32,29 @@ async function loadGroups() {
}
}
function openCreate() {
editingGroup.value = null
editorVisible.value = true
}
function openEdit(group: ShopGroupItem) {
if (!canEditOf(group)) return
editingGroup.value = group
editorVisible.value = true
}
async function removeGroup(group: ShopGroupItem) {
const ok = await openAdminConfirm({ message: `确定删除分组“${group.name}”吗?`, danger: true })
if (!ok) return
try {
await deleteShopGroup(group.id)
ElMessage.success('删除成功')
await loadGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(loadGroups)
</script>
@@ -27,8 +63,9 @@ onMounted(loadGroups)
<div class="page-heading">
<div>
<h2>数据权限分组</h2>
<p>管理店铺数据访问分组和组员范围</p>
<p>管理店铺数据访问分组和组员范围组长不可更改组员为普通账号</p>
</div>
<el-button type="primary" @click="openCreate">新建分组</el-button>
</div>
<el-row :gutter="12" style="margin-bottom: 12px">
<el-col :span="8">
@@ -50,13 +87,36 @@ onMounted(loadGroups)
</el-row>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column type="index" label="序号" width="90" />
<el-table-column prop="name" label="分组名称" min-width="220" />
<el-table-column prop="leaderUsername" label="组长" width="180" />
<el-table-column prop="memberCount" label="组员数量" width="120" />
<el-table-column prop="createdAt" label="创建时间" min-width="180" />
<el-table-column type="index" label="序号" width="80" />
<el-table-column prop="name" label="分组名称" min-width="180" />
<el-table-column prop="leaderUsername" label="组长" width="170" />
<el-table-column prop="memberCount" label="组员数量" width="100" />
<el-table-column label="组员" min-width="240">
<template #default="{ row }">
<template v-if="row.memberUsernames.length">
<el-tag v-for="name in row.memberUsernames.slice(0, 10)" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
<span v-if="row.memberUsernames.length > 10" class="member-more">+{{ row.memberUsernames.length - 10 }}</span>
</template>
<span v-else class="member-none"></span>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
<template #default="{ row }">{{ row.updatedAt || row.createdAt || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<GroupEditorDialog
v-model="editorVisible"
:group="editingGroup"
:operator-super="isSuperAdmin"
@saved="loadGroups"
/>
</div>
</template>
@@ -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;
}
</style>
@@ -1,6 +1,13 @@
/** 数据权限分组加载适配(任务 59):GET /api/admin/shop-manages/groups → 分组列表。 */
/** 数据权限分组 API 适配(任务 59module 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<ShopGroupItem[]> {
const { data } = await http.get<unknown>(SHOP_GROUPS_ENDPOINT)
return parseShopGroupList(data)
}
/** 新建分组:POST /api/admin/shop-manages/groups(创建人 = 组长 = 当前操作者)。 */
export async function createShopGroup(name: string, memberUserIds: number[], createdById: number): Promise<ShopGroupItem> {
const { data } = await http.post<unknown>(SHOP_GROUPS_ENDPOINT, toShopGroupCreatePayload(name, memberUserIds, createdById))
const created = parseShopGroupItem(unwrap<unknown>(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<ShopGroupItem> {
const { data } = await http.put<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`, toShopGroupUpdatePayload(name, memberUserIds))
const updated = parseShopGroupItem(unwrap<unknown>(data))
if (!updated) throw new Error('更新分组响应异常:缺少分组数据')
return updated
}
/** 删除分组:DELETE /api/admin/shop-manages/groups/{id}。 */
export async function deleteShopGroup(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
@@ -1,13 +1,20 @@
/** 数据权限分组列表模型(任务 59):解析 Java ShopManageGroupItemVo + 摘要统计,纯逻辑。 */
/** 数据权限分组列表模型(任务 59module 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<number>()
for (const v of ids) {
const id = numberOrNull(v)
if (id !== null && id > 0) set.add(id)
}
return [...set].sort((a, b) => a - b)
}
+70
View File
@@ -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')
})