完成系统管理部分
This commit is contained in:
5
.env
5
.env
@@ -2,7 +2,4 @@
|
||||
VITE_APP_TITLE=楠溪屿后台管理系统
|
||||
|
||||
# API基础URL
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 1Panel API 配置
|
||||
VITE_1PANEL_API_KEY=KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI
|
||||
VITE_API_BASE_URL=https://api.superwax.cn:4433/api
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
VITE_APP_TITLE=楠溪屿后台管理系统
|
||||
|
||||
# API基础URL - 生产环境
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_BASE_URL=https://api.superwax.cn:4433/api
|
||||
|
||||
|
||||
1122
src/api/1panel.ts
1122
src/api/1panel.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* API 模块导出
|
||||
*/
|
||||
|
||||
// 1Panel API
|
||||
export * from './1panel'
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface ProjectInfo {
|
||||
lastDeployMessage?: string
|
||||
createdTime?: string
|
||||
updatedTime?: string
|
||||
// 新增字段
|
||||
systemType?: 'admin' | 'portal' // 系统类型
|
||||
integrateToFramework?: boolean // 是否集成到框架
|
||||
menuCount?: number // 菜单数量
|
||||
}
|
||||
|
||||
export interface DeployRequest {
|
||||
@@ -86,6 +90,15 @@ export async function getAllProjects() {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取集成到框架的业务项目
|
||||
* 返回 systemType=admin 且 integrateToFramework=true 的项目
|
||||
*/
|
||||
export async function getIntegratedProjects(): Promise<ProjectInfo[]> {
|
||||
const res = await request.get<ProjectInfo[]>('/platform/project/integrated')
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
@@ -200,14 +213,12 @@ export async function checkUploadFiles(projectId: number, paths: string[]): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备上传(清理旧文件)
|
||||
* 准备上传(已废弃)
|
||||
* @deprecated upload 接口支持 overwrite 参数,不再需要此函数
|
||||
*/
|
||||
export async function prepareUploadFile(projectId: number, path: string): Promise<boolean> {
|
||||
const res = await request.post<boolean>('/platform/project/upload/prepare', {
|
||||
projectId,
|
||||
path
|
||||
})
|
||||
return res.data.data
|
||||
// 保留函数签名以向后兼容,但不再调用后端
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
|
||||
|
||||
export function getMenuList() {
|
||||
return request.get<MenuRecord[]>('/system/menu/list')
|
||||
export function getMenuList(params?: { projectId?: number }) {
|
||||
return request.get<MenuRecord[]>('/system/menu/list', { params })
|
||||
}
|
||||
|
||||
export function createMenu(data: MenuFormData) {
|
||||
|
||||
@@ -112,7 +112,7 @@ import {
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { UploadFile, DuplicateFile } from '@/types'
|
||||
import DuplicateFileModal from './DuplicateFileModal.vue'
|
||||
import { checkUploadFiles, prepareUploadFile, uploadFileChunk } from '@/api/project'
|
||||
import { checkUploadFiles, uploadFileChunk } from '@/api/project'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
autoUpload?: boolean
|
||||
@@ -375,21 +375,33 @@ async function handleDrop(event: DragEvent) {
|
||||
|
||||
const files: File[] = []
|
||||
|
||||
console.log('[Upload] 拖入项目数量:', items.length)
|
||||
|
||||
// 先收集所有的 entry,避免在异步操作中 items 被清空
|
||||
const entries: { entry: FileSystemEntry | null, file: File | null }[] = []
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
if (entry) {
|
||||
await traverseFileTree(entry, '', files)
|
||||
} else {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
entries.push({ entry, file })
|
||||
console.log(`[Upload] 项目 ${i}: entry=${entry?.name || 'null'}, isFile=${entry?.isFile}, isDir=${entry?.isDirectory}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理收集到的 entries
|
||||
for (const { entry, file } of entries) {
|
||||
if (entry) {
|
||||
await traverseFileTree(entry, '', files)
|
||||
} else if (file) {
|
||||
// 如果没有 entry,直接使用 file
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Upload] 解析后文件数量:', files.length)
|
||||
|
||||
if (files.length > 0) {
|
||||
addFiles(files)
|
||||
}
|
||||
@@ -405,23 +417,36 @@ async function traverseFileTree(
|
||||
): Promise<void> {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file(resolve, reject)
|
||||
})
|
||||
Object.defineProperty(file, 'relativePath', {
|
||||
value: path + entry.name,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
try {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file(resolve, reject)
|
||||
})
|
||||
// 为文件设置相对路径
|
||||
const relativePath = path + entry.name
|
||||
Object.defineProperty(file, 'relativePath', {
|
||||
value: relativePath,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
} catch (e) {
|
||||
console.error('读取文件失败:', entry.name, e)
|
||||
}
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const reader = dirEntry.createReader()
|
||||
|
||||
const entries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject)
|
||||
})
|
||||
// readEntries 可能不会一次返回所有条目,需要循环调用直到返回空数组
|
||||
let allEntries: FileSystemEntry[] = []
|
||||
let readBatch: FileSystemEntry[] = []
|
||||
|
||||
for (const childEntry of entries) {
|
||||
do {
|
||||
readBatch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject)
|
||||
})
|
||||
allEntries = allEntries.concat(readBatch)
|
||||
} while (readBatch.length > 0)
|
||||
|
||||
for (const childEntry of allEntries) {
|
||||
await traverseFileTree(childEntry, path + entry.name + '/', files)
|
||||
}
|
||||
}
|
||||
@@ -480,10 +505,7 @@ async function uploadFile(file: UploadFile): Promise<void> {
|
||||
const filePath = file.path.startsWith('/') ? file.path.substring(1) : file.path
|
||||
const fullPath = `${basePath}/${filePath}`
|
||||
|
||||
// 1. 准备上传(清理可能存在的旧文件)
|
||||
await prepareUploadFile(projectIdNum, fullPath)
|
||||
|
||||
// 2. 计算目标目录(不包含文件名)
|
||||
// 计算目标目录(不包含文件名)
|
||||
// 1Panel 接口需要的是上传到的目录路径
|
||||
const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/') + 1)
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<MenuFormData>({
|
||||
id: undefined,
|
||||
projectId: undefined, // Add this
|
||||
parentId: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
@@ -121,7 +123,8 @@ watch(
|
||||
// Reset to defaults
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
parentId: props.record?.parentId || 0, // Use record parentId if exists, else 0
|
||||
projectId: props.record?.projectId, // Copy projectId
|
||||
parentId: props.record?.parentId || 0,
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'menu',
|
||||
|
||||
@@ -180,14 +180,30 @@
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<a-layout-content class="content">
|
||||
<router-view />
|
||||
<!-- 子项目模式:使用 iframe 嵌入 -->
|
||||
<template v-if="projectStore.isInSubProject && subProjectUrl">
|
||||
<!-- 加载指示器 -->
|
||||
<div v-if="iframeLoading" class="iframe-loading">
|
||||
<a-spin size="large" tip="正在加载..." />
|
||||
</div>
|
||||
<iframe
|
||||
ref="subIframeRef"
|
||||
:src="subProjectUrl"
|
||||
:class="['sub-project-iframe', { 'iframe-hidden': iframeLoading }]"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
@load="onIframeLoad"
|
||||
/>
|
||||
</template>
|
||||
<!-- 框架模式:正常路由 -->
|
||||
<router-view v-else />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, h, type Component } from 'vue'
|
||||
import { ref, computed, watch, h, type Component, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
@@ -211,9 +227,55 @@ const projectStore = useProjectStore()
|
||||
// 项目配置
|
||||
const projectConfig = getCurrentProject()
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载集成项目
|
||||
await projectStore.loadIntegratedProjects()
|
||||
|
||||
// 【重要】检查 URL 是否是子项目路由,如 /app/{projectId}/xxx
|
||||
const path = route.path
|
||||
const match = path.match(/^\/app\/([^/]+)(.*)$/)
|
||||
if (match) {
|
||||
const projectId = match[1]
|
||||
const subPath = match[2] || '/dashboard'
|
||||
|
||||
// 切换到对应项目
|
||||
projectStore.switchProject(projectId)
|
||||
|
||||
// 获取项目信息并加载 iframe
|
||||
const project = projectStore.getProjectById(projectId)
|
||||
if (project?.baseUrl) {
|
||||
iframeLoading.value = true
|
||||
subProjectUrl.value = `${project.baseUrl}${subPath}?__embedded=true`
|
||||
|
||||
// 【修复】恢复菜单选中状态
|
||||
// subPath 如 /community/tags,需要找到对应的菜单 key
|
||||
const menuKey = subPath.startsWith('/') ? subPath : `/${subPath}`
|
||||
selectedKeys.value = [menuKey]
|
||||
|
||||
// 【修复】展开父级菜单
|
||||
// 例如 /community/tags 的父级是 /community
|
||||
const pathParts = menuKey.split('/').filter(Boolean)
|
||||
if (pathParts.length > 1) {
|
||||
// 构造父级路径,如 ['community', 'tags'] -> '/community'
|
||||
const parentPath = `/${pathParts[0]}`
|
||||
// 子项目菜单使用 sub-{key} 格式
|
||||
openKeys.value = [`sub-${parentPath}`]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const openKeys = ref<string[]>([])
|
||||
const subProjectUrl = ref<string>('') // 子项目 iframe URL
|
||||
const iframeLoading = ref(false) // iframe 加载状态
|
||||
const subIframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// iframe 加载完成回调
|
||||
function onIframeLoad() {
|
||||
iframeLoading.value = false
|
||||
}
|
||||
|
||||
// 图标名称到组件的映射
|
||||
// 获取图标组件
|
||||
@@ -269,6 +331,7 @@ function handleMenuClick({ key }: { key: string | number }) {
|
||||
// 返回框架
|
||||
if (menuKey === '__back_to_framework') {
|
||||
projectStore.exitSubProject()
|
||||
subProjectUrl.value = '' // 清空 iframe
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
@@ -277,16 +340,38 @@ function handleMenuClick({ key }: { key: string | number }) {
|
||||
if (menuKey.startsWith('project-')) {
|
||||
const projectId = menuKey.replace('project-', '')
|
||||
projectStore.switchProject(projectId)
|
||||
// 跳转到子项目的默认页面
|
||||
router.push(`/app/${projectId}/dashboard`)
|
||||
|
||||
// 获取项目的 baseUrl,构建 iframe URL
|
||||
const project = projectStore.getProjectById(projectId)
|
||||
if (project?.baseUrl) {
|
||||
// 使用 baseUrl 作为 iframe 地址,添加嵌入模式标识
|
||||
iframeLoading.value = true // 开始加载
|
||||
subProjectUrl.value = `${project.baseUrl}?__embedded=true`
|
||||
// 【重要】同步更新地址栏,使用 /app/{projectId}/dashboard 格式
|
||||
router.push(`/app/${projectId}/dashboard`)
|
||||
} else {
|
||||
console.warn(`项目 ${projectId} 没有配置 baseUrl`)
|
||||
subProjectUrl.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 子项目模式下的菜单点击
|
||||
if (projectStore.isInSubProject) {
|
||||
const subMenuPath = projectStore.getMenuRoutePath(menuKey)
|
||||
if (subMenuPath) {
|
||||
router.push(subMenuPath)
|
||||
const project = projectStore.currentProject
|
||||
if (project?.baseUrl) {
|
||||
// 从菜单 key 中提取路径
|
||||
// 注意:子项目菜单的 key 格式可能是 "item-/dashboard" 或直接 "/dashboard"
|
||||
let menuPath = menuKey
|
||||
if (menuPath.startsWith('item-')) {
|
||||
menuPath = menuPath.replace('item-', '')
|
||||
}
|
||||
menuPath = menuPath.startsWith('/') ? menuPath : `/${menuPath}`
|
||||
|
||||
iframeLoading.value = true // 开始加载
|
||||
subProjectUrl.value = `${project.baseUrl}${menuPath}?__embedded=true`
|
||||
// 【重要】同步更新地址栏
|
||||
router.push(`/app/${project.id}${menuPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -539,4 +624,26 @@ watch(() => route.path, updateMenuState, { immediate: true })
|
||||
color: #fff;
|
||||
background: rgba(24, 144, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 子项目 iframe 样式 */
|
||||
.sub-project-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.iframe-hidden {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -138,81 +138,8 @@ const codePortMenus: ProjectMenuItem[] = [
|
||||
]
|
||||
|
||||
// Mock 项目数据 - 包含 codePort 项目
|
||||
export const mockProjects: PlatformProject[] = [
|
||||
{
|
||||
id: 'codePort',
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
enabled: true,
|
||||
menuCount: 25,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus,
|
||||
currentVersion: '1.0.0',
|
||||
versions: [
|
||||
{
|
||||
id: 'v1.2.0',
|
||||
version: '1.2.0',
|
||||
description: '新增了客服系统和会话管理模块',
|
||||
createdAt: '2024-03-15T10:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: true,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 5242880, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v1.1.0',
|
||||
version: '1.1.0',
|
||||
description: '优化了用户管理和项目列表',
|
||||
createdAt: '2024-02-01T10:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: false,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 4718592, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v1.0.0',
|
||||
version: '1.0.0',
|
||||
description: '初始版本',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: false,
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
// Mock 项目数据 - 暂时为空,由后端接口加载
|
||||
export const mockProjects: PlatformProject[] = []
|
||||
|
||||
/**
|
||||
* 获取所有启用的项目
|
||||
|
||||
@@ -19,6 +19,16 @@ const constantRoutes: RouteRecordRaw[] = [
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
// 子项目路由(通配符捕获,由 MainLayout 处理 iframe 加载)
|
||||
{
|
||||
path: '/app/:projectId/:pathMatch(.*)*',
|
||||
name: 'SubProject',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
meta: {
|
||||
title: '子项目',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 注意:动态路由添加前不要有通配符路由,否则会覆盖动态路由
|
||||
// 404 路由将在动态路由生成后添加到最后
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ProjectMenuItem,
|
||||
type ProjectVersion
|
||||
} from '@/mock/projects'
|
||||
import { getIntegratedProjects, type ProjectInfo } from '@/api/project'
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
// 所有项目列表(响应式)
|
||||
@@ -293,6 +294,134 @@ export const useProjectStore = defineStore('project', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目菜单
|
||||
* 从全局菜单列表中提取各项目的菜单
|
||||
*/
|
||||
async function syncProjectMenus(allMenus: any[]) {
|
||||
if (!allMenus || allMenus.length === 0) return
|
||||
|
||||
// 动态导入以避免循环依赖
|
||||
const { buildMenuTree, transformToAntdMenu } = await import('@/utils/route')
|
||||
|
||||
// 1. 收集所有菜单中涉及的 projectId(排除 null 和框架本身 9)
|
||||
const projectIdsInMenus = new Set<string>()
|
||||
allMenus.forEach(m => {
|
||||
if (m.projectId && String(m.projectId) !== '9') {
|
||||
projectIdsInMenus.add(String(m.projectId))
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 确保 projects 中包含这些项目(如果不存在则创建临时占位)
|
||||
projectIdsInMenus.forEach(pid => {
|
||||
const exists = projects.value.some(p => String(p.id) === pid)
|
||||
if (!exists) {
|
||||
// 创建一个临时项目对象
|
||||
projects.value.push({
|
||||
id: pid,
|
||||
name: `项目 ${pid}`,
|
||||
shortName: `P${pid}`,
|
||||
logo: '代',
|
||||
color: '#1890ff',
|
||||
description: '',
|
||||
baseUrl: '',
|
||||
enabled: true,
|
||||
menus: [],
|
||||
menuCount: 0,
|
||||
createdAt: new Date().toISOString()
|
||||
} as any)
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 同步菜单到各项目
|
||||
for (const project of projects.value) {
|
||||
// 过滤出属于该项目的菜单
|
||||
const projectMenus = allMenus.filter(m =>
|
||||
m.projectId && String(m.projectId) === String(project.id)
|
||||
)
|
||||
|
||||
if (projectMenus.length > 0) {
|
||||
// 构建菜单树
|
||||
const tree = buildMenuTree(projectMenus)
|
||||
// 转换为 Ant Design 菜单格式 (MainLayout 需要 label 字段)
|
||||
const antdMenus = transformToAntdMenu(tree)
|
||||
project.menus = JSON.parse(JSON.stringify(antdMenus))
|
||||
|
||||
// 重新计算菜单数量
|
||||
const count = projectMenus.filter(m => ['directory', 'menu'].includes(m.type)).length
|
||||
project.menuCount = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载集成到框架的项目
|
||||
*/
|
||||
async function loadIntegratedProjects() {
|
||||
try {
|
||||
const list = await getIntegratedProjects()
|
||||
// 将后端返回的项目信息合并到 projects 中
|
||||
// 注意:这里我们假设后端返回的 id 是 number,而 store 中是 string,需要转换
|
||||
// 且我们需要保留 mock 数据中的菜单配置(如果 id 匹配的话),或者为新项目提供默认菜单
|
||||
|
||||
const integratedProjects: PlatformProject[] = list.map(item => {
|
||||
// 查找是否已存在(匹配已有数据,可能是 syncProjectMenus 创建的临时对象)
|
||||
const existing = projects.value.find(p => p.id === String(item.id))
|
||||
|
||||
if (existing) {
|
||||
// 更新基本信息,【重要】保留已同步的菜单
|
||||
return {
|
||||
...existing,
|
||||
name: item.name,
|
||||
shortName: item.shortName || item.name,
|
||||
logo: item.logo || existing.logo,
|
||||
color: item.color || existing.color,
|
||||
baseUrl: item.url || existing.baseUrl,
|
||||
enabled: true,
|
||||
// 保留已同步的菜单(如果有的话)
|
||||
menus: existing.menus || [],
|
||||
menuCount: existing.menuCount || 0
|
||||
}
|
||||
} else {
|
||||
// 新项目,创建默认结构
|
||||
return {
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
shortName: item.shortName || item.name,
|
||||
logo: item.logo || item.name.charAt(0),
|
||||
color: item.color || '#1890ff',
|
||||
description: item.description,
|
||||
baseUrl: item.url || '',
|
||||
enabled: true,
|
||||
menus: [],
|
||||
menuCount: 0,
|
||||
createdAt: item.createdTime || new Date().toISOString()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 更新 projects 列表
|
||||
// 策略:保留非 integrated 的项目(可能是纯 mock 的),替换/添加 integrated 的项目
|
||||
// 但为了简单,我们先把 integratedProjects 合并进去
|
||||
|
||||
for (const p of integratedProjects) {
|
||||
const index = projects.value.findIndex(existing => existing.id === p.id)
|
||||
if (index > -1) {
|
||||
projects.value[index] = p
|
||||
} else {
|
||||
projects.value.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
// 此外,我们可能需要一个专门的列表来存储“仅后端返回的集成项目”,
|
||||
// 但目前 MainLayout 使用 enabledProjects (即 projects.filter(enabled))
|
||||
// 只要我们设置 enabled: true,它们就会显示。
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载集成项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
projects,
|
||||
@@ -318,6 +447,9 @@ export const useProjectStore = defineStore('project', () => {
|
||||
addFilesToVersion,
|
||||
// 菜单路由方法
|
||||
getMenuRoutePath,
|
||||
getMenuRouteMap
|
||||
getMenuRouteMap,
|
||||
// 新增
|
||||
loadIntegratedProjects,
|
||||
syncProjectMenus
|
||||
}
|
||||
})
|
||||
|
||||
@@ -112,16 +112,32 @@ export const useUserStore = defineStore('user', () => {
|
||||
// 动态导入 router 以避免循环依赖
|
||||
const { default: router } = await import('@/router')
|
||||
|
||||
// 构建菜单树
|
||||
const menuTree = buildMenuTree(menus)
|
||||
// 0. 深拷贝菜单数据,防止 buildMenuTree 修改原始对象导致污染
|
||||
const menuList = JSON.parse(JSON.stringify(menus))
|
||||
|
||||
// 生成动态路由
|
||||
const routes = generateRoutes(menuTree)
|
||||
// 1. 同步菜单到各个项目
|
||||
try {
|
||||
const { useProjectStore } = await import('@/stores/project')
|
||||
await useProjectStore().syncProjectMenus(JSON.parse(JSON.stringify(menus)))
|
||||
} catch (e) {
|
||||
console.warn('Sync project menus failed', e)
|
||||
}
|
||||
|
||||
// 2. 筛选框架菜单:projectId 为空或 9 (NanxiAdmin)
|
||||
const frameworkMenus = menuList.filter((m: any) => !m.projectId || String(m.projectId) === '9')
|
||||
const frameworkMenuTree = buildMenuTree(frameworkMenus)
|
||||
|
||||
// 生成 Ant Design 菜单 (仅包含框架菜单)
|
||||
antdMenus.value = transformToAntdMenu(frameworkMenuTree)
|
||||
|
||||
// 3. 生成动态路由 (仅为框架菜单生成,子项目菜单不在框架中生成路由)
|
||||
// 子项目的组件文件不在框架项目中,因此不能直接生成路由
|
||||
// 子项目将通过 iframe 嵌入或外部链接的方式访问
|
||||
|
||||
// 只使用框架菜单生成路由 (projectId 为空或等于 9)
|
||||
const routes = generateRoutes(frameworkMenuTree)
|
||||
dynamicRoutes.value = routes
|
||||
|
||||
// 生成 Ant Design 菜单
|
||||
antdMenus.value = transformToAntdMenu(menuTree)
|
||||
|
||||
// 动态添加路由到 router 实例
|
||||
routes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export interface SysMenu {
|
||||
id: number
|
||||
projectId?: number
|
||||
parentId: number
|
||||
name: string
|
||||
code: string
|
||||
|
||||
@@ -6,6 +6,8 @@ import { message } from 'ant-design-vue'
|
||||
import type { ApiResponse } from '@/types/api/response'
|
||||
|
||||
// 创建axios实例
|
||||
// 创建axios实例
|
||||
console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL)
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'menuCount'">
|
||||
<a-badge :count="countMenuItems(record.menus)" :overflow-count="99" show-zero>
|
||||
<a-badge
|
||||
:count="record.menuCount || 0"
|
||||
:overflow-count="99"
|
||||
show-zero
|
||||
:number-style="{ backgroundColor: record.color || '#1890ff' }"
|
||||
>
|
||||
<a-button type="link" size="small" @click="goToMenus(record as any)">
|
||||
查看菜单
|
||||
</a-button>
|
||||
@@ -134,6 +139,25 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="系统类型" name="systemType">
|
||||
<a-radio-group v-model:value="formData.systemType">
|
||||
<a-radio value="admin">管理端</a-radio>
|
||||
<a-radio value="portal">门户类</a-radio>
|
||||
</a-radio-group>
|
||||
<div class="form-item-help">管理端:后台管理系统;门户类:用户端网站</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
v-if="formData.systemType === 'admin'"
|
||||
label="集成到框架"
|
||||
name="integrateToFramework"
|
||||
>
|
||||
<a-switch v-model:checked="formData.integrateToFramework" />
|
||||
<span style="margin-left: 8px; color: #666;">
|
||||
{{ formData.integrateToFramework ? '将在左侧菜单的"业务项目"中显示' : '不在框架中显示' }}
|
||||
</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="项目标识" name="code">
|
||||
<a-input
|
||||
v-model:value="formData.code"
|
||||
@@ -687,7 +711,10 @@ const formData = reactive({
|
||||
serverId: undefined as number | undefined,
|
||||
deployPath: '',
|
||||
remark: '',
|
||||
fileList: [] as any[]
|
||||
fileList: [] as any[],
|
||||
// 新增字段
|
||||
systemType: 'admin' as 'admin' | 'portal', // 系统类型
|
||||
integrateToFramework: false // 是否集成到框架
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -842,7 +869,10 @@ function handleAdd() {
|
||||
serverId: undefined,
|
||||
deployPath: '',
|
||||
remark: '',
|
||||
fileList: []
|
||||
fileList: [],
|
||||
// 新增字段默认值
|
||||
systemType: 'admin',
|
||||
integrateToFramework: false
|
||||
})
|
||||
formVisible.value = true
|
||||
}
|
||||
@@ -864,7 +894,10 @@ function handleEdit(record: any) {
|
||||
serverId: record.serverId,
|
||||
deployPath: record.deployPath || '',
|
||||
remark: record.remark || '',
|
||||
fileList: []
|
||||
fileList: [],
|
||||
// 新增字段
|
||||
systemType: record.systemType || 'admin',
|
||||
integrateToFramework: record.integrateToFramework || false
|
||||
})
|
||||
|
||||
// 触发联动逻辑(如果需要加载域名列表),并保留现有域名设置
|
||||
@@ -897,7 +930,10 @@ async function handleFormSubmit() {
|
||||
projectGroup: formData.group,
|
||||
serverId: formData.serverId,
|
||||
deployPath: formData.deployPath,
|
||||
remark: formData.remark
|
||||
remark: formData.remark,
|
||||
// 新增字段
|
||||
systemType: formData.systemType,
|
||||
integrateToFramework: formData.systemType === 'admin' ? formData.integrateToFramework : false
|
||||
}
|
||||
|
||||
if (currentProject.value?.id) {
|
||||
@@ -947,7 +983,21 @@ function handleDelete(record: any) {
|
||||
}
|
||||
|
||||
function goToMenus(record: PlatformProject) {
|
||||
router.push({ path: '/platform/menus', query: { projectId: record.id } })
|
||||
// 尝试匹配路由,防止死循环
|
||||
// 先尝试 /system/menus (对应文件夹名)
|
||||
let targetPath = '/system/menus'
|
||||
const route1 = router.resolve(targetPath)
|
||||
if (route1.matched.length === 0) {
|
||||
// 如果没匹配到,尝试 /system/menu (对应常见命名)
|
||||
targetPath = '/system/menu'
|
||||
const route2 = router.resolve(targetPath)
|
||||
if (route2.matched.length === 0) {
|
||||
message.error('未找到菜单管理页面路由')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
router.push({ path: targetPath, query: { projectId: record.id } })
|
||||
}
|
||||
|
||||
const uploadProjectDeployPath = ref<string>('')
|
||||
|
||||
@@ -3,9 +3,22 @@
|
||||
<!-- 操作栏 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>
|
||||
<a-button type="primary" @click="handleAdd()">
|
||||
<PlusOutlined /> 新增菜单
|
||||
</a-button>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="queryProjectId"
|
||||
placeholder="所属项目"
|
||||
style="width: 200px"
|
||||
allowClear
|
||||
@change="loadData"
|
||||
>
|
||||
<a-select-option v-for="p in projectList" :key="p.id" :value="p.id">
|
||||
{{ p.shortName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="handleAdd()">
|
||||
<PlusOutlined /> 新增菜单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 表格(树形) -->
|
||||
@@ -64,13 +77,26 @@ import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
|
||||
import { getMenuList, deleteMenu } from '@/api/system/menu'
|
||||
import { getAllProjects, type ProjectInfo } from '@/api/project'
|
||||
import MenuFormModal from '@/components/system/menu/MenuFormModal.vue'
|
||||
import { buildMenuTree } from '@/utils/route'
|
||||
// Note: buildMenuTree expects SysMenu, MenuRecord extends SysMenu so it's fine.
|
||||
// Using 'any' cast if strict checking fails due to optional properties.
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const treeData = ref<MenuRecord[]>([])
|
||||
const queryProjectId = ref<number | undefined>(undefined)
|
||||
const projectList = ref<ProjectInfo[]>([])
|
||||
|
||||
// 加载项目列表
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await getAllProjects()
|
||||
projectList.value = res || []
|
||||
} catch (error) {
|
||||
console.error('加载项目列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
@@ -111,7 +137,7 @@ function getTypeName(type: string): string {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMenuList()
|
||||
const res = await getMenuList({ projectId: queryProjectId.value })
|
||||
// res.data.data is the array
|
||||
const list = res.data.data || []
|
||||
// Cast to any because buildMenuTree typings might be strict about SysMenu properties
|
||||
@@ -125,7 +151,8 @@ async function loadData() {
|
||||
|
||||
function handleAdd(parentId?: number) {
|
||||
currentRecord.value = {
|
||||
parentId: parentId || 0
|
||||
parentId: parentId || 0,
|
||||
projectId: queryProjectId.value // 传入当前选中的项目ID(如果未选中则是undefined/null,即归属主框架)
|
||||
} as MenuFormData
|
||||
modalVisible.value = true
|
||||
}
|
||||
@@ -145,7 +172,29 @@ async function handleDelete(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await loadProjects()
|
||||
|
||||
// 从路由获取参数,如果有则优先使用
|
||||
if (route.query.projectId) {
|
||||
const pid = Number(route.query.projectId)
|
||||
if (!isNaN(pid)) {
|
||||
queryProjectId.value = pid
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有选中项目,默认选中“楠溪框架”
|
||||
if (!queryProjectId.value && projectList.value.length > 0) {
|
||||
const defaultProject = projectList.value.find(p =>
|
||||
p.code === 'NanxiAdmin' ||
|
||||
p.shortName === 'NanxiAdmin' ||
|
||||
p.name === '楠溪框架'
|
||||
)
|
||||
if (defaultProject) {
|
||||
queryProjectId.value = defaultProject.id
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,8 @@ import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
// 使用绝对路径,确保在任何子路由下都能正确加载资源
|
||||
base: '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
// Vue函数自动导入
|
||||
@@ -67,39 +69,6 @@ export default defineConfig({
|
||||
console.log(`[Backend API Proxy] ${req.method} ${req.url}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 主服务器代理
|
||||
'/1panel-api/server1': {
|
||||
target: 'http://47.109.57.58:42588',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/1panel-api\/server1/, '/api/v2'),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (_proxyReq, req) => {
|
||||
console.log(`[1Panel Proxy - 主服务器] ${req.method} ${req.url}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 测试服务器代理
|
||||
'/1panel-api/server2': {
|
||||
target: 'http://192.168.1.100:42588',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/1panel-api\/server2/, '/api/v2'),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (_proxyReq, req) => {
|
||||
console.log(`[1Panel Proxy - 测试服务器] ${req.method} ${req.url}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 备份服务器代理
|
||||
'/1panel-api/server3': {
|
||||
target: 'http://10.0.0.1:42588',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/1panel-api\/server3/, '/api/v2'),
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (_proxyReq, req) => {
|
||||
console.log(`[1Panel Proxy - 备份服务器] ${req.method} ${req.url}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user