feat: 新增窗口头部组件

This commit is contained in:
DEV_DSW
2025-12-18 14:35:04 +08:00
parent 9bfcc49411
commit 8dec7d676e
17 changed files with 342 additions and 93 deletions

7
env.d.ts vendored
View File

@@ -1,7 +0,0 @@
declare module "@store/*";
declare module "@modules/*";
declare module "@utils/*";
declare module "@assets/images/*";
declare module "@constant/*";
declare module "@remixicon/vue";
declare module "vue-router";

37
global.d.ts vendored
View File

@@ -49,11 +49,12 @@ declare global {
external: { external: {
open: (url: string) => void open: (url: string) => void
}, },
window: { minimizeWindow: () => void,
minimize: () => void, maximizeWindow: () => void,
maximize: () => void, closeWindow: () => void,
close: () => void onWindowMaximized: (callback: (isMaximized: boolean) => void) => void,
}, isWindowMaximized: () => Promise<boolean>,
viewIsReady: () => void
app: { app: {
setFrameless: (route?: string) => void setFrameless: (route?: string) => void
}, },
@@ -69,10 +70,34 @@ declare global {
on: (event: 'tab-updated' | 'tab-created' | 'tab-closed' | 'tab-switched', handler: (payload: any) => void) => void on: (event: 'tab-updated' | 'tab-created' | 'tab-closed' | 'tab-switched', handler: (payload: any) => void) => void
}, },
readFile: (filePath: string) => Promise<{success: boolean, data?: string, error?: string}>, readFile: (filePath: string) => Promise<{success: boolean, data?: string, error?: string}>,
logToMain: (logLevel: string, message: string) => void, logger: {
debug: (message: string, ...meta?: any[]) => void;
info: (message: string, ...meta?: any[]) => void;
warn: (message: string, ...meta?: any[]) => void;
error: (message: string, ...meta?: any[]) => void;
},
} }
declare interface Window { declare interface Window {
api: WindowApi; api: WindowApi;
} }
} }
declare module "@store/*";
declare module "@modules/*";
declare module "@utils/*";
declare module "@assets/images/*";
declare module "@constant/*";
declare module "@remixicon/vue";
declare module "vue-router";
declare module '@iconify/vue' {
import { DefineComponent } from 'vue'
export const Icon: DefineComponent<{
icon: string
width?: string | number
height?: string | number
color?: string
flip?: string
rotate?: number
}>
}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://8.138.234.141; connect-src 'self' http://8.138.234.141" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://8.138.234.141; connect-src 'self' http://8.138.234.141 https://api.iconify.design"
/> />
</head> </head>
<body> <body>

32
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iconify-json/material-symbols": "^1.2.50",
"@iconify/vue": "^5.0.0",
"@remixicon/vue": "^4.7.0", "@remixicon/vue": "^4.7.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
@@ -1533,6 +1535,36 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@iconify-json/material-symbols": {
"version": "1.2.50",
"resolved": "https://registry.npmmirror.com/@iconify-json/material-symbols/-/material-symbols-1.2.50.tgz",
"integrity": "sha512-71tjHR70h46LHtBFab3fAd2V/wPTO7JMV5lKnRn3IcF303LaFgAlO0BZeTJDcmCv9d0snRZmnoLZAJVD7/eisw==",
"license": "Apache-2.0",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@inquirer/checkbox": { "node_modules/@inquirer/checkbox": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-3.0.1.tgz",

View File

