重写复现方法

This commit is contained in:
2025-09-05 15:10:25 +08:00
parent a4ef2d7770
commit 89ed87e362
70 changed files with 14 additions and 8797 deletions

View File

@@ -1,5 +0,0 @@
# 接口前缀
VITE_BASE_API = '/cApi'
# 后端真实地址
VITE_SERVER_URL = 'http://127.0.0.1:31001'

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,4 +0,0 @@
<template>
<!-- 所有页面都会在这里渲染 -->
<router-view />
</template>

View File

@@ -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')

View File

@@ -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 || []
}

View File

@@ -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 || []
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -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
>
&lt;
</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
>
&gt;
</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>

View File

@@ -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>

View File

@@ -1,9 +0,0 @@
<template>
jjjjjjjjj
</template>
<script>
</script>
<style>
</style>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,8 +0,0 @@
<template>
</template>
<script>
</script>
<style>
</style>

View File

@@ -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 }
}

View File

@@ -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;

View File

@@ -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')

View File

@@ -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

View File

@@ -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')
}
}
})

View File

@@ -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

View File

@@ -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 '访客';
}

View File

@@ -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>

View File

@@ -1,9 +0,0 @@
<template>
app
</template>
<script>
</script>
<style>
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,9 +0,0 @@
<template>
顶顶顶顶顶顶顶顶顶顶顶顶顶顶
</template>
<script>
</script>
<style>
</style>

View File

@@ -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>

View File

@@ -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')
}
}
})
}

View File

@@ -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;
}
}

View File

@@ -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/**"
);
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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";
}
}

View File

@@ -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("*"); // 生产环境中应该限制域名
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}

View File

@@ -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";
}
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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};

View File

@@ -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};

View File

@@ -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};

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -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}

View File

@@ -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};

View File

@@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}}

View File

@@ -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};

View File

@@ -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}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB