merge: project form and timezone fix
This commit is contained in:
@@ -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 }) => <div>{children}</div> }));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
@@ -117,16 +118,44 @@ describe("AdminPage active tickets", () => {
|
||||
expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("创建项目使用独立表单", () => {
|
||||
it("创建项目与维护项目使用相同字段", () => {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/new"]}><AdminPage /></MemoryRouter>);
|
||||
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(<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", () => {
|
||||
render(<MemoryRouter initialEntries={["/admin/display"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
|
||||
@@ -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<void> }) {
|
||||
function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise<void> }) {
|
||||
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<SettingsDraft>) => {
|
||||
const update = (patch: Partial<ProjectDraft>) => {
|
||||
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 (
|
||||
<form className="panel project-settings-form" aria-label="维护项目" onSubmit={submit}>
|
||||
<form className="panel project-settings-form" aria-label={project ? "维护项目" : "创建项目"} onSubmit={submit}>
|
||||
{!project ? <div className="panel__header"><div><h2>创建项目</h2><p>创建项目并同时设置运行规则。</p></div></div> : null}
|
||||
<div className="settings-grid">
|
||||
<label className="field">
|
||||
<span>项目名称</span>
|
||||
<input value={name} onChange={(event) => { setName(event.target.value); setNotice(null); }} maxLength={120} disabled={saving} required />
|
||||
<input autoFocus={!project} value={draft.name} onChange={(event) => update({ name: event.target.value })} maxLength={120} disabled={saving} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>项目编码</span>
|
||||
<input value={code} onChange={(event) => { setCode(event.target.value.toUpperCase()); setNotice(null); }} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required />
|
||||
<input value={draft.code} onChange={(event) => update({ code: event.target.value.toUpperCase() })} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>票号格式</span>
|
||||
@@ -194,7 +214,8 @@ function ProjectSettingsForm({ project, onSaved }: { project: AdminProjectDto; o
|
||||
</div>
|
||||
<div className="project-settings-form__actions">
|
||||
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null}
|
||||
<button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : "保存项目"}</button>
|
||||
{!project ? <Link className="button button--secondary" to="/admin/projects">取消</Link> : null}
|
||||
<button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : project ? "保存项目" : "创建项目"}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
@@ -210,40 +231,11 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
|
||||
</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> }) {
|
||||
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">
|
||||
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects">返回列表</Link></div>
|
||||
<ProjectSettingsForm project={project} onSaved={onRefresh} />
|
||||
<ProjectForm project={project} onSaved={onRefresh} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -454,7 +446,7 @@ export function AdminPage() {
|
||||
<>
|
||||
<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 === "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 === "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 === "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}
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user