4 Commits

Author SHA1 Message Date
6c1af1fd05 build: add frontend container image 2026-07-14 19:40:36 +08:00
b8582ba3e9 docs: plan web container implementation 2026-07-14 19:37:34 +08:00
6748861327 docs: design web container deployment 2026-07-14 19:29:02 +08:00
4a73f81195 修改dockerfile 2026-07-14 19:15:13 +08:00
14 changed files with 282 additions and 126 deletions

View File

@@ -0,0 +1,141 @@
# Web Container Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a production frontend image so `docker build -t xqkqueue-web:<tag> ./web` succeeds and React client-side routes work behind Kubernetes Ingress.
**Architecture:** A Node.js 22 Alpine build stage installs locked pnpm dependencies and creates the Vite `dist` output. An Nginx Alpine runtime stage serves that output on port 80 and falls back to `index.html` for client-side routes; Kubernetes Ingress routes `/api/*` directly to the API Service.
**Tech Stack:** Node.js 22, pnpm 10.17.0, Vite 7, Nginx 1.28 Alpine, Docker
## Global Constraints
- Keep the Docker build context as `./web`.
- Serve the frontend on container port 80.
- Do not proxy `/api` in the web container; Kubernetes Ingress owns API routing.
- Use `pnpm install --frozen-lockfile` for reproducible dependency resolution.
- Do not modify frontend business code or add Kubernetes manifests.
---
### Task 1: Production web image and SPA server
**Files:**
- Create: `web/Dockerfile`
- Create: `web/nginx.conf`
- Create: `web/.dockerignore`
**Interfaces:**
- Consumes: `web/package.json`, `web/pnpm-lock.yaml`, the Vite source tree, and a Kubernetes Ingress that sends non-API paths to port 80.
- Produces: a static Nginx image exposing port 80, with Vite assets in `/usr/share/nginx/html` and SPA fallback for unknown non-file paths.
- [ ] **Step 1: Reproduce the missing build definition**
Run from the repository root:
```bash
docker build -t xqkqueue-web:verify ./web
```
Expected before implementation: FAIL with `failed to read dockerfile: open Dockerfile: no such file or directory`.
- [ ] **Step 2: Add the multi-stage Dockerfile**
Create `web/Dockerfile`:
```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.17.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
- [ ] **Step 3: Add SPA-aware Nginx configuration**
Create `web/nginx.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
- [ ] **Step 4: Exclude local and generated files from the build context**
Create `web/.dockerignore`:
```dockerignore
node_modules
dist
coverage
playwright-report
test-results
*.log
*.tsbuildinfo
.DS_Store
.idea
.vscode
```
- [ ] **Step 5: Verify the existing production frontend build**
Run:
```bash
pnpm --dir web build
```
Expected: TypeScript checks and the Vite production build finish with exit code 0 and write `web/dist/index.html`.
- [ ] **Step 6: Build the container image**
Run:
```bash
docker build --progress=plain -t xqkqueue-web:verify ./web
```
Expected: exit code 0, including successful `pnpm install --frozen-lockfile`, `pnpm build`, and Nginx runtime stages.
- [ ] **Step 7: Verify root and client-side route responses**
Run:
```bash
docker run --rm -d --name xqkqueue-web-verify -p 18080:80 xqkqueue-web:verify
curl --fail http://127.0.0.1:18080/
curl --fail http://127.0.0.1:18080/admin
docker stop xqkqueue-web-verify
```
Expected: both requests return the Vite application HTML with HTTP 200, and the verification container stops cleanly.
- [ ] **Step 8: Review and commit the implementation**
Run:
```bash
git diff --check
git status --short
git add web/Dockerfile web/nginx.conf web/.dockerignore
git commit -m "build: add frontend container image"
```
Expected: only the three intended web container files are committed.

View File

