通知合并 #3

Closed
hnu202326010419 wants to merge 1 commits from zhaowenqi_branch into develop

@ -1,2 +0,0 @@
# Baoma

@ -1,2 +0,0 @@
# Baoma

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

@ -0,0 +1,96 @@
/**
* @fileoverview ESLint configuration for Vue 3 + TypeScript
* Follows Google Style Guide, with necessary overrides for Vue/TS.
* * Google 规范要求4空格缩进必须使用分号
* Vue/TS 社区惯例2空格缩进可选分号
* * 此配置在继承 Google 规范的基础上强制使用 2空格缩进和不使用分号
* 以便更好地适应现代前端开发环境
*/
module.exports = {
// 指定环境
'env': {
'browser': true,
'es2021': true,
'node': true
},
// 指定解析器
'parser': 'vue-eslint-parser',
'parserOptions': {
// 解析 <script> 块中的代码
'parser': '@typescript-eslint/parser',
'ecmaVersion': 'latest',
'sourceType': 'module',
'project': './tsconfig.json', // 引用您的 TypeScript 配置文件
'extraFileExtensions': ['.vue']
},
// 继承配置:这是核心!
'extends': [
// 1. Vue 3 推荐规则
'plugin:vue/vue3-recommended',
// 2. Google 编码规范 (必须在 Vue 规则之后,以便覆盖 Vue 的默认规则)
'google',
// 3. TypeScript 推荐规则
'plugin:@typescript-eslint/recommended',
// 4. Prettier (如果使用 Prettier 来处理格式化,放在最后以避免冲突)
'prettier'
],
// 规则覆盖和自定义
'rules': {
// ------------------------------------------------
// 强制覆盖 Google 规范的规则 (以适应 Vue/TS 社区惯例)
// ------------------------------------------------
// 1. 缩进Google 默认 4 空格,这里强制使用 2 空格
// Vue 模板的缩进也需要通过 'plugin:vue/vue3-recommended' 进行配置
'indent': ['error', 2, {
'SwitchCase': 1,
// 允许 Vue 模板中的表达式换行
'FunctionExpression': { 'parameters': 'first' },
'CallExpression': { 'arguments': 'first' },
'ArrayExpression': 'first',
'ObjectExpression': 'first'
}],
// 2. 分号Google 默认必须有分号,这里强制不使用分号 (现代JS风格)
'semi': ['error', 'never'],
// 3. 引号Google 默认单引号,这里保持单引号
'quotes': ['error', 'single'],
// 4. 最大行长Google 默认 100这里放宽到 120 (以适应现代宽屏)
'max-len': ['error', {
'code': 120,
'ignoreUrls': true,
'ignoreStrings': true,
'ignoreTemplateLiterals': true
}],
// ------------------------------------------------
// 针对 Vue 组件和 TypeScript 的规则
// ------------------------------------------------
// 禁用 TypeScript 的 `no-var-requires`,在某些 node 环境中可能需要 require
'@typescript-eslint/no-var-requires': 'off',
// 允许使用 any但在迁移阶段有用 (完成后建议设为 'error')
'@typescript-eslint/no-explicit-any': 'off',
// Vue 组件名必须是 PascalCase (例如: MyComponent)
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
'registeredComponentsOnly': false
}],
// Vue 属性排序规则
'vue/attributes-order': 'error',
'vue/html-self-closing': ['error', {
'html': {
'void': 'always',
'normal': 'always',
'component': 'always'
}
}]
}
};

@ -0,0 +1 @@
* text=auto eol=lf

@ -8,17 +8,29 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