@@ -46,6 +46,8 @@
"vite": "^7.1.9" "vite": "^7.1.9"
}, },
"dependencies": { "dependencies": {
"@iconify-json/material-symbols": "^1.2.50",
"@iconify/vue": "^5.0.0",
"@remixicon/vue": "^4.7.0", "@remixicon/vue": "^4.7.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",

View File

@@ -25,7 +25,14 @@ export enum IPC_EVENTS {
FILE_WRITE = 'file:write', FILE_WRITE = 'file:write',
GET_WINDOW_ID='get-window-id', GET_WINDOW_ID='get-window-id',
CUSTOM_EVENT ='custom:event', CUSTOM_EVENT ='custom:event',
TIME_UPDATE = 'time:update' TIME_UPDATE = 'time:update',
RENDERER_IS_READY = 'renderer-ready',
// 发送日志
LOG_DEBUG = 'log-debug',
LOG_INFO = 'log-info',
LOG_WARN = 'log-warn',
LOG_ERROR = 'log-error',
} }
export const MAIN_WIN_SIZE = { export const MAIN_WIN_SIZE = {

View File

@@ -30,6 +30,8 @@ class AppMain {
resizable: true, resizable: true,
maximizable: true, maximizable: true,
minimizable: true, minimizable: true,
titleBarStyle: 'hidden',
title: 'NIANXX',
webPreferences: { webPreferences: {
devTools: this.isDev, devTools: this.isDev,
nodeIntegration: false, nodeIntegration: false,

View File

@@ -8,11 +8,12 @@ const api: WindowApi = {
open: (url: string) => ipcRenderer.invoke('external-open', url) open: (url: string) => ipcRenderer.invoke('external-open', url)
}, },
window: { closeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE),
minimize: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE), minimizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MINIMIZE),
maximize: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE), maximizeWindow: () => ipcRenderer.send(IPC_EVENTS.WINDOW_MAXIMIZE),
close: () => ipcRenderer.send(IPC_EVENTS.WINDOW_CLOSE) onWindowMaximized: (callback: (isMaximized: boolean) => void) => ipcRenderer.on(IPC_EVENTS.WINDOW_MAXIMIZE + 'back', (_, isMaximized) => callback(isMaximized)),
}, isWindowMaximized: () => ipcRenderer.invoke(IPC_EVENTS.WINDOW_MAXIMIZE),
viewIsReady: () => ipcRenderer.send(IPC_EVENTS.RENDERER_IS_READY),
app: { app: {
setFrameless: (route?: string) => ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route) setFrameless: (route?: string) => ipcRenderer.invoke(IPC_EVENTS.APP_SET_FRAMELESS, route)
@@ -63,7 +64,12 @@ const api: WindowApi = {
getCurrentWindowId: () => ipcRenderer.sendSync(IPC_EVENTS.GET_WINDOW_ID), getCurrentWindowId: () => ipcRenderer.sendSync(IPC_EVENTS.GET_WINDOW_ID),
// 发送日志 // 发送日志
logToMain: (logLevel: string, message: string) => ipcRenderer.send(IPC_EVENTS.LOG_TO_MAIN, logLevel, message), logger: {
debug: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_DEBUG, message, ...meta),
info: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_INFO, message, ...meta),
warn: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_WARN, message, ...meta),
error: (message: string, ...meta: any[]) => ipcRenderer.send(IPC_EVENTS.LOG_ERROR, message, ...meta),
}
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)

View File

