22 Commits
Author SHA1 Message Date
wood911010 5f7e8e2ad5 chore: 更新后端和前端服务的镜像版本至 v1.0.3 2025-12-09 17:37:43 +08:00
wood911010 0678fe248c feat: 添加仪表盘背景图片,优化主题切换按钮和样式,调整统计卡片暗模式背景 2025-12-07 10:43:39 +08:00
wood911010 0ca39951a8 docs: 更新 README 文件,增加对多语言和软件产品支持的描述,添加商业咨询与合作信息 2025-11-28 00:54:02 +08:00
wood911010 ce789093bd feat: 更新许可证表单和授权信息展示,增加功能配置和自定义参数支持,优化国际化文本 2025-11-28 00:23:31 +08:00
wood911010 22898510eb feat: 增加授权管理功能与限制设置,支持动态配置和多语言文本,优化许可证表单交互体验 2025-11-27 20:00:29 +08:00
wood911010 78b71ca325 chore: 更新后端和前端服务的镜像版本至 v1.0.2 2025-11-26 13:33:24 +08:00
wood911010 be69e0cbbb feat: 发布 v1.0.2,新增官方文档连接、许可证和授权文件下载功能,并优化仪表盘授权趋势图表 2025-11-26 13:26:57 +08:00
wood911010 994a5de632 feat: 添加下载授权文件功能,支持下载包含授权码和RSA公钥的压缩包,并更新相关国际化文本 2025-11-25 23:10:35 +08:00
wood911010 c9958b12c3 chore: 更新后端和前端服务的镜像版本至 v1.0.1 2025-11-22 13:29:23 +08:00
wood911010 670088660f chore: 移除导航组件中的搜索点击处理函数,简化代码结构 2025-11-22 13:09:57 +08:00
wood911010 8e67176edf docs: 更新 v1.0.0 文档标题为“首个正式版本发布”,新增 v1.0.1 文档,包含新功能和优化说明 2025-11-22 13:00:24 +08:00
wood911010 151d0343aa chore: 更新前端部署流程,确保目录存在并清理临时文件以优化生产环境部署 2025-11-22 12:49:03 +08:00
wood911010 994f7b71dd chore: 更新前端部署流程,添加构建环境变量和验证构建产物中的 base 路径 2025-11-22 02:18:46 +08:00
wood911010 a4313ef770 chore: 更新前端部署流程,设置构建输出目录为 production,并调整文件传输路径以匹配新结构 2025-11-22 02:08:11 +08:00
wood911010 65dc1861da chore: 更新前端部署流程,使用 rsync 进行文件传输并添加进度显示,简化部署步骤 2025-11-22 01:48:07 +08:00
wood911010 4079fd0e69 chore: 修改前端部署流程,使用临时目录进行部署,添加回滚机制以增强稳定性 2025-11-21 23:23:09 +08:00
wood911010 ac0d673d3b feat: 更新导航组件,暂时禁用搜索和通知按钮,添加语言切换功能,优化语言选择界面 2025-11-21 22:59:56 +08:00
wood911010 0478c201db feat: 实现许可证撤销功能,更新相关国际化文本,优化许可证信息展示 2025-11-21 22:36:25 +08:00
wood911010 1f25df980d feat: 添加许可证撤销和下载功能,更新国际化文本,优化许可证信息展示 2025-11-21 22:21:45 +08:00
wood911010 bed116fcb7 feat: 添加数据库初始化重试机制,增强系统稳定性 2025-11-19 22:27:45 +08:00
wood911010 7db2097435 chore: 更新 Docker Compose 文件中的镜像版本至 v1.0.0,并添加 v0.1.0 版本发布文档,概述初始框架功能和未来计划 2025-11-19 21:31:33 +08:00
wood911010 cefb7005db feat: 添加许可证管理页面的相关文档和图片,增强用户对许可证管理功能的理解 2025-11-19 17:39:08 +08:00
42 changed files with 2527 additions and 274 deletions

No files matched your search