@ -0,0 +1,40 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
// 自定义规则
{
rules: {
// 忽略以下划线开头的未使用变量(用于占位参数)
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
},
},
skipFormatting,
)

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自然语言数据库查询系统</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<!-- 引入 Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 配置自定义主题 -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#165DFF',
secondary: '#36BFFA',
neutral: '#F5F7FA',
dark: '#1D2129',
success: '#00B42A',
warning: '#FF7D00',
danger: '#F53F3F',
},
fontFamily: {
inter: ['Inter', 'sans-serif'],
}
}
}
}
</script>
<!-- 自定义 CSS -->
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-neutral font-inter text-dark min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,55 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tsconfig/node24": "^24.0.3",
"@types/chart.js": "^2.9.41",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vitest/eslint-plugin": "^1.5.0",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-config-google": "^0.14.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-vue": "~10.5.1",
"jiti": "^2.6.1",
"jsdom": "^27.2.0",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.0",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.14",
"vue-tsc": "^3.1.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -0,0 +1,494 @@
<!--
@file App.vue
@description 应用程序根组件
===== 核心职责 =====
1. 根据用户角色渲染对应的 MainLayout 和页面组件
2. 管理全局状态用户信息角色页面路由
3. 处理页面导航和事件转发不处理具体业务逻辑
===== 架构设计原则 =====
- App.vue 只负责路由和状态管理不包含具体页面逻辑
- 具体页面逻辑在各自的 Page 组件中UserPageDataAdminPageSysAdminPage
- QueryPage 的状态管理已移至 QueryPage.vue 内部
- MainLayout 负责布局侧边栏TopHeader内容区域
===== 用户角色与页面映射 =====
- sys-admin: 系统管理员
* 页面dashboard, user-management, system-log, llm-config, notification-management, account, settings
* 无查询功能无历史面板
- data-admin: 数据管理员
* 页面dashboard, query, history, datasource, user-permission, notification-management,
connection-log, notifications, account, friends, settings
* 有查询功能有历史面板
- normal-user: 普通用户
* 页面query, history, notifications, friends, account, settings
* 有查询功能有历史面板
===== 状态管理说明 =====
- userRole: 当前用户角色null 表示未登录
- currentUser: 当前用户基本信息name, avatarUrl
- activePage: 普通用户的当前页面Page 类型
- activeSysAdminPage: 系统管理员的当前页面SysAdminPageType 类型
- activeDataAdminPage: 数据管理员的当前页面DataAdminPageType 类型
- queryPageTitle: 查询页面的动态标题 QueryPage 通过事件更新
===== 事件流转说明 =====
1. 登录LoginPage @login -> handleLogin -> loadUserInfo
2. 页面切换Sidebar @update:active-page -> setActivePage/setActiveSysAdminPage/setActiveDataAdminPage
3. 查询标题更新QueryPage @update:title -> handleUpdateQueryTitle -> queryPageTitle
4. 通知点击TopHeader @notification-click -> handleNotificationClick -> 切换到 notifications 页面
5. 头像点击TopHeader @avatar-click -> handleAvatarClick -> 切换到 account 页面
6. 新对话TopHeader @new-conversation -> handleNewConversation -> 切换到 query 页面
7. 历史面板TopHeader @toggle-history -> handleToggleHistory -> 转发到 QueryPage空实现逻辑在子组件
@author Frontend Team
@since 1.0.0
-->
<template>
<!-- 未登录状态 - 登录页面 -->
<LoginPage v-if="!userRole" @login="handleLogin" />
<!-- 系统管理员页面 -->
<MainLayout
v-else-if="userRole === 'sys-admin'"
:user-role="userRole"
:current-user="currentUser"
:active-page="activeSysAdminPage"
:header-title="headerTitle"
:is-query-page="false"
@update:active-page="setActiveSysAdminPage"
@logout="handleLogout"
@notification-click="handleNotificationClick"
@avatar-click="handleAvatarClick"
>
<SysAdminPage :active-page="activeSysAdminPage" @update:active-page="setActiveSysAdminPage" />
</MainLayout>
<!-- 数据管理员页面 -->
<MainLayout
v-else-if="userRole === 'data-admin'"
:user-role="userRole"
:current-user="currentUser"
:active-page="activeDataAdminPage"
:header-title="headerTitle"
:is-query-page="activeDataAdminPage === 'query'"
@update:active-page="setActiveDataAdminPage"
@logout="handleLogout"
@notification-click="handleNotificationClick"
@avatar-click="handleAvatarClick"
@toggle-history="handleToggleHistory"
@new-conversation="handleNewConversation"
@update:query-title="handleUpdateQueryTitle"
>
<DataAdminPage
ref="dataAdminPageRef"
:active-page="activeDataAdminPage"
:set-active-page="setActiveDataAdminPage"
@update:query-title="handleUpdateQueryTitle"
@switch-to-query="activeDataAdminPage = 'query'"
/>
</MainLayout>
<!-- 普通用户页面 -->
<MainLayout
v-else-if="userRole === 'normal-user'"
:user-role="userRole"
:current-user="currentUser"
:active-page="activePage"
:header-title="headerTitle"
:is-query-page="activePage === 'query'"
@update:active-page="setActivePage"
@logout="handleLogout"
@notification-click="handleNotificationClick"
@avatar-click="handleAvatarClick"
@toggle-history="handleToggleHistory"
@new-conversation="handleNewConversation"
@update:query-title="handleUpdateQueryTitle"
>
<UserPage
ref="userPageRef"
:active-page="activePage"
@update:query-title="handleUpdateQueryTitle"
@switch-to-query="activePage = 'query'"
/>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
type UserRole,
type Page,
type SysAdminPageType,
type DataAdminPageType,
} from './types'
//
import LoginPage from './views/LoginPage.vue'
import UserPage from './views/UserPage.vue'
import SysAdminPage from './views/admin/SysAdminPage.vue'
import DataAdminPage from './views/DataAdminPage.vue'
import MainLayout from './components/layout/MainLayout.vue'
// API
import { userApi } from './services/api.real'
/**
* 侧边栏页面名称映射用于显示 TopHeader 标题
*
* 逻辑说明
* - 查询页面query不使用此函数使用 queryPageTitle
* - 其他页面根据角色和页面 key 返回对应的中文名称
* - 如果找不到对应页面返回空字符串
*/
const getSidebarPageName = (role: UserRole | null, page: string) => {
const normalUserPages: Record<string, string> = {
history: '收藏夹',
notifications: '通知中心',
friends: '好友管理',
account: '账户管理',
settings: '设置',
}
const dataAdminPages: Record<string, string> = {
history: '收藏夹',
datasource: '数据源管理',
dashboard: '数据源概览',
'user-permission': '用户权限管理',
'notification-management': '通知管理(数据员)',
'connection-log': '数据源连接日志',
notifications: '通知中心',
account: '我的账户',
friends: '好友管理',
settings: '设置',
}
const sysAdminPages: Record<string, string> = {
dashboard: '系统概览',
'user-management': '用户管理',
account: '我的账户',
'system-log': '系统日志',
'llm-config': '大模型配置',
'notification-management': '通知管理',
settings: '设置',
}
if (role === 'normal-user') {
return normalUserPages[page] || ''
} else if (role === 'data-admin') {
return dataAdminPages[page] || ''
} else if (role === 'sys-admin') {
return sysAdminPages[page] || ''
}
return ''
}
// ===== =====
/** 当前用户角色null 表示未登录 */
const userRole = ref<UserRole | null>(null)
/** 当前用户基本信息 */
const currentUser = ref({ name: '', avatarUrl: '' })
// ref
const userPageRef = ref<InstanceType<typeof UserPage> | null>(null)
const dataAdminPageRef = ref<InstanceType<typeof DataAdminPage> | null>(null)
// ===== =====
/** 普通用户的当前页面,默认为查询页面 */
const activePage = ref<Page>('query')
/** 系统管理员的当前页面,默认为仪表盘 */
const activeSysAdminPage = ref<SysAdminPageType>('dashboard')
/** 数据管理员的当前页面,默认为仪表盘 */
const activeDataAdminPage = ref<DataAdminPageType>('dashboard')
/** 查询页面的动态标题,由 QueryPage 通过 @update:title 事件更新 */
const queryPageTitle = ref<string>('新对话')
// ===== =====
/**
* 顶部导航栏标题
*
* 逻辑说明
* 1. 查询页面query使用 queryPageTitle QueryPage 动态更新
* 2. 其他页面使用 getSidebarPageName 根据角色和页面 key 获取中文名称
* 3. 未登录或未知页面返回空字符串
*/
const headerTitle = computed(() => {
//
const isQueryPage =
(userRole.value === 'normal-user' && activePage.value === 'query') ||
(userRole.value === 'data-admin' && activeDataAdminPage.value === 'query')
// 使
if (isQueryPage) {
return queryPageTitle.value
}
// 使
if (userRole.value === 'normal-user') {
return getSidebarPageName(userRole.value, activePage.value)
} else if (userRole.value === 'data-admin') {
return getSidebarPageName(userRole.value, activeDataAdminPage.value)
} else if (userRole.value === 'sys-admin') {
return getSidebarPageName(userRole.value, activeSysAdminPage.value)
}
return ''
})
// ===== =====
/**
* 登录处理
*
* 逻辑说明
* 1. 优先使用传入的 role否则从 sessionStorage 读取
* 2. 设置 userRole 后加载用户信息
* 3. 登录成功后页面状态保持默认值query dashboard
*/
const handleLogin = async (role: UserRole) => {
const savedRole = sessionStorage.getItem('userRole') as UserRole
const roleToSet = role || savedRole
if (roleToSet) {
userRole.value = roleToSet
await loadUserInfo()
}
}
/**
* 加载用户信息
*
* 逻辑说明
* 1. sessionStorage 获取 userId
* 2. 调用 API 获取用户数据
* 3. 更新 currentUsername, avatarUrl
* 4. 失败时使用默认值
*/
const loadUserInfo = async () => {
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
const userData = await userApi.getById(userId)
currentUser.value = {
name: userData.username,
avatarUrl: userData.avatarUrl || '/default-avatar.png',
}
} catch (error) {
console.error('加载用户信息失败:', error)
currentUser.value = { name: '用户', avatarUrl: '/default-avatar.png' }
}
}
/**
* 退出登录
*
* 逻辑说明
* 1. 清除 sessionStorage 中的 userRole
* 2. 重置 userRole null触发显示 LoginPage
* 3. 重置所有页面状态为默认值
*/
const handleLogout = () => {
sessionStorage.removeItem('userRole')
userRole.value = null
activePage.value = 'query'
activeSysAdminPage.value = 'dashboard'
activeDataAdminPage.value = 'dashboard'
}
/**
* 更新 QueryPage 标题
*
* 逻辑说明
* - QueryPage 通过 @update:title 事件调用
* - 更新 queryPageTitle用于 headerTitle 计算属性
*/
const handleUpdateQueryTitle = (title: string) => {
queryPageTitle.value = title
}
/**
* 创建新对话
*
* 逻辑说明
* - TopHeader "新对话"按钮触发
* - 切换到查询页面query
* - 实际的新对话逻辑在 QueryPage 内部处理
*/
const handleNewConversation = () => {
if (userRole.value === 'normal-user') {
activePage.value = 'query'
//
setTimeout(() => {
if (userPageRef.value) {
userPageRef.value.handleNewConversation()
}
}, 100)
} else if (userRole.value === 'data-admin') {
activeDataAdminPage.value = 'query'
//
setTimeout(() => {
if (dataAdminPageRef.value) {
dataAdminPageRef.value.handleNewConversation()
}
}, 100)
}
// sys-admin
}
/**
* 通知图标点击
*
* 逻辑说明
* - TopHeader 的通知图标触发
* - 切换到对应角色的通知页面notifications
*/
const handleNotificationClick = () => {
if (userRole.value === 'normal-user') {
activePage.value = 'notifications'
} else if (userRole.value === 'data-admin') {
activeDataAdminPage.value = 'notifications'
}
// sys-admin
}
/**
* 头像点击
*
* 逻辑说明
* - TopHeader 的头像触发
* - 切换到对应角色的账户页面account
*/
const handleAvatarClick = () => {
if (userRole.value === 'normal-user') {
activePage.value = 'account'
} else if (userRole.value === 'data-admin') {
activeDataAdminPage.value = 'account'
} else if (userRole.value === 'sys-admin') {
activeSysAdminPage.value = 'account'
}
}
/**
* 切换历史面板
*
* 逻辑说明
* - TopHeader 的历史按钮触发仅在查询页面显示
* - 转发到 UserPage/DataAdminPage然后转发到 QueryPage
*/
const handleToggleHistory = () => {
if (userRole.value === 'normal-user') {
//
if (activePage.value !== 'query') {
activePage.value = 'query'
setTimeout(() => {
if (userPageRef.value) {
userPageRef.value.handleToggleHistory()
}
}, 100)
} else if (userPageRef.value) {
userPageRef.value.handleToggleHistory()
}
} else if (userRole.value === 'data-admin') {
//
if (activeDataAdminPage.value !== 'query') {
activeDataAdminPage.value = 'query'
setTimeout(() => {
if (dataAdminPageRef.value) {
dataAdminPageRef.value.handleToggleHistory()
}
}, 100)
} else if (dataAdminPageRef.value) {
dataAdminPageRef.value.handleToggleHistory()
}
}
}
// ===== =====
/**
* 设置普通用户页面
* - Sidebar @update:active-page 事件触发
*/
const setActivePage = (page: Page) => {
activePage.value = page
}
/**
* 设置系统管理员页面
* - Sidebar @update:active-page 事件触发
*/
const setActiveSysAdminPage = (page: SysAdminPageType) => {
activeSysAdminPage.value = page
}
/**
* 设置数据管理员页面
* - Sidebar @update:active-page 事件触发
*/
const setActiveDataAdminPage = (page: DataAdminPageType) => {
activeDataAdminPage.value = page
}
/**
* 处理头像更新事件
*
* 逻辑说明
* - AccountPage/AdminAccountPage 通过自定义事件触发
* - 更新 currentUser avatarUrl
*/
const handleAvatarUpdate = (event: CustomEvent) => {
currentUser.value = {
...currentUser.value,
avatarUrl: event.detail.avatarUrl,
}
}
// ===== =====
onMounted(() => {
// sessionStorage
const savedRole = sessionStorage.getItem('userRole') as UserRole
if (savedRole) {
handleLogin(savedRole)
}
// AccountPage/AdminAccountPage
window.addEventListener('userAvatarUpdated', handleAvatarUpdate as EventListener)
})
onUnmounted(() => {
//
window.removeEventListener('userAvatarUpdated', handleAvatarUpdate as EventListener)
})
</script>
<style scoped>
/* 确保组件正确布局 */
.h-screen {
height: 100vh;
}
.bg-neutral {
background-color: #f5f5f5;
}
.flex {
display: flex;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-col {
flex-direction: column;
}
.overflow-hidden {
overflow: hidden;
}
</style>

@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

@ -0,0 +1,855 @@
/* index.css */
/* ===== 主题样式变量 ===== */
:root {
/* 浅色主题 */
--light-bg: #F5F7FA;
--light-card: #FFFFFF;
--light-text: #1D2129;
--light-primary: #165DFF;
/* 深色主题 */
--dark-bg: #1F2937;
--dark-card: #374151;
--dark-text: #F9FAFB;
--dark-primary: #3B82F6;
/* 蓝色主题 */
--blue-bg: #EFF6FF;
--blue-card: #BFDBFE;
--blue-text: #1E3A8A;
--blue-primary: #2563EB;
/* 绿色主题 */
--green-bg: #F0FDF4;
--green-card: #A7F3D0;
--green-text: #14532D;
--green-primary: #10B981;
/* 紫色主题 */
--purple-bg: #FAF5FF;
--purple-card: #E9D5FF;
--purple-text: #581C87;
--purple-primary: #8B5CF6;
/* 大字主题(使用浅色配色) */
--large-bg: #F5F7FA;
--large-card: #FFFFFF;
--large-text: #1D2129;
--large-primary: #165DFF;
/* 当前主题变量(默认浅色) */
--theme-bg: var(--light-bg);
--theme-card: var(--light-card);
--theme-text: var(--light-text);
--theme-primary: var(--light-primary);
/* 字体大小变量 */
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
}
/* 主题类 */
.theme-light {
--theme-bg: var(--light-bg);
--theme-card: var(--light-card);
--theme-text: var(--light-text);
--theme-primary: var(--light-primary);
}
.theme-dark {
--theme-bg: var(--dark-bg);
--theme-card: var(--dark-card);
--theme-text: var(--dark-text);
--theme-primary: var(--dark-primary);
}
.theme-blue {
--theme-bg: var(--blue-bg) !important;
--theme-card: var(--blue-card) !important;
--theme-text: var(--blue-text) !important;
--theme-primary: var(--blue-primary) !important;
}
.theme-blue body,
.theme-blue .bg-neutral,
.theme-blue .bg-gray-50 {
background-color: var(--blue-bg) !important;
}
.theme-blue .bg-white {
background-color: var(--blue-card) !important;
}
.theme-green {
--theme-bg: var(--green-bg) !important;
--theme-card: var(--green-card) !important;
--theme-text: var(--green-text) !important;
--theme-primary: var(--green-primary) !important;
}
.theme-green body,
.theme-green .bg-neutral,
.theme-green .bg-gray-50 {
background-color: var(--green-bg) !important;
}
.theme-green .bg-white {
background-color: var(--green-card) !important;
}
.theme-purple {
--theme-bg: var(--purple-bg) !important;
--theme-card: var(--purple-card) !important;
--theme-text: var(--purple-text) !important;
--theme-primary: var(--purple-primary) !important;
}
.theme-purple body,
.theme-purple .bg-neutral,
.theme-purple .bg-gray-50 {
background-color: var(--purple-bg) !important;
}
.theme-purple .bg-white {
background-color: var(--purple-card) !important;
}
.theme-large {
--theme-bg: var(--large-bg);
--theme-card: var(--large-card);
--theme-text: var(--large-text);
--theme-primary: var(--large-primary);
}
/* 应用主题到html和body */
html {
background-color: var(--theme-bg);
transition: background-color 0.3s ease;
}
body {
background-color: var(--theme-bg) !important;
color: var(--theme-text) !important;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 确保主题变量应用到所有元素 */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* ===== 大字主题字体样式 ===== */
.theme-large-font {
font-size: 1.25rem !important; /* 基础字体增大 */
}
.theme-large-font .text-sm {
font-size: 1rem !important;
}
.theme-large-font .text-base {
font-size: 1.25rem !important;
}
.theme-large-font .text-lg {
font-size: 1.5rem !important;
}
.theme-large-font .text-xl {
font-size: 1.75rem !important;
}
.theme-large-font .text-2xl {
font-size: 2rem !important;
}
.theme-large-font h1 {
font-size: 2.5rem !important;
}
.theme-large-font h2 {
font-size: 2rem !important;
}
.theme-large-font h3 {
font-size: 1.75rem !important;
}
.theme-large-font button {
font-size: 1.125rem !important;
padding: 0.75rem 1.5rem !important;
}
.theme-large-font input,
.theme-large-font textarea,
.theme-large-font select {
font-size: 1.125rem !important;
padding: 0.75rem 1rem !important;
}
/* ===== 全局主题样式类替代硬编码的Tailwind类 ===== */
/* 背景色 */
.theme-bg {
background-color: var(--theme-bg) !important;
}
.theme-card {
background-color: var(--theme-card) !important;
}
/* 文字颜色 */
.theme-text {
color: var(--theme-text) !important;
}
.theme-text-secondary {
color: var(--theme-text);
opacity: 0.7;
}
/* 主色调 */
.theme-primary {
color: var(--theme-primary) !important;
}
.theme-primary-bg {
background-color: var(--theme-primary) !important;
}
.theme-primary-border {
border-color: var(--theme-primary) !important;
}
/* 边框颜色 */
.theme-border {
border-color: rgba(0, 0, 0, 0.1) !important;
}
.theme-dark .theme-border {
border-color: rgba(255, 255, 255, 0.1) !important;
}
/* 阴影 */
.theme-shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
}
.theme-dark .theme-shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2) !important;
}
/* 输入框样式 */
.theme-input {
background-color: var(--theme-card) !important;
color: var(--theme-text) !important;
border-color: var(--theme-border) !important;
}
.theme-input:focus {
border-color: var(--theme-primary) !important;
background-color: var(--theme-card) !important;
}
.theme-input::placeholder {
color: var(--theme-text);
opacity: 0.5;
}
/* 卡片样式 */
.theme-card-container {
background-color: var(--theme-card) !important;
color: var(--theme-text) !important;
border-color: var(--theme-border) !important;
}
/* 悬停效果 */
.theme-hover:hover {
background-color: var(--theme-bg) !important;
}
.theme-dark .theme-hover:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* 图表容器样式 */
.chart-container {
position: relative;
height: 256px; /* h-64 */
width: 100%;
}
/* 渐变背景动画 */
.animated-gradient {
background: linear-gradient(-45deg, #eef2ff, #f3e8ff, #dbeafe, #e0f2fe);
background-size: 400% 400%;
animation: gradient-animation 15s ease infinite;
}
@keyframes gradient-animation {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* 加载动画 */
.dot-flashing {
position: relative;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: var(--color-primary, #3b82f6);
color: var(--color-primary, #3b82f6);
animation: dot-flashing 1s infinite linear alternate;
animation-delay: 0.5s;
margin: 0 auto;
}
.dot-flashing::before,
.dot-flashing::after {
content: '';
display: inline-block;
position: absolute;
top: 0;
}
.dot-flashing::before {
left: -15px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: var(--color-primary, #165dff);
color: var(--color-primary, #165dff);
animation: dot-flashing 1s infinite alternate;
animation-delay: 0s;
}
.dot-flashing::after {
left: 15px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: var(--color-primary, #165dff);
color: var(--color-primary, #165dff);
animation: dot-flashing 1s infinite alternate;
animation-delay: 1s;
}
@keyframes dot-flashing {
0% {
background-color: var(--color-primary, #165dff);
}
50%,
100% {
background-color: rgba(22, 93, 255, 0.2);
}
}
/* 响应式字体大小 */
@media (max-width: 640px) {
html {
font-size: 14px;
}
}
/* 打印样式 */
@media print {
.no-print {
display: none;
}
}
/* ==================== 响应式侧边栏和页面布局 ==================== */
/* 通用滚动容器 */
.scrollable-content {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 小屏幕小于1024px时自动隐藏侧边栏 */
@media (max-width: 1023px) {
/* 隐藏桌面端侧边栏(移动端侧边栏通过 v-if 和 v-show 控制,不受此规则影响) */
aside:not([class*="z-\[60\]"]) {
display: none !important;
}
/* 确保移动端侧边栏显示fixed定位的侧边栏通过v-show控制显示/隐藏) */
aside[class*="z-\[60\]"] {
display: flex !important;
}
/* 移动端侧边栏隐藏时 */
aside[class*="z-\[60\]"][style*="display: none"] {
display: none !important;
}
/* 确保移动端侧边栏header样式与TopHeader一致 */
aside[class*="z-\[60\]"] .p-4.border-b {
padding: 1rem !important;
border-bottom: 1px solid #e5e7eb !important;
background-color: white !important;
flex-shrink: 0 !important;
}
/* 确保桌面端侧边栏header样式与TopHeader一致 */
aside:not([class*="w-0"]) .p-4.border-b {
padding: 1rem !important;
border-bottom: 1px solid #e5e7eb !important;
background-color: white !important;
flex-shrink: 0 !important;
}
/* 主内容区域填充整个宽度 */
/* 只针对App.vue中的main不覆盖子组件 */
.flex.h-screen > main {
width: 100% !important;
flex: 1 1 100% !important;
}
/* 确保页面容器可以滚动 */
.flex.h-screen {
overflow-y: auto;
height: auto;
min-height: 100vh;
}
/* 页面内容区域可以滚动 */
/* 只针对App.vue中的main不覆盖子组件 */
.flex.h-screen > main > .scrollable-content {
overflow-y: auto;
overflow-x: hidden;
}
/* 移动端表单模态框内容全部展示,不设置滚动 */
@media (max-width: 1023px) {
.bg-white.rounded-xl.shadow-xl {
max-height: none !important;
overflow-y: visible !important;
}
/* 表单内容区域不限制高度,全部展示 */
.bg-white.rounded-xl.shadow-xl .p-6 {
overflow-y: visible !important;
max-height: none;
}
}
}
/* 大屏幕大于等于1024px时显示侧边栏 */
@media (min-width: 1024px) {
/* 显示侧边栏 */
aside:not([class*="w-0"]) {
display: flex !important;
}
/* 侧边栏收起时隐藏 */
aside[class*="w-0"] {
display: none !important;
}
/* 主内容区域自适应 */
/* 只针对App.vue中的main不覆盖子组件 */
.flex.h-screen > main {
flex: 1 1 0%;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
transition: width 0.3s;
}
/* 当侧边栏收起时,主内容区域填满屏幕 */
.flex.h-screen.sidebar-collapsed > main {
width: 100% !important;
flex: 1 1 100% !important;
}
}
/* 确保所有页面内容区域可以滚动 */
/* 注意这个样式只应用于App.vue中的main标签不覆盖子组件中的main */
.flex.h-screen > main {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */
}
/* 页面容器自适应 */
.page-container {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ==================== 响应式表格样式 ==================== */
/* 系统日志表格容器 */
.system-log-table-container {
width: 100%;
display: flex;
flex-direction: column;
}
/* 系统日志表格包装器 */
.system-log-table-wrapper {
width: 100%;
position: relative;
overflow-x: auto;
overflow-y: visible; /* 允许内容自然显示,不限制高度 */
}
/* 系统日志表格 */
.system-log-table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
font-size: 0.9375rem; /* 15px比默认的14px稍大 */
}
.system-log-table th,
.system-log-table td {
border-bottom: 1px solid #e5e7eb;
padding: 0.75rem 1.5rem; /* 增加内边距 */
}
.system-log-table thead th {
border-bottom: 2px solid #e5e7eb;
background-color: #f9fafb;
font-weight: 600;
color: #374151;
position: sticky;
top: 0;
z-index: 10;
}
.system-log-table tbody tr:hover {
background-color: #f9fafb;
}
.system-log-table tbody tr:last-child td {
border-bottom: none;
}
/* 小屏幕小于768px时表格横向滚动 */
@media (max-width: 767px) {
.system-log-table {
min-width: 900px; /* 确保表格有最小宽度,触发横向滚动 */
}
.system-log-table th,
.system-log-table td {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
/* 表格容器添加滚动指示 */
.system-log-table-wrapper::after {
content: '← 滑动查看更多 →';
position: absolute;
bottom: -20px;
right: 0;
font-size: 12px;
color: #999;
pointer-events: none;
}
}
/* 中等屏幕768px-1023px时优化表格显示 */
@media (min-width: 768px) and (max-width: 1023px) {
.system-log-table th,
.system-log-table td {
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
}
}
/* 大屏幕大于等于1024px时正常显示 */
@media (min-width: 1024px) {
.system-log-table {
width: 100%;
}
.system-log-table th,
.system-log-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
}
/* 表格单元格文本溢出处理 */
.system-log-table td {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 操作内容列允许换行 */
.system-log-table td:nth-child(4) {
white-space: normal;
word-break: break-word;
max-width: 250px;
}
/* 允许某些列换行 */
.system-log-table td.allow-wrap {
white-space: normal;
word-break: break-word;
}
/* 通用响应式表格样式(保留兼容性) */
.responsive-table {
min-width: 100%;
border-collapse: collapse;
}
.responsive-table th,
.responsive-table td {
border-bottom: 1px solid #e5e7eb;
}
.responsive-table thead th {
border-bottom: 2px solid #e5e7eb;
background-color: #f9fafb;
font-weight: 600;
color: #374151;
}
.responsive-table tbody tr:hover {
background-color: #f9fafb;
}
/* ==================== 深色主题样式(使用主题类,优化视觉效果) ==================== */
.theme-dark {
color-scheme: dark;
}
.theme-dark body {
background-color: #1a1a1a !important;
color: #e5e5e5 !important;
}
/* 通用背景色 - 覆盖硬编码的Tailwind类 */
.theme-dark .bg-white {
background-color: #2d2d2d !important;
}
.theme-dark .bg-neutral {
background-color: #1a1a1a !important;
}
.theme-dark .bg-gray-50,
.theme-dark .bg-gray-100 {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.theme-dark .bg-gray-200 {
background-color: rgba(255, 255, 255, 0.08) !important;
}
/* 边框颜色 */
.theme-dark .border-gray-200,
.theme-dark .border-gray-300,
.theme-dark .border-b,
.theme-dark .border-t,
.theme-dark .border-r,
.theme-dark .border-l {
border-color: rgba(255, 255, 255, 0.1) !important;
}
/* 文字颜色 */
.theme-dark .text-gray-500,
.theme-dark .text-gray-600 {
color: rgba(255, 255, 255, 0.6) !important;
}
.theme-dark .text-gray-400 {
color: rgba(255, 255, 255, 0.5) !important;
}
.theme-dark .text-dark,
.theme-dark h1,
.theme-dark h2,
.theme-dark h3,
.theme-dark h4,
.theme-dark h5,
.theme-dark h6 {
color: var(--dark-text) !important;
}
.theme-dark .text-gray-700,
.theme-dark .text-gray-800,
.theme-dark .text-gray-900 {
color: rgba(255, 255, 255, 0.8) !important;
}
/* 悬停效果 */
.theme-dark .hover\:bg-gray-50:hover,
.theme-dark .hover\:bg-gray-100:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
/* 输入框 */
.theme-dark input,
.theme-dark textarea,
.theme-dark select {
background-color: var(--dark-card) !important;
color: var(--dark-text) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark input:focus,
.theme-dark textarea:focus,
.theme-dark select:focus {
background-color: var(--dark-card) !important;
border-color: var(--dark-primary) !important;
}
.theme-dark input::placeholder,
.theme-dark textarea::placeholder {
color: rgba(255, 255, 255, 0.5) !important;
}
/* 卡片和容器 */
.theme-dark .bg-white.rounded-xl,
.theme-dark .bg-white.rounded-lg {
background-color: var(--dark-card) !important;
}
.theme-dark .shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3) !important;
}
.theme-dark .shadow-md {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2) !important;
}
.theme-dark .shadow-lg {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2) !important;
}
/* 滚动条深色模式 */
.theme-dark ::-webkit-scrollbar-track {
background: var(--dark-card) !important;
}
.theme-dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2) !important;
}
.theme-dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
/* 侧边栏特定样式 */
.theme-dark aside {
background-color: var(--dark-card) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark aside header,
.theme-dark aside .p-4.border-b {
background-color: var(--dark-card) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark aside .category-title {
color: rgba(255, 255, 255, 0.6) !important;
}
.theme-dark aside .category-header:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.theme-dark aside .category-divider {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark aside a.text-gray-600 {
color: rgba(255, 255, 255, 0.6) !important;
}
.theme-dark aside a:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* TopHeader 深色模式 */
.theme-dark header {
background-color: var(--dark-card) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark header h1 {
color: var(--dark-text) !important;
}
.theme-dark header button {
color: rgba(255, 255, 255, 0.6) !important;
}
.theme-dark header button:hover {
color: var(--dark-primary) !important;
}
/* 设置侧边栏深色模式 */
.theme-dark aside.border-l {
background-color: var(--dark-card) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark aside.border-l .p-4.border-b {
background-color: var(--dark-card) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
}
.theme-dark aside.border-l h1,
.theme-dark aside.border-l h3 {
color: var(--dark-text) !important;
}
.theme-dark aside.border-l .text-gray-700 {
color: rgba(255, 255, 255, 0.8) !important;
}
.theme-dark aside.border-l .text-gray-500 {
color: rgba(255, 255, 255, 0.6) !important;
}
.theme-dark aside.border-l button.bg-gray-50 {
background-color: rgba(255, 255, 255, 0.05) !important;
}
.theme-dark aside.border-l button.bg-gray-50:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}

@ -0,0 +1,96 @@
<!--
@file components/admin/AdminModal.vue
@description 管理员模态框组件
功能
- 通用管理操作弹窗
- 表单内容插槽
- 遮罩层点击关闭
@author Frontend Team
-->
<template>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
@click="handleBackdropClick"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 fade-in" :class="isMobile ? 'max-h-none overflow-y-visible' : 'max-h-[95vh] overflow-y-auto'" @click.stop>
<!-- 标题栏 -->
<div class="p-4 border-b flex justify-between items-center flex-shrink-0">
<h3 class="font-bold text-lg">{{ title }}</h3>
<button @click="handleClose" class="text-gray-400 hover:text-gray-600">
<i class="fa fa-times"></i>
</button>
</div>
<!-- 内容区域 -->
<div class="p-6" :class="isMobile ? 'overflow-y-visible' : 'overflow-y-visible'">
<slot></slot>
</div>
<!-- 底部操作区域 -->
<div v-if="$slots.footer" class="p-4 border-t">
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
// Props
interface AdminModalProps {
isOpen: boolean
title: string
}
defineProps<AdminModalProps>()
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024 // lg breakpoint
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
// Emits
const emit = defineEmits<{
(e: 'close'): void
}>()
// Methods
const handleClose = () => {
emit('close')
}
const handleBackdropClick = () => {
handleClose()
}
</script>
<style scoped>
.fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

@ -0,0 +1,86 @@
<!--
@file components/admin/StatCard.vue
@description 统计卡片组件
功能
- 显示统计数值
- 趋势指示器
- 图标和标题
- 用于仪表盘概览
@author Frontend Team
-->
<template>
<div class="bg-white rounded-xl p-6 shadow-sm card-hover">
<div class="flex justify-between items-start">
<div>
<p class="text-gray-500 text-sm">{{ title }}</p>
<h3 class="text-3xl font-bold mt-2">{{ value }}</h3>
<p :class="[changeColor, 'text-sm mt-2']">
<i :class="`fa ${changeIcon}`"></i> {{ change.value }}
</p>
</div>
<div
:class="`w-12 h-12 rounded-full ${bgColor} flex items-center justify-center ${textColor}`"
>
<i :class="`fa ${icon} text-xl`"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ChangeInfo {
value: string
type: 'positive' | 'negative' | 'neutral' | 'warning'
}
interface StatCardProps {
icon: string
title: string
value: string
change: ChangeInfo
color: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'
}
const props = defineProps<StatCardProps>()
const colorMapping = {
primary: { bg: 'bg-primary/10', text: 'text-primary' },
secondary: { bg: 'bg-secondary/10', text: 'text-secondary' },
success: { bg: 'bg-success/10', text: 'text-success' },
warning: { bg: 'bg-warning/10', text: 'text-warning' },
danger: { bg: 'bg-danger/10', text: 'text-danger' },
}
const { bg, text } = colorMapping[props.color]
const changeColor = computed(() => {
switch (props.change.type) {
case 'positive':
return 'text-success'
case 'negative':
return 'text-danger'
case 'warning':
return 'text-warning'
default:
return 'text-gray-500'
}
})
const changeIcon = computed(() => {
switch (props.change.type) {
case 'positive':
return 'fa-arrow-up'
case 'negative':
return 'fa-arrow-down'
default:
return 'fa-minus'
}
})
const bgColor = computed(() => bg)
const textColor = computed(() => text)
</script>

@ -0,0 +1,5 @@
/**
*
*/
export { default as AdminModal } from './AdminModal.vue'
export { default as StatCard } from './StatCard.vue'

@ -0,0 +1,150 @@
<!--
@file components/common/BaseSidebar.vue
@description 基础侧边栏组件
功能
- 提供侧边栏基础布局
- 插槽支持自定义内容
- 登出按钮
- 其他侧边栏的基础组件
@author Frontend Team
-->
<template>
<!-- 移动端遮罩层 -->
<div
v-if="isMobile && isOpen"
></div>
<!-- 移动端侧边栏覆盖模式 -->
<aside
v-if="isMobile"
v-show="isOpen"
:class="[
'bg-white shadow-md h-screen flex flex-col border-r border-gray-200 fixed left-0 top-0 z-[60] w-64',
]"
>
<!-- Header区域与TopHeader对齐的灰色分隔线但不与TopHeader连在一起 -->
<div v-if="$slots.header" class="p-4 border-b flex items-center justify-between bg-white flex-shrink-0">
<div class="flex items-center space-x-2">
<slot name="header"></slot>
</div>
<!-- 关闭按钮汉堡图标移动端显示 -->
<button
@click="handleClose"
class="p-2 text-gray-500 hover:text-primary transition-colors"
title="关闭侧边栏"
>
<i class="fa fa-bars text-xl"></i>
</button>
</div>
<nav class="py-2 flex-grow overflow-y-auto">
<ul>
<slot></slot>
</ul>
</nav>
<div class="border-t border-gray-200 p-4">
<button
@click="handleLogout"
class="flex items-center justify-center text-gray-600 hover:text-danger transition-colors w-full text-base font-medium py-3"
>
<i class="fa fa-sign-out w-6 text-lg"></i>
<span>退出登录</span>
</button>
</div>
</aside>
<!-- 桌面端侧边栏正常布局 -->
<aside
v-if="!isMobile"
:class="[
'bg-white shadow-md h-screen flex-shrink-0 flex flex-col transition-all duration-300 border-r border-gray-200',
isCollapsed ? 'w-0 overflow-hidden hidden lg:block' : 'w-64 hidden lg:flex',
]"
>
<!-- Header区域与TopHeader对齐的灰色分隔线但不与TopHeader连在一起 -->
<div v-if="$slots.header" class="p-4 border-b flex items-center justify-between bg-white flex-shrink-0">
<div class="flex items-center space-x-2">
<slot name="header"></slot>
</div>
<!-- 收起按钮汉堡图标仅桌面端显示 -->
<button
@click="handleToggle"
class="p-2 text-gray-500 hover:text-primary transition-colors"
:title="isCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
<i class="fa fa-bars text-xl"></i>
</button>
</div>
<nav class="py-2 flex-grow overflow-y-auto">
<ul>
<slot></slot>
</ul>
</nav>
<div class="border-t border-gray-200 p-4">
<button
@click="handleLogout"
class="flex items-center justify-center text-gray-600 hover:text-danger transition-colors w-full text-base font-medium py-3"
>
<i class="fa fa-sign-out w-6 text-lg"></i>
<span>退出登录</span>
</button>
</div>
</aside>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface BaseSidebarProps {
isOpen?: boolean
isCollapsed?: boolean
}
const props = withDefaults(defineProps<BaseSidebarProps>(), {
isOpen: false,
isCollapsed: false,
})
// Emits
const emit = defineEmits<{
(e: 'logout'): void
(e: 'close'): void
(e: 'toggle'): void
}>()
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024 // lg breakpoint
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
const handleLogout = () => {
emit('logout')
}
const handleClose = () => {
emit('close')
}
const handleToggle = () => {
if (isMobile.value) {
emit('close')
} else {
emit('toggle')
}
}
</script>

@ -0,0 +1,115 @@
<!--
@file components/common/FilterBar.vue
@description 筛选条组件
功能
- 模型/数据库选择
- 筛选条件组合
- 统一的筛选 UI
@author Frontend Team
-->
<template>
<div class="filter-bar">
<div class="flex items-center justify-end space-x-2 mb-2">
<!-- 模型选择器 -->
<FilterSelect
v-if="showModelFilter"
:type="'model'"
:label="modelLabel"
:options="modelOptions"
:value="currentModelName"
@change="handleModelChange"
:icon="modelIcon"
:loading="modelLoading"
/>
<!-- 数据库选择器 -->
<FilterSelect
v-if="showDatabaseFilter"
:type="'database'"
:label="databaseLabel"
:options="databaseOptions"
:value="currentDatabaseName"
@change="handleDatabaseChange"
:icon="databaseIcon"
:loading="databaseLoading"
/>
<!-- 自定义筛选器插槽 -->
<slot name="custom-filters"></slot>
<!-- 操作按钮插槽 -->
<slot name="actions"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import FilterSelect from './FilterSelect.vue'
interface Option {
id: string
name: string
disabled?: boolean
description?: string
}
interface Props {
//
showModelFilter?: boolean
modelOptions: Option[]
currentModelName: string
modelLabel?: string
modelIcon?: string
modelLoading?: boolean
//
showDatabaseFilter?: boolean
databaseOptions: Option[]
currentDatabaseName: string
databaseLabel?: string
databaseIcon?: string
databaseLoading?: boolean
//
compact?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showModelFilter: true,
showDatabaseFilter: true,
modelLabel: '大模型',
modelIcon: 'fa-cogs',
databaseLabel: '数据库',
databaseIcon: 'fa-database',
compact: false,
modelLoading: false,
databaseLoading: false,
})
const emit = defineEmits<{
'model-change': [modelId: string, modelName: string]
'database-change': [databaseId: string, databaseName: string]
}>()
const handleModelChange = (value: string) => {
const selectedOption = props.modelOptions.find((opt) => opt.name === value)
if (selectedOption) {
emit('model-change', selectedOption.id, value)
}
}
const handleDatabaseChange = (value: string) => {
const selectedOption = props.databaseOptions.find((opt) => opt.name === value)
if (selectedOption) {
emit('database-change', selectedOption.id, value)
}
}
</script>
<style scoped>
.filter-bar {
@apply w-full;
}
</style>

@ -0,0 +1,145 @@
<!--
@file components/common/FilterSelect.vue
@description 带图标的筛选选择器
功能
- 下拉选择框
- 可配置图标
- 紧凑/标准模式
@author Frontend Team
-->
<template>
<div class="relative inline-block" :class="{ compact: compact }">
<!-- 带图标的选择器 -->
<div class="relative">
<select
:value="value"
@change="handleChange"
:disabled="disabled || loading"
:class="[
'filter-select',
'px-3 py-1.5 text-sm rounded-md border bg-white font-medium',
'focus:ring-2 focus:ring-primary/30 focus:border-primary/50',
disabled || loading ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'cursor-pointer',
compact ? 'pl-8 pr-6' : 'pl-10 pr-6',
hasIcon ? 'with-icon' : '',
]"
:style="selectStyle"
>
<!-- 占位符选项 -->
<option v-if="placeholder && !compact" value="" disabled hidden>
{{ placeholder }}
</option>
<!-- 选项列表 -->
<option
v-for="option in options"
:key="option.value || option.id"
:value="option.value || option.name"
:disabled="option.disabled"
>
{{ option.label || option.name }}
</option>
</select>
<!-- 图标 -->
<div
v-if="icon"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
>
<i :class="`fa ${icon}`"></i>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none">
<div
class="animate-spin h-4 w-4 border-2 border-primary/30 border-t-primary rounded-full"
></div>
</div>
</div>
<!-- 标签紧凑模式下显示在左侧 -->
<label v-if="label && !compact" class="block text-xs font-medium text-gray-500 mb-1">
{{ label }}
</label>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Option {
id?: string
name?: string
value?: string
label?: string
disabled?: boolean
}
interface Props {
//
type?: string
label?: string
value: string
options: Option[]
//
placeholder?: string
icon?: string
compact?: boolean
//
disabled?: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
compact: false,
disabled: false,
loading: false,
})
const emit = defineEmits<{
change: [value: string]
'update:value': [value: string]
}>()
const selectStyle = computed(() => ({
backgroundImage:
"url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center',
backgroundSize: '1em',
paddingRight: 'calc(1em + 1rem)',
}))
const hasIcon = computed(() => !!props.icon)
const handleChange = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('change', target.value)
emit('update:value', target.value)
}
</script>
<style scoped>
.filter-select {
transition: all 0.2s ease;
min-width: 140px;
}
.filter-select:focus {
outline: none;
}
.filter-select.compact {
min-width: 100px;
padding-left: 2rem;
}
.filter-select.with-icon {
padding-left: 2.5rem;
}
</style>

@ -0,0 +1,117 @@
<!--
@file components/common/SidebarCategory.vue
@description 侧边栏分类标题组件可折叠
功能
- 显示分类标题"查询功能"
- 支持折叠/展开
- 带分割线
-->
<template>
<li class="sidebar-category">
<div
class="category-header"
:class="{ 'is-collapsed': isCollapsed }"
@click="toggleCollapse"
>
<span class="category-title">{{ title }}</span>
<i
class="fa category-icon"
:class="isCollapsed ? 'fa-chevron-down' : 'fa-chevron-up'"
></i>
</div>
<div class="category-divider"></div>
<div v-show="!isCollapsed" class="category-content">
<slot></slot>
</div>
</li>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface SidebarCategoryProps {
title: string
defaultCollapsed?: boolean
}
const props = withDefaults(defineProps<SidebarCategoryProps>(), {
defaultCollapsed: false,
})
const isCollapsed = ref(props.defaultCollapsed)
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value
}
</script>
<style scoped>
.sidebar-category {
margin-top: 0.25rem;
}
.sidebar-category:first-child {
margin-top: 0;
}
.category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.5rem;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.category-header:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.category-title {
font-size: 0.9375rem; /* 15px, 比 text-sm 稍大 */
font-weight: 600;
color: #4b5563; /* text-gray-600, 更深一点 */
text-transform: uppercase;
letter-spacing: 0.05em;
}
.category-icon {
font-size: 0.75rem;
color: #9ca3af; /* text-gray-400 */
transition: transform 0.2s;
}
.category-header.is-collapsed .category-icon {
transform: rotate(-90deg);
}
.category-divider {
height: 1px;
background-color: #e5e7eb; /* border-gray-200 */
margin: 0 1.5rem;
}
/* 夜间模式样式 */
.dark .category-title {
color: #a0a0a0 !important;
}
.dark .category-icon {
color: #808080 !important;
}
.dark .category-header:hover {
background-color: #3a3a3a !important;
}
.dark .category-divider {
background-color: #404040 !important;
}
.category-content {
padding-top: 0.25rem;
}
</style>

@ -0,0 +1,51 @@
<!--
@file components/common/SidebarItem.vue
@description 侧边栏菜单项组件
功能
- 导航链接渲染
- 激活状态样式
- 图标和文本展示
@author Frontend Team
-->
<template>
<li>
<a
:href="`#${href}`"
:class="[
'flex items-center px-6 py-3 text-base transition-colors duration-200',
isActive ? activeClass : inactiveClass,
]"
@click.prevent="handleClick"
>
<i :class="['fa', icon, 'w-6']"></i>
<span>{{ label }}</span>
</a>
</li>
</template>
<script setup lang="ts">
// 使
interface SidebarItemProps<T extends string> {
href: T
icon: string
label: string
isActive: boolean
}
// props
const props = defineProps<SidebarItemProps<any>>()
// Emits
const emit = defineEmits<{
(e: 'click', page: any): void
}>()
const handleClick = () => {
emit('click', props.href)
}
const activeClass = 'bg-primary/10 text-primary border-l-4 border-primary'
const inactiveClass = 'text-gray-600 hover:bg-gray-50'
</script>

@ -0,0 +1,7 @@
/**
*
*/
export { default as BaseSidebar } from './BaseSidebar.vue'
export { default as FilterBar } from './FilterBar.vue'
export { default as FilterSelect } from './FilterSelect.vue'
export { default as SidebarItem } from './SidebarItem.vue'

@ -0,0 +1,132 @@
<!--
@file components/data-admin/PermissionModalContent.vue
@description 权限分配模态框内容
功能
- 数据源权限选择
- 表级别权限控制
- 批量用户分配
@author Frontend Team
-->
<template>
<div class="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div v-for="ds in dataSources" :key="ds.id" class="p-3 border rounded-lg">
<h4 class="font-semibold mb-2">{{ ds.name }}</h4>
<div class="border-t pt-2">
<div v-if="allTablesForDs(ds.id).length > 0">
<label class="flex items-center mb-2 font-medium text-sm">
<input
type="checkbox"
@change="
(e) => handleSelectAllTables(String(ds.id), (e.target as HTMLInputElement).checked)
"
:checked="areAllTablesSelected(ds.id)"
class="mr-2 h-4 w-4"
/>
全选
</label>
<div class="grid grid-cols-2 gap-2 text-sm">
<label v-for="table in allTablesForDs(ds.id)" :key="table" class="flex items-center">
<input
type="checkbox"
:checked="isTableSelected(String(ds.id), table)"
@change="
(e) =>
handleTableToggle(String(ds.id), table, (e.target as HTMLInputElement).checked)
"
class="mr-2 h-4 w-4"
/>
{{ table }}
</label>
</div>
</div>
<div v-else class="text-sm text-gray-500 py-2">暂无可用表或加载失败</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="onClose" class="px-4 py-2 border rounded-lg">取消</button>
<button @click="handleSave" class="px-4 py-2 bg-primary text-white rounded-lg">保存</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { DataSourcePermission } from '../../types'
import type { DbConnection } from '../../services/api.real'
interface Props {
users: { id: string; username: string }[]
existingPermissions: DataSourcePermission[]
dataSources: DbConnection[]
availableTables: Record<string, string[]>
}
interface Emits {
(e: 'save', userIds: string[], permissions: DataSourcePermission[]): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const permissions = ref<DataSourcePermission[]>(
props.dataSources.map((ds) => {
const existing = props.existingPermissions.find((p) => p.dataSourceId === String(ds.id))
return {
dataSourceId: String(ds.id),
dataSourceName: ds.name,
tables: existing ? [...existing.tables] : [],
}
}),
)
//
const allTablesForDs = computed(() => (dsId: string) => {
return props.availableTables[dsId] || []
})
const areAllTablesSelected = computed(() => (dsId: string) => {
const currentPerm = permissions.value.find((p) => p.dataSourceId === dsId)
const allTables = allTablesForDs.value(dsId)
return currentPerm
? currentPerm.tables.length === allTables.length && allTables.length > 0
: false
})
const isTableSelected = computed(() => (dsId: string, table: string) => {
const currentPerm = permissions.value.find((p) => p.dataSourceId === dsId)
return currentPerm ? currentPerm.tables.includes(table) : false
})
//
const handleTableToggle = (dsId: string, table: string, checked: boolean) => {
permissions.value = permissions.value.map((p) => {
if (p.dataSourceId === dsId) {
const newTables = new Set(p.tables)
if (checked) newTables.add(table)
else newTables.delete(table)
return { ...p, tables: Array.from(newTables) }
}
return p
})
}
const handleSelectAllTables = (dsId: string, checked: boolean) => {
const currentTables = allTablesForDs.value(dsId)
permissions.value = permissions.value.map((p) =>
p.dataSourceId === dsId ? { ...p, tables: checked ? currentTables : [] } : p,
)
}
const handleSave = () => {
const userIds = props.users.map((u) => u.id)
emit('save', userIds, permissions.value)
}
const onClose = () => {
emit('close')
}
</script>

@ -0,0 +1,4 @@
/**
*
*/
export { default as PermissionModalContent } from './PermissionModalContent.vue'

@ -0,0 +1,95 @@
<!--
@file components/feature/ForgotPasswordModal.vue
@description 忘记密码模态框
功能
- 邮箱输入验证
- 发送重置密码链接
- 表单提交处理
@author Frontend Team
-->
<template>
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
@click="$emit('close')"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6" @click.stop>
<div v-if="!resetEmailSent">
<h2 class="text-xl font-bold mb-4 text-center">重置密码</h2>
<form @submit.prevent="handleRequestReset">
<p class="text-sm text-gray-600 mb-4">
请输入您注册时使用的邮箱地址我们将向您发送一封密码重置邮件
</p>
<div class="mb-4">
<label for="reset-email" class="block text-gray-700 font-medium mb-2">邮箱</label>
<input
type="email"
id="reset-email"
v-model="resetEmail"
placeholder="请输入邮箱"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
<div class="flex justify-end space-x-2">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
取消
</button>
<button
type="submit"
class="px-4 py-2 bg-primary text-white rounded-lg hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200"
>
发送重置链接
</button>
</div>
</form>
</div>
<div v-else class="text-center">
<div
class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"
>
<i class="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">已发送</h3>
<p class="text-sm text-gray-500">
如果该邮箱地址已注册一封包含密码重置链接的邮件已经发送到您的邮箱
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 1. Emits
const emit = defineEmits(['close'])
// 2. State
const resetEmail = ref('')
const resetEmailSent = ref(false)
// 3. Methods
const handleRequestReset = () => {
// API
console.log(`请求重置密码的邮箱:${resetEmail.value}`)
resetEmailSent.value = true
// 3
setTimeout(() => {
emit('close')
// 便
setTimeout(() => {
resetEmailSent.value = false
resetEmail.value = ''
}, 300)
}, 3000)
}
</script>

@ -0,0 +1,53 @@
<template>
<!-- 现代化聊天消息样式 -->
<div :class="wrapperClass">
<div :class="bubbleClass">
<template v-if="typeof content === 'string'">
<p class="whitespace-pre-wrap leading-relaxed break-words">{{ content }}</p>
</template>
<template v-else>
<QueryResult
:result="content"
:saved-queries="savedQueries"
@save-query="onSaveQuery"
@share-query="onShareQuery"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Message, QueryResultData } from '../../../types'
import QueryResult from '../query/QueryResult.vue'
interface ChatMessageProps {
message: Message
onSaveQuery: (query: QueryResultData) => void
onShareQuery: (queryId: string, friendId: string) => void
savedQueries: QueryResultData[]
}
const props = defineProps<ChatMessageProps>()
const { role, content } = props.message
//
const isUser = computed(() => role === 'user')
//
const wrapperClass = computed(
() => `w-full flex ${isUser.value ? 'justify-end' : 'justify-start'} mb-4`,
)
//
const bubbleClass = computed(
() =>
`inline-block p-4 md:p-5 text-base rounded-2xl transition-all duration-200 max-w-[85%] break-words ${
isUser.value
? 'bg-gradient-to-br from-blue-500 to-blue-600 text-white shadow-lg hover:shadow-xl'
: 'bg-white border-2 border-gray-100 shadow-md hover:shadow-lg text-gray-800'
}`,
// AI85%
)
// max-w-[75%]calc(75% + 2rem)
</script>

@ -0,0 +1,350 @@
<!--
@file components/feature/chat/ChatModal.vue
@description 好友聊天模态框
功能
- 实时消息发送接收
- 查询结果分享
- 消息已读状态
- 模拟自动回复
@author Frontend Team
-->
<template>
<!-- 聊天主弹窗 -->
<Modal
:is-open="isOpen"
@close="onClose"
:title="`与 ${friend.name} 聊天中`"
content-class-name="max-w-3xl max-h-[90vh] min-h-[60vh]"
>
<div class="h-[80vh] min-h-[500px] flex flex-col">
<!-- 消息展示区域 -->
<div
ref="messagesContainerRef"
class="flex-1 p-4 bg-gray-100 rounded-t-lg overflow-y-auto space-y-4"
>
<!-- 渲染所有聊天消息 -->
<div
v-for="message in messages"
:key="message.id"
:class="`flex items-start gap-3 ${message.isSent ? 'flex-row-reverse' : ''}`"
>
<!-- 头像 -->
<img
:src="
message.isSent
? 'https://i.pravatar.cc/150?u=zhang-san'
: friend.avatarUrl || '/default-avatar.png'
"
:alt="message.isSent ? '自己' : friend.name || '好友'"
class="w-8 h-8 rounded-full mt-1 flex-shrink-0"
/>
<!-- 消息内容+时间戳容器 -->
<div :class="`flex flex-col ${message.isSent ? 'items-end' : 'items-start'}`">
<!-- 消息气泡 -->
<div
:class="`
shadow-sm text-sm p-3 rounded-xl
inline-block whitespace-pre-wrap
min-w-[60px] box-border
${message.isSent ? 'bg-primary text-white' : 'bg-white text-gray-800'}
`"
style="max-width: 80%; white-space: pre-wrap; line-height: 1.5; vertical-align: top"
>
<p style="margin: 0">{{ message.content }}</p>
</div>
<!-- 时间戳+已读状态 -->
<div
:class="`flex items-center mt-1 text-xs ${
message.isSent ? 'text-primary-200' : 'text-gray-400'
}`"
>
<span>{{ formatTime(message.timestamp) }}</span>
<span class="ml-2 text-gray-500">{{ message.isRead ? '已读' : '未读' }}</span>
</div>
</div>
</div>
</div>
<!-- 消息输入区域 -->
<div class="p-4 border-t">
<div class="flex items-center space-x-2">
<!-- 分享查询按钮 -->
<button
@click="handleOpenShareModal"
class="p-2 w-10 h-10 flex items-center justify-center text-gray-600 rounded-full text-xl hover:bg-gray-100 transition-colors"
title="分享查询结果"
>
<i class="fa fa-share-alt"></i>
</button>
<!-- 消息输入框 -->
<input
ref="inputRef"
type="text"
placeholder="输入消息..."
v-model="inputText"
@keydown.enter.prevent="handleSendMessage"
class="flex-1 px-4 py-3 border border-gray-300 rounded-full focus:ring-2 focus:ring-primary/30 outline-none"
/>
<!-- 发送按钮 -->
<button
@click="handleSendMessage"
:disabled="!inputText.trim()"
class="px-4 py-3 bg-primary text-white rounded-full hover:bg-primary/90 disabled:bg-gray-300 transition-colors"
aria-label="发送"
>
<i class="fa fa-paper-plane text-lg"></i>
</button>
</div>
</div>
</div>
</Modal>
<!-- 分享查询弹窗 -->
<Modal :is-open="isShareModalOpen" @close="isShareModalOpen = false" title="分享查询结果">
<div class="space-y-4">
<p class="text-sm text-gray-600">
从您的历史记录中选择一个查询结果分享给 {{ friend.name }}
</p>
<!-- 可分享查询列表 -->
<div class="max-h-60 overflow-y-auto space-y-2 p-2 bg-gray-50 rounded-lg border">
<template v-if="savedQueries.length > 0">
<label
v-for="query in savedQueries"
:key="query.id"
:class="`flex items-center p-3 rounded-lg cursor-pointer transition-colors ${
selectedQueryId === query.id ? 'bg-primary/20' : 'hover:bg-gray-200'
}`"
>
<input
type="radio"
name="query-share-selection"
:checked="selectedQueryId === query.id"
@change="selectedQueryId = query.id"
class="mr-3"
/>
<span class="text-sm font-medium text-dark truncate" :title="query.userPrompt">
{{ query.userPrompt }}
</span>
</label>
</template>
<p v-else class="text-center text-gray-500 text-sm p-4">没有收藏的查询记录</p>
</div>
</div>
<!-- 分享弹窗底部按钮 -->
<div class="mt-6 flex justify-end space-x-3">
<button
@click="isShareModalOpen = false"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"
>
取消
</button>
<button
@click="handleConfirmShare"
:disabled="!selectedQueryId"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 disabled:bg-primary/50 disabled:cursor-not-allowed"
>
确认分享
</button>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import Modal from '../../ui/Modal.vue'
import type { ChatMessage, Friend, QueryResultData } from '../../../types'
import { friendChatApi } from '../../../services/api.real'
// Props
const props = defineProps<{
isOpen: boolean
onClose: () => void
friend: Friend
savedQueries: QueryResultData[]
currentUnreadCount: number
updateUnreadCount: (friendId: string, count: number) => void
messages: ChatMessage[]
updateMessages: (newMessages: ChatMessage[]) => void
}>()
//
const inputText = ref('')
const messagesContainerRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const isShareModalOpen = ref(false)
const selectedQueryId = ref<string | null>(null)
//
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
//
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainerRef.value) {
const container = messagesContainerRef.value
container.scrollTop = container.scrollHeight
//
if (Math.abs(container.scrollHeight - (container.scrollTop + container.clientHeight)) > 10) {
container.scrollTop = container.scrollHeight
}
}
})
}
//
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
//
setTimeout(() => {
// 1.
props.updateUnreadCount(props.friend.id, 0)
// 2. ""
const updatedMessages = props.messages.map((msg) =>
!msg.isSent && !msg.isRead ? { ...msg, isRead: true } : msg,
)
props.updateMessages(updatedMessages)
// 3.
scrollToBottom()
// 4.
setTimeout(() => {
inputRef.value?.focus()
}, 100)
}, 200)
//
inputText.value = ''
}
},
{ immediate: true },
)
//
const handleSendMessage = async () => {
const content = inputText.value.trim()
if (!content) return
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
const friendId = Number(props.friend.friendId || props.friend.id)
// API
const savedChat = await friendChatApi.create({
userId: userId, // 使
friendId: friendId, // 使
contentType: 'text', //
content: { text: content }, // content
isRead: false,
})
// 使 API
//
let messageContent = ''
if (typeof savedChat.content === 'string') {
messageContent = savedChat.content
} else if (savedChat.content && typeof savedChat.content === 'object' && 'text' in savedChat.content) {
messageContent = (savedChat.content as any).text || ''
} else {
messageContent = String(savedChat.content || '')
}
const newMessage: ChatMessage = {
id: String(savedChat.id),
content: messageContent,
isSent: true,
timestamp: savedChat.sendTime ? new Date(savedChat.sendTime) : new Date(),
isRead: savedChat.isRead || false,
}
//
const updatedMessages = [...props.messages, newMessage]
props.updateMessages(updatedMessages)
//
inputText.value = ''
//
scrollToBottom()
} catch (error) {
console.error('发送消息失败:', error)
alert('发送消息失败,请稍后重试')
}
}
//
const handleOpenShareModal = () => {
selectedQueryId.value = null
isShareModalOpen.value = true
}
// 使
// API
// QueryShare queryLogId
const handleConfirmShare = async () => {
if (!selectedQueryId.value) return
const selectedQuery = props.savedQueries.find((q) => q.id === selectedQueryId.value)
if (!selectedQuery) return
try {
const { shareQuery } = await import('../../../services/queryShareService')
const friendId = String(props.friend.id)
// 使
// queryLogId
await shareQuery(selectedQueryId.value, friendId, selectedQuery)
// ""
const shareId = `${Date.now()}-share-${Math.random().toString(36).slice(2, 8)}`
const shareMessage: ChatMessage = {
id: shareId,
content: `分享了查询:${selectedQuery.userPrompt}`,
isSent: true,
timestamp: new Date(),
isRead: false,
}
// API
try {
await friendChatApi.create({
userId: Number(sessionStorage.getItem('userId')),
friendId: Number(props.friend.id),
contentType: 'query_share',
content: {
queryId: selectedQuery.id,
title: selectedQuery.userPrompt,
},
})
} catch (error) {
console.error('发送分享消息失败:', error)
}
//
const updatedMessages = [...props.messages, shareMessage]
props.updateMessages(updatedMessages)
//
inputText.value = ''
isShareModalOpen.value = false
//
scrollToBottom()
alert('分享成功!')
} catch (error) {
console.error('分享失败:', error)
alert('分享失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
</script>

@ -0,0 +1,5 @@
/**
*
*/
export { default as ChatModal } from './ChatModal.vue'
export { default as ChatMessage } from './ChatMessage.vue'

@ -0,0 +1,6 @@
/**
*
*/
export * from './query'
export * from './chat'
export { default as ForgotPasswordModal } from './ForgotPasswordModal.vue'

@ -0,0 +1,345 @@
<!--
@file components/feature/query/ChartComparison.vue
@description 图表对比组件
功能
- 新旧查询图表并排对比
- 支持多种图表类型
- 模态框展示
@author Frontend Team
-->
<template>
<Modal
:isOpen="isOpen"
@close="onClose"
title="查询快照对比"
:contentClass="'max-w-3xl max-h-[90vh] min-h-[60vh]'"
>
<!-- 弹窗内容区域 -->
<div class="p-4 space-y-6 max-h-[calc(100%-2rem)] overflow-y-auto">
<!-- 对比说明 -->
<div>
<p class="text-gray-600 text-sm">对比查询 "{{ oldQuery.userPrompt }}" 的两个不同版本</p>
</div>
<!-- 对比摘要卡片 -->
<div class="bg-white p-4 rounded-xl shadow-sm border">
<h3 class="font-bold mb-3">对比摘要</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div
v-for="item in summaryItems"
:key="item.label"
class="flex items-center p-3 bg-gray-50 rounded-lg"
>
<i :class="`fa ${item.icon} ${item.color} text-2xl mr-3`"></i>
<div>
<p class="text-xl font-bold">{{ item.value }}</p>
<p class="text-sm text-gray-500">{{ item.label }}</p>
</div>
</div>
</div>
</div>
<!-- 对比视图切换区域 -->
<div class="bg-white p-4 rounded-xl shadow-sm border">
<div class="border-b mb-3">
<div class="flex space-x-6">
<button
@click="activeView = 'table'"
:class="`py-2 text-sm ${activeView === 'table' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`"
>
表格对比
</button>
<button
@click="activeView = 'chart'"
:class="`py-2 text-sm ${activeView === 'chart' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`"
>
图表对比
</button>
</div>
</div>
<!-- 根据当前视图状态渲染对应的对比组件 -->
<TableComparison
v-if="activeView === 'table'"
:old-query="oldQuery"
:new-query="newQuery"
:changes="changes"
/>
<ChartComparison v-else :old-query="oldQuery" :new-query="newQuery" />
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Chart as ChartJS, registerables } from 'chart.js'
import { Chart } from 'vue-chartjs'
import type { QueryResultData } from '../../../types'
import Modal from './Modal.vue'
// ChartJS
ChartJS.register(...registerables)
// Props
interface ComparisonModalProps {
oldQuery: QueryResultData
newQuery: QueryResultData
isOpen: boolean
onClose: () => void
}
// DiffResult
interface DiffResult {
added: string[][]
deleted: string[][]
modified: { oldRow: string[]; newRow: string[] }[]
common: string[][]
}
// Props
const props = defineProps<ComparisonModalProps>()
//
const activeView = ref<'table' | 'chart'>('table')
//
const changes = computed<DiffResult>(() => {
return findRowChanges(props.oldQuery.tableData.rows, props.newQuery.tableData.rows)
})
//
const summaryItems = computed(() => [
{
icon: 'fa-plus-circle',
color: 'text-green-500',
value: changes.value.added.length,
label: '新增行',
},
{
icon: 'fa-minus-circle',
color: 'text-red-500',
value: changes.value.deleted.length,
label: '删除行',
},
{
icon: 'fa-pencil-square-o',
color: 'text-yellow-500',
value: changes.value.modified.length,
label: '变更行',
},
])
//
const findRowChanges = (oldRows: string[][], newRows: string[][]): DiffResult => {
const oldMap = new Map(oldRows.map((row) => [row[0], row]))
const newMap = new Map(newRows.map((row) => [row[0], row]))
const result: DiffResult = { added: [], deleted: [], modified: [], common: [] }
//
oldMap.forEach((oldRow, key) => {
if (!newMap.has(key)) {
result.deleted.push(oldRow)
} else {
const newRow = newMap.get(key)!
if (JSON.stringify(oldRow) !== JSON.stringify(newRow)) {
result.modified.push({ oldRow, newRow })
} else {
result.common.push(oldRow)
}
}
})
//
newMap.forEach((newRow, key) => {
if (!oldMap.has(key)) {
result.added.push(newRow)
}
})
return result
}
// TableComparison
interface TableComparisonProps {
oldQuery: QueryResultData
newQuery: QueryResultData
changes: DiffResult
}
const TableComparison = (props: TableComparisonProps) => {
//
const renderCell = (oldCell: string, newCell: string) => {
if (oldCell !== newCell) {
return {
isModified: true,
element: (
<span class="bg-yellow-100 text-yellow-800 p-1 rounded">
{newCell} <span class="text-xs text-gray-500 line-through ml-1">{oldCell}</span>
</span>
),
}
}
return { isModified: false, element: newCell }
}
return (
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 旧数据表格 */}
<div>
<h3 class="font-bold mb-2">
旧数据 ({new Date(props.oldQuery.queryTime).toLocaleString()})
</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
{props.oldQuery.tableData.headers.map((h) => (
<th key={h} class="p-2">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* 显示删除的行 */}
{props.changes.deleted.map((row, i) => (
<tr key={`del-${i}`} class="bg-red-100">
<td colspan={row.length} class="p-2 text-center text-red-700 line-through">
{row.join(' | ')}
</td>
</tr>
))}
{/* 显示修改的旧行 */}
{props.changes.modified.map(({ oldRow }, i) => (
<tr key={`mod-old-${i}`} class="border-b">
{oldRow.map((cell, j) => (
<td key={j} class="p-2 text-center">
{cell}
</td>
))}
</tr>
))}
{/* 显示未变化的共同行 */}
{props.changes.common.map((row, i) => (
<tr key={`com-${i}`} class="border-b">
{row.map((cell, j) => (
<td key={j} class="p-2 text-center">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 新数据表格 */}
<div>
<h3 class="font-bold mb-2">
新数据 ({new Date(props.newQuery.queryTime).toLocaleString()})
</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
{props.newQuery.tableData.headers.map((h) => (
<th key={h} class="p-2">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{/* 显示新增的行 */}
{props.changes.added.map((row, i) => (
<tr key={`add-${i}`} class="bg-green-100">
{row.map((cell, j) => (
<td key={j} class="p-2 text-center text-green-700">
{cell}
</td>
))}
</tr>
))}
{/* 显示修改的新行 */}
{props.changes.modified.map(({ oldRow, newRow }, i) => (
<tr key={`mod-new-${i}`} class="border-b">
{newRow.map((cell, j) => {
const cellResult = renderCell(oldRow[j], cell)
return (
<td key={j} class="p-2 text-center">
{cellResult.isModified ? cellResult.element : cell}
</td>
)
})}
</tr>
))}
{/* 显示未变化的共同行 */}
{props.changes.common.map((row, i) => (
<tr key={`com-${i}`} class="border-b">
{row.map((cell, j) => (
<td key={j} class="p-2 text-center">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
// ChartComparison
interface ChartComparisonProps {
oldQuery: QueryResultData
newQuery: QueryResultData
}
const ChartComparison = (props: ChartComparisonProps) => {
//
const chartData = {
labels: [...new Set([...props.oldQuery.chartData.labels, ...props.newQuery.chartData.labels])],
datasets: [
{
...props.oldQuery.chartData.datasets[0],
label: `${props.oldQuery.chartData.datasets[0].label} (旧)`,
backgroundColor: 'rgba(22, 93, 255, 0.3)',
borderColor: 'rgba(22, 93, 255, 0.5)',
borderDash: [5, 5],
},
{
...props.newQuery.chartData.datasets[0],
label: `${props.newQuery.chartData.datasets[0].label} (新)`,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
},
],
}
return (
<div class="bg-white p-4 rounded-lg border">
<div class="chart-container h-64">
<Chart
type={props.newQuery.chartData.type}
data={chartData}
options={{ responsive: true, maintainAspectRatio: false }}
/>
</div>
</div>
)
}
</script>
<style scoped>
.chart-container {
position: relative;
width: 100%;
}
</style>

@ -0,0 +1,346 @@
<!--
@file components/feature/query/ComparisonModal.vue
@description 查询对比模态框
功能
- 新旧查询结果对比
- 表格和图表视图切换
- 差异分析展示
@author Frontend Team
-->
<template>
<Modal
:is-open="isOpen"
@close="onClose"
title="查询快照对比"
content-class="max-w-6xl max-h-[90vh] min-h-[60vh]"
>
<!-- 弹窗内容区域 -->
<div class="p-4 space-y-6 max-h-[calc(100%-2rem)] overflow-y-auto">
<!-- 对比说明 -->
<div>
<p class="text-gray-600 text-sm">对比查询 "{{ oldQuery.userPrompt }}" 的两个不同版本</p>
</div>
<!-- 对比摘要卡片 -->
<div class="bg-white p-4 rounded-xl shadow-sm border">
<h3 class="font-bold mb-3">对比摘要</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div
v-for="item in summaryItems"
:key="item.label"
class="flex items-center p-3 bg-gray-50 rounded-lg"
>
<i :class="`fa ${item.icon} ${item.color} text-2xl mr-3`"></i>
<div>
<p class="text-xl font-bold">{{ item.value }}</p>
<p class="text-sm text-gray-500">{{ item.label }}</p>
</div>
</div>
</div>
</div>
<!-- 对比视图切换区域 -->
<div class="bg-white p-4 rounded-xl shadow-sm border">
<div class="border-b mb-3">
<div class="flex space-x-6">
<button
@click="activeView = 'table'"
:class="`py-2 text-sm ${activeView === 'table' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`"
>
表格对比
</button>
<button
@click="activeView = 'chart'"
:class="`py-2 text-sm ${activeView === 'chart' ? 'border-b-2 border-primary text-primary' : 'text-gray-500'}`"
>
图表对比
</button>
</div>
</div>
<!-- 表格对比组件 -->
<div v-if="activeView === 'table'">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 旧数据表格 -->
<div>
<h3 class="font-bold mb-2">旧数据 ({{ formatDate(oldQuery.queryTime) }})</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
<th v-for="header in oldQuery.tableData.headers" :key="header" class="p-2">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<!-- 显示删除的行 -->
<tr v-for="(row, i) in changes.deleted" :key="`del-${i}`" class="bg-red-100">
<td :colspan="row.length" class="p-2 text-center text-red-700 line-through">
{{ row.join(' | ') }}
</td>
</tr>
<!-- 显示修改的旧行 -->
<tr
v-for="({ oldRow }, i) in changes.modified"
:key="`mod-old-${i}`"
class="border-b"
>
<td v-for="(cell, j) in oldRow" :key="j" class="p-2 text-center">
{{ cell }}
</td>
</tr>
<!-- 显示未变化的共同行 -->
<tr v-for="(row, i) in changes.common" :key="`com-${i}`" class="border-b">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center">
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 新数据表格 -->
<div>
<h3 class="font-bold mb-2">新数据 ({{ formatDate(newQuery.queryTime) }})</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
<th v-for="header in newQuery.tableData.headers" :key="header" class="p-2">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<!-- 显示新增的行 -->
<tr v-for="(row, i) in changes.added" :key="`add-${i}`" class="bg-green-100">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center text-green-700">
{{ cell }}
</td>
</tr>
<!-- 显示修改的新行 -->
<tr
v-for="({ oldRow, newRow }, i) in changes.modified"
:key="`mod-new-${i}`"
class="border-b"
>
<td v-for="(cell, j) in newRow" :key="j" class="p-2 text-center">
<span
v-if="oldRow[j] !== cell"
class="bg-yellow-100 text-yellow-800 p-1 rounded"
>
{{ cell }}
<span class="text-xs text-gray-500 line-through ml-1">
{{ oldRow[j] }}
</span>
</span>
<span v-else>
{{ cell }}
</span>
</td>
</tr>
<!-- 显示未变化的共同行 -->
<tr v-for="(row, i) in changes.common" :key="`com-${i}`" class="border-b">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center">
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 图表对比组件 -->
<div v-else class="bg-white p-4 rounded-lg border">
<div class="chart-container h-64">
<Bar
v-if="newQuery.chartData.type === 'bar'"
:data="chartData"
:options="chartOptions as any"
/>
<Line
v-else-if="newQuery.chartData.type === 'line'"
:data="chartData"
:options="chartOptions as any"
/>
<Pie
v-else-if="newQuery.chartData.type === 'pie'"
:data="chartData"
:options="chartOptions as any"
/>
<Doughnut
v-else-if="newQuery.chartData.type === 'doughnut'"
:data="chartData"
:options="chartOptions as any"
/>
</div>
</div>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Chart as ChartJS, registerables } from 'chart.js'
import { Bar, Line, Pie, Doughnut } from 'vue-chartjs'
import Modal from '../../ui/Modal.vue'
import type { QueryResultData } from '../../../types'
import type { ChartOptions } from 'chart.js'
ChartJS.register(...registerables)
interface Props {
oldQuery: QueryResultData
newQuery: QueryResultData
isOpen: boolean
onClose: () => void
}
const props = defineProps<Props>()
//
const activeView = ref<'table' | 'chart'>('table')
//
const changes = computed(() =>
findRowChanges(props.oldQuery.tableData.rows, props.newQuery.tableData.rows),
)
//
const summaryItems = computed(() => [
{
icon: 'fa-plus-circle',
color: 'text-green-500',
value: changes.value.added.length,
label: '新增行',
},
{
icon: 'fa-minus-circle',
color: 'text-red-500',
value: changes.value.deleted.length,
label: '删除行',
},
{
icon: 'fa-pencil-square-o',
color: 'text-yellow-500',
value: changes.value.modified.length,
label: '变更行',
},
])
//
const chartData = computed(() => {
const oldLabels = props.oldQuery.chartData?.labels || []
const newLabels = props.newQuery.chartData?.labels || []
const labels = [...new Set([...oldLabels, ...newLabels])]
const oldDataset = props.oldQuery.chartData?.datasets?.[0] || { label: '旧数据', data: [] }
const newDataset = props.newQuery.chartData?.datasets?.[0] || { label: '新数据', data: [] }
return {
labels,
datasets: [
{
...oldDataset,
label: `${oldDataset.label} (旧)`,
backgroundColor: 'rgba(22, 93, 255, 0.3)',
borderColor: 'rgba(22, 93, 255, 0.5)',
borderDash: [5, 5],
},
{
...newDataset,
label: `${newDataset.label} (新)`,
backgroundColor: 'rgba(54, 162, 235, 0.6)',
borderColor: 'rgba(54, 162, 235, 1)',
},
],
}
})
//
const chartOptions: ChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
},
scales:
props.newQuery.chartData?.type === 'bar'
? {
x: {
grid: {
display: false,
},
},
y: {
beginAtZero: true,
},
}
: undefined,
}
//
const findRowChanges = (oldRows: string[][], newRows: string[][]): DiffResult => {
const oldMap = new Map(oldRows.map((row) => [row[0], row]))
const newMap = new Map(newRows.map((row) => [row[0], row]))
const result: DiffResult = { added: [], deleted: [], modified: [], common: [] }
//
oldMap.forEach((oldRow, key) => {
if (!newMap.has(key)) {
result.deleted.push(oldRow)
} else {
const newRow = newMap.get(key)!
if (JSON.stringify(oldRow) !== JSON.stringify(newRow)) {
result.modified.push({ oldRow, newRow })
} else {
result.common.push(oldRow)
}
}
})
//
newMap.forEach((newRow, key) => {
if (!oldMap.has(key)) {
result.added.push(newRow)
}
})
return result
}
//
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString()
}
//
type DiffResult = {
added: string[][]
deleted: string[][]
modified: { oldRow: string[]; newRow: string[] }[]
common: string[][]
}
</script>
<style scoped>
.chart-container {
position: relative;
width: 100%;
height: 16rem; /* h-64 */
}
</style>

@ -0,0 +1,490 @@
<!--
@file components/feature/query/QueryResult.vue
@description 查询结果展示组件
功能
- 表格/图表双视图切换
- SQL 代码展示
- 保存/分享/导出操作
- 响应式布局
@author Frontend Team
-->
<template>
<div class="w-full max-w-full">
<!-- 标题和元信息 -->
<div class="flex items-center justify-between mb-2">
<h3 class="text-base font-bold text-dark">查询结果{{ formatQueryTime }}</h3>
<p class="text-sm">
<!-- 可选内容 -->
</p>
</div>
<div class="text-xs text-gray-500 space-y-0.5 mb-2">
<p>
数据库<span>{{ result.database || '销售数据库' }}</span>
</p>
<p>
大模型<span>{{ result.model || 'gemini-2.5-pro' }}</span>
</p>
<p>
执行耗时<span>{{ result.executionTime }}</span>
</p>
</div>
<!-- 标签页导航 -->
<div class="border-b mb-4">
<div class="flex space-x-6">
<button
v-for="tab in tabs"
:key="tab.view"
@click="activeView = tab.view"
:class="[
'py-2 text-sm transition-colors duration-200',
activeView === tab.view
? 'border-b-2 border-primary text-primary font-medium'
: 'text-gray-500 hover:text-dark',
]"
>
{{ tab.label }}
</button>
</div>
</div>
<!-- 内容区域 -->
<div class="min-h-[200px]">
<div v-if="activeView === 'table'">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b">
<th
v-for="(header, i) in result.tableData.headers"
:key="i"
class="px-4 py-3 font-semibold text-dark text-center"
>
{{ header }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, i) in result.tableData.rows"
:key="i"
class="border-b last:border-b-0 hover:bg-gray-50"
>
<td
v-for="(cell, j) in row"
:key="j"
:class="[
'px-4 py-3 text-center',
typeof cell === 'string' && cell.startsWith('+')
? 'text-success font-medium'
: 'text-gray-700',
]"
>
{{ cell }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="activeView === 'chart'">
<div
v-if="!hasChartData"
class="flex flex-col items-center justify-center py-12 text-gray-500"
>
<i class="fa fa-chart-bar text-6xl mb-4 opacity-20"></i>
<p class="text-lg">暂无可视化数据</p>
<p class="text-sm mt-2">当前查询结果不适合生成图表</p>
</div>
<div v-else class="bg-white p-2 rounded-lg">
<div class="flex justify-center space-x-2 mb-4">
<button
v-for="type in chartTypes"
:key="type.value"
@click="chartType = type.value"
:class="[
'px-3 py-1.5 text-sm rounded-md flex items-center transition-colors',
chartType === type.value
? 'bg-primary text-white'
: 'bg-gray-200 hover:bg-gray-300',
]"
>
<i :class="`fa ${type.icon} mr-2`"></i>{{ type.label }}
</button>
</div>
<div class="chart-container">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</div>
<div v-else-if="activeView === 'prompt'">
<div class="p-4 bg-gray-50 rounded-md">
<p class="text-gray-800 whitespace-pre-wrap">{{ result.userPrompt }}</p>
</div>
</div>
<div v-else-if="activeView === 'sql'">
<div class="relative">
<button
@click="handleCopySql"
class="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-600 hover:bg-gray-700 text-white rounded transition-colors"
>
<i :class="`fa ${copySuccess ? 'fa-check' : 'fa-copy'}`"></i>
{{ copySuccess ? '已复制' : '复制' }}
</button>
<pre class="p-4 bg-gray-800 text-white rounded-md text-sm overflow-x-auto">
<code>{{ result.sqlQuery }}</code>
</pre>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 mt-4 ml-auto justify-end">
<button
v-if="showActions.save"
@click="handleSave"
:class="[
'px-3 py-2 border rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-2 min-w-[80px] max-w-[100px]',
isSaved ? 'border-primary bg-primary/10 text-primary' : 'border-gray-300'
]"
>
<i :class="['fa flex-shrink-0', isSaved ? 'fa-star' : 'fa-star-o']"></i>
<span class="truncate">{{ isSaved ? '已收藏' : '收藏' }}</span>
</button>
<button
v-if="showActions.share"
@click="handleOpenShareModal"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-2 min-w-[80px] max-w-[100px]"
>
<i class="fa fa-share-alt flex-shrink-0"></i>
<span class="truncate">分享</span>
</button>
<button
v-if="showActions.export"
@click="handleExport"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 flex items-center gap-2 min-w-[80px] max-w-[100px]"
>
<i class="fa fa-download flex-shrink-0"></i>
<span class="truncate">导出</span>
</button>
</div>
<!-- 分享模态框 -->
<Modal :is-open="activeModal === 'share'" @close="activeModal = null" title="分享查询结果">
<div class="space-y-4">
<p class="text-sm text-gray-600">选择一位好友来分享本次的查询结果</p>
<div class="max-h-60 overflow-y-auto space-y-2 p-2 bg-gray-50 rounded-lg border">
<template v-if="MOCK_FRIENDS_LIST.length > 0">
<label
v-for="friend in MOCK_FRIENDS_LIST"
:key="friend.id"
:class="[
'flex items-center p-3 rounded-lg cursor-pointer transition-colors',
selectedFriendId === friend.id ? 'bg-primary/20' : 'hover:bg-gray-200',
]"
>
<input
type="radio"
name="friend-share-selection"
:checked="selectedFriendId === friend.id"
@change="selectedFriendId = friend.id"
class="mr-3"
/>
<div class="flex items-center space-x-3">
<img :src="friend.avatarUrl" :alt="friend.name" class="w-8 h-8 rounded-full" />
<span class="text-sm font-medium text-dark truncate" :title="friend.name">{{
friend.name
}}</span>
</div>
</label>
</template>
<p v-else class="text-center text-gray-500 text-sm p-4">没有可分享的好友</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
@click="activeModal = null"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"
>
取消
</button>
<button
@click="handleConfirmShare"
:disabled="!selectedFriendId"
:class="[
'px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200',
!selectedFriendId ? 'bg-primary/50 cursor-not-allowed' : '',
]"
>
确认分享
</button>
</div>
</Modal>
<!-- 成功模态框 -->
<Modal :is-open="activeModal === 'saveSuccess'" @close="activeModal = null" hide-title>
<div class="text-center">
<div
class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"
>
<i class="fa fa-check text-green-500 text-3xl"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">收藏成功</h3>
<p class="text-sm text-gray-500">您可以在收藏夹页面查看已收藏的查询</p>
<button @click="activeModal = null" class="mt-4 px-6 py-2 bg-primary text-white rounded-lg">
确定
</button>
</div>
</Modal>
<Modal :is-open="activeModal === 'exportSuccess'" @close="activeModal = null" hide-title>
<div class="text-center">
<div
class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4"
>
<i class="fa fa-download text-blue-500 text-2xl"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">导出已开始</h3>
<p class="text-sm text-gray-500">文件将保存到您的下载文件夹中</p>
<button @click="activeModal = null" class="mt-4 px-6 py-2 bg-primary text-white rounded-lg">
确定
</button>
</div>
</Modal>
<Modal :is-open="activeModal === 'shareSuccess'" @close="activeModal = null" hide-title>
<div class="text-center">
<div
class="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4"
>
<i class="fa fa-share-alt text-orange-500 text-2xl"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">分享成功</h3>
<button @click="activeModal = null" class="mt-4 px-6 py-2 bg-primary text-white rounded-lg">
确定
</button>
</div>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { Chart, registerables, type ChartType } from 'chart.js'
import Modal from '../../ui/Modal.vue'
import type { QueryResultData } from '../../../types'
import { MOCK_FRIENDS_LIST } from '../../../constants'
// Chart.js
Chart.register(...registerables)
// Props
interface QueryResultProps {
result: QueryResultData
onSaveQuery?: (query: QueryResultData) => void
onShareQuery?: (queryId: string, friendId: string) => void
savedQueries?: QueryResultData[]
showActions?: {
save?: boolean
share?: boolean
export?: boolean
}
}
// Emits
const emit = defineEmits<{
(e: 'save-query', query: QueryResultData): void
(e: 'share-query', queryId: string, friendId: string): void
}>()
const props = withDefaults(defineProps<QueryResultProps>(), {
savedQueries: () => [],
showActions: () => ({ save: true, share: true, export: true }),
})
//
type ViewType = 'table' | 'chart' | 'prompt' | 'sql'
const activeView = ref<ViewType>('table')
const chartType = ref<ChartType>(props.result.chartData?.type || 'bar')
const activeModal = ref<string | null>(null)
const copySuccess = ref(false)
const selectedFriendId = ref<string | null>(null)
const chartCanvas = ref<HTMLCanvasElement | null>(null)
const chartInstance = ref<Chart | null>(null)
//
const tabs = [
{ view: 'table' as ViewType, label: '表格视图' },
{ view: 'chart' as ViewType, label: '图表视图' },
{ view: 'prompt' as ViewType, label: '原始提问' },
{ view: 'sql' as ViewType, label: 'SQL语句' },
]
//
const chartTypes = [
{ value: 'bar' as ChartType, label: '竖状图', icon: 'fa-bar-chart' },
{ value: 'line' as ChartType, label: '折线图', icon: 'fa-line-chart' },
{ value: 'pie' as ChartType, label: '饼状图', icon: 'fa-pie-chart' },
]
//
const hasChartData = computed(() => {
return (
props.result.chartData &&
props.result.chartData.labels &&
props.result.chartData.labels.length > 0
)
})
const formatQueryTime = computed(() => {
return new Date(props.result.queryTime).toLocaleString()
})
//
const isSaved = computed(() => {
if (!props.savedQueries || props.savedQueries.length === 0) return false
// id userPrompt + sqlQuery
return props.savedQueries.some((q) => {
return q.id === props.result.id ||
(q.userPrompt === props.result.userPrompt && q.sqlQuery === props.result.sqlQuery)
})
})
//
const createChart = () => {
if (!chartCanvas.value || !props.result.chartData || !hasChartData.value) return
const ctx = chartCanvas.value.getContext('2d')
if (!ctx) return
chartInstance.value = new Chart(ctx, {
type: chartType.value,
data: {
labels: props.result.chartData.labels,
datasets: props.result.chartData.datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: chartType.value === 'pie',
},
},
},
})
}
//
const destroyChart = () => {
if (chartInstance.value) {
chartInstance.value.destroy()
chartInstance.value = null
}
}
//
watch(
() => props.result.chartData?.type,
(newType) => {
if (newType) {
chartType.value = newType
}
},
)
//
watch(
[activeView, chartType, () => props.result.chartData],
([view, _type, chartData]) => {
if (view === 'chart' && chartData && hasChartData.value) {
destroyChart()
nextTick(() => {
createChart()
})
} else {
destroyChart()
}
},
{ immediate: true },
)
//
onUnmounted(() => {
destroyChart()
})
// SQL
const handleCopySql = async () => {
try {
await navigator.clipboard.writeText(props.result.sqlQuery)
copySuccess.value = true
setTimeout(() => {
copySuccess.value = false
}, 2000)
} catch (err) {
console.error('复制失败:', err)
}
}
//
const handleOpenShareModal = () => {
selectedFriendId.value = null
activeModal.value = 'share'
}
const handleConfirmShare = () => {
if (!selectedFriendId.value) return
//
if (props.onShareQuery) {
props.onShareQuery(props.result.id, selectedFriendId.value)
} else {
emit('share-query', props.result.id, selectedFriendId.value)
}
activeModal.value = null
setTimeout(() => {
activeModal.value = 'shareSuccess'
}, 350)
}
//
const handleSave = () => {
//
if (props.onSaveQuery) {
props.onSaveQuery(props.result)
} else {
emit('save-query', props.result)
}
activeModal.value = 'saveSuccess'
}
// CSV
const handleExport = () => {
const { headers, rows } = props.result.tableData
const csvContent =
'data:text/csv;charset=utf-8,' +
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n')
const encodedUri = encodeURI(csvContent)
const link = document.createElement('a')
link.setAttribute('href', encodedUri)
link.setAttribute('download', `query_result_${props.result.id}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
activeModal.value = 'exportSuccess'
}
</script>
<style scoped></style>

@ -0,0 +1,109 @@
<!--
@file components/feature/query/TableComparison.vue
@description 表格对比组件
功能
- 新旧查询表格并排对比
- 差异高亮显示
- 响应式布局
@author Frontend Team
-->
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 旧数据表格 -->
<div>
<h3 class="font-bold mb-2">旧数据 ({{ formatDate(oldQuery.queryTime) }})</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
<th v-for="header in oldQuery.tableData.headers" :key="header" class="p-2">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<!-- 显示删除的行 -->
<tr v-for="(row, i) in changes.deleted" :key="`del-${i}`" class="bg-red-100">
<td :colspan="row.length" class="p-2 text-center text-red-700 line-through">
{{ row.join(' | ') }}
</td>
</tr>
<!-- 显示修改的旧行 -->
<tr v-for="({ oldRow }, i) in changes.modified" :key="`mod-old-${i}`" class="border-b">
<td v-for="(cell, j) in oldRow" :key="j" class="p-2 text-center">{{ cell }}</td>
</tr>
<!-- 显示未变化的共同行 -->
<tr v-for="(row, i) in changes.common" :key="`com-${i}`" class="border-b">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 新数据表格 -->
<div>
<h3 class="font-bold mb-2">新数据 ({{ formatDate(newQuery.queryTime) }})</h3>
<div class="overflow-x-auto border rounded-lg max-h-[40vh]">
<table class="w-full text-sm">
<thead>
<tr class="border-b bg-gray-50">
<th v-for="header in newQuery.tableData.headers" :key="header" class="p-2">
{{ header }}
</th>
</tr>
</thead>
<tbody>
<!-- 显示新增的行 -->
<tr v-for="(row, i) in changes.added" :key="`add-${i}`" class="bg-green-100">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center text-green-700">
{{ cell }}
</td>
</tr>
<!-- 显示修改的新行 -->
<tr
v-for="({ oldRow, newRow }, i) in changes.modified"
:key="`mod-new-${i}`"
class="border-b"
>
<td v-for="(cell, j) in newRow" :key="j" class="p-2 text-center">
<span v-if="oldRow[j] !== cell" class="bg-yellow-100 text-yellow-800 p-1 rounded">
{{ cell }}
<span class="text-xs text-gray-500 line-through ml-1">{{ oldRow[j] }}</span>
</span>
<span v-else>{{ cell }}</span>
</td>
</tr>
<!-- 显示未变化的共同行 -->
<tr v-for="(row, i) in changes.common" :key="`com-${i}`" class="border-b">
<td v-for="(cell, j) in row" :key="j" class="p-2 text-center">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { QueryResultData } from '../../../types'
interface Props {
oldQuery: QueryResultData
newQuery: QueryResultData
changes: {
added: string[][]
deleted: string[][]
modified: { oldRow: string[]; newRow: string[] }[]
common: string[][]
}
}
defineProps<Props>()
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString()
}
</script>

@ -0,0 +1,7 @@
/**
*
*/
export { default as QueryResult } from './QueryResult.vue'
export { default as ChartComparison } from './ChartComparison.vue'
export { default as TableComparison } from './TableComparison.vue'
export { default as ComparisonModal } from './ComparisonModal.vue'

@ -0,0 +1,41 @@
/**
* @file components/index.ts
* @description
*
*
* - ui/: UI Modal, Dropdown, Toast
* - layout/: TopHeader, Sidebars
* - feature/:
* - query/:
* - chat/:
* - common/:
* - admin/:
* - data-admin/:
*
* @example
* import { Modal, Dropdown, TopHeader } from '@/components'
*
* @author Frontend Team
* @since 1.0.0
*/
// UI 组件
export * from './ui'
// 布局组件
export * from './layout'
// 业务组件 - 查询
export * from './feature/query'
// 业务组件 - 聊天
export * from './feature/chat'
// 通用组件
export * from './common'
// Admin 组件
export * from './admin'
// Data Admin 组件
export * from './data-admin'

@ -0,0 +1,136 @@
<!--
@file components/layout/MainLayout.vue
@description 主布局组件
功能
- 管理侧边栏和 TopHeader 的布局
- 处理侧边栏的打开/关闭和收起/展开
- 根据用户角色渲染不同的侧边栏
-->
<template>
<div :class="['flex h-screen bg-neutral', { 'sidebar-collapsed': isSidebarCollapsed }]">
<!-- 侧边栏 -->
<component
:is="sidebarComponent"
:active-page="activePage"
:is-open="isSidebarOpen"
:is-collapsed="isSidebarCollapsed"
@update:active-page="handlePageChange"
@logout="handleLogout"
@close="isSidebarOpen = false"
@toggle="isSidebarCollapsed = !isSidebarCollapsed"
/>
<!-- 主内容区域 -->
<main class="flex-1 flex flex-col overflow-hidden">
<TopHeader
:user="{
name: currentUser.name,
role: userRole,
avatarUrl: currentUser.avatarUrl,
}"
:notification-count="0"
:notifications="[]"
:show-history-toggle="isQueryPage"
@notification-click="handleNotificationClick"
@avatar-click="handleAvatarClick"
@toggle-history="handleToggleHistory"
@new-conversation="handleNewConversation"
:current-conversation-name="headerTitle"
@toggle-sidebar="handleToggleSidebar"
:is-sidebar-collapsed="isSidebarCollapsed"
/>
<!-- 内容区域所有页面自己管理布局MainLayout不做特殊处理 -->
<slot></slot>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { UserRole } from '../../types'
import TopHeader from './TopHeader.vue'
import UserSidebar from './sidebars/UserSidebar.vue'
import SysAdminSidebar from './sidebars/SysAdminSidebar.vue'
import DataAdminSidebar from './sidebars/DataAdminSidebar.vue'
interface Props {
userRole: UserRole
currentUser: { name: string; avatarUrl: string }
activePage: string
headerTitle: string
isQueryPage: boolean
}
interface Emits {
(e: 'update:active-page', page: string): void
(e: 'logout'): void
(e: 'notification-click'): void
(e: 'avatar-click'): void
(e: 'toggle-history'): void
(e: 'new-conversation'): void
(e: 'update:query-title', title: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const isSidebarOpen = ref(false)
const isSidebarCollapsed = ref(false)
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 1024 // lg breakpoint
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
//
const sidebarComponent = computed(() => {
if (props.userRole === 'sys-admin') return SysAdminSidebar
if (props.userRole === 'data-admin') return DataAdminSidebar
return UserSidebar
})
//
const handlePageChange = (page: string) => {
emit('update:active-page', page)
}
const handleLogout = () => {
emit('logout')
}
const handleToggleSidebar = () => {
if (isMobile.value) {
isSidebarOpen.value = !isSidebarOpen.value
} else {
isSidebarCollapsed.value = !isSidebarCollapsed.value
}
}
const handleNotificationClick = () => {
emit('notification-click')
}
const handleAvatarClick = () => {
emit('avatar-click')
}
const handleToggleHistory = () => {
emit('toggle-history')
}
const handleNewConversation = () => {
emit('new-conversation')
}
</script>

@ -0,0 +1,90 @@
<!--
@file components/layout/RecommendationLayout.vue
@description 推荐布局组件
功能
- 管理推荐侧边栏的显示桌面端和移动端
- 桌面端固定在右侧
- 移动端滑动侧边栏覆盖层
布局说明
- 桌面端右侧固定宽度无背景色避免多余的白色div
- 移动端覆盖层模式从右侧滑出
-->
<template>
<!-- 桌面端推荐侧边栏 - 固定在右侧窄宽度只显示按钮 -->
<div class="hidden lg:block w-20 flex-shrink-0 px-2 py-4 border-l border-gray-200 overflow-hidden">
<RightSidebar
:current-conversation="currentConversation"
@recommendation-click="handleRecommendationClick"
@open-common="() => $emit('open-common')"
@open-suggestions="() => $emit('open-suggestions')"
class="h-full"
/>
</div>
<!-- 移动端推荐侧边栏 - 从右侧滑出 -->
<div
v-if="isMobileOpen"
class="lg:hidden fixed top-0 right-0 w-80 h-full bg-white border-l border-gray-200 shadow-lg z-[60] transform transition-transform duration-300"
>
<div class="h-full flex flex-col">
<div class="flex justify-between items-center p-4 border-b">
<h2 class="text-lg font-bold flex items-center">
<i class="fa fa-star text-primary mr-2"></i>
常用推荐
</h2>
<button
@click="$emit('close-mobile')"
class="text-gray-500 hover:text-gray-700 transition-colors p-2"
>
<i class="fa fa-times text-lg"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto">
<RightSidebar
:current-conversation="currentConversation"
@recommendation-click="handleRecommendationClick"
class="h-full"
/>
</div>
</div>
</div>
<!-- 移动端推荐侧边栏遮罩层 -->
<div
v-if="isMobileOpen"
@click="$emit('close-mobile')"
class="lg:hidden fixed inset-0 bg-black/30 z-[50] transition-opacity duration-300"
></div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import RightSidebar from './sidebars/QueryRecommendSidebar.vue'
import type { Conversation } from '../../types'
interface Props {
currentConversation: Conversation | undefined
isMobileOpen?: boolean
}
interface Emits {
(e: 'recommendation-click', prompt: string): void
(e: 'close-mobile'): void
(e: 'open-common'): void
(e: 'open-suggestions'): void
}
const props = withDefaults(defineProps<Props>(), {
isMobileOpen: false,
})
const emit = defineEmits<Emits>()
const handleRecommendationClick = (recommendation: string) => {
emit('close-mobile') //
emit('recommendation-click', recommendation)
}
</script>

@ -0,0 +1,312 @@
<!--
@file components/layout/TopHeader.vue
@description 系统顶部导航栏
功能
- 显示当前页面/对话标题
- 用户头像和下拉菜单
- 通知图标和未读数量
- 新建对话按钮查询页面
@author Frontend Team
-->
<template>
<header class="p-4 border-b flex justify-between items-center bg-white flex-shrink-0">
<div class="flex items-center space-x-2">
<!-- 移动端和桌面端侧边栏收起时汉堡图标 -->
<button
v-if="onToggleSidebar"
@click="onToggleSidebar"
:class="[
'p-2 text-gray-500 hover:text-primary transition-colors mr-2',
isSidebarCollapsed ? '' : 'lg:hidden'
]"
:title="isSidebarCollapsed ? '展开侧边栏' : '打开侧边栏'"
>
<i class="fa fa-bars text-xl"></i>
</button>
<h1 v-if="currentConversationName" class="text-xl font-bold truncate max-w-xs">
{{ currentConversationName }}
</h1>
</div>
<div class="flex items-center space-x-4">
<template v-if="showHistoryToggle">
<button
v-if="onNewConversation"
@click="onNewConversation"
class="p-2 text-gray-500 hover:text-primary transition-colors"
title="新对话"
>
<i class="fa fa-plus-circle text-xl"></i>
</button>
<button
v-if="onToggleHistory"
@click="onToggleHistory"
class="p-2 text-gray-500 hover:text-primary transition-colors"
title="历史对话"
>
<i class="fa fa-history text-xl"></i>
</button>
</template>
<!-- 主题切换按钮 -->
<button
@click="toggleTheme"
class="p-2 text-gray-500 hover:text-primary transition-colors"
:title="theme === 'light' ? '切换到夜间模式' : '切换到日间模式'"
>
<i :class="['fa', theme === 'light' ? 'fa-sun-o' : 'fa-moon-o', 'text-xl']"></i>
</button>
<div class="relative" ref="notificationRef">
<button
@click="toggleNotification"
class="p-2 text-gray-500 hover:text-primary transition-colors"
title="通知中心"
>
<i class="fa fa-bell text-xl"></i>
<span
v-if="notificationCount > 0"
class="absolute top-0.5 right-0.5 block h-4 w-4 rounded-full bg-danger text-white text-[10px] flex items-center justify-center ring-2 ring-white"
>
{{ notificationCount > 9 ? '9+' : notificationCount }}
</span>
</button>
<div
v-if="isNotificationOpen"
class="absolute top-full right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50"
>
<div class="p-4 border-b">
<h3 class="font-bold text-lg">通知中心</h3>
</div>
<ul class="divide-y divide-gray-100">
<li
v-for="notification in notificationsToShow"
:key="notification.id"
class="p-4 flex items-start space-x-3 hover:bg-gray-50"
>
<div :class="['mt-1 w-6 text-center', getNotificationDetails(notification).color]">
<i :class="['fa', getNotificationDetails(notification).icon]"></i>
</div>
<div class="flex-1">
<p class="text-sm text-dark">
<template
v-for="(part, i) in formatNotificationTitle(notification.title)"
:key="i"
>
<strong v-if="part.type === 'strong'">{{ part.content }}</strong>
<span v-else>{{ part.content }}</span>
</template>
</p>
<p class="text-xs text-gray-400 mt-1">
{{ formatTimeAgo(notification.timestamp) }}
</p>
</div>
</li>
</ul>
<div class="p-2 border-t text-center">
<button
@click="handleViewAll"
class="w-full text-sm text-primary py-2 hover:bg-primary/10 rounded"
>
查看全部通知
</button>
</div>
</div>
</div>
<button
@click="onAvatarClick"
class="flex items-center space-x-2 text-left transition-colors hover:bg-gray-100 p-1 rounded-lg"
>
<template v-if="avatarError || !user.avatarUrl">
<div
class="w-9 h-9 rounded-full bg-primary/80 flex items-center justify-center text-sm font-bold text-white"
>
<i v-if="!user.name" class="fa fa-user"></i>
<span v-else>{{ getInitials(user.name) }}</span>
</div>
</template>
<img
v-else
:src="user.avatarUrl"
alt="User Avatar"
class="w-9 h-9 rounded-full object-cover"
@error="avatarError = true"
/>
<div class="hidden md:block">
<p class="text-sm font-medium">{{ user.name }}</p>
<p class="text-xs text-gray-500">{{ getRoleName(user.role) }}</p>
</div>
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import type { UserRole, Notification } from '../../types'
import { useTheme } from '../../composables/useTheme'
const { theme, toggleTheme } = useTheme()
//
interface User {
name: string
role: UserRole | null
avatarUrl: string
}
interface TopHeaderProps {
user: User
notificationCount: number
notifications: Notification[]
onNewConversation?: () => void
onNotificationClick: () => void
showHistoryToggle?: boolean
onToggleHistory?: () => void
onAvatarClick: () => void
currentConversationName?: string
onToggleSidebar?: () => void
isSidebarCollapsed?: boolean
}
const props = defineProps<TopHeaderProps>()
//
const isNotificationOpen = ref(false)
//
const avatarError = ref(false)
// DOM
const notificationRef = ref<HTMLDivElement | null>(null)
// URL ( useEffect [user.avatarUrl])
watch(
() => props.user.avatarUrl,
(newUrl) => {
if (newUrl) {
avatarError.value = false
}
},
)
// ( useEffect [notificationRef])
const handleClickOutside = (event: MouseEvent) => {
if (
notificationRef.value &&
!notificationRef.value.contains(event.target as Node) &&
isNotificationOpen.value //
) {
isNotificationOpen.value = false
}
}
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
})
// --- ---
/** 根据用户角色获取角色中文名称 */
const getRoleName = (role: UserRole | null) => {
if (!role) return ''
switch (role) {
case 'sys-admin':
return '系统管理员'
case 'data-admin':
return '数据管理员'
case 'normal-user':
return '普通用户'
default:
return '用户'
}
}
/** 格式化通知标题:指定关键词加粗显示 (返回 Vue 支持的 VNode/Fragment) */
const formatNotificationTitle = (title: string) => {
const keywords = ['王小明', 'Gemini']
const regex = new RegExp(`(${keywords.join('|')})`, 'g')
const parts = title.split(regex)
// VNode
return parts.map((part, i) =>
keywords.includes(part)
? { type: 'strong', content: part, key: i }
: { type: 'text', content: part, key: i },
)
}
/** 根据通知内容获取对应的图标和颜色 */
const getNotificationDetails = (notification: Notification) => {
let icon = 'fa-info-circle'
let color = 'text-primary'
// 绿
if (notification.title.includes('新用户')) {
icon = 'fa-user-plus'
color = 'text-green-500'
}
//
else if (notification.title.includes('连接失败')) {
icon = 'fa-exclamation-triangle'
color = 'text-danger'
}
return { icon, color }
}
/** 格式化时间戳为相对时间3秒前、2小时前 */
const formatTimeAgo = (timestamp: string): string => {
const now = new Date()
const then = new Date(timestamp)
const diffInSeconds = Math.round((now.getTime() - then.getTime()) / 1000)
if (isNaN(diffInSeconds)) return '未知时间'
if (diffInSeconds < 60) return `${diffInSeconds}秒前`
const diffInMinutes = Math.round(diffInSeconds / 60)
if (diffInMinutes < 60) return `${diffInMinutes}分钟前`
const diffInHours = Math.round(diffInMinutes / 60)
if (diffInHours < 24) return `${diffInHours}小时前`
const diffInDays = Math.round(diffInHours / 24)
return `${diffInDays}天前`
}
/** 获取用户姓名首字母(用于头像占位符) */
const getInitials = (name: string) => {
if (!name) return '?'
return name.trim().charAt(0).toUpperCase()
}
/** 查看全部通知:关闭下拉框并触发回调 */
const handleViewAll = () => {
isNotificationOpen.value = false
props.onNotificationClick()
}
/** 筛选通知下拉框显示内容优先显示3条未读通知不足时补充已读通知 */
const notificationsToShow = computed(() => {
const notifications = props.notifications
const list = notifications.filter((n) => !n.isRead).slice(0, 3)
if (list.length < 3) {
const readNotifications = notifications.filter((n) => n.isRead)
list.push(...readNotifications.slice(0, 3 - list.length))
}
return list
})
/** 切换通知下拉框状态 */
const toggleNotification = () => {
isNotificationOpen.value = !isNotificationOpen.value
}
</script>

@ -0,0 +1,12 @@
/**
*
*/
export { default as TopHeader } from './TopHeader.vue'
// Sidebars
export { default as DataAdminSidebar } from './sidebars/DataAdminSidebar.vue'
export { default as LoginSidebar } from './sidebars/LoginSidebar.vue'
export { default as QueryHistorySidebar } from './sidebars/QueryHistorySidebar.vue'
export { default as QueryRecommendSidebar } from './sidebars/QueryRecommendSidebar.vue'
export { default as SysAdminSidebar } from './sidebars/SysAdminSidebar.vue'
export { default as UserSidebar } from './sidebars/UserSidebar.vue'

@ -0,0 +1,121 @@
<!--
@file components/layout/sidebars/DataAdminSidebar.vue
@description 数据管理员侧边栏
功能
- 数据管理导航数据源权限日志等
- 查询功能导航
- 登出按钮
- 基于 BaseSidebar 封装
@author Frontend Team
-->
<template>
<BaseSidebar :is-open="isOpen" :is-collapsed="isCollapsed" @logout="handleLogout" @close="handleClose" @toggle="handleToggle">
<template #header>
<i class="fa fa-database text-primary text-2xl"></i>
<h1 class="text-lg font-bold">数据管理中心</h1>
</template>
<SidebarCategory title="查询功能">
<SidebarItem
v-for="item in queryItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleItemClick"
/>
</SidebarCategory>
<SidebarCategory title="管理功能">
<SidebarItem
v-for="item in managementItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleItemClick"
/>
</SidebarCategory>
<SidebarCategory title="个人设置">
<SidebarItem
v-for="item in personalItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleItemClick"
/>
</SidebarCategory>
</BaseSidebar>
</template>
<script setup lang="ts">
import BaseSidebar from '../../common/BaseSidebar.vue'
import SidebarItem from '../../common/SidebarItem.vue'
import SidebarCategory from '../../common/SidebarCategory.vue'
import type { DataAdminPageType } from '../../types'
interface DataAdminSidebarProps {
activePage: DataAdminPageType | string
onLogout: () => void
isOpen?: boolean
isCollapsed?: boolean
}
const props = withDefaults(defineProps<DataAdminSidebarProps>(), {
isOpen: false,
isCollapsed: false,
})
const emit = defineEmits<{
(e: 'update:activePage', page: DataAdminPageType | string): void
(e: 'close'): void
(e: 'toggle'): void
}>()
const queryItems = [
{ href: 'query' as DataAdminPageType, icon: 'fa-search', label: '数据查询' },
{ href: 'history' as DataAdminPageType, icon: 'fa-star', label: '收藏夹' },
]
const managementItems = [
{ href: 'dashboard' as DataAdminPageType, icon: 'fa-tachometer', label: '仪表盘' },
{ href: 'datasource' as DataAdminPageType, icon: 'fa-plug', label: '数据源管理' },
{ href: 'user-permission' as DataAdminPageType, icon: 'fa-key', label: '用户权限管理' },
{ href: 'notification-management' as DataAdminPageType, icon: 'fa-bullhorn', label: '通知管理' },
{ href: 'connection-log' as DataAdminPageType, icon: 'fa-link', label: '连接日志' },
]
const personalItems = [
{ href: 'notifications' as DataAdminPageType, icon: 'fa-bell', label: '通知中心' },
{ href: 'account' as DataAdminPageType, icon: 'fa-user', label: '账户管理' },
{ href: 'friends' as DataAdminPageType, icon: 'fa-users', label: '好友管理' },
{ href: 'settings' as DataAdminPageType, icon: 'fa-cog', label: '设置' },
]
const handleItemClick = (page: DataAdminPageType | string) => {
emit('update:activePage', page)
//
if (props.isOpen && window.innerWidth < 1024) {
emit('close')
}
}
const handleLogout = () => {
props.onLogout() // onLogout Prop
}
const handleClose = () => {
emit('close')
}
const handleToggle = () => {
emit('toggle')
}
</script>

@ -0,0 +1,41 @@
<!--
@file components/layout/sidebars/LoginSidebar.vue
@description 登录页面右侧装饰栏
功能
- 展示系统 Logo 和标语
- 功能特性展示
- 仅在大屏幕显示
@author Frontend Team
-->
<template>
<div class="hidden lg:flex flex-col items-center justify-center bg-blue-50 p-12 text-center">
<div class="max-w-md">
<h3 class="text-3xl font-bold text-primary mb-4">智能数据一语洞穿</h3>
<p class="text-gray-600 mb-8">
无需复杂的SQL只需用您最熟悉的自然语言提问即可获得精准的数据洞察直观的可视化图表并轻松与团队分享
</p>
<ul class="space-y-4 text-left inline-block">
<li class="flex items-center text-gray-700">
<i class="fa fa-magic text-primary w-6 text-lg"></i><span>自然语言查询</span>
</li>
<li class="flex items-center text-gray-700">
<i class="fa fa-pie-chart text-primary w-6 text-lg"></i><span>智能图表生成</span>
</li>
<li class="flex items-center text-gray-700">
<i class="fa fa-share-alt text-primary w-6 text-lg"></i><span>结果轻松分享</span>
</li>
</ul>
</div>
<div class="mt-8">
<img :src="illustrationSvg" alt="Data Analysis Illustration" class="w-full max-w-sm h-auto" />
</div>
</div>
</template>
<script setup lang="ts">
//
const illustrationSvg =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTQzMyAxNDFhMTQgMTQgMCAwIDAtMTQgMTQuMDQ0djE5OC45MTJBMTQgMTQgMCAwIDAgNDMzIDM2OGgxNFYxNDFoLTE0eiIvPjxwYXRoIGZpbGw9IiNDRUQ4RkYiIGQ9Ik0zMzUgMTg0YTQgNCAwIDAgMC00IDR2MTM2YTQgNCAwIDAgMCA4IDBWMTg4YTQgNCAwIDAgMC00LTR6TTI1NSA5N2E0IDQgMCAwIDAtNCA0djI1MGE0IDQgMCAwIDAgOCAwdl0yNTBhNCA0IDAgMCAwLTQtNHpNMzc1IDIyN2E0IDQgMCAwIDAtNCA0djg5YTQgNCAwIDAgMCA4IDB2LTg5YTQgNCAwIDAgMC00LTR6Ii8+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTgxIDIwMWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2MTM3LjkyQTE0IDE0IDAgMCAwIDgxIDM2OGgxNFYyMDFIODF6Ii8+PHBhdGggZmlsbD0iI0NFRDhGRiIgZD0iTTE5NSA2MWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2MjgwLjkyQTE0IDE0IDAgMCAwIDE5NSAzNjhIMTlWMjYwaDE2NHYtNTZIMTlWOTFoMTc2VjYxaC0xNHptLTE2NCAxODVWMjEzaDE2NFYxNTVIMzF2NDh6bTE2NC05NVY5OWgtMTZWNzdhNCA0IDAgMCAwLTQgNFY2MWgtNDB2MTJhNCA0IDAgMCAwLTQgN3YxNkgzMXY0MGgxNjR6Ii8+PHBhdGggZmlsbD0iI0VBRUlGRiIgZD0iTTI5NiAyNTlhMTQgMTQgMCAwIDAtMTQgMTQuMDQ0djk0LjkyQTE0IDE0IDAgMCAwIDI5NiAzODJoMTRWMjU5aC0xNHpNMzU2IDMwOWExNCAxNCAwIDAgMC0xNCAxNC4wNDR2NTUuOTJBMTQgMTQgMCAwIDAgMzU2IDM5MWgxNFYzMDloLTM1eiIvPjxwYXRoIGZpbGw9IiNDRUQ4RkYiIGQ9Ik0xMzUgMTI0YTQgNCAwIDAgMC00IDR2MjE3YTQgNCAwIDAgMCA4IDBWMTE4YTQgNCAwIDAgMC00LTR6Ii8+PHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTQyMyAzODRINjlhMTQgMTQgMCAwIDAtMTQgMTR2NTRoMzg1VjM5OGExNCAxNCAwIDAgMC0xNC0xNHoiLz48cGF0aCBmaWxsPSIjQ0VEOEZGIiBkPSJNMzY4IDQyNEgxNDR2MTZoMjI0di0xNnpNMzYxIDQwNUg4NmExNCAxNCAwIDAgMCAwIDI4aDI3NWExNCAxNCAwIDAgMCAwLTI4eiIvPjxwYXRoIGZpbGw9IiNGNEY5RkYiIGQ9Ik01NSAzOTIuMTY0VjM5OGExNCAxNCAwIDAgMCAxNCAxNGg1NDFBMTQgMTQgMCAwIDEgNDM3IDQyNkg1OFY0MDZoMzAzdjEyaDI0di0xMmgyOHYxMmgyNHYtMTJoLTh2LTEySDU4djEyLjE2NEExMy45IDEzLjkgMCAwIDEgNTUgMzkyLjE2NHptMCAzMS44MzZWMzk4YTE0IDE0IDAgMCAxIDE0LTE0aDM1N2EzMyAzMyAwIDAgMSAzMyAzM2gtNDIydi0zMmgzMDN2LTguMTY0QTEzLjkgMTMuOSAwIDAgMSA0MjQgNDI0eiIvPjxwYXRoIGZpbGw9IiM0Q0I2RkYiIGQ9Ik0yMTEgMjg4aC0zM2wtMzUtOTBoLTI4bDQ3IDEyM2g0OGwxNi00NGg0NmwtOCA0NGg0Mmw0Ny0xMjNoLTM4bC0yMyA2M2gtNDVsMTYtNDVaIi8+PHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTk1IDQ1MmgyNHYyNEg5NXptMjA4IDBoMjR2MjRIMzAzem0xMjAgMGgyNHYyNGgtMjR6Ii8+PC9zdmc+'
</script>

@ -0,0 +1,184 @@
<!--
@file components/layout/sidebars/QueryHistorySidebar.vue
@description 查询历史侧边栏
功能
- 显示对话/会话列表
- 切换/删除对话
- 新建对话
- 可折叠显示
@author Frontend Team
-->
<template>
<div :class="sidebarClasses">
<div class="w-80 h-full flex flex-col overflow-hidden">
<div class="flex justify-between items-center p-4 border-b">
<h2 class="text-xl font-bold">对话历史</h2>
<button
@click="handleClose"
class="text-gray-500 hover:text-gray-700 transition-colors p-2"
>
<i class="fa fa-times text-lg"></i>
</button>
</div>
<div class="p-4 border-b">
<button
@click="newConversationAndClose"
class="w-full bg-primary text-white py-2 rounded-lg hover:bg-primary-dark transition-colors flex items-center justify-center space-x-2 shadow-md"
>
<i class="fa fa-plus-circle"></i>
<span>新建对话</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<p v-if="conversations.length === 0" class="text-gray-500 text-center py-4"></p>
<div
v-for="conv in conversations"
:key="conv.id"
:class="[
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors',
conv.id === currentConversationId
? 'bg-primary/10 border border-primary'
: 'hover:bg-gray-100',
]"
>
<div @click="switchAndClose(conv.id)" class="flex-1 min-w-0 pr-2">
<p
class="truncate font-medium text-sm"
:class="{ 'text-primary': conv.id === currentConversationId }"
>
{{ conv.title || '无标题对话' }}
</p>
<p class="text-xs text-gray-500 mt-0.5">
创建于: {{ new Date(conv.createTime).toLocaleDateString() }}
</p>
</div>
<button
@click.stop="startDeleteConfirmation(conv.id)"
class="text-gray-400 hover:text-danger p-1 rounded-full transition-colors"
title="删除对话"
>
<i class="fa fa-trash text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<div
v-if="isOpen"
@click="handleClose"
class="fixed inset-0 bg-black/30 z-30 transition-opacity duration-300"
></div>
<div
v-if="showDeleteConfirm"
class="fixed inset-0 flex items-center justify-center z-50 bg-black/40"
>
<div class="bg-white rounded-lg shadow-2xl p-6 w-full max-w-sm" @click.stop>
<h3 class="text-lg font-medium text-gray-900 mb-2">确认删除</h3>
<p class="text-gray-600 text-sm mb-6">确定要删除这条对话吗删除后无法恢复</p>
<div class="flex justify-end gap-3">
<button
@click="cancelDelete"
class="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50"
>
取消
</button>
<button
@click="confirmDelete"
class="px-4 py-2 bg-danger text-white rounded-md text-sm hover:bg-red-600"
>
确认删除
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// Conversation
import type { Conversation } from '../../../types'
// --- 1. Props Emits ---
interface HistorySidebarProps {
isOpen: boolean
conversations: Conversation[]
currentConversationId: string
}
const props = defineProps<HistorySidebarProps>()
const emit = defineEmits<{
// on... (使 kebab-case)
(e: 'close'): void
(e: 'switch-conversation', id: string): void
(e: 'new-conversation'): void
(e: 'delete-conversation', id: string): void
}>()
// --- 2. ---
//
const showDeleteConfirm = ref(false)
// ID
const deleteTargetId = ref<string | null>(null)
// --- 3. Computed Properties () ---
//
const sidebarClasses = computed(() => [
'bg-white border-l border-gray-200 h-full flex flex-col',
'transition-transform duration-300 ease-in-out',
'fixed top-0 right-0 z-40 transform',
// /
props.isOpen ? 'translate-x-0' : 'translate-x-full',
'w-80 shadow-lg',
])
// --- 4. Methods () ---
// ID
const startDeleteConfirmation = (id: string) => {
deleteTargetId.value = id
showDeleteConfirm.value = true
}
//
const cancelDelete = () => {
showDeleteConfirm.value = false
deleteTargetId.value = null
}
//
const confirmDelete = () => {
if (deleteTargetId.value) {
emit('delete-conversation', deleteTargetId.value)
}
cancelDelete() //
}
//
const switchAndClose = (id: string) => {
emit('switch-conversation', id)
emit('close') //
}
//
const newConversationAndClose = () => {
emit('new-conversation')
emit('close')
}
//
const handleClose = () => {
emit('close')
}
</script>

@ -0,0 +1,261 @@
<!--
@file components/layout/sidebars/QueryRecommendSidebar.vue
@description 查询推荐侧边栏
功能
- 基于当前查询结果推荐相关查询
- 查询失败时的建议
- 可折叠显示通过星键控制
布局说明
- 常用推荐卡片白色背景圆角底部边框
- 悬浮星键固定在右上角
@author Frontend Team
-->
<template>
<!-- 侧边栏容器显示触发按钮和下拉菜单 -->
<div
:class="[
'relative flex flex-col items-end gap-3',
className?.includes('h-full') ? 'h-full' : '',
className,
]"
>
<!-- 常用搜索触发按钮和下拉菜单 -->
<div class="relative" ref="commonButtonRef">
<button
@click="toggleCommonMenu"
class="w-10 h-10 rounded-lg bg-white border border-gray-300 shadow-md hover:bg-gray-50 hover:shadow-lg transition-all flex items-center justify-center text-gray-600 hover:text-primary"
title="常用搜索"
>
<span class="text-lg"></span>
</button>
<!-- 常用搜索下拉菜单在按钮左侧显示不遮挡按钮 -->
<!-- 注意使用!important确保样式不被覆盖避免与页面其他样式冲突 -->
<transition name="fade-slide">
<div
v-if="isCommonMenuOpen"
ref="commonMenuRef"
class="absolute right-full top-0 mr-2 w-64 bg-gradient-to-br from-white via-blue-50/30 to-indigo-50/20 border-2 border-primary/20 rounded-xl shadow-xl overflow-hidden backdrop-blur-sm"
style="z-index: 10000 !important; position: absolute !important;"
@click.stop
>
<div class="p-2 space-y-1 bg-white/50 backdrop-blur-sm">
<div class="px-3 py-2 text-sm font-semibold text-gray-800 border-b border-primary/20 mb-1 bg-gradient-to-r from-primary/10 to-transparent">
<i class="fa fa-star text-primary mr-2"></i>常用搜索
</div>
<button
v-for="query in COMMON_RECOMMENDATIONS"
:key="query"
@click="handleRecommendationClick(query)"
class="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-gray-100 transition-colors"
>
{{ query }}
</button>
</div>
</div>
</transition>
</div>
<!-- 大模型思考触发按钮和下拉菜单 - 只在有建议时显示 -->
<div v-if="showSuggestions" class="relative" ref="suggestionsButtonRef">
<button
@click="toggleSuggestionsMenu"
class="w-10 h-10 rounded-lg bg-white border border-gray-300 shadow-md hover:bg-gray-50 hover:shadow-lg transition-all flex items-center justify-center text-gray-600 hover:text-primary"
title="大模型思考"
>
<i class="fa fa-magic text-base"></i>
</button>
<!-- 大模型思考下拉菜单在按钮左侧显示不遮挡按钮 -->
<!-- 注意使用!important确保样式不被覆盖避免与页面其他样式冲突 -->
<transition name="fade-slide">
<div
v-if="isSuggestionsMenuOpen"
ref="suggestionsMenuRef"
class="absolute right-full top-0 mr-2 w-64 bg-gradient-to-br from-white via-purple-50/30 to-indigo-50/20 border-2 border-purple-200/30 rounded-xl shadow-xl overflow-hidden backdrop-blur-sm"
style="z-index: 10000 !important; position: absolute !important;"
@click.stop
>
<div class="p-2 space-y-1 bg-white/50 backdrop-blur-sm">
<div class="px-3 py-2 text-sm font-semibold text-gray-800 border-b border-purple-200/30 mb-1 bg-gradient-to-r from-purple-100/50 to-transparent">
<i class="fa fa-magic text-purple-600 mr-2"></i>大模型思考
</div>
<!-- 状态提示 -->
<div
:class="[
'mx-2 mb-2 p-2 rounded-md text-xs',
queryFailed ? 'bg-red-50 text-red-600 border border-red-100' : 'bg-green-50 text-green-600 border border-green-100'
]"
>
{{ queryFailed ? '查询似乎遇到了问题,您可以尝试:' : '基于当前结果,您可以继续探索:' }}
</div>
<button
v-for="(query, index) in relatedSearches"
:key="index"
@click="handleRecommendationClick(query)"
class="w-full text-left px-3 py-2 text-sm rounded-md hover:bg-gray-100 transition-colors"
>
{{ query }}
</button>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import type { Conversation, Message, QueryResultData } from '../../../types'
import {
COMMON_RECOMMENDATIONS,
MOCK_FAILURE_SUGGESTIONS,
MOCK_SUCCESS_SUGGESTIONS,
} from '../../../constants'
// --- 1. Props Emits ---
interface RightSidebarProps {
currentConversation: Conversation | undefined
className?: string
}
// onRecommendationClick Emit
const props = defineProps<RightSidebarProps>()
const emit = defineEmits<{
// Vue (使 kebab-case)
(e: 'recommendation-click', prompt: string): void
(e: 'open-common'): void //
(e: 'open-suggestions'): void //
}>()
// --- 2. ---
const isCommonMenuOpen = ref(false)
const isSuggestionsMenuOpen = ref(false)
const commonButtonRef = ref<HTMLDivElement | null>(null)
const suggestionsButtonRef = ref<HTMLDivElement | null>(null)
const commonMenuRef = ref<HTMLDivElement | null>(null)
const suggestionsMenuRef = ref<HTMLDivElement | null>(null)
// --- 2. Helper Type Guard () ---
const isQueryResult = (content: any): content is QueryResultData => {
return content && typeof content === 'object' && 'sqlQuery' in content
}
// --- 3. ---
//
const lastMessage = computed<Message | undefined>(() => {
const messages = props.currentConversation?.messages
if (!messages || messages.length === 0) return undefined
return messages[messages.length - 1]
})
// AI
const showSuggestions = computed<boolean>(() => {
const conv = props.currentConversation
return (
!!conv && conv.messages.length > 1 && !!lastMessage.value && lastMessage.value.role === 'ai'
)
})
// ()
const queryFailed = computed<boolean>(() => {
if (!showSuggestions.value) return false
// QueryResultData
if (!isQueryResult(lastMessage.value?.content)) {
return true
}
// TODO: QueryResultData status status.
return false
})
// / ()
const relatedSearches = computed<string[]>(() => {
if (!showSuggestions.value) return []
return queryFailed.value ? MOCK_FAILURE_SUGGESTIONS : MOCK_SUCCESS_SUGGESTIONS
})
// --- 4. Methods ---
//
const toggleCommonMenu = () => {
isCommonMenuOpen.value = !isCommonMenuOpen.value
if (isCommonMenuOpen.value) {
isSuggestionsMenuOpen.value = false //
}
}
//
const toggleSuggestionsMenu = () => {
isSuggestionsMenuOpen.value = !isSuggestionsMenuOpen.value
if (isSuggestionsMenuOpen.value) {
isCommonMenuOpen.value = false //
}
}
//
const handleRecommendationClick = (prompt: string) => {
emit('recommendation-click', prompt)
isCommonMenuOpen.value = false
isSuggestionsMenuOpen.value = false
}
//
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
//
const isClickInCommon =
(commonButtonRef.value && commonButtonRef.value.contains(target)) ||
(commonMenuRef.value && commonMenuRef.value.contains(target))
//
const isClickInSuggestions =
(suggestionsButtonRef.value && suggestionsButtonRef.value.contains(target)) ||
(suggestionsMenuRef.value && suggestionsMenuRef.value.contains(target))
//
if (!isClickInCommon && !isClickInSuggestions) {
isCommonMenuOpen.value = false
isSuggestionsMenuOpen.value = false
}
}
//
const commonRecommendations = COMMON_RECOMMENDATIONS
//
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', handleClickOutside)
})
</script>
<style scoped>
/* 下拉菜单淡入滑出动画 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.2s ease-out;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(10px);
}
</style>

@ -0,0 +1,95 @@
<!--
@file components/layout/sidebars/SettingsSidebar.vue
@description 设置侧边栏
功能
- 主题切换
- 其他设置选项
-->
<template>
<aside
:class="[
'bg-white shadow-md h-screen flex-shrink-0 flex flex-col transition-all duration-300 border-l border-gray-200 w-80 fixed right-0 top-0 z-[70]',
isOpen ? 'translate-x-0' : 'translate-x-full',
]"
>
<!-- Header -->
<div class="p-4 border-b flex items-center justify-between bg-white flex-shrink-0">
<div class="flex items-center space-x-2">
<i class="fa fa-cog text-primary text-2xl"></i>
<h1 class="text-lg font-bold">设置</h1>
</div>
<button
@click="handleClose"
class="p-2 text-gray-500 hover:text-primary transition-colors"
title="关闭设置"
>
<i class="fa fa-times text-xl"></i>
</button>
</div>
<!-- 设置内容 -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- 主题设置 -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">外观设置</h3>
<div class="space-y-2">
<button
@click="handleToggleTheme"
class="w-full flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors"
>
<div class="flex items-center space-x-3">
<i :class="['fa', theme === 'light' ? 'fa-sun-o' : 'fa-moon-o', 'text-primary text-lg']"></i>
<span class="text-sm font-medium">主题模式</span>
</div>
<span class="text-xs text-gray-500">{{ theme === 'light' ? '日间模式' : '夜间模式' }}</span>
</button>
</div>
</div>
<!-- 其他设置可以在这里添加 -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">其他设置</h3>
<div class="space-y-2">
<div class="p-3 bg-gray-50 rounded-lg">
<p class="text-xs text-gray-500">更多设置功能即将推出...</p>
</div>
</div>
</div>
</div>
</aside>
<!-- 遮罩层 -->
<div
v-if="isOpen"
class="fixed inset-0 bg-black/50 z-[60]"
@click="handleClose"
></div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTheme } from '../../../composables/useTheme'
interface Props {
isOpen: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { theme, toggleTheme } = useTheme()
const handleClose = () => {
emit('close')
}
const handleToggleTheme = () => {
toggleTheme()
}
</script>

@ -0,0 +1,118 @@
<!--
@file components/layout/sidebars/SysAdminSidebar.vue
@description 系统管理员侧边栏
功能
- 管理功能导航概览用户日志模型通知
- 登出按钮
- 基于 BaseSidebar 封装
@author Frontend Team
-->
<template>
<BaseSidebar :is-open="isOpen" :is-collapsed="isCollapsed" @logout="handleLogout" @close="handleClose" @toggle="handleToggle">
<template #header>
<i class="fa fa-shield text-primary text-2xl"></i>
<h1 class="text-lg font-bold">系统管理中心</h1>
</template>
<SidebarCategory title="系统监控">
<SidebarItem
v-for="item in monitoringItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleSetActivePage"
/>
</SidebarCategory>
<SidebarCategory title="核心管理">
<SidebarItem
v-for="item in managementItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleSetActivePage"
/>
</SidebarCategory>
<SidebarCategory title="个人设置">
<SidebarItem
v-for="item in personalItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleSetActivePage"
/>
</SidebarCategory>
</BaseSidebar>
</template>
<script setup lang="ts">
import type { SysAdminPageType } from '../../types'
import BaseSidebar from '../../common/BaseSidebar.vue'
import SidebarItem from '../../common/SidebarItem.vue'
import SidebarCategory from '../../common/SidebarCategory.vue'
// 1. Props/Emits
interface SysAdminSidebarProps {
activePage: SysAdminPageType
isOpen?: boolean
isCollapsed?: boolean
}
const props = withDefaults(defineProps<SysAdminSidebarProps>(), {
isOpen: false,
isCollapsed: false,
})
const emit = defineEmits<{
(e: 'update:activePage', page: SysAdminPageType): void
(e: 'logout'): void
(e: 'close'): void
(e: 'toggle'): void
}>()
// 2.
const monitoringItems = [
{ href: 'dashboard' as SysAdminPageType, icon: 'fa-tachometer', label: '仪表盘' },
{ href: 'system-log' as SysAdminPageType, icon: 'fa-history', label: '系统日志' },
]
const managementItems = [
{ href: 'user-management' as SysAdminPageType, icon: 'fa-users', label: '用户管理' },
{ href: 'llm-config' as SysAdminPageType, icon: 'fa-cogs', label: '大模型配置' },
{ href: 'notification-management' as SysAdminPageType, icon: 'fa-bullhorn', label: '通知管理' },
]
const personalItems = [
{ href: 'account' as SysAdminPageType, icon: 'fa-user-circle-o', label: '我的账户' },
{ href: 'settings' as SysAdminPageType, icon: 'fa-cog', label: '设置' },
]
// 3. Methods
const handleSetActivePage = (page: SysAdminPageType) => {
emit('update:activePage', page)
//
if (props.isOpen && window.innerWidth < 1024) {
emit('close')
}
}
const handleLogout = () => {
emit('logout') // BaseSidebar logout
}
const handleClose = () => {
emit('close')
}
const handleToggle = () => {
emit('toggle')
}
</script>

@ -0,0 +1,102 @@
<!--
@file components/layout/sidebars/UserSidebar.vue
@description 普通用户侧边栏
功能
- 导航菜单查询收藏通知好友账户
- 登出按钮
- 基于 BaseSidebar 封装
@author Frontend Team
-->
<template>
<BaseSidebar :is-open="isOpen" :is-collapsed="isCollapsed" @logout="handleLogout" @close="handleClose" @toggle="handleToggle">
<template #header>
<i class="fa fa-user text-primary text-2xl"></i>
<h1 class="text-lg font-bold">用户中心</h1>
</template>
<SidebarCategory title="查询中心">
<SidebarItem
v-for="item in queryItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleSetActivePage"
/>
</SidebarCategory>
<SidebarCategory title="个人中心">
<SidebarItem
v-for="item in personalItems"
:key="item.href"
:href="item.href"
:icon="item.icon"
:label="item.label"
:is-active="activePage === item.href"
@click="handleSetActivePage"
/>
</SidebarCategory>
</BaseSidebar>
</template>
<script setup lang="ts">
import type { Page } from '../../types'
import BaseSidebar from '../../common/BaseSidebar.vue'
import SidebarItem from '../../common/SidebarItem.vue'
import SidebarCategory from '../../common/SidebarCategory.vue'
// 1. Props/Emits
interface SidebarProps {
activePage: Page
isOpen?: boolean
isCollapsed?: boolean
}
const props = withDefaults(defineProps<SidebarProps>(), {
isOpen: false,
isCollapsed: false,
})
const emit = defineEmits<{
(e: 'update:active-page', page: Page): void
(e: 'logout'): void
(e: 'close'): void
(e: 'toggle'): void
}>()
// 2.
const queryItems = [
{ href: 'query' as Page, icon: 'fa-search', label: '数据查询' },
{ href: 'history' as Page, icon: 'fa-star', label: '收藏夹' },
]
const personalItems = [
{ href: 'notifications' as Page, icon: 'fa-bell', label: '通知中心' },
{ href: 'friends' as Page, icon: 'fa-users', label: '好友管理' },
{ href: 'account' as Page, icon: 'fa-user', label: '账户管理' },
{ href: 'settings' as Page, icon: 'fa-cog', label: '设置' },
]
// 3. Methods
const handleSetActivePage = (page: Page) => {
emit('update:active-page', page)
//
if (props.isOpen && window.innerWidth < 1024) {
emit('close')
}
}
const handleLogout = () => {
emit('logout') // BaseSidebar logout
}
const handleClose = () => {
emit('close')
}
const handleToggle = () => {
emit('toggle')
}
</script>

@ -0,0 +1,126 @@
<!--
@file components/ui/Dropdown.vue
@description 下拉选择组件
功能
- 模型/数据库选择器
- 支持禁用选项
- 点击外部自动关闭
- 自定义选项渲染
@author Frontend Team
-->
<template>
<div class="relative" ref="dropdownRef">
<button
@click="toggleDropdown"
:class="[
'inline-flex items-center justify-center bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg text-sm transition-colors',
mobileIconOnly ? 'w-10 h-10 md:w-auto md:h-auto md:px-3 md:py-2 md:bg-primary md:text-white md:hover:bg-primary/90' : 'h-10 px-3 py-2',
]"
:title="mobileIconOnly && isMobile ? selected : ''"
>
<i :class="['fa', icon, mobileIconOnly ? 'md:mr-2' : 'mr-2', 'text-sm']"></i>
<span v-if="!mobileIconOnly || !isMobile" class="truncate text-xs md:text-sm max-w-[120px]">{{ selected }}</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 bottom-full mb-1 w-48 bg-white border border-gray-300 rounded-lg shadow-lg z-10"
>
<button
v-for="option in options"
:key="option.name"
@click="handleSelect(option)"
:disabled="option.disabled"
:title="option.description"
:class="[
'w-full text-left px-4 py-2 text-sm transition-colors',
//
selected === option.name ? 'bg-primary/10 text-primary' : '',
option.disabled ? 'text-gray-400 cursor-not-allowed bg-gray-100' : 'hover:bg-gray-100',
]"
>
{{ option.name }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
// ModelOption
import type { ModelOption } from '../../types'
// --- 1. Props Emits ---
interface DropdownProps {
// v-model prop
selected: string
options: ModelOption[]
icon: string
mobileIconOnly?: boolean //
}
const props = withDefaults(defineProps<DropdownProps>(), {
mobileIconOnly: false,
})
const emit = defineEmits<{
// 使 update:selected v-model:selected
(e: 'update:selected', optionName: string): void
(e: 'select', optionName: string): void // select
}>()
// --- 2. ---
const isOpen = ref(false)
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 768 // md breakpoint
}
// --- 3. DOM ---
const dropdownRef = ref<HTMLDivElement | null>(null)
// --- 4. ---
//
const handleClickOutside = (event: MouseEvent) => {
//
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
isOpen.value = false
}
}
//
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('mousedown', handleClickOutside)
})
//
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
document.removeEventListener('mousedown', handleClickOutside)
})
// --- 5. Methods () ---
const handleSelect = (option: ModelOption) => {
if (option.disabled) return
// v-model
emit('update:selected', option.name)
// select
emit('select', option.name)
isOpen.value = false
}
//
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
</script>

@ -0,0 +1,92 @@
<!--
@file components/ui/ErrorBoundary.vue
@description 错误边界组件
功能
- 捕获子组件渲染错误
- 显示友好的错误提示
- 提供重试按钮
- 防止错误扩散到整个应用
@author Frontend Team
-->
<template>
<div v-if="hasError" class="error-boundary">
<div class="error-content">
<div class="error-icon"></div>
<h2 class="error-title">出错了</h2>
<p class="error-message">{{ errorMessage }}</p>
<button class="error-button" @click="retry"></button>
</div>
</div>
<slot v-else></slot>
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import { getErrorMessage } from '../composables/useErrorHandler'
const hasError = ref(false)
const errorMessage = ref('')
onErrorCaptured((error) => {
hasError.value = true
errorMessage.value = getErrorMessage(error)
console.error('ErrorBoundary 捕获错误:', error)
return false //
})
const retry = () => {
hasError.value = false
errorMessage.value = ''
}
</script>
<style scoped>
.error-boundary {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
padding: 40px;
}
.error-content {
text-align: center;
max-width: 400px;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.error-message {
font-size: 14px;
color: #6b7280;
margin-bottom: 20px;
line-height: 1.5;
}
.error-button {
padding: 10px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.error-button:hover {
background: #2563eb;
}
</style>

@ -0,0 +1,52 @@
<!--
@file components/ui/FilterDropdown.vue
@description 筛选下拉框组件
功能
- 列表筛选选择器
- 统一的筛选 UI 样式
- 双向绑定支持
@author Frontend Team
-->
<template>
<div class="relative">
<select
:value="value"
@change="handleChange"
class="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
:style="selectStyle"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
type: 'date' | 'model' | 'database'
options: Array<{ value: string; label: string }>
value: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
change: [type: 'date' | 'model' | 'database', value: string]
}>()
const selectStyle = {
backgroundImage:
"url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center',
backgroundSize: '1em',
}
const handleChange = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('change', props.type, target.value)
}
</script>

@ -0,0 +1,89 @@
<!--
@file components/ui/Modal.vue
@description 通用模态框组件
功能
- 可配置标题和关闭按钮
- 支持遮罩层点击关闭
- 过渡动画效果
- 插槽内容自定义
@author Frontend Team
-->
<template>
<div v-if="isRendered" :class="modalContainerClass" @click="handleClose">
<div :class="modalContentClass" @click.stop>
<div v-if="!hideTitle" class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">{{ title }}</h3>
<button @click="handleClose" class="text-gray-400 hover:text-gray-600">&times;</button>
</div>
<slot></slot>
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
interface ModalProps {
isOpen: boolean
title?: string
hideTitle?: boolean
contentClassName?: string //
}
const props = withDefaults(defineProps<ModalProps>(), {
hideTitle: false,
contentClassName: '',
})
const emit = defineEmits<{
(e: 'close'): void
}>()
// --- ---
const isRendered = ref(props.isOpen)
// isOpen isRendered
watch(
() => props.isOpen,
(newVal) => {
if (newVal) {
//
isRendered.value = true
} else {
// 300ms DOM
setTimeout(() => {
isRendered.value = false
}, 300)
}
},
{ immediate: true },
)
// --- ---
//
const modalContainerClass = computed(() =>
[
'fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-50 transition-opacity duration-300',
props.isOpen ? 'opacity-100' : 'opacity-0', //
].join(' '),
)
// /
const modalContentClass = computed(() =>
[
'bg-white rounded-xl p-6 w-full mx-4 my-auto transition-all duration-300 max-w-md max-h-[90vh]',
props.isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0', //
props.contentClassName, //
].join(' '),
)
const handleClose = () => {
emit('close')
}
</script>

@ -0,0 +1,45 @@
<!--
@file components/ui/TabButton.vue
@description 标签页按钮组件
功能
- 标签页切换
- 激活状态样式
- 可选未读数量徽章
@author Frontend Team
-->
<template>
<button
@click="$emit('tabChange', tab)"
:class="`py-2 px-4 text-sm font-medium transition-colors duration-200 relative ${
activeTab === tab ? 'border-b-2 border-primary text-primary' : 'text-gray-500 hover:text-dark'
}`"
>
{{ label }}
<span v-if="count !== undefined && count > 0" class="ml-1 text-xs"> ({{ count }}) </span>
</button>
</template>
<script setup>
defineProps({
tab: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
count: {
type: Number,
default: undefined,
},
activeTab: {
type: String,
required: true,
},
})
defineEmits(['tabChange'])
</script>

@ -0,0 +1,141 @@
<!--
@file components/ui/ToastContainer.vue
@description Toast 通知容器组件
功能
- 显示成功/错误/警告/信息通知
- 自动消失动画
- 点击关闭
- 堆叠显示多条通知
使用方式
App.vue 中引入此组件即可全局使用 toast
@author Frontend Team
-->
<template>
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast">
<div
v-for="item in toasts"
:key="item.id"
:class="['toast', `toast-${item.type}`]"
@click="remove(item.id)"
>
<span class="toast-icon">
<template v-if="item.type === 'success'"></template>
<template v-else-if="item.type === 'error'"></template>
<template v-else-if="item.type === 'warning'"></template>
<template v-else></template>
</span>
<span class="toast-message">{{ item.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useToast } from '../composables/useToast'
const { toasts, remove } = useToast()
</script>
<style scoped>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
background: white;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 0 1px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
}
.toast:hover {
transform: translateX(-4px);
}
.toast-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
flex-shrink: 0;
}
.toast-message {
font-size: 14px;
line-height: 1.4;
color: #333;
}
/* 类型样式 */
.toast-success {
border-left: 4px solid #10b981;
}
.toast-success .toast-icon {
background: #d1fae5;
color: #10b981;
}
.toast-error {
border-left: 4px solid #ef4444;
}
.toast-error .toast-icon {
background: #fee2e2;
color: #ef4444;
}
.toast-warning {
border-left: 4px solid #f59e0b;
}
.toast-warning .toast-icon {
background: #fef3c7;
color: #f59e0b;
}
.toast-info {
border-left: 4px solid #3b82f6;
}
.toast-info .toast-icon {
background: #dbeafe;
color: #3b82f6;
}
/* 动画 */
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>

