feat: restore core admin modules and harden ai model fallback
This commit is contained in:
@@ -16,7 +16,7 @@ from app.base_schema import PaginatedResponse, ResponseModel
|
||||
from utils.context import get_current_user_id_from_context
|
||||
from ai_platform.models import (
|
||||
Agent, AgentConversation, AgentMessage,
|
||||
LLMModel, AIWorkflow,
|
||||
LLMModel, LLMProvider, AIWorkflow,
|
||||
)
|
||||
from core.application.model import Application
|
||||
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]:
|
||||
result = await db.execute(
|
||||
select(LLMModel)
|
||||
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
|
||||
.where(
|
||||
LLMModel.is_deleted == False,
|
||||
LLMModel.is_active == True,
|
||||
LLMModel.model_type == "chat",
|
||||
LLMProvider.is_deleted == False,
|
||||
LLMProvider.is_active == True,
|
||||
)
|
||||
.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 ai_platform.models import (
|
||||
Agent, AgentConversation, AgentMessage, AIWorkflow, LLMModel,
|
||||
Agent, AgentConversation, AgentMessage, AIWorkflow, LLMModel, LLMProvider,
|
||||
)
|
||||
from .llm_service import LLMService
|
||||
|
||||
@@ -212,10 +212,14 @@ class AgentService:
|
||||
|
||||
if agent.model_id:
|
||||
result = await self._db.execute(
|
||||
select(LLMModel).where(
|
||||
select(LLMModel)
|
||||
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
|
||||
.where(
|
||||
LLMModel.id == agent.model_id,
|
||||
LLMModel.is_deleted == False,
|
||||
LLMModel.is_active == True,
|
||||
LLMProvider.is_deleted == False,
|
||||
LLMProvider.is_active == True,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
@@ -223,10 +227,13 @@ class AgentService:
|
||||
|
||||
result = await self._db.execute(
|
||||
select(LLMModel)
|
||||
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
|
||||
.where(
|
||||
LLMModel.is_deleted == False,
|
||||
LLMModel.is_active == True,
|
||||
LLMModel.model_type == "chat",
|
||||
LLMProvider.is_deleted == False,
|
||||
LLMProvider.is_active == True,
|
||||
)
|
||||
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
|
||||
)
|
||||
|
||||
@@ -44,14 +44,17 @@ class LLMService:
|
||||
if not self._db:
|
||||
raise ValueError("未找到可用的 chat 模型,请先在模型配置中启用一个模型")
|
||||
|
||||
from ai_platform.models import LLMModel
|
||||
from ai_platform.models import LLMModel, LLMProvider
|
||||
|
||||
result = await self._db.execute(
|
||||
select(LLMModel)
|
||||
.join(LLMProvider, LLMProvider.id == LLMModel.provider_id)
|
||||
.where(
|
||||
LLMModel.is_deleted == False,
|
||||
LLMModel.is_active == True,
|
||||
LLMModel.model_type == "chat",
|
||||
LLMProvider.is_deleted == False,
|
||||
LLMProvider.is_active == True,
|
||||
)
|
||||
.order_by(LLMModel.sort.desc(), LLMModel.sys_create_datetime.desc())
|
||||
)
|
||||
|
||||
@@ -17,13 +17,14 @@ from core.menu.schema import MenuCreate, MenuUpdate
|
||||
menu_cache = CacheManager(prefix="menu:")
|
||||
|
||||
# 缓存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}"
|
||||
USER_ROUTE_CACHE_PREFIX = f"user_route:ai_agent_admin:{AI_AGENT_ADMIN_MENU_CACHE_VERSION}:"
|
||||
|
||||
AI_AGENT_ADMIN_MENU_NAMES = {
|
||||
"AccountSettings",
|
||||
"ControlCenter",
|
||||
"Application",
|
||||
"AIPlatform",
|
||||
"AIAgent",
|
||||
"AIModelConfig",
|
||||
@@ -39,6 +40,10 @@ AI_AGENT_ADMIN_MENU_NAMES = {
|
||||
"SystemPost",
|
||||
"SystemDict",
|
||||
"SystemFileManager",
|
||||
"LoginLog",
|
||||
"OrgNode",
|
||||
"SystemLoginLog",
|
||||
"SystemOrgChart",
|
||||
"SystemRole",
|
||||
"SystemConfigManager",
|
||||
"UIConfigManager",
|
||||
@@ -50,6 +55,7 @@ AI_AGENT_ADMIN_MENU_NAMES = {
|
||||
|
||||
AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
|
||||
"ControlCenter",
|
||||
"Application",
|
||||
"AIPlatform",
|
||||
"AIAgent",
|
||||
"AIModelConfig",
|
||||
@@ -65,6 +71,10 @@ AI_AGENT_ADMIN_VISIBLE_MENU_NAMES = {
|
||||
"SystemPost",
|
||||
"SystemDict",
|
||||
"SystemFileManager",
|
||||
"LoginLog",
|
||||
"OrgNode",
|
||||
"SystemLoginLog",
|
||||
"SystemOrgChart",
|
||||
"SystemRole",
|
||||
"SystemConfigManager",
|
||||
"UIConfigManager",
|
||||
@@ -82,10 +92,29 @@ AI_AGENT_ADMIN_SYSTEM_CHILD_MENU_NAMES = {
|
||||
"SystemPost",
|
||||
"SystemDict",
|
||||
"SystemFileManager",
|
||||
"LoginLog",
|
||||
"OrgNode",
|
||||
"SystemLoginLog",
|
||||
"SystemOrgChart",
|
||||
"SystemRole",
|
||||
"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 = {
|
||||
"SystemConfigManager": {
|
||||
"component": "/_core/system-config/index",
|
||||
@@ -100,6 +129,16 @@ AI_AGENT_ADMIN_MENU_OVERRIDES = {
|
||||
"component": "/_core/file-manager/index",
|
||||
"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": {
|
||||
"component": "/_core/ui-config/index",
|
||||
"path": "/system/ui-config",
|
||||
@@ -131,18 +170,32 @@ def _normalize_ai_agent_admin_menu(menu: 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}
|
||||
system_menu = next(
|
||||
(menu for menu in menus if menu.name == "SystemManagement"),
|
||||
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:
|
||||
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:
|
||||
menu.parent_id = system_menu.id
|
||||
else:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from core.chat.api import router as chat_router
|
||||
from core.dept.api import router as dept_router
|
||||
from core.device.api import router as device_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.message.api import announcement_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(user_router)
|
||||
router.include_router(file_manager_router)
|
||||
router.include_router(login_log_router)
|
||||
router.include_router(message_router)
|
||||
router.include_router(announcement_router)
|
||||
router.include_router(oauth_router)
|
||||
|
||||
@@ -29,17 +29,7 @@ const baseModules = import.meta.glob([
|
||||
'./langs/zh-CN/ui.json',
|
||||
]);
|
||||
|
||||
const businessModules = import.meta.glob([
|
||||
'./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 businessModules = import.meta.glob('./langs/zh-CN/*.json');
|
||||
|
||||
const baseLocalesMap = loadLocalesMapFromDir(
|
||||
/\.\/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": "服务器监控",
|
||||
"uiConfig": "界面配置",
|
||||
"loginLog": "登录日志",
|
||||
"orgChart": "组织架构",
|
||||
"roleManagement": "角色权限",
|
||||
"permissionManagement": "API管理",
|
||||
"menuManagement": "菜单管理",
|
||||
|
||||
@@ -175,14 +175,17 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
'../views/_core/agent-chat/**/*.vue',
|
||||
'../views/_core/announcement/index.vue',
|
||||
'../views/_core/announcement/list.vue',
|
||||
'../views/_core/application/index.vue',
|
||||
'../views/_core/authentication/login.vue',
|
||||
'../views/_core/dept/index.vue',
|
||||
'../views/_core/dict/index.vue',
|
||||
'../views/_core/fallback/**/*.vue',
|
||||
'../views/_core/file-manager/index.vue',
|
||||
'../views/_core/file-preview/index.vue',
|
||||
'../views/_core/login-log/index.vue',
|
||||
'../views/_core/menu/index.vue',
|
||||
'../views/_core/message/index.vue',
|
||||
'../views/_core/org-chart/index.vue',
|
||||
'../views/_core/page-render/index.vue',
|
||||
'../views/_core/permission/index.vue',
|
||||
'../views/_core/post/index.vue',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const LIGHT_MENU_NAMES = new Set([
|
||||
'AccountSettings',
|
||||
'Application',
|
||||
'ControlCenter',
|
||||
'AIAgent',
|
||||
'AIKnowledgeDetail',
|
||||
@@ -14,10 +15,14 @@ export const LIGHT_MENU_NAMES = new Set([
|
||||
'MessageCenter',
|
||||
'MessageList',
|
||||
'KnowledgeBase',
|
||||
'LoginLog',
|
||||
'OrgNode',
|
||||
'SystemDept',
|
||||
'SystemDict',
|
||||
'SystemFileManager',
|
||||
'SystemLoginLog',
|
||||
'SystemManagement',
|
||||
'SystemOrgChart',
|
||||
'SystemConfig',
|
||||
'SystemConfigManager',
|
||||
'SystemMenu',
|
||||
@@ -38,6 +43,7 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
|
||||
'/ai-platform/workflow',
|
||||
'/ai-platform/workflow-runs',
|
||||
'/agent-chat',
|
||||
'/application',
|
||||
'/message/list',
|
||||
'/message/announcement',
|
||||
'/message/announcement-list',
|
||||
@@ -47,6 +53,8 @@ const LIGHT_ROUTE_PATH_PREFIXES = [
|
||||
'/system/dept',
|
||||
'/system/dict',
|
||||
'/system/file-manager',
|
||||
'/system/login-log',
|
||||
'/system/org-chart',
|
||||
'/system/post',
|
||||
'/system/config',
|
||||
'/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>
|
||||
Reference in New Issue
Block a user