From c4f53f28e93f1e5a585bae388906977342c78bef Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 8 Sep 2026 10:01:21 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E8=AF=81=E4=B9=A6=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=BE=93=E5=85=A5=E5=B8=83=E5=B1=80=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigSettings/ConfigField/ConfigField.js | 39 ++++++++++++++++++- .../ConfigField/ConfigField.vue | 11 ++++-- .../ConfigSettings/ConfigForm/ConfigForm.css | 5 +++ .../ConfigSettings/ConfigForm/ConfigForm.js | 4 ++ test/frontend/config-form.test.mjs | 36 +++++++++++++++++ 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.js b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.js index 330e7ed..8cbd417 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.js +++ b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.js @@ -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 + } } diff --git a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue index 320840d..a6fd3b8 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue +++ b/frontend/src/views/components/ConfigSettings/ConfigField/ConfigField.vue @@ -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" /> diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css index ebb3ef4..c745a40 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.css @@ -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); diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 777c623..98f3c42 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -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' }) diff --git a/test/frontend/config-form.test.mjs b/test/frontend/config-form.test.mjs index db46e90..1a125ad 100644 --- a/test/frontend/config-form.test.mjs +++ b/test/frontend/config-form.test.mjs @@ -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 }],