Merge branch 'feature/dsw' of https://git.nianxx.cn/duanshuwen/zn-ai into feature/lishaohua

This commit is contained in:
kongbeiwu
2025-12-28 16:08:45 +08:00
28 changed files with 547 additions and 645 deletions

View File

@@ -1,5 +1,7 @@
import { BrowserView, BrowserWindow } from 'electron'
import { BrowserView, BrowserWindow, ipcMain, IpcMainInvokeEvent } from 'electron'
import { randomUUID } from 'crypto'
import { IPC_EVENTS } from '@common/constants'
import path from 'node:path'
type TabId = string
type TabInfo = { id: TabId; url: string; title: string; isLoading: boolean; canGoBack: boolean; canGoForward: boolean }
@@ -16,6 +18,42 @@ export class TabManager {
constructor(win: BrowserWindow) {
this.win = win
this.win.on('resize', () => this.updateActiveBounds())
this._setupIpcEvents()
}
private _setupIpcEvents() {
ipcMain.handle(IPC_EVENTS.TAB_CREATE, (_e, url?: string) => {
const info = this.create(url)
return info
})
ipcMain.handle(IPC_EVENTS.TAB_LIST, () => {
return this.list()
})
ipcMain.handle(IPC_EVENTS.TAB_NAVIGATE, (_e, { tabId, url }: { tabId: TabId; url: string }) => {
this.navigate(tabId, url)
})
ipcMain.handle(IPC_EVENTS.TAB_RELOAD, (_e, tabId: TabId) => {
this.reload(tabId)
})
ipcMain.handle(IPC_EVENTS.TAB_BACK, (_e, tabId: TabId) => {
this.goBack(tabId)
})
ipcMain.handle(IPC_EVENTS.TAB_FORWARD, (_e, tabId: TabId) => {
this.goForward(tabId)
})
ipcMain.handle(IPC_EVENTS.TAB_SWITCH, (_e, tabId: TabId) => {
this.switch(tabId)
})
ipcMain.handle(IPC_EVENTS.TAB_CLOSE, (_e, tabId: TabId) => {
this.close(tabId)
})
}
enable() {
@@ -30,15 +68,40 @@ export class TabManager {
if (view) this.win.removeBrowserView(view)
}
destroy() {
this.disable()
this.views.forEach(view => {
// @ts-ignore
view.webContents.destroy()
})
this.views.clear()
ipcMain.removeHandler(IPC_EVENTS.TAB_CREATE)
ipcMain.removeHandler(IPC_EVENTS.TAB_LIST)
ipcMain.removeHandler(IPC_EVENTS.TAB_NAVIGATE)
ipcMain.removeHandler(IPC_EVENTS.TAB_RELOAD)
ipcMain.removeHandler(IPC_EVENTS.TAB_BACK)
ipcMain.removeHandler(IPC_EVENTS.TAB_FORWARD)
ipcMain.removeHandler(IPC_EVENTS.TAB_SWITCH)
ipcMain.removeHandler(IPC_EVENTS.TAB_CLOSE)
}
list(): TabInfo[] {
return Array.from(this.views.entries()).map(([id, view]) => this.info(id, view))
}
create(url?: string): TabInfo {
create(url?: string, active = true): TabInfo {
const id = randomUUID()
const view = new BrowserView({ webPreferences: { sandbox: true } })
const view = new BrowserView({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
},
})
this.views.set(id, view)
if (this.enabled) this.attach(id)
if (this.enabled && active) this.attach(id)
const target = url && url.length > 0 ? url : 'about:blank'
view.webContents.loadURL(target)
this.bindEvents(id, view)
@@ -110,8 +173,23 @@ export class TabManager {
if (!this.enabled || !this.activeId) return
const view = this.views.get(this.activeId)
if (!view) return
const [width, height] = this.win.getContentSize()
view.setBounds({ x: 0, y: UI_HEIGHT, width, height: Math.max(0, height - UI_HEIGHT) })
const [winWidth, winHeight] = this.win.getContentSize()
const HEADER_HEIGHT = 88
const PADDING = 8
const RIGHT_PANEL_WIDTH = 392 + 80 + 8 + 8 // TaskList + SideMenu + Gap + RightPadding
const x = PADDING
const y = HEADER_HEIGHT + PADDING
const width = winWidth - RIGHT_PANEL_WIDTH - PADDING
const height = winHeight - HEADER_HEIGHT - (PADDING * 2)
view.setBounds({
x,
y,
width: Math.max(0, width),
height: Math.max(0, height)
})
}
private bindEvents(id: TabId, view: BrowserView): void {

View File

@@ -27,7 +27,9 @@ interface SizeOptions {
}
const SHARED_WINDOW_OPTIONS = {
frame: false,
titleBarStyle: 'hidden',
trafficLightPosition: { x: -100, y: -100 },
show: false,
title: 'NIANXX',
darkTheme: themeManager.isDark,

View File

@@ -7,6 +7,8 @@ import { menuManager } from '@main/service/menu-service'
import { logManager } from '@main/service/logger'
import { configManager } from '@main/service/config-service'
import { trayManager } from '@main/service/tray-service'
import { TabManager } from '@service/tab-manager'
import path from 'node:path'
const handleTray = (minimizeToTray: boolean) => {
if (minimizeToTray) {
@@ -107,6 +109,38 @@ export function setupMainWindow() {
});
handleTray(minimizeToTray);
registerMenus(mainWindow);
const tabManager = new TabManager(mainWindow)
tabManager.enable()
let tabsInitialized = false
mainWindow.on('closed', () => {
tabManager.destroy()
})
const getPageUrl = (page: string) => {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
return `${MAIN_WINDOW_VITE_DEV_SERVER_URL}/html/${page}.html`
}
return `file://${path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/html/${page}.html`)}`
}
const initTabs = () => {
if (tabsInitialized) return
tabsInitialized = true
tabManager.create(getPageUrl('home'), true)
tabManager.create(getPageUrl('knowledge'), false)
tabManager.create(getPageUrl('task'), false)
tabManager.create(getPageUrl('setting'), false)
}
mainWindow.webContents.on('did-finish-load', () => {
const url = mainWindow.webContents.getURL()
const isDevRoot = MAIN_WINDOW_VITE_DEV_SERVER_URL && (url === MAIN_WINDOW_VITE_DEV_SERVER_URL || url === `${MAIN_WINDOW_VITE_DEV_SERVER_URL}/`)
if (url.includes('/html/index.html') || url.endsWith('index.html') || isDevRoot) {
initTabs()
}
})
});
windowManager.create(WINDOW_NAMES.MAIN, MAIN_WIN_SIZE);

View File

@@ -16,7 +16,6 @@ export function authOauth2TokenUsingPost({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic Y3VzdG9tUEM6Y3VzdG9tUEM=',
},
data: body,
...(options || {}),

View File

@@ -21,17 +21,107 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { menus } from '@constant/menus'
import { useRouter } from "vue-router"
import { ref, onMounted, onUnmounted } from 'vue'
import { menus, type MenuItem } from '@constant/menus'
const router = useRouter()
const currentId = ref(1)
const tabMap = new Map<number, string>()
const cleanupListeners: (() => void)[] = []
const handleClick = async (item: any) => {
const getHtmlPath = (menuUrl: string) => {
const cleanUrl = menuUrl.startsWith('/') ? menuUrl.slice(1) : menuUrl
let filename = ''
switch (cleanUrl) {
case 'home':
filename = 'home.html'
break
case 'knowledge':
filename = 'knowledge.html'
break
case 'task':
filename = 'task.html'
break
case 'setting':
filename = 'setting.html'
break
default:
filename = 'home.html'
}
if (import.meta.env.DEV) {
return `/html/${filename}`
}
return filename
}
onMounted(async () => {
if (window.api && window.api.tabs) {
const cleanupClosed = window.api.tabs.on('tab-closed', (payload: any) => {
const { tabId } = payload
for (const [menuId, id] of tabMap.entries()) {
if (id === tabId) {
tabMap.delete(menuId)
break
}
}
})
if (cleanupClosed) cleanupListeners.push(cleanupClosed)
const cleanupCreated = window.api.tabs.on('tab-created', (tab: any) => {
for (const menu of menus) {
const targetHtml = getHtmlPath(menu.url)
if (tab.url.includes(targetHtml)) {
tabMap.set(menu.id, tab.id)
break
}
}
})
if (cleanupCreated) cleanupListeners.push(cleanupCreated)
try {
const tabs = await window.api.tabs.list()
if (tabs && tabs.length > 0) {
for (const tab of tabs) {
for (const menu of menus) {
const targetHtml = getHtmlPath(menu.url)
if (tab.url.includes(targetHtml)) {
tabMap.set(menu.id, tab.id)
break
}
}
}
}
} catch (e) {
console.error('Failed to sync tabs', e)
}
}
})
onUnmounted(() => {
cleanupListeners.forEach(fn => fn())
})
const handleClick = async (item: MenuItem) => {
console.log("🚀 ~ handleClick ~ item:", item)
currentId.value = item.id
router.push(item.url);
const existingTabId = tabMap.get(item.id)
if (existingTabId) {
await window.api.tabs.switch(existingTabId)
} else {
const htmlFile = getHtmlPath(item.url)
const targetUrl = new URL(htmlFile, window.location.href).href
try {
const tabInfo = await window.api.tabs.create(targetUrl)
if (tabInfo && tabInfo.id) {
tabMap.set(item.id, tabInfo.id)
await window.api.tabs.switch(tabInfo.id)
}
} catch (e) {
console.error('Failed to create tab', e)
}
}
}
</script>

View File

@@ -33,7 +33,7 @@ export const menus: MenuItem[] = [
icon: RiApps2AiLine,
color: '#525866',
activeColor: '#2B7FFF',
url: '/stock',
url: '/task',
},
{
id: 4,

View File

@@ -19,10 +19,12 @@ import 'element-plus/dist/index.css'
// 引入全局组件
import HeaderBar from '@components/HeaderBar/index.vue'
import DragRegion from '@components/DragRegion/index.vue'
import Layout from '@components/Layout/index.vue'
const components: Plugin = (app) => {
app.component('HeaderBar', HeaderBar);
app.component('DragRegion', DragRegion);
app.component('Layout', Layout);
}
// 创建 Vue 应用实例

View File

@@ -1,6 +1,6 @@
import router from './router'
import { isPathMatch } from '@utils/validate'
import { getToken } from '@utils/auth'
import { Session } from '@renderer/utils/storage'
// 白名单
const whiteList = ['/login', '/register']
@@ -8,7 +8,7 @@ const whiteList = ['/login', '/register']
const isWhiteList = (path: string) => whiteList.some(pattern => isPathMatch(pattern, path))
router.beforeEach((to: any, _from: any, next: any) => {
if(getToken()) {
if(Session.getToken()) {
// has token
if (to.path === '/login') {
next({path: '/home'})

View File

@@ -1,10 +1,11 @@
import { defineStore } from 'pinia'
import { authOauth2TokenUsingPost } from "@renderer/api"
import { getToken, setToken, removeToken } from '@utils/auth'
import { Session } from '@utils/storage'
import { encryption } from '@utils/other'
export const useUserStore = defineStore('userInfo', {
state: () => ({
token: getToken(),
token: Session.get('token'),
}),
actions: {
@@ -15,15 +16,37 @@ export const useUserStore = defineStore('userInfo', {
* @param {Object} data - 登录数据
* @returns {Promise<Object>}
*/
async login(data: LoginForm) {
async login(data: any) {
data.grant_type = 'password';
data.scope = 'server';
// const { VITE_OAUTH2_PASSWORD_CLIENT, VITE_PWD_ENC_KEY } = (import.meta as any).env
// const basicAuth = 'Basic ' + window.btoa(VITE_OAUTH2_PASSWORD_CLIENT);
// Session.set('basicAuth', basicAuth);
// let encPassword = data.password;
// 密码加密
// if (VITE_PWD_ENC_KEY) {
// encPassword = encryption(data.password, VITE_PWD_ENC_KEY);
// }
return new Promise((resolve, reject) => {
authOauth2TokenUsingPost({body: {...data, clientId: ''}})
authOauth2TokenUsingPost({
body: { clientId: '', ...data },
options: {
headers: {
isToken: true,
// Authorization: basicAuth,
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic Y3VzdG9tUEM6Y3VzdG9tUEM='
}
}
})
.then((res: any) => {
// 存储token 信息
setToken(res.access_token)
// 存储token 信息
Session.set('token', res.access_token);
Session.set('refresh_token', res.refresh_token);
resolve(res)
})
.catch((err) => {

View File

@@ -1,15 +0,0 @@
import Cookies from 'js-cookie'
const TokenKey = 'Nianxx-Token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token: string) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}

View File

@@ -0,0 +1,32 @@
import * as CryptoJS from 'crypto-js';
/**
*加密处理
*/
export function encryption(src: string, keyWord: string) {
const key = CryptoJS.enc.Utf8.parse(keyWord);
// 加密
var encrypted = CryptoJS.AES.encrypt(src, key, {
iv: key,
mode: CryptoJS.mode.CFB,
padding: CryptoJS.pad.NoPadding,
});
return encrypted.toString();
}
/**
* 解密
* @param {*} params 参数列表
* @returns 明文
*/
export function decryption(src: string, keyWord: string) {
const key = CryptoJS.enc.Utf8.parse(keyWord);
// 解密逻辑
var decryptd = CryptoJS.AES.decrypt(src, key, {
iv: key,
mode: CryptoJS.mode.CFB,
padding: CryptoJS.pad.NoPadding,
});
return decryptd.toString(CryptoJS.enc.Utf8);
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import cache from '@utils/cache'
import errorCode from '@constant/errorCode'
import { ElNotification , ElMessageBox, ElMessage } from 'element-plus'
import { getToken } from '@utils/auth'
import { Session } from '@renderer/utils/storage'
import { tansParams } from '@utils/tansParams'
// 获取.env中的服务地址
@@ -28,9 +28,12 @@ instance.interceptors.request.use(
const isToken = (config.headers || {}).isToken === false
// 是否需要防止数据重复提交
const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
const token = Session.getToken();
if (token && !isToken) {
config.headers['Authorization'] = `Bearer ${token}` // 让每个请求携带自定义token 请根据实际情况自行修改
}
// get请求映射params参数
if (config.method === 'get' && config.params) {
let url = config.url + '?' + tansParams(config.params)

View File

@@ -0,0 +1,85 @@
import Cookies from 'js-cookie';
/**
* window.localStorage 浏览器永久缓存
* @method set 设置永久缓存
* @method get 获取永久缓存
* @method remove 移除永久缓存
* @method clear 移除全部永久缓存
*/
export const Local = {
// 查看 v2.4.3版本更新日志
setKey(key: string) {
// @ts-ignore
return `${__NEXT_NAME__}:${key}`;
},
// 设置永久缓存
set<T>(key: string, val: T) {
window.localStorage.setItem(Local.setKey(key), JSON.stringify(val));
},
// 获取永久缓存
get(key: string) {
let json = <string>window.localStorage.getItem(Local.setKey(key));
return JSON.parse(json);
},
// 移除永久缓存
remove(key: string) {
window.localStorage.removeItem(Local.setKey(key));
},
// 移除全部永久缓存
clear() {
window.localStorage.clear();
},
};
/**
* window.sessionStorage 浏览器临时缓存
* @method set 设置临时缓存
* @method get 获取临时缓存
* @method remove 移除临时缓存
* @method clear 移除全部临时缓存
*/
export const Session = {
// 设置临时缓存
set(key: string, val: any) {
if (key === 'token' || key === 'refresh_token') {
Cookies.set(key, val);
}
window.sessionStorage.setItem(key, JSON.stringify(val));
},
// 获取临时缓存
get(key: string) {
if (key === 'token' || key === 'refresh_token') return Cookies.get(key);
let json = <string>window.sessionStorage.getItem(key);
return JSON.parse(json);
},
// 移除临时缓存
remove(key: string) {
if (key === 'token' || key === 'refresh_token') return Cookies.remove(key);
window.sessionStorage.removeItem(key);
},
// 移除全部临时缓存
clear() {
Cookies.remove('token');
Cookies.remove('refresh_token');
Cookies.remove('tenantId');
window.sessionStorage.clear();
},
// 获取当前存储的 token
getToken() {
return this.get('token');
},
// 获取当前的租户
getTenant() {
return Local.get('tenantId') ? Local.get('tenantId') : 1;
},
};

View File

@@ -0,0 +1,8 @@
<template>
<div class="bg-white h-full w-full p-[20px]">
<h1>首页 Dashboard</h1>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,40 @@
import { createApp, type Plugin } from "vue"
import errorHandler from '@utils/errorHandler'
// 引入 Element Plus 组件库
import ElementPlus from 'element-plus'
import locale from 'element-plus/es/locale/lang/zh-cn'
// 引入 i18n 插件
import i18n from '@renderer/i18n'
import Home from './HomeTab.vue'
// 样式文件隔离
import '@renderer/styles/index.css'
import 'element-plus/dist/index.css'
// 引入全局组件
import HeaderBar from '@components/HeaderBar/index.vue'
import DragRegion from '@components/DragRegion/index.vue'
import Layout from '@components/Layout/index.vue'
const components: Plugin = (app) => {
app.component('HeaderBar', HeaderBar);
app.component('DragRegion', DragRegion);
app.component('Layout', Layout);
}
// 创建 Vue 应用实例
const app = createApp(Home);
const pinia = createPinia();
// 使用 Pinia 状态管理
app.use(pinia);
app.use(ElementPlus, { locale })
app.use(components)
app.use(i18n)
app.use(errorHandler)
// 挂载应用到 DOM
app.mount("#app");

View File

@@ -0,0 +1,38 @@
import { createApp, type Plugin } from "vue"
import errorHandler from '@utils/errorHandler'
// 引入 Element Plus 组件库
import ElementPlus from 'element-plus'
import locale from 'element-plus/es/locale/lang/zh-cn'
// 引入 i18n 插件
import i18n from '@renderer/i18n'
import Knowledge from './index.vue'
// 样式文件隔离
import '@renderer/styles/index.css'
import 'element-plus/dist/index.css'
// 引入全局组件
import HeaderBar from '@components/HeaderBar/index.vue'
import DragRegion from '@components/DragRegion/index.vue'
const components: Plugin = (app) => {
app.component('HeaderBar', HeaderBar);
app.component('DragRegion', DragRegion);
}
// 创建 Vue 应用实例
const app = createApp(Knowledge);
const pinia = createPinia();
// 使用 Pinia 状态管理
app.use(pinia);
app.use(ElementPlus, { locale })
app.use(components)
app.use(i18n)
app.use(errorHandler)
// 挂载应用到 DOM
app.mount("#app");

View File

@@ -1,13 +1,5 @@
<!--
* @Author: kongbeiwu lishaohua-520@qq.com
* @Date: 2025-12-21 23:02:06
* @LastEditors: kongbeiwu lishaohua-520@qq.com
* @LastEditTime: 2025-12-22 01:24:00
* @FilePath: /project/zn-ai/src/renderer/views/knowledge/index.vue
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
-->
<template>
<div class="bg-white box-border w-full h-full rounded-[16px] p-[20px]">
<div class="bg-white box-border w-full h-full p-[20px]">
<TitleSection title="知识库管理" desc="内容管理" />
<el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane label="事件管理" name="first">

View File

@@ -1,5 +1,5 @@
<template>
<div class="bg-white box-border w-full h-full rounded-[16px] flex">
<div class="bg-white box-border w-full h-full flex">
<SystemConfig @change=onChange />
<component :is="currentComponent" />

View File

@@ -17,12 +17,10 @@ import 'element-plus/dist/index.css'
// 引入全局组件
import HeaderBar from '@components/HeaderBar/index.vue'
import DragRegion from '@components/DragRegion/index.vue'
import Layout from '@components/Layout/index.vue'
const components: Plugin = (app) => {
app.component('HeaderBar', HeaderBar);
app.component('DragRegion', DragRegion);
app.component('Layout', Layout);
}
// 创建 Vue 应用实例

View File

@@ -1,12 +1,6 @@
<template>
<div class="bg-gray-100 h-screen flex flex-col">
<header-bar>
<drag-region class="w-full" />
</header-bar>
<layout>
</layout>
<div class="bg-white h-full flex flex-col p-[20px]">
任务中心
</div>
</template>