修正bug

main
cysamurai 4 weeks ago
parent 36804fbeb9
commit 5077d7cefc

@ -0,0 +1,17 @@
---
description: Prevent AI from making assumptions or implementing unrequested features
alwaysApply: true
---
## Core Behavior Constraints (P0)
**Do NOT** implement features, code, or logic that was not explicitly requested by the user.
**Do NOT** add comments, documentation, or explanatory text unless explicitly requested.
**Do NOT** provide multiple solutions. Choose the optimal solution and implement it directly.
## Handling Ambiguity (P0)
If the user's request is ambiguous or critical information is missing, **MUST** ask exactly **ONE** clarifying question before taking any action.
**DO NOT** generate any code or assume the missing information.
## Citation Rules (P0)
**MUST** strictly reference rules from the **actual files** located in `.cursor/rules/`.
**DO NOT** invent, cite, or assume the existence of any rule that is not explicitly defined.

@ -12,6 +12,16 @@ export const userLogin = (loginData) => {
})
}
// 获取当前用户信息pad.auth -> /profile
export const getAuthProfile = (params = {}) => {
return request({
tag: 'pad.auth',
path: '/profile',
method: 'GET',
query: params
})
}
// 今日进厅数据(按大厅相关归到 pad.hallSystem可根据后端最终路由调整
export const getdailyEntry = (params) => {
return request({
@ -50,4 +60,14 @@ export const getBizList = (params = {}) => {
method: 'GET',
query: params
})
}
// 生成健康报告查询 URLpad.business -> /health-reopt/url
export const getHealthReportUrl = (params = {}) => {
return request({
tag: 'pad.business',
path: '/health-reopt/url',
method: 'POST',
body: params
})
}

@ -0,0 +1,32 @@
import request from '@/utils/request.js'
// 获取打印小票模板(无自定义配置时返回默认模板)
export const getPrintTemplate = (params = {}) => {
return request({
tag: 'pad.print',
path: '/template',
method: 'GET',
query: params
})
}
// 按当前模板打印小票
export const printTicket = (params = {}) => {
return request({
tag: 'pad.print',
path: '/ticket',
method: 'POST',
body: params
})
}
// 保存打印小票模板POST + body签名参数与其他 POST 接口一致)
export const savePrintTemplate = (params = {}) => {
const body = JSON.parse(JSON.stringify(params))
return request({
tag: 'pad.print',
path: '/template',
method: 'POST',
body
})
}

@ -8,4 +8,69 @@ export const takeTicket = (params = {}) => {
method: 'POST',
body: params
})
}
// 实名核验记录查询
export const searchTicketVerify = (params = {}) => {
return request({
tag: 'pad.ticket',
path: '/verify/search',
method: 'POST',
body: params
})
}
// 回签(复号)
export const resumeTicket = (params = {}) => {
return request({
tag: 'pad.ticket',
path: '/resume',
method: 'POST',
body: params
})
}
// 优先取号(插队票)
export const createJumpTicket = (params = {}) => {
return request({
tag: 'pad.ticket',
path: '/create-jump',
method: 'POST',
body: params
})
}
// 获取人脸照片pad.ticket -> GET /face/image/{uid}
export const getFaceImage = (uid) => {
const id = String(uid || '').trim()
if (!id) {
return Promise.reject(new Error('uid 不能为空'))
}
const requestParams = {
tag: 'pad.ticket',
path: `/face/image/${encodeURIComponent(id)}`,
method: 'GET',
query: {}
}
console.log('[face-image] 请求参数:', {
uid: id,
tag: requestParams.tag,
method: requestParams.method,
path: requestParams.path,
query: requestParams.query,
})
return request(requestParams)
.then((result) => {
console.log('[face-image] 返回值:', result)
try {
console.log('[face-image] 返回值 JSON:', JSON.stringify(result, null, 2))
} catch (error) {
console.log('[face-image] 返回值无法 JSON 序列化:', error)
}
return result
})
.catch((error) => {
console.log('[face-image] 请求失败:', error)
throw error
})
}

