日志,配置,票池

main
cysamurai 3 months ago
parent 80d82aaee4
commit 991ce5dc0f

@ -0,0 +1,125 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import type { AppConfig, JsonValue } from '../shared/types/app-config'
const CONFIG_FILE_NAME = 'config.json'
/** 单次持久化内容最大约 2MB防止异常写入撑爆磁盘 */
const MAX_CONFIG_JSON_BYTES = 2 * 1024 * 1024
let configDirCache = ''
let config: AppConfig = {}
/**
* Linux XDG $XDG_CONFIG_HOME/<>
* 使 ~/.config/<>
* 使 userData Electron
*/
export function getConfigDirectory(): string {
if (configDirCache) return configDirCache
const appFolder = app.getName() || 'call-client'
if (process.platform === 'linux') {
const home = os.homedir()
const xdgConfig = process.env.XDG_CONFIG_HOME
const base =
xdgConfig && path.isAbsolute(xdgConfig) ? xdgConfig : path.join(home, '.config')
configDirCache = path.join(base, appFolder)
} else {
configDirCache = app.getPath('userData')
}
return configDirCache
}
export function getConfigFilePath(): string {
return path.join(getConfigDirectory(), CONFIG_FILE_NAME)
}
function ensureConfigDir(): void {
fs.mkdirSync(getConfigDirectory(), { recursive: true })
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
/** 仅保留可 JSON 克隆的数据,避免函数等进入配置 */
function sanitizeConfig(input: unknown): AppConfig {
try {
const s = JSON.stringify(input)
if (s === undefined) return {}
return JSON.parse(s) as AppConfig
} catch {
return {}
}
}
function readConfigFromFile(): AppConfig {
const filePath = getConfigFilePath()
if (!fs.existsSync(filePath)) {
return {}
}
try {
const raw = fs.readFileSync(filePath, 'utf8')
const parsed: unknown = JSON.parse(raw)
if (!isPlainObject(parsed)) {
return {}
}
return sanitizeConfig(parsed)
} catch {
return {}
}
}
function writeConfigToFile(): void {
ensureConfigDir()
const filePath = getConfigFilePath()
const json = JSON.stringify(config, null, 2)
if (Buffer.byteLength(json, 'utf8') > MAX_CONFIG_JSON_BYTES) {
throw new Error('Config too large')
}
const tmp = `${filePath}.${process.pid}.tmp`
fs.writeFileSync(tmp, json, 'utf8')
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch {
// ignore
}
fs.renameSync(tmp, filePath)
}
/**
* 使
*/
export function initAppConfig(): void {
config = readConfigFromFile()
}
/** 当前内存中的完整配置(深拷贝,避免外部修改内部对象) */
export function getAppConfig(): AppConfig {
return sanitizeConfig(config) as AppConfig
}
/**
*
*/
export function mergeAppConfig(partial: Record<string, JsonValue>): void {
const next = { ...config, ...partial }
const json = JSON.stringify(next)
if (Buffer.byteLength(json, 'utf8') > MAX_CONFIG_JSON_BYTES) {
throw new Error('Config too large')
}
config = sanitizeConfig(next)
writeConfigToFile()
}
/** 主进程内直接读取(不拷贝,仅只读使用,勿修改引用) */
export function getAppConfigRef(): Readonly<AppConfig> {
return config
}

@ -0,0 +1,156 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
/** 日志保留天数 */
const RETENTION_DAYS = 7
const RETENTION_MS = RETENTION_DAYS * 24 * 60 * 60 * 1000
/**
* 100MB1GB 100MB
* 1GB `1024 * 1024 * 1024`
*/
const MAX_LOG_FILE_BYTES = 100 * 1024 * 1024
const LOG_FILE_PREFIX = 'log'
const LOG_EXT = '.txt'
export type FileLogLevel = 'debug' | 'info' | 'warn' | 'error'
let logDir = ''
let currentFilePath = ''
let currentSize = 0
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0')
}
/** 文件名log + 生成时间(本地) */
function makeLogFileName(): string {
const d = new Date()
const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${pad(d.getMilliseconds(), 3)}`
return `${LOG_FILE_PREFIX}${ts}${LOG_EXT}`
}
function isOurLogFile(name: string): boolean {
return name.startsWith(LOG_FILE_PREFIX) && name.endsWith(LOG_EXT)
}
/**
* Linux XDG Base Directory $XDG_STATE_HOME/<app>/logs
* XDG_STATE_HOME 使 ~/.local/state/<app>/logs
* userData/logs
*/
export function getLogDirectory(): string {
if (logDir) return logDir
const appFolder = app.getName() || 'call-client'
if (process.platform === 'linux') {
const home = os.homedir()
const xdgState = process.env.XDG_STATE_HOME
const stateBase =
xdgState && path.isAbsolute(xdgState) ? xdgState : path.join(home, '.local', 'state')
logDir = path.join(stateBase, appFolder, 'logs')
} else {
logDir = path.join(app.getPath('userData'), 'logs')
}
return logDir
}
function ensureDir(dir: string): void {
fs.mkdirSync(dir, { recursive: true })
}
/** 删除修改时间早于保留期的日志文件(仅匹配本应用生成的 log*.txt */
export function purgeExpiredLogFiles(): void {
const dir = getLogDirectory()
if (!fs.existsSync(dir)) return
const now = Date.now()
let names: string[]
try {
names = fs.readdirSync(dir)
} catch {
return
}
for (const name of names) {
if (!isOurLogFile(name)) continue
const full = path.join(dir, name)
try {
const stat = fs.statSync(full)
if (!stat.isFile()) continue
if (now - stat.mtimeMs > RETENTION_MS) {
fs.unlinkSync(full)
}
} catch {
// ignore single file errors
}
}
}
function openNewLogFile(): void {
const dir = getLogDirectory()
ensureDir(dir)
purgeExpiredLogFiles()
currentFilePath = path.join(dir, makeLogFileName())
fs.writeFileSync(currentFilePath, '', { encoding: 'utf8' })
currentSize = 0
}
function formatLine(level: FileLogLevel, message: string): string {
const d = new Date()
const offsetMin = -d.getTimezoneOffset()
const sign = offsetMin >= 0 ? '+' : '-'
const abs = Math.abs(offsetMin)
const oh = pad(Math.floor(abs / 60))
const om = pad(abs % 60)
const timestamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${sign}${oh}:${om}`
return `${timestamp} [${level.toUpperCase()}] ${message}`
}
function rotateIfNeeded(): void {
if (currentSize < MAX_LOG_FILE_BYTES) return
/** 轮转前同样先清理过期文件,再生成新文件 */
openNewLogFile()
}
/**
*
* app.ready
*/
export function initFileLogger(): void {
openNewLogFile()
}
export function writeFileLog(level: FileLogLevel, message: string): void {
if (!currentFilePath) {
initFileLogger()
}
const line = formatLine(level, message)
const chunk = `${line}\n`
const bytes = Buffer.byteLength(chunk, 'utf8')
try {
fs.appendFileSync(currentFilePath, chunk, { encoding: 'utf8' })
currentSize += bytes
rotateIfNeeded()
} catch (e) {
console.error('[file-logger] write failed', e)
}
}
export const fileLogger = {
debug: (msg: string) => writeFileLog('debug', msg),
info: (msg: string) => writeFileLog('info', msg),
warn: (msg: string) => writeFileLog('warn', msg),
error: (msg: string) => writeFileLog('error', msg),
/** 当前日志目录(用于排障) */
getDirectory: () => getLogDirectory(),
getCurrentFilePath: () => currentFilePath,
}

