feat: add party-size queueing and call modes
This commit is contained in:
@@ -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}最新取号信息`}>
|
||||
|
||||
Reference in New Issue
Block a user