feat: 增加授权管理功能与限制设置,支持动态配置和多语言文本,优化许可证表单交互体验
This commit is contained in:
1 parent
78b71ca325
commit
22898510eb
7 files changed
+930
-12
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -338,7 +338,18 @@
|
||||
"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",
|
||||
"customParameters": "Feature Settings",
|
||||
"usageLimits": "Usage Limits",
|
||||
"typeColumn": "Type",
|
||||
"keyColumn": "Key",
|
||||
"valueColumn": "Value",
|
||||
"keyValueEmpty": "No configuration available",
|
||||
"typeOptions": {
|
||||
"string": "String",
|
||||
"number": "Number",
|
||||
"bool": "Boolean"
|
||||
}
|
||||
},
|
||||
"licenseInfo": {
|
||||
"addButton": "Add License",
|
||||
@@ -437,7 +448,9 @@
|
||||
},
|
||||
"sections": {
|
||||
"basicInfo": "Basic Information",
|
||||
"licenseConfig": "License Configuration"
|
||||
"licenseConfig": "License Configuration",
|
||||
"customParameters": "Feature Settings",
|
||||
"usageLimits": "Usage Limits"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "Associated Customer Name",
|
||||
@@ -506,6 +519,46 @@
|
||||
"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 Settings JSON",
|
||||
"importTitleLimit": "Import Usage Limits 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,18 @@
|
||||
"authorizationCode": "認証コード",
|
||||
"forActivation": "(クライアントアクティベーション用)",
|
||||
"code": "認証コード",
|
||||
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます"
|
||||
"tipText": "この認証コードを顧客に提供してください。顧客がソフトウェア製品で設定すると、ソフトウェアライセンスを自動的に取得できます",
|
||||
"customParameters": "機能設定",
|
||||
"usageLimits": "制限設定",
|
||||
"typeColumn": "タイプ",
|
||||
"keyColumn": "キー",
|
||||
"valueColumn": "値",
|
||||
"keyValueEmpty": "設定はありません",
|
||||
"typeOptions": {
|
||||
"string": "文字列",
|
||||
"number": "数値",
|
||||
"bool": "ブール"
|
||||
}
|
||||
},
|
||||
"licenseInfo": {
|
||||
"addButton": "ライセンスを追加",
|
||||
@@ -437,7 +448,9 @@
|
||||
},
|
||||
"sections": {
|
||||
"basicInfo": "基本情報",
|
||||
"licenseConfig": "ライセンス設定"
|
||||
"licenseConfig": "ライセンス設定",
|
||||
"customParameters": "機能設定",
|
||||
"usageLimits": "制限設定"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "関連顧客名",
|
||||
@@ -506,6 +519,46 @@
|
||||
"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 をインポート",
|
||||
"importDescription": "キーを設定名とするオブジェクト形式の JSON を貼り付けてください。値は文字列・数値・ブールをサポートします。確定すると現在のリストを上書きします。",
|
||||
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
|
||||
"importCancel": "キャンセル",
|
||||
"importConfirm": "解析して上書き",
|
||||
"importSuccess": "JSON を取り込みました",
|
||||
"importFailed": "JSON の解析に失敗しました。形式を確認してください",
|
||||
"importEmpty": "JSON が空です"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +338,18 @@
|
||||
"authorizationCode": "授权码",
|
||||
"forActivation": "(用于客户端激活)",
|
||||
"code": "授权码",
|
||||
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证"
|
||||
"tipText": "将此授权码提供给客户,客户在软件产品中配置后可自动获取软件许可证",
|
||||
"customParameters": "功能设置",
|
||||
"usageLimits": "限制设置",
|
||||
"typeColumn": "类型",
|
||||
"keyColumn": "键",
|
||||
"valueColumn": "值",
|
||||
"keyValueEmpty": "暂无配置",
|
||||
"typeOptions": {
|
||||
"string": "字符串",
|
||||
"number": "数字",
|
||||
"bool": "布尔"
|
||||
}
|
||||
},
|
||||
"licenseInfo": {
|
||||
"addButton": "新增许可证",
|
||||
@@ -437,7 +448,9 @@
|
||||
},
|
||||
"sections": {
|
||||
"basicInfo": "基本信息",
|
||||
"licenseConfig": "授权配置"
|
||||
"licenseConfig": "授权配置",
|
||||
"customParameters": "功能设置",
|
||||
"usageLimits": "限制设置"
|
||||
},
|
||||
"fields": {
|
||||
"customerName": "关联客户名称",
|
||||
@@ -506,6 +519,46 @@
|
||||
"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",
|
||||
"importDescription": "粘贴一个对象格式的 JSON,键为配置项,值支持字符串、数字或布尔类型。提交后将覆盖当前列表。",
|
||||
"importPlaceholder": "{ \"feature_dashboard\": \"enabled\" }",
|
||||
"importCancel": "取消",
|
||||
"importConfirm": "解析并覆盖",
|
||||
"importSuccess": "JSON 解析成功",
|
||||
"importFailed": "JSON 解析失败,请检查格式",
|
||||
"importEmpty": "JSON 内容为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,13 +210,216 @@
|
||||
</el-form-item>
|
||||
</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="addFeatureEntry">
|
||||
{{ t('pages.licenses.form.keyValue.addItem') }}
|
||||
</el-button>
|
||||
<el-button size="small" text :disabled="!featureEntries.length" @click="clearFeatureEntries">
|
||||
{{ t('pages.licenses.form.keyValue.clearAll') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
:disabled="!featureJsonPreview"
|
||||
@click="copyJson(featureJsonPreview)"
|
||||
>
|
||||
{{ 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 featureEntries" :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="removeFeatureEntry(index)">
|
||||
{{ t('pages.licenses.form.keyValue.remove') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!featureEntries.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="featureJsonPreview" 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>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="importDialogVisible"
|
||||
:title="
|
||||
importDialogTarget === 'feature'
|
||||
? t('pages.licenses.form.keyValue.importTitleFeature')
|
||||
: t('pages.licenses.form.keyValue.importTitleLimit')
|
||||
"
|
||||
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 +433,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 +462,18 @@ const submitting = ref(false)
|
||||
const customerLoading = ref(false)
|
||||
const customerOptions = ref<Customer[]>([])
|
||||
|
||||
const featureEntries = ref<KeyValueItem[]>([])
|
||||
const limitEntries = ref<KeyValueItem[]>([])
|
||||
const importDialogVisible = ref(false)
|
||||
const importDialogTarget = ref<'feature' | 'limit'>('feature')
|
||||
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 +490,16 @@ 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 featureJsonPreview = computed(() => buildJsonString(featureEntries.value))
|
||||
const usageJsonPreview = computed(() => buildJsonString(limitEntries.value))
|
||||
|
||||
// 计算属性
|
||||
const isEdit = computed(() => {
|
||||
return route.name === 'licenses-edit' && route.params.id
|
||||
@@ -415,6 +646,252 @@ 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 buildJsonString = (entries: KeyValueItem[]) => {
|
||||
if (!entries.length) {
|
||||
return ''
|
||||
}
|
||||
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 ? JSON.stringify(result) : ''
|
||||
}
|
||||
|
||||
const addFeatureEntry = () => {
|
||||
featureEntries.value.push(createEmptyEntry())
|
||||
}
|
||||
|
||||
const addLimitEntry = () => {
|
||||
limitEntries.value.push(createEmptyEntry())
|
||||
}
|
||||
|
||||
const removeFeatureEntry = (index: number) => {
|
||||
featureEntries.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const removeLimitEntry = (index: number) => {
|
||||
limitEntries.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearFeatureEntries = () => {
|
||||
featureEntries.value = []
|
||||
}
|
||||
|
||||
const clearLimitEntries = () => {
|
||||
limitEntries.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') => {
|
||||
importDialogTarget.value = target
|
||||
importDialogContent.value =
|
||||
target === 'feature' ? featureJsonPreview.value : usageJsonPreview.value
|
||||
importDialogError.value = ''
|
||||
importDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportConfirm = () => {
|
||||
importDialogError.value = ''
|
||||
try {
|
||||
const content = importDialogContent.value.trim()
|
||||
if (!content) {
|
||||
if (importDialogTarget.value === 'feature') {
|
||||
featureEntries.value = []
|
||||
} else {
|
||||
limitEntries.value = []
|
||||
}
|
||||
} else {
|
||||
const entries = parseJsonToEntries(content, { strict: true })
|
||||
if (!entries.length) {
|
||||
throw new Error(t('pages.licenses.form.keyValue.importEmpty'))
|
||||
}
|
||||
if (importDialogTarget.value === 'feature') {
|
||||
featureEntries.value = entries
|
||||
} else {
|
||||
limitEntries.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 +1077,9 @@ const loadLicenseDetail = async () => {
|
||||
} as Customer
|
||||
]
|
||||
}
|
||||
|
||||
hydrateEntriesFromField(data.custom_parameters, featureEntries)
|
||||
hydrateEntriesFromField(data.usage_limits, limitEntries)
|
||||
} else {
|
||||
throw new Error(response.message || t('pages.licenses.form.messages.loadDetailError'))
|
||||
}
|
||||
@@ -622,6 +1102,16 @@ const handleSubmit = async () => {
|
||||
// 执行表单验证
|
||||
await formRef.value.validate()
|
||||
|
||||
const customValid = validateKeyValueEntries(featureEntries.value)
|
||||
const limitValid = validateKeyValueEntries(limitEntries.value)
|
||||
if (!customValid || !limitValid) {
|
||||
ElMessage.error(t('pages.licenses.form.keyValue.validationFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
formData.custom_parameters = featureJsonPreview.value
|
||||
formData.usage_limits = usageJsonPreview.value
|
||||
|
||||
submitting.value = true
|
||||
|
||||
if (isEdit.value) {
|
||||
@@ -709,6 +1199,22 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
featureEntries,
|
||||
() => {
|
||||
formData.custom_parameters = featureJsonPreview.value
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
limitEntries,
|
||||
() => {
|
||||
formData.usage_limits = usageJsonPreview.value
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await loadEnumOptions()
|
||||
@@ -801,6 +1307,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,51 @@
|
||||
</div>
|
||||
</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="`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>
|
||||
</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 +121,56 @@ 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 customParameters = computed(() => parseKeyValueData(props.licenseData?.custom_parameters))
|
||||
const usageLimits = computed(() => parseKeyValueData(props.licenseData?.usage_limits))
|
||||
|
||||
// 获取授权期限类型
|
||||
const getLicensePeriodType = () => {
|
||||
// 这里假设有个字段表示授权期限类型,如果没有则返回"有效期限"
|
||||
@@ -200,6 +291,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