update user page
This commit is contained in:
@@ -1,7 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { api } from '@/utils/request'
|
import { api } from '@/utils/request'
|
||||||
import { useUserManageStore, type ScopeRecord } from '@/stores/user-manage'
|
import { useUserManageStore, type ScopeRecord } from '@/stores/user-manage'
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
|
||||||
|
interface ScopeTreeNode {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
children?: ScopeTreeNode[]
|
||||||
|
isLeaf?: boolean
|
||||||
|
scopeType?: 'company' | 'platform' | 'store'
|
||||||
|
scopeId?: number
|
||||||
|
rawTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -18,47 +27,263 @@ const store = useUserManageStore()
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const scopes = ref<ScopeRecord[]>([])
|
const scopeMode = ref<'company' | 'platform'>('company')
|
||||||
|
const searchValue = ref('')
|
||||||
|
const checkedKeys = ref<string[]>([])
|
||||||
|
const halfCheckedKeys = ref<string[]>([])
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
const autoExpandParent = ref(true)
|
||||||
|
|
||||||
// 基础数据列表
|
// 基础数据列表
|
||||||
const companies = ref<{ id: number; name: string; label: string }[]>([])
|
const companies = ref<{ id: number; name: string; label: string }[]>([])
|
||||||
const platforms = ref<{ id: number; developer_id: number }[]>([])
|
const platforms = ref<{ id: number; name: string; label: string; developer_id: number }[]>([])
|
||||||
const stores = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
const storeList = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
||||||
|
|
||||||
const scopeTypeOptions = [
|
const modeOptions = [
|
||||||
{ value: 'company', label: '公司' },
|
{ value: 'company', label: '按公司授权' },
|
||||||
{ value: 'platform', label: '平台' },
|
{ value: 'platform', label: '按平台授权' },
|
||||||
{ value: 'store', label: '店铺' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function entityOptions(scopeType: string) {
|
// 名称查找辅助
|
||||||
if (scopeType === 'company') {
|
function getCompanyName(id: number): string {
|
||||||
return companies.value.map((c) => ({ value: c.id, label: c.label || c.name }))
|
const c = companies.value.find((x) => x.id === id)
|
||||||
|
return c?.label || c?.name || `公司#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformName(id: number): string {
|
||||||
|
const p = platforms.value.find((x) => x.id === id)
|
||||||
|
return p?.label || p?.name || `平台#${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllKeys(nodes: ScopeTreeNode[]): string[] {
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
keys.push(node.key)
|
||||||
|
if (node.children) keys.push(...getAllKeys(node.children))
|
||||||
}
|
}
|
||||||
if (scopeType === 'platform') {
|
return keys
|
||||||
return platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` }))
|
}
|
||||||
|
|
||||||
|
// 公司模式树:Company → [Platform] Store
|
||||||
|
const companyTree = computed<ScopeTreeNode[]>(() => {
|
||||||
|
return companies.value.map((c) => {
|
||||||
|
const label = c.label || c.name || `公司#${c.id}`
|
||||||
|
const children = storeList.value
|
||||||
|
.filter((s) => s.company_id === c.id)
|
||||||
|
.map((s) => {
|
||||||
|
const storeLabel = s.label || s.name || `店铺#${s.id}`
|
||||||
|
const title = `[${getPlatformName(s.platform_id)}] ${storeLabel}`
|
||||||
|
return {
|
||||||
|
key: `store:${s.id}`,
|
||||||
|
title,
|
||||||
|
rawTitle: title,
|
||||||
|
isLeaf: true,
|
||||||
|
scopeType: 'store' as const,
|
||||||
|
scopeId: s.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
key: `company:${c.id}`,
|
||||||
|
title: label,
|
||||||
|
rawTitle: label,
|
||||||
|
scopeType: 'company' as const,
|
||||||
|
scopeId: c.id,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 平台模式树:Platform → [Company] Store
|
||||||
|
const platformTree = computed<ScopeTreeNode[]>(() => {
|
||||||
|
return platforms.value
|
||||||
|
.map((p) => {
|
||||||
|
const label = p.label || p.name || `平台#${p.id}`
|
||||||
|
const children = storeList.value
|
||||||
|
.filter((s) => s.platform_id === p.id)
|
||||||
|
.map((s) => {
|
||||||
|
const storeLabel = s.label || s.name || `店铺#${s.id}`
|
||||||
|
const title = `[${getCompanyName(s.company_id)}] ${storeLabel}`
|
||||||
|
return {
|
||||||
|
key: `store:${s.id}`,
|
||||||
|
title,
|
||||||
|
rawTitle: title,
|
||||||
|
isLeaf: true,
|
||||||
|
scopeType: 'store' as const,
|
||||||
|
scopeId: s.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
key: `platform:${p.id}`,
|
||||||
|
title: label,
|
||||||
|
rawTitle: label,
|
||||||
|
scopeType: 'platform' as const,
|
||||||
|
scopeId: p.id,
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((node) => node.children.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeTree = computed(() =>
|
||||||
|
scopeMode.value === 'company' ? companyTree.value : platformTree.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const tipMessage = computed(() =>
|
||||||
|
scopeMode.value === 'company'
|
||||||
|
? '勾选公司节点 = 授权其全部店铺;也可展开后仅勾选特定店铺'
|
||||||
|
: '勾选平台节点 = 授权该平台全部店铺(跨公司);也可展开后仅勾选特定店铺'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 搜索高亮
|
||||||
|
function highlightParts(text: string, keyword: string): { text: string; highlight: boolean }[] {
|
||||||
|
if (!keyword) return [{ text, highlight: false }]
|
||||||
|
const lowerText = text.toLowerCase()
|
||||||
|
const lowerKw = keyword.toLowerCase()
|
||||||
|
const parts: { text: string; highlight: boolean }[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let idx = lowerText.indexOf(lowerKw)
|
||||||
|
while (idx !== -1) {
|
||||||
|
if (idx > lastIndex) {
|
||||||
|
parts.push({ text: text.slice(lastIndex, idx), highlight: false })
|
||||||
|
}
|
||||||
|
parts.push({ text: text.slice(idx, idx + keyword.length), highlight: true })
|
||||||
|
lastIndex = idx + keyword.length
|
||||||
|
idx = lowerText.indexOf(lowerKw, lastIndex)
|
||||||
}
|
}
|
||||||
if (scopeType === 'store') {
|
if (lastIndex < text.length) {
|
||||||
return stores.value.map((s) => ({ value: s.id, label: s.label || s.name }))
|
parts.push({ text: text.slice(lastIndex), highlight: false })
|
||||||
}
|
}
|
||||||
return []
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模式切换
|
||||||
|
function applyModeSwitch(mode: 'company' | 'platform') {
|
||||||
|
scopeMode.value = mode
|
||||||
|
checkedKeys.value = []
|
||||||
|
halfCheckedKeys.value = []
|
||||||
|
searchValue.value = ''
|
||||||
|
expandedKeys.value = getAllKeys(activeTree.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModeChange(val: unknown) {
|
||||||
|
const newMode = val as 'company' | 'platform'
|
||||||
|
if (checkedKeys.value.length > 0) {
|
||||||
|
const oldMode = scopeMode.value
|
||||||
|
Modal.confirm({
|
||||||
|
title: '切换授权模式',
|
||||||
|
content: '切换模式将清空当前选择,确认?',
|
||||||
|
onOk() {
|
||||||
|
applyModeSwitch(newMode)
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
// 强制刷新 segmented 受控值回弹
|
||||||
|
scopeMode.value = oldMode
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
applyModeSwitch(newMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 树事件
|
||||||
|
function onCheck(checked: (string | number)[], info: { halfCheckedKeys: (string | number)[] }) {
|
||||||
|
checkedKeys.value = checked.map(String)
|
||||||
|
halfCheckedKeys.value = info.halfCheckedKeys.map(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExpand(keys: string[]) {
|
||||||
|
expandedKeys.value = keys
|
||||||
|
autoExpandParent.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始模式检测
|
||||||
|
function detectInitialMode(userScopes: ScopeRecord[]): 'company' | 'platform' {
|
||||||
|
const hasPlatform = userScopes.some((s) => s.scope_type === 'platform')
|
||||||
|
const hasCompany = userScopes.some((s) => s.scope_type === 'company')
|
||||||
|
if (hasPlatform && !hasCompany) return 'platform'
|
||||||
|
return 'company'
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopes → checkedKeys(父节点需补全子节点 key,受控模式不触发级联)
|
||||||
|
function scopesToCheckedKeys(userScopes: ScopeRecord[]): string[] {
|
||||||
|
const keys: string[] = []
|
||||||
|
const tree = activeTree.value
|
||||||
|
for (const scope of userScopes) {
|
||||||
|
if (scopeMode.value === 'company') {
|
||||||
|
if (scope.scope_type === 'company') {
|
||||||
|
const parentKey = `company:${scope.scope_id}`
|
||||||
|
keys.push(parentKey)
|
||||||
|
const node = tree.find((n) => n.key === parentKey)
|
||||||
|
if (node?.children) keys.push(...node.children.map((c) => c.key))
|
||||||
|
} else if (scope.scope_type === 'store') {
|
||||||
|
keys.push(`store:${scope.scope_id}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (scope.scope_type === 'platform') {
|
||||||
|
const parentKey = `platform:${scope.scope_id}`
|
||||||
|
keys.push(parentKey)
|
||||||
|
const node = tree.find((n) => n.key === parentKey)
|
||||||
|
if (node?.children) keys.push(...node.children.map((c) => c.key))
|
||||||
|
} else if (scope.scope_type === 'store') {
|
||||||
|
keys.push(`store:${scope.scope_id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkedKeys → scopes(智能判断全选/部分选)
|
||||||
|
function checkedKeysToScopes(): { scope_type: string; scope_id: number }[] {
|
||||||
|
const scopes: { scope_type: string; scope_id: number }[] = []
|
||||||
|
const keySet = new Set(checkedKeys.value)
|
||||||
|
|
||||||
|
for (const key of checkedKeys.value) {
|
||||||
|
const [type, idStr] = key.split(':')
|
||||||
|
const id = Number(idStr)
|
||||||
|
if (!type || Number.isNaN(id)) continue
|
||||||
|
|
||||||
|
if (scopeMode.value === 'company') {
|
||||||
|
if (type === 'company') {
|
||||||
|
scopes.push({ scope_type: 'company', scope_id: id })
|
||||||
|
} else if (type === 'store') {
|
||||||
|
const s = storeList.value.find((x) => x.id === id)
|
||||||
|
if (s && !keySet.has(`company:${s.company_id}`)) {
|
||||||
|
scopes.push({ scope_type: 'store', scope_id: id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (type === 'platform') {
|
||||||
|
scopes.push({ scope_type: 'platform', scope_id: id })
|
||||||
|
} else if (type === 'store') {
|
||||||
|
const s = storeList.value.find((x) => x.id === id)
|
||||||
|
if (s && !keySet.has(`platform:${s.platform_id}`)) {
|
||||||
|
scopes.push({ scope_type: 'store', scope_id: id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopes
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('update:open', false)
|
emit('update:open', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow() {
|
async function loadEntities() {
|
||||||
scopes.value.push({ scope_type: 'company', scope_id: 0 })
|
try {
|
||||||
}
|
const [c, p, s] = await Promise.all([
|
||||||
|
api.get<typeof companies.value>('/api/v1/companies'),
|
||||||
function removeRow(index: number) {
|
api.get<typeof platforms.value>('/api/v1/platforms'),
|
||||||
scopes.value.splice(index, 1)
|
api.get<typeof storeList.value>('/api/v1/stores'),
|
||||||
}
|
])
|
||||||
|
companies.value = c
|
||||||
function handleTypeChange(index: number) {
|
platforms.value = p
|
||||||
const row = scopes.value[index]
|
storeList.value = s
|
||||||
if (row) row.scope_id = 0
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '加载基础数据失败'
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
@@ -70,47 +295,22 @@ async function loadData() {
|
|||||||
loadEntities(),
|
loadEntities(),
|
||||||
])
|
])
|
||||||
if (scopeData.status === 'fulfilled') {
|
if (scopeData.status === 'fulfilled') {
|
||||||
scopes.value = scopeData.value.scopes.map((s) => ({
|
const userScopes = scopeData.value.scopes
|
||||||
scope_type: s.scope_type,
|
scopeMode.value = detectInitialMode(userScopes)
|
||||||
scope_id: s.scope_id,
|
await nextTick()
|
||||||
}))
|
checkedKeys.value = scopesToCheckedKeys(userScopes)
|
||||||
} else {
|
} else {
|
||||||
scopes.value = []
|
checkedKeys.value = []
|
||||||
}
|
}
|
||||||
|
expandedKeys.value = getAllKeys(activeTree.value)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntities() {
|
|
||||||
try {
|
|
||||||
const [c, p, s] = await Promise.all([
|
|
||||||
api.get<typeof companies.value>('/api/v1/companies'),
|
|
||||||
api.get<typeof platforms.value>('/api/v1/platforms'),
|
|
||||||
api.get<typeof stores.value>('/api/v1/stores'),
|
|
||||||
])
|
|
||||||
companies.value = c
|
|
||||||
platforms.value = p
|
|
||||||
stores.value = s
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = err instanceof Error ? err.message : '加载基础数据失败'
|
|
||||||
message.error(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!props.userId) return
|
if (!props.userId) return
|
||||||
// 过滤掉未选择实体的行,并去重
|
const validScopes = checkedKeysToScopes()
|
||||||
const seen = new Set<string>()
|
|
||||||
const validScopes = scopes.value
|
|
||||||
.filter((s) => {
|
|
||||||
if (s.scope_id <= 0) return false
|
|
||||||
const key = `${s.scope_type}:${s.scope_id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map(({ scope_type, scope_id }) => ({ scope_type, scope_id }))
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await store.saveUserDataScope(props.userId, validScopes)
|
await store.saveUserDataScope(props.userId, validScopes)
|
||||||
@@ -125,13 +325,38 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 搜索时自动展开匹配节点的父级
|
||||||
|
watch(searchValue, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
expandedKeys.value = getAllKeys(activeTree.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const matched: string[] = []
|
||||||
|
const findParents = (nodes: ScopeTreeNode[], parents: string[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.rawTitle?.toLowerCase().includes(val.toLowerCase())) {
|
||||||
|
matched.push(...parents)
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
findParents(node.children, [...parents, node.key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
findParents(activeTree.value, [])
|
||||||
|
expandedKeys.value = [...new Set(matched)]
|
||||||
|
autoExpandParent.value = true
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
loadData()
|
loadData()
|
||||||
} else {
|
} else {
|
||||||
scopes.value = []
|
checkedKeys.value = []
|
||||||
|
halfCheckedKeys.value = []
|
||||||
|
searchValue.value = ''
|
||||||
|
scopeMode.value = 'company'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -142,48 +367,63 @@ watch(
|
|||||||
<a-modal
|
<a-modal
|
||||||
:open="open"
|
:open="open"
|
||||||
:title="`数据范围 - ${username}`"
|
:title="`数据范围 - ${username}`"
|
||||||
width="640px"
|
width="720px"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
@cancel="handleClose"
|
@cancel="handleClose"
|
||||||
@ok="handleSave"
|
@ok="handleSave"
|
||||||
>
|
>
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<div class="mb-3">
|
<!-- 模式切换 -->
|
||||||
<a-button type="dashed" block @click="addRow">
|
<a-segmented
|
||||||
<template #icon><PlusOutlined /></template>
|
:value="scopeMode"
|
||||||
添加数据范围
|
:options="modeOptions"
|
||||||
</a-button>
|
class="mb-3"
|
||||||
</div>
|
@change="handleModeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="scopes.length === 0 && !loading" class="text-center text-gray-400 py-4">
|
<!-- 提示 -->
|
||||||
暂无数据范围配置,该用户将无法访问任何业务数据
|
<a-alert type="info" show-icon :message="tipMessage" class="mb-3" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<!-- 搜索 -->
|
||||||
v-for="(scope, index) in scopes"
|
<a-input-search
|
||||||
:key="index"
|
v-model:value="searchValue"
|
||||||
class="flex gap-2 mb-2 items-center"
|
placeholder="搜索名称"
|
||||||
>
|
allow-clear
|
||||||
<a-select
|
class="mb-3"
|
||||||
:value="scope.scope_type"
|
/>
|
||||||
:options="scopeTypeOptions"
|
|
||||||
style="width: 120px"
|
<!-- 空状态 -->
|
||||||
@change="(val: unknown) => { scope.scope_type = val as ScopeRecord['scope_type']; handleTypeChange(index) }"
|
<a-empty v-if="activeTree.length === 0 && !loading" description="暂无可授权的数据" />
|
||||||
/>
|
|
||||||
<a-select
|
<!-- 树 -->
|
||||||
:value="scope.scope_id || undefined"
|
<div v-else style="max-height: 400px; overflow-y: auto;">
|
||||||
:options="entityOptions(scope.scope_type)"
|
<a-tree
|
||||||
placeholder="选择实体"
|
:checked-keys="checkedKeys"
|
||||||
show-search
|
checkable
|
||||||
:filter-option="(input: string, option: unknown) =>
|
:tree-data="activeTree"
|
||||||
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
:selectable="false"
|
||||||
"
|
:expanded-keys="expandedKeys"
|
||||||
style="flex: 1"
|
:auto-expand-parent="autoExpandParent"
|
||||||
@change="(val: unknown) => { scope.scope_id = (val as number) ?? 0 }"
|
@check="onCheck"
|
||||||
/>
|
@expand="onExpand"
|
||||||
<a-button type="text" danger size="small" @click="removeRow(index)">
|
>
|
||||||
<template #icon><DeleteOutlined /></template>
|
<template #title="{ dataRef }">
|
||||||
</a-button>
|
<template
|
||||||
|
v-if="searchValue && dataRef.rawTitle?.toLowerCase().includes(searchValue.toLowerCase())"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(part, i) in highlightParts(dataRef.rawTitle, searchValue)"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<span v-if="part.highlight" class="text-red-500 font-semibold">{{
|
||||||
|
part.text
|
||||||
|
}}</span>
|
||||||
|
<span v-else>{{ part.text }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ dataRef.rawTitle || dataRef.title }}</span>
|
||||||
|
</template>
|
||||||
|
</a-tree>
|
||||||
</div>
|
</div>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { message } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import DataScopeModal from '@/components/DataScopeModal.vue'
|
import DataScopeModal from '@/components/DataScopeModal.vue'
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@@ -35,24 +35,24 @@ const mockScopes = {
|
|||||||
role: 'accessor',
|
role: 'accessor',
|
||||||
scopes: [
|
scopes: [
|
||||||
{ scope_type: 'company', scope_id: 1, name: '阿克米公司' },
|
{ scope_type: 'company', scope_id: 1, name: '阿克米公司' },
|
||||||
{ scope_type: 'store', scope_id: 5, name: '我的店铺' },
|
|
||||||
],
|
],
|
||||||
resolved_store_ids: [5],
|
resolved_store_ids: [5, 6],
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockCompanies = [
|
const mockCompanies = [
|
||||||
{ id: 1, name: 'acme', label: '阿克米公司', enabled: true },
|
{ id: 1, name: 'acme', label: '阿克米公司' },
|
||||||
{ id: 2, name: 'beta', label: '贝塔公司', enabled: true },
|
{ id: 2, name: 'beta', label: '贝塔公司' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const mockPlatforms = [
|
const mockPlatforms = [
|
||||||
{ id: 1, developer_id: 1 },
|
{ id: 1, name: 'shopify', label: 'Shopify', developer_id: 1 },
|
||||||
{ id: 2, developer_id: 1 },
|
{ id: 2, name: 'amazon', label: 'Amazon', developer_id: 1 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const mockStores = [
|
const mockStores = [
|
||||||
{ id: 5, company_id: 1, platform_id: 1, name: 'my-store', label: '我的店铺' },
|
{ id: 5, company_id: 1, platform_id: 1, name: 'my-store', label: '我的店铺' },
|
||||||
{ id: 6, company_id: 2, platform_id: 2, name: 'other-store', label: '其他店铺' },
|
{ id: 6, company_id: 1, platform_id: 2, name: 'store-2', label: '店铺二' },
|
||||||
|
{ id: 7, company_id: 2, platform_id: 1, name: 'beta-store', label: '贝塔店铺' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) {
|
function setupMocks(overrides: { scopes?: typeof mockScopes } = {}) {
|
||||||
@@ -99,15 +99,26 @@ describe('DataScopeModal', () => {
|
|||||||
return document.body.querySelectorAll(selector)
|
return document.body.querySelectorAll(selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('数据加载', () => {
|
describe('树渲染', () => {
|
||||||
it('打开时加载用户 scope 数据', async () => {
|
it('公司模式下渲染 Company → Store 层级', async () => {
|
||||||
setupMocks()
|
setupMocks()
|
||||||
await mountModal()
|
await mountModal()
|
||||||
|
|
||||||
expect(vi.mocked(api.get)).toHaveBeenCalled()
|
const treeNodes = queryBody('.ant-tree-treenode')
|
||||||
const calls = vi.mocked(api.get).mock.calls.map((c) => c[0])
|
expect(treeNodes.length).toBeGreaterThanOrEqual(3)
|
||||||
expect(calls.some((url) => url.includes('/data-scope'))).toBe(true)
|
|
||||||
expect(calls.some((url) => url.includes('/companies'))).toBe(true)
|
const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
|
||||||
|
expect(titles.some((t) => t?.includes('阿克米公司'))).toBe(true)
|
||||||
|
expect(titles.some((t) => t?.includes('贝塔公司'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('店铺节点标题包含平台名前缀', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
|
||||||
|
expect(titles.some((t) => t?.includes('[Shopify]'))).toBe(true)
|
||||||
|
expect(titles.some((t) => t?.includes('[Amazon]'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('标题包含用户名', async () => {
|
it('标题包含用户名', async () => {
|
||||||
@@ -125,61 +136,88 @@ describe('DataScopeModal', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Scope 行操作', () => {
|
describe('平台模式', () => {
|
||||||
it('加载后显示 scope 行', async () => {
|
it('切换到平台模式后渲染 Platform → Store 层级', async () => {
|
||||||
setupMocks()
|
setupMocks({
|
||||||
|
scopes: { ...mockScopes, scopes: [] },
|
||||||
|
})
|
||||||
await mountModal()
|
await mountModal()
|
||||||
|
|
||||||
// 2 scope rows rendered as flex rows
|
const segments = Array.from(queryBody('.ant-segmented-item'))
|
||||||
const selects = queryBody('.ant-select')
|
const platformSeg = segments.find((el) => el.textContent?.includes('按平台授权'))
|
||||||
// Each row has 2 selects (type + entity), so expect >= 4
|
expect(platformSeg).toBeTruthy()
|
||||||
expect(selects.length).toBeGreaterThanOrEqual(4)
|
;(platformSeg as HTMLElement).click()
|
||||||
})
|
|
||||||
|
|
||||||
it('点击添加按钮增加一行', async () => {
|
|
||||||
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
|
|
||||||
await mountModal()
|
|
||||||
|
|
||||||
const addBtn = Array.from(queryBody('.ant-btn-dashed')).find(
|
|
||||||
(btn) => btn.textContent?.includes('添加数据范围'),
|
|
||||||
) as HTMLElement
|
|
||||||
expect(addBtn).toBeTruthy()
|
|
||||||
addBtn.click()
|
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Should have at least one row with selects now
|
const titles = Array.from(queryBody('.ant-tree-title')).map((el) => el.textContent)
|
||||||
const selects = queryBody('.ant-select')
|
expect(titles.some((t) => t?.includes('Shopify'))).toBe(true)
|
||||||
expect(selects.length).toBeGreaterThanOrEqual(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('点击删除按钮移除行', async () => {
|
|
||||||
setupMocks()
|
|
||||||
await mountModal()
|
|
||||||
|
|
||||||
const deleteBtns = queryBody('.anticon-delete')
|
|
||||||
const initialCount = deleteBtns.length
|
|
||||||
expect(initialCount).toBe(2)
|
|
||||||
|
|
||||||
// Click first delete
|
|
||||||
const firstDeleteBtn = deleteBtns[0]?.closest('button') as HTMLElement
|
|
||||||
if (firstDeleteBtn) {
|
|
||||||
firstDeleteBtn.click()
|
|
||||||
await flushPromises()
|
|
||||||
await nextTick()
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterDeleteBtns = queryBody('.anticon-delete')
|
|
||||||
expect(afterDeleteBtns.length).toBe(initialCount - 1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('保存', () => {
|
describe('模式切换确认', () => {
|
||||||
it('保存调用正确 API', async () => {
|
it('有勾选项时切换模式弹出确认框', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const confirmSpy = vi.spyOn(Modal, 'confirm')
|
||||||
|
const segments = Array.from(queryBody('.ant-segmented-item'))
|
||||||
|
const platformSeg = segments.find((el) => el.textContent?.includes('按平台授权'))
|
||||||
|
;(platformSeg as HTMLElement).click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('初始化', () => {
|
||||||
|
it('已有 company scope 时打开弹窗后有勾选节点', async () => {
|
||||||
|
setupMocks()
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const checkedBoxes = queryBody('.ant-tree-checkbox-checked')
|
||||||
|
expect(checkedBoxes.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已有 platform scope 时自动切换到平台模式', async () => {
|
||||||
|
setupMocks({
|
||||||
|
scopes: {
|
||||||
|
...mockScopes,
|
||||||
|
scopes: [{ scope_type: 'platform', scope_id: 1, name: 'Shopify' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const activeSegment = document.body.querySelector('.ant-segmented-item-selected')
|
||||||
|
expect(activeSegment?.textContent).toContain('按平台授权')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('搜索高亮', () => {
|
||||||
|
it('搜索关键词后匹配节点文字被高亮', async () => {
|
||||||
|
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const input = document.body.querySelector('.ant-input-search input') as HTMLInputElement
|
||||||
|
expect(input).toBeTruthy()
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.value = '阿克米'
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const highlighted = queryBody('.text-red-500')
|
||||||
|
expect(highlighted.length).toBeGreaterThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('保存逻辑', () => {
|
||||||
|
it('全选公司节点保存为 company scope', async () => {
|
||||||
setupMocks()
|
setupMocks()
|
||||||
await mountModal()
|
await mountModal()
|
||||||
|
|
||||||
// Click OK button
|
|
||||||
const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
const okBtn = Array.from(queryBody('.ant-btn-primary')).find(
|
||||||
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
|
(btn) => btn.textContent?.includes('确') || btn.textContent?.includes('OK'),
|
||||||
) as HTMLElement
|
) as HTMLElement
|
||||||
@@ -190,12 +228,11 @@ describe('DataScopeModal', () => {
|
|||||||
|
|
||||||
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
expect(vi.mocked(api.put)).toHaveBeenCalledWith(
|
||||||
'/api/v1/users/1/data-scope',
|
'/api/v1/users/1/data-scope',
|
||||||
{
|
expect.objectContaining({
|
||||||
scopes: [
|
scopes: expect.arrayContaining([
|
||||||
{ scope_type: 'company', scope_id: 1 },
|
expect.objectContaining({ scope_type: 'company', scope_id: 1 }),
|
||||||
{ scope_type: 'store', scope_id: 5 },
|
]),
|
||||||
],
|
}),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,4 +269,14 @@ describe('DataScopeModal', () => {
|
|||||||
expect(spy).toHaveBeenCalledWith('保存出错')
|
expect(spy).toHaveBeenCalledWith('保存出错')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('提示文案', () => {
|
||||||
|
it('公司模式显示公司授权提示', async () => {
|
||||||
|
setupMocks({ scopes: { ...mockScopes, scopes: [] } })
|
||||||
|
await mountModal()
|
||||||
|
|
||||||
|
const alert = document.body.querySelector('.ant-alert-message')
|
||||||
|
expect(alert?.textContent).toContain('勾选公司节点')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ describe('useUserManageStore', () => {
|
|||||||
expect(store.filters.username).toBe('')
|
expect(store.filters.username).toBe('')
|
||||||
expect(store.filters.email).toBe('')
|
expect(store.filters.email).toBe('')
|
||||||
expect(store.filters.status).toBeUndefined()
|
expect(store.filters.status).toBeUndefined()
|
||||||
|
expect(store.filters.role_id).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ describe('useUserManageStore', () => {
|
|||||||
username: undefined,
|
username: undefined,
|
||||||
email: undefined,
|
email: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
role_id: undefined,
|
||||||
})
|
})
|
||||||
expect(store.users).toEqual(mockUsers)
|
expect(store.users).toEqual(mockUsers)
|
||||||
expect(store.pagination.total).toBe(2)
|
expect(store.pagination.total).toBe(2)
|
||||||
@@ -95,11 +97,13 @@ describe('useUserManageStore', () => {
|
|||||||
const store = useUserManageStore()
|
const store = useUserManageStore()
|
||||||
store.filters.username = 'admin'
|
store.filters.username = 'admin'
|
||||||
store.filters.status = 1
|
store.filters.status = 1
|
||||||
|
store.filters.role_id = 2
|
||||||
await store.fetchUsers()
|
await store.fetchUsers()
|
||||||
|
|
||||||
expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({
|
expect(api.get).toHaveBeenCalledWith('/api/v1/users', expect.objectContaining({
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
status: 1,
|
status: 1,
|
||||||
|
role_id: 2,
|
||||||
}))
|
}))
|
||||||
expect(store.users).toHaveLength(1)
|
expect(store.users).toHaveLength(1)
|
||||||
})
|
})
|
||||||
@@ -155,6 +159,7 @@ describe('useUserManageStore', () => {
|
|||||||
store.filters.username = 'test'
|
store.filters.username = 'test'
|
||||||
store.filters.email = 'test@test.com'
|
store.filters.email = 'test@test.com'
|
||||||
store.filters.status = 1
|
store.filters.status = 1
|
||||||
|
store.filters.role_id = 3
|
||||||
store.pagination.page = 3
|
store.pagination.page = 3
|
||||||
|
|
||||||
store.resetFilters()
|
store.resetFilters()
|
||||||
@@ -162,6 +167,7 @@ describe('useUserManageStore', () => {
|
|||||||
expect(store.filters.username).toBe('')
|
expect(store.filters.username).toBe('')
|
||||||
expect(store.filters.email).toBe('')
|
expect(store.filters.email).toBe('')
|
||||||
expect(store.filters.status).toBeUndefined()
|
expect(store.filters.status).toBeUndefined()
|
||||||
|
expect(store.filters.role_id).toBeUndefined()
|
||||||
expect(store.pagination.page).toBe(1)
|
expect(store.pagination.page).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -181,9 +187,20 @@ describe('Users Page', () => {
|
|||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mountPage() {
|
const mockRoles = [
|
||||||
|
{ id: 1, name: 'administrator', description: '管理员' },
|
||||||
|
{ id: 2, name: 'accessor', description: '访问者' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function mountPage(opts: { rolesError?: boolean } = {}) {
|
||||||
const { api } = await import('@/utils/request')
|
const { api } = await import('@/utils/request')
|
||||||
vi.mocked(api.get).mockResolvedValue(mockPaginatedResponse)
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/roles') {
|
||||||
|
if (opts.rolesError) return Promise.reject(new Error('roles fail'))
|
||||||
|
return Promise.resolve(mockRoles) as never
|
||||||
|
}
|
||||||
|
return Promise.resolve(mockPaginatedResponse) as never
|
||||||
|
})
|
||||||
|
|
||||||
// 设置 admin 用户以显示操作按钮
|
// 设置 admin 用户以显示操作按钮
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -293,4 +310,21 @@ describe('Users Page', () => {
|
|||||||
const drawer = document.body.querySelector('.ant-drawer')
|
const drawer = document.body.querySelector('.ant-drawer')
|
||||||
expect(drawer).toBeTruthy()
|
expect(drawer).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders role filter select with options', async () => {
|
||||||
|
await mountPage()
|
||||||
|
const labels = Array.from(document.body.querySelectorAll('.ant-form-item-label label'))
|
||||||
|
const roleLabel = labels.find((l) => l.textContent?.includes('角色'))
|
||||||
|
expect(roleLabel).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('page still works when role list fails to load', async () => {
|
||||||
|
await mountPage({ rolesError: true })
|
||||||
|
|
||||||
|
// 页面标题仍正常渲染
|
||||||
|
expect(wrapper.find('h2').text()).toBe('用户管理')
|
||||||
|
// 表格仍正常渲染
|
||||||
|
const rows = document.body.querySelectorAll('.ant-table-tbody tr.ant-table-row')
|
||||||
|
expect(rows).toHaveLength(mockUsers.length)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const assigningUser = ref<UserRecord | null>(null)
|
|||||||
const selectedRoleId = ref<number | undefined>(undefined)
|
const selectedRoleId = ref<number | undefined>(undefined)
|
||||||
const roleOptions = ref<{ value: number; label: string }[]>([])
|
const roleOptions = ref<{ value: number; label: string }[]>([])
|
||||||
|
|
||||||
|
// Role filter options
|
||||||
|
const roleFilterOptions = ref<{ value: number; label: string }[]>([])
|
||||||
|
|
||||||
// Data scope modal
|
// Data scope modal
|
||||||
const scopeModalOpen = ref(false)
|
const scopeModalOpen = ref(false)
|
||||||
const scopeUserId = ref<number | null>(null)
|
const scopeUserId = ref<number | null>(null)
|
||||||
@@ -48,6 +51,11 @@ const columns = [
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.fetchUsers()
|
store.fetchUsers()
|
||||||
|
api.get<{ id: number; name: string; description: string }[]>('/api/v1/roles')
|
||||||
|
.then((roles) => {
|
||||||
|
roleFilterOptions.value = roles.map((r) => ({ value: r.id, label: r.description || r.name }))
|
||||||
|
})
|
||||||
|
.catch(() => { /* 角色列表加载失败不影响页面 */ })
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
@@ -185,6 +193,15 @@ function formatTime(time: string) {
|
|||||||
<a-select-option :value="0">禁用</a-select-option>
|
<a-select-option :value="0">禁用</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="角色">
|
||||||
|
<a-select
|
||||||
|
v-model:value="store.filters.role_id"
|
||||||
|
placeholder="全部"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
:options="roleFilterOptions"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" html-type="submit">
|
<a-button type="primary" html-type="submit">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface UserFilters {
|
|||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
status: number | undefined
|
status: number | undefined
|
||||||
|
role_id: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScopeRecord {
|
export interface ScopeRecord {
|
||||||
@@ -44,6 +45,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
|||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
role_id: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
@@ -55,6 +57,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
|||||||
username: filters.username || undefined,
|
username: filters.username || undefined,
|
||||||
email: filters.email || undefined,
|
email: filters.email || undefined,
|
||||||
status: filters.status,
|
status: filters.status,
|
||||||
|
role_id: filters.role_id,
|
||||||
})
|
})
|
||||||
users.value = data.items
|
users.value = data.items
|
||||||
pagination.total = data.total
|
pagination.total = data.total
|
||||||
@@ -71,6 +74,7 @@ export const useUserManageStore = defineStore('user-manage', () => {
|
|||||||
filters.username = ''
|
filters.username = ''
|
||||||
filters.email = ''
|
filters.email = ''
|
||||||
filters.status = undefined
|
filters.status = undefined
|
||||||
|
filters.role_id = undefined
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user