Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/src/assets/iconfont.js

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -941,17 +941,18 @@ const formValue = ref<Dict<DynamicFormValue>>({})
### FolderTree

Workspace 的文件夹虚拟树业务组件。组件根据当前资源上下文和 `source` 调用统一文件夹接口,负责
文件夹树查询、搜索、排序、创建、编辑、移动和删除。通过 `v-model` 控制当前文件夹 ID,选择
文件夹时触发 `select`。`showAll`、`showShared` 分别控制全部和共享入口,`rootLabel` 可覆盖根入口
文案,`disabledFolderIds` 用于只选场景中禁用指定节点。
文件夹树查询、搜索、排序、创建、编辑、移动和删除。通过 `v-model` 控制当前文件夹 ID,首次加载
完成后通过 `loaded` 返回该 ID 对应的完整文件夹,选择文件夹时触发 `select`。传入的 ID 不存在时,
优先回退到“全部”入口,否则回退到首个可用文件夹。`showAll`、`showShared` 分别控制全部和共享入口,
`rootLabel` 可覆盖根入口文案,`disabledFolderIds` 用于只选场景中禁用指定节点。

```vue
<script setup lang="ts">
import { RESOURCE_TYPE } from '@/api/enums'
import FolderTree from '@/components/business/folder-tree/index.vue'
</script>

<FolderTree v-model="currentFolderId" :source="RESOURCE_TYPE.TOOL" draggable @select="handleFolderSelect" />
<FolderTree v-model="currentFolderId" :source="RESOURCE_TYPE.TOOL" draggable @loaded="handleFolderLoaded" @select="handleFolderSelect" />
```

页面顶部需要触发根目录创建时,通过页面语义处理方法调用组件暴露的 `openCreate()`;外部数据变化
Expand Down
15 changes: 13 additions & 2 deletions ui/src/components/business/folder-tree/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const props = withDefaults(

const currentNodeKey = defineModel<string>({ default: FOLDER_ENTRY_ID.ALL })

const emit = defineEmits<{ select: [folder: FolderItem] }>()
const emit = defineEmits<{ loaded: [folder?: FolderItem]; select: [folder: FolderItem] }>()

const folderEntries = computed(() => FOLDER_ENTRIES[props.source])
const rootFolderEntry = computed(() => ({ ...folderEntries.value.all, name: props.rootLabel || folderEntries.value.all.name }))
Expand Down Expand Up @@ -250,6 +250,13 @@ function findFolderById(folders: FolderItem[], folderId: string): FolderItem | u
}
}

function resolveCurrentFolder() {
if (currentNodeKey.value === FOLDER_ENTRY_ID.ALL && props.showAll) return rootFolderEntry.value
if (currentNodeKey.value === FOLDER_ENTRY_ID.SHARED && props.showShared) return folderEntries.value.shared

return findFolderById(folderTreeData.value, currentNodeKey.value) ?? (props.showAll ? rootFolderEntry.value : sortTreeData.value[0])
}

function getFolderDeleteContext(folder: FolderItem) {
const deletedFolder = findFolderById(folderTreeData.value, folder.id) ?? folder
const positionCache = readCustomPositions()
Expand Down Expand Up @@ -314,7 +321,11 @@ onMounted(() => {
if (Object.values(FOLDER_SORT).includes(savedSort as FolderSort)) {
currentSort.value = savedSort as FolderSort
}
loadFolders()
void loadFolders().then(() => {
const currentFolder = resolveCurrentFolder()
if (currentFolder) currentNodeKey.value = currentFolder.id
emit('loaded', currentFolder)
})
})

defineExpose({ refresh: loadFolders, openCreate: handleOpenCreateFolder })
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/business/workspace-dropdown/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const emit = defineEmits<{ select: [option: WorkspaceItem] }>()
<template>
<MkFilterableDropdown v-model="selectedWorkspaceId" :options="props.options" :props="{ label: 'name', value: 'id' }" @select="emit('select', $event)">
<template #default="{ text }">
<el-button text class="flex max-w-50 items-center gap-1 rounded-md px-2! py-[7px]!">
<el-button text class="flex max-w-50 items-center gap-1 rounded-md px-2! py-[7px]! text-N900!">
<MkIcon name="icon_moments-categories_outlined" class="mr-1" />
<span class="min-w-0 flex-1 truncate" :title="text">{{ text }}</span>
<MkIcon :icon="CaretBottom" :size="14" class="ml-1 shrink-0 text-N600!" />
Expand Down
2 changes: 1 addition & 1 deletion ui/src/layout/LayoutHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function switchMode() {
<el-divider direction="vertical" />
<!-- 企业版: 工作空间下拉框 -->
<HeaderWorkspaceDropdown v-if="isWorkspace(mode)" />
<span v-if="isSystem(mode)" class="text-lg">系统管理</span>
<span v-if="isSystem(mode)">系统管理</span>
</div>
</div>

Expand Down
70 changes: 70 additions & 0 deletions ui/src/layout/ResourceDetailLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { getMatchedChildRouteList } from '@/router/admin/utils'
import type { LayoutMenuItem } from './types'

defineOptions({ name: 'ResourceDetailLayout' })

const props = withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })

