Compare commits
4 Commits
codex/proj
...
6c1af1fd05
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c1af1fd05 | |||
| b8582ba3e9 | |||
| 6748861327 | |||
| 4a73f81195 |
141
docs/superpowers/plans/2026-07-14-web-container.md
Normal file
141
docs/superpowers/plans/2026-07-14-web-container.md
Normal 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.
|
||||||
47
docs/superpowers/specs/2026-07-14-web-container-design.md
Normal file
47
docs/superpowers/specs/2026-07-14-web-container-design.md
Normal 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.
|
||||||
10
findings.md
10
findings.md
@@ -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` 与当前运行状态。
|
||||||
|
|||||||
12
progress.md
12
progress.md
@@ -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`。
|
|
||||||
- 本地浏览器连接验证时无可用管理端登录会话,未代填账号密码;未登录页面正确显示管理端登录入口,页面业务布局由组件测试和构建验证覆盖。
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
11
task_plan.md
11
task_plan.md
@@ -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
10
web/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
13
web/Dockerfile
Normal file
13
web/Dockerfile
Normal 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
11
web/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user