Nginx UI 自签名证书体验增强:StringListInput 共享组件、自动续期提示与 Name 必填校验实现解析
2026/9/23 22:24:01 网站建设 项目流程
  • 后端
  • 前端
  • 运维
  • MCP 服务

【免费下载链接】nginx-ui

Yet another WebUI for Nginx

项目地址:https://gitcode.com/gh_mirrors/ngi/nginx-ui
点击查看免费下载

导读

本文围绕 Nginx UI 仓库中 自签名证书 UX 增强实现计划 与其配套设计规格展开,深入讲解三项核心改造:将「Custom Domains 多行编辑器」抽象为可复用的StringListInput组件并复用到自签名证书的 Domains / IP Addresses 字段;在自签名表单顶部增加自动续期策略提示;在前端与后端同时强制自签名证书 Name 必填。读完本文,你将掌握该功能模块的组件抽象思路、payload 种子与过滤模式、Go 后端binding:"required"校验契约,以及完整的测试与手工冒烟验证方法。

背景:一次手工测试暴露的三个体验问题

自签名证书创建流程合并进「申请证书(Issue Certificate)」对话框后,Nginx UI 在手工测试中发现三个不一致点,构成了本次增强的动因:

  1. 编辑器风格不统一。同一对话框里,Custom Domains 分支用的是带「Add/Remove」按钮的多行AInput列表,而自签名证书的 Domains 与 IP Addresses 用的是 chip 风格的 tags 下拉(ASelect mode="tags")。同样的输入语义,两种编辑模型。
  2. 缺少自动续期上下文提示。用户在创建自签名证书时,表单内没有任何提示说明 Nginx UI 会自动续期;而续期策略取决于全局设置与单张证书的有效期,用户无从知晓。
  3. 空 Name 可静默通过。后端此前对空Name不报错,导致证书列表出现空白行,且文件系统 slug 只能回退到第一个域名或 IP。对自签名证书而言,Name 应当必填。

设计文档还明确划定了非目标(Non-goals),避免改造范围失控:不做前端 IP 格式校验(后端已通过binding:"omitempty,dive,ip"兜底);不迁移已存在的空 Name 历史数据(用户重新打开并保存时新校验自然生效);不改名DNSIssueCertificate.vue;不改变 ACME(Wildcard / Custom Domains)提交语义。

总体架构与文件地图

方案的核心分层非常清晰:前端组件复用 + 三处消费方接线 + 请求边界校验

新增文件 2 个:

文件职责
app/src/components/StringListInput/StringListInput.vue可复用的多行字符串数组输入(Add / Remove)
app/src/components/StringListInput/index.ts组件再导出入口

修改文件 9 个:

文件改动
app/src/views/certificate/components/DNSIssueCertificate.vueCustom Domains 改用StringListInputsubmitSelfSigned裁剪过滤并校验 Name;payload 种子['']
app/src/views/certificate/components/SelfSignedCertFields.vueDomains / IPs 改用StringListInput;新增续期AAlert(带hideRenewalNote开关);Name 必填
app/src/views/certificate/components/SelfSignedCertForm.vueemptyForm()种子空行数组;submit()裁剪过滤并校验 Name
app/src/views/certificate/components/SelfSignedCertManagement.vuehide-renewal-note避免双重提示
app/src/views/certificate/CertificateEditor.vuesave()isSelfSigned分支裁剪过滤并校验 Name
app/src/api/cert.tstoSelfSignedPayload()种子空行数组
api/certificate/self_signed.goSelfSignedCertRequest.Name增加binding:"required"
api/certificate/self_signed_test.go新增空 Name 返回 4xx 的接口测试

另有明确不修改的文件:app/src/views/site/site_edit/components/Cert/SelfSignedCert.vue(它以defaultDomains调用SelfSignedCertForm,下游改动自动传播)以及 internal/cert/self_signed.go 等后端辅助代码(校验统一收敛在请求边界)。

组件层:StringListInput 的设计与实现

组件 API

interface Props { placeholder?: string addButtonText?: string // 默认: $gettext('Add Item') // v1 不提供 validator prop —— YAGNI }

v-model的类型是string[](必填)。设计上允许数组内含单个空字符串——这是用户在输入第一个值时的正常中间状态,消费方负责在提交时过滤空值

组件行为

依据 StringListInput.vue 的实现,行为可归纳为:

