task-253(admin.html观感对齐): 数据权限分组页对齐(新建/编辑/删除 + 组员 chips + 组长锁定)
This commit is contained in:
@@ -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 适配(任务 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<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 + 摘要统计,纯逻辑。 */
|
||||
/** 数据权限分组列表模型(任务 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<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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user