diff --git a/src/components/TopNavBar/index.vue b/src/components/TopNavBar/index.vue
index 12272ab..9ff7458 100644
--- a/src/components/TopNavBar/index.vue
+++ b/src/components/TopNavBar/index.vue
@@ -152,6 +152,11 @@ const hasBackListener = computed(() => {
// 处理返回事件
const handleBack = () => {
+ if (hasBackListener.value) {
+ emit("back");
+ return;
+ }
+
const pages = getCurrentPages();
if (pages.length <= 1) {
@@ -161,10 +166,6 @@ const handleBack = () => {
return;
}
- if (hasBackListener.value) {
- emit("back");
- return;
- }
// 如果没有监听back事件,默认执行返回上一页
uni.navigateBack({
delta: 1,
diff --git a/src/pages-base/queue/components/FeedbackBanner.vue b/src/pages-base/queue/components/FeedbackBanner.vue
new file mode 100644
index 0000000..1833a93
--- /dev/null
+++ b/src/pages-base/queue/components/FeedbackBanner.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ {{ title }}
+ {{ message }}
+
+
+
+
+
+
diff --git a/src/pages-base/queue/components/FreshnessBanner.vue b/src/pages-base/queue/components/FreshnessBanner.vue
new file mode 100644
index 0000000..67559fb
--- /dev/null
+++ b/src/pages-base/queue/components/FreshnessBanner.vue
@@ -0,0 +1,26 @@
+
+
+
+ {{ text }}
+
+
+
+
+
diff --git a/src/pages-base/queue/components/LoadingState.vue b/src/pages-base/queue/components/LoadingState.vue
new file mode 100644
index 0000000..a2e7d8c
--- /dev/null
+++ b/src/pages-base/queue/components/LoadingState.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
diff --git a/src/pages-base/queue/components/QueuePublicHeader.vue b/src/pages-base/queue/components/QueuePublicHeader.vue
new file mode 100644
index 0000000..7df1668
--- /dev/null
+++ b/src/pages-base/queue/components/QueuePublicHeader.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/src/pages-base/queue/components/TicketCard.vue b/src/pages-base/queue/components/TicketCard.vue
new file mode 100644
index 0000000..60f91f9
--- /dev/null
+++ b/src/pages-base/queue/components/TicketCard.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+ {{ ticket.project_name }}
+ {{ ticketLabel }}
+
+
+
+
+
+ 我的号码
+ {{ ticket.ticket_number }}
+ 本号 {{ ticket.party_size }} 人
+
+
+
+
+ 当前最新到号
+ {{ ticket.latest_called_number || "暂无" }}
+
+
+
+ 预计等待时间
+ {{ formatEstimatedWait(ticket.estimated_wait) }}
+
+
+ 前方排队
+ {{ ticket.tickets_ahead == null ? "未知" : ticket.tickets_ahead + " 个号码" }}
+ {{ ticket.people_ahead == null ? "人数未知" : "共 " + ticket.people_ahead + " 人" }}
+
+
+
+
+
+ 服务状态
+ 号码已保留,项目恢复后会重新估算预计叫号时间。
+
+
+
+ 现在请前往
+ {{ ticket.entrance || "现场入口" }}
+ 请出示排队号码,叫号时间 {{ formatDateTime(ticket.called_at) }}
+
+
+
+ {{ statusMeta.guidance }}
+
+ {{ ticket.message }}
+
+
+ 手机号:尾号 {{ phoneLast4 }}
+ 更新于 {{ formatDateTime(lastUpdatedAt || ticket.last_updated_at) }}
+
+
+
+
+ 官方提示
+ {{ visitorNotice }}
+
+
+
+
+
diff --git a/src/pages-base/queue/index.vue b/src/pages-base/queue/index.vue
index e374577..4db9008 100644
--- a/src/pages-base/queue/index.vue
+++ b/src/pages-base/queue/index.vue
@@ -1,16 +1,736 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取号成功
+ 您的排队号码
+ {{ createdTicket.ticket.ticket_number }}
+
+ {{ createdTicketProjectName }} · {{ createdTicket.ticket.party_size }} 人 · 手机号尾号
+ {{ createdTicket.ticket.phone_last4 || "未返回" }}
+
+
+
+
+
+
+
+
+ {{ selectedProject.visitor_notice }}
+
+
+
+
+
+
+
+
+ 称谓(选填)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 游客查询
+ 输入手机号查看排队情况
+ 请输入取号时登记的手机号,查询当前仍在排队或已叫到的号码。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 排队数据会自动更新,现场以工作人员指引为准。
+
+
+
+
+
-
-
\ No newline at end of file
+
diff --git a/src/pages-base/queue/request/api/QueueApi copy.js b/src/pages-base/queue/request/api/QueueApi copy.js
new file mode 100644
index 0000000..42211a6
--- /dev/null
+++ b/src/pages-base/queue/request/api/QueueApi copy.js
@@ -0,0 +1,106 @@
+import { normalizePublicStatus } from "../../utils/queueUtils.mjs";
+
+// Weixin production builds must add this host to the mini program request domain allowlist.
+const QUEUE_API_BASE = "https://queue.nianxx.cn";
+
+export class QueueApiError extends Error {
+ constructor(message, status = 0, details = null, code = "") {
+ super(message);
+ this.name = "QueueApiError";
+ this.status = status;
+ this.details = details;
+ this.code = code;
+ }
+}
+
+const getQueueApiBase = () => {
+ return QUEUE_API_BASE;
+}
+
+const normalizePath = (path) => {
+ if (/^https?:\/\//.test(path)) return path;
+ return `${getQueueApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
+}
+
+const getErrorPayload = (body) => {
+ if (!body || typeof body !== "object") return {};
+ const nested = body.error && typeof body.error === "object" ? body.error : {};
+ return {
+ message: body.message || nested.message || body.error || body.detail,
+ code: nested.code || body.code,
+ details: nested.details || body.details || body,
+ };
+}
+
+const queueRequest = (path, { method = "GET", data, header = {} } = {}) => {
+ return new Promise((resolve, reject) => {
+ uni.request({
+ url: normalizePath(path),
+ method,
+ data,
+ header: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ ...header,
+ },
+ success: (res) => {
+ const statusCode = res.statusCode || 0;
+ if (statusCode >= 200 && statusCode < 300) {
+ resolve(res.data || {});
+ return;
+ }
+
+ const payload = getErrorPayload(res.data);
+ reject(new QueueApiError(
+ typeof payload.message === "string" ? payload.message : `请求未成功(${statusCode})`,
+ statusCode,
+ payload.details,
+ typeof payload.code === "string" ? payload.code : "",
+ ));
+ },
+ fail: (err) => {
+ reject(new QueueApiError("无法连接服务,请检查网络后重试。", 0, err));
+ },
+ });
+ });
+}
+
+export const newQueueIdempotencyKey = () => {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `mini-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+export const getPublicProjects = () => {
+ return queueRequest("/api/public/projects").then((response) => ({
+ projects: Array.isArray(response.projects) ? response.projects : [],
+ }));
+}
+
+export const createPublicTicket = (projectId, payload, idempotencyKey = newQueueIdempotencyKey()) => {
+ return queueRequest(`/api/public/projects/${encodeURIComponent(projectId)}/tickets`, {
+ method: "POST",
+ data: payload,
+ header: {
+ "Idempotency-Key": idempotencyKey,
+ },
+ }).then((response) => ({
+ ...response,
+ ticket: normalizePublicStatus(response.ticket),
+ }));
+}
+
+export const getPublicStatus = (token) => {
+ return queueRequest(`/api/public/status/${encodeURIComponent(token)}`)
+ .then((response) => normalizePublicStatus(response));
+}
+
+export const searchPublicStatusByPhone = (phone) => {
+ return queueRequest("/api/public/status/search", {
+ method: "POST",
+ data: { phone },
+ }).then((response) => ({
+ tickets: Array.isArray(response.tickets) ? response.tickets.map(normalizePublicStatus) : [],
+ }));
+}
diff --git a/src/pages-base/queue/request/api/QueueApi.js b/src/pages-base/queue/request/api/QueueApi.js
new file mode 100644
index 0000000..8da20aa
--- /dev/null
+++ b/src/pages-base/queue/request/api/QueueApi.js
@@ -0,0 +1,98 @@
+import { normalizePublicStatus } from "../../utils/queueUtils.mjs";
+import request from "../base/request";
+
+export class QueueApiError extends Error {
+ constructor(message, status = 0, details = null, code = "") {
+ super(message);
+ this.name = "QueueApiError";
+ this.status = status;
+ this.details = details;
+ this.code = code;
+ }
+}
+
+const getResponseMessage = (response) => {
+ if (!response || typeof response !== "object") return "";
+ return response.message || response.msg || response.error?.message || response.error || "";
+};
+
+const getResponseCode = (response) => {
+ if (!response || typeof response !== "object") return "";
+ return response.code ?? response.error?.code ?? "";
+}
+
+const isSuccessCode = (code) => {
+ return code === 0 || code === "0" || code === "SUCCESS" || code === "success";
+}
+
+const unwrapQueueResponse = (response) => {
+ if (!response || typeof response !== "object") return {};
+
+ const hasEnvelope = Object.prototype.hasOwnProperty.call(response, "code")
+ && (Object.prototype.hasOwnProperty.call(response, "data")
+ || Object.prototype.hasOwnProperty.call(response, "message")
+ || Object.prototype.hasOwnProperty.call(response, "msg"));
+
+ if (!hasEnvelope) return response;
+
+ const code = getResponseCode(response);
+ if (!isSuccessCode(code)) {
+ throw new QueueApiError(getResponseMessage(response) || "请求未成功,请稍后重试。", 0, response, String(code || ""));
+ }
+
+ return response.data || {};
+}
+
+const normalizeQueueError = (error) => {
+ if (error instanceof QueueApiError) return error;
+ return new QueueApiError(error?.message || "无法连接服务,请检查网络后重试。", 0, error, error?.code || "");
+}
+
+const normalizeQueueRequest = (promise) => {
+ return promise
+ .then(unwrapQueueResponse)
+ .catch((error) => {
+ throw normalizeQueueError(error);
+ });
+}
+
+export const newQueueIdempotencyKey = () => {
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+ return crypto.randomUUID();
+ }
+ return `mini-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+export const getPublicProjects = () => {
+ return normalizeQueueRequest(request.get("/hotelBiz/visitorQueue/projects", {})).then((response) => ({
+ projects: Array.isArray(response.projects) ? response.projects : [],
+ }));
+}
+
+export const createPublicTicket = (projectId, payload, idempotencyKey = newQueueIdempotencyKey()) => {
+ return normalizeQueueRequest(request.post("/visitorQueue/tickets", {
+ project_id: projectId,
+ ...payload,
+ }, {
+ header: {
+ "Idempotency-Key": idempotencyKey,
+ },
+ })).then((response) => ({
+ ...response,
+ ticket: normalizePublicStatus(response.ticket),
+ }));
+}
+
+export const getPublicStatus = () => {
+ return normalizeQueueRequest(request.get("/hotelBiz/visitorQueue/status", {}))
+ .then((response) => normalizePublicStatus(response));
+}
+
+export const searchPublicStatusByPhone = () => {
+ return normalizeQueueRequest(request.get("/hotelBiz/visitorQueue/status", {})).then((response) => {
+ if (Array.isArray(response.tickets)) {
+ return { tickets: response.tickets.map(normalizePublicStatus) };
+ }
+ return { tickets: [normalizePublicStatus(response)] };
+ });
+}
diff --git a/src/pages-base/queue/request/base/request.js b/src/pages-base/queue/request/base/request.js
new file mode 100644
index 0000000..759cdf7
--- /dev/null
+++ b/src/pages-base/queue/request/base/request.js
@@ -0,0 +1,3 @@
+import request from "@/request/base/request";
+
+export default request;
diff --git a/src/pages-base/queue/result.vue b/src/pages-base/queue/result.vue
new file mode 100644
index 0000000..3c3de9a
--- /dev/null
+++ b/src/pages-base/queue/result.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 当前活动号码
+ 找到 {{ tickets.length }} 张排队单
+
+ 请选择要查看的号码
+
+
+
+
+
+
+
+
+ 排队数据会自动更新,现场以工作人员指引为准。
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages-base/queue/scss/queue.scss b/src/pages-base/queue/scss/queue.scss
new file mode 100644
index 0000000..56bd04e
--- /dev/null
+++ b/src/pages-base/queue/scss/queue.scss
@@ -0,0 +1,803 @@
+.queue-page {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+ background: #f4f7f3;
+ color: #10261d;
+}
+
+.queue-scroll {
+ flex: 1;
+ height: 0;
+}
+
+.queue-public-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18rpx;
+ padding: 24rpx 32rpx 18rpx;
+ border-bottom: 1rpx solid rgba(113, 128, 121, 0.28);
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.queue-brand {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 16rpx;
+}
+
+.queue-brand__logo {
+ width: 164rpx;
+ height: 68rpx;
+ flex: 0 0 auto;
+}
+
+.queue-brand__text {
+ display: grid;
+ min-width: 0;
+ line-height: 1.2;
+
+ text:first-child {
+ color: #064a2a;
+ font-size: 28rpx;
+ font-weight: 800;
+ }
+
+ text:last-child {
+ margin-top: 6rpx;
+ color: #56665f;
+ font-size: 22rpx;
+ font-weight: 600;
+ }
+}
+
+.queue-live {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 8rpx;
+ color: #137a4b;
+ font-size: 23rpx;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.queue-live__dot,
+.queue-freshness__dot {
+ width: 14rpx;
+ height: 14rpx;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 0 7rpx rgba(19, 122, 75, 0.12);
+}
+
+.queue-live--stale {
+ color: #9b550d;
+}
+
+.queue-live--offline {
+ color: #53635a;
+}
+
+.queue-content {
+ display: grid;
+ gap: 24rpx;
+ padding: 24rpx 28rpx 56rpx;
+}
+
+.queue-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ overflow: hidden;
+ border: 1rpx solid #cbd8cf;
+ border-radius: 20rpx;
+ padding: 6rpx;
+ background: #ffffff;
+ box-shadow: 0 8rpx 24rpx rgba(16, 38, 29, 0.07);
+}
+
+.queue-tab,
+.queue-button,
+.queue-back-button,
+.queue-honorific__option,
+.queue-result-option,
+.queue-feedback__action {
+ margin: 0;
+ border: 0;
+ border-radius: 16rpx;
+ background: transparent;
+ color: inherit;
+ line-height: 1.35;
+
+ &::after {
+ border: 0;
+ }
+}
+
+.queue-tab {
+ display: flex;
+ min-height: 76rpx;
+ align-items: center;
+ justify-content: center;
+ padding: 0 16rpx;
+ color: #56665f;
+ font-size: 30rpx;
+ font-weight: 800;
+ line-height: 1;
+ text-align: center;
+}
+
+.queue-tab--active {
+ background: #0b6b3a;
+ color: #ffffff;
+ box-shadow: 0 8rpx 18rpx rgba(11, 107, 58, 0.2);
+}
+
+.queue-panel {
+ display: grid;
+ gap: 22rpx;
+}
+
+.queue-card {
+ display: grid;
+ gap: 24rpx;
+ border: 1rpx solid rgba(113, 128, 121, 0.34);
+ border-radius: 24rpx;
+ padding: 28rpx;
+ background: #ffffff;
+ box-shadow: 0 10rpx 28rpx rgba(16, 38, 29, 0.08);
+}
+
+.queue-card__intro {
+ display: grid;
+ gap: 10rpx;
+
+ > text:first-child {
+ color: #137a4b;
+ font-size: 22rpx;
+ font-weight: 900;
+ letter-spacing: 1rpx;
+ }
+}
+
+.queue-card__title {
+ color: #10261d;
+ font-size: 40rpx;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.queue-card__description {
+ color: #56665f;
+ font-size: 26rpx;
+ line-height: 1.55;
+}
+
+.queue-form {
+ display: grid;
+ gap: 22rpx;
+}
+
+.queue-field {
+ display: grid;
+ gap: 10rpx;
+
+ > text:first-child {
+ color: #10261d;
+ font-size: 25rpx;
+ font-weight: 800;
+ }
+
+ input,
+ .queue-picker {
+ min-height: 96rpx;
+ box-sizing: border-box;
+ border: 1rpx solid #aebeb4;
+ border-radius: 18rpx;
+ padding: 0 24rpx;
+ background: #ffffff;
+ color: #10261d;
+ font-size: 30rpx;
+ }
+
+ input {
+ line-height: 96rpx;
+ }
+}
+
+.queue-picker {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12rpx;
+}
+
+.queue-field__hint {
+ color: #68736c;
+ font-size: 23rpx;
+}
+
+.queue-field__error {
+ color: #b72d2d;
+ font-size: 23rpx;
+ font-weight: 700;
+}
+
+.queue-honorific {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8rpx;
+ min-height: 96rpx;
+ align-items: stretch;
+}
+
+.queue-honorific__option {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: center;
+ border: 1rpx solid #d5ded8;
+ background: #f7faf7;
+ color: #56665f;
+ font-size: 24rpx;
+ font-weight: 800;
+}
+
+.queue-honorific__option--active {
+ border-color: #137a4b;
+ background: #eaf7f0;
+ color: #075d31;
+}
+
+.queue-notice {
+ margin: 0;
+ border-left: 6rpx solid #137a4b;
+ border-radius: 14rpx;
+ padding: 18rpx 20rpx;
+ background: #eaf7f0;
+ color: #365243;
+ font-size: 25rpx;
+ line-height: 1.55;
+}
+
+.queue-button {
+ display: flex;
+ min-height: 92rpx;
+ align-items: center;
+ justify-content: center;
+ padding: 0 28rpx;
+ font-size: 29rpx;
+ font-weight: 900;
+}
+
+.queue-button--primary {
+ background: #0b6b3a;
+ color: #ffffff;
+ box-shadow: 0 8rpx 18rpx rgba(11, 107, 58, 0.2);
+}
+
+.queue-button--secondary {
+ border: 1rpx solid #0b6b3a;
+ background: #ffffff;
+ color: #0b6b3a;
+}
+
+.queue-button--ghost {
+ border: 1rpx solid #cbd8cf;
+ background: transparent;
+ color: #10261d;
+}
+
+.queue-button[disabled] {
+ opacity: 0.55;
+}
+
+.queue-ticket-created {
+ display: grid;
+ justify-items: center;
+ gap: 14rpx;
+ text-align: center;
+}
+
+.queue-ticket-created__eyebrow {
+ border-radius: 999rpx;
+ padding: 8rpx 18rpx;
+ background: #eaf7f0;
+ color: #137a4b;
+ font-size: 23rpx;
+ font-weight: 900;
+}
+
+.queue-ticket-created__label,
+.queue-ticket-created__meta {
+ color: #56665f;
+ font-size: 25rpx;
+}
+
+.queue-ticket-created__number {
+ color: #075d31;
+ font-size: 88rpx;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.queue-feedback {
+ display: grid;
+ grid-template-columns: 8rpx minmax(0, 1fr);
+ overflow: hidden;
+ border: 1rpx solid #d5ded8;
+ border-radius: 18rpx;
+ background: #ffffff;
+}
+
+.queue-feedback__bar {
+ background: #0b6b3a;
+}
+
+.queue-feedback__body {
+ display: grid;
+ gap: 8rpx;
+ padding: 18rpx 20rpx;
+}
+
+.queue-feedback__title {
+ color: #10261d;
+ font-size: 27rpx;
+ font-weight: 900;
+}
+
+.queue-feedback__message {
+ color: #56665f;
+ font-size: 24rpx;
+ line-height: 1.5;
+}
+
+.queue-feedback--warning {
+ background: #fff8ed;
+
+ .queue-feedback__bar {
+ background: #9b550d;
+ }
+}
+
+.queue-feedback--danger {
+ background: #fff4f4;
+
+ .queue-feedback__bar {
+ background: #b72d2d;
+ }
+}
+
+.queue-feedback__action {
+ display: flex;
+ justify-self: start;
+ min-height: 62rpx;
+ align-items: center;
+ justify-content: center;
+ border: 1rpx solid #0b6b3a;
+ padding: 0 24rpx;
+ background: #ffffff;
+ color: #0b6b3a;
+ font-size: 24rpx;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.queue-loading {
+ display: grid;
+ gap: 18rpx;
+ border: 1rpx solid rgba(113, 128, 121, 0.34);
+ border-radius: 24rpx;
+ padding: 30rpx;
+ background: #ffffff;
+ box-shadow: 0 10rpx 28rpx rgba(16, 38, 29, 0.08);
+
+ text {
+ justify-self: center;
+ color: #56665f;
+ font-size: 26rpx;
+ font-weight: 800;
+ }
+}
+
+.queue-loading__line,
+.queue-loading__number {
+ overflow: hidden;
+ border-radius: 999rpx;
+ background: #e5ece8;
+}
+
+.queue-loading__line--short {
+ width: 34%;
+ height: 20rpx;
+}
+
+.queue-loading__line--title {
+ width: 58%;
+ height: 34rpx;
+}
+
+.queue-loading__line--wide {
+ width: 100%;
+ height: 64rpx;
+ border-radius: 18rpx;
+}
+
+.queue-loading__number {
+ width: 72%;
+ height: 132rpx;
+ justify-self: center;
+ border-radius: 22rpx;
+}
+
+.queue-back-button {
+ display: flex;
+ align-self: flex-start;
+ min-height: 70rpx;
+ align-items: center;
+ gap: 8rpx;
+ border: 1rpx solid #cbd8cf;
+ padding: 0 20rpx;
+ background: #ffffff;
+ color: #10261d;
+ font-size: 25rpx;
+ font-weight: 800;
+}
+
+.queue-freshness {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ border: 1rpx solid #cbd8cf;
+ border-radius: 18rpx;
+ padding: 18rpx 20rpx;
+ background: #ffffff;
+ color: #137a4b;
+ font-size: 24rpx;
+ font-weight: 800;
+
+ text {
+ flex: 1;
+ min-width: 0;
+ }
+
+ button {
+ display: flex;
+ margin: 0;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 12rpx;
+ padding: 0 18rpx;
+ background: #eaf7f0;
+ color: #0b6b3a;
+ font-size: 23rpx;
+ line-height: 1;
+
+ &::after {
+ border: 0;
+ }
+ }
+}
+
+.queue-freshness--stale {
+ color: #9b550d;
+ background: #fff8ed;
+}
+
+.queue-freshness--offline {
+ color: #53635a;
+ background: #f2f4f2;
+}
+
+.queue-ticket {
+ position: relative;
+ display: grid;
+ gap: 24rpx;
+ overflow: hidden;
+ border: 1rpx solid rgba(113, 128, 121, 0.34);
+ border-radius: 26rpx;
+ padding: 34rpx 28rpx 28rpx;
+ background: linear-gradient(135deg, rgba(11, 107, 58, 0.06), transparent 44%), #ffffff;
+ box-shadow: 0 10rpx 28rpx rgba(16, 38, 29, 0.08);
+
+ &::before {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ height: 10rpx;
+ content: "";
+ background: linear-gradient(90deg, #0b6b3a, #43a864 58%, transparent);
+ }
+}
+
+.queue-ticket--warning::before {
+ background: linear-gradient(90deg, #9b550d, #f2a900 58%, transparent);
+}
+
+.queue-ticket--success::before {
+ background: linear-gradient(90deg, #0b6b3a, #43a864 58%, transparent);
+}
+
+.queue-ticket--neutral::before {
+ background: linear-gradient(90deg, #53635a, #a7b0aa 58%, transparent);
+}
+
+.queue-ticket__topline {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20rpx;
+}
+
+.queue-ticket__project {
+ display: block;
+ color: #56665f;
+ font-size: 24rpx;
+ font-weight: 800;
+}
+
+.queue-ticket__title {
+ display: block;
+ margin-top: 4rpx;
+ color: #10261d;
+ font-size: 42rpx;
+ font-weight: 900;
+ line-height: 1.2;
+}
+
+.queue-ticket__status-mark {
+ width: 24rpx;
+ height: 24rpx;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: #0b6b3a;
+ box-shadow: 0 0 0 9rpx rgba(11, 107, 58, 0.1);
+}
+
+.queue-ticket__number {
+ display: grid;
+ justify-items: center;
+ gap: 8rpx;
+ border-radius: 24rpx;
+ padding: 24rpx;
+ background: #f4faf5;
+
+ > text:first-child,
+ .queue-ticket__number-meta {
+ color: #56665f;
+ font-size: 24rpx;
+ font-weight: 800;
+ }
+}
+
+.queue-ticket__number-value {
+ max-width: 100%;
+ color: #075d31;
+ font-size: 108rpx;
+ font-weight: 950;
+ line-height: 1;
+ word-break: break-all;
+}
+
+.queue-ticket__metrics {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16rpx;
+}
+
+.queue-ticket__metric {
+ display: grid;
+ gap: 8rpx;
+ border: 1rpx solid #d5ded8;
+ border-radius: 18rpx;
+ padding: 20rpx;
+ background: #ffffff;
+
+ > text:first-child,
+ .queue-ticket__metric-meta {
+ color: #56665f;
+ font-size: 23rpx;
+ font-weight: 750;
+ }
+}
+
+.queue-ticket__metric-value {
+ color: #10261d;
+ font-size: 30rpx;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.queue-ticket__metric--wide {
+ grid-column: 1 / -1;
+}
+
+.queue-ticket__service-status,
+.queue-ticket__message,
+.queue-ticket__guidance,
+.queue-call-action,
+.queue-official-notice {
+ border-radius: 18rpx;
+ padding: 20rpx;
+ background: #fff8ed;
+ color: #5f3b13;
+ font-size: 25rpx;
+ line-height: 1.5;
+}
+
+.queue-ticket__service-status {
+ display: grid;
+ gap: 8rpx;
+
+ > text:first-child {
+ color: #9b550d;
+ font-weight: 900;
+ }
+}
+
+.queue-call-action {
+ display: grid;
+ justify-items: center;
+ gap: 8rpx;
+ background: #eaf7f0;
+ color: #075d31;
+ text-align: center;
+
+ > text:first-child {
+ font-size: 24rpx;
+ font-weight: 800;
+ }
+}
+
+.queue-call-action__entrance {
+ font-size: 42rpx;
+ font-weight: 900;
+}
+
+.queue-call-action__desc {
+ color: #365243;
+ font-size: 24rpx;
+ font-weight: 600;
+}
+
+.queue-ticket__message,
+.queue-ticket__guidance {
+ display: block;
+ background: #f2f4f2;
+ color: #53635a;
+}
+
+.queue-ticket__metadata {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 10rpx 20rpx;
+ color: #68736c;
+ font-size: 22rpx;
+}
+
+.queue-official-notice {
+ display: grid;
+ gap: 8rpx;
+ margin-top: 18rpx;
+ background: #ffffff;
+ color: #365243;
+ box-shadow: inset 0 0 0 1rpx #d5ded8;
+}
+
+.queue-official-notice__title {
+ color: #137a4b;
+ font-size: 26rpx;
+ font-weight: 900;
+}
+
+.queue-official-notice__body {
+ font-size: 24rpx;
+ line-height: 1.55;
+}
+
+.queue-refresh {
+ margin-top: 2rpx;
+}
+
+.queue-trust {
+ margin: 0;
+ color: #68736c;
+ font-size: 23rpx;
+ text-align: center;
+}
+
+.queue-results {
+ display: grid;
+ gap: 18rpx;
+}
+
+.queue-results__heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 20rpx;
+ padding: 0 4rpx;
+
+ > text,
+ view > text:first-child {
+ color: #137a4b;
+ font-size: 22rpx;
+ font-weight: 900;
+ }
+
+ > text {
+ color: #68736c;
+ white-space: nowrap;
+ }
+}
+
+.queue-results__title {
+ display: block;
+ margin-top: 6rpx;
+ color: #10261d;
+ font-size: 32rpx;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.queue-result-options {
+ display: grid;
+ gap: 12rpx;
+}
+
+.queue-result-option {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6rpx 16rpx;
+ border: 1rpx solid #d5ded8;
+ padding: 20rpx 22rpx;
+ background: #ffffff;
+ text-align: left;
+
+ > text:first-child {
+ min-width: 0;
+ overflow: hidden;
+ color: #56665f;
+ font-size: 24rpx;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+}
+
+.queue-result-option__number {
+ color: #075d31;
+ font-size: 34rpx;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.queue-result-option__meta {
+ grid-column: 1 / -1;
+ color: #137a4b;
+ font-size: 23rpx;
+ font-weight: 800;
+}
+
+.queue-result-option--active {
+ border-color: #137a4b;
+ background: #eaf7f0;
+}
+
+@media (max-width: 360px) {
+ .queue-ticket__metrics {
+ grid-template-columns: 1fr;
+ }
+
+ .queue-results__heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 6rpx;
+ }
+}
diff --git a/src/pages-base/queue/static/xiaoqikong-logo.jpg b/src/pages-base/queue/static/xiaoqikong-logo.jpg
new file mode 100644
index 0000000..d556065
Binary files /dev/null and b/src/pages-base/queue/static/xiaoqikong-logo.jpg differ
diff --git a/src/pages-base/queue/utils/queueUtils.mjs b/src/pages-base/queue/utils/queueUtils.mjs
new file mode 100644
index 0000000..ee14546
--- /dev/null
+++ b/src/pages-base/queue/utils/queueUtils.mjs
@@ -0,0 +1,158 @@
+const IMMINENT_PEOPLE_THRESHOLD = 50;
+
+export const isPhoneInputValid = (value) => {
+ return /^\d{7,15}$/.test(String(value ?? "").trim());
+};
+
+export const digitsOnly = (value) => {
+ return String(value ?? "").replace(/\D/g, "");
+}
+
+export const formatDateTime = (value, withSeconds = false) => {
+ if (!value) return "尚未更新";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+ const seconds = String(date.getSeconds()).padStart(2, "0");
+ return withSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`;
+}
+
+export const formatEstimatedWait = (value) => {
+ if (value === null || value === undefined || value === "") return "暂不可估算";
+ if (typeof value === "number") return `约 ${Math.max(0, Math.round(value))} 分钟`;
+ if (typeof value === "string") {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) ? `约 ${Math.max(0, Math.round(numeric))} 分钟` : value;
+ }
+ if (typeof value === "object") {
+ const estimate = Number(value.estimate_minutes);
+ if (Number.isFinite(estimate)) return `约 ${Math.max(0, Math.round(estimate))} 分钟`;
+
+ const min = Number(value.min ?? value.min_minutes ?? value.lower ?? value.lower_minutes);
+ const max = Number(value.max ?? value.max_minutes ?? value.upper ?? value.upper_minutes);
+ if (Number.isFinite(min) && Number.isFinite(max)) {
+ return `预计 ${Math.max(0, Math.round(min))}-${Math.max(0, Math.round(max))} 分钟`;
+ }
+
+ const minutes = Number(value.minutes ?? value.value);
+ if (Number.isFinite(minutes)) return `约 ${Math.max(0, Math.round(minutes))} 分钟`;
+ }
+ return "暂不可估算";
+}
+
+export const isTimestampStale = (value, thresholdMs = 30_000, now = Date.now()) => {
+ if (!value) return true;
+ const timestamp = new Date(value).getTime();
+ return Number.isNaN(timestamp) || now - timestamp > thresholdMs;
+}
+
+export const ticketStatusMeta = (status) => {
+ const normalized = String(status ?? "").toUpperCase();
+ const map = {
+ WAITING: { label: "正在排队", tone: "info", guidance: "可暂时离开,请留意页面更新。" },
+ CALLED: { label: "您的号码已叫到", tone: "success", guidance: "请尽快前往现场入口并出示排队号码。" },
+ ARRIVED: { label: "已到场", tone: "success", guidance: "请按现场指引等待入场。" },
+ SERVING: { label: "正在入场", tone: "info", guidance: "请按工作人员指引通行。" },
+ COMPLETED: { label: "本次排队已完成", tone: "success", guidance: "感谢您的配合。" },
+ MISSED: { label: "已过号", tone: "warning", guidance: "请联系工作人员处理。" },
+ CANCELED: { label: "排队已取消", tone: "neutral", guidance: "如需重新排队,请联系工作人员。" },
+ CANCELLED: { label: "排队已取消", tone: "neutral", guidance: "如需重新排队,请联系工作人员。" },
+ };
+ return map[normalized] ?? { label: status || "状态未知", tone: "neutral", guidance: "请联系现场工作人员确认。" };
+}
+
+export const visitorStatusMeta = (status, peopleAhead) => {
+ const normalized = String(status ?? "").toUpperCase();
+ if (normalized === "WAITING" && peopleAhead != null && Number(peopleAhead) <= IMMINENT_PEOPLE_THRESHOLD) {
+ return { label: "即将到号", tone: "warning", guidance: "前方号码不多,请留意现场叫号。", state: "IMMINENT" };
+ }
+ if (normalized === "CALLED") {
+ return { label: "已到号", tone: "success", guidance: "请尽快前往现场入口并出示排队号码。", state: "CALLED" };
+ }
+ const meta = ticketStatusMeta(status);
+ const knownStates = ["WAITING", "ARRIVED", "SERVING", "COMPLETED", "MISSED", "CANCELED", "CANCELLED"];
+ return {
+ ...meta,
+ state: knownStates.includes(normalized) ? normalized.replace("CANCELLED", "CANCELED") : "UNKNOWN",
+ };
+}
+
+export const normalizePublicStatus = (ticket = {}) => {
+ const partySize = Number(ticket.party_size);
+ return {
+ ...ticket,
+ party_size: Number.isFinite(partySize) && partySize > 0 ? partySize : 1,
+ ticket_number: ticket.ticket_number || ticket.display_number || "暂无",
+ };
+}
+
+export const buildTicketKey = (ticket = {}) => {
+ return `${ticket.project?.id ?? ticket.project_name}:${ticket.ticket_number}`;
+}
+
+export const resolveStatusTarget = (response = {}) => {
+ if (response.public_token) return { token: response.public_token };
+
+ const url = response.public_url || response.status_path || "";
+ const match = String(url).match(/\/visitor\/([^/?#]+)/);
+ if (match?.[1]) return { token: decodeURIComponent(match[1]) };
+
+ return null;
+}
+
+export const extractVisitorToken = (value) => {
+ const text = String(value ?? "").trim();
+ if (!text) return "";
+
+ const tokenMatch = text.match(/(?:^|[?])(?:token|public_token)=([^]+)/);
+ if (tokenMatch?.[1]) return decodeMaybe(tokenMatch[1]);
+
+ const visitorMatch = text.match(/\/visitor\/([^/?#]+)/);
+ if (visitorMatch?.[1]) return decodeMaybe(visitorMatch[1]);
+
+ if (!text.includes("=") && !text.includes("&") && !text.includes("?") && !text.includes("/")) {
+ return decodeMaybe(text);
+ }
+ return "";
+}
+
+export const buildQueueResultUrl = (phone) => {
+ return `/pages-base/queue/result?phone=${encodeURIComponent(String(phone ?? "").trim())}`;
+}
+
+export const buildQueueTokenResultUrl = (token) => {
+ return `/pages-base/queue/result?token=${encodeURIComponent(String(token ?? "").trim())}`;
+}
+
+export const createSingleFlight = () => {
+ let running = false;
+ let generation = 0;
+ return {
+ get running() {
+ return running;
+ },
+ reset() {
+ generation += 1;
+ running = false;
+ },
+ async run(task) {
+ if (running) return false;
+ running = true;
+ const currentGeneration = generation;
+ try {
+ return await task();
+ } finally {
+ if (generation === currentGeneration) running = false;
+ }
+ },
+ };
+}
+
+export const decodeMaybe = (value) => {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
diff --git a/src/pages.json b/src/pages.json
index bb002d3..64b5043 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -96,6 +96,12 @@
"style": {
"navigationStyle": "custom"
}
+ },
+ {
+ "path": "queue/result",
+ "style": {
+ "navigationStyle": "custom"
+ }
}
]
},