diff --git a/call-client/src/main/app-config.ts b/call-client/src/main/app-config.ts new file mode 100644 index 0000000..5591293 --- /dev/null +++ b/call-client/src/main/app-config.ts @@ -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 { + 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): 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 { + return config +} diff --git a/call-client/src/main/file-logger.ts b/call-client/src/main/file-logger.ts new file mode 100644 index 0000000..e1c258e --- /dev/null +++ b/call-client/src/main/file-logger.ts @@ -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 + +/** + * 单文件最大体积(字节)。需求为「不超过 100MB~1GB」区间内的上限控制,默认取 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//logs + * 未设置 XDG_STATE_HOME 时使用 ~/.local/state//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, +} diff --git a/call-client/src/main/index.ts b/call-client/src/main/index.ts index c3114f8..6295e97 100644 --- a/call-client/src/main/index.ts +++ b/call-client/src/main/index.ts @@ -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) + 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 => { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Invalid dialog payload') + } + const p = payload as Record + 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. diff --git a/call-client/src/main/window.ts b/call-client/src/main/window.ts index 792812a..e4f5dd3 100644 --- a/call-client/src/main/window.ts +++ b/call-client/src/main/window.ts @@ -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, // 不可调整大小 diff --git a/call-client/src/preload/index.ts b/call-client/src/preload/index.ts index 658873d..a6ba0e4 100644 --- a/call-client/src/preload/index.ts +++ b/call-client/src/preload/index.ts @@ -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 => 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) => 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), diff --git a/call-client/src/renderer/src/api/index.ts b/call-client/src/renderer/src/api/index.ts index 3696b5a..f6fc47b 100644 --- a/call-client/src/renderer/src/api/index.ts +++ b/call-client/src/renderer/src/api/index.ts @@ -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('/call-terminal/start', data), complete: (data: ReCallRequest) => http.post('/call-terminal/complete', data), evaluate: (data: ReCallRequest) => http.post('/call-terminal/evaluate', data), + pool: (params: TicketPoolRequest) => + http.get('/call-terminal/pool', params), }, } diff --git a/call-client/src/renderer/src/env.d.ts b/call-client/src/renderer/src/env.d.ts index 275e76e..72a7349 100644 --- a/call-client/src/renderer/src/env.d.ts +++ b/call-client/src/renderer/src/env.d.ts @@ -1,5 +1,7 @@ /// +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 +} + +interface IAppConfig { + getAll: () => Promise + set: (partial: Record) => Promise +} + +interface INativeDialogConfirmOptions { + title: string + message: string + okLabel?: string + cancelLabel?: string +} + +interface INativeDialog { + confirm: (options: INativeDialogConfirmOptions) => Promise +} + +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 } } diff --git a/call-client/src/renderer/src/main.ts b/call-client/src/renderer/src/main.ts index 8ecac9a..af252b3 100644 --- a/call-client/src/renderer/src/main.ts +++ b/call-client/src/renderer/src/main.ts @@ -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 { + 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() diff --git a/call-client/src/renderer/src/router/index.ts b/call-client/src/renderer/src/router/index.ts index ade815e..d24667b 100644 --- a/call-client/src/renderer/src/router/index.ts +++ b/call-client/src/renderer/src/router/index.ts @@ -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 diff --git a/call-client/src/renderer/src/types/ticket.ts b/call-client/src/renderer/src/types/ticket.ts new file mode 100644 index 0000000..5a61e8c --- /dev/null +++ b/call-client/src/renderer/src/types/ticket.ts @@ -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 +} diff --git a/call-client/src/renderer/src/utils/service.ts b/call-client/src/renderer/src/utils/service.ts index 556a88e..b100ca7 100644 --- a/call-client/src/renderer/src/utils/service.ts +++ b/call-client/src/renderer/src/utils/service.ts @@ -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 { diff --git a/call-client/src/renderer/src/views/Login.vue b/call-client/src/renderer/src/views/Login.vue index f22bf92..7d403b7 100644 --- a/call-client/src/renderer/src/views/Login.vue +++ b/call-client/src/renderer/src/views/Login.vue @@ -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>([]) let sessionState: SessionState = { empUid: null, winUid: null, @@ -86,20 +87,41 @@ const handleLogin = async (): Promise => { 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 => { const handleWindowLogin = async (): Promise => { 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) }) diff --git a/call-client/src/renderer/src/views/Main.vue b/call-client/src/renderer/src/views/Main.vue index 25b9c9b..e6fd459 100644 --- a/call-client/src/renderer/src/views/Main.vue +++ b/call-client/src/renderer/src/views/Main.vue @@ -1,10 +1,23 @@ diff --git a/call-client/src/renderer/src/views/ServerSetup.vue b/call-client/src/renderer/src/views/ServerSetup.vue new file mode 100644 index 0000000..e035711 --- /dev/null +++ b/call-client/src/renderer/src/views/ServerSetup.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/call-client/src/renderer/src/views/TicketList.vue b/call-client/src/renderer/src/views/TicketList.vue index d1efa19..f8d7f51 100644 --- a/call-client/src/renderer/src/views/TicketList.vue +++ b/call-client/src/renderer/src/views/TicketList.vue @@ -1,5 +1,24 @@