const emit = defineEmits<{ back: [] }>()

defineSlots<{ 'resource-header': () => unknown }>()

const route = useRoute()
const router = useRouter()
const detailMenuItems = computed(() => getMatchedChildRouteList(route))
const activeDetailMenuName = computed(() => route.meta.detailActiveMenu ?? String(route.name ?? ''))

function isDetailMenuActive(detailMenuItem: LayoutMenuItem) {
return detailMenuItem.name === activeDetailMenuName.value
}

function navigateBack() {
emit('back')
}

function navigateToDetailMenu(detailMenuItem: LayoutMenuItem) {
void router.push({ name: detailMenuItem.name, params: route.params, query: route.query })
}
</script>

<template>
<MkViewLayout :loading="props.loading" title="">
<template #aside="{ Header }">
<component :is="Header">
<div class="flex min-w-0 items-center gap-1">
<el-button class="-ml-1" text @click="navigateBack">
<MkIcon name="icon_arrow-left_outlined" :size="20" />
</el-button>
<slot name="resource-header" />
</div>
</component>

<el-scrollbar class="min-h-0 flex-1">
<div class="space-y-1 px-4">
<template v-for="detailMenuItem in detailMenuItems" :key="detailMenuItem.name">
<MkListItem :active="isDetailMenuActive(detailMenuItem)" @click="navigateToDetailMenu(detailMenuItem)">
<template #default="{ active }">
<MkIcon
v-if="detailMenuItem.icon"
class="mr-3"
:name="active ? (detailMenuItem.activeIcon ?? detailMenuItem.icon) : detailMenuItem.icon"
/>
<span class="min-w-0 flex-1 truncate" :title="detailMenuItem.label">{{ detailMenuItem.label }}</span>
</template>
</MkListItem>
</template>
</div>
</el-scrollbar>
</template>

