feat: 增加授权管理功能与限制设置,支持动态配置和多语言文本,优化许可证表单交互体验

This commit is contained in:
wood911010 committed 2025-11-27 20:00:29 +08:00
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. 详情页可直接阅读与复制已配置的功能、限制。
+4 -4
View File
@@ -84,8 +84,8 @@ export interface AuthorizationCodeCreateRequest {
software_version?: string;
max_activations: number;
feature_config?: any;
usage_limits?: any;
custom_parameters?: any;
usage_limits?: string | null;
custom_parameters?: string | null;
}
// 更新授权请求参数
@@ -98,8 +98,8 @@ export interface LicenseUpdateRequest {
software_version?: string;
max_activations?: number;
feature_config?: any;
usage_limits?: any;
custom_parameters?: any;
usage_limits?: string | null;
custom_parameters?: string | null;
change_type: string;
reason?: string;
}
+55 -2
View File
@@ -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"
}
}
}
+55 -2
View File
@@ -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 が空です"
}
}
}
+55 -2
View File
@@ -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 内容为空"
}
}
}
+588 -2
View File
@@ -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;
}
}
// 响应式布局