升级导税PAD至1.2.1:预检取号体检、工作台改版与会话续期

Co-authored-by: Cursor <cursoragent@cursor.com>
main
cysamurai 3 days ago
parent 5077d7cefc
commit 233d47483d

@ -1,16 +1,46 @@
<script> <script>
import {
markSessionNeedsRestore,
restoreSessionIfNeeded
} from '@/utils/request.js'
import { appLog, flushLogs, installAppLogger } from '@/utils/appLog.js'
export default { export default {
onLaunch: function() { onLaunch: function() {
console.log('App Launch') installAppLogger()
appLog('info', '[app] onLaunch')
const token = uni.getStorageSync('token')
const refreshToken =
uni.getStorageSync('refresh_token') ||
uni.getStorageSync('refreshToken')
appLog(
'info',
'[app] onLaunch 本地登录态',
`hasToken=${Boolean(token)}`,
`hasRefreshToken=${Boolean(refreshToken)}`
)
if (token || refreshToken) {
markSessionNeedsRestore()
}
flushLogs()
}, },
onShow: function() { onShow: function() {
console.log('App Show') appLog('info', '[app] onShow 回到前台')
// #ifdef APP-PLUS // #ifdef APP-PLUS
plus.navigator.setFullscreen(true); // plus.navigator.setFullscreen(true); //
// #endif // #endif
restoreSessionIfNeeded().then((ok) => {
appLog('info', '[app] onShow 续会话结果:', ok)
flushLogs()
}).catch((err) => {
appLog('error', '[app] onShow 续会话异常:', err)
flushLogs()
})
}, },
onHide: function() { onHide: function() {
console.log('App Hide') appLog('info', '[app] onHide 进入后台/锁屏')
markSessionNeedsRestore()
flushLogs()
} }
} }
</script> </script>
@ -18,9 +48,13 @@
<style lang="scss"> <style lang="scss">
@import '@/uni_modules/tuniaoui-vue3/index.css'; @import '@/uni_modules/tuniaoui-vue3/index.css';
page {
--pad-h: 100vh;
--pad-vh: 1vh;
}
/*每个页面公共css */ /*每个页面公共css */
.page-container { .page-container {
// background: linear-gradient(to bottom, #000066, #0099ff);
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
} }

@ -0,0 +1,61 @@
const COMPANY_BASE = 'https://api-dsb.dingtax.cn/dsb/tax-appoint/api/dx/third'
const isCompanyOk = (body) => {
if (!body || typeof body !== 'object') return false
if (body.respCode !== undefined && Number(body.respCode) !== 0) return false
const code = body.code
if (code === undefined || code === null || code === '') return true
return code === 0 || code === '0' || code === 200 || code === '200'
}
const companyPost = (path, payload) => {
const token = uni.getStorageSync('token') || ''
const header = {
'Content-Type': 'application/json',
}
if (token) {
header.token = token
header.Authorization = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
uni.request({
url: `${COMPANY_BASE}${path}`,
method: 'POST',
data: payload,
header,
timeout: 20000,
success: (res) => {
let body = res.data
if (typeof body === 'string') {
try {
body = JSON.parse(body)
} catch (error) {
reject(error)
return
}
}
if (Number(res.statusCode) !== 200 || !isCompanyOk(body)) {
reject(body || res)
return
}
resolve(body)
},
fail: (err) => reject(err),
})
})
}
export const searchCompanyPage = (params = {}) => {
return companyPost('/company/info/page', {
zjhm: params.zjhm,
pageNum: params.pageNum || 1,
pageSize: params.pageSize || 50,
})
}
export const getCompanyRisk = (params = {}) => {
return companyPost('/company/risk/info', {
zjhm: params.zjhm,
djxh: params.djxh,
})
}

@ -20,13 +20,12 @@ export const printTicket = (params = {}) => {
}) })
} }
// 保存打印小票模板POST + body签名参数与其他 POST 接口一致) // 保存打印小票模板
export const savePrintTemplate = (params = {}) => { export const savePrintTemplate = (params = {}) => {
const body = JSON.parse(JSON.stringify(params))
return request({ return request({
tag: 'pad.print', tag: 'pad.print',
path: '/template', path: '/template',
method: 'POST', method: 'PUT',
body body: params
}) })
} }

@ -0,0 +1,53 @@
import request from '@/utils/request.js'
import { appLog, flushLogs } from '@/utils/appLog.js'
const maskPhoneForLog = (phone) => {
const text = String(phone || '').trim()
if (!text) return ''
if (text.length < 7) return `${text.slice(0, 1)}***`
return `${text.slice(0, 3)}****${text.slice(-4)}`
}
// 实名制采集保存pad.ticket -> POST /save-cjxx
export const saveSmzcjxx = (params = {}) => {
const body = {
rxsfzBdxh: params.rxsfzBdxh,
sjhm: params.sjhm,
}
if (params.dzyx) body.dzyx = params.dzyx
if (params.lxdh) body.lxdh = params.lxdh
appLog(
'info',
'[smz] saveSmzcjxx 请求',
`rxsfzBdxh=${body.rxsfzBdxh || ''}`,
`sjhm=${maskPhoneForLog(body.sjhm)}`,
`hasDzyx=${Boolean(body.dzyx)}`,
`hasLxdh=${Boolean(body.lxdh)}`
)
flushLogs()
return request({
tag: 'pad.ticket',
path: '/save-cjxx',
method: 'POST',
body
})
.then((result) => {
appLog(
'info',
'[smz] saveSmzcjxx 返回',
`success=${result?.success}`,
`code=${result?.code}`,
`mess=${result?.mess || result?.msg || ''}`,
result
)
flushLogs()
return result
})
.catch((error) => {
appLog('error', '[smz] saveSmzcjxx 请求失败', error)
flushLogs()
throw error
})
}

@ -11,12 +11,13 @@ export const takeTicket = (params = {}) => {
} }
// 实名核验记录查询 // 实名核验记录查询
export const searchTicketVerify = (params = {}) => { export const searchTicketVerify = (params = {}, requestOptions = {}) => {
return request({ return request({
tag: 'pad.ticket', tag: 'pad.ticket',
path: '/verify/search', path: '/verify/search',
method: 'POST', method: 'POST',
body: params body: params,
...requestOptions
}) })
} }

@ -220,7 +220,7 @@ const handleClose = () => {
background: white; background: white;
border-radius: 20rpx; border-radius: 20rpx;
width: 100%; width: 100%;
max-height: 75vh; max-height: calc(75 * var(--pad-vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.25); box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.25);
@ -271,7 +271,7 @@ const handleClose = () => {
.ticket-modal-body { .ticket-modal-body {
flex: 1; flex: 1;
padding: 0 24rpx 24rpx; padding: 0 24rpx 24rpx;
max-height: calc(75vh - 120rpx); max-height: calc(75 * var(--pad-vh) - 120rpx);
} }
/* 信息卡片 */ /* 信息卡片 */
@ -514,11 +514,11 @@ const handleClose = () => {
} }
.ticket-modal-content { .ticket-modal-content {
max-height: 80vh; max-height: calc(80 * var(--pad-vh));
} }
.ticket-modal-body { .ticket-modal-body {
max-height: calc(80vh - 120rpx); max-height: calc(80 * var(--pad-vh) - 120rpx);
} }
.info-row { .info-row {

@ -1,49 +1,81 @@
<template> <template>
<view> <view>
<uni-popup ref="phonePopup" type="center" :is-mask-click="false"> <uni-popup ref="ticketFormPopup" type="center" :is-mask-click="false">
<view class="phone-popup"> <view class="ticket-form-popup">
<view class="popup-header"> <view class="popup-head">
<text class="popup-title">请输入手机号码</text> <view class="popup-head-bar"></view>
<view class="report-close" @click="closePhonePopup">×</view> <text class="popup-head-title">取号</text>
<view class="popup-head-close" @click="closeTicketFormPopup">×</view>
</view> </view>
<view class="phone-popup-body">
<text class="phone-popup-tip">{{ phonePopupTip }}</text> <view class="section">
<view class="section-title">
<view class="section-bar"></view>
<text>纳税人或缴费人</text>
</view>
<view class="form-grid">
<view class="form-row">
<text class="form-label">姓名</text>
<input class="form-input" :value="form.name" disabled placeholder="" />
</view>
<view class="form-row">
<text class="form-label">证件类型</text>
<input class="form-input" :value="form.idType" disabled placeholder="" />
</view>
<view class="form-row">
<text class="form-label">证件号码</text>
<input class="form-input" :value="form.idCard" disabled placeholder="" />
</view>
<view class="form-row">
<text class="form-label">
手机号码<span class="required">*</span>
</text>
<input <input
v-model="inputPhone" v-model="form.phone"
class="phone-popup-input" class="form-input form-input-required"
type="number" type="number"
maxlength="11" maxlength="11"
placeholder="请输入11位手机号码" placeholder="请输入手机号码"
/> />
</view> </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>
</view> </view>
</uni-popup>
<uni-popup ref="ticketPopup" type="center"> <view class="section">
<view class="ticket-popup"> <view class="section-title">
<view class="popup-header"> <view class="section-bar"></view>
<text class="popup-title">选择业务直接取号</text> <text>请选择办理业务</text>
<view class="report-close" @click="closeTicketPopup">×</view>
</view> </view>
<view class="biz-grid"> <view class="form-row form-row-top">
<text class="form-label">
窗口业务<span class="required">*</span>
</text>
<view class="radio-group">
<view <view
v-for="(item, index) in businessList" v-for="(item, index) in businessList"
:key="index" :key="index"
class="biz-item" class="radio-item"
:class="{ disabled: ticketLoading }" :class="{ active: selectedBizUid === item.value }"
@click="takeTicketByBusiness(item)" @click="selectedBizUid = item.value"
> >
<view class="biz-icon"> <view class="radio-dot">
<uni-icons type="wallet" size="22" color="#111827"></uni-icons> <view v-if="selectedBizUid === item.value" class="radio-dot-inner"></view>
</view>
<text class="radio-text">{{ item.name }}</text>
<text class="radio-waiting">等候{{ item.waitingCount ?? 0 }}</text>
</view>
</view> </view>
<view class="biz-name">{{ item.name }}</view>
<view class="biz-waiting">等候 {{ item.waitingCount ?? 0 }} </view>
</view> </view>
</view> </view>
<view class="popup-actions">
<button class="action-btn action-ghost" :disabled="ticketLoading" @click="closeTicketFormPopup">
取消
</button>
<button class="action-btn action-primary" :disabled="ticketLoading" @click="confirmTakeTicket">
取号
</button>
</view>
</view> </view>
</uni-popup> </uni-popup>
</view> </view>
@ -52,37 +84,38 @@
<script setup> <script setup>
import { getBizList } from "@/api/index.js"; import { getBizList } from "@/api/index.js";
import { takeTicket } from "@/api/ticket.js"; import { takeTicket } from "@/api/ticket.js";
import { computed, ref, watch } from "vue"; import { reactive, ref, watch } from "vue";
import { validatePhoneNumber } from "@/utils/validator"; import { validatePhoneNumber } from "@/utils/validator";
const emit = defineEmits(["success", "loading"]); const emit = defineEmits(["success", "loading", "visibleChange"]);
const phonePopup = ref(null); const ticketFormPopup = ref(null);
const ticketPopup = ref(null);
const inputPhone = ref("");
const businessList = ref([]); const businessList = ref([]);
const ticketLoading = ref(false); const ticketLoading = ref(false);
const currentRow = ref(null); const currentRow = ref(null);
const tktType = ref("normal"); const tktType = ref("normal");
const rankUserPhone = ref(""); const selectedBizUid = ref("");
const phonePopupTip = computed(() => const form = reactive({
tktType.value === "normal" name: "",
? "普通取号请先填写手机号码" idType: "",
: "当前记录无手机号,取号前请先填写", idCard: "",
); phone: "",
});
const getTakeTicketPhone = () => {
if (currentRow.value) {
return String(currentRow.value.phone || "").trim();
}
return String(rankUserPhone.value || "").trim();
};
watch(ticketLoading, (value) => { watch(ticketLoading, (value) => {
emit("loading", value); emit("loading", value);
}); });
const resetForm = () => {
form.name = "";
form.idType = "";
form.idCard = "";
form.phone = "";
selectedBizUid.value = "";
currentRow.value = null;
};
const loadBusinessList = async () => { const loadBusinessList = async () => {
try { try {
const res = await getBizList({ withWaiting: true }); const res = await getBizList({ withWaiting: true });
@ -92,95 +125,86 @@ const loadBusinessList = async () => {
value: item.value || item.uid || item.id || String(index), value: item.value || item.uid || item.id || String(index),
waitingCount: Number(item.waitingCount ?? 0), waitingCount: Number(item.waitingCount ?? 0),
})); }));
if (businessList.value.length === 1) {
selectedBizUid.value = businessList.value[0].value;
}
} catch (error) { } catch (error) {
console.log("获取业务列表失败:", error); console.log("获取业务列表失败:", error);
} }
}; };
const openTicketBusinessPopup = async () => { const openTicketForm = async () => {
if (!businessList.value.length) { emit("visibleChange", true);
await loadBusinessList(); await loadBusinessList();
} ticketFormPopup.value?.open();
ticketPopup.value?.open();
}; };
/** 普通取号:无核验记录,先填写手机号再选择业务 */ /** 普通取号:无实名信息,姓名/证件为空,手机号必填 */
const open = () => { const open = async () => {
currentRow.value = null; resetForm();
tktType.value = "normal"; tktType.value = "normal";
rankUserPhone.value = ""; await openTicketForm();
inputPhone.value = "";
phonePopup.value?.open();
}; };
/** 预检取号:基于核验记录取号 */ /** 预检取号:回填人员信息,手机号必填 */
const openWithRow = (row) => { const openWithRow = async (row) => {
resetForm();
currentRow.value = row; currentRow.value = row;
tktType.value = "realname"; tktType.value = "realname";
rankUserPhone.value = ""; form.name = String(row?.name || "").trim();
const phone = String(row?.phone || "").trim(); form.idType = "身份证";
if (!phone) { form.idCard = String(row?.idCard || "").trim();
inputPhone.value = ""; form.phone = String(row?.phone || "").trim();
phonePopup.value?.open(); await openTicketForm();
return;
}
openTicketBusinessPopup();
}; };
const closePhonePopup = () => { const closeTicketFormPopup = () => {
phonePopup.value?.close(); ticketFormPopup.value?.close();
}; emit("visibleChange", false);
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 buildTakeTicketParams = (bizItem) => {
const params = { const params = {
bizUid: bizItem.value, bizUid: bizItem.value,
tktType: tktType.value, tktType: tktType.value,
rankUserPhone: getTakeTicketPhone(), rankUserPhone: String(form.phone || "").trim(),
}; };
if (currentRow.value) { if (currentRow.value) {
params.uid = currentRow.value.uid; params.uid = currentRow.value.uid;
params.idCard = currentRow.value.idCard; params.idCard = currentRow.value.idCard || form.idCard;
params.rankUserName = currentRow.value.name; params.rankUserName = currentRow.value.name || form.name;
} }
return params; return params;
}; };
const takeTicketByBusiness = async (bizItem) => { const confirmTakeTicket = async () => {
if (ticketLoading.value) return; if (ticketLoading.value) return;
if (!getTakeTicketPhone()) { const phoneResult = validatePhoneNumber(form.phone);
if (!phoneResult.valid) {
uni.showToast({
title: phoneResult.message || "请输入正确手机号",
icon: "none",
});
return;
}
const bizItem = businessList.value.find((item) => item.value === selectedBizUid.value);
if (!bizItem) {
uni.showToast({ uni.showToast({
title: "请先填写手机号码", title: "请选择窗口业务",
icon: "none", icon: "none",
}); });
return; return;
} }
if (currentRow.value) {
currentRow.value = {
...currentRow.value,
phone: form.phone.trim(),
};
}
ticketLoading.value = true; ticketLoading.value = true;
try { try {
const result = await takeTicket(buildTakeTicketParams(bizItem)); const result = await takeTicket(buildTakeTicketParams(bizItem));
@ -189,7 +213,7 @@ const takeTicketByBusiness = async (bizItem) => {
title: tktId ? `取号成功:${tktId}` : "取号成功", title: tktId ? `取号成功:${tktId}` : "取号成功",
icon: "success", icon: "success",
}); });
closeTicketPopup(); closeTicketFormPopup();
emit("success", result); emit("success", result);
} catch (error) { } catch (error) {
console.log("取号失败", error); console.log("取号失败", error);
@ -206,133 +230,223 @@ defineExpose({
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.ticket-popup, .ticket-form-popup {
.phone-popup { width: calc(75 * var(--pad-vw));
width: 680rpx; max-height: calc(86 * var(--pad-vh));
max-width: 90vw;
background: #fff; background: #fff;
border-radius: 12px; border-radius: 16px;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 18px 48px rgba(30, 58, 138, 0.16);
} }
.phone-popup-body { .popup-head {
padding: 14px; position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 18px 52px 12px;
background: linear-gradient(180deg, #f7f9fd 0%, #fff 100%);
} }
.phone-popup-tip { .popup-head-bar {
display: block; position: absolute;
font-size: 14px; left: 20px;
color: #6b7280; top: 50%;
margin-bottom: 12px; width: 4px;
height: 18px;
margin-top: -3px;
border-radius: 2px;
background: #1d4ed8;
} }
.phone-popup-input { .popup-head-title {
width: 100%; font-size: 20px;
height: 40px; font-weight: 700;
border: 1px solid #d1d5db; color: #1e3a8a;
border-radius: 8px; }
padding: 0 12px;
font-size: 14px; .popup-head-close {
box-sizing: border-box; position: absolute;
right: 16px;
top: 14px;
width: 32px;
height: 32px;
line-height: 30px;
text-align: center;
border-radius: 16px;
background: #eef3f9;
color: #64748b;
font-size: 20px;
}
.section {
padding: 8px 24px 4px;
} }
.phone-popup-actions { .section-title {
padding: 12px 14px 14px;
display: flex; display: flex;
justify-content: flex-end; align-items: center;
gap: 10px; gap: 8px;
font-size: 15px;
font-weight: 700;
color: #1e3a8a;
margin-bottom: 14px;
}
.section-bar {
width: 4px;
height: 14px;
border-radius: 2px;
background: #2563eb;
} }
.popup-header { .form-grid {
height: 48px; display: grid;
padding: 0 14px; grid-template-columns: repeat(2, minmax(0, 1fr));
border-bottom: 1px solid #eee; gap: 14px 24px;
}
.form-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 12px;
min-width: 0;
} }
.popup-title { .form-row-top {
font-size: 15px; align-items: flex-start;
color: #111827;
font-weight: 600;
} }
.report-close { .form-label {
width: 28px; width: 88px;
height: 28px; flex-shrink: 0;
line-height: 28px; font-size: 14px;
text-align: center; color: #475569;
font-size: 22px; text-align: right;
color: #9ca3af; }
cursor: pointer;
.required {
color: #ef4444;
margin-left: 2px;
} }
.biz-grid { .form-input {
flex: 1;
min-width: 0;
height: 40px;
border: 1px solid #d7e2ee;
border-radius: 10px;
padding: 0 12px;
font-size: 14px;
color: #1e293b;
background: #f4f7fb;
box-sizing: border-box;
}
.form-input-required {
background: #fff;
border-color: #c7d7ea;
}
.form-input[disabled] {
background: #f4f7fb;
color: #64748b;
}
.radio-group {
flex: 1;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px; gap: 10px 12px;
padding: 14px;
} }
.biz-item { .radio-item {
border: 1px solid #e5e7eb; min-width: 0;
border-radius: 10px;
min-height: 92px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
flex-direction: column; padding: 10px 12px;
cursor: pointer; border: 1px solid #d7e2ee;
border-radius: 10px;
background: #f8fafc;
} }
.biz-item.disabled { .radio-item.active {
opacity: 0.6; border-color: #2563eb;
pointer-events: none; background: #eff6ff;
} }
.biz-icon { .radio-dot {
margin-bottom: 6px; width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #94a3b8;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
} }
.biz-name { .radio-item.active .radio-dot {
font-size: 13px; border-color: #2563eb;
color: #111827;
text-align: center;
} }
.biz-waiting { .radio-dot-inner {
margin-top: 6px; width: 8px;
font-size: 12px; height: 8px;
color: #fa541c; border-radius: 50%;
text-align: center; background: #2563eb;
} }
.btn { .radio-text {
min-width: 72px;
height: 32px;
line-height: 32px;
padding: 0 14px;
font-size: 14px; font-size: 14px;
border-radius: 6px; color: #1e293b;
border: none; font-weight: 600;
}
.radio-waiting {
margin-left: auto;
font-size: 12px;
color: #94a3b8;
flex-shrink: 0;
} }
.popup-btn { .popup-actions {
min-width: 72px; padding: 16px 24px 20px;
display: flex;
justify-content: flex-end;
gap: 12px;
} }
.btn-primary { .action-btn {
min-width: 112px;
height: 42px;
padding: 0 22px;
border-radius: 21px;
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.action-ghost {
background: #fff;
color: #1e3a8a;
border-color: #c7d7ea;
}
.action-primary {
background: #2563eb; background: #2563eb;
color: #fff; color: #fff;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22);
} }
.btn-default { .action-btn[disabled] {
background: #fff; opacity: 0.45;
color: #374151; box-shadow: none;
border: 1px solid #d1d5db;
} }
</style> </style>

@ -300,7 +300,7 @@ const handleClose = () => {
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
border-radius: 24rpx; border-radius: 24rpx;
width: 100%; width: 100%;
height: 90vh; height: calc(90 * var(--pad-vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2); box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.2);
@ -350,7 +350,7 @@ const handleClose = () => {
.tax-report-body { .tax-report-body {
flex: 1; flex: 1;
padding: 32rpx; padding: 32rpx;
max-height: calc(90vh - 120rpx); max-height: calc(90 * var(--pad-vh) - 120rpx);
} }
/* 企业基本信息 */ /* 企业基本信息 */

@ -25,7 +25,7 @@ const handleBack = () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: #fff; background-color: #fff;
padding: 6vh 20px 10px 20px; padding: calc(6 * var(--pad-vh)) 20px 10px 20px;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;

@ -4,7 +4,7 @@
<image class="head-icon" src="/static/head-icon.png" /> <image class="head-icon" src="/static/head-icon.png" />
<span>{{ hallName }}</span> <span>{{ hallName }}</span>
</div> </div>
<div class="head-time"> <div v-if="showTime" class="head-time">
<text class="dt-text">{{ currentDate }}</text> <text class="dt-text">{{ currentDate }}</text>
<text class="dt-text">{{ currentDay }}</text> <text class="dt-text">{{ currentDay }}</text>
<text class="dt-text">{{ currentTime }}</text> <text class="dt-text">{{ currentTime }}</text>
@ -20,6 +20,13 @@
} from 'vue' } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
const props = defineProps({
showTime: {
type: Boolean,
default: true,
},
})
const currentTime = ref('') const currentTime = ref('')
const currentDate = ref('') const currentDate = ref('')
const currentDay = ref('') const currentDay = ref('')
@ -58,8 +65,11 @@
onMounted(() => { onMounted(() => {
loadHallNameFromStorage() loadHallNameFromStorage()
uni.$on(hallNameEvent, loadHallNameFromStorage) uni.$on(hallNameEvent, loadHallNameFromStorage)
updateDateTime() // if (!props.showTime) {
timer = setInterval(updateDateTime, 1000) // return
}
updateDateTime()
timer = setInterval(updateDateTime, 1000)
}); });
onShow(() => { onShow(() => {
@ -79,36 +89,37 @@
.login-head { .login-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding-top: $statusbar-height; align-items: center;
padding: $statusbar-height 5vw 0;
.head-title { .head-title {
display: flex; display: flex;
align-items: center; align-items: center;
height: 6vh; gap: 12px;
height: calc(6 * var(--pad-vh));
.head-icon { .head-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
margin-left: 1vw; flex-shrink: 0;
} }
span { span {
color: #fff; color: #fff;
font-size: 3vh; font-size: calc(3 * var(--pad-vh));
font-weight: 800; font-weight: 800;
margin-left: 1vw;
} }
} }
.head-time { .head-time {
display: flex; display: flex;
align-items: center; align-items: center;
height: 6vh; gap: 12px;
height: calc(6 * var(--pad-vh));
.dt-text { .dt-text {
color: #fff; color: #fff;
font-size: 2vh; font-size: 2vh;
margin-right: 1vw;
} }
} }
} }

File diff suppressed because it is too large Load Diff

@ -0,0 +1,121 @@
/**
* 企业信息查询接口调试代理零依赖Node >= 14
*
* 用途
* 1. 托管同目录下的 index.html访问 http://localhost:5500
* 2. 将其余请求原样转发到后端 127.0.0.1:8081并在响应上附加 CORS
* 规避浏览器跨域限制方便前端页面直接联调
*
* 启动 node proxy.js
* 自定义PORT=5501 TARGET_PORT=9090 node proxy.js
*/
"use strict";
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const PORT = Number(process.env.PORT) || 9527;
const TARGET_HOST = process.env.TARGET_HOST || "127.0.0.1";
const TARGET_PORT = Number(process.env.TARGET_PORT) || 8081;
const CX_HOST = process.env.CX_HOST || "api-cx.dingtax.cn";
const PUB_HOST = process.env.PUB_HOST || "apit-dsb.dingtax.cn";
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon"
};
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,PATCH,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With"
};
}
const server = http.createServer((req, res) => {
const cors = corsHeaders();
if (req.method === "OPTIONS") {
res.writeHead(204, cors);
return res.end();
}
// 静态文件:/ 及 /index.html其余静态路径按扩展名尝试读取
const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
if (req.method === "GET" && !urlPath.startsWith("/dsb/") && !urlPath.startsWith("/cx/") && !urlPath.startsWith("/pub/")) {
let file = path.join(__dirname, urlPath === "/" ? "index.html" : urlPath);
if (!file.startsWith(__dirname)) file = path.join(__dirname, "index.html");
fs.readFile(file, (err, buf) => {
if (err) {
res.writeHead(404, { ...cors, "Content-Type": "text/plain; charset=utf-8" });
return res.end("404 Not Found: " + urlPath);
}
res.writeHead(200, { ...cors, "Content-Type": MIME[path.extname(file)] || "application/octet-stream" });
res.end(buf);
});
return;
}
// /cx/* → 公网 api-cx.dingtax.cn (HTTPS)/dsb/* → 本机后端 (HTTP)
const isCx = urlPath.startsWith("/cx/");
const isPub = urlPath.startsWith("/pub/");
const qs = (req.url || "").split("?").slice(1).join("?");
// apit-dsb 后端会校验 Origin/RefererInvalid CORS request / Rap csrf Verify转发前剥离浏览器来源头
const fwdHeaders = { ...req.headers };
["origin", "referer", "sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest", "sec-fetch-user", "accept-language"].forEach(h => delete fwdHeaders[h]);
const options = isCx
? {
host: CX_HOST,
port: 443,
path: req.url,
method: req.method,
headers: { ...fwdHeaders, host: CX_HOST },
rejectUnauthorized: false
}
: isPub
? {
host: PUB_HOST,
port: 443,
path: "/dsb" + urlPath.slice(4) + (qs ? "?" + qs : ""),
method: req.method,
headers: { ...fwdHeaders, host: PUB_HOST },
rejectUnauthorized: false
}
: {
host: TARGET_HOST,
port: TARGET_PORT,
path: req.url,
method: req.method,
headers: { ...fwdHeaders, host: `${TARGET_HOST}:${TARGET_PORT}` }
};
const upstream = (isCx || isPub ? https : http).request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, { ...proxyRes.headers, ...cors });
proxyRes.pipe(res);
});
upstream.on("error", (e) => {
res.writeHead(502, { ...cors, "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify({
code: "-1",
message: `代理转发失败: ${e.message}(后端 ${TARGET_HOST}:${TARGET_PORT} 不可达?)`
}));
});
req.pipe(upstream);
});
server.listen(PORT, () => {
console.log("企业信息接口调试代理已启动");
console.log(` 测试页面: http://localhost:${PORT}`);
console.log(` 转发目标: http://${TARGET_HOST}:${TARGET_PORT}`);
console.log(" 按 Ctrl+C 停止");
});