@@ -0,0 +1,47 @@
# Web Container and Kubernetes Routing Design
## Goal
Provide a production container image for the React/Vite frontend so the existing Jenkins command `docker build ./web` succeeds and the application works correctly behind Kubernetes Ingress.
## Architecture
The web image uses a multi-stage build:
1. A Node.js 22 Alpine stage installs the locked pnpm dependencies and runs the existing `pnpm build` script.
2. An Nginx Alpine stage serves the generated `dist` directory on port 80.
The web container serves static assets only. It does not proxy API traffic and therefore has no dependency on Kubernetes service names.
## Request Routing
Kubernetes Ingress exposes the frontend and API on one origin:
- `/api/*` routes to the API Service on port 8080.
- All other paths route to the web Service on port 80.
This matches the frontend's existing relative `/api` requests and keeps session cookies same-origin. No CORS configuration or production `VITE_API_BASE_URL` is required.
## Nginx Behavior
Nginx serves files from the Vite build output. Requests that do not match a real file fall back to `/index.html`, allowing React Router routes such as `/admin`, `/staff`, `/visitor/:token`, and `/display/:token` to survive direct navigation and page refreshes.
Static files receive normal Nginx content types. The container runs in the foreground using the base image's standard entrypoint and listens on port 80.
## Build Inputs
The Docker build context remains `./web`. Dependency manifests are copied before application sources so dependency installation can be cached. Installation uses the lockfile in frozen mode to make Jenkins builds reproducible.
A `.dockerignore` excludes `node_modules`, `dist`, coverage output, test reports, logs, and local editor files from the build context.
## Verification
Verification covers:
- `pnpm build` completes successfully.
- The Dockerfile parses and the web image builds when the configured Docker registry is available.
- The image contains the generated frontend files and Nginx SPA fallback configuration.
- A container request to `/` returns the application entry page.
- A request to a client-side route such as `/admin` also returns the application entry page.
Kubernetes manifests and Ingress resources are outside this change because they are not present in this repository. The required path mapping is documented above for the deployment configuration.

View File

@@ -1,15 +1,5 @@
# 景区排队叫号系统:调研发现与决策台账 # 景区排队叫号系统:调研发现与决策台账
## 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 ## 员工端末号预计时长与闪屏2026-07-12
- `queueSnapshot` 当前把 `metrics.estimated_wait` 固定写成 `nil`,等待票的 `staffTicketView` 也未见按位置注入 ETA员工前端却只读取最后一张等待票的 `estimated_wait`,因此稳定落入“暂不可估算”。 - `queueSnapshot` 当前把 `metrics.estimated_wait` 固定写成 `nil`,等待票的 `staffTicketView` 也未见按位置注入 ETA员工前端却只读取最后一张等待票的 `estimated_wait`,因此稳定落入“暂不可估算”。
- 后台、公屏和游客接口已经统一调用 `domain.CalculateETA`;员工快照应按 `waiting_count - 1` 作为末号前方人数,并传入项目级 `ETAIntervalSeconds` 与当前运行状态。 - 后台、公屏和游客接口已经统一调用 `domain.CalculateETA`;员工快照应按 `waiting_count - 1` 作为末号前方人数,并传入项目级 `ETAIntervalSeconds` 与当前运行状态。

View File

