284 lines
6.0 KiB
Vue
284 lines
6.0 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import { deepComputedEffect } from 'vue3-ts-util'
|
||
import { message } from 'ant-design-vue'
|
||
import { t } from '@/i18n'
|
||
|
||
interface KVPair {
|
||
key: string
|
||
value: any
|
||
}
|
||
interface KVPairLocal {
|
||
key: string
|
||
value: string
|
||
}
|
||
|
||
interface Props {
|
||
modelValue: KVPair
|
||
allKeys?: string[]
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'update:modelValue', value: KVPair): void
|
||
(e: 'remove'): void
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
const emit = defineEmits<Emits>()
|
||
|
||
// Key 错误状态
|
||
const keyError = ref('')
|
||
|
||
// 校验 key 是否有效
|
||
const validateKey = (key: string): boolean => {
|
||
if (!key.trim()) {
|
||
keyError.value = t('keyRequired')
|
||
return false
|
||
}
|
||
|
||
if (props.allKeys && props.allKeys.includes(key.trim())) {
|
||
keyError.value = t('keyMustBeUnique')
|
||
return false
|
||
}
|
||
|
||
keyError.value = ''
|
||
return true
|
||
}
|
||
|
||
const modeInitial= () => {
|
||
const value = localKv.value.value
|
||
if (!value) return 'str' // 空值默认为字符串模式
|
||
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
// 如果解析成功且不是字符串类型,则认为是 JSON 模式
|
||
return typeof parsed !== 'string' ? 'json' : 'str'
|
||
} catch {
|
||
// 解析失败,是字符串模式
|
||
return 'str'
|
||
}
|
||
}
|
||
// 组件内部状态:判断当前值是否为 JSON 模式
|
||
const mode = ref('str' as 'str' | 'json')
|
||
|
||
// 本地状态,用于双向绑定
|
||
const localKv = deepComputedEffect<KVPairLocal>({
|
||
get () {
|
||
const val = props.modelValue.value
|
||
const isString = typeof val === 'string' && mode.value !== 'json'
|
||
return {
|
||
...props.modelValue,
|
||
value: isString
|
||
? val
|
||
: JSON.stringify(props.modelValue.value, null, 2)
|
||
}
|
||
},
|
||
set (newValue: KVPairLocal) {
|
||
// 校验 key,如果无效则不 emit
|
||
if (!validateKey(newValue.key)) {
|
||
return
|
||
}
|
||
|
||
const value = newValue.value
|
||
const allVal = {
|
||
...newValue,
|
||
value: mode.value === 'json' ? JSON.parse(value) : value
|
||
}
|
||
emit('update:modelValue', allVal)
|
||
}
|
||
})
|
||
|
||
mode.value = modeInitial()
|
||
|
||
|
||
|
||
|
||
// 判断当前值是否为有效 JSON
|
||
const isValidJson = ref(true)
|
||
|
||
// JSON 输入的 computed
|
||
const jsonInput = ref('{}')
|
||
|
||
watch([() => localKv.value.value, () => mode.value], () => {
|
||
if (mode.value === 'json') {
|
||
try {
|
||
const parsed = JSON.parse(localKv.value.value)
|
||
jsonInput.value = JSON.stringify(parsed, null, 2)
|
||
} catch (e) {
|
||
console.warn('Invalid JSON, resetting jsonInput to empty object', localKv.value.value)
|
||
jsonInput.value = '{}'
|
||
}
|
||
}
|
||
},{ immediate: true })
|
||
|
||
|
||
|
||
const onJsonUpdate = () => {
|
||
const newValue = jsonInput.value
|
||
if (checkIsValidJson(newValue)) {
|
||
localKv.value.value = newValue
|
||
isValidJson.value = true
|
||
} else {
|
||
isValidJson.value = false
|
||
}
|
||
}
|
||
|
||
// 字符串输入的 computed
|
||
const stringInput = computed({
|
||
get () {
|
||
return localKv.value.value
|
||
},
|
||
set (newValue: string) {
|
||
|
||
localKv.value.value = newValue
|
||
}
|
||
})
|
||
|
||
const checkIsValidJson = (str: string) => {
|
||
try {
|
||
JSON.parse(str)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
// 处理模式切换
|
||
const handleModeChange = (newMode: 'str' | 'json') => {
|
||
const currentValue = localKv.value.value
|
||
|
||
// 如果当前值为空,允许切换
|
||
if (!currentValue) {
|
||
mode.value = newMode
|
||
return
|
||
}
|
||
|
||
// 检查是否允许切换
|
||
if (newMode === 'json' && currentValue) {
|
||
message.warning(t('clearBeforeSwitchToJson'))
|
||
return
|
||
}
|
||
|
||
if (newMode === 'str' && jsonInput.value.trim()) {
|
||
message.warning(t('clearBeforeSwitchToString'))
|
||
console.warn('Switching to string mode requires empty value', {
|
||
val: jsonInput.value
|
||
})
|
||
return
|
||
}
|
||
|
||
localKv.value.value = '' // 切换模式前清空当前值
|
||
|
||
|
||
mode.value = newMode
|
||
}
|
||
|
||
// 处理删除
|
||
const handleRemove = () => {
|
||
emit('remove')
|
||
}
|
||
|
||
// 暴露校验方法给父组件
|
||
const validate = (): boolean => {
|
||
const keyValid = validateKey(localKv.value.key)
|
||
const jsonValid = mode.value === 'json' ? isValidJson.value : true
|
||
return keyValid && jsonValid
|
||
}
|
||
|
||
// 暴露方法和状态给父组件
|
||
defineExpose({
|
||
validate,
|
||
keyError,
|
||
isValidJson
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="kv-pair-editor">
|
||
<div class="kv-key-wrapper">
|
||
<a-input v-model:value="localKv.key" :placeholder="t('keyPlaceholder')" class="kv-input kv-key" />
|
||
<div v-if="keyError" class="key-error-hint">{{ keyError }}</div>
|
||
</div>
|
||
|
||
<div v-if="mode === 'json'" class="kv-value-wrapper">
|
||
<ATextarea v-model:value="jsonInput" @blur="onJsonUpdate" :placeholder="t('jsonValuePlaceholder')"
|
||
:auto-size="{ maxRows: 8 }" class="kv-input kv-value" />
|
||
<div v-if="!isValidJson" class="json-error-hint">{{ t('jsonFormatError') }}</div>
|
||
</div>
|
||
|
||
<ATextarea v-else :auto-size="{ maxRows: 8 }" v-model:value="stringInput" :placeholder="t('stringValuePlaceholder')" class="kv-input kv-value" />
|
||
|
||
<a-select :value="mode" size="small" class="mode-selector"
|
||
:getPopupContainer="(trigger: any) => trigger.parentNode as HTMLDivElement" @update:value="handleModeChange"
|
||
style="width: 80px">
|
||
<a-select-option value="str">{{ t('stringMode') }}</a-select-option>
|
||
<a-select-option value="json">{{ t('jsonMode') }}</a-select-option>
|
||
</a-select>
|
||
|
||
<a-button size="small" danger @click="handleRemove" class="delete-btn">
|
||
{{ t('delete') }}
|
||
</a-button>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.kv-pair-editor {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.kv-input {
|
||
font-size: 12px;
|
||
}
|
||
|
||
.kv-key-wrapper {
|
||
min-width: 50px;
|
||
max-width: 120px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.kv-key {
|
||
width: 100%;
|
||
}
|
||
|
||
.kv-value {
|
||
min-width: 200px;
|
||
}
|
||
|
||
.kv-value-wrapper {
|
||
min-width: 200px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
flex: 1;
|
||
}
|
||
|
||
.key-error-hint {
|
||
font-size: 11px;
|
||
color: #ff4d4f;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.json-error-hint {
|
||
font-size: 11px;
|
||
color: #ff4d4f;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.mode-selector {
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.delete-btn {
|
||
font-size: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
</style>
|