This commit is contained in:
gyq
2025-12-01 10:49:30 +08:00
commit a15324ae9e
51 changed files with 7696 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

29
.env.development Normal file
View File

@@ -0,0 +1,29 @@
# 本地环境
ENV = development
# 测试ws
# VITE_API_WSS = 'wss://sockets.sxczgkj.com/wss'
# 正式ws
# VITE_API_WSS = 'wss://czgeatws.sxczgkj.com/wss'
# 本地ws
VITE_API_WSS = 'ws://192.168.1.42:2348'
# 正式 php
VITE_API_PHP_URL = 'https://newblockwlx.sxczgkj.cn/index.php/api'
# 测试 php 开票
# VITE_API_KP_URL = 'http://192.168.1.13:8888/api'
# 正式 php 开票
VITE_API_KP_URL = 'https://invoice.sxczgkj.cn/api'
# 本地调试连接
VITE_API_URL = 'http://192.168.1.42/'
# 线上测试
# VITE_API_URL = 'https://tapi.cashier.sxczgkj.cn'
# 线上正式
# VITE_API_URL = 'https://cashier.sxczgkj.com'

14
.env.production Normal file
View File

@@ -0,0 +1,14 @@
# 线上环境
ENV = production
# 正式ws
VITE_API_WSS = 'wss://czgeatws.sxczgkj.com/wss'
# 正式 php
VITE_API_PHP_URL = 'https://newblockwlx.sxczgkj.cn/index.php/api'
# 正式 php 开票
VITE_API_KP_URL = 'https://invoice.sxczgkj.cn/api'
# 线上环境接口地址
VITE_API_URL = 'https://cashier.sxczgkj.com/'

29
.env.test Normal file
View File

@@ -0,0 +1,29 @@
# 测试环境
ENV = test
# 测试ws
VITE_API_WSS = 'ws://192.168.1.42:2348'
# 测试ws
# VITE_API_WSS = 'wss://sockets.sxczgkj.com/wss'
# 正式ws
# VITE_API_WSS = 'wss://czgeatws.sxczgkj.com/wss'
# 正式 php
VITE_API_PHP_URL = 'https://newblockwlx.sxczgkj.cn/index.php/api'
# 测试 php 开票
# VITE_API_KP_URL = 'http://192.168.1.13:8888/api'
# 正式 php 开票
VITE_API_KP_URL = 'https://invoice.sxczgkj.cn/api'
# 测试Java
# VITE_API_URL = 'https://fv901fw8033.vicp.fun/'
# 正式Java
# VITE_API_URL = 'https://cashier.sxczgkj.com/'
# 本地调试连接
VITE_API_URL = 'http://192.168.1.42/'

View File

@@ -0,0 +1,88 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ElLoading": true,
"ElMessage": true,
"ElMessageBox": true,
"ElNotification": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"ShallowRef": true,
"Slot": true,
"Slots": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"getCurrentWatcher": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"isShallow": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useId": true,
"useLink": true,
"useModel": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"useTemplateRef": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*

3
.npmrc Normal file
View File

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

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

4
.prettierrc.yaml Normal file
View File

@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
}

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# kitchen-desktop
An Electron application with Vue
## 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)
## Project Setup
### Install
```bash
$ pnpm install
```
### Development
```bash
$ pnpm dev
```
### Build
```bash
# For windows
$ pnpm build:win
# For macOS
$ pnpm build:mac
# For Linux
$ pnpm build:linux
```

20
addVersion.js Normal file
View File

@@ -0,0 +1,20 @@
//npm run build打包前执行此段代码
const fs = require('fs')
//返回package的json数据
function getPackageJson() {
let data = fs.readFileSync('./package.json');//fs读取文件
return JSON.parse(data);//转换为json对象
}
let packageData = getPackageJson();//获取package的json
let arr = packageData.version.split('.');//切割后的版本号数组
arr[2] = parseInt(arr[2]) + 1;
packageData.version = arr.join('.');//转换为以"."分割的字符串
//用packageData覆盖package.json内容
fs.writeFile(
'./package.json',
JSON.stringify(packageData, null, "\t"
),
(err) => { }
);

View File

@@ -0,0 +1,12 @@
<?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>

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

44
electron-builder.yml Normal file
View File

@@ -0,0 +1,44 @@
appId: com.electron.app
productName: kitchen-desktop
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
asarUnpack:
- resources/**
win:
executableName: kitchen-desktop
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/

80
electron.vite.config.mjs Normal file
View File

@@ -0,0 +1,80 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import { loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd(), "")
const rendererSrc = resolve(__dirname, 'src/renderer/src')
return {
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
// 保持并合并原 vite 配置proxy、resolve、esbuild 等)
server: {
hmr: { enabled: true },
proxy: {
'/api': {
target: env.VITE_API_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/php': {
target: env.VITE_API_PHP_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/php/, ''),
},
'/kp': {
target: env.VITE_API_KP_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/kp/, ''),
},
},
},
resolve: {
alias: {
'@renderer': rendererSrc,
'@': rendererSrc,
},
extensions: ['.vue', '.js', '.jsx', '.json'],
preserveSymlinks: true,
ignoreCase: false,
},
plugins: [
vue(),
// 自动导入 Composition API、vue-router 等,生成类型声明到 src/auto-imports.d.ts
AutoImport({
imports: [
'vue',
'vue-router',
{
'element-plus': [
'ElMessage',
'ElMessageBox',
'ElNotification',
'ElLoading'
]
}
],
dts: 'src/auto-imports.d.ts',
// 生成 ESLint 全局变量配置,防止 ESLint 报未定义
eslintrc: {
enabled: true,
filepath: './.eslintrc-auto-import.json',
globalsPropValue: true
}
}),
],
esbuild: {
drop: env.ENV == 'production' ? ['console'] : [],
},
},
}
})

30
eslint.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import eslintConfig from '@electron-toolkit/eslint-config'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
export default [
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
eslintConfig,
...eslintPluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaFeatures: {
jsx: true
},
extraFileExtensions: ['.vue']
}
}
},
{
files: ['**/*.{js,jsx,vue}'],
rules: {
'vue/require-default-prop': 'off',
'vue/multi-word-component-names': 'off'
}
},
eslintConfigPrettier
]

29
jsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
],
"@renderer/*": [
"src/renderer/src/*"
]
},
"moduleResolution": "Node",
"lib": [
"ESNext",
"DOM"
]
},
"include": [
"src/**/*.js",
"src/**/*.vue",
"src/auto-imports.d.ts", // 关键:包含自动导入的声明文件
"src/components.d.ts"
],
"exclude": [
"node_modules"
]
}

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "yinshouke-kitchen",
"version": "1.0.0",
"description": "后厨专用软件",
"main": "./out/main/index.js",
"author": "example.com",
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "electron-vite build",
"build:test": "electron-vite build --mode test && electron-builder --win",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "node ./addVersion.js && vite build && npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"element-plus": "^2.11.8",
"lodash": "^4.17.21",
"pinia": "^3.0.4",
"reconnecting-websocket": "^4.4.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^2.1.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@vitejs/plugin-vue": "5.1.4",
"electron": "^38.1.2",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.4.0",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.6.2",
"sass": "^1.94.2",
"unplugin-auto-import": "^20.3.0",
"vite": "5.4.8",
"vue": "^3.5.21",
"vue-eslint-parser": "^10.2.0",
"vue-router": "4"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
}
}

4869
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

9
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// This file is generated by unplugin-auto-import. If you don't see it after install+dev, the plugin will create it at runtime.
// You can keep this placeholder so editors recognize the path before generation.
// eslint-disable-next-line
declare module 'vue' {
export function ref<T = any>(value?: T): import('vue').Ref<T>
export function reactive<T = any>(value: T): T
export function onMounted(fn: () => void): void
}

108
src/main/index.js Normal file
View File

