改造后台权限和APP权限

This commit is contained in:
super
2026-04-19 15:06:09 +08:00
parent d5764f3b50
commit 8db9f3a408
23 changed files with 1343 additions and 36 deletions

View File

@@ -20,6 +20,24 @@
</div>
<!-- 按指定ASIN文档上传 -->
<div class="section-title">执行配置</div>
<div class="mode-zone">
<label class="mode-check-row">
<input v-model="executionMode" type="radio" class="mode-check-input" value="FINITE" />
<span class="mode-check-text">执行固定轮次</span>
<span class="mode-hint-inline">按整批店铺重复执行指定次数</span>
</label>
<label class="mode-check-row">
<input v-model="executionMode" type="radio" class="mode-check-input" value="INFINITE" />
<span class="mode-check-text">无限执行</span>
<span class="mode-hint-inline">上一轮全部成功后立即开始下一轮</span>
</label>
<div v-if="executionMode === 'FINITE'" class="loop-round-input">
<span class="hint">轮次</span>
<el-input-number v-model="roundCount" :min="1" :max="9999" :disabled="hasRunningLoop || pushing" />
</div>
</div>
<template v-if="asinModeEnabled">
<div class="section-title">ASIN文档 <span class="tag-badge">自定义模式</span></div>
<div class="upload-zone">
@@ -101,12 +119,23 @@
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
{{ matching ? '匹配中' : '匹配店铺' }}
</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonQueue">
<button type="button" class="btn-run btn-queue" :disabled="pushing || !hasValidMode" @click="pushToPythonLoopQueue">
{{ pushing ? '推送中' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">上传ASIN文档和多选店铺<strong>可同时生效</strong>跟价结果合并输出全量和自定义的区别仅在于ASIN来源不同</p>
<div v-if="activeLoopRun" class="loop-status-card">
<div class="loop-status-line">循环任务 ID{{ activeLoopRun.id }}</div>
<div class="loop-status-line">状态{{ activeLoopRun.status || '-' }}</div>
<div class="loop-status-line">进度{{ loopProgressText }}</div>
<div class="loop-status-line">当前店铺{{ ((activeLoopRun.currentShopIndex || 0) + 1) }}/{{ activeLoopRun.totalShopCount || 0 }}</div>
<div v-if="activeLoopRun.errorMessage" class="loop-status-line loop-error">{{ activeLoopRun.errorMessage }}</div>
<button type="button" class="opt-btn loop-stop-btn" :disabled="!hasRunningLoop || stopLoopPending" @click="stopLoopExecution">
{{ stopLoopPending ? '停止中...' : '停止循环' }}
</button>
</div>
<div v-if="queuePushResult" class="queue-debug-card">
<div class="section-title queue-debug-title">推送结果</div>
<div class="queue-debug-line">{{ queuePushResult }}</div>
@@ -176,6 +205,7 @@
<li v-for="item in currentSectionItems" :key="`cur-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
@@ -198,6 +228,7 @@
<li v-for="item in historySectionItems" :key="`his-${item.resultId}-${item.taskId}`"
class="task-item split-result-item">
<div class="left split-result-main">
<div v-if="item.roundIndex" class="files">轮次 {{ item.roundIndex }} </div>
<span class="id" :title="item.shopName || ''">{{ item.shopName || '-' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.outputFilename" class="files">文件{{ item.outputFilename }}</div>
@@ -230,7 +261,10 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import {
addPriceTrackCandidate,
completePriceTrackLoopChild,
createPriceTrackTask,
createPriceTrackLoopRun,
dispatchNextPriceTrackLoopRun,
deletePendingPriceTrackShopResult,
deletePriceTrackCandidate,
deletePriceTrackHistory,
@@ -238,17 +272,21 @@ import {
getPriceTrackCountryPreference,
getPriceTrackDashboard,
getPriceTrackHistory,
getPriceTrackLoopRun,
getPriceTrackResultDownloadUrl,
getPriceTrackTaskProgressBatch,
getPriceTrackTasksBatch,
listPriceTrackCandidates,
matchPriceTrackShops,
putPriceTrackCountryPreference,
stopPriceTrackLoopRun,
type PriceTrackAsinParsedRow,
type PriceTrackCandidateVo,
type PriceTrackCreateTaskVo,
type PriceTrackDashboardVo,
type PriceTrackExecutionMode,
type PriceTrackHistoryItem,
type PriceTrackLoopRunVo,
type PriceTrackShopQueueItem,
type PriceTrackTaskDetailVo,
} from '@/shared/api/java-modules'
@@ -273,9 +311,15 @@ const pushing = ref(false)
const queuePushResult = ref('')
const queuePayloadText = ref('')
const matchAsinRowsByCountry = ref<Record<string, PriceTrackAsinParsedRow[]>>({})
const executionMode = ref<PriceTrackExecutionMode>('FINITE')
const roundCount = ref(1)
const activeLoopRunId = ref<number | null>(null)
const activeLoopRun = ref<PriceTrackLoopRunVo | null>(null)
const loopDispatching = ref(false)
const stopLoopPending = ref(false)
// 跟价模式(可同时启用)
const statusModeEnabled = ref(false) // 按商品状态(全量)
const statusModeEnabled = ref(true) // 按商品状态(全量)
const asinModeEnabled = ref(false) // 按指定ASIN文档自定义
// 上传文件
@@ -283,6 +327,19 @@ const asinFiles = ref<string[]>([]) // ASIN文档
// 是否有有效模式
const hasValidMode = computed(() => statusModeEnabled.value || asinModeEnabled.value)
const loopIsTerminal = computed(() => {
const status = activeLoopRun.value?.status || ''
return status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED'
})
const hasRunningLoop = computed(() => !!activeLoopRunId.value && !loopIsTerminal.value)
const loopProgressText = computed(() => {
const loop = activeLoopRun.value
if (!loop) return ''
const round = loop.currentRound || 1
if (loop.executionMode === 'INFINITE') return `${round} 轮(无限)`
const total = Math.max(1, loop.targetRounds || roundCount.value || 1)
return `${round}/${total}`
})
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
const dragCountryIndex = ref<number | null>(null)
@@ -324,6 +381,10 @@ function matchedItemsStorageKey() {
return `price-track:matched-items:${uidForStorage()}`
}
function activeLoopStorageKey() {
return `price-track:active-loop:${uidForStorage()}`
}
function setStorageJson(key: string, value: unknown, shouldRemove: boolean) {
if (typeof window === 'undefined') return
try {
@@ -407,6 +468,24 @@ function saveMatchedItemsToStorage() {
setStorageJson(matchedItemsStorageKey(), matchedItems.value, matchedItems.value.length === 0)
}
function loadActiveLoopRunIdFromStorage() {
try {
const raw = typeof window !== 'undefined' ? window.localStorage.getItem(activeLoopStorageKey()) : null
if (!raw) {
activeLoopRunId.value = null
return
}
const parsed = Number(JSON.parse(raw))
activeLoopRunId.value = Number.isFinite(parsed) && parsed > 0 ? parsed : null
} catch {
activeLoopRunId.value = null
}
}
function saveActiveLoopRunId() {
setStorageJson(activeLoopStorageKey(), activeLoopRunId.value, !activeLoopRunId.value)
}
// ========== 文件上传 ==========
async function selectAsinFile() {
const api = getPywebviewApi()
@@ -704,15 +783,18 @@ function rowKeyForMatch(row: { shopName?: string; shopId?: number | string | nul
function removeMatchedRowsLocally(rows: Array<{ shopName?: string; shopId?: number | string | null }>) {
if (!rows.length) return
const keys = new Set(rows.map((row) => rowKeyForMatch(row)))
matchedItems.value = matchedItems.value.filter((row) => !keys.has(rowKeyForMatch(row)))
const names = new Set(rows.map((row) => (row.shopName || '').trim()).filter((name) => !!name))
matchedItems.value = matchedItems.value.filter((row) => {
const key = rowKeyForMatch(row)
const name = (row.shopName || '').trim()
return !keys.has(key) && (!name || !names.has(name))
})
saveMatchedItemsToStorage()
}
async function removeMatchedRow(row: PriceTrackShopQueueItem) {
const key = rowKeyForMatch(row)
const backup = [...matchedItems.value]
matchedItems.value = matchedItems.value.filter((r) => rowKeyForMatch(r) !== key)
saveMatchedItemsToStorage()
removeMatchedRowsLocally([row])
const name = (row.shopName || '').trim()
if (!name) {
ElMessage.success('已从匹配结果中移除')
@@ -942,6 +1024,185 @@ async function pushToPythonQueue() {
}
}
function setLoopRun(next: PriceTrackLoopRunVo | null) {
activeLoopRun.value = next
activeLoopRunId.value = next?.id ?? null
saveActiveLoopRunId()
}
function clearLoopRunIfTerminal() {
if (!activeLoopRun.value) return
const status = activeLoopRun.value.status || ''
if (status === 'SUCCESS' || status === 'FAILED' || status === 'STOPPED') {
activeLoopRun.value = null
activeLoopRunId.value = null
saveActiveLoopRunId()
}
}
async function syncActiveLoopRun() {
if (!activeLoopRunId.value) return null
try {
const loop = await getPriceTrackLoopRun(activeLoopRunId.value)
setLoopRun(loop)
clearLoopRunIfTerminal()
return activeLoopRun.value
} catch {
activeLoopRun.value = null
activeLoopRunId.value = null
saveActiveLoopRunId()
return null
}
}
function recordCreatedTask(taskVo: PriceTrackCreateTaskVo) {
taskSnapshots.value = {
...taskSnapshots.value,
[taskVo.taskId]: {
task: { id: taskVo.taskId, status: 'RUNNING' },
items: taskVo.items,
},
}
taskDetails.value = {
...taskDetails.value,
[taskVo.taskId]: 'RUNNING',
}
saveTaskSnapshotsToStorage()
saveTaskDetailsToStorage()
}
async function stopLoopExecution() {
if (!activeLoopRunId.value || stopLoopPending.value) return
stopLoopPending.value = true
try {
const loop = await stopPriceTrackLoopRun(activeLoopRunId.value)
setLoopRun(loop)
clearLoopRunIfTerminal()
ElMessage.success(loop.status === 'STOPPED' ? '循环已停止' : '已请求停止,当前子任务完成后将收尾')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '停止循环失败')
} finally {
stopLoopPending.value = false
}
}
async function runLoopExecution(loopId?: number) {
if (loopDispatching.value) return
if (loopId && activeLoopRunId.value !== loopId) {
activeLoopRunId.value = loopId
saveActiveLoopRunId()
}
if (!activeLoopRunId.value) return
const api = getPywebviewApi()
if (!api?.enqueue_json) {
throw new Error('当前环境未启用 pywebview enqueue_json')
}
loopDispatching.value = true
try {
while (activeLoopRunId.value) {
const loop = await syncActiveLoopRun()
if (!loop || loop.status === 'SUCCESS' || loop.status === 'FAILED' || loop.status === 'STOPPED') break
if (loop.activeTaskId) {
addPollingTask(loop.activeTaskId)
scheduleNextPoll(true)
const finalStatus = await waitForTaskTerminal(loop.activeTaskId)
const updatedLoop = await completePriceTrackLoopChild(loop.id, loop.activeTaskId)
setLoopRun(updatedLoop)
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
clearLoopRunIfTerminal()
if (finalStatus !== 'SUCCESS') {
throw new Error(updatedLoop.errorMessage || `任务 ${loop.activeTaskId} 执行失败`)
}
continue
}
const dispatch = await dispatchNextPriceTrackLoopRun(loop.id)
setLoopRun(dispatch.loopRun || loop)
if (!dispatch.childTaskRequest) {
clearLoopRunIfTerminal()
break
}
const row = dispatch.childTaskRequest.items?.[0]
if (!row) throw new Error('循环任务缺少待执行店铺')
const taskVo = await createPriceTrackTask(dispatch.childTaskRequest)
recordCreatedTask(taskVo)
const queuePayload = buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload)
if (!pushResult?.success) {
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
}
addPollingTask(taskVo.taskId)
scheduleNextPoll(true)
removeMatchedRowsLocally([row])
queuePushResult.value = `循环任务 ${loop.id}${dispatch.childTaskRequest.roundIndex || 1} 轮已推送:${row.shopName || taskVo.taskId}`
}
} finally {
loopDispatching.value = false
}
}
async function pushToPythonLoopQueue() {
if (hasRunningLoop.value) {
try {
await runLoopExecution(activeLoopRunId.value || undefined)
} catch (error) {
if (error instanceof Error && /子任务不存在|循环任务不存在|404/.test(error.message)) {
activeLoopRun.value = null
activeLoopRunId.value = null
saveActiveLoopRunId()
}
const message = error instanceof Error ? error.message : '循环执行失败'
queuePushResult.value = message
ElMessage.error(message)
}
return
}
if (!hasValidMode.value) {
ElMessage.warning('请至少选择一个跟价模式')
return
}
if (asinModeEnabled.value && !asinFiles.value.length) {
ElMessage.warning('请先选择 ASIN 文件')
return
}
const matchedRows = matchedItems.value.filter((item) => item.matched)
if (!matchedRows.length) {
ElMessage.warning('请先匹配可用店铺')
return
}
pushing.value = true
queuePayloadText.value = ''
try {
const loop = await createPriceTrackLoopRun({
userId: 0,
statusMode: statusModeEnabled.value,
asinMode: asinModeEnabled.value,
items: matchedRows,
asinFiles: asinFiles.value,
countryCodes: [...orderedCountryCodes.value],
executionMode: executionMode.value,
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
})
setLoopRun(loop)
queuePushResult.value = `循环任务 ${loop.id} 已创建`
await runLoopExecution(loop.id)
clearLoopRunIfTerminal()
if (!activeLoopRunId.value) {
ElMessage.success('跟价循环已启动')
}
} catch (error) {
const message = error instanceof Error ? error.message : '启动循环失败'
queuePushResult.value = message
ElMessage.error(message)
} finally {
pushing.value = false
}
}
function getPollIntervalMs() {
return document.visibilityState === 'visible' ? 6000 : 20000
}
@@ -1103,6 +1364,9 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
} else if (item.taskId != null) {
await deletePriceTrackTask(item.taskId)
}
if (activeLoopRun.value && ((item.loopRunId != null && item.loopRunId === activeLoopRun.value.id) || (item.taskId != null && item.taskId === activeLoopRun.value.activeTaskId))) {
await syncActiveLoopRun()
}
if (item.taskId != null) removePollingTask(item.taskId)
removeMatchedRowsLocally([item])
await loadHistory()
@@ -1116,15 +1380,33 @@ async function deleteTaskRecord(item: PriceTrackHistoryItem) {
// ========== 生命周期 ==========
onMounted(() => {
if (!statusModeEnabled.value && !asinModeEnabled.value) {
selectMode('status')
}
loadPollingIdsFromStorage()
loadTaskDetailsFromStorage()
loadTaskSnapshotsFromStorage()
loadMatchedItemsFromStorage()
loadActiveLoopRunIdFromStorage()
loadCandidates().catch(() => undefined)
loadCountryPreference().catch(() => undefined)
loadHistory().catch(() => undefined)
loadDashboard().catch(() => undefined)
if (pollingTaskIds.value.length) scheduleNextPoll(true)
if (activeLoopRunId.value) {
void syncActiveLoopRun().then((loop) => {
if (loop?.activeTaskId) {
addPollingTask(loop.activeTaskId)
scheduleNextPoll(true)
}
if (loop && loop.status !== 'SUCCESS' && loop.status !== 'FAILED' && loop.status !== 'STOPPED') {
void runLoopExecution(loop.id)
}
}).catch(() => {
activeLoopRunId.value = null
saveActiveLoopRunId()
})
}
})
onUnmounted(() => {
@@ -1258,6 +1540,22 @@ onUnmounted(() => {
font-size: 12px;
}
.loop-round-input {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.loop-round-input :deep(.el-input-number) {
width: 160px;
}
.loop-round-input :deep(.el-input__wrapper) {
background-color: #2a2a2a;
box-shadow: 0 0 0 1px #3a3a3a inset;
}
.tag-badge {
display: inline-block;
padding: 2px 8px;
@@ -1533,6 +1831,28 @@ onUnmounted(() => {
line-height: 1.5;
}
.loop-status-card {
margin-top: 14px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #2f3d2f;
background: rgba(39, 174, 96, 0.08);
}
.loop-status-line {
font-size: 12px;
color: #d6e6d8;
line-height: 1.6;
}
.loop-error {
color: #ff8a80;
}
.loop-stop-btn {
margin-top: 10px;
}
.loading-msg code {
font-size: 11px;
color: #8fd3ff;

View File

@@ -7,8 +7,8 @@
<nav class="nav-sections" aria-label="顶部功能导航">
<section
v-for="group in navGroups"
:key="group.label"
v-for="group in visibleNavGroups"
:key="group.columnKey"
class="nav-section"
>
<div class="nav-section-title">{{ group.label }}</div>
@@ -39,6 +39,10 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
type ActiveNavKey =
| 'brand'
| 'dedupe'
@@ -55,14 +59,22 @@ type NavItem = {
href?: string
}
type NavGroup = {
columnKey: string
label: string
items: ReadonlyArray<NavItem>
}
const props = defineProps<{
active: ActiveNavKey
}>()
const active = props.active
const allowedColumnKeys = ref<string[] | null>(null)
const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }> = [
const navGroups: ReadonlyArray<NavGroup> = [
{
columnKey: 'brand_front_tools',
label: '前端工具',
items: [
{ key: 'collect', label: '采集数据' },
@@ -74,6 +86,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
],
},
{
columnKey: 'brand_operation_tools',
label: '运营工具',
items: [
{ key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
@@ -86,6 +99,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
],
},
{
columnKey: 'brand_logistics_tools',
label: '后勤工具',
items: [
{ key: 'purchase', label: '采购' },
@@ -93,6 +107,22 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray<NavItem> }>
],
},
]
const visibleNavGroups = computed(() => {
if (allowedColumnKeys.value === null) {
return navGroups
}
const allowedSet = new Set(allowedColumnKeys.value)
return navGroups.filter((group) => allowedSet.has(group.columnKey))
})
onMounted(async () => {
try {
allowedColumnKeys.value = await getCurrentUserAppColumnKeys()
} catch (_error) {
allowedColumnKeys.value = null
}
})
</script>
<style scoped>

View File

@@ -955,6 +955,8 @@ export interface PriceTrackDashboardVo {
export interface PriceTrackHistoryItem {
resultId?: number;
taskId?: number;
loopRunId?: number;
roundIndex?: number;
shopName?: string;
shopId?: number | string | null;
platform?: string;
@@ -998,6 +1000,8 @@ export interface PriceTrackTaskSummary {
id?: number;
taskNo?: string;
status?: string;
loopRunId?: number;
roundIndex?: number;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
@@ -1146,6 +1150,45 @@ export interface PriceTrackCreateTaskRequest {
items: PriceTrackShopQueueItem[];
asinFiles: string[];
countryCodes: string[];
loopRunId?: number;
roundIndex?: number;
shopIndex?: number;
}
export type PriceTrackExecutionMode = "FINITE" | "INFINITE";
export interface PriceTrackLoopRunCreateRequest {
userId: number;
statusMode: boolean;
asinMode: boolean;
items: PriceTrackShopQueueItem[];
asinFiles: string[];
countryCodes: string[];
executionMode: PriceTrackExecutionMode;
targetRounds?: number;
}
export interface PriceTrackLoopRunVo {
id: number;
status?: string;
executionMode?: PriceTrackExecutionMode;
targetRounds?: number;
currentRound?: number;
currentShopIndex?: number;
totalShopCount?: number;
activeTaskId?: number;
activeTaskStatus?: string;
stopRequested?: boolean;
errorMessage?: string;
statusMode?: boolean;
asinMode?: boolean;
asinFiles?: string[];
countryCodes?: string[];
}
export interface PriceTrackLoopRunDispatchVo {
loopRun?: PriceTrackLoopRunVo;
childTaskRequest?: PriceTrackCreateTaskRequest;
}
export function createPriceTrackTask(
@@ -1176,6 +1219,52 @@ export function getPriceTrackTasksBatch(taskIds: number[]) {
);
}
export function createPriceTrackLoopRun(
request: Omit<PriceTrackLoopRunCreateRequest, "userId"> | PriceTrackLoopRunCreateRequest,
) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackLoopRunVo>, PriceTrackLoopRunCreateRequest>(
`${JAVA_API_PREFIX}/price-track/loop-runs`,
{ ...request, userId: getCurrentUserId() },
),
);
}
export function getPriceTrackLoopRun(loopRunId: number) {
return unwrapJavaResponse(
get<JavaApiResponse<PriceTrackLoopRunVo>>(
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
),
);
}
export function dispatchNextPriceTrackLoopRun(loopRunId: number) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackLoopRunDispatchVo>, undefined>(
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/dispatch-next?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
undefined,
),
);
}
export function completePriceTrackLoopChild(loopRunId: number, childTaskId: number) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackLoopRunVo>, { childTaskId: number }>(
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/child-finished?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
{ childTaskId },
),
);
}
export function stopPriceTrackLoopRun(loopRunId: number) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackLoopRunVo>, undefined>(
`${JAVA_API_PREFIX}/price-track/loop-runs/${loopRunId}/stop?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
undefined,
),
);
}
export function getPriceTrackTaskProgressBatch(taskIds: number[]) {
return unwrapJavaResponse(
post<JavaApiResponse<PriceTrackTaskBatchVo>, { taskIds: number[] }>(

View File

@@ -0,0 +1,63 @@
import { requestGetJson } from '@/shared/api/http'
export interface PermissionMenuItem {
id: number | string
name?: string
column_key?: string
route_path?: string
menu_type?: string
sort_order?: number
created_at?: string
}
interface PermissionMenuResponse {
success: boolean
items?: PermissionMenuItem[]
error?: string
}
function getCurrentUserId() {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) {
throw new Error('未获取到用户ID')
}
return value
}
function getAppPermissionCacheKey(uid: number) {
return `app_column_permissions:${String(uid)}`
}
export async function getCurrentUserAppColumnKeys() {
const uid = getCurrentUserId()
const cacheKey = getAppPermissionCacheKey(uid)
try {
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
if (Array.isArray(cachedItems)) {
return cachedItems
.map((item) => String(item.column_key || '').trim().toLowerCase())
.filter(Boolean)
}
} catch (_error) {}
const res = await requestGetJson<PermissionMenuResponse>(
`/api/admin/user/${encodeURIComponent(String(uid))}/column-permissions`,
{
params: { menu_type: 'app' },
},
)
if (!res.success) {
throw new Error(res.error || '获取菜单权限失败')
}
try {
window.localStorage.setItem(cacheKey, JSON.stringify(res.items || []))
} catch (_error) {}
return (res.items || [])
.map((item) => String(item.column_key || '').trim().toLowerCase())
.filter(Boolean)
}