@ -0,0 +1,9 @@
/**
* UI
*/
export { default as Modal } from './Modal.vue'
export { default as Dropdown } from './Dropdown.vue'
export { default as TabButton } from './TabButton.vue'
export { default as ToastContainer } from './ToastContainer.vue'
export { default as ErrorBoundary } from './ErrorBoundary.vue'
export { default as FilterDropdown } from './FilterDropdown.vue'

@ -0,0 +1,120 @@
/**
* @file composables/useErrorHandler.ts
* @description
*
*
* -
* - Toast
* -
* -
*
* @example
* import { handleError } from '@/composables/useErrorHandler'
*
* try {
* await someApi()
* } catch (error) {
* await handleError(error, {
* module: '用户管理',
* descPrefix: '删除用户失败'
* })
* }
*
* @author Frontend Team
* @since 1.0.0
*/
import { toast } from './useToast'
import { logOperation, LogModule, LogOperationType, LogStatus } from '../utils/logger'
export interface ErrorHandlerOptions {
/** 是否显示 Toast 通知 */
showToast?: boolean
/** 是否记录操作日志 */
logError?: boolean
/** 日志模块名称 */
module?: string
/** 操作类型 */
operationType?: string
/** 操作描述前缀 */
descPrefix?: string
/** 自定义错误消息 */
customMessage?: string
/** 是否在控制台打印 */
consoleLog?: boolean
}
const defaultOptions: ErrorHandlerOptions = {
showToast: true,
logError: true,
consoleLog: true,
module: LogModule.SYSTEM,
operationType: LogOperationType.QUERY,
}
/**
*
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
return '未知错误'
}
/**
*
*/
export async function handleError(
error: unknown,
options: ErrorHandlerOptions = {},
): Promise<void> {
const opts = { ...defaultOptions, ...options }
const errorMessage = opts.customMessage || getErrorMessage(error)
// 控制台打印
if (opts.consoleLog) {
console.error(opts.descPrefix ? `${opts.descPrefix}:` : '错误:', error)
}
// 显示 Toast
if (opts.showToast) {
toast.error(errorMessage)
}
// 记录日志
if (opts.logError && opts.module && opts.operationType) {
const desc = opts.descPrefix ? `${opts.descPrefix}${errorMessage}` : errorMessage
await logOperation(opts.module, opts.operationType, desc, LogStatus.FAILURE)
}
}
/**
*
*/
export function withErrorHandler<T extends unknown[], R>(
fn: (...args: T) => Promise<R>,
options: ErrorHandlerOptions = {},
): (...args: T) => Promise<R | undefined> {
return async (...args: T): Promise<R | undefined> => {
try {
return await fn(...args)
} catch (error) {
await handleError(error, options)
return undefined
}
}
}
/**
* useErrorHandler composable
*/
export function useErrorHandler() {
return {
handleError,
getErrorMessage,
withErrorHandler,
}
}

