Compare commits

..

15 Commits

Author SHA1 Message Date
24kycj f28b5dc50a 完善 DiscWorker 打包路径与产品命名,底部展示前后端版本,生产环境隐藏默认菜单。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 11:28:15 +08:00
24kycj 1d0d39995e 修复激活时 sudo 无法从 stdin 获取密码的问题,改为管道传密并后台拉起注册工具。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:35:31 +08:00
24kycj 669ee2dcd3 更新 2026-08-05 11:30:33 +08:00
24kycj eb27d72927 新增控制台界面与相关资源,修复激活命令误判失败,并兼容 package 版本与 CardsoonServer 打包路径。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:29:55 +08:00
24kycj 8248767b89 完善 gitignore 规则,移出版本库中的运行时资源与依赖产物,并更新设备展示图片。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 16:52:02 +08:00
24kycj c727492da2 优化错误 2026-06-24 11:13:47 +08:00
24kycj dee6029642 文字调整 2026-06-23 18:38:42 +08:00
24kycj 68d1968376 更新 2026-05-12 21:42:42 +08:00
24kycj 31c9d64ba9 管理页面设置光盘桶 2026-02-05 16:14:59 +08:00
24kycj b277ef38b3 优化bug 2026-01-19 02:46:47 +08:00
24kycj 9c471a494c 优化字段生成 2025-12-09 17:32:42 +08:00
24kycj 03a5a18998 更新 2025-12-06 00:27:29 +08:00
24kycj f0a3b92dd3 优化代码 2025-11-28 03:15:24 +08:00
24kycj ba25ad48f0 调整引导与帮助等 2025-11-15 14:01:39 +08:00
24kycj 4a412ea668 更新 2025-10-30 00:29:38 +08:00
137 changed files with 19381 additions and 12490 deletions
+1
View File
@@ -65,6 +65,7 @@ function startRenderer () {
contentBase: path.join(__dirname, '../'),
quiet: true,
hot: true,
disableHostCheck: true,
before (app, ctx) {
app.use(hotMiddleware)
ctx.middleware.waitUntilValid(() => {
+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
}
}
]
}
+6 -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'
}
}
@@ -139,6 +140,7 @@ let rendererConfig = {
options: options,
},
process,
isWeb: false,
};
},
minify: {
+25 -6
View File
@@ -4,12 +4,15 @@ process.env.BABEL_ENV = 'web'
const path = require('path')
const webpack = require('webpack')
const config = require('../config')
const env = process.env.NODE_ENV === 'production' ? config.build.env : config.dev.env
const MinifyPlugin = require("babel-minify-webpack-plugin")
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',
@@ -31,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$/,
@@ -62,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'
}
}
@@ -107,6 +110,7 @@ let webConfig = {
options: options,
},
process,
isWeb: true,
};
},
minify: {
@@ -117,7 +121,9 @@ let webConfig = {
nodeModules: false
}),
new webpack.DefinePlugin({
'process.env.IS_WEB': 'true'
'process.env.IS_WEB': '"true"',
'process.env.BASE_API': env.BASE_API || '"https://easy-mock.com/mock/5950a2419adc231f356a6636/vue-admin"',
'process.env.VUE_APP_SOCKET_API': env.VUE_APP_SOCKET_API || '"ws://127.0.0.1:10010"'
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
@@ -129,11 +135,24 @@ let webConfig = {
resolve: {
alias: {
'@': path.join(__dirname, '../src/renderer'),
'@/platform': path.join(__dirname, '../src/renderer/platform/web.js'),
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['.js', '.vue', '.json', '.css']
},
target: 'web'
node: {
fs: 'empty',
path: 'empty',
child_process: 'empty'
},
target: 'web',
devServer: process.env.NODE_ENV !== 'production' ? {
port: 9081,
hot: true,
open: true,
contentBase: path.join(__dirname, '../'),
publicPath: '/'
} : undefined
}
/**
+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.
-1
View File
@@ -1 +0,0 @@
+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"'
}
Binary file not shown.
+15 -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",
@@ -12,8 +12,10 @@
"build:dir": "node .electron-vue/build.js && electron-builder --dir",
"build:clean": "cross-env BUILD_TARGET=clean node .electron-vue/build.js",
"build:web": "cross-env BUILD_TARGET=web node .electron-vue/build.js",
"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",
@@ -23,16 +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": "help"
}
],
"directories": {
@@ -63,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",
@@ -134,4 +142,4 @@
"webpack-hot-middleware": "^2.22.2",
"webpack-merge": "^4.1.3"
}
}
}
+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"
+6 -5
View File
@@ -2,9 +2,10 @@
<html>
<head>
<meta charset="utf-8">
<title>DiscWorker V1.01</title>
<% if (htmlWebpackPlugin.options.nodeModules) { %>
<!-- Add `node_modules/` to global paths so `require` works properly in development -->
<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>
require('module').globalPaths.push('<%= htmlWebpackPlugin.options.nodeModules.replace(/\\/g, '\\\\') %>')
</script>
@@ -12,8 +13,8 @@
</head>
<body>
<div id="app"></div>
<!-- Set `__static` path to static files in production -->
<% if (!process.browser) { %>
<!-- Set `__static` path to static files in production (Electron only; do not inject in web build) -->
<% if (typeof isWeb === 'undefined' ? !process.browser : !isWeb) { %>
<script>
if (process.env.NODE_ENV !== 'development') window.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')
</script>
+56 -4
View File
@@ -1,4 +1,6 @@
import { app, BrowserWindow, Menu } from 'electron'
import { app, BrowserWindow, Menu, ipcMain, shell } from 'electron'
import path from 'path'
import fs from 'fs'
import '../renderer/store'
/**
@@ -14,6 +16,25 @@ const winURL = process.env.NODE_ENV === 'development'
? `http://localhost:9080`
: `file://${__dirname}/index.html`
// 获取应用根目录
// 开发环境:项目根目录(__dirname 指向 dist/electron
// 生产环境:可执行文件所在目录
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}`)
}
function createWindow () {
/**
* Initial window options
@@ -24,6 +45,7 @@ function createWindow () {
useContentSize: true,
show: false,
fullscreen: false,
icon: getAppIcon(),
webPreferences: {
webSecurity: false,
nodeIntegration: true,
@@ -32,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)
@@ -42,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') {
@@ -75,3 +104,26 @@ app.on('ready', () => {
if (process.env.NODE_ENV === 'production') autoUpdater.checkForUpdates()
})
*/
// IPC 处理:打开帮助文件
ipcMain.on('open-help-file', async event => {
const helpFilePath = path.join(root, "help", "User Manual.pdf");
if (!fs.existsSync(helpFilePath)) {
const errorMsg = `帮助文件不存在: ${helpFilePath}`;
writeLog(errorMsg);
if (event.reply) {
event.reply('open-help-file-error', errorMsg);
}
return;
}
try {
await shell.openPath(helpFilePath);
} catch (error) {
writeLog("打开帮助文件错误: " + error.message);
if (event.reply) {
event.reply('open-help-file-error', error.message);
}
}
});
+50
View File
@@ -228,4 +228,54 @@ export default {
color: #fff;
}
}
// 引导样式
.guide_body {
position: relative;
z-index: 200;
background-color: #fff;
padding: 0 10px;
border-radius: 4px;
border: 1px dashed #2e9bfb;
height: 100%;
}
.guide_box {
border: 1px dashed #2e9bfb;
padding: 12px;
.guide_title {
font-weight: bold;
font-size: 16px;
color: #000000;
line-height: 30px;
span {
font-size: 13px;
color: #999;
font-weight: 400;
}
}
.guide_desc {
font-size: 13px;
color: #000000;
line-height: 24px;
a {
color: #2e9bfb;
cursor: pointer;
}
}
.guide_btns {
padding: 10px 0 0;
display: flex;
align-items: center;
justify-content: end;
.guide_btn1 {
color: #999;
}
.guide_btn2 {
background-color: #2e9bfb;
border: none;
}
}
}
.el-popover {
padding: 0;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

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>
+104 -95
View File
@@ -8,8 +8,9 @@
</div>
<div class="top_left_line"></div>
<div class="flex_box flex_col_top tab_box">
<div @click="goPath(item)" v-for="item in tabs" :key="item.id" class="flex_box flex_row_center flex_col tab_item" :class="{'tab_item1': active===item.id}">
<div class="tab_name">{{item.name}}</div>
<div @click="goPath(item)" v-for="item in tabs" :key="item.id"
class="flex_box flex_row_center flex_col tab_item" :class="{ 'tab_item1': active === item.id }">
<div class="tab_name">{{ item.name }}</div>
<div class="tab_line"></div>
</div>
</div>
@@ -20,112 +21,120 @@
</template>
<script>
import { mapGetters } from 'vuex'
import UserInfo from '../userInfo/userInfo.vue';
export default {
name: 'TabBar',
components: { UserInfo },
props: {
active: {
type: Number,
default: 1
}
},
data () {
return {
tabs: [
{
id: 1,
name: '系统管理',
path: '/manage'
},
// {
// id: 2,
// name: '用户管理',
// path: '/user'
// },
{
id: 3,
name: '系统配置',
path: '/set'
}
]
}
},
computed: {
...mapGetters(['name', 'roles'])
},
methods: {
goHome() {
this.$router.go(-1)
},
goPath(e) {
console.log(e)
if (e.id === this.active) {
return
import { mapGetters } from 'vuex'
import UserInfo from '../userInfo/userInfo.vue';
export default {
name: 'TabBar',
components: { UserInfo },
props: {
active: {
type: Number,
default: 1
}
},
data() {
return {
tabs: [
{
id: 1,
name: '系统管理',
path: '/manage'
},
// {
// id: 2,
// name: '用户管理',
// path: '/user'
// },
{
id: 3,
name: '系统配置',
path: '/set'
}
this.$router.replace(e.path)
]
}
},
computed: {
...mapGetters(['name', 'roles'])
},
methods: {
goHome() {
this.$router.go(-1)
},
goPath(e) {
console.log(e)
if (e.id === this.active) {
return
}
this.$router.replace(e.path)
}
}
}
</script>
<style lang="scss" scoped>
.top_box {
height: 86px;
padding: 0 32px;
background-color: #ffffff;
border-bottom: 8px solid #F8F8F8;
.top_left {
.top_btn1 {
cursor: pointer;
padding: 0 16px;
height: 30px;
background: #00C325;
border-radius: 4px;
margin-right: 16px;
.top_box {
height: 86px;
padding: 0 32px;
background-color: #ffffff;
border-bottom: 8px solid #F8F8F8;
.top_left {
.top_btn1 {
cursor: pointer;
padding: 0 16px;
height: 30px;
background: #009688;
border-radius: 4px;
margin-right: 16px;
font-weight: 500;
font-size: 15px;
color: #FFFFFF;
img {
width: 18px;
height: 18px;
margin-right: 8px;
}
}
.top_left_line {
width: 2px;
height: 30px;
background: #E0E0E0;
margin: 0 16px;
}
.tab_box {
.tab_item {
padding: 20px 16px;
font-weight: 500;
font-size: 15px;
color: #FFFFFF;
img {
width: 18px;
height: 18px;
margin-right: 8px;
color: #000000;
line-height: 24px;
cursor: pointer;
.tab_name {
padding-bottom: 4px;
}
.tab_line {
display: none;
width: 24px;
height: 4px;
background: #009688;
border-radius: 4px;
}
}
.top_left_line {
width: 2px;
height: 30px;
background: #E0E0E0;
margin: 0 16px;
}
.tab_box {
.tab_item {
padding: 20px 16px;
font-weight: 500;
font-size: 15px;
color: #000000;
line-height: 24px;
cursor: pointer;
.tab_name {
padding-bottom: 4px;
}
.tab_line {
display: none;
width: 24px;
height: 4px;
background: #00C325;
border-radius: 4px;
}
}
.tab_item1 {
color: #00C325;
font-weight: bold;
.tab_line {
display: block;
}
.tab_item1 {
color: #009688;
font-weight: bold;
.tab_line {
display: block;
}
}
}
}
}
</style>
+34 -33
View File
@@ -1,49 +1,50 @@
<template>
<div>
<div class="flex_box top_right">
<div class="top_right_name">你好{{name}}</div>
<div class="top_right_name">你好{{ name }}</div>
<!-- <div @click="goOut" class="top_right_btn">登出</div> -->
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'UserInfo',
data () {
return {
}
},
computed: {
...mapGetters(['name', 'roles'])
},
methods: {
goOut() {
this.$store.dispatch('FedLogOut').then(() => {
location.reload()// In order to re-instantiate the vue-router object to avoid bugs
})
}
import { mapGetters } from 'vuex'
export default {
name: 'UserInfo',
data() {
return {
}
},
computed: {
...mapGetters(['name', 'roles'])
},
methods: {
goOut() {
this.$store.dispatch('FedLogOut').then(() => {
location.reload()// In order to re-instantiate the vue-router object to avoid bugs
})
}
}
}
</script>
<style lang="scss" scoped>
.top_right {
.top_right_name {
font-weight: 500;
font-size: 21px;
color: #000000;
padding: 0 24px;
}
.top_right_btn {
font-weight: 500;
font-size: 21px;
color: #00C325;
line-height: 50px;
padding-left: 24px;
cursor: pointer;
}
.top_right {
.top_right_name {
font-weight: 500;
font-size: 21px;
color: #000000;
padding: 0 24px;
}
.top_right_btn {
font-weight: 500;
font-size: 21px;
color: #009688;
line-height: 50px;
padding-left: 24px;
cursor: pointer;
}
}
</style>

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