@ -0,0 +1,338 @@
<template>
<view>
<uni-popup ref="phonePopup" type="center" :is-mask-click="false">
<view class="phone-popup">
<view class="popup-header">
<text class="popup-title">请输入手机号码</text>
<view class="report-close" @click="closePhonePopup">×</view>
</view>
<view class="phone-popup-body">
<text class="phone-popup-tip">{{ phonePopupTip }}</text>
<input
v-model="inputPhone"
class="phone-popup-input"
type="number"
maxlength="11"
placeholder="请输入11位手机号码"
/>
</view>
<view class="phone-popup-actions">
<button class="btn btn-default popup-btn" @click="closePhonePopup"></button>
<button class="btn btn-primary popup-btn" @click="confirmPhoneForTakeTicket"></button>
</view>
</view>
</uni-popup>
<uni-popup ref="ticketPopup" type="center">
<view class="ticket-popup">
<view class="popup-header">
<text class="popup-title">选择业务直接取号</text>
<view class="report-close" @click="closeTicketPopup">×</view>
</view>
<view class="biz-grid">
<view
v-for="(item, index) in businessList"
:key="index"
class="biz-item"
:class="{ disabled: ticketLoading }"
@click="takeTicketByBusiness(item)"
>
<view class="biz-icon">
<uni-icons type="wallet" size="22" color="#111827"></uni-icons>
</view>
<view class="biz-name">{{ item.name }}</view>
<view class="biz-waiting">等候 {{ item.waitingCount ?? 0 }} </view>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { getBizList } from "@/api/index.js";
import { takeTicket } from "@/api/ticket.js";
import { computed, ref, watch } from "vue";
import { validatePhoneNumber } from "@/utils/validator";
const emit = defineEmits(["success", "loading"]);
const phonePopup = ref(null);
const ticketPopup = ref(null);
const inputPhone = ref("");
const businessList = ref([]);
const ticketLoading = ref(false);
const currentRow = ref(null);
const tktType = ref("normal");
const rankUserPhone = ref("");
const phonePopupTip = computed(() =>
tktType.value === "normal"
? "普通取号请先填写手机号码"
: "当前记录无手机号,取号前请先填写",
);
const getTakeTicketPhone = () => {
if (currentRow.value) {
return String(currentRow.value.phone || "").trim();
}
return String(rankUserPhone.value || "").trim();
};
watch(ticketLoading, (value) => {
emit("loading", value);
});
const loadBusinessList = async () => {
try {
const res = await getBizList({ withWaiting: true });
const list = Array.isArray(res) ? res : [];
businessList.value = list.map((item, index) => ({
name: item.name || item.bizName || "",
value: item.value || item.uid || item.id || String(index),
waitingCount: Number(item.waitingCount ?? 0),
}));
} catch (error) {
console.log("获取业务列表失败:", error);
}
};
const openTicketBusinessPopup = async () => {
if (!businessList.value.length) {
await loadBusinessList();
}
ticketPopup.value?.open();
};
/** 普通取号:无核验记录,先填写手机号再选择业务 */
const open = () => {
currentRow.value = null;
tktType.value = "normal";
rankUserPhone.value = "";
inputPhone.value = "";
phonePopup.value?.open();
};
/** 预检取号:基于核验记录取号 */
const openWithRow = (row) => {
currentRow.value = row;
tktType.value = "realname";
rankUserPhone.value = "";
const phone = String(row?.phone || "").trim();
if (!phone) {
inputPhone.value = "";
phonePopup.value?.open();
return;
}
openTicketBusinessPopup();
};
const closePhonePopup = () => {
phonePopup.value?.close();
};
const confirmPhoneForTakeTicket = () => {
const phoneResult = validatePhoneNumber(inputPhone.value);
if (!phoneResult.valid) {
uni.showToast({
title: phoneResult.message || "请输入正确手机号",
icon: "none",
});
return;
}
if (currentRow.value) {
currentRow.value = {
...currentRow.value,
phone: inputPhone.value.trim(),
};
} else {
rankUserPhone.value = inputPhone.value.trim();
}
closePhonePopup();
openTicketBusinessPopup();
};
const closeTicketPopup = () => {
ticketPopup.value?.close();
};
const buildTakeTicketParams = (bizItem) => {
const params = {
bizUid: bizItem.value,
tktType: tktType.value,
rankUserPhone: getTakeTicketPhone(),
};
if (currentRow.value) {
params.uid = currentRow.value.uid;
params.idCard = currentRow.value.idCard;
params.rankUserName = currentRow.value.name;
}
return params;
};
const takeTicketByBusiness = async (bizItem) => {
if (ticketLoading.value) return;
if (!getTakeTicketPhone()) {
uni.showToast({
title: "请先填写手机号码",
icon: "none",
});
return;
}
ticketLoading.value = true;
try {
const result = await takeTicket(buildTakeTicketParams(bizItem));
const tktId = result?.tktId || result?.ticketNumber || "";
uni.showToast({
title: tktId ? `取号成功:${tktId}` : "取号成功",
icon: "success",
});
closeTicketPopup();
emit("success", result);
} catch (error) {
console.log("取号失败", error);
} finally {
ticketLoading.value = false;
}
};
defineExpose({
open,
openWithRow,
ticketLoading,
});
</script>
<style scoped lang="scss">
.ticket-popup,
.phone-popup {
width: 680rpx;
max-width: 90vw;
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.phone-popup-body {
padding: 14px;
}
.phone-popup-tip {
display: block;
font-size: 14px;
color: #6b7280;
margin-bottom: 12px;
}
.phone-popup-input {
width: 100%;
height: 40px;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 0 12px;
font-size: 14px;
box-sizing: border-box;
}
.phone-popup-actions {
padding: 12px 14px 14px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.popup-header {
height: 48px;
padding: 0 14px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.popup-title {
font-size: 15px;
color: #111827;
font-weight: 600;
}
.report-close {
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
font-size: 22px;
color: #9ca3af;
cursor: pointer;
}
.biz-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px;
}
.biz-item {
border: 1px solid #e5e7eb;
border-radius: 10px;
min-height: 92px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
cursor: pointer;
}
.biz-item.disabled {
opacity: 0.6;
pointer-events: none;
}
.biz-icon {
margin-bottom: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.biz-name {
font-size: 13px;
color: #111827;
text-align: center;
}
.biz-waiting {
margin-top: 6px;
font-size: 12px;
color: #fa541c;
text-align: center;
}
.btn {
min-width: 72px;
height: 32px;
line-height: 32px;
padding: 0 14px;
font-size: 14px;
border-radius: 6px;
border: none;
}
.popup-btn {
min-width: 72px;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-default {
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
}
</style>

@ -0,0 +1,36 @@
<template>
<view class="top-div">
<slot name="left">
<view></view>
</slot>
<slot name="right">
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="handleBack">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</slot>
</view>
</template>
<script setup>
const emit = defineEmits(["back"]);
const handleBack = () => {
emit("back");
};
</script>
<style scoped>
.top-div {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
padding: 6vh 20px 10px 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-sizing: border-box;
}
</style>

@ -2,7 +2,7 @@
<div class="login-head">
<div class="head-title">
<image class="head-icon" src="/static/head-icon.png" />
<span>国家税务总局XX市XX税务局办税服务厅</span>
<span>{{ hallName }}</span>
</div>
<div class="head-time">
<text class="dt-text">{{ currentDate }}</text>
@ -18,10 +18,20 @@
onMounted,
onUnmounted
} from 'vue'
import { onShow } from '@dcloudio/uni-app'
const currentTime = ref('')
const currentDate = ref('')
const currentDay = ref('')
const hallName = ref('国家税务总局XX市XX税务局办税服务厅')
const hallNameEvent = 'hall-name-updated'
const loadHallNameFromStorage = () => {
const savedHallName = uni.getStorageSync('swjgMC')
if (savedHallName) {
hallName.value = savedHallName
}
}
const updateDateTime = () => {
const now = new Date()
@ -46,14 +56,22 @@
let timer
onMounted(() => {
loadHallNameFromStorage()
uni.$on(hallNameEvent, loadHallNameFromStorage)
updateDateTime() //
timer = setInterval(updateDateTime, 1000) //
});
onShow(() => {
// /
loadHallNameFromStorage()
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
uni.$off(hallNameEvent, loadHallNameFromStorage)
})
</script>

@ -0,0 +1,590 @@
# PAD 接口文档
## 1. 基础信息
- 服务默认端口:`8845`
- 默认基础地址:`http://<host>:8845`
- 直连接口统一前缀:`/api/queue/**`
- 本文范围:
- `pad.*` 标签对应的直连接口
- 虽然路径不在 `/api/queue/pad/**` 下,但 Swagger 标签仍属于 `pad.*` 的配套接口
- `/public` 统一网关映射
## 2. 统一返回结构
### 2.1 直连接口返回结构
绝大多数直连接口返回统一结构 `R<T>`
```json
{
"code": 200,
"msg": "操作成功",
"data": {}
}
```
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | `int` | 状态码,成功通常为 `200` |
| `msg` | `string` | 返回消息 |
| `data` | `object/array/null` | 具体业务数据 |
### 2.2 `/public` 网关返回结构
`/public` 返回 `PublicGatewayResponseVo<T>`,比 `R<T>` 多一个 `traceId`
```json
{
"code": 200,
"msg": "OK",
"traceId": "202604210001",
"data": {}
}
```
## 3. 接口清单
> 说明:
> - `Header.Authorization` 表示请求头 `Authorization: Bearer <token>`
> - `path.xxx` 表示路径参数
> - `query.xxx` 表示查询参数
> - `body.xxx` 表示 JSON 请求体字段
> - `未接入` 表示当前不能通过 `/public``tag + map.path` 转发
### 3.1 `pad.auth`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `POST` | `/api/queue/pad/auth/login` | `pad.auth` + `/login` | `body.username`、`body.password``body.tenantId` 可选 | `R<LoginResponse>` | 登录 |
| `POST` | `/api/queue/pad/auth/refresh` | 未接入 | `body.refreshToken` | `R<LoginResponse>` | 刷新令牌 |
| `POST` | `/api/queue/pad/auth/logout` | 未接入 | `Header.Authorization` | `R<AuthLogoutVo>` | 用户退出 |
| `GET` | `/api/queue/pad/auth/profile` | 未接入 | `Header.Authorization` | `R<AuthProfileVo>` | 当前用户信息 |
| `POST` | `/api/queue/pad/auth/sign` | `pad.auth` + `/sign` | `body.tag`、`body.path`、`body.timestamp`、`body.nonce``body.query`、`body.body` 可选 | `R<SignResponseVo>` | 生成签名 |
| `GET` | `/api/queue/pad/auth/validate` | 未接入 | `Header.Authorization` | `R<TokenValidationVo>` | 校验令牌 |
### 3.2 `pad.business`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/business` | 未接入 | 无 | `R<List<Business>>` | 查询所有业务类型 |
| `POST` | `/api/queue/pad/business` | 未接入 | `body.name``body.prefix` 可选但必须唯一;`body.enabled`、`body.type`、`body.handleCount`、`body.isSpecial` 可选 | `R<Business>` | 创建业务类型 |
| `GET` | `/api/queue/pad/business/{uid}` | 未接入 | `path.uid` | `R<Business>` | 按 UID 查询 |
| `PUT` | `/api/queue/pad/business/{uid}` | 未接入 | `path.uid`、`body.name`;其他字段同创建 | `R<Business>` | 更新业务类型 |
| `DELETE` | `/api/queue/pad/business/{uid}` | 未接入 | `path.uid` | `R<BusinessOperationVo>` | 删除业务类型 |
| `PATCH` | `/api/queue/pad/business/{uid}/enabled` | 未接入 | `path.uid`、`query.enabled` | `R<BusinessOperationVo>` | 更新启用状态 |
| `GET` | `/api/queue/pad/business/enabled` | `pad.business` + `/enabled` | 无 | `R<List<Business>>` | 查询启用业务类型 |
| `POST` | `/api/queue/pad/business/health-reopt/url` | 未接入 | `body.id``body.swjgMc`、`body.swjgDm` 可选 | `R<HealthReportUrlVo>` | 生成健康报告查询 URL |
| `GET` | `/api/queue/pad/business/page` | 未接入 | `query.page` 默认 `1`、`query.size` 默认 `10` | `R<BusinessPageVo>` | 分页查询 |
| `GET` | `/api/queue/pad/business/search` | 未接入 | `query.name` | `R<List<Business>>` | 名称模糊查询 |
| `GET` | `/api/queue/pad/business/special` | 未接入 | 无 | `R<List<Business>>` | 查询特殊业务 |
| `GET` | `/api/queue/pad/business/statistics` | 未接入 | 无 | `R<BusinessStatisticsVo>` | 业务统计 |
| `GET` | `/api/queue/pad/business/type/{type}` | 未接入 | `path.type` | `R<List<Business>>` | 按类型查询 |
| `GET` | `/api/queue/pad/business/validate/name` | 未接入 | `query.name``query.excludeUid` 可选 | `R<BusinessValidationVo>` | 校验名称唯一性 |
| `GET` | `/api/queue/pad/business/validate/prefix` | 未接入 | `query.prefix``query.excludeUid` 可选 | `R<BusinessValidationVo>` | 校验前缀唯一性 |
### 3.3 `pad.menu`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/menu/business-types` | 未接入 | 无 | `R<BusinessMenuVo>` | 获取业务类型菜单 |
| `GET` | `/api/queue/pad/menu/main` | `pad.menu` + `/main` | 无 | `R<MenuPageVo>` | 获取主菜单 |
### 3.4 `pad.print`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/print/printers` | `pad.print` + `/printers` | 无 | `R<List<String>>` | 获取打印机列表 |
| `POST` | `/api/queue/pad/print/text` | `pad.print` + `/text` | `body.content``body.printerName` 可选 | `R<Boolean>` | 打印文本 |
### 3.5 `pad.test`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `POST` | `/api/queue/pad/test/medical-report/url` | 未接入 | `body.id`、`body.swjgMc`、`body.swjgDm` | `R<MedicalReportUrlVo>` | 测试用医保报告 URL |
### 3.6 `pad.ticket`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/business/list` | 未接入 | 无 | `R<List<Business>>` | 虽然路径不在 `/ticket` 下,但 Swagger Tag 属于 `pad.ticket` |
| `GET` | `/api/queue/pad/ticket/{uid}` | 未接入 | `path.uid` | `R<Ticket>` | 按 UID 查询票号 |
| `PATCH` | `/api/queue/pad/ticket/{uid}/assign-window` | 未接入 | `path.uid`、`query.winId` | `R<TicketAssignWindowVo>` | 指派窗口 |
| `GET` | `/api/queue/pad/ticket/{uid}/status` | 未接入 | `path.uid` | `R<TicketStatusVo>` | 按 UID 查询状态 |
| `PATCH` | `/api/queue/pad/ticket/{uid}/status` | 未接入 | `path.uid`、`query.status` | `R<TicketStatusUpdateVo>` | 更新票号状态 |
| `GET` | `/api/queue/pad/ticket/business/{bizUid}` | 未接入 | `path.bizUid` | `R<List<Ticket>>` | 按业务类型查询票号 |
| `POST` | `/api/queue/pad/ticket/call/{ticketNumber}` | 未接入 | `path.ticketNumber`、`query.windowNumber` | `R<TicketCallVo>` | 指定票号叫号 |
| `POST` | `/api/queue/pad/ticket/call/next` | 未接入 | `body.windowUid``body.empUid` 可选 | `R<TicketCallResultVo>` | 叫下一号 |
| `POST` | `/api/queue/pad/ticket/call/specific/{ticketUid}` | 未接入 | `path.ticketUid`、`query.windowUid``query.empUid` 可选 | `R<TicketCallResultVo>` | 叫指定票 |
| `POST` | `/api/queue/pad/ticket/create-jump` | 未接入 | `body.bizUid``body.rankUserName`、`body.rankUserPhone`、`body.idCard`、`body.tktId` 可选 | `R<TakeTicketResponse>` | 创建插队票 |
| `GET` | `/api/queue/pad/ticket/list` | `pad.ticket` + `/list` | `query.page` 默认 `1`、`query.size` 默认 `10``query.status`、`query.businessType`、`query.tabType` 可选 | `R<TicketPageVo>` | 票号列表 |
| `GET` | `/api/queue/pad/ticket/list-by-status/{status}` | 未接入 | `path.status` | `R<List<Ticket>>` | 按状态码查询 |
| `POST` | `/api/queue/pad/ticket/resume` | 未接入 | `body.resumeToken``body.targetPosition` 可选,默认 `3` | `R<TicketResumeResultVo>` | 复号 |
| `POST` | `/api/queue/pad/ticket/verify/search` | 未接入 | `body.name`、`body.idCard`、`body.phone` 至少传一个;三者均按包含匹配;`body.page` 默认 `1`、`body.size` 默认 `10` | `R<TicketVerifyQueryPageVo>` | 模糊查询 `ticket_verify` 并判断是否已完成实名/人脸比对 |
| `POST` | `/api/queue/pad/ticket/skip/{ticketNumber}` | 未接入 | `path.ticketNumber` | `R<TicketSkipVo>` | 跳过票号 |
| `GET` | `/api/queue/pad/ticket/status/{ticketNumber}` | 未接入 | `path.ticketNumber` | `R<TicketStatusDetailVo>` | 按票号字符串查询状态 |
| `POST` | `/api/queue/pad/ticket/suspend` | 未接入 | `body.ticketUid``body.windowUid`、`body.empUid`、`body.idCard`、`body.phone` 可选 | `R<TicketSuspendResultVo>` | 挂起票号 |
| `GET` | `/api/queue/pad/ticket/suspend/info/{token}` | 未接入 | `path.token` | `R<SuspendedTicketDetailVo>` | 查询挂起详情 |
| `POST` | `/api/queue/pad/ticket/take` | `pad.queue` + `/ticket/take` | `body.bizUid`,且 `body.idCard` / `body.rankUserPhone` 至少传一个;`body.rankUserName`、`body.enterpriseId`、`body.appointmentUid`、`body.tktId` 可选 | `R<TakeTicketVo>` | 取号 |
| `POST` | `/api/queue/pad/ticket/take-by-appointment` | 未接入 | `body.bizUid`、`body.appointmentUid` | `R<TakeTicketVo>` | 预约换号 |
| `GET` | `/api/queue/pad/ticket/today_unified_tickets` | 未接入 | `query.page` 默认 `1`、`query.size` 默认 `10` | `R<TicketPageVo>` | 今日统一票号列表 |
| `GET` | `/api/queue/pad/ticket/waiting-count` | 未接入 | `query.bizUid` 可选 | `R<WaitingCountVo>` | 查询等待人数 |
### 3.7 `pad.appointment`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/appointment/time-slots` | `pad.appointment` + `/time-slots` | 无 | `R<List<AppointmentTimeSlotVo>>` | 可用时间段 |
| `GET` | `/api/queue/pad/appointment/today` | 未接入 | `query.page` 默认 `1`、`query.size` 默认 `10``query.status`、`query.businessType`、`query.keyword` 可选 | `R<AppointmentPageVo>` | 今日预约列表 |
| `DELETE` | `/api/queue/pad/appointment/{appointmentId}` | 未接入 | `path.appointmentId` | `R<AppointmentActionVo>` | 取消预约 |
| `PUT` | `/api/queue/pad/appointment/{appointmentId}/status` | 未接入 | `path.appointmentId`、`query.status` | `R<AppointmentActionVo>` | 更新预约状态 |
| `GET` | `/api/queue/pad/appointment/statistics` | 未接入 | `query.date` 可选 | `R<AppointmentStatisticsVo>` | 预约统计 |
### 3.8 `pad.window`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/window/list` | `pad.window` + `/list` | `query.page` 默认 `1`、`query.size` 默认 `10``query.enabled`、`query.name` 可选 | `R<WindowPageVo>` | 窗口列表 |
| `GET` | `/api/queue/pad/window/monitor/list` | `pad.window` + `/monitor/list` | 无 | `R<WindowMonitorListVo>` | 窗口监控列表 |
| `POST` | `/api/queue/pad/window/create` | 未接入 | `body.name``body.sid` 可选但必须唯一;`body.enabled`、`body.ledAddress`、`body.ledText`、`body.rankMode`、`body.rankAddress` 可选 | `R<WindowActionVo>` | 创建窗口 |
| `PUT` | `/api/queue/pad/window/{windowId}` | 未接入 | `path.windowId`、`body.name`;其他字段同创建 | `R<WindowActionVo>` | 更新窗口 |
| `PUT` | `/api/queue/pad/window/{windowId}/status` | 未接入 | `path.windowId`、`query.enabled` | `R<WindowActionVo>` | 切换窗口状态 |
| `GET` | `/api/queue/pad/window/{windowId}` | 未接入 | `path.windowId` | `R<WindowDetailVo>` | 窗口详情 |
| `GET` | `/api/queue/pad/window/{windowId}/business` | 未接入 | `path.windowId` | `R<WindowBusinessListVo>` | 获取窗口业务关联 |
| `POST` | `/api/queue/pad/window/{windowId}/business` | 未接入 | `path.windowId``body` 为数组,元素至少要有 `businessId``priority`、`enabled` 可选 | `R<WindowActionVo>` | 设置窗口业务关联 |
| `POST` | `/api/queue/pad/window/{windowId}/led` | 未接入 | `path.windowId`、`query.text` | `R<WindowActionVo>` | LED 显示控制 |
| `DELETE` | `/api/queue/pad/window/{windowId}` | 未接入 | `path.windowId` | `R<WindowActionVo>` | 删除窗口 |
### 3.9 `tag.hallSystem`
> Swagger 中标签名是 `tag.hallSystem`,但 `/public` 网关调用时实际 tag 要传 `pad.hallSystem`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/pad/hallSystem/list` | `pad.hallSystem` + `/list` | `query.prefix` 可选 | `R<HallSystemListVo>` | 获取大厅配置列表 |
| `POST` | `/api/queue/pad/hallSystem/value` | `pad.hallSystem` + `/value` | `body.key``body.value` 可选 | `R<Boolean>` | 更新大厅配置 |
| `GET` | `/api/queue/pad/hallSystem/overview/metrics` | `pad.hallSystem` + `/overview/metrics``pad.queue` + `/overview/metrics` | 无 | `R<PadOverviewMetricsVo>` | 实时概览指标 |
| `GET` | `/api/queue/pad/hallSystem/overview/metrics/trend` | `pad.hallSystem` + `/overview/metrics/trend``pad.queue` + `/overview/metrics/trend` | `query.startTime`、`query.endTime` 可选,格式 `yyyy-MM-dd HH:mm:ss` | `R<PadOverviewMetricsTrendVo>` | 概览趋势 |
### 3.10 `pad.window` 配套 caller 窗口接口
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `GET` | `/api/queue/caller/windows/list` | 未接入 | `Header.Authorization` | `R<CallerWindowBindingResponse>` | 当前账号可绑定窗口列表 |
| `POST` | `/api/queue/caller/windows/select` | 未接入 | `Header.Authorization`、`body.windowUid` | `R<CallerWindowBindingResponse>` | 绑定窗口 |
### 3.11 `pad.callTerminal`
| 方法 | 请求地址 | 网关调用(tag/path) | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|---|
| `POST` | `/api/queue/caller/call-terminal/init` | 未接入 | `body.windowUid``body.empUid` 可选 | `R<CallTerminalActionResponse>` | 初始化评价器 |
| `POST` | `/api/queue/caller/call-terminal/call` | `pad.callTerminal` + `/call` | `body.windowUid``body.ticketUid`、`body.empUid` 可选 | `R<CallTerminalActionResponse>` | 叫号 |
| `POST` | `/api/queue/caller/call-terminal/recall` | 未接入 | `body.ticketUid``body.windowUid` 可选 | `R<CallTerminalActionResponse>` | 重呼 |
| `POST` | `/api/queue/caller/call-terminal/start` | 未接入 | `body.ticketUid``body.windowUid`、`body.empUid` 可选 | `R<CallTerminalActionResponse>` | 开始办理 |
| `POST` | `/api/queue/caller/call-terminal/complete` | 未接入 | `body.ticketUid` | `R<CallTerminalActionResponse>` | 办结 |
| `POST` | `/api/queue/caller/call-terminal/abandon` | 未接入 | `body.ticketUid` | `R<CallTerminalActionResponse>` | 弃号 |
| `POST` | `/api/queue/caller/call-terminal/transfer` | 未接入 | `body.ticketUid`、`body.targetWindowUid` | `R<CallTerminalActionResponse>` | 转移 |
| `POST` | `/api/queue/caller/call-terminal/pause` | 未接入 | `body.windowUid`、`body.empUid`、`body.pauseReason` | `R<CallTerminalActionResponse>` | 暂停窗口 |
| `POST` | `/api/queue/caller/call-terminal/resume` | 未接入 | `body.windowUid`、`body.empUid` | `R<CallTerminalActionResponse>` | 恢复窗口 |
| `POST` | `/api/queue/caller/call-terminal/evaluate` | 未接入 | `body.ticketUid`;如果提交评分,`body.rank` 必须在 `0-6` | `R<CallTerminalActionResponse>` | 发起评价或回写评价 |
| `GET` | `/api/queue/caller/call-terminal/is-rank` | 未接入 | `query.ticketUid` | `R<Map<String,Object>>` | 是否已评价 |
| `GET` | `/api/queue/caller/call-terminal/queue-count` | 未接入 | `query.windowUid` | `R<Map<String,Object>>` | 当前窗口排队人数 |
| `GET` | `/api/queue/caller/call-terminal/pool` | 未接入 | `query.winUid``query.keyword`、`query.keywords`、`query.status`、`query.page`、`query.size`、`query.pageSize`、`query.pagesize` 可选 | `R<CallTerminalTicketPoolResponse>` | 可叫号票池 |
`pad.callTerminal` 常见可选字段:
- `customText`
- `serviceUrl`
- `forward`
- `read`
- `driver`
- `clients`
- `rankUserName`
- `rankUserPhone`
- `idCard`
- `phone`
#### `pad.ticket` - 实名核验查询示例
请求示例:
```json
{
"name": "张",
"idCard": "3301",
"phone": "138",
"page": 1,
"size": 10
}
```
模糊查询规则:
- `name``LIKE %name%`
- `idCard``LIKE %idCard%`
- `phone``LIKE %phone%`
- 多个条件同时传入时按 `AND` 组合
- `takeTicketVerified` 复用当前取号实名校验逻辑,仅在传入 `idCard``phone` 时返回
- `faceComparePassed` 基于当前最新精确匹配记录的 `compareValue == 1` 判断
返回示例:
```json
{
"code": 200,
"msg": "查询成功",
"data": {
"page": 1,
"size": 10,
"total": 1,
"matched": true,
"takeTicketVerified": true,
"faceComparePassed": true,
"records": [
{
"uid": 1,
"ticketUid": 101,
"name": "张三",
"idCard": "330102199001011234",
"phone": "13800138000",
"compareCode": "IDC",
"compareValue": 1,
"compareResult": "verified",
"faceComparePassed": true,
"verifyTime": "2026-04-22T10:00:00"
}
]
}
}
```
## 4. `/public` 统一网关
### 4.1 入口
| 方法 | 请求地址 | 请求参数 | 返回值 | 备注 |
|---|---|---|---|---|
| `POST` | `/public` | 请求体为加密后的字符串;解密后结构见下文 | `PublicGatewayResponseVo<T>` | 统一网关入口 |
### 4.2 解密后的请求体结构
```json
{
"tag": "pad.ticket",
"map": {
"traceId": "202604210001",
"head": {
"method": "GET",
"contentType": "application/json",
"timestamp": 1710000000000,
"nonce": "n-1",
"signature": "hex-signature"
},
"path": "/list",
"query": {},
"body": {}
}
}
```
字段说明:
| 字段 | 说明 |
|---|---|
| `tag` | 路由标签,例如 `pad.auth`、`pad.ticket` |
| `map.traceId` | 链路跟踪号 |
| `map.head.method` | 请求方法;建议显式传 |
| `map.head.timestamp` | 签名时间戳 |
| `map.head.nonce` | 签名随机串 |
| `map.head.signature` | HMAC-SHA256 签名 |
| `map.path` | 子路径,例如 `/login`、`/list` |
| `map.query` | 查询参数对象 |
| `map.body` | 请求体对象 |
### 4.3 当前已接入 `/public` 的接口
| 网关 tag | `map.path` | 实际分发到的直连接口 | 直连接口返回 |
|---|---|---|---|
| `pad.auth` | `/login` | `POST /api/queue/pad/auth/login` | `R<LoginResponse>` |
| `pad.auth` | `/sign` | `POST /api/queue/pad/auth/sign` | `R<SignResponseVo>` |
| `pad.menu` | `/main` | `GET /api/queue/pad/menu/main` | `R<MenuPageVo>` |
| `pad.appointment` | `/time-slots` | `GET /api/queue/pad/appointment/time-slots` | `R<List<AppointmentTimeSlotVo>>` |
| `pad.hallSystem` | `/list` | `GET /api/queue/pad/hallSystem/list` | `R<HallSystemListVo>` |
| `pad.hallSystem` | `/value` | `POST /api/queue/pad/hallSystem/value` | `R<Boolean>` |
| `pad.hallSystem` | `/overview/metrics` | `GET /api/queue/pad/hallSystem/overview/metrics` | `R<PadOverviewMetricsVo>` |
| `pad.hallSystem` | `/overview/metrics/trend` | `GET /api/queue/pad/hallSystem/overview/metrics/trend` | `R<PadOverviewMetricsTrendVo>` |
| `pad.queue` | `/ticket/take` | `POST /api/queue/pad/ticket/take` | `R<TakeTicketVo>` |
| `pad.queue` | `/overview/metrics` | `GET /api/queue/pad/hallSystem/overview/metrics` | `R<PadOverviewMetricsVo>` |
| `pad.queue` | `/overview/metrics/trend` | `GET /api/queue/pad/hallSystem/overview/metrics/trend` | `R<PadOverviewMetricsTrendVo>` |
| `pad.business` | `/enabled` | `GET /api/queue/pad/business/enabled` | `R<List<Business>>` |
| `pad.ticket` | `/list` | `GET /api/queue/pad/ticket/list` | `R<TicketPageVo>` |
| `pad.window` | `/list` | `GET /api/queue/pad/window/list` | `R<WindowPageVo>` |
| `pad.window` | `/monitor/list` | `GET /api/queue/pad/window/monitor/list` | `R<WindowMonitorListVo>` |
| `pad.print` | `/printers` | `GET /api/queue/pad/print/printers` | `R<List<String>>` |
| `pad.print` | `/text` | `POST /api/queue/pad/print/text` | `R<Boolean>` |
| `pad.callTerminal` | `/call` | `POST /api/queue/caller/call-terminal/call` | `R<CallTerminalActionResponse>` |
### 4.4 `/public` 调用规则
- 除 `pad.auth /login``pad.auth /sign` 外,其余已接入网关的接口都需要 `map.head.timestamp`、`map.head.nonce`、`map.head.signature`
- `POST` 类接口建议显式传 `map.head.method`
- `pad.auth /sign` 在实际使用时还需要 Bearer Token
## 5. 常见返回字段说明
### 5.1 `LoginResponse`
主要字段:
- `access_token`
- `refresh_token`
- `tokenType`
- `expire_in`
- `refresh_expire_in`
- `client_id`
- `scope`
- `openid`
- `userInfo`
`userInfo` 主要字段:
- `id`
- `uid`
- `username`
- `realName`
- `role`
- `tenantId`
- `image`
### 5.2 `Business`
主要字段:
- `uid`
- `prefix`
- `name`
- `enabled`
- `type`
- `handleCount`
- `isSpecial`
### 5.3 `TakeTicketVo` / `TakeTicketResponse`
主要字段:
- `uid`
- `tktId`
- `tktIntid`
- `status`
- `bizUid`
- `bizName`
- `bizPrefix`
- `waitingCount`
- `waitingAhead`
- `estimatedWaitMinutes`
- `tktDate`
- `tktTime`
- `hallName`
- `windowNames`
- `message`
### 5.4 `TicketPageVo`
主要字段:
- `list`
- `total`
- `page`
- `size`
`list` 元素类型为 `UnifiedTicketVo`
### 5.5 `TicketStatusVo`
主要字段:
- `uid`
- `tktId`
- `status`
- `statusText`
- `waitingAhead`
- `estimatedWaitMinutes`
### 5.6 `TicketStatusDetailVo`
主要字段:
- `ticketNumber`
- `uid`
- `businessUid`
- `status`
- `rank`
- `customerName`
- `customerPhone`
- `generateTime`
- `windowId`
### 5.7 `WaitingCountVo`
主要字段:
- `waitingCount`
- `estimatedWaitMinutes`
- `bizUid`
### 5.8 `WindowPageVo`
主要字段:
- `list`
- `total`
- `page`
- `size`
- `enabled`
- `name`
### 5.9 `WindowActionVo`
主要字段:
- `windowId`
- `name`
- `ledAddress`
- `enabled`
- `createTime`
- `displayText`
- `businessCount`
### 5.10 `WindowDetailVo`
主要字段:
- `windowId`
- `name`
- `ledAddress`
- `ledText`
- `enabled`
- `sid`
- `rankMode`
- `rankAddress`
- `currentTicket`
- `todayServed`
### 5.11 `WindowBusinessListVo`
主要字段:
- `windowId`
- `list`
### 5.12 `WindowMonitorListVo`
主要字段:
- `list`
- `idleCount`
- `busyCount`
- `pausedCount`
### 5.13 `HallSystemListVo`
主要字段:
- `list`
- `total`
`list` 元素 `HallSystemItemVo` 主要字段:
- `key`
- `value`
- `memo`
### 5.14 `PadOverviewMetricsVo`
主要字段:
- `waitingCount`
- `todayAppointmentCount`
- `idleWindowCount`
- `idleKioskCount`
- `serverTime`
### 5.15 `PadOverviewMetricsTrendVo`
主要字段:
- `startTime`
- `endTime`
- `list`
### 5.16 `AppointmentPageVo`
主要字段:
- `list`
- `total`
- `page`
- `size`
### 5.17 `AppointmentActionVo`
主要字段:
- `appointmentId`
- `newStatus`
### 5.18 `AppointmentStatisticsVo`
主要字段:
- `totalAppointments`
- `todayAppointments`
- `completedAppointments`
- `cancelledAppointments`
- `timeSlotStats`
### 5.19 `CallerWindowBindingResponse`
主要字段:
- `locked`
- `needSelect`
- `selectedWindowUid`
- `selectedWindowCode`
- `selectedWindowName`
- `nextStep`
- `windows`
### 5.20 `CallTerminalActionResponse`
主要字段:
- `action`
- `success`
- `message`
- `ticketUid`
- `ticketNo`
- `ticketStatus`
- `ticketStatusText`
- `windowUid`
- `windowName`
- `resumeToken`
- `led`
- `taxerName`
- `taxerPhone`
- `waitSeconds`
### 5.21 `CallTerminalTicketPoolResponse`
主要字段:
- `winUid`
- `keyword`
- `status`
- `page`
- `size`
- `total`
- `returnedCount`
- `list`

@ -88,6 +88,14 @@
}
}
},
{
"path": "pages/mod/sign-priority",
"style": {
"app-plus": {
"popGesture": "none"
}
}
},
{
"path": "pages/queue/index",
"style": {

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
e<template>
<template>
<div class="page-container tn-gradient-bg__cool-5">
<cheader></cheader>
<div class="login-body">
@ -25,10 +25,25 @@ e<template>
<script lang="ts" setup>
import cheader from '@/components/header.vue'
import { userLogin } from '@/api/index.js'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
const username = ref('admin')
const password = ref('admin')
const LOGIN_USERNAME_KEY = 'login_username'
const LOGIN_PASSWORD_KEY = 'login_password'
const username = ref('')
const password = ref('')
onLoad(() => {
const savedUsername = uni.getStorageSync(LOGIN_USERNAME_KEY)
const savedPassword = uni.getStorageSync(LOGIN_PASSWORD_KEY)
if (savedUsername) {
username.value = savedUsername
}
if (savedPassword) {
password.value = savedPassword
}
})
const loginAction = async () => {
const loginParams = {
@ -40,9 +55,16 @@ e<template>
try {
const res = await userLogin(loginParams)
console.log(res)
uni.setStorage({key: 'token', data: res.access_token})
uni.setStorage({key: 'userInfo', data: JSON.stringify(res.userInfo)})
uni.navigateTo({
// profile token使 401
uni.setStorageSync('token', res.access_token)
if (res.refresh_token) {
uni.setStorageSync('refresh_token', res.refresh_token)
}
uni.setStorageSync('userInfo', JSON.stringify(res.userInfo))
uni.setStorageSync(LOGIN_USERNAME_KEY, username.value)
uni.setStorageSync(LOGIN_PASSWORD_KEY, password.value)
//
uni.reLaunch({
url: '/pages/index/index',
})
} catch (error) {

@ -1,16 +1,17 @@
<template>
<view class="page-container">
<div class="top-div">
<div></div>
<view class="top-actions">
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="refreshMonitorList">
<uni-icons type="refresh" size="16" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
</view>
</div>
<TopDivBar>
<template #right>
<view class="top-actions">
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="refreshMonitorList">
<uni-icons type="refresh" size="16" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px"></uni-icons>
</tn-button>
</view>
</template>
</TopDivBar>
<view class="content">
<!-- 窗口状态监控 -->
<view class="status-section">
@ -156,6 +157,7 @@
import { getMonitorList } from "@/api/window.js";
import { onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import TopDivBar from "@/components/TopDivBar.vue";
//
const windowPopup = ref();

@ -1,11 +1,6 @@
<template>
<view class="page-container">
<div class="top-div">
<div></div>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</div>
<TopDivBar @back="backToIndex" />
<view class="content">
<!-- 大厅参数管理 -->
<view class="params-section">
@ -86,6 +81,7 @@
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getSystemList, updateSysValue } from '@/api/system.js'
import TopDivBar from '@/components/TopDivBar.vue'
//
const hallParams = ref<any[]>([])

@ -1,175 +1,725 @@
<template>
<view class="push-message-page">
<div class="top-div">
<div></div>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</div>
<view class="page-content">
<!-- 推送目标选择 -->
<view class="section">
<view class="section-title">推送目标</view>
<tn-radio-group v-model="pushForm.targetType">
<tn-radio label="all" value="all">全部</tn-radio>
<tn-radio label="ios" value="ios">呼号端</tn-radio>
<tn-radio label="android" value="android">自助机</tn-radio>
<tn-radio label="specific" value="specific">综合屏</tn-radio>
</tn-radio-group>
<!-- 指定设备输入 -->
<view class="specific-device">
<tn-input v-model="pushForm.deviceId" placeholder="请输入设备ID" border></tn-input>
</view>
</view>
<!-- 消息内容编辑 -->
<view class="section">
<view class="section-title">推送内容</view>
<TnInput v-model="pushForm.title" placeholder="请输入推送标题" border class="input-field"></TnInput>
<TnInput v-model="pushForm.content" placeholder="请输入推送内容" border
class="textarea-field"></TnInput>
</view>
<!-- 推送按钮 -->
<view class="button-section">
<tn-button @click="handlePush" :loading="loading" class="push-button">
{{ loading ? '推送中...' : '立即推送' }}
</tn-button>
</view>
</view>
</view>
<view class="push-message-page">
<TopDivBar @back="backToIndex" />
<view class="page-body">
<view class="tabs-bar">
<tn-tabs
v-model="currentTabIndex"
:bottom-shadow="false"
:bar="false"
height="40px"
>
<TnTabsItem
v-for="(item, index) in tabsData"
:key="index"
:title="item.text"
@click="currentTabIndex = index"
/>
</tn-tabs>
</view>
<!-- 语音 -->
<view v-show="currentTabIndex === 0" class="tab-panel">
<view class="field">
<text class="field-label">要呼叫的票号</text>
<input
v-model="voiceForm.ticketNo"
class="field-input"
placeholder="请输入要呼叫的票号"
/>
</view>
<view class="field">
<text class="field-label">窗口</text>
<input
v-model="voiceForm.windowNo"
class="field-input"
placeholder="请输入窗口号或窗口名称"
/>
</view>
<view class="actions">
<tn-button
type="primary"
:loading="voiceSending"
@click="onVoiceSend"
>
发送呼叫
</tn-button>
</view>
</view>
<!-- 信息屏 -->
<view v-show="currentTabIndex === 1" class="tab-panel">
<view class="field">
<text class="field-label">推送目标</text>
<TnCheckboxGroup v-model="screenForm.targets" class="checkbox-row">
<TnCheckbox label="sync" size="lg" :border="true"
>同步屏</TnCheckbox
>
<TnCheckbox label="composite" size="lg" :border="true"
>综合屏</TnCheckbox
>
</TnCheckboxGroup>
</view>
<view class="field">
<text class="field-label">推送内容</text>
<textarea
v-model="screenForm.content"
class="field-textarea"
placeholder="请输入要推送到信息屏的内容"
:maxlength="500"
/>
</view>
<view class="actions">
<tn-button
type="primary"
:loading="screenSending"
@click="onScreenPush"
>
推送至信息屏
</tn-button>
</view>
</view>
<!-- 短信 -->
<view v-show="currentTabIndex === 2" class="tab-panel">
<view class="field">
<text class="field-label">目标手机号</text>
<input
v-model="smsForm.phone"
class="field-input"
type="number"
maxlength="11"
placeholder="请输入目标手机号码"
/>
</view>
<view class="field">
<text class="field-label">短信内容</text>
<textarea
v-model="smsForm.content"
class="field-textarea"
placeholder="请输入短信内容"
:maxlength="500"
/>
</view>
<view class="actions">
<tn-button type="primary" :loading="smsSending" @click="onSmsSend">
发送短信
</tn-button>
</view>
</view>
<!-- 小票模板 -->
<view
v-show="currentTabIndex === 3"
class="tab-panel ticket-template-panel"
>
<view v-if="templateLoading" class="template-loading"
>模板加载中...</view
>
<view
v-else-if="!printTemplateForm.sections.length"
class="template-loading"
>
暂无模板数据请点击重新加载
</view>
<view v-else class="ticket-template-layout">
<view class="ticket-editor">
<view class="section-list">
<view
v-for="(section, index) in printTemplateForm.sections"
:key="index"
class="section-card"
>
<view class="section-head">
<text class="section-type">{{
getSectionTypeLabel(section)
}}</text>
</view>
<template v-if="section.type === 'text'">
<view
v-if="isPlaceholderTextSection(section)"
class="section-field"
>
<text class="section-label">占位文本</text>
<text class="section-readonly">{{ section.content }}</text>
</view>
<view v-else class="section-field">
<text class="section-label">文本内容</text>
<input
v-model="section.content"
class="field-input section-input"
placeholder="请输入文本内容"
/>
</view>
<view class="section-field">
<text class="section-label">字号</text>
<view class="option-row">
<view
v-for="size in fontSizeOptions"
:key="size.value"
class="option-chip"
:class="{ active: section.fontSize === size.value }"
@click="section.fontSize = size.value"
>
{{ size.label }}
</view>
</view>
</view>
<view class="section-field">
<text class="section-label">对齐</text>
<view class="option-row">
<view
v-for="align in alignOptions"
:key="align.value"
class="option-chip"
:class="{ active: section.align === align.value }"
@click="section.align = align.value"
>
{{ align.label }}
</view>
</view>
</view>
<view
v-if="section.bold !== undefined"
class="section-field checkbox-field"
>
<TnCheckbox v-model="section.bold" size="lg" :border="true"
>加粗</TnCheckbox
>
</view>
</template>
<template v-else-if="section.type === 'field'">
<view class="section-field">
<text class="section-label">字段标识</text>
<text class="section-readonly">{{ section.key }}</text>
</view>
<view class="section-field">
<text class="section-label">显示标签</text>
<input
v-model="section.label"
class="field-input section-input"
placeholder="如:业务"
/>
</view>
<view class="section-field">
<text class="section-label">后缀</text>
<input
v-model="section.suffix"
class="field-input section-input"
placeholder="如: 人"
/>
</view>
<view class="section-field checkbox-field">
<TnCheckbox v-model="section.show" size="lg" :border="true"
>显示该字段</TnCheckbox
>
</view>
</template>
<view
v-else-if="section.type === 'line'"
class="section-line-tip"
>
分隔线不可编辑
</view>
</view>
</view>
</view>
<view class="ticket-preview-wrap">
<text class="preview-label">预览效果</text>
<view class="ticket-preview-ticket">
<rich-text :nodes="printPreviewHtml" />
</view>
<text class="preview-tip"
>预览数据为示例实际打印时按取号结果填充</text
>
</view>
</view>
<view class="actions template-actions">
<tn-button :loading="templateLoading" @click="loadPrintTemplate"
>重新加载</tn-button
>
<tn-button
type="primary"
:loading="templateSaving"
@click="onSavePrintTemplate"
>
保存模板
</tn-button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import {
ref,
reactive
} from 'vue'
import TnInput from '@/uni_modules/tuniaoui-vue3/components/input/src/input.vue'
//
const pushForm = reactive({
targetType: 'all',
deviceId: '',
title: '',
content: ''
})
const loading = ref(false)
//
const handlePush = () => {
//
if (!pushForm.title.trim()) {
uni.showToast({
title: '请输入推送标题',
icon: 'none'
})
return
}
if (!pushForm.content.trim()) {
uni.showToast({
title: '请输入推送内容',
icon: 'none'
})
return
}
if (pushForm.targetType === 'specific' && !pushForm.deviceId.trim()) {
uni.showToast({
title: '请输入设备ID',
icon: 'none'
})
return
}
//
uni.showModal({
title: '确认推送',
content: '确定要发送这条推送消息吗?',
success: (res) => {
if (res.confirm) {
sendPushMessage()
}
}
})
}
//
const sendPushMessage = () => {
console.log('pushing...')
}
const backToIndex = () => {
uni.navigateTo({
url: '/pages/index/index'
})
}
import { getPrintTemplate, savePrintTemplate } from "@/api/print.js";
import TopDivBar from "@/components/TopDivBar.vue";
import TnCheckboxGroup from "@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox-group.vue";
import TnCheckbox from "@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox.vue";
import TnTabsItem from "@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue";
import TnTabs from "@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs.vue";
import {
buildPrintPreviewData,
buildPrintTemplateSaveBody,
getDefaultPrintTemplate,
getMinimalPrintTemplateForTest,
isPlaceholderTextSection,
parsePrintTemplate,
PRINT_SAMPLE_DATA,
renderPrintTemplateHtml,
} from "@/utils/printTemplate.js";
import { computed, reactive, ref, watch } from "vue";
const DEFAULT_SMS_TEMPLATE =
"尊敬的纳税人:【办税服务厅】提醒您尽快办理相关业务。如有疑问请致电大厅咨询。感谢您的配合!";
const PRINT_TEMPLATE_TAB_INDEX = 3;
// true sections
const USE_MINIMAL_SECTIONS_FOR_SAVE_TEST = true;
const currentTabIndex = ref(0);
const tabsData = [
{ text: "语音" },
{ text: "信息屏" },
{ text: "短信" },
// { text: "" },
];
const fontSizeOptions = [
{ label: "小", value: 1 },
{ label: "中", value: 2 },
{ label: "大", value: 3 },
];
const alignOptions = [
{ label: "左", value: "left" },
{ label: "中", value: "center" },
{ label: "右", value: "right" },
];
const printTemplateForm = reactive({
cutPaper: true,
sections: [],
});
const templateLoading = ref(false);
const templateSaving = ref(false);
const printPreviewHtml = computed(() => {
const previewData = buildPrintPreviewData(
printTemplateForm,
PRINT_SAMPLE_DATA,
);
return renderPrintTemplateHtml(printTemplateForm, previewData);
});
const getSectionTypeLabel = (section) => {
if (section.type === "text") return "文本";
if (section.type === "field") return "字段";
if (section.type === "line") return "分隔线";
return section.type || "未知";
};
const applyPrintTemplate = (data) => {
const template = parsePrintTemplate(data);
if (!template.sections.length) {
return false;
}
printTemplateForm.cutPaper = template.cutPaper;
printTemplateForm.sections = JSON.parse(JSON.stringify(template.sections));
return true;
};
const loadPrintTemplate = async () => {
templateLoading.value = true;
try {
const data = await getPrintTemplate();
console.log("[print-template] getPrintTemplate 原始返回:", data);
console.log("[print-template] 返回详情:", JSON.stringify(data, null, 2));
const applied = applyPrintTemplate(data);
console.log(
"[print-template] 解析结果 applied:",
applied,
"sections:",
printTemplateForm.sections.length,
);
if (!applied) {
applyPrintTemplate(getDefaultPrintTemplate());
uni.showToast({
title: "模板解析失败,已使用默认模板",
icon: "none",
});
}
} catch (error) {
console.log("[print-template] 获取打印模板失败:", error);
applyPrintTemplate(getDefaultPrintTemplate());
uni.showToast({
title: "加载失败,已使用默认模板",
icon: "none",
});
} finally {
templateLoading.value = false;
}
};
const onSavePrintTemplate = async () => {
if (!printTemplateForm.sections.length) {
uni.showToast({ title: "模板区块不能为空", icon: "none" });
return;
}
templateSaving.value = true;
try {
const saveBody = USE_MINIMAL_SECTIONS_FOR_SAVE_TEST
? getMinimalPrintTemplateForTest()
: buildPrintTemplateSaveBody(printTemplateForm);
console.log(
"[print-template] 保存请求体(测试模式:",
USE_MINIMAL_SECTIONS_FOR_SAVE_TEST,
"):",
saveBody,
);
await savePrintTemplate(saveBody);
uni.showToast({
title: USE_MINIMAL_SECTIONS_FOR_SAVE_TEST ? "测试保存成功" : "保存成功",
icon: "success",
});
} catch (error) {
console.log("保存打印模板失败:", error);
} finally {
templateSaving.value = false;
}
};
watch(currentTabIndex, (index) => {
if (Number(index) === PRINT_TEMPLATE_TAB_INDEX) {
loadPrintTemplate();
}
});
const voiceForm = reactive({
ticketNo: "",
windowNo: "",
});
const voiceSending = ref(false);
const screenForm = reactive({
targets: [],
content: "",
});
const screenSending = ref(false);
const smsForm = reactive({
phone: "",
content: DEFAULT_SMS_TEMPLATE,
});
const smsSending = ref(false);
const backToIndex = () => {
uni.navigateBack({
fail: () => {
uni.navigateTo({ url: "/pages/index/index" });
},
});
};
const onVoiceSend = () => {
const ticket = voiceForm.ticketNo.trim();
const win = voiceForm.windowNo.trim();
if (!ticket) {
uni.showToast({ title: "请输入要呼叫的票号", icon: "none" });
return;
}
if (!win) {
uni.showToast({ title: "请输入窗口", icon: "none" });
return;
}
uni.showModal({
title: "确认发送",
content: `将呼叫票号「${ticket}」至窗口「${win}」,是否继续?`,
success: (res) => {
if (!res.confirm) return;
voiceSending.value = true;
try {
console.log("[voice-call]", { ticketNo: ticket, windowNo: win });
uni.showToast({ title: "呼叫指令已提交", icon: "success" });
} finally {
voiceSending.value = false;
}
},
});
};
const onScreenPush = () => {
if (!screenForm.targets.length) {
uni.showToast({ title: "请至少选择一种信息屏", icon: "none" });
return;
}
if (!screenForm.content.trim()) {
uni.showToast({ title: "请输入推送内容", icon: "none" });
return;
}
uni.showModal({
title: "确认推送",
content: "确定将内容推送到已选信息屏?",
success: (res) => {
if (!res.confirm) return;
screenSending.value = true;
try {
console.log("[screen-push]", {
targets: screenForm.targets,
content: screenForm.content,
});
uni.showToast({ title: "推送已提交", icon: "success" });
} finally {
screenSending.value = false;
}
},
});
};
const onSmsSend = () => {
const phone = smsForm.phone.trim();
if (!phone) {
uni.showToast({ title: "请输入手机号码", icon: "none" });
return;
}
if (!/^1\d{10}$/.test(phone)) {
uni.showToast({ title: "请输入11位有效手机号", icon: "none" });
return;
}
if (!smsForm.content.trim()) {
uni.showToast({ title: "请输入短信内容", icon: "none" });
return;
}
uni.showModal({
title: "确认发送",
content: `将向 ${phone} 发送短信,是否继续?`,
success: (res) => {
if (!res.confirm) return;
smsSending.value = true;
try {
console.log("[sms-send]", { phone, content: smsForm.content });
uni.showToast({ title: "短信已提交", icon: "success" });
} finally {
smsSending.value = false;
}
},
});
};
</script>
<style lang="scss" scoped>
.push-message-page {
min-height: 100vh;
background-color: #fff;
.top-div {
display: flex;
justify-content: space-between;
padding: 6vh 20px 1vh 20px;
}
.page-content {
padding: 10px;
}
.section {
background-color: #fff;
border-radius: 3px;
padding: 10px;
margin-bottom: 10px;
// box-shadow: 10px;
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #dddddd;
margin-bottom: 10px;
border-left: 8rpx solid 10px;
padding-left: 10px;
}
}
.specific-device {
margin-top: 10px;
}
.input-field {
margin-bottom: 10px;
}
.textarea-field {
margin-top: 10px;
}
.button-section {
margin-top: 20px;
text-align: center;
.push-button {
width: 80%;
height: 40px;
font-weight: 600;
}
}
}
/* 响应式设计 */
@media (min-width: 768px) {
.push-message-page {
.page-content {
max-width: 600px;
margin: 0 auto;
}
}
}
</style>
.push-message-page {
min-height: 100vh;
background-color: #f5f7fa;
}
.page-body {
padding: calc(6vh + 64px) 24px 24px;
max-width: 720px;
margin: 0 auto;
}
.ticket-template-panel {
max-width: none;
}
.ticket-template-layout {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.ticket-editor {
flex: 1;
min-width: 320px;
}
.ticket-preview-wrap {
width: 300px;
flex-shrink: 0;
}
.preview-label {
display: block;
font-size: 14px;
color: #374151;
font-weight: 500;
margin-bottom: 8px;
}
.ticket-preview-ticket {
background: #fff;
border: 1px dashed #d1d5db;
border-radius: 8px;
overflow: hidden;
min-height: 280px;
}
.preview-tip {
display: block;
margin-top: 8px;
font-size: 12px;
color: #9ca3af;
line-height: 1.5;
}
.template-loading {
padding: 40px 0;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.section-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
background: #fafafa;
}
.section-head {
margin-bottom: 10px;
}
.section-type {
font-size: 13px;
font-weight: 600;
color: #2563eb;
}
.section-field {
margin-bottom: 10px;
}
.section-label {
display: block;
font-size: 13px;
color: #6b7280;
margin-bottom: 6px;
}
.section-input {
height: 40px;
}
.section-readonly {
display: block;
font-size: 14px;
color: #111827;
padding: 8px 0;
}
.section-line-tip {
font-size: 13px;
color: #9ca3af;
}
.option-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.option-chip {
min-width: 48px;
height: 32px;
padding: 0 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #374151;
background: #fff;
}
.option-chip.active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.checkbox-field {
margin-bottom: 0;
}
.template-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tabs-bar {
background: #fff;
border-radius: 12px;
padding: 8px 12px;
margin-bottom: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.tab-panel {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.field {
margin-bottom: 20px;
}
.field-label {
display: block;
font-size: 14px;
color: #374151;
margin-bottom: 8px;
font-weight: 500;
}
.field-input {
width: 100%;
height: 44px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0 12px;
font-size: 15px;
box-sizing: border-box;
background: #fafafa;
}
.field-textarea {
width: 100%;
min-height: 140px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
font-size: 15px;
box-sizing: border-box;
background: #fafafa;
}
.checkbox-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.actions {
margin-top: 8px;
}
</style>

@ -0,0 +1,229 @@
<template>
<view class="page-container bg-white">
<TopDivBar @back="backToIndex">
<template #left>
<view class="tabs-wrap">
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false">
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text" />
</tn-tabs>
</view>
</template>
</TopDivBar>
<!-- 回签 -->
<view v-show="currentTabIndex === 0" class="panel">
<view class="form-item">
<text class="form-label">票号</text>
<input v-model="resumeForm.ticketNo" class="form-input" placeholder="请输入票号" />
</view>
<view class="actions">
<tn-button width="110px" height="36px" :plain="true" text-color="#0099ff" @click="scanTicket">
<uni-icons type="scan" size="16" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
<tn-button width="110px" height="36px" text-color="#fff" @click="confirmResume">
回签
</tn-button>
</view>
</view>
<!-- 优先 -->
<view v-show="currentTabIndex === 1" class="panel">
<view class="biz-grid">
<view
v-for="(item, index) in businessList"
:key="index"
class="biz-item"
@click="takePriorityTicket(item)"
>
<view class="biz-icon">{{ item.icon }}</view>
<view class="biz-name">{{ item.name }}</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import TopDivBar from "@/components/TopDivBar.vue";
import { getBizList } from "@/api/index.js";
import { createJumpTicket, resumeTicket } from "@/api/ticket.js";
import { onLoad } from "@dcloudio/uni-app";
import { reactive, ref } from "vue";
import TnTabs from "@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs.vue";
import TnTabsItem from "@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue";
const currentTabIndex = ref(0);
const tabsData = [
{ text: "回签" },
{ text: "优先" },
];
const resumeForm = reactive({
ticketNo: "",
});
const businessList = ref([]);
const backToIndex = () => {
uni.navigateBack({
fail: () => {
uni.navigateTo({ url: "/pages/index/index" });
},
});
};
const scanTicket = () => {
uni.scanCode({
success: (res) => {
resumeForm.ticketNo = String(res.result || "").trim();
if (!resumeForm.ticketNo) {
uni.showToast({ title: "未识别到票号", icon: "none" });
}
},
fail: () => {
uni.showToast({ title: "扫码失败", icon: "none" });
},
});
};
const confirmResume = () => {
const ticketNo = resumeForm.ticketNo.trim();
if (!ticketNo) {
uni.showToast({ title: "请输入票号", icon: "none" });
return;
}
uni.showModal({
title: "确认回签",
content: `确认将票号 ${ticketNo} 回签吗?`,
success: async (res) => {
if (!res.confirm) return;
try {
await resumeTicket({
resumeToken: ticketNo,
});
uni.showToast({ title: "回签成功", icon: "success" });
} catch (error) {
console.log("回签失败", error);
}
},
});
};
const loadBusinessList = async () => {
try {
const res = await getBizList();
const list = Array.isArray(res) ? res : [];
businessList.value = list.map((item, index) => ({
icon: item.icon || "🧾",
name: item.name || item.bizName || "",
value: item.value || item.uid || item.id || String(index),
}));
} catch (error) {
console.log("获取业务列表失败", error);
uni.showToast({ title: "获取业务列表失败", icon: "none" });
}
};
const takePriorityTicket = (item) => {
uni.showModal({
title: "确认优先取号",
content: `确认优先取号:${item.name}`,
success: async (res) => {
if (!res.confirm) return;
try {
const result = await createJumpTicket({
bizUid: item.value,
// 沿
idCard: "412827199805026017",
rankUserName: "尹朋虎",
rankUserPhone: "15382312786",
});
const tktId = result?.tktId || result?.ticketNumber || "";
uni.showToast({
title: tktId ? `取号成功:${tktId}` : "取号成功",
icon: "success",
});
} catch (error) {
console.log("优先取号失败", error);
}
},
});
};
onLoad(() => {
loadBusinessList();
});
</script>
<style lang="scss" scoped>
.page-container {
min-height: 100vh;
padding-top: calc(6vh + 64px);
}
.tabs-wrap {
width: 240rpx;
}
.bg-white {
background-color: #fff;
}
.panel {
padding: 14px 20px 20px;
}
.form-item {
margin-bottom: 14px;
}
.form-label {
display: block;
font-size: 14px;
color: #374151;
margin-bottom: 6px;
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 0 10px;
box-sizing: border-box;
}
.actions {
display: flex;
gap: 12px;
}
.biz-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.biz-item {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px 12px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.biz-icon {
font-size: 24px;
margin-bottom: 8px;
}
.biz-name {
font-size: 14px;
color: #111827;
text-align: center;
}
</style>

@ -1,490 +1,486 @@
<!-- pages/queue/business-select.vue -->
<template>
<view class="business-container">
<!-- 头部信息 -->
<!-- <view class="user-info">
<view class="business-container">
<!-- 头部信息 -->
<!-- <view class="user-info">
<text class="info-title">您好{{ userName }}</text>
<text class="info-subtitle">请选择需要办理的业务类型</text> -->
<view class="top-div">
<view></view>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="goBack">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</view>
<!-- </view> -->
<!-- 业务列表 -->
<scroll-view class="business-list" scroll-y>
<view class="business-grid">
<view v-for="(item, index) in businessList" :key="index" class="business-item"
:class="{ 'selected': selectedBusiness === item.value }" @click="selectBusiness(item)">
<view class="business-icon">{{ item.icon }}</view>
<view class="business-content">
<text class="business-name">{{ item.name }}</text>
<text class="business-desc">{{ item.description }}</text>
</view>
<view class="business-check" v-if="selectedBusiness === item.value">
<text></text>
</view>
</view>
</view>
</scroll-view>
<!-- 操作按钮 -->
<view class="action-buttons">
<button class="btn-back" @click="goBack"></button>
<button class="btn-confirm" :disabled="!selectedBusiness" @click="handleConfirm">
确认取号
</button>
</view>
<!-- 取号结果弹窗 -->
<uni-popup ref="resultPopup" type="center" :is-mask-click="false">
<view class="result-popup">
<view class="result-header">
<view class="result-close" @click="closeResultPopup">×</view>
<text class="result-icon"></text>
<text class="result-title">取号成功</text>
</view>
<view class="result-content">
<view class="ticket-info">
<text class="ticket-label">您的排队号码</text>
<text class="ticket-number">{{ ticketInfo.ticketNumber }}</text>
</view>
<view class="ticket-details">
<view class="detail-item">
<text class="detail-label">业务类型</text>
<text class="detail-value">{{ ticketInfo.businessName }}</text>
</view>
<view class="detail-item">
<text class="detail-label">取号时间</text>
<text class="detail-value">{{ ticketInfo.tktDate + ' ' + ticketInfo.tktTime }}</text>
</view>
<view class="detail-item">
<text class="detail-label">前方等候</text>
<text class="detail-value">{{ ticketInfo.waitingCount }}</text>
</view>
<!-- <view class="detail-item">
<TopDivBar @back="goBack" />
<!-- </view> -->
<!-- 业务列表 -->
<scroll-view class="business-list" scroll-y>
<view class="business-grid">
<view
v-for="(item, index) in businessList"
:key="index"
class="business-item"
:class="{ selected: selectedBusiness === item.value }"
@click="selectBusiness(item)"
>
<view class="business-icon">{{ item.icon }}</view>
<view class="business-content">
<text class="business-name">{{ item.name }}</text>
<text class="business-desc">{{ item.description }}</text>
</view>
<view class="business-check" v-if="selectedBusiness === item.value">
<text></text>
</view>
</view>
</view>
</scroll-view>
<!-- 操作按钮 -->
<view class="action-buttons">
<button class="btn-back" @click="goBack"></button>
<button
class="btn-confirm"
:disabled="!selectedBusiness"
@click="handleConfirm"
>
确认取号
</button>
</view>
<!-- 取号结果弹窗 -->
<uni-popup ref="resultPopup" type="center" :is-mask-click="false">
<view class="result-popup">
<view class="result-header">
<view class="result-close" @click="closeResultPopup">×</view>
<text class="result-icon"></text>
<text class="result-title">取号成功</text>
</view>
<view class="result-content">
<view class="ticket-info">
<text class="ticket-label">您的排队号码</text>
<text class="ticket-number">{{ ticketInfo.ticketNumber }}</text>
</view>
<view class="ticket-details">
<view class="detail-item">
<text class="detail-label">业务类型</text>
<text class="detail-value">{{ ticketInfo.businessName }}</text>
</view>
<view class="detail-item">
<text class="detail-label">取号时间</text>
<text class="detail-value">{{
ticketInfo.tktDate + " " + ticketInfo.tktTime
}}</text>
</view>
<view class="detail-item">
<text class="detail-label">前方等候</text>
<text class="detail-value">{{ ticketInfo.waitingCount }}</text>
</view>
<!-- <view class="detail-item">
<text class="detail-label">预计等候</text>
<text class="detail-value">{{ ticketInfo.waitingTime }}分钟</text>
</view> -->
<!-- <view class="detail-item">
<!-- <view class="detail-item">
<text class="detail-label">办理窗口</text>
<text class="detail-value">{{ ticketInfo.serviceWindow }}</text>
</view> -->
</view>
</view>
</view>
</view>
<view class="result-actions">
<!-- <button class="btn-print" @click="handlePrint"></button>
<view class="result-actions">
<!-- <button class="btn-print" @click="handlePrint"></button>
<button class="btn-notify" @click="handleNotify"></button> -->
</view>
</view>
</uni-popup>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad, onReady } from '@dcloudio/uni-app'
import { getBizList } from '@/api/index.js'
import { takeTicket } from '@/api/ticket.js'
const userName = ref('');
const selectedBusiness = ref('');
const businessList = ref([]);
const ticketInfo = ref({});
const resultPopup = ref(null);
//
const loadBusinessList = async () => {
try {
const res = await getBizList()
console.log(res)
//
const list = Array.isArray(res) ? res : []
businessList.value = list.map((item, index) => ({
icon: item.icon || '🧾',
name: item.name || item.bizName || '',
value: item.value || item.uid || item.id || String(index),
prefix: item.prefix || item.remark || ''
}))
} catch (error) {
console.error('获取业务列表失败:', error)
uni.showToast({
title: '获取业务列表失败',
icon: 'none'
})
//
businessList.value = []
}
}
//
onLoad((options) => {
userName.value = decodeURIComponent(options.name || '');
loadBusinessList()
});
//
const selectBusiness = (item) => {
selectedBusiness.value = item.value;
};
//
const goBack = () => {
uni.navigateBack();
};
//
const handleConfirm = async () => {
try {
//
const result = await takeTicket({
bizUid: selectedBusiness.value,
idCard: '412827199805026017',
rankUserName:'尹朋虎',
rankUserPhone:'15382312786'
});
// tktId
if (result && result.tktId && String(result.tktId).trim()) {
const currentBiz = businessList.value.find(b => b.value === selectedBusiness.value)
ticketInfo.value = {
ticketNumber: result.tktId,
businessName: result.bizName || currentBiz?.name || '',
waitingTime: result.estimatedWaitMinutes ?? result.waitingCount ?? 0,
serviceWindow: result.windowNames || '请等候叫号',
waitingCount: result.waitingCount,
tktDate:result.tktDate,
tktTime:result.tktTime
};
//
resultPopup.value.open();
} else {
uni.showToast({
title: '取号失败',
icon: 'none'
});
}
console.log(result)
} catch (error) {
uni.showToast({
title: error.message || '业务获取失败',
icon: 'none'
});
}
};
//
const handlePrint = async () => {
uni.showLoading({
title: '正在取号...'
});
try {
const result = await takeTicket({
businessType: selectedBusiness.value
});
// request
if (result) {
uni.showToast({
title: '取号成功',
icon: 'success'
});
}
} catch (error) {
uni.showToast({
title: error.message || '取号失败',
icon: 'none'
});
} finally {
uni.hideLoading();
}
};
//
const handleNotify = async () => {
uni.showLoading({
title: '正在推送...'
});
try {
const result = await sendNotification(ticketInfo.value);
if (result.code === 200) {
uni.showToast({
title: '消息已推送到手机',
icon: 'success'
});
}
} catch (error) {
uni.showToast({
title: error.message || '推送失败',
icon: 'none'
});
} finally {
uni.hideLoading();
}
};
//
const closeResultPopup = () => {
resultPopup.value?.close();
};
import { getBizList } from "@/api/index.js";
import { takeTicket } from "@/api/ticket.js";
import TopDivBar from "@/components/TopDivBar.vue";
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
const userName = ref("");
const selectedBusiness = ref("");
const businessList = ref([]);
const ticketInfo = ref({});
const resultPopup = ref(null);
//
const loadBusinessList = async () => {
try {
const res = await getBizList();
console.log(res);
//
const list = Array.isArray(res) ? res : [];
businessList.value = list.map((item, index) => ({
icon: item.icon || "🧾",
name: item.name || item.bizName || "",
value: item.value || item.uid || item.id || String(index),
prefix: item.prefix || item.remark || "",
}));
} catch (error) {
console.error("获取业务列表失败:", error);
uni.showToast({
title: "获取业务列表失败",
icon: "none",
});
//
businessList.value = [];
}
};
//
onLoad((options) => {
userName.value = decodeURIComponent(options.name || "");
loadBusinessList();
});
//
const selectBusiness = (item) => {
selectedBusiness.value = item.value;
};
//
const goBack = () => {
uni.navigateBack();
};
//
const handleConfirm = async () => {
try {
//
const result = await takeTicket({
bizUid: selectedBusiness.value,
idCard: "412827199805026017",
rankUserName: "尹朋虎",
rankUserPhone: "15382312786",
});
// tktId
if (result && result.tktId && String(result.tktId).trim()) {
const currentBiz = businessList.value.find(
(b) => b.value === selectedBusiness.value,
);
ticketInfo.value = {
ticketNumber: result.tktId,
businessName: result.bizName || currentBiz?.name || "",
waitingTime: result.estimatedWaitMinutes ?? result.waitingCount ?? 0,
serviceWindow: result.windowNames || "请等候叫号",
waitingCount: result.waitingCount,
tktDate: result.tktDate,
tktTime: result.tktTime,
};
//
resultPopup.value.open();
} else {
uni.showToast({
title: "取号失败",
icon: "none",
});
}
console.log(result);
} catch (error) {
uni.showToast({
title: error.message || "业务获取失败",
icon: "none",
});
}
};
//
const handlePrint = async () => {
uni.showLoading({
title: "正在取号...",
});
try {
const result = await takeTicket({
businessType: selectedBusiness.value,
});
// request
if (result) {
uni.showToast({
title: "取号成功",
icon: "success",
});
}
} catch (error) {
uni.showToast({
title: error.message || "取号失败",
icon: "none",
});
} finally {
uni.hideLoading();
}
};
//
const handleNotify = async () => {
uni.showLoading({
title: "正在推送...",
});
try {
const result = await sendNotification(ticketInfo.value);
if (result.code === 200) {
uni.showToast({
title: "消息已推送到手机",
icon: "success",
});
}
} catch (error) {
uni.showToast({
title: error.message || "推送失败",
icon: "none",
});
} finally {
uni.hideLoading();
}
};
//
const closeResultPopup = () => {
resultPopup.value?.close();
};
</script>
<style scoped>
.business-container {
padding: 40rpx 32rpx;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.user-info {
text-align: center;
margin-bottom: 40rpx;
display: flex;
}
.top-div {
display: flex;
justify-content: space-between;
background-color: #fff;
padding: 6vh 20px 10px 20px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-sizing: border-box;
}
.info-title {
font-size: 36rpx;
font-weight: bold;
color: #1a1a1a;
display: block;
margin-bottom: 12rpx;
}
.info-subtitle {
font-size: 26rpx;
color: #666;
display: block;
}
.business-list {
height: 80vh;
margin-bottom: 40rpx;
}
.business-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.business-item {
background: white;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
border: 2rpx solid #e0e0e0;
transition: all 0.3s ease;
}
.business-item.selected {
border-color: #007AFF;
background: #f0f7ff;
box-shadow: 0 4rpx 20rpx rgba(0, 122, 255, 0.15);
}
.business-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.business-content {
flex: 1;
}
.business-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.business-desc {
font-size: 24rpx;
color: #666;
display: block;
}
.business-check {
width: 40rpx;
height: 40rpx;
background: #007AFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
}
.action-buttons button {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
}
.btn-back {
background: #f0f0f0;
color: #666;
}
.btn-confirm {
background: linear-gradient(135deg, #007AFF 0%, #0056CC 100%);
color: white;
}
.btn-confirm:disabled {
background: #cccccc;
opacity: 0.6;
}
/* 取号结果弹窗 */
.result-popup {
width: 650rpx;
max-width: 90vw;
background: white;
border-radius: 24rpx;
overflow: hidden;
}
.result-header {
position: relative;
padding: 40rpx 40rpx 20rpx;
text-align: center;
background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
color: white;
}
.result-close {
position: absolute;
right: 24rpx;
top: 20rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
line-height: 48rpx;
text-align: center;
font-size: 40rpx;
color: #fff;
background: rgba(255, 255, 255, 0.2);
}
.result-icon {
font-size: 80rpx;
display: block;
margin-bottom: 16rpx;
}
.result-title {
font-size: 36rpx;
font-weight: bold;
display: block;
}
.result-content {
padding: 40rpx;
}
.ticket-info {
text-align: center;
margin-bottom: 40rpx;
padding-bottom: 40rpx;
border-bottom: 2rpx dashed #e0e0e0;
}
.ticket-label {
font-size: 28rpx;
color: #666;
display: block;
margin-bottom: 16rpx;
}
.ticket-number {
font-size: 72rpx;
font-weight: bold;
color: #007AFF;
display: block;
letter-spacing: 4rpx;
}
.ticket-details {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
font-size: 28rpx;
color: #666;
}
.detail-value {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.result-actions {
display: flex;
gap: 20rpx;
padding: 0 40rpx 40rpx;
}
.result-actions button {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
}
.btn-print {
background: #4CAF50;
color: white;
}
.btn-notify {
background: #2196F3;
color: white;
}
</style>
.business-container {
padding: 40rpx 32rpx;
min-height: 100vh;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.user-info {
text-align: center;
margin-bottom: 40rpx;
display: flex;
}
.info-title {
font-size: 36rpx;
font-weight: bold;
color: #1a1a1a;
display: block;
margin-bottom: 12rpx;
}
.info-subtitle {
font-size: 26rpx;
color: #666;
display: block;
}
.business-list {
height: 80vh;
margin-bottom: 40rpx;
}
.business-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.business-item {
background: white;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
border: 2rpx solid #e0e0e0;
transition: all 0.3s ease;
}
.business-item.selected {
border-color: #007aff;
background: #f0f7ff;
box-shadow: 0 4rpx 20rpx rgba(0, 122, 255, 0.15);
}
.business-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.business-content {
flex: 1;
}
.business-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.business-desc {
font-size: 24rpx;
color: #666;
display: block;
}
.business-check {
width: 40rpx;
height: 40rpx;
background: #007aff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24rpx;
}
.action-buttons {
display: flex;
gap: 20rpx;
}
.action-buttons button {
flex: 1;
height: 88rpx;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
}
.btn-back {
background: #f0f0f0;
color: #666;
}
.btn-confirm {
background: linear-gradient(135deg, #007aff 0%, #0056cc 100%);
color: white;
}
.btn-confirm:disabled {
background: #cccccc;
opacity: 0.6;
}
/* 取号结果弹窗 */
.result-popup {
width: 650rpx;
max-width: 90vw;
background: white;
border-radius: 24rpx;
overflow: hidden;
}
.result-header {
position: relative;
padding: 40rpx 40rpx 20rpx;
text-align: center;
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
color: white;
}
.result-close {
position: absolute;
right: 24rpx;
top: 20rpx;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
line-height: 48rpx;
text-align: center;
font-size: 40rpx;
color: #fff;
background: rgba(255, 255, 255, 0.2);
}
.result-icon {
font-size: 80rpx;
display: block;
margin-bottom: 16rpx;
}
.result-title {
font-size: 36rpx;
font-weight: bold;
display: block;
}
.result-content {
padding: 40rpx;
}
.ticket-info {
text-align: center;
margin-bottom: 40rpx;
padding-bottom: 40rpx;
border-bottom: 2rpx dashed #e0e0e0;
}
.ticket-label {
font-size: 28rpx;
color: #666;
display: block;
margin-bottom: 16rpx;
}
.ticket-number {
font-size: 72rpx;
font-weight: bold;
color: #007aff;
display: block;
letter-spacing: 4rpx;
}
.ticket-details {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-label {
font-size: 28rpx;
color: #666;
}
.detail-value {
font-size: 28rpx;
font-weight: 600;
color: #333;
}
.result-actions {
display: flex;
gap: 20rpx;
padding: 0 40rpx 40rpx;
}
.result-actions button {
flex: 1;
height: 80rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
}
.btn-print {
background: #4caf50;
color: white;
}
.btn-notify {
background: #2196f3;
color: white;
}
</style>

File diff suppressed because it is too large Load Diff

@ -1,14 +1,13 @@
<template>
<div class="page-container bg-white">
<div class="top-div">
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false">
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text"
@click="changeStatus(item)" />
</tn-tabs>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</div>
<TopDivBar @back="backToIndex">
<template #left>
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false">
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text"
@click="changeStatus(item)" />
</tn-tabs>
</template>
</TopDivBar>
<div class="tools-div">
<div style="width: 300px;">
<tn-input placeholder="请输入手机号搜索" height="32px" v-model="searchVal">
@ -82,6 +81,7 @@
import TnTabsItem from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue'
import TnCheckbox from '@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox.vue'
import TnCheckboxGroup from '@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox-group.vue'
import TopDivBar from '@/components/TopDivBar.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getAppointmentToday, getBizList } from '@/api/index.js'

@ -1,14 +1,13 @@
<template>
<div class="page-container bg-white">
<div class="top-div">
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false">
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text"
@click="changeStatus(item)" />
</tn-tabs>
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px;"></uni-icons>
</tn-button>
</div>
<TopDivBar @back="backToIndex">
<template #left>
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false">
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text"
@click="changeStatus(item)" />
</tn-tabs>
</template>
</TopDivBar>
<div class="tools-div">
<div style="width: 300px;">
<tn-input placeholder="请输入票号搜索" height="32px" v-model="searchVal">
@ -80,6 +79,7 @@
import TnTabsItem from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue'
import TnCheckbox from '@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox.vue'
import TnCheckboxGroup from '@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox-group.vue'
import TopDivBar from '@/components/TopDivBar.vue'
import { onLoad } from '@dcloudio/uni-app'
import { getdailyEntry, getBizList } from '@/api/index.js'

File diff suppressed because it is too large Load Diff

@ -0,0 +1,162 @@
/**
* 生成紫云智能导税 PAD 客户端测试用例 Excel
* 运行: npx --yes --package=xlsx node scripts/generate-testcases.mjs
*/
import { createRequire } from 'module'
import { writeFileSync } from 'fs'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const require = createRequire(import.meta.url)
const XLSX = require('xlsx')
const __dirname = dirname(fileURLToPath(import.meta.url))
const rootDir = join(__dirname, '..')
const HEADERS = [
'用例编号',
'模块',
'功能点',
'用例标题',
'前置条件',
'测试步骤',
'预期结果',
'优先级',
'用例类型',
'关联接口/页面',
]
/** @type {string[][]} */
const cases = [
// ========== 登录与认证 ==========
['TC-001', '登录', '账号登录', '正确账号密码登录成功', '网络正常;后端 pad.auth 可用', '1. 打开应用进入登录页\n2. 输入有效用户名和密码\n3. 点击「登录」', '登录成功Token 与用户信息写入本地;跳转智能导税首页', 'P0', '功能', 'pages/login/login.vue / pad.auth /login'],
['TC-002', '登录', '账号登录', '错误密码登录失败', '网络正常', '1. 输入正确用户名、错误密码\n2. 点击「登录」', '登录失败Toast 提示错误信息;停留在登录页', 'P0', '功能', 'pad.auth /login'],
['TC-003', '登录', '账号登录', '用户名为空时无法登录', '在登录页', '1. 用户名留空,输入密码\n2. 点击「登录」', '提示校验失败或接口返回错误;不跳转首页', 'P1', '边界', 'pages/login/login.vue'],
['TC-004', '登录', '自动回填', '再次进入登录页自动回填账号密码', '曾成功登录过', '1. 退出登录\n2. 重新进入登录页', '用户名、密码自动回填上次登录信息', 'P2', '功能', 'login_username / login_password 本地存储'],
['TC-005', '登录', 'Token', '业务请求携带 Bearer Token', '已登录', '1. 进入任意需鉴权页面触发请求\n2. 抓包或查看网络日志', '请求头含 Authorization: Bearer {token};除登录外均走签名', 'P0', '安全', 'utils/request.js'],
['TC-006', '登录', 'Token 刷新', 'Token 过期后自动刷新', '已登录access_token 过期', '1. 触发需鉴权接口\n2. 观察 401 处理', '自动用 refresh_token 刷新;刷新成功后重试原请求', 'P1', '功能', 'utils/request.js'],
// ========== 首页 ==========
['TC-010', '首页', '大厅概况', '首页指标正常展示', '已登录pad.hallSystem 可用', '1. 进入智能导税首页\n2. 查看左侧指标卡片', '显示当前等候人数、今日预约、空闲窗口、空闲自助机等数据', 'P0', '功能', 'pages/index/index.vue / pad.hallSystem /overview/metrics'],
['TC-011', '首页', '大厅概况', '指标约 30 秒自动刷新', '停留在首页', '1. 记录当前指标值\n2. 等待约 30 秒', '指标数据自动重新拉取并更新', 'P2', '功能', 'pages/index/index.vue'],
['TC-012', '首页', '导航', '点击指标跳转大厅详情', '在首页', '1. 点击任意概况指标卡片', '跳转至大厅详情页 hallInfo', 'P1', '功能', 'pages/mod/hallInfo.vue'],
['TC-013', '首页', '功能入口', '预检取号入口跳转', '在首页', '1. 点击「预检取号」', '进入 pages/queue/index', 'P0', '功能', 'pages/queue/index.vue'],
['TC-014', '首页', '功能入口', '票号管理入口跳转', '在首页', '1. 点击「票号管理」', '进入 pages/table/ticket', 'P0', '功能', 'pages/table/ticket.vue'],
['TC-015', '首页', '功能入口', '信息推送入口跳转', '在首页', '1. 点击「信息推送」', '进入 pages/mod/pushMessage', 'P1', '功能', 'pages/mod/pushMessage.vue'],
['TC-016', '首页', '功能入口', '回签优先入口跳转', '在首页', '1. 点击「回签优先」', '进入 pages/mod/sign-priority', 'P1', '功能', 'pages/mod/sign-priority.vue'],
['TC-017', '首页', '用户区', '退出登录', '已登录', '1. 点击右上角用户头像\n2. 选择退出登录', '清除登录态;跳转登录页', 'P0', '功能', 'pages/index/index.vue'],
// ========== 预检取号 ==========
['TC-020', '预检取号', '列表查询', '默认加载核验记录', '已登录;有核验数据', '1. 进入预检取号页', '自动请求 verify/search列表展示姓名、身份证脱敏、手机号脱敏、核验结果、核验时间', 'P0', '功能', 'pad.ticket /verify/search'],
['TC-021', '预检取号', '列表查询', '按姓名模糊查询', '在预检取号页', '1. 输入姓名关键字\n2. 点击「查询」', '返回匹配记录;分页信息正确', 'P0', '功能', 'searchTicketVerify'],
['TC-022', '预检取号', '列表查询', '按身份证模糊查询', '在预检取号页', '1. 输入身份证号片段\n2. 点击「查询」', '返回匹配记录', 'P1', '功能', 'searchTicketVerify'],
['TC-023', '预检取号', '列表查询', '按手机号模糊查询', '在预检取号页', '1. 输入手机号片段\n2. 点击「查询」', '返回匹配记录', 'P1', '功能', 'searchTicketVerify'],
['TC-024', '预检取号', '列表查询', '重置查询条件', '已填写查询条件', '1. 点击「重置」', '清空姓名/身份证/手机号;重新加载默认列表', 'P1', '功能', 'pages/queue/index.vue'],
['TC-025', '预检取号', '轮询刷新', '页面停留 15 秒自动刷新', '停留在预检取号页', '1. 进入页面后等待 15 秒', '列表自动重新请求;数据更新', 'P2', '功能', 'pages/queue/index.vue'],
['TC-026', '预检取号', '日期范围', '查询固定为当天至过去 3 天', '抓包或查看请求体', '1. 触发查询\n2. 检查 startDate/endDate', 'startDate 为 3 天前endDate 为当天', 'P2', '功能', 'searchTicketVerify body'],
['TC-027', '预检取号', '分页', '翻页加载数据', '总记录数大于每页条数', '1. 点击分页下一页', '展示对应页数据;当前页码与总数正确', 'P1', '功能', 'uni-pagination'],
['TC-028', '预检取号', '脱敏展示', '身份证手机号脱敏', '列表有数据', '1. 查看列表身份证、手机号列', '中间位以 * 脱敏显示', 'P1', '安全', 'maskIdCard / maskPhone'],
['TC-029', '预检取号', '核验结果', '展示 baishuiResp 文案', '记录含核验说明', '1. 查看核验结果列', '显示后端返回的人脸比对等说明文字', 'P1', '功能', 'baishuiResp'],
['TC-030', '预检取号', '人脸照片', '有 faceImg 时显示查看照片按钮', '记录 faceImg 有值', '1. 查看核验结果列', '显示「查看照片」按钮;与文案同一行不换行', 'P1', 'UI', 'pages/queue/index.vue .face-cell'],
['TC-031', '预检取号', '人脸照片', '无 faceImg 时不显示按钮', '记录 faceImg 为空', '1. 查看该行', '仅显示核验文案;无查看照片按钮', 'P2', '边界', 'hasFaceImg'],
['TC-032', '预检取号', '人脸照片', '点击查看照片弹窗展示', '记录有 uid 且后端有照片', '1. 点击「查看照片」\n2. 等待加载', '弹窗展示人脸图GET pad.ticket /face/image/{uid};使用 imageBase64', 'P0', '功能', 'getFaceImage'],
['TC-033', '预检取号', '人脸照片', '照片加载失败提示', '接口异常或无数据', '1. 点击「查看照片」', '显示加载失败或暂无照片;控制台有 face-image 日志', 'P1', '异常', 'utils/faceImage.js'],
['TC-034', '预检取号', '健康报告', '打开健康报告弹窗', '记录有身份证', '1. 点击「健康报告」', '请求 health-reopt/url弹窗内嵌展示报告页面', 'P0', '功能', 'pad.business /health-reopt/url'],
['TC-035', '预检取号', '健康报告', '无身份证时健康报告按钮禁用', '记录 idCard 为空', '1. 查看操作列', '健康报告按钮 disabled', 'P2', '边界', 'pages/queue/index.vue'],
['TC-036', '预检取号', '取号', '选择业务类型取号成功', '有效核验记录', '1. 点击「取号」\n2. 选择业务类型', '调用 take 接口;提示票号、等候、窗口等信息;列表刷新', 'P0', '功能', 'pad.ticket /take'],
['TC-037', '预检取号', '取号', '无手机号时先补录再取号', '记录 phone 为空', '1. 点击「取号」\n2. 在弹窗输入手机号并确定\n3. 选择业务', '先弹出手机号补录;补录后正常取号', 'P0', '功能', 'confirmPhoneForTakeTicket'],
['TC-038', '预检取号', '预约签到', '预约签到确认', '记录有预约信息', '1. 点击「预约签到」\n2. 查看预约信息\n3. 确认签到', '展示姓名/身份证/手机号/时间段;签到成功提示', 'P1', '功能', 'pages/queue/index.vue'],
['TC-039', '预检取号', '空数据', '无记录时空状态', '查询无结果', '1. 输入不存在条件查询', '表格显示「暂无更多数据」', 'P2', '边界', 'uni-table emptyText'],
// ========== 业务选择取号 ==========
['TC-040', '业务选择', '业务列表', '展示可办业务网格', '从录入流程进入 business-select', '1. 进入业务选择页', '动态加载 pad.business /enabled 业务列表;展示图标与名称', 'P1', '功能', 'pages/queue/business-select.vue'],
['TC-041', '业务选择', '取号', '选择业务确认取号', '已录入纳税人信息', '1. 点击某业务\n2. 点击确认取号', '取号成功弹窗展示排队号、业务、等候、窗口等', 'P1', '功能', 'takeTicket'],
['TC-042', '业务选择', '导航', '返回修改', '在业务选择页', '1. 点击「返回修改」', '返回上一页修改信息', 'P2', '功能', 'business-select.vue'],
// ========== 票号管理 ==========
['TC-050', '票号管理', 'Tab 筛选', '企业/个人 Tab 切换', '进入票号管理', '1. 切换一级 Tab\n2. 切换二级状态 Tab', '列表按 Tab 条件刷新', 'P0', '功能', 'pages/table/ticket.vue'],
['TC-051', '票号管理', '筛选', '按日期时间筛选', '在票号管理页', '1. 选择日期时间范围\n2. 点击查询', '返回对应时间段票号', 'P1', '功能', 'pad.ticket /list'],
['TC-052', '票号管理', '筛选', '按业务类型/票号关键字筛选', '在票号管理页', '1. 设置筛选条件\n2. 查询', '列表符合筛选条件', 'P1', '功能', 'getTicket'],
['TC-053', '票号管理', '详情', '点击票号查看详情', '列表有数据', '1. 点击票号链接', '弹出票号详情', 'P1', '功能', 'showDetail'],
['TC-054', '票号管理', '导税', '导税操作', '等候中票号', '1. 执行导税相关操作', '跳转导税模块或完成导税流程', 'P1', '功能', 'pages/mod/guidance.vue'],
['TC-055', '票号管理', '优先呼叫', '优先呼叫确认', '支持优先呼叫的票号', '1. 点击优先呼叫\n2. 确认', '操作成功并刷新列表', 'P2', '功能', 'confirmPriorityCall'],
['TC-056', '票号管理', '健康报告', '票号管理查看健康报告', '票号关联纳税人', '1. 打开健康报告入口', '正常展示税务健康检查报告', 'P2', '功能', 'TaxHealthReport 组件'],
// ========== 今日进厅 / 今日预约 ==========
['TC-060', '今日进厅', '列表', '进厅数据查询与 Tab 筛选', '从入口进入 dailyEntry', '1. 切换状态 Tab\n2. 点击查询', '展示进厅统计数据列表', 'P2', '功能', 'pages/table/dailyEntry.vue / pad.hallSystem /list'],
['TC-061', '今日预约', '列表', '预约数据查询', '从入口进入 appointment', '1. 切换状态 Tab\n2. 查询', '展示预约列表;支持筛选', 'P2', '功能', 'pages/table/appointment.vue / pad.appointment /time-slots'],
// ========== 大厅详情 ==========
['TC-070', '大厅详情', '窗口监控', '窗口状态列表展示', '进入 hallInfo', '1. 查看人工窗口列表', '展示窗口名称、业务、当前票号、状态、等候人数', 'P0', '功能', 'pad.window /monitor/list'],
['TC-071', '大厅详情', '设备监控', '自助设备状态展示', '进入 hallInfo', '1. 查看自助办税设备区', '展示设备名称与状态(空闲/使用中/维护中)', 'P1', '功能', 'hallInfo.vue'],
['TC-072', '大厅详情', '刷新', '手动刷新监控数据', '在大厅详情页', '1. 点击刷新按钮', '重新拉取 monitor 数据并更新界面', 'P1', '功能', 'refreshMonitorList'],
['TC-073', '大厅详情', '窗口操作', '点击窗口弹窗与操作', '有窗口数据', '1. 点击某窗口\n2. 在弹窗执行操作', '弹出窗口详情;操作符合后端能力', 'P2', '功能', 'handleWindowClick'],
// ========== 大厅控制 ==========
['TC-080', '大厅控制', '参数列表', '系统参数表格展示', '进入 hallManagment', '1. 查看参数表格', '展示参数名称、Key、当前值', 'P0', '功能', 'pad.hallSystem /list'],
['TC-081', '大厅控制', '参数编辑', '修改参数值成功', '有可编辑参数', '1. 点击编辑\n2. 修改值\n3. 确认保存', '二次确认后 POST /value列表自动刷新', 'P0', '功能', 'updateSysValue'],
['TC-082', '大厅控制', '参数编辑', 'Key 与名称只读', '打开编辑弹窗', '1. 查看编辑表单', 'Key、参数名称不可编辑仅值可改', 'P2', 'UI', 'hallManagment.vue'],
['TC-083', '大厅控制', '参数编辑', '取消编辑不保存', '编辑弹窗打开', '1. 修改值后关闭弹窗', '不调用保存接口;列表不变', 'P2', '功能', 'closeEditPopup'],
// ========== 回签优先 ==========
['TC-090', '回签优先', '回签', '输入票号回签成功', '有效等候票号', '1. 输入票号\n2. 确认回签', '二次确认后调用 resume成功提示', 'P0', '功能', 'pad.ticket /resume'],
['TC-091', '回签优先', '回签', '票号为空提示', '在回签 Tab', '1. 不输入票号点击回签', 'Toast「请输入票号」', 'P1', '边界', 'sign-priority.vue'],
['TC-092', '回签优先', '扫码', '扫码填充票号', '设备支持扫码', '1. 点击扫码\n2. 扫描有效二维码', '票号输入框自动填入扫描结果', 'P1', '功能', 'uni.scanCode'],
['TC-093', '回签优先', '优先取号', '选择业务优先取号', '在优先 Tab业务列表已加载', '1. 点击某业务类型', '调用 create-jump返回优先票号信息', 'P0', '功能', 'pad.ticket /create-jump'],
// ========== 信息推送 ==========
['TC-100', '信息推送', '语音呼叫', '发送语音呼叫', '在语音 Tab', '1. 输入票号与窗口\n2. 点击发送呼叫', '请求成功Toast 提示', 'P1', '功能', 'pushMessage Tab0'],
['TC-101', '信息推送', '信息屏', '推送至同步屏/综合屏', '在信息屏 Tab', '1. 勾选目标屏\n2. 输入内容\n3. 推送', '推送成功;内容不超过 500 字', 'P1', '功能', 'pushMessage Tab1'],
['TC-102', '信息推送', '短信', '发送短信', '在短信 Tab', '1. 输入 11 位手机号与内容\n2. 发送', '发送成功或后端返回明确结果', 'P1', '功能', 'pushMessage Tab2'],
['TC-103', '信息推送', '短信', '手机号格式校验', '在短信 Tab', '1. 输入非法手机号\n2. 发送', '前端校验或后端拒绝并提示', 'P2', '边界', 'smsForm.phone maxlength=11'],
// ========== 小票模板 ==========
['TC-110', '小票模板', '加载', '进入 Tab 自动加载模板', '进入信息推送-小票模板 Tab', '1. 切换到第 4 个 Tab', 'GET pad.print /template解析 sections 展示', 'P0', '功能', 'getPrintTemplate / parsePrintTemplate'],
['TC-111', '小票模板', '编辑', '修改 section 样式', '模板已加载', '1. 修改字号、对齐等样式\n2. 占位符 {{title}}/{{ticketNo}} 不可改文本', '样式可改;占位文本只读', 'P1', '功能', 'pushMessage.vue sections'],
['TC-112', '小票模板', '保存', '保存模板成功', '已修改模板', '1. 点击保存\n2. 确认', 'POST pad.print /templatebody 含 cutPaper 与 sections提示成功', 'P0', '功能', 'savePrintTemplate / buildPrintTemplateSaveBody'],
['TC-113', '小票模板', '保存', '保存失败签名或网关错误', '后端未注册或签名异常', '1. 点击保存', 'Toast 提示失败;控制台可见 traceId 与错误码', 'P1', '异常', 'pad.print POST /template'],
['TC-114', '小票模板', '刷新', '手动重新加载模板', '在小票模板 Tab', '1. 点击重新加载', '重新请求并覆盖当前编辑内容', 'P2', '功能', 'loadPrintTemplate'],
// ========== HTTP / 签名 / 通用 ==========
['TC-120', '请求层', '网关', '统一 POST /public 入口', '已登录业务请求', '1. 触发任意业务 API', '请求 URL 为公网 /publicbody 含 tag、map、signature 等', 'P0', '接口', 'utils/request.js'],
['TC-121', '请求层', '签名', 'HMAC-SHA256 签名流程', '非 skipSign 请求', '1. 先调 pad.sign\n2. 再发业务请求', 'query/body 深拷贝排序后参与签名;含 timestamp、nonce', 'P0', '安全', 'sortKeysDeep / pad.sign'],
['TC-122', '请求层', '响应', 'code=200 视为成功', '接口正常', '1. 调用成功接口', 'resolve dataToast 不报错', 'P0', '接口', 'handleResponse'],
['TC-123', '请求层', '响应', '业务失败 Toast 提示', '接口返回 code!=200', '1. 触发失败场景', 'uni.showToast 显示 msgPromise reject', 'P1', '异常', 'handleResponse'],
['TC-124', '请求层', '响应', 'data 为 null 返回完整响应', '特定接口 data=null', '1. 调用该接口', '返回完整 response 对象而非 null', 'P2', '接口', 'handleResponse'],
['TC-125', '请求层', '追踪', '每次请求含 traceId', '任意请求', '1. 查看请求日志', '每次请求生成唯一 traceId 便于排查', 'P2', '可维护', 'utils/request.js'],
// ========== UI / 兼容 ==========
['TC-130', 'UI', '横屏', '页面横屏布局正常', 'PAD 横屏模式', '1. 浏览各主要页面', '布局无严重错位globalStyle pageOrientation=landscape', 'P1', 'UI', 'pages.json'],
['TC-131', 'UI', '导航', 'TopDivBar 返回', '子页面', '1. 点击返回', '返回上一页或首页', 'P1', 'UI', 'TopDivBar.vue'],
['TC-132', '兼容', '多端', 'H5/Electron 人脸照片展示', 'Electron 或 H5 环境', '1. 查看人脸照片', '优先使用原生 img + Blob/dataUrl图片正常显示', 'P1', '兼容', 'faceImage.js useNativeImg'],
['TC-133', '兼容', '多端', 'App 端人脸照片展示', 'Android/iOS App', '1. 查看人脸照片', 'base64ToTempFilePath 转本地路径后 image 组件展示', 'P1', '兼容', 'utils/faceImage.js'],
// ========== 网格员 / 预留 ==========
['TC-140', '网格员', '入口', '网格员入口可进入', '首页有网格员入口', '1. 点击网格员', '进入 manager 页面或预留提示', 'P3', '功能', 'pages/mod/manager.vue'],
['TC-141', '多合一', '预留', '多合一模块可访问', '有入口时', '1. 进入 multiInOne', '页面正常加载;查询等功能可用', 'P3', '功能', 'pages/mod/multiInOne.vue'],
]
const sheetData = [HEADERS, ...cases]
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet(sheetData)
// 列宽
ws['!cols'] = [
{ wch: 10 },
{ wch: 12 },
{ wch: 12 },
{ wch: 28 },
{ wch: 24 },
{ wch: 40 },
{ wch: 36 },
{ wch: 8 },
{ wch: 10 },
{ wch: 32 },
]
XLSX.utils.book_append_sheet(wb, ws, '测试用例')
const outPath = join(rootDir, '测试用例-紫云智能导税.xlsx')
XLSX.writeFile(wb, outPath)
console.log('已生成:', outPath)
console.log('用例总数:', cases.length)

@ -0,0 +1,129 @@
export const normalizeBase64Pure = (input) => {
let text = String(input ?? '').trim().replace(/\s/g, '')
if (!text) return ''
if (/^data:image\/[\w+.-]+;base64,/i.test(text)) {
text = text.replace(/^data:image\/[\w+.-]+;base64,/i, '')
}
return text
}
/** 补齐 padding并兼容 URL-safe Base64 */
export const fixBase64ForDecode = (pure) => {
let text = String(pure ?? '').replace(/-/g, '+').replace(/_/g, '/')
const mod = text.length % 4
if (mod === 2) text += '=='
else if (mod === 3) text += '='
return text
}
export const getMimeTypeFromBase64 = (pure) => {
if (pure.startsWith('iVBORw0KGgo')) return 'image/png'
return 'image/jpeg'
}
export const extractImageBase64FromResponse = (result) => {
if (result === undefined || result === null) return ''
if (typeof result === 'string') return result
if (result.imageBase64) return result.imageBase64
if (result.data !== undefined && result.data !== null) {
return extractImageBase64FromResponse(result.data)
}
return ''
}
export const isWebPlatform = () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return false
}
try {
const sys = uni.getSystemInfoSync()
return sys.uniPlatform === 'web' || sys.uniPlatform === 'h5'
} catch {
return true
}
}
const decodeBase64ToBytes = (pure) => {
const normalized = fixBase64ForDecode(pure)
const binary = atob(normalized)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
const createBlobUrlFromBase64 = (pure, mime) => {
const bytes = decodeBase64ToBytes(pure)
const blob = new Blob([bytes], { type: mime })
const blobUrl = URL.createObjectURL(blob)
return {
src: blobUrl,
revoke: () => URL.revokeObjectURL(blobUrl),
}
}
const createDataUrlFromBase64 = (pure, mime) => {
const normalized = fixBase64ForDecode(pure)
return {
src: `data:${mime};base64,${normalized}`,
revoke: null,
}
}
const tryWebDisplay = (pure, mime, resolve) => {
if (typeof window !== 'undefined' && typeof atob === 'function') {
try {
const blobResult = createBlobUrlFromBase64(pure, mime)
console.log('[face-image] 使用 Blob URL 展示')
resolve(blobResult)
return
} catch (error) {
console.log('[face-image] Blob URL 创建失败,回退 dataUrl', error)
}
}
console.log('[face-image] 使用 dataUrl 展示')
resolve(createDataUrlFromBase64(pure, mime))
}
/**
* Base64 转为可在页面中展示的图片地址
*/
export const loadFaceImageDisplaySrc = (base64Raw) => {
return new Promise((resolve, reject) => {
const pure = normalizeBase64Pure(base64Raw)
if (!pure) {
reject(new Error('图片 Base64 为空'))
return
}
const mime = getMimeTypeFromBase64(pure)
console.log('[face-image] base64 前缀:', pure.slice(0, 24), 'mime:', mime)
if (isWebPlatform()) {
tryWebDisplay(pure, mime, resolve)
return
}
if (typeof uni.base64ToTempFilePath === 'function') {
uni.base64ToTempFilePath({
base64Data: fixBase64ForDecode(pure),
extension: mime === 'image/png' ? 'png' : 'jpg',
success: (res) => {
console.log('[face-image] 使用临时文件展示:', res.tempFilePath)
resolve({
src: res.tempFilePath,
revoke: null,
})
},
fail: (err) => {
console.log('[face-image] base64ToTempFilePath 失败', err)
tryWebDisplay(pure, mime, resolve)
},
})
return
}
tryWebDisplay(pure, mime, resolve)
})
}

@ -0,0 +1,220 @@
const FONT_SIZE_MAP = {
1: '14px',
2: '18px',
3: '28px',
}
const escapeHtml = (value) => {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const replacePlaceholders = (content, data) => {
return String(content || '').replace(/\{\{(\w+)\}\}/g, (_, key) => {
const val = data[key]
return val !== undefined && val !== null ? String(val) : ''
})
}
export const PLACEHOLDER_TEXT_CONTENTS = ['{{title}}', '{{ticketNo}}']
export const isPlaceholderTextSection = (section) => {
if (section?.type !== 'text') return false
const content = String(section.content || '').trim()
return PLACEHOLDER_TEXT_CONTENTS.includes(content)
}
const normalizeTextSection = (section) => {
const normalized = {
type: 'text',
content: String(section.content || ''),
fontSize: Number(section.fontSize) || 1,
align: section.align || 'left',
}
if (section.bold) {
normalized.bold = true
}
return normalized
}
const normalizeFieldSection = (section) => {
const normalized = {
type: 'field',
key: String(section.key || ''),
label: String(section.label || ''),
show: section.show !== false,
}
if (section.suffix) {
normalized.suffix = String(section.suffix)
}
return normalized
}
const normalizeSection = (section) => {
if (!section || !section.type) return null
if (section.type === 'line') {
return { type: 'line' }
}
if (section.type === 'text') {
return normalizeTextSection(section)
}
if (section.type === 'field') {
return normalizeFieldSection(section)
}
return null
}
export const normalizePrintTemplate = (template) => {
const sections = Array.isArray(template?.sections) ? template.sections : []
return {
cutPaper: template?.cutPaper !== false,
sections: sections.map(normalizeSection).filter(Boolean),
}
}
export const parsePrintTemplate = (raw) => {
console.log('[print-template] parsePrintTemplate 输入:', raw)
if (raw === undefined || raw === null) {
console.log('[print-template] 输入为空')
return normalizePrintTemplate({ sections: [], cutPaper: true })
}
if (typeof raw === 'string') {
const text = raw.trim()
if (!text) {
return normalizePrintTemplate({ sections: [], cutPaper: true })
}
try {
return parsePrintTemplate(JSON.parse(text))
} catch (error) {
console.log('[print-template] JSON 字符串解析失败:', error, text)
return normalizePrintTemplate({ sections: [], cutPaper: true })
}
}
if (Array.isArray(raw.sections)) {
const result = normalizePrintTemplate(raw)
console.log('[print-template] 直接解析 sections数量:', result.sections.length)
return result
}
if (typeof raw.msg === 'string' && raw.msg.trim()) {
try {
const parsed = JSON.parse(raw.msg)
if (parsed && Array.isArray(parsed.sections)) {
const result = normalizePrintTemplate(parsed)
console.log('[print-template] 从 msg 解析sections 数量:', result.sections.length)
return result
}
} catch (error) {
console.log('[print-template] msg JSON 解析失败:', error, raw.msg)
}
}
if (raw.data !== undefined && raw.data !== null) {
console.log('[print-template] 尝试解析 data 字段:', raw.data)
return parsePrintTemplate(raw.data)
}
console.log('[print-template] 未能识别模板结构,可用字段:', Object.keys(raw))
return normalizePrintTemplate({ sections: [], cutPaper: true })
}
// 保存/获取统一格式:{ cutPaper, sections }
export const buildPrintTemplateSaveBody = (template) => {
return normalizePrintTemplate(template)
}
// 极小模板,用于排查 sections 过大是否导致签名/网关异常
export const getMinimalPrintTemplateForTest = () =>
normalizePrintTemplate({
cutPaper: true,
sections: [
{ type: 'text', content: '测试', fontSize: 1, align: 'center' },
],
})
export const PRINT_SAMPLE_DATA = {
title: '国家税务总局',
ticketNo: 'A001',
businessType: '综合业务',
waitingCount: 5,
windowNumber: '3号窗口',
}
export const getDefaultPrintTemplate = () =>
normalizePrintTemplate({
cutPaper: true,
sections: [
{ type: 'text', content: '{{title}}', fontSize: 2, bold: true, align: 'center' },
{ type: 'line' },
{ type: 'text', content: '{{ticketNo}}', fontSize: 3, bold: true, align: 'center' },
{ type: 'line' },
{ type: 'field', key: 'businessType', label: '业务', show: true },
{ type: 'field', key: 'waitingCount', label: '等候', show: true, suffix: ' 人' },
{ type: 'field', key: 'windowNumber', label: '地点', show: true },
{ type: 'line' },
{ type: 'text', content: '请留意广播和显示屏叫号', fontSize: 1, align: 'center' },
],
})
export const buildPrintPreviewData = (template, context = {}) => {
return {
title: context.title ?? '',
ticketNo: context.ticketNo ?? '——',
businessType: context.businessType ?? context.businessName ?? '-',
waitingCount: context.waitingCount ?? 0,
windowNumber: context.windowNumber ?? '-',
rankUserName: context.rankUserName ?? '',
...context,
}
}
export const renderPrintTemplateHtml = (template, data = {}) => {
if (!template || !Array.isArray(template.sections)) {
return '<p style="text-align:center;color:#999;">暂无打印模板</p>'
}
const parts = [
'<div style="font-family:SimHei,monospace;padding:16px 12px;background:#fff;color:#111;">',
]
template.sections.forEach((section) => {
if (section?.show === false) return
if (section.type === 'line') {
parts.push('<div style="border-top:1px dashed #999;margin:10px 0;"></div>')
return
}
if (section.type === 'text') {
const text = replacePlaceholders(section.content, data)
const fontSize = FONT_SIZE_MAP[section.fontSize] || FONT_SIZE_MAP[1]
const fontWeight = section.bold ? 'bold' : 'normal'
const textAlign = section.align || 'left'
parts.push(
`<p style="margin:6px 0;font-size:${fontSize};font-weight:${fontWeight};text-align:${textAlign};line-height:1.4;">${escapeHtml(text)}</p>`
)
return
}
if (section.type === 'field') {
const value = data[section.key]
const display =
value !== undefined && value !== null && value !== '' ? String(value) : '-'
const suffix = section.suffix || ''
const label = section.label || ''
parts.push(
`<p style="margin:6px 0;font-size:14px;line-height:1.5;"><span>${escapeHtml(label)}</span><span>${escapeHtml(display)}${escapeHtml(suffix)}</span></p>`
)
}
})
parts.push('</div>')
return parts.join('')
}

@ -2,12 +2,14 @@ import { ref } from 'vue'
// 公网统一入口POST http://<gateway-host>/public
// 这里留一个可配置的占位符,实际项目中建议从配置文件或环境变量读取
const baseURL = 'http://padapi.queuingsystem.cn/public'
// const baseURL = 'https://api-dsb.dingtax.cn/dsb/api/tax-appoint/dx/third/request/public'
// const baseURL = 'http://padapi.queuingsystem.cn/public'
const baseURL = 'https://api-dsb.dingtax.cn/dsb/api/tax-appoint/dx/third/request/public'
// 签名计算也走统一入口,通过约定的签名 tag/path 转发到内部 /api/auth
const SIGN_TAG = 'pad.auth'
const SIGN_PATH = '/sign'
const REFRESH_TAG = 'pad.auth'
const REFRESH_PATH = '/refresh'
// 全局 loading 状态,可在页面上直接使用
export const loading = ref(false)
@ -28,6 +30,37 @@ const genNonce = () => {
return `n-${Math.random().toString(36).slice(2, 10)}`
}
// 深度排序对象 key保证签名参数序列化一致
const sortKeysDeep = (value) => {
if (Array.isArray(value)) {
return value.map((item) => sortKeysDeep(item))
}
if (value && typeof value === 'object') {
const sorted = {}
Object.keys(value)
.sort()
.forEach((key) => {
sorted[key] = sortKeysDeep(value[key])
})
return sorted
}
return value
}
const clonePayloadPart = (value) => {
if (value === undefined || value === null) {
return {}
}
try {
return JSON.parse(JSON.stringify(value))
} catch (error) {
console.log('[request][clone-payload-error]', error, value)
return {}
}
}
const isBizSuccessCode = (code) => code === 200 || code === '200'
// 组装接口要求的公共请求头
const buildHeaders = (withToken = true) => {
const headers = {
@ -70,9 +103,12 @@ const handleResponse = (res) => {
}
// 2. 业务层:文档约定 code=200 成功,其它为错误
if (data && data.code === 200) {
// 返回真正的业务数据
return data.data !== undefined ? data.data : data
if (data && isBizSuccessCode(data.code)) {
// 返回真正的业务数据data 为 null 时退回完整响应,便于从 msg 等字段解析
if (data.data !== undefined && data.data !== null) {
return data.data
}
return data
}
const bizCode = data && data.code
@ -118,7 +154,8 @@ const request = (options) => {
withToken = true,
skipSign = false,
loading: showLoading = true,
loadingText = '加载中...'
loadingText = '加载中...',
_retry401 = false
} = options
if (!tag) {
@ -129,16 +166,21 @@ const request = (options) => {
}
const finalTraceId = traceId || genTraceId()
const normalizedMethod = String(method || 'GET').toUpperCase()
const normalizedQuery = sortKeysDeep(clonePayloadPart(query))
const normalizedBody = sortKeysDeep(clonePayloadPart(body))
// 登录等 skipSign 场景:不携带 token
const useToken = skipSign ? false : withToken
const token = useToken ? uni.getStorageSync('token') : ''
const authHeader = token ? `Bearer ${token}` : ''
const doRequest = (payload) => {
const doRequest = (payload, withTokenHeader = useToken) => {
return new Promise((resolve, reject) => {
uni.request({
url: baseURL,
method: 'POST',
data: payload,
header: buildHeaders(useToken),
header: buildHeaders(withTokenHeader),
success: (res) => {
try {
const result = handleResponse(res)
@ -166,6 +208,99 @@ const request = (options) => {
})
}
// 401 自动刷新 token
const refreshAccessToken = () => {
return new Promise((resolve, reject) => {
const refreshToken =
uni.getStorageSync('refresh_token') ||
uni.getStorageSync('refreshToken')
if (!refreshToken) {
reject(new Error('refresh token 不存在'))
return
}
const refreshTraceId = genTraceId()
const refreshPayload = {
tag: REFRESH_TAG,
traceId: refreshTraceId,
map: {
traceId: refreshTraceId,
head: {
method: 'POST',
contentType: 'application/json'
},
path: REFRESH_PATH,
query: {},
body: {
refreshToken
}
}
}
uni.request({
url: baseURL,
method: 'POST',
data: refreshPayload,
// 刷新时不带旧 token避免无效令牌影响刷新
header: buildHeaders(false),
success: (res) => {
try {
if (!(res.statusCode === 200 && res.data && res.data.code === 200)) {
reject(res)
return
}
const tokenData = (res.data && res.data.data) || {}
const newAccessToken =
tokenData.access_token || tokenData.token || ''
const newRefreshToken =
tokenData.refresh_token || tokenData.refreshToken || ''
if (!newAccessToken) {
reject(new Error('刷新成功但 access token 为空'))
return
}
uni.setStorageSync('token', newAccessToken)
if (newRefreshToken) {
uni.setStorageSync('refresh_token', newRefreshToken)
}
resolve(newAccessToken)
} catch (err) {
reject(err)
}
},
fail: (err) => reject(err)
})
})
}
const with401Retry = (executor) => {
return executor().catch(async (err) => {
const isBiz401 = err && Number(err.code) === 401
const isHttp401 = err && Number(err.statusCode) === 401
const isRefreshRequest = tag === REFRESH_TAG && path === REFRESH_PATH
if (!_retry401 && !isRefreshRequest && (isBiz401 || isHttp401)) {
try {
await refreshAccessToken()
// 刷新后重试一次(重走签名流程)
return await request({
...options,
_retry401: true,
loading: false
})
} catch (refreshErr) {
console.log('[request][refresh-fail]', refreshErr)
throw err
}
}
throw err
})
}
if (showLoading) {
loading.value = true
uni.showLoading({
@ -178,18 +313,20 @@ const request = (options) => {
if (skipSign) {
const payload = {
tag,
traceId: finalTraceId,
map: {
traceId: finalTraceId,
head: {
method,
contentType: 'application/json'
method: normalizedMethod,
contentType: 'application/json',
...(authHeader ? { Authorization: authHeader } : {})
},
path,
query,
body
query: normalizedQuery,
body: normalizedBody
}
}
return doRequest(payload)
return with401Retry(() => doRequest(payload))
}
// 常规流程:先走统一入口调用签名服务,再携带 signature/timestamp/nonce 调业务接口
@ -198,7 +335,9 @@ const request = (options) => {
const tokenForSign = uni.getStorageSync('token')
const signPayload = {
tag: SIGN_TAG,
traceId: finalTraceId,
map: {
traceId: finalTraceId,
head: {
method: 'POST',
contentType: 'application/json',
@ -209,20 +348,19 @@ const request = (options) => {
},
path: SIGN_PATH,
query: {},
// 把真实业务请求关键信息放到 body 里交给签名服务计算:
// { tag, path, query, body, timestamp, nonce }
body: {
// 签名参数需与目标业务请求一致:{ tag, path, query, body, timestamp, nonce }
body: sortKeysDeep({
tag,
path,
query,
body,
query: normalizedQuery,
body: normalizedBody,
timestamp,
nonce
}
})
}
}
return new Promise((resolve, reject) => {
return with401Retry(() => new Promise((resolve, reject) => {
uni.request({
// 签名服务也通过公网统一入口 /public由 SIGN_TAG + SIGN_PATH 路由到内部 /api/sign
url: baseURL,
@ -231,18 +369,33 @@ const request = (options) => {
header: buildHeaders(useToken),
success: (signRes) => {
try {
if (!(signRes.statusCode === 200 && signRes.data && signRes.data.code === 200)) {
const msg = (signRes.data && signRes.data.msg) || '获取签名失败'
const signData = signRes.data || {}
const signOk =
signRes.statusCode === 200 && isBizSuccessCode(signData.code)
if (!signOk) {
const msg =
signData.error ||
signData.msg ||
signData.message ||
'获取签名失败'
uni.showToast({
title: msg,
icon: 'none'
})
console.log('[request][sign-response-error]', signRes)
console.log('[request][sign-request-body]', {
tag,
path,
method: normalizedMethod,
query: normalizedQuery,
body: normalizedBody,
})
throw signRes
}
const signature =
(signRes.data.data && signRes.data.data.signature) || signRes.data.signature
(signData.data && signData.data.signature) || signData.signature
if (!signature) {
console.log('[request][sign-empty]', signRes)
@ -251,18 +404,20 @@ const request = (options) => {
const payload = {
tag,
traceId: finalTraceId,
map: {
traceId: finalTraceId,
head: {
method,
method: normalizedMethod,
contentType: 'application/json',
signature,
timestamp,
nonce
nonce,
...(authHeader ? { Authorization: authHeader } : {})
},
path,
query,
body
query: normalizedQuery,
body: normalizedBody
}
}
@ -289,7 +444,7 @@ const request = (options) => {
reject(err)
}
})
})
}))
}
export default request

@ -0,0 +1,258 @@
# 紫云智能导税 PAD 客户端 — 产品介绍
## 一、产品概述
**紫云智能导税**tax-guidance是一款面向办税服务大厅的 **PAD 端业务操作客户端**,由 uni-app 构建,支持 Android / iOS / HarmonyOS 等多端部署。产品以横屏大屏交互为主,为导税员、窗口人员及大厅管理人员提供 **预检取号、实名核验、票号管理、大厅监控与参数配置** 等一体化能力。
客户端通过公网统一网关对接 **紫云 HES 排队叫号系统pad-api**,采用 `tag + map` 标准化报文与 **HMAC-SHA256 签名校验**,保障业务请求安全、可追溯,适用于税务局办税服务厅、政务服务中心等场景。
| 项目 | 说明 |
|------|------|
| 产品名称 | 紫云智能导税 |
| 当前版本 | 1.0.0 |
| 运行形态 | PAD / 平板 App横屏优先 |
| 技术栈 | uni-app + Vue 3 + TuniaoUI |
| 后端对接 | 紫云 HES 公网统一入口 `/public` |
---
## 二、产品定位与价值
### 2.1 定位
办税大厅 **现场导税与排队业务的前台操作终端**,连接纳税人身份核验、业务取号、窗口调度与大厅运营数据,减少多系统切换,提升导税效率与大厅可视化管理水平。
### 2.2 核心价值
- **一站式导税操作**:首页聚合高频功能,大厅概况实时刷新,关键指标一目了然。
- **实名核验与取号联动**:核验记录查询、人脸结果展示、按业务直接取号,形成完整业务闭环。
- **大厅透明化运营**:窗口与自助机状态监控、系统参数在线维护,便于现场快速调整。
- **安全合规接入**:除登录外,业务请求统一签名;全链路 `traceId` 支持问题追踪与审计。
---
## 三、适用对象与使用场景
| 角色 | 典型场景 |
|------|----------|
| 导税员 / 预审人员 | 纳税人信息登记、预检取号、查看核验结果与人脸照片 |
| 窗口管理人员 | 票号查询与导税、健康报告查看、预约签到 |
| 大厅管理员 | 大厅参数配置、窗口/设备状态监控、回签与优先取号 |
| 网格员 | 现场协调与辅助导税(功能入口预留扩展) |
**典型场景示例:**
1. 纳税人到厅 → 导税员在「预检取号」流程中录入信息 → 选择业务类型取号 → 打印或推送票号信息。
2. 已实名核验记录 → 在「预检取号」列表中按姓名/身份证/手机号查询 → 查看核验说明与人脸照片 → 一键取号。
3. 管理员查看「大厅概况」→ 进入大厅详情掌握窗口排队与设备空闲情况 → 在「大厅控制」中调整营业时间等系统参数。
---
## 四、功能模块说明
### 4.1 登录与首页
**登录**
- 账号密码登录,对接 `pad.auth` 认证接口。
- 登录成功后保存 Token 与用户信息,进入智能导税首页。
**智能导税首页**
- **大厅概况**(左侧指标卡片,约 30 秒自动刷新)
- 当前等候人数
- 今日预约人数
- 空闲窗口数
- 空闲自助机数
- 点击任意指标可跳转「大厅详情」查看明细
- **功能入口**(右侧操作区)
- **预检取号**:进入实名核验与取号业务主流程
- **票号管理**:当日票号列表、筛选与导税操作
- **网格员**:网格员相关功能(可扩展)
- **信息推送**:消息推送能力
- **大厅控制**:大厅系统参数配置
- **回签优先**:票号回签与优先(插队)取号
- 部分入口为预留功能,便于后续版本扩展
- **用户区**:右上角显示当前登录人头像与姓名,支持退出登录。
---
### 4.2 预检取号(核心业务)
路径:`预检取号` → 列表查询 → 取号 / 健康报告 / 预约签到
**实名核验记录查询**
- 支持按 **姓名、身份证号、手机号** 模糊查询;条件为空时可查询全部记录(分页)。
- 列表展示:姓名、身份证(脱敏)、手机号(脱敏)、核验结果、核验时间。
- **核验结果** 展示后端返回的 `baishuiResp` 文案(如人脸比对结果说明)。
- 支持 **查看照片**:弹窗展示 `faceImg` Base64 人脸抓拍图。
**取号**
- 点击「取号」→ 选择业务类型 → 调用取号接口。
- 若当前记录无手机号,先弹窗补录手机号再取号。
- 请求携带:业务 ID、身份证、姓名、手机号、核验记录 `uid` 等。
- 取号成功提示票号(如 `R0008`),并展示预计等候、办理窗口等信息。
**健康报告**
- 根据纳税人身份证获取健康报告 URL在弹窗内嵌展示报告页面。
**预约签到**
- 查看预约信息(姓名、身份证、手机号、预约时间段)并确认签到。
---
### 4.3 业务选择与取号(纳税人自助流程)
路径:信息录入 → 业务选择 → 确认取号
- 展示当前纳税人姓名,网格化展示可办 **业务类型列表**(接口动态加载)。
- 选择业务后确认取号,成功弹窗展示排队号码、业务类型、预计等候、办理窗口等。
- 支持打印小票、推送消息(按现场设备与后端能力对接)。
---
### 4.4 票号管理
- 企业/个人用户 Tab、状态筛选等候中、已完成、预约号等
- 支持时间、业务类型、票号关键字筛选。
- 列表展示企业名称、纳税人、票号及状态,支持 **导税** 跳转导税模块。
- 可查看 **税务健康检查报告**、票号详情弹窗。
---
### 4.5 大厅详情hallInfo
- **窗口状态监控**:人工窗口列表,展示窗口名称、可办业务、当前票号、状态(空闲/忙碌等)、等候人数。
- **自助办税设备**:设备名称、使用状态(空闲/使用中/维护中)。
- 顶部 **刷新** 按钮,实时拉取监控数据。
- 数据来源于 `pad.window` 等排队系统接口。
---
### 4.6 大厅控制hallManagment
- 以表格形式展示大厅 **系统参数**参数名称、Key、当前值
- 支持弹窗编辑参数值Key 与名称只读),修改前二次确认。
- 修改成功后自动刷新列表,对接 `pad.hallSystem` 参数维护接口。
---
### 4.7 回签优先sign-priority
**回签**
- 输入或扫码票号,执行回签(复号)操作。
**优先取号**
- 按业务类型网格选择,生成优先(插队)票号。
---
### 4.8 其他模块
| 模块 | 说明 |
|------|------|
| 今日进厅 | 进厅数据统计与查询 |
| 今日预约 | 预约数据查询与管理 |
| 导税 | 从票号管理进入的导税辅助流程 |
| 信息推送 | 向纳税人推送排队/办理消息 |
| 网格员 | 网格员现场协作(持续建设) |
| 多合一 / 预留功能 | 版本规划中 |
---
## 五、系统架构与对接方式
### 5.1 部署架构
```
┌─────────────────┐ HTTPS ┌──────────────────┐ 内网 ┌─────────────┐
│ PAD 客户端 │ ──────────────► │ 公网统一网关 │ ───────────► │ HES pad-api │
│ (本客户端) │ POST /public │ (tag 路由) │ │ 排队叫号服务 │
└─────────────────┘ └──────────────────┘ └─────────────┘
POST /api/sign
(HMAC-SHA256 签名)
```
- 客户端 **不直连** 内网 HES统一访问公网 `/public` 入口。
- 网关根据 **`tag` + `map.path`** 转发至内网具体接口。
- 业务请求(除登录外)先调用签名服务获取 `signature`,再携带 `timestamp`、`nonce` 发起业务调用。
### 5.2 主要接口标签Tag
| Tag | 典型能力 |
|-----|----------|
| `pad.auth` | 登录、用户信息 |
| `pad.hallSystem` | 大厅概况、系统参数、监控指标 |
| `pad.business` | 业务类型、健康报告 |
| `pad.ticket` | 取号、票号列表、核验查询、回签、优先取号 |
| `pad.window` | 窗口与设备监控 |
| `pad.appointment` | 预约相关 |
| `pad.sign` | 请求签名计算 |
### 5.3 统一返回约定
- HTTP 200 且业务 `code === 200` 视为成功。
- 失败时客户端统一 Toast 提示,并在控制台输出详细错误日志便于排查。
---
## 六、安全与可靠性
| 能力 | 说明 |
|------|------|
| 身份认证 | Bearer Token登录接口免签名、免 Token |
| 请求签名 | HMAC-SHA256参数深度排序后参与签名防篡改与重放含 timestamp、nonce |
| 敏感信息展示 | 身份证、手机号列表脱敏显示 |
| 操作确认 | 参数修改、预约签到等关键操作二次确认 |
| 链路追踪 | 每次请求自动生成 `traceId`,便于与网关、后端日志关联 |
---
## 七、终端与体验设计
- **横屏布局**`pages.json` 全局配置横屏,适配办税大厅 PAD 支架与柜台场景。
- **大屏触控**:功能按钮分区明确,色块区分业务类型,降低误触。
- **固定顶栏**:业务子页面顶栏固定,返回与刷新操作始终可见。
- **自动刷新**:首页大厅概况定时刷新,管理人员无需手动刷新即可掌握现场态势。
---
## 八、技术规格摘要
| 类别 | 说明 |
|------|------|
| 框架 | uni-appVue 3 Composition API |
| UI 组件 | TuniaoUI、uni-ui |
| 状态与存储 | 本地 StorageToken、userInfo |
| 网络封装 | 统一 `request` 模块签名、错误处理、Loading |
| 目标平台 | Android App、iOS App、HarmonyOS按 uni-app 工程配置) |
---
## 九、版本规划说明
当前版本 **1.0.0** 已具备登录、首页概况、预检取号、票号管理、大厅监控、大厅参数、回签优先等核心能力。首页及部分子模块保留 **预留功能** 入口,用于后续扩展:
- 更多统计分析与大屏联动
- 自助机远程控制、打印设备深度集成
- 网格员任务派发、多合一办事流程
---
## 十、总结
紫云智能导税 PAD 客户端是紫云办税服务大厅数字化体系中的 **现场作业终端**,将身份核验、排队取号、窗口监控与参数运维集中在一套横屏应用中,并通过统一网关与安全签名机制与 HES 后端稳定对接。产品面向导税与管理双线角色设计,兼顾操作效率、现场可视化与系统可维护性,是提升办税大厅服务品质与运营效率的重要工具。
---
*文档根据当前代码库tax-guidance与《ZIYUN_PAD(HES)接口清单与CURL验证》整理随版本迭代可同步更新功能列表与接口说明。*
Loading…
Cancel
Save