  • 每个数组元素渲染一个AInput
  • 当数组长度大于 1 时,每行右侧显示红色链接按钮Remove,点击后按索引splice删除该行;
  • 底部一个 block 级AButton,文案由addButtonText控制(默认Add Item),点击向数组push('')追加空行;
  • 组件不做任何内部校验,仅用:placeholder提供输入提示。

关键实现细节(与计划文档的说明一致,仓库代码已落地):

<script setup lang="ts"> defineProps<{ placeholder?: string addButtonText?: string }>() const items = defineModel<string[]>({ required: true }) function addItem() { items.value = [...items.value, ''] } function removeItem(index: number) { if (items.value.length <= 1) return const next = [...items.value] next.splice(index, 1) items.value = next } </script>

两点值得注意:

  • 使用defineModel<string[]>({ required: true }),这是 Vue 3.4+ 的双向绑定惯用法,项目内已有先例;
  • 删除/追加/更新都通过展开数组生成新引用[...items.value])再写回 model,保持响应式身份稳定,让v-model干净地 emit——这是相对原先「通过双向绑定直接改customDomains[index]」的显式 setter 化改造。

从源码结构看,实际仓库中的组件实现(StringListInput.vue)相比计划草案还增加了:aria-label可访问性标注,属于落地时的细节加强。

表单层:SelfSignedCertFields 的统一改造

替换 Domains / IP Addresses 编辑器

SelfSignedCertFields.vue 中,原先的<ASelect mode="tags">被替换为:

<AFormItem :label="$gettext('Domains')"> <StringListInput v-model="data.domains" :placeholder="$gettext('Enter domain name')" :add-button-text="$gettext('Add Domain')" /> </AFormItem> <AFormItem :label="$gettext('IP Addresses')"> <StringListInput v-model="data.ip_addresses" :placeholder="$gettext('Enter IP address')" :add-button-text="$gettext('Add IP Address')" /> </AFormItem>

v-model直接绑定到defineModel<SelfSignedCertPayload>派生出的data,与原先的 tags 下拉保持同样的数据契约,但交互从「chip 输入」变成「多行可增删输入」,与 Custom Domains 分支完全一致。

自动续期提示与 hideRenewalNote 开关

表单顶部新增AAlert提示,由新 prophideRenewalNote?: boolean(默认false)控制显隐:

<AAlert v-if="!props.hideRenewalNote" class="mb-4" type="info" show-icon :title="$gettext('Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate\'s validity period.')" />

消息文案明确定位了续期策略的两个决定因素:全局证书续期间隔本张证书的有效期。同时,Name 字段的<AFormItem>增加required属性(显示红色星号),placeholder 从原来的Optional改为Enter certificate name

避免双重提示:SelfSignedCertManagement 的接线

SelfSignedCertManagement.vue 是「编辑已存在自签名证书」的面板,它本身已经有一条type="success"的提示「This self-signed certificate is managed by Nginx UI and renewed automatically.」。若再叠加SelfSignedCertFields的 info 提示,用户会看到两条相邻且语义重复的横幅。因此该处显式传hide-renewal-note关闭新提示:

<SelfSignedCertFields v-model="data" is-key-type-readonly hide-renewal-note />

这形成了清晰的提示策略:新建场景显示续期策略说明,编辑场景由管理面板自身的成功态提示负责

数据流:Payload 种子与提交过滤模式

StringListInput会在模型数组中保留一个空占位行,因此三条写入路径需要做到「一进一出」的对称处理:

  • 进入编辑态(seed):数组为空时先填充[''],保证编辑器渲染出一个可输入的空行;
  • 提交前(filter)trim()去空白、filter(Boolean)丢弃空串,再发送给 API。

三个种子工厂

计划文档明确要求三个 payload 工厂统一种子策略(仓库代码已实现):

  1. DNSIssueCertificate.vue 的emptySelfSignedPayload()domains: ['']ip_addresses: ['']
  2. SelfSignedCertForm.vue 的emptyForm()domains: defaultDomains?.length ? [...defaultDomains] : ['']ip_addresses: [''](若外部传入defaultDomains则原样继承,否则也种子空行);
  3. cert.ts 的toSelfSignedPayload(c)domains: c.domains?.length ? [...c.domains] : ['']ip_addresses: c.self_signed_config?.ip_addresses?.length ? [...c.self_signed_config.ip_addresses] : [''](编辑已有证书时按存量数据决定)。

