- 后端
- 前端
- 运维
- MCP 服务
【免费下载链接】nginx-ui
Yet another WebUI for Nginx
导读
本文围绕 Nginx UI 仓库中 自签名证书 UX 增强实现计划 与其配套设计规格展开,深入讲解三项核心改造:将「Custom Domains 多行编辑器」抽象为可复用的StringListInput组件并复用到自签名证书的 Domains / IP Addresses 字段;在自签名表单顶部增加自动续期策略提示;在前端与后端同时强制自签名证书 Name 必填。读完本文,你将掌握该功能模块的组件抽象思路、payload 种子与过滤模式、Go 后端binding:"required"校验契约,以及完整的测试与手工冒烟验证方法。
背景:一次手工测试暴露的三个体验问题
自签名证书创建流程合并进「申请证书(Issue Certificate)」对话框后,Nginx UI 在手工测试中发现三个不一致点,构成了本次增强的动因:
- 编辑器风格不统一。同一对话框里,Custom Domains 分支用的是带「Add/Remove」按钮的多行
AInput列表,而自签名证书的 Domains 与 IP Addresses 用的是 chip 风格的 tags 下拉(ASelect mode="tags")。同样的输入语义,两种编辑模型。 - 缺少自动续期上下文提示。用户在创建自签名证书时,表单内没有任何提示说明 Nginx UI 会自动续期;而续期策略取决于全局设置与单张证书的有效期,用户无从知晓。
- 空 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.vue | Custom Domains 改用StringListInput;submitSelfSigned裁剪过滤并校验 Name;payload 种子[''] |
| app/src/views/certificate/components/SelfSignedCertFields.vue | Domains / IPs 改用StringListInput;新增续期AAlert(带hideRenewalNote开关);Name 必填 |
| app/src/views/certificate/components/SelfSignedCertForm.vue | emptyForm()种子空行数组;submit()裁剪过滤并校验 Name |
| app/src/views/certificate/components/SelfSignedCertManagement.vue | 传hide-renewal-note避免双重提示 |
| app/src/views/certificate/CertificateEditor.vue | save()的isSelfSigned分支裁剪过滤并校验 Name |
| app/src/api/cert.ts | toSelfSignedPayload()种子空行数组 |
| api/certificate/self_signed.go | SelfSignedCertRequest.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 工厂统一种子策略(仓库代码已实现):
- DNSIssueCertificate.vue 的
emptySelfSignedPayload()→domains: ['']、ip_addresses: ['']; - SelfSignedCertForm.vue 的
emptyForm()→domains: defaultDomains?.length ? [...defaultDomains] : ['']、ip_addresses: [''](若外部传入defaultDomains则原样继承,否则也种子空行); - 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 → Generate | cert.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_cert,modify_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.BindAndValid在GenerateSelfSignedCert与ModifySelfSignedCert两个 handler 中同时生效(两者绑定同一个结构体),空 Name 会在进入业务逻辑前被拦截。其余字段维持既有约束:IP 逐项dive,ip校验、KeyType 走自定义校验器auto_cert_key_type、有效期限制在 1–3650 天。
防御性代码保留
计划与设计文档都强调:GenerateSelfSignedCert中selfSignedSlug的 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 项检查,覆盖三条入口(申请证书对话框、证书编辑器、站点编辑器快捷入口)与两种状态(新建、编辑):
- 申请证书 → Self-signed:顶部可见续期提示;Name 字段带红色星号、placeholder 为
Enter certificate name;Domains / IP Addresses 各渲染为「空行 + Add 按钮」的多行编辑器; - 全空点 Generate → toast
Please enter a name for the certificate; - 只填 Name → toast
Please enter at least one domain or IP address; - 填 Name + 一个域名 → 证书创建成功、对话框关闭、列表刷新且显示名称;
- 打开已有自签名证书 → 仅显示管理面板自身的续期提示,不出现第二个提示(
hide-renewal-note生效); - 编辑已有证书时清空 Name → 保存被拦截,直到 Name 补齐;
- 站点编辑器的自签名快捷入口 → 续期提示可见、Name 必填;
- Custom Domains 分支 → 外观与行为与改造前一致(视觉回归检查)。
i18n 新增字符串
设计文档列出了本次新增的英文源串($gettext提取自 .vue 模板,源语言为英文,其他语言的 .po 文件在仓库 app/src/language 下同步维护):
Add Item(组件默认按钮文案)Add Domain(沿用已有)Add IP AddressEnter domain name(沿用已有)Enter IP addressEnter certificate namePlease enter a name for the certificateNginx 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 3 | SelfSignedCertFields接入组件、加续期提示、Name 必填 | 改 1 文件 |
| Task 4 | SelfSignedCertManagement传hide-renewal-note | 改 1 文件 |
| Task 5 | 三个提交/保存路径种子 + 过滤 + 校验 Name | 改 4 文件 |
| Task 6 | Custom 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
相关推荐
Nginx Proxy Manager 证书全解析:HTTP、DNS 与自定义证书的签发、验证与续期指南
Nginx Proxy Manager 证书全解析:HTTP、DNS 与自定义证书的签发、验证与续期指南 Nginx Proxy Manager(NPM)为托管
后端API网关nginx-proxy-manager 证书管理实战:HTTP、DNS 与自定义证书的签发与自动续期
nginx proxy manager 证书管理实战:HTTP、DNS 与自定义证书的签发与自动续期 导读 本文围绕 nginx proxy manager 前
后端API网关Sunshine 游戏串流实战指南:30 分钟上手,客厅大屏畅玩 PC 游戏
Sunshine 游戏串流实战指南:30 分钟上手,客厅大屏畅玩 PC 游戏 游戏 PC 摆在书房,你却在客厅想玩。给这台 PC 装上 Sunshine 就行。
音视频后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考