diff --git a/.claude/docs/ai/project-members/api-handoff-v3.md b/.claude/docs/ai/project-members/api-handoff-v3.md index 413f86f..9d48af2 100644 --- a/.claude/docs/ai/project-members/api-handoff-v3.md +++ b/.claude/docs/ai/project-members/api-handoff-v3.md @@ -68,7 +68,9 @@ 前端保留多选复选框,不因服务器已被其他域名使用而禁用或复制服务器。说明放入 `HelpTip`。409 时保留用户选择,引导修改服务器模板后重试;无权限或未知资源不显示区别提示。不要乐观清空列表,500/网络失败需刷新确认真实状态。 -域名资料保存与绑定保存仍是两个请求,不应显示为跨请求原子提交。当前域名创建后绑定失败的重复提交体验及全资源写入授权仍需后续前端/权限整改,不能误称已闭环。 +域名资料保存与绑定保存仍是两个请求,不应显示为跨请求原子提交。前端已拆分 `DomainForm.js`:收到完整资料成功响应后立即保留域名标识和列表项,关联失败时保留用户选择;资料未变更只重试关联,资料变化则更新既有域名,不重复创建。创建网络异常、服务端错误或缺少标识/项目字段时禁止在当前表单中盲目重发,提示关闭并刷新确认;这不等于服务端幂等键或跨刷新恢复。 + +编辑表单先重新读取真实关联,读取失败或缺少 `items` 数组时禁用保存并提供重读入口,不能把错误解释为空数组。仅展示域名同项目服务器,不按其他域名是否占用过滤;未知项目归属拒绝保存,新建沿用后端当前默认项目。保存期间禁用编辑和重复提交,关闭/切换以及卸载后的旧响应不得污染新表单。资料与关联均成功但列表刷新失败时提示刷新失败,不误报保存失败。全部新增逻辑仅开发,等待统一测试。 ## 当前未覆盖的边界 @@ -84,3 +86,6 @@ 4. 两个域名在同一服务器解析为不同路径;固定私钥/证书/链路径、规范化和跨文件冲突拒绝。 5. 修改服务器不能引入共享冲突;历史危险配置必须在部署前被拒绝,不能调用远端写入。 6. Vue 模板同时渲染两个已选复选框及悬浮提示;SSR 回归不代替真实浏览器、SSH 或多服务器生产推送验收。 +7. 创建成功、关联冲突或网络异常时保留域名与选择,重试不再次创建;资料变更更新已有标识,取消不会隐藏已创建列表项。 +8. 关联读取失败不得提交清空;重新读取成功后恢复选择。跨项目/缺归属服务器不显示为候选,切换表单的旧读取响应不能覆盖新表单。 +9. 重复点击、保存期间切换、卸载和资料响应不完整时不发起多余写请求;失败状态、按钮禁用及中文提示一致。上述回归源码未执行。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 829f405..d68b941 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -5,9 +5,10 @@ 需求确认:沿用已确认的多对多及同项目边界,先开发后统一测试。本批修复创建域名后关联失败的重试状态,限制服务器候选范围,并防止读取关联失败被误当作空数组提交。 - [x] 核对上一批 `e220053` 已提交推送,工作区干净;确认资料与关联仍是两个独立请求,不宣称跨请求原子性。 -- [~] 拆分域名表单逻辑,保留已创建域名标识和待保存关联,避免重试重复创建。 -- [ ] 同项目候选过滤、保存期间防重复及防切换、关联读取失败保护。 -- [ ] 补齐真实组合式状态及模板回归源码,同步总清单及交接边界。 +- [x] 已拆分 `DomainForm.js`,创建资料成功后保留域名标识并更新列表;关联失败重试不再次创建,资料未改时只重试关联,资料改动时更新既有域名。创建结果不明时禁止盲目重试,并提示关闭表单刷新确认。 +- [x] 已接入同项目候选过滤,未知归属不默认为默认项目;保存期间防重复及防切换,编辑前重新读取关联,失败时禁用保存并提供重读按钮;旧读取响应不污染新表单。 +- [x] 已补齐真实组合式状态、管理页接线及模板回归源码,覆盖共享、清空、失败重试、项目过滤、并发点击、卸载与不确定结果;已同步总清单及交接边界,仅做源码与差异检查。 +- [ ] 跨请求原子性、创建请求服务端幂等键、跨页面刷新后的不确定结果恢复仍未实现;不得把本地表单重试保护描述为完整幂等事务。其他资源写入授权及部署安全边界继续按原始清单推进。 - [!] 本批不运行测试、构建、浏览器或部署,待开发阶段完成后统一执行。 ## 2026 年 9 月 28 日:域名与服务器多对多(21:32 接续) diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index cb562ae..c66eba3 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -14,6 +14,8 @@ ### 本轮新增进展与下一步 +- [x] 21:54 接续完成多对多表单收尾源码:资料创建成功、关联失败后保留已创建标识及选择;未修改资料时只重试关联,修改资料时更新既有域名。编辑先读取真实关联,失败禁止保存并可重新读取;服务器候选按项目过滤,保存期间防重复及切换。新增组合式状态、管理页接线及模板回归源码,全部待统一测试。 +- [ ] 表单层保护不等于跨请求原子性或服务端创建幂等:创建响应丢失时要求刷新确认,不自动重发;跨刷新恢复与整项权限、部署整改仍待完成。 - [x] 生命周期收尾及七个资源读取入口已接入真实项目授权,候选已在 `709c1ef`;绑定写入授权与事务审计检查点 `f38e290` 已提交推送。后续源码仍需统一测试,不能套用此前通过记录。 - [x] 本轮多对多子项源码已补齐:两个域名可共享两台服务器,修改或清空一方不删除服务器或另一方关联;保存需双方同项目及实时权限。共享路径冲突在绑定、服务器修改及部署前检查,页面以悬浮提示说明复用。 - [x] 已新增多对多、路径冲突、失败保留、审计回滚和页面多选回归源码;仅进行源码检查、格式化与差异检查,不执行测试、构建、浏览器或远端部署。详细边界见执行清单首节及支持矩阵第二十六节。 diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index cec3216..de95525 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -1,5 +1,11 @@ # 全面审查、测试与优化记录 +## 当前仅开发记录(2026-09-28,21:54 接续) + +多对多前端表单已拆分:同项目服务器候选、编辑前重新读取关联、失败禁用保存及重读入口、资料已创建后的关联重试、防重复和防旧响应覆盖均已开发。资料保存结果不明时阻止盲目重发并要求刷新确认,不声称跨请求原子事务或服务端创建幂等。新增组合式逻辑、管理页接线与模板回归源码已纳入现有测试入口,但没有执行。 + +修改前检查点 `c732013` 已提交推送,后续候选以 Git 记录为准。本批只进行源码检查及 `git diff --check`;未运行测试、构建、浏览器、SSH、清库或部署,没有新增复审通过或生产验收证据,原始 44 项状态不变。 + ## 当前仅开发记录(2026-09-28,21:32 接续) 按用户要求先开发后统一测试。新增多对多仓储/HTTP 用例及共享服务器证书路径校验,覆盖一域名多服务器、一服务器多域名、局部修改/清空、去重、失败保留和审计回滚;服务器编辑与部署预检也接入路径冲突检查。新增工作流停止远端调用及 Vue 多选渲染用例,但全部未执行。本阶段只做源码检查、格式化和差异检查,没有测试、构建、浏览器、SSH、清库或部署。 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 6d6fb0f..4b40c0c 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -1,6 +1,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { settingsPayload } from "./SettingsPayload.js"; import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js"; +import { useDomainForm } from "./DomainForm.js"; const defaultDNSVerificationTimeoutSeconds = 60; const aliyunDNSVerificationTimeoutSeconds = 6; @@ -27,12 +28,11 @@ export function useDashboard() { const domainPageSize = ref(5); const domainPageSizeOptions = [5, 10, 15, 30]; const loading = ref(true); - const saving = ref(false); const error = ref(""); const notice = ref(""); - const formError = ref(""); - const showForm = ref(false); - const editingId = ref(""); + const domainForm = useDomainForm({ fetchAdmin, domains, servers, domainServers, notice, loadData }); + const { saving, formError, showForm, editingId, isEditing, formTitle, form, + openCreate, openEdit, closeForm, saveDomain } = domainForm; const showServerForm = ref(false); const serverEditingId = ref(""); const serverSaving = ref(false); @@ -233,10 +233,6 @@ export function useDashboard() { { value: "other", label: "其他服务商" }, ]; - const isEditing = computed(() => Boolean(editingId.value)); - const formTitle = computed(() => - isEditing.value ? "编辑域名配置" : "添加托管域名", - ); const selectedDNSProvider = computed(() => dnsProviderCatalog.value.find( (provider) => provider.id === settingsForm.value.dns.provider, @@ -305,7 +301,6 @@ export function useDashboard() { const defaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem"; const defaultPrivateKeyPath = "/ssl/{域名}/{证书类型}/key.pem"; - const form = ref(createEmptyForm()); const serverForm = ref(createEmptyServerForm()); const sshKeyForm = ref(createEmptySSHKeyForm()); @@ -363,18 +358,6 @@ export function useDashboard() { return displayLabel(attemptStatusLabels, value); } - function createEmptyForm() { - return { - name: "", - certificate_type: "single", - sans: "", - auto_renew: true, - acme_environment: "staging", - dns_provider: "", - server_ids: [], - }; - } - function createEmptyServerForm() { return { name: "", @@ -589,34 +572,6 @@ export function useDashboard() { domainPage.value = Math.min(domainPageCount.value, domainPage.value + 1); } - function openCreate() { - editingId.value = ""; - form.value = createEmptyForm(); - formError.value = ""; - showForm.value = true; - } - - function openEdit(item) { - editingId.value = item.id; - form.value = { - name: item.name, - certificate_type: item.certificate_type, - sans: (item.sans || []).filter((name) => name !== item.name).join("\n"), - auto_renew: item.auto_renew !== false, - acme_environment: item.acme_environment || "staging", - dns_provider: item.dns_provider || "", - server_ids: (domainServers.value[item.id] || []).map( - (server) => server.id, - ), - }; - formError.value = ""; - showForm.value = true; - } - - function closeForm() { - if (!saving.value) showForm.value = false; - } - function openServerCreate() { serverEditingId.value = ""; serverForm.value = createEmptyServerForm(); @@ -1121,74 +1076,6 @@ export function useDashboard() { } } - async function saveDomainServers(domainId, serverIds) { - const response = await fetchAdmin(`/api/v1/domains/${domainId}/servers`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ server_ids: serverIds }), - }); - const result = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(result.error || "保存域名服务器关联失败"); - domainServers.value = { - ...domainServers.value, - [domainId]: result.items || [], - }; - } - - function parseSANs(value) { - return value - .split(/[\n,,]/) - .map((item) => item.trim()) - .filter(Boolean); - } - - async function saveDomain() { - formError.value = ""; - notice.value = ""; - if (!form.value.name.trim()) { - formError.value = "请输入主域名"; - return; - } - if ( - form.value.certificate_type === "san" && - parseSANs(form.value.sans).length === 0 - ) { - formError.value = "SAN 证书至少需要填写一个附加域名"; - return; - } - saving.value = true; - try { - const payload = { - name: form.value.name, - certificate_type: form.value.certificate_type, - sans: parseSANs(form.value.sans), - auto_renew: form.value.auto_renew, - acme_environment: form.value.acme_environment, - dns_provider: form.value.dns_provider, - }; - const path = isEditing.value - ? `/api/v1/domains/${editingId.value}` - : "/api/v1/domains"; - const response = await fetchAdmin(path, { - method: isEditing.value ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const result = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(result.error || "保存域名失败"); - await saveDomainServers(result.id, form.value.server_ids); - showForm.value = false; - notice.value = isEditing.value - ? "域名配置已更新" - : "域名已添加,等待申请证书"; - await loadData(); - } catch (err) { - formError.value = err.message || "保存域名失败"; - } finally { - saving.value = false; - } - } - async function deleteDomain(item) { if ( !window.confirm( @@ -1303,6 +1190,7 @@ export function useDashboard() { }); return { + ...domainForm, status, currentUser, domains, diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index fcec640..9160b6f 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import "./DeploymentResult.test.js"; import "./DashboardLogout.test.js"; import "./DomainModal.test.js"; +import "./DomainForm.test.js"; import test from "node:test"; import { nextTick, ref } from "vue"; import { useDashboard } from "./Dashboard.js"; @@ -9,6 +10,29 @@ import { useDashboardNavigation } from "./DashboardNavigation.js"; import { summarizeRuntime, runtimeTime, useRuntimeOverview } from "./RuntimeOverview.js"; import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js"; +test("管理页面使用拆分后的域名表单与同项目服务器候选", async (t) => { + const requests = []; + const item = { id: "domain-form", name: "example.test", project_id: "default" }; + const server = { id: "server-form", name: "共享服务器", project_id: "default" }; + const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => { + if (options.method) { + requests.push({ path, ...options }); + return Response.json(path.endsWith("/servers") ? { items: [server] } : item); + } + if (path === "/api/v1/servers") return Response.json({ items: [server, { id: "other", project_id: "other" }] }); + return Response.json({ items: [] }); + }); + state.openCreate(); + assert.deepEqual(state.domainServerOptions.value, [server]); + state.form.value.name = item.name; + state.form.value.server_ids = [server.id]; + await state.saveDomain(); + assert.deepEqual(requests.map(({ method }) => method), ["POST", "PUT"]); + assert.equal(requests[1].path, `/api/v1/domains/${item.id}/servers`); + assert.deepEqual(JSON.parse(requests[1].body), { server_ids: [server.id] }); + assert.equal(state.showForm.value, false); +}); + test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => { const navigation = useDashboardNavigation(ref({ role: "super_admin" })); assert.equal(navigation.activeSection.value, "overview"); @@ -615,7 +639,11 @@ test("domainModal.compilesAndRendersSanFieldsWithoutLiteralNewline", async () => server_ids: [], }, dnsProviders: [], - servers: [], + domainServerOptions: [], + domainBindingsLoading: false, + domainBindingsReady: true, + domainSaveUncertain: false, + reloadDomainBindings() {}, displayLabel: (_, value) => value, webServerLabels: {}, formError: "", diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js new file mode 100644 index 0000000..cc7a017 --- /dev/null +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -0,0 +1,232 @@ +import { computed, onBeforeUnmount, ref } from "vue"; + +function emptyForm() { + return { + project_id: "default", + name: "", + certificate_type: "single", + sans: "", + auto_renew: true, + acme_environment: "staging", + dns_provider: "", + server_ids: [], + }; +} + +function domainPayload(form) { + return { + name: form.name, + certificate_type: form.certificate_type, + sans: form.sans.split(/[\n,,]/).map((item) => item.trim()).filter(Boolean), + auto_renew: form.auto_renew, + acme_environment: form.acme_environment, + dns_provider: form.dns_provider, + }; +} + +export function useDomainForm({ fetchAdmin, domains, servers, domainServers, notice, loadData }) { + const form = ref(emptyForm()); + const editingId = ref(""); + const saving = ref(false); + const showForm = ref(false); + const formError = ref(""); + const domainBindingsLoading = ref(false); + const domainBindingsReady = ref(true); + const domainSaveUncertain = ref(false); + const isEditing = computed(() => Boolean(editingId.value)); + const formTitle = computed(() => isEditing.value ? "编辑域名配置" : "添加托管域名"); + const domainServerOptions = computed(() => servers.value.filter( + (server) => Boolean(form.value.project_id) && server.project_id === form.value.project_id, + )); + let version = 0; + let savedPayload = ""; + let createdInForm = false; + + onBeforeUnmount(() => { version += 1; }); + + function openCreate() { + if (saving.value) return; + version += 1; + editingId.value = ""; + form.value = emptyForm(); + formError.value = ""; + savedPayload = ""; + createdInForm = false; + domainBindingsLoading.value = false; + domainBindingsReady.value = true; + domainSaveUncertain.value = false; + showForm.value = true; + } + + async function openEdit(item) { + if (saving.value) return; + version += 1; + editingId.value = item.id; + form.value = { + ...emptyForm(), + project_id: item.project_id || "", + name: item.name, + certificate_type: item.certificate_type, + sans: (item.sans || []).filter((name) => name !== item.name).join("\n"), + auto_renew: item.auto_renew !== false, + acme_environment: item.acme_environment || "staging", + dns_provider: item.dns_provider || "", + }; + formError.value = ""; + savedPayload = ""; + createdInForm = false; + domainBindingsLoading.value = false; + domainBindingsReady.value = false; + domainSaveUncertain.value = false; + showForm.value = true; + await reloadDomainBindings(); + } + + function closeForm() { + if (saving.value) return; + version += 1; + domainBindingsLoading.value = false; + showForm.value = false; + } + + async function reloadDomainBindings() { + if (saving.value || domainBindingsLoading.value || !editingId.value) return; + const currentVersion = version; + const id = editingId.value; + domainBindingsLoading.value = true; + domainBindingsReady.value = false; + formError.value = ""; + try { + const response = await fetchAdmin(`/api/v1/domains/${id}/servers`); + const result = await response.json(); + if (!response.ok || !Array.isArray(result.items)) { + throw new Error("读取服务器关联失败,请重新读取后再保存,避免覆盖现有关联"); + } + if (currentVersion !== version) return; + form.value.server_ids = result.items.map((server) => server.id); + domainServers.value = { ...domainServers.value, [id]: result.items }; + domainBindingsReady.value = true; + } catch { + if (currentVersion === version) { + formError.value = "读取服务器关联失败,请重新读取后再保存,避免覆盖现有关联"; + } + } finally { + if (currentVersion === version) domainBindingsLoading.value = false; + } + } + + async function saveDomain() { + if (saving.value || domainBindingsLoading.value || domainSaveUncertain.value) return; + formError.value = ""; + notice.value = ""; + if (!domainBindingsReady.value) { + formError.value = "请先成功读取服务器关联,再保存域名配置"; + return; + } + if (!form.value.project_id) { + formError.value = "域名项目归属未确认,请刷新列表后重新编辑"; + return; + } + const payload = domainPayload(form.value); + if (!payload.name.trim()) { + formError.value = "请输入主域名"; + return; + } + if (payload.certificate_type === "san" && payload.sans.length === 0) { + formError.value = "SAN 证书至少需要填写一个附加域名"; + return; + } + const serverIds = [...form.value.server_ids]; + const allowedIds = new Set(domainServerOptions.value.map((server) => server.id)); + if (serverIds.length > 100 || serverIds.some((id) => !allowedIds.has(id))) { + formError.value = "请选择当前项目内可用的服务器,每个域名最多关联一百台"; + return; + } + const serialized = JSON.stringify(payload); + const currentVersion = version; + saving.value = true; + try { + // 资料与关联不是同一事务;已确认保存的资料不因关联失败而重复创建。 + if (savedPayload !== serialized) { + const existingId = editingId.value; + let response; + try { + response = await fetchAdmin(existingId ? `/api/v1/domains/${existingId}` : "/api/v1/domains", { + method: existingId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: serialized, + }); + } catch { + if (currentVersion !== version) return; + domainSaveUncertain.value = true; + throw new Error("域名保存结果未确认,请关闭表单并刷新列表确认,暂不重复提交"); + } + if (currentVersion !== version) return; + const result = await response.json().catch(() => null); + if (currentVersion !== version) return; + if (!response.ok) { + if (response.status >= 500) domainSaveUncertain.value = true; + throw new Error(domainSaveUncertain.value + ? "域名保存结果未确认,请关闭表单并刷新列表确认,暂不重复提交" + : result?.error || "保存域名失败"); + } + if (!result || !/^[A-Za-z0-9_-]{1,128}$/.test(result.id || "") || + !result.project_id || (existingId && result.id !== existingId)) { + domainSaveUncertain.value = true; + throw new Error("域名保存响应不完整,请关闭表单并刷新列表确认,暂不重复提交"); + } + editingId.value = result.id; + form.value.project_id = result.project_id; + createdInForm ||= !existingId; + savedPayload = serialized; + const index = domains.value.findIndex((item) => item.id === result.id); + if (index < 0) domains.value = [...domains.value, result]; + else domains.value = domains.value.map((item) => item.id === result.id ? result : item); + } + if (currentVersion !== version) return; + const currentIds = new Set(domainServerOptions.value.map((server) => server.id)); + if (serverIds.some((id) => !currentIds.has(id))) { + throw new Error("服务器所属项目或可见范围已变化,请重新选择服务器"); + } + const id = editingId.value; + let response; + try { + response = await fetchAdmin(`/api/v1/domains/${id}/servers`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ server_ids: serverIds }), + }); + } catch { + throw new Error("服务器关联保存结果未确认,请保留选择重试或刷新列表确认"); + } + const result = await response.json().catch(() => null); + if (currentVersion !== version) return; + if (!response.ok || !Array.isArray(result?.items)) { + throw new Error(result?.error || "保存服务器关联结果未确认,可保留选择后重试"); + } + domainServers.value = { ...domainServers.value, [id]: result.items }; + showForm.value = false; + notice.value = createdInForm ? "域名已添加,等待申请证书" : "域名配置已更新"; + try { + await loadData(); + } catch { + if (currentVersion === version) notice.value = "域名配置已保存,但列表刷新失败,请手动刷新"; + } + } catch (err) { + if (currentVersion === version) { + const prefix = !savedPayload ? "" : savedPayload === serialized + ? "域名资料已保存;服务器关联尚未确认。" + : "此前域名资料已保存;本次修改尚未完成。"; + formError.value = prefix + (err.message || "保存域名失败"); + } + } finally { + if (currentVersion === version) saving.value = false; + } + } + + return { + form, editingId, saving, showForm, formError, isEditing, formTitle, + domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, + openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings, + }; +} diff --git a/frontend/src/views/Dashboard/DomainForm.test.js b/frontend/src/views/Dashboard/DomainForm.test.js new file mode 100644 index 0000000..91f3d88 --- /dev/null +++ b/frontend/src/views/Dashboard/DomainForm.test.js @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ref } from "vue"; +import { useDomainForm } from "./DomainForm.js"; +import { deferred, mountComposable } from "../../test-support/mount.js"; + +const firstServer = { id: "server-one", project_id: "default", name: "共享服务器一" }; +const secondServer = { id: "server-two", project_id: "default", name: "共享服务器二" }; +const otherServer = { id: "server-other", project_id: "other", name: "其他项目服务器" }; +const firstDomain = { + id: "domain-one", project_id: "default", name: "one.example.test", + certificate_type: "single", sans: [], auto_renew: true, + acme_environment: "staging", dns_provider: "", +}; + +async function mountForm(t, fetcher) { + const deps = { + fetchAdmin: (...args) => fetch(...args), + domains: ref([]), + servers: ref([firstServer, secondServer, otherServer]), + domainServers: ref({ "domain-two": [firstServer, secondServer] }), + notice: ref(""), + loadData: t.mock.fn(async () => {}), + }; + const mounted = await mountComposable(t, () => useDomainForm(deps), fetcher); + return { ...mounted, deps }; +} + +function startCreate(state) { + state.openCreate(); + state.form.value.name = firstDomain.name; + state.form.value.server_ids = [firstServer.id, secondServer.id]; +} + +test("创建成功但关联冲突后只重试关联,不重复创建且不改变另一域名", async (t) => { + const requests = []; + let attempts = 0; + const { state, deps } = await mountForm(t, async (path, options) => { + requests.push({ path, ...options }); + if (path === "/api/v1/domains") return Response.json(firstDomain, { status: 201 }); + attempts += 1; + return attempts === 1 + ? Response.json({ error: "共享路径冲突" }, { status: 409 }) + : Response.json({ items: [firstServer, secondServer] }); + }); + startCreate(state); + await state.saveDomain(); + assert.equal(state.editingId.value, firstDomain.id); + assert.equal(state.showForm.value, true); + assert.equal(state.saving.value, false); + assert.deepEqual(state.form.value.server_ids, [firstServer.id, secondServer.id]); + assert.match(state.formError.value, /域名资料已保存.*共享路径冲突/); + assert.deepEqual(deps.domains.value, [firstDomain]); + await state.saveDomain(); + assert.deepEqual(requests.map(({ method }) => method), ["POST", "PUT", "PUT"]); + assert.equal(requests[1].path, `/api/v1/domains/${firstDomain.id}/servers`); + assert.equal(requests[2].body, requests[1].body); + assert.deepEqual(deps.domainServers.value["domain-two"], [firstServer, secondServer]); + assert.deepEqual(deps.domainServers.value[firstDomain.id], [firstServer, secondServer]); + assert.equal(state.showForm.value, false); + assert.match(deps.notice.value, /域名已添加/); + assert.equal(deps.loadData.mock.callCount(), 1); +}); + +test("部分成功后修改资料使用已有标识更新,取消也保留已创建列表项", async (t) => { + const requests = []; + const { state, deps } = await mountForm(t, async (path, options) => { + requests.push({ path, ...options }); + if (path.endsWith("/servers")) return Response.json({ error: "关联未保存" }, { status: 403 }); + return Response.json({ ...firstDomain, ...JSON.parse(options.body) }); + }); + startCreate(state); + await state.saveDomain(); + state.form.value.auto_renew = false; + await state.saveDomain(); + assert.deepEqual(requests.map(({ method }) => method), ["POST", "PUT", "PUT", "PUT"]); + assert.equal(requests[2].path, `/api/v1/domains/${firstDomain.id}`); + assert.equal(JSON.parse(requests[2].body).auto_renew, false); + state.closeForm(); + assert.equal(deps.domains.value.length, 1); + assert.equal(deps.domains.value[0].auto_renew, false); + state.openCreate(); + assert.equal(state.editingId.value, ""); + assert.deepEqual(state.form.value.server_ids, []); +}); + +test("编辑先读取真实关联,明确清空只提交当前域名", async (t) => { + const requests = []; + const { state, deps } = await mountForm(t, async (path, options = {}) => { + requests.push({ path, ...options }); + if (!options.method) return Response.json({ items: [firstServer, secondServer] }); + if (path.endsWith("/servers")) return Response.json({ items: [] }); + return Response.json(firstDomain); + }); + await state.openEdit(firstDomain); + assert.deepEqual(state.form.value.server_ids, [firstServer.id, secondServer.id]); + state.form.value.server_ids = []; + await state.saveDomain(); + assert.equal(requests.length, 3); + assert.deepEqual(JSON.parse(requests[2].body), { server_ids: [] }); + assert.deepEqual(deps.domainServers.value[firstDomain.id], []); + assert.deepEqual(deps.domainServers.value["domain-two"], [firstServer, secondServer]); +}); + +test("服务器按域名项目过滤,不限制其他域名共享,缺归属不默认为默认项目", async (t) => { + const { state, deps } = await mountForm(t, async () => Response.json({ items: [] })); + deps.servers.value.push({ id: "unknown" }); + startCreate(state); + assert.deepEqual(state.domainServerOptions.value.map((server) => server.id), [firstServer.id, secondServer.id]); + await state.openEdit({ ...firstDomain, project_id: "other" }); + assert.deepEqual(state.domainServerOptions.value, [otherServer]); + state.form.value.server_ids = [firstServer.id]; + await state.saveDomain(); + assert.match(state.formError.value, /当前项目/); + await state.openEdit({ ...firstDomain, project_id: "" }); + assert.deepEqual(state.domainServerOptions.value, []); + await state.saveDomain(); + assert.match(state.formError.value, /项目归属未确认/); +}); + +for (const failure of ["forbidden", "network", "malformed"]) { + test(`关联读取失败不作为空数组保存,可重新读取:${failure}`, async (t) => { + let reads = 0; + const { state, deps } = await mountForm(t, async (_, options = {}) => { + assert.equal(options.method, undefined); + reads += 1; + if (reads > 1) return Response.json({ items: [firstServer] }); + if (failure === "network") throw new Error("offline"); + return failure === "forbidden" + ? Response.json({ error: "无权限" }, { status: 403 }) + : Response.json({}); + }); + deps.domainServers.value[firstDomain.id] = [firstServer]; + await state.openEdit(firstDomain); + assert.equal(state.domainBindingsReady.value, false); + await state.saveDomain(); + assert.equal(reads, 1); + assert.deepEqual(deps.domainServers.value[firstDomain.id], [firstServer]); + await state.reloadDomainBindings(); + assert.equal(state.domainBindingsReady.value, true); + assert.deepEqual(state.form.value.server_ids, [firstServer.id]); + }); +} + +test("切换表单时旧关联读取结果不能污染新域名", async (t) => { + const pending = deferred(); + const { state, deps } = await mountForm(t, async () => pending.promise); + const reading = state.openEdit(firstDomain); + assert.equal(state.domainBindingsLoading.value, true); + state.closeForm(); + state.openCreate(); + pending.resolve(Response.json({ items: [firstServer] })); + await reading; + assert.equal(state.editingId.value, ""); + assert.deepEqual(state.form.value.server_ids, []); + assert.equal(state.domainBindingsLoading.value, false); + assert.equal(deps.domainServers.value[firstDomain.id], undefined); +}); + +test("保存期间重复点击、关闭和切换表单不创建额外请求", async (t) => { + const pending = deferred(); + let calls = 0; + const { state } = await mountForm(t, async (path) => { + calls += 1; + return path.endsWith("/servers") ? Response.json({ items: [firstServer, secondServer] }) : pending.promise; + }); + startCreate(state); + const saving = state.saveDomain(); + await state.saveDomain(); + state.openCreate(); + await state.openEdit({ ...firstDomain, id: "other-domain" }); + state.closeForm(); + assert.equal(calls, 1); + assert.equal(state.showForm.value, true); + assert.equal(state.form.value.name, firstDomain.name); + pending.resolve(Response.json(firstDomain)); + await saving; + assert.equal(calls, 2); +}); + +for (const failure of ["network", "server", "malformed", "missing-project"]) { + test(`资料保存结果不明确时禁止盲目重新创建:${failure}`, async (t) => { + let calls = 0; + const { state } = await mountForm(t, async () => { + calls += 1; + if (failure === "network") throw new Error("offline"); + if (failure === "server") return Response.json({}, { status: 500 }); + return Response.json(failure === "missing-project" ? { id: firstDomain.id } : {}); + }); + startCreate(state); + await state.saveDomain(); + assert.equal(state.domainSaveUncertain.value, true); + assert.match(state.formError.value, /刷新列表确认/); + await state.saveDomain(); + assert.equal(calls, 1); + }); +} + +test("卸载后不继续保存关联,不改写列表", async (t) => { + const pending = deferred(); + let calls = 0; + const { state, deps, unmount } = await mountForm(t, async () => { + calls += 1; + return pending.promise; + }); + startCreate(state); + const saving = state.saveDomain(); + unmount(); + pending.resolve(Response.json(firstDomain)); + await saving; + assert.equal(calls, 1); + assert.deepEqual(deps.domains.value, []); +}); + +test("关联网络失败可重试,但刷新失败不能被误报为保存失败", async (t) => { + let bindings = 0; + const { state, deps } = await mountForm(t, async (path) => { + if (!path.endsWith("/servers")) return Response.json(firstDomain); + bindings += 1; + if (bindings === 1) throw new Error("offline"); + return Response.json({ items: [firstServer, secondServer] }); + }); + deps.loadData.mock.mockImplementation(async () => { throw new Error("offline"); }); + startCreate(state); + await state.saveDomain(); + assert.match(state.formError.value, /关联保存结果未确认/); + assert.equal(state.domainSaveUncertain.value, false); + await state.saveDomain(); + assert.equal(state.showForm.value, false); + assert.equal(state.formError.value, ""); + assert.match(deps.notice.value, /配置已保存.*列表刷新失败/); +}); diff --git a/frontend/src/views/Dashboard/DomainModal.test.js b/frontend/src/views/Dashboard/DomainModal.test.js index 6d33c01..57c4b83 100644 --- a/frontend/src/views/Dashboard/DomainModal.test.js +++ b/frontend/src/views/Dashboard/DomainModal.test.js @@ -5,7 +5,7 @@ import * as Vue from "vue"; import { parse, compileTemplate } from "@vue/compiler-sfc"; import { renderToString } from "@vue/server-renderer"; -test("域名表单使用复选框同时显示两台已关联服务器并提供复用提示", async () => { +async function renderModal(overrides = {}) { const source = await readFile(new URL("./DomainModal.vue", import.meta.url), "utf8"); const { descriptor, errors } = parse(source); assert.deepEqual(errors, []); @@ -35,7 +35,7 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供 server_ids: ["server-one", "server-two"], }, dnsProviders: [], - servers: [ + domainServerOptions: [ { id: "server-one", name: "共享服务器一", address: "192.0.2.1", web_server: "nginx" }, { id: "server-two", name: "共享服务器二", address: "192.0.2.2", web_server: "nginx" }, ], @@ -43,6 +43,11 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供 webServerLabels: {}, formError: "", saving: false, + domainBindingsLoading: false, + domainBindingsReady: true, + domainSaveUncertain: false, + reloadDomainBindings() {}, + ...overrides, }), }); app.component("HelpTip", { @@ -51,6 +56,11 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供 app.config.warnHandler = (message) => warnings.push(message); const html = await renderToString(app); assert.deepEqual(warnings, []); + return html; +} + +test("域名表单使用复选框同时显示两台已关联服务器并提供复用提示", async () => { + const html = await renderModal(); const inputs = html.match(/]*>/g) || []; for (const id of ["server-one", "server-two"]) { const input = inputs.find((value) => value.includes(`value="${id}"`)); @@ -60,4 +70,23 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供 } assert.match(html, /同一服务器可关联多个域名/); assert.match(html, /不影响其他域名/); + assert.match(html, /当前域名所属项目/); +}); + +test("关联读取失败或结果未确认时禁用提交,读取失败提供重试入口", async () => { + for (const overrides of [ + { domainBindingsReady: false }, + { domainBindingsLoading: true }, + { domainSaveUncertain: true }, + { saving: true }, + ]) { + const html = await renderModal(overrides); + const submit = (html.match(/]*>/g) || []).find((tag) => tag.includes('type="submit"')); + assert.ok(submit); + assert.match(submit, /\bdisabled(?:[\s=>])/); + const picker = (html.match(/]*>/g) || [])[0]; + assert.match(picker, /\bdisabled(?:[\s=>])/); + if (overrides.domainBindingsReady === false) assert.match(html, /重新读取关联/); + if (overrides.domainBindingsLoading) assert.match(html, /正在读取服务器关联/); + } }); diff --git a/frontend/src/views/Dashboard/DomainModal.vue b/frontend/src/views/Dashboard/DomainModal.vue index 3c9a04d..f6d4533 100644 --- a/frontend/src/views/Dashboard/DomainModal.vue +++ b/frontend/src/views/Dashboard/DomainModal.vue @@ -9,7 +9,11 @@ const { saveDomain, form, dnsProviders, - servers, + domainServerOptions, + domainBindingsLoading, + domainBindingsReady, + domainSaveUncertain, + reloadDomainBindings, displayLabel, webServerLabels, formError, @@ -34,6 +38,7 @@ const { class="close-button" type="button" aria-label="关闭" + :disabled="saving" @click="closeForm" > × @@ -46,6 +51,7 @@ const { id="domain-name" name="name" v-model="form.name" + :disabled="saving || domainSaveUncertain" type="text" placeholder="例如 example.com" autocomplete="off" @@ -56,6 +62,7 @@ const { id="certificate-type" name="certificate_type" v-model="form.certificate_type" + :disabled="saving || domainSaveUncertain" > @@ -71,6 +78,7 @@ const { id="domain-sans" name="sans" v-model="form.sans" + :disabled="saving || domainSaveUncertain" rows="2" :placeholder="'www.example.com\napi.example.com'" > @@ -82,6 +90,7 @@ const { id="acme-environment" name="acme_environment" v-model="form.acme_environment" + :disabled="saving || domainSaveUncertain" > @@ -93,6 +102,7 @@ const { id="dns-provider" name="dns_provider" v-model="form.dns_provider" + :disabled="saving || domainSaveUncertain" >