@ -0,0 +1,200 @@
/**
* @file composables/useQueryCollection.ts
* @description
*
*
* -
* - CRUD
* -
*
* @author Frontend Team
*/
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import { queryCollectionApi, collectionRecordApi } from '../services/api.real'
// --- 接口定义 ---
export interface QueryCollection {
id: number
userId: number
collectionName: string
description?: string
createTime: string
}
export interface CollectionRecord {
id: string
collectionId: number
queryLogId: number
addTime: string
}
// --- 核心 Composition Function ---
/**
*
* * @param userId - ID Ref
*/
export const useQueryCollection = (userId: Ref<number> | number) => {
// 响应式状态:使用 ref 替代 useState
const collections = ref<QueryCollection[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// 辅助函数:获取 userId 的当前值,以处理 Ref 或普通数字输入
const getUserIdValue = (): number => {
return typeof userId === 'object' && 'value' in userId ? userId.value : (userId as number)
}
/** 列表加载逻辑 */
const loadCollections = async () => {
const currentUserId = getUserIdValue()
if (!currentUserId) return
loading.value = true
error.value = null
try {
const data = await queryCollectionApi.getByUser(currentUserId)
collections.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : '加载收藏夹失败'
} finally {
loading.value = false
}
}
// 监听 userId 变化并加载数据
watch(
() => getUserIdValue(),
(newUserId) => {
if (newUserId) {
loadCollections()
} else {
collections.value = [] // 用户 ID 清空时,清空数据
}
},
{ immediate: true }, // 立即执行一次,模拟组件挂载时的初始加载
)
/** 创建新的收藏夹 */
const createCollection = async (name: string, description?: string) => {
loading.value = true
error.value = null
try {
const newCollection = await queryCollectionApi.create({
userId: getUserIdValue(),
collectionName: name,
description,
})
collections.value = [...collections.value, newCollection]
return newCollection
} catch (err) {
error.value = err instanceof Error ? err.message : '创建收藏夹失败'
return null
} finally {
loading.value = false
}
}
/** 更新收藏夹信息 */
const updateCollection = async (id: number, name: string, description?: string) => {
loading.value = true
error.value = null
try {
const updated = await queryCollectionApi.update({
id,
collectionName: name,
description,
})
// ⚠️ 状态更新逻辑全部使用 .value
collections.value = collections.value.map((c) => (c.id === id ? updated : c))
return updated
} catch (err) {
error.value = err instanceof Error ? err.message : '更新收藏夹失败'
return null
} finally {
loading.value = false
}
}
/** 删除收藏夹 */
const deleteCollection = async (id: number) => {
loading.value = true
error.value = null
try {
await queryCollectionApi.delete(id)
// ⚠️ 状态更新逻辑全部使用 .value
collections.value = collections.value.filter((c) => c.id !== id)
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '删除收藏夹失败'
return false
} finally {
loading.value = false
}
}
/** 添加查询记录到收藏夹 */
const addQueryToCollection = async (collectionId: number, queryLogId: number) => {
loading.value = true
error.value = null
try {
await collectionRecordApi.create({
collectionId,
queryLogId,
})
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '添加到收藏夹失败'
return false
} finally {
loading.value = false
}
}
/** 从收藏夹移除查询记录 */
const removeQueryFromCollection = async (recordId: string) => {
loading.value = true
error.value = null
try {
await collectionRecordApi.delete(recordId)
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '从收藏夹移除失败'
return false
} finally {
loading.value = false
}
}
/** 获取某个收藏夹下的所有记录 */
const getCollectionRecords = async (collectionId: string) => {
loading.value = true
error.value = null
try {
const records = await collectionRecordApi.getByCollection(collectionId)
return records
} catch (err) {
error.value = err instanceof Error ? err.message : '获取收藏记录失败'
return []
} finally {
loading.value = false
}
}
// 返回响应式状态和方法。
// 在 Vue 组件的 <template> 中,这些 ref 会被自动解包。
return {
collections,
loading,
error,
loadCollections,
createCollection,
updateCollection,
deleteCollection,
addQueryToCollection,
removeQueryFromCollection,
getCollectionRecords,
}
}

@ -0,0 +1,114 @@
/**
* @fileoverview Composable
*
*/
// 从 Vue 导入响应式 API
import { ref } from 'vue'
import type { Ref } from 'vue'
import { queryShareApi } from '../services/api.real'
// 定义返回类型接口,用于 JSDoc 提示和类型安全
export interface QueryShareResult {
shareQuery: (queryLogId: number, receiveUserId: number) => Promise<boolean>
markAsRead: (shareId: number) => Promise<boolean>
deleteShare: (shareId: number) => Promise<boolean>
loading: Ref<boolean>
error: Ref<string | null>
}
/**
*
* * @returns {QueryShareResult}
*/
export const useQueryShare = (): QueryShareResult => {
/**
*
* @type {Ref<boolean>}
*/
const loading = ref(false)
/**
*
* @type {Ref<string | null>}
*/
const error = ref<string | null>(null)
/**
*
* * @param {number} queryLogId - ID
* @param {number} receiveUserId - ID
* @returns {Promise<boolean>} true false
*/
const shareQuery = async (queryLogId: number, receiveUserId: number): Promise<boolean> => {
loading.value = true
error.value = null
try {
// 从 sessionStorage 获取用户 ID
const shareUserId = Number(sessionStorage.getItem('userId') || '1')
await queryShareApi.create({
shareUserId,
receiveUserId,
queryLogId,
receiveStatus: 0,
})
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '分享失败'
return false
} finally {
loading.value = false
}
}
/**
*
* * @param {number} shareId - ID
* @returns {Promise<boolean>} true false
*/
const markAsRead = async (shareId: number): Promise<boolean> => {
// 标记操作通常较快,可以不设置 loading但为了统一流程这里保留。
loading.value = true
error.value = null
try {
await queryShareApi.update({
id: shareId,
receiveStatus: 1,
})
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '标记失败'
return false
} finally {
loading.value = false
}
}
/**
*
* * @param {number} shareId - ID
* @returns {Promise<boolean>} true false
*/
const deleteShare = async (shareId: number): Promise<boolean> => {
loading.value = true
error.value = null
try {
await queryShareApi.delete(shareId)
return true
} catch (err) {
error.value = err instanceof Error ? err.message : '删除失败'
return false
} finally {
loading.value = false
}
}
return {
shareQuery,
markAsRead,
deleteShare,
loading,
error,
}
}

@ -0,0 +1,158 @@
/**
* @file composables/useTheme.ts
* @description composable
*
*
* - 绿
* -
*/
import { ref } from 'vue'
export type Theme = 'light' | 'dark' | 'blue' | 'green' | 'purple' | 'large'
// 主题配置
export const themeConfig = {
light: {
name: '浅色主题',
icon: 'fa-sun-o',
description: '经典浅色模式,适合日间使用',
bg: 'bg-gray-50',
card: 'bg-white',
text: 'text-gray-900',
primary: '#165DFF',
},
dark: {
name: '深色主题',
icon: 'fa-moon-o',
description: '护眼深色模式,适合夜间使用',
bg: 'bg-gray-900',
card: 'bg-gray-800',
text: 'text-gray-100',
primary: '#3B82F6',
},
blue: {
name: '蓝色主题',
icon: 'fa-tint',
description: '清新蓝色风格,专业商务',
bg: 'bg-blue-50',
card: 'bg-white',
text: 'text-blue-900',
primary: '#2563EB',
},
green: {
name: '绿色主题',
icon: 'fa-leaf',
description: '自然绿色风格,舒适护眼',
bg: 'bg-green-50',
card: 'bg-white',
text: 'text-green-900',
primary: '#10B981',
},
purple: {
name: '紫色主题',
icon: 'fa-diamond',
description: '优雅紫色风格,创意设计',
bg: 'bg-purple-50',
card: 'bg-white',
text: 'text-purple-900',
primary: '#8B5CF6',
},
large: {
name: '大字主题',
icon: 'fa-font',
description: '大字体模式,适合阅读和视力保护',
bg: 'bg-gray-50',
card: 'bg-white',
text: 'text-gray-900',
primary: '#165DFF',
},
}
const theme = ref<Theme>('light')
// 应用主题到 DOM
const applyTheme = (newTheme: Theme) => {
const root = document.documentElement
const body = document.body
// 移除所有主题类
root.classList.remove('theme-light', 'theme-dark', 'theme-blue', 'theme-green', 'theme-purple', 'theme-large')
body.classList.remove('theme-large-font')
// 添加新主题类
root.classList.add(`theme-${newTheme}`)
// 如果是大字主题,添加字体大小类
if (newTheme === 'large') {
body.classList.add('theme-large-font')
}
// 设置CSS变量使用CSS变量定义的值
const config = themeConfig[newTheme]
root.style.setProperty('--theme-primary', config.primary)
}
// 从 localStorage 读取主题设置
const loadTheme = () => {
if (typeof window === 'undefined') return
const savedTheme = localStorage.getItem('theme') as Theme | null
const validThemes: Theme[] = ['light', 'dark', 'blue', 'green', 'purple', 'large']
if (savedTheme && validThemes.includes(savedTheme)) {
theme.value = savedTheme
} else {
// 默认使用浅色主题
theme.value = 'light'
}
applyTheme(theme.value)
}
// 初始化主题(在应用启动时立即调用)
if (typeof window !== 'undefined') {
// 立即加载并应用主题
loadTheme()
// 确保在DOM加载完成后再次应用主题防止被覆盖
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
applyTheme(theme.value)
})
} else {
// DOM已经加载完成立即应用
applyTheme(theme.value)
}
// 监听系统主题变化(仅用于默认主题选择)
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
// 如果用户没有手动设置过主题,则跟随系统
if (!localStorage.getItem('theme')) {
theme.value = e.matches ? 'dark' : 'light'
applyTheme(theme.value)
}
})
}
export function useTheme() {
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
localStorage.setItem('theme', theme.value)
applyTheme(theme.value)
}
// 设置主题
const setTheme = (newTheme: Theme) => {
theme.value = newTheme
localStorage.setItem('theme', theme.value)
applyTheme(theme.value)
}
return {
theme,
toggleTheme,
setTheme,
}
}