统一的提交前校验形状

三条提交/保存路径使用完全一致的「裁剪 + 双重校验」模板:

const name = (payload.name ?? '').trim() const domains = payload.domains.map(d => d.trim()).filter(Boolean) const ip_addresses = payload.ip_addresses.map(s => s.trim()).filter(Boolean) if (!name) { message.error($gettext('Please enter a name for the certificate')) return } if (domains.length === 0 && ip_addresses.length === 0) { message.error($gettext('Please enter at least one domain or IP address')) return } await cert.generate_self_signed({ ...payload, name, domains, ip_addresses })

应用此模式的三处:

位置触发时机调用的 API
DNSIssueCertificate.submitSelfSigned()申请证书对话框 → Self-signed → Generatecert.generate_self_signed
SelfSignedCertForm.submit()站点编辑器内的自签名快捷创建cert.generate_self_signed
CertificateEditor.save()isSelfSigned分支)编辑已有自签名证书后保存cert.modify_self_signed

校验顺序值得注意:先校验 Name,再校验域名/IP 至少填一项——这是刻意设计的消息优先级,保证用户先收到关于必填 Name 的明确指引。对应 API 封装在 cert.ts 中,generate_self_signedPOST 到/self_signed_certmodify_self_signedPOST 到/self_signed_cert/{id}

后端契约:binding:"required" 与接口测试

请求结构变更

后端校验统一收敛在请求边界。api/certificate/self_signed.go 中SelfSignedCertRequest的完整结构如下(Name已带binding:"required"):

type SelfSignedCertRequest struct { Name string `json:"name" binding:"required"` Domains []string `json:"domains" binding:"omitempty"` IPAddresses []string `json:"ip_addresses" binding:"omitempty,dive,ip"` KeyType string `json:"key_type" binding:"omitempty,auto_cert_key_type"` ValidityDays int `json:"validity_days" binding:"omitempty,min=1,max=3650"` SyncNodeIds []uint64 `json:"sync_node_ids" binding:"omitempty"` }

binding:"required"通过cosy.BindAndValidGenerateSelfSignedCertModifySelfSignedCert两个 handler 中同时生效(两者绑定同一个结构体),空 Name 会在进入业务逻辑前被拦截。其余字段维持既有约束:IP 逐项dive,ip校验、KeyType 走自定义校验器auto_cert_key_type、有效期限制在 1–3650 天。

防御性代码保留

计划与设计文档都强调:GenerateSelfSignedCertselfSignedSlug的 CommonName 回退逻辑(self_signed.go 中 slug 为空时回退到defaultSelfSignedSlug或首个域名/IP)保留为防御性代码——虽然对新请求而言空 Name 已被拦截,但直接调用selfSignedSlug的内部路径仍受保护。配套的 slug 测试(如TestSelfSignedSlugConvertsIDNToPunycode验证中文域名转 punycode、TestSelfSignedSlugSanitizesPathTraversal验证路径穿越清理)佐证了这一设计意图。

新增接口测试

api/certificate/self_signed_test.go 中已落地TestGenerateSelfSignedCertRejectsEmptyName,它复用了与 rollback 测试相同的setupSelfSignedAPITest夹具(gin TestMode + 内存 sqlite +query.SetDefault),核心断言是:

body, err := json.Marshal(SelfSignedCertRequest{ Domains: []string{"named.example"}, KeyType: string(certcrypto.EC256), ValidityDays: 30, }) // POST /self_signed_cert ... if rec.Code < 400 || rec.Code >= 500 { t.Fatalf("status = %d, want a 4xx for missing name", rec.Code) } if !strings.Contains(strings.ToLower(rec.Body.String()), "name") { t.Fatalf("response body %q did not mention the missing name field", rec.Body.String()) }

该测试不要求 4xx 的精确状态码,但断言响应体中必须提及name字段,确保错误信息对用户可诊断。既有测试TestGenerateSelfSignedCertRollsBackDBOnFileWriteFailure发送的请求自带Name: "rollback-test",不受新约束影响;buildSelfSignedOptions的单元测试走内部函数、不经过 binding 校验,同样不受影响——这保证了新增校验不会破坏存量测试。

验证体系:三层质量门

计划文档将验证拆成三层,值得作为同类改造的范式参考。

前端质量门

cd app && bun run lint && bun run typecheck

