Files
datahub/frontend/src/components/DataScopeModal.vue
T

431 lines
12 KiB
Vue
Raw Normal View History

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>