2026-03-19 11:19:55 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { api } from '@/utils/request'
|
|
|
|
|
import { useUserManageStore, type ScopeRecord } from '@/stores/user-manage'
|
2026-04-01 15:42:14 +08:00
|
|
|
|
|
|
|
|
interface ScopeTreeNode {
|
|
|
|
|
key: string
|
|
|
|
|
title: string
|
|
|
|
|
children?: ScopeTreeNode[]
|
|
|
|
|
isLeaf?: boolean
|
|
|
|
|
scopeType?: 'company' | 'platform' | 'store'
|
|
|
|
|
scopeId?: number
|
|
|
|
|
rawTitle?: string
|
|
|
|
|
}
|
2026-03-19 11:19:55 +08:00
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
open: boolean
|
|
|
|
|
userId: number | null
|
|
|
|
|
username: string
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
'update:open': [value: boolean]
|
|
|
|
|
saved: []
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
const store = useUserManageStore()
|
|
|
|
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const saving = ref(false)
|
2026-04-01 15:42:14 +08:00
|
|
|
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)
|
2026-03-19 11:19:55 +08:00
|
|
|
|
|
|
|
|
// 基础数据列表
|
|
|
|
|
const companies = ref<{ id: number; name: string; label: string }[]>([])
|
2026-04-01 15:42:14 +08:00
|
|
|
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 }[]>([])
|
2026-03-19 11:19:55 +08:00
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
const modeOptions = [
|
|
|
|
|
{ value: 'company', label: '按公司授权' },
|
|
|
|
|
{ value: 'platform', label: '按平台授权' },
|
2026-03-19 11:19:55 +08:00
|
|
|
]
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
// 名称查找辅助
|
|
|
|
|
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))
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
2026-04-01 15:42:14 +08:00
|
|
|
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)
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
2026-04-01 15:42:14 +08:00
|
|
|
if (lastIndex < text.length) {
|
|
|
|
|
parts.push({ text: text.slice(lastIndex), highlight: false })
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
2026-04-01 15:42:14 +08:00
|
|
|
return parts
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
// 模式切换
|
|
|
|
|
function applyModeSwitch(mode: 'company' | 'platform') {
|
|
|
|
|
scopeMode.value = mode
|
|
|
|
|
checkedKeys.value = []
|
|
|
|
|
halfCheckedKeys.value = []
|
|
|
|
|
searchValue.value = ''
|
|
|
|
|
expandedKeys.value = getAllKeys(activeTree.value)
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
// 树事件
|
|
|
|
|
function onCheck(checked: (string | number)[], info: { halfCheckedKeys: (string | number)[] }) {
|
|
|
|
|
checkedKeys.value = checked.map(String)
|
|
|
|
|
halfCheckedKeys.value = info.halfCheckedKeys.map(String)
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
function onExpand(keys: string[]) {
|
|
|
|
|
expandedKeys.value = keys
|
|
|
|
|
autoExpandParent.value = false
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
// 初始模式检测
|
|
|
|
|
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}`)
|
|
|
|
|
}
|
2026-03-19 11:19:55 +08:00
|
|
|
} else {
|
2026-04-01 15:42:14 +08:00
|
|
|
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}`)
|
|
|
|
|
}
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-01 15:42:14 +08:00
|
|
|
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)
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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'),
|
2026-04-01 15:42:14 +08:00
|
|
|
api.get<typeof storeList.value>('/api/v1/stores'),
|
2026-03-19 11:19:55 +08:00
|
|
|
])
|
|
|
|
|
companies.value = c
|
|
|
|
|
platforms.value = p
|
2026-04-01 15:42:14 +08:00
|
|
|
storeList.value = s
|
2026-03-19 11:19:55 +08:00
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : '加载基础数据失败'
|
|
|
|
|
message.error(msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
async function loadData() {
|
|
|
|
|
if (!props.userId) return
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const [scopeData] = await Promise.allSettled([
|
|
|
|
|
store.fetchUserDataScope(props.userId),
|
|
|
|
|
loadEntities(),
|
|
|
|
|
])
|
|
|
|
|
if (scopeData.status === 'fulfilled') {
|
|
|
|
|
const userScopes = scopeData.value.scopes
|
|
|
|
|
scopeMode.value = detectInitialMode(userScopes)
|
|
|
|
|
await nextTick()
|
|
|
|
|
checkedKeys.value = scopesToCheckedKeys(userScopes)
|
|
|
|
|
} else {
|
|
|
|
|
checkedKeys.value = []
|
|
|
|
|
}
|
|
|
|
|
expandedKeys.value = getAllKeys(activeTree.value)
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 11:19:55 +08:00
|
|
|
async function handleSave() {
|
|
|
|
|
if (!props.userId) return
|
2026-04-01 15:42:14 +08:00
|
|
|
const validScopes = checkedKeysToScopes()
|
2026-03-19 11:19:55 +08:00
|
|
|
saving.value = true
|
|
|
|
|
try {
|
|
|
|
|
await store.saveUserDataScope(props.userId, validScopes)
|
|
|
|
|
message.success('数据范围保存成功')
|
|
|
|
|
emit('saved')
|
|
|
|
|
handleClose()
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : '保存失败'
|
|
|
|
|
message.error(msg)
|
|
|
|
|
} finally {
|
|
|
|
|
saving.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
// 搜索时自动展开匹配节点的父级
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-19 11:19:55 +08:00
|
|
|
watch(
|
|
|
|
|
() => props.open,
|
|
|
|
|
(val) => {
|
|
|
|
|
if (val) {
|
|
|
|
|
loadData()
|
|
|
|
|
} else {
|
2026-04-01 15:42:14 +08:00
|
|
|
checkedKeys.value = []
|
|
|
|
|
halfCheckedKeys.value = []
|
|
|
|
|
searchValue.value = ''
|
|
|
|
|
scopeMode.value = 'company'
|
2026-03-19 11:19:55 +08:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
)
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<a-modal
|
|
|
|
|
:open="open"
|
|
|
|
|
:title="`数据范围 - ${username}`"
|
2026-04-01 15:42:14 +08:00
|
|
|
width="720px"
|
2026-03-19 11:19:55 +08:00
|
|
|
:confirm-loading="saving"
|
|
|
|
|
@cancel="handleClose"
|
|
|
|
|
@ok="handleSave"
|
|
|
|
|
>
|
|
|
|
|
<a-spin :spinning="loading">
|
2026-04-01 15:42:14 +08:00
|
|
|
<!-- 模式切换 -->
|
|
|
|
|
<a-segmented
|
|
|
|
|
:value="scopeMode"
|
|
|
|
|
:options="modeOptions"
|
|
|
|
|
class="mb-3"
|
|
|
|
|
@change="handleModeChange"
|
|
|
|
|
/>
|
2026-03-19 11:19:55 +08:00
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
<!-- 提示 -->
|
|
|
|
|
<a-alert type="info" show-icon :message="tipMessage" class="mb-3" />
|
|
|
|
|
|
|
|
|
|
<!-- 搜索 -->
|
|
|
|
|
<a-input-search
|
|
|
|
|
v-model:value="searchValue"
|
|
|
|
|
placeholder="搜索名称"
|
|
|
|
|
allow-clear
|
|
|
|
|
class="mb-3"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
<a-empty v-if="activeTree.length === 0 && !loading" description="暂无可授权的数据" />
|
2026-03-19 11:19:55 +08:00
|
|
|
|
2026-04-01 15:42:14 +08:00
|
|
|
<!-- 树 -->
|
|
|
|
|
<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>
|
2026-03-19 11:19:55 +08:00
|
|
|
</div>
|
|
|
|
|
</a-spin>
|
|
|
|
|
</a-modal>
|
|
|
|
|
</template>
|