@ -0,0 +1,101 @@
/**
* @file composables/useToast.ts
* @description Toast
*
*
* - ///
* -
* -
* -
*
* @example
* // 方式一:使用 composable
* const { success, error } = useToast()
* success('保存成功')
* error('操作失败')
*
* // 方式二:直接使用 toast 单例
* import { toast } from '@/composables/useToast'
* toast.success('保存成功')
*
* @author Frontend Team
* @since 1.0.0
*/
import { ref, readonly } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface ToastMessage {
id: number
type: ToastType
message: string
duration: number
}
// 全局状态
const toasts = ref<ToastMessage[]>([])
let toastId = 0
/**
* Toast
*/
const show = (message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastId
const toast: ToastMessage = { id, type, message, duration }
toasts.value.push(toast)
if (duration > 0) {
setTimeout(() => {
remove(id)
}, duration)
}
return id
}
/**
* Toast
*/
const remove = (id: number) => {
const index = toasts.value.findIndex((t) => t.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
/**
* Toast
*/
const clear = () => {
toasts.value = []
}
// 便捷方法
const success = (message: string, duration?: number) => show(message, 'success', duration)
const error = (message: string, duration?: number) => show(message, 'error', duration ?? 5000)
const warning = (message: string, duration?: number) => show(message, 'warning', duration)
const info = (message: string, duration?: number) => show(message, 'info', duration)
export function useToast() {
return {
toasts: readonly(toasts),
show,
remove,
clear,
success,
error,
warning,
info,
}
}
// 导出单例供全局使用
export const toast = {
show,
remove,
clear,
success,
error,
warning,
info,
}

@ -0,0 +1,17 @@
/**
* @file config.ts
* @description
*
*
* - api.baseUrl: API
* - api.useMock: 使
*
* @author Frontend Team
*/
export const config = {
api: {
// 从环境变量读取API地址开发环境使用代理生产环境使用相对路径
baseUrl: import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? '' : '/api'),
useMock: false, // 使用真实API不使用模拟数据
},
}

@ -0,0 +1,504 @@
/**
* @file constants.ts
* @description
*
*
* - /
* -
* -
*
* @author Frontend Team
*/
import type {
Conversation,
QueryResultData,
Notification,
UserProfile,
Friend,
FriendRequest,
AdminNotification,
SystemLog,
DataSource,
ConnectionLog,
PermissionLog,
QueryShare,
} from './types'
// 注意MODEL_OPTIONS 和 DATABASE_OPTIONS 已从后端API动态加载
// 请参考 QueryPage.vue 中的 loadAvailableModels() 和 loadDatabaseConnections() 函数
// Mocks for normal user view
export const MOCK_INITIAL_CONVERSATION: Conversation = {
id: 'conv-1',
title: '',
messages: [
{
role: 'ai',
content:
'您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。',
},
],
createTime: new Date().toISOString(),
}
const MOCK_QUERY_RESULT_1: QueryResultData = {
id: 'query-1',
userPrompt: '展示2023年各季度的订单量',
sqlQuery:
"SELECT strftime('%Y-Q', order_date) as quarter, COUNT(order_id) as order_count FROM orders WHERE strftime('%Y', order_date) = '2023' GROUP BY quarter ORDER BY quarter;",
conversationId: 'conv-1',
queryTime: new Date('2023-11-20T10:30:00Z').toISOString(),
executionTime: '0.8秒',
tableData: {
headers: ['季度', '订单量', '同比增长'],
rows: [
['2023-Q1', '1,200', '+15%'],
['2023-Q2', '1,550', '+18%'],
['2023-Q3', '1,400', '+12%'],
['2023-Q4', '1,850', '+25%'],
],
},
chartData: {
type: 'bar',
labels: ['2023-Q1', '2023-Q2', '2023-Q3', '2023-Q4'],
datasets: [
{
label: '订单量',
data: [1200, 1550, 1400, 1850],
backgroundColor: 'rgba(22, 93, 255, 0.6)',
},
],
},
database: '销售数据库',
model: 'gemini-2.5-pro',
}
const MOCK_QUERY_RESULT_2: QueryResultData = {
id: 'query-2',
userPrompt: '展示2023年各季度的订单量', // Same prompt
sqlQuery:
"SELECT strftime('%Y-Q', order_date) as quarter, COUNT(order_id) as order_count FROM orders WHERE strftime('%Y', order_date) = '2023' GROUP BY quarter ORDER BY quarter;",
conversationId: 'conv-2', // Different conversation
queryTime: new Date('2023-11-21T11:00:00Z').toISOString(), // Later time
executionTime: '0.9秒',
tableData: {
headers: ['季度', '订单量', '同比增长'],
rows: [
// ['2023-Q1', '1,200', '+15%'] is now deleted
['2023-Q2', '1,600', '+20%'], // Changed value
['2023-Q3', '1,400', '+12%'], // Same value
['2023-Q4', '1,850', '+25%'], // Same value
['2023-Q5 (预测)', '2,100', '+30%'], // Added row
],
},
chartData: {
type: 'bar',
labels: ['2023-Q2', '2023-Q3', '2023-Q4', '2023-Q5 (预测)'], // Changed labels
datasets: [
{
label: '订单量',
data: [1600, 1400, 1850, 2100], // Changed data
backgroundColor: 'rgba(54, 162, 235, 0.6)',
},
],
},
database: '销售数据库',
model: 'qwen3-max',
}
const MOCK_QUERY_RESULT_3: QueryResultData = {
id: 'query-3',
userPrompt: '统计每月新增用户数', // A different prompt
sqlQuery:
"SELECT strftime('%Y-%m', registration_date) as month, COUNT(user_id) as new_users FROM users GROUP BY month;",
conversationId: 'conv-3',
queryTime: new Date('2023-11-22T09:00:00Z').toISOString(),
executionTime: '1.1秒',
tableData: {
headers: ['月份', '新增用户数'],
rows: [
['2023-09', '5,200'],
['2023-10', '6,100'],
],
},
chartData: {
type: 'line',
labels: ['2023-09', '2023-10'],
datasets: [
{
label: '新增用户数',
data: [5200, 6100],
backgroundColor: 'rgba(75, 192, 192, 0.6)',
},
],
},
database: '产品数据库',
model: 'qwen3-max',
}
const MOCK_QUERY_RESULT_4: QueryResultData = {
id: 'query-4',
userPrompt: '各产品线销售额占比',
sqlQuery:
'SELECT product_line, SUM(sales) as total_sales FROM sales_by_product_line GROUP BY product_line;',
conversationId: 'conv-4',
queryTime: new Date('2023-11-23T14:00:00Z').toISOString(),
executionTime: '0.7秒',
tableData: {
headers: ['产品线', '销售额', '占比'],
rows: [
['电子产品', '550,000', '55%'],
['家居用品', '250,000', '25%'],
['服装配饰', '200,000', '20%'],
],
},
chartData: {
type: 'pie',
labels: ['电子产品', '家居用品', '服装配饰'],
datasets: [
{
label: '销售额',
data: [550000, 250000, 200000],
backgroundColor: [
'rgba(22, 93, 255, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
],
},
],
},
database: '用户数据库',
model: 'qwen3-max',
}
export const MOCK_SAVED_QUERIES: QueryResultData[] = [
MOCK_QUERY_RESULT_1,
MOCK_QUERY_RESULT_2,
MOCK_QUERY_RESULT_3,
MOCK_QUERY_RESULT_4,
]
export const MOCK_NOTIFICATIONS: Notification[] = [
{
id: '3',
type: 'system',
title: '系统将在今晚2点进行维护。',
content: '系统将在今晚2点进行维护。',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
isRead: false,
isPinned: true,
},
{
id: '1',
type: 'system',
title: '新用户 王小明 已注册。',
content: '新用户 王小明 已注册。',
timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
isRead: false,
isPinned: false,
},
{
id: '2',
type: 'system',
title: '模型 Gemini 连接失败。',
content: '模型 Gemini 连接失败。',
timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
isRead: false,
isPinned: false,
},
{
id: 'share-1',
type: 'share',
title: '李琪雯 分享了一个查询给你',
content: '"展示2023年各季度的订单量"',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
isRead: false,
isPinned: false,
fromUser: { name: '李琪雯', avatarUrl: 'https://i.pravatar.cc/150?u=li-si' },
relatedShareId: 'share-1',
},
]
export const MOCK_USER_PROFILE: UserProfile = {
id: 'user-001',
userId: 'zhangsan',
name: '李瑜清',
email: 'zhangsan@example.com',
phoneNumber: '13812345678',
avatarUrl: 'https://i.pravatar.cc/150?u=zhang-san',
registrationDate: '2024-03-22',
accountStatus: 'normal',
preferences: {
defaultModel: 'gemini-2.5-pro',
defaultDatabase: '销售数据库',
},
}
export const MOCK_FRIENDS_LIST: Friend[] = [
{
id: 'friend-1',
name: '李琪雯',
avatarUrl: 'https://i.pravatar.cc/150?u=li-si',
isOnline: true,
email: 'Maem12129@gmail.com',
},
{
id: 'friend-2',
name: '马芳琼',
avatarUrl: 'https://i.pravatar.cc/150?u=wang-wu',
isOnline: false,
email: 'DonQuixote@gmail.com',
},
]
export const MOCK_FRIEND_REQUESTS: FriendRequest[] = [
{
id: 'req-1',
fromUser: { name: '赵文琪', avatarUrl: 'https://i.pravatar.cc/150?u=zhao-liu' },
timestamp: '2小时前',
},
]
export const MOCK_QUERY_SHARES: QueryShare[] = [
{
id: 'share-1',
sender: {
id: 'friend-1',
name: '李琪雯',
avatarUrl: 'https://i.pravatar.cc/150?u=li-si',
isOnline: true,
email: 'Maem12129@gmail.com',
},
recipientId: 'user-001',
querySnapshot: MOCK_QUERY_RESULT_1,
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
status: 'unread',
},
{
id: 'share-2',
sender: {
id: 'friend-2',
name: '马芳琼',
avatarUrl: 'https://i.pravatar.cc/150?u=wang-wu',
isOnline: false,
email: 'DonQuixote@gmail.com',
},
recipientId: 'user-001',
querySnapshot: MOCK_QUERY_RESULT_3,
timestamp: new Date(Date.now() - 28 * 60 * 60 * 1000).toISOString(),
status: 'read',
},
]
// Used in QueryRecommendSidebar.vue
export const COMMON_RECOMMENDATIONS = [
'近7天用户增长趋势',
'上个季度各产品线销售额对比',
'查询华东地区销量最高的产品',
'统计每月新增用户数',
]
export const MOCK_SUCCESS_SUGGESTIONS = [
'按地区细分订单量',
'与去年同期数据进行对比',
'分析各季度订单的平均金额',
]
export const MOCK_FAILURE_SUGGESTIONS = [
'换一种更简单的问法',
'检查是否选择了正确的数据源',
'尝试询问“你能做什么?”',
]
// Mocks for Admin Panel
export const MOCK_ADMIN_NOTIFICATIONS: AdminNotification[] = [
{
id: 1,
title: '系统将于今晚23:00进行升级维护',
content: '...',
role: 'all',
priority: 'urgent',
pinned: true,
publisher: '系统管理员',
publishTime: '2025-10-28 18:00',
status: 'published',
},
{
id: 2,
title: '【草稿】新功能发布预告',
content: '...',
role: 'normal-user',
priority: 'normal',
pinned: false,
publisher: '系统管理员',
publishTime: '2025-10-27 09:00',
status: 'draft',
},
]
export const MOCK_DATA_ADMIN_NOTIFICATIONS: AdminNotification[] = [
{
id: 1,
title: '销售数据库表结构变更',
content: '`orders` 表新增 `discount_rate` 字段。',
role: 'data-admin',
priority: 'important',
pinned: false,
publisher: '李琪雯',
publishTime: '2025-10-28 10:00',
status: 'published',
dataSourceTopic: '销售数据库',
},
{
id: 2,
title: '用户数据库计划下线旧表',
content: '`users_old` 表将于11月30日下线请及时迁移。',
role: 'all',
priority: 'normal',
pinned: false,
publisher: '李琪雯',
publishTime: '2025-10-26 15:00',
status: 'published',
dataSourceTopic: '用户数据库',
},
]
export const MOCK_SYSTEM_LOGS: SystemLog[] = [
{
id: '#LOG001',
time: '2025-10-29 14:32:18',
user: '李瑜清',
action: '执行自然语言查询',
model: 'gemini-2.5-pro',
ip: '192.168.1.102',
status: 'success',
},
{
id: '#LOG002',
time: '2025-10-29 14:28:45',
user: '李琪雯',
action: '添加MySQL数据源',
model: '-',
ip: '192.168.1.105',
status: 'success',
},
{
id: '#LOG003',
time: '2025-10-29 14:25:10',
user: '李瑜清',
action: '执行自然语言查询',
model: 'GPT-4',
ip: '192.168.1.102',
status: 'failure',
details:
'Error: API call to OpenAI failed with status 429 - Too Many Requests. Please check your plan and billing details.',
},
{
id: '#LOG004',
time: '2025-10-29 13:50:21',
user: '未知用户',
action: '尝试登录系统',
model: '-',
ip: '203.0.113.45',
status: 'failure',
details: 'Authentication failed: Invalid credentials provided for user "unknown".',
},
{
id: '#LOG005',
time: '2025-10-29 12:15:05',
user: 'admin',
action: '更新用户角色',
model: '-',
ip: '127.0.0.1',
status: 'success',
},
]
// Mocks for Data Admin Panel
export const MOCK_DATASOURCES: DataSource[] = [
{
id: 'ds-1',
name: '销售数据库',
type: 'MySQL',
address: '192.168.1.101:3306',
status: 'connected',
},
{
id: 'ds-2',
name: '用户数据库',
type: 'PostgreSQL',
address: '192.168.1.102:5432',
status: 'connected',
},
{ id: 'ds-3', name: '产品数据库', type: 'MySQL', address: '192.168.1.103:3306', status: 'error' },
{
id: 'ds-4',
name: '日志数据库',
type: 'SQL Server',
address: '192.168.1.104:1433',
status: 'disconnected',
},
{
id: 'ds-5',
name: '库存数据库',
type: 'Oracle',
address: '192.168.1.105:1521',
status: 'disabled',
},
]
export const MOCK_CONNECTION_LOGS: ConnectionLog[] = [
{ id: 'log-1', time: '2023-06-15 16:42:30', datasource: '销售数据库', status: '成功' },
{
id: 'log-2',
time: '2023-06-15 14:20:15',
datasource: '产品数据库',
status: '失败',
details:
'Error: Connection timed out after 15000ms. Could not connect to 192.168.1.103:3306. Please check network connectivity and firewall rules.',
},
{ id: 'log-3', time: '2023-06-14 11:05:42', datasource: '用户数据库', status: '成功' },
{
id: 'log-4',
time: '2023-06-13 09:30:18',
datasource: '日志数据库',
status: '失败',
details:
"SQLSTATE[28000]: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Login failed for user 'log_reader'.",
},
{
id: 'log-5',
time: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
datasource: '产品数据库',
status: '失败',
details: "Error: Access denied for user 'prod_user'@'localhost' (using password: YES)",
},
]
export const MOCK_PERMISSION_LOGS: PermissionLog[] = [
{
id: 'plog-1',
timestamp: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
text: '管理员 <strong>李琪雯</strong> 授予 <strong>李瑜清</strong> "销售数据库" 的访问权限。',
},
{
id: 'plog-2',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
text: '管理员 <strong>李琪雯</strong> 撤销了 <strong>马芳琼</strong> 对 "产品数据库" 的所有权限。',
},
{
id: 'plog-3',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
text: '管理员 <strong>李琪雯</strong> 修改了 <strong>李瑜清</strong> 对 "销售数据库" 的 <code>orders</code> 表权限。',
},
{
id: 'plog-4',
timestamp: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
text: '系统自动为新用户 <strong>赵文琪</strong> 分配了默认权限。',
},
]
export const MOCK_QUERY_LOAD = {
labels: ['销售数据库', '用户数据库', '产品数据库', '日志数据库', '库存数据库'],
data: [1250, 890, 650, 320, 150],
}

@ -0,0 +1,32 @@
/**
* @file main.ts
* @description
*
*
* - Vue
* - Pinia Vue Router
* -
* - DOM
*
* @author Frontend Team
* @since 1.0.0
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/index.css'
// 创建 Vue 应用实例
const app = createApp(App)
// 注册 Pinia 状态管理
app.use(createPinia())
// 注册 Vue Router
app.use(router)
// 挂载应用到 #root 元素
app.mount('#root')

@ -0,0 +1,18 @@
/**
* @file router/index.ts
* @description Vue Router
*
*
* - 使 App.vue
* -
*
* @author Frontend Team
*/
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
})
export default router

@ -0,0 +1,65 @@
// ==================== API 模块 - 向后兼容导出 ====================
// 此文件从按业务域拆分的模块中重新导出所有 API
// 新代码建议直接从 './api/index' 或具体模块导入
// 通用请求
export { request, getToken, getUserId } from './api/request'
// 认证模块
export { authApi } from './api/auth'
export type { LoginRequest, LoginResponse } from './api/auth'
// 用户模块
export { userApi, userSearchApi } from './api/user'
export type { User, ChangePasswordRequest, UserSearchRecord } from './api/user'
// 查询模块
export {
queryApi,
queryLogApi,
queryShareApi,
queryCollectionApi,
collectionRecordApi,
} from './api/query'
export type {
QueryRequest,
QueryResponse,
QueryLog,
QueryShare,
QueryCollection,
CollectionRecord,
} from './api/query'
// 对话模块
export { dialogApi } from './api/dialog'
export type { DialogRecord } from './api/dialog'
// 数据库连接模块
export { dbConnectionApi } from './api/db'
export type { DbConnection } from './api/db'
// 大模型配置模块
export { llmConfigApi } from './api/llm'
export type { LlmConfig } from './api/llm'
// 权限模块
export { userDbPermissionApi } from './api/permission'
export type { UserDbPermission } from './api/permission'
// 通知模块
export { notificationApi } from './api/notification'
export type { Notification } from './api/notification'
// 好友模块
export { friendRelationApi, friendRequestApi, friendChatApi } from './api/friend'
export type {
FriendRelation,
FriendRelationWithUser,
FriendRequest,
FriendRequestWithUser,
FriendChat,
} from './api/friend'
// 日志模块
export { operationLogApi, errorLogApi, dbConnectionLogApi } from './api/log'
export type { OperationLog, ErrorLog, DbConnectionLog } from './api/log'

@ -0,0 +1,61 @@
/**
* @file services/api/auth.ts
* @description API
*
*
* - login:
* - logout:
* - isAuthenticated:
*
* @author Frontend Team
*/
import { request, getToken } from './request'
// ==================== 认证接口 ====================
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
userId: number
username: string
email: string
roleId: number
roleName: string
avatarUrl: string
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
})
// 保存token和用户信息到sessionStorage每个标签页独立
if (response.token) {
sessionStorage.setItem('token', response.token)
sessionStorage.setItem('userId', String(response.userId))
sessionStorage.setItem('username', response.username)
sessionStorage.setItem('roleId', String(response.roleId))
sessionStorage.setItem('roleName', response.roleName || '')
}
return response
},
logout: () => {
sessionStorage.removeItem('token')
sessionStorage.removeItem('userId')
sessionStorage.removeItem('username')
sessionStorage.removeItem('roleId')
sessionStorage.removeItem('roleName')
},
isAuthenticated: (): boolean => {
return !!getToken()
},
}

@ -0,0 +1,65 @@
/**
* @file services/api/db.ts
* @description API
*
*
* - dbConnectionApi: CRUD
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 数据库连接接口 ====================
export interface DbConnection {
id: number
name: string
dbTypeId: number
url: string
username: string
password?: string
status: string
createUserId: number
}
export const dbConnectionApi = {
getList: async (): Promise<DbConnection[]> => {
return await request<DbConnection[]>('/db-connection/list')
},
getById: async (id: number): Promise<DbConnection> => {
return await request<DbConnection>(`/db-connection/${id}`)
},
getListByUser: async (createUserId: number): Promise<DbConnection[]> => {
return await request<DbConnection[]>(`/db-connection/list/${createUserId}`)
},
create: async (dbConnection: Partial<DbConnection>): Promise<DbConnection> => {
return await request<DbConnection>('/db-connection', {
method: 'POST',
body: JSON.stringify(dbConnection),
})
},
update: async (dbConnection: Partial<DbConnection>): Promise<DbConnection> => {
return await request<DbConnection>('/db-connection', {
method: 'PUT',
body: JSON.stringify(dbConnection),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/db-connection/${id}`, {
method: 'DELETE',
})
},
test: async (id: number): Promise<boolean> => {
return await request<boolean>(`/db-connection/test/${id}`)
},
getTables: async (id: number): Promise<string[]> => {
return await request<string[]>(`/db-connection/${id}/tables`)
},
}

@ -0,0 +1,51 @@
/**
* @file services/api/dialog.ts
* @description API
*
*
* - dialogApi: / CRUD
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 对话接口 ====================
export interface DialogRecord {
dialogId: string
userId: number
topic: string
totalRounds: number
startTime: string
lastTime: string
}
export const dialogApi = {
getList: async (): Promise<DialogRecord[]> => {
return await request<DialogRecord[]>('/dialog/list')
},
getById: async (dialogId: string): Promise<DialogRecord> => {
return await request<DialogRecord>(`/dialog/${dialogId}`)
},
create: async (dialog: Partial<DialogRecord>): Promise<DialogRecord> => {
return await request<DialogRecord>('/dialog', {
method: 'POST',
body: JSON.stringify(dialog),
})
},
update: async (dialogId: string, dialog: Partial<DialogRecord>): Promise<DialogRecord> => {
return await request<DialogRecord>(`/dialog/${dialogId}`, {
method: 'PUT',
body: JSON.stringify(dialog),
})
},
delete: async (dialogId: string): Promise<void> => {
return await request<void>(`/dialog/${dialogId}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,172 @@
/**
* @file services/api/friend.ts
* @description API
*
*
* - friendRelationApi:
* - friendRequestApi:
* - friendChatApi:
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 好友关系接口 ====================
import type { User } from './user'
export interface FriendRelation {
id: number
userId: number
friendId: number
remark?: string
remarkName?: string // 后端实际字段名
onlineStatus: number
createTime: string
}
export interface FriendRelationWithUser extends FriendRelation {
friendUser?: User
}
export const friendRelationApi = {
getByUser: async (userId: number): Promise<FriendRelation[]> => {
return await request<FriendRelation[]>(`/friend-relation/list/${userId}`)
},
getByUserAndFriend: async (userId: number, friendId: number): Promise<FriendRelation> => {
return await request<FriendRelation>(`/friend-relation/${userId}/${friendId}`)
},
create: async (friendRelation: Partial<FriendRelation>): Promise<FriendRelation> => {
return await request<FriendRelation>('/friend-relation', {
method: 'POST',
body: JSON.stringify(friendRelation),
})
},
update: async (friendRelation: Partial<FriendRelation>): Promise<FriendRelation> => {
return await request<FriendRelation>('/friend-relation', {
method: 'PUT',
body: JSON.stringify(friendRelation),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/friend-relation/${id}`, {
method: 'DELETE',
})
},
deleteByUserAndFriend: async (userId: number, friendId: number): Promise<void> => {
return await request<void>(`/friend-relation/${userId}/${friendId}`, {
method: 'DELETE',
})
},
}
// ==================== 好友请求接口 ====================
export interface FriendRequest {
id: number
applicantId: number
recipientId: number
applyMsg?: string
status: number
createTime: string
handleTime?: string
}
export interface FriendRequestWithUser extends FriendRequest {
applicantUser?: User
}
export const friendRequestApi = {
getByRecipient: async (recipientId: number): Promise<FriendRequest[]> => {
return await request<FriendRequest[]>(`/friend-request/list/${recipientId}`)
},
getByRecipientAndStatus: async (
recipientId: number,
status: number,
): Promise<FriendRequest[]> => {
return await request<FriendRequest[]>(`/friend-request/list/${recipientId}/${status}`)
},
getById: async (id: number): Promise<FriendRequest> => {
return await request<FriendRequest>(`/friend-request/${id}`)
},
create: async (friendRequest: Partial<FriendRequest>): Promise<FriendRequest> => {
return await request<FriendRequest>('/friend-request', {
method: 'POST',
body: JSON.stringify(friendRequest),
})
},
update: async (friendRequest: Partial<FriendRequest>): Promise<FriendRequest> => {
return await request<FriendRequest>('/friend-request', {
method: 'PUT',
body: JSON.stringify(friendRequest),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/friend-request/${id}`, {
method: 'DELETE',
})
},
accept: async (id: number): Promise<string> => {
return await request<string>(`/friend-request/${id}/accept`, {
method: 'POST',
})
},
reject: async (id: number): Promise<string> => {
return await request<string>(`/friend-request/${id}/reject`, {
method: 'POST',
})
},
}
// ==================== 好友聊天接口 ====================
export interface FriendChat {
id: string
userId: number // 发送人ID后端实际字段
friendId: number // 接收人ID后端实际字段
sendUserId?: number // 兼容字段
receiveUserId?: number // 兼容字段
contentType?: string // 消息类型text, query_share, image
content: string | Record<string, any> // 消息内容(可能是字符串或对象)
sendTime: string
isRead: boolean
}
export const friendChatApi = {
getByUserAndFriend: async (userId: number, friendId: number): Promise<FriendChat[]> => {
return await request<FriendChat[]>(`/friend-chat/list/${userId}/${friendId}`)
},
getUnreadByFriend: async (friendId: number): Promise<FriendChat[]> => {
return await request<FriendChat[]>(`/friend-chat/unread/${friendId}`)
},
create: async (friendChat: Partial<FriendChat>): Promise<FriendChat> => {
return await request<FriendChat>('/friend-chat', {
method: 'POST',
body: JSON.stringify(friendChat),
})
},
update: async (friendChat: Partial<FriendChat>): Promise<FriendChat> => {
return await request<FriendChat>('/friend-chat', {
method: 'PUT',
body: JSON.stringify(friendChat),
})
},
delete: async (id: string): Promise<void> => {
return await request<void>(`/friend-chat/${id}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,90 @@
/**
* @file services/api/index.ts
* @description API
*
*
* - auth: /
* - user:
* - query: ///
* - dialog:
* - db:
* - llm:
* - permission:
* - notification:
* - friend:
* - log:
*
* @example
* // 推荐:从统一入口导入
* import { authApi, userApi, queryApi } from '@/services/api'
*
* // 或从具体模块导入
* import { authApi } from '@/services/api/auth'
*
* @author Frontend Team
* @since 1.0.0
*/
// ==================== API 模块统一导出 ====================
// 通用请求
export { request, getToken, getUserId } from './request'
// 认证模块
export { authApi } from './auth'
export type { LoginRequest, LoginResponse } from './auth'
// 用户模块
export { userApi, userSearchApi } from './user'
export type { User, ChangePasswordRequest, UserSearchRecord } from './user'
// 查询模块
export {
queryApi,
queryLogApi,
queryShareApi,
queryCollectionApi,
collectionRecordApi,
} from './query'
export type {
QueryRequest,
QueryResponse,
QueryLog,
QueryShare,
QueryCollection,
CollectionRecord,
} from './query'
// 对话模块
export { dialogApi } from './dialog'
export type { DialogRecord } from './dialog'
// 数据库连接模块
export { dbConnectionApi } from './db'
export type { DbConnection } from './db'
// 大模型配置模块
export { llmConfigApi } from './llm'
export type { LlmConfig } from './llm'
// 权限模块
export { userDbPermissionApi } from './permission'
export type { UserDbPermission } from './permission'
// 通知模块
export { notificationApi } from './notification'
export type { Notification } from './notification'
// 好友模块
export { friendRelationApi, friendRequestApi, friendChatApi } from './friend'
export type {
FriendRelation,
FriendRelationWithUser,
FriendRequest,
FriendRequestWithUser,
FriendChat,
} from './friend'
// 日志模块
export { operationLogApi, errorLogApi, dbConnectionLogApi } from './log'
export type { OperationLog, ErrorLog, DbConnectionLog } from './log'

