align(分组管理): 组员候选按归属过滤(超管全量/非超管仅本组长创建的普通账号)、新建组长恒为当前用户、补创建时间列、chips 全量渲染(对齐 admin.js getEligibleShopManageGroupUsers/帮助文案)
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 { AdminUser } from '@/types/admin'
|
||||||
import type { ShopGroupItem } from './shop-group-model'
|
import {
|
||||||
import { validateShopGroupForm } from './shop-group-model'
|
eligibleGroupMembers,
|
||||||
|
groupMemberCandidateHint,
|
||||||
|
validateShopGroupForm,
|
||||||
|
type ShopGroupItem,
|
||||||
|
} from './shop-group-model'
|
||||||
import { createShopGroup, updateShopGroup } from './shop-group-api'
|
import { createShopGroup, updateShopGroup } from './shop-group-api'
|
||||||
import { fetchUserList } from '@/api/users'
|
import { fetchUserList } from '@/api/users'
|
||||||
import { showAdminFeedback } from '@/components/admin-feedback-ui'
|
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 session = useAdminSessionStore()
|
||||||
const groupName = ref('')
|
const groupName = ref('')
|
||||||
const memberIds = ref<number[]>([])
|
const memberIds = ref<number[]>([])
|
||||||
const leaderId = ref<number | null>(null)
|
|
||||||
const errorText = ref('')
|
const errorText = ref('')
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const users = ref<AdminUser[]>([])
|
const users = ref<AdminUser[]>([])
|
||||||
|
|
||||||
function isAdminUser(u: AdminUser): boolean {
|
/** 组长:新建=当前登录用户;编辑=分组原组长(不可改,只读展示)。 */
|
||||||
const role = (u.role || '').toLowerCase()
|
const leaderUserId = computed<number | null>(() =>
|
||||||
return role === 'admin' || role === 'super_admin' || u.isAdmin === true
|
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(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -34,22 +44,19 @@ watch(
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
groupName.value = ''
|
groupName.value = ''
|
||||||
memberIds.value = []
|
memberIds.value = []
|
||||||
leaderId.value = null
|
|
||||||
errorText.value = ''
|
errorText.value = ''
|
||||||
if (props.group) {
|
if (props.group) {
|
||||||
groupName.value = props.group.name
|
groupName.value = props.group.name
|
||||||
memberIds.value = props.group.memberUserIds ?? []
|
memberIds.value = props.group.memberUserIds ?? []
|
||||||
leaderId.value = props.group.leaderUserId ?? null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadCandidates(): Promise<void> {
|
async function loadCandidates(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const page = await fetchUserList({ page: 1, pageSize: 200 })
|
// 对齐 admin.js loadUserOptions:page=1&page_size=999 全量拉取,避免归属过滤漏人。
|
||||||
|
const page = await fetchUserList({ page: 1, pageSize: 999 })
|
||||||
users.value = page.items
|
users.value = page.items
|
||||||
adminCandidates.value = page.items.filter(isAdminUser)
|
|
||||||
memberCandidates.value = page.items.filter((u) => !isAdminUser(u))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAdminFeedback(actionableErrorText(error), 'error')
|
showAdminFeedback(actionableErrorText(error), 'error')
|
||||||
}
|
}
|
||||||
@@ -69,13 +76,13 @@ async function save(): Promise<void> {
|
|||||||
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
|
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
|
||||||
showAdminFeedback('更新成功', 'success')
|
showAdminFeedback('更新成功', 'success')
|
||||||
} else {
|
} else {
|
||||||
|
// 对齐 admin:组长恒为当前登录用户,提交 createdBy=当前用户 id。
|
||||||
const operatorId = session.user?.id ?? 0
|
const operatorId = session.user?.id ?? 0
|
||||||
if (!operatorId) {
|
if (!operatorId) {
|
||||||
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
|
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const createdBy = leaderId.value && leaderId.value > 0 ? leaderId.value : operatorId
|
await createShopGroup(groupName.value, memberIds.value, operatorId)
|
||||||
await createShopGroup(groupName.value, memberIds.value, createdBy)
|
|
||||||
showAdminFeedback('创建成功', 'success')
|
showAdminFeedback('创建成功', 'success')
|
||||||
}
|
}
|
||||||
emit('saved')
|
emit('saved')
|
||||||
@@ -103,13 +110,8 @@ onMounted(loadCandidates)
|
|||||||
<el-form-item label="分组名称" :error="errorText || undefined">
|
<el-form-item label="分组名称" :error="errorText || undefined">
|
||||||
<el-input v-model="groupName" placeholder="请输入分组名称" />
|
<el-input v-model="groupName" placeholder="请输入分组名称" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="!group && operatorSuper" label="组长(创建人)">
|
<el-form-item label="组长">
|
||||||
<el-select v-model="leaderId" clearable filterable placeholder="默认为当前登录管理员" style="width: 100%">
|
<el-input :model-value="leaderUsername" readonly placeholder="当前登录用户" />
|
||||||
<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>
|
||||||
<el-form-item label="组员">
|
<el-form-item label="组员">
|
||||||
<el-select
|
<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-option v-for="u in memberCandidates" :key="u.id" :label="u.username" :value="u.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="candidate-hint">组员范围:当前可选普通账号(共 {{ memberCandidates.length }} 名)</div>
|
<div class="candidate-hint">{{ candidateHint }}(共 {{ memberCandidates.length }} 名)</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
@@ -95,12 +95,14 @@ onMounted(loadGroups)
|
|||||||
<el-table-column label="组员" min-width="240">
|
<el-table-column label="组员" min-width="240">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row.memberUsernames.length">
|
<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>
|
<el-tag v-for="name in row.memberUsernames" :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>
|
</template>
|
||||||
<span v-else class="member-none">无</span>
|
<span v-else class="member-none">无</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -92,6 +92,45 @@ export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]):
|
|||||||
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) }
|
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[] {
|
function dedupeIds(ids: unknown[]): number[] {
|
||||||
if (!Array.isArray(ids)) return []
|
if (!Array.isArray(ids)) return []
|
||||||
const set = new Set<number>()
|
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')
|
||||||
|
})
|
||||||
@@ -19,7 +19,8 @@ test('test_task_253_group_list_normal_variant_input', () => {
|
|||||||
assert.match(page, /canEditOf/, '需有可编辑判定')
|
assert.match(page, /canEditOf/, '需有可编辑判定')
|
||||||
assert.match(page, /leaderUserId/, '判定依赖组长 leaderUserId')
|
assert.match(page, /leaderUserId/, '判定依赖组长 leaderUserId')
|
||||||
const editor = readSource('src/pages/account/GroupEditorDialog.vue')
|
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', () => {
|
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', () => {
|
test('test_task_253_group_list_boundary_single_item', () => {
|
||||||
// 单组:无组员显示“无”,超员折叠 +N。
|
// 单组:无组员显示“无”;组员 chips 全量渲染(对齐 admin.js 全量 chips,不再收 +N)。
|
||||||
const page = readSource('src/pages/account/GroupsPage.vue')
|
const page = readSource('src/pages/account/GroupsPage.vue')
|
||||||
assert.match(page, /member-none|无/, '无组员占位')
|
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', () => {
|
test('test_task_253_group_list_boundary_limit_or_missing_field', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user