ts #1

Open
yuhaiming wants to merge 1 commits from ts into main
36 changed files with 1760 additions and 533 deletions
Showing only changes of commit ab7ec17ae0 - Show all commits

View File

@@ -1,6 +1,6 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 防御机管理系统 VITE_APP_TITLE = 驭鲲智防系统
VITE_APP_LOGO_TITLE = 防御机管理系统 VITE_APP_LOGO_TITLE = 驭鲲智防系统
# 开发环境配置 # 开发环境配置
VITE_APP_ENV = 'development' VITE_APP_ENV = 'development'
@@ -20,13 +20,11 @@ VITE_APP_SNAILJOB_ADMIN = 'http://localhost:9002/snail-job'
VITE_APP_PORT = 80 VITE_APP_PORT = 80
# 接口加密功能开关(如需关闭 后端也必须对应关闭) # 接口加密功能开关(如需关闭 后端也必须对应关闭)
VITE_APP_ENCRYPT = false VITE_APP_ENCRYPT = true
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换 # 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
#VITE_APP_RSA_PUBLIC_KEY = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==' VITE_APP_RSA_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtVIQm+1AWMfkT1uVOyugKQwBeHtgpulABOrVfaL7Xzc1/rKSdLdjYZLZnnOU+rdE1jqz20MXDBozK22spH/j53d9nFLftHkMCRUlfc0MWEqtSla+2MdzElIHEbq4CSizZOcT8QXCRw7V2OuDqnC7XRQmnpA5Ks1WaKH5V0aoKWztjsqZV6vVWPqyDC94mvu/Q/9e6KVfkqAzR+afuo9OoBFewDGQ6jfqyu1TxoGZrfSr5lXOVeADf0CVfWCDze5y4f7er4JjPHWA2+yv8Scd9bqVJ4Q+/yR2Gd516Mi11OgBKc8GdkuA4/kfmXc7n91SMjgQP6DlTVuPc/6KnB/pyQIDAQAB'
VITE_APP_RSA_PUBLIC_KEY = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAJJA9FRt24dkoEqTRBdm9yx6MQ5+f+rDIrLdCAXFUtpGcsT+kea/iGzk50OfSzSYxQYF8g/iksanB9XN0Ab6anECAwEAAQ=='
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换 # 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
#VITE_APP_RSA_PRIVATE_KEY = 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=' VITE_APP_RSA_PRIVATE_KEY = 'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDYg1dC9XDMRtKJ5IFj+Hd3bq7+r2YL1y8FM9HD111PN/YVy4K6O67cRA/TLG7+tl6eYiA73f5lzPgv1riOwBPSCmvVopxq2KiJs30uEnug0qGp6l2cwQOmjitFAQF8dOtAl4m6EM/jdXCZYbQrthJ6n3tR+vGj2IrGSJEjPrj3VRljK9I7Zw0Be0rhJn/vZw2CA4GoVfE/3gqa5bpl+3SFxMP7TwF5uZueCNqLaPnd7jK5jB1SUghI7+pq897Ng4EG6kdOGsiQe5kbHWkSPTmPEObMwgLJ18i/XCVg+LUkTDJOC/MAfB+WGcKryNHrhcbhB6+XstpoGT8JlOPOgh0BAgMBAAECggEBALZG7MH8bDguL7XTYHNPjRRJZJ4aAGlbgcR5edHMkEvPKyfyK16qPG5IBqKcN93Mnx/sMIL2Q+RkiVWNCdd0MbuU9m/m9JSnTkyPhYWyHc6pRV0NaD496Nrhud/gFuY2cI/yhArXeI1gI6mdrddW83u0pFfCUojEFyETmsz3UzU1LMkVbwCXNbZ1b3GfcVnONMn8T/7CMlRPlKyOuE0OPnTut4wddAybe5BKDQYnGzuosUdO+Q+2K3hqaTD6Bb33D4ATHjvR3Vt9itE07BiaBFjZkE2odP4x3fSsnc7Cw3A7zHQivpfgqcoBcGUaMcN1FdgpaF2mbe9/eHn/lYCpevkCgYEA4FoKq3fPOITAtA6bjwCHXvIQUjv4vQ0BZtt7OX7zwpRwqOhJfN0ZRkd7NMUqYnB10N9NgPRz0kKC6qff5duN+zkYIzjHrlm5lzdIEAouovRlrGXST4n8gAYteh5D5Or9wRXj7USQoaNyiWKB9A/07ortJvul09sPjJU7IVfzzXMCgYEA9w45Gh2bAvYvm2bWieKZRhcV6Pwy9R8587qmKLC7V/0gfVojBHx7xTmTEfKCGrt3fK4jB5v/2Dar8gDF+RM7g01u0YuFJt17jeFr/4o5KMimfXhoEulDbgjfvhxQOrtkrF5iHpKxiEXqIPiH+B7155PAnE43011AAJCBSylmTrsCgYEAkxr3PA9HFKwXHvklDtMt8BeQlBs2sd9BOAxZ9A1GECP86wPEi9b9p5NfOe6+J+XNmrOQwimHeCqcZPjGWpVnt35sUUv9wlia1Igu/DVw9vCBalUpXXYA1oE2eIg3xHZBBMYxuXXnz9S4WVT6GOoNlAwMDC+dQBi3TVrcdrSQ6/kCgYBA254ICh+ovmKvJGdMGY3thZ/94z+pdEIthyGZ6xOzvMMrxV8ODXQcycmfW4/mXrK2q6yMkdqvs2KejK9sfS0RgmGGZ19UXa7TB4vnsSziVRLIO7TuyggmufOrIBm74XhDfB+8MPykbt5RO43OiKo72mElZ69mMMPdohIfXkX6gwKBgH4L9qJRhNunWSfHO4/8dST+2meSV+Cpqf0b/9cV6v5FMS22m+szzW9XoBXnI9dbAqiEDlCHCLw34pRlC3OzBNlbT6HzNmYXLko2Ttg9thiDq7WS5tV+EHJRP6kLabYGq0Aghtq+6LIwwogbHzhRi8Q52o3CNmM9lThNWZIkzaBe'
VITE_APP_RSA_PRIVATE_KEY = 'MIIBOgIBAAJBAIHPXmO+vf9u33iXv97aYeuPsJ7Ut+GdBjpT7JOIEE1rIEIfaKK268uPwAHW+/fNOKQiTBuunI4PAiL7CAQoVeECAwEAAQJACS6S3aP9/9W8MY/XqZAcQbOjPvOK94bKNvv9YvN1O785/2U5z7hUHvF8O8qIttlMNqhJ60JO9x58EUE51/dZ1QIhAISwFmIah9HoOLR5M+sBsUb5dIAm1mg0hj7T/5LyMTO7AiEA+nKebopGVz7LVkX53m29975r3T+DYu9n24eIqNwTDRMCIHn6xxj4+tNlgHuo0KnCrI5q1UbabVDuuFsZ2WHldIXxAiEAx6BA5H9XHgexcC0Kw97ZgvXHaG1NvrhxIizgIyh4Dv0CIB+VslCqTQTr9idyfVuURAiDdzg/9tm07GhWLDn6I6+E'
# 客户端id # 客户端id
VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e' VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
@@ -36,3 +34,4 @@ VITE_APP_WEBSOCKET = false
# sse 开关 # sse 开关
VITE_APP_SSE = true VITE_APP_SSE = true

View File

@@ -1,6 +1,6 @@
# 页面标题 # 页面标题
VITE_APP_TITLE = 防御机管理系统 VITE_APP_TITLE = 驭鲲智防系统
VITE_APP_LOGO_TITLE = 防御机管理系统 VITE_APP_LOGO_TITLE = 驭鲲智防系统
# 生产环境配置 # 生产环境配置
VITE_APP_ENV = 'production' VITE_APP_ENV = 'production'
@@ -23,12 +23,12 @@ VITE_BUILD_COMPRESS = gzip
VITE_APP_PORT = 80 VITE_APP_PORT = 80
# 接口加密功能开关(如需关闭 后端也必须对应关闭) # 接口加密功能开关(如需关闭 后端也必须对应关闭)
# defensive_machine 后端 api-decrypt.enabled = false # defensive_machine 后端 api-decrypt.enabled = true
VITE_APP_ENCRYPT = false VITE_APP_ENCRYPT = true
# 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换 # 接口加密传输 RSA 公钥与后端解密私钥对应 如更换需前后端一同更换
VITE_APP_RSA_PUBLIC_KEY = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdHnzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==' VITE_APP_RSA_PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtVIQm+1AWMfkT1uVOyugKQwBeHtgpulABOrVfaL7Xzc1/rKSdLdjYZLZnnOU+rdE1jqz20MXDBozK22spH/j53d9nFLftHkMCRUlfc0MWEqtSla+2MdzElIHEbq4CSizZOcT8QXCRw7V2OuDqnC7XRQmnpA5Ks1WaKH5V0aoKWztjsqZV6vVWPqyDC94mvu/Q/9e6KVfkqAzR+afuo9OoBFewDGQ6jfqyu1TxoGZrfSr5lXOVeADf0CVfWCDze5y4f7er4JjPHWA2+yv8Scd9bqVJ4Q+/yR2Gd516Mi11OgBKc8GdkuA4/kfmXc7n91SMjgQP6DlTVuPc/6KnB/pyQIDAQAB'
# 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换 # 接口响应解密 RSA 私钥与后端加密公钥对应 如更换需前后端一同更换
VITE_APP_RSA_PRIVATE_KEY = 'MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=' VITE_APP_RSA_PRIVATE_KEY = 'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDYg1dC9XDMRtKJ5IFj+Hd3bq7+r2YL1y8FM9HD111PN/YVy4K6O67cRA/TLG7+tl6eYiA73f5lzPgv1riOwBPSCmvVopxq2KiJs30uEnug0qGp6l2cwQOmjitFAQF8dOtAl4m6EM/jdXCZYbQrthJ6n3tR+vGj2IrGSJEjPrj3VRljK9I7Zw0Be0rhJn/vZw2CA4GoVfE/3gqa5bpl+3SFxMP7TwF5uZueCNqLaPnd7jK5jB1SUghI7+pq897Ng4EG6kdOGsiQe5kbHWkSPTmPEObMwgLJ18i/XCVg+LUkTDJOC/MAfB+WGcKryNHrhcbhB6+XstpoGT8JlOPOgh0BAgMBAAECggEBALZG7MH8bDguL7XTYHNPjRRJZJ4aAGlbgcR5edHMkEvPKyfyK16qPG5IBqKcN93Mnx/sMIL2Q+RkiVWNCdd0MbuU9m/m9JSnTkyPhYWyHc6pRV0NaD496Nrhud/gFuY2cI/yhArXeI1gI6mdrddW83u0pFfCUojEFyETmsz3UzU1LMkVbwCXNbZ1b3GfcVnONMn8T/7CMlRPlKyOuE0OPnTut4wddAybe5BKDQYnGzuosUdO+Q+2K3hqaTD6Bb33D4ATHjvR3Vt9itE07BiaBFjZkE2odP4x3fSsnc7Cw3A7zHQivpfgqcoBcGUaMcN1FdgpaF2mbe9/eHn/lYCpevkCgYEA4FoKq3fPOITAtA6bjwCHXvIQUjv4vQ0BZtt7OX7zwpRwqOhJfN0ZRkd7NMUqYnB10N9NgPRz0kKC6qff5duN+zkYIzjHrlm5lzdIEAouovRlrGXST4n8gAYteh5D5Or9wRXj7USQoaNyiWKB9A/07ortJvul09sPjJU7IVfzzXMCgYEA9w45Gh2bAvYvm2bWieKZRhcV6Pwy9R8587qmKLC7V/0gfVojBHx7xTmTEfKCGrt3fK4jB5v/2Dar8gDF+RM7g01u0YuFJt17jeFr/4o5KMimfXhoEulDbgjfvhxQOrtkrF5iHpKxiEXqIPiH+B7155PAnE43011AAJCBSylmTrsCgYEAkxr3PA9HFKwXHvklDtMt8BeQlBs2sd9BOAxZ9A1GECP86wPEi9b9p5NfOe6+J+XNmrOQwimHeCqcZPjGWpVnt35sUUv9wlia1Igu/DVw9vCBalUpXXYA1oE2eIg3xHZBBMYxuXXnz9S4WVT6GOoNlAwMDC+dQBi3TVrcdrSQ6/kCgYBA254ICh+ovmKvJGdMGY3thZ/94z+pdEIthyGZ6xOzvMMrxV8ODXQcycmfW4/mXrK2q6yMkdqvs2KejK9sfS0RgmGGZ19UXa7TB4vnsSziVRLIO7TuyggmufOrIBm74XhDfB+8MPykbt5RO43OiKo72mElZ69mMMPdohIfXkX6gwKBgH4L9qJRhNunWSfHO4/8dST+2meSV+Cpqf0b/9cV6v5FMS22m+szzW9XoBXnI9dbAqiEDlCHCLw34pRlC3OzBNlbT6HzNmYXLko2Ttg9thiDq7WS5tV+EHJRP6kLabYGq0Aghtq+6LIwwogbHzhRi8Q52o3CNmM9lThNWZIkzaBe'
# 客户端id # 客户端id
VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e' VITE_APP_CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'

View File

@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" /> <meta name="renderer" content="webkit" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>%VITE_APP_TITLE%</title> <title>%VITE_APP_TITLE%</title>
<!--[if lt IE 11 <!--[if lt IE 11
]><script> ]><script>

View File

@@ -35,8 +35,10 @@
"image-conversion": "2.1.1", "image-conversion": "2.1.1",
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"jsencrypt": "3.5.4", "jsencrypt": "3.5.4",
"jszip": "^3.10.1",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"pinia": "3.0.4", "pinia": "3.0.4",
"qrcode": "^1.5.4",
"screenfull": "6.0.2", "screenfull": "6.0.2",
"vue": "3.5.30", "vue": "3.5.30",
"vue-cropper": "1.1.4", "vue-cropper": "1.1.4",
@@ -55,6 +57,7 @@
"@types/js-cookie": "3.0.6", "@types/js-cookie": "3.0.6",
"@types/node": "^25.4.0", "@types/node": "^25.4.0",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/qrcode": "^1.5.6",
"@unocss/preset-attributify": "66.6.6", "@unocss/preset-attributify": "66.6.6",
"@unocss/preset-icons": "66.6.6", "@unocss/preset-icons": "66.6.6",
"@unocss/preset-uno": "66.6.6", "@unocss/preset-uno": "66.6.6",

View File

