Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f7e8e2ad5 | ||
|
|
0678fe248c | ||
|
|
0ca39951a8 | ||
|
|
ce789093bd | ||
|
|
22898510eb | ||
|
|
78b71ca325 |
No files matched your search
@@ -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群管理员。
|
||||
|
||||
## 系统界面
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -333,27 +334,16 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,25 +659,16 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@ services:
|
||||
|
||||
# Backend service - using GitHub registry image
|
||||
backend:
|
||||
image: ghcr.io/cedar-v/license-manager-backend:v1.0.1
|
||||
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.1
|
||||
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.3
|
||||
container_name: license-manager-frontend-git
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. 详情页可直接阅读与复制已配置的功能、限制。
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -158,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>
|
||||
@@ -191,6 +195,9 @@ const { notificationCount = 24 } = defineProps<Props>()
|
||||
// 功能暂未启用,隐藏通知入口
|
||||
const showNotification = false
|
||||
|
||||
// 主题功能暂未开发完成,禁用主题切换
|
||||
const showThemeToggle = false
|
||||
|
||||
type ExternalLinkKey = 'docs' | 'github'
|
||||
interface ExternalLink {
|
||||
key: ExternalLinkKey
|
||||
|
||||
@@ -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文字颜色 */
|
||||
|
||||
@@ -338,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",
|
||||
@@ -437,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",
|
||||
@@ -506,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,19 @@
|
||||
"authorizationCode": "認証コード",
|
||||
"forActivation": "(クライアントアクティベーション用)",
|
||||
"code": "認証コード",
|
||||
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます"
|
||||
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます",
|
||||
"featureConfig": "機能構成",
|
||||
"usageLimits": "利用制限",
|
||||
"customParameters": "カスタムパラメータ",
|
||||
"typeColumn": "タイプ",
|
||||
"keyColumn": "キー",
|
||||
"valueColumn": "値",
|
||||
"keyValueEmpty": "設定はありません",
|
||||
"typeOptions": {
|
||||
"string": "文字列",
|
||||
"number": "数値",
|
||||
"bool": "ブール"
|
||||
}
|
||||
},
|
||||
"licenseInfo": {
|
||||
"addButton": "ライセンスを追加",
|
||||
@@ -437,7 +449,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"basicInfo": "基本情報",
|
||||
"licenseConfig": "ライセンス設定"
|
||||
"licenseConfig": "ライセンス設定",
|
||||
"featureConfig": "機能構成",
|
||||
"usageLimits": "利用制限",
|
||||
"customParameters": "カスタムパラメータ"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "関連顧客名",
|
||||
@@ -506,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 が空です"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,19 @@
|
||||
"authorizationCode": "授权码",
|
||||
"forActivation": "(用于客户端激活)",
|
||||
"code": "授权码",
|
||||
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证"
|
||||
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证",
|
||||
"featureConfig": "功能配置",
|
||||
"usageLimits": "使用限制",
|
||||
"customParameters": "自定义参数",
|
||||
"typeColumn": "类型",
|
||||
"keyColumn": "键",
|
||||
"valueColumn": "值",
|
||||
"keyValueEmpty": "暂无配置",
|
||||
"typeOptions": {
|
||||
"string": "字符串",
|
||||
"number": "数字",
|
||||
"bool": "布尔"
|
||||
}
|
||||
},
|
||||
"licenseInfo": {
|
||||
"addButton": "新增许可证",
|
||||
@@ -437,7 +449,10 @@
|
||||
},
|
||||
"sections": {
|
||||
"basicInfo": "基本信息",
|
||||
"licenseConfig": "授权配置"
|
||||
"licenseConfig": "授权配置",
|
||||
"featureConfig": "功能配置",
|
||||
"usageLimits": "使用限制",
|
||||
"customParameters": "自定义参数"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "关联客户名称",
|
||||
@@ -506,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 内容为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格卡片暗模式 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式布局
|
||||
|
||||
Reference in new issue
Block a user