呼号端初版

main
cysamurai 3 months ago
parent 5a11975bd5
commit 80d82aaee4

@ -1,22 +1,22 @@
{
"sqltools.connections": [
{
"connectionTimeout": 15,
"mssqlOptions": {
"appName": "SQLTools",
"useUTC": true,
"encrypt": true,
"trustServerCertificate": false
},
"ssh": "Disabled",
"previewLimit": 50,
"server": "47.96.78.103",
"port": 1433,
"askForPassword": true,
"driver": "MSSQL",
"name": "QS aliyun",
"username": "sa",
"database": "QueuingSystem"
}
]
"sqltools.connections": [
{
"connectionTimeout": 15,
"mssqlOptions": {
"appName": "SQLTools",
"useUTC": true,
"encrypt": true,
"trustServerCertificate": false
},
"ssh": "Disabled",
"previewLimit": 50,
"server": "47.96.78.103",
"port": 1433,
"askForPassword": true,
"driver": "MSSQL",
"name": "QS aliyun",
"username": "sa",
"database": "QueuingSystem"
}
]
}

@ -4,6 +4,6 @@ root = true
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = auto
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

@ -1,4 +0,0 @@
node_modules
dist
out
.gitignore

@ -1,23 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'@electron-toolkit',
'@electron-toolkit/eslint-config-ts/eslint-recommended',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier'
],
rules: {
'vue/require-default-prop': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'vue/multi-word-component-names': 'off',
'prefer-const': 'false'
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
}

@ -2,4 +2,5 @@ node_modules
dist
out
.DS_Store
.eslintcache
*.log*

@ -1,3 +1,2 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true

@ -1,7 +0,0 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
tabWidth: 2
endOfLine: auto
trailingComma: es5