@@ -0,0 +1,76 @@
<template>
<header class="title-bar flex items-start justify-between h-[40px]">
<div class="title-bar-main flex-auto">
<slot>{{ title ?? '' }}</slot>
</div>
<div class="title-bar-controls w-[168px] flex items-center justify-end text-tx-secondary">
<native-tooltip content="最小化">
<button v-show="isMinimizable" class="flex items-center justify-center cursor-pointer w-[40px] h-[40px]"
@click="minimizeWindow">
<iconify-icon icon="material-symbols:check-indeterminate-small" color="#ffffff" :width="btnSize"
:height="btnSize" />
</button>
</native-tooltip>
<native-tooltip :content="isMaximized ? '还原' : '最大化'">
<button v-show="isMaximizable" class="flex items-center justify-center cursor-pointer w-[40px] h-[40px]"
@click="maximizeWindow">
<iconify-icon icon="material-symbols:chrome-maximize-outline-sharp" color="#ffffff" :width="btnSize"
:height="btnSize" v-show="!isMaximized" />
<iconify-icon icon="material-symbols:chrome-restore-outline-sharp" color="#ffffff" :width="btnSize"
:height="btnSize" v-show="isMaximized" />
</button>
</native-tooltip>
<native-tooltip content="关闭">
<button v-show="isClosable" class="flex items-center justify-center cursor-pointer w-[40px] h-[40px]"
@click="handleClose">
<iconify-icon icon="material-symbols:close" color="#ffffff" :width="btnSize" :height="btnSize" />
</button>
</native-tooltip>
</div>
</header>
</template>
<script setup lang="ts">
import { Icon as IconifyIcon } from '@iconify/vue'
import { useWinManager } from '@hooks/useWinManager'
import NativeTooltip from '@components/NativeTooltip/index.vue'
interface HeaderBarProps {
title?: string;
isMaximizable?: boolean;
isMinimizable?: boolean;
isClosable?: boolean;
}
defineOptions({ name: 'HeaderBar' })
withDefaults(defineProps<HeaderBarProps>(), {
isMaximizable: true,
isMinimizable: true,
isClosable: true,
})
const emit = defineEmits(['close']);
// const { t } = useI18n();
const btnSize = 16;
const {
isMaximized,
closeWindow,
minimizeWindow,
maximizeWindow
} = useWinManager();
function handleClose() {
emit('close');
closeWindow();
}
</script>
<style scoped>
.title-bar {
background-color: rgba(255, 255, 255, 0.2);
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<template v-if="slots.default()[0].el">
<slot></slot>
</template>
<template v-else>
<span :title="content">
<slot></slot>
</span>
</template>
</template>
<script setup lang="ts">
import { logger } from '@utils/logger'
interface Props {
content: string;
}
defineOptions({ name: 'NativeTooltip' });
const props = defineProps<Props>();
const slots = defineSlots()
if (slots?.default?.().length > 1) {
logger.warn('NativeTooltip only support one slot.')
}
const updateTooltipContent = (content: string) => {
const defaultSlot = slots?.default?.();
if (defaultSlot) {
const slotElement = defaultSlot[0]?.el
if (slotElement && slotElement instanceof HTMLElement) {
slotElement.title = content;
}
}
}
onMounted(() => updateTooltipContent(props.content))
watch(() => props.content, (val: string) => updateTooltipContent(val));
</script>

View File

@@ -0,0 +1,31 @@
export function useWinManager() {
const isMaximized = ref(false)
function closeWindow() {
window.api.closeWindow();
}
function minimizeWindow() {
window.api.minimizeWindow();
}
function maximizeWindow() {
window.api.maximizeWindow();
}
onMounted(async () => {
await nextTick();
window.api.viewIsReady();
isMaximized.value = await window.api.isWindowMaximized();
window.api.onWindowMaximized((_isMaximized: boolean) => isMaximized.value = _isMaximized);
})
return {
isMaximized,
closeWindow,
minimizeWindow,
maximizeWindow
}
};
export default useWinManager;

View File

@@ -1,4 +1,4 @@
import { createApp } from "vue"; import { createApp, type Plugin } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import router from "./router"; import router from "./router";
import App from "./App.vue"; import App from "./App.vue";
@@ -10,6 +10,15 @@ import locale from 'element-plus/es/locale/lang/zh-cn'
import "./styles/index.css"; import "./styles/index.css";
import 'element-plus/dist/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 应用实例 // 创建 Vue 应用实例
const app = createApp(App); const app = createApp(App);
@@ -20,6 +29,7 @@ app.use(createPinia());
// 使用 Vue Router // 使用 Vue Router
app.use(router); app.use(router);
app.use(ElementPlus, { locale }) app.use(ElementPlus, { locale })
app.use(components)
// 挂载应用到 DOM // 挂载应用到 DOM
app.mount("#app"); app.mount("#app");

View File

@@ -0,0 +1,12 @@
export const logger = window.api.logger ?? console;
if (window.api.logger) {
console.debug = logger.debug;
console.log = logger.info;
console.info = logger.info;
console.warn = logger.warn;
console.error = logger.error;
}
export default logger;

View File

@@ -1,86 +1,92 @@
<template> <template>
<div class="h-screen box-border p-[8px] login-bg flex items-center justify-center"> <div class="h-screen login-bg flex flex-col">
<div class="w-[836px] h-full bg-white rounded-2xl p-[32px] flex flex-col"> <header-bar>
<div class="flex items-center"> <drag-region class="w-full" />
<img class="w-[48px] h-[48px]" src="@assets/images/login/blue_logo.png" /> </header-bar>
<span class="ml-auto text-[14px] text-gray-600">没有账号</span> <main class="box-border p-[8px] flex flex-auto items-center justify-center">
<button
class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none">注册</button>
</div>
<div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[108px]"> <div class="w-[836px] h-full bg-white rounded-2xl p-[32px] flex flex-col">
<img class="w-[80px] h-[80px] mb-[12px]" src="@assets/images/login/user_icon.png" /> <div class="flex items-center">
<div class="text-[24px] font-500 text-gray-800 line-height-[32px] mb-[4px]">登录</div> <img class="w-[48px] h-[48px]" src="@assets/images/login/blue_logo.png" />
<div class="text-[16px] text-gray-500 line-height-[24px]">24小时在岗从不打烊的数字员工</div>
</div>
<el-form class="w-[392px] ml-auto mr-auto" ref="formRef" :rules="rules" :model="form" label-position="top" <span class="ml-auto text-[14px] text-gray-600">没有账号</span>
@keyup.enter="onSubmit"> <button
<el-form-item prop="username"> class="bg-sky-50 rounded-[8px] text-[14px] text-sky-600 px-[12px] py-[6px] focus-visible:outline-none">注册</button>
<div class="text-[14px] text-gray-600">账号</div> </div>
<el-input v-model.trim="form.username" placeholder="请输入账号" clearable autocomplete="off">
<template #prefix>
<RiUser3Fill size="20px" color="#99A0AE" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<div class="text-[14px] text-gray-600">密码</div>
<el-input v-model.trim="form.password" placeholder="请输入密码" clearable autocomplete="off">
<template #prefix>
<RiKey2Fill size="20px" color="#99A0AE" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="code"> <div class="flex flex-col items-center justify-center mb-[24px] box-border pt-[108px]">
<span class="text-[14px] text-gray-600">验证码</span> <img class="w-[80px] h-[80px] mb-[12px]" src="@assets/images/login/user_icon.png" />
<el-input v-model.trim="form.code" placeholder="请输入验证码" clearable autocomplete="off"> <div class="text-[24px] font-500 text-gray-800 line-height-[32px] mb-[4px]">登录</div>
<template #suffix> <div class="text-[16px] text-gray-500 line-height-[24px]">24小时在岗从不打烊的数字员工</div>
<img class="w-[80px] h-[38px] cursor-pointer" :src="imgSrc" @click="getVerifyCode" /> </div>
</template>
</el-input>
</el-form-item>
<!-- 记住密码|忘记密码 --> <el-form class="w-[392px] ml-auto mr-auto" ref="formRef" :rules="rules" :model="form" label-position="top"
<div class="flex items-center justify-between mb-[24px] mt-[24px]"> @keyup.enter="onSubmit">
<div class="flex items-center gap-2 cursor-pointer"> <el-form-item prop="username">
<input type="checkbox" v-model="showPwd" class="w-[14px] h-[14px] rounded-[4px]" /> <div class="text-[14px] text-gray-600">账号</div>
<span class="text-[14px] text-gray-600">记住密码</span> <el-input v-model.trim="form.username" placeholder="请输入账号" clearable autocomplete="off">
<template #prefix>
<RiUser3Fill size="20px" color="#99A0AE" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<div class="text-[14px] text-gray-600">密码</div>
<el-input v-model.trim="form.password" placeholder="请输入密码" clearable autocomplete="off">
<template #prefix>
<RiKey2Fill size="20px" color="#99A0AE" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<span class="text-[14px] text-gray-600">验证码</span>
<el-input v-model.trim="form.code" placeholder="请输入验证码" clearable autocomplete="off">
<template #suffix>
<img class="w-[80px] h-[38px] cursor-pointer" :src="imgSrc" @click="getVerifyCode" />
</template>
</el-input>
</el-form-item>
<!-- 记住密码|忘记密码 -->
<div class="flex items-center justify-between mb-[24px] mt-[24px]">
<div class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="showPwd" class="w-[14px] h-[14px] rounded-[4px]" />
<span class="text-[14px] text-gray-600">记住密码</span>
</div>
<span class="text-[14px] text-sky-600">忘记密码</span>
</div> </div>
<span class="text-[14px] text-sky-600">忘记密码</span>
<!-- 登录按钮 -->
<button class="w-full py-2 bg-blue-600 text-white rounded-[8px] hover:bg-blue-700 disabled:bg-blue-300"
@click="onSubmit">
{{ loading ? '登录中' : '登录' }}
</button>
<!-- 同意协议 -->
<div class="flex items-center justify-center gap-2 mt-[24px]">
<input type="checkbox" v-model="isAgree" class="w-[14px] h-[14px] rounded-[4px]" />
<span class="text-[14px] text-gray-600">我已同意</span>
<span class="text-[14px] text-sky-600">使用协议</span>
<span class="text-[14px] text-gray-600"></span>
<span class="text-[14px] text-sky-600">隐私协议</span>
</div>
</el-form>
<!-- Copy Right -->
<div class="text-[14px] text-gray-500 text-center mt-auto">
© 2025 贵州智念科技服务有限公司 版权所有
</div> </div>
<!-- 登录按钮 -->
<button class="w-full py-2 bg-blue-600 text-white rounded-[8px] hover:bg-blue-700 disabled:bg-blue-300"
@click="onSubmit">
{{ loading ? '登录中' : '登录' }}
</button>
<!-- 同意协议 -->
<div class="flex items-center justify-center gap-2 mt-[24px]">
<input type="checkbox" v-model="isAgree" class="w-[14px] h-[14px] rounded-[4px]" />
<span class="text-[14px] text-gray-600">我已同意</span>
<span class="text-[14px] text-sky-600">使用协议</span>
<span class="text-[14px] text-gray-600"></span>
<span class="text-[14px] text-sky-600">隐私协议</span>
</div>
</el-form>
<!-- Copy Right -->
<div class="text-[14px] text-gray-500 text-center mt-auto">
© 2025 贵州智念科技服务有限公司 版权所有
</div> </div>
</div>
<img class="w-[570px]" src="@assets/images/login/logo.png" /> <img class="w-[570px]" src="@assets/images/login/logo.png" />
</main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue";
import { useRouter } from "vue-router";
import { authOauth2TokenUsingPost } from "@renderer/api"; import { authOauth2TokenUsingPost } from "@renderer/api";
import { RiUser3Fill, RiKey2Fill } from '@remixicon/vue' import { RiUser3Fill, RiKey2Fill } from '@remixicon/vue'
import { generateUUID } from "@utils/generateUUID"; import { generateUUID } from "@utils/generateUUID";

View File

@@ -34,9 +34,11 @@
"@utils/*": ["src/renderer/utils/*"], "@utils/*": ["src/renderer/utils/*"],
"@common/*": ["src/common/*"], "@common/*": ["src/common/*"],
"@modules/*": ["src/main/modules/*"], "@modules/*": ["src/main/modules/*"],
"@locales/*": ["locales/*"] "@locales/*": ["locales/*"],
"@hooks/*": ["src/renderer/hooks/*"],
"@components/*": ["src/renderer/components/*"],
}, },
"types": ["element-plus/global", "vue"] "types": []
}, },
"include": [ "include": [
"forge.env.d.ts", "forge.env.d.ts",

View File

@@ -25,6 +25,8 @@ export default defineConfig(async () => {
"@assets": resolve(__dirname, "./src/assets"), "@assets": resolve(__dirname, "./src/assets"),
'@common': resolve(__dirname, './src/common'), '@common': resolve(__dirname, './src/common'),
"@constant": resolve(__dirname, "./src/renderer/constant"), "@constant": resolve(__dirname, "./src/renderer/constant"),
"@components": resolve(__dirname, "./src/renderer/components"),
"@hooks": resolve(__dirname, "./src/renderer/hooks"),
"@store": resolve(__dirname, "./src/renderer/store"), "@store": resolve(__dirname, "./src/renderer/store"),
"@utils": resolve(__dirname, "./src/renderer/utils"), "@utils": resolve(__dirname, "./src/renderer/utils"),
"@shared": resolve(__dirname, "./src/renderer/shared"), "@shared": resolve(__dirname, "./src/renderer/shared"),