@ -0,0 +1,63 @@
/**
* @file services/api/llm.ts
* @description API
*
*
* - llmConfigApi: CRUD/
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 大模型配置接口 ====================
export interface LlmConfig {
id: number
name: string
version: string
apiKey?: string
apiUrl: string
statusId: number
isDisabled: number
timeout: number
}
export const llmConfigApi = {
getList: async (): Promise<LlmConfig[]> => {
return await request<LlmConfig[]>('/llm-config/list')
},
getAvailable: async (): Promise<LlmConfig[]> => {
return await request<LlmConfig[]>('/llm-config/list/available')
},
getById: async (id: number): Promise<LlmConfig> => {
return await request<LlmConfig>(`/llm-config/${id}`)
},
create: async (llmConfig: Partial<LlmConfig>): Promise<LlmConfig> => {
return await request<LlmConfig>('/llm-config', {
method: 'POST',
body: JSON.stringify(llmConfig),
})
},
update: async (llmConfig: Partial<LlmConfig>): Promise<LlmConfig> => {
return await request<LlmConfig>('/llm-config', {
method: 'PUT',
body: JSON.stringify(llmConfig),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/llm-config/${id}`, {
method: 'DELETE',
})
},
toggle: async (id: number): Promise<void> => {
return await request<void>(`/llm-config/${id}/toggle`, {
method: 'PUT',
})
},
}

@ -0,0 +1,154 @@
/**
* @file services/api/log.ts
* @description API
*
*
* - operationLogApi:
* - errorLogApi:
* - dbConnectionLogApi:
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 操作日志接口 ====================
export interface OperationLog {
id: number
userId: number
username?: string
module: string
relatedLlm?: string // 涉及的大模型名称
// 后端字段名为 operation兼容旧的 operateType/operateDesc
operation?: string
operateType?: string
operateDesc?: string
operateTime: string
ip?: string
ipAddress?: string
// 后端字段名为 result兼容旧的 status
result?: number
status?: number
errorMsg?: string
}
export const operationLogApi = {
getList: async (): Promise<OperationLog[]> => {
return await request<OperationLog[]>('/operation-log/list')
},
getByUser: async (userId: number): Promise<OperationLog[]> => {
return await request<OperationLog[]>(`/operation-log/list/user/${userId}`)
},
getByModule: async (module: string): Promise<OperationLog[]> => {
return await request<OperationLog[]>(`/operation-log/list/module/${encodeURIComponent(module)}`)
},
getFailed: async (): Promise<OperationLog[]> => {
return await request<OperationLog[]>('/operation-log/list/failed')
},
getById: async (id: number): Promise<OperationLog> => {
return await request<OperationLog>(`/operation-log/${id}`)
},
create: async (log: Partial<OperationLog>): Promise<OperationLog> => {
return await request<OperationLog>('/operation-log', {
method: 'POST',
body: JSON.stringify(log),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/operation-log/${id}`, {
method: 'DELETE',
})
},
}
// ==================== 错误日志接口 ====================
export interface ErrorLog {
id: number
errorTypeId: number
errorCount: number
statPeriod: string
statTime: string
}
export const errorLogApi = {
getList: async (): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>('/error-log/list')
},
getByErrorType: async (errorTypeId: number): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>(`/error-log/list/type/${errorTypeId}`)
},
getByPeriod: async (period: string): Promise<ErrorLog[]> => {
return await request<ErrorLog[]>(`/error-log/list/period/${encodeURIComponent(period)}`)
},
getById: async (id: number): Promise<ErrorLog> => {
return await request<ErrorLog>(`/error-log/${id}`)
},
create: async (log: Partial<ErrorLog>): Promise<ErrorLog> => {
return await request<ErrorLog>('/error-log', {
method: 'POST',
body: JSON.stringify(log),
})
},
update: async (log: Partial<ErrorLog>): Promise<ErrorLog> => {
return await request<ErrorLog>('/error-log', {
method: 'PUT',
body: JSON.stringify(log),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/error-log/${id}`, {
method: 'DELETE',
})
},
}
// ==================== 数据库连接日志接口 ====================
export interface DbConnectionLog {
id: number
dbConnectionId: number
dbName?: string
connectTime: string
disconnectTime?: string
status: string // 后端为字符串,如 connected / error / disconnected
remark?: string
errorMessage?: string // 兼容旧字段
}
export const dbConnectionLogApi = {
getList: async (): Promise<DbConnectionLog[]> => {
return await request<DbConnectionLog[]>('/db-connection-log/list')
},
getByConnection: async (dbConnectionId: number): Promise<DbConnectionLog[]> => {
return await request<DbConnectionLog[]>(`/db-connection-log/list/connection/${dbConnectionId}`)
},
getById: async (id: number): Promise<DbConnectionLog> => {
return await request<DbConnectionLog>(`/db-connection-log/${id}`)
},
create: async (log: Partial<DbConnectionLog>): Promise<DbConnectionLog> => {
return await request<DbConnectionLog>('/db-connection-log', {
method: 'POST',
body: JSON.stringify(log),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/db-connection-log/${id}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,115 @@
/**
* @file services/api/notification.ts
* @description API
*
*
* - notificationApi: CRUD
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 通知接口 ====================
export interface Notification {
id: number
title: string
content: string
targetId: number
priorityId: number
publishTime?: string
isTop: number
publisherId: number
createTime: string
latestUpdateTime: string
}
export interface NotificationWithReadStatus extends Notification {
isRead: number
}
export const notificationApi = {
getList: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list')
},
getPublished: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list/published')
},
getDrafts: async (): Promise<Notification[]> => {
return await request<Notification[]>('/notification/list/drafts')
},
getByTarget: async (targetId: number): Promise<Notification[]> => {
return await request<Notification[]>(`/notification/list/target/${targetId}`)
},
getById: async (id: number): Promise<Notification> => {
return await request<Notification>(`/notification/${id}`)
},
create: async (notification: Partial<Notification>): Promise<Notification> => {
return await request<Notification>('/notification', {
method: 'POST',
body: JSON.stringify(notification),
})
},
update: async (notification: Partial<Notification>): Promise<Notification> => {
return await request<Notification>('/notification', {
method: 'PUT',
body: JSON.stringify(notification),
})
},
publish: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}/publish`, {
method: 'PUT',
})
},
toggleTop: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}/toggle-top`, {
method: 'PUT',
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/notification/${id}`, {
method: 'DELETE',
})
},
// ==================== 用户相关API ====================
/** 获取用户可见的通知列表(带已读状态) */
getUserNotifications: async (): Promise<NotificationWithReadStatus[]> => {
return await request<NotificationWithReadStatus[]>('/notification/user/list')
},
/** 获取用户未读通知数量 */
getUnreadCount: async (): Promise<number> => {
return await request<number>('/notification/user/unread-count')
},
/** 标记通知为已读 */
markAsRead: async (notificationId: number): Promise<void> => {
return await request<void>(`/notification/user/${notificationId}/read`, {
method: 'PUT',
})
},
/** 标记通知为未读 */
markAsUnread: async (notificationId: number): Promise<void> => {
return await request<void>(`/notification/user/${notificationId}/unread`, {
method: 'PUT',
})
},
/** 用户删除通知(只能删除非置顶通知) */
deleteByUser: async (id: number): Promise<void> => {
return await request<void>(`/notification/user/${id}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,59 @@
/**
* @file services/api/permission.ts
* @description API
*
*
* - userDbPermissionApi:
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 用户数据权限接口 ====================
export interface UserDbPermission {
id: number
userId: number
permissionDetails: string // JSON字符串
isAssigned: number
lastGrantUserId: number
lastGrantTime?: string
}
export const userDbPermissionApi = {
getList: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list')
},
getAssigned: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list/assigned')
},
getUnassigned: async (): Promise<UserDbPermission[]> => {
return await request<UserDbPermission[]>('/user-db-permission/list/unassigned')
},
getByUserId: async (userId: number): Promise<UserDbPermission> => {
return await request<UserDbPermission>(`/user-db-permission/user/${userId}`)
},
create: async (permission: Partial<UserDbPermission>): Promise<UserDbPermission> => {
return await request<UserDbPermission>('/user-db-permission', {
method: 'POST',
body: JSON.stringify(permission),
})
},
update: async (permission: Partial<UserDbPermission>): Promise<UserDbPermission> => {
return await request<UserDbPermission>('/user-db-permission', {
method: 'PUT',
body: JSON.stringify(permission),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/user-db-permission/${id}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,231 @@
/**
* @file services/api/query.ts
* @description API
*
*
* - queryApi:
* - queryLogApi:
* - queryShareApi:
* - queryCollectionApi:
* - collectionRecordApi:
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 查询接口 ====================
export interface QueryRequest {
userPrompt: string
model: string
database: string
dbConnectionId?: number // 数据库连接ID用于实际连接
conversationId?: string
}
export interface QueryResponse {
id: string
userPrompt: string
sqlQuery: string
conversationId: string
queryTime: string
executionTime: string
database: string
model: string
tableData: {
headers: string[]
rows: string[][]
}
chartData?: {
type: string
labels: string[]
datasets: Array<{
label: string
data: number[]
backgroundColor?: string | string[]
}>
}
}
export const queryApi = {
execute: async (queryRequest: QueryRequest): Promise<QueryResponse> => {
return await request<QueryResponse>('/query/execute', {
method: 'POST',
body: JSON.stringify(queryRequest),
})
},
}
// ==================== 查询日志接口 ====================
export interface QueryLog {
id: number
userId: number
dialogId: string
userPrompt: string
sqlQuery: string
queryResult: string
queryTime: string
executionTime: string
llmConfigId: number
dbConnectionId: number
}
export const queryLogApi = {
getList: async (): Promise<QueryLog[]> => {
return await request<QueryLog[]>('/query-log/list')
},
getByUser: async (userId: number): Promise<QueryLog[]> => {
return await request<QueryLog[]>(`/query-log/list/user/${userId}`)
},
getByDialog: async (dialogId: string): Promise<QueryLog[]> => {
return await request<QueryLog[]>(`/query-log/list/dialog/${dialogId}`)
},
getById: async (id: number): Promise<QueryLog> => {
return await request<QueryLog>(`/query-log/${id}`)
},
create: async (queryLog: Partial<QueryLog>): Promise<QueryLog> => {
return await request<QueryLog>('/query-log', {
method: 'POST',
body: JSON.stringify(queryLog),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-log/${id}`, {
method: 'DELETE',
})
},
}
// ==================== 查询分享接口 ====================
/**
*
*
* queryLogId QueryShare
*
* - id, shareUserId, receiveUserId, dialogId, targetRounds, queryTitle, shareTime, receiveStatus
*
*
* - queryLogId
* - dialogId targetRounds
*
*
* 1. QueryShare queryLogId
* 2. Controller queryLogId QueryLog dialogId
*/
export interface QueryShare {
id: number
shareUserId: number
receiveUserId: number
queryLogId: number // ⚠️ 后端实体类缺少此字段
shareTime: string
receiveStatus: number
}
export const queryShareApi = {
getByReceiveUser: async (receiveUserId: number): Promise<QueryShare[]> => {
return await request<QueryShare[]>(`/query-share/list/receive/${receiveUserId}`)
},
getByReceiveUserAndStatus: async (
receiveUserId: number,
receiveStatus: number,
): Promise<QueryShare[]> => {
return await request<QueryShare[]>(
`/query-share/list/receive/${receiveUserId}/${receiveStatus}`,
)
},
getByShareUser: async (shareUserId: number): Promise<QueryShare[]> => {
return await request<QueryShare[]>(`/query-share/list/share/${shareUserId}`)
},
getById: async (id: number): Promise<QueryShare> => {
return await request<QueryShare>(`/query-share/${id}`)
},
create: async (queryShare: Partial<QueryShare>): Promise<QueryShare> => {
return await request<QueryShare>('/query-share', {
method: 'POST',
body: JSON.stringify(queryShare),
})
},
update: async (queryShare: Partial<QueryShare>): Promise<QueryShare> => {
return await request<QueryShare>('/query-share', {
method: 'PUT',
body: JSON.stringify(queryShare),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-share/${id}`, {
method: 'DELETE',
})
},
}
// ==================== 查询收藏接口 ====================
export interface QueryCollection {
id: number
userId: number
collectionName: string
description?: string
createTime: string
}
export interface CollectionRecord {
id: string
collectionId: number
queryLogId: number
addTime: string
}
export const queryCollectionApi = {
getByUser: async (userId: number): Promise<QueryCollection[]> => {
return await request<QueryCollection[]>(`/query-collection/list/${userId}`)
},
create: async (collection: Partial<QueryCollection>): Promise<QueryCollection> => {
return await request<QueryCollection>('/query-collection', {
method: 'POST',
body: JSON.stringify(collection),
})
},
update: async (collection: Partial<QueryCollection>): Promise<QueryCollection> => {
return await request<QueryCollection>('/query-collection', {
method: 'PUT',
body: JSON.stringify(collection),
})
},
delete: async (id: number): Promise<void> => {
return await request<void>(`/query-collection/${id}`, {
method: 'DELETE',
})
},
}
export const collectionRecordApi = {
getByCollection: async (collectionId: string): Promise<CollectionRecord[]> => {
return await request<CollectionRecord[]>(`/collection-record/list/query/${collectionId}`)
},
create: async (record: Partial<CollectionRecord>): Promise<CollectionRecord> => {
return await request<CollectionRecord>('/collection-record', {
method: 'POST',
body: JSON.stringify(record),
})
},
delete: async (id: string): Promise<void> => {
return await request<void>(`/collection-record/${id}`, {
method: 'DELETE',
})
},
}

@ -0,0 +1,117 @@
/**
* @file request.ts
* @description HTTP
*
*
* - HTTP
* - Token
* -
*
* @author Frontend Team
* @since 1.0.0
*/
// ==================== 配置 ====================
/**
* API URL
* VITE_API_BASE_URL 使 '/api'
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? '/api' : '/api')
// ==================== Token 管理 ====================
/**
* Token
* 使 sessionStorage
*
* @returns {string | null} Token null
*/
export const getToken = (): string | null => {
return sessionStorage.getItem('token')
}
/**
* ID
* 使 sessionStorage
*
* @returns {string | null} ID null
*/
export const getUserId = (): string | null => {
return sessionStorage.getItem('userId')
}
// ==================== 请求函数 ====================
/**
* HTTP
*
*
* - Content-Type
* - Result
* - JSON
* -
*
* @template T -
* @param {string} endpoint - API '/user/list'
* @param {RequestInit} options - fetch
* @returns {Promise<T>}
* @throws {Error}
*
* @example
* // GET 请求
* const users = await request<User[]>('/user/list')
*
* @example
* // POST 请求
* const result = await request<LoginResponse>('/auth/login', {
* method: 'POST',
* body: JSON.stringify({ username, password })
* })
*/
export async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const userId = getUserId()
// 构建请求头
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
// 添加认证 TokenBearer 格式)
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
// 添加用户 ID部分接口需要
if (userId) {
headers['userId'] = userId
}
// 发送请求
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers,
})
// 处理 HTTP 错误
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: '请求失败' }))
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
// 处理后端统一 Result 格式:{ code, message, data }
if (data.code !== undefined) {
if (data.code === 200 || data.code === 0) {
return data.data as T
} else {
throw new Error(data.message || '请求失败')
}
}
// 直接返回数据(非 Result 格式)
return data as T
}

@ -0,0 +1,103 @@
/**
* @file services/api/user.ts
* @description API
*
*
* - userApi: CRUD
* - userSearchApi:
*
* @author Frontend Team
*/
import { request } from './request'
// ==================== 用户接口 ====================
export interface User {
id: number
username: string
email: string
phonenumber: string
roleId: number
avatarUrl: string
status: number
}
export interface ChangePasswordRequest {
userId: number
oldPassword: string
newPassword: string
}
export const userApi = {
getById: async (id: number): Promise<User> => {
return await request<User>(`/user/${id}`)
},
getList: async (): Promise<User[]> => {
return await request<User[]>('/user/list')
},
getByUsername: async (username: string): Promise<User> => {
return await request<User>(`/user/username/${username}`)
},
create: async (user: Partial<User>): Promise<string> => {
return await request<string>('/user', {
method: 'POST',
body: JSON.stringify(user),
})
},
update: async (user: Partial<User>): Promise<string> => {
return await request<string>('/user', {
method: 'PUT',
body: JSON.stringify(user),
})
},
delete: async (id: number): Promise<string> => {
return await request<string>(`/user/${id}`, {
method: 'DELETE',
})
},
changePassword: async (changePasswordRequest: ChangePasswordRequest): Promise<string> => {
return await request<string>('/user/change-password', {
method: 'POST',
body: JSON.stringify(changePasswordRequest),
})
},
}
// ==================== 用户搜索接口 ====================
export interface UserSearchRecord {
id: number
userId: number
searchKeyword: string
searchTime: string
}
export const userSearchApi = {
searchByEmail: async (email: string): Promise<User[]> => {
return await request<User[]>(`/user/search/email?email=${encodeURIComponent(email)}`)
},
searchByPhoneNumber: async (phoneNumber: string): Promise<User> => {
return await request<User>(`/user/search/phone?phoneNumber=${encodeURIComponent(phoneNumber)}`)
},
getSearchHistory: async (userId: number): Promise<UserSearchRecord[]> => {
return await request<UserSearchRecord[]>(`/user-search/list/${userId}`)
},
getTopSearches: async (userId: number, limit: number): Promise<UserSearchRecord[]> => {
return await request<UserSearchRecord[]>(`/user-search/list/top/${userId}/${limit}`)
},
saveSearch: async (userSearch: Partial<UserSearchRecord>): Promise<UserSearchRecord> => {
return await request<UserSearchRecord>('/user-search', {
method: 'POST',
body: JSON.stringify(userSearch),
})
},
}

@ -0,0 +1,144 @@
/**
* @file services/queryShareService.ts
* @description
*
* API
*
*
* 1. QueryShare queryLogId
* - query_shares dialog_id, target_rounds, query_title
* - queryLogId
*
* 2. QueryLog
* - query_logs id, dialog_id, data_source_id, user_id
* - userPrompt, sqlQuery, queryResult
* - QueryLog
*
* 3. QueryShareController.save() queryLogId
* - queryLogId dialogId targetRounds
* - queryLogId QueryLog dialogId QueryLog
*
*
* - queryLogId
* - try-catch
* -
*
*
* -
* -
* - API
*/
import { queryLogApi, queryShareApi } from './api.real'
import type { QueryResultData } from '../types'
/**
*
* @param query
* @returns queryLogId
*/
export async function saveQuery(query: QueryResultData): Promise<QueryResultData> {
const userId = Number(sessionStorage.getItem('userId') || '1')
// 调用API保存查询结果
const response = await queryLogApi.create({
userId,
userPrompt: query.userPrompt,
sqlQuery: query.sqlQuery,
queryResult: JSON.stringify({
tableData: query.tableData,
chartData: query.chartData,
}),
dialogId: query.conversationId || '',
dbConnectionId: Number(query.database) || 0,
llmConfigId: Number(query.model) || 0,
queryTime: query.queryTime || new Date().toISOString(),
executionTime: query.executionTime || '0ms',
})
// 返回保存后的查询结果使用真实的queryLogId
return {
...query,
id: String(response.id), // 使用API返回的真实ID
}
}
/**
*
* @param queryId IDIDqueryLogId
* @param friendId ID
* @param queryData queryIdID
* @returns queryLogId
*/
export async function shareQuery(
queryId: string,
friendId: string,
queryData?: QueryResultData
): Promise<string> {
const userId = Number(sessionStorage.getItem('userId'))
// 判断queryId是否是真实的queryLogId数字字符串
let queryLogId = Number(queryId)
const isRealQueryLogId = !isNaN(queryLogId) && queryLogId > 0
// 如果不是真实的queryLogId需要先保存查询
if (!isRealQueryLogId) {
if (!queryData) {
throw new Error('查询数据不存在,无法分享')
}
// 先保存查询获取真实的queryLogId
const savedQuery = await saveQuery(queryData)
queryLogId = Number(savedQuery.id)
}
// 调用分享API
// ⚠️ 注意:后端 QueryShare 实体类缺少 queryLogId 字段,此字段可能被忽略
// 后端需要修复:在 QueryShare 实体类和数据库表中添加 query_log_id 字段
try {
await queryShareApi.create({
shareUserId: userId,
receiveUserId: Number(friendId),
queryLogId: queryLogId, // 使用真实的queryLogId数字类型
receiveStatus: 0, // 未读状态
})
} catch (error) {
// 容错处理:如果后端因为字段不匹配报错,记录日志但不抛出异常
console.warn('⚠️ 分享API调用可能失败后端字段不匹配:', error)
// 如果后端返回成功但实际未保存,前端无法感知
// 建议:后端修复后移除此容错处理
}
// 返回真实的queryLogId字符串格式
return String(queryLogId)
}
/**
* ID
* @param queryId ID
* @param savedQueries
* @returns
*/
export function isQuerySaved(
queryId: string,
savedQueries: QueryResultData[]
): boolean {
return savedQueries.some((q) => q.id === queryId)
}
/**
*
* @param query
* @param savedQueries
* @returns
*/
export function isQuerySavedByContent(
query: QueryResultData,
savedQueries: QueryResultData[]
): boolean {
return savedQueries.some((q) => {
return q.id === query.id ||
(q.userPrompt === query.userPrompt && q.sqlQuery === query.sqlQuery)
})
}

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

@ -0,0 +1,5 @@
/**
* -
* './types/index'
*/
export * from './types/index'

@ -0,0 +1,5 @@
/**
* -
* './index'
*/
export type { ChatMessage, Friend, QueryResultData, ChatModalProps } from './index'

@ -0,0 +1,8 @@
/**
* -
* './index'
*/
export type { ActiveTab, FriendsPageProps } from './index'
// 为了兼容旧的导入方式,重新导出 Message 别名
export type { ChatMessage as Message } from './index'

@ -0,0 +1,271 @@
/**
* @file types/index.ts
* @description TypeScript
*
*
* - UserRole, MessageRole
* - Page, SysAdminPageType, DataAdminPageType
* - QueryResultData, Conversation, Notification
* - UserProfile, Friend, FriendRequest
* - AdminUser, SystemLog, LLMConfig
* - DataSource, DataSourcePermission
*
* 使
* ```typescript
* import type { UserRole, QueryResultData } from '@/types'
* ```
*
* @author Frontend Team
* @since 1.0.0
*/
// ==================== 基础类型 ====================
export type UserRole = 'sys-admin' | 'data-admin' | 'normal-user'
export type MessageRole = 'user' | 'ai'
// ==================== 页面导航类型 ====================
export type Page = 'query' | 'history' | 'notifications' | 'account' | 'friends' | 'comparison' | 'settings'
export type SysAdminPageType =
| 'dashboard'
| 'user-management'
| 'notification-management'
| 'system-log'
| 'llm-config'
| 'account'
| 'settings'
export type DataAdminPageType =
| 'dashboard'
| 'query'
| 'history'
| 'datasource'
| 'user-permission'
| 'notification-management'
| 'connection-log'
| 'notifications'
| 'account'
| 'friends'
| 'comparison'
| 'settings'
// ==================== 数据结构类型 ====================
export interface ModelOption {
name: string
disabled: boolean
description: string
}
export interface ChartData {
type: 'bar' | 'line' | 'pie'
labels: string[]
datasets: {
label: string
data: number[]
backgroundColor: string | string[]
}[]
}
export interface TableData {
headers: string[]
rows: string[][]
}
export interface QueryResultData {
id: string
userPrompt: string
sqlQuery: string
conversationId: string
queryTime: string
executionTime: string
tableData: TableData
chartData?: ChartData
database: string
model: string
}
export interface Message {
role: MessageRole
content: string | QueryResultData
}
export interface Conversation {
id: string
title: string
messages: Message[]
createTime: string
}
// ==================== 通知类型 ====================
export interface Notification {
id: string
type: 'system' | 'share'
title: string
content: string
timestamp: string
isRead: boolean
isPinned: boolean
fromUser?: { id?: string; name: string; avatarUrl: string }
relatedShareId?: string
}
// ==================== 用户相关类型 ====================
export interface UserProfile {
id: string
userId: string
name: string
email: string
phoneNumber: string
avatarUrl: string
registrationDate: string
accountStatus: 'normal' | 'disabled'
preferences: {
defaultModel: string
defaultDatabase: string
}
}
export interface Friend {
id: string
name: string
avatarUrl: string
isOnline: boolean
email: string
remark?: string
}
export interface FriendRequest {
id: string
fromUser: { id?: string; name: string; avatarUrl: string }
timestamp: string
}
// ==================== 查询分享类型 ====================
export interface QueryShare {
id: string
sender: Friend
recipientId: string
querySnapshot: QueryResultData
timestamp: string
status: 'unread' | 'read'
}
// ==================== 管理员类型 ====================
export interface AdminNotification {
id: number
title: string
content: string
role: 'all' | UserRole
priority: 'urgent' | 'normal' | 'low'
pinned: boolean
publisher: string
publishTime: string
status: 'published' | 'draft'
dataSourceTopic?: string
}
export interface AdminUser {
id: number
username: string
role: UserRole
email: string
regTime: string
status: 'active' | 'disabled'
phonenumber: string
}
export interface SystemLog {
id: string
time: string
user: string
action: string
model: string
llm?: string
ip: string
status: 'success' | 'failure'
details?: string
}
export interface LLMConfig {
id: string
name: string
version: string
apiKey: string
endpoint: string
status: 'available' | 'unstable' | 'unavailable' | 'testing' | 'disabled'
}
// ==================== 数据管理员类型 ====================
export interface DataSource {
id: string
name: string
type: 'MySQL' | 'PostgreSQL' | 'Oracle' | 'SQL Server'
address: string
status: 'connected' | 'disconnected' | 'error' | 'testing' | 'disabled'
}
export interface DataSourcePermission {
dataSourceId: string
dataSourceName: string
tables: string[]
}
export interface UserPermissionAssignment {
id: string
userId: string
username: string
permissions: DataSourcePermission[]
}
export interface UnassignedUser {
id: string
username: string
email: string
regTime: string
}
export interface ConnectionLog {
id: string
time: string
datasource: string
status: '成功' | '失败'
details?: string
}
export interface PermissionLog {
id: string
timestamp: string
text: string
}
// ==================== 聊天相关类型 ====================
export interface ChatMessage {
id: string
content: string
isSent: boolean
timestamp: Date
isRead: boolean
}
export interface ChatModalProps {
isOpen: boolean
onClose: () => void
friend: Friend
savedQueries: QueryResultData[]
currentUnreadCount: number
updateUnreadCount: (friendId: string, count: number) => void
messages: ChatMessage[]
updateMessages: (newMessages: ChatMessage[]) => void
}
// ==================== 好友页面类型 ====================
export type ActiveTab = 'friends' | 'requests' | 'shares'
export interface FriendsPageProps {
savedQueries: QueryResultData[]
shares: QueryShare[]
onMarkShareAsRead: (shareId: string) => void
onDeleteShare: (shareId: string) => void
onRerunQuery: (prompt: string) => void
onSaveQuery: (query: QueryResultData) => void
}

@ -0,0 +1,98 @@
/**
* @file utils/logger.ts
* @description
*
*
* -
* -
* -
*
* @example
* import { logOperation, LogModule, LogOperationType, LogStatus } from '@/utils/logger'
*
* await logOperation(
* LogModule.USER_MANAGEMENT,
* LogOperationType.CREATE,
* '创建用户: admin',
* LogStatus.SUCCESS
* )
*
* @author Frontend Team
* @since 1.0.0
*/
import { operationLogApi } from '../services/api.real'
/**
*
* @param module
* @param operateType
* @param operateDesc
* @param status 1: , 0:
*/
export const logOperation = async (
module: string,
operateType: string,
operateDesc: string,
status: number = 1,
): Promise<void> => {
try {
const userId = Number(sessionStorage.getItem('userId') || '0')
if (!userId) {
console.warn('未找到用户ID无法记录日志')
return
}
await operationLogApi.create({
userId,
module,
// 后端字段为 operation/result这里同时写入兼容字段避免错位
operation: operateType,
operateType,
operateDesc,
result: status,
status,
ip: 'unknown', // 前端无法直接获取IP由后端补充
})
} catch (error) {
console.error('记录操作日志失败:', error)
// 日志记录失败不应该影响业务操作,所以只打印错误
}
}
/**
*
*/
export const LogModule = {
USER_MANAGEMENT: '用户管理',
DATA_SOURCE: '数据源管理',
LLM_CONFIG: '大模型配置',
PERMISSION: '权限管理',
NOTIFICATION: '通知管理',
QUERY: '查询操作',
SYSTEM: '系统操作',
} as const
/**
*
*/
export const LogOperationType = {
CREATE: '创建',
UPDATE: '更新',
DELETE: '删除',
ENABLE: '启用',
DISABLE: '禁用',
TEST: '测试',
ASSIGN: '分配',
PUBLISH: '发布',
LOGIN: '登录',
LOGOUT: '登出',
} as const
/**
*
*/
export const LogStatus = {
SUCCESS: 1,
FAILURE: 0,
} as const

@ -0,0 +1,504 @@
<!--
@file views/AccountPage.vue
@description 账户管理页面
功能
- 查看/编辑个人信息
- 修改密码
- 账户设置
@author Frontend Team
-->
<template>
<section
v-if="loading"
class="p-6 overflow-y-auto bg-neutral flex justify-center items-center h-full"
>
<div class="text-gray-500">加载中...</div>
</section>
<section
v-else-if="!user"
class="p-6 overflow-y-auto bg-neutral flex justify-center items-center h-full"
>
<div class="text-gray-500">加载用户信息失败</div>
</section>
<section v-else class="p-6 overflow-y-auto bg-neutral">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Personal Info Card -->
<div class="bg-white p-8 rounded-xl shadow-sm border">
<div class="flex flex-col md:flex-row items-start gap-8">
<!-- Left side: Avatar -->
<div class="flex flex-col items-center flex-shrink-0 w-full md:w-48">
<img
:src="avatarPreview || user?.avatarUrl || '/default-avatar.png'"
alt="User Avatar"
class="w-40 h-40 rounded-full object-cover mb-4 ring-4 ring-white shadow-lg"
/>
<input
type="file"
ref="fileInputRef"
@change="handleAvatarChange"
accept="image/*"
class="hidden"
/>
<button
@click="triggerFileInput"
class="px-4 py-2 border rounded-lg text-sm w-full hover:bg-gray-50 transition-colors"
>
<i class="fa fa-upload mr-2"></i> 更换头像
</button>
</div>
<!-- Right side: Info -->
<div class="flex-1 w-full">
<div class="border-b pb-4 mb-6">
<h3 class="text-xl font-bold text-gray-900">个人基本信息</h3>
</div>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-6">
<div>
<dt class="text-sm font-medium text-gray-500">用户名</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user?.name || '未设置' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">用户ID</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user?.userId || '未设置' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">邮箱</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user?.email || '未设置' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">手机号码</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user?.phoneNumber ? maskPhoneNumber(user.phoneNumber) : '未设置' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">注册时间</dt>
<dd class="mt-1 text-sm text-gray-900">{{ user?.registrationDate || '未设置' }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">账户状态</dt>
<dd class="mt-1 text-sm text-gray-900">
<span v-if="user?.accountStatus" :class="[
'px-2 py-1 text-xs font-medium rounded-full',
user.accountStatus === 'normal' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]">
{{ user.accountStatus === 'normal' ? '正常' : '禁用' }}
</span>
<span v-else></span>
</dd>
</div>
</dl>
<div class="mt-8 flex justify-end">
<button
@click="handleOpenEditModal"
class="px-6 py-2.5 bg-primary text-white rounded-lg font-semibold hover:shadow-lg transition-all duration-200"
>
<i class="fa fa-pencil mr-2"></i> 编辑信息
</button>
</div>
</div>
</div>
</div>
<!-- Security Settings Card -->
<div class="bg-white p-8 rounded-xl shadow-sm border">
<h3 class="text-xl font-bold text-gray-900 border-b pb-4 mb-6">安全设置</h3>
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<p class="font-medium text-gray-800">修改密码</p>
<p class="text-sm text-gray-500 mt-1">上次修改时间2025-05-10</p>
</div>
<button
@click="isPasswordModalOpen = true"
class="px-4 py-2 border rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
>
立即修改
</button>
</div>
<div class="flex justify-between items-center">
<div>
<p class="font-medium text-gray-800">绑定手机</p>
<p class="text-sm text-gray-500 mt-1">
已绑定{{ user.phoneNumber ? maskPhoneNumber(user.phoneNumber) : '未绑定' }}
</p>
</div>
<button
@click="isPhoneModalOpen = true"
class="px-4 py-2 border rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
>
更换绑定
</button>
</div>
</div>
</div>
</div>
<!-- Edit Profile Modal -->
<Modal :is-open="isEditModalOpen" @close="isEditModalOpen = false" title="编辑个人信息">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
<input
ref="editNameInput"
type="text"
v-model="editForm.name"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
<input
ref="editEmailInput"
type="email"
v-model="editForm.email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">手机号码</label>
<input
ref="editPhoneInput"
type="tel"
v-model="editForm.phoneNumber"
pattern="[0-9]{11}"
title="请输入 11 位数字手机号码(可选)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-primary/30 focus:border-primary"
/>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
@click="handleCancelEdit"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-100 transition-colors"
>
取消
</button>
<button
type="button"
@click="handleSaveProfile"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-shadow"
>
确定
</button>
</div>
</Modal>
<!-- Password Change Modal -->
<Modal :is-open="isPasswordModalOpen" @close="closePasswordModal" title="修改密码">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">当前密码</label>
<input
type="password"
placeholder="请输入当前密码"
v-model="passwordForm.oldPassword"
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">新密码</label>
<input
ref="newPasswordInput"
type="password"
placeholder="请输入新密码"
v-model="passwordForm.newPassword"
minlength="6"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">确认新密码</label>
<input
ref="confirmPasswordInput"
type="password"
placeholder="请再次输入新密码"
v-model="passwordForm.confirmPassword"
minlength="6"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
@click="closePasswordModal"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-100 transition-colors"
>
取消
</button>
<button
type="button"
@click="handleChangePassword"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-shadow"
>
确定
</button>
</div>
</Modal>
<!-- Phone Change Modal -->
<Modal :is-open="isPhoneModalOpen" @close="isPhoneModalOpen = false" title="更换绑定手机">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">新手机号码</label>
<input
type="tel"
placeholder="请输入新手机号"
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
<div class="flex space-x-2">
<input
type="text"
placeholder="请输入验证码"
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
/>
<button class="px-4 py-2 border rounded-lg text-sm flex-shrink-0 hover:bg-gray-50">
获取验证码
</button>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
@click="isPhoneModalOpen = false"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm"
>
确认更换
</button>
</div>
</Modal>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Modal from '../components/ui/Modal.vue'
import type { UserProfile } from '../types'
import { userApi, type ChangePasswordRequest } from '../services/api.real'
// InfoField 使HTML
//
const user = ref<UserProfile | null>(null)
const loading = ref(true)
const isEditModalOpen = ref(false)
const isPasswordModalOpen = ref(false)
const isPhoneModalOpen = ref(false)
const editForm = ref({
name: '',
email: '',
phoneNumber: '',
})
const passwordForm = ref({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const avatarPreview = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
const editNameInput = ref<HTMLInputElement | null>(null)
const editEmailInput = ref<HTMLInputElement | null>(null)
const editPhoneInput = ref<HTMLInputElement | null>(null)
const newPasswordInput = ref<HTMLInputElement | null>(null)
const confirmPasswordInput = ref<HTMLInputElement | null>(null)
//
const maskPhoneNumber = (phone: string) => {
if (phone.length === 11) {
return `${phone.substring(0, 3)}****${phone.substring(7)}`
}
return phone
}
//
onMounted(() => {
loadUserProfile()
})
//
const loadUserProfile = async () => {
try {
loading.value = true
user.value = null //
const userId = Number(sessionStorage.getItem('userId') || '1')
if (!userId || isNaN(userId)) {
throw new Error('用户ID无效')
}
const userData = await userApi.getById(userId)
if (!userData) {
throw new Error('用户数据为空')
}
const profile: UserProfile = {
id: String(userData.id),
userId: String(userData.id),
name: userData.username || '未设置',
email: userData.email || '未设置',
phoneNumber: userData.phonenumber || '',
avatarUrl: userData.avatarUrl || '/default-avatar.png',
registrationDate: (userData as any).createTime?.split('T')[0] || '未知',
accountStatus: userData.status === 1 ? 'normal' : 'disabled',
preferences: {
defaultModel: '',
defaultDatabase: '',
},
}
user.value = profile
avatarPreview.value = profile.avatarUrl
} catch (error) {
console.error('加载用户信息失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载用户信息失败,请刷新页面重试'
console.error('错误详情:', error)
// user.valuenull
//
if (!user.value) {
user.value = null
}
} finally {
loading.value = false
}
}
const handleOpenEditModal = () => {
if (user.value) {
editForm.value = {
name: user.value.name || '',
email: user.value.email || '',
phoneNumber: user.value.phoneNumber || '',
}
isEditModalOpen.value = true
}
}
const handleCancelEdit = () => {
isEditModalOpen.value = false
}
const handleSaveProfile = async () => {
//
if (editNameInput.value && !editNameInput.value.checkValidity()) {
editNameInput.value.reportValidity()
return
}
if (editEmailInput.value && !editEmailInput.value.checkValidity()) {
editEmailInput.value.reportValidity()
return
}
if (editPhoneInput.value && !editPhoneInput.value.checkValidity()) {
editPhoneInput.value.reportValidity()
return
}
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
await userApi.update({
id: userId,
username: editForm.value.name,
email: editForm.value.email,
phonenumber: editForm.value.phoneNumber,
avatarUrl: avatarPreview.value,
})
await loadUserProfile() //
isEditModalOpen.value = false
alert('个人信息更新成功!')
} catch (error) {
console.error('更新个人信息失败:', error)
alert('更新失败,请重试')
//
}
}
const triggerFileInput = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
const handleAvatarChange = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = async () => {
const base64Avatar = reader.result as string
avatarPreview.value = base64Avatar
//
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
await userApi.update({
id: userId,
avatarUrl: base64Avatar,
})
await loadUserProfile()
} catch (error) {
console.error('更新头像失败:', error)
alert('更新头像失败')
}
}
reader.readAsDataURL(file)
}
}
const handleChangePassword = async () => {
//
if (newPasswordInput.value && !newPasswordInput.value.checkValidity()) {
newPasswordInput.value.reportValidity()
return
}
if (confirmPasswordInput.value && !confirmPasswordInput.value.checkValidity()) {
confirmPasswordInput.value.reportValidity()
return
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
alert('两次输入的新密码不一致!')
return
}
if (!passwordForm.value.oldPassword) {
alert('请输入当前密码!')
return
}
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
const changePasswordRequest: ChangePasswordRequest = {
userId: userId,
oldPassword: passwordForm.value.oldPassword,
newPassword: passwordForm.value.newPassword,
}
await userApi.changePassword(changePasswordRequest)
alert('密码修改成功!')
closePasswordModal()
} catch (error) {
console.error('修改密码失败:', error)
alert(error instanceof Error ? error.message : '修改失败,请检查当前密码是否正确')
//
}
}
const closePasswordModal = () => {
isPasswordModalOpen.value = false
passwordForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' }
}
</script>
<style scoped>
/* 如果有需要添加的组件特定样式 */
</style>

@ -0,0 +1,322 @@
<!--
@file views/CollectionsPage.vue
@description 收藏夹管理页面
功能
- 收藏夹列表展示
- 创建/编辑/删除收藏夹
- 收藏夹内查询管理
@author Frontend Team
-->
<template>
<section class="p-4 md:p-6 space-y-4 md:space-y-6 overflow-y-auto h-full">
<!-- 加载状态 -->
<div
v-if="loading && collections.length === 0"
class="p-6 space-y-6 overflow-y-auto h-full flex items-center justify-center"
>
<div class="text-center">
<div class="dot-flashing"></div>
<p class="mt-4 text-gray-500">加载收藏夹中...</p>
</div>
</div>
<!-- 主内容 -->
<div v-else>
<!-- 标题和按钮 -->
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold"><i class="fa fa-star mr-2"></i>我的收藏夹</h2>
<button
@click="handleOpenCreateModal"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
<i class="fa fa-plus mr-2"></i> 新建收藏夹
</button>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{{ error }}
</div>
<!-- 收藏夹列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<div
v-for="collection in collections"
:key="collection.id"
class="bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition-shadow"
>
<div class="flex justify-between items-start mb-4">
<div class="flex-1">
<h3 class="font-semibold text-lg text-dark">{{ collection.collectionName }}</h3>
<p v-if="collection.description" class="text-sm text-gray-500 mt-2">
{{ collection.description }}
</p>
</div>
<i class="fa fa-folder text-primary text-2xl"></i>
</div>
<div class="text-xs text-gray-400 mb-4">
创建于: {{ formatDate(collection.createTime) }}
</div>
<div class="flex space-x-3">
<button
@click="() => handleOpenEditModal(collection)"
class="flex-1 px-3 py-1.5 bg-gray-100 text-gray-700 rounded text-sm hover:bg-gray-200 transition-colors"
>
<i class="fa fa-edit mr-1"></i> 编辑
</button>
<button
@click="() => handleOpenDeleteModal(collection)"
class="flex-1 px-3 py-1.5 bg-red-50 text-red-600 rounded text-sm hover:bg-red-100 transition-colors"
>
<i class="fa fa-trash mr-1"></i> 删除
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="collections.length === 0 && !loading"
class="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm"
>
<i class="fa fa-folder-open-o text-4xl mb-3 text-gray-400"></i>
<p>还没有收藏夹点击右上角创建一个吧</p>
</div>
</div>
<!-- 创建收藏夹模态框 -->
<Modal :isOpen="isCreateModalOpen" @close="closeCreateModal" title="新建收藏夹">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">收藏夹名称 *</label>
<input
type="text"
placeholder="输入收藏夹名称"
v-model="collectionName"
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxlength="50"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">描述可选</label>
<textarea
placeholder="输入收藏夹描述"
v-model="collectionDescription"
class="w-full px-4 py-2 border border-gray-300 rounded-lg resize-none"
rows="3"
maxlength="200"
/>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
@click="isCreateModalOpen = false"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm"
>
取消
</button>
<button
@click="handleCreate"
:disabled="loading"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:bg-gray-300"
>
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</Modal>
<!-- 编辑收藏夹模态框 -->
<Modal :isOpen="isEditModalOpen" @close="closeEditModal" title="编辑收藏夹">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">收藏夹名称 *</label>
<input
type="text"
placeholder="输入收藏夹名称"
v-model="collectionName"
class="w-full px-4 py-2 border border-gray-300 rounded-lg"
maxlength="50"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">描述可选</label>
<textarea
placeholder="输入收藏夹描述"
v-model="collectionDescription"
class="w-full px-4 py-2 border border-gray-300 rounded-lg resize-none"
rows="3"
maxlength="200"
/>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button @click="closeEditModal" class="px-4 py-2 border border-gray-300 rounded-lg text-sm">
取消
</button>
<button
@click="handleUpdate"
:disabled="loading"
class="px-4 py-2 bg-primary text-white rounded-lg text-sm disabled:bg-gray-300"
>
{{ loading ? '保存中...' : '保存' }}
</button>
</div>
</Modal>
<!-- 删除收藏夹模态框 -->
<Modal :isOpen="isDeleteModalOpen" @close="closeDeleteModal" title="删除收藏夹">
<p class="text-sm text-gray-700">
确定要删除收藏夹 "{{ currentCollection?.collectionName }}"
此操作无法撤销收藏夹中的所有记录也将被删除
</p>
<div class="mt-6 flex justify-end space-x-3">
<button
@click="closeDeleteModal"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm"
>
取消
</button>
<button
@click="handleDelete"
:disabled="loading"
class="px-4 py-2 bg-danger text-white rounded-lg text-sm disabled:bg-gray-300"
>
{{ loading ? '删除中...' : '确认删除' }}
</button>
</div>
</Modal>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Modal from './Modal.vue'
import { useQueryCollection } from '../composables/useQueryCollection'
//
interface Collection {
id: number
collectionName: string
description?: string
createTime: string
userId: number
}
// ID
const userId = ref(Number(sessionStorage.getItem('userId') || '1'))
// 使hook
const { collections, loading, error, createCollection, updateCollection, deleteCollection } =
useQueryCollection(userId.value)
//
const isCreateModalOpen = ref(false)
const isEditModalOpen = ref(false)
const isDeleteModalOpen = ref(false)
const currentCollection = ref<Collection | null>(null)
const collectionName = ref('')
const collectionDescription = ref('')
//
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
}
//
const handleOpenCreateModal = () => {
collectionName.value = ''
collectionDescription.value = ''
isCreateModalOpen.value = true
}
//
const closeCreateModal = () => {
isCreateModalOpen.value = false
}
//
const handleOpenEditModal = (collection: Collection) => {
currentCollection.value = collection
collectionName.value = collection.collectionName
collectionDescription.value = collection.description || ''
isEditModalOpen.value = true
}
//
const handleOpenDeleteModal = (collection: Collection) => {
currentCollection.value = collection
isDeleteModalOpen.value = true
}
//
const closeEditModal = () => {
isEditModalOpen.value = false
currentCollection.value = null
}
//
const closeDeleteModal = () => {
isDeleteModalOpen.value = false
currentCollection.value = null
}
//
const handleCreate = async () => {
if (!collectionName.value.trim()) {
alert('请输入收藏夹名称')
return
}
const result = await createCollection(collectionName.value, collectionDescription.value)
if (result) {
isCreateModalOpen.value = false
alert('创建成功')
} else {
alert(error.value || '创建失败')
}
}
//
const handleUpdate = async () => {
if (!collectionName.value.trim()) {
alert('请输入收藏夹名称')
return
}
if (!currentCollection.value) return
const result = await updateCollection(
currentCollection.value.id,
collectionName.value,
collectionDescription.value,
)
if (result) {
closeEditModal()
alert('更新成功')
} else {
alert(error.value || '更新失败')
}
}
//
const handleDelete = async () => {
if (!currentCollection.value) return
const result = await deleteCollection(currentCollection.value.id)
if (result) {
closeDeleteModal()
alert('删除成功')
} else {
alert(error.value || '删除失败')
}
}
//
onMounted(() => {
//
})
</script>
<!-- 使用全局 index.css 中的 .dot-flashing 样式 -->

@ -0,0 +1,149 @@
<!--
@file views/DataAdminPage.vue
@description 数据管理员主页面容器
功能
- 根据 activePage 路由到对应子页面
- 管理数据管理员的所有功能模块
子页面
- dashboard: 数据源概览
- query: 数据查询
- datasource: 数据源管理
- user-permission: 用户权限管理
- connection-log: 连接日志
@author Frontend Team
-->
<template>
<!-- 移除多余的div包装避免与MainLayout和QueryPage的div重叠 -->
<DataAdminDashboardPage v-if="activePage === 'dashboard'" :set-active-page="setActivePage" />
<DataSourceManagementPage v-else-if="activePage === 'datasource'" />
<UserPermissionPage v-else-if="activePage === 'user-permission'" />
<DataAdminNotificationPage v-else-if="activePage === 'notification-management'" />
<ConnectionLogPage v-else-if="activePage === 'connection-log'" />
<QueryPage
v-else-if="activePage === 'query'"
ref="queryPageRef"
@update:title="handleUpdateQueryTitle"
@toggle-history="handleToggleHistory"
@new-conversation="handleNewConversation"
/>
<HistoryPage
v-else-if="activePage === 'history'"
@view-in-chat="handleViewInChat"
@rerun="handleRerunQuery"
/>
<NotificationsPage v-else-if="activePage === 'notifications'" />
<AccountPage v-else-if="activePage === 'account'" />
<FriendsPageWithRealAPI
v-else-if="activePage === 'friends'"
@rerun-query="handleRerunQuery"
/>
<SettingsPage v-else-if="activePage === 'settings'" />
<div v-else class="p-6 text-center text-gray-500">未知页面: {{ activePage }}</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { DataAdminPageType } from '../types'
import DataSourceManagementPage from './data-admin/DataSourceManagementPage.vue'
import UserPermissionPage from './data-admin/UserPermissionPage.vue'
import ConnectionLogPage from './data-admin/ConnectionLogPage.vue'
import QueryPage from './QueryPage.vue'
import HistoryPage from './HistoryPage.vue'
import AccountPage from './AccountPage.vue'
import FriendsPageWithRealAPI from './FriendsPage.vue'
import NotificationsPage from './NotificationsPage.vue'
import DataAdminNotificationPage from './data-admin/DataAdminNotificationPage.vue'
import DataAdminDashboardPage from './data-admin/DataAdminDashboardPage.vue'
import SettingsPage from './SettingsPage.vue'
interface Props {
activePage: DataAdminPageType
setActivePage: (page: DataAdminPageType) => void
}
interface Emits {
(e: 'update:query-title', title: string): void
(e: 'switch-to-query'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// QueryPage ref
const queryPageRef = ref<InstanceType<typeof QueryPage> | null>(null)
// QueryPage
const handleUpdateQueryTitle = (title: string) => {
emit('update:query-title', title)
}
// TopHeader
const handleToggleHistory = () => {
//
if (props.activePage !== 'query') {
emit('switch-to-query')
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.toggleHistory()
}
}, 100)
} else if (queryPageRef.value) {
queryPageRef.value.toggleHistory()
}
}
// TopHeader
const handleNewConversation = () => {
//
if (props.activePage !== 'query') {
emit('switch-to-query')
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleNewConversation()
}
}, 100)
} else if (queryPageRef.value) {
queryPageRef.value.handleNewConversation()
}
}
//
const handleViewInChat = (conversationId: string) => {
emit('switch-to-query')
// QueryPage
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleViewInChat(conversationId)
}
}, 100)
}
//
const handleRerunQuery = (prompt: string) => {
emit('switch-to-query')
// QueryPage
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleRerunQuery(prompt)
}
}, 100)
}
// activePage QueryPage
watch(() => props.activePage, (newPage) => {
if (newPage === 'query') {
// QueryPage ref
}
})
//
defineExpose({
handleToggleHistory,
handleNewConversation,
})
</script>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,629 @@
<!--
@file views/HistoryPage.vue
@description 查询历史/收藏夹页面
功能
- 显示用户查询历史记录
- 支持查看重新执行删除
- 结果详情弹窗
@author Frontend Team
-->
<template>
<section class="p-4 md:p-6 space-y-4 md:space-y-6 overflow-y-auto h-full">
<!-- 加载状态 -->
<div
v-if="loading"
class="p-6 space-y-6 overflow-y-auto h-full flex items-center justify-center"
>
<div class="text-center">
<div class="dot-flashing"></div>
<p class="mt-4 text-gray-500">加载查询历史中...</p>
</div>
</div>
<!-- 主要内容 -->
<div v-else>
<!-- 搜索和筛选栏 -->
<div class="bg-white p-4 rounded-xl shadow-sm border sticky top-0 z-10 mb-4">
<div class="flex flex-col md:flex-row gap-4 items-center">
<div class="relative w-full md:w-1/3">
<i class="fa fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
type="text"
placeholder="按查询内容搜索...(右侧下拉框可筛选)"
:value="searchTerm"
@input="(e) => (searchTerm = (e.target as HTMLInputElement).value)"
class="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/30"
/>
</div>
<div class="flex items-center space-x-4 flex-grow justify-end">
<!-- 大模型筛选 -->
<div class="relative">
<select
:value="activeFilters.model"
@change="(e) => handleFilterChange('model', (e.target as HTMLSelectElement).value)"
class="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
:style="selectStyle"
>
<option v-for="option in modelOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- 数据库筛选 -->
<div class="relative">
<select
:value="activeFilters.database"
@change="
(e) => handleFilterChange('database', (e.target as HTMLSelectElement).value)
"
class="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
:style="selectStyle"
>
<option v-for="option in databaseOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="relative">
<select
:value="activeFilters.date"
@change="(e) => handleFilterChange('date', (e.target as HTMLSelectElement).value)"
class="px-3 py-1.5 text-sm rounded-md border border-gray-300 bg-white font-bold focus:ring-2 focus:ring-primary/30 pr-6 appearance-none"
:style="selectStyle"
>
<option v-for="option in dateOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<button
@click="handleResetFilters"
class="px-3 py-1.5 text-sm rounded-md border border-gray-300 hover:bg-gray-50 transition-colors"
>
<i class="fa fa-refresh mr-1"></i> 重置
</button>
<button
@click="openBulkDeleteConfirm"
:disabled="selectedIds.size === 0"
class="px-4 py-1.5 text-sm rounded-md transition-colors disabled:bg-gray-200 disabled:text-gray-400 disabled:cursor-not-allowed enabled:bg-red-600 enabled:text-white enabled:hover:bg-red-700"
>
<i class="fa fa-trash-o mr-1"></i> 批量删除
</button>
</div>
</div>
</div>
<!-- 查询列表 -->
<div class="space-y-4">
<div
v-if="filteredGroups.length === 0"
class="text-center text-gray-500 py-16 bg-white rounded-xl shadow-sm"
>
<i class="fa fa-search-minus text-4xl mb-3 text-gray-400"></i>
<p>未找到匹配的查询记录</p>
</div>
<div v-else>
<div
v-for="[prompt, snapshots] in filteredGroups"
:key="prompt"
class="bg-white rounded-xl shadow-sm overflow-hidden border border-gray-200"
>
<!-- 分组头部 -->
<div
class="p-4 cursor-pointer hover:bg-gray-50 flex justify-between items-center"
@click="() => handleToggleGroup(prompt)"
>
<div>
<p class="font-semibold text-dark truncate max-w-lg" :title="prompt">
{{ prompt }}
</p>
<p class="text-xs text-gray-500 mt-1">{{ snapshots.length }} 次执行</p>
</div>
<div class="flex items-center space-x-2">
<template v-if="snapshots.length > 1 && expandedGroup === prompt">
<button
@click.stop="handleCompare"
:disabled="selectedIds.size !== 2"
class="px-3 py-1 bg-primary text-white rounded-md text-xs disabled:bg-primary/50 disabled:cursor-not-allowed"
>
对比差异 ({{ selectedIds.size }}/2)
</button>
</template>
<button class="text-gray-500">
<i
:class="`fa fa-chevron-down transition-transform ${expandedGroup === prompt ? 'rotate-180' : ''}`"
></i>
</button>
</div>
</div>
<!-- 分组内容 -->
<div
v-if="expandedGroup === prompt"
class="p-4 border-t border-gray-200 bg-neutral space-y-3"
>
<div
v-for="query in snapshots"
:key="query.id"
:class="`p-3 rounded-lg flex items-center justify-between ${selectedIds.has(query.id) ? 'bg-primary/10' : 'bg-white'}`"
>
<div class="flex flex-col">
<div class="flex items-center">
<input
type="checkbox"
:checked="selectedIds.has(query.id)"
@change="() => handleSelectSnapshot(query.id)"
class="mr-4 h-4 w-4 text-primary focus:ring-primary/50 border-gray-300 rounded"
/>
<p class="text-sm font-medium">执行于: {{ formatDate(query.queryTime) }}</p>
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 mt-1 text-xs text-gray-500 ml-8">
<span>耗时: {{ query.executionTime }}</span>
<span>大模型: {{ query.model }}</span>
<span>数据库: {{ query.database }}</span>
<span>所属对话: "{{ getConversationTitle(query.conversationId) }}"</span>
</div>
</div>
<div class="flex space-x-3 text-xs">
<button
@click.stop="() => setViewingQuery(query)"
class="text-primary hover:underline"
>
查看详情
</button>
<button
@click.stop="() => $emit('view-in-chat', query.conversationId)"
class="text-primary hover:underline"
>
查看对话
</button>
<button
@click.stop="() => openRerunConfirm(query.userPrompt)"
class="text-primary hover:underline"
>
重新执行
</button>
<button
@click.stop="() => openDeleteConfirm(query.id)"
class="text-danger hover:underline"
>
删除
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 查看详情模态框 -->
<Modal :is-open="!!viewingQuery" @close="setViewingQuery(null)" :title="viewingQuery ? `查看查询: ${viewingQuery.userPrompt}` : '查看查询'">
<div v-if="viewingQuery" class="max-h-[70vh] overflow-y-auto -m-6 p-6 pt-0">
<QueryResult
:result="viewingQuery"
:show-actions="{ save: false, share: true, export: true }"
/>
</div>
<div v-else class="p-6 text-center text-gray-500">
<i class="fa fa-exclamation-circle text-4xl mb-4 text-gray-400"></i>
<p>查询结果数据加载失败请稍后重试</p>
</div>
</Modal>
<!-- 确认操作模态框 -->
<Modal
:is-open="confirmModalState.isOpen"
@close="handleCancelConfirm"
:title="confirmModalContent.title"
>
<p class="text-gray-700 mb-6">{{ confirmModalContent.message }}</p>
<div class="flex justify-end space-x-3">
<button
@click="handleCancelConfirm"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:shadow-md transition-all duration-200"
>
取消
</button>
<button
@click="handleConfirmAction"
:class="`px-4 py-2 text-white rounded-lg text-sm hover:shadow-md transition-all duration-200 ${confirmModalContent.buttonClass}`"
>
{{ confirmModalContent.buttonText }}
</button>
</div>
</Modal>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Modal from '../components/ui/Modal.vue'
import QueryResult from '../components/feature/query/QueryResult.vue'
import { queryLogApi, dialogApi, llmConfigApi, dbConnectionApi } from '../services/api.real'
import type { Conversation, QueryResultData } from '../types'
// --- ---
type FilterType = 'all' | 'today' | '7days' | '30days'
type ConfirmAction = 'delete' | 'rerun' | 'bulkDelete'
interface Props {
//
}
interface ConfirmModalState {
isOpen: boolean
action: ConfirmAction | null
targetId: string | null
targetPrompt: string | null
targetIds: string[] | null
}
// --- Props Emits ---
const props = defineProps<Props>()
const emit = defineEmits<{
'view-in-chat': [conversationId: string]
rerun: [prompt: string]
compare: [id1: string, id2: string]
}>()
// queryLogs
const savedQueries = computed(() => queryLogs.value)
//
const conversations = ref<Conversation[]>([])
// ID ->
const modelMap = ref<Record<string, string>>({})
const databaseMap = ref<Record<string, string>>({})
// --- ---
const userId = Number(sessionStorage.getItem('userId') || '1')
const queryLogs = ref<QueryResultData[]>([])
const loading = ref(true)
const searchTerm = ref('')
const activeFilters = ref({
date: 'all' as FilterType,
model: 'all',
database: 'all',
})
const expandedGroup = ref<string | null>(null)
const selectedIds = ref<Set<string>>(new Set())
const viewingQuery = ref<QueryResultData | null>(null)
const confirmModalState = ref<ConfirmModalState>({
isOpen: false,
action: null,
targetId: null,
targetPrompt: null,
targetIds: null,
})
// --- ---
const selectStyle = {
backgroundImage:
"url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23333' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\")",
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center',
backgroundSize: '1em',
}
// --- ---
const isDateInRange = (date: Date, days: number): boolean => {
const today = new Date()
const pastDate = new Date()
if (days > 0) {
pastDate.setDate(today.getDate() - (days - 1))
}
today.setHours(23, 59, 59, 999)
pastDate.setHours(0, 0, 0, 0)
return date >= pastDate && date <= today
}
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString()
}
const getConversationTitle = (id: string): string => {
return conversations.value.find((c) => c.id === id)?.title || '未知对话'
}
// --- ---
onMounted(() => {
loadAllData()
})
const loadAllData = async (): Promise<void> => {
try {
loading.value = true
//
const [logs, dialogs, models, databases] = await Promise.all([
queryLogApi.getByUser(userId),
dialogApi.getList(),
llmConfigApi.getAvailable(),
dbConnectionApi.getList(),
])
//
conversations.value = dialogs.map((d) => ({
id: d.dialogId,
title: d.topic || '未命名对话',
messages: [],
createTime: d.startTime,
}))
//
modelMap.value = {}
models.forEach((m) => {
modelMap.value[String(m.id)] = m.name
})
databaseMap.value = {}
databases.forEach((db) => {
databaseMap.value[String(db.id)] = db.name
})
//
const queryData: QueryResultData[] = logs.map((log) => {
const result = JSON.parse(log.queryResult || '{}')
return {
id: String(log.id),
userPrompt: log.userPrompt,
sqlQuery: log.sqlQuery,
conversationId: log.dialogId,
queryTime: log.queryTime,
executionTime: log.executionTime,
database: databaseMap.value[String(log.dbConnectionId)] || String(log.dbConnectionId),
model: modelMap.value[String(log.llmConfigId)] || String(log.llmConfigId),
tableData: result.tableData || { headers: [], rows: [] },
chartData: result.chartData,
}
})
queryLogs.value = queryData
} catch (error) {
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// --- ---
const modelOptions = computed(() => {
//
const uniqueModels = Array.from(
new Set(queryLogs.value.map((q) => q.model).filter(Boolean)),
)
return [
{ value: 'all', label: '全部大模型' },
...uniqueModels.map((model) => ({ value: model, label: model })),
]
})
const databaseOptions = computed(() => {
//
const uniqueDatabases = Array.from(
new Set(queryLogs.value.map((q) => q.database).filter(Boolean)),
)
return [
{ value: 'all', label: '全部数据库' },
...uniqueDatabases.map((db) => ({ value: db, label: db })),
]
})
const dateOptions = [
{ value: 'all', label: '全部日期' },
{ value: 'today', label: '今天' },
{ value: '7days', label: '近7天' },
{ value: '30days', label: '近30天' },
]
const queryGroups = computed(() => {
const groups: Record<string, QueryResultData[]> = {}
queryLogs.value.forEach((query) => {
if (!groups[query.userPrompt]) {
groups[query.userPrompt] = []
}
groups[query.userPrompt].push(query)
})
Object.values(groups).forEach((snapshots) => {
snapshots.sort((a, b) => new Date(b.queryTime).getTime() - new Date(a.queryTime).getTime())
})
return groups
})
const filteredGroups = computed(() => {
return Object.entries(queryGroups.value).filter(([prompt, snapshots]) => {
const matchesSearch = prompt.toLowerCase().includes(searchTerm.value.toLowerCase())
if (!matchesSearch) return false
const filteredSnapshots = snapshots.filter((query) => {
if (activeFilters.value.date !== 'all') {
const queryDate = new Date(query.queryTime)
const dateMatch =
activeFilters.value.date === 'today'
? isDateInRange(queryDate, 1)
: activeFilters.value.date === '7days'
? isDateInRange(queryDate, 7)
: activeFilters.value.date === '30days'
? isDateInRange(queryDate, 30)
: false
if (!dateMatch) return false
}
if (activeFilters.value.model !== 'all' && query.model !== activeFilters.value.model) {
return false
}
if (
activeFilters.value.database !== 'all' &&
query.database !== activeFilters.value.database
) {
return false
}
return true
})
return filteredSnapshots.length > 0
})
})
const confirmModalContent = computed(() => {
if (confirmModalState.value.action === 'delete') {
return {
title: '确认删除查询记录?',
message: '此操作将永久删除该条查询快照。请确认是否继续?',
buttonText: '确认删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
}
}
if (confirmModalState.value.action === 'rerun') {
return {
title: '确认重新执行查询?',
message: `您确定要重新执行查询:"${confirmModalState.value.targetPrompt}" 吗? 这将消耗新的计算资源。`,
buttonText: '重新执行',
buttonClass: 'bg-primary hover:bg-primary/90',
}
}
if (confirmModalState.value.action === 'bulkDelete' && confirmModalState.value.targetIds) {
return {
title: '确认批量删除?',
message: `您确定要永久删除选中的 ${confirmModalState.value.targetIds.length} 条查询记录吗?此操作不可恢复。`,
buttonText: '批量删除',
buttonClass: 'bg-red-600 hover:bg-red-700',
}
}
return { title: '', message: '', buttonText: '', buttonClass: '' }
})
// --- ---
const handleToggleGroup = (prompt: string): void => {
expandedGroup.value = expandedGroup.value === prompt ? null : prompt
selectedIds.value = new Set()
}
const handleSelectSnapshot = (id: string): void => {
const newSet = new Set(selectedIds.value)
if (newSet.has(id)) {
newSet.delete(id)
} else if (newSet.size < 100) {
newSet.add(id)
}
selectedIds.value = newSet
}
const handleCompare = (): void => {
if (selectedIds.value.size !== 2) return
const [id1, id2] = Array.from(selectedIds.value)
emit('compare', id1, id2)
selectedIds.value = new Set()
}
const openDeleteConfirm = (id: string): void => {
confirmModalState.value = {
isOpen: true,
action: 'delete',
targetId: id,
targetPrompt: null,
targetIds: null,
}
}
const openRerunConfirm = (prompt: string): void => {
confirmModalState.value = {
isOpen: true,
action: 'rerun',
targetId: null,
targetPrompt: prompt,
targetIds: null,
}
}
const openBulkDeleteConfirm = (): void => {
confirmModalState.value = {
isOpen: true,
action: 'bulkDelete',
targetId: null,
targetPrompt: null,
targetIds: Array.from(selectedIds.value),
}
}
const handleConfirmAction = async (): Promise<void> => {
try {
if (confirmModalState.value.action === 'delete' && confirmModalState.value.targetId) {
await queryLogApi.delete(Number(confirmModalState.value.targetId))
await loadAllData() //
} else if (confirmModalState.value.action === 'rerun' && confirmModalState.value.targetPrompt) {
emit('rerun', confirmModalState.value.targetPrompt)
} else if (
confirmModalState.value.action === 'bulkDelete' &&
confirmModalState.value.targetIds
) {
await Promise.all(
confirmModalState.value.targetIds.map((id) => queryLogApi.delete(Number(id))),
)
await loadAllData() //
selectedIds.value = new Set()
}
} catch (error) {
console.error('操作失败:', error)
alert('操作失败,请稍后重试')
}
confirmModalState.value = {
isOpen: false,
action: null,
targetId: null,
targetPrompt: null,
targetIds: null,
}
}
const handleCancelConfirm = (): void => {
confirmModalState.value = {
isOpen: false,
action: null,
targetId: null,
targetPrompt: null,
targetIds: null,
}
}
const handleFilterChange = (type: 'date' | 'model' | 'database', value: string): void => {
activeFilters.value = {
...activeFilters.value,
[type]: value,
}
}
const handleResetFilters = (): void => {
activeFilters.value = {
date: 'all',
model: 'all',
database: 'all',
}
searchTerm.value = ''
}
const setViewingQuery = (query: QueryResultData | null): void => {
viewingQuery.value = query
}
</script>
<!-- 使用全局 index.css 中的 .dot-flashing 样式 -->

@ -0,0 +1,168 @@
<!--
@file views/LoginPage.vue
@description 用户登录页面
功能
- 用户名密码登录
- 登录状态验证
- 忘记密码弹窗
@author Frontend Team
-->
<template>
<div class="font-inter animated-gradient min-h-screen flex items-center justify-center p-4">
<div
class="w-full max-w-5xl bg-white rounded-2xl shadow-2xl overflow-hidden grid lg:grid-cols-2"
>
<div class="p-8 md:p-12 flex flex-col justify-center">
<div>
<div class="flex items-center space-x-3 mb-6">
<h1 class="text-4xl font-bold text-dark">自然语言查询系统</h1>
</div>
<h2 class="text-3xl font-bold text-dark mt-6">欢迎回来</h2>
<p class="text-gray-500 mt-2">请输入您的账号和密码登录</p>
</div>
<form @submit.prevent="handleLogin" class="mt-8">
<div class="mb-4">
<div class="relative">
<span class="absolute left-3.5 top-3.5 text-gray-400"
><i class="fa fa-user"></i
></span>
<input
type="text"
placeholder="请输入账号"
v-model="username"
class="w-full px-4 py-3 pl-10 bg-gray-50 text-dark border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder-gray-400"
/>
</div>
</div>
<div class="mb-6">
<div class="relative">
<span class="absolute left-3.5 top-3.5 text-gray-400"
><i class="fa fa-lock"></i
></span>
<input
type="password"
placeholder="请输入密码"
v-model="password"
@keypress.enter="handleLogin"
class="w-full px-4 py-3 pl-10 bg-gray-50 text-dark border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary/50 focus:border-primary placeholder-gray-400"
/>
</div>
</div>
<div
v-if="error"
class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"
>
{{ error }}
</div>
<div class="flex flex-col space-y-4">
<button
type="submit"
class="bg-primary text-white py-3 rounded-lg font-bold hover:shadow-lg hover:shadow-primary/30 hover:-translate-y-0.5 transition-all duration-200 flex items-center justify-center disabled:bg-primary/50 disabled:cursor-wait"
:disabled="isLoading"
>
<i v-if="isLoading" class="fa fa-spinner fa-spin"></i>
<span v-else></span>
</button>
<div class="text-center text-sm text-gray-500">
<a
href="#"
class="hover:text-primary transition-colors"
@click.prevent="isForgotModalOpen = true"
>忘记密码</a
>
</div>
</div>
</form>
<div class="text-center text-xs text-gray-400 mt-12">
© 2024 Your Company. All Rights Reserved.
</div>
</div>
<LoginSidebar />
</div>
<ForgotPasswordModal v-if="isForgotModalOpen" @close="isForgotModalOpen = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { UserRole } from '../types'
import { authApi, type LoginRequest } from '../services/api.real'
import { logOperation, LogModule, LogOperationType, LogStatus } from '../utils/logger'
import LoginSidebar from '../components/layout/sidebars/LoginSidebar.vue'
import ForgotPasswordModal from '../components/feature/ForgotPasswordModal.vue'
// 1. Emits
const emit = defineEmits<{
(e: 'loginSuccess', role: UserRole): void
}>()
// 2. State
const isForgotModalOpen = ref(false)
const isLoading = ref(false)
const username = ref('')
const password = ref('')
const error = ref<string | null>(null)
// 3. Methods
const handleLogin = async () => {
error.value = null
if (!username.value.trim() || !password.value.trim()) {
error.value = '请输入用户名和密码'
return
}
isLoading.value = true
try {
const loginRequest: LoginRequest = {
username: username.value.trim(),
password: password.value,
}
const response = await authApi.login(loginRequest)
let role: UserRole = 'normal-user'
if (response.roleId === 1) {
role = 'sys-admin'
} else if (response.roleId === 2) {
role = 'data-admin'
} else if (response.roleId === 3) {
role = 'normal-user'
} else {
throw new Error('未知的用户角色')
}
sessionStorage.setItem('userRole', role)
await logOperation(
LogModule.SYSTEM,
LogOperationType.LOGIN,
`用户 ${response.username} 登录系统`,
LogStatus.SUCCESS,
)
emit('loginSuccess', role)
window.location.reload()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '登录失败,请检查用户名和密码'
error.value = errorMessage
await logOperation(
LogModule.SYSTEM,
LogOperationType.LOGIN,
`用户 ${username.value} 登录失败:${errorMessage}`,
LogStatus.FAILURE,
)
} finally {
isLoading.value = false
}
}
</script>

@ -0,0 +1,327 @@
<!--
@file views/NotificationsPage.vue
@description 通知中心页面
功能
- 显示系统通知列表
- 通知详情弹窗
- 标记已读/未读
- 置顶通知展示
@author Frontend Team
-->
<template>
<section
v-if="loading"
class="p-6 space-y-6 overflow-y-auto flex items-center justify-center h-full"
>
<div class="text-center">
<div class="dot-flashing"></div>
<p class="mt-4 text-gray-500">加载通知中...</p>
</div>
</section>
<section v-else class="p-6 space-y-6 overflow-y-auto">
<div class="flex justify-between items-center">
<div class="space-x-2">
<button
@click="handleMarkAllRead"
class="px-3 py-1 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 transition-colors"
>
全部标记为已读
</button>
<button
@click="handleClearAll"
class="px-3 py-1 border border-danger/50 text-danger rounded-lg text-sm hover:bg-danger/10 transition-colors"
>
清空通知
</button>
</div>
</div>
<!-- 置顶通知 -->
<div v-if="pinnedNotifications.length > 0">
<h3 class="text-sm font-semibold text-gray-500 mb-2 px-2">置顶通知</h3>
<div class="bg-white rounded-xl shadow-sm">
<ul class="divide-y divide-gray-200">
<!-- 直接把通知项的代码写在这里 -->
<li
v-for="notification in pinnedNotifications"
:key="notification.id"
:class="`p-4 flex items-start space-x-4 transition-colors ${!notification.isRead ? 'bg-primary/5' : 'hover:bg-gray-50'}`"
>
<div
class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-1 relative"
>
<!-- 通知图标 -->
<i v-if="notification.type === 'share'" class="fa fa-share-alt text-primary"></i>
<i v-if="notification.type === 'system'" class="fa fa-cogs text-secondary"></i>
<i
v-if="notification.isPinned"
class="fa fa-thumb-tack text-xs text-gray-500 absolute -top-1 -right-1 bg-white p-1 rounded-full shadow"
></i>
</div>
<div class="flex-1">
<p class="font-semibold text-dark">{{ notification.title }}</p>
<p class="text-sm text-gray-600">{{ notification.content }}</p>
<span class="text-xs text-gray-400 mt-1 block">{{
formatDate(notification.timestamp)
}}</span>
</div>
<div class="flex items-center space-x-3">
<button
@click="handleToggleRead(notification.id)"
class="text-gray-400 hover:text-primary transition-colors"
:title="notification.isRead ? '标记为未读' : '标记为已读'"
>
<i :class="`fa ${notification.isRead ? 'fa-envelope-open' : 'fa-envelope'}`"></i>
</button>
<button
v-if="!notification.isPinned"
@click="handleDelete(notification.id)"
class="text-gray-400 hover:text-danger transition-colors"
title="删除通知"
>
<i class="fa fa-trash"></i>
</button>
</div>
</li>
</ul>
</div>
</div>
<!-- 普通通知 -->
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2 px-2">
{{ pinnedNotifications.length > 0 ? '普通通知' : '' }}
</h3>
<div class="bg-white rounded-xl shadow-sm">
<ul class="divide-y divide-gray-200">
<li
v-for="notification in regularNotifications"
:key="notification.id"
v-if="regularNotifications.length > 0"
:class="`p-4 flex items-start space-x-4 transition-colors ${!notification.isRead ? 'bg-primary/5' : 'hover:bg-gray-50'}`"
>
<div
class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center flex-shrink-0 mt-1 relative"
>
<i v-if="notification.type === 'share'" class="fa fa-share-alt text-primary"></i>
<i v-if="notification.type === 'system'" class="fa fa-cogs text-secondary"></i>
<i
v-if="notification.isPinned"
class="fa fa-thumb-tack text-xs text-gray-500 absolute -top-1 -right-1 bg-white p-1 rounded-full shadow"
></i>
</div>
<div class="flex-1">
<p class="font-semibold text-dark">{{ notification.title }}</p>
<p class="text-sm text-gray-600">{{ notification.content }}</p>
<span class="text-xs text-gray-400 mt-1 block">{{
formatDate(notification.timestamp)
}}</span>
</div>
<div class="flex items-center space-x-3">
<button
@click="handleToggleRead(notification.id)"
class="text-gray-400 hover:text-primary transition-colors"
:title="notification.isRead ? '标记为未读' : '标记为已读'"
>
<i :class="`fa ${notification.isRead ? 'fa-envelope-open' : 'fa-envelope'}`"></i>
</button>
<button
v-if="!notification.isPinned"
@click="handleDelete(notification.id)"
class="text-gray-400 hover:text-danger transition-colors"
title="删除通知"
>
<i class="fa fa-trash"></i>
</button>
</div>
</li>
<div v-else class="text-center text-gray-500 py-16">
<i class="fa fa-bell-slash-o text-4xl mb-3"></i>
<p>没有新的通知</p>
</div>
</ul>
</div>
</div>
<!-- 删除确认弹窗 -->
<Modal :is-open="showDeleteConfirm" @close="closeDeleteConfirm" title="确认删除">
<div class="text-center py-4">
<p class="text-gray-700 mb-6">确定要删除这条通知吗删除后无法恢复</p>
<div class="flex justify-center gap-3">
<button
@click="closeDeleteConfirm"
class="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
取消
</button>
<button
@click="handleConfirmDelete"
class="px-4 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600"
>
确认删除
</button>
</div>
</div>
</Modal>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Modal from '../components/ui/Modal.vue'
import { notificationApi } from '../services/api.real'
//
interface DisplayNotification {
id: string
type: 'share' | 'system'
title: string
content: string
timestamp: string
isRead: boolean
isPinned: boolean
}
//
const notifications = ref<DisplayNotification[]>([])
const readStatus = ref<Record<string, boolean>>({})
const loading = ref(true)
const showDeleteConfirm = ref(false)
const deleteTargetId = ref<string | null>(null)
//
const pinnedNotifications = computed(() => {
return notifications.value.filter((n) => n.isPinned)
})
const regularNotifications = computed(() => {
return notifications.value.filter((n) => !n.isPinned)
})
//
onMounted(() => {
loadNotifications()
})
//
const loadNotifications = async () => {
try {
loading.value = true
// 使API
const userNotifications = await notificationApi.getUserNotifications()
//
const displayNotifications: DisplayNotification[] = userNotifications.map((n) => ({
id: String(n.id),
type: n.priorityId === 1 ? 'system' : 'share',
title: n.title,
content: n.content,
timestamp: n.publishTime || n.createTime,
isRead: n.isRead === 1,
isPinned: n.isTop === 1,
}))
notifications.value = displayNotifications
} catch (error) {
console.error('加载通知失败:', error)
alert('加载通知失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
loading.value = false
}
}
const handleToggleRead = async (id: string) => {
try {
const notification = notifications.value.find((n) => n.id === id)
if (!notification) return
const notificationId = Number(id)
if (notification.isRead) {
await notificationApi.markAsUnread(notificationId)
} else {
await notificationApi.markAsRead(notificationId)
}
//
notification.isRead = !notification.isRead
} catch (error) {
console.error('切换已读状态失败:', error)
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const handleMarkAllRead = async () => {
try {
//
const unreadNotifications = notifications.value.filter((n) => !n.isRead)
await Promise.all(
unreadNotifications.map((n) => notificationApi.markAsRead(Number(n.id))),
)
//
notifications.value = notifications.value.map((n) => ({ ...n, isRead: true }))
} catch (error) {
console.error('标记全部已读失败:', error)
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const handleDelete = (id: string) => {
const notification = notifications.value.find((n) => n.id === id)
if (!notification) return
//
if (notification.isPinned) {
alert('不能删除置顶通知')
return
}
deleteTargetId.value = id
showDeleteConfirm.value = true
}
const handleConfirmDelete = async () => {
if (deleteTargetId.value) {
try {
await notificationApi.deleteByUser(Number(deleteTargetId.value))
//
notifications.value = notifications.value.filter((n) => n.id !== deleteTargetId.value)
} catch (error) {
console.error('删除通知失败:', error)
alert('删除失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
closeDeleteConfirm()
}
const handleClearAll = async () => {
try {
//
const nonPinnedNotifications = notifications.value.filter((n) => !n.isPinned)
await Promise.all(
nonPinnedNotifications.map((n) => notificationApi.deleteByUser(Number(n.id))),
)
//
notifications.value = notifications.value.filter((n) => n.isPinned)
} catch (error) {
console.error('清空通知失败:', error)
alert('操作失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
const closeDeleteConfirm = () => {
showDeleteConfirm.value = false
deleteTargetId.value = null
}
//
const formatDate = (timestamp: string) => {
return new Date(timestamp).toLocaleString()
}
</script>
<!-- 使用全局 index.css 中的 .dot-flashing 样式 -->

@ -0,0 +1,30 @@
<!--
@file views/PlaceholderPage.vue
@description 占位页面组件
功能
- 显示"功能开发中"提示
- 用于尚未实现的页面
@author Frontend Team
-->
<template>
<section
class="p-6 space-y-6 overflow-y-auto flex flex-col items-center justify-center h-full text-gray-500"
>
<div class="text-center">
<i class="fa fa-cogs text-6xl mb-4"></i>
<h2 class="text-2xl font-bold mb-2">{{ title }}</h2>
<p>此页面正在建设中</p>
</div>
</section>
</template>
<script setup lang="ts">
// Props
interface PlaceholderPageProps {
title: string
}
defineProps<PlaceholderPageProps>()
</script>

@ -0,0 +1,848 @@
<!--
@file views/QueryPage.vue
@description 数据查询页面
功能
- 自然语言查询输入
- SQL 生成与执行
- 结果展示表格/图表
- 查询历史侧边栏
- 推荐查询侧边栏
布局结构
- 最外层flex容器无背景色继承父级
- 左侧聊天消息区域 + 输入区域
- 右侧推荐侧边栏桌面端/ 移动端覆盖层
- 覆盖层历史对话侧边栏
@author Frontend Team
-->
<template>
<!-- 最外层全宽布局使用纯色背景使用flex-1确保不被顶部导航栏挤压 -->
<div class="flex-1 flex flex-col relative min-h-0 bg-gray-50">
<!-- 推荐按钮栏固定在右上角顶部栏下方 -->
<div class="absolute top-4 right-6 z-50">
<QueryRecommendSidebar
:current-conversation="currentConversation"
@recommendation-click="handleRecommendationClick"
@open-common="openCommonRecommendations"
@open-suggestions="openAISuggestions"
/>
</div>
<!-- 主要内容区flex容器聊天区域右边距增加避免与推荐按钮冲突 -->
<div class="flex-1 flex flex-col px-4 md:px-8 lg:px-12 py-6 pr-28 overflow-hidden">
<!-- 聊天区域 - 聊天界面容器包含消息和输入框 -->
<div class="flex-1 flex flex-col min-h-0 mx-auto w-full" style="max-width: 900px;">
<!-- 聊天界面容器包裹消息区域和输入框确保输入框固定在底部 -->
<div class="flex-1 flex flex-col min-h-0">
<!-- 聊天消息区域 - 自适应高度可滚动优化视觉效果增加右边距避免与推荐按钮重叠 -->
<div
ref="chatContainerRef"
class="flex-1 overflow-y-auto min-h-0 py-6 px-2"
style="scrollbar-width: thin; scrollbar-color: rgba(156, 163, 175, 0.5) transparent; padding-right: 1rem;"
>
<!-- 消息容器居中显示限制最大宽度与输入框等宽 -->
<div class="w-full mx-auto space-y-6">
<!-- 聊天消息 -->
<ChatMessage
v-for="(msg, index) in currentConversation?.messages"
:key="index"
:message="msg"
:saved-queries="savedQueries"
@save-query="handleSaveQuery"
@share-query="handleShareQuery"
/>
<!-- 加载中状态 - 优化设计 -->
<div v-if="isLoading" class="w-full flex justify-start">
<div class="inline-flex items-center space-x-3 px-5 py-4 bg-white rounded-2xl shadow-md border border-gray-100">
<div class="flex space-x-1">
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 0ms"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 150ms"></div>
<div class="w-2 h-2 bg-primary rounded-full animate-bounce" style="animation-delay: 300ms"></div>
</div>
<span class="text-sm text-gray-600 font-medium">AI正在思考中...</span>
</div>
</div>
</div>
</div>
<!-- 输入框容器固定在底部现代化设计与消息气泡等宽两边聊天气泡的最远端 -->
<div class="flex-shrink-0 mt-4 w-full flex justify-center">
<div class="bg-white rounded-2xl shadow-lg border border-gray-200/50 p-4 backdrop-blur-sm" style="max-width: 900px; width: 100%;">
<!-- textarea 区域现代化输入框 -->
<textarea
v-model="prompt"
@keydown.enter.prevent="handleEnterKey"
@input="handleInputChange"
@focus="showRecommendations = true"
placeholder="输入您的查询需求例如展示2023年各季度的订单量..."
class="w-full px-4 py-3 resize-none focus:outline-none text-base bg-gray-50 rounded-xl border-2 border-transparent focus:border-primary/30 focus:bg-white transition-all max-h-40 overflow-y-auto placeholder:text-gray-400"
:disabled="isLoading"
rows="1"
/>
<!-- 按钮区域优化布局和样式 -->
<div class="flex items-center justify-between pt-3 mt-3 border-t border-gray-100">
<!-- 左侧模型和数据库选择 -->
<div class="flex items-center gap-3">
<!-- 模型选择 -->
<div class="relative group">
<i class="fa fa-cogs absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none z-10 group-hover:text-primary transition-colors"></i>
<select
v-model="selectedModelId"
:class="[
'pl-9 pr-10 py-2 text-sm border-2 rounded-lg bg-white hover:border-primary/30 transition-all appearance-none cursor-pointer min-w-[150px]',
selectedModelId ? 'border-primary/50 bg-primary/5 text-gray-800' : 'border-gray-200 text-gray-600'
]"
:title="modelOptions.find(m => m.id === selectedModelId)?.name || '选择模型'"
style="white-space: normal; text-overflow: initial;"
>
<option value="">选择模型</option>
<option v-for="model in modelOptions" :key="model.id" :value="model.id">
{{ model.name }}
</option>
</select>
<i class="fa fa-chevron-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
</div>
<!-- 数据库选择 -->
<div class="relative group">
<i class="fa fa-database absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none z-10 group-hover:text-primary transition-colors"></i>
<select
v-model="selectedDatabaseId"
:class="[
'pl-9 pr-10 py-2 text-sm border-2 rounded-lg bg-white hover:border-primary/30 transition-all appearance-none cursor-pointer min-w-[150px]',
selectedDatabaseId ? 'border-primary/50 bg-primary/5 text-gray-800' : 'border-gray-200 text-gray-600'
]"
:title="databaseOptions.find(d => d.id === selectedDatabaseId)?.name || '选择数据库'"
style="white-space: normal; text-overflow: initial;"
>
<option value="">选择数据库</option>
<option v-for="db in databaseOptions" :key="db.id" :value="db.id">
{{ db.name }}
</option>
</select>
<i class="fa fa-chevron-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none"></i>
</div>
</div>
<!-- 右侧发送按钮 -->
<button
@click="handleSubmit()"
:disabled="!prompt.trim() || isLoading"
class="px-6 py-2.5 rounded-xl flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed bg-gradient-to-r from-primary to-blue-600 hover:from-primary/90 hover:to-blue-600/90 text-white font-medium shadow-md hover:shadow-lg transition-all transform hover:scale-105 disabled:transform-none min-w-[100px] max-w-[150px]"
title="发送"
>
<i v-if="isLoading" class="fa fa-spinner fa-spin text-sm flex-shrink-0"></i>
<i v-else class="fa fa-paper-plane text-sm flex-shrink-0"></i>
<span class="text-sm truncate">发送</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 历史对话侧边栏 - 覆盖层 -->
<HistorySidebar
:is-open="isHistoryOpen"
:conversations="conversations"
:current-conversation-id="currentConversationId"
@close="toggleHistory"
@switch-conversation="handleSwitchConversation"
@new-conversation="handleNewConversation"
@delete-conversation="handleDeleteConversation"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import type { Conversation, MessageRole, QueryResultData } from '../types'
import ChatMessage from '../components/feature/chat/ChatMessage.vue'
import Dropdown from '../components/ui/Dropdown.vue'
import HistorySidebar from '../components/layout/sidebars/QueryHistorySidebar.vue'
import QueryRecommendSidebar from '../components/layout/sidebars/QueryRecommendSidebar.vue'
import { queryApi, llmConfigApi, dbConnectionApi, queryShareApi, queryLogApi } from '../services/api.real'
import type { QueryResponse } from '../services/api.real'
import { COMMON_RECOMMENDATIONS, MOCK_FAILURE_SUGGESTIONS, MOCK_SUCCESS_SUGGESTIONS } from '../constants'
import { saveQuery, shareQuery, isQuerySaved, isQuerySavedByContent } from '../services/queryShareService'
interface Props {
//
initialPrompt?: string
}
interface Emits {
(e: 'update:title', title: string): void
(e: 'save-query', query: QueryResultData): void
(e: 'share-query', queryId: string, friendId: string): void
(e: 'toggle-history'): void
(e: 'new-conversation'): void
(e: 'rerun-query', prompt: string): void
(e: 'view-in-chat', conversationId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// ===== App.vue =====
const initialConversation: Conversation = {
id: 'conv-initial',
title: '',
messages: [
{
role: 'ai',
content:
'您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。',
},
],
createTime: new Date().toISOString(),
}
const conversations = ref<Conversation[]>([initialConversation])
const currentConversationId = ref<string>(initialConversation.id)
const isHistoryOpen = ref(false)
const savedQueries = ref<QueryResultData[]>([])
//
const currentConversation = computed(() => {
return conversations.value.find((c) => c.id === currentConversationId.value)
})
// TopHeader
watch(
() => currentConversation.value?.title,
(newTitle) => {
if (newTitle) {
emit('update:title', newTitle)
}
},
)
// ===== =====
const prompt = ref('')
const modelOptions = ref<
Array<{
id: string
name: string
disabled: boolean
description: string
}>
>([])
const selectedModelId = ref('')
const databaseOptions = ref<
Array<{
id: string
name: string
disabled: boolean
description: string
}>
>([])
const selectedDatabaseId = ref('')
const selectedDatabase = ref('')
const isLoading = ref(false)
const error = ref<string | null>(null)
const abortController = ref<AbortController | null>(null)
const pendingConversationId = ref<string | null>(null)
const chatContainerRef = ref<HTMLElement | null>(null)
const isMobile = ref(false)
const showRecommendations = ref(false)
const allRecommendations = ref<string[]>([])
const checkMobile = () => {
isMobile.value = window.innerWidth < 768 // md breakpoint
}
//
const filteredRecommendations = computed(() => {
if (!prompt.value.trim()) {
return allRecommendations.value.slice(0, 5) // 5
}
const lowerPrompt = prompt.value.toLowerCase()
return allRecommendations.value
.filter((rec) => rec.toLowerCase().includes(lowerPrompt))
.slice(0, 5)
})
//
const handleInputChange = () => {
if (prompt.value.trim()) {
showRecommendations.value = true
}
}
// ===== =====
/**
* 组件挂载时的初始化逻辑
*
* 统一加载流程
* 1. 初始化移动端检测
* 2. 加载大模型配置异步
* 3. 加载数据库连接异步
* 4. 初始化推荐列表同步
* 5. 初始化标题同步
* 6. 绑定事件监听器
*/
onMounted(() => {
// 1.
checkMobile()
window.addEventListener('resize', checkMobile)
// 2.
loadAvailableModels()
loadDatabaseConnections()
// 3.
allRecommendations.value = COMMON_RECOMMENDATIONS
// 4.
emit('update:title', currentConversation.value?.title || '新对话')
// 5.
document.addEventListener('click', handleClickOutside)
})
/**
* 组件卸载时的清理逻辑
*
* 统一清理流程
* 1. 移除窗口大小监听器
* 2. 移除文档点击监听器
*/
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
document.removeEventListener('click', handleClickOutside)
})
//
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
const inputArea = target.closest('form')
const recommendationPopup = target.closest('.absolute')
//
if (!inputArea && !recommendationPopup) {
showRecommendations.value = false
}
}
// Watchers
watch(
() => currentConversation.value?.messages,
() => {
nextTick(() => {
if (chatContainerRef.value) {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
}
})
},
{ deep: true },
)
watch(
() => props.initialPrompt,
(newPrompt) => {
if (newPrompt) {
prompt.value = newPrompt
handleSubmit(undefined, newPrompt)
}
},
)
watch(
() => currentConversationId.value,
() => {
if (
isLoading.value &&
pendingConversationId.value &&
pendingConversationId.value !== currentConversationId.value
) {
handleStop()
}
//
emit('update:title', currentConversation.value?.title || '新对话')
},
)
// ===== =====
/**
* 加载大模型配置
*
* 统一加载逻辑
* 1. 调用 API 获取配置列表
* 2. 转换为下拉选项格式
* 3. 设置默认选中项第一个
* 4. 错误处理记录错误并设置空数组
*/
const loadAvailableModels = async () => {
try {
const configs = await llmConfigApi.getAvailable()
const options = configs.map((config) => ({
id: String(config.id),
name: `${config.name} (${config.version})`,
disabled: false,
description: `${config.name} - ${config.version}`,
}))
modelOptions.value = options
if (options.length > 0) {
selectedModelId.value = options[0].id
}
} catch (err) {
console.error('加载大模型配置失败:', err)
modelOptions.value = []
error.value = '无法加载大模型配置,请联系管理员'
}
}
/**
* 加载数据库连接配置
*
* 统一加载逻辑
* 1. 调用 API 获取连接列表
* 2. 过滤掉已禁用的连接
* 3. 转换为下拉选项格式
* 4. 设置默认选中项第一个
* 5. 错误处理记录错误并设置空数组
*/
const loadDatabaseConnections = async () => {
try {
const connections = await dbConnectionApi.getList()
const activeConnections = connections.filter((conn) => conn.status !== 'disabled')
const options = activeConnections.map((conn) => ({
id: String(conn.id),
name: conn.name,
disabled: false,
description: `${conn.name} - ${conn.url}`,
}))
databaseOptions.value = options
if (options.length > 0) {
selectedDatabaseId.value = options[0].id
selectedDatabase.value = options[0].name
}
} catch (err) {
console.error('加载数据库连接失败:', err)
databaseOptions.value = []
error.value = '无法加载数据库连接,请联系管理员'
}
}
const handleSubmit = async (event?: Event, customPrompt?: string) => {
if (event) event.preventDefault()
const finalPrompt = customPrompt || prompt.value
if (!finalPrompt.trim() || isLoading.value) return
const requestConversationId = currentConversationId.value
pendingConversationId.value = requestConversationId
const controller = new AbortController()
abortController.value = controller
handleAddMessage('user', finalPrompt)
prompt.value = ''
isLoading.value = true
error.value = null
try {
if (!currentConversation.value) throw new Error('No active conversation.')
const response: QueryResponse = await queryApi.execute({
userPrompt: finalPrompt,
model: selectedModelId.value,
database: selectedDatabase.value,
dbConnectionId: Number(selectedDatabaseId.value),
conversationId:
currentConversation.value.id !== 'conv-initial' ? currentConversation.value.id : undefined,
})
const result: QueryResultData = {
id: response.id,
userPrompt: response.userPrompt,
sqlQuery: response.sqlQuery,
conversationId: response.conversationId,
queryTime: response.queryTime,
executionTime: response.executionTime,
database: response.database,
model: response.model,
tableData: response.tableData,
chartData: response.chartData
? {
type: (response.chartData.type || 'bar') as 'bar' | 'line' | 'pie',
labels: response.chartData.labels || [],
datasets: (response.chartData.datasets || []).map((dataset) => ({
label: dataset.label,
data: dataset.data,
backgroundColor: dataset.backgroundColor || '#3b82f6',
})),
}
: undefined,
}
if (currentConversationId.value === requestConversationId) {
handleAddMessage('ai', result)
// executeidID"query_xxxx"queryLogId
// queryLogApi.create()queryLogId
try {
const userId = Number(sessionStorage.getItem('userId') || '1')
// queryLogId
const savedLog = await queryLogApi.create({
userId,
userPrompt: result.userPrompt,
sqlQuery: result.sqlQuery,
queryResult: JSON.stringify({
tableData: result.tableData,
chartData: result.chartData,
}),
dialogId: result.conversationId || '',
dbConnectionId: Number(result.database) || 0,
llmConfigId: Number(result.model) || 0,
queryTime: result.queryTime || new Date().toISOString(),
executionTime: result.executionTime || '0ms',
})
// 使queryLogIdresultsavedQueries
const realQueryLogId = String(savedLog.id)
result.id = realQueryLogId
// ID
const currentConv = currentConversation.value
if (currentConv) {
const lastMessage = currentConv.messages[currentConv.messages.length - 1]
if (lastMessage && lastMessage.role === 'ai' && typeof lastMessage.content !== 'string') {
lastMessage.content.id = realQueryLogId
}
}
// savedQueries
const savedQuery: QueryResultData = {
...result,
id: realQueryLogId,
}
//
if (!savedQueries.value.some((q) => q.id === realQueryLogId)) {
savedQueries.value = [savedQuery, ...savedQueries.value]
}
console.log('✅ 查询结果已保存到历史记录真实queryLogId:', realQueryLogId)
} catch (saveError) {
console.error('保存查询失败:', saveError)
//
}
} else {
console.log(
`AI回复已丢弃目标对话已切换原对话ID=${requestConversationId}新对话ID=${currentConversationId.value}`,
)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
if (currentConversationId.value === requestConversationId) {
handleAddMessage('ai', '查询已被手动停止')
}
} else {
const errorMessage = err instanceof Error ? err.message : '查询失败,请稍后重试'
if (currentConversationId.value === requestConversationId) {
error.value = errorMessage
handleAddMessage('ai', errorMessage)
}
}
} finally {
if (currentConversationId.value === requestConversationId) {
isLoading.value = false
abortController.value = null
pendingConversationId.value = null
}
}
}
const handleStop = () => {
if (abortController.value) {
abortController.value.abort()
isLoading.value = false
abortController.value = null
pendingConversationId.value = null
}
}
const handleRecommendationClick = (recommendation: string) => {
prompt.value = recommendation
showRecommendations.value = false
handleSubmit(undefined, recommendation)
}
// QueryRecommendSidebar
const openCommonRecommendations = () => {
// QueryRecommendSidebar
}
// AIQueryRecommendSidebar
const openAISuggestions = () => {
// QueryRecommendSidebar
}
//
const queryFailed = computed(() => {
const lastMessage = currentConversation.value?.messages[currentConversation.value.messages.length - 1]
if (!lastMessage || lastMessage.role !== 'ai') return false
return typeof lastMessage.content === 'string'
})
const relatedSearches = computed(() => {
if (!queryFailed.value && currentConversation.value && currentConversation.value.messages.length > 1) {
const lastMessage = currentConversation.value.messages[currentConversation.value.messages.length - 1]
if (lastMessage.role === 'ai') {
return MOCK_SUCCESS_SUGGESTIONS
}
}
return queryFailed.value ? MOCK_FAILURE_SUGGESTIONS : []
})
const handleEnterKey = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
}
}
// ===== =====
const handleAddMessage = (role: MessageRole, content: string | QueryResultData) => {
if (!currentConversationId.value) return
conversations.value = conversations.value.map((conv) => {
if (conv.id === currentConversationId.value) {
const newMessages = [...conv.messages, { role, content }]
let newTitle = conv.title
// 20
if (conv.messages.length === 1 && role === 'user' && typeof content === 'string') {
newTitle = content.substring(0, 20)
emit('update:title', newTitle)
}
return { ...conv, title: newTitle, messages: newMessages }
}
return conv
})
}
const handleNewConversation = () => {
const isEmptyConv = (conv: Conversation) => {
return (
conv.messages.length === 1 &&
conv.messages[0].role === 'ai' &&
typeof conv.messages[0].content === 'string' &&
conv.messages[0].content.includes('您好!我是数据查询助手')
)
}
const existingEmptyConv = conversations.value.find(isEmptyConv)
if (existingEmptyConv) {
currentConversationId.value = existingEmptyConv.id
} else {
const newConv: Conversation = {
id: 'conv-' + Date.now(),
title: '新对话',
messages: [
{
role: 'ai',
content:
'您好!我是数据查询助手,您可以通过自然语言描述您的查询需求(例如:"展示2023年各季度的订单量"),我会为您生成相应的结果。',
},
],
createTime: new Date().toISOString(),
}
conversations.value = [newConv, ...conversations.value]
currentConversationId.value = newConv.id
}
//
if (isHistoryOpen.value) {
isHistoryOpen.value = false
}
emit('update:title', '新对话')
}
const handleSwitchConversation = (id: string) => {
currentConversationId.value = id
// @close
}
const handleDeleteConversation = (deleteId: string) => {
const updatedConversations = conversations.value.filter((conv) => conv.id !== deleteId)
if (currentConversationId.value === deleteId) {
if (updatedConversations.length > 0) {
currentConversationId.value = updatedConversations[0].id
} else {
const newConv: Conversation = {
id: 'conv-' + Date.now(),
title: '新对话',
messages: [
{
role: 'ai',
content: '您好!我是数据查询助手,您可以通过自然语言描述您的查询需求...',
},
],
createTime: new Date().toISOString(),
}
updatedConversations.unshift(newConv)
currentConversationId.value = newConv.id
}
}
conversations.value = updatedConversations
emit('update:title', currentConversation.value?.title || '新对话')
}
const toggleHistory = () => {
isHistoryOpen.value = !isHistoryOpen.value
}
// ===== 使 =====
const handleSaveQuery = async (query: QueryResultData) => {
//
if (isQuerySavedByContent(query, savedQueries.value)) {
alert('该查询结果已保存')
return
}
try {
// 使
const savedQuery = await saveQuery(query)
//
savedQueries.value = [savedQuery, ...savedQueries.value]
emit('save-query', savedQuery)
alert('查询结果已保存成功')
} catch (error) {
console.error('保存查询失败:', error)
alert('保存查询失败,请重试')
}
}
// API
// QueryShare queryLogId
const handleShareQuery = async (queryId: string, friendId: string) => {
try {
// savedQueries
let queryToShare = savedQueries.value.find((q) => q.id === queryId)
//
if (!queryToShare) {
const currentConv = currentConversation.value
if (currentConv) {
const aiMessage = currentConv.messages.find(
(msg) => msg.role === 'ai' && typeof msg.content !== 'string' && msg.content.id === queryId
)
if (aiMessage && typeof aiMessage.content !== 'string') {
queryToShare = aiMessage.content
}
}
}
// 使ID
// queryLogId
const realQueryLogId = await shareQuery(queryId, friendId, queryToShare)
// queryIdIDshareQueryID
const isRealQueryLogId = !isNaN(Number(queryId)) && Number(queryId) > 0
if (queryToShare && !isRealQueryLogId && realQueryLogId !== queryId) {
// shareQuery使ID
const savedQuery: QueryResultData = {
...queryToShare,
id: realQueryLogId,
}
// savedQueriesID
const index = savedQueries.value.findIndex((q) => q.id === queryId)
if (index !== -1) {
savedQueries.value[index] = savedQuery
} else {
// savedQueries
savedQueries.value = [savedQuery, ...savedQueries.value]
}
const currentConv = currentConversation.value
if (currentConv) {
const aiMessage = currentConv.messages.find(
(msg) => msg.role === 'ai' && typeof msg.content !== 'string' && msg.content.id === queryId
)
if (aiMessage && typeof aiMessage.content !== 'string') {
aiMessage.content.id = realQueryLogId
}
}
}
alert('分享成功!')
emit('share-query', realQueryLogId, friendId)
} catch (error) {
console.error('分享失败:', error)
alert('分享失败,请重试: ' + (error instanceof Error ? error.message : '未知错误'))
}
}
//
defineExpose({
conversations,
savedQueries,
handleRerunQuery: (prompt: string) => {
handleNewConversation()
handleSubmit(undefined, prompt)
},
handleViewInChat: (conversationId: string) => {
const conv = conversations.value.find((c) => c.id === conversationId)
if (conv) {
currentConversationId.value = conversationId
isHistoryOpen.value = true
emit('update:title', conv.title || '新对话')
}
},
toggleHistory: () => {
toggleHistory()
},
handleNewConversation: () => {
handleNewConversation()
},
})
</script>
<style scoped>
/* 确保 select 文本能够正确截断(选择框本身) */
select {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 3rem; /* 增加右边留白,为下拉箭头留出空间 */
}
/* 确保 select 选项文本完整显示(下拉时,不截断) */
select option {
white-space: normal !important;
text-overflow: initial !important;
overflow: visible !important;
padding-right: 2rem; /* 增加右边留白 */
max-width: none !important;
}
/* 美化滚动条 */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.7);
}
</style>

@ -0,0 +1,88 @@
<!--
@file views/SettingsPage.vue
@description 设置页面
功能
- 主题切换
- 其他设置选项
-->
<template>
<section class="p-6 overflow-y-auto bg-neutral h-full">
<div class="max-w-4xl mx-auto space-y-6">
<!-- 设置标题 -->
<div class="bg-white rounded-xl shadow-sm border p-6">
<h2 class="text-2xl font-bold flex items-center">
<i class="fa fa-cog text-primary text-2xl mr-3"></i>
设置
</h2>
</div>
<!-- 外观设置 -->
<div class="bg-white rounded-xl shadow-sm border p-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4">主题设置</h3>
<div class="space-y-3">
<div
v-for="themeOption in themeOptions"
:key="themeOption.value"
@click="handleSelectTheme(themeOption.value)"
:class="[
'flex items-center justify-between p-4 rounded-lg transition-all cursor-pointer border-2',
theme === themeOption.value
? 'bg-primary/10 border-primary shadow-md'
: 'bg-gray-50 hover:bg-gray-100 border-transparent hover:border-gray-300'
]"
>
<div class="flex items-center space-x-4">
<div :class="[
'w-12 h-12 rounded-lg flex items-center justify-center text-xl',
theme === themeOption.value ? 'bg-primary text-white' : 'bg-gray-200 text-gray-600'
]">
<i :class="['fa', themeOption.icon]"></i>
</div>
<div>
<span class="text-sm font-medium block">{{ themeOption.name }}</span>
<p class="text-xs text-gray-500 mt-1">{{ themeOption.description }}</p>
</div>
</div>
<div v-if="theme === themeOption.value" class="text-primary">
<i class="fa fa-check-circle text-xl"></i>
</div>
</div>
</div>
</div>
<!-- 其他设置 -->
<div class="bg-white rounded-xl shadow-sm border p-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4">其他设置</h3>
<div class="space-y-4">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500">更多设置功能即将推出...</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme, themeConfig, type Theme } from '../composables/useTheme'
const { theme, setTheme } = useTheme()
const themeOptions = computed(() => {
return Object.entries(themeConfig).map(([value, config]) => ({
value: value as Theme,
name: config.name,
icon: config.icon,
description: config.description,
}))
})
const handleSelectTheme = (selectedTheme: Theme) => {
setTheme(selectedTheme)
}
</script>

@ -0,0 +1,136 @@
<!--
@file views/UserPage.vue
@description 普通用户主页面容器
功能
- 根据 activePage 路由到对应子页面
- 管理普通用户的所有功能模块
子页面
- query: 数据查询
- history: 收藏夹
- notifications: 通知中心
- friends: 好友管理
- account: 账户管理
@author Frontend Team
-->
<template>
<!-- 移除多余的div包装避免与MainLayout和QueryPage的div重叠 -->
<QueryPage
v-if="activePage === 'query'"
ref="queryPageRef"
@update:title="handleUpdateQueryTitle"
@toggle-history="handleToggleHistory"
@new-conversation="handleNewConversation"
/>
<HistoryPage
v-else-if="activePage === 'history'"
@view-in-chat="handleViewInChat"
@rerun="handleRerunQuery"
/>
<NotificationsPage v-else-if="activePage === 'notifications'" />
<FriendsPageWithRealAPI
v-else-if="activePage === 'friends'"
@rerun-query="handleRerunQuery"
/>
<AccountPage v-else-if="activePage === 'account'" />
<SettingsPage v-else-if="activePage === 'settings'" />
<div v-else class="p-6 text-center text-gray-500">未知页面: {{ activePage }}</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Page } from '../types'
import QueryPage from './QueryPage.vue'
import HistoryPage from './HistoryPage.vue'
import AccountPage from './AccountPage.vue'
import FriendsPageWithRealAPI from './FriendsPage.vue'
import NotificationsPage from './NotificationsPage.vue'
import SettingsPage from './SettingsPage.vue'
interface Props {
activePage: Page
}
interface Emits {
(e: 'update:query-title', title: string): void
(e: 'switch-to-query'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// QueryPage ref
const queryPageRef = ref<InstanceType<typeof QueryPage> | null>(null)
// QueryPage
const handleUpdateQueryTitle = (title: string) => {
emit('update:query-title', title)
}
// TopHeader
const handleToggleHistory = () => {
//
if (props.activePage !== 'query') {
emit('switch-to-query')
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.toggleHistory()
}
}, 100)
} else if (queryPageRef.value) {
queryPageRef.value.toggleHistory()
}
}
// TopHeader
const handleNewConversation = () => {
//
if (props.activePage !== 'query') {
emit('switch-to-query')
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleNewConversation()
}
}, 100)
} else if (queryPageRef.value) {
queryPageRef.value.handleNewConversation()
}
}
// App.vue
defineExpose({
handleToggleHistory,
handleNewConversation,
})
//
const handleViewInChat = (conversationId: string) => {
emit('switch-to-query')
// QueryPage
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleViewInChat(conversationId)
}
}, 100)
}
//
const handleRerunQuery = (prompt: string) => {
emit('switch-to-query')
// QueryPage
setTimeout(() => {
if (queryPageRef.value) {
queryPageRef.value.handleRerunQuery(prompt)
}
}, 100)
}
// activePage QueryPage
watch(() => props.activePage, (newPage) => {
if (newPage === 'query') {
// QueryPage ref
}
})
</script>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save