<template #default="{ Header }">
<component :is="Header">
<h4>{{ route.meta.title }}</h4>
</component>
<RouterView />
</template>
</MkViewLayout>
</template>
48 changes: 33 additions & 15 deletions ui/src/router/ROUTE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ Admin 使用末尾 catch-all 路由 `not-found` 渲染 `views/error/NotFoundView

Admin Router 在 `admin/index.ts` 中统一处理导航初始化:

1. 导航开始时调用 `NProgress.start()`,导航完成或发生路由错误时调用
`NProgress.done()`;统一关闭 Spinner,视觉样式由 `styles/nprogress.scss` 维护。
1. 路由 path 变化时调用 `NProgress.start()`;同一路径只更新 query 或 hash 时不启动顶部进度条。
导航完成或发生路由错误时调用 `NProgress.done()`;统一关闭 Spinner,视觉样式由
`styles/nprogress.scss` 维护。
2. `login`、`forgot-password`、`not-found` 为公开路由,不校验登录状态。
3. URL 查询参数包含 `token` 时,先写入 Admin 认证 Store,用于外部认证回跳,然后使用替换
导航清理地址栏中的 token。
Expand All @@ -100,17 +101,18 @@ Chat 使用独立入口 `src/chat.ts` 和独立 Router,不要把 Chat 路由

路由扩展字段定义在 `admin/types.ts`:

| 字段 | 类型 | 说明 |
| --------------- | ----------------------------------------------------- | ------------------------------------------------------ |
| `scope` | `'workspace' \| 'system'` | 标记生成哪一套框架导航,只配置在布局根路由上 |
| `resourceScope` | `'workspace' \| 'system-resource' \| 'system-shared'` | 标记页面使用的资源范围,由对应资源父路由配置并向下继承 |
| `activeIcon` | `string` | 菜单激活状态的 iconfont Symbol ID |
| `activeMenu` | `string` | 进入子页面时需要保持激活的侧栏菜单路径 |
| `title` | `string` | 页面标题,同时作为导航名称 |
| `icon` | `string` | iconfont Symbol ID,子目录通常可以不配置 |
| `order` | `number` | 同级导航排序,数字越小越靠前 |
| `hidden` | `boolean` | 设置为 `true` 时不显示在导航中 |
| `resource` | `ResourceAuthorizationType` | 系统资源授权页面当前管理的后端资源类型 |
| 字段 | 类型 | 说明 |
| ------------------ | ----------------------------------------------------- | ------------------------------------------------------ |
| `scope` | `'workspace' \| 'system'` | 标记生成哪一套框架导航,只配置在布局根路由上 |
| `resourceScope` | `'workspace' \| 'system-resource' \| 'system-shared'` | 标记页面使用的资源范围,由对应资源父路由配置并向下继承 |
| `activeIcon` | `string` | 菜单激活状态的 iconfont Symbol ID |
| `activeMenu` | `string` | 进入子页面时需要保持激活的侧栏菜单路径 |
| `detailActiveMenu` | `string` | 更深层详情页面需要保持激活的二级菜单路由名称 |
| `title` | `string` | 页面标题,同时作为导航名称 |
| `icon` | `string` | iconfont Symbol ID,子目录通常可以不配置 |
| `order` | `number` | 同级导航排序,数字越小越靠前 |
| `hidden` | `boolean` | 设置为 `true` 时不显示在导航中 |
| `resource` | `ResourceAuthorizationType` | 系统资源授权页面当前管理的后端资源类型 |

## 导航生成

Expand All @@ -124,6 +126,13 @@ Chat 使用独立入口 `src/chat.ts` 和独立 Router,不要把 Chat 路由

`WorkspaceSidebar` 和 `SystemSidebar` 分别调用该方法,不需要在页面组件中维护菜单数组。

资源详情页面统一使用 `ResourceDetailLayout`。`admin/utils.ts` 中的
`getMatchedChildRouteList(route)` 根据当前匹配路由找到所属详情父路由,并复用与
`getChildRouteList(scope)` 相同的过滤、排序和菜单字段转换逻辑生成二级导航。详情子路由通过
`title`、`icon`、`activeIcon` 和 `order` 配置二级菜单;更深层页面通过
`detailActiveMenu` 指定需要保持激活的二级菜单路由名称,不配置 `parentPath` 或 `parentName`。
`ResourceDetailLayout` 切换二级菜单时保留当前 query,详情容器返回列表时也应透传来源 query。

路由导航图标统一使用 `src/assets/iconfont.js` 中的完整 Symbol ID 字符串,并由
`MkIcon` 的 `name` 属性渲染。需要在菜单激活后切换图标时,通过 `activeIcon` 配置激活状态
的 Symbol ID;未配置时继续使用 `icon`。Router 目录禁止导入或传入 Element Plus 图标组件。
Expand Down Expand Up @@ -177,14 +186,19 @@ Workspace 页面:
/admin/workspace/:workspaceId/model

/admin/workspace/:workspaceId/application
/admin/workspace/:workspaceId/application/:applicationId
/admin/workspace/:workspaceId/application/:applicationId/edit
/admin/workspace/:workspaceId/application/:applicationId/:type/overview
/admin/workspace/:workspaceId/application/:applicationId/:type/setting

/admin/workspace/:workspaceId/knowledge
/admin/workspace/:workspaceId/knowledge/:knowledgeId
/admin/workspace/:workspaceId/knowledge/:knowledgeId/document/:documentId
```

智能体详情路由的 `type` 来自当前 `ApplicationDetail.type`;卡片概览和简易智能体设置等详情入口
必须与 `applicationId` 一并传入该参数。高级智能体设置继续进入独立的 Workflow 路由。
智能体列表使用可选的 `folderId` query 保存当前文件夹;进入详情时继续携带该 query,返回列表后
由 `FolderTree` 恢复选中项。`folderId` 是列表来源状态,不作为详情 path 参数。

System 复用页面(仅替换根前缀):

```text
Expand Down Expand Up @@ -268,6 +282,10 @@ System 共享资源页面:
}
```

使用 `ResourceDetailLayout` 的详情路由只在详情父路由配置 `hidden: true`;需要显示在二级导航中的
直接子路由继续配置 `title`、`icon` 和 `order`,不要再配置 `hidden: true`。一级导航生成时会排除
整个详情父路由,详情布局则读取该父路由的直接子路由生成自己的二级导航。

## 新增路由约定