两命令都必须退出码 0。若perfectionist规则重排了 import 顺序(项目 lint 配置的已知行为),运行bun run lint:fix后复检即可。计划明确不为该视图新增组件级单测(项目此视图没有组件测试先例),行为由手动冒烟覆盖。

后端质量门

go test ./api/certificate/ -run TestGenerateSelfSignedCertRejectsEmptyName -race # 新测试 go test ./api/certificate/ ./internal/cert/ -race # 全量回归

格式化方面,gofmt是硬性门槛,goimports若未安装可忽略。设计文档的完整回归范围是go test ./api/certificate/... ./internal/cert/...

手动冒烟清单

计划文档为人工验收列出了 8 项检查,覆盖三条入口(申请证书对话框、证书编辑器、站点编辑器快捷入口)与两种状态(新建、编辑):

  1. 申请证书 → Self-signed:顶部可见续期提示;Name 字段带红色星号、placeholder 为Enter certificate name;Domains / IP Addresses 各渲染为「空行 + Add 按钮」的多行编辑器;
  2. 全空点 Generate → toastPlease enter a name for the certificate
  3. 只填 Name → toastPlease enter at least one domain or IP address
  4. 填 Name + 一个域名 → 证书创建成功、对话框关闭、列表刷新且显示名称;
  5. 打开已有自签名证书 → 仅显示管理面板自身的续期提示,不出现第二个提示(hide-renewal-note生效);
  6. 编辑已有证书时清空 Name → 保存被拦截,直到 Name 补齐;
  7. 站点编辑器的自签名快捷入口 → 续期提示可见、Name 必填;
  8. Custom Domains 分支 → 外观与行为与改造前一致(视觉回归检查)。

i18n 新增字符串

设计文档列出了本次新增的英文源串($gettext提取自 .vue 模板,源语言为英文,其他语言的 .po 文件在仓库 app/src/language 下同步维护):

  • Add Item(组件默认按钮文案)
  • Add Domain(沿用已有)
  • Add IP Address
  • Enter domain name(沿用已有)
  • Enter IP address
  • Enter certificate name
  • Please enter a name for the certificate
  • Nginx UI will automatically renew this certificate as it approaches expiration, based on the global certificate renewal interval and this certificate's validity period.

messages.pot的重新生成属于独立的运维跟进项,不在本次改造范围内。

落地节奏:任务分解与提交规范

实现计划将整个改造拆为 6 个代码提交 + 1 个收尾任务,顺序本身包含工程意图——先完成后端契约变更(Task 2),使后续前端改动始终对着新服务端契约做端到端验证

任务内容产物
Task 1创建StringListInput组件 + re-export新增 2 文件
Task 2后端binding:"required"+ 先写失败测试self_signed.go/self_signed_test.go
Task 3SelfSignedCertFields接入组件、加续期提示、Name 必填改 1 文件
Task 4SelfSignedCertManagementhide-renewal-note改 1 文件
Task 5三个提交/保存路径种子 + 过滤 + 校验 Name改 4 文件
Task 6Custom Domains 切换到StringListInput(纯重构)改 1 文件
Task 7最终 lint/typecheck/go test 全量 + 手动冒烟交接无代码产物

提交信息遵循项目的 imperative 风格并要求 sign-off(Co-Authored-By),开发分支为feature/self-signed-certificate,计划要求直接在该分支提交、不另开子分支。Task 6 被刻意标记为「纯重构,同一 UX、更少重复」,放在最后单独提交,便于 review 时与行为变更隔离。

小结:一处抽象、三处接线、两层校验

本次增强的精髓可以浓缩为「一处抽象、三处接线、两层校验」:一个StringListInput组件统一了 Custom Domains 与自签名 Domains / IP Addresses 的编辑模型;三个 payload 工厂对称处理空行种子、三条提交路径统一裁剪过滤;Name 必填在前端(toast 提示)与后端(binding:"required"+ 接口测试)双重落地。改造还通过hideRenewalNote精确控制了提示信息的展示语境,避免信息噪音。无论是后续扩展新的多行字符串输入场景,还是为其他表单补充请求边界校验,这套「组件抽象 + 种子/过滤对称处理 + 边界契约测试」的模式都具备直接的可复用价值。

  • 后端
  • 前端
  • 运维
  • MCP 服务

【免费下载链接】nginx-ui

Yet another WebUI for Nginx

项目地址:https://gitcode.com/gh_mirrors/ngi/nginx-ui
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询