@@ -4,6 +4,11 @@ export interface DeviceVO {
*/ */
id: string | number; id: string | number;
/**
* 绑定用户 ID
*/
userId?: string | number;
/** /**
* 设备编号 * 设备编号
*/ */
@@ -19,6 +24,11 @@ export interface DeviceVO {
*/ */
status: string; status: string;
/**
* 后端按请求语言返回的状态展示文案
*/
statusLabel?: string;
/** /**
* WiFi名称 * WiFi名称
*/ */
@@ -98,7 +108,6 @@ export interface DeviceVO {
* 识别模式 0-蓝牙 1-蓝牙+人脸 * 识别模式 0-蓝牙 1-蓝牙+人脸
*/ */
patternPerception?: string; patternPerception?: string;
} }
export interface DeviceForm extends BaseEntity { export interface DeviceForm extends BaseEntity {
@@ -107,6 +116,11 @@ export interface DeviceForm extends BaseEntity {
*/ */
id?: string | number; id?: string | number;
/**
* 绑定用户 ID
*/
userId?: string | number;
/** /**
* 设备编号 * 设备编号
*/ */

View File

@@ -1,6 +1,6 @@
export interface MessageVO { export interface MessageVO {
/** /**
* *
*/ */
id: string | number; id: string | number;
@@ -14,6 +14,11 @@ export interface MessageVO {
*/ */
categoryName: string; categoryName: string;
/**
* 后端按请求语言返回的告警类型展示文案
*/
categoryLabel?: string;
/** /**
* 图片地址 * 图片地址
*/ */
@@ -27,12 +32,11 @@ export interface MessageVO {
* 视频地址 * 视频地址
*/ */
video: string | number; video: string | number;
} }
export interface MessageForm extends BaseEntity { export interface MessageForm extends BaseEntity {
/** /**
* *
*/ */
id?: string | number; id?: string | number;
@@ -55,11 +59,9 @@ export interface MessageForm extends BaseEntity {
* 视频地址 * 视频地址
*/ */
video?: string | number; video?: string | number;
} }
export interface MessageQuery extends PageQuery { export interface MessageQuery extends PageQuery {
/** /**
* 类型 * 类型
*/ */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -2,8 +2,10 @@
<el-breadcrumb class="app-breadcrumb" separator="/"> <el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb"> <transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path"> <el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta?.title }}</span> <span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">
<a v-else @click.prevent="handleLink(item)">{{ item.meta?.title }}</a> {{ translateRouteTitle(item.meta?.title as string) }}
</span>
<a v-else @click.prevent="handleLink(item)">{{ translateRouteTitle(item.meta?.title as string) }}</a>
</el-breadcrumb-item> </el-breadcrumb-item>
</transition-group> </transition-group>
</el-breadcrumb> </el-breadcrumb>
@@ -12,6 +14,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouteLocationMatched } from 'vue-router'; import { RouteLocationMatched } from 'vue-router';
import { usePermissionStore } from '@/store/modules/permission'; import { usePermissionStore } from '@/store/modules/permission';
import { translateRouteTitle } from '@/utils/i18n';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();

View File

@@ -24,23 +24,17 @@
</el-upload> </el-upload>
<!-- 上传提示 --> <!-- 上传提示 -->
<div v-if="showTip" class="el-upload__tip"> <div v-if="showTip" class="el-upload__tip">
请上传 {{ $t('upload.tip', { size: fileSize, types: fileType.join('/') }) }}
<template v-if="fileSize">
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</template>
<template v-if="fileType">
格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
</template>
的文件
</div> </div>
<el-dialog v-model="dialogVisible" title="预览" width="800px" append-to-body> <el-dialog v-model="dialogVisible" :title="$t('upload.preview')" width="800px" append-to-body>
<img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" /> <img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" />
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { listByIds, delOss } from '@/api/system/oss'; import { listByIds, delOss } from '@/api/system/oss';
import { OssVO } from '@/api/system/oss/types'; import { OssVO } from '@/api/system/oss/types';
import { propTypes } from '@/utils/propTypes'; import { propTypes } from '@/utils/propTypes';
@@ -73,6 +67,7 @@ const props = defineProps({
}); });
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { t } = useI18n();
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const number = ref(0); const number = ref(0);
const uploadList = ref<any[]>([]); const uploadList = ref<any[]>([]);
@@ -140,35 +135,35 @@ const handleBeforeUpload = (file: any) => {
isImg = file.type.indexOf('image') > -1; isImg = file.type.indexOf('image') > -1;
} }
if (!isImg) { if (!isImg) {
proxy?.$modal.msgError(`文件格式不正确, 请上传${props.fileType.join('/')}图片格式文件!`); proxy?.$modal.msgError(t('upload.invalidFormat', { types: props.fileType.join('/') }));
return false; return false;
} }
if (file.name.includes(',')) { if (file.name.includes(',')) {
proxy?.$modal.msgError('文件名不正确,不能包含英文逗号!'); proxy?.$modal.msgError(t('upload.invalidFileName'));
return false; return false;
} }
if (props.fileSize) { if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize; const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) { if (!isLt) {
proxy?.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`); proxy?.$modal.msgError(t('upload.tooLarge', { size: props.fileSize }));
return false; return false;
} }
} }
//压缩图片,开启压缩并且大于指定的压缩大小时才压缩 //压缩图片,开启压缩并且大于指定的压缩大小时才压缩
if (props.compressSupport && file.size / 1024 > props.compressTargetSize) { if (props.compressSupport && file.size / 1024 > props.compressTargetSize) {
proxy?.$modal.loading('正在上传图片,请稍候...'); proxy?.$modal.loading(t('upload.uploading'));
number.value++; number.value++;
return compressAccurately(file, props.compressTargetSize); return compressAccurately(file, props.compressTargetSize);
} else { } else {
proxy?.$modal.loading('正在上传图片,请稍候...'); proxy?.$modal.loading(t('upload.uploading'));
number.value++; number.value++;
} }
}; };
// 文件个数超出 // 文件个数超出
const handleExceed = () => { const handleExceed = () => {
proxy?.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`); proxy?.$modal.msgError(t('upload.tooMany', { limit: props.limit }));
}; };
// 上传成功回调 // 上传成功回调
@@ -211,7 +206,7 @@ const uploadedSuccessfully = () => {
// 上传失败 // 上传失败
const handleUploadError = () => { const handleUploadError = () => {
proxy?.$modal.msgError('上传图片失败'); proxy?.$modal.msgError(t('upload.failed'));
proxy?.$modal.closeLoading(); proxy?.$modal.closeLoading();
}; };

View File

@@ -5,8 +5,12 @@
</div> </div>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item :disabled="appStore.language === 'zh_CN'" command="zh_CN"> 中文 </el-dropdown-item> <el-dropdown-item :disabled="appStore.language === LanguageEnum.zh_CN" :command="LanguageEnum.zh_CN">
<el-dropdown-item :disabled="appStore.language === 'en_US'" command="en_US"> English </el-dropdown-item> {{ t('language.chinese') }}
</el-dropdown-item>
<el-dropdown-item :disabled="appStore.language === LanguageEnum.en_US" :command="LanguageEnum.en_US">
{{ t('language.english') }}
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -16,18 +20,19 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import SvgIcon from '@/components/SvgIcon/index.vue'; import SvgIcon from '@/components/SvgIcon/index.vue';
import VXETable from 'vxe-table';
import { getVxeLanguage, LanguageEnum } from '@/enums/LanguageEnum';
import { useDynamicTitle } from '@/utils/dynamicTitle';
const appStore = useAppStore(); const appStore = useAppStore();
const { locale } = useI18n(); const { locale, t } = useI18n();
const message: any = { const handleLanguageChange = (lang: LanguageEnum) => {
zh_CN: '切换语言成功!',
en_US: 'Switch Language Successful!'
};
const handleLanguageChange = (lang: any) => {
locale.value = lang; locale.value = lang;
appStore.changeLanguage(lang); appStore.changeLanguage(lang);
ElMessage.success(message[lang] || '切换语言成功!'); VXETable.setLanguage(getVxeLanguage(lang));
useDynamicTitle();
ElMessage.success(t('language.switched'));
}; };
</script> </script>

View File

@@ -1,16 +1,22 @@
<template> <template>
<div class="top-right-btn" :style="style"> <div class="top-right-btn" :style="style">
<el-row> <el-row>
<el-tooltip v-if="search" class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top"> <el-tooltip
v-if="search"
class="item"
effect="dark"
:content="showSearch ? $t('toolbar.hideSearch') : $t('toolbar.showSearch')"
placement="top"
>
<el-button circle icon="Search" @click="toggleSearch()" /> <el-button circle icon="Search" @click="toggleSearch()" />
</el-tooltip> </el-tooltip>
<el-tooltip class="item" effect="dark" content="刷新" placement="top"> <el-tooltip class="item" effect="dark" :content="$t('toolbar.refresh')" placement="top">
<el-button circle icon="Refresh" @click="refresh()" /> <el-button circle icon="Refresh" @click="refresh()" />
</el-tooltip> </el-tooltip>
<el-tooltip v-if="columns" class="item" effect="dark" content="显示/隐藏列" placement="top"> <el-tooltip v-if="columns" class="item" effect="dark" :content="$t('toolbar.columns')" placement="top">
<div class="show-btn"> <div class="show-btn">
<el-popover placement="bottom" trigger="click"> <el-popover placement="bottom" trigger="click">
<div class="tree-header">显示/隐藏列</div> <div class="tree-header">{{ $t('toolbar.columns') }}</div>
<el-tree <el-tree
ref="columnRef" ref="columnRef"
:data="columns" :data="columns"

View File

@@ -16,15 +16,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
const appStore = useAppStore(); const appStore = useAppStore();
const size = computed(() => appStore.size); const size = computed(() => appStore.size);
const sizeOptions = ref([ const { t } = useI18n();
{ label: '较大', value: 'large' }, const sizeOptions = computed(() => [
{ label: '默认', value: 'default' }, { label: t('size.large'), value: 'large' },
{ label: '稍小', value: 'small' } { label: t('size.default'), value: 'default' },
{ label: t('size.small'), value: 'small' }
]); ]);
const handleSetSize = (size: 'large' | 'default' | 'small') => { const handleSetSize = (size: 'large' | 'default' | 'small') => {

View File

@@ -3,16 +3,16 @@
<template v-for="(item, index) in topMenus"> <template v-for="(item, index) in topMenus">
<el-menu-item v-if="index < visibleNumber" :key="index" :style="{ '--theme': theme }" :index="item.path" <el-menu-item v-if="index < visibleNumber" :key="index" :style="{ '--theme': theme }" :index="item.path"
><svg-icon v-if="item.meta && item.meta.icon && item.meta.icon !== '#'" :icon-class="item.meta ? item.meta.icon : ''" /> ><svg-icon v-if="item.meta && item.meta.icon && item.meta.icon !== '#'" :icon-class="item.meta ? item.meta.icon : ''" />
{{ item.meta?.title }}</el-menu-item {{ translateRouteTitle(item.meta?.title as string) }}</el-menu-item
> >
</template> </template>
<!-- 顶部菜单超出数量折叠 --> <!-- 顶部菜单超出数量折叠 -->
<el-sub-menu v-if="topMenus.length > visibleNumber" :style="{ '--theme': theme }" index="more"> <el-sub-menu v-if="topMenus.length > visibleNumber" :style="{ '--theme': theme }" index="more">
<template #title>更多菜单</template> <template #title>{{ $t('navbar.moreMenu') }}</template>
<template v-for="(item, index) in topMenus"> <template v-for="(item, index) in topMenus">
<el-menu-item v-if="index >= visibleNumber" :key="index" :index="item.path" <el-menu-item v-if="index >= visibleNumber" :key="index" :index="item.path"
><svg-icon :icon-class="item.meta ? item.meta.icon : ''" /> {{ item.meta?.title }}</el-menu-item ><svg-icon :icon-class="item.meta ? item.meta.icon : ''" /> {{ translateRouteTitle(item.meta?.title as string) }}</el-menu-item
> >
</template> </template>
</el-sub-menu> </el-sub-menu>
@@ -26,6 +26,7 @@ import { useAppStore } from '@/store/modules/app';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import { usePermissionStore } from '@/store/modules/permission'; import { usePermissionStore } from '@/store/modules/permission';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import { translateRouteTitle } from '@/utils/i18n';
// 顶部栏初始数 // 顶部栏初始数
const visibleNumber = ref<number>(-1); const visibleNumber = ref<number>(-1);

View File

@@ -3,3 +3,15 @@ export enum LanguageEnum {
en_US = 'en_US' en_US = 'en_US'
} }
export const isSupportedLanguage = (language: string | null | undefined): language is LanguageEnum => {
return Object.values(LanguageEnum).includes(language as LanguageEnum);
};
export const getHtmlLanguage = (language: LanguageEnum): string => {
return language === LanguageEnum.en_US ? 'en-US' : 'zh-CN';
};
export const getVxeLanguage = (language: LanguageEnum): 'en-US' | 'zh-CN' => {
return language === LanguageEnum.en_US ? 'en-US' : 'zh-CN';
};

View File

@@ -1,9 +1,84 @@
export default { export default {
app: {
title: 'Reinkun Intelligent Defense System'
},
// 路由国际化 // 路由国际化
route: { route: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
document: 'Document' document: 'Document'
}, },
routeTitles: {
: 'Dashboard',
: 'Profile',
: 'Defense Devices',
: 'System',
: 'Tenants',
: 'Monitoring',
: 'Tools',
: 'Demos',
: 'Users',
: 'Roles',
: 'Menus',
: 'Departments',
: 'Positions',
: 'Dictionaries',
: 'Parameters',
: 'Notices',
: 'Logs',
线: 'Online Users',
: 'Cache',
: 'Code Generator',
: 'Tenant Plans',
: 'Clients',
: 'Files',
: 'Job Scheduler',
: 'Operation Logs',
: 'Login Logs',
: 'Devices',
: 'Devices',
: 'Alerts',
: 'Alert Messages',
: 'Alert Messages',
: 'Messages',
APP用户管理: 'App Users',
APP用户信息: 'App Users',
: 'Families',
: 'Family Members',
: 'Family Devices',
: 'Device Users',
: 'Device Member Links',
: 'Device Tokens'
},
common: {
search: 'Search',
reset: 'Reset',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
export: 'Export',
confirm: 'Confirm',
cancel: 'Cancel',
close: 'Close',
save: 'Save',
operation: 'Actions',
index: '#',
status: 'Status',
pleaseSelect: 'Please select',
pleaseInput: 'Please enter',
operationSuccess: 'Operation successful',
deleteSuccess: 'Deleted successfully',
required: 'This field is required',
systemPrompt: 'System Notification'
},
home: {
welcome: 'Welcome to Reinkun Intelligent Defense System',
slogan: 'Intelligent Defense · Reliable Protection'
},
language: {
chinese: '中文',
english: 'English',
switched: 'Language switched to English'
},
// 登录页面国际化 // 登录页面国际化
login: { login: {
selectPlaceholder: 'Please select/enter a company name', selectPlaceholder: 'Please select/enter a company name',
@@ -73,6 +148,7 @@ export default {
navbar: { navbar: {
full: 'Full Screen', full: 'Full Screen',
language: 'Language', language: 'Language',
search: 'Search',
dashboard: 'Dashboard', dashboard: 'Dashboard',
document: 'Document', document: 'Document',
message: 'Message', message: 'Message',
@@ -80,6 +156,144 @@ export default {
selectTenant: 'Select Tenant', selectTenant: 'Select Tenant',
layoutSetting: 'Layout Setting', layoutSetting: 'Layout Setting',
personalCenter: 'Personal Center', personalCenter: 'Personal Center',
logout: 'Logout' logout: 'Logout',
logoutConfirm: 'Are you sure you want to sign out?',
prompt: 'Confirmation',
confirm: 'Confirm',
cancel: 'Cancel',
moreMenu: 'More'
},
size: {
large: 'Large',
default: 'Default',
small: 'Small'
},
tagsView: {
refresh: 'Refresh',
closeCurrent: 'Close Current',
closeOthers: 'Close Others',
closeLeft: 'Close Left',
closeRight: 'Close Right',
closeAll: 'Close All'
},
settings: {
navigation: 'Navigation',
leftMenu: 'Left Menu',
mixedMenu: 'Mixed Menu',
topMenu: 'Top Menu',
themeStyle: 'Theme',
themeColor: 'Theme Color',
darkMode: 'Dark Mode',
systemLayout: 'Layout',
enableTagsView: 'Enable Tags View',
showTagsIcon: 'Show Tag Icons',
fixedHeader: 'Fixed Header',
showLogo: 'Show Logo',
dynamicTitle: 'Dynamic Title',
save: 'Save Settings',
reset: 'Reset Settings',
saving: 'Saving locally, please wait...',
resetting: 'Clearing settings and reloading, please wait...'
},
notice: {
title: 'Notices',
readAll: 'Mark All Read',
read: 'Read',
unread: 'Unread',
empty: 'No messages',
viewMore: 'View More'
},
toolbar: {
hideSearch: 'Hide Search',
showSearch: 'Show Search',
refresh: 'Refresh',
columns: 'Show/Hide Columns'
},
upload: {
tip: 'Upload files up to {size} MB in {types} format',
preview: 'Preview',
invalidFormat: 'Invalid file format. Upload an image in {types} format.',
invalidFileName: 'The file name cannot contain commas.',
tooLarge: 'The image must be smaller than {size} MB.',
uploading: 'Uploading image, please wait...',
tooMany: 'You can upload up to {limit} files.',
failed: 'Image upload failed'
},
device: {
deviceNo: 'Device No.',
deviceName: 'Device Name',
owner: 'Owner',
wifiName: 'Wi-Fi Name',
wifiPassword: 'Wi-Fi Password',
model: 'Device Model',
modelShort: 'Model',
serialNumber: 'Serial Number',
serialNumberShort: 'Serial No.',
firmwareVersion: 'Firmware',
perceptionMode: 'Recognition Mode',
workMode: 'Work Mode',
light: 'Light',
lightEnabled: 'Enable Light',
alert: 'Alert',
alertPlayback: 'Alert Playback',
alertContent: 'Alert Content',
strikeRange: 'Strike Range',
strikeInterval: 'Strike Interval',
containerCount: 'Container Count',
strikePosition: 'Strike Position',
deploymentStart: 'Deployment Start',
deploymentEnd: 'Deployment End',
selectTime: 'Select a time',
qrCode: 'QR Code',
batchDownloadQr: 'Download QR Codes',
saveImage: 'Save Image',
addTitle: 'Add Device',
editTitle: 'Edit Device',
deleteConfirm: 'Delete the selected device record(s) {ids}?',
selectOwner: 'Please select an owner',
noData: 'No device data available',
qrGenerationFailed: 'Failed to generate QR code',
batchGenerationFailed: 'Batch generation failed',
batchPackaged: 'Packaged {count} device QR code(s)',
status: {
offline: 'Offline',
online: 'Online',
fault: 'Fault',
expired: 'Expired'
},
perception: {
bluetooth: 'Bluetooth',
bluetoothFace: 'Bluetooth + Face'
},
work: {
automatic: 'Automatic',
semiAutomatic: 'Semi-automatic'
},
lightState: {
off: 'Off',
on: 'On'
},
audioState: {
off: 'Disabled',
on: 'Enabled'
}
},
message: {
category: 'Alert Type',
description: 'Description',
deviceId: 'Device ID',
deviceName: 'Device Name',
image: 'Images',
video: 'Video',
videoUrl: 'Video URL',
videoPlayback: 'Video Playback',
addTitle: 'Add Alert Message',
editTitle: 'Edit Alert Message',
deleteConfirm: 'Delete the selected alert message(s) {ids}?',
categoryType: {
intrusion: 'Intrusion Detected',
deviceFault: 'Device Fault',
expiration: 'Expiration Reminder'
}
} }
}; };

View File

@@ -1,7 +1,7 @@
// 自定义国际化配置 // 自定义国际化配置
import { createI18n } from 'vue-i18n'; import { createI18n } from 'vue-i18n';
import { LanguageEnum } from '@/enums/LanguageEnum'; import { getHtmlLanguage, isSupportedLanguage, LanguageEnum } from '@/enums/LanguageEnum';
import zh_CN from '@/lang/zh_CN'; import zh_CN from '@/lang/zh_CN';
import en_US from '@/lang/en_US'; import en_US from '@/lang/en_US';
@@ -10,11 +10,19 @@ import en_US from '@/lang/en_US';
* @returns zh-cn|en ... * @returns zh-cn|en ...
*/ */
export const getLanguage = (): LanguageEnum => { export const getLanguage = (): LanguageEnum => {
const language = useStorage<LanguageEnum>('language', LanguageEnum.zh_CN); const storedLanguage = localStorage.getItem('language');
if (language.value) { if (isSupportedLanguage(storedLanguage)) {
return language.value; return storedLanguage;
} }
return LanguageEnum.zh_CN; if (storedLanguage !== null) {
localStorage.removeItem('language');
}
return navigator.language.toLowerCase().startsWith('zh') ? LanguageEnum.zh_CN : LanguageEnum.en_US;
};
export const setDocumentLanguage = (language: LanguageEnum): void => {
document.documentElement.lang = getHtmlLanguage(language);
}; };
const i18n = createI18n({ const i18n = createI18n({
@@ -22,12 +30,15 @@ const i18n = createI18n({
allowComposition: true, allowComposition: true,
legacy: false, legacy: false,
locale: getLanguage(), locale: getLanguage(),
fallbackLocale: LanguageEnum.zh_CN,
messages: { messages: {
zh_CN: zh_CN, [LanguageEnum.zh_CN]: zh_CN,
en_US: en_US [LanguageEnum.en_US]: en_US
} }
}); });
setDocumentLanguage(i18n.global.locale.value);
export default i18n; export default i18n;
export type LanguageType = typeof zh_CN; export type LanguageType = typeof zh_CN;

View File

@@ -1,9 +1,84 @@
export default { export default {
app: {
title: '驭鲲智防系统'
},
// 路由国际化 // 路由国际化
route: { route: {
dashboard: '首页', dashboard: '首页',
document: '项目文档' document: '项目文档'
}, },
routeTitles: {
: '首页',
: '个人中心',
: '防御机管理',
: '系统管理',
: '租户管理',
: '系统监控',
: '系统工具',
: '测试菜单',
: '用户管理',
: '角色管理',
: '菜单管理',
: '部门管理',
: '岗位管理',
: '字典管理',
: '参数设置',
: '通知公告',
: '日志管理',
线: '在线用户',
: '缓存监控',
: '代码生成',
: '租户套餐管理',
: '客户端管理',
: '文件管理',
: '任务调度中心',
: '操作日志',
: '登录日志',
: '设备管理',
: '设备信息',
: '告警管理',
: '告警消息',
: '告警消息列',
: '消息管理',
APP用户管理: 'APP用户管理',
APP用户信息: 'APP用户信息',
: '家庭管理',
: '家庭成员',
: '家庭设备管理',
: '设备用户管理',
: '设备成员关联',
: '设备令牌管理'
},
common: {
search: '搜索',
reset: '重置',
add: '新增',
edit: '修改',
delete: '删除',
export: '导出',
confirm: '确定',
cancel: '取消',
close: '关闭',
save: '保存',
operation: '操作',
index: '序号',
status: '状态',
pleaseSelect: '请选择',
pleaseInput: '请输入',
operationSuccess: '操作成功',
deleteSuccess: '删除成功',
required: '不能为空',
systemPrompt: '系统提示'
},
home: {
welcome: '欢迎使用驭鲲智防系统',
slogan: '智能防御 · 安全守护'
},
language: {
chinese: '中文',
english: 'English',
switched: '语言已切换为中文'
},
// 登录页面国际化 // 登录页面国际化
login: { login: {
selectPlaceholder: '请选择/输入公司名称', selectPlaceholder: '请选择/输入公司名称',
@@ -73,6 +148,7 @@ export default {
navbar: { navbar: {
full: '全屏', full: '全屏',
language: '语言', language: '语言',
search: '搜索',
dashboard: '首页', dashboard: '首页',
document: '项目文档', document: '项目文档',
message: '消息', message: '消息',
@@ -80,6 +156,144 @@ export default {
selectTenant: '选择租户', selectTenant: '选择租户',
layoutSetting: '布局设置', layoutSetting: '布局设置',
personalCenter: '个人中心', personalCenter: '个人中心',
logout: '退出登录' logout: '退出登录',
logoutConfirm: '确定注销并退出系统吗?',
prompt: '提示',
confirm: '确定',
cancel: '取消',
moreMenu: '更多菜单'
},
size: {
large: '较大',
default: '默认',
small: '稍小'
},
tagsView: {
refresh: '刷新页面',
closeCurrent: '关闭当前',
closeOthers: '关闭其他',
closeLeft: '关闭左侧',
closeRight: '关闭右侧',
closeAll: '全部关闭'
},
settings: {
navigation: '菜单导航设置',
leftMenu: '左侧菜单',
mixedMenu: '混合菜单',
topMenu: '顶部菜单',
themeStyle: '主题风格设置',
themeColor: '主题颜色',
darkMode: '深色模式',
systemLayout: '系统布局配置',
enableTagsView: '开启 Tags-Views',
showTagsIcon: '显示页签图标',
fixedHeader: '固定 Header',
showLogo: '显示 Logo',
dynamicTitle: '动态标题',
save: '保存配置',
reset: '重置配置',
saving: '正在保存到本地,请稍候...',
resetting: '正在清除设置缓存并刷新,请稍候...'
},
notice: {
title: '通知公告',
readAll: '全部已读',
read: '已读',
unread: '未读',
empty: '消息为空',
viewMore: '查看更多'
},
toolbar: {
hideSearch: '隐藏搜索',
showSearch: '显示搜索',
refresh: '刷新',
columns: '显示/隐藏列'
},
upload: {
tip: '请上传大小不超过 {size} MB、格式为 {types} 的文件',
preview: '预览',
invalidFormat: '文件格式不正确,请上传 {types} 格式的图片!',
invalidFileName: '文件名不正确,不能包含英文逗号!',
tooLarge: '上传图片大小不能超过 {size} MB',
uploading: '正在上传图片,请稍候...',
tooMany: '上传文件数量不能超过 {limit} 个!',
failed: '上传图片失败'
},
device: {
deviceNo: '设备编号',
deviceName: '设备名称',
owner: '所属用户',
wifiName: 'WiFi名称',
wifiPassword: 'WiFi密码',
model: '设备型号',
modelShort: '型号',
serialNumber: '设备序列号',
serialNumberShort: '序列号',
firmwareVersion: '固件版本',
perceptionMode: '识别模式',
workMode: '工作模式',
light: '强光',
lightEnabled: '强光开启',
alert: '告警',
alertPlayback: '告警播放',
alertContent: '告警内容',
strikeRange: '打击距离',
strikeInterval: '打击间隔',
containerCount: '容器数量',
strikePosition: '打击位置',
deploymentStart: '布放开始时间',
deploymentEnd: '布放结束时间',
selectTime: '选择时间',
qrCode: '二维码',
batchDownloadQr: '批量下载二维码',
saveImage: '保存图片',
addTitle: '添加设备信息',
editTitle: '修改设备信息',
deleteConfirm: '是否确认删除设备信息编号为“{ids}”的数据项?',
selectOwner: '请选择所属用户',
noData: '当前没有设备数据',
qrGenerationFailed: '二维码生成失败',
batchGenerationFailed: '批量生成失败',
batchPackaged: '已打包 {count} 个设备二维码',
status: {
offline: '离线',
online: '在线',
fault: '故障',
expired: '到期'
},
perception: {
bluetooth: '蓝牙',
bluetoothFace: '蓝牙+人脸'
},
work: {
automatic: '自动',
semiAutomatic: '半自动'
},
lightState: {
off: '不开启',
on: '开启'
},
audioState: {
off: '不播放',
on: '播放'
}
},
message: {
category: '告警类型',
description: '告警说明',
deviceId: '设备ID',
deviceName: '设备名',
image: '图片',
video: '视频',
videoUrl: '视频地址',
videoPlayback: '视频播放',
addTitle: '添加告警消息',
editTitle: '修改告警消息',
deleteConfirm: '是否确认删除告警消息编号为“{ids}”的数据项?',
categoryType: {
intrusion: '有人入侵',
deviceFault: '设备故障',
expiration: '到期提醒'
}
} }
}; };

View File

@@ -27,7 +27,7 @@
</el-select> </el-select>
<search-menu ref="searchMenuRef" /> <search-menu ref="searchMenuRef" />
<el-tooltip content="搜索" effect="dark" placement="bottom"> <el-tooltip :content="proxy.$t('navbar.search')" effect="dark" placement="bottom">
<div class="right-menu-item hover-effect" @click="openSearchMenu"> <div class="right-menu-item hover-effect" @click="openSearchMenu">
<svg-icon class-name="search-icon" icon-class="search" /> <svg-icon class-name="search-icon" icon-class="search" />
</div> </div>
@@ -47,14 +47,6 @@
</el-popover> </el-popover>
</div> </div>
</el-tooltip> </el-tooltip>
<el-tooltip content="Github" effect="dark" placement="bottom">
<ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip :content="proxy.$t('navbar.document')" effect="dark" placement="bottom">
<ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip :content="proxy.$t('navbar.full')" effect="dark" placement="bottom"> <el-tooltip :content="proxy.$t('navbar.full')" effect="dark" placement="bottom">
<screenfull id="screenfull" class="right-menu-item hover-effect" /> <screenfull id="screenfull" class="right-menu-item hover-effect" />
</el-tooltip> </el-tooltip>
@@ -105,8 +97,8 @@ import notice from './notice/index.vue';
import router from '@/router'; import router from '@/router';
import { ElMessageBoxOptions } from 'element-plus/es/components/message-box/src/message-box.type'; import { ElMessageBoxOptions } from 'element-plus/es/components/message-box/src/message-box.type';
import { NavTypeEnum } from '@/enums/NavTypeEnum'; import { NavTypeEnum } from '@/enums/NavTypeEnum';
import Logo from "@/layout/components/Sidebar/Logo.vue"; import Logo from '@/layout/components/Sidebar/Logo.vue';
import TopBar from './TopBar' import TopBar from './TopBar/index.vue';
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -170,9 +162,9 @@ const toggleSideBar = () => {
}; };
const logout = async () => { const logout = async () => {
await ElMessageBox.confirm('确定注销并退出系统吗?', '提示', { await ElMessageBox.confirm(proxy.$t('navbar.logoutConfirm'), proxy.$t('navbar.prompt'), {
confirmButtonText: '确定', confirmButtonText: proxy.$t('navbar.confirm'),
cancelButtonText: '取消', cancelButtonText: proxy.$t('navbar.cancel'),
type: 'warning' type: 'warning'
} as ElMessageBoxOptions); } as ElMessageBoxOptions);
userStore.logout().then(() => { userStore.logout().then(() => {
@@ -282,7 +274,6 @@ watch(
margin-left: 8px; margin-left: 8px;
} }
.errLog-container { .errLog-container {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;

View File

@@ -1,8 +1,8 @@
<template> <template>
<el-drawer v-model="showSettings" :with-header="false" direction="rtl" size="300px" close-on-click-modal> <el-drawer v-model="showSettings" :with-header="false" direction="rtl" size="300px" close-on-click-modal>
<h3 class="drawer-title">菜单导航设置</h3> <h3 class="drawer-title">{{ $t('settings.navigation') }}</h3>
<div class="nav-wrap"> <div class="nav-wrap">
<el-tooltip content="左侧菜单" placement="bottom"> <el-tooltip :content="$t('settings.leftMenu')" placement="bottom">
<div <div
class="item left" class="item left"
@click="handleNavType(NavTypeEnum.LEFT)" @click="handleNavType(NavTypeEnum.LEFT)"
@@ -13,7 +13,7 @@
</div> </div>
</el-tooltip> </el-tooltip>
<el-tooltip content="混合菜单" placement="bottom"> <el-tooltip :content="$t('settings.mixedMenu')" placement="bottom">
<div <div
class="item mix" class="item mix"
@click="handleNavType(NavTypeEnum.MIX)" @click="handleNavType(NavTypeEnum.MIX)"
@@ -23,7 +23,7 @@
<b></b><b></b> <b></b><b></b>
</div> </div>
</el-tooltip> </el-tooltip>
<el-tooltip content="顶部菜单" placement="bottom"> <el-tooltip :content="$t('settings.topMenu')" placement="bottom">
<div <div
class="item top" class="item top"
@click="handleNavType(NavTypeEnum.TOP)" @click="handleNavType(NavTypeEnum.TOP)"
@@ -35,14 +35,14 @@
</el-tooltip> </el-tooltip>
</div> </div>
<h3 class="drawer-title">主题风格设置</h3> <h3 class="drawer-title">{{ $t('settings.themeStyle') }}</h3>
<div class="setting-drawer-block-checbox"> <div class="setting-drawer-block-checbox">
<div class="setting-drawer-block-checbox-item" @click="handleTheme(SideThemeEnum.DARK)"> <div class="setting-drawer-block-checbox-item" @click="handleTheme(SideThemeEnum.DARK)">
<img src="@/assets/images/dark.svg" alt="dark" /> <img src="@/assets/images/dark.svg" alt="dark" />
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block"> <div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block">
<i aria-label="图标: check" class="anticon anticon-check"> <i aria-label="check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class> <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false">
<path <path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/> />
@@ -53,8 +53,8 @@
<div class="setting-drawer-block-checbox-item" @click="handleTheme(SideThemeEnum.LIGHT)"> <div class="setting-drawer-block-checbox-item" @click="handleTheme(SideThemeEnum.LIGHT)">
<img src="@/assets/images/light.svg" alt="light" /> <img src="@/assets/images/light.svg" alt="light" />
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block"> <div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block">
<i aria-label="图标: check" class="anticon anticon-check"> <i aria-label="check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class> <svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false">
<path <path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z" d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/> />
@@ -64,13 +64,13 @@
</div> </div>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>主题颜色</span> <span>{{ $t('settings.themeColor') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange" /> <el-color-picker v-model="theme" :predefine="predefineColors" @change="themeChange" />
</span> </span>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>深色模式</span> <span>{{ $t('settings.darkMode') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="isDark" class="drawer-switch" @change="toggleDark" /> <el-switch v-model="isDark" class="drawer-switch" @change="toggleDark" />
</span> </span>
@@ -78,38 +78,38 @@
<el-divider /> <el-divider />
<h3 class="drawer-title">系统布局配置</h3> <h3 class="drawer-title">{{ $t('settings.systemLayout') }}</h3>
<div class="drawer-item"> <div class="drawer-item">
<span>开启 Tags-Views</span> <span>{{ $t('settings.enableTagsView') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="settingsStore.tagsView" class="drawer-switch" /> <el-switch v-model="settingsStore.tagsView" class="drawer-switch" />
</span> </span>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>显示页签图标</span> <span>{{ $t('settings.showTagsIcon') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="settingsStore.tagsIcon" :disabled="!settingsStore.tagsView" class="drawer-switch" /> <el-switch v-model="settingsStore.tagsIcon" :disabled="!settingsStore.tagsView" class="drawer-switch" />
</span> </span>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>固定 Header</span> <span>{{ $t('settings.fixedHeader') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" /> <el-switch v-model="settingsStore.fixedHeader" class="drawer-switch" />
</span> </span>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>显示 Logo</span> <span>{{ $t('settings.showLogo') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="settingsStore.sidebarLogo" class="drawer-switch" /> <el-switch v-model="settingsStore.sidebarLogo" class="drawer-switch" />
</span> </span>
</div> </div>
<div class="drawer-item"> <div class="drawer-item">
<span>动态标题</span> <span>{{ $t('settings.dynamicTitle') }}</span>
<span class="comp-style"> <span class="comp-style">
<el-switch v-model="settingsStore.dynamicTitle" class="drawer-switch" @change="dynamicTitleChange" /> <el-switch v-model="settingsStore.dynamicTitle" class="drawer-switch" @change="dynamicTitleChange" />
</span> </span>
@@ -117,12 +117,13 @@
<el-divider /> <el-divider />
<el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">保存配置</el-button> <el-button type="primary" plain icon="DocumentAdd" @click="saveSetting">{{ $t('settings.save') }}</el-button>
<el-button plain icon="Refresh" @click="resetSetting">重置配置</el-button> <el-button plain icon="Refresh" @click="resetSetting">{{ $t('settings.reset') }}</el-button>
</el-drawer> </el-drawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useDynamicTitle } from '@/utils/dynamicTitle'; import { useDynamicTitle } from '@/utils/dynamicTitle';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
@@ -136,6 +137,7 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const appStore = useAppStore(); const appStore = useAppStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore(); const permissionStore = usePermissionStore();
const { t } = useI18n();
const showSettings = ref(false); const showSettings = ref(false);
const theme = ref(settingsStore.theme); const theme = ref(settingsStore.theme);
@@ -161,15 +163,15 @@ const toggleDark = () => useToggle(isDark);
/** 菜单导航设置 */ /** 菜单导航设置 */
watch( watch(
() => navType, navType,
(val: string) => { (val) => {
if (val.value === NavTypeEnum.TOP) { if (val === NavTypeEnum.TOP) {
appStore.toggleSideBarHide(true); appStore.toggleSideBarHide(true);
permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any); permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any);
} else if (val.value === NavTypeEnum.LEFT) { } else if (val === NavTypeEnum.LEFT) {
appStore.toggleSideBarHide(false); appStore.toggleSideBarHide(false);
permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any); permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any);
} else if (val.value === NavTypeEnum.MIX) { } else if (val === NavTypeEnum.MIX) {
appStore.toggleSideBarHide(false); appStore.toggleSideBarHide(false);
} }
}, },
@@ -200,7 +202,7 @@ const handleTheme = (val: string) => {
settingsStore.sideTheme = val; settingsStore.sideTheme = val;
}; };
const saveSetting = () => { const saveSetting = () => {
proxy?.$modal.loading('正在保存到本地,请稍候...'); proxy?.$modal.loading(t('settings.saving'));
const settings = useStorage<LayoutSetting>('layout-setting', defaultSettings); const settings = useStorage<LayoutSetting>('layout-setting', defaultSettings);
settings.value.tagsView = storeSettings.value.tagsView; settings.value.tagsView = storeSettings.value.tagsView;
settings.value.tagsIcon = storeSettings.value.tagsIcon; settings.value.tagsIcon = storeSettings.value.tagsIcon;
@@ -215,7 +217,7 @@ const saveSetting = () => {
}, 1000); }, 1000);
}; };
const resetSetting = () => { const resetSetting = () => {
proxy?.$modal.loading('正在清除设置缓存并刷新,请稍候...'); proxy?.$modal.loading(t('settings.resetting'));
useStorage<any>('layout-setting', null).value = null; useStorage<any>('layout-setting', null).value = null;
setTimeout('window.location.reload()', 1000); setTimeout('window.location.reload()', 1000);
}; };

View File

@@ -5,7 +5,9 @@
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }"> <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" /> <svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)" />
<template #title> <template #title>
<span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span> <span class="menu-title" :title="hasTitle(translateRouteTitle(onlyOneChild.meta.title))">
{{ translateRouteTitle(onlyOneChild.meta.title) }}
</span>
</template> </template>
</el-menu-item> </el-menu-item>
</app-link> </app-link>
@@ -14,7 +16,7 @@
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported> <el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
<template v-if="item.meta" #title> <template v-if="item.meta" #title>
<svg-icon :icon-class="item.meta ? item.meta.icon : ''" /> <svg-icon :icon-class="item.meta ? item.meta.icon : ''" />
<span class="menu-title" :title="hasTitle(item.meta?.title)">{{ item.meta?.title }}</span> <span class="menu-title" :title="hasTitle(translateRouteTitle(item.meta?.title))">{{ translateRouteTitle(item.meta?.title) }}</span>
</template> </template>
<sidebar-item <sidebar-item
@@ -34,6 +36,7 @@ import { isExternal } from '@/utils/validate';
import AppLink from './Link.vue'; import AppLink from './Link.vue';
import { getNormalPath } from '@/utils/ruoyi'; import { getNormalPath } from '@/utils/ruoyi';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import { translateRouteTitle } from '@/utils/i18n';
const props = defineProps({ const props = defineProps({
item: { item: {

View File

@@ -6,26 +6,28 @@
:key="tag.path" :key="tag.path"
:data-path="tag.path" :data-path="tag.path"
:class="{ 'active': isActive(tag), 'has-icon': tagsIcon }" :class="{ 'active': isActive(tag), 'has-icon': tagsIcon }"
:to="{ path: tag.path ? tag.path : '', query: tag.query, fullPath: tag.fullPath ? tag.fullPath : '' }" :to="{ path: tag.path ? tag.path : '', query: tag.query }"
class="tags-view-item" class="tags-view-item"
:style="activeStyle(tag)" :style="activeStyle(tag)"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''" @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openMenu(tag, $event)" @contextmenu.prevent="openMenu(tag, $event)"
> >
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon"/> <svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
<span class="tags-view-item-title">{{ tag.title }}</span> <span class="tags-view-item-title">{{ translateRouteTitle(tag.title as string) }}</span>
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)"> <span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
<close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle" /> <close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle" />
</span> </span>
</router-link> </router-link>
</scroll-pane> </scroll-pane>
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu"> <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"><refresh-right style="width: 1em; height: 1em" /> 刷新页面</li> <li @click="refreshSelectedTag(selectedTag)"><refresh-right style="width: 1em; height: 1em" /> {{ $t('tagsView.refresh') }}</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><close style="width: 1em; height: 1em" /> 关闭当前</li> <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<li @click="closeOthersTags"><circle-close style="width: 1em; height: 1em" /> 关闭其他</li> <close style="width: 1em; height: 1em" /> {{ $t('tagsView.closeCurrent') }}
<li v-if="!isFirstView()" @click="closeLeftTags"><back style="width: 1em; height: 1em" /> 关闭左侧</li> </li>
<li v-if="!isLastView()" @click="closeRightTags"><right style="width: 1em; height: 1em" /> 关闭右侧</li> <li @click="closeOthersTags"><circle-close style="width: 1em; height: 1em" /> {{ $t('tagsView.closeOthers') }}</li>
<li @click="closeAllTags(selectedTag)"><circle-close style="width: 1em; height: 1em" /> 全部关闭</li> <li v-if="!isFirstView()" @click="closeLeftTags"><back style="width: 1em; height: 1em" /> {{ $t('tagsView.closeLeft') }}</li>
<li v-if="!isLastView()" @click="closeRightTags"><right style="width: 1em; height: 1em" /> {{ $t('tagsView.closeRight') }}</li>
<li @click="closeAllTags(selectedTag)"><circle-close style="width: 1em; height: 1em" /> {{ $t('tagsView.closeAll') }}</li>
</ul> </ul>
</div> </div>
</template> </template>
@@ -37,6 +39,7 @@ import { useSettingsStore } from '@/store/modules/settings';
import { usePermissionStore } from '@/store/modules/permission'; import { usePermissionStore } from '@/store/modules/permission';
import { useTagsViewStore } from '@/store/modules/tagsView'; import { useTagsViewStore } from '@/store/modules/tagsView';
import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router'; import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
import { translateRouteTitle } from '@/utils/i18n';
const visible = ref(false); const visible = ref(false);
const top = ref(0); const top = ref(0);
@@ -52,7 +55,7 @@ const router = useRouter();
const visitedViews = computed(() => useTagsViewStore().getVisitedViews()); const visitedViews = computed(() => useTagsViewStore().getVisitedViews());
const routes = computed(() => usePermissionStore().getRoutes()); const routes = computed(() => usePermissionStore().getRoutes());
const theme = computed(() => useSettingsStore().theme); const theme = computed(() => useSettingsStore().theme);
const tagsIcon = computed(() => useSettingsStore().tagsIcon) const tagsIcon = computed(() => useSettingsStore().tagsIcon);
watch(route, () => { watch(route, () => {
addTags(); addTags();
@@ -180,7 +183,8 @@ const closeLeftTags = () => {
}); });
}; };
const closeOthersTags = () => { const closeOthersTags = () => {
router.push(selectedTag.value).catch(() => {}); if (!selectedTag.value) return;
router.push({ path: selectedTag.value.path, query: selectedTag.value.query }).catch(() => {});
proxy?.$tab.closeOtherPage(selectedTag.value).then(() => { proxy?.$tab.closeOtherPage(selectedTag.value).then(() => {
moveToCurrentTag(); moveToCurrentTag();
}); });
@@ -261,7 +265,11 @@ onMounted(() => {
margin-left: 5px; margin-left: 5px;
margin-top: 4px; margin-top: 4px;
border-radius: var(--app-radius-md); border-radius: var(--app-radius-md);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease, color 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
&:hover { &:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5); border-color: var(--el-color-primary-light-5);

View File

@@ -4,66 +4,69 @@
<el-sub-menu index="more" class="el-sub-menu__hide-arrow" v-if="moreRoutes.length > 0"> <el-sub-menu index="more" class="el-sub-menu__hide-arrow" v-if="moreRoutes.length > 0">
<template #title> <template #title>
<span>更多菜单</span> <span>{{ $t('navbar.moreMenu') }}</span>
</template> </template>
<sidebar-item :key="route.path + index" v-for="(route, index) in moreRoutes" :item="route" :base-path="route.path" /> <sidebar-item :key="route.path + index" v-for="(route, index) in moreRoutes" :item="route" :base-path="route.path" />
</el-sub-menu> </el-sub-menu>
</el-menu> </el-menu>
</template> </template>
<script setup> <script setup lang="ts">
import SidebarItem from '../Sidebar/SidebarItem' import SidebarItem from '../Sidebar/SidebarItem.vue';
import {useAppStore} from '@/store/modules/app' import { useAppStore } from '@/store/modules/app';
import {useSettingsStore} from '@/store/modules/settings' import { useSettingsStore } from '@/store/modules/settings';
import {usePermissionStore} from '@/store/modules/permission' import { usePermissionStore } from '@/store/modules/permission';
import type { RouteRecordRaw } from 'vue-router';
const route = useRoute() const route = useRoute();
const appStore = useAppStore() const appStore = useAppStore();
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore();
const sidebarRouters = computed(() => permissionStore.sidebarRouters) const sidebarRouters = computed(() => permissionStore.sidebarRouters);
const theme = computed(() => settingsStore.theme) const theme = computed(() => settingsStore.theme);
const device = computed(() => appStore.device) const device = computed(() => appStore.device);
const activeMenu = computed(() => { const activeMenu = computed(() => {
const { meta, path } = route const { meta, path } = route;
if (meta.activeMenu) { if (meta.activeMenu) {
return meta.activeMenu return meta.activeMenu;
} }
return path return path;
}) });
const visibleNumber = ref(5) const visibleNumber = ref(5);
const topMenus = computed(() => { const topMenus = computed<RouteRecordRaw[]>(() => {
return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(0, visibleNumber.value) return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(0, visibleNumber.value) as RouteRecordRaw[];
}) });
const moreRoutes = computed(() => { const moreRoutes = computed<RouteRecordRaw[]>(() => {
return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(visibleNumber.value, sidebarRouters.value.length - visibleNumber.value) return permissionStore.sidebarRouters
}) .filter((f) => !f.hidden)
.slice(visibleNumber.value, sidebarRouters.value.length - visibleNumber.value) as RouteRecordRaw[];
});
function setVisibleNumber() { function setVisibleNumber() {
let width = document.body.getBoundingClientRect().width let width = document.body.getBoundingClientRect().width;
if (width >= 1000) { if (width >= 1000) {
width -= 500 width -= 500;
} }
visibleNumber.value = parseInt(width / 3 / 85) visibleNumber.value = Math.floor(width / 3 / 85);
} }
onMounted(() => { onMounted(() => {
window.addEventListener('resize', setVisibleNumber) window.addEventListener('resize', setVisibleNumber);
}) });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', setVisibleNumber) window.removeEventListener('resize', setVisibleNumber);
}) });
onMounted(() => { onMounted(() => {
setVisibleNumber() setVisibleNumber();
}) });
</script> </script>
<style lang="scss"> <style lang="scss">
/* menu item */ /* menu item */
#app .topbar-menu.el-menu--horizontal .el-sub-menu__title, #app .topbar-menu.el-menu--horizontal .el-menu-item { #app .topbar-menu.el-menu--horizontal .el-sub-menu__title,
#app .topbar-menu.el-menu--horizontal .el-menu-item {
padding: 0 10px !important; padding: 0 10px !important;
} }
@@ -76,7 +79,10 @@ onMounted(() => {
margin: 0 10px !important; margin: 0 10px !important;
} }
.el-sub-menu.is-active .svg-icon, .el-menu-item.is-active .svg-icon + span, .el-sub-menu.is-active .svg-icon + span, .el-sub-menu.is-active .el-sub-menu__title span { .el-sub-menu.is-active .svg-icon,
.el-menu-item.is-active .svg-icon + span,
.el-sub-menu.is-active .svg-icon + span,
.el-sub-menu.is-active .el-sub-menu__title span {
color: v-bind(theme); color: v-bind(theme);
} }
@@ -85,7 +91,7 @@ onMounted(() => {
float: left; float: left;
line-height: 50px !important; line-height: 50px !important;
color: #303133 !important; color: #303133 !important;
margin: 0 15px -3px!important; margin: 0 15px -3px !important;
} }
/* topbar more arrow */ /* topbar more arrow */
@@ -97,7 +103,8 @@ onMounted(() => {
} }
/* menu__title el-menu-item */ /* menu__title el-menu-item */
.topbar-menu.el-menu--horizontal .el-sub-menu__title, .topbar-menu.el-menu--horizontal .el-menu-item { .topbar-menu.el-menu--horizontal .el-sub-menu__title,
.topbar-menu.el-menu--horizontal .el-menu-item {
height: 60px; height: 60px;
} }
</style> </style>

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="layout-search-dialog"> <div class="layout-search-dialog">
<el-dialog v-model="state.isShowSearch" destroy-on-close :show-close="false"> <el-dialog v-model="state.isShowSearch" destroy-on-close :show-close="false" :close-on-click-modal="true" @closed="closeSearch">
<template #footer> <template #footer>
<el-autocomplete <el-autocomplete
ref="layoutMenuAutocompleteRef" ref="layoutMenuAutocompleteRef"
v-model="state.menuQuery" v-model="state.menuQuery"
:fetch-suggestions="menuSearch" :fetch-suggestions="menuSearch"
placeholder="搜索" :placeholder="$t('navbar.search')"
:fit-input-width="true" :fit-input-width="true"
@select="onHandleSelect" @select="onHandleSelect"
> >
@@ -30,6 +30,7 @@ import { getNormalPath } from '@/utils/ruoyi';
import { isHttp } from '@/utils/validate'; import { isHttp } from '@/utils/validate';
import { usePermissionStore } from '@/store/modules/permission'; import { usePermissionStore } from '@/store/modules/permission';
import { RouteRecordRaw } from 'vue-router'; import { RouteRecordRaw } from 'vue-router';
import { translateRouteTitle } from '@/utils/i18n';
type Router = Array<{ type Router = Array<{
path: string; path: string;
icon: string; icon: string;
@@ -64,7 +65,17 @@ const openSearch = () => {
// 搜索弹窗关闭 // 搜索弹窗关闭
const closeSearch = () => { const closeSearch = () => {
state.isShowSearch = false; state.isShowSearch = false;
state.menuQuery = '';
}; };
// ESC 关闭搜索
onMounted(() => {
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && state.isShowSearch) {
closeSearch();
}
});
});
// 菜单搜索数据过滤 // 菜单搜索数据过滤
const menuSearch = (queryString: string, cb: (options: any[]) => void) => { const menuSearch = (queryString: string, cb: (options: any[]) => void) => {
const options = state.menuList.filter((item) => { const options = state.menuList.filter((item) => {
@@ -87,7 +98,7 @@ const generateRoutes = (routes: RouteRecordRaw[], basePath = '', prefixTitle: st
title: [...prefixTitle] title: [...prefixTitle]
}; };
if (r.meta && r.meta.title) { if (r.meta && r.meta.title) {
data.title = [...data.title, r.meta.title]; data.title = [...data.title, translateRouteTitle(r.meta.title as string)];
if (r.redirect !== 'noRedirect') { if (r.redirect !== 'noRedirect') {
// only push the routes with title // only push the routes with title
// special case: need to exclude parent router without redirect // special case: need to exclude parent router without redirect

View File

@@ -1,8 +1,8 @@
<template> <template>
<div v-loading="state.loading" class="layout-navbars-breadcrumb-user-news"> <div v-loading="state.loading" class="layout-navbars-breadcrumb-user-news">
<div class="head-box"> <div class="head-box">
<div class="head-box-title">通知公告</div> <div class="head-box-title">{{ $t('notice.title') }}</div>
<div class="head-box-btn" @click="readAll">全部已读</div> <div class="head-box-btn" @click="readAll">{{ $t('notice.readAll') }}</div>
</div> </div>
<div v-loading="state.loading" class="content-box"> <div v-loading="state.loading" class="content-box">
<template v-if="newsList.length > 0"> <template v-if="newsList.length > 0">
@@ -13,13 +13,13 @@
<div class="content-box-time">{{ v.time }}</div> <div class="content-box-time">{{ v.time }}</div>
</div> </div>
<!-- 已读/未读 --> <!-- 已读/未读 -->
<span v-if="v.read" class="el-tag el-tag--success el-tag--mini read">已读</span> <span v-if="v.read" class="el-tag el-tag--success el-tag--mini read">{{ $t('notice.read') }}</span>
<span v-else class="el-tag el-tag--danger el-tag--mini read">未读</span> <span v-else class="el-tag el-tag--danger el-tag--mini read">{{ $t('notice.unread') }}</span>
</div> </div>
</template> </template>
<el-empty v-else :description="'消息为空'"></el-empty> <el-empty v-else :description="$t('notice.empty')"></el-empty>
</div> </div>
<div v-if="newsList.length > 0" class="foot-box" @click="onGoToGiteeClick">前往gitee</div> <div v-if="newsList.length > 0" class="foot-box" @click="onGoToGiteeClick">{{ $t('notice.viewMore') }}</div>
</div> </div>
</template> </template>

View File

@@ -33,7 +33,13 @@ import i18n from '@/lang/index';
// vxeTable // vxeTable
import VXETable from 'vxe-table'; import VXETable from 'vxe-table';
import vxeZhCN from 'vxe-table/es/locale/lang/zh-CN';
import vxeEnUS from 'vxe-table/es/locale/lang/en-US';
import 'vxe-table/lib/style.css'; import 'vxe-table/lib/style.css';
import { getVxeLanguage } from '@/enums/LanguageEnum';
VXETable.setI18n('zh-CN', vxeZhCN);
VXETable.setI18n('en-US', vxeEnUS);
VXETable.setLanguage(getVxeLanguage(i18n.global.locale.value));
VXETable.setConfig({ VXETable.setConfig({
zIndex: 999999 zIndex: 999999
}); });
@@ -44,6 +50,8 @@ ElDialog.props.closeOnClickModal.default = false;
const app = createApp(App); const app = createApp(App);
document.title = i18n.global.t('app.title');
app.use(HighLight); app.use(HighLight);
app.use(ElementIcons); app.use(ElementIcons);
app.use(router); app.use(router);

View File

@@ -3,6 +3,8 @@ import enUS from 'element-plus/es/locale/lang/en';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { ref, reactive, computed } from 'vue'; import { ref, reactive, computed } from 'vue';
import { getLanguage, setDocumentLanguage } from '@/lang';
import { LanguageEnum } from '@/enums/LanguageEnum';
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
const sidebarStatus = useStorage('sidebarStatus', '1'); const sidebarStatus = useStorage('sidebarStatus', '1');
@@ -15,10 +17,10 @@ export const useAppStore = defineStore('app', () => {
const size = useStorage<'large' | 'default' | 'small'>('size', 'default'); const size = useStorage<'large' | 'default' | 'small'>('size', 'default');
// 语言 // 语言
const language = useStorage('language', 'zh_CN'); const language = useStorage<LanguageEnum>('language', getLanguage());
const languageObj: any = { const languageObj: Record<LanguageEnum, typeof zhCN> = {
en_US: enUS, [LanguageEnum.en_US]: enUS,
zh_CN: zhCN [LanguageEnum.zh_CN]: zhCN
}; };
const locale = computed(() => { const locale = computed(() => {
return languageObj[language.value]; return languageObj[language.value];
@@ -53,8 +55,9 @@ export const useAppStore = defineStore('app', () => {
sidebar.hide = status; sidebar.hide = status;
}; };
const changeLanguage = (val: string): void => { const changeLanguage = (val: LanguageEnum): void => {
language.value = val; language.value = val;
setDocumentLanguage(val);
}; };
return { return {

View File

@@ -1,14 +1,17 @@
import defaultSettings from '@/settings'; import defaultSettings from '@/settings';
import { useSettingsStore } from '@/store/modules/settings'; import { useSettingsStore } from '@/store/modules/settings';
import i18n from '@/lang';
import { translateRouteTitle } from '@/utils/i18n';
/** /**
* 动态修改标题 * 动态修改标题
*/ */
export const useDynamicTitle = () => { export const useDynamicTitle = () => {
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const appTitle = i18n.global.t('app.title');
if (settingsStore.dynamicTitle) { if (settingsStore.dynamicTitle) {
document.title = settingsStore.title + ' - ' + import.meta.env.VITE_APP_TITLE; document.title = translateRouteTitle(settingsStore.title) + ' - ' + appTitle;
} else { } else {
document.title = defaultSettings.title as string; document.title = appTitle || (defaultSettings.title as string);
} }
}; };

View File

@@ -6,11 +6,20 @@ import i18n from '@/lang/index';
* @param title 路由名称 * @param title 路由名称
* @returns {string} * @returns {string}
*/ */
export const translateRouteTitle = (title: string): string => { export const translateRouteTitle = (title?: string): string => {
const hasKey = i18n.global.te('route.' + title); const normalizedTitle = title?.trim();
if (hasKey) { if (!normalizedTitle) {
const translatedTitle = i18n.global.t('route.' + title); return '';
return translatedTitle;
} }
return title;
const titleKey = 'routeTitles.' + normalizedTitle;
if (i18n.global.te(titleKey)) {
return i18n.global.t(titleKey);
}
const routeKey = 'route.' + normalizedTitle;
if (i18n.global.te(routeKey)) {
return i18n.global.t(routeKey);
}
return normalizedTitle;
}; };

View File

@@ -19,7 +19,8 @@ export const isRelogin = { show: false };
export const globalHeaders = () => { export const globalHeaders = () => {
return { return {
Authorization: 'Bearer ' + getToken(), Authorization: 'Bearer ' + getToken(),
clientid: import.meta.env.VITE_APP_CLIENT_ID clientid: import.meta.env.VITE_APP_CLIENT_ID,
'Content-Language': getLanguage()
}; };
}; };
@@ -45,7 +46,7 @@ service.interceptors.request.use(
// 是否需要防止数据重复提交 // 是否需要防止数据重复提交
const isRepeatSubmit = config.headers?.repeatSubmit === false; const isRepeatSubmit = config.headers?.repeatSubmit === false;
// 是否需要加密 // 是否需要加密
const isEncrypt = config.headers?.isEncrypt === 'true'; const isEncrypt = config.headers?.isEncrypt === true || config.headers?.isEncrypt === 'true';
if (getToken() && !isToken) { if (getToken() && !isToken) {
config.headers['Authorization'] = 'Bearer ' + getToken(); // 让每个请求携带自定义token 请根据实际情况自行修改 config.headers['Authorization'] = 'Bearer ' + getToken(); // 让每个请求携带自定义token 请根据实际情况自行修改

View File

@@ -4,72 +4,35 @@
<div v-show="showSearch" class="mb-[10px]"> <div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover"> <el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> <el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="设备编号" prop="deviceNo"> <el-form-item :label="$t('device.deviceNo')" prop="deviceNo">
<el-input v-model="queryParams.deviceNo" placeholder="请输入设备编号" clearable @keyup.enter="handleQuery" /> <el-input
v-model="queryParams.deviceNo"
:placeholder="$t('device.deviceNo')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item> </el-form-item>
<el-form-item label="设备名称" prop="deviceName"> <el-form-item :label="$t('device.deviceName')" prop="deviceName">
<el-input v-model="queryParams.deviceName" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery" /> <el-input
v-model="queryParams.deviceName"
:placeholder="$t('device.deviceName')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('common.status')" prop="status">
<el-select v-model="queryParams.status" :placeholder="$t('common.pleaseSelect')" clearable style="width: 140px" @change="handleQuery">
<el-option :label="$t('device.status.online')" value="1" />
<el-option :label="$t('device.status.offline')" value="0" />
<el-option :label="$t('device.status.fault')" value="2" />
<el-option :label="$t('device.status.expired')" value="3" />
</el-select>
</el-form-item> </el-form-item>
<!-- <el-form-item label="WiFi名称" prop="wifiName">-->
<!-- <el-input v-model="queryParams.wifiName" placeholder="请输入WiFi名称" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="WiFi密码" prop="wifiPassword">-->
<!-- <el-input v-model="queryParams.wifiPassword" placeholder="请输入WiFi密码" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="强光开启" prop="light">-->
<!-- <el-select v-model="queryParams.light" placeholder="请选择" clearable @change="handleQuery">-->
<!-- <el-option label="不开启" value="0" />-->
<!-- <el-option label="开启" value="1" />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="告警播放" prop="alertAudio">-->
<!-- <el-select v-model="queryParams.alertAudio" placeholder="请选择" clearable @change="handleQuery">-->
<!-- <el-option label="不播放" value="0" />-->
<!-- <el-option label="播放" value="1" />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="识别模式" prop="patternPerception">-->
<!-- <el-select v-model="queryParams.patternPerception" placeholder="请选择" clearable @change="handleQuery">-->
<!-- <el-option label="蓝牙" value="0" />-->
<!-- <el-option label="蓝牙+人脸" value="1" />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="告警内容" prop="audioContet">-->
<!-- <el-input v-model="queryParams.audioContet" placeholder="请输入告警内容" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="打击距离" prop="strikeRange">-->
<!-- <el-input v-model="queryParams.strikeRange" placeholder="请输入打击距离" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="打击间隔" prop="strikeInterval">-->
<!-- <el-input v-model="queryParams.strikeInterval" placeholder="请输入打击间隔" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="容器数量" prop="container">-->
<!-- <el-input v-model="queryParams.container" placeholder="请输入容器数量" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="打击位置" prop="strikePosition">-->
<!-- <el-input v-model="queryParams.strikePosition" placeholder="请输入打击位置" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="自动布放开始时间" prop="startTime">-->
<!-- <el-date-picker clearable v-model="queryParams.startTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择自动布放开始时间" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="自动布放结束时间" prop="endTime">-->
<!-- <el-date-picker clearable v-model="queryParams.endTime" type="date" value-format="YYYY-MM-DD" placeholder="请选择自动布放结束时间" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="工作模式 0-自动 1-半自动" prop="workMode">-->
<!-- <el-input v-model="queryParams.workMode" placeholder="请输入工作模式 0-自动 1-半自动" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="设备型号" prop="deviceEm">-->
<!-- <el-input v-model="queryParams.deviceEm" placeholder="请输入设备型号" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="设备序列号" prop="deviceSn">-->
<!-- <el-input v-model="queryParams.deviceSn" placeholder="请输入设备序列号" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="固件版本" prop="fwVer">-->
<!-- <el-input v-model="queryParams.fwVer" placeholder="请输入固件版本" clearable @keyup.enter="handleQuery" />-->
<!-- </el-form-item>-->
<el-form-item> <el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="Search" @click="handleQuery">{{ $t('common.search') }}</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button> <el-button icon="Refresh" @click="resetQuery">{{ $t('common.reset') }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -79,97 +42,93 @@
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
<el-row :gutter="10" class="mb8"> <el-row :gutter="10" class="mb8">
<!-- <el-col :span="1.5">-->
<!-- <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['app:device:add']">新增</el-button>-->
<!-- </el-col>-->
<!-- <el-col :span="1.5">-->
<!-- <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['app:device:edit']">修改</el-button>-->
<!-- </el-col>-->
<el-col :span="1.5"> <el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['app:device:remove']" <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['app:device:add']">{{ $t('common.add') }}</el-button>
>删除</el-button
>
</el-col> </el-col>
<el-col :span="1.5"> <el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['app:device:export']">导出</el-button> <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['app:device:remove']">{{
$t('common.delete')
}}</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['app:device:export']">{{
$t('common.export')
}}</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="PictureFilled"
:loading="batchQrLoading"
:disabled="deviceList.length === 0"
@click="batchDownloadQrCodes"
>{{ $t('device.batchDownloadQr') }}</el-button
>
</el-col> </el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar> <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row> </el-row>
</template> </template>
<el-table v-loading="loading" border :data="deviceList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" border :data="deviceList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" /> <el-table-column type="selection" width="50" align="center" fixed="left" />
<el-table-column label="" align="center" prop="id" v-if="true" /> <el-table-column :label="$t('common.index')" type="index" width="60" align="center" />
<el-table-column label="设备编号" align="center" prop="deviceNo" /> <el-table-column :label="$t('device.deviceNo')" align="center" prop="deviceNo" min-width="140" show-overflow-tooltip />
<el-table-column label="设备名称" align="center" prop="deviceName" /> <el-table-column :label="$t('device.deviceName')" align="center" prop="deviceName" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" align="center" prop="status" width="100"> <el-table-column :label="$t('common.status')" align="center" prop="status" width="80">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusTagType(scope.row.status)"> <el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusLabel(scope.row.status) }} {{ statusLabel(scope.row.status, scope.row.statusLabel) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="WiFi名称" align="center" prop="wifiName" /> <el-table-column :label="$t('device.light')" align="center" prop="light" width="70">
<el-table-column label="WiFi密码" align="center" prop="wifiPassword" />
<el-table-column label="强光开启" align="center" prop="light" width="100">
<template #default="scope"> <template #default="scope">
<el-tag :type="lightTagType(scope.row.light)"> <el-tag :type="lightTagType(scope.row.light)" size="small">
{{ lightLabel(scope.row.light) }} {{ lightLabel(scope.row.light) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警播放" align="center" prop="alertAudio" width="100"> <el-table-column :label="$t('device.alert')" align="center" prop="alertAudio" width="70">
<template #default="scope"> <template #default="scope">
<el-tag :type="alertAudioTagType(scope.row.alertAudio)"> <el-tag :type="alertAudioTagType(scope.row.alertAudio)" size="small">
{{ alertAudioLabel(scope.row.alertAudio) }} {{ alertAudioLabel(scope.row.alertAudio) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="识别模式" align="center" prop="patternPerception" width="120"> <el-table-column :label="$t('device.perceptionMode')" align="center" prop="patternPerception" width="120">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.patternPerception === '1' ? 'primary' : 'info'"> <el-tag :type="scope.row.patternPerception === '1' ? 'primary' : 'info'" size="small">
{{ patternPerceptionLabel(scope.row.patternPerception) }} {{ patternPerceptionLabel(scope.row.patternPerception) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警内容" align="center" prop="audioContet" min-width="120"> <el-table-column :label="$t('device.alertContent')" align="center" prop="audioContet" min-width="140" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-tooltip v-if="scope.row.audioContet" :content="scope.row.audioContet" placement="top" :show-after="200"> <span v-if="scope.row.audioContet">{{ scope.row.audioContet }}</span>
<span class="text-ellipsis">{{ scope.row.audioContet }}</span>
</el-tooltip>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="打击距离" align="center" prop="strikeRange" /> <el-table-column :label="$t('device.modelShort')" align="center" prop="deviceEm" width="110" show-overflow-tooltip />
<el-table-column label="打击间隔" align="center" prop="strikeInterval" /> <el-table-column :label="$t('device.serialNumberShort')" align="center" prop="deviceSn" width="130" show-overflow-tooltip />
<el-table-column label="容器数量" align="center" prop="container" /> <el-table-column :label="$t('device.firmwareVersion')" align="center" prop="fwVer" width="100" />
<el-table-column label="打击位置" align="center" prop="strikePosition" /> <el-table-column :label="$t('device.qrCode')" align="center" width="90">
<el-table-column label="自动布放开始时间" align="center" prop="startTime" width="180">
<template #default="scope"> <template #default="scope">
<span>{{ parseTime(scope.row.startTime, '{y}-{m}-{d}') }}</span> <img
v-if="qrCodeMap[scope.row.id]"
:src="qrCodeMap[scope.row.id]"
@click="previewQrCode(scope.row)"
style="width: 60px; height: 60px; cursor: pointer; vertical-align: middle; object-fit: contain"
/>
<span v-else style="color: #ccc">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="自动布放结束时间" align="center" prop="endTime" width="180"> <el-table-column :label="$t('common.operation')" align="center" width="100" fixed="right">
<template #default="scope"> <template #default="scope">
<span>{{ parseTime(scope.row.endTime, '{y}-{m}-{d}') }}</span> <!-- <el-tooltip content="修改" placement="top">-->
</template> <!-- <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['app:device:edit']"></el-button>-->
</el-table-column> <!-- </el-tooltip>-->
<el-table-column label="工作模式" align="center" prop="workMode" width="100"> <el-tooltip :content="$t('common.delete')" placement="top">
<template #default="scope">
<el-tag :type="workModeTagType(scope.row.workMode)">
{{ workModeLabel(scope.row.workMode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="设备型号" align="center" prop="deviceEm" />
<el-table-column label="设备序列号" align="center" prop="deviceSn" />
<el-table-column label="固件版本" align="center" prop="fwVer" />
<el-table-column label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
<template #default="scope">
<!-- <el-tooltip content="修改" placement="top">-->
<!-- <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['app:device:edit']"></el-button>-->
<!-- </el-tooltip>-->
<el-tooltip content="删除" placement="top">
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['app:device:remove']"></el-button> <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['app:device:remove']"></el-button>
</el-tooltip> </el-tooltip>
</template> </template>
@@ -178,137 +137,230 @@
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" /> <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
</el-card> </el-card>
<!-- 添加或修改设备信息 <!-- 添加 / 修改设备 -->
对话框 --> <el-dialog :title="dialog.title" v-model="dialog.visible" width="700px" append-to-body>
<el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body> <el-form ref="deviceFormRef" :model="form" :rules="rules" label-width="110px" label-position="right">
<el-form ref="deviceFormRef" :model="form" :rules="rules" label-width="80px"> <el-row :gutter="20">
<el-form-item label="设备编号" prop="deviceNo"> <el-col :span="12">
<el-input v-model="form.deviceNo" placeholder="请输入设备编号" /> <el-form-item :label="$t('device.deviceNo')" prop="deviceNo">
</el-form-item> <el-input v-model="form.deviceNo" :placeholder="$t('device.deviceNo')" />
<el-form-item label="设备名称" prop="deviceName"> </el-form-item>
<el-input v-model="form.deviceName" placeholder="请输入设备名称" /> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item label="WiFi名称" prop="wifiName"> <el-form-item :label="$t('device.deviceName')" prop="deviceName">
<el-input v-model="form.wifiName" placeholder="请输入WiFi名称" /> <el-input v-model="form.deviceName" :placeholder="$t('device.deviceName')" />
</el-form-item> </el-form-item>
<el-form-item label="WiFi密码" prop="wifiPassword"> </el-col>
<el-input v-model="form.wifiPassword" placeholder="请输入WiFi密码" /> <el-col :span="12">
</el-form-item> <el-form-item :label="$t('device.owner')" prop="userId">
<el-form-item label="强光开启" prop="light"> <el-select
<el-switch v-model="form.light" active-value="1" inactive-value="0" /> v-model="form.userId"
</el-form-item> :placeholder="$t('device.selectOwner')"
<el-form-item label="告警播放" prop="alertAudio"> filterable
<el-switch v-model="form.alertAudio" active-value="1" inactive-value="0" /> clearable
</el-form-item> :loading="appUserLoading"
<el-form-item label="识别模式" prop="patternPerception"> style="width: 100%"
<el-select v-model="form.patternPerception" placeholder="请选择识别模式"> >
<el-option label="蓝牙" value="0" /> <el-option
<el-option label="蓝牙+人脸" value="1" /> v-for="user in appUserList"
</el-select> :key="user.userId"
</el-form-item> :label="`${user.nickName || user.userName} (${user.userName})`"
<el-form-item label="告警内容" prop="audioContet"> :value="user.userId"
<el-input v-model="form.audioContet" placeholder="请输入告警内容" /> />
</el-form-item> </el-select>
<el-form-item label="打击距离" prop="strikeRange"> </el-form-item>
<el-input v-model="form.strikeRange" placeholder="请输入打击距离" /> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item label="打击间隔" prop="strikeInterval"> <el-form-item :label="$t('device.wifiName')" prop="wifiName">
<el-input v-model="form.strikeInterval" placeholder="请输入打击间隔" /> <el-input v-model="form.wifiName" :placeholder="$t('device.wifiName')" />
</el-form-item> </el-form-item>
<el-form-item label="容器数量" prop="container"> </el-col>
<el-input v-model="form.container" placeholder="请输入容器数量" /> <el-col :span="12">
</el-form-item> <el-form-item :label="$t('device.wifiPassword')" prop="wifiPassword">
<el-form-item label="打击位置" prop="strikePosition"> <el-input v-model="form.wifiPassword" :placeholder="$t('device.wifiPassword')" />
<el-input v-model="form.strikePosition" placeholder="请输入打击位置" /> </el-form-item>
</el-form-item> </el-col>
<el-form-item label="自动布放开始时间" prop="startTime"> <el-col :span="12">
<el-date-picker clearable v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择自动布放开始时间"> <el-form-item :label="$t('device.model')" prop="deviceEm">
</el-date-picker> <el-input v-model="form.deviceEm" :placeholder="$t('device.model')" />
</el-form-item> </el-form-item>
<el-form-item label="自动布放结束时间" prop="endTime"> </el-col>
<el-date-picker clearable v-model="form.endTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="请选择自动布放结束时间"> <el-col :span="12">
</el-date-picker> <el-form-item :label="$t('device.serialNumber')" prop="deviceSn">
</el-form-item> <el-input v-model="form.deviceSn" :placeholder="$t('device.serialNumber')" />
<el-form-item label="工作模式 0-自动 1-半自动" prop="workMode"> </el-form-item>
<el-input v-model="form.workMode" placeholder="请输入工作模式 0-自动 1-半自动" /> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item label="设备型号" prop="deviceEm"> <el-form-item :label="$t('device.firmwareVersion')" prop="fwVer">
<el-input v-model="form.deviceEm" placeholder="请输入设备型号" /> <el-input v-model="form.fwVer" :placeholder="$t('device.firmwareVersion')" />
</el-form-item> </el-form-item>
<el-form-item label="设备序列号" prop="deviceSn"> </el-col>
<el-input v-model="form.deviceSn" placeholder="请输入设备序列号" /> <el-col :span="12">
</el-form-item> <el-form-item :label="$t('device.perceptionMode')" prop="patternPerception">
<el-form-item label="固件版本" prop="fwVer"> <el-select v-model="form.patternPerception" :placeholder="$t('common.pleaseSelect')">
<el-input v-model="form.fwVer" placeholder="请输入固件版本" /> <el-option :label="$t('device.perception.bluetooth')" value="0" />
</el-form-item> <el-option :label="$t('device.perception.bluetoothFace')" value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.workMode')" prop="workMode">
<el-select v-model="form.workMode" :placeholder="$t('common.pleaseSelect')">
<el-option :label="$t('device.work.automatic')" value="0" />
<el-option :label="$t('device.work.semiAutomatic')" value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.lightEnabled')" prop="light">
<el-switch v-model="form.light" active-value="1" inactive-value="0" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.alertPlayback')" prop="alertAudio">
<el-switch v-model="form.alertAudio" active-value="1" inactive-value="0" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item :label="$t('device.alertContent')" prop="audioContet">
<el-input v-model="form.audioContet" :placeholder="$t('device.alertContent')" type="textarea" :rows="2" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('device.strikeRange')" prop="strikeRange">
<el-input v-model="form.strikeRange" :placeholder="$t('common.pleaseInput')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('device.strikeInterval')" prop="strikeInterval">
<el-input v-model="form.strikeInterval" :placeholder="$t('common.pleaseInput')" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="$t('device.containerCount')" prop="container">
<el-input v-model="form.container" :placeholder="$t('common.pleaseInput')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.strikePosition')" prop="strikePosition">
<el-input v-model="form.strikePosition" :placeholder="$t('common.pleaseInput')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.deploymentStart')" prop="startTime">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="$t('device.selectTime')"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('device.deploymentEnd')" prop="endTime">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="$t('device.selectTime')"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <el-button :loading="buttonLoading" type="primary" @click="submitForm">{{ $t('common.confirm') }}</el-button>
<el-button :loading="buttonLoading" type="primary" @click="submitForm"> </el-button> <el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button @click="cancel"> </el-button> </template>
</div> </el-dialog>
<!-- 二维码预览弹窗 -->
<el-dialog v-model="qrPreviewVisible" :title="$t('device.qrCode')" width="420px" append-to-body :close-on-click-modal="true">
<div style="text-align: center; padding: 10px 0">
<img v-if="qrPreviewSrc" :src="qrPreviewSrc" style="width: 340px; height: auto; display: inline-block" />
</div>
<template #footer>
<el-button type="primary" :disabled="!qrPreviewSrc" @click="saveQrPreview">{{ $t('device.saveImage') }}</el-button>
<el-button @click="qrPreviewVisible = false">{{ $t('common.close') }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup name="Device" lang="ts"> <script setup name="Device" lang="ts">
import { useI18n } from 'vue-i18n';
import { listDevice, getDevice, delDevice, addDevice, updateDevice } from '@/api/app/device'; import { listDevice, getDevice, delDevice, addDevice, updateDevice } from '@/api/app/device';
import { DeviceVO, DeviceQuery, DeviceForm } from '@/api/app/device/types'; import { DeviceVO, DeviceQuery, DeviceForm } from '@/api/app/device/types';
import { listUser, getUser } from '@/api/app/user';
import { UserVO } from '@/api/app/user/types';
import QRCode from 'qrcode';
import JSZip from 'jszip';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { t } = useI18n();
type TagType = 'primary' | 'success' | 'warning' | 'danger' | 'info';
/** 状态标签类型映射 */ /** 状态标签类型映射 */
const statusTagType = (status: string): string => { const statusTagType = (status: string): TagType => {
const map: Record<string, string> = { '0': 'info', '1': 'success', '2': 'danger', '3': 'warning' }; const map: Record<string, TagType> = { '0': 'info', '1': 'success', '2': 'danger', '3': 'warning' };
return map[status] || 'info'; return map[status] || 'info';
}; };
/** 状态中文标签 */ /** 优先显示后端按请求语言返回的状态标签 */
const statusLabel = (status: string): string => { const statusLabel = (status: string, localizedLabel?: string): string => {
const map: Record<string, string> = { '0': '离线', '1': '在线', '2': '故障', '3': '到期' }; if (localizedLabel) {
return localizedLabel;
}
const map: Record<string, string> = {
'0': t('device.status.offline'),
'1': t('device.status.online'),
'2': t('device.status.fault'),
'3': t('device.status.expired')
};
return map[status] || status || '-'; return map[status] || status || '-';
}; };
/** 识别模式中文标签 */ /** 识别模式中文标签 */
const patternPerceptionLabel = (mode: string): string => { const patternPerceptionLabel = (mode: string): string => {
const map: Record<string, string> = { '0': '蓝牙', '1': '蓝牙+人脸' }; const map: Record<string, string> = { '0': t('device.perception.bluetooth'), '1': t('device.perception.bluetoothFace') };
return map[mode] || mode || '-'; return map[mode] || mode || '-';
}; };
/** 强光开启标签类型映射 */ /** 强光开启标签类型映射 */
const lightTagType = (light: string): string => { const lightTagType = (light: string): TagType => {
const map: Record<string, string> = { '0': 'info', '1': 'success' }; const map: Record<string, TagType> = { '0': 'info', '1': 'success' };
return map[light] || 'info'; return map[light] || 'info';
}; };
/** 强光开启中文标签 */ /** 强光开启中文标签 */
const lightLabel = (light: string): string => { const lightLabel = (light: string): string => {
const map: Record<string, string> = { '0': '不开启', '1': '开启' }; const map: Record<string, string> = { '0': t('device.lightState.off'), '1': t('device.lightState.on') };
return map[light] || light || '-'; return map[light] || light || '-';
}; };
/** 告警播放标签类型映射 */ /** 告警播放标签类型映射 */
const alertAudioTagType = (alertAudio: string): string => { const alertAudioTagType = (alertAudio: string): TagType => {
const map: Record<string, string> = { '0': 'info', '1': 'success' }; const map: Record<string, TagType> = { '0': 'info', '1': 'success' };
return map[alertAudio] || 'info'; return map[alertAudio] || 'info';
}; };
/** 告警播放中文标签 */ /** 告警播放中文标签 */
const alertAudioLabel = (alertAudio: string): string => { const alertAudioLabel = (alertAudio: string): string => {
const map: Record<string, string> = { '0': '不播放', '1': '播放' }; const map: Record<string, string> = { '0': t('device.audioState.off'), '1': t('device.audioState.on') };
return map[alertAudio] || alertAudio || '-'; return map[alertAudio] || alertAudio || '-';
}; };
/** 工作模式标签类型映射 */ /** 工作模式标签类型映射 */
const workModeTagType = (mode: string): string => { const workModeTagType = (mode: string): TagType => {
const map: Record<string, string> = { '0': 'success', '1': 'primary' }; const map: Record<string, TagType> = { '0': 'success', '1': 'primary' };
return map[mode] || 'info'; return map[mode] || 'info';
}; };
/** 工作模式中文标签 */ /** 工作模式中文标签 */
const workModeLabel = (mode: string): string => { const workModeLabel = (mode: string): string => {
const map: Record<string, string> = { '0': '自动', '1': '半自动' }; const map: Record<string, string> = { '0': t('device.work.automatic'), '1': t('device.work.semiAutomatic') };
return map[mode] || mode || '-'; return map[mode] || mode || '-';
}; };
@@ -320,6 +372,8 @@ const ids = ref<Array<string | number>>([]);
const single = ref(true); const single = ref(true);
const multiple = ref(true); const multiple = ref(true);
const total = ref(0); const total = ref(0);
const appUserList = ref<UserVO[]>([]);
const appUserLoading = ref(false);
const queryFormRef = ref<ElFormInstance>(); const queryFormRef = ref<ElFormInstance>();
const deviceFormRef = ref<ElFormInstance>(); const deviceFormRef = ref<ElFormInstance>();
@@ -329,8 +383,149 @@ const dialog = reactive<DialogOption>({
title: '' title: ''
}); });
// 二维码内联展示 — 用 ref 包装 plain object避免 reactive 动态 key 追踪问题
const qrCodeMap = ref<Record<string | number, string>>({});
/** 生成一张合成二维码(二维码 + 设备编号)→ data URL */
const makeCompositeQr = async (deviceNo: string): Promise<string> => {
const qrDataUrl = await QRCode.toDataURL(deviceNo, {
width: 300,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
});
return new Promise((resolve, reject) => {
const qrImg = new Image();
qrImg.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 340;
canvas.height = 310;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const qrSize = 280;
const qrX = (canvas.width - qrSize) / 2;
const qrY = 5;
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
ctx.fillStyle = '#333333';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(deviceNo, canvas.width / 2, qrY + qrSize + 6);
resolve(canvas.toDataURL('image/png'));
};
qrImg.onerror = () => reject(new Error(t('device.qrGenerationFailed')));
qrImg.src = qrDataUrl;
});
};
/** 生成所有设备的合成二维码 */
const generateQrCodes = async (list: DeviceVO[]) => {
const map = { ...qrCodeMap.value };
let changed = false;
for (const item of list) {
if (map[item.id]) continue;
try {
map[item.id] = await makeCompositeQr(item.deviceNo);
changed = true;
} catch {
// 生成失败,静默跳过
}
}
if (changed) qrCodeMap.value = map;
};
// 二维码预览
const qrPreviewVisible = ref(false);
const qrPreviewSrc = ref('');
const qrPreviewDeviceNo = ref('');
const previewQrCode = async (row: DeviceVO) => {
qrPreviewDeviceNo.value = row.deviceNo;
qrPreviewVisible.value = true;
// 如果有已生成的合成图直接用,否则实时生成
if (qrCodeMap.value[row.id]) {
qrPreviewSrc.value = qrCodeMap.value[row.id];
} else {
try {
qrPreviewSrc.value = await makeCompositeQr(row.deviceNo);
} catch {
proxy?.$modal.msgError(t('device.qrGenerationFailed'));
}
}
};
const saveQrPreview = () => {
if (!qrPreviewSrc.value) return;
const link = document.createElement('a');
link.download = `device_qr_${qrPreviewDeviceNo.value}.png`;
link.href = qrPreviewSrc.value;
link.click();
};
// 批量下载二维码
const batchQrLoading = ref(false);
const selectedDevices = ref<DeviceVO[]>([]);
const sanitizeQrFileName = (deviceNo: string | undefined, id: string | number) => {
const name = (deviceNo || '').replace(/[\\/:*?"<>|]/g, '_').trim();
return name || `device_${id}`;
};
const batchDownloadQrCodes = async () => {
const targetDevices = selectedDevices.value.length > 0 ? selectedDevices.value : deviceList.value;
if (targetDevices.length === 0) {
proxy?.$modal.msgWarning(t('device.noData'));
return;
}
batchQrLoading.value = true;
try {
// 确保所有二维码都已生成
const generateQrItem = async (item: DeviceVO): Promise<string> => {
if (qrCodeMap.value[item.id]) return qrCodeMap.value[item.id];
return makeCompositeQr(item.deviceNo);
};
const zip = new JSZip();
const usedNames = new Set<string>();
for (const item of targetDevices) {
const dataUrl = await generateQrItem(item);
// data:image/png;base64,xxxx → 提取 base64 部分
const base64 = dataUrl.split(',')[1];
if (base64) {
const baseName = sanitizeQrFileName(item.deviceNo, item.id);
let fileName = `${baseName}.png`;
let suffix = 1;
while (usedNames.has(fileName)) {
fileName = `${baseName}_${suffix++}.png`;
}
usedNames.add(fileName);
zip.file(fileName, base64, { base64: true });
}
}
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `device_qr_batch_${new Date().toISOString().slice(0, 10)}.zip`;
link.href = url;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
proxy?.$modal.msgSuccess(t('device.batchPackaged', { count: targetDevices.length }));
} catch {
proxy?.$modal.msgError(t('device.batchGenerationFailed'));
} finally {
batchQrLoading.value = false;
}
};
// WiFi密码显隐
const showPwd = reactive<Record<string | number, boolean>>({});
const togglePwd = (id: string | number) => {
showPwd[id] = !showPwd[id];
};
const initFormData: DeviceForm = { const initFormData: DeviceForm = {
id: undefined, id: undefined,
userId: undefined,
deviceNo: undefined, deviceNo: undefined,
deviceName: undefined, deviceName: undefined,
status: undefined, status: undefined,
@@ -377,12 +572,14 @@ const data = reactive<PageData<DeviceForm, DeviceQuery>>({
patternPerception: undefined, patternPerception: undefined,
params: {} params: {}
}, },
rules: { rules: {}
id: [{ required: true, message: '不能为空', trigger: 'blur' }]
}
}); });
const { queryParams, form, rules } = toRefs(data); const { queryParams, form } = toRefs(data);
const rules = computed(() => ({
id: [{ required: true, message: t('common.required'), trigger: 'blur' }],
userId: [{ required: true, message: t('device.selectOwner'), trigger: 'change' }]
}));
/** 查询设备信息 /** 查询设备信息
列表 */ 列表 */
@@ -391,7 +588,9 @@ const getList = async () => {
const res = await listDevice(queryParams.value); const res = await listDevice(queryParams.value);
deviceList.value = res.rows; deviceList.value = res.rows;
total.value = res.total; total.value = res.total;
selectedDevices.value = [];
loading.value = false; loading.value = false;
generateQrCodes(res.rows);
}; };
/** 取消按钮 */ /** 取消按钮 */
@@ -418,8 +617,26 @@ const resetQuery = () => {
handleQuery(); handleQuery();
}; };
const loadAppUsers = async () => {
if (appUserList.value.length > 0) return;
appUserLoading.value = true;
try {
const res = await listUser({ pageNum: 1, pageSize: 500, status: '0' });
appUserList.value = res.rows || [];
} finally {
appUserLoading.value = false;
}
};
const ensureAppUserOption = async (userId?: string | number) => {
if (userId === undefined || userId === null || appUserList.value.some((user) => user.userId == userId)) return;
const res = await getUser(userId);
if (res.data) appUserList.value.push(res.data);
};
/** 多选框选中数据 */ /** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceVO[]) => { const handleSelectionChange = (selection: DeviceVO[]) => {
selectedDevices.value = selection;
ids.value = selection.map((item) => item.id); ids.value = selection.map((item) => item.id);
single.value = selection.length != 1; single.value = selection.length != 1;
multiple.value = !selection.length; multiple.value = !selection.length;
@@ -428,18 +645,21 @@ const handleSelectionChange = (selection: DeviceVO[]) => {
/** 新增按钮操作 */ /** 新增按钮操作 */
const handleAdd = () => { const handleAdd = () => {
reset(); reset();
loadAppUsers();
dialog.visible = true; dialog.visible = true;
dialog.title = '添加设备信息'; dialog.title = t('device.addTitle');
}; };
/** 修改按钮操作 */ /** 修改按钮操作 */
const handleUpdate = async (row?: DeviceVO) => { const handleUpdate = async (row?: DeviceVO) => {
reset(); reset();
await loadAppUsers();
const _id = row?.id || ids.value[0]; const _id = row?.id || ids.value[0];
const res = await getDevice(_id); const res = await getDevice(_id);
Object.assign(form.value, res.data); Object.assign(form.value, res.data);
await ensureAppUserOption(form.value.userId);
dialog.visible = true; dialog.visible = true;
dialog.title = '修改设备信息'; dialog.title = t('device.editTitle');
}; };
/** 提交按钮 */ /** 提交按钮 */
@@ -452,7 +672,7 @@ const submitForm = () => {
} else { } else {
await addDevice(form.value).finally(() => (buttonLoading.value = false)); await addDevice(form.value).finally(() => (buttonLoading.value = false));
} }
proxy?.$modal.msgSuccess('操作成功'); proxy?.$modal.msgSuccess(t('common.operationSuccess'));
dialog.visible = false; dialog.visible = false;
await getList(); await getList();
} }
@@ -462,9 +682,9 @@ const submitForm = () => {
/** 删除按钮操作 */ /** 删除按钮操作 */
const handleDelete = async (row?: DeviceVO) => { const handleDelete = async (row?: DeviceVO) => {
const _ids = row?.id || ids.value; const _ids = row?.id || ids.value;
await proxy?.$modal.confirm('是否确认删除设备信息编号为"' + _ids + '"的数据项?').finally(() => (loading.value = false)); await proxy?.$modal.confirm(t('device.deleteConfirm', { ids: String(_ids) })).finally(() => (loading.value = false));
await delDevice(_ids); await delDevice(_ids);
proxy?.$modal.msgSuccess('删除成功'); proxy?.$modal.msgSuccess(t('common.deleteSuccess'));
await getList(); await getList();
}; };
@@ -484,14 +704,4 @@ onMounted(() => {
}); });
</script> </script>
<style scoped> <style scoped></style>
.text-ellipsis {
display: inline-block;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
cursor: default;
}
</style>

View File

@@ -4,20 +4,23 @@
<div v-show="showSearch" class="mb-[10px]"> <div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover"> <el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true"> <el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="告警类型" prop="category"> <el-form-item :label="$t('message.category')" prop="category">
<el-select v-model="queryParams.category" placeholder="请选择告警类型" clearable @change="handleQuery"> <el-select v-model="queryParams.category" :placeholder="$t('common.pleaseSelect')" clearable style="width: 160px" @change="handleQuery">
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<!-- <el-form-item label="告警说明" prop="categoryName">--> <el-form-item :label="$t('message.description')" prop="categoryName">
<!-- <el-input v-model="queryParams.categoryName" placeholder="请输入告警说明" clearable @keyup.enter="handleQuery" />--> <el-input
<!-- </el-form-item>--> v-model="queryParams.categoryName"
<!-- <el-form-item label="视频地址" prop="video"> :placeholder="$t('common.pleaseInput')"
<el-input v-model="queryParams.video" placeholder="请输入视频地址" clearable @keyup.enter="handleQuery" /> clearable
</el-form-item>--> style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button> <el-button type="primary" icon="Search" @click="handleQuery">{{ $t('common.search') }}</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button> <el-button icon="Refresh" @click="resetQuery">{{ $t('common.reset') }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -27,58 +30,73 @@
<el-card shadow="never"> <el-card shadow="never">
<template #header> <template #header>
<el-row :gutter="10" class="mb8"> <el-row :gutter="10" class="mb8">
<!-- <el-col :span="1.5">-->
<!-- <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['app:message:add']">新增</el-button>-->
<!-- </el-col>-->
<!-- <el-col :span="1.5">-->
<!-- <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['app:message:edit']">修改</el-button>-->
<!-- </el-col>-->
<el-col :span="1.5"> <el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['app:message:remove']" <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['app:message:remove']">{{
>删除</el-button $t('common.delete')
> }}</el-button>
</el-col> </el-col>
<el-col :span="1.5"> <el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['app:message:export']">导出</el-button> <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['app:message:export']">{{
$t('common.export')
}}</el-button>
</el-col> </el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar> <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row> </el-row>
</template> </template>
<el-table v-loading="loading" border :data="messageList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" border :data="messageList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" /> <el-table-column type="selection" width="45" align="center" />
<el-table-column label="ID" align="center" prop="id" v-if="true" /> <el-table-column type="index" :label="$t('common.index')" width="55" align="center" />
<el-table-column label="设备ID" align="center" prop="deviceId" v-if="true" /> <el-table-column :label="$t('message.deviceId')" align="center" prop="deviceId" width="160" show-overflow-tooltip />
<el-table-column label="设备名" align="center" prop="deviceName" v-if="true" /> <el-table-column :label="$t('message.deviceName')" align="center" prop="deviceName" width="160" show-overflow-tooltip />
<el-table-column label="告警类型" align="center" prop="category" width="120"> <el-table-column :label="$t('message.category')" align="center" prop="category" width="130">
<template #default="scope"> <template #default="scope">
<el-tag :type="categoryTagType(scope.row.category)"> <el-tag :type="categoryTagType(scope.row.category)" size="small">
{{ categoryLabel(scope.row.category) }} {{ categoryLabel(scope.row.category, scope.row.categoryLabel) }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="告警说明" align="center" prop="categoryName" /> <el-table-column :label="$t('message.description')" align="center" prop="categoryName" width="130" show-overflow-tooltip />
<el-table-column label="图片地址" align="center" prop="imageUrl" width="100"> <el-table-column :label="$t('message.image')" align="center" width="200">
<template #default="scope"> <template #default="scope">
<image-preview :src="scope.row.imageUrl" :width="50" :height="50" /> <template v-if="scope.row.imageUrl">
<el-image
v-for="(url, idx) in (scope.row.imageUrl || '').split(',').filter(Boolean).slice(0, 3)"
:key="idx"
:src="url"
:preview-src-list="(scope.row.imageUrl || '').split(',').filter(Boolean)"
:initial-index="Number(idx)"
fit="cover"
style="width: 48px; height: 48px; margin: 2px; border-radius: 4px; cursor: pointer"
/>
<el-tag
v-if="(scope.row.imageUrl || '').split(',').filter(Boolean).length > 3"
size="small"
type="info"
style="vertical-align: middle; margin-left: 4px"
>
+{{ (scope.row.imageUrl || '').split(',').filter(Boolean).length - 3 }}
</el-tag>
</template>
<span v-else class="text-gray-400 text-xs">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="视频地址" align="center" prop="video" min-width="120"> <el-table-column :label="$t('message.video')" align="center" width="120">
<template #default="scope"> <template #default="scope">
<template v-if="scope.row.video"> <template v-if="scope.row.video">
<el-button link type="primary" @click="handlePlayVideo(scope.row.video)"> <el-button link type="primary" size="small" @click="handlePlayVideo(scope.row.video)">
<el-icon><VideoPlay /></el-icon> 播放 <el-icon><VideoPlay /></el-icon>
</el-button>
<el-button link type="success" size="small" @click="openVideoUrl(scope.row.video)" style="margin-left: 0">
<el-icon><Link /></el-icon>
</el-button> </el-button>
</template> </template>
<span v-else class="text-gray-400 text-xs">-</span> <span v-else class="text-gray-400 text-xs">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center" fixed="right" class-name="small-padding fixed-width"> <el-table-column :label="$t('common.operation')" align="center" width="90" fixed="right">
<template #default="scope"> <template #default="scope">
<!-- <el-tooltip content="修改" placement="top">--> <el-tooltip :content="$t('common.delete')" placement="top">
<!-- <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['app:message:edit']"></el-button>-->
<!-- </el-tooltip>-->
<el-tooltip content="删除" placement="top">
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['app:message:remove']"></el-button> <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['app:message:remove']"></el-button>
</el-tooltip> </el-tooltip>
</template> </template>
@@ -87,34 +105,49 @@
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" /> <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
</el-card> </el-card>
<!-- 添加修改告警消息列对话框 --> <!-- 添加 / 修改告警消息 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body> <el-dialog :title="dialog.title" v-model="dialog.visible" width="620px" append-to-body>
<el-form ref="messageFormRef" :model="form" :rules="rules" label-width="80px"> <el-form ref="messageFormRef" :model="form" :rules="rules" label-width="90px" label-position="right">
<el-form-item label="告警类型" prop="category"> <el-row :gutter="20">
<el-select v-model="form.category" placeholder="请选择告警类型"> <el-col :span="12">
<el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" /> <el-form-item :label="$t('message.category')" prop="category">
</el-select> <el-select v-model="form.category" :placeholder="$t('common.pleaseSelect')">
</el-form-item> <el-option v-for="item in categoryOptions" :key="item.value" :label="item.label" :value="item.value" />
<el-form-item label="告警说明" prop="categoryName"> </el-select>
<el-input v-model="form.categoryName" placeholder="请输入告警说明" /> </el-form-item>
</el-form-item> </el-col>
<el-form-item label="图片地址" prop="image"> <el-col :span="12">
<image-upload v-model="form.image" /> <el-form-item :label="$t('message.description')" prop="categoryName">
</el-form-item> <el-input v-model="form.categoryName" :placeholder="$t('common.pleaseInput')" />
<el-form-item label="视频地址" prop="video"> </el-form-item>
<el-input v-model="form.video" placeholder="请输入视频地址" /> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item :label="$t('message.image')" prop="image">
<image-upload v-model="form.image" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('message.videoUrl')" prop="video">
<el-input v-model="form.video" :placeholder="$t('common.pleaseInput')" />
</el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <el-button :loading="buttonLoading" type="primary" @click="submitForm">{{ $t('common.confirm') }}</el-button>
<el-button :loading="buttonLoading" type="primary" @click="submitForm"> </el-button> <el-button @click="cancel">{{ $t('common.cancel') }}</el-button>
<el-button @click="cancel"> </el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 视频播放弹窗 --> <!-- 视频播放 -->
<el-dialog title="视频播放" v-model="videoDialog.visible" width="720px" append-to-body :close-on-click-modal="false" @close="destroyPlayer"> <el-dialog
v-model="videoDialog.visible"
:title="$t('message.videoPlayback')"
width="720px"
append-to-body
:close-on-click-modal="false"
@close="destroyPlayer"
>
<div ref="videoPlayerRef" class="video-player-wrap"></div> <div ref="videoPlayerRef" class="video-player-wrap"></div>
</el-dialog> </el-dialog>
</div> </div>
@@ -122,13 +155,16 @@
<script setup name="Message" lang="ts"> <script setup name="Message" lang="ts">
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { LanguageEnum } from '@/enums/LanguageEnum';
import { listMessage, getMessage, delMessage, addMessage, updateMessage } from '@/api/app/message'; import { listMessage, getMessage, delMessage, addMessage, updateMessage } from '@/api/app/message';
import { MessageVO, MessageQuery, MessageForm } from '@/api/app/message/types'; import { MessageVO, MessageQuery, MessageForm } from '@/api/app/message/types';
import { VideoPlay } from '@element-plus/icons-vue'; import { VideoPlay, Link } from '@element-plus/icons-vue';
import Player from 'xgplayer'; import Player from 'xgplayer';
import 'xgplayer-flv'; import 'xgplayer-flv';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { t, locale } = useI18n();
/** 视频播放器容器 ref */ /** 视频播放器容器 ref */
const videoPlayerRef = ref<HTMLDivElement | null>(null); const videoPlayerRef = ref<HTMLDivElement | null>(null);
@@ -141,7 +177,12 @@ const videoDialog = reactive<{ visible: boolean; url: string }>({
url: '' url: ''
}); });
/** 点击播放按钮 */ /** 新窗口打开视频 */
const openVideoUrl = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
/** 弹窗播放视频 */
const handlePlayVideo = (url: string) => { const handlePlayVideo = (url: string) => {
videoDialog.url = url; videoDialog.url = url;
videoDialog.visible = true; videoDialog.visible = true;
@@ -162,6 +203,7 @@ const initPlayer = (url: string) => {
playsinline: true, playsinline: true,
height: '100%', height: '100%',
width: '100%', width: '100%',
lang: locale.value === LanguageEnum.en_US ? 'en' : 'zh-cn',
flv: { flv: {
mediaDataSource: { mediaDataSource: {
isLive: true, isLive: true,
@@ -181,21 +223,24 @@ const destroyPlayer = () => {
}; };
/** 告警类型选项 */ /** 告警类型选项 */
const categoryOptions = [ const categoryOptions = computed(() => [
{ value: 1, label: '有人入侵' }, { value: 1, label: t('message.categoryType.intrusion') },
{ value: 2, label: '设备故障' }, { value: 2, label: t('message.categoryType.deviceFault') },
{ value: 3, label: '到期提醒' } { value: 3, label: t('message.categoryType.expiration') }
]; ]);
/** 根据 category 值返回中文标签 */ /** 优先显示后端按请求语言返回的告警类型标签 */
const categoryLabel = (category: number): string => { const categoryLabel = (category: number | string, localizedLabel?: string): string => {
return categoryOptions.find((item) => item.value === category)?.label || category?.toString() || '-'; if (localizedLabel) {
return localizedLabel;
}
return categoryOptions.value.find((item) => item.value == category)?.label || '-';
}; };
/** 根据 category 值返回 el-tag 类型 */ /** 根据 category 值返回 el-tag 类型 */
const categoryTagType = (category: number): string => { const categoryTagType = (category: number | string): 'danger' | 'warning' | 'info' => {
const map: Record<number, string> = { 1: 'danger', 2: 'warning', 3: 'info' }; const map: Record<string, 'danger' | 'warning' | 'info'> = { '1': 'danger', '2': 'warning', '3': 'info' };
return map[category] || 'info'; return map[String(category)] || 'info';
}; };
const messageList = ref<MessageVO[]>([]); const messageList = ref<MessageVO[]>([]);
@@ -233,12 +278,13 @@ const data = reactive<PageData<MessageForm, MessageQuery>>({
video: undefined, video: undefined,
params: {} params: {}
}, },
rules: { rules: {}
id: [{ required: true, message: '不能为空', trigger: 'blur' }]
}
}); });
const { queryParams, form, rules } = toRefs(data); const { queryParams, form } = toRefs(data);
const rules = computed(() => ({
id: [{ required: true, message: t('common.required'), trigger: 'blur' }]
}));
/** 查询告警消息列列表 */ /** 查询告警消息列列表 */
const getList = async () => { const getList = async () => {
@@ -284,7 +330,7 @@ const handleSelectionChange = (selection: MessageVO[]) => {
const handleAdd = () => { const handleAdd = () => {
reset(); reset();
dialog.visible = true; dialog.visible = true;
dialog.title = '添加告警消息列'; dialog.title = t('message.addTitle');
}; };
/** 修改按钮操作 */ /** 修改按钮操作 */
@@ -294,7 +340,7 @@ const handleUpdate = async (row?: MessageVO) => {
const res = await getMessage(_id); const res = await getMessage(_id);
Object.assign(form.value, res.data); Object.assign(form.value, res.data);
dialog.visible = true; dialog.visible = true;
dialog.title = '修改告警消息列'; dialog.title = t('message.editTitle');
}; };
/** 提交按钮 */ /** 提交按钮 */
@@ -307,7 +353,7 @@ const submitForm = () => {
} else { } else {
await addMessage(form.value).finally(() => (buttonLoading.value = false)); await addMessage(form.value).finally(() => (buttonLoading.value = false));
} }
proxy?.$modal.msgSuccess('操作成功'); proxy?.$modal.msgSuccess(t('common.operationSuccess'));
dialog.visible = false; dialog.visible = false;
await getList(); await getList();
} }
@@ -317,9 +363,9 @@ const submitForm = () => {
/** 删除按钮操作 */ /** 删除按钮操作 */
const handleDelete = async (row?: MessageVO) => { const handleDelete = async (row?: MessageVO) => {
const _ids = row?.id || ids.value; const _ids = row?.id || ids.value;
await proxy?.$modal.confirm('是否确认删除告警消息列编号为"' + _ids + '"的数据项?').finally(() => (loading.value = false)); await proxy?.$modal.confirm(t('message.deleteConfirm', { ids: String(_ids) })).finally(() => (loading.value = false));
await delMessage(_ids); await delMessage(_ids);
proxy?.$modal.msgSuccess('删除成功'); proxy?.$modal.msgSuccess(t('common.deleteSuccess'));
await getList(); await getList();
}; };

View File

@@ -193,10 +193,32 @@ import { UserVO, UserQuery, UserForm } from '@/api/app/user/types';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
/** 将逗号分隔的人脸地址拆分为数组 */ /** 解析后端存储的 JSON 数组,并兼容旧的逗号分隔格式 */
const faceUrls = (faceAddr: string): string[] => { const faceUrls = (faceAddr?: string | string[]): string[] => {
if (!faceAddr) return []; const normalizeUrls = (urls: unknown[]): string[] =>
return faceAddr.split(',').map(u => u.trim()).filter(Boolean); urls.filter((url): url is string => typeof url === 'string').map((url) => url.trim()).filter(Boolean);
if (Array.isArray(faceAddr)) {
return normalizeUrls(faceAddr);
}
if (!faceAddr?.trim()) {
return [];
}
const value = faceAddr.trim();
try {
const parsed: unknown = JSON.parse(value);
if (Array.isArray(parsed)) {
return normalizeUrls(parsed);
}
if (typeof parsed === 'string' && parsed.trim()) {
return [parsed.trim()];
}
} catch {
// 兼容历史数据url1,url2
}
return value.split(',').map((url) => url.trim()).filter(Boolean);
}; };
const userList = ref<UserVO[]>([]); const userList = ref<UserVO[]>([]);

View File

@@ -1,14 +1,13 @@
<template> <template>
<div class="app-container home"> <div class="app-container home">
<div class="welcome-wrapper"> <div class="welcome-wrapper">
<h1 class="welcome-title">欢迎使用智能防御机管理系统</h1> <h1 class="welcome-title">{{ $t('home.welcome') }}</h1>
<p class="welcome-desc">智能防御 · 安全守护</p> <p class="welcome-desc">{{ $t('home.slogan') }}</p>
</div> </div>
</div> </div>
</template> </template>
<script setup name="Index" lang="ts"> <script setup name="Index" lang="ts"></script>
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.home { .home {

View File

@@ -1,6 +1,117 @@
<template> <template>
<div class="login"> <div class="login">
<el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form"> <!-- 左侧品牌区新增驭鲲智防 · REINKUN 智能安防动画 -->
<aside class="brand">
<div class="sweep"></div>
<header class="brand-head">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect x="2" y="2" width="36" height="36" rx="10" stroke="rgba(125,211,252,.4)" stroke-width="1.5" fill="rgba(56,189,248,.08)" />
<circle cx="20" cy="20" r="11" stroke="#38bdf8" stroke-width="2" />
<path d="M20 13.5c3 3.8 4.8 6.1 4.8 8.6a4.8 4.8 0 1 1-9.6 0c0-2.5 1.8-4.8 4.8-8.6z" fill="url(#lg)" />
<defs>
<linearGradient id="lg" x1="20" y1="13" x2="20" y2="27" gradientUnits="userSpaceOnUse">
<stop stop-color="#38bdf8" />
<stop offset="1" stop-color="#22d3ee" />
</linearGradient>
</defs>
</svg>
<div class="brand-name">驭鲲智防<small>REINKUN SMART DEFENSE</small></div>
</header>
<!-- 产品场景动画AI 识别 锁定 水枪驱离 -->
<div class="scene">
<div class="live"><i></i>LIVE · 01 号机位</div>
<div class="status">
<span class="s1">AI 巡检中 · 人形检测</span>
<span class="s2">入侵目标已锁定</span>
<span class="s3">水枪驱离 · 喷射中</span>
</div>
<svg viewBox="0 0 520 320">
<defs>
<radialGradient id="lensGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#7dd3fc" />
<stop offset="100%" stop-color="#0ea5e9" />
</radialGradient>
</defs>
<!-- 地面参考线 -->
<line x1="40" y1="270" x2="480" y2="270" stroke="rgba(148,163,184,.18)" stroke-width="1.5" stroke-dasharray="2 8" />
<!-- 雷达波纹 -->
<circle class="ring" cx="220" cy="158" r="34" fill="none" stroke="#38bdf8" stroke-width="1.2" />
<circle class="ring r2" cx="220" cy="158" r="34" fill="none" stroke="#38bdf8" stroke-width="1.2" />
<circle class="ring r3" cx="220" cy="158" r="34" fill="none" stroke="#22d3ee" stroke-width="1" />
<!-- 摄像头吊装 -->
<g transform="translate(150,140) rotate(12)">
<rect x="-4" y="-64" width="8" height="46" rx="3.5" fill="#1e293b" stroke="rgba(148,163,184,.25)" />
<circle cx="0" cy="-14" r="7" fill="#334155" />
<rect x="-8" y="-14" width="88" height="36" rx="11" fill="#1e293b" stroke="rgba(148,163,184,.28)" />
<rect x="10" y="-20" width="34" height="7" rx="3.5" fill="#334155" />
<circle class="led" cx="6" cy="4" r="2.6" fill="#ef4444" />
<rect x="66" y="-10" width="14" height="28" rx="6" fill="#0f172a" />
<circle cx="73" cy="4" r="7.5" fill="#0b1220" stroke="url(#lensGlow)" stroke-width="2" />
<circle cx="73" cy="4" r="2.4" fill="#7dd3fc" />
<!-- 水枪喷头 -->
<rect x="76" y="11" width="10" height="6" rx="2.5" fill="#0ea5e9" />
</g>
<!-- 水柱SMIL 同步到 6s 节奏33%66% 喷射窗口 -->
<g>
<circle r="3.6" fill="#7dd3fc">
<animateMotion dur="6s" repeatCount="indefinite" path="M 232 172 Q 322 118 424 208" keyPoints="0;0;1;1" keyTimes="0;0.34;0.60;1" calcMode="linear" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;1;1;0;0" keyTimes="0;0.32;0.34;0.60;0.63;1" />
</circle>
<circle r="3" fill="#a5f3fc">
<animateMotion dur="6s" repeatCount="indefinite" path="M 232 172 Q 322 118 424 208" keyPoints="0;0;1;1" keyTimes="0;0.37;0.63;1" calcMode="linear" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;1;1;0;0" keyTimes="0;0.35;0.37;0.63;0.66;1" />
</circle>
<circle r="3.4" fill="#67e8f9">
<animateMotion dur="6s" repeatCount="indefinite" path="M 232 172 Q 322 118 424 208" keyPoints="0;0;1;1" keyTimes="0;0.40;0.66;1" calcMode="linear" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;1;1;0;0" keyTimes="0;0.38;0.40;0.66;0.69;1" />
</circle>
<circle r="2.6" fill="#bae6fd">
<animateMotion dur="6s" repeatCount="indefinite" path="M 232 172 Q 322 118 424 208" keyPoints="0;0;1;1" keyTimes="0;0.43;0.69;1" calcMode="linear" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;1;1;0;0" keyTimes="0;0.41;0.43;0.69;0.72;1" />
</circle>
<circle r="3.1" fill="#7dd3fc">
<animateMotion dur="6s" repeatCount="indefinite" path="M 232 172 Q 322 118 424 208" keyPoints="0;0;1;1" keyTimes="0;0.46;0.72;1" calcMode="linear" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;1;1;0;0" keyTimes="0;0.44;0.46;0.72;0.75;1" />
</circle>
<!-- 落点水花 -->
<ellipse cx="424" cy="210" rx="4" ry="2.5" fill="none" stroke="#7dd3fc" stroke-width="1.6">
<animate attributeName="rx" dur="6s" repeatCount="indefinite" values="3;3;16;16" keyTimes="0;0.56;0.75;1" />
<animate attributeName="ry" dur="6s" repeatCount="indefinite" values="2;2;8;8" keyTimes="0;0.56;0.75;1" />
<animate attributeName="opacity" dur="6s" repeatCount="indefinite" values="0;0;.9;0;0" keyTimes="0;0.56;0.6;0.75;1" />
</ellipse>
</g>
<!-- 入侵者剪影 + 检测框 -->
<g class="figure" style="transform-origin: 424px 268px">
<g fill="#3f4f66">
<circle cx="424" cy="206" r="11" />
<path d="M410 220 q14 -9 28 0 l4 34 h-10 l-3 -16 -5 0 -3 16 h-10 z" />
</g>
<g class="alert" fill="#ef4444">
<circle cx="424" cy="206" r="11" />
<path d="M410 220 q14 -9 28 0 l4 34 h-10 l-3 -16 -5 0 -3 16 h-10 z" />
</g>
</g>
<g class="det">
<rect x="398" y="188" width="52" height="74" fill="rgba(248,113,113,.06)" stroke="none" />
<path d="M398 200 v-12 h12 M438 188 h12 v12 M398 250 v12 h12 M438 262 h12 v-12" stroke="#f87171" stroke-width="2.4" fill="none" stroke-linecap="round" />
<rect x="392" y="168" width="64" height="15" rx="4" fill="rgba(248,113,113,.92)" />
<text x="424" y="179" text-anchor="middle" font-size="9.5" fill="#fff">入侵目标 98.6%</text>
</g>
</svg>
</div>
</aside>
<!-- 右侧登录表单原有内容未改动 -->
<div class="login-main">
<el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
<div class="title-box"> <div class="title-box">
<h3 class="title">{{ title }}</h3> <h3 class="title">{{ title }}</h3>
<lang-select /> <lang-select />
@@ -44,23 +155,23 @@
</div> </div>
</el-form-item> </el-form-item>
<el-checkbox v-model="loginForm.rememberMe" style="margin: 0 0 25px 0">{{ proxy.$t('login.rememberPassword') }}</el-checkbox> <el-checkbox v-model="loginForm.rememberMe" style="margin: 0 0 25px 0">{{ proxy.$t('login.rememberPassword') }}</el-checkbox>
<!-- <el-form-item style="float: right">--> <!-- <el-form-item style="float: right">-->
<!-- <el-button circle :title="proxy.$t('login.social.wechat')" @click="doSocialLogin('wechat')">--> <!-- <el-button circle :title="proxy.$t('login.social.wechat')" @click="doSocialLogin('wechat')">-->
<!-- <svg-icon icon-class="wechat" />--> <!-- <svg-icon icon-class="wechat" />-->
<!-- </el-button>--> <!-- </el-button>-->
<!-- <el-button circle :title="proxy.$t('login.social.maxkey')" @click="doSocialLogin('maxkey')">--> <!-- <el-button circle :title="proxy.$t('login.social.maxkey')" @click="doSocialLogin('maxkey')">-->
<!-- <svg-icon icon-class="maxkey" />--> <!-- <svg-icon icon-class="maxkey" />-->
<!-- </el-button>--> <!-- </el-button>-->
<!-- <el-button circle :title="proxy.$t('login.social.topiam')" @click="doSocialLogin('topiam')">--> <!-- <el-button circle :title="proxy.$t('login.social.topiam')" @click="doSocialLogin('topiam')">-->
<!-- <svg-icon icon-class="topiam" />--> <!-- <svg-icon icon-class="topiam" />-->
<!-- </el-button>--> <!-- </el-button>-->
<!-- <el-button circle :title="proxy.$t('login.social.gitee')" @click="doSocialLogin('gitee')">--> <!-- <el-button circle :title="proxy.$t('login.social.gitee')" @click="doSocialLogin('gitee')">-->
<!-- <svg-icon icon-class="gitee" />--> <!-- <svg-icon icon-class="gitee" />-->
<!-- </el-button>--> <!-- </el-button>-->
<!-- <el-button circle :title="proxy.$t('login.social.github')" @click="doSocialLogin('github')">--> <!-- <el-button circle :title="proxy.$t('login.social.github')" @click="doSocialLogin('github')">-->
<!-- <svg-icon icon-class="github" />--> <!-- <svg-icon icon-class="github" />-->
<!-- </el-button>--> <!-- </el-button>-->
<!-- </el-form-item>--> <!-- </el-form-item>-->
<el-form-item style="width: 100%"> <el-form-item style="width: 100%">
<el-button :loading="loading" size="large" type="primary" style="width: 100%" @click.prevent="handleLogin"> <el-button :loading="loading" size="large" type="primary" style="width: 100%" @click.prevent="handleLogin">
<span v-if="!loading">{{ proxy.$t('login.login') }}</span> <span v-if="!loading">{{ proxy.$t('login.login') }}</span>
@@ -70,10 +181,11 @@
<router-link class="link-type" :to="'/register'">{{ proxy.$t('login.switchRegisterPage') }}</router-link> <router-link class="link-type" :to="'/register'">{{ proxy.$t('login.switchRegisterPage') }}</router-link>
</div> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<!-- 底部 --> <!-- 底部仅右侧表单区域内显示避免跨背景看不清 -->
<div class="el-login-footer"> <div class="el-login-footer">
<!-- <span>Copyright © 2018-2026 疯狂的狮子Li All Rights Reserved.</span>--> <span>© 2026 驭鲲智能科技湖北有限公司 · 鄂ICP备2026000000号</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -89,10 +201,10 @@ import { useI18n } from 'vue-i18n';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const title = import.meta.env.VITE_APP_TITLE;
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const title = computed(() => t('app.title'));
const loginForm = ref<LoginData>({ const loginForm = ref<LoginData>({
tenantId: '000000', tenantId: '000000',
@@ -105,12 +217,12 @@ const loginForm = ref<LoginData>({
uuid: '' uuid: ''
} as LoginData); } as LoginData);
const loginRules: ElFormRules = { const loginRules = computed<ElFormRules>(() => ({
tenantId: [{ required: true, trigger: 'blur', message: t('login.rule.tenantId.required') }], tenantId: [{ required: true, trigger: 'blur', message: t('login.rule.tenantId.required') }],
username: [{ required: true, trigger: 'blur', message: t('login.rule.username.required') }], username: [{ required: true, trigger: 'blur', message: t('login.rule.username.required') }],
password: [{ required: true, trigger: 'blur', message: t('login.rule.password.required') }], password: [{ required: true, trigger: 'blur', message: t('login.rule.password.required') }],
code: [{ required: true, trigger: 'change', message: t('login.rule.code.required') }] code: [{ required: true, trigger: 'change', message: t('login.rule.code.required') }]
}; }));
const codeUrl = ref(''); const codeUrl = ref('');
const loading = ref(false); const loading = ref(false);
@@ -236,13 +348,315 @@ onMounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.login { .login {
display: flex;
height: 100%;
background: #ffffff;
}
.login-main {
position: relative;
flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; }
background-image: url('../assets/images/login-background.jpg');
background-size: cover; /* ============ 左侧品牌区 ============ */
background-position: center; .brand {
position: relative;
flex: 0 0 70%;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 44px 48px 32px;
color: #e2e8f0;
background:
radial-gradient(900px 500px at 30% 20%, #10264a 0%, transparent 60%),
linear-gradient(160deg, #0b1b33 0%, #081426 55%, #071122 100%);
}
.brand::before {
/* 网格 */
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(148, 163, 184, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.055) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(75% 70% at 50% 42%, #000 30%, transparent 100%);
}
.sweep {
/* 雷达扫描 */
position: absolute;
left: 50%;
top: 44%;
width: 680px;
height: 680px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: conic-gradient(from 0deg, rgba(34, 211, 238, 0.14), rgba(34, 211, 238, 0.04) 70deg, transparent 90deg);
animation: sweep 9s linear infinite;
pointer-events: none;
}
@keyframes sweep {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.brand-head {
position: relative;
display: flex;
align-items: center;
gap: 12px;
z-index: 2;
}
.brand-name {
font-size: 19px;
font-weight: 700;
letter-spacing: 0.5px;
small {
display: block;
font-size: 10px;
font-weight: 500;
letter-spacing: 2.5px;
color: #7dd3fc;
margin-top: 2px;
}
}
.scene {
position: relative;
z-index: 1;
margin: auto;
width: 100%;
max-width: 640px;
svg {
display: block;
width: 100%;
height: auto;
}
.live {
position: absolute;
top: 6px;
left: 4px;
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
letter-spacing: 1.5px;
color: #fca5a5;
i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ef4444;
animation: blink 1.4s infinite;
}
}
}
@keyframes blink {
50% {
opacity: 0.25;
}
}
.status {
position: absolute;
top: 0;
right: 0;
height: 30px;
min-width: 170px;
span {
position: absolute;
right: 0;
top: 0;
white-space: nowrap;
font-size: 12px;
padding: 5px 12px;
border-radius: 999px;
background: rgba(8, 20, 38, 0.72);
border: 1px solid rgba(125, 211, 252, 0.22);
opacity: 0;
}
.s1 {
color: #7dd3fc;
animation: st1 6s infinite;
}
.s2 {
color: #fca5a5;
border-color: rgba(248, 113, 113, 0.35);
animation: st2 6s infinite;
}
.s3 {
color: #67e8f9;
animation: st3 6s infinite;
}
}
@keyframes st1 {
0%,
22% {
opacity: 1;
}
26%,
100% {
opacity: 0;
}
}
@keyframes st2 {
0%,
24% {
opacity: 0;
}
28%,
44% {
opacity: 1;
}
48%,
100% {
opacity: 0;
}
}
@keyframes st3 {
0%,
46% {
opacity: 0;
}
50%,
74% {
opacity: 1;
}
78%,
100% {
opacity: 0;
}
}
.ring {
transform-box: fill-box;
transform-origin: center;
animation: ring 3s ease-out infinite;
opacity: 0;
&.r2 {
animation-delay: 1s;
}
&.r3 {
animation-delay: 2s;
}
}
@keyframes ring {
0% {
transform: scale(0.35);
opacity: 0.55;
}
80%,
100% {
transform: scale(2.6);
opacity: 0;
}
}
.led {
animation: blink 1.6s infinite;
}
.det {
opacity: 0;
animation: det 6s infinite;
}
@keyframes det {
0%,
22% {
opacity: 0;
}
26%,
76% {
opacity: 1;
}
80%,
100% {
opacity: 0;
}
}
.figure {
animation: recoil 6s infinite;
.alert {
opacity: 0;
animation: alert 6s infinite;
}
}
@keyframes recoil {
0%,
40% {
transform: translate(0, 0) rotate(0deg);
}
44% {
transform: translate(4px, 0) rotate(1.5deg);
}
48% {
transform: translate(-5px, 0) rotate(-2deg);
}
52% {
transform: translate(6px, 0) rotate(2deg);
}
56% {
transform: translate(-4px, 0) rotate(-1.5deg);
}
60% {
transform: translate(5px, 0) rotate(1.5deg);
}
64% {
transform: translate(-2px, 0) rotate(-0.5deg);
}
70%,
100% {
transform: translate(0, 0) rotate(0deg);
}
}
@keyframes alert {
0%,
32% {
opacity: 0;
}
36%,
66% {
opacity: 0.9;
}
72%,
100% {
opacity: 0;
}
}
/* 窄屏隐藏品牌区,表单占满 */
@media (max-width: 900px) {
.brand {
display: none;
}
} }
.title-box { .title-box {
@@ -342,11 +756,12 @@ onMounted(() => {
.el-login-footer { .el-login-footer {
height: 40px; height: 40px;
line-height: 40px; line-height: 40px;
position: fixed; position: absolute;
bottom: 0; bottom: 0;
left: 0;
width: 100%; width: 100%;
text-align: center; text-align: center;
color: rgba(255, 255, 255, 0.75); color: rgba(100, 116, 139, 0.85);
font-family: Arial, serif; font-family: Arial, serif;
font-size: 12px; font-size: 12px;
letter-spacing: 1px; letter-spacing: 1px;
@@ -358,6 +773,10 @@ onMounted(() => {
} }
:global(html.dark) { :global(html.dark) {
.login {
background: #0b1220;
}
.login-form { .login-form {
background: rgba(17, 24, 39, 0.9); background: rgba(17, 24, 39, 0.9);
border-color: rgba(148, 163, 184, 0.2); border-color: rgba(148, 163, 184, 0.2);

View File

@@ -67,7 +67,7 @@
</el-form> </el-form>
<!-- 底部 --> <!-- 底部 -->
<div class="el-register-footer"> <div class="el-register-footer">
<span>Copyright © 2018-2026 疯狂的狮子Li All Rights Reserved.</span> <span>Copyright © 2018-2026. All Rights Reserved.</span>
</div> </div>
</div> </div>
</template> </template>
@@ -80,10 +80,10 @@ import { useI18n } from 'vue-i18n';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const title = import.meta.env.VITE_APP_TITLE;
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const title = computed(() => t('app.title'));
const registerForm = ref<RegisterForm>({ const registerForm = ref<RegisterForm>({
tenantId: '', tenantId: '',
@@ -106,7 +106,7 @@ const equalToPassword = (rule: any, value: string, callback: any) => {
} }
}; };
const registerRules: ElFormRules = { const registerRules = computed<ElFormRules>(() => ({
tenantId: [{ required: true, trigger: 'blur', message: t('register.rule.tenantId.required') }], tenantId: [{ required: true, trigger: 'blur', message: t('register.rule.tenantId.required') }],
username: [ username: [
{ required: true, trigger: 'blur', message: t('register.rule.username.required') }, { required: true, trigger: 'blur', message: t('register.rule.username.required') },
@@ -122,7 +122,7 @@ const registerRules: ElFormRules = {
{ required: true, validator: equalToPassword, trigger: 'blur' } { required: true, validator: equalToPassword, trigger: 'blur' }
], ],
code: [{ required: true, trigger: 'change', message: t('register.rule.code.required') }] code: [{ required: true, trigger: 'change', message: t('register.rule.code.required') }]
}; }));
const codeUrl = ref(''); const codeUrl = ref('');
const loading = ref(false); const loading = ref(false);
const captchaEnabled = ref(true); const captchaEnabled = ref(true);
@@ -137,8 +137,7 @@ const handleRegister = () => {
const [err] = await to(register(registerForm.value)); const [err] = await to(register(registerForm.value));
if (!err) { if (!err) {
const username = registerForm.value.username; const username = registerForm.value.username;
await ElMessageBox.alert('<span style="color: red; ">' + t('register.registerSuccess', { username }) + '</font>', '系统提示', { await ElMessageBox.alert('<span style="color: red; ">' + t('register.registerSuccess', { username }) + '</span>', t('common.systemPrompt'), {
app: undefined,
dangerouslyUseHTMLString: true, dangerouslyUseHTMLString: true,
type: 'success' type: 'success'
}); });

View File

@@ -24,9 +24,14 @@ export default defineConfig(({ mode, command }) => {
open: true, open: true,
proxy: { proxy: {
[env.VITE_APP_BASE_API]: { [env.VITE_APP_BASE_API]: {
target: 'http://localhost:8080', target: 'http://localhost:8001',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.removeHeader('origin');
});
},
rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '') rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
} }
} }