@@ -0,0 +1,108 @@
import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
function createWindow() {
// 创建主窗口:这是渲染进程的承载窗口
// 说明:窗口配置可根据产品需求调整尺寸、是否显示菜单栏、是否显示时机等。
// 注意:在 Electron 中尽量将不受信任的逻辑放到 preload 中并开启 sandbox如可行
// 以降低 Renderer 被注入恶意脚本时的攻击面。本项目为兼容性将 sandbox 设为 false。
// 开发环境下使用固定宽高,生产环境改为使用屏幕可用区域宽高
let winWidth = 1280
let winHeight = 670
if (!is.dev) {
try {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
winWidth = width
winHeight = height
} catch (e) {
// 若获取屏幕信息失败则回退为默认值
console.warn('获取屏幕尺寸失败,使用默认窗口大小', e)
}
}
const mainWindow = new BrowserWindow({
width: winWidth,
height: winHeight,
show: false,
// 开发环境下显示菜单栏,生产环境下隐藏菜单栏
autoHideMenuBar: !is.dev,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
// preload 脚本用于在渲染进程加载前注入受控的桥接 API推荐用于 IPC
preload: join(__dirname, '../preload/index.js'),
// sandbox 控制是否启用 Chromium 的沙箱(提高安全性但可能影响某些原生模块行为)
sandbox: false,
// 关闭 webSecurity 会禁用同源策略和部分安全校验(危险)。
// 按用户要求在桌面端允许所有请求时启用此配置。
webSecurity: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
// 始终允许使用 F12 打开/关闭开发者工具(在任何环境都可用)
mainWindow.webContents.on('before-input-event', (event, input) => {
// 处理 F12 快捷键
if (input.key === 'F12') {
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.toggleDevTools()
event.preventDefault()
}
}
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 开发/生产加载逻辑:
// - 开发环境is.dev使用 Vite 提供的远程渲染地址热重载、HMR
// - 生产环境下,加载打包后的本地 HTML 文件(更可靠且无需热重载)。
// 注意ELECTRON_RENDERER_URL 由 electron-vite 在开发时注入,指向本地 dev server。
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// 当 Electron 初始化完成并准备创建窗口时执行(某些 API 必须在此之后使用)
app.whenReady().then(() => {
// 在 Windows 上设置 App User Model ID可支持通知、任务栏图标分组等功能
electronApp.setAppUserModelId('com.electron')
// 在开发环境中绑定默认快捷键行为F12 打开/关闭 DevTools生产环境禁用刷新快捷键等
// 具体细节由 electron-toolkit 的 optimizer 帮助管理,以提升开发体验并避免生产环境误触。
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// 简单的 IPC 测试示例:主进程监听 ping 并在控制台打印 pong
// 说明:真实项目中请在 preload 中注册受控的 IPC 接口给渲染进程使用,避免直接暴露 ipcRenderer
ipcMain.on('ping', () => console.log('pong'))
// 创建主窗口并加载渲染内容
createWindow()
// macOS 特殊行为:当 Dock 图标被点击且没有窗口时,通常需要重新创建窗口
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// 关闭应用的行为当所有窗口关闭时退出应用macOS 除外macOS 通常在菜单中保留应用运行)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// 其他主进程逻辑可以放在此文件或单独模块中并在此处引入。
// 安全建议:
// - 尽量将渲染进程与主进程的职责分离,通过 preload 提供受控的桥接接口。
// - 在生产环境打开 CSP、安全头或 nodeIntegration 限制,减少攻击面。

20
src/preload/index.js Normal file
View File

@@ -0,0 +1,20 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
// 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)
}
} else {
window.electron = electronAPI
window.api = api
}

24
src/renderer/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!--
为了允许 WebSocket 连接,需要为 connect-src 显式放行 ws: 或 wss:。
这里添加了 `connect-src 'self' ws: wss:`,表示允许当前源与任意 ws/wss 主机建立连接。
出于安全考虑,建议把 ws: wss: 替换为精确的主机或域名,例如:
connect-src 'self' wss://ws.example.com;
在生产环境中请尽量限制为可靠的主机。
-->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' *; connect-src * ws: wss: http: https: data: blob: filesystem:; script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline'; img-src * data: blob: filesystem:;" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

34
src/renderer/src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<div class="container">
<router-view />
</div>
</template>
<script setup>
import { createPinia } from 'pinia'
import { useSocketStore } from '@/stores/socket';
const pinia = createPinia()
onMounted(() => {
console.log('WS URL:', import.meta.env.VITE_WS_URL);
// 重启或刷新防止ws丢失
const socketStore = useSocketStore(pinia)
socketStore.restoreFromStorage()
})
</script>
<style lang="scss">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
html,
body {
background-color: #f7f7f7;
}
</style>

View File

@@ -0,0 +1,42 @@
import request from "@/utils/request.js";
const BASE_URL = '/account/admin'
/**
* 商户登录
* @param {*} data
* @returns
*/
export function login(data) {
return request({
method: "post",
url: `${BASE_URL}/auth/login`,
data,
});
}
/**
* 验证码获取
* @param {*} data
* @returns
*/
export function captcha(data) {
return request({
method: "get",
url: `${BASE_URL}/auth/captcha`,
data,
});
}
/**
* 退出登录
* @param {*} data
* @returns
*/
export function logout(data) {
return request({
method: "post",
url: `${BASE_URL}/auth/logout`,
data,
});
}

View File

@@ -0,0 +1,14 @@
import request from "@/utils/request.js";
/**
* 智慧充值 配置信息获取
* @param {*} data
* @returns
*/
export function shopRecharge(params) {
return request({
method: "get",
url: "/market/admin/shopRecharge",
params,
});
}

View File

@@ -0,0 +1,56 @@
import request from "@/utils/request.js";
const BASE_URL = '/order/admin'
/**
* 按台桌查看
* @param {*} params
* @returns
*/
export function getKitchenTable(params) {
return request({
method: "get",
url: `${BASE_URL}/kitchen/getKitchenTable`,
params,
});
}
/**
* 按台桌查看 商品内容
* @param {*} params
* @returns
*/
export function getKitchenTableFoods(params) {
return request({
method: "get",
url: `${BASE_URL}/kitchen/getKitchenTableFoods`,
params,
});
}
/**
* 按商品查看
* @param {*} params
* @returns
*/
export function getKitchenFood(params) {
return request({
method: "get",
url: `${BASE_URL}/kitchen/getKitchenFood`,
params,
});
}
/**
* 起菜, 上菜
* @param {*} data
* @returns
*/
export function upOrderDetail(data) {
return request({
method: "put",
url: `${BASE_URL}/order/upOrderDetail`,
data,
});
}

View File

@@ -0,0 +1,16 @@
import request from "@/utils/request.js";
const BASE_URL = '/product/admin/prod/'
/**
* 商品分类列表
* @param {*} params
* @returns
*/
export function categoryList(params) {
return request({
method: "get",
url: `${BASE_URL}/category/list`,
params,
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

82
src/renderer/src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,82 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const ElLoading: typeof import('element-plus').ElLoading
const ElMessage: typeof import('element-plus').ElMessage
const ElMessageBox: typeof import('element-plus').ElMessageBox
const ElNotification: typeof import('element-plus').ElNotification
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -0,0 +1,17 @@
<template>
<svg t="1764238036312" class="icon" viewBox="0 0 1229 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="5896" :width="size" :height="size">
<path
d="M570.365088 585.913247a296.467113 296.467113 0 0 0 161.120466-262.988421 302.262813 302.262813 0 0 0-604.389257 0 296.535298 296.535298 0 0 0 161.120466 263.056606A424.245257 424.245257 0 0 0 0 985.407455a38.592545 38.592545 0 0 0 77.18509 0 352.037651 352.037651 0 0 1 704.075302 0 38.66073 38.66073 0 0 0 77.253275 0 425.199843 425.199843 0 0 0-288.216764-399.494208zM204.554126 322.924826a225.009539 225.009539 0 1 1 224.259507 221.736672A223.850398 223.850398 0 0 1 204.554126 322.924826z m0 0l754.054693 226.236863a296.330744 296.330744 0 0 0 141.687824-251.806129 300.62638 300.62638 0 0 0-301.921889-297.762623 37.978883 37.978883 0 1 0 0 75.957766 221.736673 221.736673 0 1 1 0 443.200606 38.115252 38.115252 0 1 0 0 76.162319 349.719371 349.719371 0 0 1 352.03765 346.991983 38.592545 38.592545 0 0 0 77.18509 0 423.972518 423.972518 0 0 0-268.784121-392.948476z m0 0"
fill="#666666" p-id="5897"></path>
</svg>
</template>
<script setup>
const props = defineProps({
size: {
type: Number,
default: 20
}
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<svg t="1764236030042" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"
p-id="4757" data-spm-anchor-id="a313x.search_index.0.i2.d39c3a81bI6cex" :width="size" :height="size">
<path
d="M928 544 96 544c-17.664 0-32-14.336-32-32s14.336-32 32-32l832 0c17.696 0 32 14.336 32 32S945.696 544 928 544zM832 928l-192 0c-17.696 0-32-14.304-32-32s14.304-32 32-32l192 0c17.664 0 32-14.336 32-32l0-160c0-17.696 14.304-32 32-32s32 14.304 32 32l0 160C928 884.928 884.928 928 832 928zM352 928 192 928c-52.928 0-96-43.072-96-96l0-160c0-17.696 14.336-32 32-32s32 14.304 32 32l0 160c0 17.664 14.368 32 32 32l160 0c17.664 0 32 14.304 32 32S369.664 928 352 928zM128 384c-17.664 0-32-14.336-32-32L96 192c0-52.928 43.072-96 96-96l160 0c17.664 0 32 14.336 32 32s-14.336 32-32 32L192 160C174.368 160 160 174.368 160 192l0 160C160 369.664 145.664 384 128 384zM896 384c-17.696 0-32-14.336-32-32L864 192c0-17.632-14.336-32-32-32l-192 0c-17.696 0-32-14.336-32-32s14.304-32 32-32l192 0c52.928 0 96 43.072 96 96l0 160C928 369.664 913.696 384 896 384z"
fill="#333333" p-id="4758"></path>
</svg>
</template>
<script setup>
const props = defineProps({
size: {
type: Number,
default: 20
}
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,99 @@
import { ref } from 'vue'
/**
* useScanListener
* 简单的扫码枪监听器(基于键盘事件,按键速率判断是否为扫码枪)
*
* options:
* - timeout: 两次按键超过此毫秒数则认为是新输入(默认 50ms
* - minLength: 最小字符长度(默认 3
* - ignoreIfFocus: 如果焦点在 input/textarea/contentEditable则忽略默认 true
* - onScan: 扫描完成回调,参数为字符串
*/
export function useScanListener(options = {}) {
const {
timeout = 50,
minLength = 3,
ignoreIfFocus = true,
onScan = null,
} = options
const lastScan = ref('')
let buffer = ''
let lastTime = 0
let timer = null
let started = false
function resetBuffer() {
buffer = ''
lastTime = 0
if (timer) {
clearTimeout(timer)
timer = null
}
}
function keyHandler(e) {
try {
const active = document.activeElement
const tag = active && active.tagName
if (ignoreIfFocus && (tag === 'INPUT' || tag === 'TEXTAREA' || active?.isContentEditable)) {
return
}
const now = Date.now()
if (lastTime && now - lastTime > timeout) {
// 超过间隔认为是新一次输入
buffer = ''
}
lastTime = now
// 结束按键(许多扫码枪以 Enter 结束)
if (e.key === 'Enter' || e.key === 'Return') {
if (buffer.length >= minLength) {
lastScan.value = buffer
if (typeof onScan === 'function') {
try { onScan(buffer) } catch (err) { console.error('onScan callback error', err) }
}
}
resetBuffer()
e.preventDefault()
return
}
// 只收集可打印字符长度为1
if (e.key && e.key.length === 1) {
buffer += e.key
}
// 防止长时间不按键时残留,延迟清理
if (timer) clearTimeout(timer)
timer = setTimeout(() => resetBuffer(), Math.max(200, timeout * 4))
} catch (err) {
console.error('scan keyHandler error', err)
resetBuffer()
}
}
function start() {
if (started) return
window.addEventListener('keydown', keyHandler)
started = true
}
function stop() {
if (!started) return
window.removeEventListener('keydown', keyHandler)
resetBuffer()
started = false
}
// auto start
start()
return {
lastScan,
start,
stop,
}
}

44
src/renderer/src/main.js Normal file
View File

@@ -0,0 +1,44 @@
import { useUserStore } from '@/stores/user';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router/index.js';
// 1. 引入 Element Plus 核心库和样式
import ElementPlus from 'element-plus';
import zhCn from "element-plus/es/locale/lang/zh-cn";
import 'element-plus/dist/index.css';
// 2. 可选:引入 Element Plus 图标库(全局注册)
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
// 1. 导入 Pinia 的创建函数
import { createPinia } from 'pinia';
// 2. 可选:导入 Pinia 持久化插件
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
// 3. 创建 Pinia 实例
const pinia = createPinia();
// 4. 可选:注册持久化插件
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
app.use(router);
// 3. 全局注册 Element Plus
app.use(ElementPlus, { locale: zhCn });
// 4. 可选:全局注册所有图标(也可按需引入)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(pinia); // 挂载 Pinia
// 初始化:挂载后立即校验 Token 并跳转
router.isReady().then(() => {
const userStore = useUserStore(); // 无需 require直接使用
if (!userStore.token) {
router.push('/login');
}
app.mount('#app'); // 最后挂载应用
});

View File

@@ -0,0 +1,62 @@
// 引入 Vue Router 核心方法
import { createRouter, createWebHashHistory } from 'vue-router';
import { useUserStore } from '@/stores/user'
// 引入页面组件
import home from '@/views/home.vue';
import login from '@/views/login.vue';
// 路由规则数组
const routes = [
{
path: '/',
name: 'home',
component: home
},
{
path: '/login',
name: 'login',
meta: {
requiresAuth: false
},
component: login
}
// 可选:配置 404 页面(需先创建 NotFound.vue 组件)
// {
// path: '/:pathMatch(.*)*',
// component: () => import('../views/NotFound.vue')
// }
];
// 创建路由实例
const router = createRouter({
// 🔥 关键Electron 中推荐使用 hash 模式createWebHashHistory
// 原因Electron 渲染进程是本地文件加载history 模式会导致刷新 404
history: createWebHashHistory(),
routes // 传入路由规则
});
// 修复后的路由守卫
router.beforeEach((to, from, next) => {
const userStore = useUserStore();
const hasToken = !!userStore.token; // 转为布尔值,避免空字符串/undefined的判断误差
// 只有当目标页面需要登录且无Token时才跳转到登录页
if (to.meta.requiresAuth) {
if (hasToken) {
next(); // 有Token正常放行
} else {
next({ path: "/login" }); // 无Token跳登录页
}
} else {
// 目标页面无需登录(如登录页),直接放行
next();
}
// 【可选】额外处理:已登录状态下访问登录页,自动跳转到首页
// if (to.path === "/login" && hasToken) {
// next({ path: "/" });
// }
});
// 导出路由实例,供 main.js 挂载
export default router;

View File

@@ -0,0 +1,344 @@
import { defineStore } from 'pinia'
import ReconnectingWebSocket from 'reconnecting-websocket'
import { useUserStore } from './user'
/*
socket store 简要说明(中文注释)
目的:
- 提供一个在整个应用中可复用的 WebSocket 管理层,支持自动重连、订阅管理和单例保证。
- 使用 `reconnecting-websocket` 实现自动重连策略,避免因短暂网络抖动导致的断线影响。
使用场景:
- 在任意组件中通过 `const socketStore = useSocketStore()` 获取实例并调用 `init`/`subscribe`/`send`。
- 在非组件模块(例如在 `main.js` 或 store 之外)中,如果没有全局注册 Pinia可以传入 `pinia` 实例:`useSocketStore(pinia)`。
主要 API
- init(url, protocols?, opts?):创建并绑定事件监听器,返回底层 ReconnectingWebSocket 实例。
- connect():尝试连接或返回现有连接。
- close(code, reason):主动关闭连接并清理单例实例。
- send(payload):发送消息(自动 JSON 序列化非字符串负载)。
- subscribe(fn):订阅到达的消息,返回 unsubscribe 函数。
- onOpen(fn)、onClose(fn):分别订阅连接打开/关闭回调,同样返回 unsubscribe 函数。
- restoreFromStorage():从 localStorage 恢复上次的 url 并尝试 init。
注意store 本身不会在模块加载时自动创建 Pinia 实例或自动调用 init避免副作用。若需要自动恢复请在应用入口处显式调用 `restoreFromStorage()`。
*/
// 模块级单例变量,用于保存底层 ReconnectingWebSocket 实例,确保全局唯一
let _rws = null
// 存储最终用于创建 RWS 的配置(会合并默认值和用户传入的 opts
let _options = null
// 标记是否已经为当前 _rws 绑定了事件监听器,防止重复绑定导致回调被触发多次
let _listenersBound = false
// 心跳相关模块变量
let _heartbeatTimer = null
let _heartbeatTimeoutTimer = null
let _heartbeatOpts = null
let _missedPongs = 0
/**
* createRws
* - 创建或返回已有的 ReconnectingWebSocket 单例
* - 参数:
* - url: websocket 地址,如 `wss://example.com/ws`(必须)
* - protocols: 可选的子协议数组
* - opts: 透传给 ReconnectingWebSocket 的选项(例如 connectionTimeout、maxRetries 等)
*/
function createRws(url, protocols = [], opts = {}) {
// 如果已经存在实例,直接返回(单例保证)
if (_rws) return _rws
const defaultOpts = {
// 在浏览器运行时使用全局 WebSocket
WebSocket: WebSocket,
connectionTimeout: 4000,
// 无限重试,直到手动 close
maxRetries: Infinity,
// 如需自定义重连间隔等策略,可在 opts 中传入
// reconnectInterval: 2000
}
_options = Object.assign({}, defaultOpts, opts)
_rws = new ReconnectingWebSocket(url, protocols, _options)
return _rws
}
export const useSocketStore = defineStore('socket', {
state: () => ({
// 当前使用的 websocket 地址(由 init 设置)
url: '',
// 子协议
protocols: null,
// 是否已连接(不保证数据收发正常,仅代表 open/close 状态)
connected: false,
// 最近一次收到的消息(解析成功则为对象/数组,否则为原始字符串)
lastMessage: null,
// 内部维护的订阅集合key 为随机 idvalue 为回调函数
_subs: {},
_openSubs: {},
_closeSubs: {},
}),
actions: {
/**
* init
* - 初始化 websocket保存 url、持久化 url、创建底层 rws 实例并绑定事件
* - 注意:此函数应在应用初始化或用户登录后调用(当需要带 token 的 url
*/
init(url, protocols = [], opts = {}) {
if (!url) throw new Error('socket init requires url')
this.url = url
this.protocols = protocols
// 将 url 持久化到 localStorage刷新后可以通过 restoreFromStorage 恢复
try { localStorage.setItem('socket:url', url) } catch (e) { /* 在某些浏览器隐身模式下可能失败 */ }
// 如果已有实例且与当前 url 不同,则先关闭旧连接,避免多个连接同时存在
if (_rws && _rws.url && _rws.url !== url) {
try { _rws.close() } catch (e) { /* ignore */ }
_rws = null
_listenersBound = false
}
const rws = createRws(url, protocols, opts)
// 仅为当前实例绑定一次事件监听,防止多次 init() 导致重复回调
if (!_listenersBound) {
// 连接打开事件:更新状态并通知所有 onOpen 订阅者
rws.addEventListener('open', () => {
this.connected = true
// 注意:不在这里隐式调用 send(),避免在多个地方重复发送初始化消息
Object.values(this._openSubs).forEach(fn => { try { fn() } catch (e) { console.error(e) } })
})
// 连接关闭事件:更新状态并通知所有 onClose 订阅者
rws.addEventListener('close', () => {
this.connected = false
Object.values(this._closeSubs).forEach(fn => { try { fn() } catch (e) { console.error(e) } })
})
// 消息到达事件:尝试解析 JSON若失败则保留原始字符串并通知所有 message 订阅者
rws.addEventListener('message', (ev) => {
let data = ev.data
try { data = JSON.parse(ev.data) } catch (e) { /* 若非 JSON 则保留原始字符串 */ }
this.lastMessage = data
// 识别 pong 响应(常见约定:字符串 'pong' 或对象中包含 type === 'pong' 或 operate_type === 'pong'
const isPong = (data === 'pong') || (data && (data.type === 'pong' || data.operate_type === 'pong'))
if (isPong) {
// 收到 pong 则重置未响应计数并清除超时计时器
_missedPongs = 0
if (_heartbeatTimeoutTimer) {
clearTimeout(_heartbeatTimeoutTimer)
_heartbeatTimeoutTimer = null
}
// 不把 pong 当作普通消息继续分发
return
}
// 如果消息携带 msg_id回执给服务器避免丢失
try { if (data && data.msg_id) this.sendMsgId(data.msg_id) } catch (e) { console.error(e) }
Object.values(this._subs).forEach(fn => { try { fn(data) } catch (e) { console.error(e) } })
})
_listenersBound = true
// 如果外部传入了心跳配置,启动心跳
const hb = opts && opts.heartbeat
if (hb) {
// 存一份便于后续 stop/restart
_heartbeatOpts = Object.assign({ interval: 30000, timeout: 10000, maxMissed: 2, message: { type: 'ping' } }, hb)
try { this.startHeartbeat(_heartbeatOpts) } catch (e) { console.warn('startHeartbeat failed', e) }
}
}
// 存储最终 options以便后续 connect() 使用
_options = Object.assign({}, _options || {}, opts)
return rws
},
/**
* connect
* - 如果未指定 url则尝试从 localStorage 恢复
* - 如果已有打开的连接则直接返回
*/
connect() {
if (!this.url) {
const saved = localStorage.getItem('socket:url')
if (saved) this.init(saved)
else throw new Error('no socket url to connect')
}
if (_rws && _rws.readyState === WebSocket.OPEN) return _rws
return createRws(this.url, this.protocols, _options)
},
/**
* close
* - 主动关闭连接并清理模块级单例
* - 参数close code默认 1000 正常关闭)和 reason
*/
close(code = 1000, reason = 'client close') {
if (_rws) {
_rws.close(code, reason)
_rws = null
// 清除监听绑定标记,允许后续重新绑定新的实例
_listenersBound = false
// 停止心跳
try { this.stopHeartbeat() } catch (e) { /* ignore */ }
}
this.connected = false
},
/**
* send
* - 发送消息到服务器payload 可以是字符串或对象(对象会被 JSON.stringify
* payload 格式 { operate_type: 'init , data: {...}}
*/
send(payload = { operate_type: 'init', data: { table_code: '' } }) {
if (!_rws) throw new Error('socket not initialized')
const params = {
operate_type: 'init',
data: {
table_code: ''
},
...payload
}
const userStore = useUserStore()
let sendData = {
type: 'manage',
account: userStore.shopInfo.id,
operate_type: params.operate_type,
table_code: params.data.table_code,
shop_id: userStore.shopInfo.id
}
const data = typeof sendData === 'string' ? sendData : JSON.stringify(sendData)
_rws.send(data)
},
/**
* startHeartbeat
* - 启动应用层心跳(通过发送自定义 ping 消息),并在超时/未收到 pong 时触发重连
* - opts: { interval, timeout, maxMissed, message }
*/
startHeartbeat(opts = {}) {
// 合并默认配置
const conf = Object.assign({ interval: 10000, timeout: 10000, maxMissed: 2, message: { type: "ping_interval", set: "manage" } }, opts)
_heartbeatOpts = conf
// 停止已有心跳
this.stopHeartbeat()
// 定时发送 ping
_heartbeatTimer = setInterval(() => {
try {
if (!_rws || _rws.readyState !== WebSocket.OPEN) return
// 发送心跳消息
const hbMsg = typeof conf.message === 'string' ? conf.message : JSON.stringify(conf.message)
_rws.send(hbMsg)
// 等待 pong 的超时计时器
if (_heartbeatTimeoutTimer) clearTimeout(_heartbeatTimeoutTimer)
_heartbeatTimeoutTimer = setTimeout(() => {
_missedPongs += 1
console.warn(`[socket] missed pong ${_missedPongs}/${conf.maxMissed}`)
if (_missedPongs >= conf.maxMissed) {
// 达到最大未响应次数,主动关闭底层连接以触发重连
try { _rws.close() } catch (e) { console.error(e) }
}
}, conf.timeout)
} catch (e) {
console.error('heartbeat send error', e)
}
}, conf.interval)
},
/**
* stopHeartbeat
*/
stopHeartbeat() {
if (_heartbeatTimer) {
clearInterval(_heartbeatTimer)
_heartbeatTimer = null
}
if (_heartbeatTimeoutTimer) {
clearTimeout(_heartbeatTimeoutTimer)
_heartbeatTimeoutTimer = null
}
_missedPongs = 0
_heartbeatOpts = null
},
// 回应msg_id
sendMsgId(msg_id) {
if (!_rws) throw new Error('socket not initialized')
let sendData = {
type: 'receipt',
msg_id: msg_id
}
const data = typeof sendData === 'string' ? sendData : JSON.stringify(sendData)
_rws.send(data)
},
/**
* subscribe
* - 订阅所有到达的消息,返回一个 unsubscribe 函数
* - 订阅函数签名fn(message)
*/
subscribe(fn) {
if (typeof fn !== 'function') throw new Error('subscribe requires a function')
const id = Date.now().toString(36) + Math.random().toString(36).slice(2)
this._subs[id] = fn
return () => { delete this._subs[id] }
},
/**
* onOpen
* - 监听连接打开事件,同样返回 unsubscribe
*/
onOpen(fn) {
if (typeof fn !== 'function') return () => { }
const id = Date.now().toString(36) + Math.random().toString(36).slice(2)
this._openSubs[id] = fn
return () => { delete this._openSubs[id] }
},
/**
* onClose
* - 监听连接关闭事件,返回 unsubscribe
*/
onClose(fn) {
if (typeof fn !== 'function') return () => { }
const id = Date.now().toString(36) + Math.random().toString(36).slice(2)
this._closeSubs[id] = fn
return () => { delete this._closeSubs[id] }
},
// 手动恢复上次连接(比如页面刷新后)
restoreFromStorage() {
const saved = localStorage.getItem('socket:url')
if (saved) {
this.init(saved)
}
}
}
})
// 模块级的自动恢复提示(但不自动创建 store 实例以避免副作用)
try {
const saved = localStorage.getItem('socket:url')
if (saved) {
// 延迟到微任务,以避免模块初始化顺序问题
Promise.resolve().then(() => {
// 注意:此处不自动调用 useSocketStore(),因为在模块加载阶段可能没有注册全局 pinia。
// 如果希望页面刷新后自动重连,请在应用入口(如 main.js显式调用
// const socketStore = useSocketStore(pinia)
// socketStore.restoreFromStorage()
const storeModule = null // placeholder: do not auto-create Pinia store here
})
}
} catch (e) {
// ignore
}

View File

@@ -0,0 +1,75 @@
// 导入 Pinia 的仓库创建函数
import { defineStore } from 'pinia';
import { login as apiLogin, logout as apiLogout } from '@/api/account.js'
import { ElMessage, ElMessageBox } from 'element-plus';
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
shopInfo: '',
shopStaff: ''
}),
actions: {
async login(params) {
try {
const res = await apiLogin(params)
if (res.shopInfo.registerType == 'after') {
this.token = res.tokenInfo.tokenValue
this.shopInfo = res.shopInfo
// 登录员工
if (res.loginType == 1) this.shopStaff = res.shopStaff
// async 函数直接返回值即可 resolve
return res
} else {
ElMessage.error('先下单模式下不可登录,请联系管理员')
// 抛出错误使调用方能捕获到失败状态
throw new Error('先下单模式下不可登录')
}
} catch (error) {
console.error(error);
// 向上抛出,确保调用方能收到 rejected 状态
throw error;
}
},
logout() {
ElMessageBox.confirm('确定要退出登录吗?')
.then(async () => {
try {
await apiLogout()
// 登出成功后尝试关闭 websocket若已存在
// try {
// const mod = await import('@/stores/socket')
// const socketStore = mod.useSocketStore()
// socketStore.close()
// } catch (e) {
// console.warn('close socket failed', e)
// }
this.token = ''
this.shopInfo = ''
this.shopStaff = ''
ElMessage.success('已退出登录')
// 避免在 store 顶层直接导入 router会造成循环依赖或在模块初始化时为 undefined
const { default: router } = await import('@/router')
router.replace({ name: 'login' })
} catch (error) {
console.error(error);
throw error;
}
})
.catch(() => { })
},
},
// 核心:开启持久化,确保 Token 保存在 localStorage
persist: {
enabled: true,
strategies: [
{
key: 'kitchen-user',
storage: localStorage,
paths: ['token', 'shopInfo', 'shopStaff'] // 持久化指定状态
}
]
}
});

View File

@@ -0,0 +1,107 @@
import dayjs from 'dayjs';
/**
* 计算当前时间与传入时间的时间差,并按规则格式化
* @param {string|number|Date} targetTime - 传入的目标时间支持字符串、时间戳、Date对象
* @returns {string} 格式化后的时间差≤1小时MM:ss>1小时HH:MM:ss| 错误提示
*/
export const formatTimeDiff = (targetTime) => {
// 1. 校验传入时间的有效性
const target = dayjs(targetTime);
if (!target.isValid()) {
return '00:00'; // 无效时间返回提示
}
// 2. 计算当前时间与目标时间的**秒级总差值**(当前时间 - 传入时间)
const diffSeconds = dayjs().diff(target, 'second');
// 处理差值为负数的情况(传入时间在当前时间之后,差值为负)
const absDiffSeconds = Math.abs(diffSeconds);
// 3. 按秒数分解为 小时、分钟、秒
const hours = Math.floor(absDiffSeconds / 3600); // 1小时=3600秒
const minutes = Math.floor((absDiffSeconds % 3600) / 60);
const seconds = absDiffSeconds % 60;
// 4. 补零函数确保单个数字补0如 5 → 05
const padZero = (num) => num.toString().padStart(2, '0');
// 5. 根据是否超过1小时返回不同格式
if (hours > 0) {
// >1小时HH:MM:ss
return `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}`;
} else {
// ≤1小时MM:ss
return `${padZero(minutes)}:${padZero(seconds)}`;
}
};
/**
* 判断时分秒字符串是否大于指定分钟数
* @param {string} timeStr - 时分秒字符串,如 "26:30:40"
* @param {number} [thresholdMinutes=15] - 分钟阈值默认15分钟
* @returns {boolean} 大于阈值返回true否则false格式错误返回false
*/
export const isMoreThanSpecifiedMinutes = (timeStr, thresholdMinutes = 15) => {
// 1. 按冒号分割时分秒并转换为数字
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
// 2. 校验时分秒格式是否合法
if (
isNaN(hours) ||
isNaN(minutes) ||
isNaN(seconds) ||
hours < 0 ||
minutes < 0 || minutes >= 60 ||
seconds < 0 || seconds >= 60
) {
console.error('时分秒格式错误,示例:"26:30:40"');
return false;
}
// 3. 转换为总秒数:小时*3600 + 分钟*60 + 秒
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
// 4. 将分钟阈值转换为总秒数,进行比较
const thresholdSeconds = thresholdMinutes * 60;
return totalSeconds > thresholdSeconds;
};
/**
* 计算时分秒字符串超出指定分钟阈值的时长返回HH:MM:SS格式
* @param {string} timeStr - 时分秒字符串,如 "26:30:40"
* @param {number} [thresholdMinutes=15] - 分钟阈值默认15分钟
* @returns {string} 超时时长HH:MM:SS未超时返回"00:00:00",格式错误返回"格式错误"
*/
export const calculateTimeoutDuration = (timeStr, thresholdMinutes = 15) => {
// 1. 按冒号分割时分秒并转换为数字
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
// 2. 校验时分秒格式合法性
if (
isNaN(hours) ||
isNaN(minutes) ||
isNaN(seconds) ||
hours < 0 ||
minutes < 0 || minutes >= 60 ||
seconds < 0 || seconds >= 60
) {
console.error('时分秒格式错误,示例:"26:30:40"');
return '格式错误';
}
// 3. 转换为总秒数
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
// 4. 计算阈值对应的总秒数
const thresholdSeconds = thresholdMinutes * 60;
// 5. 计算超时秒数差值≤0则未超时
const timeoutSeconds = Math.max(totalSeconds - thresholdSeconds, 0);
// 6. 将超时秒数转换回HH:MM:SS格式补零处理
const padZero = (num) => num.toString().padStart(2, '0');
const timeoutHours = padZero(Math.floor(timeoutSeconds / 3600));
const timeoutMins = padZero(Math.floor((timeoutSeconds % 3600) / 60));
const timeoutSecs = padZero(timeoutSeconds % 60);
return `${timeoutHours}:${timeoutMins}:${timeoutSecs}`;
};

View File

@@ -0,0 +1,77 @@
import axios from "axios";
import { ElMessage } from "element-plus";
import { useUserStore } from "@/stores/user";
const service = axios.create({
baseURL:
import.meta.env.MODE == "development"
? "/api/"
: import.meta.env.VITE_API_URL,
// withCredentials: true, // 跨域请求时发送 cookies
timeout: 20000, // 请求超时
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
// 在发送请求之前做些什么 token
config.headers["platformType"] = "WEB";
if (userStore.token) {
// 让每个请求携带 token
// ['X-Token'] 是自定义标题键
// 请根据实际情况修改
config.headers["token"] = userStore.token;
if (userStore.shopInfo && userStore.shopInfo.id) {
config.headers["shopId"] = userStore.shopInfo.id;
}
// config.headers["loginName"] = useStorage.get("userInfo").loginName;
// config.headers['Content-Type'] = 'application/json'
}
return config;
},
(error) => {
// 处理请求错误
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
// 对响应数据做点什么
if (+response.status === 200) {
if (+response.data.code == 200) {
return response.data.data;
} else if (+response.data.code == 501) {
useStorage.del("token");
useStorage.del("userInfo");
useStorage.del("shopInfo");
useStorage.del("douyin");
ElMessage.error("登录已过期,请重新登录");
window.location.reload();
return Promise.reject("登录已过期,请重新登录");
} else {
// 响应错误
ElMessage.error(response.data.msg);
return Promise.reject(response.data);
}
}
},
(error) => {
// 对响应错误做点什么
if (error.message.indexOf("timeout") != -1) {
ElMessage.error("网络超时");
} else if (error.message == "Network Error") {
ElMessage.error("网络连接错误");
} else {
console.log(error);
if (error.response.data) ElMessage.error(error.response.statusText);
else ElMessage.error("接口路径找不到");
}
return Promise.reject(error);
}
);
export default service;

View File

@@ -0,0 +1,42 @@
export default [
{
label: "未绑定",
type: "unbound",
color: "#909090",
},
{
label: "空闲",
type: "idle",
color: "#187CAA",
},
{
label: "点餐中",
type: "ordering",
color: "#46AEA4",
},
{
label: "未结账",
type: "unsettled",
color: "#DD3F41",
},
{
label: "支付中",
type: "paying",
color: "#909090",
},
{
label: "待清台",
type: "settled ",
color: "#FF9500",
},
{
label: "关台",
type: "closed",
color: "#DDDDDD",
},
{
label: "预定",
type: "subscribe",
color: "#58B22C",
},
];

View File

@@ -0,0 +1,15 @@
export default {
get(key) {
return JSON.parse(localStorage.getItem(key))
},
set(key, value) {
localStorage.setItem(key, JSON.stringify(value))
},
del(key) {
localStorage.removeItem(key)
},
clear() {
localStorage.clear()
}
}

View File

@@ -0,0 +1,650 @@
<template>
<div class="container">
<div class="header_wrap">
<div class="btn" @click="userStore.logout()">
<el-icon>
<SwitchButton />
</el-icon>
<el-text>退出</el-text>
</div>
<div class="title"></div>
<div class="name">
<div class="dot" :class="{ online: socketStore.connected }"></div>
<el-text>
{{ userStore.shopStaff.name || userStore.shopInfo.shopName }}
</el-text>
</div>
</div>
<div class="tab_wrap">
<div class="left">
<div class="item" v-for="(item, index) in checkTypeList" :key="index"
@click="queryForm.tableName = ''; checkTypeHandle(item)">
<el-text :type="checkType == item.value ? 'primary' : ''">
{{ item.label }}
</el-text>
</div>
</div>
<div class="right" @click="showScanTipsHandle">
<scanIcon />
<el-text>扫码出菜</el-text>
</div>
</div>
<div class="search_wrap">
<div class="left">
<el-form inline @submit.prevent>
<el-form-item :label="checkType == 1 ? '台桌查找' : '菜品查找'">
<el-input v-model="queryForm.tableName" :placeholder="checkType == 1 ? '请输入台桌' : '请输入菜品'"
clearable @clear="inputClear"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchHandle">查询</el-button>
</el-form-item>
</el-form>
</div>
<!-- <div class="right">
<el-form inline>
<el-form-item>
<el-button type="primary" style="width: 100px;">出菜</el-button>
</el-form-item>
</el-form>
</div> -->
</div>
<div class="table_list_wrap" v-if="checkType == 1">
<div class="item list" :class="{ empty: tableList.length <= 0 }">
<div class="tab_item" v-for="item in tableList" :key="item.orderId" @click="changeTableItem(item)">
<div class="tab_header_wrap">
{{ item.tableName }} | {{ item.areaName }}
</div>
<div class="content">
<div class="btn_wrap">
<div class="btn">
<el-button type="primary" style="width: 100%;">待出菜{{ item.pendingDishCount
}}</el-button>
</div>
</div>
<div class="people_wrap">
<peopleIcon />
<span>1</span>
</div>
</div>
</div>
<el-empty v-if="tableList.length <= 0"></el-empty>
</div>
<div class="item table_info">
<div class="table_info_wrap">
<el-button type="primary">{{ selectItem.tableName || '-' }} | {{ selectItem.areaName || '-'
}}</el-button>
<el-text>待出菜{{ selectItem.pendingDishCount || '-' }}</el-text>
<el-text>员工名称{{ selectItem.staffName || notStaff }}</el-text>
<el-text>下单时间{{ selectItem.orderTime || '-' }}</el-text>
</div>
<div class="table_wrap">
<el-table :data="tableData.list" border stripe>
<el-table-column label="菜品名称" prop="productName"></el-table-column>
<el-table-column label="用时" prop="startOrderTime">
<template v-slot="scope">
<el-text v-if="scope.row.subStatus == 'READY_TO_SERVE'">{{
formatTimeDiff(scope.row.startOrderTime) }}</el-text>
<el-text v-if="scope.row.subStatus == 'TIMEOUT'">{{ scope.row.timeout_time }}</el-text>
<el-text v-if="scope.row.subStatus == 'SENT_OUT'">{{ scope.row.dishOutTime }}</el-text>
</template>
</el-table-column>
<el-table-column label="状态" prop="subStatus" width="150">
<template v-slot="scope">
<el-tag disable-transitions :type="statusFilter(scope.row.subStatus).type" size="large">
{{ statusFilter(scope.row.subStatus).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template v-slot="scope">
<el-button type="primary"
v-if="scope.row.subStatus == 'READY_TO_SERVE' || scope.row.subStatus == 'TIMEOUT'"
@click="upOrderDetailAjax(scope.row)">出菜</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<div class="goods_list_wrap" v-else>
<div class="tablef_head">
<div class="item" @click="changeGoodsIndexHandle({ id: '' }, -1)">
<el-text :type="goodsListActive == -1 ? 'primary' : ''">全部</el-text>
</div>
<div class="item" v-for="(item, index) in categorys" :key="item.id"
@click="changeGoodsIndexHandle(item, index)">
<el-text :type="goodsListActive == index ? 'primary' : ''">{{ item.name }}</el-text>
</div>
</div>
<div class="table_wrap">
<div class="table" v-for="(item, index) in goodsTableData" :key="index">
<div class="goods_table_title">
<el-text size="large">{{ item.productName }}</el-text>
</div>
<el-table :data="item.foodItems" border stripe>
<el-table-column label="下单台桌" prop="areaName"></el-table-column>
<el-table-column label="用时" prop="startOrderTime">
<template v-slot="scope">
<el-text>{{ formatTimeDiff(scope.row.startOrderTime) }}</el-text>
</template>
</el-table-column>
<el-table-column label="状态" prop="subStatus" width="150">
<template v-slot="scope">
<el-tag disable-transitions :type="statusFilter(scope.row.subStatus).type" size="large">
{{ statusFilter(scope.row.subStatus).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="下单时间" prop="orderTime"></el-table-column>
<el-table-column label="员工" prop="staffName">
<template v-slot="scope">
{{ scope.row.staffName || notStaff }}
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template v-slot="scope">
<el-button type="primary"
v-if="scope.row.subStatus == 'READY_TO_SERVE' || scope.row.subStatus == 'TIMEOUT'"
@click="upOrderDetailAjax(scope.row)">出菜</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-if="goodsTableData.length <= 0"></el-empty>
</div>
</div>
</div>
</template>
<script setup>
import dayjs from 'dayjs';
import { useUserStore } from '@/stores/user';
import scanIcon from '@/components/icons/scanIcon.vue';
import peopleIcon from '@/components/icons/peopleIcon.vue';
import tableStatus from '@/utils/tableStatusList.js'
import { formatTimeDiff, isMoreThanSpecifiedMinutes, calculateTimeoutDuration } from '@/utils'
import { categoryList } from "@/api/product.js";
import { getKitchenTable, getKitchenTableFoods, getKitchenFood, upOrderDetail } from '@/api/order.js'
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElNotification } from 'element-plus';
import { useScanListener } from '@/composables/useScanListener'
import { useSocketStore } from "@/stores/socket.js";
import { onUnmounted } from 'vue';
const socketStore = useSocketStore()
const unsub = socketStore.subscribe(msg => {
console.log('got', msg)
if (msg.type == 'bc') {
checkTypeHandle({ value: checkType.value })
}
})
const userStore = useUserStore()
const notStaff = ref('管理员')
// 扫码获取扫码内容
const { lastScan } = useScanListener({
onScan(code) {
let str = code.split(':')
handleScan(str[1])
}
})
// 扫码提示
function showScanTipsHandle() {
ElNotification({
type: 'warning',
title: '注意',
message: '请使用扫码设备扫描菜品小票出菜'
})
}
const checkTypeList = ref([
{
label: '按台桌查看',
value: 1
},
{
label: '按菜品查看',
value: 2
}
])
const checkType = ref(1)
// 清除搜索内容
function inputClear() {
goodsListActive.value = -1
checkTypeHandle({ value: checkType.value })
}
// 搜索
function searchHandle() {
goodsListActive.value = -1
checkTypeHandle({ value: checkType.value })
}
// 切换查看类型
async function checkTypeHandle(item) {
checkType.value = item.value
if (item.value == 1) {
await getKitchenTableAjax()
} else {
await getKitchenFoodAjax()
}
startUpdate()
}
const categorys = ref([])
const categoryId = ref('')
async function categoryListAjax() {
try {
const res = await categoryList()
categorys.value = res
} catch (error) {
console.log(error);
}
}
const queryForm = reactive({
tableName: ''
})
// 按台桌查看
const tableList = ref([])
const selectItem = ref({})
async function getKitchenTableAjax() {
try {
const res = await getKitchenTable({
tableName: queryForm.tableName
})
tableList.value = res
if (res.length > 0) {
selectItem.value = res[0]
getKitchenTableFoodsAjax()
} else {
selectItem.value = {}
tableData.list = []
}
} catch (error) {
console.log(error);
}
}
// 切换台桌
function changeTableItem(item) {
selectItem.value = item
getKitchenTableFoodsAjax()
}
// 按台桌查看 商品内容
const tableData = reactive({
list: []
})
async function getKitchenTableFoodsAjax() {
try {
const res = await getKitchenTableFoods({
orderId: selectItem.value.orderId,
tableCode: selectItem.value.tableCode,
isNoTable: selectItem.value.tableCode ? '' : 1
})
res.forEach(item => {
item.timeout_time = ''
})
tableData.list = res
} catch (error) {
console.log(error);
}
}
// 按菜品查看
const goodsListActive = ref(-1)
const goodsList = ref([])
const goodsTableData = ref([])
async function getKitchenFoodAjax() {
try {
const res = await getKitchenFood({
productName: queryForm.tableName,
categoryId: categoryId.value
})
goodsList.value = res
goodsTableData.value = goodsList.value
} catch (error) {
console.log(error);
}
}
// 切换类型
function changeGoodsIndexHandle(item, index) {
categoryId.value = item.id
goodsListActive.value = index
// if (index == -1) {
// goodsTableData.value = goodsList.value
// } else {
// goodsTableData.value = [goodsList.value[index]]
// }
getKitchenFoodAjax()
}
// 扫码出菜
function handleScan(code) {
console.log('handleScan', code);
try {
upOrderDetailAjax({
orderDetailId: code
})
} catch (err) {
console.error('handleScan error', err)
}
}
// 出菜
async function upOrderDetailAjax(item) {
try {
await upOrderDetail({
orderDetailId: item.orderDetailId,
type: 2
})
ElMessage.success('出菜成功');
checkTypeHandle({ value: checkType.value })
} catch (error) {
console.log(error);
}
}
// 菜品状态 待起菜 PENDING_PREP 待出菜 READY_TO_SERVE 已出菜 SENT_OUT 已上菜 DELIVERED
function statusFilter(status) {
let statusList = [
{
value: 'PENDING_PREP',
label: '待起菜',
type: 'info'
},
{
value: 'READY_TO_SERVE',
label: '待出菜',
type: 'warning'
},
{
value: 'SENT_OUT',
label: '已出菜',
type: 'success'
},
{
value: 'DELIVERED',
label: '已上菜',
type: 'primary'
},
{
value: 'TIMEOUT',
label: '已超时',
type: 'danger'
}
]
let obj = statusList.find(item => item.value == status)
if (obj && obj.value) {
return obj
} else {
return {
value: status,
label: '未知',
type: 'warning'
}
}
}
// 刷新所有数据的状态
function updateOrderStatus() {
console.log('刷新所有数据的状态.tableData.list', tableData.list);
console.log('刷新所有数据的状态.goodsTableData.value', goodsTableData.value);
if (checkType.value == 1) {
// 刷新按台桌查看的数据
tableData.list.forEach(item => {
let timeSpent = formatTimeDiff(item.startOrderTime)
if (item.subStatus == 'READY_TO_SERVE' && isMoreThanSpecifiedMinutes(timeSpent, userStore.shopInfo.serveTime)) {
item.subStatus = 'TIMEOUT'
item.timeout_time = calculateTimeoutDuration(timeSpent)
}
})
} else {
// 刷新按菜品查看的数据
goodsTableData.value.forEach(val => {
val.foodItems.forEach(item => {
let timeSpent = formatTimeDiff(item.startOrderTime)
if (item.subStatus == 'READY_TO_SERVE' && isMoreThanSpecifiedMinutes(timeSpent, userStore.shopInfo.serveTime)) {
item.subStatus = 'TIMEOUT'
item.timeout_time = calculateTimeoutDuration(timeSpent)
}
})
})
}
}
// 启动刷新
const timer = ref(null)
function startUpdate() {
if (userStore.shopInfo.isServeTimeControl && (tableList.length || tableData.list.length)) {
// 只有开启的情况下调用
updateOrderStatus()
if (timer.value !== null) {
clearInterval(timer.value)
}
timer.value = setInterval(() => {
updateOrderStatus()
}, 1000);
}
}
onMounted(async () => {
checkTypeHandle({ value: checkType.value })
categoryListAjax()
socketStore.init(import.meta.env.VITE_API_WSS)
socketStore.startHeartbeat()
// 如果需要在打开时发送初始化,可使用 onOpen 注册一次性回调:
const off = socketStore.onOpen(() => {
socketStore.send({ operate_type: 'init' })
off() // 若只需一次,注册后立即取消
})
})
onUnmounted(() => {
clearInterval(timer.value)
this.timer = null
unsub.close()
})
</script>
<style scoped lang="scss">
.container {
--padding: 24px;
.header_wrap {
--height: 57px;
display: flex;
height: var(--height);
align-items: center;
padding: 0 var(--padding);
background-color: #fff;
.btn {
width: var(--height);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.title {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
.dot {
--size: 8px;
width: var(--size);
height: var(--size);
border-radius: 50%;
position: relative;
top: 1px;
background-color: var(--el-color-danger);
&.online {
background-color: var(--el-color-success);
}
}
}
}
.tab_wrap {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ececec;
padding: 0 var(--padding) 14px;
background-color: #fff;
.left {
display: flex;
gap: var(--padding);
}
.right {
display: flex;
align-items: center;
gap: 4px;
}
}
.search_wrap {
background-color: #fff;
padding: 16px var(--padding) 0 var(--padding);
display: flex;
align-items: center;
justify-content: space-between;
}
.table_list_wrap {
margin-top: 14px;
background-color: #fff;
padding: var(--padding);
display: flex;
gap: var(--padding);
.item {
flex: 1;
&.list {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(auto, 1fr);
grid-column-gap: var(--padding);
grid-row-gap: var(--padding);
&.empty {
display: flex;
justify-content: center;
}
.tab_item {
height: 150px;
background-color: #3F9EFF;
padding: 7px;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease-in-out;
&.active {
transform: translateY(-10px) scale(1.1);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
}
.tab_header_wrap {
font-size: 14px;
color: #fff;
padding-bottom: 10px;
flex-shrink: 0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 3px;
padding: 0 7px;
.btn_wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 20px;
}
.people_wrap {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 0 20px 0;
border-top: 1px solid #ececec;
span {
font-size: 14px;
color: #666;
}
}
}
}
}
&.table_info {
.table_info_wrap {
display: flex;
align-items: center;
gap: 10px;
}
.table_wrap {
padding-top: var(--padding);
}
}
}
}
.goods_list_wrap {
margin-top: 14px;
background-color: #fff;
padding: var(--padding);
gap: var(--padding);
.tablef_head {
display: flex;
gap: var(--padding);
}
.goods_table_title {
padding: 10px 0 5px 0;
}
}
}
</style>

View File

@@ -0,0 +1,311 @@
<!-- 登录 -->
<template>
<div class="container">
<div class="login_wrap">
<div class="logo_wrap">
<img src="@/assets/logo_text.png" alt="logo" />
<span>银收客订餐</span>
</div>
<div class="login_content">
<div class="lgoin_tab">
<div class="item" :class="{ active: form.loginType == 0 }" @click="form.loginType = 0">
<div class="t">商户登录</div>
</div>
<div class="item" :class="{ active: form.loginType == 1 }" @click="form.loginType = 1">
<div class="t">员工登录</div>
</div>
</div>
<div class="form_wrap" :class="[`radius${form.loginType}`]">
<div class="title_wrap">
<span class="t1">欢迎登录</span>
<span class="t2">welcome to login in</span>
</div>
<el-form ref="formRef" :model="form" :rules="rules">
<el-form-item prop="username">
<el-input v-model="form.username" clearable prefix-icon="User" size="large" :maxlength="20"
placeholder="请输入用户名/手机号"></el-input>
</el-form-item>
<el-form-item prop="staffUserName" v-if="form.loginType == 1">
<el-input v-model="form.staffUserName" clearable prefix-icon="User" size="large"
:maxlength="20" placeholder="请输入员工用户名/手机号"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" clearable prefix-icon="Lock" size="large" :maxlength="20"
placeholder="请输入登录密码"></el-input>
</el-form-item>
<el-form-item prop="code">
<div class="ipt_wrap">
<div class="ipt">
<el-input v-model="form.code" clearable prefix-icon="Warning" size="large"
:maxlength="6" placeholder="请输入验证码" style="width: 100%;"></el-input>
</div>
<div class="code" @click="captchaAjax">
<img class="img" :src="codeImgUrl">
</div>
</div>
</el-form-item>
</el-form>
<div class="btn_wrap">
<el-button type="primary" size="large" style="width: 100%;" :loading="loading"
@click="submitHandle">登录</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { captcha } from '@/api/account.js'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const formRef = ref(null)
const loading = ref(false)
const form = ref({
loginType: 0, // 登录类型 0:商户登录 1:员工登录
username: '',
password: '',
staffUserName: '',
code: '',
uuid: ''
})
const codeImgUrl = ref('')
const rules = ref({
username: [
{
required: true,
message: ' ',
trigger: 'blur'
}
],
password: [
{
required: true,
message: ' ',
trigger: 'blur'
}
],
staffUserName: [
{
required: true,
message: ' ',
trigger: 'blur'
}
],
code: [
{
required: true,
message: ' ',
trigger: 'blur'
}
]
})
// 登录
function submitHandle() {
formRef.value.validate(vaild => {
if (vaild) {
loading.value = true
userStore.login(form.value).then(res => {
router.replace({ name: 'home' })
}).catch(err => {
loading.value = false
console.log(err);
})
}
})
}
// 获取验证码
async function captchaAjax() {
try {
const { code, uuid } = await captcha()
form.value.uuid = uuid
codeImgUrl.value = code
} catch (error) {
console.log(error);
}
}
onMounted(() => {
captchaAjax()
})
</script>
<style scoped lang="scss">
.container {
width: 100vw;
height: 100vh;
background: url('@/assets/logo_bg.png') no-repeat center center / cover;
position: relative;
.login_wrap {
width: 420px;
position: absolute;
top: 50%;
right: 4%;
transform: translateY(-50%);
.logo_wrap {
width: 100%;
display: flex;
align-items: center;
padding-bottom: 24px;
img {
width: 93px;
height: 44px;
}
span {
font-size: 40px;
color: #3D66B0;
font-weight: bold;
position: relative;
top: -2px;
}
}
.login_content {
width: 100%;
background-color: #F3F4F5;
border-radius: 20px;
overflow: hidden;
.lgoin_tab {
width: 100%;
display: flex;
background-color: #F3F4F5;
padding-top: 10px;
.item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
position: relative;
.t {
position: relative;
z-index: 2;
font-size: 16px;
font-weight: bold;
color: #666666;
}
&.active {
.t {
font-size: 20px;
color: var(--el-color-primary);
}
&::after {
content: '';
width: 110%;
height: 110%;
background-color: #fff;
position: absolute;
top: 50%;
left: 50%;
z-index: 1;
}
&::before {
content: "";
width: 40px;
height: 2px;
position: absolute;
bottom: 6px;
left: 50%;
z-index: 2;
transform: translateX(-50%);
background-color: var(--el-color-primary);
}
}
&:nth-child(1) {
&.active {
&::after {
border-radius: 0 20px 0 0;
transform: skewX(20deg) translate(-50%, -50%);
}
}
}
&:nth-child(2) {
&.active {
&::after {
border-radius: 20px 0 0 0;
transform: skewX(-20deg) translate(-50%, -50%);
}
}
}
}
}
.form_wrap {
width: 100%;
background-color: #fff;
padding: 24px 37px;
&.radius0 {
border-radius: 0 20px 0 0;
}
&.radius1 {
border-radius: 20px 0 0 0;
}
.title_wrap {
display: flex;
flex-direction: column;
margin-bottom: 28px;
.t1 {
color: #333;
font-size: 20px;
font-weight: bold;
}
.t2 {
font-size: 14px;
color: #999;
}
}
.ipt_wrap {
width: 100%;
display: flex;
gap: 10px;
.ipt {
flex: 1;
}
.code {
width: 100px;
height: 40px;
.img {
width: 100%;
height: 100%;
border-radius: 4px;
}
}
}
.btn_wrap {
width: 100%;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// 本地 vite 配置,用于在 src/renderer 目录直接运行 `vite build` 或 `vite` 时
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
// 将 '@' 映射到 src/renderer/src保持与主工程的别名一致
'@': resolve(__dirname, 'src')
}
}
})