From 49435aeaa2a1df34c8caeee59e02bec0d1f3003c Mon Sep 17 00:00:00 2001 From: wangxuming Date: Wed, 15 Jul 2026 10:05:33 +0800 Subject: [PATCH] fix: unify project create form and timezone data --- findings.md | 10 +++ progress.md | 12 +++ server/internal/httpapi/admin.go | 1 + server/internal/httpapi/admin_test.go | 10 +++ task_plan.md | 11 ++- web/src/pages/AdminPage.test.tsx | 33 ++++++++- web/src/pages/AdminPage.tsx | 102 ++++++++++++-------------- web/src/styles.css | 5 ++ 8 files changed, 126 insertions(+), 58 deletions(-) diff --git a/findings.md b/findings.md index 036f11d..f22834a 100644 --- a/findings.md +++ b/findings.md @@ -1,5 +1,15 @@ # 景区排队叫号系统:调研发现与决策台账 +## Phase 28 创建项目与项目维护表单统一(2026-07-15) + +- 创建页 `ProjectProfileForm` 当前只展示项目名称、项目编码和票号格式;项目维护页 `ProjectSettingsForm` 还展示项目状态、每次叫号数量、单号预计间隔和游客官方提示,两个页面的可见字段与布局不一致。 +- 创建页向 `POST /api/admin/projects` 隐式发送 `timezone: "Asia/Shanghai"`;后端 `validateAdminProjectRequest` 用 `time.LoadLocation` 校验时区。 +- 服务端运行镜像基于 Alpine,未安装 `tzdata`,且 Go API 未导入 `time/tzdata`;在精简运行环境中可能无法加载 `Asia/Shanghai`,造成截图中的“项目时区无效”。 +- 执行方案已获用户确认:创建页与维护页共用完整项目表单和默认值;时区继续使用维护页同样的默认值,不增加手填时区控件;服务端嵌入时区数据,并增加针对该回归的测试。 +- 已将项目名称、编码、票号格式、状态、每次叫号数量、单号预计间隔和游客官方提示统一到 `ProjectForm`;创建提交沿用维护页的默认状态、数量、间隔和官方提示,并在创建基础记录后保存运行设置。 +- Go API 的 `httpapi` 包已 blank-import `time/tzdata`,让 `Asia/Shanghai` 在 `CGO_ENABLED=0` 的 Alpine 运行镜像中也能被 `time.LoadLocation` 解析。 +- 回归结果:前端 11 个测试文件共 38 项测试、TypeScript 检查、Vite 生产构建、Go 全量测试与 `go vet` 均通过;在 `ZONEINFO` 指向不存在路径时,默认时区回归测试仍通过。 + ## 员工端末号预计时长与闪屏(2026-07-12) - `queueSnapshot` 当前把 `metrics.estimated_wait` 固定写成 `nil`,等待票的 `staffTicketView` 也未见按位置注入 ETA;员工前端却只读取最后一张等待票的 `estimated_wait`,因此稳定落入“暂不可估算”。 - 后台、公屏和游客接口已经统一调用 `domain.CalculateETA`;员工快照应按 `waiting_count - 1` 作为末号前方人数,并传入项目级 `ETAIntervalSeconds` 与当前运行状态。 diff --git a/progress.md b/progress.md index c5e3c4e..8626664 100644 --- a/progress.md +++ b/progress.md @@ -453,3 +453,15 @@ - 增加独立 `/usr/local/bin/migrate`、`/usr/local/bin/bootstrap-admin` 镜像命令和生产交接文档;明确 Redis/Kubernetes/Secret/备份由运维接入。 - 验证通过:Go 测试、race、vet、build;前端 37 项测试、类型检查、生产构建;临时 PostgreSQL 集成测试;真实 API/数据库烟测;shell 语法检查。 - 当前未在本机验证 Docker/Kubernetes,也未完成 3000 并发压测、PITR/RPO/RTO 演练;这些属于运维上线前的环境验证。 + +# Session: 2026-07-15(创建项目与项目维护表单统一) + +- 根据用户截图确认创建页只显示基础字段,维护页包含完整项目运行配置;后端错误来自隐藏 `Asia/Shanghai` 在 Alpine 精简环境中缺少时区数据。 +- 用户已明确确认执行:创建页复用维护页完整表单,沿用合法默认时区并修复服务端时区数据加载。 +- 已将 `ProjectProfileForm` 与 `ProjectSettingsForm` 合并为共享 `ProjectForm`;创建和维护现在使用同一组字段、默认值、网格布局和保存操作区。 +- 创建流程保存基础信息后继续保存完整运行设置;创建页不再只提交基础字段,维护页行为保持不变。 +- Go `httpapi` 包嵌入 `time/tzdata`,避免 Alpine 精简镜像因缺少系统时区文件把 `Asia/Shanghai` 判为无效。 +- 补充创建页字段一致性测试和默认时区测试。 +- 补充创建页移动端操作区换行规则,避免取消/创建双按钮在窄屏下挤压。 +- 验证通过:前端 TypeScript、11 个文件 38 项 Vitest、Vite 生产构建、Go 全量测试、`go vet`、缺失系统时区文件场景回归测试和 `git diff --check`。 +- 本地浏览器连接验证时无可用管理端登录会话,未代填账号密码;未登录页面正确显示管理端登录入口,页面业务布局由组件测试和构建验证覆盖。 diff --git a/server/internal/httpapi/admin.go b/server/internal/httpapi/admin.go index 8d04968..e8fdf6e 100644 --- a/server/internal/httpapi/admin.go +++ b/server/internal/httpapi/admin.go @@ -7,6 +7,7 @@ import ( "regexp" "strings" "time" + _ "time/tzdata" "unicode/utf8" "calllinesystem/server/internal/domain" diff --git a/server/internal/httpapi/admin_test.go b/server/internal/httpapi/admin_test.go index 65b81f6..1404843 100644 --- a/server/internal/httpapi/admin_test.go +++ b/server/internal/httpapi/admin_test.go @@ -17,6 +17,16 @@ func TestValidateAdminProjectRequestNormalizesProjectProfile(t *testing.T) { } } +func TestValidateAdminProjectRequestUsesDefaultTimezone(t *testing.T) { + got, err := validateAdminProjectRequest(adminProjectRequest{Name: "漂流", Code: "RIDE", TicketPrefix: "A"}) + if err != nil { + t.Fatal(err) + } + if got.Timezone != "Asia/Shanghai" { + t.Fatalf("default timezone = %q", got.Timezone) + } +} + func TestValidateAdminProjectRequestRejectsInvalidProfile(t *testing.T) { tests := []adminProjectRequest{ {Name: "", Code: "RIDE", Timezone: "Asia/Shanghai", TicketPrefix: "A"}, diff --git a/task_plan.md b/task_plan.md index ed730ad..3223c76 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,10 +4,17 @@ 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 ## Current Phase -Phase 27(生产后端与数据库基础交接) +Phase 28(创建项目与项目维护表单统一) ## Phases +### Phase 28: 创建项目与项目维护表单统一 +- [x] 让创建项目复用项目维护的完整字段与布局 +- [x] 保证创建提交的项目配置与页面默认值一致 +- [x] 修复 Alpine 运行环境缺少时区数据导致的合法时区校验失败 +- [x] 补充前后端回归测试并完成类型检查、构建验证 +- **Status:** complete + ### Phase 27: 生产后端与数据库基础交接 - [x] 确认独立 PostgreSQL、Kubernetes 业务服务、全景区上线和 3000 峰值在线用户边界 - [x] 修复真实 PostgreSQL 烟测、管理员/手动叫号/项目筛选契约 @@ -300,6 +307,8 @@ Phase 27(生产后端与数据库基础交接) | 首次从 `server/` 目录运行 gofmt 时仍使用 `server/...` 相对路径 | 1 | 改用 `internal/httpapi/...` 后 Go 全量测试通过 | | Phase 12 首次追加 findings/progress 时补丁标题或空 hunk 不匹配 | 2 | 读取文件尾部后按现有章节精确追加;代码未受影响 | | 前端生产构建发现 BatchCard 测试仍传入已删除的 `readOnly` 属性 | 1 | 删除两个遗留测试属性后类型检查和生产构建通过 | +| 本轮首次连接本地浏览器时运行时尚未初始化 | 1 | 按浏览器技能规范初始化运行时后重新连接,未影响代码验证 | +| 本轮一次 Go 回归从仓库根目录执行,未找到 `server/go.mod` | 1 | 改在 `server/` 模块目录执行,时区回归测试通过 | ## Notes - 所有网络资料保留来源链接和访问时间(2026-07-10)。 diff --git a/web/src/pages/AdminPage.test.tsx b/web/src/pages/AdminPage.test.tsx index 3301c98..969b517 100644 --- a/web/src/pages/AdminPage.test.tsx +++ b/web/src/pages/AdminPage.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; +import { api } from "../api"; vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) =>
{children}
})); vi.mock("../hooks/usePollingResource", () => ({ @@ -117,16 +118,44 @@ describe("AdminPage active tickets", () => { expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible(); }); - it("创建项目使用独立表单", () => { + it("创建项目与维护项目使用相同字段", () => { render(); expect(screen.getByRole("form", { name: "创建项目" })).toBeVisible(); expect(screen.getByRole("textbox", { name: "项目名称" })).toBeVisible(); expect(screen.getByRole("textbox", { name: "项目编码" })).toBeVisible(); expect(screen.getByRole("combobox", { name: "票号格式" })).toHaveDisplayValue("00000"); + expect(screen.getByRole("combobox", { name: "项目状态" })).toHaveDisplayValue("未开放"); + expect(screen.getByRole("spinbutton", { name: "每次叫号数量" })).toHaveValue(1); + expect(screen.getByRole("spinbutton", { name: "单个号码预计间隔时间(秒)" })).toHaveValue(60); + expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请您在景区附近等候,注意听从工作人员指引。"); expect(screen.queryByRole("textbox", { name: "时区" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "创建项目" })).toBeVisible(); }); + it("创建项目提交完整的默认配置", async () => { + const createdProject = { id: "project-2", code: "NEW-RIDE", name: "新项目", status: "NOT_OPEN", batch_size: 1, timezone: "Asia/Shanghai", ticket_prefix: "A" }; + const createProject = vi.spyOn(api, "createProject").mockResolvedValue({ project: createdProject }); + const updateProjectSettings = vi.spyOn(api, "updateProjectSettings").mockResolvedValue({ project: createdProject }); + try { + render(); + fireEvent.change(screen.getByRole("textbox", { name: "项目名称" }), { target: { value: "新项目" } }); + fireEvent.change(screen.getByRole("textbox", { name: "项目编码" }), { target: { value: "new-ride" } }); + fireEvent.submit(screen.getByRole("form", { name: "创建项目" })); + + await waitFor(() => expect(createProject).toHaveBeenCalledTimes(1)); + expect(createProject).toHaveBeenCalledWith({ name: "新项目", code: "NEW-RIDE", timezone: "Asia/Shanghai", ticket_prefix: "A" }); + expect(updateProjectSettings).toHaveBeenCalledWith("project-2", { + status: "NOT_OPEN", + call_batch_size: 1, + grace_period_minutes: 5, + eta_interval_seconds: 60, + visitor_notice: "请您在景区附近等候,注意听从工作人员指引。", + }); + } finally { + vi.restoreAllMocks(); + } + }); + it("keeps the screen center free of contact details", () => { render(); diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index 1cce931..4dcb0c9 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -96,7 +96,13 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi ); } -type SettingsDraft = { +const DEFAULT_PROJECT_TIMEZONE = "Asia/Shanghai"; +const DEFAULT_TICKET_PREFIX = "A"; +const DEFAULT_VISITOR_NOTICE = "请您在景区附近等候,注意听从工作人员指引。"; + +type ProjectDraft = { + name: string; + code: string; status: string; callBatchSize: string; gracePeriodMinutes: string; @@ -104,24 +110,25 @@ type SettingsDraft = { visitorNotice: string; }; -function draftFor(project: AdminProjectDto): SettingsDraft { +function draftFor(project?: AdminProjectDto): ProjectDraft { return { - status: project.status, - callBatchSize: String(project.call_batch_size ?? project.batch_size ?? 1), - gracePeriodMinutes: String(project.grace_period_minutes ?? 0), - etaIntervalSeconds: String(project.eta?.interval_per_number_seconds ?? 60), - visitorNotice: project.visitor_notice ?? "", + name: project?.name ?? "", + code: project?.code ?? "", + status: project?.status ?? "NOT_OPEN", + callBatchSize: String(project?.call_batch_size ?? project?.batch_size ?? 1), + gracePeriodMinutes: String(project?.grace_period_minutes ?? 5), + etaIntervalSeconds: String(project?.eta?.interval_per_number_seconds ?? 60), + visitorNotice: project ? project.visitor_notice ?? "" : DEFAULT_VISITOR_NOTICE, }; } -function ProjectSettingsForm({ project, onSaved }: { project: AdminProjectDto; onSaved: () => void | Promise }) { +function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise }) { + const navigate = useNavigate(); const [draft, setDraft] = useState(() => draftFor(project)); - const [name, setName] = useState(project.name); - const [code, setCode] = useState(project.code ?? ""); const [saving, setSaving] = useState(false); const [notice, setNotice] = useState<{ tone: "success" | "danger"; message?: string } | null>(null); - const update = (patch: Partial) => { + const update = (patch: Partial) => { setDraft((current) => ({ ...current, ...patch })); setNotice(null); }; @@ -131,38 +138,51 @@ function ProjectSettingsForm({ project, onSaved }: { project: AdminProjectDto; o setSaving(true); setNotice(null); try { - await api.updateProject(project.id, { - name, - code: code.toUpperCase(), - timezone: project.timezone ?? "Asia/Shanghai", - ticket_prefix: project.ticket_prefix ?? "A", - }); - await api.updateProjectSettings(project.id, { + const profile = { + name: draft.name, + code: draft.code.toUpperCase(), + timezone: project?.timezone ?? DEFAULT_PROJECT_TIMEZONE, + ticket_prefix: project?.ticket_prefix ?? DEFAULT_TICKET_PREFIX, + }; + let projectId = project?.id; + if (projectId) { + await api.updateProject(projectId, profile); + } else { + const response = await api.createProject(profile); + projectId = response.project.id; + } + if (!projectId) throw new Error("项目创建未返回项目编号。"); + await api.updateProjectSettings(projectId, { status: draft.status, call_batch_size: Number(draft.callBatchSize), grace_period_minutes: Number(draft.gracePeriodMinutes), eta_interval_seconds: Number(draft.etaIntervalSeconds), visitor_notice: draft.visitorNotice, }); - setNotice({ tone: "success", message: "项目维护内容已更新。" }); await onSaved(); + if (project) { + setNotice({ tone: "success", message: "项目维护内容已更新。" }); + } else { + navigate("/admin/projects", { replace: true }); + } } catch (caught) { - setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目未保存,请检查后重试。" }); + setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目保存失败,请检查后重试。" }); } finally { setSaving(false); } }; return ( -
+ + {!project ?

创建项目

创建项目并同时设置运行规则。

: null}
{notice ? {notice.message || null} : null} - + {!project ? 取消 : null} +
); @@ -210,40 +231,11 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) { ; } -function ProjectProfileForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise }) { - const navigate = useNavigate(); - const [name, setName] = useState(project?.name ?? ""); - const [code, setCode] = useState(project?.code ?? ""); - const timezone = project?.timezone ?? "Asia/Shanghai"; - const ticketPrefix = project?.ticket_prefix ?? "A"; - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const submit = async (event: FormEvent) => { - event.preventDefault(); setSaving(true); setError(null); - try { - const payload = { name, code: code.toUpperCase(), timezone, ticket_prefix: ticketPrefix }; - if (project) await api.updateProject(project.id, payload); else await api.createProject(payload); - await onSaved(); - if (!project) navigate("/admin/projects", { replace: true }); - } catch (caught) { setError(caught instanceof ApiError ? caught.message : "项目保存失败,请检查后重试。"); } - finally { setSaving(false); } - }; - return
-

{project ? "基础信息" : "创建项目"}

{project ? "修改项目名称与基础标识。" : "创建后可在项目维护中设置运行规则。"}

- {error ? : null} -
-
-
-
取消
-
-
; -} - function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise }) { if (!project) return
返回项目列表
; return

{project.name}

返回列表
- +
; } @@ -454,7 +446,7 @@ export function AdminPage() { <> {section === "overview" ? : null} - {section === "projects" ? (location.pathname.endsWith("/new") ? : location.pathname === "/admin/projects" ? : project.id === resourceId)} onRefresh={resource.refresh} />) : null} + {section === "projects" ? (location.pathname.endsWith("/new") ? : location.pathname === "/admin/projects" ? : project.id === resourceId)} onRefresh={resource.refresh} />) : null} {section === "accounts" ? (location.pathname.endsWith("/new") ? : location.pathname === "/admin/accounts" ? : ) : null} {section === "display" ? : null} diff --git a/web/src/styles.css b/web/src/styles.css index 7972e97..841a6ea 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3016,6 +3016,11 @@ tbody tr:hover { .project-settings-form__actions { justify-content: stretch; + flex-wrap: wrap; + } + + .project-settings-form__actions .feedback { + flex-basis: 100%; } .project-settings-form__actions .button {