update user page
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { api } from '@/utils/request'
|
||||
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<{
|
||||
open: boolean
|
||||
@@ -18,47 +27,263 @@ const store = useUserManageStore()
|
||||
|
||||
const loading = 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 platforms = ref<{ id: number; developer_id: number }[]>([])
|
||||
const stores = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
||||
const platforms = ref<{ id: number; name: string; label: string; developer_id: number }[]>([])
|
||||
const storeList = ref<{ id: number; company_id: number; platform_id: number; name: string; label: string }[]>([])
|
||||
|
||||
const scopeTypeOptions = [
|
||||
{ value: 'company', label: '公司' },
|
||||
{ value: 'platform', label: '平台' },
|
||||
{ value: 'store', label: '店铺' },
|
||||
const modeOptions = [
|
||||
{ value: 'company', label: '按公司授权' },
|
||||
{ value: 'platform', label: '按平台授权' },
|
||||
]
|
||||
|
||||
function entityOptions(scopeType: string) {
|
||||
if (scopeType === 'company') {
|
||||
return companies.value.map((c) => ({ value: c.id, label: c.label || c.name }))
|
||||
// 名称查找辅助
|
||||
function getCompanyName(id: number): string {
|
||||
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 platforms.value.map((p) => ({ value: p.id, label: `平台 #${p.id}` }))
|
||||
return keys
|
||||
}
|
||||
|
||||
// 公司模式树: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') {
|
||||
return stores.value.map((s) => ({ value: s.id, label: s.label || s.name }))
|
||||
if (lastIndex < text.length) {
|
||||
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() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
scopes.value.push({ scope_type: 'company', scope_id: 0 })
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
scopes.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleTypeChange(index: number) {
|
||||
const row = scopes.value[index]
|
||||
if (row) row.scope_id = 0
|
||||
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 storeList.value>('/api/v1/stores'),
|
||||
])
|
||||
companies.value = c
|
||||
platforms.value = p
|
||||
storeList.value = s
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '加载基础数据失败'
|
||||
message.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -70,47 +295,22 @@ async function loadData() {
|
||||
loadEntities(),
|
||||
])
|
||||
if (scopeData.status === 'fulfilled') {
|
||||
scopes.value = scopeData.value.scopes.map((s) => ({
|
||||
scope_type: s.scope_type,
|
||||
scope_id: s.scope_id,
|
||||
}))
|
||||
const userScopes = scopeData.value.scopes
|
||||
scopeMode.value = detectInitialMode(userScopes)
|
||||
await nextTick()
|
||||
checkedKeys.value = scopesToCheckedKeys(userScopes)
|
||||
} else {
|
||||
scopes.value = []
|
||||
checkedKeys.value = []
|
||||
}
|
||||
expandedKeys.value = getAllKeys(activeTree.value)
|
||||
} finally {
|
||||
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() {
|
||||
if (!props.userId) return
|
||||
// 过滤掉未选择实体的行,并去重
|
||||
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 }))
|
||||
const validScopes = checkedKeysToScopes()
|
||||
saving.value = true
|
||||
try {
|
||||
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(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
loadData()
|
||||
} else {
|
||||
scopes.value = []
|
||||
checkedKeys.value = []
|
||||
halfCheckedKeys.value = []
|
||||
searchValue.value = ''
|
||||
scopeMode.value = 'company'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -142,48 +367,63 @@ watch(
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="`数据范围 - ${username}`"
|
||||
width="640px"
|
||||
width="720px"
|
||||
:confirm-loading="saving"
|
||||
@cancel="handleClose"
|
||||
@ok="handleSave"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<div class="mb-3">
|
||||
<a-button type="dashed" block @click="addRow">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加数据范围
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 模式切换 -->
|
||||
<a-segmented
|
||||
:value="scopeMode"
|
||||
:options="modeOptions"
|
||||
class="mb-3"
|
||||
@change="handleModeChange"
|
||||
/>
|
||||
|
||||
<div v-if="scopes.length === 0 && !loading" class="text-center text-gray-400 py-4">
|
||||
暂无数据范围配置,该用户将无法访问任何业务数据
|
||||
</div>
|
||||
<!-- 提示 -->
|
||||
<a-alert type="info" show-icon :message="tipMessage" class="mb-3" />
|
||||
|
||||
<div
|
||||
v-for="(scope, index) in scopes"
|
||||
:key="index"
|
||||
class="flex gap-2 mb-2 items-center"
|
||||
>
|
||||
<a-select
|
||||
:value="scope.scope_type"
|
||||
:options="scopeTypeOptions"
|
||||
style="width: 120px"
|
||||
@change="(val: unknown) => { scope.scope_type = val as ScopeRecord['scope_type']; handleTypeChange(index) }"
|
||||
/>
|
||||
<a-select
|
||||
:value="scope.scope_id || undefined"
|
||||
:options="entityOptions(scope.scope_type)"
|
||||
placeholder="选择实体"
|
||||
show-search
|
||||
:filter-option="(input: string, option: unknown) =>
|
||||
((option as { label: string }).label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
"
|
||||
style="flex: 1"
|
||||
@change="(val: unknown) => { scope.scope_id = (val as number) ?? 0 }"
|
||||
/>
|
||||
<a-button type="text" danger size="small" @click="removeRow(index)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
<!-- 搜索 -->
|
||||
<a-input-search
|
||||
v-model:value="searchValue"
|
||||
placeholder="搜索名称"
|
||||
allow-clear
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty v-if="activeTree.length === 0 && !loading" description="暂无可授权的数据" />
|
||||
|
||||
<!-- 树 -->
|
||||
<div v-else style="max-height: 400px; overflow-y: auto;">
|
||||
<a-tree
|
||||
:checked-keys="checkedKeys"
|
||||
checkable
|
||||
:tree-data="activeTree"
|
||||
:selectable="false"
|
||||
:expanded-keys="expandedKeys"
|
||||
:auto-expand-parent="autoExpandParent"
|
||||
@check="onCheck"
|
||||
@expand="onExpand"
|
||||
>
|
||||
<template #title="{ dataRef }">
|
||||
<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>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
Reference in New Issue
Block a user