@ -1,6 +1,9 @@
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, Menu } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, IpcMainInvokeEvent, Menu } from 'electron'
import type { SessionState } from '../shared/types/session'
import { getAppConfig, getConfigFilePath, initAppConfig, mergeAppConfig } from './app-config'
import { fileLogger, initFileLogger, writeFileLog, type FileLogLevel } from './file-logger'
import type { JsonValue } from '../shared/types/app-config'
import { createLoginWindow, createMainWindow, createTicketWindow } from './window'
let sessionState: SessionState = {
@ -16,6 +19,32 @@ let mainWindow: BrowserWindow | null = null
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
initAppConfig()
initFileLogger()
fileLogger.info(`Loaded app config from ${getConfigFilePath()}`)
fileLogger.info('Application started')
ipcMain.handle('appConfig:get', () => getAppConfig())
ipcMain.handle('appConfig:set', (_event: IpcMainInvokeEvent, partial: unknown) => {
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
throw new Error('Invalid config: expected a plain object')
}
mergeAppConfig(partial as Record<string, JsonValue>)
return true
})
const validLogLevels: FileLogLevel[] = ['debug', 'info', 'warn', 'error']
ipcMain.handle('app:log', (_event: IpcMainInvokeEvent, level: unknown, message: unknown) => {
if (typeof level !== 'string' || !validLogLevels.includes(level as FileLogLevel)) {
return
}
if (typeof message !== 'string' || message.length > 32_000) {
return
}
writeFileLog(level as FileLogLevel, message)
})
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
@ -50,6 +79,21 @@ app.whenReady().then(() => {
if (window) window.close()
})
// TicketList -> Main.vue 触发:用于“在票号列表窗口点呼叫/评价后,把主界面对应按钮逻辑执行起来”
ipcMain.on('ticket:main-action', (_event, action: unknown) => {
if (!mainWindow) return
if (action !== 'call' && action !== 'evaluate') return
try {
mainWindow.show()
mainWindow.focus()
mainWindow.webContents.send('main:ticket-action', action)
} catch {
// ignore
}
})
// 应用菜单IPC监听器
ipcMain.on('show-context-menu', event => {
console.log('main中show-context-menu')
@ -138,7 +182,7 @@ ipcMain.handle(
sessionState.queueToken = newSessionState.queueToken
sessionState.empUid = newSessionState.empUid
sessionState.winUid = newSessionState.winUid
console.log('[Session] Token updated')
fileLogger.info('[Session] Token updated')
return true
},
)
@ -146,10 +190,40 @@ ipcMain.handle(
// ✅ 清除会话(登出)
ipcMain.handle('session:clear', () => {
sessionState = { empUid: null, winUid: null, queueToken: null }
console.log('[Session] Cleared')
fileLogger.info('[Session] Cleared')
return true
})
/** 主进程原生确认框dialog.showMessageBox返回是否点击第一按钮如「是」 */
ipcMain.handle(
'dialog:confirm',
async (event: IpcMainInvokeEvent, payload: unknown): Promise<boolean> => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid dialog payload')
}
const p = payload as Record<string, unknown>
const title = typeof p.title === 'string' ? p.title.slice(0, 256) : '提示'
const message = typeof p.message === 'string' ? p.message.slice(0, 4096) : ''
const okLabel = typeof p.okLabel === 'string' ? p.okLabel.slice(0, 64) : '是'
const cancelLabel = typeof p.cancelLabel === 'string' ? p.cancelLabel.slice(0, 64) : '否'
const parent = BrowserWindow.fromWebContents(event.sender)
const options = {
type: 'warning' as const,
title,
message,
buttons: [okLabel, cancelLabel],
defaultId: 1,
cancelId: 1,
noLink: true,
}
const { response } = parent
? await dialog.showMessageBox(parent, options)
: await dialog.showMessageBox(options)
return response === 0
},
)
// 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.