+121 -63
View File
@@ -28,17 +28,15 @@ jobs:
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
# 缓存node_modules和构建输出
- name: Cache dependencies and build
# 缓存node_modules(不缓存构建输出,确保每次都是最新构建)
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
frontend/node_modules
frontend/production
key: ${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/src/**/*', 'frontend/vite.config.ts') }}
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json') }}
${{ runner.os }}-frontend-
${{ runner.os }}-frontend-deps-
# 安装依赖
- name: Install dependencies
@@ -46,20 +44,36 @@ jobs:
cd frontend
npm ci
# 并行执行类型检查和构建
# 清理旧的构建目录(确保使用最新代码构建)
- name: Clean old build artifacts
run: |
cd frontend
rm -rf production dist
echo "Cleaned old build directories"
# 执行类型检查和构建
- name: Type check and build
run: |
cd frontend
# 并行执行类型检查和构建以节省时间
npm run type-check &
npm run build:prod &
wait
# 设置构建环境变量,确保与本地构建一致
export VITE_OUTDIR=production
export VITE_ADDRESS_BASE_URL=/production/
# 先执行类型检查
npm run type-check
# 然后执行构建
npm run build:prod
# 检查构建结果
echo "Build completed, checking production directory:"
ls -la production/
# 验证构建产物中的 base 路径
echo "Verifying base path in index.html:"
grep -o 'href="/production/' production/index.html | head -1 || echo "Warning: base path not found"
# 显示构建产物的时间戳,确认是新构建的
echo "Build timestamp:"
stat production/index.html 2>/dev/null || ls -la production/index.html
# 预处理部署目录权限
- name: Prepare deployment directory
# 准备临时部署目录
- name: Prepare temp directory
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
@@ -67,61 +81,105 @@ jobs:
password: ${{ secrets.PASS_PI }}
port: 22
script: |
echo "Preparing deployment directory..."
sudo rm -rf /home/pi/lm-www/frontend_tmp
sudo mkdir -p /home/pi/lm-www/frontend_tmp/production
sudo chown -R pi:pi /home/pi/lm-www/frontend_tmp
# 安装 rsync(用于带进度显示的文件传输)
- name: Install rsync
run: sudo apt-get update && sudo apt-get install -y rsync sshpass
# 计算要传输的文件总大小
- name: Calculate total size
id: calc_size
run: |
cd frontend/production
TOTAL_SIZE=$(du -sb . | cut -f1)
echo "total_size=$TOTAL_SIZE" >> $GITHUB_OUTPUT
echo "Total size to transfer: $(numfmt --to=iec-i --suffix=B $TOTAL_SIZE)"
# 将构建产物传输到服务器(带进度显示)
- name: Copy build files to server with progress
run: |
set -e
echo "Starting file transfer with rsync..."
# 启动后台传输(使用 --info=progress2 只显示总体进度,减少输出)
sshpass -p "${{ secrets.PASS_PI }}" rsync -avz --info=progress2 \
-e "ssh -o StrictHostKeyChecking=no" \
frontend/production/ \
pi@${{ secrets.HOST_PI }}:/home/pi/lm-www/frontend_tmp/production/ > /tmp/rsync.log 2>&1 &
RSYNC_PID=$!
# 每10秒显示一次进度
TOTAL_SIZE=${{ steps.calc_size.outputs.total_size }}
echo "📦 Total size: $(numfmt --to=iec-i --suffix=B $TOTAL_SIZE 2>/dev/null || echo "${TOTAL_SIZE}B")"
while kill -0 $RSYNC_PID 2>/dev/null; do
sleep 10
# 通过SSH检查目标目录大小
TRANSFERRED=$(sshpass -p "${{ secrets.PASS_PI }}" ssh -o StrictHostKeyChecking=no \
pi@${{ secrets.HOST_PI }} \
"du -sb /home/pi/lm-www/frontend_tmp/production 2>/dev/null | cut -f1" || echo "0")
# 停止nginx避免文件占用
sudo nginx -s quit 2>/dev/null || true
if [ "$TRANSFERRED" != "0" ] && [ "$TOTAL_SIZE" != "0" ]; then
PERCENT=$((TRANSFERRED * 100 / TOTAL_SIZE))
TRANSFERRED_HR=$(numfmt --to=iec-i --suffix=B $TRANSFERRED 2>/dev/null || echo "${TRANSFERRED}B")
TOTAL_HR=$(numfmt --to=iec-i --suffix=B $TOTAL_SIZE 2>/dev/null || echo "${TOTAL_SIZE}B")
echo "📦 Transfer progress: $PERCENT% ($TRANSFERRED_HR / $TOTAL_HR)"
else
echo "📦 Transfer in progress..."
fi
done
# 等待传输完成并检查退出码
wait $RSYNC_PID
EXIT_CODE=$?
# 显示 rsync 的最后几行输出
echo "--- rsync output (last 20 lines) ---"
tail -20 /tmp/rsync.log || true
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ File transfer completed!"
else
echo "❌ File transfer failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
env:
DEBIAN_FRONTEND: noninteractive
# 完成部署:直接替换并重启 nginx
- name: Deploy to production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
set -e
# 清理旧文件并重建目录结构
sudo rm -rf /home/pi/lm-www/frontend
echo "Deploying to production..."
# 确保 frontend 目录存在
sudo mkdir -p /home/pi/lm-www/frontend
# 删除原有的 production 目录
sudo rm -rf /home/pi/lm-www/frontend/production
# 将新的 production 目录移过去
sudo mv /home/pi/lm-www/frontend_tmp/production /home/pi/lm-www/frontend/production
# 清理临时目录
sudo rm -rf /home/pi/lm-www/frontend_tmp
# 设置正确的目录权限供文件传输
sudo chown -R pi:pi /home/pi/lm-www/frontend
sudo chmod -R 755 /home/pi/lm-www/frontend
# 将构建产物传输到服务器
- name: Copy build files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
source: "frontend/production/*"
target: "/home/pi/lm-www/"
overwrite: true
# 完成部署配置
- name: Finalize deployment
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
echo "Finalizing deployment..."
# 设置Web服务器权限
echo "Setting permissions..."
sudo chown -R www-data:www-data /home/pi/lm-www/frontend/
sudo chmod -R 644 /home/pi/lm-www/frontend/
sudo find /home/pi/lm-www/frontend/ -type d -exec chmod 755 {} \;
# 启动nginx并验证
echo "Starting nginx..."
if sudo nginx -t; then
sudo systemctl start nginx
sudo systemctl reload nginx
if sudo systemctl is-active --quiet nginx; then
echo "✅ Frontend deployment completed successfully!"
else
echo "❌ Nginx failed to start"
sudo systemctl status nginx --no-pager -l
exit 1
fi
else
echo "❌ Nginx configuration test failed!"
exit 1
fi
echo "Verifying deployment structure..."
ls -la /home/pi/lm-www/frontend/
ls -la /home/pi/lm-www/frontend/production/ || echo "Warning: production directory not found"
echo "Reloading nginx..."
sudo systemctl reload nginx
echo "✅ Deployment completed!"
+50 -1
View File
@@ -1,5 +1,23 @@
# 软件授权管理平台
<p align="center">
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/v/release/cedar-v/license-manager?logo=git&label=Latest%20Release" alt="Latest Release">
</a>
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/downloads/cedar-v/license-manager/total?label=Downloads&logo=github" alt="Downloads">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/github/license/cedar-v/license-manager?label=License&color=blue" alt="License: GPL-3.0">
</a>
<a href="https://github.com/cedar-v/license-manager/issues">
<img src="https://img.shields.io/github/issues/cedar-v/license-manager?label=Issues&logo=github" alt="Open Issues">
</a>
<a href="https://github.com/cedar-v/license-manager/pulls">
<img src="https://img.shields.io/github/issues-pr/cedar-v/license-manager?label=PRs&logo=github" alt="Open PRs">
</a>
</p>
[中文](README.md) | [English](README_EN.md)
---
@@ -8,14 +26,42 @@
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
## Demo环境
<a href="https://lm.cedar-v.com:18080">
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
</a>
访问地址:[lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
**登录凭证:**
- 用户名:`admin`
- 密码:`admin@123`
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。我们正在持续开发中,更多功能将陆续上线。
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。
## 官方文档
官方文档请访问:[https://docs.cedar-v.com](https://docs.lm.cedar-v.com)
## Star收藏与关注
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/stars/cedar-v/license-manager?style=social" alt="GitHub Stars">
</a>
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/watchers/cedar-v/license-manager?style=social" alt="GitHub Watchers">
</a>
如果这个项目对你有帮助,请给个star收藏,你的支持是我们最大的动力!
## 客户端SDK
客户端SDK请访问:[https://github.com/cedar-v/license-manager-sdk](https://github.com/cedar-v/license-manage-sdk-go)
## 商业咨询与合作
商业咨询与合作请联系QQ群管理员。
## 系统界面
@@ -28,6 +74,9 @@
### 客户管理
![客户管理页面](docs/images/customer.png)
### 许可证管理
![许可证管理页面](docs/images/license.png)
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
## 核心功能
+41
View File
@@ -1,5 +1,23 @@
# License Manager
<p align="center">
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/v/release/cedar-v/license-manager?logo=git&label=Latest%20Release" alt="Latest Release">
</a>
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/downloads/cedar-v/license-manager/total?label=Downloads&logo=github" alt="Downloads">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/github/license/cedar-v/license-manager?label=License&color=blue" alt="License: GPL-3.0">
</a>
<a href="https://github.com/cedar-v/license-manager/issues">
<img src="https://img.shields.io/github/issues/cedar-v/license-manager?label=Issues&logo=github" alt="Open Issues">
</a>
<a href="https://github.com/cedar-v/license-manager/pulls">
<img src="https://img.shields.io/github/issues-pr/cedar-v/license-manager?label=PRs&logo=github" alt="Open PRs">
</a>
</p>
[中文](README.md) | [English](README_EN.md)
---
@@ -8,7 +26,12 @@
License Manager is an independent software licensing platform that provides license code generation, distribution, validation, and management services for IoT platforms and other software systems. It offers both online and offline licensing modes with hardware-based binding for enhanced security.
✨It enables **any programming language** and **any type of software product** to **quickly integrate licensing capabilities** via standardized **HTTP APIs** and **cross-platform client SDKs**.
## Demo Environment
<a href="https://lm.cedar-v.com:18080">
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
</a>
Access URL: [lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
**Login Credentials:**
@@ -17,6 +40,21 @@ Access URL: [lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
> 💡 This is a demo environment showcasing our current features. You can experience the implemented core functionalities while we continue active development with more features coming soon.
## Stars & Followers
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/stars/cedar-v/license-manager?style=social" alt="GitHub Stars">
</a>
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/watchers/cedar-v/license-manager?style=social" alt="GitHub Watchers">
</a>
If this project helps you, please give it a star—your support keeps us moving!
## Client SDK
Client SDK repository: [https://github.com/cedar-v/license-manager-sdk](https://github.com/cedar-v/license-manage-sdk-go)
## System Interface
### Login Page
@@ -28,6 +66,9 @@ Access URL: [lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
### Customer Management
![Customer Management Page](docs/images/customer.png)
### License Management
![License Management Page](docs/images/license.png)
The customer management module provides comprehensive customer information management features, including CRUD operations for customer data, status management, and authorization associations.
## Key Features
+21 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
"time"
"license-manager/internal/api/routes"
"license-manager/internal/config"
@@ -53,10 +54,27 @@ func main() {
cfg := config.GetConfig()
// 初始化数据库
if err := database.InitDatabase(&cfg.Database); err != nil {
log.Fatalf("数据库初始化失败: %v", err)
// 初始化数据库(失败时重试)
const (
maxDBRetries = 200
retryInterval = 3 * time.Second
)
var dbErr error
for attempt := 1; attempt <= maxDBRetries; attempt++ {
dbErr = database.InitDatabase(&cfg.Database)
if dbErr == nil {
break
}
if attempt == maxDBRetries {
log.Fatalf("数据库初始化失败: %v", dbErr)
}
log.Warnf("数据库初始化失败(第%d次): %v,将在 %v 后重试...", attempt, dbErr, retryInterval)
time.Sleep(retryInterval)
}
defer database.Close()
// 运行数据库迁移
-1
View File
@@ -96,7 +96,6 @@ enums:
encryption_type:
"standard": "Standard"
"advanced": "Advanced"
authorization_status:
"normal": "Normal"
-1
View File
@@ -96,7 +96,6 @@ enums:
encryption_type:
"standard": "標準暗号化"
"advanced": "高度暗号化"
authorization_status:
"normal": "正常"
-1
View File
@@ -96,7 +96,6 @@ enums:
encryption_type:
"standard": "标准加密"
"advanced": "高级加密"
authorization_status:
"normal": "正常"
+71 -5
View File
@@ -1,4 +1,5 @@
// Package swagger Code generated by swaggo/swag. DO NOT EDIT
// Code generated by swaggo/swag. DO NOT EDIT.
package swagger
import "github.com/swaggo/swag"
@@ -1363,6 +1364,73 @@ const docTemplate = `{
}
}
},
"/api/v1/authorization-codes/{id}/download": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "下载授权码文件,包含授权码和RSA公钥,供客户端使用",
"consumes": [
"application/json"
],
"produces": [
"application/zip"
],
"tags": [
"授权码管理"
],
"summary": "下载授权文件",
"parameters": [
{
"type": "string",
"description": "授权码ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "包含授权码文件和RSA公钥的压缩包",
"schema": {
"type": "file"
}
},
"400": {
"description": "请求参数无效",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"401": {
"description": "未认证",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "授权码不存在",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"409": {
"description": "授权码不可用",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "服务器内部错误",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/v1/authorization-codes/{id}/lock": {
"put": {
"security": [
@@ -1984,7 +2052,7 @@ const docTemplate = `{
"application/json"
],
"produces": [
"application/octet-stream"
"application/zip"
],
"tags": [
"许可证管理"
@@ -2001,7 +2069,7 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "许可证文件",
"description": "包含许可证文件和RSA公钥的压缩包",
"schema": {
"type": "file"
}
@@ -3865,8 +3933,6 @@ var SwaggerInfo = &swag.Spec{
Description: "软件授权管理平台API文档",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {
+69 -2
View File
@@ -1360,6 +1360,73 @@
}
}
},
"/api/v1/authorization-codes/{id}/download": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "下载授权码文件,包含授权码和RSA公钥,供客户端使用",
"consumes": [
"application/json"
],
"produces": [
"application/zip"
],
"tags": [
"授权码管理"
],
"summary": "下载授权文件",
"parameters": [
{
"type": "string",
"description": "授权码ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "包含授权码文件和RSA公钥的压缩包",
"schema": {
"type": "file"
}
},
"400": {
"description": "请求参数无效",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"401": {
"description": "未认证",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"404": {
"description": "授权码不存在",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"409": {
"description": "授权码不可用",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
},
"500": {
"description": "服务器内部错误",
"schema": {
"$ref": "#/definitions/models.ErrorResponse"
}
}
}
}
},
"/api/v1/authorization-codes/{id}/lock": {
"put": {
"security": [
@@ -1981,7 +2048,7 @@
"application/json"
],
"produces": [
"application/octet-stream"
"application/zip"
],
"tags": [
"许可证管理"
@@ -1998,7 +2065,7 @@
],
"responses": {
"200": {
"description": "许可证文件",
"description": "包含许可证文件和RSA公钥的压缩包",
"schema": {
"type": "file"
}
+45 -2
View File
@@ -1959,6 +1959,49 @@ paths:
summary: 查询授权变更历史列表
tags:
- 授权码管理
/api/v1/authorization-codes/{id}/download:
get:
consumes:
- application/json
description: 下载授权码文件,包含授权码和RSA公钥,供客户端使用
parameters:
- description: 授权码ID
in: path
name: id
required: true
type: string
produces:
- application/zip
responses:
"200":
description: 包含授权码文件和RSA公钥的压缩包
schema:
type: file
"400":
description: 请求参数无效
schema:
$ref: '#/definitions/models.ErrorResponse'
"401":
description: 未认证
schema:
$ref: '#/definitions/models.ErrorResponse'
"404":
description: 授权码不存在
schema:
$ref: '#/definitions/models.ErrorResponse'
"409":
description: 授权码不可用
schema:
$ref: '#/definitions/models.ErrorResponse'
"500":
description: 服务器内部错误
schema:
$ref: '#/definitions/models.ErrorResponse'
security:
- BearerAuth: []
summary: 下载授权文件
tags:
- 授权码管理
/api/v1/authorization-codes/{id}/lock:
put:
consumes:
@@ -2349,10 +2392,10 @@ paths:
required: true
type: string
produces:
- application/octet-stream
- application/zip
responses:
"200":
description: 许可证文件
description: 包含许可证文件和RSA公钥的压缩包
schema:
type: file
"400":
@@ -1,11 +1,18 @@
package handlers
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
@@ -211,6 +218,148 @@ func (h *AuthorizationCodeHandler) GetAuthorizationCode(c *gin.Context) {
})
}
// DownloadAuthorizationFile 下载授权文件
// @Summary 下载授权文件
// @Description 下载授权码文件,包含授权码和RSA公钥,供客户端使用
// @Tags 授权码管理
// @Accept json
// @Produce application/zip
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Success 200 {file} binary "包含授权码文件和RSA公钥的压缩包"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 409 {object} models.ErrorResponse "授权码不可用"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id}/download [get]
func (h *AuthorizationCodeHandler) DownloadAuthorizationFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if id == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
fileData, fileName, authCode, err := h.authCodeService.GenerateAuthorizationFile(ctx, id)
if err != nil {
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
cfg := config.GetConfig()
if cfg == nil || cfg.License.RSA.PublicKeyPath == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath)
publicKeyData, err := os.ReadFile(publicKeyPath)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
const zipAuthFileName = "authorization_code.txt"
authFileWriter, err := zipWriter.Create(zipAuthFileName)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := authFileWriter.Write(fileData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem")
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := publicKeyWriter.Write(publicKeyData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if err := zipWriter.Close(); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
packageFileName := fmt.Sprintf("authorization_package_%s.zip", strings.TrimSpace(authCode))
if strings.TrimSpace(authCode) == "" {
baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
packageFileName = fmt.Sprintf("%s_package.zip", baseName)
}
c.Header("Content-Description", "File Transfer")
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName))
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
c.Data(http.StatusOK, "application/zip", buffer.Bytes())
}
// UpdateAuthorizationCode 更新授权码
// @Summary 更新授权码
// @Description 更新授权码信息,所有字段都是可选的,需要指定变更类型和原因
@@ -1,12 +1,18 @@
package handlers
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
@@ -298,10 +304,10 @@ func (h *LicenseHandler) RevokeLicense(c *gin.Context) {
// @Description 下载加密的许可证文件,用于客户端软件激活
// @Tags 许可证管理
// @Accept json
// @Produce application/octet-stream
// @Produce application/zip
// @Security BearerAuth
// @Param id path string true "许可证ID"
// @Success 200 {file} binary "许可证文件"
// @Success 200 {file} binary "包含许可证文件和RSA公钥的压缩包"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
@@ -309,9 +315,9 @@ func (h *LicenseHandler) RevokeLicense(c *gin.Context) {
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses/{id}/download [get]
func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
@@ -324,7 +330,7 @@ func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
fileData, fileName, err := h.licenseService.GenerateLicenseFile(ctx, id)
fileData, fileName, licenseKey, err := h.licenseService.GenerateLicenseFile(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
@@ -347,16 +353,99 @@ func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) {
return
}
cfg := config.GetConfig()
if cfg == nil || cfg.License.RSA.PublicKeyPath == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath)
publicKeyData, err := os.ReadFile(publicKeyPath)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
const zipLicenseFileName = "license.lic"
licenseFileWriter, err := zipWriter.Create(zipLicenseFileName)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := licenseFileWriter.Write(fileData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem")
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := publicKeyWriter.Write(publicKeyData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if err := zipWriter.Close(); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
packageFileName := fmt.Sprintf("license_package_%s.zip", strings.TrimSpace(licenseKey))
if strings.TrimSpace(licenseKey) == "" {
baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
packageFileName = fmt.Sprintf("%s_package.zip", baseName)
}
// 设置响应头,指示这是一个文件下载
c.Header("Content-Description", "File Transfer")
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName))
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
// 返回文件内容
c.Data(http.StatusOK, "application/octet-stream", fileData)
c.Data(http.StatusOK, "application/zip", buffer.Bytes())
}
// ActivateLicense 激活许可证
@@ -533,4 +622,4 @@ func (h *LicenseHandler) GetStatsOverview(c *gin.Context) {
Message: successMessage,
Data: data,
})
}
}
+8 -7
View File
@@ -76,7 +76,7 @@ func SetupRouter() *gin.Engine {
public := api.Group("")
{
public.POST("/v1/login", authHandler.Login)
// 许可证激活接口(客户端软件使用)
public.POST("/v1/activate", licenseHandler.ActivateLicense)
public.POST("/v1/heartbeat", licenseHandler.Heartbeat)
@@ -89,7 +89,7 @@ func SetupRouter() *gin.Engine {
// 认证相关
auth.POST("/v1/logout", authHandler.Logout)
auth.POST("/v1/auth/refresh", authHandler.RefreshToken)
// 客户管理
auth.GET("/customers", customerHandler.GetCustomerList)
auth.GET("/customers/:id", customerHandler.GetCustomer)
@@ -97,30 +97,31 @@ func SetupRouter() *gin.Engine {
auth.PUT("/customers/:id", customerHandler.UpdateCustomer)
auth.DELETE("/customers/:id", customerHandler.DeleteCustomer)
auth.PATCH("/customers/:id/status", customerHandler.UpdateCustomerStatus)
// 枚举管理
auth.GET("/enums", enumHandler.GetAllEnums)
auth.GET("/enums/:type", enumHandler.GetEnumsByType)
// 授权码管理
auth.GET("/v1/authorization-codes", authCodeHandler.GetAuthorizationCodeList)
auth.POST("/v1/authorization-codes", authCodeHandler.CreateAuthorizationCode)
auth.GET("/v1/authorization-codes/:id", authCodeHandler.GetAuthorizationCode)
auth.GET("/v1/authorization-codes/:id/download", authCodeHandler.DownloadAuthorizationFile)
auth.PUT("/v1/authorization-codes/:id", authCodeHandler.UpdateAuthorizationCode)
auth.PUT("/v1/authorization-codes/:id/lock", authCodeHandler.LockUnlockAuthorizationCode)
auth.DELETE("/v1/authorization-codes/:id", authCodeHandler.DeleteAuthorizationCode)
auth.GET("/v1/authorization-codes/:id/changes", authCodeHandler.GetAuthorizationChangeList)
// 许可证管理
auth.GET("/v1/licenses", licenseHandler.GetLicenseList)
auth.GET("/v1/licenses/:id", licenseHandler.GetLicense)
auth.POST("/v1/licenses", licenseHandler.CreateLicense)
auth.PUT("/v1/licenses/:id/revoke", licenseHandler.RevokeLicense)
auth.GET("/v1/licenses/:id/download", licenseHandler.DownloadLicenseFile)
// 统计分析
auth.GET("/v1/stats/overview", licenseHandler.GetStatsOverview)
// 仪表盘接口
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
@@ -228,6 +228,34 @@ func (s *authorizationCodeService) GetAuthorizationCode(ctx context.Context, id
return authCode, nil
}
// GenerateAuthorizationFile 生成授权码文件内容
func (s *authorizationCodeService) GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if id == "" {
return nil, "", "", i18n.NewI18nError("900001", lang)
}
authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, "", "", i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, "", "", i18n.NewI18nError("900004", lang, err.Error())
}
now := time.Now()
if authCode.IsLocked {
return nil, "", "", i18n.NewI18nError("300003", lang) // 授权码已锁定
}
if now.Before(authCode.StartDate) || now.After(authCode.EndDate) {
return nil, "", "", i18n.NewI18nError("300001", lang) // 授权码未生效或已过期
}
fileName := fmt.Sprintf("authorization_%s.txt", authCode.Code)
return []byte(authCode.Code), fileName, authCode.Code, nil
}
// fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段
func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) {
// 计算状态
+5 -4
View File
@@ -43,6 +43,7 @@ type AuthorizationCodeService interface {
LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error)
DeleteAuthorizationCode(ctx context.Context, id string) error
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error)
}
// LicenseService 许可证服务接口
@@ -51,12 +52,12 @@ type LicenseService interface {
GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error)
CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error)
RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error)
GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error)
GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error)
// 客户端激活和心跳接口
ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error)
Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error)
// 统计接口
GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error)
}
@@ -65,7 +66,7 @@ type LicenseService interface {
type DashboardService interface {
// 获取授权趋势数据
GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error)
// 获取最近授权列表
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
}
+69 -65
View File
@@ -11,14 +11,15 @@ import (
"strings"
"time"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type licenseService struct {
@@ -251,14 +252,14 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode
// 更新许可证状态为撤销
existingLicense.Status = "revoked"
// 如果提供了撤销原因,保存到使用数据中
if req.Reason != "" {
revokeData := map[string]interface{}{
"revoked_at": time.Now().Format(time.RFC3339),
"revoke_reason": req.Reason,
"revoked_at": time.Now().Format(time.RFC3339),
"revoke_reason": req.Reason,
}
// 保留原有的使用数据
if len(existingLicense.UsageData) > 0 {
var existingUsageData map[string]interface{}
@@ -268,7 +269,7 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode
}
}
}
usageDataBytes, err := json.Marshal(revokeData)
if err == nil {
existingLicense.UsageData = models.JSON(usageDataBytes)
@@ -284,14 +285,14 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode
}
// GenerateLicenseFile 生成许可证文件
func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error) {
func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id)
// 业务逻辑:参数验证
if id == "" {
s.logger.Error("[GenerateLicenseFile] 错误:许可证ID为空")
return nil, "", i18n.NewI18nError("900001", lang)
return nil, "", "", i18n.NewI18nError("900001", lang)
}
// 获取许可证信息
@@ -300,9 +301,9 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
if err != nil {
s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err)
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, "", i18n.NewI18nError("300006", lang) // 许可证不存在
return nil, "", "", i18n.NewI18nError("300006", lang) // 许可证不存在
}
return nil, "", i18n.NewI18nError("900004", lang, err.Error())
return nil, "", "", i18n.NewI18nError("900004", lang, err.Error())
}
s.logger.Infof("[GenerateLicenseFile] 许可证信息查询成功,license_key: %s, status: %s", license.LicenseKey, license.Status)
@@ -310,7 +311,7 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
// 检查许可证状态
if license.Status == "revoked" {
s.logger.Warnf("[GenerateLicenseFile] 许可证已被撤销,license_id: %s", id)
return nil, "", i18n.NewI18nError("300007", lang) // 许可证已被撤销
return nil, "", "", i18n.NewI18nError("300007", lang) // 许可证已被撤销
}
// 构建许可证文件内容
@@ -318,10 +319,10 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
"license_key": license.LicenseKey,
"authorization_code_id": license.AuthorizationCodeID,
"hardware_fingerprint": license.HardwareFingerprint,
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
}
// 包含授权码信息
@@ -331,48 +332,37 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
licenseFileData["end_date"] = license.AuthorizationCode.EndDate
licenseFileData["deployment_type"] = license.AuthorizationCode.DeploymentType
licenseFileData["max_activations"] = license.AuthorizationCode.MaxActivations
// 包含功能配置
if len(license.AuthorizationCode.FeatureConfig) > 0 {
var featureConfig map[string]interface{}
if err := json.Unmarshal(license.AuthorizationCode.FeatureConfig, &featureConfig); err == nil {
licenseFileData["feature_config"] = featureConfig
}
if featureConfig := parseJSONField(license.AuthorizationCode.FeatureConfig); len(featureConfig) > 0 {
licenseFileData["feature_config"] = featureConfig
}
// 包含使用限制
if len(license.AuthorizationCode.UsageLimits) > 0 {
var usageLimits map[string]interface{}
if err := json.Unmarshal(license.AuthorizationCode.UsageLimits, &usageLimits); err == nil {
licenseFileData["usage_limits"] = usageLimits
}
if usageLimits := parseJSONField(license.AuthorizationCode.UsageLimits); len(usageLimits) > 0 {
licenseFileData["usage_limits"] = usageLimits
}
// 包含自定义参数
if len(license.AuthorizationCode.CustomParameters) > 0 {
var customParameters map[string]interface{}
if err := json.Unmarshal(license.AuthorizationCode.CustomParameters, &customParameters); err == nil {
licenseFileData["custom_parameters"] = customParameters
}
if customParameters := parseJSONField(license.AuthorizationCode.CustomParameters); len(customParameters) > 0 {
licenseFileData["custom_parameters"] = customParameters
}
}
// 序列化许可证数据
licenseJSON, err := json.Marshal(licenseFileData)
if err != nil {
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
}
// 使用RSA数字签名
encryptedData, err := s.signLicenseFile(licenseJSON)
if err != nil {
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
}
// 生成文件名
fileName := fmt.Sprintf("license_%s.lic", license.LicenseKey)
return encryptedData, fileName, nil
return encryptedData, fileName, license.LicenseKey, nil
}
// generateLicenseKey 生成许可证密钥
@@ -489,9 +479,9 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa
// 检查是否已存在相同硬件指纹的许可证
var existingLicense models.License
err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?",
err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?",
authCode.ID, req.HardwareFingerprint).First(&existingLicense).Error
if err == nil {
// 已存在,直接激活
existingLicense.Status = "active"
@@ -500,7 +490,7 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa
existingLicense.ActivatedAt = &now
existingLicense.LastHeartbeat = &now
existingLicense.LastOnlineIP = &clientIP
if err := tx.Save(&existingLicense).Error; err != nil {
return err
}
@@ -655,10 +645,10 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
"license_key": license.LicenseKey,
"authorization_code_id": license.AuthorizationCodeID,
"hardware_fingerprint": license.HardwareFingerprint,
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
}
if authCode != nil {
@@ -667,27 +657,18 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
licenseFileData["end_date"] = authCode.EndDate
licenseFileData["deployment_type"] = authCode.DeploymentType
licenseFileData["max_activations"] = authCode.MaxActivations
// 包含功能配置等
if len(authCode.FeatureConfig) > 0 {
var featureConfig map[string]interface{}
if err := json.Unmarshal(authCode.FeatureConfig, &featureConfig); err == nil {
licenseFileData["feature_config"] = featureConfig
}
if featureConfig := parseJSONField(authCode.FeatureConfig); len(featureConfig) > 0 {
licenseFileData["feature_config"] = featureConfig
}
if len(authCode.UsageLimits) > 0 {
var usageLimits map[string]interface{}
if err := json.Unmarshal(authCode.UsageLimits, &usageLimits); err == nil {
licenseFileData["usage_limits"] = usageLimits
}
if usageLimits := parseJSONField(authCode.UsageLimits); len(usageLimits) > 0 {
licenseFileData["usage_limits"] = usageLimits
}
if len(authCode.CustomParameters) > 0 {
var customParameters map[string]interface{}
if err := json.Unmarshal(authCode.CustomParameters, &customParameters); err == nil {
licenseFileData["custom_parameters"] = customParameters
}
if customParameters := parseJSONField(authCode.CustomParameters); len(customParameters) > 0 {
licenseFileData["custom_parameters"] = customParameters
}
}
@@ -748,7 +729,7 @@ func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) {
// 构建签名后的许可证文件结构
licenseWithSignature := map[string]interface{}{
"data": string(data), // 原始数据(JSON字符串)
"signature": signature, // 数字签名
"signature": signature, // 数字签名
"algorithm": "RSA-PSS-SHA256",
}
@@ -764,6 +745,29 @@ func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) {
return []byte(encoded), nil
}
// parseJSONField 将数据库中的 JSON 字段解析为 map,兼容被字符串包裹的情况
func parseJSONField(raw models.JSON) map[string]interface{} {
if len(raw) == 0 {
return nil
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err == nil {
return result
}
// 兼容字段以字符串形式保存 JSON 的情况,例如 "\"{\\\"key\\\":123}\""
var rawString string
if err := json.Unmarshal(raw, &rawString); err != nil {
return nil
}
if err := json.Unmarshal([]byte(rawString), &result); err != nil {
return nil
}
return result
}
// GetStatsOverview 获取授权概览统计
func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
+2 -2
View File
@@ -32,7 +32,7 @@ services:
# Backend service - using GitHub registry image
backend:
image: ghcr.io/cedar-v/license-manager-backend:v0.1.0
image: ghcr.io/cedar-v/license-manager-backend:v1.0.3
container_name: license-manager-backend-git
restart: unless-stopped
environment:
@@ -69,7 +69,7 @@ services:
# Frontend service - using GitHub registry image
frontend:
image: ghcr.io/cedar-v/license-manager-frontend:v0.1.0
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.3
container_name: license-manager-frontend-git
restart: unless-stopped
environment:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

File renamed without changes.
+1 -1
View File
@@ -5,7 +5,7 @@
---
## 🎉 首个框架版本发布
## 🎉 首个正式版本发布
这是License Manager项目的第一个正式版本,包含了基础的系统框架和核心功能。
本版本聚焦“全生命周期 License 管理”场景,兼顾 SaaS 交付与离线部署,
+19
View File
@@ -0,0 +1,19 @@
# Release v1.0.1
**发布日期**:2025-11-22
---
## ✨ 新增功能
- **数据库初始化重试机制**:提升系统在启动过程中的稳定性与容错能力
- **许可证管理功能**:支持许可证的撤销与下载操作,增强管理灵活性
- **语言切换功能**:新增语言切换入口,优化语言选择界面,提升国际化体验
- **官方文档**:更新官方文档,增加许可证结构与验证(客户端要点)说明,商业价值说明等。
## 🛠 优化与改进
- **许可证信息展示**:更新国际化文本,优化展示逻辑,提升可读性
- **导航与菜单栏**:完善导航栏与菜单栏结构,暂时禁用搜索与通知按钮,提升整体交互体验
- **授权基础信息**:优化剩余天数展示逻辑,提升用户体验
+16
View File
@@ -0,0 +1,16 @@
# Release v1.0.2
**发布日期**:2025-11-26
---
## ✨ 新增功能
- **前端增加官方文档连接入口**:方便用户查看官方文档,提升用户体验
- **许可证文件下载功能**:支持许可证文件的下载,方便用户下载许可证文件,提升用户体验
- **授权文件下载功能**:支持授权文件的下载,方便用户下载授权文件,提升用户体验
## 🛠 优化与改进
- **仪表盘授权趋势图表**:修复Y轴数据展示异常问题
+12
View File
@@ -0,0 +1,12 @@
# Release v1.0.3
**发布日期**:2025-12-09
---
## ✨ 新增功能
- **授权管理功能与限制设置**:增加授权管理功能与限制设置,支持动态配置和多语言文本,优化许可证表单交互体验
- **添加仪表盘背景图片**:优化主题切换按钮和样式,调整统计卡片暗模式背景
- **增加客户端对接的go语言SDK**:[https://github.com/cedar-v/license-manager-sdk-go](https://github.com/cedar-v/license-manager-sdk-go)
+28
View File
@@ -0,0 +1,28 @@
{
// Vue 文件类型检查配置
"volar.takeOverMode.enabled": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
// TypeScript 检查配置
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.suggest.autoImports": true,
// 启用实时类型检查
"typescript.validate.enable": true,
"javascript.validate.enable": true,
// 显示所有诊断信息(包括未使用的变量)
"typescript.preferences.showUnused": true,
"javascript.preferences.showUnused": true,
// Vue 文件检查
"volar.codeLens.pugTools": false,
"volar.codeLens.scriptSetupTools": true,
// 文件关联
"files.associations": {
"*.vue": "vue"
}
}
@@ -0,0 +1,34 @@
# 授权管理增加功能与限制设置需求(2025-11-27)
## 1. 背景与目标
- 接口 `POST /licenses` 提供 `custom_parameters`、`usage_limits` 两个 JSON 字符串字段,用于描述授权功能和限制。
- 现有前端缺少可视化配置能力,需补充简单易用的表单,支持运营人员直接配置并提交。
## 2. 核心需求
1. 在授权创建/编辑流程中提供两个区域:“功能设置”“限制设置”,均以键值形式展示,可动态增删行。
2. 输入格式统一:`key:value`,键需唯一且只包含小写字母/数字/连字符,值为任意文本。
3. 点击“提交”前将所有条目转换为 JSON 字符串写入对应字段;为空时传空字符串或 `null`。
4. 支持复制当前配置、清空全部、实时预览 JSON。
5. 文案纳入多语言文件,保持与 Vue I18n/Element Plus 一致。
## 3. 交互要点
- UI 使用 Element Plus 表单 + 表格(或列表)即可,提供“添加条目”“删除”“复制”等基础操作。
- 删除前弹出确认,避免误操作;校验不通过时在输入框下方直接提示。
- 不做移动端适配,仅保证桌面端正常使用。
## 4. 接口约定
```json
POST /api/licenses
{
"name": "License A",
"custom_parameters": "{\"feature_dashboard\":\"enabled\"}",
"usage_limits": "{\"device_max\":\"500\"}"
}
```
- 前端负责生成字符串并提交,后端原样存储。
- 详情页读取后解析为键值表格展示,与编辑态保持一致。
## 5. 验收标准
1. 授权创建/编辑页面可顺利增删改键值对,并能提交成功。
2. 校验提示明确,禁止重复或非法键值。
3. 详情页可直接阅读与复制已配置的功能、限制。
+5 -1
View File
@@ -74,7 +74,11 @@ Axios.interceptors.request.use(config => {
})
// 添加响应拦截器
Axios.interceptors.response.use(response => {
// console.log(response)
// 处理文件下载等二进制响应
if (response.config?.responseType === 'blob') {
return response
}
const apiData = response.data as any
// 这个 Code 是和后端约定的业务 Code
const code = apiData.code
+30 -4
View File
@@ -1,3 +1,4 @@
import type { AxiosResponse } from 'axios'
import Axios from './https'
// 授权码(AuthorizationCode)类型 - 供授权管理页面使用
export interface AuthorizationCode {
@@ -83,8 +84,8 @@ export interface AuthorizationCodeCreateRequest {
software_version?: string;
max_activations: number;
feature_config?: any;
usage_limits?: any;
custom_parameters?: any;
usage_limits?: string | null;
custom_parameters?: string | null;
}
// 更新授权请求参数
@@ -97,8 +98,8 @@ export interface LicenseUpdateRequest {
software_version?: string;
max_activations?: number;
feature_config?: any;
usage_limits?: any;
custom_parameters?: any;
usage_limits?: string | null;
custom_parameters?: string | null;
change_type: string;
reason?: string;
}
@@ -214,6 +215,13 @@ export function deactivateLicense(id: string): Promise<ApiResponse<Authorization
return Axios.patch(`/api/v1/authorization-codes/${id}/deactivate`)
}
/**
* 撤销许可证
*/
export function revokeLicense(id: string, reason: string): Promise<ApiResponse> {
return Axios.put(`/api/v1/licenses/${id}/revoke`, { reason })
}
// 许可证设备信息类型(与后端 LicenseListItem 对应)
export interface LicenseDevice {
id: string
@@ -303,6 +311,24 @@ export function getLicenseDeviceDetail(id: string): Promise<ApiResponse<LicenseD
return Axios.get(`/api/v1/licenses/${id}`)
}
/**
* 下载许可证文件
*/
export function downloadLicenseFile(id: string): Promise<AxiosResponse<Blob>> {
return Axios.get(`/api/v1/licenses/${id}/download`, {
responseType: 'blob'
})
}
/**
* 下载授权文件包(授权码+公钥)
*/
export function downloadAuthorizationFile(id: string): Promise<AxiosResponse<Blob>> {
return Axios.get(`/api/v1/authorization-codes/${id}/download`, {
responseType: 'blob'
})
}
// 创建许可证请求(手动绑定设备)
export interface LicenseDeviceCreateRequest {
authorization_code_id: string
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -128,8 +128,19 @@ const quickOptions = computed(() => [
// 响应式数据
const { isMobile } = useDevice()
const selectedQuick = ref('week')
const dateRange = ref<[string, string]>(['2024-05-13', '2024-05-17']) // 初始值会被handleQuickSelect('week')覆盖
const selectedQuick = ref('month')
const getCurrentMonthRange = (): [string, string] => {
const today = new Date()
const startDate = new Date(today.getFullYear(), today.getMonth(), 1)
const endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0)
return [
startDate.toISOString().split('T')[0],
endDate.toISOString().split('T')[0]
]
}
const dateRange = ref<[string, string]>(getCurrentMonthRange())
const chartRef = ref()
const datePickerRef = ref()
@@ -141,6 +152,11 @@ const chartLoading = ref(false)
const chartOption = computed(() => {
const dates = trendData.value.map(item => item.date.substring(5)) // 只显示月-日
const values = trendData.value.map(item => item.value)
const rawMax = values.length ? Math.max(...values) : 0
const baseMax = Math.max(rawMax, 20)
const minTickCount = 4 // 至少展示4个区间
const niceInterval = Math.max(5, Math.ceil(baseMax / minTickCount / 5) * 5)
const niceMax = Math.ceil(baseMax / niceInterval) * niceInterval
return {
grid: {
@@ -169,8 +185,8 @@ const chartOption = computed(() => {
yAxis: {
type: 'value',
min: 0,
max: 20,
interval: 5,
max: niceMax,
interval: niceInterval,
axisLine: {
show: false
},
@@ -367,9 +383,9 @@ const updateChartData = () => {
onMounted(() => {
console.log('LicenseTrendChart 组件已挂载,开始初始化')
// 初始化时直接调用快捷选择,设置为本周
handleQuickSelect('week')
console.log('已调用 handleQuickSelect(week)')
// 初始化时直接调用快捷选择,设置为本月
handleQuickSelect('month')
console.log('已调用 handleQuickSelect(month)')
})
</script>
@@ -98,15 +98,34 @@
<!-- 操作按钮组 -->
<div class="action-buttons">
<!-- 搜索按钮 -->
<!-- 文档 & GitHub 外部链接 -->
<template v-for="link in externalLinks" :key="link.key">
<el-tooltip :content="t(`navigation.tooltip.${link.key}`)" placement="bottom">
<button
class="action-btn external-link-btn"
type="button"
@click="handleExternalLink(link.url)"
>
<span class="external-link-label">{{ t(`navigation.external.${link.key}`) }}</span>
</button>
</el-tooltip>
</template>
<!-- 搜索按钮(暂时禁用) -->
<!--
<el-tooltip :content="t('navigation.tooltip.search')" placement="bottom">
<button class="action-btn" @click="handleSearchClick">
<NavIcon name="search" />
</button>
</el-tooltip>
-->
<!-- 通知按钮 -->
<el-tooltip :content="t('navigation.tooltip.notification')" placement="bottom">
<!-- 通知按钮(暂时隐藏) -->
<el-tooltip
v-if="showNotification"
:content="t('navigation.tooltip.notification')"
placement="bottom"
>
<button class="action-btn notification-btn" @click="handleNotificationClick">
<NavIcon name="notification" />
<span v-if="notificationCount" class="notification-badge">
@@ -116,14 +135,35 @@
</el-tooltip>
<!-- 语言切换按钮 -->
<el-tooltip :content="t('navigation.tooltip.language')" placement="bottom">
<button class="action-btn" @click="handleLanguageClick">
<NavIcon name="language" />
</button>
</el-tooltip>
<el-dropdown trigger="click" @command="handleLanguageChange">
<span class="dropdown-trigger">
<el-tooltip :content="t('navigation.tooltip.language')" placement="bottom">
<button class="action-btn language-btn">
<NavIcon name="language" />
<span class="language-label">{{ currentLanguageLabel }}</span>
</button>
</el-tooltip>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in languageOptions"
:key="option.code"
:command="option.code"
:class="{ active: option.code === currentLanguage }"
>
{{ option.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 主题切换按钮 -->
<el-tooltip :content="t('navigation.tooltip.theme')" placement="bottom">
<!-- 主题切换按钮(暂时禁用) -->
<el-tooltip
v-if="showThemeToggle"
:content="t('navigation.tooltip.theme')"
placement="bottom"
>
<button class="action-btn" @click="handleThemeClick">
<NavIcon name="dark-mode" />
</button>
@@ -134,7 +174,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
@@ -142,6 +182,7 @@ import { useAppStore } from '@/store/modules/app'
import { useUserStore } from '@/store/modules/user'
import NavIcon from '@/components/common/icons/NavIcon.vue'
import { useBreadcrumb } from '@/utils/breadcrumb'
import { changeLanguage, type SupportedLocale } from '@/utils/language'
// 组件属性接口定义
interface Props {
@@ -151,6 +192,23 @@ interface Props {
// 定义组件属性和默认值
const { notificationCount = 24 } = defineProps<Props>()
// 功能暂未启用,隐藏通知入口
const showNotification = false
// 主题功能暂未开发完成,禁用主题切换
const showThemeToggle = false
type ExternalLinkKey = 'docs' | 'github'
interface ExternalLink {
key: ExternalLinkKey
url: string
}
const externalLinks: ExternalLink[] = [
{ key: 'docs', url: 'https://docs.lm.cedar-v.com/' },
{ key: 'github', url: 'https://github.com/cedar-v/license-manager' }
]
// 定义组件事件
const emit = defineEmits<{
sidebarToggle: []
@@ -162,7 +220,7 @@ const emit = defineEmits<{
}>()
// 使用国际化
const { t } = useI18n()
const { t, locale } = useI18n()
// 使用store和组合函数
const router = useRouter()
@@ -197,21 +255,40 @@ const handleSidebarToggle = () => {
emit('sidebarToggle')
}
// 处理搜索点击
const handleSearchClick = () => {
emit('searchClick')
}
// 处理通知按钮点击
const handleNotificationClick = () => {
emit('notificationClick')
}
// 处理语言切换点击
const handleLanguageClick = () => {
emit('languageClick')
const handleExternalLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
// 语言切换
const languageOptions: Array<{ code: SupportedLocale; label: string }> = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'ja', label: '日本語' }
]
const currentLanguage = ref(locale.value as SupportedLocale)
const currentLanguageLabel = computed(
() => languageOptions.find(option => option.code === currentLanguage.value)?.label || ''
)
const handleLanguageChange = (lang: SupportedLocale) => {
if (lang === currentLanguage.value) return
changeLanguage(lang)
currentLanguage.value = lang
emit('languageClick')
window.location.reload()
}
watch(
locale,
value => {
currentLanguage.value = value as SupportedLocale
},
{ immediate: true }
)
// 处理主题切换点击
const handleThemeClick = () => {
const currentTheme = appStore.theme
@@ -537,10 +614,43 @@ const handleUserCommand = async (command: string) => {
transition: all 0.2s;
}
.external-link-btn {
min-width: 72px;
padding: 0 16px;
width: auto;
gap: 6px;
}
.external-link-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-primary);
white-space: nowrap;
}
.action-btn:hover {
background: var(--el-color-primary-light-9);
}
.dropdown-trigger {
display: inline-flex;
}
.language-btn {
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
min-width: 40px;
padding: 0 12px;
}
.language-label {
font-size: 12px;
font-weight: 600;
color: var(--app-text-primary);
}
/* 通知徽章 */
.notification-badge {
position: absolute;
@@ -561,6 +671,11 @@ const handleUserCommand = async (command: string) => {
line-height: 1.3;
}
:deep(.el-dropdown-menu__item.active) {
color: var(--el-color-primary) !important;
font-weight: 600;
}
/* 侧边栏状态配合 - 桌面端 */
.nav-content--sidebar-collapsed {
left: 64px !important; /* 侧边栏收起时的宽度,使用!important确保优先级 */
@@ -744,6 +859,10 @@ const handleUserCommand = async (command: string) => {
background: rgba(255, 255, 255, 0.08) !important;
}
[data-theme="dark"] .external-link-label {
color: #f9fafb !important;
}
[data-theme="dark"] .action-btn:hover {
background: rgba(16, 185, 129, 0.15) !important;
}
@@ -144,7 +144,7 @@ const handleNavClick = (item: NavItem, event: Event) => {
.sidebar__header {
height: 80px;
@include flex-between;
border-bottom: 1px solid var(--app-border-light);
border-bottom: none;
}
.sidebar__logo {
@@ -418,7 +418,7 @@ const handleNavClick = (item: NavItem, event: Event) => {
/* 侧边栏头部分割线 */
[data-theme="dark"] .sidebar__header {
border-bottom-color: rgba(255, 255, 255, 0.12) !important;
border-bottom: none !important;
}
/* Logo文字颜色 */
+85 -7
View File
@@ -64,7 +64,13 @@
"search": "Search",
"notification": "Notifications",
"language": "Switch Language",
"theme": "Switch Theme"
"theme": "Switch Theme",
"docs": "Open Documentation",
"github": "Open GitHub"
},
"external": {
"docs": "Docs",
"github": "GitHub"
},
"breadcrumb": {
"dashboard": "Dashboard",
@@ -332,7 +338,19 @@
"authorizationCode": "Authorization Code",
"forActivation": "(For Client Activation)",
"code": "Authorization Code",
"tipText": "Provide this authorization code to the customer, who can automatically obtain the software license after configuring it in the software product"
"tipText": "Provide this authorization code to the customer, who can automatically obtain the software license after configuring it in the software product",
"featureConfig": "Feature Configuration",
"usageLimits": "Usage Limits",
"customParameters": "Custom Parameters",
"typeColumn": "Type",
"keyColumn": "Key",
"valueColumn": "Value",
"keyValueEmpty": "No configuration available",
"typeOptions": {
"string": "String",
"number": "Number",
"bool": "Boolean"
}
},
"licenseInfo": {
"addButton": "Add License",
@@ -348,8 +366,12 @@
"licenseKey": "License key",
"licenseStatus": "License status",
"statusDescription": "This license is generated and bound to the specified hardware for runtime validation.",
"downloadButton": "Download License File",
"messages": {
"loadError": "Failed to load license devices"
"loadError": "Failed to load license devices",
"downloadSuccess": "License file download started",
"downloadError": "Failed to download license file",
"missingLicense": "License information not found"
}
},
"licenseModal": {
@@ -387,7 +409,7 @@
"updateLicense": "Update License",
"renewLicense": "Renew License",
"revokeLicense": "Revoke License",
"downloadCertificate": "Download Deployment Package"
"downloadCertificate": "Download Authorization File"
},
"messages": {
"copySuccess": "License code copied to clipboard",
@@ -396,11 +418,17 @@
"updateComingSoon": "Update license feature is under development...",
"renewComingSoon": "Renew license feature is under development...",
"revokeComingSoon": "Revoke license feature is under development...",
"downloadComingSoon": "Download certificate feature is under development...",
"downloadSuccess": "Authorization file download started",
"downloadError": "Failed to download authorization file, please try again later",
"revokeConfirm": "Are you sure you want to revoke this license? This action cannot be undone!",
"revokeTitle": "Warning",
"revokeConfirmButton": "Confirm Revoke",
"revokeCancelButton": "Cancel",
"revokeReasonPlaceholder": "Please enter revocation reason",
"revokeReasonRequired": "Revocation reason is required",
"revokeReasonMax": "Revocation reason cannot exceed 500 characters",
"revokeSuccess": "License revoked successfully",
"revokeError": "Failed to revoke the license, please try again later",
"loadError": "Failed to load license details",
"missingId": "Missing license ID"
}
@@ -421,7 +449,10 @@
},
"sections": {
"basicInfo": "Basic Information",
"licenseConfig": "License Configuration"
"licenseConfig": "License Configuration",
"featureConfig": "Feature Configuration",
"usageLimits": "Usage Limits",
"customParameters": "Custom Parameters"
},
"fields": {
"customerName": "Associated Customer Name",
@@ -490,6 +521,47 @@
"cancelTitle": "Confirm",
"cancelConfirmButton": "Confirm",
"cancelCancelButton": "Continue Editing"
},
"keyValue": {
"addItem": "Add Item",
"clearAll": "Clear All",
"copyJson": "Copy JSON",
"copySuccess": "JSON copied",
"copyError": "Failed to copy JSON, please try again",
"copyEmpty": "Nothing to copy yet",
"keyLabel": "Key",
"valueLabel": "Value",
"typeLabel": "Type",
"typeOptions": {
"string": "String",
"number": "Number",
"bool": "Boolean"
},
"keyPlaceholder": "e.g. feature_dashboard",
"valuePlaceholder": "Enter value or description",
"remove": "Delete",
"emptyState": "No entries yet. Click Add Item to configure.",
"preview": "JSON Preview",
"keyRequired": "Please enter a key",
"keyFormat": "Key must be 1-32 characters using lowercase letters, numbers, or hyphens",
"keyDuplicate": "Key must be unique",
"valueRequired": "Please enter a value",
"numberRequired": "Please enter a valid number",
"boolRequired": "Boolean type only accepts true / false",
"booleanTrue": "true",
"booleanFalse": "false",
"validationFailed": "Fix the errors in feature or limit settings before submitting",
"importJson": "Import JSON",
"importTitleFeature": "Import Feature Configuration JSON",
"importTitleLimit": "Import Usage Limits JSON",
"importTitleCustom": "Import Custom Parameters JSON",
"importDescription": "Paste a JSON object whose values can be string, number, or boolean. Confirming will overwrite current entries.",
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
"importCancel": "Cancel",
"importConfirm": "Parse & Overwrite",
"importSuccess": "JSON imported successfully",
"importFailed": "Failed to parse JSON, please check the format",
"importEmpty": "JSON content is empty"
}
}
}
@@ -627,7 +699,13 @@
"activatedDevices": "Activated Devices Count",
"creationTime": "Creation Time",
"expiryTime": "Expiry Time",
"remainingDays": "Remaining Days"
"remainingDays": "Remaining Time",
"remainingTime": {
"permanent": "Permanent",
"years": "{years} year(s)",
"yearMonth": "{years} year(s) {months} month(s)",
"days": "{days} day(s)"
}
}
}
}
+85 -7
View File
@@ -64,7 +64,13 @@
"search": "検索",
"notification": "通知",
"language": "言語切り替え",
"theme": "テーマ切り替え"
"theme": "テーマ切り替え",
"docs": "ドキュメントを開く",
"github": "GitHub を開く"
},
"external": {
"docs": "ドキュメント",
"github": "GitHub"
},
"breadcrumb": {
"dashboard": "ダッシュボード",
@@ -332,7 +338,19 @@
"authorizationCode": "認証コード",
"forActivation": "(クライアントアクティベーション用)",
"code": "認証コード",
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます"
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます",
"featureConfig": "機能構成",
"usageLimits": "利用制限",
"customParameters": "カスタムパラメータ",
"typeColumn": "タイプ",
"keyColumn": "キー",
"valueColumn": "値",
"keyValueEmpty": "設定はありません",
"typeOptions": {
"string": "文字列",
"number": "数値",
"bool": "ブール"
}
},
"licenseInfo": {
"addButton": "ライセンスを追加",
@@ -348,8 +366,12 @@
"licenseKey": "ライセンスキー",
"licenseStatus": "ライセンス状態",
"statusDescription": "このライセンスは指定ハードウェアにバインドされ、システム検証に利用されます",
"downloadButton": "ライセンスファイルをダウンロード",
"messages": {
"loadError": "デバイスライセンスの取得に失敗しました"
"loadError": "デバイスライセンスの取得に失敗しました",
"downloadSuccess": "ライセンスファイルのダウンロードを開始しました",
"downloadError": "ライセンスファイルのダウンロードに失敗しました",
"missingLicense": "ライセンス情報が見つかりません"
}
},
"licenseModal": {
@@ -387,7 +409,7 @@
"updateLicense": "ライセンス更新",
"renewLicense": "ライセンス延長",
"revokeLicense": "ライセンス取り消し",
"downloadCertificate": "デプロイメントパッケージをダウンロード"
"downloadCertificate": "認証ファイルをダウンロード"
},
"messages": {
"copySuccess": "ライセンスコードがクリップボードにコピーされました",
@@ -396,11 +418,17 @@
"updateComingSoon": "ライセンス更新機能は開発中です...",
"renewComingSoon": "ライセンス延長機能は開発中です...",
"revokeComingSoon": "ライセンス取り消し機能は開発中です...",
"downloadComingSoon": "証明書ダウンロード機能は開発中です...",
"downloadSuccess": "認証ファイルのダウンロードを開始しました",
"downloadError": "認証ファイルのダウンロードに失敗しました。後でもう一度お試しください",
"revokeConfirm": "このライセンスを取り消してもよろしいですか?この操作は元に戻せません!",
"revokeTitle": "警告",
"revokeConfirmButton": "取り消し確認",
"revokeCancelButton": "キャンセル",
"revokeReasonPlaceholder": "取り消し理由を入力してください",
"revokeReasonRequired": "取り消し理由は必須です",
"revokeReasonMax": "取り消し理由は500文字以内で入力してください",
"revokeSuccess": "ライセンスを正常に取り消しました",
"revokeError": "ライセンスの取り消しに失敗しました。後でもう一度お試しください",
"loadError": "ライセンス詳細の読み込みに失敗しました",
"missingId": "ライセンスIDが不足しています"
}
@@ -421,7 +449,10 @@
},
"sections": {
"basicInfo": "基本情報",
"licenseConfig": "ライセンス設定"
"licenseConfig": "ライセンス設定",
"featureConfig": "機能構成",
"usageLimits": "利用制限",
"customParameters": "カスタムパラメータ"
},
"fields": {
"customerName": "関連顧客名",
@@ -490,6 +521,47 @@
"cancelTitle": "確認",
"cancelConfirmButton": "確認",
"cancelCancelButton": "編集を続行"
},
"keyValue": {
"addItem": "項目を追加",
"clearAll": "すべてクリア",
"copyJson": "JSONをコピー",
"copySuccess": "JSONをコピーしました",
"copyError": "JSONのコピーに失敗しました。後でもう一度お試しください",
"copyEmpty": "コピーできる内容がありません",
"keyLabel": "キー",
"valueLabel": "値",
"typeLabel": "タイプ",
"typeOptions": {
"string": "文字列",
"number": "数値",
"bool": "ブール"
},
"keyPlaceholder": "例: feature_dashboard",
"valuePlaceholder": "値または説明を入力してください",
"remove": "削除",
"emptyState": "まだ項目がありません。「項目を追加」をクリックしてください",
"preview": "JSONプレビュー",
"keyRequired": "キーを入力してください",
"keyFormat": "キーは小文字英数字とハイフンのみ、1〜32文字で入力してください",
"keyDuplicate": "キーは重複できません",
"valueRequired": "値を入力してください",
"numberRequired": "有効な数値を入力してください",
"boolRequired": "ブール型は true / false のみサポートします",
"booleanTrue": "true",
"booleanFalse": "false",
"validationFailed": "送信前に機能設定または制限設定のエラーを修正してください",
"importJson": "JSONをインポート",
"importTitleFeature": "機能構成 JSON をインポート",
"importTitleLimit": "利用制限 JSON をインポート",
"importTitleCustom": "カスタムパラメータ JSON をインポート",
"importDescription": "キーを設定名とするオブジェクト形式の JSON を貼り付けてください。値は文字列・数値・ブールをサポートします。確定すると現在のリストを上書きします。",
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
"importCancel": "キャンセル",
"importConfirm": "解析して上書き",
"importSuccess": "JSON を取り込みました",
"importFailed": "JSON の解析に失敗しました。形式を確認してください",
"importEmpty": "JSON が空です"
}
}
}
@@ -627,7 +699,13 @@
"activatedDevices": "アクティベート済みデバイス数",
"creationTime": "作成日時",
"expiryTime": "有効期限",
"remainingDays": "残り日数"
"remainingDays": "残り時間",
"remainingTime": {
"permanent": "永久",
"years": "{years}年",
"yearMonth": "{years}年{months}ヶ月",
"days": "{days}日"
}
}
}
}
+89 -11
View File
@@ -64,7 +64,13 @@
"search": "搜索",
"notification": "消息通知",
"language": "切换语言",
"theme": "切换主题"
"theme": "切换主题",
"docs": "查看文档",
"github": "打开 GitHub"
},
"external": {
"docs": "文档",
"github": "GitHub"
},
"breadcrumb": {
"dashboard": "仪表盘",
@@ -332,7 +338,19 @@
"authorizationCode": "授权码",
"forActivation": "(用于客户端激活)",
"code": "授权码",
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证"
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证",
"featureConfig": "功能配置",
"usageLimits": "使用限制",
"customParameters": "自定义参数",
"typeColumn": "类型",
"keyColumn": "键",
"valueColumn": "值",
"keyValueEmpty": "暂无配置",
"typeOptions": {
"string": "字符串",
"number": "数字",
"bool": "布尔"
}
},
"licenseInfo": {
"addButton": "新增许可证",
@@ -348,8 +366,12 @@
"licenseKey": "许可证密钥",
"licenseStatus": "许可证状态",
"statusDescription": "此许可证已生成并绑定到指定硬件,可用于系统运行验证",
"downloadButton": "许可证文件下载",
"messages": {
"loadError": "获取设备列表失败"
"loadError": "获取设备列表失败",
"downloadSuccess": "许可证文件已开始下载",
"downloadError": "下载许可证文件失败",
"missingLicense": "未找到许可证信息"
}
},
"licenseModal": {
@@ -386,8 +408,8 @@
"copyCode": "复制授权码",
"updateLicense": "变更授权",
"renewLicense": "延期授权",
"revokeLicense": "吊销授权",
"downloadCertificate": "下载部署包"
"revokeLicense": "撤销许可证",
"downloadCertificate": "授权文件下载"
},
"messages": {
"copySuccess": "授权码已复制到剪贴板",
@@ -395,12 +417,18 @@
"codeEmpty": "授权码为空",
"updateComingSoon": "变更授权功能开发中...",
"renewComingSoon": "延期授权功能开发中...",
"revokeComingSoon": "吊销授权功能开发中...",
"downloadComingSoon": "下载凭单功能开发中...",
"revokeConfirm": "确定要吊销此授权吗?此操作不可撤销!",
"revokeComingSoon": "撤销许可证功能开发中...",
"downloadSuccess": "授权文件已开始下载",
"downloadError": "下载授权文件失败,请稍后重试",
"revokeConfirm": "确定要撤销此许可证吗?此操作不可逆!",
"revokeTitle": "警告",
"revokeConfirmButton": "确定吊销",
"revokeConfirmButton": "确定撤销",
"revokeCancelButton": "取消",
"revokeReasonPlaceholder": "请输入撤销原因",
"revokeReasonRequired": "请填写撤销原因",
"revokeReasonMax": "撤销原因不能超过500个字符",
"revokeSuccess": "许可证已成功撤销",
"revokeError": "撤销许可证失败,请稍后重试",
"loadError": "加载授权详情失败",
"missingId": "缺少授权ID"
}
@@ -421,7 +449,10 @@
},
"sections": {
"basicInfo": "基本信息",
"licenseConfig": "授权配置"
"licenseConfig": "授权配置",
"featureConfig": "功能配置",
"usageLimits": "使用限制",
"customParameters": "自定义参数"
},
"fields": {
"customerName": "关联客户名称",
@@ -490,6 +521,47 @@
"cancelTitle": "提示",
"cancelConfirmButton": "确定",
"cancelCancelButton": "继续编辑"
},
"keyValue": {
"addItem": "添加条目",
"clearAll": "清空",
"copyJson": "复制JSON",
"copySuccess": "JSON已复制",
"copyError": "复制失败,请稍后重试",
"copyEmpty": "暂无内容可复制",
"keyLabel": "键",
"valueLabel": "值",
"typeLabel": "类型",
"typeOptions": {
"string": "字符串",
"number": "数字",
"bool": "布尔"
},
"keyPlaceholder": "例如 feature_dashboard",
"valuePlaceholder": "请输入值或说明",
"remove": "删除",
"emptyState": "暂无配置,请点击添加条目",
"preview": "JSON 预览",
"keyRequired": "请输入键",
"keyFormat": "键仅支持小写字母、数字和连字符,长度1-32字符",
"keyDuplicate": "键不能重复",
"valueRequired": "请输入值",
"numberRequired": "请输入合法的数字",
"boolRequired": "布尔类型仅支持 true / false",
"booleanTrue": "true",
"booleanFalse": "false",
"validationFailed": "请先修复功能或限制设置中的错误再提交",
"importJson": "导入JSON",
"importTitleFeature": "导入功能配置 JSON",
"importTitleLimit": "导入使用限制 JSON",
"importTitleCustom": "导入自定义参数 JSON",
"importDescription": "粘贴一个对象格式的 JSON,键为配置项,值支持字符串、数字或布尔类型。提交后将覆盖当前列表。",
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
"importCancel": "取消",
"importConfirm": "解析并覆盖",
"importSuccess": "JSON 解析成功",
"importFailed": "JSON 解析失败,请检查格式",
"importEmpty": "JSON 内容为空"
}
}
}
@@ -627,7 +699,13 @@
"activatedDevices": "已激活设备数量",
"creationTime": "创建时间",
"expiryTime": "到期时间",
"remainingDays": "剩余时间(天)"
"remainingDays": "剩余时间",
"remainingTime": {
"permanent": "永久",
"years": "{years}年",
"yearMonth": "{years}年{months}个月",
"days": "{days}天"
}
}
}
}
+34 -6
View File
@@ -185,9 +185,15 @@ const fetchDashboardStats = async () => {
if (data[item.key] !== undefined && typeof data[item.key] !== 'object') {
item.value = data[item.key]
}
// 处理嵌套在 growth_rate 中的字段
// 处理嵌套在 growth_rate 中的字段(授权码增长率和许可证增长率)
if (data.growth_rate && data.growth_rate[item.key] !== undefined) {
item.value = data.growth_rate[item.key]
const rateValue = data.growth_rate[item.key]
// 如果是数字类型,格式化为保留两位小数的百分比
if (typeof rateValue === 'number') {
item.value = `${rateValue.toFixed(2)}%`
} else {
item.value = rateValue
}
}
})
} catch (e) {
@@ -228,10 +234,26 @@ onMounted(() => {
gap: 1.04vw; /* 20px/1920 = 1.04vw */
margin-bottom: 2.08vw; /* 40px/1920 = 2.08vw */
padding: 1.25vw; /* 24px/1920 = 1.25vw */
background: linear-gradient(135deg, #1db584 0%, #5ad8a6 100%);
background: linear-gradient(135deg, #019C7C 0%, #1db584 100%);
border-radius: 0.42vw; /* 8px/1920 = 0.42vw */
position: relative;
overflow: hidden; /* 确保背景图片不溢出 */
// 背景图片层
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('@/assets/images/dashboard-bg.png') lightgray 50% / cover no-repeat;
mix-blend-mode: soft-light;
border-radius: 0.42vw; /* 8px/1920 = 0.42vw */
z-index: 0;
}
// 网格纹理层
&::before {
content: '';
position: absolute;
@@ -252,6 +274,7 @@ onMounted(() => {
-0.52vw 0vw; /* 10px/1920 = 0.52vw */
opacity: 0.15;
border-radius: 0.42vw; /* 8px/1920 = 0.42vw */
z-index: 1;
}
}
@@ -260,7 +283,7 @@ onMounted(() => {
align-items: stretch;
gap: 0.83vw; /* 16px/1920 = 0.83vw */
position: relative;
z-index: 1;
z-index: 2; /* 确保内容显示在背景图片和网格纹理之上 */
min-width: 0;
&:not(:last-child)::after {
@@ -634,9 +657,14 @@ onMounted(() => {
/* 统计卡片区域暗模式优化 */
[data-theme='dark'] .stats-section {
// background: linear-gradient(135deg, #10b981 0%, #34d399 100%) !important;
background: rgba(1, 156, 124, 1) !important;
background: linear-gradient(135deg, #019C7C 0%, #10b981 100%) !important;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15) !important;
// 暗色模式下的背景图片
&::after {
background: url('@/assets/images/dashboard-bg.png') lightgray 50% / cover no-repeat;
mix-blend-mode: soft-light;
}
}
/* 表格卡片暗模式 */
+722 -2
View File
@@ -210,13 +210,303 @@
</el-form-item>
</div>
</div>
<!-- 功能配置 -->
<div class="license-form">
<div class="key-value-section-header">
<h3 class="section-title">{{ t('pages.licenses.form.sections.featureConfig') }}</h3>
<div class="key-value-section-actions">
<el-button size="small" @click="addFeatureConfigEntry">
{{ t('pages.licenses.form.keyValue.addItem') }}
</el-button>
<el-button
size="small"
text
:disabled="!featureConfigEntries.length"
@click="clearFeatureConfigEntries"
>
{{ t('pages.licenses.form.keyValue.clearAll') }}
</el-button>
<el-button
size="small"
text
:disabled="!featureConfigPreview"
@click="copyJson(featureConfigPreview)"
>
{{ t('pages.licenses.form.keyValue.copyJson') }}
</el-button>
<el-button size="small" text @click="openImportDialog('feature')">
{{ t('pages.licenses.form.keyValue.importJson') }}
</el-button>
</div>
</div>
<div class="key-value-list">
<div v-for="(item, index) in featureConfigEntries" :key="item.id" class="key-value-row">
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
<el-input
v-model="item.key"
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
@input="item.keyError = ''"
/>
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
</div>
<div class="key-value-field type-field">
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
<el-option
v-for="type in typeOptions"
:key="type"
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
:value="type"
/>
</el-select>
</div>
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
<template v-if="item.type === 'bool'">
<el-select v-model="item.value" @change="item.valueError = ''">
<el-option
v-for="option in boolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<el-input
v-else
v-model="item.value"
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
@input="item.valueError = ''"
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
/>
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
</div>
<el-button link type="danger" @click="removeFeatureConfigEntry(index)">
{{ t('pages.licenses.form.keyValue.remove') }}
</el-button>
</div>
<div v-if="!featureConfigEntries.length" class="key-value-empty">
{{ t('pages.licenses.form.keyValue.emptyState') }}
</div>
</div>
<div class="key-value-preview">
<div class="key-value-preview-label">
{{ t('pages.licenses.form.keyValue.preview') }}
</div>
<el-input type="textarea" :rows="3" :model-value="featureConfigPreview" readonly />
</div>
</div>
<!-- 限制设置 -->
<div class="license-form">
<div class="key-value-section-header">
<h3 class="section-title">{{ t('pages.licenses.form.sections.usageLimits') }}</h3>
<div class="key-value-section-actions">
<el-button size="small" @click="addLimitEntry">
{{ t('pages.licenses.form.keyValue.addItem') }}
</el-button>
<el-button size="small" text :disabled="!limitEntries.length" @click="clearLimitEntries">
{{ t('pages.licenses.form.keyValue.clearAll') }}
</el-button>
<el-button
size="small"
text
:disabled="!usageJsonPreview"
@click="copyJson(usageJsonPreview)"
>
{{ t('pages.licenses.form.keyValue.copyJson') }}
</el-button>
<el-button size="small" text @click="openImportDialog('limit')">
{{ t('pages.licenses.form.keyValue.importJson') }}
</el-button>
</div>
</div>
<div class="key-value-list">
<div v-for="(item, index) in limitEntries" :key="item.id" class="key-value-row">
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
<el-input
v-model="item.key"
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
@input="item.keyError = ''"
/>
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
</div>
<div class="key-value-field type-field">
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
<el-option
v-for="type in typeOptions"
:key="type"
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
:value="type"
/>
</el-select>
</div>
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
<template v-if="item.type === 'bool'">
<el-select v-model="item.value" @change="item.valueError = ''">
<el-option
v-for="option in boolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<el-input
v-else
v-model="item.value"
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
@input="item.valueError = ''"
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
/>
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
</div>
<el-button link type="danger" @click="removeLimitEntry(index)">
{{ t('pages.licenses.form.keyValue.remove') }}
</el-button>
</div>
<div v-if="!limitEntries.length" class="key-value-empty">
{{ t('pages.licenses.form.keyValue.emptyState') }}
</div>
</div>
<div class="key-value-preview">
<div class="key-value-preview-label">
{{ t('pages.licenses.form.keyValue.preview') }}
</div>
<el-input type="textarea" :rows="3" :model-value="usageJsonPreview" readonly />
</div>
</div>
<!-- 自定义参数 -->
<div class="license-form">
<div class="key-value-section-header">
<h3 class="section-title">{{ t('pages.licenses.form.sections.customParameters') }}</h3>
<div class="key-value-section-actions">
<el-button size="small" @click="addCustomEntry">
{{ t('pages.licenses.form.keyValue.addItem') }}
</el-button>
<el-button size="small" text :disabled="!customEntries.length" @click="clearCustomEntries">
{{ t('pages.licenses.form.keyValue.clearAll') }}
</el-button>
<el-button
size="small"
text
:disabled="!customJsonPreview"
@click="copyJson(customJsonPreview)"
>
{{ t('pages.licenses.form.keyValue.copyJson') }}
</el-button>
<el-button size="small" text @click="openImportDialog('custom')">
{{ t('pages.licenses.form.keyValue.importJson') }}
</el-button>
</div>
</div>
<div class="key-value-list">
<div v-for="(item, index) in customEntries" :key="item.id" class="key-value-row">
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.keyLabel') }}</label>
<el-input
v-model="item.key"
:placeholder="t('pages.licenses.form.keyValue.keyPlaceholder')"
@input="item.keyError = ''"
/>
<p v-if="item.keyError" class="key-value-error">{{ item.keyError }}</p>
</div>
<div class="key-value-field type-field">
<label>{{ t('pages.licenses.form.keyValue.typeLabel') }}</label>
<el-select v-model="item.type" @change="handleEntryTypeChange(item)">
<el-option
v-for="type in typeOptions"
:key="type"
:label="t(`pages.licenses.form.keyValue.typeOptions.${type}`)"
:value="type"
/>
</el-select>
</div>
<div class="key-value-field">
<label>{{ t('pages.licenses.form.keyValue.valueLabel') }}</label>
<template v-if="item.type === 'bool'">
<el-select v-model="item.value" @change="item.valueError = ''">
<el-option
v-for="option in boolOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<el-input
v-else
v-model="item.value"
:placeholder="t('pages.licenses.form.keyValue.valuePlaceholder')"
@input="item.valueError = ''"
:inputmode="item.type === 'number' ? 'decimal' : 'text'"
/>
<p v-if="item.valueError" class="key-value-error">{{ item.valueError }}</p>
</div>
<el-button link type="danger" @click="removeCustomEntry(index)">
{{ t('pages.licenses.form.keyValue.remove') }}
</el-button>
</div>
<div v-if="!customEntries.length" class="key-value-empty">
{{ t('pages.licenses.form.keyValue.emptyState') }}
</div>
</div>
<div class="key-value-preview">
<div class="key-value-preview-label">
{{ t('pages.licenses.form.keyValue.preview') }}
</div>
<el-input type="textarea" :rows="3" :model-value="customJsonPreview" readonly />
</div>
</div>
</el-form>
</div>
</div>
<el-dialog
v-model="importDialogVisible"
:title="importDialogTitle"
width="520px"
destroy-on-close
>
<p class="import-dialog-tip">
{{ t('pages.licenses.form.keyValue.importDescription') }}
</p>
<el-input
v-model="importDialogContent"
type="textarea"
:rows="8"
:placeholder="t('pages.licenses.form.keyValue.importPlaceholder')"
/>
<p v-if="importDialogError" class="key-value-error import-error">{{ importDialogError }}</p>
<template #footer>
<el-button @click="importDialogVisible = false">
{{ t('pages.licenses.form.keyValue.importCancel') }}
</el-button>
<el-button type="primary" @click="handleImportConfirm">
{{ t('pages.licenses.form.keyValue.importConfirm') }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import type { Ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -230,9 +520,26 @@ import {
import { getCustomers, type Customer } from '@/api/customer'
import { getEnumOptions, type RawEnumItem } from '@/api/enum'
type KeyValueType = 'string' | 'number' | 'bool'
interface KeyValueItem {
id: string
key: string
value: string
type: KeyValueType
keyError?: string
valueError?: string
}
const KEY_PATTERN = /^[a-z0-9-]{1,32}$/
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const boolOptions = computed(() => [
{ label: t('pages.licenses.form.keyValue.booleanTrue'), value: 'true' },
{ label: t('pages.licenses.form.keyValue.booleanFalse'), value: 'false' }
])
// 表单引用
const formRef = ref<FormInstance>()
@@ -242,10 +549,28 @@ const submitting = ref(false)
const customerLoading = ref(false)
const customerOptions = ref<Customer[]>([])
const featureConfigEntries = ref<KeyValueItem[]>([])
const limitEntries = ref<KeyValueItem[]>([])
const customEntries = ref<KeyValueItem[]>([])
const importDialogVisible = ref(false)
const importDialogTarget = ref<'feature' | 'limit' | 'custom'>('feature')
const importDialogTitle = computed(() => {
if (importDialogTarget.value === 'limit') {
return t('pages.licenses.form.keyValue.importTitleLimit')
}
if (importDialogTarget.value === 'custom') {
return t('pages.licenses.form.keyValue.importTitleCustom')
}
return t('pages.licenses.form.keyValue.importTitleFeature')
})
const importDialogContent = ref('')
const importDialogError = ref('')
// 枚举选项
const deploymentTypeOptions = ref<RawEnumItem[]>([])
const encryptionTypeOptions = ref<RawEnumItem[]>([])
const typeOptions: KeyValueType[] = ['string', 'number', 'bool']
// 表单数据
const formData = reactive<
AuthorizationCodeCreateRequest & {
@@ -262,13 +587,17 @@ const formData = reactive<
encryption_type: 'standard',
max_activations: 1,
feature_config: {},
usage_limits: {},
custom_parameters: {}
usage_limits: '',
custom_parameters: ''
})
// 独立的响应式变量
const dateRange = ref<[string, string] | null>(null)
const featureConfigPreview = computed(() => buildJsonString(featureConfigEntries.value))
const usageJsonPreview = computed(() => buildJsonString(limitEntries.value))
const customJsonPreview = computed(() => buildJsonString(customEntries.value))
// 计算属性
const isEdit = computed(() => {
return route.name === 'licenses-edit' && route.params.id
@@ -415,6 +744,277 @@ const formRules: FormRules = {
]
}
const createEntryId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const createEmptyEntry = (): KeyValueItem => ({
id: createEntryId(),
key: '',
value: '',
type: 'string',
keyError: '',
valueError: ''
})
const convertValueByType = (value: string, type: KeyValueType) => {
if (type === 'number') {
const num = Number(value)
if (Number.isNaN(num)) {
throw new Error('invalid number')
}
return num
}
if (type === 'bool') {
return value === 'true'
}
return value
}
const buildJsonRecord = (entries: KeyValueItem[]) => {
if (!entries.length) {
return null
}
const result: Record<string, string | number | boolean> = {}
entries.forEach(item => {
const trimmedKey = item.key.trim()
if (trimmedKey) {
try {
result[trimmedKey] = convertValueByType(item.value, item.type)
} catch {
// 如果转换失败,则忽略该条目,等待校验提示
}
}
})
return Object.keys(result).length ? result : null
}
const buildJsonString = (entries: KeyValueItem[]) => {
const record = buildJsonRecord(entries)
return record ? JSON.stringify(record) : ''
}
const addFeatureConfigEntry = () => {
featureConfigEntries.value.push(createEmptyEntry())
}
const addLimitEntry = () => {
limitEntries.value.push(createEmptyEntry())
}
const addCustomEntry = () => {
customEntries.value.push(createEmptyEntry())
}
const removeFeatureConfigEntry = (index: number) => {
featureConfigEntries.value.splice(index, 1)
}
const removeLimitEntry = (index: number) => {
limitEntries.value.splice(index, 1)
}
const removeCustomEntry = (index: number) => {
customEntries.value.splice(index, 1)
}
const clearFeatureConfigEntries = () => {
featureConfigEntries.value = []
}
const clearLimitEntries = () => {
limitEntries.value = []
}
const clearCustomEntries = () => {
customEntries.value = []
}
const handleEntryTypeChange = (entry: KeyValueItem) => {
if (entry.type === 'bool') {
entry.value = entry.value === 'false' ? 'false' : 'true'
} else if (entry.type === 'number') {
if (entry.value && Number.isNaN(Number(entry.value))) {
entry.value = ''
}
}
entry.valueError = ''
}
const fallbackCopy = (value: string) => {
if (typeof document === 'undefined') {
throw new Error('document is not available')
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
const copyJson = async (value: string) => {
if (!value) {
ElMessage.info(t('pages.licenses.form.keyValue.copyEmpty'))
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
} else {
fallbackCopy(value)
}
ElMessage.success(t('pages.licenses.form.keyValue.copySuccess'))
} catch (error) {
console.error('复制JSON失败:', error)
ElMessage.error(t('pages.licenses.form.keyValue.copyError'))
}
}
const validateKeyValueEntries = (entries: KeyValueItem[]) => {
let isValid = true
const existingKeys = new Set<string>()
entries.forEach(item => {
item.keyError = ''
item.valueError = ''
const trimmedKey = item.key.trim()
if (!trimmedKey) {
item.keyError = t('pages.licenses.form.keyValue.keyRequired')
isValid = false
} else if (!KEY_PATTERN.test(trimmedKey)) {
item.keyError = t('pages.licenses.form.keyValue.keyFormat')
isValid = false
} else if (existingKeys.has(trimmedKey)) {
item.keyError = t('pages.licenses.form.keyValue.keyDuplicate')
isValid = false
} else {
existingKeys.add(trimmedKey)
}
if (item.value.trim() === '') {
item.valueError = t('pages.licenses.form.keyValue.valueRequired')
isValid = false
} else if (item.type === 'number') {
const num = Number(item.value)
if (Number.isNaN(num)) {
item.valueError = t('pages.licenses.form.keyValue.numberRequired')
isValid = false
}
} else if (item.type === 'bool') {
if (item.value !== 'true' && item.value !== 'false') {
item.valueError = t('pages.licenses.form.keyValue.boolRequired')
isValid = false
}
}
})
return isValid
}
const openImportDialog = (target: 'feature' | 'limit' | 'custom') => {
importDialogTarget.value = target
importDialogContent.value =
target === 'feature'
? featureConfigPreview.value
: target === 'limit'
? usageJsonPreview.value
: customJsonPreview.value
importDialogError.value = ''
importDialogVisible.value = true
}
const handleImportConfirm = () => {
importDialogError.value = ''
try {
const content = importDialogContent.value.trim()
if (!content) {
if (importDialogTarget.value === 'feature') {
featureConfigEntries.value = []
} else if (importDialogTarget.value === 'limit') {
limitEntries.value = []
} else {
customEntries.value = []
}
} else {
const entries = parseJsonToEntries(content, { strict: true })
if (!entries.length) {
throw new Error(t('pages.licenses.form.keyValue.importEmpty'))
}
if (importDialogTarget.value === 'feature') {
featureConfigEntries.value = entries
} else if (importDialogTarget.value === 'limit') {
limitEntries.value = entries
} else {
customEntries.value = entries
}
}
importDialogVisible.value = false
ElMessage.success(t('pages.licenses.form.keyValue.importSuccess'))
} catch (error: any) {
importDialogError.value =
error?.message || t('pages.licenses.form.keyValue.importFailed')
}
}
const mapObjectToEntries = (data: Record<string, any>): KeyValueItem[] => {
return Object.entries(data).map(([key, value]) => {
let type: KeyValueType = 'string'
let normalizedValue = ''
if (typeof value === 'number') {
type = 'number'
normalizedValue = String(value)
} else if (typeof value === 'boolean') {
type = 'bool'
normalizedValue = value ? 'true' : 'false'
} else {
normalizedValue = value === undefined || value === null ? '' : String(value)
}
return {
id: createEntryId(),
key,
value: normalizedValue,
type,
keyError: '',
valueError: ''
}
})
}
const parseJsonToEntries = (raw: unknown, options?: { strict?: boolean }): KeyValueItem[] => {
if (!raw) return []
let parsed: unknown = raw
if (typeof raw === 'string') {
if (!raw.trim()) return []
try {
parsed = JSON.parse(raw)
} catch (error) {
if (options?.strict) {
throw error
}
console.warn('解析JSON失败:', error)
return []
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
if (options?.strict) {
throw new Error('JSON payload must be an object with key-value pairs')
}
return []
}
return mapObjectToEntries(parsed as Record<string, any>)
}
const hydrateEntriesFromField = (raw: unknown, target: Ref<KeyValueItem[]>) => {
target.value = parseJsonToEntries(raw)
}
// 方法
const loadEnumOptions = async () => {
try {
@@ -600,6 +1200,10 @@ const loadLicenseDetail = async () => {
} as Customer
]
}
hydrateEntriesFromField(data.feature_config, featureConfigEntries)
hydrateEntriesFromField(data.usage_limits, limitEntries)
hydrateEntriesFromField(data.custom_parameters, customEntries)
} else {
throw new Error(response.message || t('pages.licenses.form.messages.loadDetailError'))
}
@@ -622,6 +1226,18 @@ const handleSubmit = async () => {
// 执行表单验证
await formRef.value.validate()
const featureConfigValid = validateKeyValueEntries(featureConfigEntries.value)
const limitValid = validateKeyValueEntries(limitEntries.value)
const customValid = validateKeyValueEntries(customEntries.value)
if (!featureConfigValid || !limitValid || !customValid) {
ElMessage.error(t('pages.licenses.form.keyValue.validationFailed'))
return
}
formData.feature_config = buildJsonRecord(featureConfigEntries.value) || {}
formData.usage_limits = usageJsonPreview.value
formData.custom_parameters = customJsonPreview.value
submitting.value = true
if (isEdit.value) {
@@ -709,6 +1325,30 @@ watch(
}
)
watch(
limitEntries,
() => {
formData.usage_limits = usageJsonPreview.value
},
{ deep: true }
)
watch(
featureConfigEntries,
() => {
formData.feature_config = buildJsonRecord(featureConfigEntries.value) || {}
},
{ deep: true }
)
watch(
customEntries,
() => {
formData.custom_parameters = customJsonPreview.value
},
{ deep: true }
)
// 生命周期
onMounted(async () => {
await loadEnumOptions()
@@ -801,6 +1441,86 @@ onMounted(async () => {
margin-bottom: 0;
}
.key-value-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.key-value-section-actions {
display: flex;
gap: 8px;
align-items: center;
}
.key-value-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.key-value-row {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.key-value-field {
flex: 1;
}
.key-value-field.type-field {
flex: 0 0 150px;
}
.key-value-field label {
display: block;
font-size: 12px;
color: var(--app-text-secondary);
margin-bottom: 6px;
}
.key-value-error {
color: #f56c6c;
font-size: 12px;
margin-top: 6px;
}
.key-value-empty {
border: 1px dashed var(--app-border-color);
border-radius: 4px;
padding: 16px;
text-align: center;
color: var(--app-text-secondary);
font-size: 13px;
}
.key-value-preview {
margin-top: 16px;
}
.key-value-preview-label {
font-size: 12px;
color: var(--app-text-secondary);
margin-bottom: 8px;
}
.key-value-row > .el-button {
margin-top: 22px;
}
.import-dialog-tip {
font-size: 13px;
color: var(--app-text-secondary);
margin-bottom: 12px;
}
.import-error {
margin-top: 8px;
}
/* Element Plus 样式覆盖 */
:deep(.el-form-item__label) {
font-weight: 400;
@@ -12,10 +12,6 @@
<div class="info-label">{{ t('pages.licenses.detail.authorizationInfo.customerCode') }}:</div>
<div class="info-value">{{ licenseData?.customer_info?.customer_code || '-' }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ t('pages.licenses.detail.authorizationInfo.createdBy') }}:</div>
<div class="info-value">{{ licenseData?.created_by || '-' }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ t('pages.licenses.detail.authorizationInfo.description') }}:</div>
<div class="info-value">{{ licenseData?.description || '-' }}</div>
@@ -69,10 +65,71 @@
</div>
</div>
</div>
<!-- 功能配置 -->
<div class="info-card">
<div class="card-title">{{ t('pages.licenses.detail.authorizationInfo.featureConfig') }}</div>
<div v-if="featureConfig.length" class="key-value-table">
<div class="key-value-header">
<span>{{ t('pages.licenses.detail.authorizationInfo.keyColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.typeColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.valueColumn') }}</span>
</div>
<div v-for="item in featureConfig" :key="`feature-${item.key}`" class="key-value-row">
<span class="key-cell">{{ item.key }}</span>
<span class="type-cell">{{ t(`pages.licenses.detail.authorizationInfo.typeOptions.${item.type}`) }}</span>
<span class="value-cell">{{ item.value || '-' }}</span>
</div>
</div>
<div v-else class="key-value-empty">
{{ t('pages.licenses.detail.authorizationInfo.keyValueEmpty') }}
</div>
</div>
<!-- 使用限制 -->
<div class="info-card">
<div class="card-title">{{ t('pages.licenses.detail.authorizationInfo.usageLimits') }}</div>
<div v-if="usageLimits.length" class="key-value-table">
<div class="key-value-header">
<span>{{ t('pages.licenses.detail.authorizationInfo.keyColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.typeColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.valueColumn') }}</span>
</div>
<div v-for="item in usageLimits" :key="`limit-${item.key}`" class="key-value-row">
<span class="key-cell">{{ item.key }}</span>
<span class="type-cell">{{ t(`pages.licenses.detail.authorizationInfo.typeOptions.${item.type}`) }}</span>
<span class="value-cell">{{ item.value || '-' }}</span>
</div>
</div>
<div v-else class="key-value-empty">
{{ t('pages.licenses.detail.authorizationInfo.keyValueEmpty') }}
</div>
</div>
<!-- 自定义参数 -->
<div class="info-card">
<div class="card-title">{{ t('pages.licenses.detail.authorizationInfo.customParameters') }}</div>
<div v-if="customParameters.length" class="key-value-table">
<div class="key-value-header">
<span>{{ t('pages.licenses.detail.authorizationInfo.keyColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.typeColumn') }}</span>
<span>{{ t('pages.licenses.detail.authorizationInfo.valueColumn') }}</span>
</div>
<div v-for="item in customParameters" :key="`custom-${item.key}`" class="key-value-row">
<span class="key-cell">{{ item.key }}</span>
<span class="type-cell">{{ t(`pages.licenses.detail.authorizationInfo.typeOptions.${item.type}`) }}</span>
<span class="value-cell">{{ item.value || '-' }}</span>
</div>
</div>
<div v-else class="key-value-empty">
{{ t('pages.licenses.detail.authorizationInfo.keyValueEmpty') }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AuthorizationCode } from '@/api/license'
import { formatDateShort as formatDateUtil } from '@/utils/date'
@@ -84,6 +141,57 @@ interface Props {
const props = defineProps<Props>()
const { t } = useI18n()
type KeyValueType = 'string' | 'number' | 'bool'
interface KeyValueItem {
key: string
value: string
type: KeyValueType
}
const parseKeyValueData = (source: unknown): KeyValueItem[] => {
if (!source) return []
let parsed: Record<string, any> | null = null
if (typeof source === 'string') {
if (!source.trim()) return []
try {
parsed = JSON.parse(source)
} catch (error) {
console.warn('[AuthorizationInfo] Failed to parse JSON:', error)
return []
}
} else if (typeof source === 'object') {
parsed = source as Record<string, any>
}
if (!parsed) return []
return Object.entries(parsed).map(([key, value]) => {
let type: KeyValueType = 'string'
let normalizedValue = ''
if (typeof value === 'number') {
type = 'number'
normalizedValue = String(value)
} else if (typeof value === 'boolean') {
type = 'bool'
normalizedValue = value ? 'true' : 'false'
} else {
normalizedValue = value === undefined || value === null ? '' : String(value)
}
return {
key,
value: normalizedValue,
type
}
})
}
const featureConfig = computed(() => parseKeyValueData(props.licenseData?.feature_config))
const usageLimits = computed(() => parseKeyValueData(props.licenseData?.usage_limits))
const customParameters = computed(() => parseKeyValueData(props.licenseData?.custom_parameters))
// 获取授权期限类型
const getLicensePeriodType = () => {
// 这里假设有个字段表示授权期限类型,如果没有则返回"有效期限"
@@ -204,6 +312,54 @@ const formatDateRange = () => {
flex: 1;
}
}
.key-value-table {
border: 1px solid #eef0f2;
border-radius: 4px;
overflow: hidden;
.key-value-header,
.key-value-row {
display: grid;
grid-template-columns: 200px 140px 1fr;
padding: 10px 16px;
gap: 12px;
}
.key-value-header {
background: #f5f6f8;
font-weight: 600;
font-size: 13px;
color: #1d1d1d;
}
.key-value-row {
font-size: 13px;
color: #1d1d1d;
border-top: 1px solid #eef0f2;
.key-cell {
font-weight: 600;
}
.type-cell {
color: #606266;
font-weight: 500;
}
.value-cell {
word-break: break-all;
}
}
}
.key-value-empty {
border: 1px dashed #d7dbe2;
border-radius: 4px;
padding: 12px 16px;
color: #909399;
font-size: 13px;
}
}
// 响应式布局
@@ -38,10 +38,10 @@
</div>
</div>
<!-- 剩余时间(天) -->
<!-- 剩余时间 -->
<div class="stat-card stat-card-4">
<div class="card-content">
<div class="stat-value">{{ getRemainingDays(licenseData?.end_date) }}</div>
<div class="stat-value">{{ formatRemainingTime(licenseData?.end_date) }}</div>
<div class="stat-label">{{ $t('customers.basicInfo.remainingDays') }}</div>
</div>
</div>
@@ -52,12 +52,14 @@
<script setup lang="ts">
import type { AuthorizationCode } from '@/api/license'
import { formatDateShort as formatDateUtil } from '@/utils/date'
import { useI18n } from 'vue-i18n'
interface Props {
licenseData: AuthorizationCode | null
}
defineProps<Props>()
const { t } = useI18n()
const getActivatedLicenses = (license?: AuthorizationCode | null) => {
if (!license) return 0
@@ -75,13 +77,34 @@ const formatDateShort = (date?: string) => {
return formatDateUtil(date)
}
const getRemainingDays = (endDate?: string) => {
const formatRemainingTime = (endDate?: string) => {
if (!endDate) return '-'
const now = new Date()
const end = new Date(endDate)
const diff = end.getTime() - now.getTime()
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
return days > 0 ? days : 0
if (Number.isNaN(end.getTime())) return '-'
const now = new Date()
const diffMs = end.getTime() - now.getTime()
const days = Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)))
if (days > 36500) {
return t('customers.basicInfo.remainingTime.permanent')
}
if (days >= 731) {
const years = Math.floor(days / 365)
return t('customers.basicInfo.remainingTime.years', { years })
}
if (days >= 366) {
const years = Math.floor(days / 365)
const remainingDays = days - years * 365
if (remainingDays === 0) {
return t('customers.basicInfo.remainingTime.years', { years })
}
const months = Math.max(1, Math.round(remainingDays / 30))
return t('customers.basicInfo.remainingTime.yearMonth', { years, months })
}
return t('customers.basicInfo.remainingTime.days', { days })
}
</script>
@@ -146,6 +146,28 @@
</div>
</div>
</div>
<!-- 下载按钮 -->
<div class="download-section">
<el-button
class="download-license-btn"
type="primary"
:loading="isDownloading(device.id)"
@click="handleDownloadLicense(device)"
>
{{ t('pages.licenses.detail.licenseInfo.downloadButton') }}
</el-button>
<el-button
class="revoke-license-btn"
type="danger"
plain
:loading="isRevoking(device.id)"
:disabled="device.status === 'revoked'"
@click="handleRevokeLicense(device)"
>
{{ t('pages.licenses.detail.actions.revokeLicense') }}
</el-button>
</div>
</div>
</div>
@@ -162,16 +184,20 @@
<script setup lang="ts">
import { ref, onMounted, watch, reactive, computed, nextTick } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { AuthorizationCode, LicenseDevice, LicenseDeviceCreateRequest } from '@/api/license'
import { getLicenseDevices, getLicenseDeviceDetail, createLicenseDevice } from '@/api/license'
import { getLicenseDevices, getLicenseDeviceDetail, createLicenseDevice, downloadLicenseFile, revokeLicense } from '@/api/license'
import { formatDate } from '@/utils/date'
interface Props {
licenseData: AuthorizationCode | null
}
const emit = defineEmits<{
(e: 'licenseRevoked'): void
}>()
const props = defineProps<Props>()
const { t } = useI18n()
@@ -184,6 +210,8 @@ const addForm = reactive({
hardware_fingerprint: '',
remark: ''
})
const downloadingMap = reactive<Record<string, boolean>>({})
const revokingMap = reactive<Record<string, boolean>>({})
const authorizationCodeId = computed(() => props.licenseData?.id || '')
const authorizationCodeValue = computed(() => props.licenseData?.code || '--')
@@ -322,6 +350,116 @@ const handleCopyAuthorizationCode = async () => {
}
}
const isDownloading = (licenseId: string) => {
return Boolean(licenseId && downloadingMap[licenseId])
}
const isRevoking = (licenseId: string) => {
return Boolean(licenseId && revokingMap[licenseId])
}
const extractFilename = (contentDisposition?: string) => {
if (!contentDisposition) return ''
const utfMatch = /filename\*=utf-8''([^;]+)/i.exec(contentDisposition)
if (utfMatch?.[1]) {
try {
return decodeURIComponent(utfMatch[1])
} catch (error) {
console.warn('Failed to decode filename from header:', error)
}
}
const asciiMatch = /filename="?([^"]+)"?/i.exec(contentDisposition)
return asciiMatch?.[1] || ''
}
const handleDownloadLicense = async (device: LicenseDevice) => {
if (!device?.id) {
ElMessage.warning(t('pages.licenses.detail.licenseInfo.messages.missingLicense'))
return
}
downloadingMap[device.id] = true
try {
const response = await downloadLicenseFile(device.id)
const disposition =
response.headers?.['content-disposition'] || response.headers?.['Content-Disposition']
const filename =
extractFilename(disposition) ||
`${device.customer_name || 'license'}-${device.id}.zip`
const blobUrl = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
ElMessage.success(t('pages.licenses.detail.licenseInfo.messages.downloadSuccess'))
} catch (error: any) {
console.error('下载许可证失败:', error)
const backendMessage = error?.backendMessage || error?.message
ElMessage.error(
backendMessage || t('pages.licenses.detail.licenseInfo.messages.downloadError')
)
} finally {
downloadingMap[device.id] = false
}
}
const handleRevokeLicense = async (device: LicenseDevice) => {
if (!device?.id) {
ElMessage.error(t('pages.licenses.detail.messages.missingId'))
return
}
if (device.status === 'revoked') {
return
}
let reason = ''
try {
const { value } = await ElMessageBox.prompt(
t('pages.licenses.detail.messages.revokeConfirm'),
t('pages.licenses.detail.messages.revokeTitle'),
{
confirmButtonText: t('pages.licenses.detail.messages.revokeConfirmButton'),
cancelButtonText: t('pages.licenses.detail.messages.revokeCancelButton'),
type: 'warning',
inputType: 'textarea',
inputPlaceholder: t('pages.licenses.detail.messages.revokeReasonPlaceholder'),
inputValidator: (val: string) => {
if (!val || !val.trim()) {
return t('pages.licenses.detail.messages.revokeReasonRequired')
}
if (val.trim().length > 500) {
return t('pages.licenses.detail.messages.revokeReasonMax')
}
return true
},
inputErrorMessage: t('pages.licenses.detail.messages.revokeReasonRequired')
}
)
reason = value.trim()
} catch {
return
}
revokingMap[device.id] = true
try {
await revokeLicense(device.id, reason)
ElMessage.success(t('pages.licenses.detail.messages.revokeSuccess'))
await fetchDevices()
emit('licenseRevoked')
} catch (error: any) {
ElMessage.error(error?.message || t('pages.licenses.detail.messages.revokeError'))
} finally {
revokingMap[device.id] = false
}
}
const submitAddLicense = async () => {
if (!addFormRef.value) return
@@ -621,6 +759,42 @@ const submitAddLicense = async () => {
}
}
}
.download-license-btn {
min-width: 148px;
background: #00C27C;
border: none;
color: #FFFFFF;
font-family: 'Source Han Sans CN', sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 21px;
border-radius: 4px;
&:hover {
background: #019C7C;
}
}
}
.download-section {
display: flex;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.revoke-license-btn {
min-width: 148px;
border: 1px solid #F0142F;
color: #F0142F;
background: transparent;
&:hover:not(.is-disabled) {
background: rgba(240, 20, 47, 0.08);
color: #d01228;
border-color: #d01228;
}
}
.empty-state {
@@ -62,9 +62,6 @@
<el-button class="action-btn-inline renew-btn" @click="handleRenewLicense">
{{ $t('pages.licenses.detail.actions.renewLicense') }}
</el-button>
<el-button class="action-btn-inline revoke-btn" @click="handleRevokeLicense">
{{ $t('pages.licenses.detail.actions.revokeLicense') }}
</el-button>
<el-button class="action-btn-inline download-btn" @click="handleDownloadCertificate">
{{ $t('pages.licenses.detail.actions.downloadCertificate') }}
</el-button>
@@ -75,7 +72,11 @@
<div class="tab-content-area">
<BasicInfo v-if="activeTab === 'basic'" :license-data="licenseData" />
<AuthorizationInfo v-if="activeTab === 'authorization'" :license-data="licenseData" />
<LicenseInfo v-if="activeTab === 'license'" :license-data="licenseData" />
<LicenseInfo
v-if="activeTab === 'license'"
:license-data="licenseData"
@license-revoked="loadLicenseData"
/>
<ChangeHistory v-if="activeTab === 'history'" :license-data="licenseData" />
</div>
</div>
@@ -86,9 +87,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { getLicenseDetail, type AuthorizationCode } from '@/api/license'
import { getLicenseDetail, downloadAuthorizationFile, type AuthorizationCode } from '@/api/license'
import LicenseTabIcon from '@/components/common/icons/LicenseTabIcon.vue'
import BasicInfo from './components/BasicInfo.vue'
import AuthorizationInfo from './components/AuthorizationInfo.vue'
@@ -141,20 +142,62 @@ const handleRenewLicense = () => {
ElMessage.info(t('pages.licenses.detail.messages.renewComingSoon'))
}
const handleRevokeLicense = () => {
ElMessageBox.confirm(t('pages.licenses.detail.messages.revokeConfirm'), t('pages.licenses.detail.messages.revokeTitle'), {
confirmButtonText: t('pages.licenses.detail.messages.revokeConfirmButton'),
cancelButtonText: t('pages.licenses.detail.messages.revokeCancelButton'),
type: 'warning'
}).then(() => {
ElMessage.info(t('pages.licenses.detail.messages.revokeComingSoon'))
}).catch(() => {
// 取消操作
})
const getFileNameFromDisposition = (disposition?: string | null) => {
const defaultName = 'authorization_package.zip'
if (!disposition) return defaultName
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch {
return utf8Match[1]
}
}
const asciiMatch = disposition.match(/filename="?([^";]+)"?/i)
if (asciiMatch?.[1]) {
return asciiMatch[1]
}
return defaultName
}
const handleDownloadCertificate = () => {
ElMessage.info(t('pages.licenses.detail.messages.downloadComingSoon'))
const handleDownloadCertificate = async () => {
if (!licenseData.value?.id) {
ElMessage.error(t('pages.licenses.detail.messages.missingId'))
return
}
try {
const response = await downloadAuthorizationFile(licenseData.value.id)
const fileName = getFileNameFromDisposition(response.headers['content-disposition'])
const blob = new Blob([response.data], { type: 'application/zip' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('pages.licenses.detail.messages.downloadSuccess'))
} catch (error: any) {
console.error('下载授权文件失败:', error)
let message = t('pages.licenses.detail.messages.downloadError')
if (error?.response?.data instanceof Blob) {
try {
const text = await error.response.data.text()
const parsed = JSON.parse(text)
if (parsed?.message) {
message = parsed.message
}
} catch {
// ignore parse errors
}
}
ElMessage.error(message)
}
}
const loadLicenseData = async () => {
@@ -399,16 +442,6 @@ onMounted(() => {
}
}
&.revoke-btn {
background: #F0142F;
color: white;
border: none;
&:hover {
background: #d01228;
}
}
&.download-btn {
background: #00C27C;
color: white;