保存证书路径输入布局修复进度

This commit is contained in:
smail 2026-09-08 10:01:21 +08:00
parent 52d6e90735
commit c4f53f28e9
5 changed files with 89 additions and 6 deletions

View File

@ -1,4 +1,4 @@
import { computed, nextTick } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { fieldKey } from '../ConfigForm/ConfigForm.js'
import { usePagination } from '../ConfigPagination/ConfigPagination.js'
@ -11,6 +11,8 @@ export function useField(props, emit) {
props.error && `${id.value}-error`
].filter(Boolean).join(' ') || undefined)
const entries = computed(() => Array.isArray(props.value) ? props.value : [])
const textValue = ref(typeof props.value === 'string' ? props.value : '')
const textEditing = ref(false)
const pagination = usePagination(entries, 2)
const selectOptions = computed(() => {
const values = props.field.options || []
@ -22,6 +24,17 @@ export function useField(props, emit) {
const update = value => {
if (!props.disabled && !props.field.locked) emit('change', props.field.path, value)
}
const updateText = value => {
textValue.value = String(value ?? '')
update(textValue.value)
}
const beginTextEditing = () => {
textEditing.value = true
}
const endTextEditing = () => {
textEditing.value = false
textValue.value = typeof props.value === 'string' ? props.value : ''
}
const updateEntry = (index, value) => update(entries.value.map((entry, current) => current === index ? value : entry))
const updateTarget = (index, key, value) => updateEntry(index, { ...entries.value[index], [key]: value ?? null })
const removeEntry = index => update(entries.value.filter((_, current) => current !== index))
@ -31,5 +44,27 @@ export function useField(props, emit) {
pagination.currentPage.value = pagination.pageCount.value
}
return { id, descriptionId, entries, selectOptions, update, updateEntry, updateTarget, removeEntry, addEntry, ...pagination }
watch(() => props.value, value => {
const nextValue = typeof value === 'string' ? value : ''
if (!textEditing.value || nextValue !== textValue.value) {
textValue.value = nextValue
}
})
return {
id,
descriptionId,
entries,
selectOptions,
textValue,
update,
updateText,
beginTextEditing,
endTextEditing,
updateEntry,
updateTarget,
removeEntry,
addEntry,
...pagination
}
}

View File

@ -122,7 +122,7 @@
v-else
class="field-text-input"
:id="id"
:model-value="value ?? ''"
:model-value="textValue"
:disabled="disabled || field.locked"
:placeholder="field.placeholder || '填写内容'"
:aria-describedby="descriptionId"
@ -130,7 +130,9 @@
:type="field.type === 'password' ? 'password' : 'text'"
:show-password="field.type === 'password'"
:autocomplete="field.type === 'password' ? 'new-password' : 'off'"
@update:model-value="update"
@focus="beginTextEditing"
@blur="endTextEditing"
@update:model-value="updateText"
/>
<ConfigPagination
v-if="['list', 'network'].includes(field.type)"
@ -171,8 +173,9 @@ const props = defineProps({
})
const emit = defineEmits(['change'])
const {
id, descriptionId, selectOptions, update, updateEntry, updateTarget, removeEntry,
addEntry, currentPage, pageCount, pageItems, total
id, descriptionId, selectOptions, textValue, update, updateText, beginTextEditing,
endTextEditing, updateEntry, updateTarget, removeEntry, addEntry, currentPage,
pageCount, pageItems, total
} = useField(props, emit)
</script>

View File

@ -56,6 +56,11 @@
align-items: flex-start;
}
/* SSL 路径必须保持整行宽度,避免长路径被多列布局截断。 */
.config-form[data-form-section="ssl"] .form-group[data-group="certificate"] .form-grid {
grid-template-columns: minmax(0, 1fr);
}
.section-error {
margin-left: 6px;
color: var(--el-color-danger);

View File

@ -160,6 +160,7 @@ const securityPolicyGroups = [
const sslGroups = [
group('certificate', '证书与存储', '', [
field('tls.management', '证书管理方式', 'select', {
wide: true,
fallback: 'custom',
options: options([
['web_server', 'Web 服务器管理'],
@ -168,15 +169,18 @@ const sslGroups = [
])
}),
field('tls.challenge', '证书验证方式', 'select', {
wide: true,
show: projectManagedCertificate,
fallback: 'http',
options: options([['http', 'HTTP 验证'], ['dns', 'DNS 验证']])
}),
field('tls.certificateRef', '证书文件', 'text', {
wide: true,
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/fullchain.pem'
}),
field('tls.privateKeyRef', '私钥文件', 'text', {
wide: true,
show: externalManagedCertificate,
placeholder: 'config/ssl/example.com/privkey.pem'
})

View File

@ -317,6 +317,42 @@ test('证书管理方式只展示三种业务模式并同步底层托管配置',
assert.ok(getFieldGroups('ssl', updated).flatMap(group => group.fields).some(field => field.path.join('.') === 'tls.certificateRef'))
})
test('自定义证书配置使用单列三行并完整展示路径输入框', () => {
const tls = {
management: 'custom',
mode: 'managed_upload',
defaultDirectory: 'config/ssl',
customDirectory: 'config/ssl/imported',
certificateRef: 'config/ssl/imported/fullchain.pem',
privateKeyRef: 'config/ssl/imported/privkey.pem'
}
const fields = getFieldGroups('ssl', { tls }).flatMap(group => group.fields)
assert.deepEqual(fields.map(field => field.path.join('.')), [
'tls.management',
'tls.certificateRef',
'tls.privateKeyRef'
])
assert.ok(fields.every(field => field.wide === true))
const css = readFileSync(new URL('ConfigForm/ConfigForm.css', root), 'utf8')
assert.match(
css,
/\.config-form\[data-form-section="ssl"\] \.form-group\[data-group="certificate"\] \.form-grid\s*\{\s*grid-template-columns: minmax\(0, 1fr\);/
)
})
test('普通文本输入使用本地编辑值,父级配置刷新不会抢占光标', () => {
const template = readFileSync(new URL('ConfigField/ConfigField.vue', root), 'utf8')
const script = readFileSync(new URL('ConfigField/ConfigField.js', root), 'utf8')
assert.match(template, /:model-value="textValue"/)
assert.match(template, /@focus="beginTextEditing"/)
assert.match(template, /@blur="endTextEditing"/)
assert.match(template, /@update:model-value="updateText"/)
assert.match(script, /const textValue = ref\(/)
assert.match(script, /const textEditing = ref\(false\)/)
assert.match(script, /watch\(\(\) => props\.value/)
})
test('统一加密方式同步切换 SMTP、IMAP、POP3 与已配置中继', () => {
for (const [security, expected] of [
['both', { relay: 587, relaySecurity: 'starttls', smtpStartTLS: 587, smtpTLS: 465, imap: 993, pop3: 110, pop3TLS: 995 }],