feat: restore core admin modules and harden ai model fallback

This commit is contained in:
2026-06-22 01:21:01 +08:00
parent f180fff020
commit 524c027a4a
15 changed files with 1896 additions and 21 deletions
+4 -1
View File
@@ -16,7 +16,7 @@ from app.base_schema import PaginatedResponse, ResponseModel
from utils.context import get_current_user_id_from_context from utils.context import get_current_user_id_from_context
from ai_platform.models import ( from ai_platform.models import (
Agent, AgentConversation, AgentMessage, Agent, AgentConversation, AgentMessage,
LLMModel, AIWorkflow, LLMModel, LLMProvider, AIWorkflow,
) )
from core.application.model import Application from core.application.model import Application
from ai_platform.schemas.agent_schema import ( from ai_platform.schemas.agent_schema import (
@@ -59,10 +59,13 @@ CODEX_AGENT_PROMPT = """
async def _resolve_default_chat_model_id(db: AsyncSession) -> Optional[str]: async def _resolve_default_chat_model_id(db: AsyncSession) -> Optional[str]:
result = await db.execute( result = await db.execute(
select(LLMModel) select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where( .where(
LLMModel.is_deleted == False, LLMModel.is_deleted == False,
LLMModel.is_active == True, LLMModel.is_active == True,
LLMModel.model_type == "chat", LLMModel.model_type == "chat",
LLMProvider.is_deleted == False,
LLMProvider.is_active == True,
) )
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc()) .order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
) )
@@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.models import ( from ai_platform.models import (
Agent, AgentConversation, AgentMessage, AIWorkflow, LLMModel, Agent, AgentConversation, AgentMessage, AIWorkflow, LLMModel, LLMProvider,
) )
from .llm_service import LLMService from .llm_service import LLMService
@@ -212,10 +212,14 @@ class AgentService:
if agent.model_id: if agent.model_id:
result = await self._db.execute( result = await self._db.execute(
select(LLMModel).where( select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where(
LLMModel.id == agent.model_id, LLMModel.id == agent.model_id,
LLMModel.is_deleted == False, LLMModel.is_deleted == False,
LLMModel.is_active == True, LLMModel.is_active == True,
LLMProvider.is_deleted == False,
LLMProvider.is_active == True,
) )
) )
if result.scalar_one_or_none(): if result.scalar_one_or_none():
@@ -223,10 +227,13 @@ class AgentService:
result = await self._db.execute( result = await self._db.execute(
select(LLMModel) select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where( .where(
LLMModel.is_deleted == False, LLMModel.is_deleted == False,
LLMModel.is_active == True, LLMModel.is_active == True,
LLMModel.model_type == "chat", LLMModel.model_type == "chat",
LLMProvider.is_deleted == False,
LLMProvider.is_active == True,
) )
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc()) .order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
) )
@@ -44,14 +44,17 @@ class LLMService:
if not self._db: if not self._db:
raise ValueError("未找到可用的 chat 模型,请先在模型配置中启用一个模型") raise ValueError("未找到可用的 chat 模型,请先在模型配置中启用一个模型")
from ai_platform.models import LLMModel from ai_platform.models import LLMModel, LLMProvider
result = await self._db.execute( result = await self._db.execute(
select(LLMModel) select(LLMModel)
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
.where( .where(
LLMModel.is_deleted == False, LLMModel.is_deleted == False,
LLMModel.is_active == True, LLMModel.is_active == True,
LLMModel.model_type == "chat", LLMModel.model_type == "chat",
LLMProvider.is_deleted == False,
LLMProvider.is_active == True,
) )
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc()) .order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
) )
+59 -6
View File
@@ -17,13 +17,14 @@ from core.menu.schema import MenuCreate, MenuUpdate
menu_cache = CacheManager(prefix="menu:") menu_cache = CacheManager(prefix="menu:")
# 缓存key # 缓存key
AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v16" AI_AGENT_ADMIN_MENU_CACHE_VERSION = "v19"
MENU_TREE_CACHE_KEY = f"tree:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}" MENU_TREE_CACHE_KEY = f"tree:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}"
USER_ROUTE_CACHE_PREFIX = f"user_route:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}:" USER_ROUTE_CACHE_PREFIX = f"user_route:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}:"
AI_AGENT_ADMIN_MENU_NAMES = { AI_AGENT_ADMIN_MENU_NAMES = {
"AccountSettings", "AccountSettings",
"ControlCenter", "ControlCenter",
"Application",
"AIPlatform", "AIPlatform",
"AIAgent", "AIAgent",
"AIModelConfig", "AIModelConfig",
@@ -39,6 +40,10 @@ AI_AGENT_ADMIN_MENU_NAMES = {
"SystemPost", "SystemPost",
"SystemDict", "SystemDict",
"SystemFileManager", "SystemFileManager",
"LoginLog",
"OrgNode",
"SystemLoginLog",
"SystemOrgChart",
"SystemRole", "SystemRole",
"SystemConfigManager", "SystemConfigManager",
"UIConfigManager", "UIConfigManager",
@@ -50,6 +55,7 @@ AI_AGENT_ADMIN_MENU_NAMES = {
AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = { AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
"ControlCenter", "ControlCenter",
"Application",
"AIPlatform", "AIPlatform",
"AIAgent", "AIAgent",
"AIModelConfig", "AIModelConfig",
@@ -65,6 +71,10 @@ AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
"SystemPost", "SystemPost",
"SystemDict", "SystemDict",
"SystemFileManager", "SystemFileManager",
"LoginLog",
"OrgNode",
"SystemLoginLog",
"SystemOrgChart",
"SystemRole", "SystemRole",
"SystemConfigManager", "SystemConfigManager",
"UIConfigManager", "UIConfigManager",
@@ -82,10 +92,29 @@ AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES = {
"SystemPost", "SystemPost",
"SystemDict", "SystemDict",
"SystemFileManager", "SystemFileManager",
"LoginLog",
"OrgNode",
"SystemLoginLog",
"SystemOrgChart",
"SystemRole", "SystemRole",
"UIConfigManager", "UIConfigManager",
} }
AI_AGENT_ADMIN_AI_CHILD_MENU_NAMES = {
"AIAgent",
"AIModelConfig",
"AIWorkflow",
"AIWorkflowRuns",
"CodexAgentChat",
"KnowledgeBase",
}
AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES = {
"AnnouncementList",
"AnnouncementManage",
"MessageList",
}
AI_AGENT_ADMIN_MENU_OVERRIDES = { AI_AGENT_ADMIN_MENU_OVERRIDES = {
"SystemConfigManager": { "SystemConfigManager": {
"component": "/_core/system-config/index", "component": "/_core/system-config/index",
@@ -100,6 +129,16 @@ AI_AGENT_ADMIN_MENU_OVERRIDES = {
"component": "/_core/file-manager/index", "component": "/_core/file-manager/index",
"path": "/system/file-manager", "path": "/system/file-manager",
}, },
"LoginLog": {
"component": "/_core/login-log/index",
"path": "/system/login-log",
"title": "menu-title.loginLog",
},
"OrgNode": {
"component": "/_core/org-chart/index",
"path": "/system/org-chart",
"title": "menu-title.orgChart",
},
"UIConfigManager": { "UIConfigManager": {
"component": "/_core/ui-config/index", "component": "/_core/ui-config/index",
"path": "/system/ui-config", "path": "/system/ui-config",
@@ -131,19 +170,33 @@ def _normalize_ai_agent_admin_menu(menu: Menu) -> None:
def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None: def _normalize_ai_agent_admin_parentage(menus: List[Menu]) -> None:
"""Attach retained system children back to the lightweight system catalog.""" """Attach retained children back to the lightweight product catalogs."""
retained_ids = {menu.id for menu in menus} retained_ids = {menu.id for menu in menus}
system_menu = next( system_menu = next(
(menu for menu in menus if menu.name == "SystemManagement"), (menu for menu in menus if menu.name == "SystemManagement"),
None, None,
) )
ai_menu = next(
(menu for menu in menus if menu.name == "AIPlatform"),
None,
)
message_menu = next(
(menu for menu in menus if menu.name == "Message"),
None,
)
for menu in menus: for menu in menus:
if not menu.parent_id or menu.parent_id in retained_ids:
continue
if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES: if system_menu and menu.name in AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES:
menu.parent_id = system_menu.id menu.parent_id = system_menu.id
else: continue
menu.parent_id = None if ai_menu and menu.name in AI_AGENT_ADMIN_AI_CHILD_MENU_NAMES:
menu.parent_id = ai_menu.id
continue
if message_menu and menu.name in AI_AGENT_ADMIN_MESSAGE_CHILD_MENU_NAMES:
menu.parent_id = message_menu.id
continue
if not menu.parent_id or menu.parent_id in retained_ids:
continue
menu.parent_id = None
class MenuService(BaseService[Menu, MenuCreate, MenuUpdate]): class MenuService(BaseService[Menu, MenuCreate, MenuUpdate]):
+2
View File
@@ -10,6 +10,7 @@ from core.chat.api import router as chat_router
from core.dept.api import router as dept_router from core.dept.api import router as dept_router
from core.device.api import router as device_router from core.device.api import router as device_router
from core.file_manager.router import router as file_manager_router from core.file_manager.router import router as file_manager_router
from core.login_log.api import router as login_log_router
from core.menu.api import router as menu_router from core.menu.api import router as menu_router
from core.message.api import announcement_router from core.message.api import announcement_router
from core.message.api import router as message_router from core.message.api import router as message_router
@@ -40,6 +41,7 @@ router.include_router(field_permission_router)
router.include_router(role_router) router.include_router(role_router)
router.include_router(user_router) router.include_router(user_router)
router.include_router(file_manager_router) router.include_router(file_manager_router)
router.include_router(login_log_router)
router.include_router(message_router) router.include_router(message_router)
router.include_router(announcement_router) router.include_router(announcement_router)
router.include_router(oauth_router) router.include_router(oauth_router)
+1 -11
View File
@@ -29,17 +29,7 @@ const baseModules = import.meta.glob([
'./langs/zh-CN/ui.json', './langs/zh-CN/ui.json',
]); ]);
const businessModules = import.meta.glob([ const businessModules = import.meta.glob('./langs/zh-CN/*.json');
'./langs/zh-CN/ai-platform.json',
'./langs/zh-CN/announcement.json',
'./langs/zh-CN/dashboard-design.json',
'./langs/zh-CN/menu.json',
'./langs/zh-CN/message.json',
'./langs/zh-CN/permission.json',
'./langs/zh-CN/role.json',
'./langs/zh-CN/system.json',
'./langs/zh-CN/user.json',
]);
const baseLocalesMap = loadLocalesMapFromDir( const baseLocalesMap = loadLocalesMapFromDir(
/\.\/langs\/([^/]+)\/(.*)\.json$/, /\.\/langs\/([^/]+)\/(.*)\.json$/,
@@ -0,0 +1,74 @@
{
"title": "应用管理",
"createApp": "创建应用",
"editApp": "编辑应用",
"search": "搜索",
"searchPlaceholder": "搜索应用名称或编码",
"noApps": "暂无应用",
"appName": "应用名称",
"appNamePlaceholder": "请输入应用名称",
"appCode": "应用编码",
"appCodePlaceholder": "请输入应用编码(用于URL路由)",
"appType": "应用类型",
"appTypePlaceholder": "请选择应用类型",
"appDescription": "应用描述",
"appDescriptionPlaceholder": "请输入应用描述",
"appIcon": "应用图标",
"systemMenu": "系统菜单",
"systemMenuPlaceholder": "选择子应用可访问的基础菜单(留空则显示全部)",
"selectSystemMenu": "选择系统菜单",
"save": "保存",
"create": "创建",
"cancel": "取消",
"edit": "编辑",
"publish": "发布",
"enable": "启用",
"disable": "停用",
"delete": "删除",
"publishApp": "发布应用",
"enableApp": "启用应用",
"disableApp": "停用应用",
"confirmPublish": "确认发布",
"confirmEnable": "确认启用",
"confirmDisable": "确认停用",
"publishConfirmMsg": "确定要发布应用「{name}」吗?",
"enableConfirmMsg": "确定要重新启用应用「{name}」吗?",
"publishSuccessMsg": "发布后,用户可以通过以下链接访问该应用:",
"enableSuccessMsg": "启用后,用户可以通过以下链接访问该应用:",
"disableConfirmMsg": "确定要停用应用「{name}」吗?",
"appLink": "应用链接:",
"deleteConfirm": "删除确认",
"deleteConfirmMsg": "确定要删除应用「{name}」吗?",
"confirm": "确定",
"loadFailed": "加载应用列表失败",
"publishSuccess": "发布成功",
"publishFailed": "发布失败",
"enableSuccess": "启用成功",
"enableFailed": "启用失败",
"disableSuccess": "停用成功",
"disableFailed": "停用失败",
"deleteSuccess": "删除成功",
"copySuccess": "链接已复制到剪贴板",
"copyFailed": "复制失败",
"appTypes": {
"mixed": "混合应用",
"form": "表单应用",
"workflow": "流程应用",
"ai": "AI应用",
"dashboard": "数据应用",
"screen": "大屏应用"
},
"appStatus": {
"draft": "开发中",
"published": "已发布",
"disabled": "已停用"
},
"validation": {
"nameRequired": "请输入应用名称",
"nameLength": "长度在 2 到 100 个字符",
"codeRequired": "请输入应用编码",
"codePattern": "编码必须以字母开头,只能包含字母、数字、下划线和短横线",
"codeLength": "长度在 2 到 100 个字符",
"typeRequired": "请选择应用类型"
}
}
@@ -23,6 +23,7 @@
"serverMonitoring": "服务器监控", "serverMonitoring": "服务器监控",
"uiConfig": "界面配置", "uiConfig": "界面配置",
"loginLog": "登录日志", "loginLog": "登录日志",
"orgChart": "组织架构",
"roleManagement": "角色权限", "roleManagement": "角色权限",
"permissionManagement": "API管理", "permissionManagement": "API管理",
"menuManagement": "菜单管理", "menuManagement": "菜单管理",
+3
View File
@@ -175,14 +175,17 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/_core/agent-chat/**/*.vue', '../views/_core/agent-chat/**/*.vue',
'../views/_core/announcement/index.vue', '../views/_core/announcement/index.vue',
'../views/_core/announcement/list.vue', '../views/_core/announcement/list.vue',
'../views/_core/application/index.vue',
'../views/_core/authentication/login.vue', '../views/_core/authentication/login.vue',
'../views/_core/dept/index.vue', '../views/_core/dept/index.vue',
'../views/_core/dict/index.vue', '../views/_core/dict/index.vue',
'../views/_core/fallback/**/*.vue', '../views/_core/fallback/**/*.vue',
'../views/_core/file-manager/index.vue', '../views/_core/file-manager/index.vue',
'../views/_core/file-preview/index.vue', '../views/_core/file-preview/index.vue',
'../views/_core/login-log/index.vue',
'../views/_core/menu/index.vue', '../views/_core/menu/index.vue',
'../views/_core/message/index.vue', '../views/_core/message/index.vue',
'../views/_core/org-chart/index.vue',
'../views/_core/page-render/index.vue', '../views/_core/page-render/index.vue',
'../views/_core/permission/index.vue', '../views/_core/permission/index.vue',
'../views/_core/post/index.vue', '../views/_core/post/index.vue',
@@ -1,5 +1,6 @@
export const LIGHT_MENU_NAMES = new Set([ export const LIGHT_MENU_NAMES = new Set([
'AccountSettings', 'AccountSettings',
'Application',
'ControlCenter', 'ControlCenter',
'AIAgent', 'AIAgent',
'AIKnowledgeDetail', 'AIKnowledgeDetail',
@@ -14,10 +15,14 @@ export const LIGHT_MENU_NAMES = new Set([
'MessageCenter', 'MessageCenter',
'MessageList', 'MessageList',
'KnowledgeBase', 'KnowledgeBase',
'LoginLog',
'OrgNode',
'SystemDept', 'SystemDept',
'SystemDict', 'SystemDict',
'SystemFileManager', 'SystemFileManager',
'SystemLoginLog',
'SystemManagement', 'SystemManagement',
'SystemOrgChart',
'SystemConfig', 'SystemConfig',
'SystemConfigManager', 'SystemConfigManager',
'SystemMenu', 'SystemMenu',
@@ -38,6 +43,7 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
'/ai-platform/workflow', '/ai-platform/workflow',
'/ai-platform/workflow-runs', '/ai-platform/workflow-runs',
'/agent-chat', '/agent-chat',
'/application',
'/message/list', '/message/list',
'/message/announcement', '/message/announcement',
'/message/announcement-list', '/message/announcement-list',
@@ -47,6 +53,8 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
'/system/dept', '/system/dept',
'/system/dict', '/system/dict',
'/system/file-manager', '/system/file-manager',
'/system/login-log',
'/system/org-chart',
'/system/post', '/system/post',
'/system/config', '/system/config',
'/system/menu', '/system/menu',
@@ -0,0 +1,513 @@
<script setup lang="ts">
import type { ApplicationListItem } from '#/api/core/application';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
AppWindow,
Copy,
Edit,
IconifyIcon,
Play,
Plus,
Square,
Trash2,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElEmpty,
ElInput,
ElMessage,
ElMessageBox,
ElPagination,
ElTag,
ElTooltip,
} from 'element-plus';
import {
deleteApplicationApi,
disableApplicationApi,
getApplicationListApi,
publishApplicationApi,
} from '#/api/core/application';
import ZqDialog from '#/components/zq-dialog/zq-dialog.vue';
import ApplicationFormModal from './modules/application-form-modal.vue';
defineOptions({ name: 'ApplicationList' });
// 搜索关键词
const searchKeyword = ref('');
// 应用列表
const applicationList = ref<ApplicationListItem[]>([]);
// 分页
const pagination = ref({
current: 1,
pageSize: 12,
total: 0,
});
// 加载状态
const loading = ref(false);
// 弹窗状态
const showFormModal = ref(false);
const editingApp = ref<ApplicationListItem | null>(null);
// 发布/停用对话框状态
const showPublishDialog = ref(false);
const showDisableDialog = ref(false);
const currentApp = ref<ApplicationListItem | null>(null);
const appUrl = ref('');
// Element Plus Tag 类型
type TagType =
| 'danger'
| 'info'
| 'primary'
| 'success'
| 'warning'
| undefined;
// 应用类型映射
const appTypeMap: Record<string, { color: TagType; labelKey: string }> = {
ai: { labelKey: 'application.appTypes.ai', color: 'info' },
dashboard: { labelKey: 'application.appTypes.dashboard', color: 'success' },
form: { labelKey: 'application.appTypes.form', color: 'primary' },
mixed: { labelKey: 'application.appTypes.mixed', color: undefined },
screen: { labelKey: 'application.appTypes.screen', color: 'danger' },
workflow: { labelKey: 'application.appTypes.workflow', color: 'warning' },
};
// 状态映射
const statusMap: Record<string, { color: TagType; labelKey: string }> = {
disabled: { labelKey: 'application.appStatus.disabled', color: 'danger' },
draft: { labelKey: 'application.appStatus.draft', color: 'info' },
published: { labelKey: 'application.appStatus.published', color: 'success' },
};
// 加载应用列表
const loadApplications = async () => {
loading.value = true;
try {
const res = await getApplicationListApi({
page: pagination.value.current,
pageSize: pagination.value.pageSize,
keyword: searchKeyword.value || undefined,
});
applicationList.value = res.items || [];
pagination.value.total = res.total || 0;
} catch (error) {
console.error('Failed to load applications:', error);
ElMessage.error($t('application.loadFailed'));
} finally {
loading.value = false;
}
};
// 创建新应用
const handleCreate = () => {
editingApp.value = null;
showFormModal.value = true;
};
// 编辑应用
const handleEdit = (app: ApplicationListItem) => {
editingApp.value = app;
showFormModal.value = true;
};
// 进入应用(在新 tab 打开子应用)
const handleEnter = (app: ApplicationListItem) => {
const subAppUrl = `${window.location.origin}/app/${app.code}`;
window.open(subAppUrl, '_blank');
};
// 发布应用
const handlePublish = (app: ApplicationListItem) => {
currentApp.value = app;
appUrl.value = `${window.location.origin}/app/${app.code}`;
showPublishDialog.value = true;
};
// 确认发布
const confirmPublish = async () => {
if (!currentApp.value) return;
try {
await publishApplicationApi(currentApp.value.id);
const isReEnable = currentApp.value.status === 'disabled';
ElMessage.success(
isReEnable ? $t('application.enableSuccess') : $t('application.publishSuccess'),
);
showPublishDialog.value = false;
loadApplications();
} catch {
const wasDisabled = currentApp.value?.status === 'disabled';
ElMessage.error(
wasDisabled ? $t('application.enableFailed') : $t('application.publishFailed'),
);
}
};
// 停用应用
const handleDisable = (app: ApplicationListItem) => {
currentApp.value = app;
appUrl.value = `${window.location.origin}/app/${app.code}`;
showDisableDialog.value = true;
};
// 确认停用
const confirmDisable = async () => {
if (!currentApp.value) return;
try {
await disableApplicationApi(currentApp.value.id);
ElMessage.success($t('application.disableSuccess'));
showDisableDialog.value = false;
loadApplications();
} catch {
ElMessage.error($t('application.disableFailed'));
}
};
// 删除应用
const handleDelete = async (app: ApplicationListItem) => {
try {
await ElMessageBox.confirm(
$t('application.deleteConfirmMsg', { name: app.name }),
$t('application.deleteConfirm'),
{
confirmButtonText: $t('application.confirm'),
cancelButtonText: $t('application.cancel'),
type: 'warning',
},
);
await deleteApplicationApi(app.id);
ElMessage.success($t('application.deleteSuccess'));
loadApplications();
} catch {
// 取消操作
}
};
// 搜索
const handleSearch = () => {
pagination.value.current = 1;
loadApplications();
};
// 分页变化
const handlePageChange = (page: number) => {
pagination.value.current = page;
loadApplications();
};
// 每页条数变化
const handleSizeChange = (size: number) => {
pagination.value.pageSize = size;
pagination.value.current = 1;
loadApplications();
};
// 保存成功回调
const handleSaveSuccess = () => {
loadApplications();
};
// 复制链接
const copyLink = async () => {
try {
await navigator.clipboard.writeText(appUrl.value);
ElMessage.success($t('application.copySuccess'));
} catch {
ElMessage.error($t('application.copyFailed'));
}
};
onMounted(() => {
loadApplications();
});
</script>
<template>
<div class="application-list">
<Page auto-content-height v-loading="loading">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<ElButton type="primary" @click="handleCreate">
<Plus class="mr-1 h-4 w-4" />
{{ $t('application.createApp') }}
</ElButton>
</div>
<div class="flex items-center gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="$t('application.searchPlaceholder')"
class="!w-64"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('application.search') }}
</ElButton>
</div>
</div>
</template>
<!-- 应用列表 -->
<div
v-if="applicationList.length > 0"
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
<ElCard
v-for="app in applicationList"
shadow="hover"
:key="app.id"
class="group application-card cursor-pointer transition-shadow"
:body-style="{ padding: '0' }"
style="border: none"
@click="handleEnter(app)"
>
<div class="p-4">
<!-- 头部图标 + 右侧信息区 -->
<div class="mb-4 flex gap-3">
<div class="app-icon flex-shrink-0">
<IconifyIcon
v-if="app.icon"
:icon="app.icon"
class="h-5 w-5 text-white"
/>
<AppWindow v-else class="h-5 w-5 text-white" />
</div>
<div class="min-w-0 flex-1">
<!-- name + 操作 -->
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 whitespace-nowrap text-sm font-medium group-hover:truncate">
{{ app.name }}
</div>
<div
class="flex flex-shrink-0 items-center -space-x-1 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop
>
<ElTooltip
:content="$t('application.edit')"
placement="top"
>
<ElButton text size="small" @click="handleEdit(app)">
<Edit class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
v-if="app.status === 'draft' || app.status === 'disabled'"
:content="
app.status === 'disabled'
? $t('application.enable')
: $t('application.publish')
"
placement="top"
>
<ElButton text size="small" @click="handlePublish(app)">
<Play class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
v-if="app.status === 'published'"
:content="$t('application.disable')"
placement="top"
>
<ElButton text size="small" @click="handleDisable(app)">
<Square class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('application.delete')"
placement="top"
>
<ElButton text size="small" @click="handleDelete(app)">
<Trash2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
</div>
</div>
<!-- code -->
<div class="text-muted-foreground font-mono text-xs">
{{ app.code }}
</div>
</div>
</div>
<!-- 描述 -->
<div
v-if="app.description"
class="text-muted-foreground mb-4 line-clamp-1 text-xs"
>
{{ app.description }}
</div>
<!-- 第三行标签 + 创建时间 -->
<div class="flex items-center justify-between">
<div class="flex gap-1">
<ElTag size="small" :type="appTypeMap[app.app_type]?.color">
{{
appTypeMap[app.app_type]?.labelKey
? $t(appTypeMap[app.app_type]!.labelKey)
: app.app_type
}}
</ElTag>
<ElTag
size="small"
:type="statusMap[app.status]?.color ?? 'info'"
>
{{
statusMap[app.status]?.labelKey
? $t(statusMap[app.status]!.labelKey)
: app.status
}}
</ElTag>
</div>
<span class="text-muted-foreground text-xs">
{{ app.sys_create_datetime }}
</span>
</div>
</div>
</ElCard>
</div>
<!-- 空状态 -->
<ElEmpty v-else :description="$t('application.noApps')" />
<!-- 分页 -->
<template #footer>
<div class="flex w-full items-center justify-end">
<ElPagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
</Page>
<!-- 创建/编辑弹窗 -->
<ApplicationFormModal
v-model="showFormModal"
:application="editingApp"
@success="handleSaveSuccess"
/>
<!-- 发布对话框 -->
<ZqDialog
v-model="showPublishDialog"
:title="
currentApp?.status === 'disabled'
? $t('application.enableApp')
: $t('application.publishApp')
"
width="500px"
:confirm-text="
currentApp?.status === 'disabled'
? $t('application.confirmEnable')
: $t('application.confirmPublish')
"
@confirm="confirmPublish"
@cancel="showPublishDialog = false"
>
<div class="mx-4 space-y-4">
<div>
<p class="mb-2 text-sm">
{{
currentApp?.status === 'disabled'
? $t('application.enableConfirmMsg', {
name: currentApp?.name,
})
: $t('application.publishConfirmMsg', {
name: currentApp?.name,
})
}}
</p>
<p class="text-muted-foreground text-xs">
{{
currentApp?.status === 'disabled'
? $t('application.enableSuccessMsg')
: $t('application.publishSuccessMsg')
}}
</p>
</div>
<div class="bg-secondary flex items-center justify-between rounded p-3">
<span class="mr-2 flex-1 truncate text-sm">{{ appUrl }}</span>
<ElButton text size="small" @click="copyLink">
<Copy class="h-4 w-4" />
</ElButton>
</div>
</div>
</ZqDialog>
<!-- 停用对话框 -->
<ZqDialog
v-model="showDisableDialog"
:title="$t('application.disableApp')"
width="500px"
:confirm-text="$t('application.confirmDisable')"
@confirm="confirmDisable"
@cancel="showDisableDialog = false"
>
<div class="mx-4 space-y-4">
<div>
<p class="mb-2 text-sm">
{{
$t('application.disableConfirmMsg', { name: currentApp?.name })
}}
</p>
<p class="text-muted-foreground text-xs">
{{ $t('application.appLink') }}
</p>
</div>
<div class="bg-secondary flex items-center justify-between rounded p-3">
<span class="mr-2 flex-1 truncate text-sm">{{ appUrl }}</span>
<ElButton text size="small" @click="copyLink">
<Copy class="h-4 w-4" />
</ElButton>
</div>
</div>
</ZqDialog>
</div>
</template>
<style scoped>
.application-card :deep(.el-card__body) {
padding: 0;
}
.app-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(
135deg,
var(--el-color-primary-light-3),
var(--el-color-primary)
);
border-radius: 8px;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,345 @@
<script lang="ts" setup>
import type {
ApplicationCreateInput,
ApplicationListItem,
ApplicationUpdateInput,
AppType,
} from '#/api/core/application';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElCol,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElRow,
ElSelect,
} from 'element-plus';
import {
checkApplicationUniqueApi,
createApplicationApi,
updateApplicationApi,
} from '#/api/core/application';
import ZqDialog from '#/components/zq-dialog/zq-dialog.vue';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import ZqMenuSelector from '#/components/zq-form/zq-menu-selector/zq-menu-selector.vue';
defineOptions({ name: 'ApplicationFormModal' });
const props = defineProps<{
application?: ApplicationListItem | null;
}>();
const emit = defineEmits<{
success: [];
}>();
const visible = defineModel<boolean>({ default: false });
// Dialog 引用
const dialogRef = ref();
// 表单数据
const formData = ref<{
app_type: AppType;
code: string;
description: string;
icon: string;
name: string;
system_menu_ids: string[];
}>({
name: '',
code: '',
description: '',
icon: '',
app_type: 'mixed',
system_menu_ids: [],
});
// 表单引用
const formRef = ref();
// 是否编辑模式
const isEdit = computed(() => !!props.application);
// 弹窗标题
const title = computed(() =>
isEdit.value ? $t('application.editApp') : $t('application.createApp'),
);
// 应用类型选项
const appTypeOptions = computed<Array<{ label: string; value: AppType }>>(
() => [
{ label: $t('application.appTypes.mixed'), value: 'mixed' },
// { label: $t('application.appTypes.form'), value: 'form' },
{ label: $t('application.appTypes.workflow'), value: 'workflow' },
{ label: $t('application.appTypes.ai'), value: 'ai' },
// { label: $t('application.appTypes.dashboard'), value: 'dashboard' },
// { label: $t('application.appTypes.screen'), value: 'screen' },
],
);
// 表单验证规则
const rules = computed(() => ({
name: [
{
required: true,
message: $t('application.validation.nameRequired'),
trigger: 'blur',
},
{
min: 2,
max: 100,
message: $t('application.validation.nameLength'),
trigger: 'blur',
},
],
code: [
{
required: true,
message: $t('application.validation.codeRequired'),
trigger: 'blur',
},
{
pattern: /^[a-z][\w-]*$/i,
message: $t('application.validation.codePattern'),
trigger: 'blur',
},
{
min: 2,
max: 100,
message: $t('application.validation.codeLength'),
trigger: 'blur',
},
],
app_type: [
{
required: true,
message: $t('application.validation.typeRequired'),
trigger: 'change',
},
],
}));
// 监听弹窗打开,初始化表单数据
watch(visible, (val) => {
if (val) {
formData.value = props.application
? {
name: props.application.name,
code: props.application.code,
description: props.application.description || '',
icon: props.application.icon || '',
app_type: props.application.app_type as AppType,
system_menu_ids: (props.application as any).system_menu_ids || [],
}
: {
name: '',
code: '',
description: '',
icon: '',
app_type: 'mixed',
system_menu_ids: [],
};
}
});
// 检查编码唯一性
async function checkCodeUnique(code: string): Promise<boolean> {
try {
const res = await checkApplicationUniqueApi(
'code',
code,
props.application?.id,
);
return res.data?.unique ?? true;
} catch {
return true;
}
}
// 检查名称唯一性
async function checkNameUnique(name: string): Promise<boolean> {
try {
const res = await checkApplicationUniqueApi(
'name',
name,
props.application?.id,
);
return res.data?.unique ?? true;
} catch {
return true;
}
}
// 提交表单
async function handleConfirm() {
try {
await formRef.value?.validate();
} catch {
return;
}
dialogRef.value?.setConfirmLoading(true);
try {
// 检查编码唯一性
const codeUnique = await checkCodeUnique(formData.value.code);
if (!codeUnique) {
ElMessage.error('应用编码已存在');
dialogRef.value?.setConfirmLoading(false);
return;
}
// 检查名称唯一性
const nameUnique = await checkNameUnique(formData.value.name);
if (!nameUnique) {
ElMessage.error('应用名称已存在');
dialogRef.value?.setConfirmLoading(false);
return;
}
if (isEdit.value && props.application) {
const updateData: ApplicationUpdateInput = {
name: formData.value.name,
code: formData.value.code,
description: formData.value.description,
icon: formData.value.icon,
app_type: formData.value.app_type,
system_menu_ids: formData.value.system_menu_ids,
};
await updateApplicationApi(props.application.id, updateData);
ElMessage.success('更新成功');
} else {
const createData: ApplicationCreateInput = {
name: formData.value.name,
code: formData.value.code,
description: formData.value.description,
icon: formData.value.icon,
app_type: formData.value.app_type,
system_menu_ids: formData.value.system_menu_ids,
};
await createApplicationApi(createData);
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
dialogRef.value?.setConfirmLoading(false);
}
}
// 取消
function handleCancel() {
formRef.value?.resetFields();
visible.value = false;
}
</script>
<template>
<ZqDialog
ref="dialogRef"
v-model="visible"
:title="title"
width="640px"
:confirm-text="isEdit ? $t('application.save') : $t('application.create')"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
label-position="top"
>
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem :label="$t('application.appName')" prop="name">
<ElInput
v-model="formData.name"
:placeholder="$t('application.appNamePlaceholder')"
maxlength="100"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appCode')" prop="code">
<ElInput
v-model="formData.code"
:placeholder="$t('application.appCodePlaceholder')"
maxlength="100"
show-word-limit
:disabled="isEdit"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appType')" prop="app_type">
<ElSelect
v-model="formData.app_type"
:placeholder="$t('application.appTypePlaceholder')"
style="width: 100%"
>
<ElOption
v-for="opt in appTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem
:label="$t('application.appDescription')"
prop="description"
>
<ElInput
v-model="formData.description"
type="textarea"
:placeholder="$t('application.appDescriptionPlaceholder')"
:rows="3"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appIcon')" prop="icon">
<ZqIconPicker
v-model="formData.icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem
:label="$t('application.systemMenu')"
prop="system_menu_ids"
>
<ZqMenuSelector
v-model="formData.system_menu_ids"
:multiple="true"
:system-only="true"
:placeholder="$t('application.systemMenuPlaceholder')"
:dialog-title="$t('application.selectSystemMenu')"
dialog-width="500px"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ZqDialog>
</template>
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { useUserStore } from '@vben/stores';
import OrgChartPanel from './modules/OrgChartPanel.vue';
const userStore = useUserStore();
const currentUserId = userStore.userInfo?.id;
</script>
<template>
<div class="org-chart-page">
<div class="org-chart-content">
<OrgChartPanel :user-id="currentUserId" />
</div>
</div>
</template>
<style lang="scss" scoped>
.org-chart-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
}
.org-chart-content {
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
}
</style>
@@ -0,0 +1,344 @@
<script lang="ts" setup>
import type { OrgChartNode } from '#/api/core/org-chart';
import { onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { Expand, Network } from '@vben/icons';
import { ElEmpty, ElScrollbar, ElTooltip } from 'element-plus';
import { getOrgChartChainApi, getOrgChartTopApi } from '#/api/core/org-chart';
import { $t } from '#/locales';
import OrgNode from './OrgNode.vue';
defineOptions({
name: 'OrgChartPanel',
});
const props = withDefaults(
defineProps<{
showModeToggle?: boolean;
userId?: string;
}>(),
{ userId: undefined, showModeToggle: true },
);
const topNodes = ref<OrgChartNode[]>([]);
const loading = ref(true);
const focusMode = ref(true);
provide('orgChartFocusMode', focusMode);
// 拖拽平移
const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
let startX = 0;
let startY = 0;
let scrollLeft = 0;
let scrollTop = 0;
function getWrapEl(): HTMLElement | null {
return scrollbarRef.value?.wrapRef ?? null;
}
function onMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.node-card')) return;
const el = getWrapEl();
if (!el) return;
isDragging.value = true;
startX = e.clientX;
startY = e.clientY;
scrollLeft = el.scrollLeft;
scrollTop = el.scrollTop;
containerRef.value?.classList.add('is-dragging');
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return;
const el = getWrapEl();
if (!el) return;
el.scrollLeft = scrollLeft - (e.clientX - startX);
el.scrollTop = scrollTop - (e.clientY - startY);
}
function onMouseUp() {
if (!isDragging.value) return;
isDragging.value = false;
containerRef.value?.classList.remove('is-dragging');
}
async function loadNodes() {
loading.value = true;
try {
if (props.userId) {
const chainRoot = await getOrgChartChainApi(props.userId);
topNodes.value = [chainRoot];
} else {
topNodes.value = await getOrgChartTopApi();
}
} catch (error) {
console.error('Failed to load org chart:', error);
} finally {
loading.value = false;
}
}
watch(
() => props.userId,
() => {
loadNodes();
},
);
onMounted(() => {
loadNodes();
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
</script>
<template>
<div class="org-chart-panel">
<!-- 加载骨架屏 -->
<div v-if="loading" class="org-chart-skeleton">
<div class="skeleton-tree">
<div class="skeleton-node-wrapper">
<div class="skeleton-node skeleton-animate"></div>
</div>
<div class="skeleton-connector-down"></div>
<div class="skeleton-children">
<div v-for="i in 4" :key="i" class="skeleton-child-branch">
<div class="skeleton-connector-up"></div>
<div class="skeleton-node skeleton-animate"></div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<ElEmpty
v-else-if="topNodes.length === 0"
:description="$t('org-chart.orgChart.empty')"
/>
<!-- 组织架构树 -->
<div
v-else
ref="containerRef"
class="org-tree-container"
@mousedown="onMouseDown"
>
<!-- 模式切换按钮 -->
<div v-if="showModeToggle" class="mode-toggle">
<ElTooltip
:content="
focusMode
? $t('org-chart.orgChart.expandMode')
: $t('org-chart.orgChart.focusMode')
"
placement="left"
>
<button
class="mode-toggle-btn"
:class="{ active: focusMode }"
@click="focusMode = !focusMode"
>
<Network v-if="!focusMode" :size="16" />
<Expand v-else :size="16" />
</button>
</ElTooltip>
</div>
<ElScrollbar ref="scrollbarRef">
<div class="org-tree-scroll">
<div class="org-tree">
<div v-for="node in topNodes" :key="node.id" class="org-tree-root">
<OrgNode
:node="node"
:initial-children="(node as any).children"
/>
</div>
</div>
</div>
</ElScrollbar>
</div>
</div>
</template>
<style lang="scss" scoped>
.org-chart-panel {
width: 100%;
height: 100%;
overflow: hidden;
}
.org-chart-skeleton {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
}
.skeleton-tree {
display: flex;
flex-direction: column;
align-items: center;
}
.skeleton-node {
width: 180px;
height: 64px;
background: var(--el-fill-color-light);
border-radius: 10px;
}
.skeleton-animate {
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.skeleton-connector-down {
width: 2px;
height: 24px;
background: var(--el-fill-color);
}
.skeleton-connector-up {
width: 2px;
height: 24px;
margin: 0 auto;
background: var(--el-fill-color);
}
.skeleton-children {
display: flex;
gap: 0;
}
.skeleton-child-branch {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 16px;
&::before {
content: '';
position: absolute;
top: 0;
right: 50%;
left: 0;
height: 2px;
background: var(--el-fill-color);
}
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 50%;
height: 2px;
background: var(--el-fill-color);
}
&:first-child::before {
display: none;
}
&:last-child::after {
display: none;
}
}
@keyframes skeleton-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.4;
}
100% {
opacity: 1;
}
}
.org-tree-container {
position: relative;
width: 100%;
height: 100%;
cursor: grab;
user-select: none;
&.is-dragging {
cursor: grabbing;
}
:deep(.el-scrollbar__wrap) {
overflow: auto;
}
}
.mode-toggle {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
}
.mode-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--el-text-color-secondary);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-3);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
&.active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
}
.org-tree-scroll {
display: inline-flex;
min-width: 100%;
min-height: 100%;
justify-content: center;
padding: 40px;
}
.org-tree {
display: flex;
flex-direction: column;
gap: 0;
align-items: center;
}
.org-tree-root {
display: flex;
justify-content: center;
}
</style>
@@ -0,0 +1,496 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { OrgChartNode } from '#/api/core/org-chart';
import { computed, inject, onMounted, ref } from 'vue';
import { ElTag } from 'element-plus';
import { getOrgChartChildrenApi } from '#/api/core/org-chart';
import { UserAvatar } from '#/components/user-avatar';
defineOptions({
name: 'OrgNode',
});
const props = withDefaults(
defineProps<{
autoExpand?: boolean;
initialChildren?: OrgChartNode[];
node: OrgChartNode;
}>(),
{ autoExpand: false, initialChildren: undefined },
);
const emit = defineEmits<{
(e: 'node-click', id: string): void;
}>();
const focusMode = inject<Ref<boolean>>('orgChartFocusMode', ref(false));
const expanded = ref(false);
const children = ref<OrgChartNode[]>([]);
const loading = ref(false);
const loaded = ref(false);
const partialLoaded = ref(false);
const childrenKey = ref(0);
const focusedChildId = ref<null | string>(null);
const visibleChildren = computed(() => {
if (focusMode.value && focusedChildId.value) {
return children.value.filter((c) => c.id === focusedChildId.value);
}
return children.value;
});
async function loadChildren() {
if (loaded.value && !partialLoaded.value) return;
loading.value = true;
try {
children.value = await getOrgChartChildrenApi(props.node.id);
loaded.value = true;
partialLoaded.value = false;
} catch (error) {
console.error('Failed to load children:', error);
} finally {
loading.value = false;
}
}
function onChildClick(childId: string) {
if (!focusMode.value) return;
focusedChildId.value = childId;
}
async function toggleExpand() {
if (props.node.subordinate_count === 0) return;
emit('node-click', props.node.id);
await loadChildren();
if (focusMode.value) {
// 聚焦模式:重置聚焦状态,显示所有直接下属,子树收起
focusedChildId.value = null;
childrenKey.value++;
expanded.value = true;
} else {
expanded.value = !expanded.value;
}
}
onMounted(async () => {
if (props.initialChildren && props.initialChildren.length > 0) {
children.value = props.initialChildren;
loaded.value = true;
partialLoaded.value = true;
expanded.value = true;
// 预加载的汇报链只有一个子节点,直接聚焦,不显示框
if (props.initialChildren.length === 1) {
focusedChildId.value = props.initialChildren[0]!.id;
}
} else if (props.autoExpand && props.node.subordinate_count > 0) {
await loadChildren();
expanded.value = true;
}
});
</script>
<template>
<div class="org-node-wrapper">
<!-- 当前节点 -->
<div class="org-node" @click="toggleExpand">
<div
class="node-card"
:class="{
'has-children': node.subordinate_count > 0,
'is-expanded': expanded,
'is-loading': loading,
}"
>
<UserAvatar
:user-id="node.id"
:name="node.name"
:avatar="node.avatar"
:size="48"
:font-size="20"
:shadow="false"
:show-popover="true"
:auto-load="false"
/>
<div class="node-info">
<div class="node-name">{{ node.name || node.username }}</div>
<div v-if="node.post_name" class="node-post">
{{ node.post_name }}
</div>
<div v-if="node.dept_name" class="node-dept">
{{ node.dept_name }}
</div>
</div>
<ElTag
v-if="node.subordinate_count > 0"
size="small"
round
:type="expanded ? 'primary' : 'info'"
class="node-count"
>
{{ node.subordinate_count }}
</ElTag>
</div>
</div>
<!-- 子节点连接线 + 子节点列表 -->
<template v-if="expanded && visibleChildren.length > 0">
<!-- 聚焦模式 -->
<div v-if="focusMode" class="org-children-wrapper">
<div class="connector-down"></div>
<div
class="focus-children-container"
:class="{ 'has-box': !focusedChildId }"
>
<div
v-for="child in visibleChildren"
:key="`${child.id}-${childrenKey}`"
class="focus-child-item"
>
<OrgNode
:node="child"
:initial-children="(child as any).children"
@node-click="onChildClick"
/>
</div>
</div>
</div>
<!-- 展开模式每个子节点单独连线 -->
<div v-else class="org-children-wrapper">
<div class="connector-down"></div>
<div class="connector-horizontal">
<div class="connector-line"></div>
</div>
<div class="org-children">
<div
v-for="child in visibleChildren"
:key="`${child.id}-${childrenKey}`"
class="org-child-branch"
>
<div class="connector-up"></div>
<OrgNode
:node="child"
:initial-children="(child as any).children"
@node-click="onChildClick"
/>
</div>
</div>
</div>
</template>
<!-- 加载中 -->
<div v-if="loading" class="org-loading">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.org-node-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.org-node {
display: flex;
justify-content: center;
}
.node-card {
display: flex;
gap: 10px;
align-items: center;
width: 220px;
padding: 12px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
cursor: default;
transition: all 0.25s ease;
&.has-children {
cursor: pointer;
&:hover {
border-color: var(--el-color-primary-light-3);
box-shadow: 0 4px 12px rgb(0 0 0 / 8%);
transform: translateY(-1px);
}
}
&.is-expanded {
border-color: var(--el-color-primary-light-5);
background: var(--el-color-primary-light-9);
}
&.is-loading {
opacity: 0.7;
}
}
.node-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.node-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.node-post {
font-size: 12px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
.node-dept {
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.node-count {
flex-shrink: 0;
margin-left: 4px;
}
// 连接线样式
.org-children-wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.connector-down {
width: 2px;
height: 24px;
background: var(--el-border-color);
}
.connector-horizontal {
position: relative;
width: 100%;
}
.connector-line {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 2px;
margin: 0 auto;
background: var(--el-border-color);
}
.org-children {
display: flex;
gap: 0;
justify-content: center;
}
.org-child-branch {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 16px;
}
.connector-up {
width: 2px;
height: 24px;
background: var(--el-border-color);
}
// 动态计算横线宽度
.org-children {
position: relative;
// 横线只覆盖第一个到最后一个子节点中心之间
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 0;
}
}
// 重新实现连接线:用子节点的 ::before 画
.connector-horizontal {
display: none;
}
.org-children-wrapper {
.org-children {
position: relative;
// 横线
&::before {
content: '';
position: absolute;
top: 0;
height: 2px;
background: var(--el-border-color);
}
}
// 单个子节点不需要横线
.org-children:has(.org-child-branch:only-child)::before {
display: none;
}
// 多个子节点:横线从第一个到最后一个中心
.org-children:has(.org-child-branch:nth-child(2))::before {
left: calc(50% / var(--child-count));
right: calc(50% / var(--child-count));
}
}
// 使用更简单的方式:每个子节点分支顶部都有竖线,横线用 border 实现
.org-children {
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
right: 50%;
height: 2px;
background: var(--el-border-color);
}
.org-child-branch {
&:first-child ~ .org-child-branch {
// 有兄弟节点时
}
}
}
// 最终方案:用 JS 计算的方式太复杂,改用简单的 border 方案
// 每个子节点分支用 border-top 连接
.org-children {
&::before {
display: none;
}
}
.org-child-branch {
position: relative;
// 左半横线
&::before {
content: '';
position: absolute;
top: 0;
right: 50%;
left: 0;
height: 2px;
background: var(--el-border-color);
}
// 右半横线
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
left: 50%;
height: 2px;
background: var(--el-border-color);
}
// 第一个子节点:只有右半横线
&:first-child::before {
display: none;
}
// 最后一个子节点:只有左半横线
&:last-child::after {
display: none;
}
// 唯一子节点:不需要横线
&:only-child::before,
&:only-child::after {
display: none;
}
}
// 聚焦模式容器
.focus-children-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
max-width: calc(232px * 4 + 36px);
transition: all 0.25s ease;
&.has-box {
padding: 16px;
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
}
}
.focus-child-item {
display: flex;
align-items: flex-start;
justify-content: center;
}
// 加载动画
.org-loading {
padding: 12px 0;
}
.loading-dots {
display: flex;
gap: 6px;
justify-content: center;
span {
width: 6px;
height: 6px;
background: var(--el-color-primary);
border-radius: 50%;
animation: dot-bounce 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
@keyframes dot-bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
</style>