@ -74,13 +74,18 @@ let ticketWindow: BrowserWindow | null = null
export const createTicketWindow = (parentWindow: BrowserWindow): void => {
if (ticketWindow) {
if (ticketWindow.isMinimized()) {
ticketWindow.restore()
} else if (!ticketWindow.isVisible()) {
ticketWindow.show()
}
ticketWindow.focus()
return
}
ticketWindow = new BrowserWindow({
width: 800,
height: 600,
width: 1024,
height: 720,
parent: parentWindow || undefined,
modal: false, // 如果设为 true 则为模态窗口
resizable: false, // 不可调整大小

@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from 'electron'
// import Store from 'electron-store'
import { console } from 'inspector'
import type { JsonValue } from '../shared/types/app-config'
import type { SessionState } from '../shared/types/session'
// Custom APIs for renderer
@ -47,6 +48,47 @@ contextBridge.exposeInMainWorld('session', {
clear: (): Promise<boolean> => ipcRenderer.invoke('session:clear'),
})
contextBridge.exposeInMainWorld('appLogger', {
log: (level: 'debug' | 'info' | 'warn' | 'error', message: string) =>
ipcRenderer.invoke('app:log', level, message),
})
contextBridge.exposeInMainWorld('appConfig', {
getAll: () => ipcRenderer.invoke('appConfig:get'),
set: (partial: Record<string, JsonValue>) => ipcRenderer.invoke('appConfig:set', partial),
})
contextBridge.exposeInMainWorld('nativeDialog', {
confirm: (options: { title: string; message: string; okLabel?: string; cancelLabel?: string }) =>
ipcRenderer.invoke('dialog:confirm', options),
})
// TicketList -> Main.vue 触发call/evaluate
contextBridge.exposeInMainWorld('ticketToMain', {
triggerCall: () => ipcRenderer.send('ticket:main-action', 'call'),
triggerEvaluate: () => ipcRenderer.send('ticket:main-action', 'evaluate'),
})
let currentMainTicketListener:
| ((event: Electron.IpcRendererEvent, action: 'call' | 'evaluate') => void)
| null = null
contextBridge.exposeInMainWorld('mainTicketEvents', {
onAction: (callback: (action: 'call' | 'evaluate') => void) => {
if (currentMainTicketListener) {
ipcRenderer.removeListener('main:ticket-action', currentMainTicketListener)
}
currentMainTicketListener = (event, action) => {
void event
if (action !== 'call' && action !== 'evaluate') return
callback(action)
}
ipcRenderer.on('main:ticket-action', currentMainTicketListener)
},
})
// contextBridge.exposeInMainWorld('store', {
// get: key => store.get(key),
// set: (key, val) => store.set(key, val),

@ -1,4 +1,5 @@
import type { CallRequest, CallResponse, PauseRequest, ReCallRequest } from '@renderer/types/action'
import type { TicketPoolRequest, TicketPoolResponse } from '@renderer/types/ticket'
import type { UserRequest, UserResponse } from '@renderer/types/user'
import type { WindowResponse } from '@renderer/types/window'
import { http } from '@renderer/utils/service'
@ -23,5 +24,7 @@ export const api = {
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),
pool: (params: TicketPoolRequest) =>
http.get<TicketPoolResponse>('/call-terminal/pool', params),
},
}

@ -1,5 +1,7 @@
/// <reference types="vite/client" />
import type { AppConfig, JsonValue } from '../../../shared/types/app-config'
export {}
interface IWinControl {
@ -30,6 +32,37 @@ interface ISession {
clear: () => boolean
}
type AppLogLevel = 'debug' | 'info' | 'warn' | 'error'
interface IAppLogger {
log: (level: AppLogLevel, message: string) => Promise<void>
}
interface IAppConfig {
getAll: () => Promise<AppConfig>
set: (partial: Record<string, JsonValue>) => Promise<void>
}
interface INativeDialogConfirmOptions {
title: string
message: string
okLabel?: string
cancelLabel?: string
}
interface INativeDialog {
confirm: (options: INativeDialogConfirmOptions) => Promise<boolean>
}
interface ITicketToMain {
triggerCall: () => void
triggerEvaluate: () => void
}
interface IMainTicketEvents {
onAction: (callback: (action: 'call' | 'evaluate') => void) => void
}
declare global {
interface Window {
winControl: IWinControl
@ -37,5 +70,10 @@ declare global {
store: IStore
session: ISession
pauseMenu: IPauseMenu
appLogger: IAppLogger
appConfig: IAppConfig
nativeDialog: INativeDialog
ticketToMain: ITicketToMain
mainTicketEvents: IMainTicketEvents
}
}

@ -1,3 +1,4 @@
import { applyServerIpToHttp } from '@renderer/utils/service'
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'
@ -6,4 +7,17 @@ import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
createApp(App).use(router).use(ElementPlus).mount('#app')
async function bootstrap(): Promise<void> {
const cfg = await window.appConfig.getAll()
const ip = typeof cfg.server_ip === 'string' ? cfg.server_ip.trim() : ''
if (ip) {
applyServerIpToHttp(ip)
}
const app = createApp(App)
app.use(router)
app.use(ElementPlus)
app.mount('#app')
}
void bootstrap()

@ -1,13 +1,37 @@
import Login from '@renderer/views/Login.vue'
import Main from '@renderer/views/Main.vue'
import ServerSetup from '@renderer/views/ServerSetup.vue'
import TicketList from '@renderer/views/TicketList.vue'
import type { AppConfig } from '../../../shared/types/app-config'
import { createRouter, createWebHashHistory } from 'vue-router'
export default createRouter({
history: createWebHashHistory(), // hash模式
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: Login },
{ path: '/', redirect: '/login' },
{ path: '/setup', name: 'ServerSetup', component: ServerSetup },
{ path: '/login', component: Login },
{ path: '/main', component: Main },
{ path: '/ticketList', component: TicketList },
],
})
function getServerIpFromConfig(cfg: AppConfig): string {
const v = cfg['server_ip']
return typeof v === 'string' ? v.trim() : ''
}
router.beforeEach(async (to, _from, next) => {
const cfg = await window.appConfig.getAll()
const ip = getServerIpFromConfig(cfg)
if (!ip && to.path !== '/setup') {
return next({ path: '/setup', replace: true })
}
if (ip && to.path === '/setup') {
return next({ path: '/login', replace: true })
}
next()
})
export default router

@ -0,0 +1,31 @@
export interface TicketPoolRequest {
winUid: number
keyword: string
page: number
size: number
/** 可选票号状态枚举值0~10 */
status?: number
}
export interface TicketPoolItem {
id?: number
ticketUid?: number
tktNum?: string
ticketNo?: string
startTime?: string
endTime?: string
status?: string
ticketStatusText?: string
[key: string]: unknown
}
export interface TicketPoolResponse {
list?: TicketPoolItem[]
records?: TicketPoolItem[]
items?: TicketPoolItem[]
total?: number
count?: number
page?: number
size?: number
[key: string]: unknown
}

@ -2,18 +2,54 @@ import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import type { SessionState } from '../../../shared/types/session'
/** 与后端约定的路径后缀,与 server_ip 拼接为完整 baseURL */
export const API_QUEUE_CALLER_PATH = '/api/queue/caller'
const DEFAULT_API_PORT = 8845
/**
* server_ip axios baseURL
* `192.168.1.10` 8845`192.168.1.10:8845``http://192.168.1.10:8845`
*/
export function buildBaseUrlFromServerIp(serverIp: string): string {
const raw = serverIp.trim()
if (!raw) return ''
if (raw.startsWith('http://') || raw.startsWith('https://')) {
return `${raw.replace(/\/$/, '')}${API_QUEUE_CALLER_PATH}`
}
const hostPort = raw.replace(/^\/+/, '')
const hasPort = /:\d+$/.test(hostPort) || /^\[.+\]:\d+$/.test(hostPort)
if (hasPort) {
return `http://${hostPort}${API_QUEUE_CALLER_PATH}`
}
return `http://${hostPort}:${DEFAULT_API_PORT}${API_QUEUE_CALLER_PATH}`
}
const instance = axios.create({
// baseURL: import.meta.env.VITE_API_BASE_URL,
baseURL: 'http://192.168.1.10:8845/api/queue/caller',
baseURL: '',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
export function applyServerIpToHttp(serverIp: string): void {
const url = buildBaseUrlFromServerIp(serverIp)
instance.defaults.baseURL = url
}
export function getHttpBaseUrl(): string {
return instance.defaults.baseURL ?? ''
}
// 请求拦截器
instance.interceptors.request.use(
async config => {
if (!instance.defaults.baseURL) {
return Promise.reject(new Error('未配置服务器地址,请先完成服务地址设置'))
}
// const token = localStorage.getItem('token')
let token: string | null = null
try {

@ -12,8 +12,9 @@ const isLoading = ref(false)
const formRef = ref()
const isLoginSuccessed = ref(false)
const selectedWin = ref('')
const cachedWinKey = ref('')
let options: Array<{ label: string; value: string }> = []
const options = ref<Array<{ label: string; value: string }>>([])
let sessionState: SessionState = {
empUid: null,
winUid: null,
@ -86,20 +87,41 @@ const handleLogin = async (): Promise<void> => {
await new Promise(resolve => setTimeout(resolve, 10))
const winList = await api.window.list()
options = winList.windows.map(win => {
options.value = winList.windows.map(win => {
return {
label: win.windowName,
value: win.windowUid.toString(),
}
})
if (
cachedWinKey.value &&
options.value.some(item => item.value === cachedWinKey.value)
) {
selectedWin.value = cachedWinKey.value
}
//
isLoginSuccessed.value = true
isLoading.value = false
await window.appLogger.log(
'info',
`登录成功: user=${username.value}, mode=${loginType.value}, empUid=${sessionState.empUid}`,
)
} else {
isLoading.value = false
await window.appLogger.log(
'warn',
`登录失败: 接口未返回有效 queueToken, mode=${loginType.value}, user=${username.value}`,
)
}
} catch (error) {
console.log('登录失败', error)
isLoading.value = false
const msg = error instanceof Error ? error.message : String(error)
await window.appLogger.log(
'error',
`登录失败: mode=${loginType.value}, user=${username.value}, ${msg}`,
)
}
//
@ -125,7 +147,15 @@ const handleLogin = async (): Promise<void> => {
const handleWindowLogin = async (): Promise<void> => {
console.log('handleWindowLogin')
// console.log('selectedWin', selectedWin.value)
sessionState.winUid = parseInt(selectedWin.value)
const winUid = parseInt(selectedWin.value)
const selected = options.value.find(item => item.value === selectedWin.value)
await window.appConfig.set({
last_username: username.value.trim(),
selected_win_key: selectedWin.value,
selected_win_value: selected?.label ?? '',
selected_win_uid: winUid,
})
sessionState.winUid = winUid
await window.session.set(sessionState)
window.winControl.loginSuccess()
}
@ -145,7 +175,15 @@ const handleInputFocus = (event: Event): void => {
}
}
onMounted(() => {
onMounted(async () => {
const cfg = await window.appConfig.getAll()
if (typeof cfg.last_username === 'string' && cfg.last_username.trim()) {
username.value = cfg.last_username
}
if (typeof cfg.selected_win_key === 'string' && cfg.selected_win_key.trim()) {
cachedWinKey.value = cfg.selected_win_key
selectedWin.value = cfg.selected_win_key
}
//
window.addEventListener('keydown', handleKeyPress)
})

@ -1,10 +1,23 @@
<script setup lang="ts">
import { Menu, Phone, Star, Switch, VideoPause, VideoPlay } from '@element-plus/icons-vue'
import {
CircleCheck,
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 { computed, onMounted, onUnmounted, ref } from 'vue'
import { api } from '../api'
import type { ActionButton } from '../types/action'
const logErr = async (ctx: string, error: unknown): Promise<void> => {
const msg = error instanceof Error ? error.message : String(error)
await window.appLogger.log('error', `${ctx}: ${msg}`)
}
const sessionState = ref<SessionState>({
empUid: -1,
winUid: -1,
@ -20,50 +33,96 @@ 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 EVALUATING_COUNTDOWN_SEC = 15
let evaluatingCountdownTimer: ReturnType<typeof setInterval> | null = null
function clearEvaluatingCountdown(): void {
if (evaluatingCountdownTimer !== null) {
clearInterval(evaluatingCountdownTimer)
evaluatingCountdownTimer = null
}
}
/**
* 进入 evaluating 后启动 15 秒倒计时文案显示在 log-spanmessage
* @param prefixText 倒计时前缀例如办理完成xxx评价中评价中
*/
function startEvaluatingCountdown(prefixText: string): void {
clearEvaluatingCountdown()
let left = EVALUATING_COUNTDOWN_SEC
const applyMessage = (): void => {
message.value = `${prefixText}(剩余 ${left} 秒)`
}
applyMessage()
evaluatingCountdownTimer = setInterval(() => {
left -= 1
if (left <= 0) {
clearEvaluatingCountdown()
callStatus.value = 'idle'
callBtnText.value = '呼叫'
callingTkt.value = -1
message.value = '欢迎使用紫云呼叫终端'
void window.appLogger.log('info', '评价倒计时结束,进入待机')
return
}
applyMessage()
}, 1000)
}
// buttons computedworking
const buttons = computed(() => {
const startOrComplete =
callStatus.value === 'working'
? {
icon: CircleCheck,
label: '完成',
action: 'complete',
enabled: true,
}
: {
icon: VideoPlay,
label: '开始',
action: 'start',
enabled: !['idle', 'paused', 'working', 'evaluating', 'transferring'].includes(
callStatus.value,
),
}
return [
{
icon: Phone,
label: callBtnText.value,
action: 'call',
enabled: !['connected', 'paused', 'working', 'evaluating', 'transferring'].includes(
callStatus.value,
),
},
startOrComplete,
{
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', 'working', 'transferring'].includes(callStatus.value),
},
]
})
//
const openMoreMenu = (): void => {
@ -76,23 +135,31 @@ const callAction = async (): Promise<void> => {
//
if (callStatus.value === 'calling' && callingTkt.value > 0) {
try {
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
const t = callingTkt.value
await window.appLogger.log('info', `请求重呼: windowUid=${w}, empUid=${e}, ticketUid=${t}`)
const res = await api.action.recall({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
windowUid: w,
empUid: e,
ticketUid: t,
})
updateLog(`已重呼:${res.ticketNo},请勿重复点击!`)
await window.appLogger.log('info', `重呼成功: ticketNo=${res.ticketNo}`)
return
} catch (error) {
console.log('error', error)
await logErr('重呼失败', error)
}
}
try {
//
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
await window.appLogger.log('info', `请求呼叫: windowUid=${w}, empUid=${e}`)
const res = await api.action.call({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
windowUid: w,
empUid: e,
})
//
@ -102,46 +169,114 @@ const callAction = async (): Promise<void> => {
callBtnText.value = '重呼'
callingTkt.value = res.ticketUid
updateLog(`正在呼叫:${res.ticketNo}`)
await window.appLogger.log(
'info',
`呼叫成功: ticketNo=${res.ticketNo}, ticketUid=${res.ticketUid}`,
)
} else {
//
updateLog(res.message)
await window.appLogger.log('warn', `呼叫未成功: ${res.message}`)
}
} catch (error) {
console.log('error', error)
await logErr('呼叫失败', error)
}
}
// action
const startAction = async (): Promise<void> => {
try {
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
const t = callingTkt.value
await window.appLogger.log('info', `请求开始办理: windowUid=${w}, empUid=${e}, ticketUid=${t}`)
const res = await api.action.start({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
windowUid: w,
empUid: e,
ticketUid: t,
})
if (res.success) {
callStatus.value = 'working'
updateLog(`正在办理:${res.ticketNo}`)
await window.appLogger.log('info', `开始办理成功: ticketNo=${res.ticketNo}`)
} else {
await window.appLogger.log('warn', `开始办理未成功: ${res.message ?? 'unknown'}`)
}
} catch (error) {
console.log('error', error)
await logErr('开始办理失败', error)
}
}
// action
const evaluateAction = async (): Promise<void> => {
// start evaluating
const completeAction = async (): Promise<void> => {
try {
const res = await api.action.evaluate({
windowUid: sessionState.value.winUid || -1,
empUid: sessionState.value.empUid || -1,
ticketUid: callingTkt.value,
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
const t = callingTkt.value
await window.appLogger.log('info', `请求完成办理: windowUid=${w}, empUid=${e}, ticketUid=${t}`)
const res = await api.action.complete({
windowUid: w,
empUid: e,
ticketUid: t,
})
if (res.success) {
callStatus.value = 'evaluating'
callBtnText.value = '呼叫'
if (!res.success) {
await window.appLogger.log('warn', `完成办理未成功: ${res.message ?? 'unknown'}`)
return
}
await window.appLogger.log('info', `完成办理成功: ticketNo=${res.ticketNo}`)
callStatus.value = 'evaluating'
callBtnText.value = '呼叫'
startEvaluatingCountdown(`办理完成:${res.ticketNo},评价中`)
await window.appLogger.log('info', '完成办理成功,进入评价中状态')
} catch (error) {
console.log('error', error)
await logErr('完成办理失败', error)
}
}
/** 使用当前 session 与 ticket 调用 evaluate与首次评价相同参数 */
const invokeEvaluateApi = async (): Promise<void> => {
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
const t = callingTkt.value
await window.appLogger.log('info', `请求评价: windowUid=${w}, empUid=${e}, ticketUid=${t}`)
const res = await api.action.evaluate({
windowUid: w,
empUid: e,
ticketUid: t,
})
if (res.success) {
callStatus.value = 'evaluating'
callBtnText.value = '呼叫'
startEvaluatingCountdown('评价中')
await window.appLogger.log('info', '评价请求成功')
} else {
await window.appLogger.log('warn', `评价未成功: ${res.message ?? 'unknown'}`)
}
}
// actionevaluating evaluate
const evaluateAction = async (): Promise<void> => {
if (callStatus.value === 'evaluating') {
const ok = await window.nativeDialog.confirm({
title: '提示',
message: '当前业务正在评价中,是否重新发起评价?',
okLabel: '是',
cancelLabel: '否',
})
if (!ok) {
return
}
}
try {
await invokeEvaluateApi()
} catch (error) {
console.log('error', error)
await logErr('评价失败', error)
}
}
// action
@ -152,32 +287,56 @@ const pauseAction = async (): Promise<void> => {
window.pauseMenu.showPauseMenu()
//
window.pauseMenu.pauseReasonSelected(async (action: string) => {
console.log('pauseReasonSelected', action)
window.pauseMenu.pauseReasonSelected(async (reason: string) => {
console.log('pauseReasonSelected', reason)
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}`)
try {
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
await window.appLogger.log(
'info',
`请求暂停: windowUid=${w}, empUid=${e}, pauseReason=${reason}`,
)
const res = await api.action.pause({
windowUid: w,
empUid: e,
pauseReason: reason,
})
if (res.success) {
callStatus.value = 'paused'
pauseBtnText.value = '恢复'
updateLog(`暂停中,原因:${reason}`)
await window.appLogger.log('info', `暂停成功: reason=${reason}`)
} else {
await window.appLogger.log('warn', `暂停未成功: ${res.message ?? 'unknown'}`)
}
} catch (err) {
await logErr('暂停失败', err)
}
})
} 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 = '暂停'
try {
const w = sessionState.value.winUid || -1
const e = sessionState.value.empUid || -1
await window.appLogger.log('info', `请求恢复: windowUid=${w}, empUid=${e}`)
const res = await api.action.resume({
windowUid: w,
empUid: e,
})
if (res.success) {
callStatus.value = 'idle'
pauseBtnText.value = '暂停'
await window.appLogger.log('info', '恢复成功')
} else {
await window.appLogger.log('warn', `恢复未成功: ${res.message ?? 'unknown'}`)
}
} catch (err) {
await logErr('恢复失败', err)
}
}
} catch (error) {
console.log('error', error)
await logErr('暂停/恢复流程异常', error)
}
}
//
@ -191,10 +350,14 @@ const handleButtonClick = (button: ActionButton): void => {
break
case 'transfer':
console.log('transfer')
void window.appLogger.log('info', '转移: 当前未对接接口')
break
case 'start':
startAction()
break
case 'complete':
completeAction()
break
case 'evaluate':
evaluateAction()
break
@ -210,6 +373,22 @@ const updateLog = (log: string): void => {
onMounted(async () => {
sessionState.value = await window.session.get()
// TicketList -> Main.vue
window.mainTicketEvents.onAction(action => {
if (action === 'call') {
void callAction()
return
}
if (action === 'evaluate') {
void evaluateAction()
return
}
})
})
onUnmounted(() => {
clearEvaluatingCountdown()
})
</script>

@ -0,0 +1,261 @@
<script setup lang="ts">
import { Close, Minus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { applyServerIpToHttp } from '@renderer/utils/service'
const router = useRouter()
const serverIp = ref('')
const saving = ref(false)
const isValid = computed(() => serverIp.value.trim().length > 0)
const handleMinimize = (): void => {
window.winControl.windowMinimize()
}
const handleClose = (): void => {
window.winControl.windowClose()
}
function validateHostInput(raw: string): boolean {
const s = raw.trim()
if (!s) return false
if (s.startsWith('http://') || s.startsWith('https://')) {
try {
const u = new URL(s)
return Boolean(u)
} catch {
return false
}
}
// [:] IPv4
if (/^[\w.\-]+(?::\d+)?$/.test(s) || /^\d{1,3}(\.\d{1,3}){3}(?::\d+)?$/.test(s)) {
return true
}
return false
}
const handleSave = async (): Promise<void> => {
const v = serverIp.value.trim()
if (!v) {
ElMessage.warning('请输入服务器 IP 或地址')
return
}
if (!validateHostInput(v)) {
ElMessage.warning('地址格式不正确例如192.168.1.10 或 192.168.1.10:8845')
return
}
saving.value = true
try {
await window.appConfig.set({ server_ip: v })
applyServerIpToHttp(v)
await window.appLogger.log('info', `已保存服务器地址: ${v}`)
ElMessage.success('保存成功')
await router.replace('/login')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
ElMessage.error(msg || '保存失败')
await window.appLogger.log('error', `保存服务器地址失败: ${msg}`)
} finally {
saving.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<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="login-main">
<div class="header-section" style="-webkit-app-region: drag">
<div class="app-info">
<h1 class="app-title">服务地址</h1>
<h2 class="app-subtitle">请先配置服务器 IP 或地址</h2>
</div>
</div>
<div class="form-section">
<div class="form-wrapper">
<div class="form-header">
<p class="form-subtitle">默认端口 8845可输入 host:port 或完整 http(s) 地址</p>
</div>
<el-input
v-model="serverIp"
size="large"
clearable
placeholder="例如 192.168.1.10"
@keyup.enter="handleSave"
/>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="login-button"
:loading="saving"
:disabled="!isValid"
@click="handleSave"
>
保存并进入登录
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
position: relative;
padding: 0;
margin: 0;
border-radius: 5px;
}
.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;
.control-button {
width: 32px;
height: 32px;
min-width: 32px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
.control-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.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);
}
.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;
}
}
.form-actions {
margin-top: 24px;
display: flex;
justify-content: center;
.login-button {
width: 100%;
}
}
</style>

@ -1,5 +1,24 @@
<template>
<div class="table-container">
<div class="search-wrapper">
<el-input
v-model="keyword"
placeholder="请输入关键字"
clearable
@keyup.enter="handleSearch"
/>
<el-select
v-model="statusFilter"
placeholder="请选择票号状态"
clearable
@change="handleStatusChange"
>
<el-option label="全部" :value="null" />
<el-option v-for="opt in statusSelectOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-button type="primary" @click="handleSearch"></el-button>
</div>
<!-- 表格区域 -->
<el-table
v-loading="loading"
@ -11,43 +30,46 @@
:default-sort="{ prop: sortField, order: sortOrder }"
@sort-change="handleSortChange"
>
<el-table-column prop="id" label="ID" width="60" sortable="custom" />
<el-table-column prop="ticketUid" label="UID" sortable="custom" />
<el-table-column
prop="tktNum"
label="票号"
width="100"
sortable="custom"
show-overflow-tooltip
/>
<el-table-column prop="tktNum" label="票号" sortable="custom" show-overflow-tooltip />
<el-table-column prop="dateText" label="日期" sortable="custom" show-overflow-tooltip />
<el-table-column
prop="startTime"
prop="startTimeOnly"
label="开始时间"
min-width="180"
sortable="custom"
show-overflow-tooltip
/>
<el-table-column
prop="endTime"
prop="endTimeOnly"
label="结束时间"
width="150"
sortable="custom"
:filters="departmentFilters"
:filter-method="filterDepartment"
show-overflow-tooltip
/>
<el-table-column prop="status" label="票号状态" width="100" align="center">
<el-table-column prop="bizName" label="业务值" sortable="custom" show-overflow-tooltip />
<el-table-column prop="status" label="票号状态" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '禁用' }}
<el-tag :type="statusTagTypeMap[row.status] ?? 'info'">
{{ row.statusText }}
</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 label="操作" align="center">
<template #default="{ row }">
<el-button v-if="row.canCall" type="primary" link @click="handleCall(row)"
>呼叫</el-button
>
<el-button v-if="row.canReEvaluate" type="warning" link @click="handleReEvaluate(row)">
评价
</el-button>
<span v-if="!row.canCall && !row.canReEvaluate"> -- </span>
</template>
</el-table-column>
</el-table>
@ -78,28 +100,23 @@
<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'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { api } from '../api'
//
interface Tkt {
id: number
ticketUid: number
tktNum: string
bizName: 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: '已完成',
}))
dateText: string
startTimeOnly: string
endTimeOnly: string
status: number
statusText: string
canCall: boolean
canReEvaluate: boolean
}
//
@ -107,20 +124,53 @@ const loading = ref(false)
const allData = ref<Tkt[]>([])
const currentPage = ref(1)
const pageSize = ref(20)
const sortField = ref<string>('id')
const totalCount = ref(0)
const keyword = ref('')
const statusFilter = ref<number | null>(null)
const sortField = ref<string>('ticketUid')
const sortOrder = ref<'ascending' | 'descending'>('ascending')
const departmentFilter = ref<string[]>([])
let refreshTimer: ReturnType<typeof setInterval> | null = null
const statusTextMap: Record<number, string> = {
0: '正在等候',
1: '未换领',
2: '已换领',
3: '正在呼叫',
4: '正在办理',
5: '已完成',
6: '弃号',
7: '换领超时',
8: '换领作废',
9: '等待评价',
10: '评价中',
}
//
const departmentFilters = computed(() => {
const departments = [...new Set(allData.value.map(item => item.status))]
return departments.map(dept => ({ text: dept, value: dept }))
})
const statusSelectOptions = computed(() =>
Object.keys(statusTextMap).map(k => ({
value: Number(k),
label: statusTextMap[Number(k)],
})),
)
const statusTagTypeMap: Record<number, 'success' | 'warning' | 'danger' | 'info'> = {
0: 'info',
1: 'warning',
2: 'info',
3: 'warning',
4: 'warning',
5: 'success',
6: 'danger',
7: 'warning',
8: 'danger',
9: 'warning',
10: 'success',
}
// 800x600
const tableHeight = ref(400)
// 1024 * 720
const DISPLAY_HEIGHT_PX = 720
// 720px + +
// pagination-wrapper
const tableHeight = ref(DISPLAY_HEIGHT_PX - 220)
//
//
const paginatedData = computed(() => {
let filtered = [...allData.value]
@ -132,8 +182,8 @@ const paginatedData = computed(() => {
//
filtered.sort((a, b) => {
const field = sortField.value as keyof Tkt
let aVal = a[field]
let bVal = b[field]
const aVal = a[field]
const bVal = b[field]
//
// if (field === 'joinDate') {
@ -148,18 +198,12 @@ const paginatedData = computed(() => {
}
})
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filtered.slice(start, end)
return filtered
})
//
const totalItems = computed(() => {
if (departmentFilter.value.length > 0) {
return allData.value.filter(item => departmentFilter.value.includes(item.status)).length
}
return allData.value.length
return totalCount.value
})
//
@ -187,33 +231,122 @@ const handleSortChange = (sort: {
}
}
//
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
}
currentPage.value = 1
void refreshData()
}
//
const handleCurrentChange = (page: number): void => {
currentPage.value = page
void refreshData()
}
const handleSearch = (): void => {
currentPage.value = 1
void refreshData()
}
const handleStatusChange = (): void => {
currentPage.value = 1
void refreshData()
}
function normalizeRows(raw: unknown[]): Tkt[] {
return raw.map(item => {
const row = item as Record<string, unknown>
const startTimeRaw = String(row.startTime ?? '')
const endTimeRaw = String(row.endTime ?? '')
const status = Number(row.status ?? -1)
const startDate = startTimeRaw.replace('T', ' ').trim().split(' ')[0] ?? ''
const endDate = endTimeRaw.replace('T', ' ').trim().split(' ')[0] ?? ''
const dateText =
startDate && endDate
? startDate === endDate
? startDate
: `${startDate}/${endDate}`
: startDate || endDate || ''
const startTimeOnly = startTimeRaw.replace('T', ' ').trim().split(' ')[1] ?? ''
const endTimeOnly = endTimeRaw.replace('T', ' ').trim().split(' ')[1] ?? ''
return {
ticketUid: Number(row.ticketUid ?? 0),
tktNum: String(row.tktId ?? row.tktNum ?? row.ticketNo ?? ''),
bizName: String(row.bizName ?? ''),
startTime: startTimeRaw,
endTime: endTimeRaw,
dateText,
startTimeOnly,
endTimeOnly,
status,
statusText: String(row.statusText ?? statusTextMap[status] ?? '未知状态'),
canCall: Boolean(row.canCall),
canReEvaluate: Boolean(row.canReEvaluate),
}
})
}
const handleCall = async (row: Tkt): Promise<void> => {
window.winControl.windowMinimize()
window.ticketToMain.triggerCall()
await window.appLogger.log(
'info',
`票池呼叫按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`,
)
}
// API
const handleReEvaluate = async (row: Tkt): Promise<void> => {
window.winControl.windowMinimize()
window.ticketToMain.triggerEvaluate()
await window.appLogger.log(
'info',
`票池评价按钮点击: ticketUid=${row.ticketUid}, tktNum=${row.tktNum}`,
)
}
// API
const refreshData = async (): Promise<void> => {
if (loading.value) return
loading.value = true
try {
//
await new Promise(resolve => setTimeout(resolve, 500))
allData.value = generateMockData(158) // 158
const session = await window.session.get()
const cfg = await window.appConfig.getAll()
const configuredWinUid = Number(cfg.selected_win_uid ?? cfg.selected_win_key ?? -1)
const winUid = configuredWinUid > 0 ? configuredWinUid : Number(session.winUid ?? -1)
if (winUid <= 0) {
allData.value = []
totalCount.value = 0
return
}
const res = await api.action.pool({
winUid,
keyword: keyword.value.trim(),
page: currentPage.value,
size: pageSize.value,
status: statusFilter.value === null ? undefined : statusFilter.value,
})
const rawRows = Array.isArray(res.list)
? res.list
: Array.isArray(res.records)
? res.records
: Array.isArray(res.items)
? res.items
: []
allData.value = normalizeRows(rawRows as unknown[])
totalCount.value = Number(res.total ?? res.count ?? allData.value.length)
} catch (error) {
await window.appLogger.log(
'error',
`票池刷新失败: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
loading.value = false
}
@ -221,32 +354,39 @@ const refreshData = async (): Promise<void> => {
//
onMounted(() => {
refreshData()
//
const updateTableHeight = (): void => {
// 800x600
tableHeight.value = window.innerHeight - 180
}
void refreshData()
refreshTimer = setInterval(() => {
void refreshData()
}, 20_000)
updateTableHeight()
window.addEventListener('resize', updateTableHeight)
// tableHeight
})
return () => {
window.removeEventListener('resize', updateTableHeight)
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<style scoped>
.table-container {
height: 100vh;
width: 100vw;
height: 720px;
width: 1024px;
display: flex;
background-color: #fff;
flex-direction: column;
/* padding: 16px; */
box-sizing: border-box;
overflow: hidden;
}
.search-wrapper {
display: flex;
gap: 8px;
padding: 8px 16px 0;
margin: 16px;
}
.pagination-wrapper {
@ -257,7 +397,7 @@ onMounted(() => {
}
.stats-info {
margin: 4px 0px;
margin: 8px 0px;
padding: 0px 16px;
font-size: 12px;
color: #909399;
@ -265,13 +405,22 @@ onMounted(() => {
}
:deep(.el-table) {
flex: 1;
table-layout: auto;
}
:deep(.el-table__header) {
background-color: #f5f7fa;
}
:deep(.el-table__header-wrapper),
:deep(.el-table__body-wrapper) {
overflow-x: hidden !important;
}
:deep(.el-table .cell) {
white-space: nowrap;
}
:deep(.el-pagination) {
padding: 0;
}

@ -0,0 +1,9 @@
/** 与 JSON 序列化兼容的配置值类型,可在业务中扩展为更具体的结构 */
export type JsonPrimitive = string | number | boolean | null
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
/**
* JSON
* `server_ip` IP/host[:port] http(s) URL API baseURL
*/
export type AppConfig = Record<string, JsonValue>
Loading…
Cancel
Save