@@ -453,15 +453,3 @@
- 增加独立 `/usr/local/bin/migrate``/usr/local/bin/bootstrap-admin` 镜像命令和生产交接文档;明确 Redis/Kubernetes/Secret/备份由运维接入。 - 增加独立 `/usr/local/bin/migrate``/usr/local/bin/bootstrap-admin` 镜像命令和生产交接文档;明确 Redis/Kubernetes/Secret/备份由运维接入。
- 验证通过Go 测试、race、vet、build前端 37 项测试、类型检查、生产构建;临时 PostgreSQL 集成测试;真实 API/数据库烟测shell 语法检查。 - 验证通过Go 测试、race、vet、build前端 37 项测试、类型检查、生产构建;临时 PostgreSQL 集成测试;真实 API/数据库烟测shell 语法检查。
- 当前未在本机验证 Docker/Kubernetes也未完成 3000 并发压测、PITR/RPO/RTO 演练;这些属于运维上线前的环境验证。 - 当前未在本机验证 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`
- 本地浏览器连接验证时无可用管理端登录会话,未代填账号密码;未登录页面正确显示管理端登录入口,页面业务布局由组件测试和构建验证覆盖。

View File

@@ -1,4 +1,6 @@
FROM golang:1.26.3-alpine AS build FROM golang:1.26.3-alpine AS build
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=${GOPROXY}
WORKDIR /src WORKDIR /src
COPY go.mod go.sum* ./ COPY go.mod go.sum* ./
RUN go mod download RUN go mod download

View File

@@ -7,7 +7,6 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
_ "time/tzdata"
"unicode/utf8" "unicode/utf8"
"calllinesystem/server/internal/domain" "calllinesystem/server/internal/domain"

View File

@@ -17,16 +17,6 @@ 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) { func TestValidateAdminProjectRequestRejectsInvalidProfile(t *testing.T) {
tests := []adminProjectRequest{ tests := []adminProjectRequest{
{Name: "", Code: "RIDE", Timezone: "Asia/Shanghai", TicketPrefix: "A"}, {Name: "", Code: "RIDE", Timezone: "Asia/Shanghai", TicketPrefix: "A"},

View File

@@ -4,17 +4,10 @@
在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。 在已确认的产品、技术与设计基线上,交付可运行的景区排队叫号系统纵向切片,并以自动化测试验证多项目隔离、幂等叫号与隐私边界。
## Current Phase ## Current Phase
Phase 28创建项目与项目维护表单统一 Phase 27生产后端与数据库基础交接
## Phases ## Phases
### Phase 28: 创建项目与项目维护表单统一
- [x] 让创建项目复用项目维护的完整字段与布局
- [x] 保证创建提交的项目配置与页面默认值一致
- [x] 修复 Alpine 运行环境缺少时区数据导致的合法时区校验失败
- [x] 补充前后端回归测试并完成类型检查、构建验证
- **Status:** complete
### Phase 27: 生产后端与数据库基础交接 ### Phase 27: 生产后端与数据库基础交接
- [x] 确认独立 PostgreSQL、Kubernetes 业务服务、全景区上线和 3000 峰值在线用户边界 - [x] 确认独立 PostgreSQL、Kubernetes 业务服务、全景区上线和 3000 峰值在线用户边界
- [x] 修复真实 PostgreSQL 烟测、管理员/手动叫号/项目筛选契约 - [x] 修复真实 PostgreSQL 烟测、管理员/手动叫号/项目筛选契约
@@ -307,8 +300,6 @@ Phase 28创建项目与项目维护表单统一
| 首次从 `server/` 目录运行 gofmt 时仍使用 `server/...` 相对路径 | 1 | 改用 `internal/httpapi/...` 后 Go 全量测试通过 | | 首次从 `server/` 目录运行 gofmt 时仍使用 `server/...` 相对路径 | 1 | 改用 `internal/httpapi/...` 后 Go 全量测试通过 |
| Phase 12 首次追加 findings/progress 时补丁标题或空 hunk 不匹配 | 2 | 读取文件尾部后按现有章节精确追加;代码未受影响 | | Phase 12 首次追加 findings/progress 时补丁标题或空 hunk 不匹配 | 2 | 读取文件尾部后按现有章节精确追加;代码未受影响 |
| 前端生产构建发现 BatchCard 测试仍传入已删除的 `readOnly` 属性 | 1 | 删除两个遗留测试属性后类型检查和生产构建通过 | | 前端生产构建发现 BatchCard 测试仍传入已删除的 `readOnly` 属性 | 1 | 删除两个遗留测试属性后类型检查和生产构建通过 |
| 本轮首次连接本地浏览器时运行时尚未初始化 | 1 | 按浏览器技能规范初始化运行时后重新连接,未影响代码验证 |
| 本轮一次 Go 回归从仓库根目录执行,未找到 `server/go.mod` | 1 | 改在 `server/` 模块目录执行,时区回归测试通过 |
## Notes ## Notes
- 所有网络资料保留来源链接和访问时间2026-07-10 - 所有网络资料保留来源链接和访问时间2026-07-10

10
web/.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
coverage
playwright-report
test-results
*.log
*.tsbuildinfo
.DS_Store
.idea
.vscode

13
web/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.17.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.28-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

11
web/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,7 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { api } from "../api";
vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div> })); vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
vi.mock("../hooks/usePollingResource", () => ({ vi.mock("../hooks/usePollingResource", () => ({
@@ -118,44 +117,16 @@ describe("AdminPage active tickets", () => {
expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible(); expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible();
}); });
it("创建项目与维护项目使用相同字段", () => { it("创建项目使用独立表单", () => {
render(<MemoryRouter initialEntries={["/admin/projects/new"]}><AdminPage /></MemoryRouter>); render(<MemoryRouter initialEntries={["/admin/projects/new"]}><AdminPage /></MemoryRouter>);
expect(screen.getByRole("form", { name: "创建项目" })).toBeVisible(); expect(screen.getByRole("form", { name: "创建项目" })).toBeVisible();
expect(screen.getByRole("textbox", { name: "项目名称" })).toBeVisible(); expect(screen.getByRole("textbox", { 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("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.queryByRole("textbox", { name: "时区" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "创建项目" })).toBeVisible(); 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(<MemoryRouter initialEntries={["/admin/projects/new"]}><AdminPage /></MemoryRouter>);
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", () => { it("keeps the screen center free of contact details", () => {
render(<MemoryRouter initialEntries={["/admin/display"]}><AdminPage /></MemoryRouter>); render(<MemoryRouter initialEntries={["/admin/display"]}><AdminPage /></MemoryRouter>);

View File

@@ -96,13 +96,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi
); );
} }
const DEFAULT_PROJECT_TIMEZONE = "Asia/Shanghai"; type SettingsDraft = {
const DEFAULT_TICKET_PREFIX = "A";
const DEFAULT_VISITOR_NOTICE = "请您在景区附近等候,注意听从工作人员指引。";
type ProjectDraft = {
name: string;
code: string;
status: string; status: string;
callBatchSize: string; callBatchSize: string;
gracePeriodMinutes: string; gracePeriodMinutes: string;
@@ -110,25 +104,24 @@ type ProjectDraft = {
visitorNotice: string; visitorNotice: string;
}; };
function draftFor(project?: AdminProjectDto): ProjectDraft { function draftFor(project: AdminProjectDto): SettingsDraft {
return { return {
name: project?.name ?? "", status: project.status,
code: project?.code ?? "", callBatchSize: String(project.call_batch_size ?? project.batch_size ?? 1),
status: project?.status ?? "NOT_OPEN", gracePeriodMinutes: String(project.grace_period_minutes ?? 0),
callBatchSize: String(project?.call_batch_size ?? project?.batch_size ?? 1), etaIntervalSeconds: String(project.eta?.interval_per_number_seconds ?? 60),
gracePeriodMinutes: String(project?.grace_period_minutes ?? 5), visitorNotice: project.visitor_notice ?? "",
etaIntervalSeconds: String(project?.eta?.interval_per_number_seconds ?? 60),
visitorNotice: project ? project.visitor_notice ?? "" : DEFAULT_VISITOR_NOTICE,
}; };
} }
function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise<void> }) { function ProjectSettingsForm({ project, onSaved }: { project: AdminProjectDto; onSaved: () => void | Promise<void> }) {
const navigate = useNavigate();
const [draft, setDraft] = useState(() => draftFor(project)); const [draft, setDraft] = useState(() => draftFor(project));
const [name, setName] = useState(project.name);
const [code, setCode] = useState(project.code ?? "");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState<{ tone: "success" | "danger"; message?: string } | null>(null); const [notice, setNotice] = useState<{ tone: "success" | "danger"; message?: string } | null>(null);
const update = (patch: Partial<ProjectDraft>) => { const update = (patch: Partial<SettingsDraft>) => {
setDraft((current) => ({ ...current, ...patch })); setDraft((current) => ({ ...current, ...patch }));
setNotice(null); setNotice(null);
}; };
@@ -138,51 +131,38 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved:
setSaving(true); setSaving(true);
setNotice(null); setNotice(null);
try { try {
const profile = { await api.updateProject(project.id, {
name: draft.name, name,
code: draft.code.toUpperCase(), code: code.toUpperCase(),
timezone: project?.timezone ?? DEFAULT_PROJECT_TIMEZONE, timezone: project.timezone ?? "Asia/Shanghai",
ticket_prefix: project?.ticket_prefix ?? DEFAULT_TICKET_PREFIX, ticket_prefix: project.ticket_prefix ?? "A",
}; });
let projectId = project?.id; await api.updateProjectSettings(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, status: draft.status,
call_batch_size: Number(draft.callBatchSize), call_batch_size: Number(draft.callBatchSize),
grace_period_minutes: Number(draft.gracePeriodMinutes), grace_period_minutes: Number(draft.gracePeriodMinutes),
eta_interval_seconds: Number(draft.etaIntervalSeconds), eta_interval_seconds: Number(draft.etaIntervalSeconds),
visitor_notice: draft.visitorNotice, visitor_notice: draft.visitorNotice,
}); });
await onSaved();
if (project) {
setNotice({ tone: "success", message: "项目维护内容已更新。" }); setNotice({ tone: "success", message: "项目维护内容已更新。" });
} else { await onSaved();
navigate("/admin/projects", { replace: true });
}
} catch (caught) { } catch (caught) {
setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目保存失败,请检查后重试。" }); setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目保存,请检查后重试。" });
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<form className="panel project-settings-form" aria-label={project ? "维护项目" : "创建项目"} onSubmit={submit}> <form className="panel project-settings-form" aria-label="维护项目" onSubmit={submit}>
{!project ? <div className="panel__header"><div><h2></h2><p></p></div></div> : null}
<div className="settings-grid"> <div className="settings-grid">
<label className="field"> <label className="field">
<span></span> <span></span>
<input autoFocus={!project} value={draft.name} onChange={(event) => update({ name: event.target.value })} maxLength={120} disabled={saving} required /> <input value={name} onChange={(event) => { setName(event.target.value); setNotice(null); }} maxLength={120} disabled={saving} required />
</label> </label>
<label className="field"> <label className="field">
<span></span> <span></span>
<input value={draft.code} onChange={(event) => update({ code: event.target.value.toUpperCase() })} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required /> <input value={code} onChange={(event) => { setCode(event.target.value.toUpperCase()); setNotice(null); }} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required />
</label> </label>
<label className="field"> <label className="field">
<span></span> <span></span>
@@ -214,8 +194,7 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved:
</div> </div>
<div className="project-settings-form__actions"> <div className="project-settings-form__actions">
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null} {notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null}
{!project ? <Link className="button button--secondary" to="/admin/projects"></Link> : null} <button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : "保存项目"}</button>
<button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : project ? "保存项目" : "创建项目"}</button>
</div> </div>
</form> </form>
); );
@@ -231,11 +210,40 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
</section>; </section>;
} }
function ProjectProfileForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise<void> }) {
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<string | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
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 <section className="panel account-create-panel">
<div className="panel__header"><div><h2>{project ? "基础信息" : "创建项目"}</h2><p>{project ? "修改项目名称与基础标识。" : "创建后可在项目维护中设置运行规则。"}</p></div></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack" aria-label={project ? "维护项目" : "创建项目"} onSubmit={submit}>
<div className="field-row"><label className="field"><span></span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} maxLength={120} required /></label><label className="field"><span></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" required /></label></div>
<div className="field-row"><label className="field"><span></span><select value="00000" disabled><option value="00000">00000</option></select></label></div>
<div className="account-create-panel__actions"><Link className="button button--secondary" to="/admin/projects"></Link><button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : project ? "保存项目" : "创建项目"}</button></div>
</form>
</section>;
}
function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) { function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) {
if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects"></Link></div></section>; if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects"></Link></div></section>;
return <div className="project-maintenance"> return <div className="project-maintenance">
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects"></Link></div> <div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects"></Link></div>
<ProjectForm project={project} onSaved={onRefresh} /> <ProjectSettingsForm project={project} onSaved={onRefresh} />
</div>; </div>;
} }
@@ -446,7 +454,7 @@ export function AdminPage() {
<> <>
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} /> <FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
{section === "overview" ? <OperationsOverview data={data} refreshing={resource.refreshing} onRefresh={resource.refresh} /> : null} {section === "overview" ? <OperationsOverview data={data} refreshing={resource.refreshing} onRefresh={resource.refresh} /> : null}
{section === "projects" ? (location.pathname.endsWith("/new") ? <ProjectForm onSaved={resource.refresh} /> : location.pathname === "/admin/projects" ? <ProjectManagement projects={data.projects} /> : <ProjectMaintenance project={data.projects.find((project) => project.id === resourceId)} onRefresh={resource.refresh} />) : null} {section === "projects" ? (location.pathname.endsWith("/new") ? <ProjectProfileForm onSaved={resource.refresh} /> : location.pathname === "/admin/projects" ? <ProjectManagement projects={data.projects} /> : <ProjectMaintenance project={data.projects.find((project) => project.id === resourceId)} onRefresh={resource.refresh} />) : null}
{section === "accounts" ? (location.pathname.endsWith("/new") ? <CreateAccount /> : location.pathname === "/admin/accounts" ? <AccountManagement projects={data.projects} /> : <EditAccount projects={data.projects} accountId={location.pathname.split("/").at(-1) ?? ""} />) : null} {section === "accounts" ? (location.pathname.endsWith("/new") ? <CreateAccount /> : location.pathname === "/admin/accounts" ? <AccountManagement projects={data.projects} /> : <EditAccount projects={data.projects} accountId={location.pathname.split("/").at(-1) ?? ""} />) : null}
{section === "display" ? <DisplayCenter projects={data.projects} /> : null} {section === "display" ? <DisplayCenter projects={data.projects} /> : null}
</> </>

View File

@@ -3016,11 +3016,6 @@ tbody tr:hover {
.project-settings-form__actions { .project-settings-form__actions {
justify-content: stretch; justify-content: stretch;
flex-wrap: wrap;
}
.project-settings-form__actions .feedback {
flex-basis: 100%;
} }
.project-settings-form__actions .button { .project-settings-form__actions .button {