feat: add party-size queueing and call modes

This commit is contained in:
wangxuming
2026-07-16 11:40:34 +08:00
parent 7f751bebae
commit 66951b4dc3
53 changed files with 3348 additions and 533 deletions

View File

@@ -6,7 +6,7 @@ import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../co
import { StatusBadge } from "../components/StatusBadge";
import { usePollingResource } from "../hooks/usePollingResource";
import { formatDateTime, formatTicketNumberRange, isTimestampStale } from "../lib/format";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto } from "../types";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto, CallMode } from "../types";
import { forecastRows } from "./DisplayPage";
function currentBatchLabel(value: AdminProjectDto["current_batch"]): string {
@@ -60,7 +60,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi
<div className="operations-dashboard__overview">
<section className="summary-strip" aria-label="运营汇总">
<article><span></span><strong>{summary.running_projects}</strong><small></small></article>
<article><span></span><strong>{summary.waiting_count}</strong><small></small></article>
<article><span></span><strong>{summary.waiting_people_count ?? 0}</strong><small></small></article>
</section>
</div>
<div className="operations-dashboard__body">
@@ -80,7 +80,7 @@ function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOvervi
{projects.map((project) => (
<div className="queue-project-row" key={project.id} role="listitem">
<span className="queue-project-row__name"><strong>{project.name}</strong><small>{deviceStatusLabel(project.device_status)}</small></span>
<span className="queue-project-row__metric"><strong>{project.waiting_count ?? 0}</strong><small></small></span>
<span className="queue-project-row__metric"><strong>{project.waiting_people_count ?? 0}</strong><small> · {project.waiting_ticket_count ?? project.waiting_count ?? 0} </small></span>
<span><strong>{currentBatchLabel(project.current_batch)}</strong></span>
<StatusBadge status={project.status} kind="project" />
<time>{formatDateTime(project.last_updated_at)}</time>
@@ -104,8 +104,15 @@ type ProjectDraft = {
name: string;
code: string;
status: string;
callBatchSize: string;
callMode: CallMode;
defaultCallTicketCount: string;
maxCallTicketCount: string;
defaultCallPeopleCount: string;
maxCallPeopleCount: string;
minPartySize: string;
maxPartySize: string;
gracePeriodMinutes: string;
experiencedPeopleStart: string;
etaIntervalSeconds: string;
visitorNotice: string;
};
@@ -115,9 +122,16 @@ function draftFor(project?: AdminProjectDto): ProjectDraft {
name: project?.name ?? "",
code: project?.code ?? "",
status: project?.status ?? "NOT_OPEN",
callBatchSize: String(project?.call_batch_size ?? project?.batch_size ?? 1),
callMode: project?.call_mode ?? "BOTH",
defaultCallTicketCount: String(project?.default_call_ticket_count ?? project?.call_batch_size ?? project?.batch_size ?? 1),
maxCallTicketCount: String(project?.max_call_ticket_count ?? 100),
defaultCallPeopleCount: String(project?.default_call_people_count ?? 1),
maxCallPeopleCount: String(project?.max_call_people_count ?? 100),
minPartySize: String(project?.min_party_size ?? 1),
maxPartySize: String(project?.max_party_size ?? 10),
gracePeriodMinutes: String(project?.grace_period_minutes ?? 5),
etaIntervalSeconds: String(project?.eta?.interval_per_number_seconds ?? 60),
experiencedPeopleStart: String(project?.experienced_people_start ?? 0),
etaIntervalSeconds: String(project?.eta?.interval_per_person_seconds ?? 60),
visitorNotice: project ? project.visitor_notice ?? "" : DEFAULT_VISITOR_NOTICE,
};
}
@@ -154,8 +168,15 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved:
if (!projectId) throw new Error("项目创建未返回项目编号。");
await api.updateProjectSettings(projectId, {
status: draft.status,
call_batch_size: Number(draft.callBatchSize),
call_mode: draft.callMode,
default_call_ticket_count: Number(draft.defaultCallTicketCount),
max_call_ticket_count: Number(draft.maxCallTicketCount),
default_call_people_count: Number(draft.defaultCallPeopleCount),
max_call_people_count: Number(draft.maxCallPeopleCount),
min_party_size: Number(draft.minPartySize),
max_party_size: Number(draft.maxPartySize),
grace_period_minutes: Number(draft.gracePeriodMinutes),
experienced_people_start: Number(draft.experiencedPeopleStart),
eta_interval_seconds: Number(draft.etaIntervalSeconds),
visitor_notice: draft.visitorNotice,
});
@@ -175,42 +196,138 @@ function ProjectForm({ project, onSaved }: { project?: AdminProjectDto; onSaved:
return (
<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 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={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>
<select value="00000" disabled><option value="00000">00000</option></select>
</label>
<label className="field">
<span></span>
<select value={draft.status} onChange={(event) => update({ status: event.target.value })} disabled={saving}>
<option value="NOT_OPEN"></option>
<option value="RUNNING"></option>
<option value="PAUSED"></option>
<option value="ENDED"></option>
</select>
</label>
<label className="field">
<span></span>
<input type="number" min="1" max="100" value={draft.callBatchSize} onChange={(event) => update({ callBatchSize: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="单个号码预计间隔时间(秒)" type="number" min="1" max="86400" value={draft.etaIntervalSeconds} onChange={(event) => update({ etaIntervalSeconds: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field project-settings-form__visitor-notice">
<span></span>
<textarea aria-label="游客官方提示" value={draft.visitorNotice} onChange={(event) => update({ visitorNotice: event.target.value })} maxLength={240} rows={3} disabled={saving} placeholder="例如:请在入口附近等候,叫号后凭号码入场。" />
<small> 240 </small>
</label>
<div className="project-form-sections">
<section className="project-form-section" aria-labelledby="project-form-basics-title">
<header className="project-form-section__header">
<h3 id="project-form-basics-title"></h3>
<p></p>
</header>
<div className="project-form-section__grid">
<label className="field">
<span></span>
<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={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>
<select value="00000" disabled><option value="00000">00000</option></select>
</label>
<label className="field">
<span></span>
<select value={draft.status} onChange={(event) => update({ status: event.target.value })} disabled={saving}>
<option value="NOT_OPEN"></option>
<option value="RUNNING"></option>
<option value="PAUSED"></option>
<option value="ENDED"></option>
</select>
</label>
</div>
</section>
<section className="project-form-section" aria-labelledby="project-form-call-rules-title">
<header className="project-form-section__header">
<h3 id="project-form-call-rules-title"></h3>
<p></p>
</header>
<div className="project-call-rules">
<div className="project-call-rule" role="group" aria-labelledby="project-call-mode-title">
<div className="project-call-rule__heading">
<h4 id="project-call-mode-title"></h4>
<p>使</p>
</div>
<div className="project-call-rule__fields project-call-rule__fields--single">
<label className="field">
<span></span>
<select aria-label="支持的叫号方式" value={draft.callMode} onChange={(event) => update({ callMode: event.target.value as CallMode })} disabled={saving}>
<option value="TICKET"></option>
<option value="PEOPLE"></option>
<option value="BOTH"></option>
</select>
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-party-size-title">
<div className="project-call-rule__heading">
<h4 id="project-party-size-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="单号最少人数" type="number" min="1" max={draft.maxPartySize || "10000"} value={draft.minPartySize} onChange={(event) => update({ minPartySize: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="单号最多人数" type="number" min={draft.minPartySize || "1"} max="10000" value={draft.maxPartySize} onChange={(event) => update({ maxPartySize: event.target.value })} disabled={saving} required />
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-ticket-call-title">
<div className="project-call-rule__heading">
<h4 id="project-ticket-call-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="批量叫号默认数量" type="number" min="1" max={draft.maxCallTicketCount || "10000"} value={draft.defaultCallTicketCount} onChange={(event) => update({ defaultCallTicketCount: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="批量叫号单次上限" type="number" min={draft.defaultCallTicketCount || "1"} max="10000" value={draft.maxCallTicketCount} onChange={(event) => update({ maxCallTicketCount: event.target.value })} disabled={saving} required />
<small></small>
</label>
</div>
</div>
<div className="project-call-rule" role="group" aria-labelledby="project-people-call-title">
<div className="project-call-rule__heading">
<h4 id="project-people-call-title"></h4>
<p></p>
</div>
<div className="project-call-rule__fields">
<label className="field">
<span></span>
<input aria-label="批量叫人默认人数" type="number" min="1" max={draft.maxCallPeopleCount || "10000"} value={draft.defaultCallPeopleCount} onChange={(event) => update({ defaultCallPeopleCount: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="批量叫人单次上限" type="number" min={draft.defaultCallPeopleCount || "1"} max="10000" value={draft.maxCallPeopleCount} onChange={(event) => update({ maxCallPeopleCount: event.target.value })} disabled={saving} required />
<small></small>
</label>
</div>
</div>
</div>
</section>
<section className="project-form-section" aria-labelledby="project-form-other-rules-title">
<header className="project-form-section__header">
<h3 id="project-form-other-rules-title"></h3>
<p></p>
</header>
<div className="project-form-section__grid">
<label className="field">
<span></span>
<input aria-label="已体验人数起始展示数" type="number" min="0" max="1000000000" value={draft.experiencedPeopleStart} onChange={(event) => update({ experiencedPeopleStart: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field">
<span></span>
<input aria-label="每人预计间隔时间(秒)" type="number" min="1" max="86400" value={draft.etaIntervalSeconds} onChange={(event) => update({ etaIntervalSeconds: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field project-settings-form__visitor-notice">
<span></span>
<textarea aria-label="游客官方提示" value={draft.visitorNotice} onChange={(event) => update({ visitorNotice: event.target.value })} maxLength={240} rows={3} disabled={saving} placeholder="例如:请在入口附近等候,叫号后凭号码入场。" />
<small> 240 </small>
</label>
</div>
</section>
</div>
<div className="project-settings-form__actions">
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null}
@@ -269,7 +386,9 @@ function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto; onFullscreen: (projectId: string) => Promise<void> }) {
const current = project.current_batch && typeof project.current_batch === "object" ? project.current_batch as CallBatchDto : null;
const forecasts = forecastRows(current, project.waiting_count ?? 0, project.estimated_wait);
const waitingTicketCount = project.waiting_ticket_count ?? project.waiting_count ?? 0;
const waitingPeopleCount = project.waiting_people_count ?? 0;
const forecasts = forecastRows(current, waitingTicketCount, project.estimated_wait);
const pageCount = Math.max(1, Math.ceil(forecasts.length / 4));
const [page, setPage] = useState(0);
const [now, setNow] = useState(() => new Date());
@@ -284,7 +403,9 @@ function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto
return () => window.clearInterval(timer);
}, []);
const visible = forecasts.slice(page * 4, (page + 1) * 4);
const latestTicketNumber = forecasts.at(-1)?.range.split("").at(-1) ?? "暂无";
const latestTicketNumber = project.latest_ticket_number ?? forecasts.at(-1)?.range.split("").at(-1) ?? "暂无";
const issuedTicketCount = project.issued_ticket_count ?? Number(latestTicketNumber.match(/\d+$/)?.[0] ?? 0);
const experiencedPeople = project.experienced_people ?? project.experienced_people_start ?? 0;
const dateLabel = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long", day: "numeric", weekday: "long" }).format(now);
const timeLabel = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(now);
@@ -292,11 +413,13 @@ function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto
<article className="screen-tile" data-project-screen={project.id}>
<header className="screen-tile__header">
<div className="screen-tile__clock" aria-label={`当前时间 ${dateLabel} ${timeLabel}`}>
<time dateTime={now.toISOString()}>{timeLabel}</time>
<span>{dateLabel}</span>
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
<div className="screen-tile__clock-copy">
<time dateTime={now.toISOString()}>{timeLabel}</time>
<span>{dateLabel}</span>
</div>
</div>
<div className="screen-tile__project">
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
<h2>{project.name}</h2>
</div>
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}></button>
@@ -306,7 +429,9 @@ function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto
<small>{project.current_batch ? "当前叫号" : "等待下一次叫号"}</small>
<div className="screen-tile__current-metrics">
<h3 className="screen-tile__metric" aria-label={`最新取号码:${latestTicketNumber}${latestTicketNumber === "暂无" ? "" : "号"}`}><span></span><strong>{latestTicketNumber}</strong>{latestTicketNumber === "暂无" ? null : <small></small>}</h3>
<div className="screen-tile__metric" aria-label={`总计等待${project.waiting_count ?? 0}号码`}><span></span><strong>{project.waiting_count ?? 0}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`累计取号数${issuedTicketCount}`}><span></span><strong>{issuedTicketCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`累计等待人数:${waitingPeopleCount}`}><span></span><strong>{waitingPeopleCount}</strong><small></small></div>
<div className="screen-tile__metric" aria-label={`已体验人数:${experiencedPeople}`}><span></span><strong>{experiencedPeople}</strong><small></small></div>
</div>
</div>
<section className="screen-tile__forecast" aria-label={`${project.name}最新取号信息`}>