- 每条路由必须使用全局唯一的 `name`。
Expand Down
4 changes: 2 additions & 2 deletions ui/src/router/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const router = createRouter({
],
})

router.beforeEach(async (to) => {
NProgress.start()
router.beforeEach(async (to, from) => {
if (to.path !== from.path) NProgress.start()
const notAuthRouteNameList = ['login', 'forgot-password', 'not-found']
const { auth, user } = useStore()

Expand Down
13 changes: 1 addition & 12 deletions ui/src/router/admin/system/modules/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,5 @@ import type { RouteRecordRaw } from 'vue-router'

/** System 来源的智能体详情路由,与 Workspace 复用页面组件。 */
export const systemApplicationRoutes: RouteRecordRaw[] = [
// {
// path: 'application/:applicationId',
// name: 'system-application-detail',
// component: () => import('@/views/application/ApplicationDetailView.vue'),
// meta: { title: '智能体详情', hidden: true },
// },
// {
// path: 'application/:applicationId/edit',
// name: 'system-application-edit',
// component: () => import('@/views/application/ApplicationDetailView.vue'),
// meta: { title: '编辑智能体', hidden: true },
// },

]
2 changes: 2 additions & 0 deletions ui/src/router/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ declare module 'vue-router' {
activeIcon?: string
/** 侧栏需要保持激活的菜单路径 */
activeMenu?: string
/** 资源详情页需要保持激活的二级菜单路由名称。 */
detailActiveMenu?: string
/** iconfont Symbol ID */
icon?: string
order?: number
Expand Down
60 changes: 40 additions & 20 deletions ui/src/router/admin/utils.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,52 @@
import type { RouteRecordRaw } from 'vue-router'
import type { RouteLocationNormalizedLoaded, RouteMeta, RouteRecordName, RouteRecordRaw } from 'vue-router'
import type { LayoutMenuItem } from '@/layout/types'
import type { RouteScope } from './types'
import { systemRoutes } from './system'
import { workspaceRoutes } from './workspace'

interface MenuRouteRecord {
children?: readonly MenuRouteRecord[]
meta?: RouteMeta
name?: RouteRecordName | null
}

const scopedRoutes: RouteRecordRaw[] = [workspaceRoutes, systemRoutes]

/** 根据 Workspace 或 System 路由树递归生成侧栏目录。 */
/** 将路由记录递归转换为 Layout 菜单。 */
function createMenuItems(routes: readonly MenuRouteRecord[]): LayoutMenuItem[] {
return routes
.filter((route) => route.name && route.meta?.title && route.meta.hidden !== true)
.sort((a, b) => (a.meta?.order ?? Number.MAX_SAFE_INTEGER) - (b.meta?.order ?? Number.MAX_SAFE_INTEGER))
.map((route) => {
const children = route.children ? createMenuItems(route.children) : []

return {
name: String(route.name),
label: route.meta!.title!,
activeIcon: route.meta?.activeIcon,
icon: route.meta?.icon,
route: { name: route.name! },
children: children.length ? children : undefined,
}
})
}

/** 根据 Workspace 或 System 路由树生成一级侧栏目录。 */
export function getChildRouteList(scope: RouteScope): LayoutMenuItem[] {
const rootRoute = scopedRoutes.find((route) => route.meta?.scope === scope)

const createMenuItems = (routes: readonly RouteRecordRaw[]): LayoutMenuItem[] => {
return routes
.filter((route) => route.name && route.meta?.title && route.meta.hidden !== true)
.sort((a, b) => (a.meta?.order ?? Number.MAX_SAFE_INTEGER) - (b.meta?.order ?? Number.MAX_SAFE_INTEGER))
.map((route) => {
const children = route.children ? createMenuItems(route.children) : []

return {
name: String(route.name),
label: route.meta!.title!,
activeIcon: route.meta?.activeIcon,
icon: route.meta?.icon,
route: { name: route.name! },
children: children.length ? children : undefined,
}
})
}

return createMenuItems(rootRoute?.children ?? [])
}

/** 根据当前匹配路由生成所属详情父路由的二级侧栏目录。 */
export function getMatchedChildRouteList(route: RouteLocationNormalizedLoaded): LayoutMenuItem[] {
const activeRouteName = route.meta.detailActiveMenu ?? route.name

if (!activeRouteName) return []

const parentRoute = [...route.matched]
.reverse()
.find((matchedRoute) => matchedRoute.children.some((childRoute) => childRoute.name === activeRouteName))

return createMenuItems(parentRoute?.children ?? [])
}
Loading
Loading