Compare commits

..

5 Commits

123 changed files with 16855 additions and 11898 deletions
+40
View File
@@ -0,0 +1,40 @@
'use strict'
const silenceLegacySassApi = {
sassOptions: {
silenceDeprecations: ['legacy-js-api']
}
}
module.exports = {
scss: {
loader: 'sass-loader',
options: silenceLegacySassApi
},
sass: {
loader: 'sass-loader',
options: {
indentedSyntax: true,
...silenceLegacySassApi
}
},
vueScssLoaders: [
'vue-style-loader',
'css-loader',
{
loader: 'sass-loader',
options: silenceLegacySassApi
}
],
vueSassLoaders: [
'vue-style-loader',
'css-loader',
{
loader: 'sass-loader',
options: {
indentedSyntax: true,
...silenceLegacySassApi
}
}
]
}
+5 -4
View File
@@ -12,6 +12,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const sassLoaders = require('./sass-loader-config')
/**
* List of node_modules to include in webpack bundle
@@ -45,11 +46,11 @@ let rendererConfig = {
},
{
test: /\.scss$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
use: ['vue-style-loader', 'css-loader', 'postcss-loader', sassLoaders.scss]
},
{
test: /\.sass$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader?indentedSyntax']
use: ['vue-style-loader', 'css-loader', 'postcss-loader', sassLoaders.sass]
},
{
test: /\.less$/,
@@ -79,8 +80,8 @@ let rendererConfig = {
options: {
extractCSS: process.env.NODE_ENV === 'production',
loaders: {
sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
scss: 'vue-style-loader!css-loader!sass-loader',
sass: sassLoaders.vueSassLoaders,
scss: sassLoaders.vueScssLoaders,
less: 'vue-style-loader!css-loader!less-loader'
}
}
+5 -4
View File
@@ -12,6 +12,7 @@ const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const sassLoaders = require('./sass-loader-config')
let webConfig = {
devtool: '#cheap-module-eval-source-map',
@@ -33,11 +34,11 @@ let webConfig = {
},
{
test: /\.scss$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
use: ['vue-style-loader', 'css-loader', 'postcss-loader', sassLoaders.scss]
},
{
test: /\.sass$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader?indentedSyntax']
use: ['vue-style-loader', 'css-loader', 'postcss-loader', sassLoaders.sass]
},
{
test: /\.less$/,
@@ -64,8 +65,8 @@ let webConfig = {
options: {
extractCSS: true,
loaders: {
sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
scss: 'vue-style-loader!css-loader!sass-loader',
sass: sassLoaders.vueSassLoaders,
scss: sassLoaders.vueScssLoaders,
less: 'vue-style-loader!css-loader!less-loader'
}
}
+27 -3
View File
@@ -1,11 +1,35 @@
# OS
.DS_Store
thumbs.db
# Dependencies
node_modules/
# Webpack / electron-vue build output
dist/electron/*
dist/web/*
!dist/electron/.gitkeep
!dist/web/.gitkeep
# electron-builder pack output (keep icons for packaging)
build/*
!build/icons
node_modules/
!build/icons/**
# Logs
npm-debug.log
npm-debug.log.*
thumbs.db
!.gitkeep
yarn-error.log
yarn-debug.log*
# Local env (secrets)
.env
.env.local
.env.*.local
# Runtime / deploy assets (not source)
/CardsoonServer
/cardsoonServer
/User Templates
/docs
/.cursor
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.
Binary file not shown.
-13
View File
@@ -1,13 +0,0 @@
[system_setting]
downDevicePath=/dev/sr1
downDeviceType=0
end_time=cWL0ocEfX1NTQW0+hs3SGnKIgYkMeNru
logLevel=1
retryFreq=2
serverPath=/home/test/CDCache
server_name=127.0.0.1
server_port=10010
topDevicePath=/dev/sr0
topDeviceType=0
update_time=iWfJMJHeTnMCacgifeF9oACX0fK8UsnmNBUJgTgF2cs=
-80
View File
@@ -1,80 +0,0 @@
#!/bin/bash
# 路径
HOME=$(cd $(dirname $0);pwd)
# 启动
start(){
cd $HOME
# 将C++程序后台启动,并将启动日志指向springBootThree-1.0.log中
spid=$(pgrep -f "controlServer") #$(ps -ef | grep "[m]controlServer" | grep -v grep | awk '{print $2}')
if [ -z "$spid" ]
then
./controlServer > /dev/null 2>&1 &
# 打印字符串
echo "启动成功"
else
echo Application is already started
fi
}
# 停止
stop(){
# 查询到项目的pid
tpid=$(pgrep -f "controlServer") #$(ps -ef | grep "[m]controlServer" | grep -v grep | awk '{print $2}')
# 判断是否为空
if [ -z "$tpid" ]
then
# 如果为空 则打印这句话
echo Application is already stoped
else
# 如果不为空则执行
echo kill $tpid
# 关闭进程
kill -9 $tpid
echo "程序已关闭"
fi
}
# 查看状态
status(){
# 获取pid的
pid=$(pgrep -f "controlServer") #$(ps -ef | grep "[m]controlServer" | grep -v grep)
if [ -z "$pid" ]
then
echo "程序不存在"
else
echo $pid
fi
}
# 查看日志信息
log(){
tail -f $HOME/CSPServer.log
}
case $1 in
start)
start
;;
stop)
stop
;;
status)
status
;;
log)
log
;;
restart)
$0 stop
sleep 5
$0 start
;;
*)
echo "只能是{start|stop|log|restart}"
;;
esac
exit 0
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
功能:统一相对路径导入 fileSize 工具,修复打包别名解析问题
- 将 '@/utils/fileSize' 全部改为相对路径导入,兼容打包环境
- 修复 store 与页面中别名解析失败导致的模块找不到问题
- 保持逻辑不变,仅调整导入路径,降低打包风险
+3 -1
View File
@@ -1,5 +1,7 @@
module.exports = {
NODE_ENV: '"development"',
BASE_API: '"https://easy-mock.com/mock/5950a2419adc231f356a6636/vue-admin"',
VUE_APP_SOCKET_API: '"ws://127.0.0.1:10010"'
VUE_APP_SOCKET_API: '"ws://127.0.0.1:10010"',
VUE_APP_CONSOLE_V2: '"true"',
VUE_APP_CONSOLE_DESIGN_ONLY: '"true"'
}
+3 -1
View File
@@ -1,5 +1,7 @@
module.exports = {
NODE_ENV: '"production"',
BASE_API: '"https://easy-mock.com/mock/5950a2419adc231f356a6636/vue-admin"',
VUE_APP_SOCKET_API: '"ws://127.0.0.1:10010"'
VUE_APP_SOCKET_API: '"ws://127.0.0.1:10010"',
VUE_APP_CONSOLE_V2: '"false"',
VUE_APP_CONSOLE_DESIGN_ONLY: '"false"'
}
-226
View File
@@ -1,226 +0,0 @@
# 宝塔部署 Web 端说明
## 一、构建前配置
### 1. 配置后端 API 地址
编辑 `config/prod.env.js`,将 `BASE_API` 改为实际的后端接口地址:
```javascript
module.exports = {
NODE_ENV: '"production"',
BASE_API: '"https://your-api-domain.com/api"', // 改为实际后端地址
VUE_APP_SOCKET_API: '"ws://127.0.0.1:10010"' // WebSocket 默认地址(用户可在登录页修改)
}
```
**注意:**
- `BASE_API` 是 HTTP 接口地址(登录、数据请求等)
- `VUE_APP_SOCKET_API` 是 WebSocket 默认地址,用户可在登录页修改,所以这里可以保持默认或设为公网地址
### 2. 执行构建
在项目根目录执行:
```bash
npm run build:web
```
构建完成后,产物在 `dist/web/` 目录下,包含:
- `index.html`(入口文件)
- `web.js`(主 JS 文件,包含所有代码和样式)
- `1.js` ~ `7.js`(代码分割的 chunk 文件)
- `fonts/`(字体文件)
- `imgs/`(图片资源)
**注意**CSS 样式被打包进 `web.js` 中,运行时动态注入,无需单独引用 CSS 文件。
---
## 二、宝塔部署步骤
### 1. 创建网站
1. 登录宝塔面板
2. 点击「网站」→「添加站点」
3. 填写:
- **域名**:例如 `your-domain.com``192.168.1.100`(内网 IP
- **根目录**:例如 `/www/wwwroot/your-domain.com`
- **PHP 版本**:选择「纯静态」或任意版本(前端不需要 PHP)
### 2. 上传文件
`dist/web/` 目录下的**所有文件**上传到网站根目录:
```
/www/wwwroot/your-domain.com/
├── index.html
├── web.js
├── styles.css
└── static/
├── imgs/
└── fonts/
```
**上传方式:**
- 方式一:在宝塔「文件」中直接上传压缩包并解压
- 方式二:使用 FTP/SFTP 工具上传
- 方式三:使用宝塔「终端」执行 `scp``rsync` 命令
### 3. 配置 Nginx(重要)
由于是 Vue Router 的 hash 模式(`#/login``#/manage`),通常不需要特殊配置。但建议添加以下配置以确保正常访问:
在宝塔「网站」→ 选择站点 →「设置」→「配置文件」中,在 `server` 块内添加:
```nginx
server {
listen 80;
server_name your-domain.com;
root /www/wwwroot/your-domain.com;
index index.html;
# 前端路由支持(hash 模式通常不需要,但加上更稳妥)
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}
```
### 4. 配置 HTTPS(可选但推荐)
如果使用域名且需要 HTTPS
1. 在宝塔「网站」→ 选择站点 →「SSL」→「Let's Encrypt」申请免费证书
2. 开启「强制 HTTPS」
3. **注意**:如果后端 API 或 WebSocket 使用 HTTPS/WSS,确保:
- `BASE_API` 使用 `https://`
- WebSocket 地址使用 `wss://`(或在登录页填写 `wss://` 地址)
### 5. 配置跨域(如需要)
如果后端 API 与前端不在同一域名,后端需要配置 CORS:
- 允许的源:`https://your-domain.com`(或 `*` 用于开发)
- 允许的请求头:`Content-Type, X-Token`(根据实际后端要求)
- 允许的方法:`GET, POST, PUT, DELETE`
---
## 三、WebSocket 配置
### 情况 1WebSocket 服务在服务器本机
如果 WebSocket 服务运行在**宝塔服务器本机**(例如 `ws://127.0.0.1:10010`):
- 用户访问网页时,浏览器会尝试连接**用户电脑**的 `127.0.0.1`**无法连接**
- **解决方案**
1. 在登录页填写 `ws://服务器公网IP:10010``ws://服务器域名:10010`
2. 或配置 Nginx 反向代理 WebSocket(见下方)
### 情况 2:使用 Nginx 反向代理 WebSocket(推荐)
在宝塔「网站」→ 选择站点 →「设置」→「配置文件」中添加:
```nginx
# WebSocket 代理(假设后端 WebSocket 在 127.0.0.1:10010
location /ws {
proxy_pass http://127.0.0.1:10010;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
然后在登录页填写:`wss://your-domain.com/ws`HTTPS 用 `wss://`HTTP 用 `ws://`
### 情况 3WebSocket 服务在公网服务器
如果 WebSocket 服务部署在**公网服务器**(例如 `wss://ws-api.xxx.com`):
- 在登录页填写该地址即可
- 或在构建时设置 `VUE_APP_SOCKET_API``wss://ws-api.xxx.com`
---
## 四、访问测试
1. 打开浏览器访问:`http://your-domain.com``https://your-domain.com`
2. 应能看到登录页
3. 点击「设置 WebSocket 服务地址」,填写实际可访问的 WebSocket 地址
4. 输入账号密码登录
5. 检查:
- 登录是否成功
- WebSocket 是否连接成功(查看浏览器控制台 Network → WS
- 各功能页面是否正常加载
---
## 五、常见问题
### 1. 页面空白
- **检查**:浏览器控制台是否有 JS 错误
- **解决**:确认 `index.html` 中引用的 `web.js``styles.css` 路径正确;检查 Nginx 配置是否正确
### 2. 接口请求失败(404 或 CORS
- **检查**`BASE_API` 是否正确;后端是否允许跨域
- **解决**:修改 `config/prod.env.js` 中的 `BASE_API` 后重新构建;后端配置 CORS
### 3. WebSocket 连接失败
- **检查**:浏览器控制台 Network → WS,查看连接地址和错误信息
- **解决**
- 确认 WebSocket 服务已启动
- 在登录页填写正确的地址(本机用 `ws://127.0.0.1:10010`,服务器用 `ws://服务器IP:10010`HTTPS 用 `wss://`
- 如使用 Nginx 代理,确认代理配置正确
### 4. 静态资源 404
- **检查**`static/` 目录是否上传完整
- **解决**:重新上传 `dist/web/static/` 目录
### 5. 路由跳转异常
- **检查**:Nginx 配置中是否有 `try_files $uri $uri/ /index.html;`
- **解决**:添加该配置后重启 Nginx
---
## 六、更新部署
当代码更新后:
1. 修改 `config/prod.env.js`(如需要)
2. 执行 `npm run build:web`
3.`dist/web/` 下的文件**覆盖上传**到宝塔网站根目录
4. 清除浏览器缓存或强制刷新(Ctrl+F5)
---
## 七、总结
- **构建产物**`dist/web/` 目录
- **上传位置**:宝塔网站根目录
- **配置要点**
- `BASE_API`:后端 HTTP 接口地址
- WebSocket:用户可在登录页配置,或通过 Nginx 反向代理
- **访问方式**`http(s)://your-domain.com`
部署完成后,用户访问网站即可使用,首次登录时记得在登录页配置 WebSocket 服务地址。
-82
View File
@@ -1,82 +0,0 @@
# WebSocket 与网页端部署说明
## 问题说明
程序通过 **WebSocket** 与后端服务通信(设备状态、作业列表、系统配置等)。该服务在**桌面端**场景下是 Windows 本机运行的服务(默认 `ws://127.0.0.1:10010`)。
**网页端打包部署后能否连接,取决于“浏览器连的是谁”:**
- 网页运行在**用户浏览器**里,`new WebSocket("ws://127.0.0.1:10010")` 会连接的是**当前用户所在设备**的 127.0.0.1,而不是你放网页的那台服务器。
- 因此:
- **用户和 Windows 服务在同一台电脑**:例如在本机浏览器打开部署在本机的页面,用 `ws://127.0.0.1:10010` 可以连到本机服务,**可以连接**。
- **用户和 Windows 服务不在同一台电脑**:例如网页部署在服务器 A,用户在手机或另一台电脑 B 打开,此时 127.0.0.1 指向的是 B,连不到 A 上的服务,**无法连接**。
所以:**把程序打包放到网页端后,只有在“能访问到 WebSocket 服务”的情况下才可以连接**;默认写死 127.0.0.1 时,只适合“网页和服务在同一台机器”或“用户本机也跑了该服务”的场景。
---
## 已做改造:可配置 WebSocket 地址
为支持不同部署方式,已做两点改动:
### 1. 连接时优先读“用户配置”
- 连接 WebSocket 时**优先使用**本地存储中的 **`WS_SOCKET_API`**localStorage)。
- 若未配置,再使用构建时注入的 **`process.env.VUE_APP_SOCKET_API`**(默认 `ws://127.0.0.1:10010`)。
因此同一套网页包可以:
- 部署在服务器上,由用户在登录页填写“实际要连的 WebSocket 地址”;
- 或由部署方在构建时通过环境变量写死一个公网/内网地址。
### 2. 登录页可填写并保存地址
- 在**登录页**增加 **“设置 WebSocket 服务地址”**(可展开/收起)。
- 用户可输入例如:
- `ws://127.0.0.1:10010`(本机服务)
- `ws://192.168.1.100:10010`(内网某台机器上的服务)
- `wss://your-domain.com/ws`(通过域名 + HTTPS 的 WSS 服务)
- 失焦或保存后写入 **localStorage**,下次进入或刷新后,连接会使用该地址。
这样**网页端打包部署后**,只要用户(或管理员)在登录页把地址改成“实际可访问的 WebSocket 服务地址”,就可以正常连接。
---
## 常见部署方式与是否可连接
| 部署方式 | 网页访问方式 | WebSocket 地址建议 | 能否连接 |
|----------|--------------|--------------------|----------|
| 网页和 Windows 服务都在**同一台电脑** | 本机浏览器打开(如 http://localhost:9081 或 file:// | `ws://127.0.0.1:10010`(默认即可) | 可以 |
| 网页在**服务器**,服务仍在**用户本机** | 用户在本机浏览器打开网站 | 在登录页设为 `ws://127.0.0.1:10010`(本机须已运行该服务) | 可以(仅限本机也跑服务时) |
| 网页在**服务器**,服务在**内网另一台机器** | 用户在内网任意电脑打开网站 | 在登录页设为 `ws://内网机器IP:10010`(或该机域名) | 可以(需网络可达、无策略拦截) |
| 网页在**公网**,服务在**公网服务器** | 任意设备打开网站 | 构建时设 `VUE_APP_SOCKET_API=wss://api.xxx.com/ws`,或在登录页填写该地址 | 可以(需后端提供 wss 与跨域策略) |
---
## 使用步骤(网页端部署后)
1. 打开登录页,点击 **“设置 WebSocket 服务地址”**。
2. 填入实际可访问的地址,例如:
- 本机:`ws://127.0.0.1:10010`
- 内网机器:`ws://192.168.1.100:10010`
- 公网:`wss://your-api.com/ws`
3. 失焦后会自动保存到当前浏览器;之后登录或刷新页面,会使用该地址连接。
4. 若更换环境(换电脑/换网络),在同一浏览器重新打开登录页,修改并保存新地址即可。
---
## 构建时写死地址(可选)
若部署环境固定(例如始终连同一台服务器),可在构建网页时注入默认地址,减少用户手动填写:
- 开发/测试:在 `config/dev.env.js` 中设置 `VUE_APP_SOCKET_API`
- 生产:在 `config/prod.env.js` 或 CI 中设置 `VUE_APP_SOCKET_API`(如 `wss://api.xxx.com/ws`)。
未在登录页保存过地址时,会使用该默认值;保存过后以登录页配置为准。
---
## 总结
- **默认 `ws://127.0.0.1:10010` 只适合“浏览器所在机器 = 运行 WebSocket 服务的机器”的情况。**
- **网页端打包部署后**:通过**登录页可配置 WebSocket 地址**并写入 localStorage,连接时**优先使用该配置**,因此只要用户填写的地址在浏览器侧可达(本机/内网/公网),就可以正常连接。
-154
View File
@@ -1,154 +0,0 @@
# DiscWorker 网页端功能清单与实现情况
本文档列出全部功能,并标明:**无改动**(桌面/网页一致)、**有改动**(为兼容网页做了适配)、**网页端无法实现**(仅桌面端或需后端扩展)。
---
## 一、全局与基础设施
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 路由(/、/login、/manage、/set、/user、/404 | 无改动 | 使用 Vue Router,hash 模式,网页端可直接部署 |
| 登录鉴权(permission.js | 无改动 | 基于 token 与 store,与运行环境无关 |
| 路由守卫(白名单、未登录跳转登录) | 无改动 | 纯前端逻辑 |
| HTTP 请求(request.js、BASE_API | 有改动 | 网页构建通过 DefinePlugin 注入 BASE_API,需配置后端地址 |
| 多语言(vue-i18n | 无改动 | 纯前端 |
| Element UI、主题与尺寸 | 无改动 | 纯前端 |
| Vuex Storeuser、chat、app 等) | 有改动 | store 仅桌面端条件引入 vuex-electron,网页端不引入 |
---
## 二、登录页(/login
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 用户名/密码输入、记住账号 | 无改动 | 纯表单 + localStorage |
| 登录请求(/user/login | 无改动 | 走 axios + BASE_API,网页端需后端可用 |
| 新手引导(第一步) | 无改动 | 纯前端弹窗与步骤 |
| **WebSocket 服务地址设置** | **有改动** | 登录页可展开「设置 WebSocket 服务地址」,填写后写入 localStoragekey: `WS_SOCKET_API`),连接时优先使用该地址,便于网页端部署后连接本机/内网/公网服务。详见 [WEBSOCKET_DEPLOY.md](./WEBSOCKET_DEPLOY.md) |
---
## 三、首页 / 看板(/
### 3.1 顶部与状态
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 回到主界面、系统设置入口 | 无改动 | 路由跳转 |
| 服务启停开关(打开/关闭服务) | **网页端无法实现** | 依赖桌面端执行 `control.sh`,网页端显示「网页端」占位与 tooltip「仅桌面端支持启停服务」 |
| 完成刻录数/废弃刻录数/错误数 | 无改动 | 来自 WebSocket 的 task_list,与端无关 |
| 试用版天数、前往激活 | 有改动 | 「前往激活」仅桌面端显示(依赖 runCmd 执行 regist.sh);网页端不显示该按钮 |
| 用户信息组件 | 无改动 | 展示 store 中的用户信息 |
### 3.2 设备与作业
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 设备状态展示(图示、状态文案) | 无改动 | 数据来自 WebSocket printer_info |
| 运行选项(重启设备、清除错误、设置光盘桶、刷新光盘桶等) | 无改动 | 通过 WebSocket 下发,网页端在已连接服务时可用 |
| 作业列表(表格、重试/取消) | 无改动 | WebSocket task_list + websocketsend |
| 新建作业、打开作业 | 有改动 | **打开作业**:桌面端用系统对话框选 .dwk 后读文件;网页端用 `<input type="file">` 选文件并 FileReader 读内容,再解析填入 workAdd,**可正常使用**。新建作业为打开弹窗,无改动 |
| 保存作业(workAdd 内) | 有改动 | 桌面端:系统保存对话框 + 写入本地路径;网页端:无保存对话框,直接触发浏览器下载 .dwk 文件,**可正常使用** |
| 动态/日志(最新日志、全部日志) | 无改动 | 数据来自 WebSocket log,与端无关 |
### 3.3 帮助与其它
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 帮助(打开帮助文档) | 有改动 | 桌面端:IPC 打开本机 PDF;网页端:`window.open(HELP_PDF_URL)`,需部署或配置帮助 PDF 地址 |
| 关于我们弹窗 | 无改动 | 纯前端 |
---
## 四、作业弹窗(WorkAdd 组件)
### 4.1 作业配置
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 任务名称、CD 类型、刻录类型、标签、UDF、关联任务等表单 | 无改动 | 纯表单与校验 |
| 模板下拉(从 User Templates 加载) | 有改动 | 桌面端:读本地「User Templates」目录;网页端:readdir 不可用,模板列表为空,**网页端无法使用本地模板列表**;若需网页端模板,需后端提供列表接口 |
| 选择模板(加载 .soon 文件) | 有改动 | 仅桌面端可用(依赖本地路径 + readFile);网页端无模板列表,该入口不适用 |
| 「···」选择作业文件(.soon) | 有改动 | 桌面端:系统对话框选文件;网页端:可用平台 showOpenFileDialoginput file),**单文件选择可用**;多选仍仅桌面端 |
| 新建标签(打开 SoonDesign | **网页端无法实现** | 依赖桌面端 exec('soondesign'),网页端按钮改为灰色 + tooltip「仅桌面端支持」 |
### 4.2 文件与路径
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 添加文件(单/多选) | 有改动 | 桌面端:系统对话框 + 本地路径 + stat;网页端:input file 单文件,以「web://」虚拟路径加入列表,**可添加单文件** |
| 添加文件夹 | **网页端无法实现** | 依赖系统选择目录,网页端 showOpenDirectoryDialog 拒绝,提示「仅桌面端支持」 |
| 拖放添加文件/文件夹 | 有改动 | 桌面端:支持路径与 calcSize;网页端:仅支持 File 对象(无 path),用 name 与 size,不计算目录大小,**可拖放文件** |
| 选择路径(归档路径) | **网页端无法实现** | 依赖系统选择目录,网页端不支持 |
| 保存作业为 .dwk | 见「三、首页」保存作业 | 网页端为下载 |
### 4.3 提交与打包
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 提交任务(刻录/打印) | 有改动 | 桌面端:提交本地路径 + mtime 等,由本机服务访问本地文件;网页端:当前提交的为「web://」虚拟路径,**后端无法按路径读文件,故网页端提交任务需后端提供文件上传接口**,否则仅桌面端可完整使用 |
| 复制文件到某路径(file-back 复制) | **网页端无法实现** | 依赖 fs.cp,仅桌面端;网页端点击会提示「仅桌面端支持」 |
| 压缩为 zip/加密 zipfile-back | **网页端无法实现** | 依赖 fs stream + archiver,仅桌面端;网页端会提示「仅桌面端支持」 |
---
## 五、系统管理(/manage
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 设置光驱(手动设置光驱、设置光盘桶类型) | 无改动 | 全部通过 WebSocket 与后端/设备通信,网页端在连接服务后**可正常使用** |
| 校准设备、维护设备 | 无改动 | WebSocket 下发指令,网页端可用 |
| 刷新时间、执行 | 无改动 | WebSocket,网页端可用 |
| 清除任务(服务重启时是否删除已完成/已取消任务) | 无改动 | WebSocket/配置,网页端可用 |
| 新手引导开关 | 无改动 | 本地存储,网页端可用 |
| 光盘类型列表(getCurrentOSDiscTypes | 有改动 | fileSize 在网页端返回固定 1024 与 web 类型,manage 仅读配置列表,**网页端可正常使用** |
---
## 六、系统配置(/set
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| LogLevel、AutoRetryTimes、PrintQuality、CacheDir、SkipSr0Check 等 | 无改动 | 通过 WebSocket 获取/提交系统配置,网页端**可正常使用** |
| 保存 | 无改动 | websocketsend,网页端可用 |
---
## 七、用户管理(/user
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 用户列表展示(当前为静态示例数据) | 无改动 | 纯前端表格,若后续接真实接口则与端无关 |
---
## 八、其它
| 功能 | 实现情况 | 说明 |
|------|----------|------|
| 404 页 | 无改动 | 纯静态 |
| TabBar 导航 | 无改动 | 路由跳转 |
| WebSocket 连接(chat 模块) | 有改动 | 网页端通过 VUE_APP_SOCKET_API 连接,需后端或本机服务提供 WebSocket;**网页端需能访问该地址**(同源或 CORS/代理) |
| 文件大小显示(filterSize、fileSize | 有改动 | fileSize 在网页端 getOS 返回 'web'、getBaseSize 固定 1024**显示正常** |
| 右键菜单(show-context-menu | 有改动 | 桌面端 IPC;网页端 no-op,无影响 |
---
## 九、总结表
| 类型 | 数量 | 说明 |
|------|------|------|
| **无改动** | 绝大多数 | 登录、配置、设备指令、作业列表、重试/取消、日志、系统管理、系统配置等均与运行环境无关或仅依赖 WebSocket/HTTP |
| **有改动且网页端可用** | 若干 | 打开作业(选文件 + 读内容)、保存作业(下载)、帮助(新标签打开 PDF)、添加单文件、拖放文件、服务开关/激活 UI 占位与提示 |
| **网页端无法实现** | 少量 | 服务启停(执行 control.sh)、前往激活(执行 regist.sh)、新建标签(打开 SoonDesign)、选择目录、复制到本地路径、本地 zip 打包;提交任务在无上传接口时仅桌面端完整可用 |
---
## 十、网页端使用前提
1. **后端与 WebSocket**:配置 `BASE_API``VUE_APP_SOCKET_API`,确保登录接口与 WebSocket 可访问(同源或代理/CORS)。
2. **帮助文档**:如需帮助入口,部署帮助 PDF 并配置 `HELP_PDF_URL`(或使用默认 `/help/User Manual.pdf`)。
3. **提交任务**:若要在网页端完整使用「提交刻录/打印任务」,需后端提供文件上传接口,前端改为先上传再提交任务参数;当前实现仍为提交路径,仅桌面端可被本机服务访问。
按当前实现,网页端可正常完成:登录、看板查看、设备状态与指令、作业列表与重试/取消、打开/保存作业(选文件与下载)、系统管理、系统配置、日志查看等;仅上述「网页端无法实现」项在网页端为禁用或提示。
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "discworker",
"version": "0.0.1",
"version": "1.1.1.10",
"author": "24kycj <1637269896@qq.com>",
"description": "KaShu SoonWorkerDisk",
"homepage": "https://www.cardsoon.com/index.php/cn",
@@ -15,6 +15,7 @@
"dev:web": "cross-env NODE_ENV=development node node_modules/webpack-dev-server/bin/webpack-dev-server.js --config .electron-vue/webpack.web.config.js --open",
"electron:linux": "vue-cli-service electron:build -l --x64",
"electron:arm": "vue-cli-service electron:build -l --arm64",
"build:deb:arm64": "bash scripts/build-deb-arm64.sh",
"dev": "node .electron-vue/dev-runner.js",
"lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter src",
"lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix src",
@@ -24,20 +25,20 @@
"postinstall": "npm run lint:fix"
},
"build": {
"productName": "discworker",
"productName": "DiscWorker",
"appId": "com.kashu.soonworkerdisk",
"extraFiles": [
{
"from": "./User Templates",
"to": "."
"to": "User Templates"
},
{
"from": "./cardsoonServer",
"to": "."
"from": "./CardsoonServer",
"to": "CardsoonServer"
},
{
"from": "./help",
"to": "."
"to": "help"
}
],
"directories": {
@@ -68,11 +69,13 @@
"icon": "build/icons/icon.ico"
},
"linux": {
"icon": "build/icons"
"icon": "build/icons",
"executableName": "DiscWorker"
}
},
"dependencies": {
"@electron/remote": "^2.1.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"archiver": "^7.0.1",
"archiver-zip-encrypted": "^2.0.0",
"axios": "^0.18.0",
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# 构建 Linux arm64 deb,并保证安装路径与 productName/executableName=DiscWorker 一致
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
EXE="DiscWorker"
SANITIZED="DiscWorker"
OPT_DIR="/opt/${SANITIZED}"
BIN_LINK="/usr/bin/${EXE}"
echo "[build-deb-arm64] executable: ${EXE}"
echo "[build-deb-arm64] opt dir: ${OPT_DIR}"
node .electron-vue/build.js
npx electron-builder --linux deb --arm64
shopt -s nullglob
mapfile -t DEBS < <(ls -t build/*.deb 2>/dev/null || true)
if [[ ${#DEBS[@]} -eq 0 ]]; then
echo "[build-deb-arm64] 未找到 build/*.deb" >&2
exit 1
fi
DEB="${DEBS[0]}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "[build-deb-arm64] patching: $DEB"
dpkg-deb -R "$DEB" "$WORK/pkg"
CONTROL_DIR="$WORK/pkg/DEBIAN"
mkdir -p "$CONTROL_DIR"
# 若 builder 已生成钩子,把旧小写路径替换为 DiscWorker;否则写入默认钩子
patch_or_write() {
local file="$1"
local kind="$2"
if [[ -f "$file" ]]; then
sed -i \
-e "s|/opt/discworker|/opt/${SANITIZED}|g" \
-e "s|/usr/bin/discworker|/usr/bin/${EXE}|g" \
-e "s|discworker/chrome-sandbox|${SANITIZED}/chrome-sandbox|g" \
-e "s|/opt/discworker/discworker|${OPT_DIR}/${EXE}|g" \
"$file"
chmod 755 "$file"
echo "[build-deb-arm64] patched existing ${kind}"
return
fi
if [[ "$kind" == "postinst" ]]; then
cat > "$file" <<EOF
#!/bin/bash
set -e
if [ -f ${OPT_DIR}/chrome-sandbox ]; then
chown root:root ${OPT_DIR}/chrome-sandbox
chmod 4755 ${OPT_DIR}/chrome-sandbox
fi
if [ -x ${OPT_DIR}/${EXE} ]; then
ln -sfn ${OPT_DIR}/${EXE} ${BIN_LINK}
fi
exit 0
EOF
else
cat > "$file" <<EOF
#!/bin/bash
set -e
if [ "\$1" = "remove" ] || [ "\$1" = "purge" ]; then
rm -f ${BIN_LINK}
fi
exit 0
EOF
fi
chmod 755 "$file"
echo "[build-deb-arm64] wrote default ${kind}"
}
patch_or_write "$CONTROL_DIR/postinst" "postinst"
patch_or_write "$CONTROL_DIR/postrm" "postrm"
OUT="build/${SANITIZED}-arm64-patched.deb"
dpkg-deb -b "$WORK/pkg" "$OUT"
echo "[build-deb-arm64] done: $OUT"
+2 -1
View File
@@ -2,7 +2,8 @@
<html>
<head>
<meta charset="utf-8">
<title>DiscWorker V1.01</title>
<title>DiscWorker</title>
<link rel="icon" href="<%= process.env.NODE_ENV === 'development' ? '/static/favicon.ico' : './static/favicon.ico' %>" type="image/x-icon">
<% if (typeof isWeb === 'undefined' ? htmlWebpackPlugin.options.nodeModules : !isWeb && htmlWebpackPlugin.options.nodeModules) { %>
<!-- Add `node_modules/` to global paths so `require` works properly in development (Electron only) -->
<script>
+18 -3
View File
@@ -23,6 +23,13 @@ const root = process.env.NODE_ENV === "development"
? path.resolve(__dirname, '../../')
: path.dirname(app.getPath("exe"))
function getAppIcon() {
if (process.env.NODE_ENV === 'development') {
return path.join(root, 'src/renderer/assets/images/logo.png')
}
return path.join(__dirname, 'static/icon.png')
}
// 日志函数
function writeLog(message) {
console.log(`[${new Date().toISOString()}] ${message}`)
@@ -38,6 +45,7 @@ function createWindow () {
useContentSize: true,
show: false,
fullscreen: false,
icon: getAppIcon(),
webPreferences: {
webSecurity: false,
nodeIntegration: true,
@@ -46,8 +54,10 @@ function createWindow () {
})
require("@electron/remote/main").initialize();
require("@electron/remote/main").enable(mainWindow.webContents);
// 禁用默认菜单
// Menu.setApplicationMenu(null);
// dev 保留 Electron 默认菜单;打包后隐藏
if (process.env.NODE_ENV !== 'development') {
Menu.setApplicationMenu(null)
}
// mainWindow.maximize()
mainWindow.show()
mainWindow.loadURL(winURL)
@@ -56,7 +66,12 @@ function createWindow () {
})
}
app.on('ready', createWindow)
app.on('ready', () => {
if (process.platform === 'darwin' && app.dock) {
app.dock.setIcon(getAppIcon())
}
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 425 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 317 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,781 @@
<template>
<dw-card class="device-panel" flex-body :title="''">
<template #header>
<div class="device-panel__head">
<console-icon name="device" :size="16" />
<span>设备状态</span>
</div>
<dw-status-pill :variant="tagVariant" :label="tagLabel" />
</template>
<div v-if="loading" class="device-panel__skeleton">
<div class="dw-skeleton device-panel__sk-preview" />
<div class="dw-skeleton" style="height:88px;margin-bottom:12px" />
<div class="dw-skeleton" style="height:96px" />
</div>
<template v-else>
<div class="device-panel__preview" :class="{ 'device-panel__preview--offline': !connected }">
<span class="device-panel__online-dot" :class="{ 'device-panel__online-dot--off': !online }" />
<img class="device-panel__img" :src="deviceImg" alt="device" />
<div class="device-panel__name">{{ deviceTitle }}</div>
<div class="device-panel__sn">序列号: {{ serialLabel }}</div>
<div v-if="!connected" class="device-panel__offline-tip">
<console-icon name="wifi" :size="14" />
请确认服务已启动并已联机
<button type="button" class="device-panel__refresh" @click="$emit('refresh')">刷新</button>
</div>
</div>
<div class="device-panel__section">
<div class="device-panel__section-title">
<console-icon name="ink" :size="14" />
墨水余量
</div>
<ink-levels
v-if="connected && hasInkData"
:blue="printerInfo.blue_last"
:red="printerInfo.red_last"
:yellow="printerInfo.yellow_last"
/>
<div v-else class="device-panel__placeholder">联机后显示墨水余量</div>
</div>
<div class="device-panel__section">
<div class="device-panel__section-title">
<console-icon name="bin" :size="14" />
盘仓监控
</div>
<disc-bins v-if="discBins.length" :bins="discBins" />
<div v-else class="device-panel__placeholder">联机后显示盘仓容量</div>
<div v-if="showSureBar && changeCD.req_info" class="device-panel__cd-types">
<div
v-for="(item, index) in changeCD.req_info.strong_list"
:key="index"
class="device-panel__cd-type-row"
>
<span>盘仓 {{ item.strong_pos }}</span>
<el-select
size="mini"
:value="item.strong_type"
@change="(v) => $emit('command-cd', v, item.strong_pos)"
>
<el-option
v-for="opt in cdTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
</div>
<div v-if="showSureBar" class="device-panel__sure">
<span>{{ sureText }}</span>
<div>
<button type="button" class="dw-btn dw-btn--ghost" @click="$emit('cancel-cd')">取消</button>
<button type="button" class="dw-btn dw-btn--primary" @click="$emit('confirm-cd')">确定</button>
</div>
</div>
<details v-if="connected" class="device-panel__details">
<summary>设备详情</summary>
<div class="device-panel__meta">
<div>主机名{{ printerInfo.system_name || '—' }}</div>
<div>主机IP{{ printerInfo.system_ip || '—' }}</div>
<div>缓存{{ cacheText }}</div>
<div>状态{{ runText }}</div>
</div>
</details>
</template>
<template #footer>
<button type="button" class="dw-btn dw-btn--primary device-panel__cta" @click="$emit('new-work')">
<console-icon name="plus" :size="16" />
新建刻录作业
</button>
<el-dropdown trigger="click" @command="(cmd) => $emit('maintain', cmd)">
<button type="button" class="dw-btn dw-btn--outline device-panel__more">
<console-icon name="settings" :size="14" />
更多维护
</button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="item in runList" :key="item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</dw-card>
</template>
<script>
import DwCard from '../ui/DwCard.vue'
import DwStatusPill from '../ui/DwStatusPill.vue'
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import InkLevels from './InkLevels.vue'
import DiscBins from './DiscBins.vue'
import { getDeviceImages, resolveDeviceImageKey } from '@/utils/console/constants'
export default {
name: 'DevicePanel',
components: { DwCard, DwStatusPill, ConsoleIcon, InkLevels, DiscBins },
props: {
printerInfo: { type: Object, default: () => ({}) },
runText: { type: String, default: '' },
tagVariant: { type: String, default: 'idle' },
tagLabel: { type: String, default: '空闲' },
online: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
runList: { type: Array, default: () => [] },
currentRunVal: { type: [Number, String], default: 0 },
sureText: { type: String, default: '' },
changeCD: { type: Object, default: () => ({}) },
cdTypeOptions: { type: Array, default: () => [] },
CDTypes: { type: Object, default: () => ({}) }
},
computed: {
connected() {
return !!(this.printerInfo && this.printerInfo.printer_name)
},
hasInkData() {
const p = this.printerInfo
return p.blue_last != null || p.red_last != null || p.yellow_last != null
},
deviceImages() {
return getDeviceImages()
},
deviceImg() {
const key = resolveDeviceImageKey(this.printerInfo.printer_name)
const imgs = this.deviceImages[key]
if (!imgs) return ''
return this.connected && this.printerInfo.cover_flag ? imgs.img2 : imgs.img1
},
deviceTitle() {
const p = this.printerInfo.printer_path
if (!this.connected) return 'TF-Publish 4200 系列'
const n = this.printerInfo.printer_name || ''
if (n === '4200' || n === 'DP-4200') {
return `TF-Publish 4200${p ? ` (${p})` : ''}`
}
return `国产光盘刻录一体机 ${n}${p ? ` (${p})` : ''}`
},
serialLabel() {
return (this.connected && this.printerInfo.serial_no) || '—'
},
cacheText() {
const free = this.printerInfo.free_cache_space
const total = this.printerInfo.total_cache_space
if (!total) return '—'
return `${this.$filterSize(free)} / ${this.$filterSize(total)}`
},
showSureBar() {
return parseInt(this.currentRunVal) === 6
},
discBins() {
if (!this.connected) return []
const info = this.printerInfo
const key = resolveDeviceImageKey(info.printer_name)
const limits = this.deviceImages[key] || this.deviceImages['4200']
const sl = info.strongList || this.buildStrongList(info.strong_list)
const build = (pos, labelKey) => {
const row = sl[pos]
if (!row) return null
const count = row.cd_num || 0
const percent = Math.min(100, (count / 50) * 100)
let levelClass = ''
if (count < limits.num2) levelClass = 'disc-bins__stack--warn'
if (count < limits.num1) levelClass = 'disc-bins__stack--danger'
const typeLabel = this.CDTypes[row.cd_type] || '光盘'
return { key: labelKey, label: typeLabel, count, percent, levelClass }
}
return [build(1, 'l'), build(2, 'r')].filter(Boolean)
}
},
methods: {
buildStrongList(list) {
if (!list || !list.length) return {}
const out = {}
list.forEach((item) => {
out[item.strong_pos] = item
})
return out
}
}
}
</script>
<style lang="scss" scoped>
.device-panel {
height: 100%;
box-shadow: var(--dw-shadow-card);
&__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--dw-text);
}
&__sk-preview {
height: 160px;
margin-bottom: 12px;
border-radius: var(--dw-radius-md);
}
&__preview {
position: relative;
text-align: center;
padding: 18px 16px 14px;
margin-bottom: 14px;
background: var(--dw-gradient-device);
border-radius: var(--dw-radius-md);
border: 1px solid rgba(30, 79, 217, 0.08);
&--offline {
.device-panel__img {
opacity: 0.72;
filter: grayscale(0.25);
}
}
}
&__online-dot {
position: absolute;
top: 12px;
right: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dw-success);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.15);
animation: dw-pulse 2s infinite;
&--off {
background: var(--dw-text-muted);
box-shadow: none;
animation: none;
}
}
&__img {
max-width: 168px;
max-height: 120px;
object-fit: contain;
display: block;
margin: 0 auto;
transition: opacity 0.2s ease;
}
&__name {
font-size: 14px;
font-weight: 600;
margin-top: 10px;
line-height: 1.35;
}
&__sn {
font-size: 12px;
color: var(--dw-text-secondary);
margin-top: 4px;
}
&__offline-tip {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin-top: 10px;
padding: 4px 10px;
font-size: 12px;
color: var(--dw-warning);
background: var(--dw-warning-soft);
border-radius: 999px;
}
&__refresh {
border: none;
background: none;
color: var(--dw-primary);
font-size: 12px;
cursor: pointer;
padding: 0 2px;
text-decoration: underline;
}
&__section {
margin-bottom: 14px;
}
&__section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--dw-text);
}
&__placeholder {
padding: 14px 12px;
font-size: 12px;
color: var(--dw-text-muted);
text-align: center;
background: #f8fafc;
border: 1px dashed var(--dw-border);
border-radius: var(--dw-radius-sm);
}
&__cd-types {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
&__cd-type-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--dw-text-secondary);
}
&__sure {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: var(--dw-primary-soft);
border-radius: var(--dw-radius-sm);
font-size: 13px;
margin-bottom: 10px;
gap: 8px;
}
&__details {
font-size: 12px;
color: var(--dw-text-secondary);
margin-bottom: 8px;
summary {
cursor: pointer;
color: var(--dw-primary);
margin-bottom: 6px;
}
}
&__meta div {
line-height: 1.8;
}
&__cta {
width: 100%;
justify-content: center;
margin-bottom: 8px;
padding: 10px 14px;
font-size: 14px;
}
&__more {
width: 100%;
justify-content: center;
}
}
</style>
@@ -0,0 +1,185 @@
<template>
<div class="disc-bins">
<div v-for="bin in bins" :key="bin.key" class="disc-bins__item">
<div class="disc-bins__box">
<div
class="disc-bins__stack"
:style="{ height: bin.percent + '%' }"
:class="bin.levelClass"
/>
</div>
<div class="disc-bins__label">{{ bin.label }}</div>
<div class="disc-bins__count">{{ bin.count }} </div>
</div>
</div>
</template>
<script>
export default {
name: 'DiscBins',
props: {
bins: { type: Array, default: () => [] }
}
}
</script>
<style lang="scss" scoped>
.disc-bins {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
&__item {
text-align: center;
}
&__box {
display: flex;
align-items: flex-end;
justify-content: center;
height: 92px;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid var(--dw-border);
border-radius: var(--dw-radius-md);
padding: 10px 14px 8px;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.04);
}
&__stack {
width: 56%;
background: var(--dw-gradient-disc);
border-radius: 6px 6px 4px 4px;
min-height: 6px;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
transition: height 0.35s ease;
position: relative;
&::after {
content: '';
position: absolute;
top: -3px;
left: 8%;
right: 8%;
height: 5px;
background: #94a3b8;
border-radius: 50%;
}
&--warn {
background: linear-gradient(180deg, #fde68a 0%, #f59e0b 100%);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.25);
}
&--danger {
background: linear-gradient(180deg, #fecaca 0%, #ef4444 100%);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25);
}
}
&__label {
font-size: 12px;
font-weight: 600;
color: var(--dw-text);
margin-top: 8px;
}
&__count {
font-size: 11px;
color: var(--dw-text-secondary);
margin-top: 2px;
}
}
</style>
@@ -0,0 +1,141 @@
<template>
<div class="ink-levels">
<div v-for="item in items" :key="item.key" class="ink-levels__row">
<div class="ink-levels__label">
<span class="ink-levels__swatch" :style="{ background: item.color }" />
<span>{{ item.label }}</span>
<span class="ink-levels__pct">{{ item.value }}%</span>
</div>
<dw-progress-bar :value="item.value" :color="item.color" />
</div>
</div>
</template>
<script>
import DwProgressBar from '../ui/DwProgressBar.vue'
export default {
name: 'InkLevels',
components: { DwProgressBar },
props: {
blue: { type: Number, default: 0 },
red: { type: Number, default: 0 },
yellow: { type: Number, default: 0 }
},
computed: {
items() {
return [
{ key: 'c', label: 'Cyan (青)', value: parseInt(this.blue) || 0, color: '#06b6d4' },
{ key: 'm', label: 'Magenta (品红)', value: parseInt(this.red) || 0, color: '#ec4899' },
{ key: 'y', label: 'Yellow (黄)', value: parseInt(this.yellow) || 0, color: '#eab308' }
]
}
}
}
</script>
<style lang="scss" scoped>
.ink-levels {
display: flex;
flex-direction: column;
gap: 14px;
&__row {
width: 100%;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--dw-text-secondary);
margin-bottom: 6px;
}
&__swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 1px 2px rgba(0, 0, 0, 0.12);
}
&__pct {
margin-left: auto;
font-weight: 600;
color: var(--dw-text);
}
}
</style>
@@ -0,0 +1,387 @@
<template>
<div class="drive-card" :class="{ 'drive-card--burning': burning }">
<div class="drive-card__icon" :class="{ 'drive-card__icon--spin': burning }">
<console-icon v-if="burning" name="spinner" :size="22" spin />
<console-icon v-else name="disc" :size="22" />
</div>
<div class="drive-card__body">
<div class="drive-card__top">
<div class="drive-card__title">{{ title }}</div>
<dw-status-pill :variant="statusVariant" :label="statusLabel" />
</div>
<div v-if="discTypeLabel" class="drive-card__type">{{ discTypeLabel }}</div>
<div v-if="burning" class="drive-card__progress">
<dw-progress-bar :value="progress" color="#059669" />
<div class="drive-card__meta">
已刻录 {{ usedSize }} / {{ totalSize }}
<span v-if="speed"> · 速度 {{ speed }}X</span>
</div>
</div>
<div v-if="taskId" class="drive-card__task">
<span class="drive-card__task-label">任务</span>
{{ taskId }}
</div>
<div v-if="sim" class="drive-card__sim">模拟刻录</div>
</div>
</div>
</template>
<script>
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import DwStatusPill from '../ui/DwStatusPill.vue'
import DwProgressBar from '../ui/DwProgressBar.vue'
export default {
name: 'DriveCard',
components: { ConsoleIcon, DwStatusPill, DwProgressBar },
props: {
drive: { type: Object, default: () => ({}) },
CDStatus: { type: Object, default: () => ({}) },
CDName: { type: Object, default: () => ({}) },
CDTypes: { type: Object, default: () => ({}) },
diskTypes: { type: Object, default: () => ({}) }
},
computed: {
title() {
const path = this.drive.dev_path || '光驱'
const pos = this.CDName[this.drive.cd_pos] || this.fallbackPosLabel
return `${path} (${pos})`
},
fallbackPosLabel() {
return this.drive.cd_pos === 1 ? '下光驱' : this.drive.cd_pos === 2 ? '上光驱' : '光驱'
},
burning() {
return this.drive.copy_scheduler > 0
},
progress() {
return parseFloat(this.drive.copy_scheduler) || 0
},
statusLabel() {
if (this.burning) return `刻录中 ${this.progress}%`
return this.CDStatus[this.drive.cd_status] || '未知'
},
statusVariant() {
if (this.burning) return 'busy'
if (this.drive.cd_status === 1) return 'neutral'
if (this.drive.cd_status === 4) return 'online'
return 'idle'
},
discTypeLabel() {
if (!this.drive.cd_type) return ''
return this.CDTypes[this.drive.cd_type] || this.diskTypes[this.drive.cd_type] || ''
},
usedSize() {
return this.$filterSize(this.drive.use_size || 0)
},
totalSize() {
return this.$filterSize(this.drive.total_size || 0)
},
speed() {
return this.drive.burn_speed ? this.drive.burn_speed.toFixed(2) : ''
},
taskId() {
return this.drive.cd_taskid && this.burning ? this.drive.cd_taskid : ''
},
sim() {
return this.drive.copy_scheduler && this.drive.is_sim
}
}
}
</script>
<style lang="scss" scoped>
.drive-card {
display: flex;
gap: 14px;
padding: 16px;
background: #fff;
border: 1px solid var(--dw-border);
border-radius: var(--dw-radius-md);
box-shadow: var(--dw-shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
box-shadow: var(--dw-shadow-md);
border-color: rgba(30, 79, 217, 0.15);
}
&--burning {
border-color: rgba(5, 150, 105, 0.25);
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
}
&__icon {
width: 52px;
height: 52px;
border-radius: var(--dw-radius-md);
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
color: var(--dw-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid rgba(30, 79, 217, 0.12);
&--spin {
color: var(--dw-success);
background: linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
border-color: rgba(5, 150, 105, 0.2);
}
}
&__body {
flex: 1;
min-width: 0;
}
&__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
&__title {
font-size: 14px;
font-weight: 600;
line-height: 1.35;
}
&__type {
font-size: 12px;
color: var(--dw-text-secondary);
margin-bottom: 6px;
}
&__progress {
margin-top: 8px;
}
&__meta {
font-size: 12px;
color: var(--dw-text-secondary);
margin-top: 6px;
}
&__task {
margin-top: 6px;
font-size: 12px;
color: var(--dw-text-secondary);
word-break: break-all;
}
&__task-label {
display: inline-block;
padding: 1px 6px;
margin-right: 4px;
background: #f3f4f6;
border-radius: 4px;
font-size: 11px;
}
&__sim {
display: inline-block;
margin-top: 8px;
font-size: 11px;
padding: 2px 8px;
background: var(--dw-warning-soft);
color: var(--dw-warning);
border-radius: 4px;
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<dw-card class="drive-monitor" :title="''">
<template #header>
<div class="drive-monitor__head">
<console-icon name="drive" :size="16" />
<span>光驱监控</span>
</div>
</template>
<div v-if="loading" class="drive-monitor__grid">
<div class="dw-skeleton" style="height:120px" />
<div class="dw-skeleton" style="height:120px" />
</div>
<dw-empty v-else-if="!cdList.length" scene="drives" title="无光驱数据" description="联机后将显示上下光驱状态" />
<div v-else class="drive-monitor__grid">
<drive-card
v-for="(item, index) in cdList"
:key="index"
:drive="item"
:CDStatus="CDStatus"
:CDName="CDName"
:CDTypes="CDTypes"
:diskTypes="diskTypes"
/>
</div>
</dw-card>
</template>
<script>
import DwCard from '../ui/DwCard.vue'
import DwEmpty from '../ui/DwEmpty.vue'
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import DriveCard from './DriveCard.vue'
export default {
name: 'DriveMonitor',
components: { DwCard, DwEmpty, ConsoleIcon, DriveCard },
props: {
cdList: { type: Array, default: () => [] },
CDStatus: { type: Object, default: () => ({}) },
CDName: { type: Object, default: () => ({}) },
CDTypes: { type: Object, default: () => ({}) },
diskTypes: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false }
}
}
</script>
<style lang="scss" scoped>
.drive-monitor {
box-shadow: var(--dw-shadow-card);
&__head {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
}
&__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
}
</style>
@@ -0,0 +1,67 @@
<template>
<i
class="dw-icon"
:class="iconClasses"
:style="iconStyle"
aria-hidden="true"
/>
</template>
<script>
const FA_MAP = {
disc: 'fa-compact-disc',
logo: 'fa-compact-disc',
drive: 'fa-hdd',
device: 'fa-print',
server: 'fa-server',
ink: 'fa-tint',
bin: 'fa-box',
folder: 'fa-folder-open',
settings: 'fa-cog',
plus: 'fa-plus',
tasks: 'fa-list',
logs: 'fa-stream',
spinner: 'fa-spinner',
offline: 'fa-circle-exclamation',
wifi: 'fa-wifi',
help: 'fa-circle-question',
user: 'fa-user',
refresh: 'fa-rotate-right'
}
export default {
name: 'ConsoleIcon',
props: {
name: { type: String, required: true },
size: { type: [Number, String], default: 16 },
spin: { type: Boolean, default: false },
fixed: { type: Boolean, default: true }
},
computed: {
faClass() {
return FA_MAP[this.name] || 'fa-circle'
},
iconClasses() {
return [
this.fixed ? 'fa-solid' : 'fas',
this.faClass,
{ 'fa-spin': this.spin }
]
},
iconStyle() {
const n = parseInt(this.size, 10) || 16
return { fontSize: `${n}px`, width: `${n}px`, lineHeight: `${n}px` }
}
}
}
</script>
<style lang="scss" scoped>
.dw-icon {
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,372 @@
<template>
<div class="console-header">
<div class="console-header__brand">
<console-icon name="logo" :size="22" class="console-header__logo-icon" />
<img class="console-header__logo" src="@/assets/images/logo.png" alt="logo" />
<div>
<div class="console-header__title">DiscWorker 控制台</div>
<div class="console-header__sub">国产光盘打印刻录系统 v2.0</div>
<div v-if="previewMode" class="console-header__preview-tag">预览模式</div>
<div v-else-if="demoMode" class="console-header__preview-tag console-header__preview-tag--demo">设计模式 · Mock 数据</div>
</div>
</div>
<div class="console-header__center">
<dw-status-pill :variant="connectionVariant" :label="connectionLabel" dot />
<dw-kpi-chip label="完成" :value="taskNum[0]" variant="success" icon="check" />
<dw-kpi-chip label="废弃" :value="taskNum[1]" variant="muted" />
<dw-kpi-chip label="错误" :value="taskNum[2]" variant="danger" icon="alert" />
</div>
<div class="console-header__actions">
<template v-if="printerInfo.test_use > -1 && printerInfo.test_use !== 255">
<span class="console-header__trial">试用版 {{ printerInfo.test_use }} </span>
<button v-if="hasRunCmd" type="button" class="dw-btn dw-btn--ghost" @click="$emit('activate')">激活</button>
</template>
<button type="button" class="dw-btn dw-btn--outline" @click="$emit('open-work')">
<console-icon name="folder" :size="15" />
打开文件
</button>
<button type="button" class="dw-btn dw-btn--outline" @click="$emit('go-settings')">
<console-icon name="settings" :size="15" />
设置
</button>
<el-dropdown trigger="click" @command="handleUserCommand">
<button type="button" class="console-header__user">
<span class="console-header__avatar">{{ userInitial }}</span>
<span>{{ userName }}</span>
<i class="el-icon-arrow-down" />
</button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-if="hasRunCmd" command="toggle-service">
{{ workStatus ? '关闭服务' : '开启服务' }}
</el-dropdown-item>
<el-dropdown-item v-else disabled>网页端无服务控制</el-dropdown-item>
<el-dropdown-item command="legacy">切换旧版界面</el-dropdown-item>
<el-dropdown-item divided command="logout">登出</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import DwStatusPill from '../ui/DwStatusPill.vue'
import DwKpiChip from '../ui/DwKpiChip.vue'
export default {
name: 'ConsoleHeader',
components: { ConsoleIcon, DwStatusPill, DwKpiChip },
props: {
userName: { type: String, default: 'Admin' },
taskNum: { type: Array, default: () => [0, 0, 0] },
workStatus: { type: Boolean, default: false },
hasRunCmd: { type: Boolean, default: false },
previewMode: { type: Boolean, default: false },
demoMode: { type: Boolean, default: false },
printerInfo: { type: Object, default: () => ({}) },
isConnect: { type: Boolean, default: false },
serviceStatus: { type: Boolean, default: false }
},
computed: {
userInitial() {
return (this.userName || 'A').charAt(0).toUpperCase()
},
connectionVariant() {
if (!this.isConnect || !this.serviceStatus) return 'error'
return 'online'
},
connectionLabel() {
if (this.demoMode && this.isConnect && this.serviceStatus) return '系统联机'
if (!this.hasRunCmd) return '网页端'
if (!this.isConnect || !this.serviceStatus) return '未联机'
return '系统联机'
}
},
methods: {
handleUserCommand(cmd) {
this.$emit('user-command', cmd)
}
}
}
</script>
<style lang="scss" scoped>
.console-header {
height: var(--dw-header-h);
padding: 0 20px;
background: var(--dw-surface);
border-bottom: 1px solid var(--dw-border);
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: var(--dw-shadow-sm);
flex-shrink: 0;
&__brand {
display: flex;
align-items: center;
gap: 12px;
}
&__logo-icon {
color: var(--dw-primary);
}
&__logo {
width: 36px;
height: 36px;
object-fit: contain;
}
&__title {
font-size: 16px;
font-weight: 700;
color: var(--dw-primary);
line-height: 1.2;
}
&__sub {
font-size: 11px;
color: var(--dw-text-muted);
margin-top: 1px;
}
&__preview-tag {
display: inline-block;
margin-top: 2px;
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
color: var(--dw-warning);
background: var(--dw-warning-soft);
&--demo {
color: var(--dw-info);
background: var(--dw-info-soft);
}
}
&__center {
display: flex;
align-items: center;
gap: 10px;
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
}
&__trial {
font-size: 12px;
color: var(--dw-text-secondary);
padding: 4px 8px;
background: #f9fafb;
border-radius: var(--dw-radius-sm);
margin-right: 2px;
}
&__user {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--dw-border);
background: #fff;
border-radius: var(--dw-radius-sm);
padding: 4px 10px 4px 4px;
cursor: pointer;
font-size: 13px;
transition: border-color 0.15s ease;
&:hover {
border-color: rgba(30, 79, 217, 0.35);
}
}
&__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
color: var(--dw-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
border: 1px solid rgba(30, 79, 217, 0.15);
}
}
</style>
@@ -0,0 +1,134 @@
<template>
<div class="console-shell">
<console-header
:user-name="userName"
:task-num="taskNum"
:work-status="workStatus"
:has-run-cmd="hasRunCmd"
:preview-mode="previewMode"
:demo-mode="demoMode"
:printer-info="printerInfo"
:is-connect="isConnect"
:service-status="serviceStatus"
@open-work="$emit('open-work')"
@go-settings="$emit('go-settings')"
@activate="$emit('activate')"
@user-command="$emit('user-command', $event)"
/>
<main class="console-main">
<div class="console-main__device">
<device-panel
:printer-info="printerInfo"
:run-text="runText"
:tag-variant="tagVariant"
:tag-label="tagLabel"
:online="workStatus"
:loading="loading"
:run-list="runList"
:current-run-val="currentRunVal"
:sure-text="sureText"
:change-c-d="changeCD"
:cd-type-options="cdTypeOptions"
:CDTypes="CDTypes"
@new-work="$emit('new-work')"
@maintain="$emit('maintain', $event)"
@command-cd="(e, type) => $emit('command-cd', e, type)"
@confirm-cd="$emit('confirm-cd')"
@cancel-cd="$emit('cancel-cd')"
@refresh="$emit('refresh')"
/>
</div>
<div class="console-main__drive">
<drive-monitor
:cd-list="cdList"
:CDStatus="CDStatus"
:CDName="CDName"
:CDTypes="CDTypes"
:diskTypes="diskTypes"
:loading="loading"
/>
</div>
<div class="console-main__task">
<task-workspace
:task-list="taskList"
:log-list="logList"
:show-all-logs="showAllLogs"
@retry="$emit('retry', $event)"
@cancel="$emit('cancel-task', $event)"
@toggle-logs="$emit('toggle-logs')"
/>
</div>
</main>
<console-status-bar
:serial-no="printerInfo.serial_no"
:cache-text="cacheText"
:version-text="versionText"
@help="$emit('help')"
@about="$emit('about')"
/>
<slot />
</div>
</template>
<script>
import ConsoleHeader from './ConsoleHeader.vue'
import ConsoleStatusBar from './ConsoleStatusBar.vue'
import DevicePanel from '../device/DevicePanel.vue'
import DriveMonitor from '../drive/DriveMonitor.vue'
import TaskWorkspace from '../task/TaskWorkspace.vue'
export default {
name: 'ConsoleShell',
components: {
ConsoleHeader,
ConsoleStatusBar,
DevicePanel,
DriveMonitor,
TaskWorkspace
},
props: {
previewMode: { type: Boolean, default: false },
demoMode: { type: Boolean, default: false },
userName: { type: String, default: '' },
taskNum: { type: Array, default: () => [0, 0, 0] },
workStatus: { type: Boolean, default: false },
hasRunCmd: { type: Boolean, default: false },
isConnect: { type: Boolean, default: false },
serviceStatus: { type: Boolean, default: false },
printerInfo: { type: Object, default: () => ({}) },
runText: { type: String, default: '' },
tagVariant: { type: String, default: 'idle' },
tagLabel: { type: String, default: '' },
loading: { type: Boolean, default: false },
runList: { type: Array, default: () => [] },
currentRunVal: { type: [Number, String], default: 0 },
sureText: { type: String, default: '' },
changeCD: { type: Object, default: () => ({}) },
cdTypeOptions: { type: Array, default: () => [] },
cdList: { type: Array, default: () => [] },
CDStatus: { type: Object, default: () => ({}) },
CDName: { type: Object, default: () => ({}) },
CDTypes: { type: Object, default: () => ({}) },
diskTypes: { type: Object, default: () => ({}) },
taskList: { type: Array, default: () => [] },
logList: { type: Array, default: () => [] },
showAllLogs: { type: Boolean, default: false },
versionText: { type: String, default: '' }
},
computed: {
cacheText() {
const p = this.printerInfo
if (!p.total_cache_space) return ''
return `缓存 ${this.$filterSize(p.free_cache_space)} / ${this.$filterSize(p.total_cache_space)}`
}
}
}
</script>
<style lang="scss" scoped>
.console-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
</style>
@@ -0,0 +1,109 @@
<template>
<div class="console-statusbar">
<div class="console-statusbar__left">
<span class="console-statusbar__version">{{ displayVersion }}</span>
<span v-if="serialNo" class="console-statusbar__item">SN: {{ serialNo }}</span>
<span v-if="cacheText" class="console-statusbar__item">{{ cacheText }}</span>
</div>
<div class="console-statusbar__links">
<a href="#" @click.prevent="$emit('help')">
<console-icon name="help" :size="13" />
帮助
</a>
<span class="console-statusbar__sep">|</span>
<a href="#" @click.prevent="$emit('about')">关于</a>
</div>
</div>
</template>
<script>
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import { loadAppVersionLine } from '@/utils/console/appVersion'
export default {
name: 'ConsoleStatusBar',
components: { ConsoleIcon },
props: {
serialNo: { type: String, default: '' },
cacheText: { type: String, default: '' },
versionText: { type: String, default: '' }
},
data() {
return {
localVersionText: ''
}
},
computed: {
displayVersion() {
return this.versionText || this.localVersionText || 'DiscWorker'
}
},
created() {
if (!this.versionText) {
loadAppVersionLine().then((text) => {
this.localVersionText = text
})
}
}
}
</script>
<style lang="scss" scoped>
.console-statusbar {
height: var(--dw-statusbar-h);
padding: 0 20px;
background: var(--dw-surface);
border-top: 1px solid var(--dw-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--dw-text-muted);
flex-shrink: 0;
&__left {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
flex: 1;
}
&__version {
color: var(--dw-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__item {
white-space: nowrap;
}
&__links {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
flex-shrink: 0;
a {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--dw-text-secondary);
text-decoration: none;
}
a:hover {
color: var(--dw-primary);
}
}
&__sep {
margin: 0 6px;
opacity: 0.45;
}
}
</style>
@@ -0,0 +1,110 @@
<template>
<div class="activity-log">
<div class="activity-log__toolbar">
<button type="button" class="dw-btn dw-btn--ghost" @click="$emit('toggle-all')">
{{ showAll ? '最新日志' : '全部日志' }}
</button>
</div>
<div class="activity-log__list">
<div
v-for="(item, index) in list"
:key="index"
class="activity-log__item"
:class="`activity-log__item--${(item.status || 'INFO').toLowerCase()}`"
>
<span class="activity-log__badge">{{ item.status || 'INFO' }}</span>
<span class="activity-log__text">{{ item.text }}</span>
<span class="activity-log__time">{{ item.time }}</span>
</div>
<dw-empty v-if="!list.length" scene="logs" title="暂无日志" />
</div>
</div>
</template>
<script>
import DwEmpty from '../ui/DwEmpty.vue'
export default {
name: 'ActivityLog',
components: { DwEmpty },
props: {
list: { type: Array, default: () => [] },
showAll: { type: Boolean, default: false }
}
}
</script>
<style lang="scss" scoped>
.activity-log {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
&__toolbar {
padding-bottom: 8px;
}
&__list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
&__item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: var(--dw-radius-sm);
margin-bottom: 6px;
font-size: 13px;
background: #fff;
border: 1px solid var(--dw-border);
&--error {
background: var(--dw-danger-soft);
border-color: rgba(220, 38, 38, 0.15);
}
&--debug {
background: var(--dw-warning-soft);
border-color: rgba(217, 119, 6, 0.15);
}
}
&__badge {
flex-shrink: 0;
min-width: 44px;
text-align: center;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
background: var(--dw-info-soft);
color: var(--dw-info);
}
&__item--error &__badge {
background: rgba(220, 38, 38, 0.12);
color: var(--dw-danger);
}
&__item--debug &__badge {
background: rgba(217, 119, 6, 0.12);
color: var(--dw-warning);
}
&__text {
flex: 1;
min-width: 0;
line-height: 1.4;
}
&__time {
flex-shrink: 0;
font-size: 12px;
opacity: 0.8;
}
}
</style>
@@ -0,0 +1,331 @@
<template>
<div class="task-table-wrap">
<div class="task-table-wrap__toolbar">
<el-input
v-model="keyword"
size="small"
clearable
placeholder="搜索任务名称或 ID"
prefix-icon="el-icon-search"
class="task-table-wrap__search"
/>
</div>
<el-table
row-key="id"
default-expand-all
class="task-table dw-console-table"
:data="filteredList"
height="100%"
stripe
:tree-props="{ children: 'ass_task', hasChildren: 'hasChildren' }"
>
<template slot="empty">
<dw-empty scene="tasks" title="暂无作业列表" description="点击左侧「新建刻录作业」开始第一个任务" />
</template>
<el-table-column prop="task_uuid" label="作业ID" min-width="130" show-overflow-tooltip />
<el-table-column prop="task_name" label="任务名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="cdType" label="类型" width="88">
<template slot-scope="scope">
<span v-if="scope.row.cdType" class="task-table__type">{{ scope.row.cdType }}</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="大小" width="96">
<template slot-scope="scope">{{ $filterSize(scope.row.task_size) }}</template>
</el-table-column>
<el-table-column prop="start_time" label="开始时间" width="150" show-overflow-tooltip />
<el-table-column prop="finish_time" label="结束时间" width="150" show-overflow-tooltip />
<el-table-column label="完成度" width="120">
<template slot-scope="scope">
<template v-if="scope.row.task_uuid && isRunning(scope.row.task_status)">
<dw-progress-bar :value="taskProgress(scope.row)" color="#0284c7" />
</template>
<span v-else-if="scope.row.task_uuid">{{ scope.row.finish_freq }}/{{ scope.row.total_freq }}</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="状态" width="148" fixed="right">
<template slot-scope="scope">
<div v-if="scope.row.task_uuid && scope.row.task_status == 5" class="task-table__actions">
<dw-status-pill variant="error" :label="statusLabel(scope.row.task_status)" />
<el-button type="text" size="mini" icon="el-icon-refresh-right" @click="$emit('retry', scope.row)" />
<el-button type="text" size="mini" icon="el-icon-circle-close" @click="$emit('cancel', scope.row)" />
</div>
<dw-status-pill
v-else-if="scope.row.task_uuid"
:variant="statusVariant(scope.row.task_status)"
:label="statusLabel(scope.row.task_status)"
/>
<span v-else class="task-table__sub">{{ statusLabel(scope.row.task_status) }}</span>
</template>
</el-table-column>
</el-table>
<div class="task-table-wrap__footer">
<strong>{{ filteredList.length }}</strong> 条作业
</div>
</div>
</template>
<script>
import DwEmpty from '../ui/DwEmpty.vue'
import DwStatusPill from '../ui/DwStatusPill.vue'
import DwProgressBar from '../ui/DwProgressBar.vue'
import { TASK_STATUS_LABELS, taskStatusVariant } from '@/utils/console/constants'
export default {
name: 'TaskTable',
components: { DwEmpty, DwStatusPill, DwProgressBar },
props: {
taskList: { type: Array, default: () => [] }
},
data() {
return { keyword: '' }
},
computed: {
filteredList() {
const kw = (this.keyword || '').trim().toLowerCase()
if (!kw) return this.taskList
return this.taskList.filter((row) => {
const name = (row.task_name || '').toLowerCase()
const id = (row.task_uuid || '').toLowerCase()
return name.includes(kw) || id.includes(kw)
})
}
},
methods: {
statusLabel(status) {
return TASK_STATUS_LABELS[status] || status
},
statusVariant(status) {
return taskStatusVariant(status)
},
isRunning(status) {
return [3, 4, 6].includes(parseInt(status, 10))
},
taskProgress(row) {
if (!row.total_freq) return 0
return Math.min(100, Math.round((row.finish_freq / row.total_freq) * 100))
}
}
}
</script>
<style lang="scss" scoped>
.task-table-wrap {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
&__toolbar {
padding: 0 0 10px;
}
&__search {
max-width: 280px;
}
&__footer {
padding-top: 10px;
font-size: 12px;
color: var(--dw-text-muted);
strong {
color: var(--dw-text);
font-weight: 600;
}
}
}
.task-table {
flex: 1;
min-height: 0;
}
.task-table__type {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
color: var(--dw-primary);
background: var(--dw-primary-soft);
border-radius: 4px;
}
.task-table__sub {
font-size: 12px;
color: var(--dw-text-secondary);
}
.task-table__actions {
display: flex;
align-items: center;
gap: 4px;
}
</style>
@@ -0,0 +1,197 @@
<template>
<dw-card class="task-workspace" flex-body flush>
<template #header>
<el-tabs v-model="activeTab" class="task-workspace__tabs">
<el-tab-pane name="tasks">
<span slot="label" class="task-workspace__tab-label">
<console-icon name="tasks" :size="14" />
作业列表
</span>
</el-tab-pane>
<el-tab-pane name="logs">
<span slot="label" class="task-workspace__tab-label">
<console-icon name="logs" :size="14" />
动态日志
</span>
</el-tab-pane>
</el-tabs>
</template>
<div class="task-workspace__body">
<task-table
v-show="activeTab === 'tasks'"
:task-list="taskList"
@retry="$emit('retry', $event)"
@cancel="$emit('cancel', $event)"
/>
<activity-log
v-show="activeTab === 'logs'"
:list="logList"
:show-all="showAllLogs"
@toggle-all="$emit('toggle-logs')"
/>
</div>
</dw-card>
</template>
<script>
import DwCard from '../ui/DwCard.vue'
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import TaskTable from './TaskTable.vue'
import ActivityLog from './ActivityLog.vue'
export default {
name: 'TaskWorkspace',
components: { DwCard, ConsoleIcon, TaskTable, ActivityLog },
props: {
taskList: { type: Array, default: () => [] },
logList: { type: Array, default: () => [] },
showAllLogs: { type: Boolean, default: false }
},
data() {
return { activeTab: 'tasks' }
}
}
</script>
<style lang="scss" scoped>
.task-workspace {
height: 100%;
box-shadow: var(--dw-shadow-card);
&__tabs {
::v-deep .el-tabs__header {
margin: 0;
}
::v-deep .el-tabs__nav-wrap::after {
display: none;
}
::v-deep .el-tabs__item {
font-size: 14px;
font-weight: 500;
height: 44px;
line-height: 44px;
}
::v-deep .el-tabs__item.is-active {
color: var(--dw-primary);
font-weight: 600;
}
}
&__tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
&__body {
padding: 12px 16px 16px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
height: calc(100% - 0px);
}
}
</style>
@@ -0,0 +1,79 @@
<template>
<div class="dw-card" :class="{ 'dw-card--flush': flush }">
<div v-if="$slots.header || title" class="dw-card__header">
<slot name="header">
<span class="dw-card__title">{{ title }}</span>
</slot>
<div v-if="$slots.extra" class="dw-card__extra">
<slot name="extra" />
</div>
</div>
<div class="dw-card__body" :class="{ 'dw-card__body--flex': flexBody }">
<slot />
</div>
<div v-if="$slots.footer" class="dw-card__footer">
<slot name="footer" />
</div>
</div>
</template>
<script>
export default {
name: 'DwCard',
props: {
title: { type: String, default: '' },
flush: { type: Boolean, default: false },
flexBody: { type: Boolean, default: false }
}
}
</script>
<style lang="scss" scoped>
.dw-card {
background: var(--dw-surface);
border-radius: var(--dw-radius-lg);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: var(--dw-shadow-card);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--dw-border);
flex-shrink: 0;
}
&__title {
font-size: 15px;
font-weight: 600;
color: var(--dw-text);
}
&__body {
padding: 16px;
flex: 1;
min-height: 0;
overflow: auto;
&--flex {
display: flex;
flex-direction: column;
}
}
&--flush .dw-card__body {
padding: 0;
}
&__footer {
padding: 12px 16px;
border-top: 1px solid var(--dw-border);
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,215 @@
<template>
<div class="dw-empty" :class="`dw-empty--${scene}`">
<div class="dw-empty__visual">
<img
v-if="scene === 'offline'"
class="dw-empty__device-img"
:src="placeholderDeviceImg"
alt=""
/>
<console-icon v-else :name="iconName" :size="iconSize" />
</div>
<div class="dw-empty__title">{{ title }}</div>
<div v-if="description" class="dw-empty__desc">{{ description }}</div>
<button v-if="actionText" type="button" class="dw-btn dw-btn--outline" @click="$emit('action')">
<console-icon v-if="scene === 'offline'" name="wifi" :size="14" />
{{ actionText }}
</button>
</div>
</template>
<script>
import ConsoleIcon from '../icons/ConsoleIcon.vue'
import { getDeviceImages } from '@/utils/console/constants'
export default {
name: 'DwEmpty',
components: { ConsoleIcon },
props: {
title: { type: String, default: '暂无数据' },
description: { type: String, default: '' },
actionText: { type: String, default: '' },
scene: {
type: String,
default: 'default',
validator: (v) => ['default', 'offline', 'tasks', 'drives', 'logs'].includes(v)
}
},
computed: {
iconName() {
const map = { tasks: 'tasks', drives: 'drive', logs: 'tasks', default: 'device', offline: 'offline' }
return map[this.scene] || 'device'
},
iconSize() {
return this.scene === 'offline' ? 32 : 40
},
placeholderDeviceImg() {
return getDeviceImages()['4200'].img1
}
}
}
</script>
<style lang="scss" scoped>
.dw-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 16px;
text-align: center;
color: var(--dw-text-secondary);
&__visual {
width: 88px;
height: 88px;
border-radius: 50%;
background: linear-gradient(145deg, #f8fafc 0%, #eef2ff 100%);
border: 1px solid var(--dw-border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
color: var(--dw-primary);
box-shadow: var(--dw-shadow-sm);
}
&__device-img {
max-width: 72px;
max-height: 56px;
object-fit: contain;
opacity: 0.55;
filter: grayscale(0.35);
}
&--offline &__visual {
width: auto;
height: auto;
padding: 12px 16px;
border-radius: var(--dw-radius-md);
background: var(--dw-gradient-device);
}
&__title {
font-size: 14px;
font-weight: 600;
color: var(--dw-text);
margin-bottom: 4px;
}
&__desc {
font-size: 13px;
margin-bottom: 14px;
max-width: 280px;
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,127 @@
<template>
<div class="dw-kpi" :class="`dw-kpi--${variant}`">
<span v-if="icon === 'check'" class="dw-kpi__dot dw-kpi__dot--success" />
<span v-else-if="icon === 'alert'" class="dw-kpi__dot dw-kpi__dot--danger" />
<span class="dw-kpi__label">{{ label }}</span>
<span class="dw-kpi__value">{{ value }}</span>
</div>
</template>
<script>
export default {
name: 'DwKpiChip',
props: {
label: { type: String, required: true },
value: { type: [Number, String], default: 0 },
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'success', 'muted', 'danger'].includes(v)
},
icon: { type: String, default: '' }
}
}
</script>
<style lang="scss" scoped>
.dw-kpi {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 999px;
background: #fff;
border: 1px solid var(--dw-border);
box-shadow: var(--dw-shadow-sm);
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--success { background: var(--dw-success); }
&--danger { background: var(--dw-danger); }
}
&__label {
font-size: 12px;
color: var(--dw-text-secondary);
}
&__value {
font-size: 14px;
font-weight: 700;
min-width: 18px;
text-align: center;
}
&--success .dw-kpi__value { color: var(--dw-success); }
&--muted .dw-kpi__value { color: var(--dw-text-muted); }
&--danger .dw-kpi__value { color: var(--dw-danger); }
}
</style>
@@ -0,0 +1,62 @@
<template>
<div class="dw-progress">
<div v-if="showLabel" class="dw-progress__label">
<span>{{ labelLeft }}</span>
<span>{{ labelRight }}</span>
</div>
<div class="dw-progress__track">
<div
class="dw-progress__fill"
:class="{ 'dw-progress__fill--warning': value >= 90 && value < 100, 'dw-progress__fill--danger': value >= 100 }"
:style="{ width: clamped + '%', background: color || undefined }"
/>
</div>
</div>
</template>
<script>
export default {
name: 'DwProgressBar',
props: {
value: { type: Number, default: 0 },
color: { type: String, default: '' },
showLabel: { type: Boolean, default: false },
labelLeft: { type: String, default: '' },
labelRight: { type: String, default: '' }
},
computed: {
clamped() {
return Math.min(100, Math.max(0, this.value))
}
}
}
</script>
<style lang="scss" scoped>
.dw-progress {
&__label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--dw-text-secondary);
margin-bottom: 6px;
}
&__track {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
&__fill {
height: 100%;
background: var(--dw-primary);
border-radius: 4px;
transition: width 0.3s ease;
&--warning { background: var(--dw-warning) !important; }
&--danger { background: var(--dw-danger) !important; }
}
}
</style>
@@ -0,0 +1,54 @@
<template>
<span class="dw-pill" :class="`dw-pill--${variant}`">
<span v-if="dot" class="dw-pill__dot" />
<slot>{{ label }}</slot>
</span>
</template>
<script>
export default {
name: 'DwStatusPill',
props: {
variant: {
type: String,
default: 'idle',
validator: (v) => ['online', 'idle', 'busy', 'error', 'warning', 'info', 'neutral'].includes(v)
},
label: { type: String, default: '' },
dot: { type: Boolean, default: false }
}
}
</script>
<style lang="scss" scoped>
.dw-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
&__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
&--online {
background: var(--dw-success-soft);
color: var(--dw-success);
.dw-pill__dot { animation: dw-pulse 2s infinite; }
}
&--idle { background: #f3f4f6; color: var(--dw-text-secondary); }
&--busy { background: var(--dw-info-soft); color: var(--dw-info); }
&--error { background: var(--dw-danger-soft); color: var(--dw-danger); }
&--warning { background: var(--dw-warning-soft); color: var(--dw-warning); }
&--info { background: var(--dw-info-soft); color: var(--dw-info); }
&--neutral { background: #f3f4f6; color: #4b5563; }
}
</style>
@@ -0,0 +1,230 @@
<template>
<div v-if="previewMode" class="work-add-modal-v2">
<div v-if="visible" class="work-add-modal-v2__mask">
<div class="work-add-modal-v2__dialog">
<div class="work-add-modal-v2__header">
<span>新建刻录作业</span>
<button type="button" class="work-add-modal-v2__close" @click="close">×</button>
</div>
<div class="work-add-modal-v2__body">
<div class="work-add-modal-v2__left">
<h3>1. 数据内容</h3>
<div class="work-add-modal-v2__row">
<el-select v-model="previewForm.cd_type" size="small" style="width:130px">
<el-option v-for="item in previewCdTypes" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="previewForm.label" size="small" placeholder="卷标" />
</div>
<div class="work-add-modal-v2__drop">
<p>点击或拖放文件到此处</p>
<span>支持 ISO, IMG, 文件夹</span>
</div>
<dw-progress-bar show-label :value="previewPercent" label-left="已选: 0 B" label-right="上限: 4.7 GB" />
</div>
<work-label-panel
:templates="previewTemplates"
:preview-title="previewForm.label"
preview-date="2025-11-23"
/>
</div>
<div class="work-add-modal-v2__footer">
<div class="work-add-modal-v2__freq">
<label>份数</label>
<el-input-number v-model="previewForm.task_freq" size="small" :min="1" />
</div>
<div>
<button type="button" class="dw-btn dw-btn--outline" @click="stub">保存草稿</button>
<button type="button" class="dw-btn dw-btn--primary" @click="stub">开始刻录</button>
</div>
</div>
</div>
</div>
</div>
<work-add
v-else
ref="legacy"
class="console-work-add-bridge"
:is-new="isNew"
:save-work-list="saveWorkList"
@close="$emit('close')"
/>
</template>
<script>
import WorkAdd from '@/components/workAdd/workAdd.vue'
import WorkLabelPanel from './WorkLabelPanel.vue'
import DwProgressBar from '../ui/DwProgressBar.vue'
export default {
name: 'WorkAddModalV2',
components: { WorkAdd, WorkLabelPanel, DwProgressBar },
props: {
previewMode: { type: Boolean, default: false },
isNew: { type: Boolean, default: true },
saveWorkList: { type: Object, default: undefined }
},
data() {
return {
visible: false,
previewForm: { cd_type: 2, copy_type: 1, label: '2025-11-23_Archive', task_freq: 1 },
previewPercent: 0,
previewCdTypes: [
{ value: 1, label: 'CD (700MB)' },
{ value: 2, label: 'DVD (4.7GB)' }
],
previewTemplates: [{ value: 1, label: '标准日期模板' }]
}
},
methods: {
show() {
if (this.previewMode) {
this.visible = true
return
}
this.$refs.legacy.show()
},
close() {
if (this.previewMode) {
this.visible = false
this.$emit('close')
return
}
this.$refs.legacy.close()
},
stub() {
this.$message.info('预览模式')
}
}
}
</script>
<style lang="scss" scoped>
.work-add-modal-v2 {
&__mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
}
&__dialog {
width: 960px;
max-width: calc(100vw - 32px);
height: 600px;
max-height: calc(100vh - 32px);
background: #fff;
border-radius: var(--dw-radius-lg);
box-shadow: var(--dw-shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid var(--dw-border);
background: #f8fafc;
font-weight: 600;
}
&__close {
border: none;
background: none;
font-size: 22px;
cursor: pointer;
color: var(--dw-text-secondary);
}
&__body {
flex: 1;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
min-height: 0;
overflow: hidden;
> * {
padding: 16px 20px;
overflow: auto;
min-height: 0;
}
> *:first-child {
border-right: 1px solid var(--dw-border);
}
}
&__left {
display: flex;
flex-direction: column;
gap: 10px;
h3 {
margin: 0;
font-size: 14px;
}
}
&__row {
display: flex;
gap: 10px;
}
&__drop {
flex: 1;
min-height: 140px;
border: 2px dashed #bfdbfe;
background: #f0f9ff;
border-radius: var(--dw-radius-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--dw-text-secondary);
font-size: 13px;
p {
margin: 0 0 4px;
color: var(--dw-text);
font-weight: 500;
}
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-top: 1px solid var(--dw-border);
}
&__freq {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--dw-text-secondary);
}
}
</style>
<style lang="scss">
.console-app .console-work-add-bridge.add_box .el-dialog {
width: 960px !important;
max-width: calc(100vw - 40px);
border-radius: var(--dw-radius-lg, 14px);
overflow: hidden;
}
.console-app .console-work-add-bridge .work_top_text,
.console-app .console-work-add-bridge .work_left_top_label,
.console-app .console-work-add-bridge .work_right_top_label {
color: var(--dw-text, #111827);
}
</style>
@@ -0,0 +1,137 @@
<template>
<div class="work-label-panel">
<div class="work-label-panel__head">
<h3 class="work-label-panel__title">2. 盘面标签</h3>
<button v-if="hasRunCmd" type="button" class="dw-btn dw-btn--ghost" @click="$emit('new-label')">新建标签</button>
<span v-else class="work-label-panel__hint">仅桌面端支持新建标签</span>
</div>
<div class="work-label-panel__field">
<label>选择模板</label>
<el-select v-model="currentTemplate" size="small" placeholder="选择模板" @change="$emit('template-change', $event)">
<el-option v-for="item in templates" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</div>
<div class="work-label-panel__preview">
<img v-if="soonImg" class="work-label-panel__img" :src="soonImg" alt="label preview" />
<div v-else class="work-label-panel__disc">
<div class="work-label-panel__disc-title">{{ previewTitle }}</div>
<div class="work-label-panel__disc-date">{{ previewDate }}</div>
</div>
</div>
<div v-for="(item, index) in soonList" :key="index" class="work-label-panel__field">
<label>{{ item.name }}</label>
<el-input v-if="item.type == 3 || item.type == 4 || item.type == 5" v-model="item.val" size="small" clearable :disabled="csvIsExist" />
</div>
<div class="work-label-panel__warn">
请确保打印机已装载光盘托架且墨盒余量充足
</div>
</div>
</template>
<script>
export default {
name: 'WorkLabelPanel',
props: {
templates: { type: Array, default: () => [] },
currentTemplate: { type: [String, Number], default: '' },
soonImg: { type: String, default: '' },
soonList: { type: Array, default: () => [] },
csvIsExist: { type: Boolean, default: false },
hasRunCmd: { type: Boolean, default: false },
previewTitle: { type: String, default: '同方档案' },
previewDate: { type: String, default: '' }
},
model: {
prop: 'currentTemplate',
event: 'update:currentTemplate'
}
}
</script>
<style lang="scss" scoped>
.work-label-panel {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
&__head {
display: flex;
justify-content: space-between;
align-items: center;
}
&__title {
margin: 0;
font-size: 14px;
font-weight: 600;
}
&__hint {
font-size: 12px;
color: var(--dw-text-muted);
}
&__field {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--dw-text-secondary);
}
}
&__preview {
flex: 1;
min-height: 140px;
background: #f8fafc;
border: 1px solid var(--dw-border);
border-radius: var(--dw-radius-sm);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&__img {
max-width: 100%;
max-height: 180px;
object-fit: contain;
}
&__disc {
width: 150px;
height: 150px;
border-radius: 50%;
background: conic-gradient(from 45deg, #e2e8f0, #fff, #e2e8f0, #cbd5e1, #e2e8f0);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
box-shadow: var(--dw-shadow-md);
}
&__disc-title {
font-weight: 700;
margin-bottom: 36px;
}
&__disc-date {
margin-top: 36px;
color: var(--dw-text-secondary);
}
&__warn {
font-size: 12px;
color: #9a3412;
background: #fff7ed;
border: 1px solid #ffedd5;
border-radius: var(--dw-radius-sm);
padding: 8px 10px;
}
}
</style>
+178
View File
@@ -0,0 +1,178 @@
export const MOCK_PRINTER_STATUS = {
I: '空闲',
B: '忙碌',
P: '正在打印'
}
export function getConsoleMock() {
return {
isConnect: true,
serviceStatus: true,
taskNum: [12, 2, 1],
printer_info: {
blue_last: 78,
red_last: 65,
yellow_last: 72,
calibration_flag: 0,
lack_ink: 0,
cover_flag: false,
printer_name: '4200',
printer_path: '/dev/usb/lp0',
printer_status: 'I',
printer_tray: 'I',
system_ip: '192.168.1.100',
system_name: 'discworker-host',
serial_no: 'TF-2025-X88',
free_cache_space: 51587919872,
total_cache_space: 105152176128,
test_use: 255,
is_set_cddev: 0,
strongList: {
1: { strong_pos: 1, cd_type: 1, cd_num: 38 },
2: { strong_pos: 2, cd_type: 2, cd_num: 25 }
},
strong_list: [
{ strong_pos: 1, cd_type: 1, cd_num: 38 },
{ strong_pos: 2, cd_type: 2, cd_num: 25 }
]
},
cd_list: [
{
cd_pos: 2,
cd_status: 1,
cd_taskid: '',
dev_path: '/dev/sr0',
dev_type: 1,
cd_type: 0,
cd_assid: '',
total_size: 4700000000,
use_size: 0,
copy_scheduler: 0,
burn_speed: 0,
is_sim: false
},
{
cd_pos: 1,
cd_status: 4,
cd_taskid: '1763431953099',
dev_path: '/dev/sr1',
dev_type: 2,
cd_type: 2,
cd_assid: 'Archive_2025',
total_size: 4700000000,
use_size: 2115000000,
copy_scheduler: 45,
burn_speed: 4.2,
is_sim: false
}
],
CDName: { 1: '下光驱', 2: '上光驱' },
CDStatus: {
0: '无法确定光盘状态',
1: '没有光盘',
2: '光驱托盘打开',
3: '光驱未准备好',
4: '光盘状态正常,光盘已插入',
5: '校验中'
},
CDTypes: { 1: 'CD', 2: 'DVD', 3: 'DVD_DL', 4: 'BD' },
diskTypes: { 1: 'CD', 2: 'DVD', 3: 'BD' },
task_list: [
{
task_uuid: '1763452409668',
task_name: 'MU_FU_QIANG_Project_A',
finish_freq: 1,
total_freq: 1,
task_status: 100,
start_time: '2025-11-18 15:54:00',
finish_time: '2025-11-18 16:10:00',
task_size: 1771674009,
cdType: 'DVD',
id: '1763452409668',
ass_task: [
{
id: '1763452409668_1',
cd_type: 2,
task_size: 1771674009,
task_status: 100,
start_time: '2025-11-18 15:54:00',
finish_time: '2025-11-18 16:10:00',
cdType: 'DVD_1'
}
]
},
{
task_uuid: '1763447668606',
task_name: 'System_Backup_Weekly',
finish_freq: 0,
total_freq: 1,
task_status: 5,
start_time: '2025-11-18 14:35:00',
finish_time: '',
task_size: 3435973836,
cdType: 'DVD',
id: '1763447668606',
ass_task: [
{
id: '1763447668606_1',
cd_type: 2,
task_size: 3435973836,
task_status: 5,
start_time: '2025-11-18 14:35:00',
finish_time: '',
cdType: 'DVD_1'
}
]
},
{
task_uuid: '1763446831835',
task_name: 'MU_FU_QIANG_Docs',
finish_freq: 0,
total_freq: 1,
task_status: 7,
start_time: '2025-11-18 14:21:00',
finish_time: '',
task_size: 434865438,
cdType: 'CD',
id: '1763446831835',
ass_task: []
},
{
task_uuid: '1763431953073',
task_name: 'WuGuiSheng_Media',
finish_freq: 0,
total_freq: 1,
task_status: 7,
start_time: '2025-11-18 10:38:00',
finish_time: '',
task_size: 1771674009,
cdType: 'DVD',
id: '1763431953073',
ass_task: []
},
{
task_uuid: '1763431953099',
task_name: 'Auto_Archive_2025',
finish_freq: 0,
total_freq: 1,
task_status: 3,
start_time: '2025-11-18 16:01:00',
finish_time: '',
task_size: 1771674009,
cdType: 'DVD',
id: '1763431953099',
ass_task: []
}
],
logList: [
{ time: '16:01:02', text: '任务 Auto_Archive_2025 开始刻录', status: 'INFO' },
{ time: '15:54:10', text: '任务 MU_FU_QIANG_Project_A 已完成', status: 'INFO' },
{ time: '14:35:22', text: 'System_Backup_Weekly 刻录失败', status: 'ERROR' },
{ time: '14:21:05', text: '用户取消任务 MU_FU_QIANG_Docs', status: 'DEBUG' }
],
allLogList: [
{ time: '10:00:00', text: '系统启动完成', status: 'INFO' },
{ time: '09:58:12', text: '设备 TF-2025-X88 已注册', status: 'INFO' }
]
}
}
+3 -2
View File
@@ -3,13 +3,14 @@ import store from './store'
import NProgress from 'nprogress' // Progress 进度条
import 'nprogress/nprogress.css'// Progress 进度条样式
import { Message } from 'element-ui'
import { getDefaultHomeRoute } from '@/utils/console/homeRoute'
const whiteList = ['/login'] // 不重定向白名单
const whiteList = ['/login', '/console/preview'] // 不重定向白名单
router.beforeEach((to, from, next) => {
NProgress.start()
if (store.getters.token) {
if (to.path === '/login') {
next({ path: '/' })
next({ path: getDefaultHomeRoute() })
NProgress.done() // if current page is dashboard will not trigger afterEach hook, so manually handle it
} else {
next()

Some files were not shown because too many files have changed in this diff Show More