@ -0,0 +1,103 @@
// company-api-test/index.html 自动化验收测试
const { chromium } = require("playwright");
const path = require("path");
const OUT = (n) => path.join(require("os").tmpdir(), n);
(async () => {
const errors = [];
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on("pageerror", (e) => errors.push(String(e)));
page.on("console", (m) => {
// 故意触发的 405/404 网络日志不算页面错误
if (m.type() === "error" && !/Failed to load resource/.test(m.text())) errors.push(m.text());
});
await page.goto("http://localhost:9527");
await page.waitForLoadState("networkidle");
// 1) 主链路:公网 api-cx 实名查询(真实网络请求)
const idv = await page.inputValue("#idPlain");
if (idv !== "430703198610010059") throw new Error("默认身份证明文不对: " + idv);
const preview = await page.textContent("#cxPreview");
if (!preview.includes("sign=") || !preview.includes("zjhm=J4jfHM")) throw new Error("签名 URL 预览异常: " + preview);
await page.click("#btnSendCx");
await page.waitForTimeout(4000);
const chipCode = await page.textContent("#chipCode");
if (!chipCode.includes("0")) throw new Error("respCode 应为 0: " + chipCode);
const meta = await page.textContent("#metaLine");
if (!meta.includes("430703198610010059")) throw new Error("台账 meta 应含身份证明文");
const emptyMsg = await page.textContent("#ledgerBody");
if (!emptyMsg.includes("暂无企业数据")) throw new Error("应显示空数据提示: " + emptyMsg);
await page.screenshot({ path: OUT("v2_1_cx_query.png"), fullPage: true });
// 2) 校验密文解密(后端解密回显 → 证明 AES 正确)
await page.click("#btnVerifyZjhm");
await page.waitForTimeout(3000);
const ledger = await page.textContent("#ledgerBody");
if (!ledger.includes("430703198610010059")) throw new Error("校验台账应含明文");
if (!ledger.includes("一致")) throw new Error("校验结论应为一致");
await page.screenshot({ path: OUT("v2_2_verify.png"), fullPage: true });
// 3) JSON 标签页
await page.click('.tab[data-tab="json"]');
const j = await page.textContent("#jsonScreen");
if (!j.includes("respCode")) throw new Error("JSON 视图应含 respCode");
// 4) 次链路tax-appoint 离线示例
await page.check("#mockToggle");
await page.click("#btnSend");
await page.waitForTimeout(400);
const rows = await page.locator("#ledgerBody tr").count();
if (rows !== 2) throw new Error("示例模式应渲染 2 行, 实际 " + rows);
if (!(await page.textContent("#ledgerBody")).includes("义乌市万竹贤口腔诊所")) throw new Error("示例台账缺少企业");
const t2 = await page.textContent("#ledgerBody");
if (t2.includes("330725198202084328")) throw new Error("脱敏开启时不应显示完整证件号");
// 5) 关闭脱敏 → 完整证件号
await page.uncheck("#maskToggle");
await page.waitForTimeout(200);
if (!(await page.textContent("#ledgerBody")).includes("330725198202084328")) throw new Error("关闭脱敏后应显示完整证件号");
await page.check("#maskToggle");
// 6) tax-appoint 真实请求(经代理 → 405→ 友好错误面板
await page.uncheck("#mockToggle");
await page.click("#btnSameOrigin");
await page.click("#btnSend");
await page.waitForTimeout(2500);
if (!(await page.locator("#stageError").isVisible())) throw new Error("后端不可用时应展示错误面板");
const chipHttp = await page.textContent("#chipHttp");
if (!/405|502/.test(chipHttp)) throw new Error("状态条应显示 405/502: " + chipHttp);
await page.screenshot({ path: OUT("v2_3_ta_error.png"), fullPage: true });
// 7) 历史
if ((await page.locator(".h-item").count()) < 2) throw new Error("应有 ≥2 条历史记录");
// 8) AES 工具
await page.fill("#aesPlain", "330725198202084328");
await page.click("#btnAesEnc");
const enc = await page.inputValue("#aesCipherOut");
if (enc !== "MoV2Ra2USY/6tc6b9xQXZkuEA+I6X3u90CSNgvFqzlE=") throw new Error("AES 加密与文档示例不一致: " + enc);
await page.fill("#aesCipherIn", enc);
await page.click("#btnAesDec");
if ((await page.inputValue("#aesPlainOut")) !== "330725198202084328") throw new Error("AES 解密不一致");
await page.fill("#aesKey", "shortkey");
await page.click("#btnAesEnc");
if (!/错误/.test(await page.inputValue("#aesCipherOut"))) throw new Error("非法密钥应提示错误");
await page.fill("#aesKey", "Gi4swf3llyafmsuK");
// 9) 密文一键填入 zjhm
await page.click("#btnAesEnc");
await page.click("#btnFillZjhm");
if ((await page.inputValue("#zjhm")) !== "MoV2Ra2USY/6tc6b9xQXZkuEA+I6X3u90CSNgvFqzlE=") throw new Error("填入 zjhm 失败");
await page.screenshot({ path: OUT("v2_4_final.png"), fullPage: true });
await browser.close();
if (errors.length) {
console.log("CONSOLE/PAGE ERRORS:");
errors.forEach((e) => console.log(" -", e.slice(0, 300)));
process.exit(1);
}
console.log("ALL CHECKS PASSED");
})().catch((e) => { console.error("FAIL:", e.message); process.exit(1); });

@ -0,0 +1,159 @@
// 验证将嵌入 index.html 的纯 JS AES-128-ECB 实现与 Node crypto 的一致性
const crypto = require("crypto");
const KEY = "Gi4swf3llyafmsuK";
/* ===== 以下实现与嵌入页面的代码完全一致 ===== */
var AESTool = (function(){
"use strict";
var sbox = new Uint8Array([
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
]);
var invSbox = new Uint8Array(256);
(function(){ for (var i = 0; i < 256; i++) invSbox[sbox[i]] = i; })();
var RCON = [1,2,4,8,16,32,64,128,27,54];
function expandKey(keyBytes){
var rk = new Uint8Array(176), i = 16, rconIdx = 0, j, t;
rk.set(keyBytes);
while (i < 176) {
t = [rk[i-4], rk[i-3], rk[i-2], rk[i-1]];
if (i % 16 === 0) t = [sbox[t[1]] ^ RCON[rconIdx++], sbox[t[2]], sbox[t[3]], sbox[t[0]]];
for (j = 0; j < 4; j++) { rk[i] = rk[i-16] ^ t[j]; i++; }
}
return rk;
}
function xtime(a){ return ((a << 1) ^ (a & 128 ? 27 : 0)) & 255; }
function mul(a, b){
var r = 0;
while (b) { if (b & 1) r ^= a; a = xtime(a); b >>= 1; }
return r & 255;
}
function encryptBlock(b, rk){
var i, c, r, t;
for (i = 0; i < 16; i++) b[i] ^= rk[i];
for (var round = 1; round <= 10; round++) {
for (i = 0; i < 16; i++) b[i] = sbox[b[i]];
// ShiftRows按行循环左移 r
for (r = 1; r < 4; r++) {
var row = [b[r], b[r+4], b[r+8], b[r+12]];
for (c = 0; c < 4; c++) b[r + 4*c] = row[(c + r) % 4];
}
if (round < 10) {
for (c = 0; c < 4; c++) {
var o = 4*c, a0 = b[o], a1 = b[o+1], a2 = b[o+2], a3 = b[o+3];
b[o] = mul(a0,2) ^ mul(a1,3) ^ a2 ^ a3;
b[o+1] = a0 ^ mul(a1,2) ^ mul(a2,3) ^ a3;
b[o+2] = a0 ^ a1 ^ mul(a2,2) ^ mul(a3,3);
b[o+3] = mul(a0,3) ^ a1 ^ a2 ^ mul(a3,2);
}
}
for (i = 0; i < 16; i++) b[i] ^= rk[16*round + i];
}
return b;
}
function decryptBlock(b, rk){
var i, c, r, round;
for (i = 0; i < 16; i++) b[i] ^= rk[160 + i];
for (round = 9; round >= 0; round--) {
if (round < 9) {
for (c = 0; c < 4; c++) {
var o = 4*c, a0 = b[o], a1 = b[o+1], a2 = b[o+2], a3 = b[o+3];
b[o] = mul(a0,14) ^ mul(a1,11) ^ mul(a2,13) ^ mul(a3,9);
b[o+1] = mul(a0,9) ^ mul(a1,14) ^ mul(a2,11) ^ mul(a3,13);
b[o+2] = mul(a0,13) ^ mul(a1,9) ^ mul(a2,14) ^ mul(a3,11);
b[o+3] = mul(a0,11) ^ mul(a1,13) ^ mul(a2,9) ^ mul(a3,14);
}
}
for (r = 1; r < 4; r++) {
var row = [b[r], b[r+4], b[r+8], b[r+12]];
for (c = 0; c < 4; c++) b[r + 4*c] = row[(c - r + 4) % 4];
}
for (i = 0; i < 16; i++) b[i] = invSbox[b[i]];
for (i = 0; i < 16; i++) b[i] ^= rk[16*round + i];
}
return b;
}
function b2a(bytes){ var s = ""; for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return s; }
function a2b(str){ var u = new Uint8Array(str.length); for (var i = 0; i < str.length; i++) u[i] = str.charCodeAt(i) & 255; return u; }
function pkcs7(bytes){
var pad = 16 - (bytes.length % 16), out = new Uint8Array(bytes.length + pad);
out.set(bytes); out.fill(pad, bytes.length);
return out;
}
function encrypt(plainText, keyStr){
if (keyStr.length !== 16) throw new Error("密钥长度必须为 16 位");
var data = pkcs7(new TextEncoder().encode(plainText));
var rk = expandKey(new TextEncoder().encode(keyStr));
var out = "";
for (var off = 0; off < data.length; off += 16) {
var b = data.slice(off, off + 16);
encryptBlock(b, rk);
out += b2a(b);
}
return btoa(out);
}
function decrypt(b64Text, keyStr){
if (keyStr.length !== 16) throw new Error("密钥长度必须为 16 位");
var bin = atob(b64Text.trim());
if (bin.length === 0 || bin.length % 16 !== 0) throw new Error("密文长度不合法(须为 16 字节的整数倍)");
var rk = expandKey(new TextEncoder().encode(keyStr));
var bytes = a2b(bin), out = [];
for (var off = 0; off < bytes.length; off += 16) {
var b = bytes.slice(off, off + 16);
decryptBlock(b, rk);
out.push(b2a(b));
}
var all = out.join("");
var u8 = a2b(all);
var pad = u8[u8.length - 1];
if (pad < 1 || pad > 16) throw new Error("解密失败:填充不合法(密钥错误或密文损坏)");
for (var i = u8.length - pad; i < u8.length; i++) if (u8[i] !== pad) throw new Error("解密失败:填充不合法(密钥错误或密文损坏)");
return new TextDecoder().decode(u8.slice(0, u8.length - pad));
}
return { encrypt: encrypt, decrypt: decrypt };
})();
/* ===== 实现结束 ===== */
// 与 Node crypto 对比验证
function nodeEnc(p){ const e = crypto.createCipheriv("aes-128-ecb", KEY, null); return e.update(p,"utf8","base64") + e.final("base64"); }
function nodeDec(c){ const d = crypto.createDecipheriv("aes-128-ecb", KEY, null); return d.update(c,"base64","utf8") + d.final("utf8"); }
const cases = [
"330725198202084328",
"1",
"330725199001011234",
"a",
"abc123你好身份证测试测试", // 多字节 UTF-8 + 非 16 倍数
"十六个字节正好16byte", // 恰好 32 字节 UTF-8?
"x".repeat(16), // 恰好 16 字节(触发整块填充)
"y".repeat(48) // 多块
];
let fail = 0;
for (const p of cases) {
const mine = AESTool.encrypt(p, KEY);
const ref = nodeEnc(p);
if (mine !== ref) { console.log("ENCRYPT MISMATCH:", JSON.stringify(p), mine, ref); fail++; }
const back = AESTool.decrypt(mine, KEY);
if (back !== p) { console.log("ROUNDTRIP FAIL:", JSON.stringify(p), back); fail++; }
}
const sample = "MoV2Ra2USY/6tc6b9xQXZkuEA+I6X3u90CSNgvFqzlE=";
if (AESTool.decrypt(sample, KEY) !== "330725198202084328") { console.log("SAMPLE DECRYPT FAIL"); fail++; }
if (AESTool.encrypt("330725198202084328", KEY) !== sample) { console.log("SAMPLE ENCRYPT FAIL"); fail++; }
if (nodeDec(AESTool.encrypt("交叉验证文本123", KEY)) !== "交叉验证文本123") { console.log("CROSS DECRYPT FAIL"); fail++; }
console.log(fail === 0 ? "AES IMPLEMENTATION VERIFIED" : "FAILED: " + fail);
process.exit(fail === 0 ? 0 : 1);

