align(分组管理): 组员候选按归属过滤(超管全量/非超管仅本组长创建的普通账号)、新建组长恒为当前用户、补创建时间列、chips 全量渲染(对齐 admin.js getEligibleShopManageGroupUsers/帮助文案)

This commit is contained in:
2026-09-05 23:00:08 +08:00
parent 63339b401c
commit 1692fa4a69
5 changed files with 142 additions and 30 deletions
@@ -1,8 +1,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, 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 {
eligibleGroupMembers,
groupMemberCandidateHint,
validateShopGroupForm,
type ShopGroupItem,
} from './shop-group-model'
import { createShopGroup, updateShopGroup } from './shop-group-api'
import { fetchUserList } from '@/api/users'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
@@ -15,18 +19,24 @@ const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'save
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 leaderUserId = computed<number | null>(() =>
props.group ? (props.group.leaderUserId ?? null) : (session.user?.id ?? null),
)
const leaderUsername = computed<string>(() =>
props.group ? (props.group.leaderUsername || '') : (session.user?.username || '当前登录用户'),
)
const operatorRole = computed<string>(() => session.user?.role || '')
const adminCandidates = ref<AdminUser[]>([])
const memberCandidates = ref<AdminUser[]>([])
/** 组员候选:按组长归属过滤(超管全量、非超管仅本组长创建的普通账号)。 */
const memberCandidates = computed(() =>
eligibleGroupMembers(users.value, leaderUserId.value, operatorRole.value),
)
const candidateHint = computed(() => groupMemberCandidateHint(memberCandidates.value.length, operatorRole.value))
watch(
() => props.modelValue,
@@ -34,22 +44,19 @@ watch(
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 })
// 对齐 admin.js loadUserOptionspage=1&page_size=999 全量拉取,避免归属过滤漏人。
const page = await fetchUserList({ page: 1, pageSize: 999 })
users.value = page.items
adminCandidates.value = page.items.filter(isAdminUser)
memberCandidates.value = page.items.filter((u) => !isAdminUser(u))
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
}
@@ -69,13 +76,13 @@ async function save(): Promise<void> {
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
showAdminFeedback('更新成功', 'success')
} else {
// 对齐 admin:组长恒为当前登录用户,提交 createdBy=当前用户 id。
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)
await createShopGroup(groupName.value, memberIds.value, operatorId)
showAdminFeedback('创建成功', 'success')
}
emit('saved')
@@ -103,13 +110,8 @@ onMounted(loadCandidates)
<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 label="组长">
<el-input :model-value="leaderUsername" readonly placeholder="当前登录用户" />
</el-form-item>
<el-form-item label="组员">
<el-select
@@ -122,7 +124,7 @@ onMounted(loadCandidates)
>
<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>
<div class="candidate-hint">{{ candidateHint }}(共 {{ memberCandidates.length }} 名)</div>
</el-form-item>
</el-form>
<template #footer>
@@ -95,12 +95,14 @@ onMounted(loadGroups)
<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>
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
</template>
<span v-else class="member-none"></span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
</el-table-column>
@@ -92,6 +92,45 @@ export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]):
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) }
}
/** 组员候选用户(对齐 admin.js getEligibleShopManageGroupUsers 的过滤语义)。 */
export interface GroupMemberCandidateUser {
id: number
username: string
role: string
createdById?: number | null
}
/**
* 组员候选过滤(对齐 admin.js:3020-3028):
* - 组长本人永不进候选;
* - 超管可选全部用户(不论角色/归属);
* - 非超管仅可选"组长(创建人)名下"的普通账号。
*/
export function eligibleGroupMembers(
users: GroupMemberCandidateUser[],
leaderUserId: number | null,
operatorRole: string,
): GroupMemberCandidateUser[] {
const leaderKey = leaderUserId != null && leaderUserId > 0 ? String(leaderUserId) : ''
const isSuper = (operatorRole || '').toLowerCase() === 'super_admin'
return users.filter((u) => {
if (String(u.id) === leaderKey) return false
if (isSuper) return true
return (
(u.role || '').toLowerCase() === 'normal' &&
String(u.createdById ?? '') === leaderKey
)
})
}
/** 组员候选帮助文案(对齐 admin.js:3179-3182)。 */
export function groupMemberCandidateHint(candidateCount: number, operatorRole: string): string {
if (candidateCount > 0) return '可添加当前组长创建的普通员工账号'
return (operatorRole || '').toLowerCase() === 'normal'
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
: '当前组长名下暂无可添加的普通员工账号。'
}
function dedupeIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
eligibleGroupMembers,
groupMemberCandidateHint,
} from '../src/pages/account/shop-group-model.ts'
/** 对齐 admin.js:3020-3028 getEligibleShopManageGroupUsers 与 3179-3182 帮助文案。 */
const USERS = [
{ id: 1, username: '组长A', role: 'admin', createdById: null },
{ id: 2, username: '员工A1', role: 'normal', createdById: 1 },
{ id: 3, username: '员工A2', role: 'normal', createdById: 1 },
{ id: 4, username: '别人员工', role: 'normal', createdById: 9 },
{ id: 5, username: '另一个管理员', role: 'admin', createdById: null },
{ id: 6, username: '超管本人', role: 'super_admin', createdById: null },
]
test('align_group_eligible_super_admin_sees_all_except_leader', () => {
// 超管:候选 = 全部用户(除组长本人),不区分归属。
const result = eligibleGroupMembers(USERS, 1, 'super_admin')
assert.deepEqual(result.map((u) => u.id), [2, 3, 4, 5, 6])
})
test('align_group_eligible_non_super_only_own_normal_employees', () => {
// 管理员/组长:候选 = created_by=组长本人的普通账号,别人名下/管理员/超管不可选。
const result = eligibleGroupMembers(USERS, 1, 'admin')
assert.deepEqual(result.map((u) => u.id), [2, 3])
})
test('align_group_eligible_excludes_leader_self', () => {
// 组长本人永不进候选(即使是 normal 且归属自己)。
const withSelf = [{ id: 1, username: '组长', role: 'normal', createdById: 1 }, ...USERS]
const result = eligibleGroupMembers(withSelf, 1, 'super_admin')
assert.equal(result.some((u) => u.id === 1), false)
})
test('align_group_eligible_null_leader_means_own_created_only_for_non_super', () => {
// 组长 id 缺失时按字符串比较兜底,不误匹配。
const result = eligibleGroupMembers(USERS, null, 'admin')
assert.equal(result.length, 0)
})
test('align_group_candidate_hint_with_candidates', () => {
assert.match(groupMemberCandidateHint(3, 'admin'), /可添加当前组长创建的普通员工账号/)
})
test('align_group_candidate_hint_empty_for_normal', () => {
assert.match(groupMemberCandidateHint(0, 'normal'), /普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。/)
})
test('align_group_candidate_hint_empty_for_admin', () => {
assert.match(groupMemberCandidateHint(0, 'admin'), /当前组长名下暂无可添加的普通员工账号。/)
})
test('align_group_editor_dialog_ui_wiring', () => {
// UI 接线:候选请求 page_size=999;新建时组长恒为当前登录用户(无改选下拉);候选/提示走新模型。
const dialog = readSource('src/pages/account/GroupEditorDialog.vue')
assert.match(dialog, /pageSize:\s*999/, '候选列表按参考 page_size=999 拉取')
assert.doesNotMatch(dialog, /组长(创建人)/, '新建弹窗不再提供组长改选下拉')
assert.match(dialog, /当前登录用户/, '新建时组长只读显示当前登录用户')
assert.match(dialog, /eligibleGroupMembers/, '组员候选走归属过滤')
assert.match(dialog, /groupMemberCandidateHint/, '候选提示文案走对齐逻辑')
const page = readSource('src/pages/account/GroupsPage.vue')
assert.match(page, /创建时间/, '列表补创建时间列')
assert.doesNotMatch(page, /slice\(0,\s*10\)/, '组员 chips 全量渲染不再收 +N')
})
+4 -3
View File
@@ -19,7 +19,8 @@ test('test_task_253_group_list_normal_variant_input', () => {
assert.match(page, /canEditOf/, '需有可编辑判定')
assert.match(page, /leaderUserId/, '判定依赖组长 leaderUserId')
const editor = readSource('src/pages/account/GroupEditorDialog.vue')
assert.match(editor, /组长不可更改|组长(创建人)/, '组长语义可见')
assert.match(editor, /组长/, '编辑弹窗含组长展示')
assert.match(editor, /当前登录用户/, '新建时组长只读为当前登录用户')
})
test('test_task_253_group_list_normal_repeated_operation_is_idempotent', () => {
@@ -39,10 +40,10 @@ test('test_task_253_group_list_boundary_empty_input', () => {
})
test('test_task_253_group_list_boundary_single_item', () => {
// 单组:无组员显示“无”,超员折叠 +N。
// 单组:无组员显示“无”;组员 chips 全量渲染(对齐 admin.js 全量 chips,不再收 +N
const page = readSource('src/pages/account/GroupsPage.vue')
assert.match(page, /member-none|无/, '无组员占位')
assert.match(page, /slice\(0,\s*10\)/, '超员折叠')
assert.doesNotMatch(page, /slice\(0,\s*10\)/, '组员全量渲染')
})
test('test_task_253_group_list_boundary_limit_or_missing_field', () => {