重写复现方法
@@ -1,5 +0,0 @@
|
||||
# 接口前缀
|
||||
VITE_BASE_API = '/cApi'
|
||||
|
||||
# 后端真实地址
|
||||
VITE_SERVER_URL = 'http://127.0.0.1:31001'
|
||||
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cApi系统管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3671
capi-ui/package-lock.json
generated
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "vue3_cli_default",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@ant-design/plots": "^2.6.3",
|
||||
"@iconify-icons/mdi": "^1.2.48",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.11.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"echarts": "^6.0.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.2.8",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/carbon": "^1.2.13",
|
||||
"@vitejs/plugin-vue": "^1.6.0",
|
||||
"@vue/compiler-sfc": "^3.2.6",
|
||||
"sass": "^1.91.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"vite": "^2.5.2"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,4 +0,0 @@
|
||||
<template>
|
||||
<!-- 所有页面都会在这里渲染 -->
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface LoginParams {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
}
|
||||
|
||||
/** 登录接口 */
|
||||
export const apiLogin = (data: LoginParams) =>
|
||||
request.post<LoginResult>('/Sys/login/userLogin', data)
|
||||
|
||||
/** 退出登录 */
|
||||
export const apiLogout = () => request.post('/Sys/login/userLogout')
|
||||
@@ -1,52 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface DbItem {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface dbResponse {
|
||||
code: number
|
||||
message: string
|
||||
result: DbItem[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface SyncTables {
|
||||
createTime: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
sourceDbId: string;
|
||||
sourceTable: string;
|
||||
targetTable: string;
|
||||
isActive: string;
|
||||
lastSyncTime: string;
|
||||
updateTime: string;
|
||||
dbType: string;
|
||||
dbId: string;
|
||||
successRows: number;
|
||||
}
|
||||
|
||||
|
||||
/** 获取数据库下拉列表 */
|
||||
export const getDbList = async (): Promise<DbItem[]> => {
|
||||
const response = await request.get<dbResponse>('/Sys/data/getDbList')
|
||||
|
||||
if (response.code !== 200) {
|
||||
console.error('获取数据失败:', response.message)
|
||||
throw new Error(`获取数据失败: ${response.message}`)
|
||||
}
|
||||
return response.result || []
|
||||
}
|
||||
|
||||
|
||||
export const getSyncTables = async (data: SyncTables): Promise<SyncTables[]> =>{
|
||||
const response = await request.get<SyncTables>('/Sys/data/getTableList', { params: data })
|
||||
if (response.code !== 200) {
|
||||
console.error('获取数据失败:', response.message)
|
||||
throw new Error(`获取数据失败: ${response.message}`)
|
||||
}
|
||||
return response.result || []
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 菜单模块接口定义
|
||||
*/
|
||||
export interface MenuItem {
|
||||
menuId: string
|
||||
menuName: string
|
||||
menuCode: string
|
||||
moduleCode: string
|
||||
cicon: string
|
||||
chref: string
|
||||
ftenantId: string
|
||||
fflowId: string | null
|
||||
fflowTaskId: string | null
|
||||
fflowState: string | null
|
||||
}
|
||||
|
||||
export interface ModuleItem {
|
||||
moduleName: string
|
||||
moduleCode: string
|
||||
icon: string
|
||||
href: string
|
||||
menus: MenuItem[]
|
||||
}
|
||||
|
||||
export interface MenuResponse {
|
||||
code: number
|
||||
message: string
|
||||
result: ModuleItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块菜单数据
|
||||
*/
|
||||
export const getModuleMenus = async (): Promise<ModuleItem[]> => {
|
||||
const response = await request.get<MenuResponse>('/Sys/login/getModules')
|
||||
|
||||
if (response.code !== 200) {
|
||||
console.error('获取菜单数据失败:', response.message)
|
||||
throw new Error(`获取菜单失败: ${response.message}`)
|
||||
}
|
||||
|
||||
return response.result || []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,670 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<a-layout style="min-height: 100vh;">
|
||||
<!-- 顶部导航栏 -->
|
||||
<a-layout-header class="header">
|
||||
<div class="logo">
|
||||
<img src="/my.png" class="logo-img" alt="系统Logo" />
|
||||
<span class="system-name">cApi</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<SearchOutlined class="search-icon" />
|
||||
<input type="text" placeholder="搜索" class="search-input" />
|
||||
</div>
|
||||
<div class="action-icons">
|
||||
<a-tooltip title="收藏">
|
||||
<StarOutlined class="action-icon" @click="showFavorite = true" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="通知">
|
||||
<BellOutlined class="action-icon" @click="showNotification = true" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="帮助">
|
||||
<QuestionCircleOutlined class="action-icon" @click="showHelp = true" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="设置">
|
||||
<SettingOutlined class="action-icon" @click="showSettings = true" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<a-dropdown
|
||||
placement="bottomRight"
|
||||
:auto-adjust-overflow="true"
|
||||
@visible-change="handleDropdownVisible"
|
||||
>
|
||||
<div class="user-avatar">
|
||||
<img src="https://picsum.photos/id/1005/200/200" alt="用户头像" class="avatar-img" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu class="user-menu" @click="handleUserMenuClick">
|
||||
<a-menu-item key=""> <img src="https://picsum.photos/id/1005/200/200" alt="用户头像" class="avatar-img" />{{userName}}</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined class="mr-2" /> 个人资料
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings">
|
||||
<ToolFilled class="mr-2" /> 修改密码
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" class="logout-menu-item">
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<LogoutOutlined class="mr-2" /> 退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 中间主体布局 -->
|
||||
<a-layout style="flex: 1; height: calc(100vh - 64px);">
|
||||
<!-- 左侧菜单 -->
|
||||
<a-layout-sider
|
||||
class="side-menu-container"
|
||||
width="200"
|
||||
:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
:class="{ 'sider-collapsed': collapsed }"
|
||||
>
|
||||
<div v-if="loading" class="menu-loading"><a-spin size="large" /></div>
|
||||
<div v-else class="menu-scroll-container">
|
||||
<div
|
||||
v-for="(module, moduleKey) in moduleMenusConfig"
|
||||
:key="module.moduleCode"
|
||||
class="parent-menu-item"
|
||||
>
|
||||
<!-- 折叠状态:悬浮子菜单 -->
|
||||
<a-dropdown
|
||||
v-if="collapsed"
|
||||
placement="right"
|
||||
:auto-adjust-overflow="true"
|
||||
:open="collapsedMenuVisible[module.moduleCode]"
|
||||
@openChange="(visible) => handleCollapsedMenuVisible(visible, module.moduleCode)"
|
||||
>
|
||||
<div class="parent-menu-header collapsed-mode" @click.stop>
|
||||
<!-- 动态图标:通过封装的图标库获取 -->
|
||||
<component :is="getIconComponent(module.icon)" class="parent-menu-icon" />
|
||||
<a-tooltip :title="module.moduleName" placement="right">
|
||||
<span class="collapsed-tooltip-placeholder" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu
|
||||
class="collapsed-sub-menu"
|
||||
@click="(e) => handleCollapsedSubMenuClick(e, module.menus)"
|
||||
>
|
||||
<a-menu-item
|
||||
v-for="menu in module.menus"
|
||||
:key="menu.menuCode"
|
||||
:selected="selectedKey === menu.menuCode"
|
||||
:disabled="!menu.chref"
|
||||
>
|
||||
<component :is="getIconComponent(menu.cicon)" class="sub-menu-icon" />
|
||||
<span>{{ menu.menuName }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 展开状态:点击展开子菜单 -->
|
||||
<div v-else>
|
||||
<div
|
||||
class="parent-menu-header"
|
||||
@click="toggleSubMenu(module.moduleCode)"
|
||||
>
|
||||
<component :is="getIconComponent(module.icon)" class="parent-menu-icon" />
|
||||
<span class="parent-menu-title">{{ module.moduleName }}</span>
|
||||
<!-- 动态图标:直接使用全局注册的图标(无需映射) -->
|
||||
<component
|
||||
:is="isSubMenuOpen(module.moduleCode) ? 'CaretUpOutlined' : 'CaretDownOutlined'"
|
||||
class="parent-menu-caret"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="sub-menu-list"
|
||||
:class="{ 'sub-menu-visible': isSubMenuOpen(module.moduleCode) }"
|
||||
>
|
||||
<div
|
||||
v-for="menu in module.menus"
|
||||
:key="menu.menuCode"
|
||||
class="sub-menu-item"
|
||||
:class="{
|
||||
'sub-menu-item-active': selectedKey === menu.menuCode,
|
||||
'sub-menu-item-disabled': !menu.chref
|
||||
}"
|
||||
@click="handleSubMenuItemClick(menu)"
|
||||
:style="{ cursor: menu.chref ? 'pointer' : 'not-allowed' }"
|
||||
>
|
||||
<component :is="getIconComponent(menu.cicon)" class="sub-menu-icon" />
|
||||
<span>{{ menu.menuName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-trigger" @click="collapsed = !collapsed">
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<component :is="collapsed ? 'MenuUnfoldOutlined' : 'MenuFoldOutlined'" />
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<a-layout-content class="right-content">
|
||||
<!-- 标签页 -->
|
||||
<div class="tab-bar">
|
||||
<a-tag
|
||||
:class="['console-tab', $route.path === '/index/console' ? 'console-tab--active' : '']"
|
||||
@click="switchToConsole"
|
||||
>
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<HomeOutlined class="console-tab-icon" />
|
||||
<span>控制台</span>
|
||||
</a-tag>
|
||||
<a-button
|
||||
size="small"
|
||||
class="tab-nav-btn"
|
||||
@click="switchTab('prev')"
|
||||
:disabled="currentTabIndex === 0 || otherTabs.length === 0"
|
||||
ghost
|
||||
>
|
||||
<
|
||||
</a-button>
|
||||
<a-tabs
|
||||
v-model:activeKey="activeTabKey"
|
||||
@change="handleTabChange"
|
||||
class="google-tabs"
|
||||
:animated="false"
|
||||
hide-add
|
||||
>
|
||||
<a-tab-pane
|
||||
v-for="tab in otherTabs"
|
||||
:key="tab.key"
|
||||
:tab="renderTabTitle(tab)"
|
||||
/>
|
||||
</a-tabs>
|
||||
<a-button
|
||||
size="small"
|
||||
class="tab-nav-btn"
|
||||
@click="switchTab('next')"
|
||||
:disabled="currentTabIndex === otherTabs.length - 1 || otherTabs.length === 0"
|
||||
ghost
|
||||
>
|
||||
>
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
class="close-all-btn"
|
||||
@click="closeAllTabs"
|
||||
:disabled="otherTabs.length === 0"
|
||||
ghost
|
||||
>
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<CloseOutlined class="close-icon" /> 关闭所有
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区核心:Suspense + RouterView -->
|
||||
<a-layout-content class="main-content">
|
||||
<div class="home-content">
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<RouterView @error="handleRouterViewError" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="content-loading">
|
||||
<a-spin size="large" tip="页面加载中..." />
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
|
||||
<div v-if="componentError" class="component-error">
|
||||
<!-- 直接使用全局注册的图标 -->
|
||||
<ExclamationCircleOutlined class="error-icon" />
|
||||
<p class="error-title">组件加载失败</p>
|
||||
<p class="error-desc">请检查路由配置或页面文件是否存在</p>
|
||||
<p class="error-path">失败路由:{{ errorPath }}</p>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="middle"
|
||||
class="retry-btn"
|
||||
@click="reloadCurrentRoute"
|
||||
>
|
||||
重新加载
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
|
||||
<!-- 退出确认对话框 -->
|
||||
<a-modal
|
||||
v-model:open="logoutVisible"
|
||||
:centered="true"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
:width="420"
|
||||
wrap-class-name="logout-modal-wrapper"
|
||||
>
|
||||
<div class="logout-body">
|
||||
<p class="logout-title">
|
||||
<ExclamationCircleOutlined style="color:#faad14; margin-right:8px;" />
|
||||
温馨提示
|
||||
</p>
|
||||
<p class="logout-desc">是否确认退出系统?</p>
|
||||
<div class="logout-actions">
|
||||
<a-button size="large" @click="logoutVisible = false">取消</a-button>
|
||||
<a-button type="primary" size="large" style="margin-left: 16px" @click="doLogout">确定</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
|
||||
<!-- 放在根节点最下方即可 -->
|
||||
<Favorite v-if="showFavorite" :visible="showFavorite" @close="showFavorite = false" />
|
||||
<Notification v-if="showNotification" :visible="showNotification" @close="showNotification = false" />
|
||||
<Help v-if="showHelp" :visible="showHelp" @close="showHelp = false" />
|
||||
<Settings v-if="showSettings" :visible="showSettings" @close="showSettings = false" />
|
||||
|
||||
<!-- 个人资料 -->
|
||||
<UserProfile v-if="showUserProfile" :visible="showUserProfile" @close="showUserProfile = false" />
|
||||
<!-- 修改密码 -->
|
||||
<ChangePwd v-if="showChangePwd" :visible="showChangePwd" @close="showChangePwd = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, reactive, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter, useRoute, RouterView, RouteLocationNormalized } from 'vue-router';
|
||||
import { Layout, Menu, Button, Tabs, Tag, Dropdown, Modal, Tooltip, Spin, message } from 'ant-design-vue';
|
||||
import { getModuleMenus, ModuleItem, MenuItem } from '@/api/menu';
|
||||
import { getUserName } from '@/utils/user';
|
||||
import { apiLogout } from '@/api/auth'
|
||||
|
||||
// 关键修改1:导入封装的图标库(全局注册的基础,动态图标从这里获取)
|
||||
import icons from '@/icons';
|
||||
import type { AntdIcon } from '@/icons'; // 导入图标类型,确保TypeScript提示
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const userName = computed(() => getUserName());
|
||||
|
||||
// 登录态校验守卫
|
||||
const removeAuthGuard = router.beforeEach((to, from, next) => {
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
// 未登录且访问非登录页时跳转登录
|
||||
if (to.path !== '/login' && !userInfo) {
|
||||
next('/login');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const collapsed = ref(false);
|
||||
const logoutVisible = ref(false);
|
||||
const selectedKey = ref('');
|
||||
const expandedParentMenus = ref<string[]>([]);
|
||||
const loading = ref(true);
|
||||
const componentError = ref(false);
|
||||
const errorPath = ref('');
|
||||
const isNavigating = ref(false);
|
||||
|
||||
const showFavorite = ref(false)
|
||||
const showNotification = ref(false)
|
||||
const showHelp = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showUserProfile = ref(false);
|
||||
const showChangePwd = ref(false);
|
||||
|
||||
// 菜单配置
|
||||
const moduleMenusConfig = ref<ModuleItem[]>([]);
|
||||
const collapsedMenuVisible = reactive<Record<string, boolean>>({});
|
||||
|
||||
// 标签页数据结构(初始路径为/index/console)
|
||||
interface TabItem {
|
||||
key: string;
|
||||
title: string;
|
||||
path: string; // 统一为/index/xxx格式
|
||||
closable: boolean;
|
||||
}
|
||||
const allTabs = ref<TabItem[]>([
|
||||
{ key: 'console', title: '控制台', path: '/index/console', closable: false }
|
||||
]);
|
||||
|
||||
// 计算属性
|
||||
const otherTabs = computed(() => allTabs.value.filter(tab => tab.key !== 'console'));
|
||||
const activeTabKey = computed({
|
||||
get() {
|
||||
const matchedTab = allTabs.value.find(tab => tab.path === route.path);
|
||||
return matchedTab ? matchedTab.key : 'console';
|
||||
},
|
||||
set(key) {
|
||||
const matchedTab = allTabs.value.find(tab => tab.key === key);
|
||||
if (matchedTab && !isNavigating.value) {
|
||||
navigateToPath(matchedTab.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
const currentTabIndex = computed(() => {
|
||||
return otherTabs.value.findIndex(tab => tab.path === route.path);
|
||||
});
|
||||
|
||||
// 菜单数据加载(标准化chref为/index/xxx)
|
||||
const fetchMenuData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const modules = await getModuleMenus();
|
||||
// 标准化菜单路径:确保为/index/xxx格式
|
||||
const normalizedModules = modules.map(module => ({
|
||||
...module,
|
||||
menus: module.menus.map(menu => ({
|
||||
...menu,
|
||||
chref: menu.chref
|
||||
? menu.chref.startsWith('/index')
|
||||
? menu.chref
|
||||
: `/index${menu.chref.startsWith('/') ? menu.chref : `/${menu.chref}`}`
|
||||
: ''
|
||||
}))
|
||||
}));
|
||||
moduleMenusConfig.value = normalizedModules;
|
||||
normalizedModules.forEach(module => collapsedMenuVisible[module.moduleCode] = false);
|
||||
syncMenuStateWithRoute(route);
|
||||
} catch (error) {
|
||||
console.error('菜单接口请求失败:', error);
|
||||
Modal.error({ title: '菜单加载失败', content: '请刷新页面重试' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 路由跳转封装
|
||||
const navigateToPath = async (path: string) => {
|
||||
if (isNavigating.value || path === route.path) return;
|
||||
try {
|
||||
isNavigating.value = true;
|
||||
await router.push(path);
|
||||
} catch (err) {
|
||||
console.error('路由跳转失败:', err);
|
||||
Modal.error({ title: '页面跳转失败', content: `目标路径不存在:${path}` });
|
||||
} finally {
|
||||
isNavigating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 同步菜单与路由状态
|
||||
const syncMenuStateWithRoute = (currentRoute: RouteLocationNormalized) => {
|
||||
// 匹配控制台路径/index/console
|
||||
if (currentRoute.path === '/index/console') {
|
||||
selectedKey.value = '';
|
||||
expandedParentMenus.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 匹配其他菜单路由
|
||||
let matched = false;
|
||||
for (const module of moduleMenusConfig.value) {
|
||||
const menu = module.menus.find(item => item.chref === currentRoute.path);
|
||||
if (menu) {
|
||||
selectedKey.value = menu.menuCode;
|
||||
expandedParentMenus.value = [module.moduleCode];
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 未匹配时跳回控制台
|
||||
if (!matched && currentRoute.path !== '/index/console') {
|
||||
navigateToPath('/index/console');
|
||||
}
|
||||
};
|
||||
|
||||
// 关键修改2:重写getIconComponent,从封装的图标库获取图标(不再依赖局部导入)
|
||||
const getIconComponent = (iconName: string) => {
|
||||
// 图标名称格式校验(确保是有效的Antd图标名称,如"ApiOutlined")
|
||||
const validIconName = iconName as AntdIcon;
|
||||
// 从封装的图标库中获取,不存在则返回默认图标(ApiOutlined)
|
||||
return icons[validIconName] || icons.ApiOutlined;
|
||||
};
|
||||
|
||||
// 检查父菜单是否展开
|
||||
const isSubMenuOpen = (moduleCode: string) => {
|
||||
return expandedParentMenus.value.includes(moduleCode);
|
||||
};
|
||||
|
||||
// 菜单交互
|
||||
const toggleSubMenu = (moduleCode: string) => {
|
||||
expandedParentMenus.value = isSubMenuOpen(moduleCode) ? [] : [moduleCode];
|
||||
};
|
||||
|
||||
const handleCollapsedMenuVisible = (visible: boolean, moduleCode: string) => {
|
||||
Object.keys(collapsedMenuVisible).forEach(key => {
|
||||
if (key !== moduleCode) collapsedMenuVisible[key] = false;
|
||||
});
|
||||
collapsedMenuVisible[moduleCode] = visible;
|
||||
};
|
||||
|
||||
const handleSubMenuItemClick = (menu: MenuItem) => {
|
||||
if (!menu.chref) return;
|
||||
navigateToPath(menu.chref);
|
||||
|
||||
// 添加标签页
|
||||
const tabExists = allTabs.value.some(tab => tab.path === menu.chref);
|
||||
if (!tabExists) {
|
||||
allTabs.value.push({
|
||||
key: menu.menuCode,
|
||||
title: menu.menuName,
|
||||
path: menu.chref,
|
||||
closable: true
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
|
||||
};
|
||||
|
||||
const handleCollapsedSubMenuClick = (e: { key: string }, menus: MenuItem[]) => {
|
||||
const menuCode = e.key;
|
||||
const menu = menus.find(item => item.menuCode === menuCode);
|
||||
if (menu) handleSubMenuItemClick(menu);
|
||||
};
|
||||
|
||||
// 标签页交互
|
||||
const renderTabTitle = (tab: TabItem) => {
|
||||
return h('div', { class: 'tab-title-container' }, [
|
||||
h('span', { class: 'tab-title-text' }, tab.title),
|
||||
tab.closable && h(
|
||||
'span',
|
||||
{
|
||||
class: 'tab-close-btn',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation();
|
||||
deleteTab(tab);
|
||||
}
|
||||
},
|
||||
// 关键修改:用 getIconComponent 获取 CloseOutlined 图标
|
||||
h(getIconComponent('CloseOutlined'), { size: 12 })
|
||||
)
|
||||
]);
|
||||
};
|
||||
|
||||
const switchToConsole = () => {
|
||||
navigateToPath('/index/console');
|
||||
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
|
||||
};
|
||||
|
||||
const switchTab = (direction: 'prev' | 'next') => {
|
||||
const tabs = otherTabs.value;
|
||||
if (tabs.length === 0) return;
|
||||
|
||||
const currentIdx = currentTabIndex.value;
|
||||
let targetIdx = direction === 'prev' ? currentIdx - 1 : currentIdx + 1;
|
||||
targetIdx = Math.max(0, Math.min(targetIdx, tabs.length - 1));
|
||||
|
||||
navigateToPath(tabs[targetIdx].path);
|
||||
};
|
||||
|
||||
const deleteTab = (tab: TabItem) => {
|
||||
if (!tab.closable) return;
|
||||
|
||||
const tabIndex = allTabs.value.findIndex(item => item.key === tab.key);
|
||||
if (tabIndex === -1) return;
|
||||
|
||||
const isActive = tab.path === route.path;
|
||||
allTabs.value = allTabs.value.filter(item => item.key !== tab.key);
|
||||
|
||||
if (isActive) {
|
||||
const tabs = otherTabs.value;
|
||||
if (tabs.length > 0) {
|
||||
const targetTab = tabs[tabIndex - 1] || tabs[tabIndex];
|
||||
navigateToPath(targetTab.path);
|
||||
} else {
|
||||
navigateToPath('/index/console');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeAllTabs = () => {
|
||||
allTabs.value = allTabs.value.filter(tab => tab.key === 'console');
|
||||
navigateToPath('/index/console');
|
||||
Object.keys(collapsedMenuVisible).forEach(key => collapsedMenuVisible[key] = false);
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const matchedTab = allTabs.value.find(tab => tab.key === key);
|
||||
if (matchedTab) navigateToPath(matchedTab.path);
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
const handleRouterViewError = (err: Error) => {
|
||||
componentError.value = true;
|
||||
errorPath.value = route.path;
|
||||
console.error('路由组件加载失败:', err);
|
||||
};
|
||||
|
||||
const reloadCurrentRoute = () => {
|
||||
componentError.value = false;
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: { ...route.query, t: Date.now() }
|
||||
});
|
||||
};
|
||||
|
||||
// 用户菜单与退出登录
|
||||
const handleDropdownVisible = (visible: boolean) => {
|
||||
console.log('用户下拉菜单状态:', visible ? '显示' : '隐藏');
|
||||
};
|
||||
|
||||
const handleUserMenuClick = ({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
showUserProfile.value = true;
|
||||
break;
|
||||
case 'settings':
|
||||
showChangePwd.value = true;
|
||||
break;
|
||||
case 'logout':
|
||||
handleLogout();
|
||||
break;
|
||||
default:
|
||||
console.log('用户菜单点击:', key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutVisible.value = true;
|
||||
};
|
||||
|
||||
|
||||
const doLogout = async () => {
|
||||
try {
|
||||
// 1. 调用后端登出接口(可选)
|
||||
const response = await apiLogout();
|
||||
if (response.code == 200 ){
|
||||
// 2. 清掉所有缓存
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
logoutVisible.value = false;
|
||||
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('退出失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchMenuData();
|
||||
router.afterEach((to) => {
|
||||
componentError.value = false;
|
||||
syncMenuStateWithRoute(to);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeAuthGuard();
|
||||
});
|
||||
|
||||
watch(collapsed, (newVal) => {
|
||||
if (newVal) expandedParentMenus.value = [];
|
||||
else syncMenuStateWithRoute(route);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import "styles/app-layout.css";
|
||||
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
height: calc(100% - 48px);
|
||||
overflow: auto;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.component-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #f5222d;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-desc, .error-path {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sub-menu-item-disabled {
|
||||
color: #ccc !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="修改密码"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
jjjjjjjjj
|
||||
</template>
|
||||
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="个人资料"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,511 +0,0 @@
|
||||
/* 基础样式 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, .app-container { height: 100vh; overflow: hidden; }
|
||||
.app-container { display: flex; flex-direction: column; }
|
||||
|
||||
/* 顶部导航栏样式 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 64px;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, #e6f7ff 0%, #f0f9ff 100%);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 52px;
|
||||
border-right: 0px solid rgba(24, 144, 255, 0.1);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.system-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.header-actions { display: flex; align-items: center; gap: 16px; }
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
width: 240px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-icon { color: #8c8c8c; margin-right: 8px; }
|
||||
.search-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #333;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
.search-input::placeholder { color: #b3b3b3; }
|
||||
|
||||
.action-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-right: 16px;
|
||||
border-right: 1px solid rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.action-icon:hover { color: #096dd9; transform: scale(1.1); }
|
||||
|
||||
.user-avatar { cursor: pointer; }
|
||||
.avatar-img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid transparent;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.user-avatar:hover .avatar-img {
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-menu { width: 160px; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); }
|
||||
.logout-menu-item { color: #f5222d !important; }
|
||||
|
||||
/* 左侧菜单样式 - 核心区域 */
|
||||
.side-menu-container {
|
||||
background: #fff;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10; /* 确保菜单在内容上方 */
|
||||
}
|
||||
|
||||
/* 折叠状态的侧边栏 */
|
||||
.sider-collapsed {
|
||||
width: 64px !important;
|
||||
}
|
||||
|
||||
/* 菜单加载状态 */
|
||||
.menu-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 菜单滚动容器 */
|
||||
.menu-scroll-container {
|
||||
flex: 1;
|
||||
padding: 16px 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 60px; /* 为底部按钮留出空间 */
|
||||
}
|
||||
|
||||
/* 父级菜单项容器 */
|
||||
.parent-menu-item {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 父级菜单标题基础样式 */
|
||||
.parent-menu-header {
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
/* 展开状态父菜单交互 */
|
||||
.parent-menu-header:hover {
|
||||
background-color: #f5f9ff;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
/* 折叠状态父菜单样式 */
|
||||
.parent-menu-header.collapsed-mode {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-left: none;
|
||||
height: 48px; /* 增大点击区域 */
|
||||
width: 100%;
|
||||
}
|
||||
.parent-menu-header.collapsed-mode:hover {
|
||||
background-color: #f5f9ff;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* 折叠状态tooltip占位元素 */
|
||||
.collapsed-tooltip-placeholder {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* 父级菜单图标 */
|
||||
.parent-menu-icon {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
/* 折叠状态图标 */
|
||||
.parent-menu-header.collapsed-mode .parent-menu-icon {
|
||||
margin-right: 0;
|
||||
font-size: 20px; /* 折叠时图标放大 */
|
||||
}
|
||||
|
||||
/* 父级菜单文字 */
|
||||
.parent-menu-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 父级菜单箭头 */
|
||||
.parent-menu-caret {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* 展开状态子菜单列表容器 */
|
||||
.sub-menu-list {
|
||||
background-color: #fafafa;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
transition: max-height 0.3s ease-out;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 展开状态子菜单显示 */
|
||||
.sub-menu-visible {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* 展开状态子菜单项样式 */
|
||||
.sub-menu-item {
|
||||
height: 38px;
|
||||
padding: 0 16px 0 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* 子菜单图标 */
|
||||
.sub-menu-icon {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 展开状态子菜单项交互 */
|
||||
.sub-menu-item:hover {
|
||||
background-color: #f0f7ff;
|
||||
color: #1890ff;
|
||||
padding-left: 42px;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
.sub-menu-item:hover .sub-menu-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 子菜单项选中样式 */
|
||||
.sub-menu-item-active {
|
||||
background-color: #e6f4ff;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
padding-left: 42px;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
.sub-menu-item-active .sub-menu-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 折叠状态:悬浮子菜单样式 */
|
||||
:deep(.collapsed-sub-menu) {
|
||||
min-width: 180px !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
padding: 6px 0 !important;
|
||||
z-index: 999 !important; /* 确保悬浮菜单在最上层 */
|
||||
}
|
||||
:deep(.collapsed-sub-menu .ant-menu-item) {
|
||||
height: 40px !important;
|
||||
line-height: 40px !important;
|
||||
padding: 0 16px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
:deep(.collapsed-sub-menu .ant-menu-item:hover) {
|
||||
background-color: #f5f9ff !important;
|
||||
color: #1890ff !important;
|
||||
}
|
||||
:deep(.collapsed-sub-menu .ant-menu-item-selected) {
|
||||
background-color: #e6f4ff !important;
|
||||
color: #1890ff !important;
|
||||
}
|
||||
:deep(.collapsed-sub-menu .ant-menu-item .anticon) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* 折叠/展开按钮 */
|
||||
.collapse-trigger {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: -18px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 11; /* 确保按钮在菜单上方 */
|
||||
color: #1890ff;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.collapse-trigger:hover {
|
||||
color: #096dd9;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.15);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 右侧内容区样式 */
|
||||
.right-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 0 20px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.console-tab {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.console-tab-icon { margin-right: 6px; font-size: 14px; }
|
||||
.console-tab--active {
|
||||
background: #e8f4ff !important;
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
.console-tab:hover:not(.console-tab--active) {
|
||||
background: #f5f9ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.tab-nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tab-nav-btn:hover { background: #f5f9ff; color: #1890ff; }
|
||||
.tab-nav-btn:disabled { color: #ccc !important; cursor: not-allowed; background: transparent !important; }
|
||||
|
||||
.google-tabs { flex: 1; overflow: hidden; }
|
||||
:deep(.ant-tabs-nav) { height: 100%; margin: 0 !important; }
|
||||
:deep(.ant-tabs-nav-list) { height: 100%; align-items: center; }
|
||||
:deep(.ant-tabs-tab) {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
margin: 0 2px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
:deep(.ant-tabs-tab:hover:not(.ant-tabs-tab-active)) { background: #f5f9ff; color: #1890ff; }
|
||||
:deep(.ant-tabs-tab.ant-tabs-tab-active) {
|
||||
background: #e8f4ff !important;
|
||||
color: #1890ff !important;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(24, 144, 255, 0.1);
|
||||
border-bottom: 2px solid #1890ff;
|
||||
}
|
||||
:deep(.ant-tabs-ink-bar) { display: none; }
|
||||
|
||||
.tab-title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.tab-title-text { white-space: nowrap; }
|
||||
.tab-close-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-title-container:hover .tab-close-btn { display: inline-flex; }
|
||||
:deep(.ant-tabs-tab-active) .tab-close-btn { color: #1890ff; }
|
||||
.tab-close-btn:hover { background: #d1eaff; color: #1890ff; }
|
||||
|
||||
.close-all-btn {
|
||||
color: #666;
|
||||
margin-left: 2px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.close-all-btn:hover { color: #1890ff; background: #f5f9ff; }
|
||||
.close-all-btn:disabled { color: #ccc !important; cursor: not-allowed; background: transparent !important; }
|
||||
.close-icon { margin-right: 4px; font-size: 12px; }
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.home-content {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-height: calc(100vh - 64px - 44px - 32px);
|
||||
}
|
||||
|
||||
/* 内容加载状态 */
|
||||
.content-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* 组件加载错误样式 */
|
||||
.component-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
color: #f5222d;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-path {
|
||||
font-family: monospace;
|
||||
margin-top: 8px;
|
||||
color: #8c8c8c;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
/* 退出对话框样式 */
|
||||
:deep(.logout-modal-wrapper .ant-modal) { width: 480px !important; }
|
||||
.logout-body { text-align: center; padding: 8px 0 4px; }
|
||||
.logout-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
text-align: left;
|
||||
color: #333;
|
||||
}
|
||||
.logout-desc {
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
margin-bottom: 32px;
|
||||
text-align: left;
|
||||
}
|
||||
.logout-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="收藏"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="帮助"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="通知"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="设置"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')">
|
||||
<p>这里是个人资料表单内容……</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
</script>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
// src/composables/useRequest.ts
|
||||
import { ref } from 'vue'
|
||||
import service from '@/utils/request'
|
||||
|
||||
interface UseRequestOptions<T> {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
params?: any
|
||||
data?: any
|
||||
immediate?: boolean
|
||||
onSuccess?: (data: T) => void
|
||||
onError?: (error: any) => void
|
||||
}
|
||||
|
||||
export function useRequest<T = any>(options: UseRequestOptions<T>) {
|
||||
const { url, method = 'GET', params, data, immediate = true, onSuccess, onError } = options
|
||||
|
||||
const loading = ref(false)
|
||||
const response = ref<T | null>(null)
|
||||
const error = ref<any>(null)
|
||||
|
||||
const run = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await service({ url, method, params, data })
|
||||
response.value = res
|
||||
onSuccess?.(res)
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
onError?.(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
run()
|
||||
}
|
||||
|
||||
return { loading, response, error, run }
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// 导入所有Ant Design图标
|
||||
import * as AllIcons from '@ant-design/icons-vue';
|
||||
|
||||
// 过滤掉非图标组件
|
||||
const exclude = [
|
||||
'createFromIconfontCN',
|
||||
'getTwoToneColor',
|
||||
'setTwoToneColor',
|
||||
'default'
|
||||
];
|
||||
|
||||
// 提取并导出所有图标组件
|
||||
const icons = Object.entries(AllIcons)
|
||||
.filter(([name]) => !exclude.includes(name))
|
||||
.reduce((acc, [name, component]) => {
|
||||
acc[name] = component;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
export default icons;
|
||||
export type AntdIcon = keyof typeof icons;
|
||||
@@ -1,20 +0,0 @@
|
||||
// src/main.ts
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router' // 新增
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import icons from './icons'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(createPinia()) // 注册Pinia
|
||||
app.use(router) // 注册路由
|
||||
app.use(pinia);
|
||||
app.use(Antd) // 注册Ant Design Vue
|
||||
Object.entries(icons).forEach(([name, component]) => {
|
||||
app.component(name, component)
|
||||
})
|
||||
app.mount('#app')
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const Login = () => import('@/views/login/Login.vue');
|
||||
const Home = () => import('@/components/Layout/Main.vue');
|
||||
const Index = () => import('@/views/sys/index.vue');
|
||||
const NotFound = () => import('@/views/sys/NotFound.vue');
|
||||
|
||||
// 1. 自动导入biz模块路由
|
||||
const bizModules = import.meta.glob('../views/biz/**/*.vue')
|
||||
|
||||
// 2. 生成biz子路由(保留index后缀)
|
||||
function buildBizRoutes() {
|
||||
return Object.entries(bizModules).map(([filePath, asyncComp]) => {
|
||||
let routePath = filePath
|
||||
.replace(/^..\/views/, '') // 保留biz及子目录
|
||||
.replace(/\.vue$/, ''); // 不移除index后缀
|
||||
|
||||
// 处理路径开头的斜杠
|
||||
if (!routePath) routePath = '';
|
||||
else if (routePath.startsWith('/')) routePath = routePath.slice(1);
|
||||
|
||||
const routeName = routePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('') || 'BizRoot'
|
||||
|
||||
return {
|
||||
path: routePath,
|
||||
name: routeName,
|
||||
component: asyncComp,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 路由配置(核心:将NotFound作为/index的子路由)
|
||||
const routes = [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/index', // 父路由
|
||||
name: 'Index',
|
||||
component: Index, // 包含内容区RouterView
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
...buildBizRoutes(), // 业务路由
|
||||
{ path: 'console', component: Home, name: 'Console' },
|
||||
{ path: '', redirect: 'console' },
|
||||
// 关键:NotFound作为子路由,匹配/index下的所有未定义路径
|
||||
{
|
||||
path: ':pathMatch(.*)*', // 子路由通配符
|
||||
name: 'IndexNotFound',
|
||||
component: NotFound
|
||||
}
|
||||
]
|
||||
},
|
||||
// 顶层通配符:将非/index的无效路径重定向到/index下的NotFound
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: to => {
|
||||
return `/index/${to.params.pathMatch.join('/')}`;
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,32 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
// 定义存储的状态类型
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
userInfo: any | null // 可根据实际用户信息结构定义具体类型
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
// 初始化时从localStorage读取,保证刷新后状态不丢失
|
||||
token: localStorage.getItem('token') || null,
|
||||
userInfo: localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')!) : null
|
||||
}),
|
||||
actions: {
|
||||
// 保存登录状态(登录成功时调用)
|
||||
setAuthInfo(token: string, userInfo: any) {
|
||||
this.token = token
|
||||
this.userInfo = userInfo
|
||||
// 同步到localStorage持久化
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('userInfo', JSON.stringify(userInfo))
|
||||
},
|
||||
// 清除登录状态(退出登录或token失效时调用)
|
||||
clearAuthInfo() {
|
||||
this.token = null
|
||||
this.userInfo = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('userInfo')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
// src/utils/request.ts
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
// 创建 axios 实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_API, // 从环境变量读取
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可添加 token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => response.data,
|
||||
(error) => {
|
||||
// 统一错误处理
|
||||
console.error('Request error:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
@@ -1,13 +0,0 @@
|
||||
// utils/user.ts
|
||||
export function getUserName(): string {
|
||||
try {
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
if (userInfo) {
|
||||
const parsed = JSON.parse(userInfo);
|
||||
return parsed.uname || '用户';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败:', e);
|
||||
}
|
||||
return '访客';
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<div class="icon-gallery" :style="containerStyle">
|
||||
<!-- 搜索框 -->
|
||||
<a-input
|
||||
v-if="showSearch"
|
||||
v-model:value="keyword"
|
||||
placeholder="搜索图标名称..."
|
||||
allow-clear
|
||||
class="search-input"
|
||||
:size="searchSize"
|
||||
/>
|
||||
|
||||
<!-- 图标网格 -->
|
||||
<div class="icons-grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="item in filteredIcons"
|
||||
:key="item.name"
|
||||
class="icon-item"
|
||||
:style="itemStyle"
|
||||
@mouseenter="handleMouseEnter(item.name)"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<component :is="item.component" class="icon" :style="iconStyle" />
|
||||
<span class="icon-name">{{ item.name }}</span>
|
||||
|
||||
<!-- 复制按钮 - 仅在hover时显示 -->
|
||||
<div v-if="hoveredIcon === item.name" class="copy-buttons">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click.stop="copyWithTags(item.name)"
|
||||
class="copy-btn"
|
||||
>
|
||||
<CopyOutlined class="mr-1" />标签
|
||||
</a-button>
|
||||
<a-button
|
||||
type="default"
|
||||
size="small"
|
||||
@click.stop="copyName(item.name)"
|
||||
class="copy-btn"
|
||||
>
|
||||
<CopyOutlined class="mr-1" />名称
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredIcons.length === 0" class="empty-state">
|
||||
<a-empty description="没有找到匹配的图标" />
|
||||
</div>
|
||||
|
||||
<!-- 复制提示 -->
|
||||
<a-message
|
||||
v-if="showMessage"
|
||||
:content="messageContent"
|
||||
:type="messageType"
|
||||
:duration="messageDuration"
|
||||
class="copy-message"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, PropType, onMounted } from 'vue'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
import { Input, Button, Empty, Message } from 'ant-design-vue'
|
||||
import { CopyOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 是否显示搜索框
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 搜索框尺寸
|
||||
searchSize: {
|
||||
type: String as PropType<'small' | 'middle' | 'large'>,
|
||||
default: 'middle'
|
||||
},
|
||||
// 网格列数
|
||||
columns: {
|
||||
type: Number,
|
||||
default: 6
|
||||
},
|
||||
// 图标大小
|
||||
iconSize: {
|
||||
type: Number,
|
||||
default: 24
|
||||
},
|
||||
// 每个图标项的内边距
|
||||
itemPadding: {
|
||||
type: String,
|
||||
default: '16px'
|
||||
},
|
||||
// 复制提示消息持续时间(毫秒)
|
||||
messageDuration: {
|
||||
type: Number,
|
||||
default: 2000
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤掉非图标成员
|
||||
const exclude = [
|
||||
'createFromIconfontCN',
|
||||
'getTwoToneColor',
|
||||
'setTwoToneColor',
|
||||
'default',
|
||||
'CopyOutlined' // 排除我们在按钮中使用的复制图标
|
||||
]
|
||||
|
||||
// 生成图标列表
|
||||
const iconList = Object.entries(Icons)
|
||||
.filter(([name]) => !exclude.includes(name))
|
||||
.map(([name, component]) => ({ name, component }))
|
||||
|
||||
// 搜索相关
|
||||
const keyword = ref('')
|
||||
const filteredIcons = computed(() => {
|
||||
if (!keyword.value) return iconList
|
||||
|
||||
return iconList.filter(({ name }) =>
|
||||
name.toLowerCase().includes(keyword.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 交互状态
|
||||
const hoveredIcon = ref('')
|
||||
const showMessage = ref(false)
|
||||
const messageContent = ref('')
|
||||
const messageType = ref<'success' | 'error'>('success')
|
||||
|
||||
// 处理鼠标悬停
|
||||
function handleMouseEnter(iconName: string) {
|
||||
hoveredIcon.value = iconName
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
hoveredIcon.value = ''
|
||||
}
|
||||
|
||||
// 复制功能
|
||||
function copyWithTags(name: string) {
|
||||
const code = `<${name} />`
|
||||
copyToClipboard(code)
|
||||
.then(() => {
|
||||
showMessageToast(`已复制: ${code}`, 'success')
|
||||
})
|
||||
.catch(() => {
|
||||
showMessageToast('复制失败,请手动复制', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
function copyName(name: string) {
|
||||
copyToClipboard(name)
|
||||
.then(() => {
|
||||
showMessageToast(`已复制: ${name}`, 'success')
|
||||
})
|
||||
.catch(() => {
|
||||
showMessageToast('复制失败,请手动复制', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('无法复制文本: ', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示消息提示
|
||||
function showMessageToast(content: string, type: 'success' | 'error') {
|
||||
messageContent.value = content
|
||||
messageType.value = type
|
||||
showMessage.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
showMessage.value = false
|
||||
}, props.messageDuration)
|
||||
}
|
||||
|
||||
// 计算样式
|
||||
const containerStyle = computed(() => ({
|
||||
padding: '16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px'
|
||||
}))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
gap: '12px',
|
||||
marginTop: props.showSearch ? '16px' : 0
|
||||
}))
|
||||
|
||||
const itemStyle = computed(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e8e8e8',
|
||||
borderRadius: '6px',
|
||||
padding: props.itemPadding,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}))
|
||||
|
||||
const iconStyle = computed(() => ({
|
||||
fontSize: `${props.iconSize}px`,
|
||||
color: '#262626',
|
||||
marginBottom: '8px'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-gallery {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.icons-grid {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-item:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.icon-name {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.copy-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 2px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy-message {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
app
|
||||
</template>
|
||||
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="申请数据源"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
@cancel="$emit('close')"
|
||||
@ok="$emit('confirm')"
|
||||
>
|
||||
<div class="apply-modal-content">
|
||||
<div class="data-name-section">
|
||||
<span class="label">申请数据源:</span>
|
||||
<span class="value">{{ taskName }} {{ taskId }}</span>
|
||||
</div>
|
||||
<div class="apply-form-section">
|
||||
<a-form-item label="申请理由" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
||||
<a-input
|
||||
placeholder="请说明申请该数据的用途"
|
||||
rows="4"
|
||||
type="textarea"
|
||||
v-model:value="applyReason"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="申请期限" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
||||
<a-date-picker placeholder="选择申请截止日期" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, defineProps, defineEmits } from 'vue';
|
||||
import { Form, Input, DatePicker, Modal } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
taskName: string;
|
||||
taskId: string ;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(['close', 'confirm']);
|
||||
|
||||
const modalWidth = computed(() => window.innerWidth * 0.6);
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${window.innerHeight * 0.6}px`,
|
||||
overflowY: 'auto'
|
||||
}));
|
||||
|
||||
const applyReason = ref('');
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.apply-modal-content .data-name-section { margin-bottom: 10px; }
|
||||
.apply-modal-content .data-name-section .label { color: #666; font-weight: 500; margin-right: 8px; }
|
||||
.apply-modal-content .data-name-section .value { color: #1890ff; font-weight: 500; }
|
||||
.apply-modal-content .apply-form-section { margin-top: 20px; }
|
||||
.apply-modal-content .apply-form-section .ant-form-item { margin-bottom: 16px; }
|
||||
</style>
|
||||
@@ -1,649 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="表详情"
|
||||
:width="modalWidth"
|
||||
:centered="true"
|
||||
:body-style="bodyStyle"
|
||||
:footer="null"
|
||||
@cancel="$emit('close')"
|
||||
>
|
||||
<div class="table-detail-container">
|
||||
<!-- 左侧表基础信息区域 -->
|
||||
<div class="left-section">
|
||||
<h3 class="section-title">表基础信息</h3>
|
||||
|
||||
<!-- 基础信息卡片容器 -->
|
||||
<a-card class="info-card basic-info-card">
|
||||
<div class="basic-info-item">
|
||||
<span class="label">数据库:</span>
|
||||
<span class="value">{{ tableBasicInfo.database }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">表名称:</span>
|
||||
<span class="value">{{ tableBasicInfo.tableName }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">表描述:</span>
|
||||
<span class="value">{{ tableBasicInfo.tableDesc }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">创建时间:</span>
|
||||
<span class="value">{{ tableBasicInfo.createTime }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">更新时间:</span>
|
||||
<span class="value">{{ tableBasicInfo.updateTime }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">数据类型:</span>
|
||||
<span class="value">{{ tableBasicInfo.dataType }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">更新频率:</span>
|
||||
<span class="value">{{ tableBasicInfo.updateFreq }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">字段数量:</span>
|
||||
<span class="value">{{ tableBasicInfo.fieldCount }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">访问权限:</span>
|
||||
<span class="value">{{ tableBasicInfo.accessRights }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="label">备注:</span>
|
||||
<span class="value">{{ tableBasicInfo.remark }}</span>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 存储量卡片容器 -->
|
||||
<a-card class="info-card storage-card">
|
||||
<div class="storage-info">
|
||||
<span class="storage-label">存储量:</span>
|
||||
<span class="storage-value">{{ tableBasicInfo.storageCount }}</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 右侧标签页内容区域 -->
|
||||
<div class="right-section">
|
||||
<a-tabs
|
||||
default-active-key="fieldInfo"
|
||||
@change="handleTabChange"
|
||||
class="google-style-tabs"
|
||||
>
|
||||
<a-tab-pane tab="字段信息" key="fieldInfo">
|
||||
<div class="tab-content">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="fieldData"
|
||||
bordered
|
||||
:pagination="false"
|
||||
:scroll="{ y: 'calc(100% - 20px)' }"
|
||||
class="fixed-header-table"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="生成SELECT" key="generateSelect">
|
||||
<div class="tab-content sql-content">
|
||||
<div class="sql-header">
|
||||
<span>SELECT 语句</span>
|
||||
<a-button
|
||||
type="text"
|
||||
icon="copy"
|
||||
@click="copyToClipboard(selectSql)"
|
||||
class="copy-btn"
|
||||
>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<pre>{{ selectSql }}</pre>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane tab="生成DDL" key="generateDDL">
|
||||
<div class="tab-content sql-content">
|
||||
<div class="sql-header">
|
||||
<span>DDL 语句</span>
|
||||
<a-button
|
||||
type="text"
|
||||
icon="copy"
|
||||
@click="copyToClipboard(ddlSql)"
|
||||
class="copy-btn"
|
||||
>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<pre>{{ ddlSql }}</pre>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, defineEmits, ref } from 'vue';
|
||||
import { Modal, Tabs, Table, Card, Button, message } from 'ant-design-vue';
|
||||
import { CopyOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
taskName?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits(['close', 'tabChange']);
|
||||
|
||||
// 处理标签页切换
|
||||
const handleTabChange = (key: string) => {
|
||||
emit('tabChange', key);
|
||||
};
|
||||
|
||||
// 复制到剪贴板功能
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success('复制成功');
|
||||
}).catch(() => {
|
||||
message.error('复制失败,请手动复制');
|
||||
});
|
||||
};
|
||||
|
||||
// 表基础信息数据
|
||||
const tableBasicInfo = ref({
|
||||
database: 'work',
|
||||
tableName: 'biz_cities',
|
||||
tableDesc: '市区信息表',
|
||||
storageCount: '333条',
|
||||
createTime: '2025-08-29 15:54:41',
|
||||
updateTime: '2025-08-29 15:54:41',
|
||||
dataType: '结构化数据表(MySQL)',
|
||||
updateFreq: '每日增量更新',
|
||||
fieldCount: '13个(含主键、时间戳、业务字段)',
|
||||
accessRights: '需申请(审批后只读访问)',
|
||||
remark: '该表包含核心业务数据,禁止用于非授权场景。'
|
||||
});
|
||||
|
||||
// 字段信息表格列定义
|
||||
const columns = ref([
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '字段名称',
|
||||
dataIndex: 'fieldName',
|
||||
key: 'fieldName',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '字段类型',
|
||||
dataIndex: 'fieldType',
|
||||
key: 'fieldType',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '是否主键',
|
||||
dataIndex: 'isPrimary',
|
||||
key: 'isPrimary',
|
||||
width: 120,
|
||||
align: 'center'
|
||||
}
|
||||
]);
|
||||
|
||||
// 字段信息表格数据
|
||||
const fieldData = ref([
|
||||
{
|
||||
key: '1',
|
||||
index: '1',
|
||||
fieldName: 'id',
|
||||
fieldType: 'datetime',
|
||||
description: '主键',
|
||||
isPrimary: 'PRI'
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
index: '2',
|
||||
fieldName: 'create_time',
|
||||
fieldType: 'varchar',
|
||||
description: '记录时间',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
index: '3',
|
||||
fieldName: 'province_code',
|
||||
fieldType: 'varchar',
|
||||
description: '省份编码',
|
||||
isPrimary: 'MUL'
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
index: '4',
|
||||
fieldName: 'city_code',
|
||||
fieldType: 'varchar',
|
||||
description: '市区编码',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
index: '5',
|
||||
fieldName: 'city_name',
|
||||
fieldType: 'varchar',
|
||||
description: '市区名称',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '6',
|
||||
index: '6',
|
||||
fieldName: 'area_code',
|
||||
fieldType: 'varchar',
|
||||
description: '市区区号',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '7',
|
||||
index: '7',
|
||||
fieldName: 'area_type',
|
||||
fieldType: 'varchar',
|
||||
description: '市区级别',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '8',
|
||||
index: '8',
|
||||
fieldName: 'update_time',
|
||||
fieldType: 'varchar',
|
||||
description: '更新时间',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '9',
|
||||
index: '9',
|
||||
fieldName: 'data_status',
|
||||
fieldType: 'varchar',
|
||||
description: '数据状态',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '10',
|
||||
index: '10',
|
||||
fieldName: 'f_tenant_id',
|
||||
fieldType: 'varchar',
|
||||
description: '租户id',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '11',
|
||||
index: '11',
|
||||
fieldName: 'f_flow_id',
|
||||
fieldType: 'datetime',
|
||||
description: '流程id',
|
||||
isPrimary: 'MUL'
|
||||
},
|
||||
{
|
||||
key: '12',
|
||||
index: '12',
|
||||
fieldName: 'f_flow_task_id',
|
||||
fieldType: 'datetime',
|
||||
description: '流程任务主键',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '13',
|
||||
index: '13',
|
||||
fieldName: 'f_flow_state',
|
||||
fieldType: 'bigint',
|
||||
description: '流程任务状态',
|
||||
isPrimary: ''
|
||||
},
|
||||
// 添加更多数据用于测试滚动效果
|
||||
{
|
||||
key: '14',
|
||||
index: '14',
|
||||
fieldName: 'test_field1',
|
||||
fieldType: 'varchar',
|
||||
description: '测试字段1',
|
||||
isPrimary: ''
|
||||
},
|
||||
{
|
||||
key: '15',
|
||||
index: '15',
|
||||
fieldName: 'test_field2',
|
||||
fieldType: 'int',
|
||||
description: '测试字段2',
|
||||
isPrimary: ''
|
||||
}
|
||||
]);
|
||||
|
||||
// SELECT语句
|
||||
const selectSql = ref(`SELECT
|
||||
id,
|
||||
create_time,
|
||||
province_code,
|
||||
city_code,
|
||||
city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,
|
||||
data_status,
|
||||
city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,
|
||||
city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,city_name,
|
||||
area_code,
|
||||
area_type,
|
||||
update_time,
|
||||
f_tenant_id,
|
||||
f_flow_id,
|
||||
f_flow_task_id,
|
||||
f_flow_state
|
||||
FROM
|
||||
biz_cities;`);
|
||||
|
||||
// DDL语句
|
||||
const ddlSql = ref(`CREATE TABLE \`biz_cities\` (
|
||||
\`id\` datetime NOT NULL COMMENT '主键',
|
||||
\`create_time\` varchar COMMENT '记录时间',
|
||||
\`province_code\` varchar COMMENT '省份编码',
|
||||
\`city_code\` varchar COMMENT '市区编码',
|
||||
\`city_name\` varchar COMMENT '市区名称',
|
||||
\`area_code\` varchar COMMENT '市区区号',
|
||||
\`area_type\` varchar COMMENT '市区级别',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`update_time\` varchar COMMENT '更新时间',
|
||||
\`data_status\` varchar COMMENT '数据状态',
|
||||
\`f_tenant_id\` varchar COMMENT '租户id',
|
||||
\`f_flow_id\` datetime COMMENT '流程id',
|
||||
\`f_flow_task_id\` datetime COMMENT '流程任务主键',
|
||||
\`f_flow_state\` bigint COMMENT '流程任务状态',
|
||||
PRIMARY KEY (\`id\`),
|
||||
KEY \`idx_province_code\` (\`province_code\`),
|
||||
KEY \`idx_f_flow_id\` (\`f_flow_id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='市区信息表';`);
|
||||
|
||||
// 计算模态框宽度
|
||||
const modalWidth = computed(() => {
|
||||
return Math.min(window.innerWidth * 0.9, 1400);
|
||||
});
|
||||
|
||||
// 计算模态框内容区域样式
|
||||
const bodyStyle = computed(() => ({
|
||||
height: `${Math.min(window.innerHeight * 0.8, 800)}px`,
|
||||
overflowY: 'hidden',
|
||||
padding: '16px 24px'
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.table-detail-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.right-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
margin: 0 0 8px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
// 卡片样式
|
||||
.info-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.basic-info-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.storage-card {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storage-label {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.storage-value {
|
||||
color: #1a73e8;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.basic-info-item {
|
||||
display: flex;
|
||||
padding: 6px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.basic-info-item .label {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.basic-info-item .value {
|
||||
color: #1f2329;
|
||||
flex-grow: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 谷歌风格标签页
|
||||
.google-style-tabs {
|
||||
--active-tab-color: #e8f0fe;
|
||||
--active-text-color: #1967d2;
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 12px 24px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #5f6368;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #1967d2;
|
||||
background-color: rgba(25, 103, 210, 0.04);
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
color: var(--active-text-color);
|
||||
background-color: var(--active-tab-color);
|
||||
border-bottom: 2px solid var(--active-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
// SQL内容样式
|
||||
.sql-content {
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sql-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background-color: #f1f3f4;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
color: #1967d2;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(25, 103, 210, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
.fixed-header-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条样式优化
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,219 +0,0 @@
|
||||
<template>
|
||||
<div class="data-list-container">
|
||||
<div class="search-card" :bordered="false">
|
||||
<a-row :gutter="4" align="middle" class="search-row">
|
||||
<!-- 下拉框 -->
|
||||
<a-col :span="3" :sm="4" :xs="24" class="select-col">
|
||||
<a-select
|
||||
placeholder="下拉框"
|
||||
style="width: 100%"
|
||||
:bordered="false"
|
||||
class="custom-select"
|
||||
v-model:value="selectValue"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="option in selectOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<!-- 输入框 -->
|
||||
<a-col :span="15" :sm="14" :xs="24" class="input-col">
|
||||
<a-input
|
||||
placeholder="输入框"
|
||||
style="width: 100%; padding: 0 8px;"
|
||||
:bordered="false"
|
||||
class="custom-input"
|
||||
v-model:value="inputValue"
|
||||
clearable
|
||||
/>
|
||||
</a-col>
|
||||
<!-- 按钮区域 -->
|
||||
<a-col :span="6" :sm="6" :xs="24" class="button-col">
|
||||
<div class="search-buttons">
|
||||
<a-button type="primary" class="search-btn" @click="handleSearch">搜索</a-button>
|
||||
<a-button class="reset-btn" @click="handleReset">重置</a-button>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<div v-else class="data-cards">
|
||||
<a-card class="data-card" v-for="(item, index) in dataList" :key="index" :bordered="true">
|
||||
<a-row class="card-header">
|
||||
<a-col :span="16">
|
||||
<div class="data-name" @click="handleNameClick(item)">{{ item.targetTable }}</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div class="data-storage"><span class="label">数据库: </span>{{ item.dbType }}</div>
|
||||
<div class="data-storage"><span class="label">存储量: </span>{{ item.successRows }} 条</div>
|
||||
</a-col>
|
||||
<a-col :span="4" class="time-info">
|
||||
<div class="create-time">创建时间: {{ item.createTime }}</div>
|
||||
<div class="update-time">更新时间: {{ item.updateTime }}</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row class="card-body">
|
||||
<a-col :span="24">
|
||||
<div class="data-description"><span class="label">描述: </span>{{ item.taskName }}</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row class="card-footer">
|
||||
<a-col :span="24" class="card-actions">
|
||||
<a-button size="small" class="favorite-btn" @click="handleFavoriteToggle(item)" :disabled="item.isActive">
|
||||
{{ item.isActive ? '已收藏' : '收藏' }}
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" class="apply-btn" @click="handleApplyClick(item)">申请</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-if="dataList && dataList.length === 0" class="no-data">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApplyModal v-if="applyModalVisible" :visible="applyModalVisible" :task-name="currentItem?.taskName" :task-id="currentItem?.taskId" @close="applyModalVisible = false" @confirm="handleApplyConfirm" />
|
||||
<NameDetailModal v-if="nameModalVisible" :visible="nameModalVisible" :task-name="currentItem?.taskName" :task-id="currentItem?.taskId" @close="nameModalVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { Card, Row, Col, Select, Input, Button, Spin } from 'ant-design-vue';
|
||||
import ApplyModal from './Apply.vue';
|
||||
import NameDetailModal from './NameDetai.vue';
|
||||
import { getDbList, DbItem, SyncTables, getSyncTables } from '@/api/data';
|
||||
|
||||
// 搜索框数据绑定
|
||||
const selectValue = ref('');
|
||||
const inputValue = ref('');
|
||||
|
||||
const selectOptions = ref<DbItem[]>();
|
||||
const selectLoading = ref(false);
|
||||
|
||||
// 数据列表和加载状态
|
||||
const dataList = ref<SyncTables[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 重置事件
|
||||
const handleReset = () => {
|
||||
selectValue.value = '';
|
||||
inputValue.value = '';
|
||||
// 重置后重新加载数据
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 搜索事件
|
||||
const handleSearch = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 弹窗控制
|
||||
const currentItem = ref<SyncTables | null>(null);
|
||||
const applyModalVisible = ref(false);
|
||||
const nameModalVisible = ref(false);
|
||||
|
||||
// 获取数据列表
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 构建查询参数
|
||||
const params = {
|
||||
dbId: selectValue.value,
|
||||
targetTable: inputValue.value
|
||||
};
|
||||
console.log(params)
|
||||
// 调用接口获取数据
|
||||
const result = await getSyncTables(params);
|
||||
dataList.value = result || [];
|
||||
|
||||
console.log(result)
|
||||
|
||||
} catch (e) {
|
||||
console.error('获取数据失败:', e);
|
||||
dataList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载下拉框选项
|
||||
selectLoading.value = true;
|
||||
try {
|
||||
selectOptions.value = await getDbList();
|
||||
} catch (e) {
|
||||
console.error('获取数据库列表失败:', e);
|
||||
} finally {
|
||||
selectLoading.value = false;
|
||||
}
|
||||
|
||||
// 页面加载时默认获取数据
|
||||
fetchData();
|
||||
});
|
||||
|
||||
// 事件处理
|
||||
const handleFavoriteToggle = (item: SyncTables) => {
|
||||
item.isFavorite = !item.isFavorite;
|
||||
};
|
||||
const handleApplyClick = (item: SyncTables) => {
|
||||
currentItem.value = item;
|
||||
applyModalVisible.value = true;
|
||||
};
|
||||
const handleNameClick = (item: SyncTables) => {
|
||||
currentItem.value = item;
|
||||
nameModalVisible.value = true;
|
||||
};
|
||||
const handleApplyConfirm = () => {
|
||||
console.log('申请提交:', currentItem.value?.taskName);
|
||||
applyModalVisible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.data-list-container { padding: 2px; min-height: 80vh; }
|
||||
.search-card { background-color: #f0f7ff; padding: 16px; margin-bottom: 16px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); position: sticky; top: 0; z-index: 10; }
|
||||
.search-row { white-space: nowrap; width: 100%; display: flex; align-items: center; }
|
||||
.select-col, .input-col, .button-col { display: inline-block; vertical-align: middle; }
|
||||
.custom-select, .custom-input { width: 100%; border: 1px solid #d9d9d9; border-radius: 4px; background-color: #fff; height: 32px; }
|
||||
.custom-select .a-select-selector, .custom-input .a-input-inner { border: none !important; box-shadow: none !important; background-color: transparent !important; height: 100% !important; line-height: 32px !important; }
|
||||
.search-buttons { display: flex; gap: 8px; margin: 0; padding: 0; height: 32px; align-items: center; }
|
||||
.search-btn, .reset-btn { height: 32px; padding: 0 16px; }
|
||||
.data-cards { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.data-card { background-color: #fff; border-radius: 4px; overflow: hidden; transition: all 0.3s ease; }
|
||||
.data-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
|
||||
.card-header { padding: 16px 16px 8px; border-bottom: 1px solid #f0f0f0; }
|
||||
.data-name { font-size: 16px; font-weight: 500; color: #1890ff; word-break: break-all; cursor: pointer; text-decoration: underline; }
|
||||
.time-info { text-align: right; font-size: 12px; color: #666; }
|
||||
.time-info .create-time, .time-info .update-time { margin-bottom: 4px; }
|
||||
.card-body { padding: 12px 16px 16px; }
|
||||
.data-description, .data-storage { margin-bottom: 8px; font-size: 14px; }
|
||||
.data-description .label, .data-storage .label { color: #666; font-weight: 500; }
|
||||
.card-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.card-footer { margin-top: auto; padding: 12px 16px; border-top: 1px solid rgba(0,0,0,0.08); background-color: rgba(255,255,255,0.8); border-bottom-left-radius: inherit; border-bottom-right-radius: inherit; }
|
||||
.favorite-btn { border-color: #1890ff; color: #1890ff; }
|
||||
.favorite-btn:hover { background-color: #e6f7ff; }
|
||||
.favorite-btn.ant-btn-disabled { color: #52c41a !important; border-color: #52c41a !important; background-color: rgba(82,196,26,0.1) !important; cursor: default !important; }
|
||||
.apply-btn { background-color: #1890ff; border-color: #1890ff; }
|
||||
.apply-btn:hover { background-color: #096dd9; }
|
||||
.loading-container { display: flex; justify-content: center; padding: 40px 0; }
|
||||
.no-data { text-align: center; padding: 40px 0; color: #666; }
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.search-row { flex-wrap: wrap; gap: 8px; }
|
||||
.select-col, .input-col, .button-col { width: 100% !important; margin-bottom: 8px; }
|
||||
.button-col { margin-bottom: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<!-- 根容器:100%填充父容器(home-content),确保自适应 -->
|
||||
<div class="empty-page-container">
|
||||
<!-- 空白页核心内容区:垂直居中 -->
|
||||
<div class="empty-content">
|
||||
<!-- 空状态图标 -->
|
||||
<div class="empty-icon">
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
|
||||
<!-- 标题与描述 -->
|
||||
<h2 class="empty-title">这是一个空白页面</h2>
|
||||
<p class="empty-desc">
|
||||
你可以在这里扩展功能,比如添加数据展示、表单提交、图表分析等内容<br>
|
||||
页面已适配父容器尺寸,支持高度/宽度自适应
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮:提供基础交互入口 -->
|
||||
<div class="empty-actions">
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<PlusOutlined class="mr-2" /> 创建内容
|
||||
</a-button>
|
||||
<a-button @click="handleGuide" style="margin-left: 12px;">
|
||||
<QuestionCircleOutlined class="mr-2" /> 查看使用指南
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 辅助提示:展示页面基础信息 -->
|
||||
<div class="empty-meta">
|
||||
<span class="meta-item">页面路径:@/views/EmptyTemplatePage.vue</span>
|
||||
<span class="meta-item">适配场景:列表页、详情页、表单页等初始模板</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
// 引入必要组件(与原项目组件库一致)
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { FileTextOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
// 页面基础状态(可根据需求扩展)
|
||||
const pageName = ref('空白模板页面');
|
||||
|
||||
// 按钮交互事件(示例:可根据业务逻辑修改)
|
||||
const handleCreate = () => {
|
||||
console.log(`[${pageName.value}] 触发「创建内容」操作`);
|
||||
// 实际场景可扩展:打开创建弹窗、跳转表单页面等
|
||||
alert('可在此处实现「创建内容」的业务逻辑');
|
||||
};
|
||||
|
||||
const handleGuide = () => {
|
||||
console.log(`[${pageName.value}] 查看使用指南`);
|
||||
// 实际场景可扩展:打开帮助文档、显示引导弹窗等
|
||||
alert('使用指南:\n1. 此页面已适配父容器自适应尺寸\n2. 可在 empty-content 内添加业务组件\n3. 样式可参考原项目规范调整');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 根容器:100%填充父容器,确保自适应 */
|
||||
.empty-page-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 空白页内容区:居中展示,控制宽高比例 */
|
||||
.empty-content {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 空状态图标:突出显示,统一风格 */
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background-color: #e8f4ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.empty-icon :deep(svg) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* 标题样式:与原项目标题层级一致 */
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 描述文本:浅色、易读 */
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 操作按钮区:间距控制 */
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 辅助信息:弱化显示,底部对齐 */
|
||||
.empty-meta {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 响应式:小屏幕适配 */
|
||||
@media (max-width: 768px) {
|
||||
.empty-content {
|
||||
padding: 32px 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.empty-icon :deep(svg) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-meta {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<div class="login-wrapper">
|
||||
<a-card class="login-card" :bordered="false">
|
||||
<div class="login-header">
|
||||
<img class="logo" src="@/assets/logo.png" alt="logo" />
|
||||
<h1>cApi管理系统</h1>
|
||||
<p>安全、高效的一站式管理解决方案,为您的业务保驾护航</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="formRef" :model="form" :rules="rules" size="large" @finish="handleSubmit">
|
||||
<a-form-item name="account">
|
||||
<a-input v-model:value="form.account" placeholder="请输入用户名" allow-clear>
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password v-model:value="form.password" placeholder="请输入密码" allow-clear>
|
||||
<template #prefix><LockOutlined /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" html-type="submit" :loading="loading" block>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
import type { LoginParams, LoginResult } from '@/api/auth'
|
||||
import { apiLogin } from '@/api/auth'
|
||||
|
||||
import { useAuthStore } from '@/store/index' // 引入状态管理
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive<LoginParams>({ account: '', password: '' })
|
||||
|
||||
const rules = {
|
||||
account: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: true, message: '请输入密码' }]
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 防止重复提交
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
const authStore = useAuthStore(); // 提前获取store实例,避免多次调用
|
||||
try {
|
||||
// 更清晰的变量命名,API返回通常包含code、data、msg等字段
|
||||
const response: LoginResult = await apiLogin(form);
|
||||
// 检查请求是否成功(通常200为成功状态码)
|
||||
if (response.code === 200 && response.data) {
|
||||
const token = response.data.token;
|
||||
localStorage.setItem('token', token );
|
||||
localStorage.setItem('userInfo', response.data);
|
||||
authStore.setAuthInfo(token, response.data);
|
||||
message.success(response.msg || '登录成功');
|
||||
// 延迟跳转提升用户体验
|
||||
setTimeout(() => {
|
||||
router.replace('/index/console');
|
||||
}, 800);
|
||||
return; // 成功后终止函数,避免执行后续错误提示
|
||||
}
|
||||
// 处理业务错误(如账号密码错误)
|
||||
message.error(response.msg || '登录失败,请检查账号密码');
|
||||
} catch (error) {
|
||||
// 处理网络错误或异常情况
|
||||
console.error('登录请求异常:', error);
|
||||
message.error('网络异常,请检查网络连接后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-wrapper {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 200px;
|
||||
|
||||
/* 渐变与背景图片融合设置 */
|
||||
background:
|
||||
linear-gradient(120deg, rgba(79, 172, 254, 0.7) 0%, rgba(0, 242, 254, 0.7) 50%, rgba(122, 90, 248, 0.7) 100%),
|
||||
url('@/assets/imges/backImg.jpg') center center no-repeat;
|
||||
|
||||
/* 背景融合模式 - 使渐变与图片颜色叠加融合 */
|
||||
background-blend-mode: overlay;
|
||||
/* 确保背景完全覆盖容器 */
|
||||
background-size: cover;
|
||||
}
|
||||
.login-text{
|
||||
text-align: center;
|
||||
color: white;
|
||||
width: 800px;
|
||||
padding: 32px 48px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.login-card {
|
||||
width: 520px;
|
||||
padding: 32px 48px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
.logo { width: 64px; height: 64px; margin-bottom: 12px; }
|
||||
h1 { font-size: 24px; font-weight: 600; margin: 0; }
|
||||
p { font-size: 14px; color: #8c8c8c; margin: 8px 0 0; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
顶顶顶顶顶顶顶顶顶顶顶顶顶顶
|
||||
</template>
|
||||
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@@ -1,12 +0,0 @@
|
||||
<!-- src/views/sys/index.vue -->
|
||||
<template>
|
||||
<AppLayout />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppLayout from '@/components/Layout/AppLayout.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可选:局部样式覆盖 */
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import Icons from 'unplugin-icons/vite' // 图标插件
|
||||
import IconsResolver from 'unplugin-icons/resolver' // 图标解析器
|
||||
import Components from 'unplugin-vue-components/vite' // 组件自动导入
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
|
||||
export default ({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
const isProduction = mode === 'production'
|
||||
|
||||
return defineConfig({
|
||||
base: isProduction ? '/cApi/' : '/',
|
||||
plugins: [
|
||||
vue(),
|
||||
Components({
|
||||
resolvers: [
|
||||
// 自动解析图标组件(前缀为Icon,如<IconLogo />)
|
||||
IconsResolver({
|
||||
prefix: 'Icon',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Icons({
|
||||
compiler: 'vue3',
|
||||
autoInstall: true, // 自动安装图标依赖
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
[env.VITE_BASE_API]: {
|
||||
target: env.VITE_SERVER_URL,
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(dirname(fileURLToPath(import.meta.url)), 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.mini.capi.config;
|
||||
|
||||
import com.mini.capi.biz.domain.ApiUser;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
@Component
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler) throws Exception {
|
||||
HttpSession session = request.getSession();
|
||||
ApiUser apiUser = (ApiUser) session.getAttribute("userInfo");
|
||||
if (apiUser == null) {
|
||||
response.sendRedirect(request.getContextPath() + "/login");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.mini.capi.config;
|
||||
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
|
||||
@Override
|
||||
public void addViewControllers(ViewControllerRegistry registry) {
|
||||
registry.addViewController("/login").setViewName("forward:/index.html");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
protected Resource getResource(String resourcePath, Resource location) throws IOException {
|
||||
Resource requested = super.getResource(resourcePath, location);
|
||||
// 文件存在就返回,不存在返回 index.html
|
||||
return requested != null ? requested : new ClassPathResource("/static/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/**") // 拦截所有请求
|
||||
.excludePathPatterns(
|
||||
// ① 排除 Vue 静态资源(匹配 static 目录下所有 js/css/img 等)
|
||||
"/js/**",
|
||||
"/css/**",
|
||||
"/img/**",
|
||||
"/favicon.ico",
|
||||
"/index.html", // 排除首页(Vue 入口)
|
||||
"/assets/**", // 若 Vue 打包后有 assets 目录,需排除
|
||||
"/resource/**", // 若有其他静态资源目录,需排除
|
||||
"/cApi/index/**",
|
||||
|
||||
"/login",
|
||||
"/Sys/login/**", // 你的登录接口(原 /Sys/login/** 缺少 /cApi 前缀,修复)
|
||||
"/Sys/jobs/**", // 原路径补充 /cApi 前缀
|
||||
"/Sys/hosts/**",
|
||||
"/Sys/dbs/**",
|
||||
|
||||
"/cApi/swagger-ui/**",
|
||||
"/cApi/v3/api-docs/**"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.mini.capi.model;
|
||||
|
||||
import com.mini.capi.biz.domain.SyncTablesView;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ApiResult<T> implements Serializable {
|
||||
|
||||
@@ -15,12 +15,6 @@ public class CustomErrorController implements ErrorController {
|
||||
@RequestMapping("/error")
|
||||
public Object handleError(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// 1. 前端路由:统一转发到 index.html
|
||||
if (uri.startsWith("/cApi/index/")) {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
// 2. 其他 404:返回 JSON
|
||||
HttpStatus status = getStatus(request);
|
||||
return ResponseEntity
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.mini.capi.sys.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class RootController {
|
||||
|
||||
@GetMapping("/")
|
||||
public String redirectToSwagger() {
|
||||
return "redirect:/swagger-ui.html";
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.mini.capi.webssh.config;
|
||||
|
||||
import com.mini.capi.webssh.websocket.SSHWebSocketHandler;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
@Resource
|
||||
private SSHWebSocketHandler sshWebSocketHandler;
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(sshWebSocketHandler, "/ssh")
|
||||
.setAllowedOriginPatterns("*"); // 生产环境中应该限制域名
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package com.mini.capi.webssh.controller;
|
||||
|
||||
import com.mini.capi.biz.domain.SshServers;
|
||||
import com.mini.capi.biz.service.SshServersService;
|
||||
import com.mini.capi.webssh.service.FileTransferService;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/files")
|
||||
public class FileTransferController {
|
||||
|
||||
@Resource
|
||||
private FileTransferService fileTransferService;
|
||||
|
||||
@Resource
|
||||
private SshServersService serverService;
|
||||
|
||||
/**
|
||||
* 上传文件到服务器
|
||||
*/
|
||||
@PostMapping("/upload/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> uploadFile(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("remotePath") String remotePath) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "文件不能为空"));
|
||||
}
|
||||
|
||||
fileTransferService.uploadFile(serverOpt.get(), file, remotePath);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "文件上传成功",
|
||||
"filename", file.getOriginalFilename(),
|
||||
"size", file.getSize()
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "上传失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传文件
|
||||
*/
|
||||
@PostMapping("/upload-batch/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam("files") MultipartFile[] files,
|
||||
@RequestParam("remotePath") String remotePath) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
if (files == null || files.length == 0) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "请选择要上传的文件"));
|
||||
}
|
||||
|
||||
fileTransferService.uploadFiles(serverOpt.get(), files, remotePath);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "批量上传成功",
|
||||
"count", files.length
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "批量上传失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务器下载文件
|
||||
*/
|
||||
@GetMapping("/download/{serverId}")
|
||||
public ResponseEntity<byte[]> downloadFile(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam("remoteFilePath") String remoteFilePath) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
byte[] fileContent = fileTransferService.downloadFile(serverOpt.get(), remoteFilePath);
|
||||
|
||||
// 从路径中提取文件名
|
||||
String filename = remoteFilePath.substring(remoteFilePath.lastIndexOf('/') + 1);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(fileContent);
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出远程目录内容
|
||||
*/
|
||||
@GetMapping("/list/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> listDirectory(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam("remotePath") String remotePath) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
List<FileTransferService.FileInfo> files =
|
||||
fileTransferService.listDirectory(serverOpt.get(), remotePath);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"files", files,
|
||||
"path", remotePath
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "获取目录列表失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建远程目录
|
||||
*/
|
||||
@PostMapping("/mkdir/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> createDirectory(
|
||||
@PathVariable Long serverId,
|
||||
@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
String remotePath = request.get("remotePath");
|
||||
if (remotePath == null || remotePath.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "目录路径不能为空"));
|
||||
}
|
||||
|
||||
fileTransferService.createRemoteDirectory(serverOpt.get(), remotePath);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "目录创建成功",
|
||||
"path", remotePath
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "创建目录失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除远程文件或目录
|
||||
*/
|
||||
@DeleteMapping("/delete/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> deleteFile(
|
||||
@PathVariable Long serverId,
|
||||
@RequestParam("remotePath") String remotePath,
|
||||
@RequestParam(value = "isDirectory", defaultValue = "false") boolean isDirectory) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
fileTransferService.deleteRemoteFile(serverOpt.get(), remotePath, isDirectory);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", (isDirectory ? "目录" : "文件") + "删除成功",
|
||||
"path", remotePath
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "删除失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名远程文件
|
||||
*/
|
||||
@PostMapping("/rename/{serverId}")
|
||||
public ResponseEntity<Map<String, Object>> renameFile(
|
||||
@PathVariable Long serverId,
|
||||
@RequestBody Map<String, String> request) {
|
||||
try {
|
||||
Optional<SshServers> serverOpt = serverService.getOptById(serverId);
|
||||
if (!serverOpt.isPresent()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "服务器不存在"));
|
||||
}
|
||||
|
||||
String oldPath = request.get("oldPath");
|
||||
String newPath = request.get("newPath");
|
||||
|
||||
if (oldPath == null || newPath == null || oldPath.trim().isEmpty() || newPath.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "路径不能为空"));
|
||||
}
|
||||
|
||||
fileTransferService.renameRemoteFile(serverOpt.get(), oldPath, newPath);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "重命名成功",
|
||||
"oldPath", oldPath,
|
||||
"newPath", newPath
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "重命名失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package com.mini.capi.webssh.controller;
|
||||
|
||||
import com.mini.capi.biz.domain.SshServers;
|
||||
import com.jcraft.jsch.JSch;
|
||||
import com.jcraft.jsch.Session;
|
||||
import com.mini.capi.biz.service.SshServersService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/servers")
|
||||
public class ServerController {
|
||||
|
||||
@Resource
|
||||
private SshServersService serverService;
|
||||
|
||||
/**
|
||||
* 获取服务器列表
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<List<SshServers>> getServers() {
|
||||
List<SshServers> servers = serverService.list();
|
||||
return ResponseEntity.ok(servers);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个服务器配置
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<SshServers> getServer(@PathVariable Long id) {
|
||||
try {
|
||||
Optional<SshServers> server = serverService.getOptById(id);
|
||||
if (server.isPresent()) {
|
||||
return ResponseEntity.ok(server.get());
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加服务器
|
||||
*/
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> addServer(@RequestBody SshServers server) {
|
||||
try {
|
||||
// 验证必要参数
|
||||
if (server.getHost() == null || server.getHost().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "服务器地址不能为空"));
|
||||
}
|
||||
if (server.getUsername() == null || server.getUsername().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "用户名不能为空"));
|
||||
}
|
||||
if (server.getPassword() == null || server.getPassword().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "密码不能为空"));
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if (server.getPort() == null) {
|
||||
server.setPort(22);
|
||||
}
|
||||
if (server.getName() == null || server.getName().trim().isEmpty()) {
|
||||
server.setName(server.getUsername() + "@" + server.getHost());
|
||||
}
|
||||
|
||||
serverService.save(server);
|
||||
return ResponseEntity.ok(Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除服务器
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Map<String, Object>> deleteServer(@PathVariable Long id) {
|
||||
try {
|
||||
serverService.removeById(id);
|
||||
return ResponseEntity.ok(Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试服务器连接
|
||||
*/
|
||||
@PostMapping("/test")
|
||||
public ResponseEntity<Map<String, Object>> testConnection(@RequestBody SshServers server) {
|
||||
try {
|
||||
// 验证必要参数
|
||||
if (server.getHost() == null || server.getHost().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "服务器地址不能为空"));
|
||||
}
|
||||
if (server.getUsername() == null || server.getUsername().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "用户名不能为空"));
|
||||
}
|
||||
if (server.getPassword() == null || server.getPassword().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "密码不能为空"));
|
||||
}
|
||||
|
||||
// 设置默认端口
|
||||
int port = server.getPort() != null ? server.getPort() : 22;
|
||||
|
||||
// 简单的连接测试
|
||||
JSch jsch = new JSch();
|
||||
Session session = jsch.getSession(server.getUsername(), server.getHost(), port);
|
||||
session.setPassword(server.getPassword());
|
||||
session.setConfig("StrictHostKeyChecking", "no");
|
||||
session.connect(5000); // 5秒超时
|
||||
session.disconnect();
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "连接测试成功"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(Map.of("success", false, "message", "连接测试失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package com.mini.capi.webssh.service;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import com.mini.capi.biz.domain.SshServers;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.Vector;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class FileTransferService {
|
||||
|
||||
/**
|
||||
* 上传文件到远程服务器
|
||||
*/
|
||||
public void uploadFile(SshServers server, MultipartFile file, String remotePath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
// 确保远程目录存在
|
||||
createRemoteDirectory(sftpChannel, remotePath);
|
||||
|
||||
// 上传文件
|
||||
String remoteFilePath = remotePath + "/" + file.getOriginalFilename();
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
sftpChannel.put(inputStream, remoteFilePath);
|
||||
}
|
||||
|
||||
log.info("文件上传成功: {} -> {}", file.getOriginalFilename(), remoteFilePath);
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从远程服务器下载文件
|
||||
*/
|
||||
public byte[] downloadFile(SshServers server, String remoteFilePath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
InputStream inputStream = sftpChannel.get(remoteFilePath)) {
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
log.info("文件下载成功: {}", remoteFilePath);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出远程目录内容
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<FileInfo> listDirectory(SshServers server, String remotePath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
List<FileInfo> files = new ArrayList<>();
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
Vector<ChannelSftp.LsEntry> entries = sftpChannel.ls(remotePath);
|
||||
|
||||
for (ChannelSftp.LsEntry entry : entries) {
|
||||
String filename = entry.getFilename();
|
||||
if (!filename.equals(".") && !filename.equals("..")) {
|
||||
SftpATTRS attrs = entry.getAttrs();
|
||||
files.add(new FileInfo(
|
||||
filename,
|
||||
attrs.isDir(),
|
||||
attrs.getSize(),
|
||||
attrs.getMTime() * 1000L, // Convert to milliseconds
|
||||
getPermissionString(attrs.getPermissions())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("目录列表获取成功: {}, 文件数: {}", remotePath, files.size());
|
||||
return files;
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建远程目录
|
||||
*/
|
||||
public void createRemoteDirectory(SshServers server, String remotePath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
createRemoteDirectory(sftpChannel, remotePath);
|
||||
log.info("远程目录创建成功: {}", remotePath);
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除远程文件或目录
|
||||
*/
|
||||
public void deleteRemoteFile(SshServers server, String remotePath, boolean isDirectory) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
if (isDirectory) {
|
||||
sftpChannel.rmdir(remotePath);
|
||||
} else {
|
||||
sftpChannel.rm(remotePath);
|
||||
}
|
||||
|
||||
log.info("远程文件删除成功: {}", remotePath);
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名远程文件
|
||||
*/
|
||||
public void renameRemoteFile(SshServers server, String oldPath, String newPath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
sftpChannel.rename(oldPath, newPath);
|
||||
log.info("文件重命名成功: {} -> {}", oldPath, newPath);
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传文件
|
||||
*/
|
||||
public void uploadFiles(SshServers server, MultipartFile[] files, String remotePath) throws Exception {
|
||||
Session session = null;
|
||||
ChannelSftp sftpChannel = null;
|
||||
|
||||
try {
|
||||
session = createSession(server);
|
||||
sftpChannel = (ChannelSftp) session.openChannel("sftp");
|
||||
sftpChannel.connect();
|
||||
|
||||
// 确保远程目录存在
|
||||
createRemoteDirectory(sftpChannel, remotePath);
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (!file.isEmpty()) {
|
||||
String remoteFilePath = remotePath + "/" + file.getOriginalFilename();
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
sftpChannel.put(inputStream, remoteFilePath);
|
||||
log.info("文件上传成功: {}", file.getOriginalFilename());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("批量上传完成,共上传 {} 个文件", files.length);
|
||||
|
||||
} finally {
|
||||
closeConnections(sftpChannel, session);
|
||||
}
|
||||
}
|
||||
|
||||
// 私有辅助方法
|
||||
|
||||
private Session createSession(SshServers server) throws JSchException {
|
||||
JSch jsch = new JSch();
|
||||
Session session = jsch.getSession(server.getUsername(), server.getHost(), server.getPort());
|
||||
session.setPassword(server.getPassword());
|
||||
|
||||
Properties config = new Properties();
|
||||
config.put("StrictHostKeyChecking", "no");
|
||||
config.put("PreferredAuthentications", "password");
|
||||
session.setConfig(config);
|
||||
session.connect(10000); // 10秒超时
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private void createRemoteDirectory(ChannelSftp sftpChannel, String remotePath) {
|
||||
try {
|
||||
String[] pathParts = remotePath.split("/");
|
||||
String currentPath = "";
|
||||
|
||||
for (String part : pathParts) {
|
||||
if (!part.isEmpty()) {
|
||||
currentPath += "/" + part;
|
||||
try {
|
||||
sftpChannel.mkdir(currentPath);
|
||||
} catch (SftpException e) {
|
||||
log.error(e.getMessage(),e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("创建远程目录失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void closeConnections(ChannelSftp sftpChannel, Session session) {
|
||||
if (sftpChannel != null && sftpChannel.isConnected()) {
|
||||
sftpChannel.disconnect();
|
||||
}
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private String getPermissionString(int permissions) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// Owner permissions
|
||||
sb.append((permissions & 0400) != 0 ? 'r' : '-');
|
||||
sb.append((permissions & 0200) != 0 ? 'w' : '-');
|
||||
sb.append((permissions & 0100) != 0 ? 'x' : '-');
|
||||
|
||||
// Group permissions
|
||||
sb.append((permissions & 0040) != 0 ? 'r' : '-');
|
||||
sb.append((permissions & 0020) != 0 ? 'w' : '-');
|
||||
sb.append((permissions & 0010) != 0 ? 'x' : '-');
|
||||
|
||||
// Others permissions
|
||||
sb.append((permissions & 0004) != 0 ? 'r' : '-');
|
||||
sb.append((permissions & 0002) != 0 ? 'w' : '-');
|
||||
sb.append((permissions & 0001) != 0 ? 'x' : '-');
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// 文件信息内部类
|
||||
public static class FileInfo {
|
||||
private String name;
|
||||
private boolean isDirectory;
|
||||
private long size;
|
||||
private long lastModified;
|
||||
private String permissions;
|
||||
|
||||
public FileInfo(String name, boolean isDirectory, long size, long lastModified, String permissions) {
|
||||
this.name = name;
|
||||
this.isDirectory = isDirectory;
|
||||
this.size = size;
|
||||
this.lastModified = lastModified;
|
||||
this.permissions = permissions;
|
||||
}
|
||||
|
||||
// Getters
|
||||
public String getName() { return name; }
|
||||
public boolean isDirectory() { return isDirectory; }
|
||||
public long getSize() { return size; }
|
||||
public long getLastModified() { return lastModified; }
|
||||
public String getPermissions() { return permissions; }
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.mini.capi.webssh.service;
|
||||
|
||||
import com.jcraft.jsch.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SSHConnectionManager {
|
||||
|
||||
private final Map<String, Session> connections = new ConcurrentHashMap<>();
|
||||
private final Map<String, ChannelShell> channels = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 建立SSH连接
|
||||
*/
|
||||
public String createConnection(String host, int port, String username, String password) {
|
||||
try {
|
||||
JSch jsch = new JSch();
|
||||
Session session = jsch.getSession(username, host, port);
|
||||
|
||||
// 配置连接参数
|
||||
Properties config = new Properties();
|
||||
config.put("StrictHostKeyChecking", "no");
|
||||
config.put("PreferredAuthentications", "password");
|
||||
session.setConfig(config);
|
||||
session.setPassword(password);
|
||||
|
||||
// 建立连接
|
||||
session.connect(30000); // 30秒超时
|
||||
|
||||
// 创建Shell通道
|
||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||
channel.setPty(true);
|
||||
channel.setPtyType("xterm", 80, 24, 640, 480);
|
||||
|
||||
// 生成连接ID
|
||||
String connectionId = UUID.randomUUID().toString();
|
||||
|
||||
// 保存连接和通道
|
||||
connections.put(connectionId, session);
|
||||
channels.put(connectionId, channel);
|
||||
|
||||
log.info("SSH连接建立成功: {}@{}:{}", username, host, port);
|
||||
return connectionId;
|
||||
|
||||
} catch (JSchException e) {
|
||||
log.error("SSH连接失败: {}", e.getMessage());
|
||||
throw new RuntimeException("SSH连接失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SSH通道
|
||||
*/
|
||||
public ChannelShell getChannel(String connectionId) {
|
||||
return channels.get(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取SSH会话
|
||||
*/
|
||||
public Session getSession(String connectionId) {
|
||||
return connections.get(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭SSH连接
|
||||
*/
|
||||
public void closeConnection(String connectionId) {
|
||||
ChannelShell channel = channels.remove(connectionId);
|
||||
if (channel != null && channel.isConnected()) {
|
||||
channel.disconnect();
|
||||
}
|
||||
|
||||
Session session = connections.remove(connectionId);
|
||||
if (session != null && session.isConnected()) {
|
||||
session.disconnect();
|
||||
}
|
||||
|
||||
log.info("SSH连接已关闭: {}", connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*/
|
||||
public boolean isConnected(String connectionId) {
|
||||
Session session = connections.get(connectionId);
|
||||
return session != null && session.isConnected();
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package com.mini.capi.webssh.websocket;
|
||||
|
||||
import com.mini.capi.webssh.service.SSHConnectionManager;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jcraft.jsch.ChannelShell;
|
||||
import com.jcraft.jsch.JSchException;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SSHWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
@Resource
|
||||
private SSHConnectionManager connectionManager;
|
||||
|
||||
private final Map<WebSocketSession, String> sessionConnections = new ConcurrentHashMap<>();
|
||||
private final Map<WebSocketSession, String> sessionUsers = new ConcurrentHashMap<>();
|
||||
|
||||
// 为每个WebSocket会话添加同步锁
|
||||
private final Map<WebSocketSession, Object> sessionLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) {
|
||||
log.info("WebSocket连接建立: {}", session.getId());
|
||||
// 为每个会话创建同步锁
|
||||
sessionLocks.put(session, new Object());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
try {
|
||||
String payload = message.getPayload();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode jsonNode = mapper.readTree(payload);
|
||||
|
||||
String type = jsonNode.get("type").asText();
|
||||
|
||||
switch (type) {
|
||||
case "connect":
|
||||
handleConnect(session, jsonNode);
|
||||
break;
|
||||
case "command":
|
||||
handleCommand(session, jsonNode);
|
||||
break;
|
||||
case "resize":
|
||||
handleResize(session, jsonNode);
|
||||
break;
|
||||
case "disconnect":
|
||||
handleDisconnect(session);
|
||||
break;
|
||||
default:
|
||||
log.warn("未知的消息类型: {}", type);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("处理WebSocket消息失败", e);
|
||||
sendError(session, "处理消息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SSH连接请求
|
||||
*/
|
||||
private void handleConnect(WebSocketSession session, JsonNode jsonNode) {
|
||||
try {
|
||||
String host = jsonNode.get("host").asText();
|
||||
int port = jsonNode.get("port").asInt(22);
|
||||
String username = jsonNode.get("username").asText();
|
||||
String password = jsonNode.get("password").asText();
|
||||
boolean enableCollaboration = jsonNode.has("enableCollaboration") &&
|
||||
jsonNode.get("enableCollaboration").asBoolean();
|
||||
|
||||
// 存储用户信息
|
||||
sessionUsers.put(session, username);
|
||||
|
||||
// 建立SSH连接
|
||||
String connectionId = connectionManager.createConnection(host, port, username, password);
|
||||
sessionConnections.put(session, connectionId);
|
||||
|
||||
// 启动SSH通道
|
||||
ChannelShell channel = connectionManager.getChannel(connectionId);
|
||||
startSSHChannel(session, channel);
|
||||
|
||||
// 发送连接成功消息
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("type", "connected");
|
||||
response.put("message", "SSH连接建立成功");
|
||||
sendMessage(session, response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("建立SSH连接失败", e);
|
||||
sendError(session, "连接失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理命令执行请求
|
||||
*/
|
||||
private void handleCommand(WebSocketSession session, JsonNode jsonNode) {
|
||||
String connectionId = sessionConnections.get(session);
|
||||
if (connectionId == null) {
|
||||
sendError(session, "SSH连接未建立");
|
||||
return;
|
||||
}
|
||||
|
||||
String command = jsonNode.get("command").asText();
|
||||
ChannelShell channel = connectionManager.getChannel(connectionId);
|
||||
String username = sessionUsers.get(session);
|
||||
|
||||
if (channel != null && channel.isConnected()) {
|
||||
try {
|
||||
// 发送命令到SSH通道
|
||||
OutputStream out = channel.getOutputStream();
|
||||
out.write(command.getBytes());
|
||||
out.flush();
|
||||
} catch (IOException e) {
|
||||
log.error("发送SSH命令失败", e);
|
||||
sendError(session, "命令执行失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动SSH通道并处理输出
|
||||
*/
|
||||
private void startSSHChannel(WebSocketSession session, ChannelShell channel) {
|
||||
try {
|
||||
// 连接通道
|
||||
channel.connect();
|
||||
|
||||
// 处理SSH输出
|
||||
InputStream in = channel.getInputStream();
|
||||
|
||||
// 在单独的线程中读取SSH输出
|
||||
new Thread(() -> {
|
||||
byte[] buffer = new byte[4096];
|
||||
try {
|
||||
while (channel.isConnected() && session.isOpen()) {
|
||||
if (in.available() > 0) {
|
||||
int len = in.read(buffer);
|
||||
if (len > 0) {
|
||||
String output = new String(buffer, 0, len, "UTF-8");
|
||||
|
||||
// 发送给当前会话
|
||||
sendMessage(session, Map.of(
|
||||
"type", "output",
|
||||
"data", output
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// 没有数据时短暂休眠,避免CPU占用过高
|
||||
Thread.sleep(10);
|
||||
}
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn("SSH输出读取中断: {}", e.getMessage());
|
||||
}
|
||||
}, "SSH-Output-Reader-" + session.getId()).start();
|
||||
|
||||
} catch (JSchException | IOException e) {
|
||||
log.error("启动SSH通道失败", e);
|
||||
sendError(session, "通道启动失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理终端大小调整
|
||||
*/
|
||||
private void handleResize(WebSocketSession session, JsonNode jsonNode) {
|
||||
String connectionId = sessionConnections.get(session);
|
||||
if (connectionId != null) {
|
||||
ChannelShell channel = connectionManager.getChannel(connectionId);
|
||||
if (channel != null) {
|
||||
try {
|
||||
int cols = jsonNode.get("cols").asInt();
|
||||
int rows = jsonNode.get("rows").asInt();
|
||||
channel.setPtySize(cols, rows, cols * 8, rows * 16);
|
||||
} catch (Exception e) {
|
||||
log.warn("调整终端大小失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理断开连接
|
||||
*/
|
||||
private void handleDisconnect(WebSocketSession session) {
|
||||
String connectionId = sessionConnections.remove(session);
|
||||
String username = sessionUsers.remove(session);
|
||||
|
||||
if (connectionId != null) {
|
||||
connectionManager.closeConnection(connectionId);
|
||||
}
|
||||
// 清理锁资源
|
||||
sessionLocks.remove(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
handleDisconnect(session);
|
||||
log.info("WebSocket连接关闭: {}", session.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到WebSocket客户端(线程安全)
|
||||
*/
|
||||
private void sendMessage(WebSocketSession session, Object message) {
|
||||
Object lock = sessionLocks.get(session);
|
||||
if (lock == null) return;
|
||||
|
||||
synchronized (lock) {
|
||||
try {
|
||||
if (session.isOpen()) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String json = mapper.writeValueAsString(message);
|
||||
session.sendMessage(new TextMessage(json));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("发送WebSocket消息失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误消息
|
||||
*/
|
||||
private void sendError(WebSocketSession session, String error) {
|
||||
sendMessage(session, Map.of(
|
||||
"type", "error",
|
||||
"message", error
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话中获取用户信息
|
||||
*/
|
||||
private String getUserFromSession(WebSocketSession session) {
|
||||
// 简化实现,实际应用中可以从session中获取认证用户信息
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话中获取主机信息
|
||||
*/
|
||||
private String getHostFromSession(WebSocketSession session) {
|
||||
// 简化实现,实际应用中可以保存连接信息
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,3 @@ logging.level.root=INFO
|
||||
logging.level.com.example.webssh=DEBUG
|
||||
logging.file.name=logs/webssh.log
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
|
||||
# ===============================
|
||||
# Custom WebSSH
|
||||
# ===============================
|
||||
webssh.ssh.connection-timeout=30000
|
||||
webssh.ssh.session-timeout=1800000
|
||||
webssh.ssh.max-connections-per-user=10
|
||||
webssh.file.upload-max-size=100MB
|
||||
webssh.file.temp-dir=/ogsapp/temp/webssh-uploads
|
||||
webssh.collaboration.enabled=true
|
||||
webssh.collaboration.max-participants=10
|
||||
webssh.collaboration.session-timeout=3600000
|
||||
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration
|
||||
@@ -1 +0,0 @@
|
||||
@charset "UTF-8";.login-wrapper[data-v-c077d3e4]{height:100vh;display:flex;align-items:center;justify-content:flex-end;padding-right:200px;background:linear-gradient(120deg,rgba(79,172,254,.7) 0%,rgba(0,242,254,.7) 50%,rgba(122,90,248,.7) 100%),url(/cApi/assets/backImg.5cfc718b.jpg) center center no-repeat;background-blend-mode:overlay;background-size:cover}.login-text[data-v-c077d3e4]{text-align:center;color:#fff;width:800px;padding:32px 48px;border-radius:8px}.login-card[data-v-c077d3e4]{width:520px;padding:32px 48px;border-radius:8px;box-shadow:0 8px 24px #0000001f}.login-header[data-v-c077d3e4]{text-align:center;margin-bottom:32px}.login-header .logo[data-v-c077d3e4]{width:64px;height:64px;margin-bottom:12px}.login-header h1[data-v-c077d3e4]{font-size:24px;font-weight:600;margin:0}.login-header p[data-v-c077d3e4]{font-size:14px;color:#8c8c8c;margin:8px 0 0}
|
||||
@@ -1 +0,0 @@
|
||||
import{d as S,_ as E,a as h,u as D,r as f,b as k,o as w,c as y,e as u,w as o,f as s,g as c,h as _,U as x,L as b,i as L,m}from"./index.04365c99.js";import{a as N}from"./auth.545ce83f.js";var O="/cApi/assets/logo.03d6d6da.png";const U=S("auth",{state:()=>({token:localStorage.getItem("token")||null,userInfo:localStorage.getItem("userInfo")?JSON.parse(localStorage.getItem("userInfo")):null}),actions:{setAuthInfo(i,r){this.token=i,this.userInfo=r,localStorage.setItem("token",i),localStorage.setItem("userInfo",JSON.stringify(r))},clearAuthInfo(){this.token=null,this.userInfo=null,localStorage.removeItem("token"),localStorage.removeItem("userInfo")}}});const J={class:"login-wrapper"},R=h({__name:"Login",setup(i){const r=D(),g=f(),n=f(!1),t=k({account:"",password:""}),F={account:[{required:!0,message:"\u8BF7\u8F93\u5165\u7528\u6237\u540D"}],password:[{required:!0,message:"\u8BF7\u8F93\u5165\u5BC6\u7801"}]},B=async()=>{if(n.value)return;n.value=!0;const p=U();try{const e=await N(t);if(e.code===200&&e.data){const l=e.data.token,a=JSON.stringify(e.data);localStorage.setItem("token",l),localStorage.setItem("userInfo",a),p.setAuthInfo(l,a),m.success(e.msg||"\u767B\u5F55\u6210\u529F"),setTimeout(()=>{r.replace("/index/console")},800);return}m.error(e.msg||"\u767B\u5F55\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u8D26\u53F7\u5BC6\u7801")}catch(e){console.error("\u767B\u5F55\u8BF7\u6C42\u5F02\u5E38:",e),m.error("\u7F51\u7EDC\u5F02\u5E38\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u540E\u91CD\u8BD5")}finally{n.value=!1}};return(p,e)=>{const l=s("a-input"),a=s("a-form-item"),v=s("a-input-password"),I=s("a-button"),A=s("a-form"),C=s("a-card");return w(),y("div",J,[u(C,{class:"login-card",bordered:!1},{default:o(()=>[e[3]||(e[3]=c("div",{class:"login-header"},[c("img",{class:"logo",src:O,alt:"logo"}),c("h1",null,"cApi\u7BA1\u7406\u7CFB\u7EDF"),c("p",null,"\u5B89\u5168\u3001\u9AD8\u6548\u7684\u4E00\u7AD9\u5F0F\u7BA1\u7406\u89E3\u51B3\u65B9\u6848\uFF0C\u4E3A\u60A8\u7684\u4E1A\u52A1\u4FDD\u9A7E\u62A4\u822A")],-1)),u(A,{ref_key:"formRef",ref:g,model:t,rules:F,size:"large",onFinish:B},{default:o(()=>[u(a,{name:"account"},{default:o(()=>[u(l,{value:t.account,"onUpdate:value":e[0]||(e[0]=d=>t.account=d),placeholder:"\u8BF7\u8F93\u5165\u7528\u6237\u540D","allow-clear":""},{prefix:o(()=>[u(_(x))]),_:1},8,["value"])]),_:1}),u(a,{name:"password"},{default:o(()=>[u(v,{value:t.password,"onUpdate:value":e[1]||(e[1]=d=>t.password=d),placeholder:"\u8BF7\u8F93\u5165\u5BC6\u7801","allow-clear":""},{prefix:o(()=>[u(_(b))]),_:1},8,["value"])]),_:1}),u(a,null,{default:o(()=>[u(I,{type:"primary","html-type":"submit",loading:n.value,block:""},{default:o(()=>[...e[2]||(e[2]=[L(" \u767B\u5F55 ",-1)])]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])]),_:1})])}}});var T=E(R,[["__scopeId","data-v-c077d3e4"]]);export{T as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as r}from"./index.04365c99.js";const e={};function j(_,c){return" jjjjjjjjj "}var t=r(e,[["render",j]]);export{t as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as r}from"./index.04365c99.js";const e={};function t(_,c){return" \u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876\u9876 "}var o=r(e,[["render",t]]);export{o as default};
|
||||
|
Before Width: | Height: | Size: 83 KiB |
@@ -1 +0,0 @@
|
||||
.icon-gallery[data-v-64d10c62]{width:100%;box-sizing:border-box}.search-input[data-v-64d10c62]{max-width:400px}.icons-grid[data-v-64d10c62]{width:100%;box-sizing:border-box}.icon-item[data-v-64d10c62]:hover{border-color:#1890ff;box-shadow:0 2px 8px #00000014}.icon-name[data-v-64d10c62]{font-size:12px;color:#595959;text-align:center;word-break:break-all;max-width:100%}.copy-buttons[data-v-64d10c62]{display:flex;gap:8px;margin-top:8px;width:100%;justify-content:center}.copy-btn[data-v-64d10c62]{padding:2px 8px!important;font-size:12px!important}.empty-state[data-v-64d10c62]{padding:40px 0;text-align:center}.copy-message[data-v-64d10c62]{position:fixed;bottom:24px;left:50%;transform:translate(-50%);z-index:1000}
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as U,a as W,I as q,r as a,n as u,o as s,c as l,j as g,t as p,g as w,F as G,y as H,A as m,e as r,f as y,z as J,v as K,w as S,h as F,D as T,i as z,k as B}from"./index.04365c99.js";const Q=["onMouseenter"],X={class:"icon-name"},Y={key:0,class:"copy-buttons"},Z={key:1,class:"empty-state"},ee=W({__name:"icon",props:{showSearch:{type:Boolean,default:!0},searchSize:{type:String,default:"middle"},columns:{type:Number,default:6},iconSize:{type:Number,default:24},itemPadding:{type:String,default:"16px"},messageDuration:{type:Number,default:2e3}},setup(c){const n=c,N=["createFromIconfontCN","getTwoToneColor","setTwoToneColor","default","CopyOutlined"],_=Object.entries(q).filter(([e])=>!N.includes(e)).map(([e,t])=>({name:e,component:t})),i=a(""),C=u(()=>i.value?_.filter(({name:e})=>e.toLowerCase().includes(i.value.toLowerCase())):_),f=a(""),v=a(!1),h=a(""),D=a("success");function M(e){f.value=e}function $(){f.value=""}function E(e){const t=`<${e} />`;x(t).then(()=>{d(`\u5DF2\u590D\u5236: ${t}`,"success")}).catch(()=>{d("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u590D\u5236","error")})}function I(e){x(e).then(()=>{d(`\u5DF2\u590D\u5236: ${e}`,"success")}).catch(()=>{d("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u590D\u5236","error")})}async function x(e){try{return await navigator.clipboard.writeText(e),!0}catch(t){return console.error("\u65E0\u6CD5\u590D\u5236\u6587\u672C: ",t),!1}}function d(e,t){h.value=e,D.value=t,v.value=!0,setTimeout(()=>{v.value=!1},n.messageDuration)}const L=u(()=>({padding:"16px",backgroundColor:"#f5f5f5",borderRadius:"4px"})),A=u(()=>({display:"grid",gridTemplateColumns:`repeat(${n.columns}, 1fr)`,gap:"12px",marginTop:n.showSearch?"16px":0})),V=u(()=>({display:"flex",flexDirection:"column",alignItems:"center",backgroundColor:"#fff",border:"1px solid #e8e8e8",borderRadius:"6px",padding:n.itemPadding,cursor:"pointer",transition:"all 0.2s"})),O=u(()=>({fontSize:`${n.iconSize}px`,color:"#262626",marginBottom:"8px"}));return(e,t)=>{const j=y("a-input"),b=y("a-button"),P=y("a-empty"),R=y("a-message");return s(),l("div",{class:"icon-gallery",style:m(L.value)},[c.showSearch?(s(),g(j,{key:0,value:i.value,"onUpdate:value":t[0]||(t[0]=o=>i.value=o),placeholder:"\u641C\u7D22\u56FE\u6807\u540D\u79F0...","allow-clear":"",class:"search-input",size:c.searchSize},null,8,["value","size"])):p("",!0),w("div",{class:"icons-grid",style:m(A.value)},[(s(!0),l(G,null,H(C.value,o=>(s(),l("div",{key:o.name,class:"icon-item",style:m(V.value),onMouseenter:k=>M(o.name),onMouseleave:$},[(s(),g(J(o.component),{class:"icon",style:m(O.value)},null,8,["style"])),w("span",X,K(o.name),1),f.value===o.name?(s(),l("div",Y,[r(b,{type:"primary",size:"small",onClick:B(k=>E(o.name),["stop"]),class:"copy-btn"},{default:S(()=>[r(F(T),{class:"mr-1"}),t[1]||(t[1]=z("\u6807\u7B7E ",-1))]),_:1},8,["onClick"]),r(b,{type:"default",size:"small",onClick:B(k=>I(o.name),["stop"]),class:"copy-btn"},{default:S(()=>[r(F(T),{class:"mr-1"}),t[2]||(t[2]=z("\u540D\u79F0 ",-1))]),_:1},8,["onClick"])])):p("",!0)],44,Q))),128))],4),C.value.length===0?(s(),l("div",Z,[r(P,{description:"\u6CA1\u6709\u627E\u5230\u5339\u914D\u7684\u56FE\u6807"})])):p("",!0),v.value?(s(),g(R,{key:2,content:h.value,type:D.value,duration:c.messageDuration,class:"copy-message"},null,8,["content","type","duration"])):p("",!0)],4)}}});var oe=U(ee,[["__scopeId","data-v-64d10c62"]]);export{oe as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as r}from"./index.04365c99.js";const e={};function _(c,n){return" app "}var a=r(e,[["render",_]]);export{a as default};
|
||||
@@ -1 +0,0 @@
|
||||
.empty-page-container[data-v-2de931e9]{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#fff;border-radius:8px;box-shadow:0 1px 2px #0000000d}.empty-content[data-v-2de931e9]{width:100%;max-width:600px;padding:48px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:20px}.empty-icon[data-v-2de931e9]{width:80px;height:80px;border-radius:50%;background-color:#e8f4ff;display:flex;align-items:center;justify-content:center;color:#1890ff}.empty-icon[data-v-2de931e9] svg{width:40px;height:40px}.empty-title[data-v-2de931e9]{font-size:18px;font-weight:500;color:#333;margin:0}.empty-desc[data-v-2de931e9]{font-size:14px;color:#666;line-height:1.6;margin:0}.empty-actions[data-v-2de931e9]{display:flex;gap:12px;margin-top:8px}.empty-meta[data-v-2de931e9]{margin-top:16px;display:flex;flex-wrap:wrap;justify-content:center;gap:16px;font-size:12px;color:#999}@media (max-width: 768px){.empty-content[data-v-2de931e9]{padding:32px 16px;gap:16px}.empty-icon[data-v-2de931e9]{width:64px;height:64px}.empty-icon[data-v-2de931e9] svg{width:32px;height:32px}.empty-title[data-v-2de931e9]{font-size:16px}.empty-meta[data-v-2de931e9]{flex-direction:column;gap:8px}}
|
||||
@@ -1,4 +0,0 @@
|
||||
import{_ as B,a as E,r as d,o as p,c as r,g as e,e as t,h as a,E as A,i as s,w as F,f as m,P as c,Q as C}from"./index.04365c99.js";const _={class:"empty-page-container"},D={class:"empty-content"},v={class:"empty-icon"},y={class:"empty-actions"},f=E({__name:"index",setup(x){const n=d("\u7A7A\u767D\u6A21\u677F\u9875\u9762"),l=()=>{console.log(`[${n.value}] \u89E6\u53D1\u300C\u521B\u5EFA\u5185\u5BB9\u300D\u64CD\u4F5C`),alert("\u53EF\u5728\u6B64\u5904\u5B9E\u73B0\u300C\u521B\u5EFA\u5185\u5BB9\u300D\u7684\u4E1A\u52A1\u903B\u8F91")},i=()=>{console.log(`[${n.value}] \u67E5\u770B\u4F7F\u7528\u6307\u5357`),alert(`\u4F7F\u7528\u6307\u5357\uFF1A
|
||||
1. \u6B64\u9875\u9762\u5DF2\u9002\u914D\u7236\u5BB9\u5668\u81EA\u9002\u5E94\u5C3A\u5BF8
|
||||
2. \u53EF\u5728 empty-content \u5185\u6DFB\u52A0\u4E1A\u52A1\u7EC4\u4EF6
|
||||
3. \u6837\u5F0F\u53EF\u53C2\u8003\u539F\u9879\u76EE\u89C4\u8303\u8C03\u6574`)};return(g,u)=>{const o=m("a-button");return p(),r("div",_,[e("div",D,[e("div",v,[t(a(A))]),u[2]||(u[2]=e("h2",{class:"empty-title"},"\u8FD9\u662F\u4E00\u4E2A\u7A7A\u767D\u9875\u9762",-1)),u[3]||(u[3]=e("p",{class:"empty-desc"},[s(" \u4F60\u53EF\u4EE5\u5728\u8FD9\u91CC\u6269\u5C55\u529F\u80FD\uFF0C\u6BD4\u5982\u6DFB\u52A0\u6570\u636E\u5C55\u793A\u3001\u8868\u5355\u63D0\u4EA4\u3001\u56FE\u8868\u5206\u6790\u7B49\u5185\u5BB9"),e("br"),s(" \u9875\u9762\u5DF2\u9002\u914D\u7236\u5BB9\u5668\u5C3A\u5BF8\uFF0C\u652F\u6301\u9AD8\u5EA6/\u5BBD\u5EA6\u81EA\u9002\u5E94 ")],-1)),e("div",y,[t(o,{type:"primary",onClick:l},{default:F(()=>[t(a(c),{class:"mr-2"}),u[0]||(u[0]=s(" \u521B\u5EFA\u5185\u5BB9 ",-1))]),_:1}),t(o,{onClick:i,style:{"margin-left":"12px"}},{default:F(()=>[t(a(C),{class:"mr-2"}),u[1]||(u[1]=s(" \u67E5\u770B\u4F7F\u7528\u6307\u5357 ",-1))]),_:1})]),u[4]||(u[4]=e("div",{class:"empty-meta"},[e("span",{class:"meta-item"},"\u9875\u9762\u8DEF\u5F84\uFF1A@/views/EmptyTemplatePage.vue"),e("span",{class:"meta-item"},"\u9002\u914D\u573A\u666F\uFF1A\u5217\u8868\u9875\u3001\u8BE6\u60C5\u9875\u3001\u8868\u5355\u9875\u7B49\u521D\u59CB\u6A21\u677F")],-1))])])}}});var N=B(f,[["__scopeId","data-v-2de931e9"]]);export{N as default};
|
||||
@@ -1 +0,0 @@
|
||||
html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}@-ms-viewport{width:device-width}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{-webkit-text-decoration:underline dotted;text-decoration:underline;text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type="range"]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/cApi/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cApi系统管理</title>
|
||||
<script type="module" crossorigin src="/cApi/assets/index.04365c99.js"></script>
|
||||
<link rel="stylesheet" href="/cApi/assets/index.fc5e98cf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 23 KiB |