@ -0,0 +1,67 @@
// 验证将嵌入页面的纯 JS MD5 —— 采用已按 RFC 1321 向量验证通过的 Wikipedia 伪代码式实现(无符号运算)
var MD5 = (function(){
"use strict";
var K = []; for (var i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0;
var S = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,
5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,
6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
function rotl(x,c){ return ((x << c) | (x >>> (32 - c))) >>> 0; }
function md5bytes(bytes){
var msg = bytes.slice();
msg.push(0x80);
while (msg.length % 64 !== 56) msg.push(0);
var bitLen = bytes.length * 8;
for (var i = 0; i < 8; i++) msg.push(Math.floor(bitLen / Math.pow(2, 8*i)) & 0xFF);
var a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
for (var off = 0; off < msg.length; off += 64) {
var M = [];
for (var j = 0; j < 16; j++) M[j] = (msg[off+4*j] | (msg[off+4*j+1] << 8) | (msg[off+4*j+2] << 16) | (msg[off+4*j+3] << 24)) >>> 0;
var A = a0, B = b0, C = c0, D = d0;
for (i = 0; i < 64; i++) {
var F, g;
if (i < 16) { F = (B & C) | (~B & D); g = i; }
else if (i < 32) { F = (D & B) | (~D & C); g = (5*i + 1) % 16; }
else if (i < 48) { F = B ^ C ^ D; g = (3*i + 5) % 16; }
else { F = C ^ (B | ~D); g = (7*i) % 16; }
F = (F + A + K[i] + M[g]) >>> 0;
var oB = B; A = D; D = C; C = B;
B = (oB + rotl(F, S[i])) >>> 0;
}
a0 = (a0 + A) >>> 0; b0 = (b0 + B) >>> 0; c0 = (c0 + C) >>> 0; d0 = (d0 + D) >>> 0;
}
function hex(n){
var s = "";
for (var q = 0; q < 4; q++) s += ((n >>> (8*q)) & 0xFF).toString(16).padStart(2, "0");
return s;
}
return hex(a0) + hex(b0) + hex(c0) + hex(d0);
}
return function(text){ return md5bytes(Array.from(new TextEncoder().encode(text))); };
})();
// RFC 1321 全部标准向量 + Node crypto 交叉验证 + sign 黄金样本
var crypto = require("crypto");
var vectors = [
["", "d41d8cd98f00b204e9800998ecf8427e"],
["a", "0cc175b9c0f1b6a831c399e269772661"],
["abc", "900150983cd24fb0d6963f7d28e17f72"],
["message digest", "f96b697d7cb7938d525a2f31aaf161d0"],
["abcdefghijklmnopqrstuvwxyz", "c3fcd3d76192e4007dfb496cca67e13b"],
["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", "d174ab98d277d9f5a5611c2c9f419d9f"],
["12345678901234567890123456789012345678901234567890123456789012345678901234567890", "57edf4a22be3c955ac49da2e2107b67a"]
];
var fail = 0;
vectors.forEach(function(v){
var got = MD5(v[0]);
if (got !== v[1]) { console.log("FAIL:", JSON.stringify(v[0]).slice(0,40), "got", got); fail++; }
});
["中文测试身份信息","J4jfHM+yZFENVRar0N+/NxgQzgEAdluPWGVyQ4Csq9A=","430703198610010059"].forEach(function(cn){
if (MD5(cn) !== crypto.createHash("md5").update(cn,"utf8").digest("hex")) { console.log("CN FAIL:", cn); fail++; }
});
var sign = MD5("D5flYECKtjoOaXB5UiHkO0DKYvmCk7dFFW7plSwDhPI=" + "1789254840029" + "CKtjoOaXB5UiHkO0DKYvmCk7dFFW7plSwDhPI=" + "1789254840029".slice(6)).toUpperCase();
if (sign !== "4CB87B6061991364D261753E3291F2A1") { console.log("SIGN FAIL:", sign); fail++; }
console.log(fail === 0 ? "MD5 + SIGN ALL VERIFIED" : "FAILED: " + fail);
process.exit(fail ? 1 : 0);

@ -0,0 +1,263 @@
# 实名制采集保存smz.saveSmzcjxx— Java 改写说明
> 对应原 C#`CallClient/TaxTrueNameCaiji.cs` → `BaishuiModel.SmzSaveSmzcjxx`
> 目标:前端传入手机号 / 邮箱 / 固话 / 比对序号Java 后端再转发调用百税 `smz.saveSmzcjxx`
---
## 1. 原 C# 行为摘要
```text
办税员弹窗 TaxerInfo
→ 点击「实名采集」(未采集时可见)
→ 弹出 TaxTrueNameCaiji
→ 用户填写:手机号(必填)、电子邮箱、固定电话
→ 点击登记
→ 调用百税 smz.saveSmzcjxx
入参rxsfzBdxh、sjhm、dzyx、lxdh
→ result.code == "00" → 提示成功,关闭弹窗,隐藏采集按钮并回填手机号
```
比对序号 `rxsfzBdxh` 在原客户端来自办税员窗已加载的 `CompareCode`(取号时人脸比对结果),不在采集窗输入。
---
## 2. 改写后的职责划分
| 角色 | 职责 |
|------|------|
| 前端 | 收集并校验:手机号、电子邮箱、固定电话、人像身份证比对序号;调用 Java API |
| Java 后端 | 接收前端参数 → 校验 → 组装百税请求 → 调用 `smz.saveSmzcjxx` → 将结果返回前端 |
| 百税网关 | 按原协议处理采集保存 |
前端不再直连百税;百税 URL、编码、表单协议只存在于 Java 侧。
---
## 3. Java 对外 API暴露给前端
### 3.1 接口定义(建议)
| 项 | 建议值 |
|----|--------|
| Method | `POST` |
| Path | `/api/smz/save-cjxx`(可按现有工程规范调整) |
| Content-Type | `application/json` |
### 3.2 请求 Body
```json
{
"rxsfzBdxh": "418206",
"sjhm": "13800138000",
"dzyx": "a@example.com",
"lxdh": "0571-88888888"
}
```
| 字段 | 类型 | 必填 | 含义 | 对应百税字段 |
|------|------|------|------|--------------|
| `rxsfzBdxh` | string | 是 | 人像身份证比对序号 | `rxsfzBdxh` |
| `sjhm` | string | 是 | 手机号码 | `sjhm` |
| `dzyx` | string | 否 | 电子邮箱 | `dzyx` |
| `lxdh` | string | 否 | 固定电话 | `lxdh` |
### 3.3 前端校验(与 C# 对齐)
| 规则 | 说明 |
|------|------|
| `sjhm` 不能为空 | C#`所登记的手机号不能为空` |
| 占位文案不作为有效值 | C# 会把「请输入手机号码」等占位当成空串 |
| `rxsfzBdxh` 不能为空 | 原客户端从办税员窗带入Java 版由前端显式传入,后端应二次校验 |
邮箱、固话格式是否严格校验:原 C# 未做正则Java 侧可选增强,非必须。
### 3.4 响应 Body建议
透传百税业务结果,便于前端按原逻辑提示:
```json
{
"success": true,
"code": "00",
"mess": "保存成功"
}
```
| 字段 | 说明 |
|------|------|
| `success` | Java 层业务成功标志(建议:`code == "00"` 或 `"0"` 时为 true |
| `code` | 百税 `result.code` |
| `mess` | 百税 `result.mess`;空时可回落「保存成功」 |
失败示例:
```json
{
"success": false,
"code": "99",
"mess": "无返回错误信息"
}
```
网关/网络异常时返回明确错误信息HTTP 状态可用 `502` / `500`,或统一 `200` + `success=false`(与现有工程约定一致即可)。
---
## 4. Java 调用百税 `smz.saveSmzcjxx`
### 4.1 百税协议(与 BaiShuiSDK 一致)
| 项 | 值 |
|----|-----|
| Method | `POST` |
| URL | 配置项,对应原 `REALNAME_CHECK_API` / `ApiUrl` |
| Content-Type | `application/x-www-form-urlencoded` |
| Body | `domain.ywId=smz.saveSmzcjxx&domain.parmJson={UrlEncode(JSON)}` |
`parmJson` 序列化规则:
1. 整对象转 JSON`ver`、`bid` 及业务字段)
2. **忽略 null 字段**
3. 中文保持原文,不做 `\uXXXX`
4. 再对整段 JSON 做 URL 编码;建议与 C# `HttpUtils.UrlEncode(..., Encoding.Default)` 对齐(中文环境多为 GBK`%XX` 大写),避免 `2003 JSON解析出错`
### 4.2 组装给百税的 `parmJson`
```json
{
"ver": "1.0",
"bid": "smz.saveSmzcjxx",
"rxsfzBdxh": "418206",
"sjhm": "13800138000",
"dzyx": "a@example.com",
"lxdh": "0571-88888888"
}
```
字段映射前端入参原样写入同名字段空的可选字段不传null 忽略)。
### 4.3 百税响应
HTTP 外层:
```json
{
"domain": {
"ywId": "smz.saveSmzcjxx",
"retJson": "{\"result\":{\"code\":\"00\",\"mess\":\"保存成功\"}}"
}
}
```
解析 `domain.retJson` 后:
```json
{
"result": {
"code": "00",
"mess": "保存成功"
}
}
```
无额外业务字段(`JhxtSaveSmzcjxxResult` 仅含 `code` / `mess`)。
C# SDK 中 `SmzSaveSmzcjxx` **不强制**校验 `code == "00"` 后抛异常,而是把 `result` 交回 UI 判断。Java 建议:
- 能解析出 `result`:按 `code` 组装对外响应,不抛中断(除非工程统一要求失败抛异常)
- 通信失败 / 无 `retJson`:记日志并返回失败给前端
---
## 5. 后端处理流程
```text
① 接收前端 JSON
② 校验 sjhm、rxsfzBdxh 非空(可选:手机号格式)
③ 组装 BsRequest
ver=1.0, bid=smz.saveSmzcjxx,
rxsfzBdxh, sjhm, dzyx?, lxdh?
④ POST 百税网关form-urlencoded + UrlEncode
⑤ 解析 domain.retJson → result.code / result.mess
⑥ 返回给前端success / code / mess
```
伪代码:
```java
@PostMapping("/api/smz/save-cjxx")
public ApiResult saveCjxx(@RequestBody SaveSmzcjxxReq req) {
if (isBlank(req.getSjhm())) {
return ApiResult.fail("所登记的手机号不能为空");
}
if (isBlank(req.getRxsfzBdxh())) {
return ApiResult.fail("人像身份证比对序号不能为空");
}
Map<String, Object> parm = new LinkedHashMap<>();
parm.put("ver", "1.0");
parm.put("bid", "smz.saveSmzcjxx");
parm.put("rxsfzBdxh", req.getRxsfzBdxh());
parm.put("sjhm", req.getSjhm());
if (notBlank(req.getDzyx())) parm.put("dzyx", req.getDzyx());
if (notBlank(req.getLxdh())) parm.put("lxdh", req.getLxdh());
BsResult result = bsClient.execute("smz.saveSmzcjxx", parm);
boolean ok = "00".equals(result.getCode()) || "0".equals(result.getCode());
String mess = blankToDefault(result.getMess(), ok ? "保存成功" : "无返回错误信息");
return new ApiResult(ok, result.getCode(), mess);
}
```
---
## 6. 配置项
| 配置键(建议) | 含义 | 对应原系统 |
|----------------|------|------------|
| `baishui.api-url` / `REALNAME_CHECK_API` | 百税网关完整 URL | `TaxTrueNameCaiji.ApiUrl` / `SystemControl.Get("REALNAME_CHECK_API")` |
| 超时时间 | HTTP 超时 | C# `PostSync` 约 60s 量级 |
---
## 7. 前端对接要点(办税员窗复刻)
1. 打开采集弹窗前,确保已有 `rxsfzBdxh`(来自票的比对序号 / 办税员信息加载结果)。
2. 用户填写 `sjhm`(必填)、`dzyx`、`lxdh`。
3. 调用 Java `/api/smz/save-cjxx`
4. `success == true`(或 `code == "00"`
- 提示成功
- 关闭弹窗
- 隐藏「实名采集」按钮
- 回填展示手机号(原 `SetTruceNameCaijiOk`
5. 失败:展示 `mess`
说明:查询是否已采集仍可走原逻辑对应的 `smz.getSmzcjxx`(若也改写到 Java另文说明**本文仅覆盖采集保存**。
---
## 8. 实现清单
| 优先级 | 项 | 说明 |
|--------|----|------|
| P0 | 对外 REST API | 暴露四个参数,校验手机号与比对序号 |
| P0 | 百税 Client | form-urlencoded + `domain.ywId` / `domain.parmJson` + UrlEncode |
| P0 | 调用 `smz.saveSmzcjxx` | 字段映射与响应解析 |
| P1 | 统一错误码 / 日志 | 记录入参(可脱敏手机号)与百税返回 |
| P2 | 手机号格式校验 | 原 C# 未做,可选 |
---
## 9. 源码索引
| 说明 | 路径 |
|------|------|
| 采集弹窗 / 提交 | `CallClient/TaxTrueNameCaiji.cs` |
| 办税员窗打开采集 | `CallClient/WPF/TaxerInfo.xaml.cs``Caiji_Click` |
| 成功回调 | `TaxerInfo.SetTruceNameCaijiOk` |
| 请求模型 | `BaiShuiSDK/Request/SmzJhxtSaveSmzcjxxRequest.cs``bid = smz.saveSmzcjxx` |
| 响应模型 | `BaiShuiSDK/Response/SmzJhxtSaveSmzcjxxResponse.cs` |
| SDK 封装 | `BaiShuiSDK/Model/BaishuiModel.cs``SmzSaveSmzcjxx` |
| HTTP 协议 | `BaiShuiSDK/Client/BsDefaultClient.cs` |
| 协议参考 | `BaiShuiSDK/smz.rxsfzBd-接口说明.md`协议相同bid 不同) |

@ -0,0 +1,226 @@
# 智能导税 PAD — 功能需求说明书(优化稿)
> 依据合同附件《紫云智慧办税大厅排队取号系统功能需求说明书》原「3. 智能导税 PAD」章节并结合当前 **紫云智能导税tax-guidance** 客户端实现能力修订。
> 说明:本文仅描述功能与技术参数,不涉及源代码。
---
## 1. 产品定位
**智能导税 PAD** 是导税员 / 预审人员 / 大厅管理员在办税服务大厅使用的 **横屏平板业务终端**,部署于取号区或导税台。
产品对接紫云 HES 排队叫号系统(经公网统一网关),覆盖 **登录认证、大厅概况、预检取号、普通取号、实名采集、票号管理、大厅监控、参数维护、回签优先、信息推送** 等现场作业能力,实现身份核验与排队取号业务闭环。
| 项目 | 说明 |
|------|------|
| 产品名称 | 紫云智能导税 |
| 版本 | 1.0.0 |
| 运行形态 | PAD / 平板 App横屏优先 |
| 适用场景 | 税务局办税服务厅、政务服务中心 |
---
## 2. 功能需求
### 2.1 登录与会话
| 功能点 | 需求说明 |
|--------|----------|
| 账号登录 | 支持用户名 + 密码登录;登录成功后进入智能导税首页 |
| 凭证保持 | 登录成功后本地保存访问令牌与用户信息;支持记住账号密码便于再次登录 |
| 会话续期 | 应用从锁屏 / 后台回到前台时,自动校验或刷新登录态;短时锁屏应尽量保持登录,避免频繁重登 |
| 退出登录 | 首页用户区支持退出;退出后清除登录态并返回登录页 |
| 运行日志 | 支持在用户菜单中查看客户端运行日志,便于现场排查网络、登录与接口问题;支持清空日志 |
### 2.2 智能导税首页
| 功能点 | 需求说明 |
|--------|----------|
| 大厅概况 | 展示当前等候人数、今日预约人数、空闲窗口数、空闲自助机数;支持定时自动刷新 |
| 概况下钻 | 点击概况指标可进入大厅详情,查看窗口与自助设备状态 |
| 功能入口 | 提供预检取号、票号管理、普通取号、信息推送、回签优先、网格员等入口(部分为预留扩展) |
| 用户信息 | 右上角展示当前登录人员姓名 / 头像 |
### 2.3 预检取号(核心)
面向已完成或待完善实名核验的纳税人,由导税员查询记录并完成取号、采集与报告查看。
| 功能点 | 需求说明 |
|--------|----------|
| 条件查询 | 支持按姓名、身份证号、手机号模糊查询;支持分页 |
| 列表展示 | 展示姓名、身份证(脱敏)、手机号(脱敏)、票号、核验结果文案、核验时间 |
| 查看照片 | 有人脸抓拍信息时,可弹窗查看人脸照片 |
| 实名采集 | 当核验结果文案包含「未采集」时显示「采集」按钮;填写手机号(必填)、电子邮箱、固定电话后提交实名采集保存;成功后回填手机号并隐藏采集按钮 |
| 健康报告 | 根据身份证获取并弹窗展示税务健康检查报告 |
| 取号 | 选择业务类型后取号;无手机号时须先补录;取号成功提示票号,并刷新列表 |
| 列表刷新 | 页面停留期间支持定时自动刷新核验列表,保证现场数据时效 |
### 2.4 普通取号
| 功能点 | 需求说明 |
|--------|----------|
| 入口 | 首页「普通取号」直接打开业务选择弹窗(无需先进入预检列表) |
| 手机号必填 | 普通取号须先填写手机号码 |
| 业务选择 | 展示可办业务及各业务当前等候人数;每次打开时刷新等候人数 |
| 取号类型 | 普通取号与预检取号在业务侧区分类型(如 normal / realname分别满足大厅不同取号场景 |
### 2.5 票号管理
| 功能点 | 需求说明 |
|--------|----------|
| 分类筛选 | 支持企业 / 个人等分类及等候中、已完成、预约号等状态筛选 |
| 条件筛选 | 支持按时间、业务类型、票号关键字等条件查询 |
| 详情与导税 | 支持查看票号详情;可进行导税相关操作或跳转导税辅助流程 |
| 健康报告 | 支持查看关联纳税人的税务健康检查报告 |
### 2.6 大厅详情(监控)
| 功能点 | 需求说明 |
|--------|----------|
| 窗口监控 | 展示人工窗口名称、可办业务、当前票号、状态(空闲 / 忙碌等)、等候人数 |
| 自助设备 | 展示自助办税设备名称及使用状态(空闲 / 使用中 / 维护中等) |
| 手动刷新 | 支持一键刷新监控数据 |
### 2.7 大厅控制(参数维护)
| 功能点 | 需求说明 |
|--------|----------|
| 参数列表 | 表格展示大厅系统参数名称、Key、当前值 |
| 参数修改 | 支持弹窗编辑参数值Key / 名称只读);修改前二次确认;保存后刷新列表 |
### 2.8 回签优先
| 功能点 | 需求说明 |
|--------|----------|
| 回签(复号) | 支持输入或扫码票号,确认后执行回签 |
| 优先取号 | 按业务类型网格选择,生成优先(插队)票号 |
### 2.9 信息推送与小票模板
| 功能点 | 需求说明 |
|--------|----------|
| 语音呼叫 | 按票号、窗口等信息发起语音呼叫 |
| 信息屏推送 | 向同步屏 / 综合屏推送文字内容 |
| 短信推送 | 向指定手机号发送短信内容 |
| 小票模板 | 支持加载、编辑样式并保存打印小票模板配置 |
### 2.10 其他与扩展
| 功能点 | 需求说明 |
|--------|----------|
| 今日进厅 / 今日预约 | 支持进厅与预约相关数据查询(按大厅业务需要开放入口) |
| 网格员 / 多合一 / 预留功能 | 预留扩展入口,便于后续版本增加现场协作与综合办事能力 |
---
## 3. 典型业务场景PAD
### 场景 A预检取号
1. 导税员登录 PAD进入「预检取号」
2. 按姓名 / 身份证 / 手机号查询核验记录
3. 查看核验结果;如提示「未采集」,完成实名采集
4. 必要时查看人脸照片、健康报告
5. 选择业务类型取号,纳税人凭票等候叫号
### 场景 B普通取号
1. 首页点击「普通取号」
2. 填写手机号,选择业务类型
3. 取号成功后提示票号
### 场景 C大厅运维
1. 首页查看等候与空闲窗口概况
2. 进入大厅详情核对窗口 / 自助机状态
3. 在大厅控制中调整营业时间等系统参数
4. 需要时在回签优先中处理复号或插队
---
## 4. 非功能与安全要求
| 类别 | 要求 |
|------|------|
| 交互形态 | 横屏大屏触控布局,适配柜台支架与导税台操作 |
| 数据脱敏 | 列表中身份证、手机号须脱敏展示 |
| 身份认证 | 业务接口携带登录令牌;登录接口除外 |
| 请求安全 | 业务请求经统一网关,采用签名校验(防篡改 / 防重放);支持链路追踪标识 |
| 可靠性 | 锁屏或短暂切后台后应尽量自动续会话;鉴权失效时明确提示并引导重新登录 |
| 可运维性 | 提供客户端运行日志查看与清空能力,便于现场排障 |
---
## 5. 技术参数
### 5.1 开发语言与框架
| 项目 | 说明 |
|------|------|
| 开发语言 | JavaScript / TypeScript前端页面以 Vue SFC 为主) |
| 前端框架 | Vue 3Composition API |
| 跨端框架 | uni-appVue3 编译器) |
| UI 组件 | TuniaoUI、uni-ui 等 |
| 样式 | SCSS |
### 5.2 开发工具与工程环境
| 项目 | 说明 |
|------|------|
| 主要 IDE | HBuilderXuni-app 官方推荐);亦可配合 Visual Studio Code / Cursor 进行源码编辑 |
| 包管理 / 构建 | 遵循 uni-app 工程规范HBuilderX 发行打包 / 云打包或本地打包) |
| 版本管理 | Git |
| 目标 AppID | 以工程 `manifest` 配置为准(当前工程标识:紫云智能导税) |
### 5.3 开发环境建议
| 项目 | 说明 |
|------|------|
| 操作系统 | Windows 10/11开发机可配合 macOS若需 iOS 相关调试) |
| Node.js | 建议使用当前 LTS 版本(与 uni-app / HBuilderX 兼容) |
| 调试终端 | Android 平板 / HarmonyOS 平板真机HBuilderX 标准基座或自定义调试基座 |
| 联调后端 | 紫云 HES pad-api经公网统一 `/public` 网关);开发 / 生产网关地址按部署环境配置 |
### 5.4 运行环境
| 项目 | 说明 |
|------|------|
| 终端设备 | 办税大厅 PAD / 平板(推荐 10 寸及以上,横屏支架固定) |
| 操作系统 | AndroidHarmonyOS工程已配置鸿蒙应用包名与平板设备类型可按项目需要扩展 iOS |
| 屏幕方向 | 横屏(应用全局横屏配置) |
| 网络 | 可访问公网统一业务网关HTTPS/HTTP 以部署为准) |
| 关联硬件(可选) | 人脸识别 / 身份证核验一体机(用于实名核验数据采集,与 PAD 业务联动);打印机(小票,按现场对接) |
### 5.5 对接与接口形态
| 项目 | 说明 |
|------|------|
| 接入方式 | 客户端 → 公网统一网关 `/public` → 内网 HES pad-api |
| 报文约定 | `tag + path + query/body` 标准化报文 |
| 安全机制 | Bearer Token业务请求 HMAC-SHA256 签名(含 timestamp、nonce登录等少数接口可免签 |
| 主要业务域(示例) | 认证auth、大厅系统hallSystem、业务类型business、票号ticket、窗口监控window、预约appointment、实名采集smz、打印print等 |
### 5.6 部署方式
| 项目 | 说明 |
|------|------|
| 安装形态 | 各大厅按需安装多台 PAD 客户端 |
| 升级方式 | 应用商店 / 内网安装包 / MDM 分发(按甲方现场规范) |
| 与整体系统关系 | 作为紫云排队取号体系中的「导税端」终端,与中心管理平台、大厅边缘服务、窗口呼叫终端、广播同步屏协同工作 |
---
## 6. 与附件原文对照说明(修订要点)
相对合同附件原「3. 智能导税 PAD」条目本优化稿主要补充
1. **预检取号细节**:查询字段、脱敏、人脸照片、健康报告、列表自动刷新
2. **实名采集**:按「未采集」文案触发采集登记(手机号必填等)
3. **普通取号**:与预检取号分流,强调手机号必填与等候人数刷新
4. **信息推送 / 小票模板**:语音、信息屏、短信、模板维护
5. **登录会话与运维日志**:续会话、退出、日志查看
6. **技术参数专章**:开发语言、工具、开发 / 运行环境、网关对接方式
---
*本优化稿供更新《紫云智慧办税大厅排队取号系统功能需求说明书》中「智能导税 PAD」章节使用如需回填 Word/PDF 正文,可直接替换原第 3 节及相关部署表中 PAD 描述。*

@ -0,0 +1,259 @@
# 预检取号列表「一键体检 / 智能提醒」数据需求分析
> 视角:产品经理(现场导税作业,不以技术方案为先)
> 依据:参考图中进厅人员表格的 **一键体检**、**智能提醒** 两列,对照当前紫云智能导税 PAD「预检取号」能力。
> 说明:图中栏名为「一键体检」;现场口头常称「一键提醒」。本文按图中展示内容定义需求,统一称为 **一键体检**。
> 本文只说明 **要什么数据、为什么要、怎么用**,不涉及源代码。
---
## 1. 要解决什么问题
导税员在取号前需要 **35 秒内** 判断:
1. 这个人/关联企业有没有风险、欠申报、违法违章,要不要先提醒再办?
2. 这个人是什么身份标签、历史上评价好不好,沟通时要注意什么?
当前 PAD 预检列表只有核验结果、票号、健康报告入口,**列表内看不到结构化风险摘要和历史服务画像**。健康报告是整页外链,不适合作为「一眼扫描」信息。
图中两栏的产品价值是:**把报告结论压成列表摘要,把详情留给点击下钻。**
---
## 2. 列表栏位定义(对照参考图)
| 栏位 | 现场用途 | 展示形态 | 是否必须可点 |
|------|----------|----------|----------------|
| 一键体检 | 税务/合规风险扫描摘要 | 多行短文案 + 颜色预警 | 是,点开看明细 |
| 智能提醒 | 人员标签 + 历史服务评价画像 | 多行短文案 | 建议可点,看评价明细 |
不合规辅导(图中另有一列)不在本次范围。
---
## 3. 身份主键:先对齐「查谁」
两栏都不是排队系统自己能算出来的,必须先锁定 **自然人身份**,再关联企业和历史办税记录。
### 3.1 查询主键(必选)
| 数据项 | 说明 | 当前 PAD 是否已有 |
|--------|------|------------------|
| 身份证号 | 自然人唯一检索键;列表已脱敏展示,接口侧需明文或安全令牌 | 有(`idCard` |
| 姓名 | 辅助核对,防串人 | 有 |
| 实名核验记录 UID | 对应本次进厅/核验流水,便于追溯「哪一次进厅」 | 有(`uid` |
| 手机号 | 用于短信提醒、评价匹配辅助 | 有(可能为空,采集后才有) |
### 3.2 建议补齐的关联键
| 数据项 | 为什么需要 |
|--------|------------|
| 纳税人识别号 / 统一社会信用代码列表 | 一人多户,体检要按「关联企业」汇总 |
| 人员身份类型 | 自然人 / 办税员 / 中介 / 代理记账,影响提醒话术和权限 |
| 实名采集状态 | 图中姓名旁「已采集 / 未采集」;未采集时部分金三数据可能查不到 |
| 本次进厅时间 / 票号 | 把体检结果挂在「这一次进厅」上,避免和历史进厅混淆 |
**产品结论:** 没有可靠身份证号,这两栏应显示「暂无法体检 / 暂无提醒」,而不是空白让导税员误以为「没问题」。
---
## 4. 「一键体检」需要什么数据
产品目标:用 **36 条短句** 回答「这个人关联的企业现在健不健康」。
### 4.1 列表摘要(每行必出)
| 摘要项 | 参考图文案 | 所需底层数据 | 展示规则建议 |
|--------|------------|--------------|----------------|
| 关联企业户数 | 「2户关联企业」 | 该身份证作为法人/财务/办税员/股东等角色关联的企业数量 | 0 户时写「无关联企业」 |
| 风险预警分色户数 | 「预警: 1户红色」「预警: 3户蓝色」 | 每户风险等级(红/黄/蓝/绿或局方标准色)及户数 | 只展示非绿色的汇总;多种颜色分行或同行分隔 |
| 未处理违法违章条数 | 「12条未处理违法违章」 | 未办结/未处理的违法违章记录数 | 0 可不展示或灰字「无违法违章」 |
| 未申报条数 | 「1条未申报」 | 当期或历史未申报税种/申报任务数 | 需约定统计口径:本期未申报 vs 全部未申报 |
以上四项是图中 **最低可交付摘要**。没有这四项,栏位无法复现参考效果。
### 4.2 点击下钻(导税真正要看的)
列表只给结论。点「一键体检」后,至少要能看到:
| 分组 | 需要的数据 | 用途 |
|------|------------|------|
| 关联企业清单 | 企业名称、税号、登记状态、与该人的关系(法人/财务负责人/办税员等) | 确认提醒对象是哪几户 |
| 分户风险 | 每户风险色、风险标签名称、风险产生时间、是否已处置 | 决定红户是否优先辅导 |
| 违法违章明细 | 违法类型、文书号/编号、发生日期、处理状态、所属税种 | 告知纳税人「还有哪些没处理」 |
| 申报情况 | 税种、所属期、申报期限、申报状态(未申报/已申报/逾期) | 提醒当期该报未报 |
| 可选增强 | 欠税余额、非正常户、注销办理中、发票异常、信用等级 | 大厅高频问询,可二期 |
### 4.3 口径必须产品拍板的问题(否则研发会对不齐)
1. **关联企业范围**只算法人还是办税员、财务负责人、股东都算图中有「21户关联企业」更像是全角色汇总。
2. **预警颜色标准**:红/蓝对应局方哪套风险模型(税收风险、信用、还是大厅自定义)?
3. **未申报时间窗**:本征期、近 12 个月,还是全部历史?
4. **未处理违法违章**:是否含已送达未缴款、听证中、复议中?
5. **一人多证/曾用名**:是否按历史证件合并?
这些不定,数据源给了也没法验收。
### 4.4 数据源(产品侧依赖,不指定实现)
通常来自税务核心或外挂风控,而不是排队叫号库:
- 金税三期 / 电子税务局:登记、申报、违法违章
- 风险/信用系统:红黄蓝预警
- 现有「健康报告」页:可能已有部分结论,但 **HTML 报告 ≠ 列表结构化字段**,需要单独的摘要接口
当前 PAD 仅有「按身份证换健康报告 URL」**不能直接支撑列表摘要**。
---
## 5. 「智能提醒」需要什么数据
产品目标:告诉导税员 **这个人是谁、过去在大厅体验如何**,避免对差评客投诉式沟通、对专业服务人员用错话术。
### 5.1 列表摘要(每行必出)
| 摘要项 | 参考图文案 | 所需底层数据 | 展示规则建议 |
|--------|------------|--------------|----------------|
| 人员标签 | 「标签: 涉税专业服务人员」 | 标签编码 + 标签名称,可多个 | 无标签显示「暂无标签」;多个时取优先级最高的 12 个 |
| 评价次数 | 「评价: 20次」 | 历史有效评价总次数 | 0 次显示「暂无评价」 |
| 最低评价 | 「最低评价: 非常满意 / 不满意」 | 历史最低一档评价等级 | 无评价则不展示该行 |
| 分档次数 | 「非常满意: 18次」「基本满意: 1次」「不满意: 1次」 | 各评价档位计数 | 与大厅评价器档位对齐 |
图中档位至少包括:非常满意、基本满意、不满意。需与现有窗口评价器档位(如 06 星或三档/五档)做 **映射表**,否则列表文案和窗口评价对不上。
### 5.2 点击下钻
| 需要的数据 | 用途 |
|------------|------|
| 标签全量列表及来源(系统打标 / 人工打标) | 解释为什么是「专业服务人员」 |
| 评价明细:时间、大厅、窗口、票号、档位、是否文字评语 | 判断差评是否偶发 |
| 最近一次差评原因(若有) | 导税话术准备 |
| 可选:历史办税频次、常办业务类型 | 主动分流(如常来代开) |
### 5.3 口径必须产品拍板的问题
1. **评价统计范围**:本大厅、本市、全省?跨大厅是否共享?
2. **时间窗**:全部历史还是近 1 年?
3. **最低评价**:取最低档,还是近 N 次中的最低?
4. **标签来源**:金三职业资格、实名身份、大厅自定义?谁维护、谁能改?
5. **未实名 / 未采集**:能否出标签和评价?建议不能则明确提示。
### 5.4 数据源
- 排队叫号:窗口评价器回写的票号评价(本系统相对可控)
- 统一身份 / 实名:人员标签
- 可能还有局方纳税人画像平台
当前票号详情已有「单票评价」,但 **没有按身份证汇总的历史评价画像**,智能提醒栏无法只靠单票数据完成。
---
## 6. 列表接口应返回的最小数据包(产品验收用)
建议在预检核验列表 **每条记录上附带摘要对象**,避免导税员一屏 10 人再打 20 次外部查询。
### 6.1 一键体检摘要 `healthSummary`
| 字段含义 | 类型 | 必填 |
|----------|------|------|
| 是否可体检 | 是/否 | 是 |
| 不可体检原因 | 文案(无身份证/未采集/接口失败) | 否 |
| 关联企业户数 | 数字 | 可体检时必填 |
| 红色预警户数 | 数字 | 可体检时必填 |
| 蓝色预警户数 | 数字 | 可体检时必填 |
| 其他色预警户数 | 数字(若有黄/橙) | 否 |
| 未处理违法违章条数 | 数字 | 可体检时必填 |
| 未申报条数 | 数字 | 可体检时必填 |
| 摘要生成时间 | 时间 | 建议有,避免过期数据 |
颜色名称按局方标准扩展,不要写死只有红蓝。
### 6.2 智能提醒摘要 `smartReminder`
| 字段含义 | 类型 | 必填 |
|----------|------|------|
| 是否可生成提醒 | 是/否 | 是 |
| 主标签列表 | 名称数组(最多 2 个用于列表) | 否 |
| 评价总次数 | 数字 | 是(可为 0 |
| 最低评价档位 | 枚举文案 | 有评价时必填 |
| 各档位次数 | 档位→次数 | 有评价时必填 |
### 6.3 性能与时效(产品约束)
| 约束 | 建议 |
|------|------|
| 列表刷新 | 预检页约 15 秒静默刷新,摘要必须能批量带出或短缓存 |
| 超时 | 单行外部画像超过 12 秒则该行显示「体检生成中/失败」,不能拖死整表 |
| 缓存 | 同一身份证当日可缓存摘要;取号成功或采集成功后应刷新 |
| 失败 | 明确「查询失败」与「查询成功但无风险」两种空态,禁止都显示成空白 |
---
## 7. 交互与权限(数据之外但影响要哪些字段)
| 点 | 产品要求 | 对数据的影响 |
|----|----------|----------------|
| 点击摘要 | 打开详情(可用现有健康报告页,或新做成结构化详情) | 详情接口需要企业清单、明细列表 |
| 红户是否阻断取号 | 若「先辅导再取号」,需要风险等级字段做前端提示,不必直接禁点取号 | 至少要有最高风险色 |
| 脱敏 | 列表不展示完整税号、法人手机;详情可按权限展示 | 列表用脱敏,详情用完整 |
| 日志 | 导税员点开体检/提醒应可审计 | 需要操作人、纳税人身份证哈希、时间 |
---
## 8. 与现有 PAD 能力的差距
| 现有能力 | 能否支撑两栏 | 缺口 |
|----------|----------------|------|
| 预检核验列表(姓名/身份证/手机/票号/核验结果) | 只能提供查询主键 | 无风险摘要、无评价汇总 |
| 健康报告 URL | 可作「下钻详情」候选 | 不是结构化摘要,不能直接铺进表格 |
| 窗口评价(按票号) | 是评价明细的数据来源之一 | 缺按人汇总、缺最低评价、缺分档计数 |
| 实名采集状态 | 影响能否查到金三数据 | 需在摘要里体现「未采集则无法体检」 |
---
## 9. 分期建议(便于排期)
### P0没有这些栏位不成立
1. 身份证号可用时,返回关联企业户数、分色预警户数、未处理违法违章数、未申报数
2. 按人汇总:评价次数、最低档、分档次数
3. 两种空态:无法查询 / 查询成功无数据
4. 列表批量带摘要,保证刷新不卡
### P1
1. 点击进入分户风险和企业清单
2. 人员标签
3. 与健康报告详情打通(摘要 + 原报告)
### P2
1. 欠税、非正常户、发票异常等扩展指标
2. 差评原因、常办业务
3. 红户取号前提示话术模板
---
## 10. 验收标准(产品可用)
对照参考图,同一纳税人在列表中应能同时看到类似信息:
- 一键体检:`N户关联企业` + `预警: X户红色 / Y户蓝色` + `M条未处理违法违章` + `K条未申报`(无则按空态文案)
- 智能提醒:`标签: xxx` + `评价: N次` + `最低评价: xxx` + 各档次数
点开后能核对企业清单或评价明细;无身份证或未采集时有明确原因,不出现「空白=没问题」。
---
## 11. 需要业务/局方确认的清单(开会用)
1. 「一键体检」官方栏名是否沿用图中名称,还是改成「一键提醒」
2. 关联企业统计角色范围
3. 预警色标准与数据提供系统
4. 未申报、违法违章的时间窗与状态范围
5. 评价是否跨大厅汇总、档位如何映射
6. 标签由谁维护
7. 列表摘要是否允许展示具体条数(有的局方只允许「有/无」)
8. 详情是否继续用现有健康报告页,还是大厅自建结构化页
以上 8 条不确定,接口字段无法定稿。

@ -0,0 +1,365 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>预检取号 · 一键体检 / 智能提醒 预览</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background: #eef3f9;
color: #1e293b;
height: 100vh;
overflow: hidden;
}
.page {
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
}
.toolbar-left { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.back-btn {
min-width: 84px; height: 40px; padding: 0 14px;
border: 1px solid #c7d7ea; border-radius: 10px;
background: #fff; color: #1e3a8a; font-weight: 600; cursor: pointer;
}
.title-wrap { padding-left: 12px; border-left: 3px solid #1d4ed8; }
.title { font-size: 18px; font-weight: 700; color: #1e3a8a; }
.sub { margin-top: 2px; font-size: 11px; color: #64748b; letter-spacing: 0.5px; }
.badge {
margin-left: 8px; padding: 2px 8px; border-radius: 999px;
font-size: 11px; color: #1d4ed8; background: #eff6ff; font-weight: 600;
}
.tool-input { display: flex; flex: 1; gap: 10px; min-width: 0; }
.search-field {
flex: 1; height: 42px; display: flex; align-items: center;
padding: 0 12px; background: #f4f7fb; border: 1px solid #d7e2ee; border-radius: 10px;
}
.search-label { margin-right: 8px; font-size: 12px; color: #64748b; font-weight: 600; white-space: nowrap; }
.search-field input {
flex: 1; border: 0; outline: none; background: transparent; font-size: 14px; color: #1e293b;
}
.tools-btn { display: flex; gap: 8px; flex-shrink: 0; }
.btn {
height: 42px; min-width: 88px; border: none; border-radius: 10px;
font-size: 14px; font-weight: 600; cursor: pointer;
}
.btn-primary { background: #2563eb; color: #fff; box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22); }
.btn-ghost { background: #fff; color: #1e3a8a; border: 1px solid #c7d7ea; }
.table-wrap { flex: 1; min-height: 0; padding: 12px 16px; overflow: auto; }
.table-card {
background: #fff; border: 1px solid #d7e2ee; border-radius: 12px;
overflow: hidden; box-shadow: 0 10px 28px rgba(30, 58, 138, 0.05);
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th {
background: #f3f6fb; color: #475569; font-size: 13px; font-weight: 600;
padding: 10px 8px; border-bottom: 1px solid #e5e7eb; text-align: center;
}
td {
font-size: 12px; color: #1e293b; padding: 10px 8px;
border-bottom: 1px solid #f1f5f9; vertical-align: top; text-align: center;
}
tr:nth-child(even) td { background: #f8fafc; }
.col-name { width: 86px; }
.col-id { width: 128px; }
.col-phone { width: 108px; }
.col-ticket { width: 72px; }
.col-verify { width: 120px; }
.col-time { width: 148px; }
.col-photo { width: 72px; }
.col-health { width: 210px; text-align: left; }
.col-smart { width: 240px; text-align: left; }
.col-op { width: 168px; }
td.col-health, td.col-smart, th.col-health, th.col-smart { text-align: left; }
.summary { line-height: 1.55; color: #334155; cursor: pointer; }
.summary:hover { color: #1d4ed8; }
.warn-red { color: #dc2626; font-weight: 700; }
.warn-blue { color: #2563eb; font-weight: 700; }
.muted { color: #94a3b8; }
.link { color: #2563eb; background: none; border: 0; cursor: pointer; font-size: 12px; }
.row-actions { display: flex; gap: 4px; justify-content: flex-start; }
.table-action {
min-width: 56px; height: 28px; padding: 0 10px; border: none; border-radius: 8px;
font-size: 12px; cursor: pointer; color: #fff;
background-image: linear-gradient(to bottom right, #60a5fa, #1d4ed8);
}
.table-action.gray {
color: #111827; background: #e5e7eb; background-image: none;
}
.footer {
flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;
padding: 10px 20px; background: #fff; border-top: 1px solid #d7e2ee;
}
.page-span { color: #1d4ed8; margin: 0 4px; font-weight: 700; }
.page-actions { display: flex; align-items: center; gap: 12px; }
.page-pill {
min-width: 92px; height: 42px; border-radius: 21px; background: #eef3f9;
display: flex; align-items: center; justify-content: center; font-weight: 600; color: #64748b;
}
.page-pill b { color: #1e3a8a; font-size: 18px; margin-right: 4px; }
.page-btn {
min-width: 112px; height: 42px; border-radius: 21px; border: 1px solid #c7d7ea;
background: #fff; color: #1e3a8a; font-weight: 600; cursor: pointer;
}
.page-btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
.page-btn:disabled { opacity: 0.4; }
.mask {
display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45);
align-items: center; justify-content: center; z-index: 50;
}
.mask.show { display: flex; }
.modal {
width: 560px; max-width: 92vw; max-height: 80vh; overflow: auto;
background: #fff; border-radius: 16px; box-shadow: 0 18px 48px rgba(30, 58, 138, 0.16);
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #eef2f7;
}
.modal-head h3 { color: #1e3a8a; font-size: 18px; }
.close { width: 32px; height: 32px; border: 0; border-radius: 16px; background: #eef3f9; cursor: pointer; }
.modal-body { padding: 16px 20px 20px; font-size: 14px; line-height: 1.7; }
.kv { margin-bottom: 8px; }
.kv b { color: #475569; font-weight: 600; }
.note { margin-top: 12px; font-size: 12px; color: #94a3b8; }
</style>
</head>
<body>
<div class="page">
<div class="toolbar">
<div class="toolbar-left">
<button class="back-btn" onclick="alert('预览页:返回仅作展示')">← 返回</button>
<div class="title-wrap">
<div class="title">预检取号 <span class="badge">展示预览</span></div>
<div class="sub">核验记录查询与现场取号 · 含一键体检 / 智能提醒示意</div>
</div>
</div>
<div class="tool-input">
<label class="search-field"><span class="search-label">姓名</span><input id="qName" placeholder="模糊匹配" /></label>
<label class="search-field"><span class="search-label">身份证</span><input id="qId" placeholder="模糊匹配" /></label>
<label class="search-field"><span class="search-label">手机号</span><input id="qPhone" placeholder="模糊匹配" /></label>
</div>
<div class="tools-btn">
<button class="btn btn-primary" onclick="applyFilter()">查询</button>
<button class="btn btn-ghost" onclick="resetFilter()">重置</button>
</div>
</div>
<div class="table-wrap">
<div class="table-card">
<table>
<thead>
<tr>
<th class="col-name">姓名</th>
<th class="col-id">身份证</th>
<th class="col-phone">手机号</th>
<th class="col-ticket">票号</th>
<th class="col-verify">核验结果</th>
<th class="col-time">核验时间</th>
<th class="col-photo">查看照片</th>
<th class="col-health">一键体检</th>
<th class="col-smart">智能提醒</th>
<th class="col-op">操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
</div>
<div class="footer">
<div><span class="page-span" id="totalText">0</span>条 · 每页 10 条</div>
<div class="page-actions">
<button class="page-btn" id="prevBtn" onclick="changePage(-1)">上一页</button>
<div class="page-pill"><b id="pageNum">1</b> / <span id="pageTotal">1</span></div>
<button class="page-btn primary" id="nextBtn" onclick="changePage(1)">下一页</button>
</div>
</div>
</div>
<div class="mask" id="mask" onclick="if(event.target.id==='mask') closeModal()">
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">详情</h3>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script>
const ALL = [
{
name: "戴瑾", idCard: "330123199001015626", phone: "13800138001", ticketId: "A003",
baishuiResp: "已采集,已注册", verifyTime: "2026-08-21 08:08:47", hasPhoto: true, needCollect: false,
health: { companyCount: 2, red: 1, blue: 0, violation: 12, undeclare: 0 },
smart: { tag: "涉税专业服务人员", total: 20, lowest: "非常满意", very: 20, basic: 0, bad: 0 }
},
{
name: "易华斌", idCard: "330102198512123318", phone: "13900139002", ticketId: "Q001",
baishuiResp: "已采集,已注册", verifyTime: "2026-08-21 08:22:10", hasPhoto: true, needCollect: false,
health: { companyCount: 21, red: 0, blue: 3, violation: 0, undeclare: 1 },
smart: { tag: "单位办税员", total: 8, lowest: "基本满意", very: 5, basic: 2, bad: 1 }
},
{
name: "王立君", idCard: "330106197803084215", phone: "13700137003", ticketId: "A002",
baishuiResp: "未采集,未注册", verifyTime: "2026-08-21 09:01:33", hasPhoto: false, needCollect: true,
health: { unavailable: true, reason: "未采集,暂无法体检" },
smart: { unavailable: true, reason: "未采集,暂无提醒" }
},
{
name: "潘国丹", idCard: "330108199206188429", phone: "15912341926", ticketId: "A001",
baishuiResp: "已采集,已注册", verifyTime: "2026-08-21 09:16:02", hasPhoto: true, needCollect: false,
health: { companyCount: 0, red: 0, blue: 0, violation: 0, undeclare: 0 },
smart: { tag: "", total: 0, lowest: "", very: 0, basic: 0, bad: 0 }
},
{
name: "许洪武", idCard: "350301198011220332", phone: "15988881926", ticketId: "C026",
baishuiResp: "未采集,未注册", verifyTime: "2026-08-21 11:16:40", hasPhoto: true, needCollect: true,
health: { unavailable: true, reason: "未采集,暂无法体检" },
smart: { tag: "", total: 2, lowest: "不满意", very: 0, basic: 1, bad: 1 }
},
{
name: "王莉", idCard: "350203199403157821", phone: "13805920011", ticketId: "Z029",
baishuiResp: "已采集,已注册", verifyTime: "2026-08-21 11:18:05", hasPhoto: true, needCollect: false,
health: { companyCount: 4, red: 1, blue: 1, violation: 3, undeclare: 2 },
smart: { tag: "自然人", total: 6, lowest: "非常满意", very: 6, basic: 0, bad: 0 }
},
{
name: "张凤云", idCard: "350102196812090219", phone: "", ticketId: "VC024",
baishuiResp: "已采集,已注册", verifyTime: "2026-08-21 11:20:18", hasPhoto: false, needCollect: false,
health: { companyCount: 1, red: 0, blue: 0, violation: 0, undeclare: 0 },
smart: { tag: "财务负责人", total: 1, lowest: "基本满意", very: 0, basic: 1, bad: 0 }
},
{
name: "李郁艳", idCard: "350582199808081234", phone: "13606001234", ticketId: "",
baishuiResp: "未采集,未注册", verifyTime: "2026-08-21 11:33:44", hasPhoto: true, needCollect: true,
health: { queryFail: true, reason: "体检查询失败" },
smart: { unavailable: true, reason: "暂无评价信息" }
}
];
const SIZE = 10;
let filtered = ALL.slice();
let page = 1;
const maskId = (v) => !v ? "-" : v.replace(/^(.{4}).*(.{4})$/, "$1********$2");
const maskPhone = (v) => !v ? "-" : v.replace(/^(.{3}).*(.{4})$/, "$1****$2");
function healthHtml(h) {
if (h.unavailable || h.queryFail) return `<span class="muted">${h.reason}</span>`;
if (!h.companyCount) return `0户关联企业`;
const parts = [`${h.companyCount}户关联企业`];
if (h.red) parts.push(`预警: <span class="warn-red">${h.red}户红色</span>`);
if (h.blue) parts.push(`预警: <span class="warn-blue">${h.blue}户蓝色</span>`);
if (h.violation) parts.push(`${h.violation}条未处理违法违章`);
if (h.undeclare) parts.push(`${h.undeclare}条未申报`);
return parts.join("");
}
function smartHtml(s) {
if (s.unavailable) return `<span class="muted">${s.reason || "暂无提醒"}</span>`;
const tag = s.tag ? `标签: ${s.tag}` : "标签: 无";
if (!s.total) return `${tag}<span class="muted">无评价信息</span>`;
return `${tag};评价: ${s.total}次,最低评价: ${s.lowest}。非常满意${s.very}次,基本满意${s.basic}次,不满意${s.bad}次`;
}
function render() {
const totalPages = Math.max(1, Math.ceil(filtered.length / SIZE));
if (page > totalPages) page = totalPages;
const start = (page - 1) * SIZE;
const rows = filtered.slice(start, start + SIZE);
document.getElementById("tbody").innerHTML = rows.map((item, i) => `
<tr>
<td class="col-name">${item.name || "-"}</td>
<td class="col-id">${maskId(item.idCard)}</td>
<td class="col-phone">${maskPhone(item.phone)}</td>
<td class="col-ticket">${item.ticketId || "-"}</td>
<td class="col-verify">${item.baishuiResp || "-"}</td>
<td class="col-time">${item.verifyTime || "-"}</td>
<td class="col-photo">${item.hasPhoto ? '<button class="link" onclick="alert(\'预览:查看照片\')">查看照片</button>' : "-"}</td>
<td class="col-health"><div class="summary" onclick="openHealth(${start + i})">${healthHtml(item.health)}</div></td>
<td class="col-smart"><div class="summary" onclick="openSmart(${start + i})">${smartHtml(item.smart)}</div></td>
<td class="col-op">
<div class="row-actions">
<button class="table-action" onclick="alert('预览:取号')">取号</button>
<button class="table-action" onclick="alert('预览:健康报告')">健康报告</button>
${item.needCollect ? '<button class="table-action gray" onclick="alert(\'预览:采集\')">采集</button>' : ""}
</div>
</td>
</tr>
`).join("");
document.getElementById("totalText").textContent = filtered.length;
document.getElementById("pageNum").textContent = page;
document.getElementById("pageTotal").textContent = totalPages;
document.getElementById("prevBtn").disabled = page <= 1;
document.getElementById("nextBtn").disabled = page >= totalPages;
}
function applyFilter() {
const n = document.getElementById("qName").value.trim();
const id = document.getElementById("qId").value.trim();
const p = document.getElementById("qPhone").value.trim();
filtered = ALL.filter((x) =>
(!n || x.name.includes(n)) &&
(!id || x.idCard.includes(id)) &&
(!p || x.phone.includes(p))
);
page = 1;
render();
}
function resetFilter() {
document.getElementById("qName").value = "";
document.getElementById("qId").value = "";
document.getElementById("qPhone").value = "";
filtered = ALL.slice();
page = 1;
render();
}
function changePage(d) { page += d; render(); }
function openHealth(i) {
const item = filtered[i];
const h = item.health;
document.getElementById("modalTitle").textContent = `一键体检 · ${item.name}`;
document.getElementById("modalBody").innerHTML = h.unavailable || h.queryFail
? `<div class="kv">${h.reason}</div><div class="note">无可靠身份或采集未完成时,应明确提示,避免空白被理解成「没问题」。</div>`
: `<div class="kv"><b>关联企业:</b>${h.companyCount} 户</div>
<div class="kv"><b>红色预警:</b><span class="warn-red">${h.red} 户</span></div>
<div class="kv"><b>蓝色预警:</b><span class="warn-blue">${h.blue} 户</span></div>
<div class="kv"><b>未处理违法违章:</b>${h.violation} 条</div>
<div class="kv"><b>未申报:</b>${h.undeclare} 条</div>
<div class="note">此为展示用模拟数据。点击列表摘要可下钻到分户风险与申报明细(正式环境对接金三/风控)。</div>`;
document.getElementById("mask").classList.add("show");
}
function openSmart(i) {
const item = filtered[i];
const s = item.smart;
document.getElementById("modalTitle").textContent = `智能提醒 · ${item.name}`;
document.getElementById("modalBody").innerHTML = s.unavailable
? `<div class="kv">${s.reason}</div>`
: `<div class="kv"><b>人员标签:</b>${s.tag || "无"}</div>
<div class="kv"><b>评价次数:</b>${s.total} 次</div>
<div class="kv"><b>最低评价:</b>${s.lowest || "-"}</div>
<div class="kv"><b>非常满意 / 基本满意 / 不满意:</b>${s.very} / ${s.basic} / ${s.bad}</div>
<div class="note">此为展示用模拟数据。正式环境按身份证汇总窗口评价器历史记录。</div>`;
document.getElementById("mask").classList.add("show");
}
function closeModal() { document.getElementById("mask").classList.remove("show"); }
render();
</script>
</body>
</html>

@ -2,8 +2,8 @@
"name" : "紫云智能导税", "name" : "紫云智能导税",
"appid" : "__UNI__A083CF1", "appid" : "__UNI__A083CF1",
"description" : "紫云智能导税", "description" : "紫云智能导税",
"versionName" : "1.0.0", "versionName" : "1.2.1",
"versionCode" : "100", "versionCode" : "121",
"transformPx" : false, "transformPx" : false,
/* 5+App */ /* 5+App */
"app-plus" : { "app-plus" : {

@ -10,6 +10,15 @@
}, },
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": {
"disableScroll": true,
"app-plus": {
"popGesture": "none"
}
}
},
{
"path": "pages/mod/appLog",
"style": { "style": {
"app-plus": { "app-plus": {
"popGesture": "none" "popGesture": "none"
@ -96,6 +105,14 @@
} }
} }
}, },
{
"path": "pages/mod/duty-todos",
"style": {
"app-plus": {
"popGesture": "none"
}
}
},
{ {
"path": "pages/queue/index", "path": "pages/queue/index",
"style": { "style": {

@ -1,76 +1,104 @@
<template> <template>
<view class="page-container tn-gradient-bg__cool-5" @click="hideUserMenu"> <view class="page-container tn-gradient-bg__cool-5" @click="hideUserMenu">
<cheader></cheader> <cheader :show-time="false"></cheader>
<div class="index-body"> <div class="index-body">
<view class="duty-rail">
<text class="duty-clock">{{ clockText }}</text>
<text class="duty-date">{{ dateText }}</text>
<view class="duty-block">
<text class="duty-kicker">当班人员</text>
<text class="duty-value">{{ userInfo.realName || "未登录" }}</text>
</view>
<view class="duty-todos">
<text class="duty-kicker">待办事项</text>
<scroll-view class="duty-todo-list" scroll-y>
<text v-if="todos.length === 0" class="duty-todo-empty"></text>
<view
v-for="item in todos"
:key="item.id"
class="duty-todo-item"
:class="{ 'duty-todo-done': item.done }"
>
<text class="duty-todo-text">{{ item.text }}</text>
</view>
</scroll-view>
</view>
<view class="duty-tip" @click="goToDutyTodos">
<text class="duty-tip-text">编辑待办事项</text>
<uni-icons type="right" size="16" color="#fff"></uni-icons>
</view>
</view>
<div class="main-pane">
<div class="body-title"> <div class="body-title">
<span class="title-mian"></span> <view class="title-left">
<!-- 右上角用户信息缩略展示 --> <span class="title-mian">导税工作台</span>
<view class="user-mini" @click.stop="toggleUserMenu"> <text class="app-version">v{{ appVersion }}</text>
<image :src="userInfo.image" class="user-mini-avatar"></image> </view>
<text class="user-mini-name">{{ userInfo.realName || "" }}</text> <view
class="user-mini"
:class="{ 'user-mini-open': showUserMenu }"
@click.stop="toggleUserMenu"
>
<image
v-if="userInfo.image"
:src="userInfo.image"
class="user-mini-avatar"
></image>
<view v-else class="user-mini-avatar user-mini-fallback">
<uni-icons type="person-filled" size="16" color="#1e3a8a"></uni-icons>
</view>
<text class="user-mini-name">{{ userInfo.realName || "未登录" }}</text>
<view
class="user-mini-caret"
:class="{ 'user-mini-caret-open': showUserMenu }"
>
<uni-icons type="bottom" size="12" color="#fff"></uni-icons>
</view>
<view v-if="showUserMenu" class="user-menu" @click.stop> <view v-if="showUserMenu" class="user-menu" @click.stop>
<view class="user-menu-item" @click.stop="loginOutAction" <view class="user-menu-item" @click.stop="goToAppLog">
>退出登录</view <uni-icons type="list" size="16" color="#1e3a8a"></uni-icons>
<text>查看日志</text>
</view>
<view class="user-menu-sep"></view>
<view
class="user-menu-item user-menu-item-danger"
@click.stop="loginOutAction"
> >
<uni-icons type="locked" size="16" color="#dc2626"></uni-icons>
<text>退出登录</text>
</view>
</view> </view>
</view> </view>
</div> </div>
<div class="body-content">
<!-- 左侧数据展示纵向排列 -->
<div class="stats-column">
<h2>大厅概况</h2>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">当前等候人数</p>
<p class="stat-value">{{ waitingCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">今日预约人数</p>
<p class="stat-value">{{ todayAppointmentCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">空闲窗口</p>
<p class="stat-value">{{ idleWindowCount }}</p>
</div>
<div class="stat-card" @click="goToHallInfo()">
<p class="stat-title">空闲自助机</p>
<p class="stat-value">{{ idleKioskCount }}</p>
</div>
</div>
<!-- 右侧功能按钮区 -->
<div class="btn-content"> <div class="btn-content">
<div class="btn-row1"> <div class="btn-row1">
<div class="row1-item1 col-center" @click="goToQueue()"> <div class="home-tile row1-item1 col-center" @click="goToQueue()">
<uni-icons type="compose" size="60" color="#fff"></uni-icons> <uni-icons type="compose" size="60" color="#fff"></uni-icons>
<p class="btn-p">预检取号</p> <p class="btn-p">预检取号</p>
</div> </div>
<div class="row1-item2 col-center" @click="goToTicket()"> <div class="home-tile row1-item2 col-center" @click="goToTicket()">
<uni-icons type="list" size="60" color="#fff"></uni-icons> <uni-icons type="list" size="60" color="#fff"></uni-icons>
<p class="btn-p">票号管理</p> <p class="btn-p">票号管理</p>
</div> </div>
<div class="row1-item3 col-center" @click="goToManager()"> <div class="home-tile row1-item3 col-center" @click="goToManager()">
<uni-icons type="staff-filled" size="60" color="#fff"></uni-icons> <uni-icons type="staff-filled" size="60" color="#fff"></uni-icons>
<p class="btn-p">网格员</p> <p class="btn-p">网格员</p>
</div> </div>
</div> </div>
<!-- <div style="height: 2vh"></div> -->
<div class="btn-row2"> <div class="btn-row2">
<div class="row2-item1"> <div class="row2-item1">
<div class="row2-item1-top row-center" @click="goToPushMessage()"> <div class="home-tile row2-item1-top row-center" @click="goToPushMessage()">
<uni-icons type="info" size="60" color="#fff"></uni-icons> <uni-icons type="info" size="60" color="#fff"></uni-icons>
<p class="btn-p">信息推送</p> <p class="btn-p">信息推送</p>
</div> </div>
<!-- <div style="height: 2vh"></div> --> <div class="home-tile row2-item1-bottom row-center">
<div
class="row2-item1-bottom row-center"
@click="openNormalTakeTicket"
>
<uni-icons type="compose" size="60" color="#fff"></uni-icons> <uni-icons type="compose" size="60" color="#fff"></uni-icons>
<p class="btn-p">普通取号</p> <p class="btn-p">预留功能</p>
</div> </div>
</div> </div>
<div class="row2-item2"> <div class="row2-item2">
<div <div
class="row2-item2-left col-center" class="home-tile row2-item2-left col-center"
@click="goToSignPriority()" @click="goToSignPriority()"
> >
<uni-icons <uni-icons
@ -80,8 +108,7 @@
></uni-icons> ></uni-icons>
<p class="btn-p">回签优先</p> <p class="btn-p">回签优先</p>
</div> </div>
<!-- <div style="width: 2vh"></div> --> <div class="home-tile row2-item2-center col-center">
<div class="row2-item2-center col-center">
<uni-icons <uni-icons
type="auth-filled" type="auth-filled"
size="60" size="60"
@ -89,8 +116,7 @@
></uni-icons> ></uni-icons>
<p class="btn-p">预留功能</p> <p class="btn-p">预留功能</p>
</div> </div>
<!-- <div style="width: 2vh"></div> --> <div class="home-tile row2-item2-right col-center">
<div class="row2-item2-right col-center">
<uni-icons <uni-icons
type="settings-filled" type="settings-filled"
size="60" size="60"
@ -98,21 +124,21 @@
></uni-icons> ></uni-icons>
<p class="btn-p">预留功能</p> <p class="btn-p">预留功能</p>
</div> </div>
<!-- <div v-for="item in 3" style="flex: 1;"></div> -->
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<TakeTicketPopup ref="takeTicketPopupRef" />
</view> </view>
</template> </template>
<script setup> <script setup>
import { getAuthProfile } from "@/api/index.js"; import { getAuthProfile } from "@/api/index.js";
import { getOverview } from "@/api/system.js";
import cheader from "@/components/header.vue"; import cheader from "@/components/header.vue";
import TakeTicketPopup from "@/components/TakeTicketPopup/TakeTicketPopup.vue"; import { appLog, flushLogs } from "@/utils/appLog.js";
import { loadDutyTodos } from "@/utils/dutyTodos.js";
import { waitSessionReady } from "@/utils/request.js";
import { getAppVersion } from "@/utils/appVersion.js";
import { import {
onBackPress, onBackPress,
onHide, onHide,
@ -120,7 +146,19 @@ import {
onShow, onShow,
onUnload, onUnload,
} from "@dcloudio/uni-app"; } from "@dcloudio/uni-app";
import { ref } from "vue"; import { computed, ref } from "vue";
const WEEK_DAYS = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
];
const appVersion = getAppVersion();
const userInfo = ref({ const userInfo = ref({
realName: "", realName: "",
@ -128,7 +166,6 @@ const userInfo = ref({
image: "", image: "",
}); });
//
const showUserMenu = ref(false); const showUserMenu = ref(false);
const toggleUserMenu = () => { const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value; showUserMenu.value = !showUserMenu.value;
@ -137,13 +174,21 @@ const hideUserMenu = () => {
showUserMenu.value = false; showUserMenu.value = false;
}; };
// const now = ref(new Date());
const waitingCount = ref(0); const todos = ref([]);
const todayAppointmentCount = ref(0); let clockTimer = null;
const idleWindowCount = ref(0);
const idleKioskCount = ref(0); const pad2 = (value) => String(value).padStart(2, "0");
const takeTicketPopupRef = ref(null);
let overviewTimer = null; const clockText = computed(() => {
const date = now.value;
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
});
const dateText = computed(() => {
const date = now.value;
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${WEEK_DAYS[date.getDay()]}`;
});
onBackPress(() => { onBackPress(() => {
return true; return true;
@ -161,25 +206,11 @@ onLoad(() => {
}; };
console.log("用户信息:", userInfo.value); console.log("用户信息:", userInfo.value);
} }
initData();
} catch (error) { } catch (error) {
console.error("获取用户信息失败:", error); console.error("获取用户信息失败:", error);
} }
}); });
const initData = async () => {
try {
const res = await getOverview();
console.log("大厅概况", res);
waitingCount.value = Number(res?.waitingCount ?? 0);
todayAppointmentCount.value = Number(res?.todayAppointmentCount ?? 0);
idleWindowCount.value = Number(res?.idleWindowCount ?? 0);
idleKioskCount.value = Number(res?.idleKioskCount ?? 0);
} catch (error) {
console.log("获取大厅概况失败", error);
}
};
const loadHallNameFromProfile = async () => { const loadHallNameFromProfile = async () => {
try { try {
const profile = await getAuthProfile(); const profile = await getAuthProfile();
@ -198,34 +229,34 @@ const loadHallNameFromProfile = async () => {
} }
}; };
const startOverviewTimer = () => { const startClockTimer = () => {
if (overviewTimer) { stopClockTimer();
clearInterval(overviewTimer); now.value = new Date();
} clockTimer = setInterval(() => {
overviewTimer = setInterval(() => { now.value = new Date();
initData(); }, 1000);
}, 300000);
}; };
const stopOverviewTimer = () => { const stopClockTimer = () => {
if (overviewTimer) { if (clockTimer) {
clearInterval(overviewTimer); clearInterval(clockTimer);
overviewTimer = null; clockTimer = null;
} }
}; };
onShow(() => { onShow(async () => {
await waitSessionReady();
loadHallNameFromProfile(); loadHallNameFromProfile();
initData(); todos.value = loadDutyTodos();
startOverviewTimer(); startClockTimer();
}); });
onHide(() => { onHide(() => {
stopOverviewTimer(); stopClockTimer();
}); });
onUnload(() => { onUnload(() => {
stopOverviewTimer(); stopClockTimer();
}); });
const goToGuidance = () => { const goToGuidance = () => {
@ -282,20 +313,23 @@ const goToHallManagment = () => {
}); });
}; };
const goToHallInfo = () => { const goToSignPriority = () => {
uni.navigateTo({ uni.navigateTo({
url: "/pages/mod/hallInfo", url: "/pages/mod/sign-priority",
}); });
}; };
const goToSignPriority = () => { const goToDutyTodos = () => {
uni.navigateTo({ uni.navigateTo({
url: "/pages/mod/sign-priority", url: "/pages/mod/duty-todos",
}); });
}; };
const openNormalTakeTicket = () => { const goToAppLog = () => {
takeTicketPopupRef.value?.open(); showUserMenu.value = false;
uni.navigateTo({
url: "/pages/mod/appLog",
});
}; };
const loginOutAction = () => { const loginOutAction = () => {
@ -304,156 +338,308 @@ const loginOutAction = () => {
content: "是否退出登录,并返回到登录界面?", content: "是否退出登录,并返回到登录界面?",
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
// 退 appLog('info', '[index] 用户退出登录')
stopOverviewTimer(); flushLogs()
// swjgMC 便 stopClockTimer();
uni.removeStorageSync("token"); uni.removeStorageSync("token");
uni.removeStorageSync("refresh_token"); uni.removeStorageSync("refresh_token");
uni.removeStorageSync("userInfo"); uni.removeStorageSync("userInfo");
//
uni.reLaunch({ uni.reLaunch({
url: "/pages/login/login", url: "/pages/login/login",
}); });
} }
//
}, },
}); });
}; };
</script> </script>
<style lang="scss"> <style lang="scss">
.page-container {
height: var(--pad-h);
overflow: hidden;
display: flex;
flex-direction: column;
}
.index-body { .index-body {
height: 90vh; flex: 1;
width: 100vw; min-height: 0;
margin-top: 2vh; width: 100%;
padding: 12px 5vw 16px;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: hidden;
--module-gap: 10px;
.duty-rail {
width: 20vw;
flex: 0 0 20vw;
min-height: 0;
margin-right: 16px;
padding: 20px 16px 14px;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16px;
background: rgba(15, 23, 42, 0.22);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(14px);
}
.duty-clock {
font-size: 36px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
line-height: 1;
font-variant-numeric: tabular-nums;
font-family: "DIN Alternate", "Roboto Mono", "Courier New", monospace;
}
.duty-date {
margin-top: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.78);
letter-spacing: 0.5px;
}
.duty-block {
margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// justify-content: center; }
.duty-kicker {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 2px;
}
.duty-value {
margin-top: 6px;
font-size: 15px;
font-weight: 600;
color: #fff;
line-height: 1.4;
word-break: break-all;
}
.duty-todos {
flex: 1;
min-height: 0;
margin-top: 16px;
display: flex;
flex-direction: column;
}
.duty-todo-list {
flex: 1;
min-height: 0;
margin-top: 8px;
}
.duty-todo-empty {
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
}
.duty-todo-item {
margin-bottom: 8px;
padding-left: 8px;
border-left: 2px solid rgba(255, 255, 255, 0.35);
}
.duty-todo-done {
opacity: 0.55;
}
.duty-todo-text {
font-size: 13px;
line-height: 1.4;
color: #fff;
word-break: break-all;
}
.duty-todo-done .duty-todo-text {
text-decoration: line-through;
}
.duty-tip {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.16);
display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.duty-tip-text {
font-size: 14px;
font-weight: 700;
color: #fff;
}
.main-pane {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: visible;
}
.body-title { .body-title {
height: 10vh; min-height: 0;
margin-bottom: 2vh; margin-bottom: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
/* 关键让标题区域占满可用宽度space-between 才能拉开两端 */ flex-shrink: 0;
width: 80vw; position: relative;
max-width: 100vw; z-index: 30;
overflow: visible;
.title-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-mian { .title-mian {
background: linear-gradient(to bottom, #adadad 10%, #fff 90%); color: #fff;
/* 2. 将背景裁剪成文字形状 (需要-webkit-前缀) */ font-size: 32px;
-webkit-background-clip: text; font-weight: 700;
background-clip: text; letter-spacing: 1px;
/* 3. 将文字颜色设为透明,显示出渐变背景 */ text-shadow: 0 6px 18px rgba(15, 23, 42, 0.25);
color: transparent; }
/* 为了效果明显,可以设置一些字体样式 */
font-size: 36px; .app-version {
font-weight: 600; font-size: 14px;
color: rgba(255, 255, 255, 0.78);
font-weight: 500;
} }
/* 顶部用户缩略信息 */
.user-mini { .user-mini {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
background-color: rgba(0, 0, 0, 0.3); background-color: rgba(255, 255, 255, 0.18);
border-radius: 25px; border: 1px solid rgba(255, 255, 255, 0.32);
padding: 6px 10px 6px 6px; border-radius: 22px;
padding: 4px 10px 4px 4px;
backdrop-filter: blur(8px);
flex-shrink: 0;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
.user-mini-avatar { .user-mini-avatar {
width: 40px; width: 32px;
height: 40px; height: 32px;
border-radius: 50%; border-radius: 50%;
border: 1px solid #fff; border: 1px solid rgba(255, 255, 255, 0.85);
margin-right: 10px; margin-right: 8px;
object-fit: cover; object-fit: cover;
background: #e8eef7;
flex-shrink: 0;
}
.user-mini-fallback {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-color: #fff;
} }
.user-mini-name { .user-mini-name {
font-size: 18px; font-size: 14px;
color: #fff; color: #fff;
font-weight: 600;
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.2;
}
.user-mini-caret {
margin-left: 6px;
display: flex;
align-items: center;
opacity: 0.9;
}
.user-mini-caret-open {
transform: rotate(180deg);
} }
.user-menu { .user-menu {
position: absolute; position: absolute;
top: 100%; top: calc(100% + 8px);
right: 0; right: 0;
margin-top: 6px; background-color: #fff;
background-color: rgba(0, 0, 0, 0.3); border-radius: 12px;
border-radius: 8px; border: 1px solid #d7e2ee;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18);
overflow: hidden; overflow: hidden;
min-width: 120px; min-width: 156px;
z-index: 10; padding: 6px;
z-index: 40;
.user-menu-item { .user-menu-item {
padding: 10px 16px; display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px; font-size: 14px;
color: #fff; font-weight: 600;
text-align: center; color: #1e3a8a;
text-align: left;
} }
.user-menu-item:active { .user-menu-item-danger {
background-color: #f5f5f5; color: #dc2626;
}
}
}
} }
.body-content { .user-menu-sep {
display: flex; height: 1px;
flex-direction: row; margin: 4px 8px;
align-items: stretch; background: #e2e8f0;
width: 82vw;
max-width: 82vw;
--module-gap: 10px;
/* 左侧数据展示列 */
.stats-column {
width: 18vw;
margin-right: 4vw;
display: flex;
flex-direction: column;
flex: 0 0 18vw;
gap: var(--module-gap);
h2 {
color: #fff;
margin-bottom: 0;
} }
.stat-card { .user-menu-item:active {
background: #ffffffe0; background-color: #eef3f9;
border-radius: $card-border-radius; }
padding: 16px 20px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
} }
.stat-title {
font-size: 16px;
color: #555;
margin-bottom: 8px;
} }
.stat-value { .user-mini-open {
font-size: 28px; background-color: rgba(255, 255, 255, 0.3);
font-weight: bold; border-color: rgba(255, 255, 255, 0.5);
color: #007aff;
} }
} }
.btn-content { .btn-content {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--module-gap); gap: var(--module-gap);
overflow: hidden;
.home-tile {
min-height: 0;
border-radius: $card-border-radius;
overflow: hidden;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.18);
}
.btn-row1 { .btn-row1 {
flex: 1; flex: 1;
@ -464,18 +650,18 @@ const loginOutAction = () => {
gap: var(--module-gap); gap: var(--module-gap);
.row1-item1 { .row1-item1 {
background: #cc3333; background-color: #2563eb;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #93c5fd 0%, #2563eb 48%, #1e3a8a 100%);
} }
.row1-item2 { .row1-item2 {
background-color: #cc3366; background-color: #1d4ed8;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #7dd3fc 0%, #3b82f6 48%, #1e40af 100%);
} }
.row1-item3 { .row1-item3 {
background-color: #cc3399; background-color: #0284c7;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #67e8f9 0%, #0ea5e9 48%, #0369a1 100%);
} }
} }
@ -488,46 +674,49 @@ const loginOutAction = () => {
gap: var(--module-gap); gap: var(--module-gap);
.row2-item1 { .row2-item1 {
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--module-gap); gap: var(--module-gap);
.row2-item1-top { .row2-item1-top {
flex: 1; flex: 1;
background-color: #99ccff; min-height: 0;
border-radius: $card-border-radius; background-color: #4f46e5;
background-image: linear-gradient(to bottom right, #a5b4fc 0%, #6366f1 48%, #3730a3 100%);
} }
.row2-item1-bottom { .row2-item1-bottom {
flex: 1; flex: 1;
background-color: #9999ff; min-height: 0;
border-radius: $card-border-radius; background-color: #64748b;
background-image: linear-gradient(to bottom right, #cbd5e1 0%, #94a3b8 48%, #475569 100%);
} }
} }
.row2-item2 { .row2-item2 {
grid-column: span 2; grid-column: span 2;
min-height: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--module-gap); gap: var(--module-gap);
.row2-item2-left { .row2-item2-left {
flex: 1; flex: 1;
background-color: #ffcc99; background-color: #0e7490;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #67e8f9 0%, #06b6d4 48%, #155e75 100%);
} }
.row2-item2-center { .row2-item2-center {
flex: 1; flex: 1;
background-color: #ffcccc; background-color: #64748b;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #cbd5e1 0%, #94a3b8 48%, #475569 100%);
} }
.row2-item2-right { .row2-item2-right {
flex: 1; flex: 1;
background-color: #ffccff; background-color: #64748b;
border-radius: $card-border-radius; background-image: linear-gradient(to bottom right, #e2e8f0 0%, #94a3b8 48%, #334155 100%);
}
} }
} }
} }

@ -2,21 +2,45 @@
<div class="page-container tn-gradient-bg__cool-5"> <div class="page-container tn-gradient-bg__cool-5">
<cheader></cheader> <cheader></cheader>
<div class="login-body"> <div class="login-body">
<div class="login-form tn-grey_shadow"> <div class="login-card">
<p>账号登录</p> <view class="login-head-row">
<tn-input v-model="username" placeholder="请输入用户名"> <view class="login-bar"></view>
<template #prefix> <view class="login-titles">
<uni-icons type="contact" size="18" color="#ccc"></uni-icons> <text class="login-title">账号登录</text>
</template> <text class="login-sub">紫云智能导税 PAD</text>
</tn-input> </view>
<div class="divider"></div> </view>
<tn-input v-model="password" type="password" placeholder="请输入密码">
<template #prefix> <view class="login-field">
<uni-icons type="locked-filled" size="18" color="#ccc"></uni-icons> <text class="login-label">用户名</text>
</template> <view class="login-input-wrap">
</tn-input> <uni-icons type="contact" size="18" color="#64748b"></uni-icons>
<div class="divider"></div> <input
<tn-button width="250px" height="36px" @click="loginAction()"> </tn-button> v-model="username"
class="login-input"
placeholder="请输入用户名"
confirm-type="next"
/>
</view>
</view>
<view class="login-field">
<text class="login-label">密码</text>
<view class="login-input-wrap">
<uni-icons type="locked-filled" size="18" color="#64748b"></uni-icons>
<input
v-model="password"
class="login-input"
password
placeholder="请输入密码"
confirm-type="done"
@confirm="loginAction"
/>
</view>
</view>
<button class="login-btn" @click="loginAction()"> </button>
<text class="login-version">版本 {{ appVersion }}</text>
</div> </div>
</div> </div>
</div> </div>
@ -27,7 +51,9 @@
import { userLogin } from '@/api/index.js' import { userLogin } from '@/api/index.js'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue' import { ref } from 'vue'
import { getAppVersion } from '@/utils/appVersion.js'
const appVersion = getAppVersion()
const LOGIN_USERNAME_KEY = 'login_username' const LOGIN_USERNAME_KEY = 'login_username'
const LOGIN_PASSWORD_KEY = 'login_password' const LOGIN_PASSWORD_KEY = 'login_password'
@ -77,32 +103,108 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 90vh; height: calc(90 * var(--pad-vh));
}
.login-form {
height: 40vh; .login-card {
width: 30vw; width: 420px;
padding: 15px 40px; max-width: calc(86 * var(--pad-vw));
background-color: #fff; padding: 32px 36px 24px;
border-radius: 5px; background: #fff;
border-radius: 16px;
box-shadow: 0 18px 48px rgba(30, 58, 138, 0.16);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; }
align-items: center;
.login-head-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 28px;
}
.login-bar {
width: 4px;
height: 36px;
margin-top: 4px;
border-radius: 2px;
background: #1d4ed8;
flex-shrink: 0;
}
.login-titles {
display: flex;
flex-direction: column;
}
p { .login-title {
font-size: 28px; font-size: 28px;
font-weight: 700;
color: #1e3a8a;
line-height: 1.2;
}
.login-sub {
margin-top: 4px;
font-size: 13px;
color: #64748b;
letter-spacing: 0.4px;
}
.login-field {
margin-bottom: 16px;
}
.login-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600; font-weight: 600;
margin-bottom: 20px; color: #475569;
background: linear-gradient(to right, #1874CD 70%, #0099ff);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.divider { .login-input-wrap {
height: 20px; height: 44px;
padding: 0 2px;
border: none;
border-bottom: 1px solid #d7e2ee;
border-radius: 0;
background: transparent;
display: flex;
align-items: center;
gap: 8px;
}
.login-input {
flex: 1;
height: 44px;
font-size: 15px;
color: #1e293b;
} }
.login-btn {
margin-top: 8px;
width: 100%;
height: 44px;
border: none;
border-radius: 22px;
background: #2563eb;
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.28);
box-sizing: border-box;
} }
.login-version {
margin-top: 16px;
text-align: center;
font-size: 12px;
color: #94a3b8;
} }
</style> </style>

@ -0,0 +1,136 @@
<template>
<view class="page-container bg-white">
<view class="top-bar">
<tn-button
width="90px"
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>
<text class="title">运行日志</text>
<tn-button
width="90px"
height="32px"
:plain="true"
text-color="#fa541c"
@click="handleClear"
>
清空日志
</tn-button>
</view>
<scroll-view
class="log-scroll"
scroll-y
:scroll-into-view="scrollIntoView"
scroll-with-animation
>
<text v-if="!logText" class="log-empty"></text>
<text v-else class="log-text" selectable>{{ logText }}</text>
<view id="log-bottom" class="log-bottom"></view>
</scroll-view>
</view>
</template>
<script setup>
import { clearLogs, getLogsText } from '@/utils/appLog.js'
import { onShow } from '@dcloudio/uni-app'
import { nextTick, ref } from 'vue'
const logText = ref('')
const scrollIntoView = ref('')
const refreshLogs = async () => {
logText.value = getLogsText()
scrollIntoView.value = ''
await nextTick()
scrollIntoView.value = 'log-bottom'
}
const goBack = () => {
uni.navigateBack({
fail: () => {
uni.navigateTo({ url: '/pages/index/index' })
},
})
}
const handleClear = () => {
uni.showModal({
title: '清空日志',
content: '确认清空全部运行日志吗?',
success: (res) => {
if (!res.confirm) return
clearLogs()
refreshLogs()
uni.showToast({ title: '已清空', icon: 'none' })
},
})
}
onShow(() => {
refreshLogs()
})
</script>
<style scoped>
.page-container {
min-height: var(--pad-h);
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(6 * var(--pad-vh)) 20px 10px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.title {
font-size: 18px;
font-weight: 600;
color: #111827;
}
.log-scroll {
flex: 1;
height: 0;
margin: 12px;
padding: 12px;
background: #111827;
border-radius: 10px;
box-sizing: border-box;
}
.log-text {
display: block;
color: #d1fae5;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
font-family: Consolas, Monaco, monospace;
}
.log-empty {
color: #9ca3af;
font-size: 14px;
}
.log-bottom {
height: 1px;
}
</style>

@ -0,0 +1,271 @@
<template>
<view class="page-container">
<view class="page-toolbar">
<button class="btn btn-ghost back-btn" @click="goBack">
<uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
返回
</button>
<view class="toolbar-title-wrap">
<text class="toolbar-title">待办事项</text>
</view>
</view>
<view class="add-bar">
<input
v-model="draft"
class="add-input"
confirm-type="done"
placeholder="请输入待办内容"
@confirm="addTodo"
/>
<button class="btn btn-primary add-btn" @click="addTodo"></button>
</view>
<scroll-view class="todo-list" scroll-y>
<view v-if="todos.length === 0" class="empty-text"></view>
<view v-for="item in todos" :key="item.id" class="todo-item">
<view class="todo-check" @click="toggleTodo(item.id)">
<uni-icons
:type="item.done ? 'checkbox-filled' : 'smallcircle'"
size="22"
:color="item.done ? '#2563eb' : '#94a3b8'"
></uni-icons>
</view>
<input
class="todo-input"
:class="{ 'todo-input-done': item.done }"
:value="item.text"
@blur="onTodoBlur(item.id, $event)"
/>
<button class="btn btn-ghost del-btn" @click="removeTodo(item.id)">
删除
</button>
</view>
</scroll-view>
</view>
</template>
<script setup>
import {
createDutyTodo,
loadDutyTodos,
saveDutyTodos,
} from "@/utils/dutyTodos.js";
import { onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
const draft = ref("");
const todos = ref([]);
const persist = (next) => {
todos.value = saveDutyTodos(next);
};
const addTodo = () => {
const text = String(draft.value || "").trim();
if (!text) {
uni.showToast({
title: "请输入待办内容",
icon: "none",
});
return;
}
persist([createDutyTodo(text), ...todos.value]);
draft.value = "";
};
const updateTodo = (id, value) => {
const text = String(value || "").trim();
persist(
todos.value
.map((item) => (item.id === id ? { ...item, text } : item))
.filter((item) => item.text),
);
};
const onTodoBlur = (id, event) => {
updateTodo(id, event?.detail?.value);
};
const toggleTodo = (id) => {
persist(
todos.value.map((item) =>
item.id === id ? { ...item, done: !item.done } : item,
),
);
};
const removeTodo = (id) => {
persist(todos.value.filter((item) => item.id !== id));
};
const goBack = () => {
uni.navigateBack({
fail: () => {
uni.navigateTo({ url: "/pages/index/index" });
},
});
};
onShow(() => {
todos.value = loadDutyTodos();
});
</script>
<style lang="scss" scoped>
.page-container {
height: var(--pad-h);
overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
}
.page-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: calc(env(safe-area-inset-top) + 10px) 20px 10px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
z-index: 20;
}
.page-toolbar button {
width: auto;
flex: none;
}
.back-btn {
width: auto;
min-width: 84px;
height: 40px;
padding: 0 14px;
margin: 0;
gap: 4px;
flex-shrink: 0;
}
.toolbar-title-wrap {
flex-shrink: 0;
padding-left: 10px;
border-left: 3px solid #1d4ed8;
}
.toolbar-title {
font-size: 18px;
font-weight: 700;
color: #1e3a8a;
}
.add-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
}
.add-input {
flex: 1;
height: 42px;
padding: 0 12px;
background: #f4f7fb;
border: 1px solid #d7e2ee;
border-radius: 10px;
font-size: 15px;
color: #1e293b;
}
.add-btn {
width: 88px;
height: 42px;
}
.todo-list {
flex: 1;
min-height: 0;
padding: 12px 20px 20px;
}
.empty-text {
padding: 48px 0;
text-align: center;
color: #94a3b8;
font-size: 14px;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 10px 12px;
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
}
.todo-check {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
}
.todo-input {
flex: 1;
min-width: 0;
height: 36px;
font-size: 15px;
color: #1e293b;
}
.todo-input-done {
color: #94a3b8;
text-decoration: line-through;
}
.del-btn {
width: auto;
min-width: 64px;
height: 32px;
padding: 0 12px;
font-size: 13px;
}
.btn {
width: auto;
min-width: 84px;
height: 32px;
margin: 0;
border-radius: 10px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.btn::after {
border: none;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-ghost {
background: #fff;
color: #1e3a8a;
border: 1px solid #c7d7ea;
}
</style>

@ -378,12 +378,12 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
min-height: 100vh; min-height: var(--pad-h);
background-color: #fff; background-color: #fff;
} }
.content { .content {
padding: 6vh 20px 0 20px; padding: calc(6 * var(--pad-vh)) 20px 0 20px;
} }
// //

@ -1,18 +1,19 @@
<template> <template>
<view class="page-container"> <view class="page-container">
<TopDivBar> <view class="page-toolbar">
<template #right> <button class="btn btn-ghost back-btn" @click="backToIndex">
<view class="top-actions"> <uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
<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> </button>
</tn-button> <view class="toolbar-title-wrap">
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="backToIndex"> <text class="toolbar-title">大厅概况</text>
<uni-icons type="arrow-left" size="18" color="#0099ff" style="margin-right: 5px"></uni-icons> </view>
</tn-button> <button class="btn btn-ghost" @click="refreshMonitorList">
</view> <uni-icons type="refresh" size="16" color="#1e3a8a" style="margin-right: 5px"></uni-icons>
</template> 刷新
</TopDivBar> </button>
<view class="content"> </view>
<scroll-view class="content" scroll-y>
<!-- 窗口状态监控 --> <!-- 窗口状态监控 -->
<view class="status-section"> <view class="status-section">
<view class="section-card"> <view class="section-card">
@ -83,7 +84,7 @@
</view> --> </view> -->
</view> </view>
</view> </view>
</view> </scroll-view>
<!-- 窗口详情弹窗 --> <!-- 窗口详情弹窗 -->
<uni-popup ref="windowPopup" type="center" background-color="#fff"> <uni-popup ref="windowPopup" type="center" background-color="#fff">
@ -157,7 +158,6 @@
import { getMonitorList } from "@/api/window.js"; import { getMonitorList } from "@/api/window.js";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import TopDivBar from "@/components/TopDivBar.vue";
// //
const windowPopup = ref(); const windowPopup = ref();
@ -410,8 +410,12 @@
}; };
const backToIndex = () => { const backToIndex = () => {
uni.navigateTo({ uni.navigateBack({
url: "/pages/index/index", fail: () => {
uni.redirectTo({
url: "/pages/queue/index",
});
},
}); });
}; };
@ -422,31 +426,61 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
min-height: 100vh; height: var(--pad-h);
background-color: #f5f7fa; overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
} }
.top-div { .page-toolbar {
flex-shrink: 0;
display: flex; display: flex;
justify-content: space-between; align-items: center;
background-color: #fff; gap: 12px;
padding: 6vh 20px 10px 20px; padding: calc(env(safe-area-inset-top) + 10px) 20px 10px;
position: fixed; background: #fff;
top: 0; border-bottom: 1px solid #d7e2ee;
left: 0; box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
right: 0; z-index: 20;
z-index: 100; }
box-sizing: border-box;
.toolbar-title-wrap {
flex: 1;
padding-left: 10px;
border-left: 3px solid #1d4ed8;
} }
.top-actions { .toolbar-title {
font-size: 18px;
font-weight: 700;
color: #1e3a8a;
}
.btn {
min-width: 84px;
height: 40px;
padding: 0 14px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
border: 1px solid #c7d7ea;
background: #fff;
color: #1e3a8a;
display: flex; display: flex;
gap: 10px; align-items: center;
justify-content: center;
}
.back-btn {
gap: 4px;
} }
.content { .content {
padding: 20rpx; flex: 1;
margin-top: 100px; height: 0;
padding: 16px 20px;
box-sizing: border-box;
} }
// //
@ -721,7 +755,7 @@
.popup-body { .popup-body {
padding: 30rpx; padding: 30rpx;
max-height: 60vh; max-height: calc(60 * var(--pad-vh));
overflow-y: auto; overflow-y: auto;
} }

@ -178,7 +178,7 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
min-height: 100vh; min-height: var(--pad-h);
background-color: #f5f7fa; background-color: #f5f7fa;
} }
@ -186,7 +186,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
background-color: #fff; background-color: #fff;
padding: 6vh 20px 10px 20px; padding: calc(6 * var(--pad-vh)) 20px 10px 20px;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -490,7 +490,7 @@
.popup-body { .popup-body {
padding: 30rpx; padding: 30rpx;
max-height: 60vh; max-height: calc(60 * var(--pad-vh));
overflow-y: auto; overflow-y: auto;
} }

@ -203,7 +203,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
// //
const searchKeyword = ref('') const searchKeyword = ref('')
@ -535,7 +535,7 @@
.detail-popup { .detail-popup {
.popup-content { .popup-content {
width: 700rpx; width: 700rpx;
max-height: 80vh; max-height: calc(80 * var(--pad-vh));
border-radius: 20rpx; border-radius: 20rpx;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
@ -557,7 +557,7 @@
.popup-body { .popup-body {
padding: 30rpx; padding: 30rpx;
max-height: 60vh; max-height: calc(60 * var(--pad-vh));
overflow-y: auto; overflow-y: auto;
} }

@ -581,12 +581,12 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
min-height: 100vh; min-height: var(--pad-h);
background-color: #f5f7fa; background-color: #f5f7fa;
} }
.content { .content {
padding: 6vh 20px 0 20px; padding: calc(6 * var(--pad-vh)) 20px 0 20px;
} }
// //

@ -1,22 +1,26 @@
<template> <template>
<view class="push-message-page"> <view class="push-message-page">
<TopDivBar @back="backToIndex" /> <view class="page-toolbar">
<button class="btn btn-ghost back-btn" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
返回
</button>
<view class="toolbar-title-wrap">
<text class="toolbar-title">信息推送</text>
</view>
</view>
<view class="page-body"> <scroll-view class="page-body" scroll-y>
<view class="tabs-bar"> <view class="tabs-bar">
<tn-tabs <view
v-model="currentTabIndex"
:bottom-shadow="false"
:bar="false"
height="40px"
>
<TnTabsItem
v-for="(item, index) in tabsData" v-for="(item, index) in tabsData"
:key="index" :key="index"
:title="item.text" class="tab-item"
:class="{ 'tab-item-active': currentTabIndex === index }"
@click="currentTabIndex = index" @click="currentTabIndex = index"
/> >
</tn-tabs> {{ item.text }}
</view>
</view> </view>
<!-- 语音 --> <!-- 语音 -->
@ -253,17 +257,14 @@
</tn-button> </tn-button>
</view> </view>
</view> </view>
</view> </scroll-view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { getPrintTemplate, savePrintTemplate } from "@/api/print.js"; 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 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 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 { import {
buildPrintPreviewData, buildPrintPreviewData,
buildPrintTemplateSaveBody, buildPrintTemplateSaveBody,
@ -515,14 +516,103 @@ const onSmsSend = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.push-message-page { .push-message-page {
min-height: 100vh; height: var(--pad-h);
background-color: #f5f7fa; overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
}
.page-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: calc(env(safe-area-inset-top) + 10px) 20px 10px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
z-index: 20;
}
.page-toolbar button,
.page-toolbar .back-btn {
width: auto !important;
flex: none;
}
.toolbar-title-wrap {
flex-shrink: 0;
padding-left: 10px;
border-left: 3px solid #1d4ed8;
}
.toolbar-title {
font-size: 18px;
font-weight: 700;
color: #1e3a8a;
}
.tabs-bar {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
}
.tab-item {
min-width: 88px;
height: 36px;
padding: 0 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
}
.tab-item-active {
background: #2563eb;
color: #fff;
}
.back-btn {
width: auto;
min-width: 84px;
height: 40px;
padding: 0 14px;
margin: 0;
gap: 4px;
flex-shrink: 0;
}
.btn {
border-radius: 10px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.btn-ghost {
background: #fff;
color: #1e3a8a;
border: 1px solid #c7d7ea;
} }
.page-body { .page-body {
padding: calc(6vh + 64px) 24px 24px; flex: 1;
max-width: 720px; height: 0;
margin: 0 auto; padding: 16px 24px 24px;
box-sizing: border-box;
} }
.ticket-template-panel { .ticket-template-panel {

@ -1,29 +1,40 @@
<template> <template>
<view class="page-container bg-white"> <view class="page-container">
<TopDivBar @back="backToIndex"> <view class="page-toolbar">
<template #left> <button class="btn btn-ghost back-btn" @click="backToIndex">
<view class="tabs-wrap"> <uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
<tn-tabs v-model="currentTabIndex" :bottom-shadow="false" :bar="false"> 返回
<TnTabsItem v-for="(item, index) in tabsData" :key="index" :title="item.text" /> </button>
</tn-tabs> <view class="toolbar-title-wrap">
<text class="toolbar-title">回签优先</text>
</view>
</view> </view>
</template>
</TopDivBar>
<scroll-view class="page-body" scroll-y>
<view class="tabs-bar">
<view
v-for="(item, index) in tabsData"
:key="index"
class="tab-item"
:class="{ 'tab-item-active': currentTabIndex === index }"
@click="currentTabIndex = index"
>
{{ item.text }}
</view>
</view>
<!-- 回签 --> <!-- 回签 -->
<view v-show="currentTabIndex === 0" class="panel"> <view v-show="currentTabIndex === 0" class="panel panel-card">
<view class="form-item"> <view class="form-item">
<text class="form-label">票号</text> <text class="form-label">票号</text>
<input v-model="resumeForm.ticketNo" class="form-input" placeholder="请输入票号" /> <input v-model="resumeForm.ticketNo" class="form-input" placeholder="请输入票号" />
</view> </view>
<view class="actions"> <view class="actions">
<tn-button width="110px" height="36px" :plain="true" text-color="#0099ff" @click="scanTicket"> <button class="btn btn-ghost" @click="scanTicket">
<uni-icons type="scan" size="16" color="#0099ff" style="margin-right: 5px;"></uni-icons> <uni-icons type="scan" size="16" color="#1e3a8a" style="margin-right: 5px;"></uni-icons>
</tn-button> 扫码
<tn-button width="110px" height="36px" text-color="#fff" @click="confirmResume"> </button>
回签 <button class="btn btn-primary" @click="confirmResume"></button>
</tn-button>
</view> </view>
</view> </view>
@ -41,17 +52,15 @@
</view> </view>
</view> </view>
</view> </view>
</scroll-view>
</view> </view>
</template> </template>
<script setup> <script setup>
import TopDivBar from "@/components/TopDivBar.vue";
import { getBizList } from "@/api/index.js"; import { getBizList } from "@/api/index.js";
import { createJumpTicket, resumeTicket } from "@/api/ticket.js"; import { createJumpTicket, resumeTicket } from "@/api/ticket.js";
import { onLoad } from "@dcloudio/uni-app"; import { onLoad } from "@dcloudio/uni-app";
import { reactive, ref } from "vue"; 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 currentTabIndex = ref(0);
const tabsData = [ const tabsData = [
@ -159,39 +168,117 @@ onLoad(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.page-container { .page-container {
min-height: 100vh; height: var(--pad-h);
padding-top: calc(6vh + 64px); overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
} }
.tabs-wrap { .page-toolbar {
width: 240rpx; flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: calc(env(safe-area-inset-top) + 10px) 20px 10px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
z-index: 20;
} }
.bg-white { .page-toolbar button,
background-color: #fff; .page-toolbar .back-btn {
width: auto !important;
flex: none;
} }
.panel { .back-btn {
padding: 14px 20px 20px; width: auto;
min-width: 84px;
height: 40px;
padding: 0 14px;
margin: 0;
gap: 4px;
flex-shrink: 0;
}
.toolbar-title-wrap {
flex-shrink: 0;
padding-left: 10px;
border-left: 3px solid #1d4ed8;
}
.toolbar-title {
font-size: 18px;
font-weight: 700;
color: #1e3a8a;
}
.tabs-bar {
display: inline-flex;
align-items: center;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
}
.tab-item {
min-width: 88px;
height: 36px;
padding: 0 16px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
color: #475569;
display: flex;
align-items: center;
justify-content: center;
}
.tab-item-active {
background: #2563eb;
color: #fff;
}
.page-body {
flex: 1;
height: 0;
padding: 16px 20px;
box-sizing: border-box;
}
.panel-card {
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 28px rgba(30, 58, 138, 0.05);
} }
.form-item { .form-item {
margin-bottom: 14px; margin-bottom: 16px;
} }
.form-label { .form-label {
display: block; display: block;
font-size: 14px; font-size: 14px;
color: #374151; color: #475569;
margin-bottom: 6px; font-weight: 600;
margin-bottom: 8px;
} }
.form-input { .form-input {
width: 100%; width: 100%;
height: 36px; height: 42px;
border: 1px solid #d1d5db; border: 1px solid #d7e2ee;
border-radius: 6px; border-radius: 10px;
padding: 0 10px; padding: 0 12px;
background: #f8fafc;
box-sizing: border-box; box-sizing: border-box;
} }
@ -200,6 +287,36 @@ onLoad(() => {
gap: 12px; gap: 12px;
} }
.btn {
min-width: 110px;
height: 42px;
border-radius: 21px;
font-size: 15px;
font-weight: 600;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn {
width: auto;
min-width: 84px;
padding: 0 12px;
gap: 4px;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-ghost {
background: #fff;
color: #1e3a8a;
border-color: #c7d7ea;
}
.biz-grid { .biz-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -207,13 +324,15 @@ onLoad(() => {
} }
.biz-item { .biz-item {
border: 1px solid #e5e7eb; border: 1px solid #d7e2ee;
border-radius: 10px; border-radius: 12px;
padding: 16px 12px; padding: 18px 12px;
background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 20px rgba(30, 58, 138, 0.05);
} }
.biz-icon { .biz-icon {
@ -223,7 +342,8 @@ onLoad(() => {
.biz-name { .biz-name {
font-size: 14px; font-size: 14px;
color: #111827; color: #1e293b;
font-weight: 600;
text-align: center; text-align: center;
} }
</style> </style>

@ -251,7 +251,7 @@ const closeResultPopup = () => {
<style scoped> <style scoped>
.business-container { .business-container {
padding: 40rpx 32rpx; padding: 40rpx 32rpx;
min-height: 100vh; min-height: var(--pad-h);
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
} }
@ -276,7 +276,7 @@ const closeResultPopup = () => {
} }
.business-list { .business-list {
height: 80vh; height: calc(80 * var(--pad-vh));
margin-bottom: 40rpx; margin-bottom: 40rpx;
} }
@ -370,7 +370,7 @@ const closeResultPopup = () => {
/* 取号结果弹窗 */ /* 取号结果弹窗 */
.result-popup { .result-popup {
width: 650rpx; width: 650rpx;
max-width: 90vw; max-width: calc(90 * var(--pad-vw));
background: white; background: white;
border-radius: 24rpx; border-radius: 24rpx;
overflow: hidden; overflow: hidden;

File diff suppressed because it is too large Load Diff

@ -20,7 +20,7 @@
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="shareToggle"> <tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="shareToggle">
筛选<uni-icons type="down" size="12" color="#0099ff" style="margin-left: 5px;"></uni-icons> 筛选<uni-icons type="down" size="12" color="#0099ff" style="margin-left: 5px;"></uni-icons>
</tn-button> </tn-button>
<div style="width: 2vw;"></div> <div style="width: calc(2 * var(--pad-vw));"></div>
<tn-button width="80px" height="32px" text-color="#fff" @click="search"> <tn-button width="80px" height="32px" text-color="#fff" @click="search">
<uni-icons type="search" size="18" color="#fff" style="margin-right: 5px;"></uni-icons> <uni-icons type="search" size="18" color="#fff" style="margin-right: 5px;"></uni-icons>
</tn-button> </tn-button>
@ -66,7 +66,7 @@
@click="resetSelectValue"> @click="resetSelectValue">
重置 重置
</tn-button> </tn-button>
<div style="width: 2vw;"></div> <div style="width: calc(2 * var(--pad-vw));"></div>
<tn-button width="80px" height="32px" text-color="#fff" @click="closeShareToggle"> <tn-button width="80px" height="32px" text-color="#fff" @click="closeShareToggle">
确定 确定
</tn-button> </tn-button>
@ -236,7 +236,7 @@
.top-div { .top-div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 6vh 20px 1vh 20px; padding: calc(6 * var(--pad-vh)) 20px calc(1 * var(--pad-vh)) 20px;
} }
.tools-div { .tools-div {
@ -273,7 +273,7 @@
} }
.filter-btn { .filter-btn {
height: 6vh; height: calc(6 * var(--pad-vh));
padding: 10px 0; padding: 10px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;

@ -20,7 +20,7 @@
<tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="shareToggle"> <tn-button width="80px" height="32px" :plain="true" text-color="#0099ff" @click="shareToggle">
筛选<uni-icons type="down" size="12" color="#0099ff" style="margin-left: 5px;"></uni-icons> 筛选<uni-icons type="down" size="12" color="#0099ff" style="margin-left: 5px;"></uni-icons>
</tn-button> </tn-button>
<div style="width: 2vw;"></div> <div style="width: calc(2 * var(--pad-vw));"></div>
<tn-button width="80px" height="32px" text-color="#fff" @click="search"> <tn-button width="80px" height="32px" text-color="#fff" @click="search">
<uni-icons type="search" size="18" color="#fff" style="margin-right: 5px;"></uni-icons> <uni-icons type="search" size="18" color="#fff" style="margin-right: 5px;"></uni-icons>
</tn-button> </tn-button>
@ -64,7 +64,7 @@
@click="resetSelectValue"> @click="resetSelectValue">
重置 重置
</tn-button> </tn-button>
<div style="width: 2vw;"></div> <div style="width: calc(2 * var(--pad-vw));"></div>
<tn-button width="80px" height="32px" text-color="#fff" @click="closeShareToggle"> <tn-button width="80px" height="32px" text-color="#fff" @click="closeShareToggle">
确定 确定
</tn-button> </tn-button>
@ -228,7 +228,7 @@
.top-div { .top-div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 6vh 20px 1vh 20px; padding: calc(6 * var(--pad-vh)) 20px calc(1 * var(--pad-vh)) 20px;
} }
.tools-div { .tools-div {
@ -265,7 +265,7 @@
} }
.filter-btn { .filter-btn {
height: 6vh; height: calc(6 * var(--pad-vh));
padding: 10px 0; padding: 10px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;

@ -1,9 +1,14 @@
<template> <template>
<div class="page-container bg-white"> <div class="page-container">
<TopDivBar @back="backToIndex"> <view class="page-toolbar">
<template #left> <button class="btn btn-ghost back-btn" @click="backToIndex">
<uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
返回
</button>
<view class="toolbar-title-wrap">
<text class="toolbar-title">票号管理</text>
</view>
<div class="tabs-div"> <div class="tabs-div">
<!-- 一级菜单 -->
<div class="first-level-tabs"> <div class="first-level-tabs">
<tn-tabs <tn-tabs
v-model="currentFirstLevelTab" v-model="currentFirstLevelTab"
@ -19,8 +24,6 @@
/> />
</tn-tabs> </tn-tabs>
</div> </div>
<!-- 二级菜单 -->
<div class="second-level-tabs"> <div class="second-level-tabs">
<tn-tabs <tn-tabs
v-model="currentSecondLevelTab" v-model="currentSecondLevelTab"
@ -37,63 +40,52 @@
</tn-tabs> </tn-tabs>
</div> </div>
</div> </div>
</template>
</TopDivBar>
<div class="tools-div">
<div class="tool-input"> <div class="tool-input">
<tn-button <tn-button
width="11vw" width="211px"
height="32px" height="36px"
:plain="true" :plain="true"
text-color="#0099ff" text-color="#1e3a8a"
@click="openDateTimePicker = !openDateTimePicker" @click="openDateTimePicker = !openDateTimePicker"
> >
时间筛选<uni-icons 时间筛选<uni-icons
type="down" type="down"
size="12" size="12"
color="#0099ff" color="#1e3a8a"
style="margin-left: 5px" style="margin-left: 5px"
></uni-icons> ></uni-icons>
</tn-button> </tn-button>
<div style="width: 1vw"></div>
<tn-button <tn-button
width="11vw" width="211px"
height="32px" height="36px"
:plain="true" :plain="true"
text-color="#0099ff" text-color="#1e3a8a"
@click="shareToggle" @click="shareToggle"
> >
业务筛选<uni-icons 业务筛选<uni-icons
type="down" type="down"
size="12" size="12"
color="#0099ff" color="#1e3a8a"
style="margin-left: 5px" style="margin-left: 5px"
></uni-icons> ></uni-icons>
</tn-button> </tn-button>
<div style="width: 1vw"></div>
<tn-input <tn-input
placeholder="请输入票号搜索" placeholder="请输入票号搜索"
height="36px" height="36px"
v-model="searchVal" v-model="searchVal"
> >
<template #prefix> <template #prefix>
<uni-icons type="search" size="18" color="#ccc"></uni-icons> <uni-icons type="search" size="18" color="#94a3b8"></uni-icons>
</template> </template>
</tn-input> </tn-input>
</div> </div>
<div class="tools-btn"> <div class="tools-btn">
<tn-button width="84px" height="32px" text-color="#fff" @click="search"> <button class="btn btn-primary action-btn" @click="search"></button>
<uni-icons
type="search"
size="18"
color="#fff"
style="margin-right: 5px"
></uni-icons
>搜索
</tn-button>
</div> </div>
</div> </view>
<div>
<scroll-view class="table-wrapper" scroll-y>
<view class="table-card">
<uni-table <uni-table
ref="table" ref="table"
:loading="loading" :loading="loading"
@ -141,22 +133,35 @@
</uni-td> </uni-td>
</uni-tr> </uni-tr>
</uni-table> </uni-table>
<div class="pagination-div"> </view>
<p> </scroll-view>
<span class="page-span">{{ total }}</span
>条数据每页显示<span class="page-span">{{ pageSize }}</span <view class="pagination-div">
> <text class="page-info">
</p> <span class="page-span">{{ total }}</span> · 每页{{ pageSize }}
<view class="uni-pagination-box" </text>
><uni-pagination <view class="page-actions">
show-icon <button
:page-size="pageSize" class="page-btn"
:current="pageCurrent" :disabled="pageCurrent <= 1 || loading"
:total="total" @click="handlePrevPage"
@change="change" >
/></view> 上一页
</div> </button>
</div> <view class="page-pill">
<text class="page-pill-num">{{ pageCurrent }}</text>
<text class="page-pill-sep">/</text>
<text>{{ totalPages }}</text>
</view>
<button
class="page-btn page-btn-primary"
:disabled="pageCurrent >= totalPages || loading"
@click="handleNextPage"
>
下一页
</button>
</view>
</view>
<!-- 税务健康检查报告组件 --> <!-- 税务健康检查报告组件 -->
<TaxHealthReport <TaxHealthReport
@ -226,7 +231,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import TopDivBar from "@/components/TopDivBar.vue";
import TnCheckboxGroup from "@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox-group.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 TnCheckbox from "@/uni_modules/tuniaoui-vue3/components/checkbox/src/checkbox.vue";
import TnDateTimePicker from "@/uni_modules/tuniaoui-vue3/components/date-time-picker/src/date-time-picker.vue"; import TnDateTimePicker from "@/uni_modules/tuniaoui-vue3/components/date-time-picker/src/date-time-picker.vue";
@ -307,6 +311,9 @@ let pageSize = ref(10);
let pageCurrent = ref(1); let pageCurrent = ref(1);
// //
let total = ref(0); let total = ref(0);
const totalPages = computed(() =>
Math.max(1, Math.ceil(Number(total.value) / Number(pageSize.value)) || 1),
);
let loading = ref(false); let loading = ref(false);
// //
let statusType = ref("0"); let statusType = ref("0");
@ -362,10 +369,20 @@ const formatDuration = (diffMs: number) => {
return `${totalMinutes}分钟`; return `${totalMinutes}分钟`;
}; };
const handlePrevPage = () => {
if (pageCurrent.value <= 1) return;
pageCurrent.value -= 1;
getData();
};
const handleNextPage = () => {
if (pageCurrent.value >= totalPages.value) return;
pageCurrent.value += 1;
getData();
};
// //
const change = (e) => { const change = (e) => {
// $refs.table.clearSelection()
// console.log(e)
pageCurrent.value = e.current; pageCurrent.value = e.current;
getData(); getData();
}; };
@ -664,54 +681,196 @@ const backToIndex = () => {
<style lang="scss"> <style lang="scss">
.page-container { .page-container {
min-height: 100vh; height: var(--pad-h);
padding-top: calc(6vh + 64px); overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
} }
.bg-white { .page-toolbar {
background-color: #fff; flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: calc(env(safe-area-inset-top) + 10px) 16px 10px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
z-index: 20;
} }
.tabs-div { .toolbar-title-wrap {
.first-level-tabs { flex-shrink: 0;
width: 100%; padding-left: 10px;
border-left: 3px solid #1d4ed8;
} }
.second-level-tabs { .toolbar-title {
width: 100%; font-size: 18px;
} font-weight: 700;
color: #1e3a8a;
} }
.tools-div { .tabs-div {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; min-width: 220px;
padding: 10px 20px; flex-shrink: 0;
}
.tool-input { .tool-input {
display: flex; display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
} }
.tools-btn { .tools-btn {
flex-shrink: 0;
}
.btn {
min-width: 84px;
height: 36px;
border-radius: 10px;
font-size: 14px;
border: none;
display: flex; display: flex;
align-items: center;
justify-content: center;
}
.back-btn {
width: auto;
min-width: 84px;
height: 40px;
padding: 0 12px;
gap: 4px;
}
.action-btn {
width: 88px;
height: 40px;
font-weight: 600;
}
.btn-primary {
background: #2563eb;
color: #fff;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22);
}
.btn-ghost {
background: #fff;
color: #1e3a8a;
border: 1px solid #c7d7ea;
}
.table-wrapper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
padding: 12px 16px;
box-sizing: border-box;
} }
.table-card {
min-height: 100%;
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(30, 58, 138, 0.05);
}
.table-wrapper :deep(.uni-table-th) {
background: #f3f6fb;
color: #475569;
font-weight: 600;
} }
.click-able { .click-able {
color: #0033ff; color: #2563eb;
text-decoration: underline; text-decoration: underline;
} }
.pagination-div { .pagination-div {
flex-shrink: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 10px 20px; align-items: center;
padding: 10px 20px calc(env(safe-area-inset-bottom) + 12px);
background: #fff;
border-top: 1px solid #d7e2ee;
}
.page-info {
font-size: 14px;
color: #64748b;
}
.page-span { .page-span {
display: inline; color: #1d4ed8;
color: #0099ff;
margin: 0 4px; margin: 0 4px;
font-weight: 700;
} }
.page-actions {
display: flex;
align-items: center;
gap: 12px;
}
.page-pill {
min-width: 92px;
height: 42px;
padding: 0 16px;
border-radius: 21px;
background: #eef3f9;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #64748b;
font-weight: 600;
}
.page-pill-num {
color: #1e3a8a;
font-size: 18px;
}
.page-pill-sep {
margin: 0 4px;
color: #94a3b8;
}
.page-btn {
min-width: 112px;
height: 42px;
padding: 0 18px;
border: 1px solid #c7d7ea;
border-radius: 21px;
background: #fff;
color: #1e3a8a;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn-primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.page-btn[disabled] {
opacity: 0.4;
} }
.uni-group { .uni-group {
@ -725,7 +884,7 @@ const backToIndex = () => {
} }
.filter-btn { .filter-btn {
height: 6vh; height: calc(6 * var(--pad-vh));
padding: 10px 0; padding: 10px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -733,5 +892,4 @@ const backToIndex = () => {
align-items: center; align-items: center;
} }
} }
</style> </style>

@ -1,162 +0,0 @@
/**
* 生成紫云智能导税 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,119 @@
const AES_KEY = 'Gi4swf3llyafmsuK'
const sbox = new Uint8Array([
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
])
const RCON = [1, 2, 4, 8, 16, 32, 64, 128, 27, 54]
const expandKey = (keyBytes) => {
const rk = new Uint8Array(176)
let i = 16
let rconIdx = 0
rk.set(keyBytes)
while (i < 176) {
let t = [rk[i - 4], rk[i - 3], rk[i - 2], rk[i - 1]]
if (i % 16 === 0) {
t = [sbox[t[1]] ^ RCON[rconIdx++], sbox[t[2]], sbox[t[3]], sbox[t[0]]]
}
for (let j = 0; j < 4; j++) {
rk[i] = rk[i - 16] ^ t[j]
i++
}
}
return rk
}
const xtime = (a) => ((a << 1) ^ (a & 128 ? 27 : 0)) & 255
const mul = (a, b) => {
let r = 0
while (b) {
if (b & 1) r ^= a
a = xtime(a)
b >>= 1
}
return r & 255
}
const encryptBlock = (b, rk) => {
for (let i = 0; i < 16; i++) b[i] ^= rk[i]
for (let round = 1; round <= 10; round++) {
for (let i = 0; i < 16; i++) b[i] = sbox[b[i]]
for (let r = 1; r < 4; r++) {
const row = [b[r], b[r + 4], b[r + 8], b[r + 12]]
for (let c = 0; c < 4; c++) b[r + 4 * c] = row[(c + r) % 4]
}
if (round < 10) {
for (let c = 0; c < 4; c++) {
const o = 4 * c
const a0 = b[o]
const a1 = b[o + 1]
const a2 = b[o + 2]
const a3 = b[o + 3]
b[o] = mul(a0, 2) ^ mul(a1, 3) ^ a2 ^ a3
b[o + 1] = a0 ^ mul(a1, 2) ^ mul(a2, 3) ^ a3
b[o + 2] = a0 ^ a1 ^ mul(a2, 2) ^ mul(a3, 3)
b[o + 3] = mul(a0, 3) ^ a1 ^ a2 ^ mul(a3, 2)
}
}
for (let i = 0; i < 16; i++) b[i] ^= rk[16 * round + i]
}
return b
}
const pkcs7 = (bytes) => {
const pad = 16 - (bytes.length % 16)
const out = new Uint8Array(bytes.length + pad)
out.set(bytes)
out.fill(pad, bytes.length)
return out
}
const b2a = (bytes) => {
let s = ''
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
return s
}
const utf8Bytes = (text) => {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder().encode(text)
}
const escaped = unescape(encodeURIComponent(text))
const out = new Uint8Array(escaped.length)
for (let i = 0; i < escaped.length; i++) out[i] = escaped.charCodeAt(i) & 255
return out
}
export const encryptAesEcb = (plainText, keyStr = AES_KEY) => {
const text = String(plainText ?? '')
if (!text) return ''
if (String(keyStr).length !== 16) {
throw new Error('密钥长度必须为 16 位')
}
const data = pkcs7(utf8Bytes(text))
const rk = expandKey(utf8Bytes(keyStr))
let out = ''
for (let off = 0; off < data.length; off += 16) {
const b = data.slice(off, off + 16)
encryptBlock(b, rk)
out += b2a(b)
}
return btoa(out)
}

@ -0,0 +1,148 @@
const STORAGE_KEY = 'app_runtime_logs'
const MAX_LOGS = 800
let logs = []
let installed = false
let persistTimer = null
const nativeConsole = {
log: typeof console.log === 'function' ? console.log.bind(console) : () => {},
info: typeof console.info === 'function' ? console.info.bind(console) : null,
warn: typeof console.warn === 'function' ? console.warn.bind(console) : null,
error: typeof console.error === 'function' ? console.error.bind(console) : null,
}
const loadFromStorage = () => {
try {
const raw = uni.getStorageSync(STORAGE_KEY)
if (!raw) {
logs = []
return
}
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
logs = Array.isArray(parsed) ? parsed : []
} catch (error) {
logs = []
}
}
const persistNow = () => {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
try {
uni.setStorageSync(STORAGE_KEY, JSON.stringify(logs))
} catch (error) {
// ignore persist errors
}
}
const schedulePersist = () => {
if (persistTimer) return
persistTimer = setTimeout(() => {
persistTimer = null
persistNow()
}, 300)
}
const formatArg = (arg) => {
if (arg === null) return 'null'
if (arg === undefined) return 'undefined'
if (typeof arg === 'string') return arg
if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg)
try {
return JSON.stringify(arg)
} catch (error) {
return String(arg)
}
}
const pad = (n) => String(n).padStart(2, '0')
const formatTime = (date = new Date()) => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${String(date.getMilliseconds()).padStart(3, '0')}`
}
export const appendLog = (level, args) => {
const message = (Array.isArray(args) ? args : [args])
.map(formatArg)
.join(' ')
logs.push({
time: formatTime(),
level: level || 'log',
message,
})
if (logs.length > MAX_LOGS) {
logs = logs.slice(logs.length - MAX_LOGS)
}
schedulePersist()
}
/** 业务关键日志:写入本地日志,同时输出到原生 console避免二次拦截 */
export const appLog = (level, ...args) => {
const lv = level || 'info'
appendLog(lv, args)
const printer = nativeConsole[lv] || nativeConsole.log || (() => {})
try {
printer(...args)
} catch (error) {
// ignore
}
}
export const flushLogs = () => {
persistNow()
}
export const getLogs = () => {
if (!logs.length) {
loadFromStorage()
}
return logs.slice()
}
export const getLogsText = () => {
return getLogs()
.map((item) => `[${item.time}] [${item.level}] ${item.message}`)
.join('\n')
}
export const clearLogs = () => {
logs = []
try {
uni.removeStorageSync(STORAGE_KEY)
} catch (error) {
// ignore
}
}
export const installAppLogger = () => {
if (installed) return
installed = true
loadFromStorage()
nativeConsole.log = console.log.bind(console)
nativeConsole.info = console.info ? console.info.bind(console) : nativeConsole.log
nativeConsole.warn = console.warn.bind(console)
nativeConsole.error = console.error.bind(console)
const wrap = (level, original) => {
return (...args) => {
try {
appendLog(level, args)
} catch (error) {
// ignore logger errors
}
if (typeof original === 'function') {
return original.apply(console, args)
}
}
}
console.log = wrap('log', nativeConsole.log)
console.info = wrap('info', nativeConsole.info)
console.warn = wrap('warn', nativeConsole.warn)
console.error = wrap('error', nativeConsole.error)
appLog('info', '[appLog] logger installed')
}

@ -0,0 +1,10 @@
const FALLBACK_VERSION = '1.2.1'
export const getAppVersion = () => {
try {
const info = uni.getSystemInfoSync() || {}
return info.appWgtVersion || info.appVersion || FALLBACK_VERSION
} catch (error) {
return FALLBACK_VERSION
}
}

@ -0,0 +1,31 @@
const STORAGE_KEY = "dutyTodos";
export const loadDutyTodos = () => {
try {
const raw = uni.getStorageSync(STORAGE_KEY);
const list = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(list)) return [];
return list
.map((item) => ({
id: String(item?.id || ""),
text: String(item?.text || "").trim(),
done: Boolean(item?.done),
}))
.filter((item) => item.id && item.text);
} catch (error) {
console.log("读取待办事项失败", error);
return [];
}
};
export const saveDutyTodos = (list) => {
const next = Array.isArray(list) ? list : [];
uni.setStorageSync(STORAGE_KEY, JSON.stringify(next));
return next;
};
export const createDutyTodo = (text) => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
text: String(text || "").trim(),
done: false,
});

@ -0,0 +1,143 @@
import { getCompanyRisk, searchCompanyPage } from '@/api/company.js'
import { encryptAesEcb } from '@/utils/aesEcb.js'
const healthCache = new Map()
const healthInflight = new Map()
const listLen = (value) => (Array.isArray(value) ? value.length : 0)
const personIdCard = (row) => String(row?.idCard || '').trim()
const mapPool = async (items, limit, mapper) => {
const result = new Array(items.length)
let cursor = 0
const worker = async () => {
while (cursor < items.length) {
const index = cursor
cursor += 1
result[index] = await mapper(items[index], index)
}
}
const size = Math.min(Math.max(limit, 1), items.length || 1)
await Promise.all(Array.from({ length: size }, () => worker()))
return result
}
const fetchCompanyList = async (zjhm) => {
const pageSize = 50
let pageNum = 1
let all = []
let total = 0
while (pageNum <= 10) {
const res = await searchCompanyPage({ zjhm, pageNum, pageSize })
const list = Array.isArray(res?.data) ? res.data : []
total = Number(res?.count) || 0
all = all.concat(list)
if (list.length < pageSize || (total > 0 && all.length >= total)) break
pageNum += 1
}
return all
}
const buildHealth = (companies, risks) => {
let violation = 0
let undeclare = 0
let owing = 0
let abnormal = 0
const details = companies.map((company, index) => {
const risk = risks[index] || null
const nsr = risk?.nsrxx || {}
const wfwz = listLen(risk?.wfwzxxList)
const wsb = listLen(risk?.sfzwsbList) + listLen(risk?.cwbbwsbList)
const qj = listLen(risk?.sfqjxxList)
violation += wfwz
undeclare += wsb
owing += qj
const ztDm = String(nsr.nsrztDm || company.nsrztDm || '')
if (ztDm && ztDm !== '03') abnormal += 1
return {
nsrmc: nsr.nsrmc || company.nsrmc || '',
nsrsbh: nsr.shxydm || nsr.nsrsbh || company.nsrsbh || '',
djxh: nsr.djxh || company.djxh || '',
nsrztDm: ztDm,
nsrztmc: nsr.nsrztmc || '',
fddbrxm: nsr.fddbrxm || company.fddbrxm || '',
violation: wfwz,
undeclare: wsb,
owing: qj,
}
})
return {
companyCount: companies.length,
abnormal,
violation,
undeclare,
owing,
details,
}
}
const queryPersonHealth = async (idCard) => {
const zjhm = encryptAesEcb(idCard)
const companies = await fetchCompanyList(zjhm)
const risks = await mapPool(companies, 3, async (company) => {
const djxh = String(company?.djxh || '').trim()
if (!djxh) return null
try {
const res = await getCompanyRisk({
zjhm,
djxh: encryptAesEcb(djxh),
})
return res?.data || null
} catch (error) {
return null
}
})
return buildHealth(companies, risks)
}
export const getHealthState = (row) => {
const idCard = personIdCard(row)
if (!idCard) {
return { unavailable: true, reason: '无身份证,暂无法体检' }
}
if (healthCache.has(idCard)) {
return healthCache.get(idCard)
}
return { loading: true }
}
export const loadHealthForRecords = async (records, onUpdate) => {
const list = Array.isArray(records) ? records : []
const need = []
const seen = new Set()
list.forEach((row) => {
const idCard = personIdCard(row)
if (!idCard || seen.has(idCard)) return
seen.add(idCard)
if (healthCache.has(idCard) || healthInflight.has(idCard)) return
need.push(idCard)
})
if (!need.length) return
if (typeof onUpdate === 'function') onUpdate()
await Promise.all(
need.map(async (idCard) => {
const task = queryPersonHealth(idCard)
.then((health) => {
healthCache.set(idCard, health)
return health
})
.catch(() => {
const failed = { queryFail: true, reason: '体检查询失败' }
healthCache.set(idCard, failed)
return failed
})
.finally(() => {
healthInflight.delete(idCard)
})
healthInflight.set(idCard, task)
await task
if (typeof onUpdate === 'function') onUpdate()
}),
)
}

@ -130,7 +130,6 @@ export const buildPrintTemplateSaveBody = (template) => {
return normalizePrintTemplate(template) return normalizePrintTemplate(template)
} }
// 极小模板,用于排查 sections 过大是否导致签名/网关异常
export const getMinimalPrintTemplateForTest = () => export const getMinimalPrintTemplateForTest = () =>
normalizePrintTemplate({ normalizePrintTemplate({
cutPaper: true, cutPaper: true,

@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { appLog, flushLogs } from '@/utils/appLog.js'
// 公网统一入口POST http://<gateway-host>/public // 公网统一入口POST http://<gateway-host>/public
// 这里留一个可配置的占位符,实际项目中建议从配置文件或环境变量读取 // 这里留一个可配置的占位符,实际项目中建议从配置文件或环境变量读取
@ -61,6 +62,243 @@ const clonePayloadPart = (value) => {
const isBizSuccessCode = (code) => code === 200 || code === '200' const isBizSuccessCode = (code) => code === 200 || code === '200'
const getBizCode = (err) => {
if (!err) return null
if (err.code !== undefined && err.code !== null && err.code !== '') {
return Number(err.code)
}
if (err.data && err.data.code !== undefined && err.data.code !== null) {
return Number(err.data.code)
}
return null
}
const isUnauthorizedError = (err) => {
if (!err) return false
if (Number(err.statusCode) === 401) return true
return getBizCode(err) === 401
}
const getErrorMessage = (err, fallback = '请求失败') => {
if (!err) return fallback
return (
err.msg ||
err.message ||
err.error ||
(err.data && (err.data.msg || err.data.message || err.data.error)) ||
fallback
)
}
let refreshPromise = null
let sessionRestorePromise = null
let needSessionRestore = false
let sessionGateResolve = null
let sessionGatePromise = Promise.resolve()
const closeSessionGate = () => {
if (!sessionGateResolve) {
sessionGatePromise = new Promise((resolve) => {
sessionGateResolve = resolve
})
}
}
const openSessionGate = () => {
if (sessionGateResolve) {
sessionGateResolve()
sessionGateResolve = null
}
sessionGatePromise = Promise.resolve()
}
/** 后台恢复续会话完成前,业务请求在此等待,避免与刷新 token 并发导致误登出 */
export const waitSessionReady = () => sessionGatePromise
const isRefreshAuthFailure = (err) => {
if (!err) return false
if (isUnauthorizedError(err)) return true
const msg = getErrorMessage(err, '')
if (/refresh token 不存在|access token 为空|无效.*token|token.*无效|未授权/i.test(msg)) {
return true
}
return false
}
const isLoginPage = () => {
try {
const pages = getCurrentPages()
if (!pages || !pages.length) return false
const route = String(pages[pages.length - 1].route || '')
return route.indexOf('login') !== -1
} catch (error) {
return false
}
}
const clearAuthAndGoLogin = () => {
appLog('warn', '[auth] clearAuthAndGoLogin: 登录态失效,准备跳转登录页')
flushLogs()
uni.removeStorageSync('token')
uni.removeStorageSync('refresh_token')
uni.removeStorageSync('refreshToken')
uni.removeStorageSync('userInfo')
if (isLoginPage()) {
appLog('info', '[auth] 已在登录页,跳过 reLaunch')
return
}
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/login'
})
}, 500)
}
const isNetworkError = (err) => {
if (!err) return true
if (err.errMsg && /fail|timeout/i.test(String(err.errMsg))) return true
if (err.message && /fail|timeout|network/i.test(String(err.message))) return true
return false
}
/** 标记需要在下次前台时续会话(锁屏/切后台) */
export const markSessionNeedsRestore = () => {
needSessionRestore = true
closeSessionGate()
appLog('info', '[session] markSessionNeedsRestore: 已标记,等待回到前台续会话')
flushLogs()
}
/**
* 解锁或回到前台时续会话
* 1. 先用本地 access_token 校验 /profile短时锁屏通常仍有效
* 2. 401 时再刷新 token
* 3. 刷新/校验鉴权失败才登出签名或网络问题保留登录态
*/
export const restoreSession = async () => {
if (sessionRestorePromise) {
appLog('info', '[session] restoreSession: 已有续会话进行中,复用')
return sessionRestorePromise
}
sessionRestorePromise = (async () => {
try {
const token = uni.getStorageSync('token')
const refreshToken =
uni.getStorageSync('refresh_token') ||
uni.getStorageSync('refreshToken')
appLog(
'info',
'[session] restoreSession: 开始',
`hasToken=${Boolean(token)}`,
`hasRefreshToken=${Boolean(refreshToken)}`,
`onLoginPage=${isLoginPage()}`
)
if (!token && !refreshToken) {
appLog('warn', '[session] restoreSession: 本地无登录态,跳过')
return false
}
const tryProfile = async () => {
appLog('info', '[session] restoreSession: 校验 profile')
await request({
tag: 'pad.auth',
path: '/profile',
method: 'GET',
loading: false,
quiet: true,
skipSessionGate: true,
})
}
try {
if (token) {
await tryProfile()
appLog('info', '[session] restoreSession: 现有 token 仍有效')
flushLogs()
if (isLoginPage()) {
appLog('info', '[session] restoreSession: 当前在登录页,跳转首页')
uni.reLaunch({
url: '/pages/index/index',
})
}
return true
}
} catch (profileErr) {
if (!isUnauthorizedError(profileErr)) {
if (isNetworkError(profileErr)) {
appLog('warn', '[session] restoreSession: profile 网络异常,保留本地登录态')
return false
}
appLog('warn', '[session] restoreSession: profile 非鉴权失败,保留本地登录态', profileErr)
return false
}
appLog('warn', '[session] restoreSession: profile 401尝试刷新 token')
}
if (!refreshToken) {
appLog('error', '[session] restoreSession: 无 refresh_token无法续会话')
clearAuthAndGoLogin()
return false
}
appLog('info', '[session] restoreSession: 主动刷新 token')
await refreshAccessToken()
await tryProfile()
appLog('info', '[session] restoreSession: 续会话成功')
flushLogs()
if (isLoginPage()) {
appLog('info', '[session] restoreSession: 当前在登录页,跳转首页')
uni.reLaunch({
url: '/pages/index/index',
})
}
return true
} catch (error) {
appLog('error', '[session] restoreSession: 续会话失败', error)
const msg = getErrorMessage(error, '')
if (/signature is blank/i.test(msg)) {
appLog('warn', '[session] restoreSession: 刷新签名失败,保留本地登录态')
return false
}
if (isUnauthorizedError(error) || isRefreshAuthFailure(error)) {
appLog('warn', '[session] restoreSession: 鉴权失败,清理登录态')
clearAuthAndGoLogin()
return false
}
if (isNetworkError(error)) {
appLog('warn', '[session] restoreSession: 网络异常,保留本地登录态')
return false
}
appLog('warn', '[session] restoreSession: 未知异常,保留本地登录态', error)
return false
} finally {
openSessionGate()
sessionRestorePromise = null
}
})()
return sessionRestorePromise
}
/** 仅在从后台恢复时续会话,避免首次冷启动重复打断登录页 */
export const restoreSessionIfNeeded = async () => {
if (!needSessionRestore) {
appLog('info', '[session] restoreSessionIfNeeded: 无需续会话')
openSessionGate()
return true
}
appLog('info', '[session] restoreSessionIfNeeded: 触发续会话')
needSessionRestore = false
return restoreSession()
}
// 组装接口要求的公共请求头 // 组装接口要求的公共请求头
const buildHeaders = (withToken = true) => { const buildHeaders = (withToken = true) => {
const headers = { const headers = {
@ -82,8 +320,11 @@ const buildHeaders = (withToken = true) => {
} }
// 统一处理 HTTP 状态码和业务状态码(文档规范) // 统一处理 HTTP 状态码和业务状态码(文档规范)
const handleResponse = (res) => { const handleResponse = (res, meta = {}) => {
const { statusCode, data } = res const { statusCode, data } = res
const apiLabel = `${meta.tag || ''}${meta.path || ''}` || 'unknown'
const methodLabel = meta.method || ''
const traceLabel = meta.traceId || ''
// 1. HTTP 层异常 // 1. HTTP 层异常
if (statusCode !== 200) { if (statusCode !== 200) {
@ -94,12 +335,26 @@ const handleResponse = (res) => {
if (statusCode === 404) msg = '资源不存在' if (statusCode === 404) msg = '资源不存在'
if (statusCode >= 500) msg = '服务器内部错误' if (statusCode >= 500) msg = '服务器内部错误'
appLog(
statusCode === 401 ? 'warn' : 'error',
'[request][http-error]',
`${methodLabel} ${apiLabel}`,
`http=${statusCode}`,
msg,
data && data.msg,
data && data.code,
traceLabel ? `traceId=${traceLabel}` : ''
)
// 401 交给上层静默刷新,避免先弹出错误打断体验
if (statusCode !== 401 && !meta.quiet) {
uni.showToast({ uni.showToast({
title: `${msg} (${statusCode})`, title: `${msg} (${statusCode})`,
icon: 'none' icon: 'none'
}) })
}
console.log('[request][http-error]', res) console.log('[request][http-error]', res)
throw res throw Object.assign({}, res, { code: statusCode === 401 ? 401 : (data && data.code) })
} }
// 2. 业务层:文档约定 code=200 成功,其它为错误 // 2. 业务层:文档约定 code=200 成功,其它为错误
@ -114,12 +369,23 @@ const handleResponse = (res) => {
const bizCode = data && data.code const bizCode = data && data.code
const bizMsg = (data && data.msg) || '请求失败' const bizMsg = (data && data.msg) || '请求失败'
appLog(
Number(bizCode) === 401 ? 'warn' : 'error',
'[request][biz-error]',
`${methodLabel} ${apiLabel}`,
`code=${bizCode}`,
bizMsg,
traceLabel ? `traceId=${traceLabel}` : ''
)
if (Number(bizCode) !== 401 && !meta.quiet) {
uni.showToast({ uni.showToast({
title: `${bizMsg}${bizCode ? ` (${bizCode})` : ''}`, title: `${bizMsg}${bizCode ? ` (${bizCode})` : ''}`,
icon: 'none' icon: 'none'
}) })
}
console.log('[request][biz-error]', data) console.log('[request][biz-error]', data)
throw data throw Object.assign({}, data || {}, { code: bizCode, statusCode })
} }
/** /**
@ -144,6 +410,10 @@ const handleResponse = (res) => {
* - loadingText: loading 文案 * - loadingText: loading 文案
*/ */
const request = (options) => { const request = (options) => {
return runRequest(options)
}
const runRequest = async (options) => {
const { const {
tag, tag,
path, path,
@ -153,11 +423,19 @@ const request = (options) => {
traceId, traceId,
withToken = true, withToken = true,
skipSign = false, skipSign = false,
skipSessionGate = false,
quiet = false,
loading: showLoading = true, loading: showLoading = true,
loadingText = '加载中...', loadingText = '加载中...',
_retry401 = false _retry401 = false,
_skip401Retry = false
} = options } = options
const isRefreshRequest = tag === REFRESH_TAG && path === REFRESH_PATH
if (!skipSign && !isRefreshRequest && !skipSessionGate) {
await waitSessionReady()
}
if (!tag) { if (!tag) {
throw new Error('request 需要传入 tag') throw new Error('request 需要传入 tag')
} }
@ -175,6 +453,13 @@ const request = (options) => {
const authHeader = token ? `Bearer ${token}` : '' const authHeader = token ? `Bearer ${token}` : ''
const doRequest = (payload, withTokenHeader = useToken) => { const doRequest = (payload, withTokenHeader = useToken) => {
const reqMeta = {
tag,
path,
method: normalizedMethod,
traceId: finalTraceId,
quiet
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.request({ uni.request({
url: baseURL, url: baseURL,
@ -183,7 +468,7 @@ const request = (options) => {
header: buildHeaders(withTokenHeader), header: buildHeaders(withTokenHeader),
success: (res) => { success: (res) => {
try { try {
const result = handleResponse(res) const result = handleResponse(res, reqMeta)
resolve(result) resolve(result)
} catch (e) { } catch (e) {
console.log('[request][handleResponse-error]', e) console.log('[request][handleResponse-error]', e)
@ -191,10 +476,19 @@ const request = (options) => {
} }
}, },
fail: (err) => { fail: (err) => {
appLog(
'error',
'[request][network-fail]',
`${normalizedMethod} ${tag}${path}`,
err && err.errMsg,
finalTraceId ? `traceId=${finalTraceId}` : ''
)
if (!quiet) {
uni.showToast({ uni.showToast({
title: '网络请求失败', title: '网络请求失败',
icon: 'none' icon: 'none'
}) })
}
console.log('[request][network-fail]', err) console.log('[request][network-fail]', err)
reject(err) reject(err)
}, },
@ -208,95 +502,44 @@ const request = (options) => {
}) })
} }
// 401 自动刷新 token // 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) => { const with401Retry = (executor) => {
return executor().catch(async (err) => { 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 const isRefreshRequest = tag === REFRESH_TAG && path === REFRESH_PATH
if (!_retry401 && !isRefreshRequest && (isBiz401 || isHttp401)) { if (!_retry401 && !_skip401Retry && !isRefreshRequest && isUnauthorizedError(err)) {
appLog(
'warn',
'[request][401]',
`tag=${tag}`,
`path=${path}`,
'触发静默刷新并重试'
)
try { try {
await refreshAccessToken() await refreshAccessToken()
// 刷新后重试一次(重走签名流程) // 刷新后重试一次(重走签名流程)
appLog('info', '[request][401]', `tag=${tag}`, `path=${path}`, '刷新成功,重试原请求')
return await request({ return await request({
...options, ...options,
_retry401: true, _retry401: true,
loading: false loading: false
}) })
} catch (refreshErr) { } catch (refreshErr) {
console.log('[request][refresh-fail]', refreshErr) appLog('error', '[request][401]', '刷新失败', refreshErr)
if (isRefreshAuthFailure(refreshErr)) {
clearAuthAndGoLogin()
} else {
appLog('warn', '[request][401]', '刷新失败(非鉴权原因),保留登录态')
}
throw err throw err
} }
} }
if (_retry401 && isUnauthorizedError(err)) {
appLog('error', '[request][401]', `tag=${tag}`, `path=${path}`, '重试后仍 401')
clearAuthAndGoLogin()
}
throw err throw err
}) })
} }
@ -360,6 +603,34 @@ const request = (options) => {
} }
} }
if (isRefreshRequest) {
appLog(
'info',
'[auth][refresh-sign]',
'准备调用 /sign',
`baseURL=${baseURL}`,
`traceId=${finalTraceId}`,
`hasAccessToken=${Boolean(tokenForSign)}`,
`accessTokenLen=${tokenForSign ? String(tokenForSign).length : 0}`,
`timestamp=${timestamp}`,
`nonce=${nonce}`,
'signBody=',
{
tag,
path,
query: normalizedQuery,
bodyKeys: Object.keys(normalizedBody || {}),
hasRefreshToken: Boolean(normalizedBody && normalizedBody.refreshToken),
refreshTokenLen: normalizedBody && normalizedBody.refreshToken
? String(normalizedBody.refreshToken).length
: 0,
timestamp,
nonce,
}
)
flushLogs()
}
return with401Retry(() => new Promise((resolve, reject) => { return with401Retry(() => new Promise((resolve, reject) => {
uni.request({ uni.request({
// 签名服务也通过公网统一入口 /public由 SIGN_TAG + SIGN_PATH 路由到内部 /api/sign // 签名服务也通过公网统一入口 /public由 SIGN_TAG + SIGN_PATH 路由到内部 /api/sign
@ -373,16 +644,67 @@ const request = (options) => {
const signOk = const signOk =
signRes.statusCode === 200 && isBizSuccessCode(signData.code) signRes.statusCode === 200 && isBizSuccessCode(signData.code)
if (isRefreshRequest) {
const dataKeys =
signData.data && typeof signData.data === 'object'
? Object.keys(signData.data)
: []
const rawSig =
(signData.data && signData.data.signature) ||
signData.signature ||
''
appLog(
'info',
'[auth][refresh-sign]',
'/sign 返回',
`http=${signRes.statusCode}`,
`code=${signData.code}`,
`msg=${signData.msg || signData.message || signData.error || ''}`,
`signOk=${signOk}`,
`dataKeys=${JSON.stringify(dataKeys)}`,
`hasSignature=${Boolean(rawSig)}`,
`signatureLen=${rawSig ? String(rawSig).length : 0}`,
`signaturePrefix=${rawSig ? String(rawSig).slice(0, 12) : ''}`,
'signData=',
signData
)
flushLogs()
}
if (!signOk) { if (!signOk) {
const msg = const signErr = Object.assign({}, signRes, {
code: signData.code,
msg:
signData.error || signData.error ||
signData.msg || signData.msg ||
signData.message || signData.message ||
'获取签名失败' '获取签名失败',
data: signData
})
// 401不弹 Toast交给 with401Retry 静默刷新后重试
if (isUnauthorizedError(signErr)) {
appLog(
'warn',
'[request][sign]',
`业务=${tag}${path}`,
'签名接口返回 401准备静默刷新'
)
} else {
appLog(
'error',
'[request][sign]',
`业务=${tag}${path}`,
'获取签名失败',
signErr.msg,
signData.code
)
if (!quiet) {
uni.showToast({ uni.showToast({
title: msg, title: getErrorMessage(signErr, '获取签名失败'),
icon: 'none' icon: 'none'
}) })
}
}
console.log('[request][sign-response-error]', signRes) console.log('[request][sign-response-error]', signRes)
console.log('[request][sign-request-body]', { console.log('[request][sign-request-body]', {
tag, tag,
@ -391,13 +713,22 @@ const request = (options) => {
query: normalizedQuery, query: normalizedQuery,
body: normalizedBody, body: normalizedBody,
}) })
throw signRes throw signErr
} }
const signature = const signature =
(signData.data && signData.data.signature) || signData.signature (signData.data && signData.data.signature) || signData.signature
if (!signature) { if (!signature) {
appLog(
'error',
'[request][sign]',
`业务=${tag}${path}`,
'签名结果为空',
`traceId=${finalTraceId}`,
'signData=',
signData
)
console.log('[request][sign-empty]', signRes) console.log('[request][sign-empty]', signRes)
throw new Error('签名结果为空') throw new Error('签名结果为空')
} }
@ -421,12 +752,78 @@ const request = (options) => {
} }
} }
doRequest(payload).then(resolve).catch(reject) if (isRefreshRequest) {
const head = payload.map.head || {}
appLog(
'info',
'[auth][refresh-req]',
'即将调用 /refresh',
`baseURL=${baseURL}`,
`traceId=${finalTraceId}`,
`path=${payload.map.path}`,
`hasSignature=${Boolean(head.signature)}`,
`signatureLen=${head.signature ? String(head.signature).length : 0}`,
`signaturePrefix=${head.signature ? String(head.signature).slice(0, 12) : ''}`,
`timestamp=${head.timestamp}`,
`nonce=${head.nonce}`,
`hasAuthorization=${Boolean(head.Authorization)}`,
`bodyHasRefreshToken=${Boolean(
payload.map.body && payload.map.body.refreshToken
)}`,
'headKeys=',
Object.keys(head)
)
flushLogs()
}
doRequest(payload)
.then((result) => {
if (isRefreshRequest) {
appLog(
'info',
'[auth][refresh-req]',
'/refresh 业务成功',
`hasAccessToken=${Boolean(
result && (result.access_token || result.token)
)}`,
`hasRefreshToken=${Boolean(
result && (result.refresh_token || result.refreshToken)
)}`
)
flushLogs()
}
resolve(result)
})
.catch((err) => {
if (isRefreshRequest) {
appLog(
'error',
'[auth][refresh-req]',
'/refresh 业务失败',
`http=${err && err.statusCode}`,
`code=${getBizCode(err)}`,
`msg=${getErrorMessage(err, '')}`,
'err=',
err
)
flushLogs()
}
reject(err)
})
} catch (err) { } catch (err) {
if (showLoading) { if (showLoading) {
loading.value = false loading.value = false
uni.hideLoading() uni.hideLoading()
} }
if (!isUnauthorizedError(err)) {
appLog(
'error',
'[request][sign-process-error]',
`业务=${tag}${path}`,
getErrorMessage(err, '签名流程异常'),
`traceId=${finalTraceId}`
)
}
console.log('[request][sign-process-error]', err) console.log('[request][sign-process-error]', err)
reject(err) reject(err)
} }
@ -436,10 +833,29 @@ const request = (options) => {
loading.value = false loading.value = false
uni.hideLoading() uni.hideLoading()
} }
if (isRefreshRequest) {
appLog(
'error',
'[auth][refresh-sign]',
'/sign 网络失败',
err && err.errMsg,
`traceId=${finalTraceId}`
)
flushLogs()
}
appLog(
'error',
'[request][sign-network-fail]',
`业务=${tag}${path}`,
err && err.errMsg,
`traceId=${finalTraceId}`
)
if (!quiet) {
uni.showToast({ uni.showToast({
title: '获取签名接口失败', title: '获取签名接口失败',
icon: 'none' icon: 'none'
}) })
}
console.log('[request][sign-network-fail]', err) console.log('[request][sign-network-fail]', err)
reject(err) reject(err)
} }
@ -447,4 +863,88 @@ const request = (options) => {
})) }))
} }
const refreshAccessToken = () => {
if (refreshPromise) {
appLog('info', '[auth] refreshAccessToken: 复用进行中的刷新请求')
return refreshPromise
}
refreshPromise = (async () => {
const refreshToken =
uni.getStorageSync('refresh_token') ||
uni.getStorageSync('refreshToken')
if (!refreshToken) {
appLog('error', '[auth] refreshAccessToken: refresh token 不存在')
throw new Error('refresh token 不存在')
}
appLog(
'info',
'[auth] refreshAccessToken: 开始刷新 token签名网关',
`baseURL=${baseURL}`,
`hasAccessToken=${Boolean(uni.getStorageSync('token'))}`,
`refreshTokenLen=${String(refreshToken).length}`
)
flushLogs()
// /sign 需要 Bearer短时后台时旧 access_token 通常仍可用来签名
// 若 access 已失效,签名会 401由上层决定是否登出
const tokenData = await runRequest({
tag: REFRESH_TAG,
path: REFRESH_PATH,
method: 'POST',
body: { refreshToken },
withToken: true,
quiet: true,
loading: false,
skipSessionGate: true,
_skip401Retry: true,
})
const newAccessToken =
tokenData?.access_token || tokenData?.token || ''
const newRefreshToken =
tokenData?.refresh_token || tokenData?.refreshToken || ''
if (!newAccessToken) {
appLog(
'error',
'[auth] refreshAccessToken: 刷新成功但 access token 为空',
'tokenDataKeys=',
tokenData && typeof tokenData === 'object' ? Object.keys(tokenData) : [],
'tokenData=',
tokenData
)
throw new Error('刷新成功但 access token 为空')
}
uni.setStorageSync('token', newAccessToken)
if (newRefreshToken) {
uni.setStorageSync('refresh_token', newRefreshToken)
}
appLog(
'info',
'[auth] refreshAccessToken: 刷新成功',
`accessTokenLen=${String(newAccessToken).length}`,
`gotNewRefreshToken=${Boolean(newRefreshToken)}`
)
flushLogs()
return newAccessToken
})().catch((err) => {
appLog(
'error',
'[auth] refreshAccessToken: 最终失败',
`code=${getBizCode(err)}`,
`msg=${getErrorMessage(err, '')}`,
err
)
flushLogs()
throw err
}).finally(() => {
refreshPromise = null
})
return refreshPromise
}
export default request export default request
Loading…
Cancel
Save