10 Commits
33 changed files with 2008 additions and 157 deletions

No files matched your search

+19 -11
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,23 +44,33 @@ 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
# 设置构建环境变量,确保与本地构建一致
export VITE_OUTDIR=production
export VITE_ADDRESS_BASE_URL=/production/
# 并行执行类型检查和构建以节省时间
npm run type-check &
npm run build:prod &
wait
# 先执行类型检查
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 temp directory
+47 -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群管理员。
## 系统界面
+38
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
-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:v1.0.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:v1.0.0
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.3
container_name: license-manager-frontend-git
restart: unless-stopped
environment:
+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. 详情页可直接阅读与复制已配置的功能、限制。
+13 -4
View File
@@ -84,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;
}
// 更新授权请求参数
@@ -98,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;
}
@@ -320,6 +320,15 @@ export function downloadLicenseFile(id: string): Promise<AxiosResponse<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,6 +98,19 @@
<!-- 操作按钮组 -->
<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">
@@ -145,8 +158,12 @@
</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>
@@ -178,6 +195,20 @@ 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: []
@@ -224,16 +255,15 @@ const handleSidebarToggle = () => {
emit('sidebarToggle')
}
// 处理搜索点击
const handleSearchClick = () => {
emit('searchClick')
}
// 处理通知按钮点击
const handleNotificationClick = () => {
emit('notificationClick')
}
const handleExternalLink = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
// 语言切换
const languageOptions: Array<{ code: SupportedLocale; label: string }> = [
{ code: 'en', label: 'English' },
@@ -584,6 +614,20 @@ 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);
}
@@ -815,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文字颜色 */
+68 -5
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",
@@ -391,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",
@@ -400,7 +418,8 @@
"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",
@@ -430,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",
@@ -499,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"
}
}
}
+68 -5
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": "ライセンスを追加",
@@ -391,7 +409,7 @@
"updateLicense": "ライセンス更新",
"renewLicense": "ライセンス延長",
"revokeLicense": "ライセンス取り消し",
"downloadCertificate": "デプロイメントパッケージをダウンロード"
"downloadCertificate": "認証ファイルをダウンロード"
},
"messages": {
"copySuccess": "ライセンスコードがクリップボードにコピーされました",
@@ -400,7 +418,8 @@
"updateComingSoon": "ライセンス更新機能は開発中です...",
"renewComingSoon": "ライセンス延長機能は開発中です...",
"revokeComingSoon": "ライセンス取り消し機能は開発中です...",
"downloadComingSoon": "証明書ダウンロード機能は開発中です...",
"downloadSuccess": "認証ファイルのダウンロードを開始しました",
"downloadError": "認証ファイルのダウンロードに失敗しました。後でもう一度お試しください",
"revokeConfirm": "このライセンスを取り消してもよろしいですか?この操作は元に戻せません!",
"revokeTitle": "警告",
"revokeConfirmButton": "取り消し確認",
@@ -430,7 +449,10 @@
},
"sections": {
"basicInfo": "基本情報",
"licenseConfig": "ライセンス設定"
"licenseConfig": "ライセンス設定",
"featureConfig": "機能構成",
"usageLimits": "利用制限",
"customParameters": "カスタムパラメータ"
},
"fields": {
"customerName": "関連顧客名",
@@ -499,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 が空です"
}
}
}
+68 -5
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": "新增许可证",
@@ -391,7 +409,7 @@
"updateLicense": "变更授权",
"renewLicense": "延期授权",
"revokeLicense": "撤销许可证",
"downloadCertificate": "下载部署包"
"downloadCertificate": "授权文件下载"
},
"messages": {
"copySuccess": "授权码已复制到剪贴板",
@@ -400,7 +418,8 @@
"updateComingSoon": "变更授权功能开发中...",
"renewComingSoon": "延期授权功能开发中...",
"revokeComingSoon": "撤销许可证功能开发中...",
"downloadComingSoon": "下载凭单功能开发中...",
"downloadSuccess": "授权文件已开始下载",
"downloadError": "下载授权文件失败,请稍后重试",
"revokeConfirm": "确定要撤销此许可证吗?此操作不可逆!",
"revokeTitle": "警告",
"revokeConfirmButton": "确定撤销",
@@ -430,7 +449,10 @@
},
"sections": {
"basicInfo": "基本信息",
"licenseConfig": "授权配置"
"licenseConfig": "授权配置",
"featureConfig": "功能配置",
"usageLimits": "使用限制",
"customParameters": "自定义参数"
},
"fields": {
"customerName": "关联客户名称",
@@ -499,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 内容为空"
}
}
}
+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;
@@ -65,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'
@@ -80,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 = () => {
// 这里假设有个字段表示授权期限类型,如果没有则返回"有效期限"
@@ -200,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;
}
}
// 响应式布局
@@ -386,7 +386,7 @@ const handleDownloadLicense = async (device: LicenseDevice) => {
response.headers?.['content-disposition'] || response.headers?.['Content-Disposition']
const filename =
extractFilename(disposition) ||
`${device.customer_name || 'license'}-${device.id}.lic`
`${device.customer_name || 'license'}-${device.id}.zip`
const blobUrl = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
@@ -89,7 +89,7 @@ import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
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'
@@ -142,8 +142,62 @@ const handleRenewLicense = () => {
ElMessage.info(t('pages.licenses.detail.messages.renewComingSoon'))
}
const handleDownloadCertificate = () => {
ElMessage.info(t('pages.licenses.detail.messages.downloadComingSoon'))
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 = 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 () => {