@ -1,46 +1,10 @@
<h1 align="center">electron-app</h1>
# call-client
<p align="center">An Electron application with Vue3 and TypeScript</p>
<p align="center">
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron" alt="electron-version">
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron-vite" alt="electron-vite-version" />
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/electron-builder" alt="electron-builder-version" />
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/vite" alt="vite-version" />
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/vue" alt="vue-version" />
<img src="https://img.shields.io/github/package-json/dependency-version/alex8088/electron-vite-boilerplate/dev/typescript" alt="typescript-version" />
</p>
<p align='center'>
<img src='./build/electron-vite-vue-ts.png'/>
</p>
## Features
- 💡 Optimize asset handling
- 🚀 Fast HMR for renderer processes
- 🔥 Hot reloading for main process and preload scripts
- 🔌 Easy to debug
- 🔒 Compile to v8 bytecode to protect source code
## Getting Started
Read [documentation](https://electron-vite.org/) for more details.
- [Configuring](https://electron-vite.org/config/)
- [Development](https://electron-vite.org/guide/dev.html)
- [Asset Handling](https://electron-vite.org/guide/assets.html)
- [HMR](https://electron-vite.org/guide/hmr.html) & [Hot Reloading](https://electron-vite.org/guide/hot-reloading.html)
- [Debugging](https://electron-vite.org/guide/debugging.html)
- [Source code protection](https://electron-vite.org/guide/source-code-protection.html)
- [Distribution](https://electron-vite.org/guide/distribution.html)
- [Troubleshooting](https://electron-vite.org/guide/troubleshooting.html)
You can also use the [create-electron](https://github.com/alex8088/quick-start/tree/master/packages/create-electron) tool to scaffold your project for other frameworks (e.g. `React`, `Svelte` or `Solid`).
An Electron application with Vue and TypeScript
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin)
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Project Setup
@ -67,10 +31,28 @@ $ npm run build:mac
# For Linux
$ npm run build:linux
# For Kylin Desktop OS (x64)
$ npm run build:kylin:x64
# For Kylin Desktop OS (ARM64)
$ npm run build:kylin:arm64
# For Kylin Desktop OS deb only (x64)
$ npm run build:kylin:x64:deb
# For Kylin Desktop OS deb only (ARM64)
$ npm run build:kylin:arm64:deb
```
## Examples
### Kylin Packaging Notes
- [electron-vite-bytecode-example](https://github.com/alex8088/electron-vite-bytecode-example), source code protection
- [electron-vite-decorator-example](https://github.com/alex8088/electron-vite-decorator-example), typescipt decorator
- [electron-vite-worker-example](https://github.com/alex8088/electron-vite-worker-example), worker and fork
- It is recommended to package directly on a Kylin machine with the same CPU architecture as the target machine.
- The build output is generated in `dist/`, including `.deb` and `.AppImage`.
- If you package on Windows, `.AppImage` may fail due to symlink permission and `.deb` may fail due to missing `fpm`. Packaging on Kylin/Linux avoids these issues.
- Install `.deb` with:
```bash
sudo dpkg -i dist/*.deb
sudo apt-get -f install -y
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

@ -0,0 +1,3 @@
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: call-client-updater

@ -1,18 +1,18 @@
appId: com.electron.app
productName: electron-app
productName: call-client
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: electron-app
executableName: call-client
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
@ -30,11 +30,12 @@ dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
- AppImage
executableName: call-client
maintainer: electronjs.org
category: Utility
artifactName: ${name}-${version}-${arch}.${ext}
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false

@ -1,20 +1,16 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin, bytecodePlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'electron-vite'
import { resolve } from 'path'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
preload: {
plugins: [externalizeDepsPlugin(), bytecodePlugin()]
},
main: {},
preload: {},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
}
'@renderer': resolve('src/renderer/src'),
},
},
plugins: [vue()]
}
plugins: [vue()],
},
})

@ -0,0 +1,42 @@
import { defineConfig } from 'eslint/config'
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
export default defineConfig(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
extraFileExtensions: ['.vue'],
parser: tseslint.parser,
},
},
},
{
files: ['**/*.{ts,mts,tsx,vue}'],
rules: {
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'vue/no-v-for-template-key': 'off',
'vue/block-lang': [
'error',
{
script: {
lang: 'ts',
},
},
],
},
},
eslintConfigPrettier,
)

File diff suppressed because it is too large Load Diff

@ -1,5 +1,5 @@
{
"name": "electron-app",
"name": "call-client",
"version": "1.0.0",
"description": "An Electron application with Vue and TypeScript",
"main": "./out/main/index.js",
@ -7,7 +7,7 @@
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts,.vue --fix",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
@ -18,35 +18,44 @@
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
"build:linux": "npm run build && electron-builder --linux",
"build:kylin:x64": "npm run build && electron-builder --linux deb AppImage --x64",
"build:kylin:arm64": "npm run build && electron-builder --linux deb AppImage --arm64",
"build:kylin:x64:deb": "npm run build && electron-builder --linux deb --x64",
"build:kylin:arm64:deb": "npm run build && electron-builder --linux deb --arm64"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@vicons/ionicons5": "^0.13.0",
"naive-ui": "^2.42.0",
"vue-router": "^4.5.1"
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.5",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"element-plus": "^2.13.2",
"vue-router": "^5.0.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@rushstack/eslint-patch": "^1.7.1",
"@types/node": "^18.19.9",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"electron": "^28.2.0",
"electron-builder": "^24.9.1",
"electron-vite": "^2.0.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"prettier": "^3.2.4",
"sass": "^1.90.0",
"sass-loader": "^16.0.5",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vue": "^3.4.15",
"vue-tsc": "^1.8.27"
"@electron-toolkit/eslint-config-prettier": "3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@types/debug": "^4.1.12",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.19.1",
"@types/web-bluetooth": "^0.0.20",
"@vicons/ionicons5": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.2",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.6.2",
"prettier": "^3.7.4",
"sass": "^1.97.3",
"sass-loader": "^16.0.6",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vue": "^3.5.25",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.6"
}
}

@ -1,9 +1,16 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import window from './window'
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, Menu } from 'electron'
import type { SessionState } from '../shared/types/session'
import { createLoginWindow, createMainWindow, createTicketWindow } from './window'
let sessionState: SessionState = {
empUid: null,
winUid: null,
queueToken: null,
}
let mainWindow: BrowserWindow | null = null
let loginWindow: BrowserWindow | null = null
let mainWindow: BrowserWindow | null = null
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
@ -12,7 +19,7 @@ app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
loginWindow = window.createLoginWindow()
loginWindow = createLoginWindow()
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
@ -24,7 +31,7 @@ app.whenReady().then(() => {
// IPC test
ipcMain.on('login-success', () => {
// 创建主窗口
mainWindow = window.createMainWindow()
mainWindow = createMainWindow()
// 关闭登录窗口
if (loginWindow) {
loginWindow.close()
@ -32,15 +39,117 @@ app.whenReady().then(() => {
}
})
// 窗口控制的IPC监听器
ipcMain.on('window-minimize', event => {
const window = BrowserWindow.fromWebContents(event.sender)
if (window) window.minimize()
})
ipcMain.on('window-close', event => {
const window = BrowserWindow.fromWebContents(event.sender)
if (window) window.close()
})
// 应用菜单IPC监听器
ipcMain.on('show-context-menu', event => {
console.log('main中show-context-menu')
const template = [
{
label: '办税员窗口',
click: () => {
event.sender.copy() // 执行复制
},
},
{
label: '票号列表',
click: () => {
if (mainWindow) {
createTicketWindow(mainWindow)
}
},
},
{ type: 'separator' },
{
label: '退出程序',
click: () => {
app.quit()
},
},
]
const menu = Menu.buildFromTemplate(template as Electron.MenuItemConstructorOptions[])
menu.popup()
})
// 暂停菜单IPC监听器
ipcMain.on('show-pause-menu', event => {
console.log('main中show-pause-menu')
const template = [
{
label: '午休',
click: () => {
event.sender.send('pause-reason-selected', '午休')
},
},
{
label: '休息一下',
click: () => {
event.sender.send('pause-reason-selected', '休息一下')
},
},
{
label: '整理资料',
click: () => {
event.sender.send('pause-reason-selected', '整理资料')
},
},
{
label: '其他',
click: () => {
event.sender.send('pause-reason-selected', '其他')
},
},
]
const menu = Menu.buildFromTemplate(template as Electron.MenuItemConstructorOptions[])
menu.popup()
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
loginWindow = window.createLoginWindow()
}
if (BrowserWindow.getAllWindows().length === 0) loginWindow = createLoginWindow()
})
})
// ✅ 安全的 IPC 接口:获取整个会话状态
ipcMain.handle('session:get', () => {
return sessionState
})
// ✅ 安全的 IPC 接口:设置 access token专用不暴露通用 setter
ipcMain.handle(
'session:setAccessToken',
(_event: IpcMainInvokeEvent, newSessionState: SessionState) => {
if (
typeof newSessionState.queueToken !== 'string' ||
newSessionState.queueToken.length > 2048
) {
throw new Error('Invalid token')
}
sessionState.queueToken = newSessionState.queueToken
sessionState.empUid = newSessionState.empUid
sessionState.winUid = newSessionState.winUid
console.log('[Session] Token updated')
return true
},
)
// ✅ 清除会话(登出)
ipcMain.handle('session:clear', () => {
sessionState = { empUid: null, winUid: null, queueToken: null }
console.log('[Session] Cleared')
return true
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
@ -50,15 +159,14 @@ app.on('window-all-closed', () => {
}
})
// In this file you can include the rest of your app"s specific main process
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
// 导出窗口实例和方法供其他模块使用
module.exports = {
getMainWindow: () => mainWindow,
getLoginWindow: () => loginWindow,
createMainWindow: () => {
mainWindow = window.createMainWindow()
mainWindow = createMainWindow()
return mainWindow
},
closeLoginWindow: () => {
@ -66,5 +174,5 @@ module.exports = {
loginWindow.close()
loginWindow = null
}
}
},
}

@ -1,27 +1,31 @@
import { BrowserWindow } from 'electron'
import { is } from '@electron-toolkit/utils'
import { join } from 'path'
import { BrowserWindow } from 'electron'
import path from 'node:path'
const isDev = process.env.NODE_ENV === 'development'
function createLoginWindow() {
export const createLoginWindow = (): BrowserWindow => {
const win = new BrowserWindow({
width: 500,
height: 440,
width: 480,
height: 600,
titleBarStyle: 'hidden',
transparent: true, // 透明窗口
frame: false, // 无边框窗口
resizable: false, // 不可调整大小
alwaysOnTop: true, // 保持在最上层
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
preload: join(__dirname, '../preload/index.js')
contextIsolation: true,
preload: path.join(__dirname, '../preload/index.js'),
},
// frame: false, // 无边框窗口
resizable: false, // 不可调整大小
autoHideMenuBar: true
autoHideMenuBar: true, // 隐藏菜单栏
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
win.loadFile(path.join(__dirname, '../renderer/index.html'))
}
if (isDev) {
@ -31,24 +35,28 @@ function createLoginWindow() {
return win
}
function createMainWindow() {
export const createMainWindow = (): BrowserWindow => {
const win = new BrowserWindow({
width: 500,
height: 100,
titleBarStyle: 'hidden',
transparent: true, // 透明窗口
frame: false, // 无边框窗口
resizable: false, // 不可调整大小
alwaysOnTop: true, // 保持在最上层
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
preload: join(__dirname, '../preload/index.js')
contextIsolation: true,
preload: path.join(__dirname, '../preload/index.js'),
},
resizable: false, // 不可调整大小
autoHideMenuBar: true,
show: false // 先不显示,等加载完成再显示
show: false, // 先不显示,等加载完成再显示
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/main`)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'), { hash: '#/main' })
win.loadFile(path.join(__dirname, '../renderer/index.html'), { hash: '#/main' })
}
win.on('ready-to-show', () => {
@ -62,7 +70,43 @@ function createMainWindow() {
return win
}
export default {
createLoginWindow,
createMainWindow
let ticketWindow: BrowserWindow | null = null
export const createTicketWindow = (parentWindow: BrowserWindow): void => {
if (ticketWindow) {
ticketWindow.focus()
return
}
ticketWindow = new BrowserWindow({
width: 800,
height: 600,
parent: parentWindow || undefined,
modal: false, // 如果设为 true 则为模态窗口
resizable: false, // 不可调整大小
show: false,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: true,
},
autoHideMenuBar: true, // 隐藏菜单栏
})
// 加载相同的应用,但可以通过路由参数区分
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
ticketWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/ticketList`)
} else {
ticketWindow.loadFile(path.join(__dirname, '../renderer/index.html'), {
hash: '#/ticketList',
})
}
ticketWindow.once('ready-to-show', () => {
ticketWindow?.show()
})
ticketWindow.on('closed', () => {
ticketWindow = null
})
}

@ -1,8 +1 @@
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}
/// <reference types="vite/client" />

@ -1,17 +1,64 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// import Store from 'electron-store'
import { console } from 'inspector'
import type { SessionState } from '../shared/types/session'
// Custom APIs for renderer
const api = {
onLoginSuccess: (callback) => ipcRenderer.on('login-success', callback)
onLoginSuccess: callback => ipcRenderer.on('login-success', callback),
}
let currentPauseCallback: ((event: Electron.IpcRendererEvent, reason: string) => void) | null = null
// const store = new Store()
contextBridge.exposeInMainWorld('winControl', {
// 窗口控制
windowMinimize: () => ipcRenderer.send('window-minimize'),
windowClose: () => ipcRenderer.send('window-close'),
loginSuccess: () => ipcRenderer.send('login-success'),
})
contextBridge.exposeInMainWorld('contextMenu', {
showContextMenu: options => {
ipcRenderer.send('show-context-menu', options)
},
})
contextBridge.exposeInMainWorld('pauseMenu', {
pauseReasonSelected: (callback: (reason: string) => void) => {
// 移除旧监听器(关键!)
if (currentPauseCallback) {
ipcRenderer.removeListener('pause-reason-selected', currentPauseCallback)
}
currentPauseCallback = (_event: Electron.IpcRendererEvent, reason: string) => callback(reason)
ipcRenderer.on('pause-reason-selected', currentPauseCallback)
},
showPauseMenu: options => {
ipcRenderer.send('show-pause-menu', options)
},
})
contextBridge.exposeInMainWorld('session', {
get: (): Promise<SessionState> => ipcRenderer.invoke('session:get'),
set: (newSessionState: SessionState): Promise<boolean> =>
ipcRenderer.invoke('session:setAccessToken', newSessionState),
clear: (): Promise<boolean> => ipcRenderer.invoke('session:clear'),
})
// contextBridge.exposeInMainWorld('store', {
// get: key => store.get(key),
// set: (key, val) => store.set(key, val),
// delete: key => store.delete(key),
// has: key => store.has(key),
// })
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)

@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://padapi.queuingsystem.cn http://192.168.1.10:8845;"
/>
</head>
<body>

@ -1,4 +1,5 @@
<script setup lang="ts"></script>
<script setup lang="ts">
</script>
<template>
<RouterView/>

@ -0,0 +1,27 @@
import type { CallRequest, CallResponse, PauseRequest, ReCallRequest } from '@renderer/types/action'
import type { UserRequest, UserResponse } from '@renderer/types/user'
import type { WindowResponse } from '@renderer/types/window'
import { http } from '@renderer/utils/service'
export const api = {
// 用户相关
user: {
login: (data: UserRequest) => http.post<UserResponse>('/auth/login', data),
},
// 窗口
window: {
list: () => http.get<WindowResponse>('/windows/list'),
},
action: {
call: (data: CallRequest) => http.post<CallResponse>('/call-terminal/call', data),
recall: (data: ReCallRequest) => http.post<CallResponse>('/call-terminal/recall', data),
abandon: (data: ReCallRequest) => http.post<CallResponse>('/call-terminal/abandon', data),
pause: (data: PauseRequest) => http.post<CallResponse>('/call-terminal/pause', data),
resume: (data: CallRequest) => http.post<CallResponse>('/call-terminal/resume', data),
start: (data: ReCallRequest) => http.post<CallResponse>('/call-terminal/start', data),
complete: (data: ReCallRequest) => http.post<CallResponse>('/call-terminal/complete', data),
evaluate: (data: ReCallRequest) => http.post<CallResponse>('/call-terminal/evaluate', data),
},
}

@ -8,6 +8,8 @@ body {
background-image: url('./wavy-lines.svg');
background-size: cover;
user-select: none;
padding: 0;
margin: 0;
}
code {

@ -1,8 +1,41 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
export {}
interface IWinControl {
windowMinimize: () => void
windowClose: () => void
loginSuccess: () => void
}
interface IContextMenu {
showContextMenu: () => void
}
interface IPauseMenu {
showPauseMenu: () => void
pauseReasonSelected: (callback: (reason: string) => void) => void
}
interface IStore {
get: (key: string) => unknown
set: (key: string, val: unknown) => void
delete: (key: string) => void
has: (key: string) => boolean
}
interface ISession {
get: () => SessionState
set: (SessionState) => boolean
clear: () => boolean
}
declare global {
interface Window {
winControl: IWinControl
contextMenu: IContextMenu
store: IStore
session: ISession
pauseMenu: IPauseMenu
}
}

@ -1,7 +1,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'
import router from './router'
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
createApp(App).use(router).mount('#app')
createApp(App).use(router).use(ElementPlus).mount('#app')

@ -1,10 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import Login from '@renderer/views/Login.vue'
import Main from '@renderer/views/Main.vue'
import TicketList from '@renderer/views/TicketList.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
export default createRouter({
history: createWebHashHistory(), // hash模式
routes: [
{ path: '/', component: Login },
{ path: '/login', component: Login },
{ path: '/main', component: Main }]
{ path: '/main', component: Main },
{ path: '/ticketList', component: TicketList },
],
})

@ -0,0 +1,39 @@
export type CallStatus = 'idle' | 'calling' | 'paused' | 'working' | 'evaluating' | 'transferring'
export interface ActionButton {
icon: unknown
label: string
action: string
}
// 呼叫
export interface CallRequest {
windowUid: number
empUid: number
}
export interface CallResponse {
action: string
success: boolean
message: string
windowUid: number
windowName: string
ticketNo: string
ticketStatus: number
ticketStatusText: string
ticketUid: number
}
// 重呼
export interface ReCallRequest {
windowUid: number
empUid: number
ticketUid: number
}
// 暂停
export interface PauseRequest {
windowUid: number
empUid: number
pauseReason: string
}

@ -0,0 +1,6 @@
// types/http.ts
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
}

@ -0,0 +1,26 @@
export interface UserRequest {
loginMode: string
clientType: string
username: string
password: string
hallRegNum: string
// tenantId: string
}
export interface UserResponse {
queueToken: string
refreshToken: string
tokenType: string
expiresIn: string
operatorProfile: UserInfo
}
export interface UserInfo {
empUid: number
name: string
username: string
avatar: string
tenantId: string
hallCode: string
hallName: string
}

@ -0,0 +1,9 @@
export interface WindowResponse {
windows: Window[]
}
export interface Window {
windowUid: number
windowCode: string
windowName: string
}

@ -0,0 +1,132 @@
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import type { SessionState } from '../../../shared/types/session'
const instance = axios.create({
// baseURL: import.meta.env.VITE_API_BASE_URL,
baseURL: 'http://192.168.1.10:8845/api/queue/caller',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
instance.interceptors.request.use(
async config => {
// const token = localStorage.getItem('token')
let token: string | null = null
try {
const sessionState: SessionState = await window.session.get()
token = sessionState.queueToken
} catch (error) {
console.error('Error retrieving token:', error)
}
if (token && !config.url?.includes('/login')) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error),
)
// 响应拦截器
instance.interceptors.response.use(
(response: AxiosResponse) => {
// 这里根据你的后端返回结构调整
const { data } = response
// console.log('响应数据:', data)
if (data.code !== 200) {
ElMessage.error(data.message || '请求失败')
// token 过期处理
if (data.code === 401) {
ElMessage.error('请求token过期请重新登录')
}
return Promise.reject(new Error(data.message || '请求失败'))
}
return data.data // 直接返回 data避免嵌套
},
error => {
// 处理 HTTP 状态码错误
if (error.response) {
switch (error.response.status) {
case 400:
ElMessage.error('请求错误')
break
case 401:
ElMessage.error('未授权,请重新登录')
break
case 403:
ElMessage.error('拒绝访问')
break
case 404:
ElMessage.error('请求地址不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
default:
ElMessage.error('请求失败')
}
} else if (error.request) {
// 请求未收到响应
ElMessage.error('网络连接异常')
} else {
// 请求配置错误
ElMessage.error(error.message)
}
return Promise.reject(error)
},
)
// 封装请求方法
export const http = {
// 原生请求
request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
return instance.request(config)
},
// GET 请求
get<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.get(url, { params, ...config })
},
// POST 请求
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.post(url, data, config)
},
// PUT 请求
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.put(url, data, config)
},
// DELETE 请求
delete<T = unknown>(url: string, params?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.delete(url, { params, ...config })
},
// PATCH 请求
patch<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return instance.patch(url, data, config)
},
// 文件上传
upload<T = unknown>(url: string, formData: FormData): Promise<T> {
return instance.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
}

@ -1,97 +1,593 @@
<script setup lang="ts">
const { ipcRenderer } = require('electron')
import { NRadioGroup, NSpace, NRadio, NInput, NButton } from 'naive-ui'
import { ref } from 'vue';
import { Close, Lock, Minus, User } from '@element-plus/icons-vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { SessionState } from '../../../shared/types/session'
import { api } from '../api'
let username
let password
let selectType = ref(null)
//
const username = ref('free8974')
const password = ref('123456')
const loginType = ref('NSFW') // 'platform' 'custom'
const isLoading = ref(false)
const formRef = ref()
const isLoginSuccessed = ref(false)
const selectedWin = ref('')
const loginType = [{value:0, label:'综合管理平台账号'}, {value:1, label:'自建账号'}].map((s) => {
s.value = s.value
return s
let options: Array<{ label: string; value: string }> = []
let sessionState: SessionState = {
empUid: null,
winUid: null,
queueToken: null,
}
//
const handleMinimize = (): void => {
console.log('handleMinimize')
window.winControl.windowMinimize()
}
const handleClose = (): void => {
console.log('handleClose')
window.winControl.windowClose()
}
// -webkit-app-region: drag
//
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 5, message: '密码长度不能少于5位', trigger: 'blur' },
],
}
//
import { ElForm, ElMessage } from 'element-plus'
const isFormValid = computed(() => {
return username.value.trim() !== '' && password.value !== ''
})
const isWindowSelected = computed(() => {
return selectedWin.value !== ''
})
const handleLogin = () => {
//
ipcRenderer.send('login-success')
const handleLogin = async (): Promise<void> => {
if (!isFormValid.value) {
ElMessage.warning('请输入用户名和密码')
return
}
formRef.value?.validate(async valid => {
if (valid) {
isLoading.value = true
try {
const loginRes = await api.user.login({
loginMode: loginType.value,
clientType: 'CALLER',
username: username.value,
password: password.value,
hallRegNum: '',
// tenantId: '1',
})
if (loginRes && loginRes.queueToken) {
// localStorage.setItem('token', loginRes.queueToken)
console.log('loginRes', loginRes)
sessionState.queueToken = loginRes.queueToken
sessionState.empUid = loginRes.operatorProfile.empUid
sessionState.winUid = 0
await window.session.set(sessionState) //
//
await new Promise(resolve => setTimeout(resolve, 10))
const winList = await api.window.list()
options = winList.windows.map(win => {
return {
label: win.windowName,
value: win.windowUid.toString(),
}
})
//
isLoginSuccessed.value = true
isLoading.value = false
}
} catch (error) {
console.log('登录失败', error)
isLoading.value = false
}
//
// setTimeout(() => {
// //
// // ipcRenderer.send('login-success')
// // window.winControl.loginSuccess()
// isLoginSuccessed.value = true
// isLoading.value = false
// //
// // ElNotification({
// // title: '',
// // message: '',
// // type: 'success',
// // position: 'bottom-right',
// // })
// }, 1500)
}
})
}
const handleWindowLogin = async (): Promise<void> => {
console.log('handleWindowLogin')
// console.log('selectedWin', selectedWin.value)
sessionState.winUid = parseInt(selectedWin.value)
await window.session.set(sessionState)
window.winControl.loginSuccess()
}
//
const handleKeyPress = (event: KeyboardEvent): void => {
if (event.key === 'Enter' && isFormValid.value) {
handleLogin()
}
}
//
const handleInputFocus = (event: Event): void => {
const target = event.target as HTMLInputElement
if (target) {
target.select()
}
}
onMounted(() => {
//
window.addEventListener('keydown', handleKeyPress)
})
onUnmounted(() => {
//
window.removeEventListener('keydown', handleKeyPress)
})
</script>
<template>
<div class="login-bg">
<span class="login-h1">紫云智慧大厅</span>
<span class="login-h2">呼叫客户端</span>
<div class="radio-div">
<NRadioGroup v-model:value="selectType">
<NSpace>
<NRadio v-for="type in loginType" :key="type.value" :value="type.value">{{ type.label }}</NRadio>
</NSpace>
</NRadioGroup>
<div class="login-container" @keyup.enter="handleKeyPress">
<!-- 背景装饰 -->
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<div class="input-div">
<span>账号</span>
<n-input v-model:value="username" type="text" size="small" placeholder="" />
<!-- 标题窗口操作区域 -->
<div class="login-header" style="-webkit-app-region: drag">
<a class="control-button" @click="handleMinimize">
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</a>
<a class="control-button" @click="handleClose">
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</a>
</div>
<div class="input-div">
<span>密码</span>
<n-input v-model:value="password" type="text" size="small" placeholder="" />
<!-- 主登录区域 -->
<div class="login-main">
<!-- Logo和标题 -->
<div class="header-section" style="-webkit-app-region: drag">
<div class="app-info">
<h1 class="app-title">紫云智慧大厅</h1>
<h2 class="app-subtitle">呼叫客户端系统</h2>
</div>
</div>
<!-- 登录表单 -->
<div class="form-section">
<div class="form-wrapper">
<!-- 用户名密码界面 -->
<div v-show="!isLoginSuccessed" class="user-form">
<!-- 登录头部 -->
<div class="form-header">
<p class="form-subtitle">请选择登录方式并输入账号信息</p>
</div>
<!-- 登录类型选择 -->
<div class="type-selector">
<el-radio-group v-model="loginType" size="large">
<el-radio-button value="NSFW"> 综合管理平台账号 </el-radio-button>
<el-radio-button value="QUEUE"> 自建账号 </el-radio-button>
</el-radio-group>
</div>
<!-- 登录表单 -->
<el-form
ref="formRef"
:model="{ username, password }"
:rules="rules"
class="login-form"
label-position="top"
@submit.prevent
>
<el-form-item label="账号" prop="username">
<el-input
v-model="username"
:prefix-icon="User"
placeholder="请输入用户名/手机号"
size="large"
@focus="handleInputFocus"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="password"
:prefix-icon="Lock"
type="password"
placeholder="请输入登录密码"
size="large"
show-password
@focus="handleInputFocus"
/>
</el-form-item>
<!-- 登录按钮 -->
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isFormValid"
class="login-button"
@click="handleLogin"
>
{{ isLoading ? '登录中...' : '立即登录' }}
</el-button>
</div>
</el-form>
</div>
<!-- 窗口选择界面 -->
<div v-show="isLoginSuccessed" class="window-form">
<div class="form-header">
<p class="form-subtitle">请选择登录窗口</p>
</div>
<el-form>
<el-form-item style="margin: 80px 0px">
<el-select v-model="selectedWin" placeholder="请选择登录窗口" size="large">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isWindowSelected"
class="login-button"
@click="handleWindowLogin"
>
{{ isLoading ? '登录中...' : '立即登录' }}
</el-button>
</div>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="login-button"
@click="isLoginSuccessed = false"
>
{{ '返回' }}
</el-button>
</div>
</el-form>
</div>
<!-- 版本信息 -->
<div class="version-info">
<div class="version">版本号V0.1.0</div>
<div class="copyright">© 2023 紫云科技 版权所有</div>
</div>
</div>
</div>
</div>
<div style="height: 30px;"></div>
<NButton type="info" size="large" @click="handleLogin">
<span class="btn-span">{{ '登录' }}</span>
</NButton>
</div>
</template>
<style lang="scss">
.login-bg{
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
background-color: #E0FFFF;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
// justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
position: relative;
padding: 0px;
margin: 0px;
border-radius: 5px;
}
.login-h1{
color: #000;
font-weight: 400;
font-size: 26px;
align-items: center;
.background-elements {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
}
&.circle-2 {
width: 200px;
height: 200px;
bottom: -80px;
right: -80px;
}
}
}
.login-header {
height: 32px;
width: 100vw;
backdrop-filter: blur(10px);
display: flex;
justify-content: right;
align-items: center;
color: white;
position: relative;
z-index: 1000;
.login-h2{
color: #000;
font-weight: 600;
font-size: 40px;
.control-button {
width: 32px;
height: 32px;
min-width: 32px;
padding: 0 10px;
display: flex;
align-items: center;
margin-bottom: 10px;
letter-spacing: 10px;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
.control-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
}
.radio-div{
padding: 5px 0;
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.input-div{
display: flex;
flex-direction: row;
padding: 10px 0;
.login-main {
width: 100vw;
max-width: 420px;
z-index: 1;
}
.header-section {
text-align: center;
margin-bottom: 30px;
.app-title {
font-size: 28px;
font-weight: 700;
color: white;
margin: 0 0 8px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
span{
color: #000;
font-weight: 400;
.app-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
font-weight: 500;
}
}
.form-section {
.form-wrapper {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 5px;
padding: 30px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
}
.form-header {
text-align: center;
margin-bottom: 25px;
.form-subtitle {
color: #666;
font-size: 13px;
margin: 0;
}
}
.type-selector {
margin-bottom: 25px;
:deep(.el-radio-group) {
width: 100%;
.el-radio-button {
flex: 1;
.el-radio-button__inner {
width: 100%;
padding: 12px 15px;
font-size: 14px;
border: 1px solid #dcdfe6;
&:hover {
color: #667eea;
}
}
&.is-active {
.el-radio-button__inner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
box-shadow: none;
}
}
}
}
}
.login-form {
:deep(.el-form-item) {
margin-bottom: 25px;
.el-form-item__label {
font-weight: 500;
color: #333;
padding-bottom: 8px;
font-size: 14px;
}
.el-input__wrapper {
padding: 0 15px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
&.is-focus {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
}
}
}
}
.form-actions {
.login-button {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 500;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
transition: all 0.3s ease;
margin-top: 10px;
&:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
&:active:not(.is-disabled) {
transform: translateY(0);
}
}
}
.version-info {
margin-top: 10px;
text-align: center;
color: #999;
font-size: 12px;
padding-top: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
.version {
margin-bottom: 5px;
}
.copyright {
opacity: 0.8;
}
}
//
@media (max-width: 480px) {
.login-container {
// padding: 15px;
}
.form-wrapper {
padding: 25px 20px !important;
}
.header-section {
.app-title {
font-size: 24px;
}
.app-subtitle {
font-size: 14px;
}
}
.form-header {
.form-title {
font-size: 18px;
width: 80px;
}
}
.btn-span{
font-size: 18px;
font-weight: 600;
padding: 0 60px;
letter-spacing: 20px;
.type-selector :deep(.el-radio-button__inner) {
padding: 10px 12px !important;
font-size: 13px !important;
}
}
@media (max-height: 600px) {
.login-main {
max-width: 380px;
}
.header-section {
margin-bottom: 10px;
.app-title {
font-size: 22px;
}
}
.form-wrapper {
padding: 25px !important;
}
.form-header {
margin-bottom: 15px;
}
.type-selector {
margin-bottom: 15px;
}
.login-form :deep(.el-form-item) {
margin-bottom: 18px;
}
}
</style>

@ -1,57 +1,251 @@
<script setup lang="ts">
import { NButton, NIcon } from 'naive-ui'
import { CallOutline, PlayOutline, SwapHorizontal, PauseOutline, HappyOutline } from '@vicons/ionicons5'
import { Menu, Phone, Star, Switch, VideoPause, VideoPlay } from '@element-plus/icons-vue'
import { SessionState } from 'src/shared/types/session'
import { computed, onMounted, ref } from 'vue'
import { api } from '../api'
import type { ActionButton } from '../types/action'
const iconColor = '#98FB98'
const sessionState = ref<SessionState>({
empUid: -1,
winUid: -1,
queueToken: '',
})
const textColor = ref('#99ccff')
const iconColor = ref('#dcdfe6')
const message = ref('欢迎使用紫云呼叫终端')
// :idle | :calling | :paused | :working | :evaluating | transferring
const callStatus = ref('idle')
const callBtnText = ref('呼叫')
const pauseBtnText = ref('暂停')
const callingTkt = ref(-1)
// buttons computed callStatus pauseBtnText
const buttons = computed(() => [
// { icon: Menu, label: '', color: iconColor, action: 'showMenu' }, //
{
icon: Phone,
label: callBtnText.value,
action: 'call',
enabled: !['connected', 'paused', 'working', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
{
icon: VideoPlay,
label: '开始',
action: 'start',
enabled: !['idle', 'paused', 'working', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
{
icon: Switch,
label: '转移',
action: 'transfer',
enabled: !['idle', 'calling', 'paused', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
{
icon: VideoPause,
label: pauseBtnText.value,
action: 'pause',
enabled: !['calling', 'calling', 'working', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
{
icon: Star,
label: '评价',
action: 'evaluate',
enabled: !['idle', 'calling', 'paused', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
])
//
const openMoreMenu = (): void => {
console.log('showContextMenu')
window.contextMenu.showContextMenu()
}
// / action
const callAction = async (): Promise<void> => {
//
if (callStatus.value === 'calling' && callingTkt.value > 0) {
try {
const res = await api.action.recall({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
})
updateLog(`已重呼:${res.ticketNo},请勿重复点击!`)
return
} catch (error) {
console.log('error', error)
}
}
try {
//
const res = await api.action.call({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
})
//
if (res.success === true) {
console.log('call success')
callStatus.value = 'calling'
callBtnText.value = '重呼'
callingTkt.value = res.ticketUid
updateLog(`正在呼叫:${res.ticketNo}`)
} else {
//
updateLog(res.message)
}
} catch (error) {
console.log('error', error)
}
}
// action
const startAction = async (): Promise<void> => {
try {
const res = await api.action.start({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
})
if (res.success) {
callStatus.value = 'working'
updateLog(`正在办理:${res.ticketNo}`)
}
} catch (error) {
console.log('error', error)
}
}
// action
const evaluateAction = async (): Promise<void> => {
try {
const res = await api.action.evaluate({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
})
if (res.success) {
callStatus.value = 'evaluating'
callBtnText.value = '呼叫'
}
} catch (error) {
console.log('error', error)
}
}
// action
const pauseAction = async (): Promise<void> => {
try {
if (callStatus.value !== 'paused') {
//
window.pauseMenu.showPauseMenu()
//
window.pauseMenu.pauseReasonSelected(async (action: string) => {
console.log('pauseReasonSelected', action)
const res = await api.action.pause({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
pauseReason: action,
})
if (res.success) {
callStatus.value = 'paused'
pauseBtnText.value = '恢复'
updateLog(`暂停中,原因:${action}`)
}
})
} else {
const res = await api.action.resume({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
})
if (res.success) {
callStatus.value = 'idle'
pauseBtnText.value = '暂停'
}
}
} catch (error) {
console.log('error', error)
}
}
//
const handleButtonClick = (button: ActionButton): void => {
switch (button.action) {
case 'call':
callAction()
break
case 'pause':
pauseAction()
break
case 'transfer':
console.log('transfer')
break
case 'start':
startAction()
break
case 'evaluate':
evaluateAction()
break
default:
break
}
}
//
const updateLog = (log: string): void => {
message.value = log
}
onMounted(async () => {
sessionState.value = await window.session.get()
})
</script>
<template>
<div class="main-bg">
<div class="btn-div">
<n-button secondary strong size="small">
<template #icon>
<NIcon :color="iconColor">
<CallOutline />
</NIcon>
</template>
<span class="btn-span" :style="{ color: iconColor }">呼叫</span>
</n-button>
<n-button secondary strong size="small">
<template #icon>
<NIcon :color="iconColor">
<PlayOutline />
</NIcon>
</template>
<span class="btn-span" :style="{ color: iconColor }">开始</span>
</n-button>
<n-button secondary strong size="small">
<template #icon>
<NIcon :color="iconColor">
<SwapHorizontal />
</NIcon>
</template>
<span class="btn-span" :style="{ color: iconColor }">转移</span>
</n-button>
<n-button secondary strong size="small">
<template #icon>
<NIcon :color="iconColor">
<PauseOutline />
</NIcon>
</template>
<span class="btn-span" :style="{ color: iconColor }">暂停</span>
</n-button>
<n-button secondary strong size="small">
<template #icon>
<NIcon :color="iconColor">
<HappyOutline />
</NIcon>
</template>
<span class="btn-span" :style="{ color: iconColor }">评价</span>
</n-button>
<a class="action-button" @click="openMoreMenu()">
<el-icon class="button-icon">
<component :is="Menu" />
</el-icon>
</a>
<div class="divider-vertical"></div>
<a
v-for="(btn, index) in buttons"
:key="index"
class="action-button"
:class="{ disabled: !btn.enabled }"
:style="{
color: !btn.enabled ? '#ccc' : textColor,
padding: '5px',
cursor: !btn.enabled ? 'not-allowed' : 'pointer',
opacity: !btn.enabled ? '0.5' : '1',
}"
link
@click="handleButtonClick(btn)"
>
<el-icon class="button-icon" :style="{ color: !btn.enabled ? '#ccc' : iconColor }">
<component :is="btn.icon" />
</el-icon>
<span class="button-label">{{ btn.label }}</span>
</a>
</div>
<div class="divider-horizontal"></div>
<div class="log-div"></div>
<div class="log-div" style="-webkit-app-region: drag">
<span class="log-span">{{ message }}</span>
</div>
</div>
</template>
@ -66,31 +260,67 @@ const iconColor = '#98FB98'
align-items: center;
.btn-div {
width: 100%;
height: 50%;
width: 100vw;
height: 48%;
padding: 0px 5px;
display: flex;
flex-direction: row;
justify-content: center;
justify-content: left;
align-items: center;
.btn-span{
// color: #98FB98;
font-weight: 500;
.action-button {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
// border: none !important;
// background: transparent !important;
// transition: background-color 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.button-icon {
margin: 0px 2px;
font-size: 18px;
}
.button-label {
margin: 0px 2px;
font-size: 18px;
}
}
.action-button.disabled {
pointer-events: none;
}
}
.log-div {
width: 100%;
height: 50%;
height: 48%;
padding: 0px 5px;
display: flex;
align-items: center;
.log-span {
font-size: 18px;
color: #99ccff;
}
}
.divider-horizontal {
height: 0.5px;
width: 100%;
margin: 5px 0;
background-color: #C1C1C1;
background-color: #c1c1c1;
}
.divider-vertical {}
.divider-vertical {
width: 0.5px;
height: 70%;
margin: 0px 5px;
background-color: #c1c1c1;
}
}
</style>

@ -0,0 +1,278 @@
<template>
<div class="table-container">
<!-- 表格区域 -->
<el-table
v-loading="loading"
:data="paginatedData"
:height="tableHeight"
stripe
border
style="width: 100%"
:default-sort="{ prop: sortField, order: sortOrder }"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="60" sortable="custom" />
<el-table-column
prop="tktNum"
label="票号"
width="100"
sortable="custom"
show-overflow-tooltip
/>
<el-table-column
prop="startTime"
label="开始时间"
min-width="180"
sortable="custom"
show-overflow-tooltip
/>
<el-table-column
prop="endTime"
label="结束时间"
width="150"
sortable="custom"
:filters="departmentFilters"
:filter-method="filterDepartment"
/>
<el-table-column prop="status" label="票号状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="salary" label="操作" width="120" sortable="custom" align="right">
<template #default="{ row }"> ¥{{ row.salary.toLocaleString() }} </template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<div class="pagination-wrapper">
<el-config-provider :locale="zhCn">
<el-pagination
:current-page="currentPage"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalItems"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</el-config-provider>
</div>
<!-- 统计信息 -->
<div v-if="!loading" class="stats-info">
显示第 {{ startIndex }} 到第 {{ endIndex }} 条记录 {{ totalItems }}
</div>
</div>
</template>
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { computed, onMounted, ref } from 'vue'
//
interface Tkt {
id: number
tktNum: string
startTime: string
endTime: string
status: string
}
//
const generateMockData = (count: number): Tkt[] => {
const tktNum = ['A000', 'B000', 'C000', 'D000', 'E000', 'F000', 'G000', 'H000', 'I000', 'J000']
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
tktNum: tktNum[index % tktNum.length] + (Math.floor(index / tktNum.length) + 1),
startTime: '12:00:00',
endTime: '12:00:00',
status: '已完成',
}))
}
//
const loading = ref(false)
const allData = ref<Tkt[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const sortField = ref<string>('id')
const sortOrder = ref<'ascending' | 'descending'>('ascending')
const departmentFilter = ref<string[]>([])
//
const departmentFilters = computed(() => {
const departments = [...new Set(allData.value.map(item => item.status))]
return departments.map(dept => ({ text: dept, value: dept }))
})
// 800x600
const tableHeight = ref(400)
//
const paginatedData = computed(() => {
let filtered = [...allData.value]
//
// if (departmentFilter.value.length > 0) {
// filtered = filtered.filter(item => departmentFilter.value.includes(item.department))
// }
//
filtered.sort((a, b) => {
const field = sortField.value as keyof Tkt
let aVal = a[field]
let bVal = b[field]
//
// if (field === 'joinDate') {
// aVal = new Date(aVal).getTime() as string | number
// bVal = new Date(bVal).getTime() as string | number
// }
if (sortOrder.value === 'ascending') {
return aVal > bVal ? 1 : -1
} else {
return aVal < bVal ? 1 : -1
}
})
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filtered.slice(start, end)
})
//
const totalItems = computed(() => {
if (departmentFilter.value.length > 0) {
return allData.value.filter(item => departmentFilter.value.includes(item.status)).length
}
return allData.value.length
})
//
const startIndex = computed(() => {
if (totalItems.value === 0) return 0
return (currentPage.value - 1) * pageSize.value + 1
})
const endIndex = computed(() => {
return Math.min(currentPage.value * pageSize.value, totalItems.value)
})
//
const handleSortChange = (sort: {
prop: string
order: 'ascending' | 'descending' | null
}): void => {
if (sort.prop && sort.order) {
sortField.value = sort.prop
sortOrder.value = sort.order
} else {
//
sortField.value = 'id'
sortOrder.value = 'ascending'
}
}
//
const filterDepartment = (value: string, row: Tkt): boolean => {
return row.status === value
}
//
const handleSizeChange = (size: number): void => {
pageSize.value = size
//
const maxPage = Math.ceil(totalItems.value / size)
if (currentPage.value > maxPage) {
currentPage.value = maxPage
}
}
//
const handleCurrentChange = (page: number): void => {
currentPage.value = page
}
// API
const refreshData = async (): Promise<void> => {
loading.value = true
try {
//
await new Promise(resolve => setTimeout(resolve, 500))
allData.value = generateMockData(158) // 158
} finally {
loading.value = false
}
}
//
onMounted(() => {
refreshData()
//
const updateTableHeight = (): void => {
// 800x600
tableHeight.value = window.innerHeight - 180
}
updateTableHeight()
window.addEventListener('resize', updateTableHeight)
return () => {
window.removeEventListener('resize', updateTableHeight)
}
})
</script>
<style scoped>
.table-container {
height: 100vh;
width: 100vw;
display: flex;
background-color: #fff;
flex-direction: column;
/* padding: 16px; */
box-sizing: border-box;
}
.pagination-wrapper {
margin-top: 16px;
padding: 0px 16px;
display: flex;
justify-content: flex-end;
}
.stats-info {
margin: 4px 0px;
padding: 0px 16px;
font-size: 12px;
color: #909399;
text-align: right;
}
:deep(.el-table) {
flex: 1;
}
:deep(.el-table__header) {
background-color: #f5f7fa;
}
:deep(.el-pagination) {
padding: 0;
}
</style>

@ -0,0 +1,5 @@
export interface SessionState {
empUid: number | null
winUid: number | null
queueToken: string | null
}

@ -1,4 +1,5 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
}

@ -1,6 +1,6 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*","src/shared/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]

@ -4,8 +4,9 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.vue",
"src/preload/*.d.ts"
],
"src/preload/*.d.ts",
"src/shared/**/*"
, "src/shared/types/session.ts" ],
"compilerOptions": {
"composite": true,
"baseUrl": ".",

File diff suppressed because it is too large Load Diff

@ -0,0 +1,8 @@
{
"devDependencies": {
"@types/cacheable-request": "^6.0.3",
"@types/estree": "^1.0.8",
"@types/http-cache-semantics": "^4.2.0",
"@types/keyv": "^3.1.4"
}
}

@ -0,0 +1,121 @@
# 接口列表与 CURL 验证紫云HES对接
## 1. 基础信息
- 服务基址(直连):`http://<HES-host>:8845`
- 当前项目路径前缀:`/api/queue/**`
- 统一返回结构:`{"code":200|500|401,...,"msg":"...","data":...}`
- 全链路追踪:请求传入 `traceId`,响应原样回传;`traceId` 放置层级(顶层或 `map`)待定稿。
## 2. 公网统一入口规范
本项目部署形态:`HES(pad-api)` 仅在内网
### 2.1 调用模式说明
- 外部调用方仅访问公网:`POST /xxx/public`
- 公网网关根据 `tag` 映射到内网 `HES(pad-api)` 具体接口
- `map` 使用结构化键承载参数:`head`(请求元信息)、`path`、`query`、`body`。
### 2.2 公共请求报文(建议定稿)
```json
{
"tag": "pad.auth",
"map": {
"traceId": "",
"head": {},
"path": "",
"query": {},
"body": {}
}
}
```
> 说明:
> - `tag` 作为路由标识,网关按 `tag` 映射到固定内网接口(`method + path`)。
> - `map.head` 放请求元信息,`map.path` 使用字符串承载子路径(如 `/login`、`/main``map.query/body` 分别承载查询参数、请求体参数GET 接口建议 `body` 为空POST/PUT/PATCH 业务参数放 `body`
> - `traceId` 用于全链路追踪,建议响应报文原样回传;`traceId` 放置层级(顶层或 `map`)待定稿。
| 字段 | 位置 | 必填 | 说明 |
|---|---|---|---|
| tag | 顶层 | 是 | 路由标签,如 `pad.auth` |
| traceId | 顶层或map待定 | 是 | 链路追踪号,响应需原样返回 |
| head | map | 否 | 请求元信息(如 `method`、`contentType`、`authorization` |
| path | map | 否 | 子路径字符串(如 `/login` |
| query | map | 否 | 查询参数对象 |
| body | map | 否 | 请求体对象 |
### 2.3 公共响应报文(建议定稿)
```json
{
"code": 200,
"msg": "OK",
"traceId": "",
"data": {}
}
```
### 2.4 关键约束
- 走公网统一入口时:统一包 `tag/map`
- `tag` 为对外契约,不随内部路径调整而频繁变更
- `traceId` 放置层级(顶层或 `map`)待与第三方确认后定稿
## 3. PAD 统一入口路由约定
> 对外统一入口:`POST /xxx/public`
> 路由规则:后端按 `tag + map.path` 进行函数转发;`map.head.method` 用于参数校验与审计。
| Tag | map.path示例 | 说明 |
|---|---|---|
| `pad.auth` | `/login` | 认证相关 |
| `pad.menu` | `/main` | 菜单相关 |
| `pad.appointment` | `/time-slots` | 预约相关 |
| `pad.hallSystem` | `/list` | 大厅配置 |
| `pad.business` | `/enabled` | 业务类型 |
| `pad.ticket` | `/list` | 票号相关 |
| `pad.window` | `/list` | 窗口相关 |
| `pad.print` | `/printers` | 打印相关 |
| `pad.callTerminal` | `/call` | 呼号终端动作 |
> 说明:模块内新增接口时,保持 `tag` 不变,仅新增对应 `map.path` 映射即可。
## 4. CURL 验证模板(公网统一入口)
> 对外联调统一使用公网入口:`POST http://<gateway-host>/xxx/public`
### 4.1 登录样例pad.auth -> /api/queue/pad/auth/login
```bash
curl -X POST "http://<gateway-host>/xxx/public" \
-H "Content-Type: application/json" \
-d "{
\"tag\":\"pad.auth\",
\"map\":{
\"traceId\":\"202603061430001234\",
\"head\":{\"method\":\"POST\",\"contentType\":\"application/json\"},
\"path\":\"/login\",
\"query\":{},
\"body\":{\"loginMode\":\"QUEUE\",\"clientType\":\"PAD\",\"username\":\"demo\",\"password\":\"123456\"}
}
}"
```
### 4.2 菜单样例pad.menu -> /api/queue/pad/menu/main
```bash
curl -X POST "http://<gateway-host>/xxx/public" \
-H "Content-Type: application/json" \
-d "{
\"tag\":\"pad.menu\",
\"map\":{
\"traceId\":\"202603061430001235\",
\"head\":{\"method\":\"GET\",\"contentType\":\"application/json\"},
\"path\":\"/main\",
\"query\":{},
\"body\":{}
}
}"
```
## 5 错误码pad-api HES
| code | 名称 | 说明 |
|---|---|---|
| 200 | SUCCESS | 操作成功 |
| 400 | BAD_REQUEST | 请求参数错误 |
| 401 | UNAUTHORIZED | 未授权访问 |
| 403 | FORBIDDEN | 权限不足 |
| 404 | NOT_FOUND | 资源不存在 |
| 500 | INTERNAL_ERROR | 服务器内部错误 |

@ -1,51 +1,53 @@
// api/index.js
import request from '@/utils/request.js'
// 登录接口
// 登录接口pad.auth -> /login不携带 token不调用 /api/sign
export const userLogin = (loginData) => {
return request({
url: '/v1/auth/login',
method: 'POST',
data: loginData,
withToken: false // 登录请求本身通常不需要Token
})
return request({
tag: 'pad.auth',
path: '/login',
method: 'POST',
body: loginData,
withToken: false,
skipSign: true
})
}
// 今日进厅数据
// 今日进厅数据(按大厅相关归到 pad.hallSystem可根据后端最终路由调整
export const getdailyEntry = (params) => {
return request({
url: '/v1/daily-entry/list',
method: 'get',
data: params,
withToken: true // 登录请求本身通常不需要Token
})
return request({
tag: 'pad.hallSystem',
path: '/list',
method: 'GET',
query: params
})
}
// 获取今日预约
// 获取今日预约pad.appointment -> /time-slots
export const getAppointmentToday = (params) => {
return request({
url: '/v1/appointment/today',
method: 'get',
data: params,
withToken: true // 登录请求本身通常不需要Token
})
return request({
tag: 'pad.appointment',
path: '/time-slots',
method: 'GET',
query: params
})
}
// 票号管理
// 票号管理pad.ticket -> /list
export const getTicket = (params) => {
return request({
url: '/v1/ticket/today_unified_tickets',
method: 'get',
data: params,
withToken: true // 登录请求本身通常不需要Token
})
return request({
tag: 'pad.ticket',
path: '/list',
method: 'GET',
query: params
})
}
// 获取业务列表
export const getBizList = () => {
return request({
url: '/v1/business',
method: 'get',
withToken: true // 登录请求本身通常不需要Token
})
// 获取业务列表pad.business -> /enabled
export const getBizList = (params = {}) => {
return request({
tag: 'pad.business',
path: '/enabled',
method: 'GET',
query: params
})
}

@ -0,0 +1,31 @@
import request from '@/utils/request.js'
// 获取首页概览数据
export const getOverview = (params = {}) => {
return request({
tag: 'pad.hallSystem',
path: '/overview/metrics',
method: 'GET',
query: params
})
}
// 获取大厅system表数据
export const getSystemList = (params = {}) => {
return request({
tag: 'pad.hallSystem',
path: '/list',
method: 'GET',
query: params
})
}
// 修改大厅system表数据
export const updateSysValue = (params = {}) => {
return request({
tag: 'pad.hallSystem',
path: '/value',
method: 'POST',
query: params
})
}

@ -0,0 +1,11 @@
import request from '@/utils/request.js'
// 取窗口号
export const takeTicket = (params = {}) => {
return request({
tag: 'pad.ticket',
path: '/take',
method: 'POST',
body: params
})
}

@ -0,0 +1,11 @@
import request from '@/utils/request.js'
// 获取首页概览数据
export const getMonitorList = (params = {}) => {
return request({
tag: 'pad.window',
path: '/monitor/list',
method: 'GET',
query: params
})
}

@ -32,10 +32,10 @@ ZPqdAjBLkf8NPZy7KVog98+iCTLq35DJ2ZVxkCxknA9YhiHVyXf4HPm4JlT7rW7o
Q+FzM3c=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICwDCCAkWgAwIBAgIOCfwGa9/UGPctNQVfK9swCgYIKoZIzj0EAwMwYjELMAkG
MIICvzCCAkWgAwIBAgIOY+QdhW73sGoJCdU8GFMwCgYIKoZIzj0EAwMwYjELMAkG
A1UEBgwCQ04xDzANBgNVBAoMBkh1YXdlaTETMBEGA1UECwwKSHVhd2VpIENCRzEt
MCsGA1UEAwwkSHVhd2VpIENCRyBEZXZlbG9wZXIgUmVsYXRpb25zIENBIEcyMB4X
DTI1MDkxNzA3MDg1OFoXDTI2MDMxNjA3MDg1OFowbzELMAkGA1UEBhMCQ04xDzAN
DTI2MDMxNzA4MTkxMloXDTI2MDkxMzA4MTkxMlowbzELMAkGA1UEBhMCQ04xDzAN
BgNVBAoMBuaIkOaZlDEcMBoGA1UECwwTMTc2MjM2OTUwMzIyOTkzNTQ4OTExMC8G
A1UEAwwo5oiQ5pmUKDE3NjIzNjk1MDMyMjk5MzU0ODkpXCxEZXZlbG9wbWVudDBZ
MBMGByqGSM49AgEGCCqGSM49AwEHA0IABId6o3RX8OYfuTzRhNhEYTpul0I2OcDc
@ -44,7 +44,7 @@ HQYDVR0OBBYEFNfiSgk4DDqojcl/sIgSZMlH7s2eMAwGA1UdEwEB/wQCMAAwHwYD
VR0jBBgwFoAU216TsiPo0OT+cXpm6aRzR1t/814wWQYDVR0fBFIwUDBOoEygSoZI
aHR0cDovL2g1aG9zdGluZy1kcmNuLmRiYW5rY2RuLmNuL2NjaDUvY3JsL2hkcmNh
ZzIvSHVhd2VpQ0JHSERSRzJjcmwuY3JsMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUE
DDAKBggrBgEFBQcDAzAKBggqhkjOPQQDAwNpADBmAjEAufjI1vxxLmHE9m2bSwPW
0RLZ2pZcZBvOcA3vrg7SKhmgRN8s9oiCOF8mCeyY8IQ6AjEA1k+g17zU2CvYtc+8
Wg4/Lu2hLibW7LlwgxRhJ+74plENwJ2VwwEcI+tQShEoQQNe
DDAKBggrBgEFBQcDAzAKBggqhkjOPQQDAwNoADBlAjBlLOS2y2Fy9EsXxKqiO5sL
NzPk0ATDrBB4/ac+I39J0T502hgdYtzNZObr2IxUMB8CMQDYt/n6Ibi+uBuvXeNv
6ykd67TLfu9KvCNwc4iK13zsEjHwK6r/YlsMKpp/nJD1MPg=
-----END CERTIFICATE-----

@ -9,6 +9,7 @@
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"screenOrientation" : [ "portrait-primary", "landscape-primary", "landscape-secondary" ],
"compilerVersion" : 3,
"popGesture" : "none", //
"splashscreen" : {
@ -109,10 +110,10 @@
"bundleName" : "com.ziyun.taxguidance",
"signingConfigs" : {
"default" : {
"certpath" : "ziyun_debug.cer",
"certpath" : "ziyun_debug_20160317.cer",
"keyAlias" : "ziyun_debug",
"keyPassword" : "0000001C0A434C6EAD0E542DD56B04458C2B66CC2F31DBD5DAE971DCDC08E1E50F36DF37A9288699FBE4B97B",
"profile" : "紫云智能导税调试Debug.p7b",
"profile" : "调试Debug.p7b",
"signAlg" : "SHA256withECDSA",
"storeFile" : "ziyun_debug.p12",
"storePassword" : "0000001C7E1A6AAD38035864C0D38355D93B30E5998D5D1674A5F09736672B97B02B5F8FE2566518D6E02BA6"
@ -121,7 +122,8 @@
"icons" : {
"foreground" : "E:/DNF70/icon_1024.png",
"background" : "E:/DNF70/icon_1024.png"
}
},
"deviceTypes" : [ "phone", "tablet" ]
}
}
}

@ -107,7 +107,8 @@
],
"globalStyle": {
"navigationStyle": "custom",
"backgroundColor": "#F8F8F8"
"backgroundColor": "#F8F8F8",
"pageOrientation": "landscape"
},
"easycom": {
"custom": {

@ -1,25 +1,45 @@
<template>
<view class="page-container tn-gradient-bg__cool-5">
<view class="page-container tn-gradient-bg__cool-5" @click="hideUserMenu">
<cheader></cheader>
<div class="index-body">
<div class="body-title">
<span class="title-mian">智能导税</span>
<!-- <span class="title-mian">紫云纳税服务综合管理系统</span>
<span class="title-child">智能导税</span> -->
<span class="title-mian"></span>
<!-- 右上角用户信息缩略展示 -->
<view class="user-mini" @click.stop="toggleUserMenu">
<image :src="userInfo.image" class="user-mini-avatar"></image>
<text class="user-mini-name">{{ userInfo.realName || '' }}</text>
<view v-if="showUserMenu" class="user-menu" @click.stop>
<view class="user-menu-item" @click.stop="loginOutAction">退出登录</view>
</view>
</view>
</div>
<div class="body-content">
<div class="emp-content">
<image :src="userInfo.image" class="emp-image"></image>
<p class="emp-name emp-p">{{ userInfo.realName || '' }}</p>
<p class="emp-id emp-p">工号:{{ userInfo.id || '' }}</p>
<tn-button width="180px" height="50px" @click="loginOutAction()">退 </tn-button>
<!-- 左侧数据展示纵向排列 -->
<div class="stats-column">
<h2>大厅概况</h2>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">当前等候人数</p>
<p class="stat-value">{{ waitingCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">今日预约人数</p>
<p class="stat-value">{{ todayAppointmentCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">空闲窗口</p>
<p class="stat-value">{{ idleWindowCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">空闲自助机</p>
<p class="stat-value">{{ idleKioskCount }}</p>
</div>
</div>
<div style="width: 2vw;"></div>
<!-- 右侧功能按钮区 -->
<div class="btn-content">
<div class="btn-row1">
<div class="row1-item1 col-center" @click="goToQueue()">
<uni-icons type="compose" size="60" color="#fff"></uni-icons>
<p class="btn-p">导税取票</p>
<p class="btn-p">预检取号</p>
</div>
<div class="row1-item2 col-center" @click="goToTicket()">
<uni-icons type="list" size="60" color="#fff"></uni-icons>
@ -33,9 +53,9 @@
<div style="height: 2vh;"></div>
<div class="btn-row2">
<div class="row2-item1">
<div class="row2-item1-top row-center" @click="goToHallInfo()">
<div class="row2-item1-top row-center">
<uni-icons type="info" size="60" color="#fff"></uni-icons>
<p class="btn-p">大厅流量</p>
<p class="btn-p">预留功能</p>
</div>
<div style="height: 2vh;"></div>
<div class="row2-item1-bottom row-center" @click="goToHallManagment()">
@ -49,12 +69,12 @@
<p class="btn-p">预留功能</p>
</div>
<div style="width: 2vh;"></div>
<div class="row2-item2-center col-center" @click="goToMultiInOne()">
<div class="row2-item2-center col-center">
<uni-icons type="auth-filled" size="60" color="#fff"></uni-icons>
<p class="btn-p">预留功能</p>
</div>
<div style="width: 2vh;"></div>
<div class="row2-item2-right col-center" >
<div class="row2-item2-right col-center">
<uni-icons type="settings-filled" size="60" color="#fff"></uni-icons>
<p class="btn-p">预留功能</p>
</div>
@ -69,10 +89,15 @@
<script setup>
import cheader from '@/components/header.vue'
import TnIcon from '@/uni_modules/tuniaoui-vue3/components/icon/src/icon.vue'
import {
getOverview
} from '@/api/system.js'
import {
onBackPress,
onLoad
onHide,
onLoad,
onShow,
onUnload
} from '@dcloudio/uni-app'
import {
ref
@ -84,6 +109,22 @@
image: ''
})
//
const showUserMenu = ref(false)
const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value
}
const hideUserMenu = () => {
showUserMenu.value = false
}
//
const waitingCount = ref(0)
const todayAppointmentCount = ref(0)
const idleWindowCount = ref(0)
const idleKioskCount = ref(0)
let overviewTimer = null
onBackPress(() => {
return true
})
@ -100,11 +141,54 @@
}
console.log('用户信息:', userInfo.value)
}
initData()
} catch (error) {
console.error('获取用户信息失败:', error)
}
})
const initData = async () => {
try {
const res = await getOverview()
console.log('大厅概况', res)
waitingCount.value = Number(res?.waitingCount ?? 0)
todayAppointmentCount.value = Number(res?.todayAppointmentCount ?? 0)
idleWindowCount.value = Number(res?.idleWindowCount ?? 0)
idleKioskCount.value = Number(res?.idleKioskCount ?? 0)
} catch (error) {
console.log('获取大厅概况失败', error)
}
}
const startOverviewTimer = () => {
if (overviewTimer) {
clearInterval(overviewTimer)
}
overviewTimer = setInterval(() => {
initData()
}, 30000)
}
const stopOverviewTimer = () => {
if (overviewTimer) {
clearInterval(overviewTimer)
overviewTimer = null
}
}
onShow(() => {
initData()
startOverviewTimer()
})
onHide(() => {
stopOverviewTimer()
})
onUnload(() => {
stopOverviewTimer()
})
const goToGuidance = () => {
uni.navigateTo({
url: '/pages/mod/guidance?isFromTicket=false'
@ -188,17 +272,24 @@
.index-body {
height: 90vh;
width: 100vw;
margin-top: 2vh;
display: flex;
flex-direction: column;
justify-content: center;
// justify-content: center;
align-items: center;
.body-title {
height: 6vh;
height: 10vh;
margin-bottom: 2vh;
display: flex;
align-items: center;
justify-content: space-between;
/* 关键让标题区域占满可用宽度space-between 才能拉开两端 */
width: 80vw;
max-width: 100vw;
.title-mian {
background: linear-gradient(to bottom, #DEDEDE 10%, #fff 90%);
background: linear-gradient(to bottom, #ADADAD 10%, #fff 90%);
/* 2. 将背景裁剪成文字形状 (需要-webkit-前缀) */
-webkit-background-clip: text;
background-clip: text;
@ -206,62 +297,102 @@
color: transparent;
/* 为了效果明显,可以设置一些字体样式 */
font-size: 36px;
font-weight: 600;
}
.title-child {
background: linear-gradient(to bottom, #483D8B 10%, #8A2BE2 90%);
/* 2. 将背景裁剪成文字形状 (需要-webkit-前缀) */
-webkit-background-clip: text;
background-clip: text;
/* 3. 将文字颜色设为透明,显示出渐变背景 */
color: transparent;
/* 为了效果明显,可以设置一些字体样式 */
font-size: 36px;
/* 顶部用户缩略信息 */
.user-mini {
position: relative;
display: flex;
align-items: center;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 25px;
padding: 6px 10px 6px 6px;
.user-mini-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid #fff;
margin-right: 10px;
object-fit: cover;
}
.user-mini-name {
font-size: 18px;
color: #fff;
}
.user-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 6px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 10;
.user-menu-item {
padding: 10px 16px;
font-size: 14px;
color: #fff;
text-align: center;
}
.user-menu-item:active {
background-color: #f5f5f5;
}
}
}
}
.body-content {
display: flex;
flex-direction: row;
align-items: stretch;
.emp-content {
height: 60vh;
width: 20vw;
background-color: #ffffff50;
/* 左侧数据展示列 */
.stats-column {
width: 18vw;
margin-right: 4vw;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 30px;
.emp-image {
// height: auto;
max-width: 12vw;
max-width: 80%;
object-fit: contain;
/* 保持比例 */
height: 30vh;
padding: 4px;
border: 1px solid #ddd;
}
flex: 1;
.emp-p {
h2 {
color: #fff;
font-size: 20px;
font-weight: 800;
margin-bottom: 2vh;
}
.stat-card {
background: #ffffffe0;
border-radius: $card-border-radius;
padding: 16px 20px;
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 2vh;
}
.emp-name {
margin-top: 20px;
margin-bottom: 5px;
.stat-title {
font-size: 16px;
color: #555;
margin-bottom: 8px;
}
.emp-id {
margin-bottom: 20px;
.stat-value {
font-size: 28px;
font-weight: bold;
color: #007aff;
}
}
.btn-content {
flex: 1;
.btn-row1 {
height: 22vh;
@ -272,15 +403,18 @@
.row1-item1 {
background: #cc3333;
margin-right: 2vh;
border-radius: $card-border-radius;
}
.row1-item2 {
background-color: #cc3366;
margin-right: 2vh;
border-radius: $card-border-radius;
}
.row1-item3 {
background-color: #cc3399;
border-radius: $card-border-radius;
}
}
@ -298,11 +432,13 @@
.row2-item1-top {
flex: 1;
background-color: #99ccff;
border-radius: $card-border-radius;
}
.row2-item1-bottom {
flex: 1;
background-color: #9999ff;
border-radius: $card-border-radius;
}
}
@ -314,16 +450,19 @@
.row2-item2-left {
flex: 1;
background-color: #ffcc99;
border-radius: $card-border-radius;
}
.row2-item2-center {
flex: 1;
background-color: #ffcccc;
border-radius: $card-border-radius;
}
.row2-item2-right {
flex: 1;
background-color: #ffccff;
border-radius: $card-border-radius;
}
}
}

@ -39,7 +39,8 @@ e<template>
try {
const res = await userLogin(loginParams)
uni.setStorage({key: 'token', data: res.accessToken})
console.log(res)
uni.setStorage({key: 'token', data: res.access_token})
uni.setStorage({key: 'userInfo', data: JSON.stringify(res.userInfo)})
uni.navigateTo({
url: '/pages/index/index',

@ -2,9 +2,14 @@
<view class="page-container">
<div class="top-div">
<div></div>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
<view class="top-actions">
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="refreshMonitorList">
<uni-icons type="refresh" size="16" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
</view>
</div>
<view class="content">
<!-- 窗口状态监控 -->
@ -48,7 +53,7 @@
</view>
<!-- 自助设备 -->
<view class="window-group">
<!-- <view class="window-group">
<view class="group-title">
<uni-icons type="settings" size="20" color="#52c41a"></uni-icons>
<text>自助办税设备</text>
@ -74,7 +79,7 @@
</view>
</view>
</view>
</view>
</view> -->
</view>
</view>
</view>
@ -111,19 +116,24 @@
</view>
<view class="detail-item">
<text class="detail-label">当前业务</text>
<text class="detail-value">{{ selectedWindow.currentTask || '无' }}</text>
<text class="detail-value">{{
selectedWindow.currentTask || "无"
}}</text>
</view>
<view class="detail-item">
<text class="detail-label">工作人员</text>
<text class="detail-value">{{ selectedWindow.operator || '未分配' }}</text>
<text class="detail-value">{{
selectedWindow.operator || "未分配"
}}</text>
</view>
</view>
<view class="waiting-list" v-if="selectedWindow.waitingCount > 0">
<view class="list-title">等候队列</view>
<view class="list-items">
<view v-for="(person, index) in getWaitingList(selectedWindow.waitingCount)" :key="index"
class="list-item">
<view v-for="(person, index) in getWaitingList(
selectedWindow.waitingCount,
)" :key="index" class="list-item">
<text class="queue-number">{{ index + 1 }}</text>
<text class="estimate-time">预计等待: {{ (index + 1) * 5 }}分钟</text>
</view>
@ -143,212 +153,269 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { getMonitorList } from "@/api/window.js";
import { onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
//
const windowPopup = ref()
const windowPopup = ref();
//
const manualWindows = ref([{
id: 1,
number: 'A001',
type: '综合业务',
status: 'busy',
waitingCount: 5,
currentTask: '增值税申报',
operator: '张三'
},
{
id: 2,
number: 'A002',
type: '发票办理',
status: 'free',
waitingCount: 2,
currentTask: '',
operator: '李四'
},
{
id: 3,
number: 'A003',
type: '税务登记',
status: 'busy',
waitingCount: 3,
currentTask: '新办企业登记',
operator: '王五'
},
{
id: 4,
number: 'A004',
type: '社保缴纳',
status: 'free',
waitingCount: 0,
currentTask: '',
operator: '赵六'
},
{
id: 5,
number: 'A005',
type: '个税业务',
status: 'pause',
waitingCount: 4,
currentTask: '个税汇算清缴',
operator: '钱七'
},
{
id: 6,
number: 'A006',
type: '咨询导税',
status: 'free',
waitingCount: 1,
currentTask: '',
operator: '孙八'
}
])
const manualWindows = ref([
{
id: 1,
number: "A001",
type: "综合业务",
status: "busy",
waitingCount: 5,
currentTask: "增值税申报",
operator: "张三",
},
{
id: 2,
number: "A002",
type: "发票办理",
status: "free",
waitingCount: 2,
currentTask: "",
operator: "李四",
},
{
id: 3,
number: "A003",
type: "税务登记",
status: "busy",
waitingCount: 3,
currentTask: "新办企业登记",
operator: "王五",
},
{
id: 4,
number: "A004",
type: "社保缴纳",
status: "free",
waitingCount: 0,
currentTask: "",
operator: "赵六",
},
{
id: 5,
number: "A005",
type: "个税业务",
status: "pause",
waitingCount: 4,
currentTask: "个税汇算清缴",
operator: "钱七",
},
{
id: 6,
number: "A006",
type: "咨询导税",
status: "free",
waitingCount: 1,
currentTask: "",
operator: "孙八",
},
]);
//
const selfServiceDevices = ref([{
id: 1,
name: '自助发票申领机',
status: 'free',
icon: 'printer'
},
{
id: 2,
name: '自助代开发票机',
status: 'busy',
icon: 'document'
},
{
id: 3,
name: '自助查询终端',
status: 'free',
icon: 'search'
},
{
id: 4,
name: '自助申报设备',
status: 'maintenance',
icon: 'cloud-upload'
},
{
id: 5,
name: '自助打印终端',
status: 'free',
icon: 'paperplane'
},
{
id: 6,
name: '自助缴税设备',
status: 'busy',
icon: 'card'
}
])
const selfServiceDevices = ref([
{
id: 1,
name: "自助发票申领机",
status: "free",
icon: "printer",
},
{
id: 2,
name: "自助代开发票机",
status: "busy",
icon: "document",
},
{
id: 3,
name: "自助查询终端",
status: "free",
icon: "search",
},
{
id: 4,
name: "自助申报设备",
status: "maintenance",
icon: "cloud-upload",
},
{
id: 5,
name: "自助打印终端",
status: "free",
icon: "paperplane",
},
{
id: 6,
name: "自助缴税设备",
status: "busy",
icon: "card",
},
]);
//
const freeWindowsCount = computed(() => {
return manualWindows.value.filter(window => window.status === 'free').length
})
return manualWindows.value.filter((window) => window.status === "空闲")
.length;
});
const busyWindowsCount = computed(() => {
return manualWindows.value.filter(window => window.status === 'busy').length
})
return manualWindows.value.filter((window) => window.status === "忙碌")
.length;
});
//
const editing = computed(() => {
return hallParams.value.some(param => param.editing)
})
// //
// const editing = computed(() => {
// return hallParams.value.some(param => param.editing)
// })
//
const selectedWindow = ref<any>(null)
const selectedWindow = ref<any>(null);
//
const getWindowStatusClass = (status : string) => {
const classMap : any = {
free: 'window-free',
busy: 'window-busy',
pause: 'window-pause'
}
return classMap[status] || 'window-free'
}
free: "window-free",
busy: "window-busy",
pause: "window-pause",
空闲: "window-free",
忙碌: "window-busy",
暂停: "window-pause",
};
return classMap[status] || "window-free";
};
const getWindowStatusText = (status : string) => {
const textMap : any = {
free: '空闲',
busy: '忙碌',
pause: '暂停'
}
return textMap[status] || '未知'
}
free: "空闲",
busy: "忙碌",
pause: "暂停",
空闲: "空闲",
忙碌: "忙碌",
暂停: "暂停",
};
return textMap[status] || "未知";
};
//
const getDeviceStatusClass = (status : string) => {
const classMap : any = {
free: 'device-free',
busy: 'device-busy',
maintenance: 'device-maintenance'
}
return classMap[status] || 'device-free'
}
free: "device-free",
busy: "device-busy",
maintenance: "device-maintenance",
};
return classMap[status] || "device-free";
};
const getDeviceStatusText = (status : string) => {
const textMap : any = {
free: '空闲',
busy: '使用中',
maintenance: '维护中'
}
return textMap[status] || '未知'
}
free: "空闲",
busy: "使用中",
maintenance: "维护中",
};
return textMap[status] || "未知";
};
const getDeviceIconColor = (status : string) => {
const colorMap : any = {
free: '#52c41a',
busy: '#fa541c',
maintenance: '#faad14'
free: "#52c41a",
busy: "#fa541c",
maintenance: "#faad14",
};
return colorMap[status] || "#52c41a";
};
//
const refreshMonitorList = async () => {
try {
const res = await getMonitorList();
console.log("大厅监控列表", res);
//
const windows = res?.list || res?.windowList || res?.windows;
const devices = res?.selfServiceDevices || res?.deviceList || res?.devices;
if (Array.isArray(windows)) {
manualWindows.value = windows.map((item : any, index : number) => {
const businesses = Array.isArray(item?.supportedBusinesses)
? item.supportedBusinesses
: [];
const type = businesses
.slice()
.sort((a : any, b : any) => (a?.priority ?? 0) - (b?.priority ?? 0))
.map((b : any) => b?.businessName)
.filter(Boolean)
.join(",");
return {
...item,
id: item?.windowId ?? index,
number: item?.windowName || "",
type,
currentTask: item?.currentTicketNo || "",
status: item?.windowStatus || "",
waitingCount: Number(item?.waitingCount ?? 0),
};
});
}
if (Array.isArray(devices)) {
selfServiceDevices.value = devices;
}
} catch (error) {
console.log("获取大厅监控列表失败", error);
}
return colorMap[status] || '#52c41a'
}
};
//
const handleWindowClick = (window : any) => {
selectedWindow.value = window
windowPopup.value.open()
}
selectedWindow.value = window;
windowPopup.value.open();
};
const closeWindowPopup = () => {
windowPopup.value.close()
}
windowPopup.value.close();
};
//
const getWaitingList = (count : number) => {
return Array.from({
length: count
}, (_, index) => ({
id: index + 1,
queueNumber: index + 1
}))
}
return Array.from(
{
length: count,
},
(_, index) => ({
id: index + 1,
queueNumber: index + 1,
}),
);
};
//
const handleWindowAction = (window : any) => {
uni.showActionSheet({
itemList: ['暂停服务', '重置窗口', '呼叫下一位', '查看统计'],
itemList: ["暂停服务", "重置窗口", "呼叫下一位", "查看统计"],
success: (res) => {
const actions = ['pause', 'reset', 'callNext', 'viewStats']
const action = actions[res.tapIndex]
const actions = ["pause", "reset", "callNext", "viewStats"];
const action = actions[res.tapIndex];
//
uni.showToast({
title: `执行操作: ${action}`,
icon: 'success'
})
}
})
}
icon: "success",
});
},
});
};
const backToIndex = () => {
uni.navigateTo({
url: '/pages/index/index'
})
}
url: "/pages/index/index",
});
};
onLoad(() => {
refreshMonitorList();
});
</script>
<style lang="scss" scoped>
@ -362,10 +429,22 @@
justify-content: space-between;
background-color: #fff;
padding: 6vh 20px 10px 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-sizing: border-box;
}
.top-actions {
display: flex;
gap: 10px;
}
.content {
padding: 20rpx;
margin-top: 100px;
}
//

@ -17,40 +17,30 @@
{{ editing ? '保存中...' : '编辑参数' }}
</tn-button> -->
</view>
<uni-table border stripe emptyText="暂无参数数据">
<uni-table class="params-table" border stripe emptyText="暂无参数数据">
<uni-tr>
<uni-th width="200" align="center">参数名称</uni-th>
<uni-th width="250" align="center">参数值</uni-th>
<uni-th width="150" align="center">操作</uni-th>
<uni-th align="center">操作</uni-th>
<uni-th align="center">参数名称</uni-th>
<uni-th align="center">参数Key</uni-th>
<uni-th align="center">参数值</uni-th>
</uni-tr>
<uni-tr v-for="(param, index) in hallParams" :key="param.key">
<uni-td align="center">
<view class="param-actions">
<tn-button size="sm" type="primary" plain @click="startEditParam(index)">
编辑
</tn-button>
</view>
</uni-td>
<uni-td align="center">
<text class="param-name">{{ param.name }}</text>
</uni-td>
<uni-td align="center">
<view v-if="!param.editing" class="param-value">
<text>{{ param.value }}</text>
<text class="param-unit" v-if="param.unit">{{ param.unit }}</text>
</view>
<view v-else class="param-edit">
<tn-input v-model="param.editValue" :type="param.inputType || 'text'"
:placeholder="`请输入${param.name}`" size="small" border />
</view>
<text class="param-key">{{ param.key }}</text>
</uni-td>
<uni-td align="center">
<view class="param-actions">
<tn-button v-if="!param.editing" size="sm" type="primary" plain
@click="startEditParam(index)">
编辑
</tn-button>
<view v-else class="edit-actions">
<tn-button size="sm" type="primary" @click="saveParam(index)">
保存
</tn-button>
<tn-button size="sm" type="default" @click="cancelEditParam(index)">
取消
</tn-button>
</view>
<view class="param-value">
<text>{{ param.value }}</text>
</view>
</uni-td>
</uni-tr>
@ -58,138 +48,125 @@
</view>
</view>
</view>
<!-- 参数编辑弹窗 -->
<uni-popup ref="editPopup" type="center" background-color="#fff">
<view class="popup-content" v-if="selectedParam">
<view class="popup-header">
<text class="popup-title">编辑参数</text>
<uni-icons type="close" size="20" color="#999" @click="closeEditPopup"></uni-icons>
</view>
<view class="popup-body">
<view class="detail-grid">
<view class="detail-item">
<text class="detail-label">参数名称</text>
<text class="detail-value">{{ selectedParam.name }}</text>
</view>
<view class="detail-item">
<text class="detail-label">参数Key</text>
<text class="detail-value">{{ selectedParam.key }}</text>
</view>
<view class="detail-item">
<text class="detail-label">参数值</text>
<view class="detail-value" style="flex: 1; margin-left: 16rpx;">
<tn-input v-model="editValue" type="text" :placeholder="`请输入${selectedParam.name}`" border />
</view>
</view>
</view>
</view>
<view class="popup-footer">
<tn-button type="primary" @click="confirmSaveParam"></tn-button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getSystemList, updateSysValue } from '@/api/system.js'
//
const hallParams = ref([
{
key: 'hallName',
name: '大厅名称',
value: '某某市税务局第一办税服务厅',
editing: false,
editValue: '',
inputType: 'text'
},
{
key: 'workTimeStart',
name: '上班时间',
value: '09:00',
editing: false,
editValue: '',
inputType: 'text'
},
{
key: 'workTimeEnd',
name: '下班时间',
value: '17:00',
editing: false,
editValue: '',
inputType: 'text'
},
{
key: 'lunchStart',
name: '午休开始',
value: '12:00',
editing: false,
editValue: '',
inputType: 'text'
},
{
key: 'lunchEnd',
name: '午休结束',
value: '13:30',
editing: false,
editValue: '',
inputType: 'text'
},
{
key: 'maxWaiting',
name: '最大等候人数',
value: '50',
editing: false,
editValue: '',
inputType: 'number',
unit: '人'
},
{
key: 'serviceTimeout',
name: '业务办理超时',
value: '30',
editing: false,
editValue: '',
inputType: 'number',
unit: '分钟'
},
{
key: 'weekendService',
name: '周末服务',
value: '不开放',
editing: false,
editValue: '',
inputType: 'text'
const hallParams = ref<any[]>([])
const editPopup = ref()
const selectedParam = ref<any>(null)
const editValue = ref('')
const fetchSystemList = async () => {
try {
const res = await getSystemList()
console.log(res)
const list = Array.isArray(res.list) ? res.list : []
hallParams.value = list.map((item : any) => ({
key: item.key,
name: item.memo,
value: item.value
}))
} catch (error) {
console.log('获取大厅参数失败', error)
uni.showToast({
title: '获取参数失败',
icon: 'none'
})
}
])
}
//
//
const startEditParam = (index : number) => {
hallParams.value.forEach((param, i) => {
if (i === index) {
param.editing = true
param.editValue = param.value
} else {
param.editing = false
}
})
const current = hallParams.value[index]
selectedParam.value = { ...current }
editValue.value = String(current?.value ?? '')
editPopup.value?.open()
}
const saveParam = (index : number) => {
const param = hallParams.value[index]
if (param.editValue.trim()) {
param.value = param.editValue
param.editing = false
const closeEditPopup = () => {
editPopup.value?.close()
}
const confirmSaveParam = () => {
const value = editValue.value?.toString().trim()
if (!selectedParam.value?.key) {
uni.showToast({
title: '参数保存成功',
icon: 'success'
title: '参数Key缺失',
icon: 'none'
})
// API
console.log(`保存参数: ${param.name} = ${param.value}`)
} else {
return
}
if (!value) {
uni.showToast({
title: '参数值不能为空',
icon: 'none'
})
return
}
}
const cancelEditParam = (index : number) => {
hallParams.value[index].editing = false
hallParams.value[index].editValue = ''
}
//
const handleEditParams = () => {
if (editing.value) {
//
hallParams.value.forEach((param, index) => {
if (param.editing) {
saveParam(index)
uni.showModal({
title: '二次确认',
content: `确认将 ${selectedParam.value.name} 修改为 ${value} 吗?`,
success: async (res) => {
if (!res.confirm) return
console.log(selectedParam.value.key)
try {
await updateSysValue({
key: selectedParam.value.key,
value
})
uni.showToast({
title: '修改成功',
icon: 'success'
})
closeEditPopup()
await fetchSystemList()
} catch (error) {
console.log('修改大厅参数失败', error)
uni.showToast({
title: '修改失败',
icon: 'none'
})
}
})
} else {
//
uni.showToast({
title: '进入编辑模式',
icon: 'success'
})
}
}
})
}
const backToIndex = () => {
@ -198,8 +175,8 @@
})
}
onMounted(() => {
// API
onLoad(() => {
fetchSystemList()
})
</script>
@ -214,10 +191,17 @@
justify-content: space-between;
background-color: #fff;
padding: 6vh 20px 10px 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-sizing: border-box;
}
.content {
padding: 20rpx;
margin-top: 100px;
}
//
@ -435,10 +419,26 @@
}
//
.params-table {
:deep(.uni-table-th),
:deep(.uni-table-td) {
white-space: normal !important;
word-break: break-all;
overflow-wrap: anywhere;
}
}
.param-name {
font-size: 26rpx;
color: #333;
font-weight: 500;
word-break: break-all;
}
.param-key {
font-size: 24rpx;
color: #666;
word-break: break-all;
}
.param-value {
@ -446,6 +446,8 @@
align-items: center;
justify-content: center;
gap: 8rpx;
word-break: break-all;
white-space: normal;
.param-unit {
font-size: 22rpx;

@ -2,22 +2,31 @@
<template>
<view class="business-container">
<!-- 头部信息 -->
<view class="user-info">
<!-- <view class="user-info">
<text class="info-title">您好{{ userName }}</text>
<text class="info-subtitle">请选择需要办理的业务类型</text>
</view>
<text class="info-subtitle">请选择需要办理的业务类型</text> -->
<view class="top-div">
<view></view>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="goBack">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</view>
<!-- </view> -->
<!-- 业务列表 -->
<scroll-view class="business-list" scroll-y>
<view v-for="(item, index) in businessList" :key="index" class="business-item"
:class="{ 'selected': selectedBusiness === item.value }" @click="selectBusiness(item)">
<view class="business-icon">{{ item.icon }}</view>
<view class="business-content">
<text class="business-name">{{ item.name }}</text>
<text class="business-desc">{{ item.description }}</text>
</view>
<view class="business-check" v-if="selectedBusiness === item.value">
<text></text>
<view class="business-grid">
<view v-for="(item, index) in businessList" :key="index" class="business-item"
:class="{ 'selected': selectedBusiness === item.value }" @click="selectBusiness(item)">
<view class="business-icon">{{ item.icon }}</view>
<view class="business-content">
<text class="business-name">{{ item.name }}</text>
<text class="business-desc">{{ item.description }}</text>
</view>
<view class="business-check" v-if="selectedBusiness === item.value">
<text></text>
</view>
</view>
</view>
</scroll-view>
@ -34,6 +43,7 @@
<uni-popup ref="resultPopup" type="center" :is-mask-click="false">
<view class="result-popup">
<view class="result-header">
<view class="result-close" @click="closeResultPopup">×</view>
<text class="result-icon"></text>
<text class="result-title">取号成功</text>
</view>
@ -50,19 +60,27 @@
<text class="detail-value">{{ ticketInfo.businessName }}</text>
</view>
<view class="detail-item">
<text class="detail-label">预计等候</text>
<text class="detail-value">{{ ticketInfo.waitingTime }}分钟</text>
<text class="detail-label">取号时间</text>
<text class="detail-value">{{ ticketInfo.tktDate + ' ' + ticketInfo.tktTime }}</text>
</view>
<view class="detail-item">
<text class="detail-label">办理窗口</text>
<text class="detail-value">{{ ticketInfo.serviceWindow }}</text>
<text class="detail-label">前方等候</text>
<text class="detail-value">{{ ticketInfo.waitingCount }}</text>
</view>
<!-- <view class="detail-item">
<text class="detail-label">预计等候</text>
<text class="detail-value">{{ ticketInfo.waitingTime }}分钟</text>
</view> -->
<!-- <view class="detail-item">
<text class="detail-label">办理窗口</text>
<text class="detail-value">{{ ticketInfo.serviceWindow }}</text>
</view> -->
</view>
</view>
<view class="result-actions">
<button class="btn-print" @click="handlePrint"></button>
<button class="btn-notify" @click="handleNotify"></button>
<!-- <button class="btn-print" @click="handlePrint"></button>
<button class="btn-notify" @click="handleNotify"></button> -->
</view>
</view>
</uni-popup>
@ -70,18 +88,10 @@
</template>
<script setup>
import {
ref
} from 'vue';
import {
onLoad,
onReady
} from '@dcloudio/uni-app'
// import {
// generateTicket,
// printTicket,
// sendNotification
// } from '@/utils/api';
import { ref } from 'vue';
import { onLoad, onReady } from '@dcloudio/uni-app'
import { getBizList } from '@/api/index.js'
import { takeTicket } from '@/api/ticket.js'
const userName = ref('');
const selectedBusiness = ref('');
@ -89,48 +99,34 @@
const ticketInfo = ref({});
const resultPopup = ref(null);
//
const loadBusinessList = async () => {
try {
const res = await getBizList()
console.log(res)
//
const list = Array.isArray(res) ? res : []
businessList.value = list.map((item, index) => ({
icon: item.icon || '🧾',
name: item.name || item.bizName || '',
value: item.value || item.uid || item.id || String(index),
prefix: item.prefix || item.remark || ''
}))
} catch (error) {
console.error('获取业务列表失败:', error)
uni.showToast({
title: '获取业务列表失败',
icon: 'none'
})
//
businessList.value = []
}
}
//
onLoad((options) => {
userName.value = decodeURIComponent(options.name || '');
//
businessList.value = [{
icon: '🏦',
name: '税务登记',
value: 'tax_register',
description: '企业税务登记、变更、注销'
},
{
icon: '🧾',
name: '发票办理',
value: 'invoice',
description: '发票申领、开具、验旧'
},
{
icon: '📊',
name: '纳税申报',
value: 'tax_declare',
description: '各类税种申报缴纳'
},
{
icon: '🎯',
name: '税收优惠',
value: 'tax_preference',
description: '优惠政策咨询办理'
},
{
icon: '📝',
name: '证明开具',
value: 'certificate',
description: '完税证明、涉税证明'
},
{
icon: '🔍',
name: '税务查询',
value: 'tax_query',
description: '纳税记录、信用查询'
}
];
loadBusinessList()
});
//
@ -147,24 +143,38 @@
const handleConfirm = async () => {
try {
//
const result = await generateTicket({
businessType: selectedBusiness.value
const result = await takeTicket({
bizUid: selectedBusiness.value,
idCard: '412827199805026017',
rankUserName:'尹朋虎',
rankUserPhone:'15382312786'
});
if (result.code === 200) {
// tktId
if (result && result.tktId && String(result.tktId).trim()) {
const currentBiz = businessList.value.find(b => b.value === selectedBusiness.value)
ticketInfo.value = {
ticketNumber: result.data.ticketNumber,
businessName: businessList.value.find(b => b.value === selectedBusiness.value).name,
waitingTime: result.data.waitingTime || '15-20',
serviceWindow: result.data.serviceWindow || '请等候叫号'
ticketNumber: result.tktId,
businessName: result.bizName || currentBiz?.name || '',
waitingTime: result.estimatedWaitMinutes ?? result.waitingCount ?? 0,
serviceWindow: result.windowNames || '请等候叫号',
waitingCount: result.waitingCount,
tktDate:result.tktDate,
tktTime:result.tktTime
};
//
resultPopup.value.open();
} else {
uni.showToast({
title: '取号失败',
icon: 'none'
});
}
console.log(result)
} catch (error) {
uni.showToast({
title: error.message || '取号失败',
title: error.message || '业务获取失败',
icon: 'none'
});
}
@ -173,21 +183,24 @@
//
const handlePrint = async () => {
uni.showLoading({
title: '正在打印...'
title: '正在取号...'
});
try {
const result = await printTicket(ticketInfo.value);
const result = await takeTicket({
businessType: selectedBusiness.value
});
if (result.code === 200) {
// request
if (result) {
uni.showToast({
title: '打印指令已发送',
title: '取号成功',
icon: 'success'
});
}
} catch (error) {
uni.showToast({
title: error.message || '打印失败',
title: error.message || '取号失败',
icon: 'none'
});
} finally {
@ -219,6 +232,11 @@
uni.hideLoading();
}
};
//
const closeResultPopup = () => {
resultPopup.value?.close();
};
</script>
<style scoped>
@ -231,6 +249,20 @@
.user-info {
text-align: center;
margin-bottom: 40rpx;
display: flex;
}
.top-div {
display: flex;
justify-content: space-between;
background-color: #fff;
padding: 6vh 20px 10px 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-sizing: border-box;
}
.info-title {
@ -248,10 +280,16 @@
}
.business-list {
height: 60vh;
height: 80vh;
margin-bottom: 40rpx;
}
.business-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.business-item {
background: white;
border-radius: 16rpx;
@ -343,12 +381,27 @@
}
.result-header {
position: relative;
padding: 40rpx 40rpx 20rpx;
text-align: center;
background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
color: white;
}
.result-close {
position: absolute;
right: 24rpx;
top: 20rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
line-height: 48rpx;
text-align: center;
font-size: 40rpx;
color: #fff;
background: rgba(255, 255, 255, 0.2);
}
.result-icon {
font-size: 80rpx;
display: block;

@ -237,13 +237,14 @@
try {
const res = await getTicket(params)
console.log(res)
loading.value = true
if (res && res.data) {
if (res && res.list) {
total.value = res.total || 0
pageCurrent.value = res.page || 1
//
tableData.value = res.data.map((item : any) => ({
tableData.value = res.list.map((item : any) => ({
//
customerName: item.customerName || '--',
companyName: item.companyName || '--',

@ -78,3 +78,4 @@ $uni-font-size-paragraph:15px;
/* 电池栏高度 */
$statusbar-height: 4vh;
$card-border-radius: 4px;

@ -1,82 +1,295 @@
// utils/request.js
import {
ref
} from 'vue'
const baseURL = 'http://padapi.queuingsystem.cn/pad-api' // 替换为你的基础URL
import { ref } from 'vue'
// 全局加载状态,可根据需要在使用页面绑定
// 公网统一入口POST http://<gateway-host>/public
// 这里留一个可配置的占位符,实际项目中建议从配置文件或环境变量读取
const baseURL = 'http://padapi.queuingsystem.cn/public'
// const baseURL = 'https://api-dsb.dingtax.cn/dsb/api/tax-appoint/dx/third/request/public'
// 签名计算也走统一入口,通过约定的签名 tag/path 转发到内部 /api/auth
const SIGN_TAG = 'pad.auth'
const SIGN_PATH = '/sign'
// 全局 loading 状态,可在页面上直接使用
export const loading = ref(false)
// 生成 traceId时间戳 + 随机数(满足文档“可追踪”的要求即可)
const genTraceId = () => {
const ts = new Date()
.toISOString()
.replace(/[-:TZ.]/g, '')
const rand = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0')
return `${ts}${rand}`
}
// 生成随机 nonce用于签名防重放
const genNonce = () => {
return `n-${Math.random().toString(36).slice(2, 10)}`
}
// 组装接口要求的公共请求头
const buildHeaders = (withToken = true) => {
const headers = {
'Content-Type': 'application/json'
}
// 如有额外网关签名、appId 等,统一在这里追加
// headers['X-App-Id'] = 'xxx'
// headers['X-Sign'] = 'xxx'
if (withToken) {
const token = uni.getStorageSync('token')
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
return headers
}
// 统一处理 HTTP 状态码和业务状态码(文档规范)
const handleResponse = (res) => {
const { statusCode, data } = res
// 1. HTTP 层异常
if (statusCode !== 200) {
let msg = '网络请求异常'
if (statusCode === 400) msg = '请求参数错误'
if (statusCode === 401) msg = '未授权访问'
if (statusCode === 403) msg = '权限不足'
if (statusCode === 404) msg = '资源不存在'
if (statusCode >= 500) msg = '服务器内部错误'
uni.showToast({
title: `${msg} (${statusCode})`,
icon: 'none'
})
console.log('[request][http-error]', res)
throw res
}
// 2. 业务层:文档约定 code=200 成功,其它为错误
if (data && data.code === 200) {
// 返回真正的业务数据
return data.data !== undefined ? data.data : data
}
const bizCode = data && data.code
const bizMsg = (data && data.msg) || '请求失败'
uni.showToast({
title: `${bizMsg}${bizCode ? ` (${bizCode})` : ''}`,
icon: 'none'
})
console.log('[request][biz-error]', data)
throw data
}
/**
* 统一 PAD 公网入口请求封装
*
* 签名流程
* 1. 前端先准备业务参数不含 signature
* 2. 调用签名服务同样走 /public传入 { tag, path, query, body, timestamp, nonce }
* 3. 后端用 secret key 计算 HMAC-SHA256返回 signature
* 4. 前端携带 signature/timestamp/nonce 调用真正网关入口 /public
*
* @param {Object} options
* - tag: 路由标签必填 'pad.auth'
* - path: map.path 子路径必填 '/login'
* - method: 业务含义上的 method必填 'GET' / 'POST'会写入 map.head.method
* - query: 对应 map.query 对象
* - body: 对应 map.body 对象
* - traceId: 可选自定义链路号不传则自动生成
* - withToken: 是否携带 token登录等接口传 false
* - skipSign: 是否跳过签名登录 /login 时传 true不调 /api/sign不校验签名
* - loading: 是否展示 loading
* - loadingText: loading 文案
*/
const request = (options) => {
// 解构参数,并设置默认值
const {
url,
method = 'GET',
data = {},
withToken = true, // 默认携带Token
loading: showLoading = true, // 默认显示loading
loadingText = '加载中...'
} = options
return new Promise((resolve, reject) => {
// 请求开始显示loading
if (showLoading) {
loading.value = true
uni.showLoading({
title: loadingText,
mask: true
})
}
// 发起请求
uni.request({
url: baseURL + url,
method,
data,
header: {
'Content-Type': 'application/json',
// 根据配置决定是否携带Token
...(withToken && {
'Authorization': `Bearer ${uni.getStorageSync('token')}`
})
},
success: (res) => {
console.log(res)
// 此处可根据后端返回结构调整
if (res.statusCode === 200) {
resolve(res.data)
} else if (res.statusCode === 400) {
// HTTP状态码错误处理
uni.showToast({
title: `请求参数错误: ${res.statusCode}`,
icon: 'none'
})
reject(res)
} else if (res.statusCode === 401) {
// HTTP状态码错误处理
uni.showToast({
title: `用户名或密码错误: ${res.statusCode}`,
icon: 'none'
})
reject(res)
}
},
fail: (err) => {
uni.showToast({
title: '网络请求失败',
icon: 'none'
})
reject(err)
},
complete: () => {
// 隐藏loading
if (showLoading) {
loading.value = false
uni.hideLoading()
}
}
})
})
const {
tag,
path,
method = 'GET',
query = {},
body = {},
traceId,
withToken = true,
skipSign = false,
loading: showLoading = true,
loadingText = '加载中...'
} = options
if (!tag) {
throw new Error('request 需要传入 tag')
}
if (!path) {
throw new Error('request 需要传入 path')
}
const finalTraceId = traceId || genTraceId()
// 登录等 skipSign 场景:不携带 token
const useToken = skipSign ? false : withToken
const doRequest = (payload) => {
return new Promise((resolve, reject) => {
uni.request({
url: baseURL,
method: 'POST',
data: payload,
header: buildHeaders(useToken),
success: (res) => {
try {
const result = handleResponse(res)
resolve(result)
} catch (e) {
console.log('[request][handleResponse-error]', e)
reject(e)
}
},
fail: (err) => {
uni.showToast({
title: '网络请求失败',
icon: 'none'
})
console.log('[request][network-fail]', err)
reject(err)
},
complete: () => {
if (showLoading) {
loading.value = false
uni.hideLoading()
}
}
})
})
}
if (showLoading) {
loading.value = true
uni.showLoading({
title: loadingText,
mask: true
})
}
// 登录等接口:跳过 /api/sign直接请求业务网关head 中不含 signature/timestamp/nonce
if (skipSign) {
const payload = {
tag,
map: {
traceId: finalTraceId,
head: {
method,
contentType: 'application/json'
},
path,
query,
body
}
}
return doRequest(payload)
}
// 常规流程:先走统一入口调用签名服务,再携带 signature/timestamp/nonce 调业务接口
const timestamp = Date.now()
const nonce = genNonce()
const tokenForSign = uni.getStorageSync('token')
const signPayload = {
tag: SIGN_TAG,
map: {
head: {
method: 'POST',
contentType: 'application/json',
timestamp,
nonce,
// 调用签名服务时在 head 中显式传入 token
...(tokenForSign ? { Authorization: `Bearer ${tokenForSign}` } : {})
},
path: SIGN_PATH,
query: {},
// 把真实业务请求关键信息放到 body 里交给签名服务计算:
// { tag, path, query, body, timestamp, nonce }
body: {
tag,
path,
query,
body,
timestamp,
nonce
}
}
}
return new Promise((resolve, reject) => {
uni.request({
// 签名服务也通过公网统一入口 /public由 SIGN_TAG + SIGN_PATH 路由到内部 /api/sign
url: baseURL,
method: 'POST',
data: signPayload,
header: buildHeaders(useToken),
success: (signRes) => {
try {
if (!(signRes.statusCode === 200 && signRes.data && signRes.data.code === 200)) {
const msg = (signRes.data && signRes.data.msg) || '获取签名失败'
uni.showToast({
title: msg,
icon: 'none'
})
console.log('[request][sign-response-error]', signRes)
throw signRes
}
const signature =
(signRes.data.data && signRes.data.data.signature) || signRes.data.signature
if (!signature) {
console.log('[request][sign-empty]', signRes)
throw new Error('签名结果为空')
}
const payload = {
tag,
map: {
traceId: finalTraceId,
head: {
method,
contentType: 'application/json',
signature,
timestamp,
nonce
},
path,
query,
body
}
}
doRequest(payload).then(resolve).catch(reject)
} catch (err) {
if (showLoading) {
loading.value = false
uni.hideLoading()
}
console.log('[request][sign-process-error]', err)
reject(err)
}
},
fail: (err) => {
if (showLoading) {
loading.value = false
uni.hideLoading()
}
uni.showToast({
title: '获取签名接口失败',
icon: 'none'
})
console.log('[request][sign-network-fail]', err)
reject(err)
}
})
})
}
export default request

@ -1,19 +1,46 @@
import { app as e, BrowserWindow as r } from "electron";
import { fileURLToPath as t } from "url";
import o from "path";
const a = t(import.meta.url), i = o.dirname(a);
e.whenReady().then(() => {
const n = new r({
width: 800,
height: 600,
import { app, BrowserWindow, ipcMain } from "electron";
import path from "path";
import { fileURLToPath } from "url";
const __filename$1 = fileURLToPath(import.meta.url);
const __dirname$1 = path.dirname(__filename$1);
let mainWindow = null;
const isDev = process.env.NODE_ENV === "development";
const createWindow = () => {
mainWindow = new BrowserWindow({
width: 600,
height: 800,
webPreferences: {
nodeIntegration: !1,
contextIsolation: !0,
preload: o.join(i, "../preload/index.js")
}
nodeIntegration: false,
contextIsolation: true
// preload: path.join(__dirname, '../../../dist-electron/preload/index.js'),
},
autoHideMenuBar: true
});
process.env.VITE_DEV_SERVER_URL ? n.loadURL(process.env.VITE_DEV_SERVER_URL) : n.loadFile(o.join(i, "../../dist/index.html"));
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(__dirname$1, "../../dist/index.html"));
}
if (isDev) {
mainWindow.webContents.openDevTools();
}
};
app.whenReady().then(() => {
createWindow();
app.on("activate", function() {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
e.on("window-all-closed", () => {
process.platform !== "darwin" && e.quit();
ipcMain.handle("print-ticket", async (event, ticketInfo) => {
console.log("打印小票:", ticketInfo, event);
await new Promise((resolve) => setTimeout(resolve, 1e3));
return {
success: true,
message: "小票打印成功"
};
});

@ -1,5 +1,15 @@
import { contextBridge as o } from "electron";
o.exposeInMainWorld("electronAPI", {
// 可以在这里添加需要暴露的API
platform: process.platform
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
// 打印相关
printTicket: (ticketInfo) => ipcRenderer.invoke("print-ticket", ticketInfo),
// 业务相关
getBusinesses: () => ipcRenderer.invoke("get-businesses"),
// 窗口控制
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// 监听事件
onWindowStateChange: (callback) => {
ipcRenderer.on("window-state-changed", (_, state) => callback(state));
}
});

@ -0,0 +1,36 @@
const { build } = require('vite-plugin-electron')
module.exports = {
productName: 'Ticket Client',
copyright: 'Copyright © 2026',
files: ['dist', 'dist-electron', '!**/package-lock.json'],
directories: {
output: 'release/${version}',
buildResources: 'build-resources',
},
asar: true,
compression: 'maximum',
win: {
icon: 'build-resources/icon.ico',
target: [
{
target: 'nsis',
arch: ['x64'],
},
],
},
nsis: {
oneClick: false,
allowElevation: true,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'Ticket Client',
uninstallDisplayName: 'Ticket Client',
runAfterFinish: false,
include: 'build/installer.nsh',
perMachine: true,
deleteAppDataOnUninstall: true,
},
}

@ -6,13 +6,17 @@
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vue-tsc -b && vite build && electron-builder -c electron.config.cjs",
"preview": "vite preview",
"electron:dev": "vite dev --mode electron",
"electron:build": "vue-tsc -b && vite build --mode electron",
"electron:start": "electron ."
"electron:start": "electron .",
"electron:package": "npm run electron:build && electron-builder -c electron.config.js"
},
"dependencies": {
"axios": "^1.13.4",
"element-plus": "^2.13.1",
"pinia": "^3.0.4",
"vue": "^3.5.24"
},
"devDependencies": {
@ -21,6 +25,8 @@
"@vue/tsconfig": "^0.8.1",
"electron": "^40.0.0",
"electron-builder": "^26.4.0",
"sass": "^1.97.3",
"sass-loader": "^16.0.6",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vite-plugin-electron": "^0.29.0",

@ -1,30 +1,29 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<HelloWorld msg="Vite + Vue" />
<HomeView />
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
<script setup lang="ts">
import HomeView from '../src/views/HomeView.vue'
</script>
<style lang="scss">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
body {
font-family:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑',
Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
</style>

@ -0,0 +1,206 @@
<template>
<header class="app-header">
<div class="header-left">
<h1 class="app-title">
<el-icon class="title-icon"><OfficeBuilding /></el-icon>
业务办理系统
</h1>
<div class="header-subtitle">欢迎使用自助服务终端</div>
</div>
<div class="header-right">
<div class="header-info">
<div class="current-time">
<el-icon><Clock /></el-icon>
<span>{{ currentTime }}</span>
</div>
<div class="server-status">
<!-- <el-tag :type="isOnline ? 'success' : 'danger'" size="small">
<el-icon :class="isOnline ? 'online-icon' : 'offline-icon'">
{{ isOnline ? 'SuccessFilled' : 'WarningFilled' }}
</el-icon>
{{ isOnline ? '在线' : '离线' }}
</el-tag> -->
</div>
</div>
<div class="header-actions">
<el-tooltip content="刷新业务列表" placement="bottom">
<el-button type="info" circle @click="handleRefresh" :loading="loading">
<el-icon><Refresh /></el-icon>
</el-button>
</el-tooltip>
<!-- <el-tooltip content="系统设置" placement="bottom">
<el-button type="info" circle>
<el-icon><Setting /></el-icon>
</el-button>
</el-tooltip> -->
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { Clock, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, onUnmounted, ref } from 'vue'
interface Props {
loading?: boolean
}
interface Emits {
(e: 'refresh'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const currentTime = ref('')
const isOnline = ref(true)
const updateCurrentTime = () => {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
const handleRefresh = () => {
emit('refresh')
ElMessage.success('刷新成功')
}
const checkOnlineStatus = async () => {
try {
// const response = await fetch('https://www.google.com', { mode: 'no-cors' })
isOnline.value = true
} catch {
isOnline.value = false
}
}
onMounted(() => {
updateCurrentTime()
const timer = setInterval(updateCurrentTime, 1000)
// 30
checkOnlineStatus()
const networkTimer = setInterval(checkOnlineStatus, 30000)
onUnmounted(() => {
clearInterval(timer)
clearInterval(networkTimer)
})
})
</script>
<script lang="ts">
export default {
name: 'AppHeader',
}
</script>
<style lang="scss" scoped>
.app-header {
background: linear-gradient(135deg, #2c3e50 0%, #1a1a2e 100%);
padding: 16px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
color: white;
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
.app-title {
margin: 0;
font-size: 24px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
.title-icon {
font-size: 28px;
color: #409eff;
}
}
.header-subtitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-left: 36px;
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.header-actions {
display: flex;
gap: 8px;
.el-button {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
.header-info {
display: flex;
flex-direction: column;
gap: 8px;
.current-time {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
.el-icon {
color: #409eff;
}
}
.server-status {
display: flex;
justify-content: flex-end;
.el-tag {
backdrop-filter: blur(10px);
.el-icon {
margin-right: 4px;
&.online-icon {
color: #67c23a;
}
&.offline-icon {
color: #f56c6c;
}
}
}
}
}
}
}
</style>

@ -0,0 +1,322 @@
<template>
<div
class="business-card"
:style="{ '--business-color': business.color || '#409EFF' }"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<el-button
class="business-button"
:type="buttonType"
@click="handleClick"
:loading="isProcessing"
:disabled="isProcessing || disabled"
:circle="isCompact"
>
<div class="business-content" :class="{ compact: isCompact }">
<!-- <div class="business-icon-wrapper">
<div class="business-icon">
<el-icon v-if="business.icon" :size="iconSize">
<component :is="business.icon" />
</el-icon>
<el-icon v-else :size="iconSize"><Operation /></el-icon>
</div>
<div v-if="showBadge" class="business-badge" :class="badgeType">
{{ badgeText }}
</div>
</div> -->
<div class="business-icon-wrapper">
<span class="business-prefix">{{ business.prefix }}</span>
</div>
<div v-if="!isCompact" class="business-info">
<h3 class="business-name">{{ business.name }}</h3>
<!-- <p v-if="business.description" class="business-desc">
{{ business.description }}
</p> -->
<div class="business-footer">
<!-- <span class="business-code">{{ business.prefix }}</span> -->
<span class="business-hint">点击办理</span>
</div>
</div>
<div v-if="isCompact" class="business-compact-info">
<span class="business-name">{{ business.name }}</span>
</div>
</div>
</el-button>
<transition name="slide-up">
<div v-if="isHovered && !isCompact" class="business-hover-effect">
<div class="ripple-effect" :style="rippleStyle"></div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import type { BusinessItem } from '../types/business'
interface Props {
business: BusinessItem
isProcessing?: boolean
disabled?: boolean
compact?: boolean
}
interface Emits {
(e: 'click', business: BusinessItem): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isHovered = ref(false)
//
const buttonType = computed(() => {
const colors: Record<string, string> = {
'#409EFF': 'primary',
'#67C23A': 'success',
'#E6A23C': 'warning',
'#F56C6C': 'danger',
'#909399': 'info',
}
return colors[props.business.color || '#409EFF'] || 'primary'
})
const isCompact = computed(() => props.compact || false)
// const iconSize = computed(() => (isCompact.value ? 24 : 36))
// const showBadge = computed(() => props.business.id === 'hot')
// const badgeType = computed(() => {
// if (props.business.id === 'hot') return 'hot'
// return ''
// })
// const badgeText = computed(() => {
// if (props.business.id === 'hot') return ''
// return ''
// })
const rippleStyle = computed(() => ({
backgroundColor: props.business.color || '#409EFF',
opacity: isHovered.value ? 0.1 : 0,
}))
//
const handleClick = () => {
if (props.disabled || props.isProcessing) return
ElMessage.info(`开始办理 ${props.business.name}`)
emit('click', props.business)
}
</script>
<script lang="ts">
export default {
name: 'BusinessCard',
}
</script>
<style lang="scss" scoped>
.business-card {
--business-color: #409eff;
position: relative;
height: 100%;
.business-button {
width: 100%;
height: 120px; /* 修改这里设置固定高度 */
padding: 20px;
background: white;
border: 2px solid transparent;
border-radius: 16px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
justify-content: flex-start; /* 内容靠左显示 */
&:hover:not(:disabled) {
transform: translateY(-6px);
box-shadow: 0 12px 30px rgba(var(--business-color, 64, 158, 255), 0.25);
border-color: var(--business-color);
.business-icon-wrapper {
transform: scale(1.05);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.is-circle {
width: 80px;
height: 80px;
padding: 0;
}
}
.business-content {
display: flex;
align-items: center;
gap: 16px;
text-align: left;
height: 100%;
&.compact {
flex-direction: column;
gap: 8px;
justify-content: center;
}
}
.business-icon-wrapper {
position: relative;
transition: transform 0.3s ease;
.business-prefix {
width: 60px;
height: 60px;
font-size: 36px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(var(--business-color, 64, 158, 255), 0.15) 0%,
rgba(var(--business-color, 64, 158, 255), 0.05) 100%
);
border-radius: 50%;
border: 2px solid rgba(var(--business-color, 64, 158, 255), 0.3);
color: var(--business-color);
}
.business-icon {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(var(--business-color, 64, 158, 255), 0.15) 0%,
rgba(var(--business-color, 64, 158, 255), 0.05) 100%
);
border-radius: 50%;
border: 2px solid rgba(var(--business-color, 64, 158, 255), 0.3);
.el-icon {
color: var(--business-color);
}
}
.business-badge {
position: absolute;
top: -8px;
right: -8px;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: white;
&.hot {
background: linear-gradient(45deg, #ff6b6b, #ffa726);
}
}
}
.business-info {
flex: 1;
display: flex;
flex-direction: column;
.business-name {
margin: 0 0 8px 0;
color: #303133;
font-size: 18px;
font-weight: 600;
}
.business-desc {
margin: 0 0 12px 0;
color: #909399;
font-size: 13px;
line-height: 1.4;
flex: 1;
}
.business-footer {
display: flex;
justify-content: space-between;
align-items: center;
.business-code {
padding: 4px 10px;
background: rgba(var(--business-color, 64, 158, 255), 0.08);
color: var(--business-color);
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.business-hint {
color: #c0c4cc;
font-size: 11px;
font-weight: 500;
}
}
}
.business-compact-info {
.business-name {
font-size: 12px;
font-weight: 600;
color: #303133;
text-align: center;
}
}
.business-hover-effect {
position: absolute;
top: -8px;
left: 0;
right: 0;
bottom: 8px;
pointer-events: none;
border-radius: 16px;
overflow: hidden;
.ripple-effect {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transition: opacity 0.3s ease;
border-radius: 16px;
}
}
}
//
.slide-up-enter-active,
.slide-up-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>

@ -0,0 +1,105 @@
<template>
<div class="business-grid">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="6" animated />
</div>
<div v-else class="grid-container">
<BusinessCard
v-for="business in businesses"
:key="business.uid"
:business="business"
:is-processing="isProcessing && processingBusinessId === business.tenantId"
:disabled="isProcessing && processingBusinessId !== business.tenantId"
@click="handleBusinessClick(business)"
/>
</div>
<div v-if="!loading && businesses.length === 0" class="empty-state">
<el-empty description="暂无业务数据" />
<el-button type="primary" @click="$emit('refresh')"></el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { BusinessItem } from '../types/business'
import BusinessCard from './BusinessCard.vue'
interface Props {
businesses: BusinessItem[]
loading: boolean
isProcessing?: boolean
processingBusinessId?: string | null
}
interface Emits {
(e: 'business-click', business: BusinessItem): void
(e: 'refresh'): void
(e: 'retry'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const handleBusinessClick = (business: BusinessItem) => {
if (props.isProcessing) {
ElMessage.warning('正在处理其他业务,请稍后再试')
return
}
ElMessage.info(`开始办理 ${business.name}`)
emit('business-click', business)
}
</script>
<script lang="ts">
export default {
name: 'BusinessGrid',
}
</script>
<style lang="scss" scoped>
.business-grid {
height: 100%;
display: flex;
flex-direction: column;
.loading-container {
padding: 20px;
background: white;
border-radius: 16px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 120px; /* 控制每行高度 */
gap: 20px;
padding: 20px;
overflow-y: auto;
flex: 1;
/* 隐藏滚动条但保持滚动功能 */
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 20px;
background: white;
border-radius: 16px;
padding: 40px;
}
}
</style>

@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

@ -0,0 +1,532 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:before-close="handleBeforeClose"
>
<div class="info-input-dialog">
<!-- 业务信息 -->
<div class="business-info-section">
<div class="section-header">
<el-icon><Briefcase /></el-icon>
<span>业务信息</span>
</div>
<div class="business-details">
<div class="business-item">
<span class="label">业务名称</span>
<span class="value">{{ business?.name || '未知业务' }}</span>
</div>
<!-- <div class="business-item">
<span class="label">业务编码</span>
<el-tag type="info">{{ business.prefix }}</el-tag>
</div>
<div class="business-item">
<span class="label">业务描述</span>
<span class="value">{{ business.description || '无描述信息' }}</span>
</div> -->
</div>
</div>
<!-- 客户信息输入 -->
<div class="user-info-section">
<div class="section-header">
<el-icon><User /></el-icon>
<span>请输入客户信息至少填写一项</span>
</div>
<el-form
ref="formRef"
:model="userInfo"
:rules="formRules"
label-width="80px"
label-position="left"
class="user-form"
@submit.prevent
>
<div class="form-grid">
<!-- 票号 -->
<el-form-item label="票号" prop="ticketNumber">
<el-input
v-model="userInfo.ticketNumber"
placeholder="请输入票号(选填)"
clearable
:maxlength="20"
@input="validateForm"
>
<template #prefix>
<el-icon><Ticket /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 手机号 -->
<el-form-item label="手机号" prop="phoneNumber">
<el-input
v-model="userInfo.phoneNumber"
placeholder="请输入手机号(选填)"
clearable
:maxlength="11"
@input="validateForm"
>
<template #prefix>
<el-icon><Iphone /></el-icon>
</template>
</el-input>
<div v-if="userInfo.phoneNumber && !isValidPhone" class="form-tip">
<el-icon color="#E6A23C"><Warning /></el-icon>
<span>手机号格式不正确</span>
</div>
</el-form-item>
<!-- 身份证 -->
<el-form-item label="身份证" prop="idCard">
<el-input
v-model="userInfo.idCard"
placeholder="请输入身份证号(选填)"
clearable
:maxlength="18"
@input="validateForm"
>
<template #prefix>
<el-icon><CreditCard /></el-icon>
</template>
</el-input>
<div v-if="userInfo.idCard && !isValidIdCard" class="form-tip">
<el-icon color="#E6A23C"><Warning /></el-icon>
<span>身份证号格式不正确</span>
</div>
</el-form-item>
<!-- 客户姓名 -->
<el-form-item label="客户姓名" prop="customerName">
<el-input
v-model="userInfo.customerName"
placeholder="请输入客户姓名(选填)"
clearable
:maxlength="50"
@input="validateForm"
>
<template #prefix>
<el-icon><Avatar /></el-icon>
</template>
</el-input>
</el-form-item>
</div>
<!-- 信息验证提示 -->
<div class="validation-hint">
<el-alert v-if="showValidationError" type="warning" :closable="false" show-icon>
请至少填写一项客户信息票号手机号身份证或姓名
</el-alert>
<div v-else class="valid-hint">
<el-icon color="#67C23A"><SuccessFilled /></el-icon>
<span>信息填写完整可以提交</span>
</div>
</div>
<!-- 填写示例 -->
<div class="example-section">
<div class="section-header">
<el-icon><InfoFilled /></el-icon>
<span>填写示例</span>
</div>
<div class="examples">
<div class="example-item">
<span class="label">票号</span>
<code>T20231128001</code>
</div>
<div class="example-item">
<span class="label">手机号</span>
<code>13800138000</code>
</div>
<div class="example-item">
<span class="label">身份证</span>
<code>330102199001011234</code>
</div>
</div>
</div>
</el-form>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCancel" :disabled="submitting"> 取消 </el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
:disabled="!isFormValid"
>
<template #loading>
<span>提交中...</span>
</template>
确定提交
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Briefcase,
CreditCard,
InfoFilled,
Iphone,
SuccessFilled,
Ticket,
User,
Warning,
} from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { computed, nextTick, ref, watch } from 'vue'
import type { UserInfo } from '../shared/types'
import type { BusinessItem } from '../types/business'
interface Props {
modelValue: boolean
business: BusinessItem
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'submit', data: { business: BusinessItem; userInfo: UserInfo }): void
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const visible = ref(false)
const userInfo = ref<UserInfo>({
ticketNumber: '',
phoneNumber: '',
idCard: '',
customerName: '',
})
const submitting = ref(false)
const formRef = ref<FormInstance>()
const showValidationError = ref(false)
//
const formRules: FormRules = {
phoneNumber: [
{
validator: (rule, value, callback) => {
console.log(rule)
if (value && !/^1[3-9]\d{9}$/.test(value)) {
callback(new Error('请输入正确的手机号'))
} else {
callback()
}
},
},
],
idCard: [
{
validator: (rule, value, callback) => {
console.log(rule)
if (value && !/(^\d{15}$)|(^\d{17}(\d|X|x)$)/.test(value)) {
callback(new Error('请输入正确的身份证号'))
} else {
callback()
}
},
},
],
}
//
const dialogTitle = computed(() => {
if (!props.business) return '办理业务'
return `办理 ${props.business.name} 业务`
})
const isValidPhone = computed(() => {
if (!userInfo.value.phoneNumber) return true
return /^1[3-9]\d{9}$/.test(userInfo.value.phoneNumber)
})
const isValidIdCard = computed(() => {
if (!userInfo.value.idCard) return true
return /(^\d{15}$)|(^\d{17}(\d|X|x)$)/.test(userInfo.value.idCard)
})
const isFormValid = computed(() => {
const { ticketNumber, phoneNumber, idCard, customerName } = userInfo.value
const hasInfo = !!ticketNumber || !!phoneNumber || !!idCard || !!customerName
const phoneValid = !phoneNumber || isValidPhone.value
const idCardValid = !idCard || isValidIdCard.value
return hasInfo && phoneValid && idCardValid
})
//
watch(
() => props.modelValue,
newVal => {
visible.value = newVal
if (newVal) {
resetForm()
}
}
)
watch(visible, newVal => {
if (newVal !== props.modelValue) {
emit('update:modelValue', newVal)
}
})
//
const resetForm = () => {
userInfo.value = {
ticketNumber: '',
phoneNumber: '',
idCard: '',
customerName: '',
}
showValidationError.value = false
if (formRef.value) {
formRef.value.clearValidate()
}
}
const validateForm = () => {
nextTick(() => {
const { ticketNumber, phoneNumber, idCard, customerName } = userInfo.value
const hasInfo = !!ticketNumber || !!phoneNumber || !!idCard || !!customerName
showValidationError.value = !hasInfo
})
}
const handleBeforeClose = (done: () => void) => {
if (submitting.value) {
ElMessage.warning('正在提交,请稍候...')
return
}
handleCancel()
done()
}
const handleCancel = () => {
if (submitting.value) return
ElMessage.info('已取消信息输入')
emit('cancel')
visible.value = false
}
const handleSubmit = async () => {
if (!isFormValid.value || submitting.value) return
try {
submitting.value = true
//
const valid = await formRef.value?.validate()
if (!valid) return
//
const cleanedInfo: UserInfo = {
ticketNumber: userInfo.value.ticketNumber?.trim(),
phoneNumber: userInfo.value.phoneNumber?.trim(),
idCard: userInfo.value.idCard?.trim(),
customerName: userInfo.value.customerName?.trim(),
}
//
emit('submit', {
business: props.business,
userInfo: cleanedInfo,
})
} catch (error) {
console.error('表单验证失败:', error)
ElMessage.error('请检查填写的信息是否正确')
} finally {
submitting.value = false
}
}
//
// const generateExampleData = () => {
// if (import.meta.env.DEV) {
// userInfo.value = {
// ticketNumber: `T${Date.now().toString().slice(-8)}`,
// phoneNumber: '13800138000',
// idCard: '330102199001011234',
// customerName: '',
// }
// validateForm()
// }
// }
//
// defineExpose({
// generateExampleData,
// })
</script>
<style lang="scss" scoped>
.info-input-dialog {
.business-info-section,
.user-info-section,
.example-section {
margin-bottom: 24px;
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
.el-icon {
color: #409eff;
font-size: 18px;
}
span {
font-weight: 600;
color: #303133;
font-size: 16px;
}
}
}
.business-details {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
border: 1px solid #e4e7ed;
.business-item {
display: flex;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: #909399;
font-size: 14px;
flex-shrink: 0;
}
.value {
color: #303133;
font-size: 14px;
flex: 1;
}
}
}
.user-form {
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 5px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.form-tip {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
font-size: 12px;
color: #e6a23c;
.el-icon {
font-size: 14px;
}
}
}
.validation-hint {
margin: 16px 0;
.valid-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 4px;
color: #67c23a;
font-size: 14px;
.el-icon {
font-size: 18px;
}
}
}
.example-section {
.examples {
background: #f5f7fa;
padding: 16px;
border-radius: 8px;
border: 1px dashed #dcdfe6;
.example-item {
display: flex;
align-items: center;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: #606266;
font-size: 13px;
flex-shrink: 0;
}
code {
background: white;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #e4e7ed;
color: #f56c6c;
font-family: 'Consolas', monospace;
font-size: 13px;
}
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
.el-button {
min-width: 100px;
}
}
</style>
<script lang="ts">
export default {
name: 'InfoInputDialog',
}
</script>

@ -0,0 +1,694 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="550px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
class="print-dialog"
:class="printStatus"
>
<div class="print-content">
<!-- 打印中状态 -->
<div v-if="isPrinting" class="printing-status">
<div class="print-animation">
<div class="printer-icon">
<el-icon><Printer /></el-icon>
</div>
<div class="paper-animation">
<div class="paper-sheet" v-for="n in 3" :key="n" :style="{ '--delay': n }"></div>
</div>
</div>
<div class="print-progress">
<el-progress
:percentage="progress"
:status="progressStatus"
:stroke-width="8"
striped
striped-flow
/>
<p class="progress-text">{{ progressText }}</p>
</div>
<div class="print-steps">
<el-steps :active="activeStep" align-center>
<el-step title="处理业务" description="正在处理您的业务请求" />
<el-step title="打印小票" description="正在打印业务凭证" />
<el-step title="完成" description="业务办理完成" />
</el-steps>
</div>
</div>
<!-- 成功状态 -->
<div v-else-if="isSuccess" class="success-status">
<div class="success-animation">
<div class="success-icon-wrapper">
<el-icon class="success-icon"><CircleCheck /></el-icon>
<div class="success-circle"></div>
</div>
</div>
<div class="success-header">
<h3>取号成功</h3>
<p class="success-message">您的业务已成功办理请妥善保管小票</p>
</div>
<div class="ticket-details">
<div class="ticket-header">
<div class="ticket-title">
<el-icon><Ticket /></el-icon>
<span>业务凭证</span>
</div>
<el-tag type="success" size="small">
<el-icon><Time /></el-icon>
已打印
</el-tag>
</div>
<el-descriptions :column="1" border class="ticket-info">
<el-descriptions-item label="票号">
<div class="ticket-number">
<span class="number-value">{{ ticketInfo?.ticketNumber || '未知' }}</span>
<el-button type="text" size="small" @click="copyTicketNumber">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="业务名称">
<el-tag :color="getBusinessColor(ticketInfo?.businessCode || 'UNK')">
{{ ticketInfo?.businessName || '未知业务' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="排队号码">
<div class="queue-number">
<span class="number-highlight">{{ ticketInfo?.queueNumber || 0 }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="办理窗口">
<div class="window-info">
<el-icon><Monitor /></el-icon>
<span>{{ ticketInfo?.windowNumber || '请等待叫号' }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="预计等待时间">
<div class="wait-time">
<el-icon><Clock /></el-icon>
<span>{{ ticketInfo?.estimatedWaitTime || '约5-10分钟' }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="取号时间">
{{ ticketInfo?.createTime ? formatTime(ticketInfo.createTime) : '未知时间' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="ticket-notice">
<el-alert type="info" :closable="false">
<template #title>
<span class="notice-content">
<el-icon><InfoFilled /></el-icon>
温馨提示请及时到指定窗口办理业务过号需重新取号
</span>
</template>
</el-alert>
</div>
</div>
<!-- 失败状态 -->
<div v-else-if="isFailed" class="failed-status">
<div class="failed-animation">
<el-icon class="failed-icon"><CircleClose /></el-icon>
</div>
<div class="failed-header">
<h3>打印失败</h3>
<p class="failed-message">{{ errorMessage || '小票打印失败,请重试' }}</p>
</div>
<div class="failed-actions">
<el-button type="danger" plain @click="retryPrint">
<el-icon><Refresh /></el-icon>
重新打印
</el-button>
<el-button type="info" plain @click="manualPrint">
<el-icon><Printer /></el-icon>
手动打印
</el-button>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button v-if="isPrinting" type="info" @click="cancelPrint" :loading="cancelling">
<el-icon><Close /></el-icon>
取消打印
</el-button>
<el-button v-else type="primary" @click="handleConfirm">
<el-icon><Check /></el-icon>
{{ confirmText }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import {
Check,
CircleCheck,
CircleClose,
Clock,
Close,
CopyDocument,
InfoFilled,
Monitor,
Printer,
Refresh,
Ticket,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref, watch } from 'vue'
import type { PrintStatusType, TicketInfo } from '../shared/types'
import { PrintStatus } from '../shared/types'
interface Props {
modelValue: boolean
printStatus: PrintStatusType
ticketInfo: TicketInfo | null
errorMessage?: string
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'cancel'): void
(e: 'retry'): void
(e: 'manual-print'): void
(e: 'confirm'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
//
const visible = ref(false)
const progress = ref(0)
const activeStep = ref(0)
const cancelling = ref(false)
//
const dialogTitle = computed(() => {
switch (props.printStatus) {
case PrintStatus.PRINTING:
return '正在打印小票'
case PrintStatus.SUCCESS:
return '取号成功'
case PrintStatus.FAILED:
return '打印失败'
default:
return '业务办理'
}
})
const isPrinting = computed(() => props.printStatus === PrintStatus.PRINTING)
const isSuccess = computed(() => props.printStatus === PrintStatus.SUCCESS)
const isFailed = computed(() => props.printStatus === PrintStatus.FAILED)
const progressStatus = computed(() => {
if (progress.value < 30) return 'exception'
if (progress.value < 70) return 'warning'
return 'success'
})
const progressText = computed(() => {
if (progress.value < 30) return '正在处理业务...'
if (progress.value < 70) return '正在打印小票...'
return '即将完成...'
})
const confirmText = computed(() => {
if (isSuccess.value) return '我知道了'
if (isFailed.value) return '关闭'
return '确定'
})
//
let timer: number | null = null
const startProgress = () => {
progress.value = 0
activeStep.value = 0
//
if (timer) {
clearInterval(timer)
timer = null
}
timer = window.setInterval(() => {
if (progress.value < 100) {
progress.value += 2
if (progress.value >= 30) activeStep.value = 1
if (progress.value >= 70) activeStep.value = 2
} else {
clearInterval(timer!)
timer = null
}
}, 100)
}
const stopProgress = () => {
progress.value = 100
if (timer) {
clearInterval(timer)
timer = null
}
}
//
watch(
() => props.modelValue,
newVal => {
visible.value = newVal
}
)
watch(visible, newVal => {
if (newVal !== props.modelValue) {
emit('update:modelValue', newVal)
}
if (newVal && isPrinting.value) {
startProgress()
}
})
watch(
isPrinting,
printing => {
if (printing) {
startProgress()
} else {
stopProgress()
if (isSuccess.value) {
progress.value = 100
activeStep.value = 3
}
}
},
{ immediate: true }
)
const formatTime = (time: string) => {
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
const getBusinessColor = (code: string): string => {
const colors: Record<string, string> = {
DEP: '#67C23A',
WIT: '#409EFF',
ACC: '#E6A23C',
TRA: '#909399',
CON: '#409EFF',
}
const prefix = code.substring(0, 3)
return colors[prefix] || '#409EFF'
}
const copyTicketNumber = () => {
if (!props.ticketInfo?.ticketNumber) {
ElMessage.warning('票号信息为空,无法复制')
return
}
navigator.clipboard
.writeText(props.ticketInfo.ticketNumber)
.then(() => ElMessage.success('票号已复制到剪贴板'))
.catch(() => ElMessage.error('复制失败'))
}
const cancelPrint = () => {
cancelling.value = true
emit('cancel')
setTimeout(() => {
cancelling.value = false
visible.value = false
}, 500)
}
const retryPrint = () => {
emit('retry')
}
const manualPrint = () => {
emit('manual-print')
}
const handleConfirm = () => {
emit('confirm')
visible.value = false
}
onMounted(() => {
visible.value = props.modelValue
})
</script>
<script lang="ts">
export default {
name: 'PrintDialog',
}
</script>
<style lang="scss" scoped>
.print-dialog {
&.printing {
:deep(.el-dialog__header) {
border-bottom: 2px solid #409eff;
}
}
&.success {
:deep(.el-dialog__header) {
border-bottom: 2px solid #67c23a;
}
}
&.failed {
:deep(.el-dialog__header) {
border-bottom: 2px solid #f56c6c;
}
}
}
.print-content {
.printing-status {
.print-animation {
display: flex;
justify-content: center;
align-items: center;
gap: 30px;
margin-bottom: 30px;
.printer-icon {
font-size: 60px;
color: #409eff;
animation: pulse 2s infinite;
}
.paper-animation {
position: relative;
width: 60px;
height: 100px;
.paper-sheet {
position: absolute;
width: 50px;
height: 30px;
background: white;
border: 1px solid #dcdfe6;
border-radius: 4px;
animation: paperMove 3s infinite;
animation-delay: calc(var(--delay, 0) * 0.5s);
&::after {
content: '';
position: absolute;
top: 50%;
left: 10px;
right: 10px;
height: 2px;
background: #ebeef5;
transform: translateY(-50%);
}
}
}
}
.print-progress {
margin-bottom: 30px;
.progress-text {
text-align: center;
color: #606266;
margin-top: 8px;
font-size: 14px;
}
}
.print-steps {
:deep(.el-step__head) {
&.is-process {
color: #409eff;
border-color: #409eff;
}
}
}
}
.success-status {
.success-animation {
display: flex;
justify-content: center;
margin-bottom: 20px;
.success-icon-wrapper {
position: relative;
.success-icon {
font-size: 60px;
color: #67c23a;
position: relative;
z-index: 2;
}
.success-circle {
position: absolute;
top: 50%;
left: 50%;
width: 80px;
height: 80px;
background: rgba(103, 194, 58, 0.1);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: circleExpand 2s ease-out;
}
}
}
.success-header {
text-align: center;
margin-bottom: 25px;
h3 {
margin: 0 0 8px 0;
color: #303133;
font-size: 22px;
}
.success-message {
margin: 0;
color: #909399;
font-size: 14px;
}
}
.ticket-details {
background: #f5f7fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
.ticket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.ticket-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #303133;
.el-icon {
color: #409eff;
}
}
}
.ticket-info {
:deep(.el-descriptions__label) {
width: 120px;
font-weight: 500;
}
.ticket-number {
display: flex;
align-items: center;
justify-content: space-between;
.number-value {
font-size: 18px;
font-weight: 600;
color: #67c23a;
letter-spacing: 1px;
}
}
.queue-number {
font-size: 16px;
.number-highlight {
color: #f56c6c;
font-size: 24px;
font-weight: bold;
margin: 0 4px;
}
}
.window-info,
.wait-time {
display: flex;
align-items: center;
gap: 6px;
.el-icon {
color: #409eff;
}
}
}
}
.ticket-notice {
.notice-content {
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
}
}
}
.failed-status {
text-align: center;
.failed-animation {
margin-bottom: 20px;
.failed-icon {
font-size: 60px;
color: #f56c6c;
animation: shake 0.5s ease-in-out;
}
}
.failed-header {
margin-bottom: 30px;
h3 {
margin: 0 0 8px 0;
color: #303133;
font-size: 22px;
}
.failed-message {
margin: 0;
color: #909399;
font-size: 14px;
}
}
.failed-actions {
display: flex;
justify-content: center;
gap: 16px;
}
}
}
.dialog-footer {
display: flex;
justify-content: center;
.el-button {
min-width: 120px;
}
}
//
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes paperMove {
0% {
transform: translateY(0);
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
transform: translateY(-80px);
opacity: 0;
}
}
@keyframes circleExpand {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0;
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
</style>

@ -1,30 +1,59 @@
import { app, BrowserWindow } from 'electron'
import { fileURLToPath } from 'url'
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
let mainWindow: BrowserWindow | null = null
const isDev = process.env.NODE_ENV === 'development'
const createWindow = () => {
mainWindow = new BrowserWindow({
width: 600,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
// preload: path.join(__dirname, '../../../dist-electron/preload/index.js'),
},
autoHideMenuBar: true,
})
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL as string)
} else {
mainWindow.loadFile(path.join(__dirname, '../../dist/index.html'))
}
if (isDev) {
mainWindow.webContents.openDevTools()
}
}
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '../preload/index.js')
}
})
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL as string)
} else {
win.loadFile(path.join(__dirname, '../../dist/index.html'))
}
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
if (process.platform !== 'darwin') {
app.quit()
}
})
// IPC处理器
ipcMain.handle('print-ticket', async (event, ticketInfo) => {
// 这里可以连接实际的打印机
console.log('打印小票:', ticketInfo, event)
// 模拟打印延迟
await new Promise(resolve => setTimeout(resolve, 1000))
return {
success: true,
message: '小票打印成功',
}
})

@ -1,8 +1,34 @@
// 预加载脚本
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
// 暴露API给渲染进程
// 暴露安全的API给渲染进程
contextBridge.exposeInMainWorld('electronAPI', {
// 可以在这里添加需要暴露的API
platform: process.platform
// 打印相关
printTicket: (ticketInfo: any) => ipcRenderer.invoke('print-ticket', ticketInfo),
// 业务相关
getBusinesses: () => ipcRenderer.invoke('get-businesses'),
// 窗口控制
minimize: () => ipcRenderer.send('window-minimize'),
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'),
// 监听事件
onWindowStateChange: (callback: (state: string) => void) => {
ipcRenderer.on('window-state-changed', (_, state) => callback(state))
},
})
// 声明全局类型
declare global {
interface Window {
electronAPI: {
printTicket: (ticketInfo: any) => Promise<any>
getBusinesses: () => Promise<any>
minimize: () => void
maximize: () => void
close: () => void
onWindowStateChange: (callback: (state: string) => void) => void
}
}
}

@ -1,5 +1,43 @@
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { userLogin } from './services/authService'
import './style.css'
// 应用启动初始化
const initApp = async () => {
try {
// 执行自动登录
const loginSuccess = await userLogin()
if (!loginSuccess) {
console.warn('自动登录失败,应用将继续运行但部分功能可能受限')
}
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(ElementPlus, {
size: 'default',
zIndex: 2000,
})
app.mount('#app')
} catch (error) {
console.error('应用启动失败:', error)
}
}
createApp(App).mount('#app')
// 启动应用
initApp()

@ -0,0 +1,142 @@
import type { LoginRequest, LoginResponse } from '../types/user'
import service from './service'
// 默认登录凭据(可根据需要修改或从环境变量获取)
const DEFAULT_LOGIN_CREDENTIALS: LoginRequest = {
username: 'admin',
password: 'admin',
tenantId: '',
}
export const userLogin = async (data?: Partial<LoginRequest>): Promise<LoginResponse> => {
try {
const loginData = data || DEFAULT_LOGIN_CREDENTIALS
const response: LoginResponse = await service.post('/pad/auth/login', loginData)
console.log('登录响应:', response)
// 检查响应是否成功且包含有效数据
if (response.accessToken) {
const loginData = response
console.log('登录成功')
// 安全地保存token到localStorage
if (loginData.accessToken) {
localStorage.setItem('access_token', loginData.accessToken)
localStorage.setItem('refresh_token', loginData.refreshToken || '')
// 计算token过期时间默认1小时
const expiresIn = loginData.expiresIn || 3600
localStorage.setItem('token_expires', (Date.now() + expiresIn * 1000).toString())
// 安全保存用户信息
if (loginData.userInfo) {
localStorage.setItem('user_info', JSON.stringify(loginData.userInfo))
}
}
} else {
console.error('登录响应错误:', response)
throw new Error('登录失败')
}
return response
} catch (error) {
console.error('登录异常:', error)
throw error
}
}
/*
export const authService = {
// 用户登录
login: async (credentials?: LoginRequest): Promise<LoginResponse> => {
const loginData = credentials || DEFAULT_LOGIN_CREDENTIALS
try {
const response = await service.post<LoginResponse>('/pad/auth/login', loginData)
console.log('登录响应:', response)
// 检查响应是否成功且包含有效数据
if (response.accessToken === 200 && response.data) {
const loginData = response.data
console.log('登录成功')
// 安全地保存token到localStorage
if (loginData.accessToken) {
localStorage.setItem('access_token', loginData.accessToken)
localStorage.setItem('refresh_token', loginData.refreshToken || '')
// 计算token过期时间默认1小时
const expiresIn = loginData.expiresIn || 3600
localStorage.setItem('token_expires', (Date.now() + expiresIn * 1000).toString())
// 安全保存用户信息
if (loginData.userInfo) {
localStorage.setItem('user_info', JSON.stringify(loginData.userInfo))
}
}
} else {
console.error('登录响应错误:', response.data)
throw new Error(response.data?.message || '登录失败')
}
return response.data
} catch (error) {
console.error('登录失败:', error)
throw error
}
},
// 检查token是否有效
isTokenValid: (): boolean => {
const token = localStorage.getItem('access_token')
const expires = localStorage.getItem('token_expires')
if (!token || !expires) {
return false
}
return Date.now() < parseInt(expires)
},
// 获取当前token
getToken: (): string | null => {
return localStorage.getItem('access_token')
},
// 清除认证信息
logout: (): void => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('token_expires')
localStorage.removeItem('user_info')
},
}
// 应用启动时自动登录
export const initializeAuth = async (): Promise<boolean> => {
// 检查是否已有有效token避免重复登录
if (authService.isTokenValid()) {
console.log('检测到有效token跳过自动登录')
return true
}
// 清除过期的认证信息
authService.logout()
try {
const result = await authService.login()
if (result.success) {
console.log('自动登录成功')
return true
} else {
console.error('自动登录失败:', result.message)
return false
}
} catch (error) {
console.error('自动登录异常:', error)
return false
}
}
*/

@ -0,0 +1,258 @@
import type { BusinessItem } from '../types/business'
import service from './service'
export const getBusinessList = async (): Promise<BusinessItem[]> => {
try {
// 后端直接返回业务数组不是包裹在data字段中的格式
const response = await service.get<BusinessItem[]>('/pad/business')
return response.data
// return response
} catch (error) {
console.error('获取业务列表失败:', error)
throw error
}
}
/*
// 声明Electron API类型
export interface ElectronAPI {
printTicket: (ticketInfo: any) => Promise<void>
}
// declare global {
// interface Window {
// electronAPI?: ElectronAPI
// }
// }
// 将后端业务数据转换为前端需要的格式
const transformBusinesses = (backendBusinesses: BusinessItem[]): BusinessItem[] => {
return backendBusinesses.map(business => {
// 根据业务类型设置不同的图标和颜色
const typeConfig = getBusinessTypeConfig(business.type)
return {
...business,
id: business.uid.toString(),
code: business.prefix, // code 来自 prefix 字段
description: getBusinessDescription(business.type),
apiEndpoint: `/business/${business.uid}`,
icon: typeConfig.icon,
color: typeConfig.color,
}
})
}
// 业务类型配置常量,提高可维护性
const BUSINESS_TYPE_CONFIGS = {
0: { icon: 'Operation', color: '#409EFF', name: '测试' },
1: { icon: 'Money', color: '#67C23A', name: '存款' },
2: { icon: 'BankCard', color: '#409EFF', name: '取款' },
3: { icon: 'DocumentAdd', color: '#E6A23C', name: '开户' },
4: { icon: 'DocumentRemove', color: '#F56C6C', name: '销户' },
5: { icon: 'Refresh', color: '#909399', name: '转账' },
6: { icon: 'ChatLineRound', color: '#409EFF', name: '咨询' },
} as const
// 默认配置,用于未知业务类型
const DEFAULT_CONFIG = { icon: 'Operation', color: '#409EFF', name: '未知业务' }
// 根据业务类型获取配置
const getBusinessTypeConfig = (type: number) => {
// 验证输入类型是否为有效数字
if (typeof type !== 'number' || !Number.isInteger(type)) {
console.warn(`无效的业务类型: ${type},使用默认配置`)
return DEFAULT_CONFIG
}
// 检查类型是否在预定义的配置中
const config = BUSINESS_TYPE_CONFIGS[type as keyof typeof BUSINESS_TYPE_CONFIGS]
if (!config) {
console.warn(`未知业务类型: ${type},使用默认配置`)
return DEFAULT_CONFIG
}
return config
}
// 根据业务类型生成描述
const getBusinessDescription = (type: number) => {
const descriptions: Record<number, string> = {
0: '测试业务',
1: '现金存款、转账存款',
2: '现金取款、转账取款',
3: '新开银行账户',
4: '注销银行账户',
5: '行内转账、跨行转账',
6: '业务咨询、信息查询',
}
const description = descriptions[type]
if (!description) {
console.warn(`未知业务类型 ${type},使用默认描述`)
return `业务类型 ${type} 办理`
}
return description
}
// 打印小票的公共方法
const printTicket = async (ticketInfo: any): Promise<void> => {
const electronAPI = window.electronAPI
if (electronAPI && typeof electronAPI.printTicket === 'function') {
try {
await electronAPI.printTicket(ticketInfo)
} catch (printError) {
console.error('打印小票失败:', printError)
throw new Error('打印设备连接失败,请检查打印机状态')
}
} else {
console.warn('Electron API不可用跳过打印操作')
}
}
// 将取号响应转换为前端需要的TicketInfo格式
const transformCreateJumpResponse = (response: TicketResponseData, business: BusinessItem) => {
return {
ticketId: response.uid.toString(),
ticketNumber: response.tktId,
businessCode: business.prefix,
businessName: business.name,
createTime: response.tktDate,
queueNumber: response.waitingCount,
estimatedWaitTime: `${response.estimatedWaitMinutes}分钟`,
windowNumber: response.windowNames,
}
}
export const businessService = {
// 获取业务列表
getBusinessList: async (): Promise<ApiResponse<BusinessItem[]>> => {
try {
// 后端直接返回业务数组不是包裹在data字段中的格式
const backendBusinesses = await service.get<BusinessItem[]>('/pad/business')
// 过滤掉已删除的业务,并检查空数组
const validBusinesses = (backendBusinesses.data || []).filter(business => !business.deleted)
if (validBusinesses.length === 0) {
console.warn('获取到的业务列表为空或所有业务已被删除')
}
// 将后端数据转换为前端需要的格式
const transformedBusinesses = transformBusinesses(validBusinesses)
// 返回转换后的数据
return {
code: 200,
message: 'success',
data: transformedBusinesses,
}
} catch (error) {
console.error('获取业务列表失败:', error)
// 提供更具体的错误信息
if (error instanceof Error) {
if (error.message.includes('Network Error')) {
throw new Error('网络连接失败,请检查网络连接后重试')
} else if (error.message.includes('404')) {
throw new Error('业务接口不存在,请联系管理员')
} else if (error.message.includes('401')) {
throw new Error('认证失败,请重新登录')
}
}
throw new Error('获取业务列表失败,请稍后重试')
}
},
// 处理业务(取号)
processBusiness: async (
business: BusinessItem,
userInfo?: {
ticketNumber?: string
phoneNumber?: string
idCard?: string
customerName?: string
}
): Promise<ApiResponse<PrintResponse>> => {
// 构建取号请求参数
const createJumpRequest = {
bizUid: business.uid,
rankUserName: userInfo?.customerName || '',
rankUserPhone: userInfo?.phoneNumber || '',
idCard: userInfo?.idCard || '',
enterpriseId: userInfo?.ticketNumber || '', // 使用票号作为企业ID
appointmentUid: 0,
}
let createJumpResponse: TicketResponseData
if (!import.meta.env.DEV) {
// 开发环境模拟取号接口调用
await new Promise(resolve => setTimeout(resolve, 1000))
createJumpResponse = {
uid: Math.floor(Math.random() * 10000) + 1000,
tktId: `${business.prefix}${Math.floor(Math.random() * 100)
.toString()
.padStart(3, '0')}`,
tktIntid: Math.floor(Math.random() * 100) + 1,
status: 0,
bizUid: business.uid,
bizName: business.name,
bizPrefix: business.prefix,
waitingCount: Math.floor(Math.random() * 20) + 1,
estimatedWaitMinutes: Math.floor(Math.random() * 30) + 5,
tktDate: new Date().toISOString(),
tktTime: new Date().toISOString(),
hallName: '营业大厅',
windowNames: '1号窗口, 2号窗口',
message: `您是今天第${Math.floor(Math.random() * 100) + 1}位取号`,
}
} else {
// 生产环境调用实际接口
const response = await service.post<TicketResponseData>(
'/pad/ticket/create-jump',
createJumpRequest
)
if (!response.data) {
throw new Error('取号接口返回数据为空')
}
// 检查API响应状态码
if (response.code !== 0) {
throw new Error(`取号失败: ${response.message}`)
}
createJumpResponse = response.data
}
// 转换为前端需要的TicketInfo格式
const ticketInfo = transformCreateJumpResponse(createJumpResponse, business)
// 调用打印功能
try {
await printTicket(ticketInfo)
} catch (error) {
// 打印失败时记录日志但不中断业务处理
console.error('打印失败,但业务已成功处理:', error)
}
return {
code: 200,
message: '业务处理成功',
data: {
ticketInfo,
printStatus: 'success',
message: createJumpResponse.message || '小票打印完成',
},
success: true,
}
},
}
*/

@ -0,0 +1,79 @@
// src/utils/http.ts
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import axios from 'axios'
// 可选:定义通用响应结构(根据后端约定调整)
interface ApiResponse<T = any> {
code: number
message: string
data: T
}
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://192.168.0.129:8845/api'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: baseURL, // 使用 Vite 环境变量
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 添加 token 示例
const token = localStorage.getItem('access_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { code, message, data } = response.data
// 根据业务状态码判断
if (code === 200) {
return data // 直接返回 data
} else {
// 业务错误(如 token 失效、权限不足等)
console.error('业务错误:', message)
return Promise.reject(new Error(message))
}
},
error => {
// 网络错误或 HTTP 状态码非 2xx
if (error.response) {
// 服务器返回了错误状态码
switch (error.response.status) {
case 401:
// 未授权,跳转登录
console.error('未授权的操作')
break
case 403:
console.error('Forbidden')
break
case 500:
console.error('服务器内部错误')
break
default:
console.error('请求失败:', error.response.data)
}
} else if (error.request) {
console.error('无响应:', error.request)
} else {
console.error('请求配置错误:', error.message)
}
return Promise.reject(error)
}
)
export default service

@ -0,0 +1,157 @@
// API响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
timestamp: string
databaseType?: string
}
// 登录响应数据
export interface LoginResponse {
accessToken: string
refreshToken: string
tokenType: string
expiresIn: number
userInfo: {
id: string
username: string
realName: string
role: string
tenantId: string
uid: string
image?: string
}
}
// // 后端业务类型定义(根据实际接口响应格式)
// export interface BusinessItem {
// uid: number
// tenantId: string
// deleted: boolean
// prefix: string
// name: string
// enabled?: number
// type: number
// color: string
// }
// 票号信息
export interface TicketInfo {
uid: number
ticketId: string
ticketNumber: number
status: number
statusText: string
businessId: number
businessName: string
businessPrefix: string
waitingCount: number
estimatedWaitMinutes: number
ticketDate: string
ticketTime: string
hallName: string
windowName: string
message: string
createTime: string
}
// 打印状态 - 使用常量对象替代枚举
export const PrintStatus = {
IDLE: 'idle',
PRINTING: 'printing',
SUCCESS: 'success',
FAILED: 'failed',
} as const
export type PrintStatusType = (typeof PrintStatus)[keyof typeof PrintStatus]
// 登录请求参数
export interface LoginRequest {
username: string
password: string
tenantId: string
}
// 业务处理状态
export interface BusinessState {
businesses: BusinessItem[]
loading: boolean
isProcessing: boolean
processingBusinessId: string | null
printStatus: PrintStatusType
currentTicket: TicketInfo | null
}
// 新增:用户信息输入
export interface UserInfo {
ticketNumber?: string // 票号(可选)
phoneNumber?: string // 手机号(可选)
idCard?: string // 身份证(可选)
customerName?: string // 客户姓名(可选)
}
// 新增:业务提交数据
export interface BusinessSubmitData {
business: BusinessItem
userInfo: UserInfo
}
// 更新:打印响应类型
export interface PrintResponse {
ticketInfo: TicketInfo
printStatus: 'success' | 'failed'
message: string
submittedData?: BusinessSubmitData // 新增:提交的数据
}
// 新增:取号请求参数
export interface CreateTicketRequest {
bizUid: number // 业务类型ID
rankUserName?: string // 用户姓名
rankUserPhone?: string // 用户手机号
idCard?: string // 身份证号
enterpriseId?: string // 企业ID
appointmentUid?: number // 预约ID
priority?: boolean // 是否优先(新增)
customerType?: string // 客户类型(新增)
}
// 新增:取号响应数据
export interface TicketResponseData {
uid: number // 用户ID
tktId: string // 票号(完整)
tktIntid: number // 票号(数字部分)
status: number // 状态 0=等待中,1=办理中,2=已完成,3=已取消
bizUid: number // 业务类型ID
bizName: string // 业务名称
bizPrefix: string // 业务前缀
waitingCount: number // 等待人数
estimatedWaitMinutes: number // 预计等待分钟数
tktDate: string // 取号日期
tktTime: string // 取号时间
hallName: string // 大厅名称
windowNames: string // 可办理窗口
message: string // 提示消息
}
// 新增API基础响应
export interface ApiBaseResponse<T = any> {
code: number
message: string
data: T
pagination?: any
timestamp: string
requestId?: string
databaseType?: string
}
// 新增:业务映射配置
export interface BusinessConfig {
bizUid: number
bizName: string
bizPrefix: string
hallName: string
windows: string
description?: string
}

@ -0,0 +1,246 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { getBusinessList } from '../services/businessService'
import type { PrintStatusType, TicketInfo } from '../shared/types'
import { PrintStatus } from '../shared/types'
import type { BusinessItem } from '../types/business'
export const useBusinessStore = defineStore('business', () => {
// State
const businesses = ref<BusinessItem[]>([])
const loading = ref(false)
const isProcessing = ref(false)
const processingBusinessId = ref<string | null>(null)
const printStatus = ref<PrintStatusType>(PrintStatus.IDLE)
const currentTicket = ref<TicketInfo | null>(null)
// Getters
const hasBusinesses = computed(() => businesses.value.length > 0)
const currentProcessingBusiness = computed(() =>
businesses.value.find(b => b.uid.toString() === processingBusinessId.value)
)
const getBusinessById = (id: string) => businesses.value.find(b => b.uid.toString() === id)
// Actions
const fetchBusinesses = async () => {
try {
loading.value = true
const response = await getBusinessList()
// 转换业务数据添加缺失的color字段以匹配shared/types中的BusinessItem
const transformedBusinesses = response.map(business => {
// 将来自../types/business的BusinessItem转换为../shared/types的BusinessItem
return {
...business,
color: (business as any).color || getBusinessColorByType(business.type), // 添加缺失的颜色
} as BusinessItem
})
businesses.value = transformedBusinesses
return transformedBusinesses
} catch (error) {
console.error('Failed to fetch businesses:', error)
throw error
} finally {
loading.value = false
}
}
// 根据业务类型获取颜色
const getBusinessColorByType = (type: number): string => {
const typeColors: Record<number, string> = {
0: '#409EFF',
1: '#67C23A',
2: '#409EFF',
3: '#E6A23C',
4: '#F56C6C',
5: '#909399',
6: '#409EFF',
}
return typeColors[type] || '#409EFF'
}
const processBusiness = async (
business: BusinessItem,
userInfo?: {
ticketNumber?: string
phoneNumber?: string
idCard?: string
customerName?: string
}
) => {
try {
isProcessing.value = true
processingBusinessId.value = business.uid.toString()
printStatus.value = PrintStatus.PRINTING
// 由于无法访问businessService.processBusiness这里需要实现实际的API调用
// 模拟实际的业务处理流程
const createJumpRequest = {
bizUid: business.uid,
rankUserName: userInfo?.customerName || '',
rankUserPhone: userInfo?.phoneNumber || '',
idCard: userInfo?.idCard || '',
enterpriseId: userInfo?.ticketNumber || '',
appointmentUid: 0,
}
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
const createJumpResponse = {
uid: Math.floor(Math.random() * 10000) + 1000,
tktId: `${business.prefix}${Math.floor(Math.random() * 100)
.toString()
.padStart(3, '0')}`,
tktIntid: Math.floor(Math.random() * 100) + 1,
status: 0,
bizUid: business.uid,
bizName: business.name,
bizPrefix: business.prefix,
waitingCount: Math.floor(Math.random() * 20) + 1,
estimatedWaitMinutes: Math.floor(Math.random() * 30) + 5,
tktDate: new Date().toISOString(),
tktTime: new Date().toISOString(),
hallName: '营业大厅',
windowNames: '1号窗口, 2号窗口',
message: `您是今天第${Math.floor(Math.random() * 100) + 1}位取号`,
}
// 模拟打印延迟
await new Promise(resolve => setTimeout(resolve, 1500))
// 转换响应数据
const ticketInfo: TicketInfo = {
uid: createJumpResponse.uid,
ticketId: createJumpResponse.uid.toString(),
ticketNumber: parseInt(createJumpResponse.tktId.match(/\d+/)?.[0] || '0'), // 提取数字部分
status: createJumpResponse.status,
statusText: 'success',
businessId: createJumpResponse.bizUid,
businessName: createJumpResponse.bizName,
businessPrefix: createJumpResponse.bizPrefix,
waitingCount: createJumpResponse.waitingCount,
estimatedWaitMinutes: createJumpResponse.estimatedWaitMinutes,
ticketDate: createJumpResponse.tktDate,
ticketTime: createJumpResponse.tktTime,
hallName: createJumpResponse.hallName,
windowName: createJumpResponse.windowNames,
message: createJumpResponse.message,
createTime: (() => {
const datePart = createJumpResponse.tktDate?.split('T')[0]
const timePart = createJumpResponse.tktTime?.split('T')[1]?.substring(0, 8)
return datePart && timePart ? `${datePart} ${timePart}` : new Date().toISOString()
})(),
}
printStatus.value = PrintStatus.SUCCESS
currentTicket.value = ticketInfo
return {
code: 200,
message: '业务处理成功',
data: {
ticketInfo,
printStatus: 'success',
message: createJumpResponse.message || '小票打印完成',
},
success: true,
}
} catch (error) {
printStatus.value = PrintStatus.FAILED
console.error('Failed to process business:', error)
throw error
} finally {
isProcessing.value = false
processingBusinessId.value = null
}
}
const resetPrintStatus = () => {
printStatus.value = PrintStatus.IDLE
currentTicket.value = null
}
const handleTicketResult = async (ticketData: any, business: BusinessItem) => {
try {
isProcessing.value = true
processingBusinessId.value = business.uid.toString()
printStatus.value = PrintStatus.PRINTING
// 将取号响应转换为前端需要的TicketInfo格式
const ticketInfo: TicketInfo = {
uid: ticketData.uid || 0,
ticketId: ticketData.uid?.toString() || '',
ticketNumber:
typeof ticketData.tktId === 'number'
? ticketData.tktId
: parseInt(ticketData.tktId.match(/\d+/)?.[0] || '0'),
status: 1,
statusText: 'success',
businessId: business.uid,
businessName: business.name,
businessPrefix: business.prefix,
waitingCount: ticketData.waitingCount || 0,
estimatedWaitMinutes: ticketData.estimatedWaitMinutes || 0,
ticketDate: ticketData.tktDate || '',
ticketTime: ticketData.tktTime || '',
hallName: ticketData.hallName || '大厅',
windowName: ticketData.windowNames || '请关注叫号屏幕',
message: ticketData.message || '请耐心等待',
createTime: ticketData.tktDate
? `${ticketData.tktDate} ${ticketData.tktTime || ''}`
: new Date().toISOString(),
}
currentTicket.value = ticketInfo
// 模拟打印功能
try {
// 在实际应用中,这里会调用真实的打印服务
console.log('正在打印票据:', ticketInfo)
printStatus.value = PrintStatus.SUCCESS
} catch (error) {
// 打印失败时记录日志但不中断业务处理
console.error('打印失败,但业务已成功处理:', error)
printStatus.value = PrintStatus.FAILED
}
return ticketInfo
} catch (error) {
printStatus.value = PrintStatus.FAILED
console.error('Failed to handle ticket result:', error)
throw error
} finally {
isProcessing.value = false
processingBusinessId.value = null
}
}
const refreshBusinesses = async () => {
await fetchBusinesses()
}
return {
// State
businesses,
loading,
isProcessing,
processingBusinessId,
printStatus,
currentTicket,
// Getters
hasBusinesses,
currentProcessingBusiness,
getBusinessById,
// Actions
fetchBusinesses,
processBusiness,
handleTicketResult,
resetPrintStatus,
refreshBusinesses,
}
})

@ -61,7 +61,7 @@ button:focus-visible {
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
padding: 0;
text-align: center;
}

@ -0,0 +1,13 @@
// 业务响应数据
export interface BusinessItem {
id: string
uid: number
tenantId: string
deleted: boolean
prefix: string
name: string
enabled?: number
type: number
handleCount: number
isSpecial: number
}

@ -0,0 +1,23 @@
// 登录请求参数
export interface LoginRequest {
username: string
password: string
tenantId: string
}
// 登录响应数据
export interface LoginResponse {
accessToken: string
refreshToken: string
tokenType: string
expiresIn: number
userInfo: {
id: string
username: string
realName: string
role: string
tenantId: string
uid: string
image?: string
}
}

@ -0,0 +1,170 @@
<template>
<div class="home-view">
<AppHeader :loading="businessStore.loading" @refresh="handleRefresh" />
<main class="main-content">
<BusinessGrid
:businesses="businessStore.businesses"
:loading="businessStore.loading"
:is-processing="businessStore.isProcessing"
:processing-business-id="businessStore.processingBusinessId"
@business-click="handleBusinessClick"
@refresh="handleRefresh"
@retry="handleRefresh"
/>
</main>
<!-- 信息输入对话框 -->
<InfoInputDialog
v-model="showInputDialog"
:business="selectedBusiness!"
@submit="handleInfoSubmit"
@cancel="handleInputCancel"
/>
<PrintDialog
v-model="showPrintDialog"
:print-status="businessStore.printStatus"
:ticket-info="businessStore.currentTicket"
@cancel="handleCancelPrint"
@retry="handleRetryPrint"
@manual-print="handleManualPrint"
@confirm="handleConfirmPrint"
/>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import BusinessGrid from '../components/BusinessGrid.vue'
import InfoInputDialog from '../components/InfoInputDialog.vue'
import PrintDialog from '../components/PrintDialog.vue'
import type { BusinessSubmitData, UserInfo } from '../shared/types'
import { useBusinessStore } from '../stores/businessStore'
import type { BusinessItem } from '../types/business'
// Store
const businessStore = useBusinessStore()
//
const showPrintDialog = ref(false)
const showInputDialog = ref(false)
const selectedBusiness = ref<BusinessItem | null>(null)
const submittedData = ref<BusinessSubmitData | null>(null)
//
onMounted(() => {
fetchBusinesses()
})
//
const fetchBusinesses = async () => {
try {
await businessStore.fetchBusinesses()
} catch (error) {
ElMessage.error('加载业务列表失败')
}
}
const handleRefresh = async () => {
await fetchBusinesses()
ElMessage.success('刷新成功')
}
const handleBusinessClick = async (business: BusinessItem) => {
if (businessStore.isProcessing) {
ElMessage.warning('正在处理其他业务,请稍后再试')
return
}
selectedBusiness.value = business
showInputDialog.value = true
}
const handleInfoSubmit = async (data: { business: BusinessItem; userInfo: UserInfo }) => {
try {
showInputDialog.value = false
submittedData.value = data
showPrintDialog.value = true
//
await businessStore.processBusiness(data.business, data.userInfo)
ElMessage.success('数据提交成功')
} catch (error) {
ElMessage.error('业务处理失败')
console.error('业务处理失败:', error)
showInputDialog.value = true //
}
}
const handleInputCancel = () => {
selectedBusiness.value = null
submittedData.value = null
ElMessage.info('已取消信息输入')
}
const handleCancelPrint = async () => {
try {
await ElMessageBox.confirm('确定要取消打印吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
businessStore.resetPrintStatus()
showPrintDialog.value = false
submittedData.value = null
ElMessage.info('操作已取消')
} catch {
//
}
}
const handleRetryPrint = () => {
if (submittedData.value) {
showPrintDialog.value = false
showInputDialog.value = true
}
}
const handleManualPrint = () => {
ElMessage.info('请手动打印小票')
//
if (typeof window.print === 'function') {
window.print()
} else {
ElMessage.error('当前环境不支持打印功能')
}
}
const handleConfirmPrint = () => {
businessStore.resetPrintStatus()
submittedData.value = null
ElMessage.success('操作完成')
}
</script>
<script lang="ts">
export default {
name: 'HomeView',
}
</script>
<style lang="scss" scoped>
.home-view {
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
overflow: hidden;
.main-content {
flex: 1;
// padding: 24px;
overflow: hidden;
}
}
</style>

@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

@ -1,26 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import electron from 'vite-plugin-electron'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), electron([
{
entry: 'src/electron/main/index.ts',
vite:{
build: {
outDir: 'dist-electron/main'
}
}
},
{
entry: 'src/electron/preload/index.ts',
vite:{
build: {
outDir: 'dist-electron/preload'
}
}
}
])],
plugins: [
vue(),
electron([
{
entry: 'src/electron/main/index.ts',
vite: {
build: {
outDir: 'dist-electron/main',
},
},
},
{
entry: 'src/electron/preload/index.ts',
vite: {
build: {
outDir: 'dist-electron/preload',
},
},
},
]),
],
})

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save