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 (
-
);
@@ -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 ;
}
@@ -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 {