This commit is contained in:
Mrx
2026-05-21 17:05:09 +08:00
parent 6137c69034
commit d18feb3090
18 changed files with 577 additions and 544 deletions

22
.env
View File

@@ -1,9 +1,17 @@
# 客户端可读变量必须以 VITE_ 开头
# 不配置 VITE_API_BASE_URL 时,逻辑在 src/api/http.js 的 resolveBaseUrl()
# - H5默认 /api/v1配合 vite.config.js 里 proxy
# - 非 H5默认 https://www.tianyuancha.cn/api/v1
# API 基础地址控制
# ──────────────────────────────────────────────
# 方式1不设置 VITE_API_BASE_URL留空由代码自动选择
# H5 dev → /api/v1走 Vite proxy
# 小程序 dev → http://127.0.0.1:8888/api/v1直连本地
# 生产 → https://www.quannengcha.com/api/v1
#
# 需要覆盖时再取消注释其一:
# VITE_API_BASE_URL=/api/v1
# 方式2设置 VITE_API_BASE_URL 强制覆盖所有端
# 连线上:VITE_API_BASE_URL=https://www.quannengcha.com/api/v1
# 连本地VITE_API_BASE_URL=http://127.0.0.1:8888/api/v1
# ──────────────────────────────────────────────
# 想用线上接口时取消下面这行注释:
# VITE_API_BASE_URL=https://www.quannengcha.com/api/v1
# 想用本地接口时注释掉上面那行,取消下面这行注释:
VITE_API_BASE_URL=http://127.0.0.1:8888/api/v1
# VITE_API_BASE_URL=https://www.tianyuancha.cn/api/v1

2
.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境:一般留空,由 http.js 按平台默认连 qnc-server-v38888
# H5 默认 /api/v1 + Vite 代理;小程序默认 http://127.0.0.1:8888/api/v1

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# 生产构建:指向 qnc-server-v3 线上网关
VITE_API_BASE_URL=https://www.quannengcha.com/api/v1

12
env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 覆盖全部端的 API 根路径,如 /api/v1 或 http://127.0.0.1:8888/api/v1 */
readonly VITE_API_BASE_URL?: string
/** H5 开发代理目标,仅 vite 使用,默认 http://127.0.0.1:8888 */
readonly VITE_API_PROXY_TARGET?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,19 +1,16 @@
<script setup>
import { onLaunch } from '@dcloudio/uni-app'
// #ifdef MP-WEIXIN
import { hasToken } from '@/utils/session'
import { tryWxMiniProgramAuth } from '@/utils/wxMiniAuth'
// #endif
onLaunch(async () => {
// #ifdef MP-WEIXIN
if (hasToken())
return
try {
await tryWxMiniProgramAuth({ silent: true })
}
catch (e) {
console.warn('[app] wx silent login', e)
console.warn('[app] wx silent login failed', e)
}
// #endif
})

View File

@@ -1,7 +1,8 @@
import un from '@uni-helper/uni-network'
import { QNC_API } from '@/config/api'
/** 未单独配置登录页时401 回到「我的」 */
const AUTH_FALLBACK_PAGE = '/pages/mine'
/** 未单独配置登录页时401 回到登录页 */
const AUTH_FALLBACK_PAGE = '/pages/login'
const TOKEN_KEY = 'token'
const SILENT_TOAST_CODES = new Set([200002, 200003, 200004, 100009])
@@ -21,22 +22,28 @@ function hideRequestLoading() {
}
}
/** H5 与其它端分支由 uni 条件编译裁剪,源码中并存会触发 no-unreachable */
/**
* 解析 API 根路径(含 /api/v1
* 优先级VITE_API_BASE_URL > 按端/环境的 QNC_API 默认值。
*/
function resolveBaseUrl() {
const fromEnv = import.meta.env.VITE_API_BASE_URL
const fromEnv = import.meta.env.VITE_API_BASE_URL?.trim()
if (fromEnv)
return fromEnv.replace(/\/$/, '')
/* eslint-disable no-unreachable */
// #ifdef H5
return '/api/v1'
// #endif
// #ifndef H5
return 'https://www.tianyuancha.cn/api/v1'
// #endif
/* eslint-enable no-unreachable */
if (import.meta.env.DEV) {
/* eslint-disable no-unreachable */
// #ifdef H5
return QNC_API.devH5Base
// #endif
// #ifndef H5
return QNC_API.devMpBase
// #endif
/* eslint-enable no-unreachable */
}
return QNC_API.prodBase
}
/** 对齐 tyc-webview-v2 useApiFetchh5 / wxh5其它端单独标识 */
/** 对齐 qnc-server-v3 平台标识h5 / wxh5 / wxmini 等 */
function getXPlatform() {
let platform = 'h5'
// #ifdef H5
@@ -45,7 +52,7 @@ function getXPlatform() {
platform = 'wxh5'
// #endif
// #ifdef MP-WEIXIN
// 须与 tyc-server-v2 model.PlatformWxMini"wxmini")一致,勿用 mp-weixin否则 JWT 生成会失败
// 须与 qnc-server-v3 model.PlatformWxMini"wxmini")一致,勿用 mp-weixin否则 JWT 失败
platform = 'wxmini'
// #endif
// #ifdef MP-ALIPAY
@@ -100,7 +107,7 @@ function toastBizIfNeeded(body) {
}
/**
* 与 tyc-webview-v2 `useApiFetch` 对齐的实例:
* 请求 qnc-server-v3 的 HTTP 实例:
* - baseUrl `/api/v1`H5 默认,可配 VITE_API_BASE_URL
* - Header`Authorization`、`X-Platform`
* - Query`t` 时间戳防缓存

View File

@@ -0,0 +1,133 @@
import { onUnmounted, ref } from 'vue'
import { postAuthSendSmsBindMobile, postUserBindMobile } from '@/api'
import { saveAuthSession } from '@/utils/session'
/**
* 绑定手机号弹窗逻辑(可在任意页面复用)
* 使用方式:
* const { bindVisible, bindPhone, bindCode, ... } = useBindMobile(onSuccess)
*/
export function useBindMobile(onSuccess?: () => void | Promise<void>) {
const bindVisible = ref(false)
const bindPhone = ref('')
const bindCode = ref('')
const bindSending = ref(false)
const bindSubmitting = ref(false)
const bindCountingDown = ref(false)
const bindCountdown = ref(60)
let bindSmsTimer: ReturnType<typeof setInterval> | null = null
function clearBindSmsTimer() {
if (bindSmsTimer) {
clearInterval(bindSmsTimer)
bindSmsTimer = null
}
}
function startBindCountdown() {
clearBindSmsTimer()
bindCountingDown.value = true
bindCountdown.value = 60
bindSmsTimer = setInterval(() => {
if (bindCountdown.value > 0) {
bindCountdown.value--
}
else {
clearBindSmsTimer()
bindCountingDown.value = false
}
}, 1000)
}
const isBindPhoneValid = () => /^1[3-9]\d{9}$/.test(bindPhone.value)
function openBindModal() {
bindPhone.value = ''
bindCode.value = ''
bindVisible.value = true
}
function closeBindModal() {
bindVisible.value = false
clearBindSmsTimer()
bindCountingDown.value = false
}
function onBindPhoneInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
bindPhone.value = String(raw).replace(/\D/g, '').slice(0, 11)
}
function onBindCodeInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
bindCode.value = String(raw).replace(/\D/g, '').slice(0, 6)
}
async function sendBindSms() {
if (bindSending.value || bindCountingDown.value)
return
if (!isBindPhoneValid()) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
bindSending.value = true
try {
const res = await postAuthSendSmsBindMobile({ mobile: bindPhone.value }) as { code?: number }
if (res && res.code === 200) {
uni.showToast({ title: '验证码已发送', icon: 'none' })
startBindCountdown()
}
}
finally {
bindSending.value = false
}
}
async function submitBindMobile() {
if (!isBindPhoneValid()) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (bindCode.value.length < 6) {
uni.showToast({ title: '请输入 6 位验证码', icon: 'none' })
return
}
bindSubmitting.value = true
try {
const res = await postUserBindMobile({
mobile: bindPhone.value,
code: bindCode.value,
}) as { code?: number, data?: { accessToken: string, refreshAfter: number | string, accessExpire: number | string } }
if (res && res.code === 200 && res.data) {
saveAuthSession(res.data)
uni.showToast({ title: '绑定成功', icon: 'success' })
closeBindModal()
await onSuccess?.()
}
}
finally {
bindSubmitting.value = false
}
}
onUnmounted(() => {
clearBindSmsTimer()
})
return {
bindVisible,
bindPhone,
bindCode,
bindSending,
bindSubmitting,
bindCountingDown,
bindCountdown,
isBindPhoneValid,
openBindModal,
closeBindModal,
onBindPhoneInput,
onBindCodeInput,
sendBindSms,
submitBindMobile,
}
}

16
src/config/api.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* 与 qnc-server-v3 对齐的 API 地址main.yaml Host:8888生产域名见 Promotion.OfficialDomain
* 开发时一般无需改此处;可用根目录 .env 的 VITE_API_BASE_URL 覆盖全部端。
*/
export const QNC_API = {
/** 本地 qnc-server-v3 监听地址 */
devServerOrigin: 'http://127.0.0.1:8888',
/** H5 开发:走 vite.config.js 代理,避免跨域 */
devH5Base: '/api/v1',
/** 小程序 / App 开发:直连本机后端(微信开发者工具需关闭域名校验或配置合法域名) */
get devMpBase() {
return `${this.devServerOrigin}/api/v1`
},
/** 生产环境(与 qnc-server-v3 etc/main.yaml 一致) */
prodBase: 'https://www.quannengcha.com/api/v1',
}

View File

@@ -3,6 +3,7 @@ import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getQueryExample } from '@/api'
import VehicleReportShell from '@/components/report/VehicleReportShell.vue'
import { parseEncryptedQueryReport } from '@/utils/queryReportParse'
import { normalizeVehicleQueryData } from '@/utils/vehicleReportNormalize'
definePage({
@@ -36,20 +37,19 @@ async function load() {
errText.value = ''
try {
const res = await getQueryExample(feature.value)
if (res?.code === 200 && res.data) {
productName.value = res.data.product_name || feature.value
queryParams.value = res.data.query_params || {}
rows.value = normalizeVehicleQueryData(res.data.query_data || [])
const parsed = parseEncryptedQueryReport(res)
productName.value = parsed.productName || feature.value
if (parsed.ok) {
queryParams.value = parsed.queryParams
rows.value = parsed.rows
if (!rows.value.length)
errText.value = '该产品暂无示例模块数据'
}
else if (res?.code === 200003) {
productName.value = feature.value
else if (parsed.empty) {
errText.value = '暂无示例报告'
}
else {
productName.value = feature.value
errText.value = res?.msg || '加载失败'
errText.value = parsed.msg || '加载失败'
}
}
catch {

View File

@@ -3,6 +3,7 @@ import { onLoad } from '@dcloudio/uni-app'
import { computed, onUnmounted, ref } from 'vue'
import { getProductByEn, getUserDetail, postAuthSendSmsQuery, postPayPayment, postQueryService, postUploadImage } from '@/api'
import { productHasSmsCode, useInquireForm } from '@/composables/useInquireForm'
import { useBindMobile } from '@/composables/useBindMobile'
import { aesEncrypt, QUERY_PAYLOAD_AES_HEX_KEY } from '@/utils/crypto.js'
definePage({
@@ -17,6 +18,23 @@ definePage({
const feature = ref('')
const { formData, isPhoneNumberValid, isIdCardValid, isHasInput, buildRequestPayload } = useInquireForm(feature)
const {
bindVisible: bindModalOpen,
bindPhone,
bindCode,
bindSending,
bindSubmitting,
bindCountingDown,
bindCountdown,
isBindPhoneValid,
openBindModal,
closeBindModal,
onBindPhoneInput,
onBindCodeInput,
sendBindSms,
submitBindMobile,
} = useBindMobile()
const loading = ref(true)
const productLoadOk = ref(false)
const submitting = ref(false)
@@ -149,7 +167,15 @@ function readToken() {
async function ensureLoginAndMobile() {
if (!readToken()) {
uni.showToast({ title: '请先登录', icon: 'none' })
// #ifdef MP-WEIXIN
try {
const { tryWxMiniProgramAuth } = await import('@/utils/wxMiniAuth')
const ok = await tryWxMiniProgramAuth({ silent: false })
if (ok)
return true
}
catch { /* fallback to login page */ }
// #endif
uni.navigateTo({ url: '/pages/login' })
return false
}
@@ -158,12 +184,12 @@ async function ensureLoginAndMobile() {
const mobile = (res?.data?.userInfo?.mobile || '').trim()
if (res?.code !== 200 || !/^1[3-9]\d{9}$/.test(mobile)) {
uni.showModal({
title: '提示',
content: '请先在「我的」中绑定手机号后再发起查询。',
title: '绑定手机号',
content: '为保证查询报告可同步查看,请先绑定手机号。',
confirmText: '去绑定',
success(r) {
if (r.confirm)
uni.switchTab({ url: '/pages/mine' })
showCancel: false,
success() {
openBindModal()
},
})
return false
@@ -703,6 +729,59 @@ async function onConfirmPay() {
</view>
</view>
</view>
<view v-if="bindModalOpen" class="bind-mask" @tap.self="closeBindModal">
<view class="bind-sheet" @tap.stop>
<view class="bind-sheet-title">
绑定手机号
</view>
<view class="bind-field">
<text class="bind-label">手机号</text>
<input
class="bind-input"
type="digit"
:value="bindPhone"
:maxlength="11"
placeholder="请输入手机号"
placeholder-class="bind-ph"
confirm-type="done"
@input="onBindPhoneInput"
>
</view>
<view class="bind-field bind-field-row">
<view class="bind-field-grow">
<text class="bind-label">验证码</text>
<input
class="bind-input"
type="digit"
:value="bindCode"
:maxlength="6"
placeholder="6 位短信码"
placeholder-class="bind-ph"
confirm-type="done"
@input="onBindCodeInput"
>
</view>
<view
class="bind-sms"
:class="{ disabled: bindSending || bindCountingDown || !isBindPhoneValid() }"
@tap="sendBindSms"
>
{{ bindCountingDown ? `${bindCountdown}s` : '获取验证码' }}
</view>
</view>
<view
class="bind-submit"
:class="{ disabled: bindSubmitting || !isBindPhoneValid() || bindCode.length < 6 }"
@tap="submitBindMobile"
>
确认绑定
</view>
<view class="bind-cancel" @tap="closeBindModal">
取消
</view>
</view>
</view>
</view>
</template>
@@ -1134,4 +1213,113 @@ async function onConfirmPay() {
.pay-ok.disabled {
opacity: 0.55;
}
/* ═══ 绑定手机弹窗 ═══ */
.bind-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1001;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bind-sheet {
width: 100%;
background: #fff;
border-radius: 24rpx 24rpx 0 0;
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.bind-sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
margin-bottom: 24rpx;
text-align: center;
}
.bind-field {
margin-bottom: 20rpx;
padding-bottom: 12rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.bind-field-row {
display: flex;
align-items: flex-end;
gap: 16rpx;
}
.bind-field-grow {
flex: 1;
min-width: 0;
}
.bind-label {
display: block;
font-size: 24rpx;
color: #86909c;
margin-bottom: 8rpx;
}
.bind-input {
width: 100%;
height: 72rpx;
font-size: 30rpx;
color: #1d2129;
box-sizing: border-box;
}
.bind-ph {
color: #c9cdd4;
}
.bind-sms {
flex-shrink: 0;
height: 64rpx;
line-height: 64rpx;
padding: 0 20rpx;
font-size: 24rpx;
color: #1768ff;
background: #f0f5ff;
border-radius: 12rpx;
text-align: center;
}
.bind-sms.disabled {
color: #c9cdd4;
background: #f7f8fa;
pointer-events: none;
}
.bind-submit {
margin-top: 12rpx;
height: 88rpx;
line-height: 88rpx;
text-align: center;
background: linear-gradient(90deg, #1768ff 0%, #4d94ff 100%);
color: #fff;
font-size: 30rpx;
font-weight: 600;
border-radius: 44rpx;
}
.bind-submit.disabled {
opacity: 0.45;
pointer-events: none;
}
.bind-cancel {
margin-top: 20rpx;
text-align: center;
font-size: 28rpx;
color: #86909c;
padding: 12rpx;
}
</style>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { postAuthSendSms, postUserMobileCodeLogin } from '@/api'
import { saveAuthSession } from '@/utils/session'
import { onUnmounted, ref } from 'vue'
import { tryWxMiniProgramAuth } from '@/utils/wxMiniAuth'
definePage({
@@ -13,105 +11,10 @@ definePage({
},
})
const phoneMode = ref(false)
const mobile = ref('')
const code = ref('')
const agreed = ref(false)
const sending = ref(false)
const submitting = ref(false)
const wxLoading = ref(false)
const isCountingDown = ref(false)
const countdown = ref(60)
let timer: ReturnType<typeof setInterval> | null = null
const isPhoneValid = computed(() => /^1[3-9]\d{9}$/.test(mobile.value))
const canSendSms = computed(() => isPhoneValid.value && !isCountingDown.value && !sending.value)
const canSubmit = computed(
() => isPhoneValid.value && code.value.length >= 6 && agreed.value && !submitting.value,
)
const canWxMiniLogin = computed(() => agreed.value && !wxLoading.value)
function clearTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function startCountdown() {
clearTimer()
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearTimer()
isCountingDown.value = false
}
}, 1000)
}
function openPhoneForm() {
phoneMode.value = true
}
function closePhoneForm() {
phoneMode.value = false
}
async function handleSendSms() {
if (!canSendSms.value)
return
if (!isPhoneValid.value) {
uni.showToast({ title: '手机号有误', icon: 'none' })
return
}
sending.value = true
try {
const res = await postAuthSendSms({ mobile: mobile.value }) as { code?: number }
if (res && res.code === 200) {
uni.showToast({ title: '已发送', icon: 'none' })
startCountdown()
}
}
finally {
sending.value = false
}
}
async function handleSubmit() {
if (!isPhoneValid.value) {
uni.showToast({ title: '手机号有误', icon: 'none' })
return
}
if (code.value.length < 6) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
if (!agreed.value) {
uni.showToast({ title: '请先勾选协议', icon: 'none' })
return
}
submitting.value = true
try {
const res = await postUserMobileCodeLogin({
mobile: mobile.value,
code: code.value,
}) as { code?: number, data?: { accessToken: string, refreshAfter: number | string, accessExpire: number | string } }
if (res && res.code === 200 && res.data) {
saveAuthSession(res.data)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack({ delta: 1 })
}, 400)
}
}
finally {
submitting.value = false
}
}
const canWxMiniLogin = ref(true)
function toggleAgree() {
agreed.value = !agreed.value
@@ -129,22 +32,13 @@ function goLegalAuthorization() {
uni.navigateTo({ url: '/pages/legal/authorization' })
}
function onMobileInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
mobile.value = String(raw).replace(/\D/g, '').slice(0, 11)
}
function onCodeInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
code.value = String(raw).replace(/\D/g, '').slice(0, 6)
}
async function handleWxMiniLogin() {
if (!agreed.value) {
uni.showToast({ title: '请先勾选协议', icon: 'none' })
return
}
wxLoading.value = true
canWxMiniLogin.value = false
try {
const ok = await tryWxMiniProgramAuth({ silent: false })
if (!ok)
@@ -159,20 +53,16 @@ async function handleWxMiniLogin() {
}
finally {
wxLoading.value = false
canWxMiniLogin.value = true
}
}
onUnmounted(() => {
clearTimer()
})
</script>
<template>
<view class="page-root">
<view class="bg-blob" />
<view class="page">
<!-- 入口仅按钮 + 一行协议 -->
<view v-show="!phoneMode" class="gate">
<view class="gate">
<view class="brand-mark" />
<view class="brand-title">
全能查
@@ -185,15 +75,17 @@ onUnmounted(() => {
<!-- #ifdef MP-WEIXIN -->
<view
class="btn btn-wx"
:class="{ disabled: !canWxMiniLogin }"
:class="{ disabled: !canWxMiniLogin || wxLoading }"
@tap="handleWxMiniLogin"
>
{{ wxLoading ? '' : '微信登录' }}
{{ wxLoading ? '登录中...' : '微信一键登录' }}
</view>
<!-- #endif -->
<view class="btn btn-phone" @tap="openPhoneForm">
手机号登录
<!-- #ifndef MP-WEIXIN -->
<view class="btn btn-wx" @tap="handleWxMiniLogin">
一键登录
</view>
<!-- #endif -->
</view>
<view class="agree-row" @tap.stop>
@@ -208,75 +100,6 @@ onUnmounted(() => {
<text class="link" @tap="goLegalAuthorization">授权书</text>
</view>
</view>
<!-- 手机号表单 -->
<view v-show="phoneMode" class="form-sheet">
<view class="form-head">
<view class="back" @tap="closePhoneForm">
</view>
<text class="form-title">手机号登录</text>
<view class="back-spacer" />
</view>
<view class="form-card">
<view class="inp-wrap">
<text class="inp-label">手机号</text>
<input
class="inp"
type="digit"
:value="mobile"
:maxlength="11"
placeholder="11 位手机号"
placeholder-class="inp-ph"
confirm-type="done"
@input="onMobileInput"
>
</view>
<view class="inp-wrap inp-row">
<view class="inp-flex">
<text class="inp-label">验证码</text>
<input
class="inp"
type="digit"
:value="code"
:maxlength="6"
placeholder="6 位验证码"
placeholder-class="inp-ph"
confirm-type="done"
@input="onCodeInput"
>
</view>
<view
class="sms"
:class="{ disabled: !canSendSms }"
@tap="handleSendSms"
>
{{ isCountingDown ? `${countdown}s` : '获取' }}
</view>
</view>
</view>
<view class="agree-row agree-form" @tap.stop>
<view class="agree-tap" @tap="toggleAgree">
<view class="check" :class="{ on: agreed }" />
<text class="agree-txt">同意</text>
</view>
<text class="link" @tap="goLegalUserAgreement">用户协议</text>
<text class="agree-gap"></text>
<text class="link" @tap="goLegalPrivacyPolicy">隐私政策</text>
<text class="agree-gap"></text>
<text class="link" @tap="goLegalAuthorization">授权书</text>
</view>
<view
class="btn btn-submit"
:class="{ disabled: !canSubmit }"
@tap="handleSubmit"
>
登录
</view>
</view>
</view>
</view>
</template>
@@ -333,7 +156,7 @@ onUnmounted(() => {
font-size: 26rpx;
color: #86909c;
margin-top: 12rpx;
margin-bottom: 72rpx;
margin-bottom: 120rpx;
}
.btn-stack {
@@ -363,13 +186,6 @@ onUnmounted(() => {
pointer-events: none;
}
.btn-phone {
background: #fff;
color: #1768ff;
border: 2rpx solid rgba(23, 104, 255, 0.35);
box-shadow: 0 8rpx 24rpx rgba(15, 35, 52, 0.06);
}
.agree-row {
display: flex;
align-items: center;
@@ -412,117 +228,4 @@ onUnmounted(() => {
margin: 0 4rpx;
color: #86909c;
}
.agree-form {
margin-top: 0;
margin-bottom: 24rpx;
}
.form-sheet {
padding-top: 8rpx;
}
.form-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 36rpx;
}
.back {
width: 72rpx;
height: 72rpx;
line-height: 72rpx;
text-align: center;
font-size: 48rpx;
color: #1d2129;
font-weight: 300;
}
.back-spacer {
width: 72rpx;
}
.form-title {
font-size: 34rpx;
font-weight: 600;
color: #1d2129;
}
.form-card {
background: #fff;
border-radius: 28rpx;
padding: 8rpx 28rpx 8rpx;
border: 1rpx solid rgba(23, 104, 255, 0.08);
box-shadow: 0 20rpx 48rpx rgba(15, 35, 52, 0.06);
margin-bottom: 36rpx;
}
.inp-wrap {
padding: 22rpx 0;
border-bottom: 1rpx solid #f0f1f5;
}
.inp-wrap:last-child {
border-bottom: none;
}
.inp-row {
display: flex;
align-items: flex-end;
gap: 20rpx;
}
.inp-flex {
flex: 1;
min-width: 0;
}
.inp-label {
display: block;
font-size: 22rpx;
color: #86909c;
margin-bottom: 10rpx;
}
.inp {
width: 100%;
height: 72rpx;
font-size: 32rpx;
color: #1d2129;
box-sizing: border-box;
}
.inp-ph {
color: #c9cdd4;
}
.sms {
flex-shrink: 0;
height: 68rpx;
line-height: 68rpx;
padding: 0 28rpx;
font-size: 26rpx;
font-weight: 500;
color: #1768ff;
background: #f0f5ff;
border-radius: 34rpx;
}
.sms.disabled {
color: #c9cdd4;
background: #f7f8fa;
pointer-events: none;
}
.btn-submit {
background: linear-gradient(90deg, #1768ff 0%, #4d94ff 100%);
color: #fff;
box-shadow: 0 16rpx 40rpx rgba(23, 104, 255, 0.28);
}
.btn-submit.disabled {
opacity: 0.45;
pointer-events: none;
}
</style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { onShareAppMessage, onShareTimeline, onShow } from '@dcloudio/uni-app'
import { onUnmounted, ref } from 'vue'
import { clearAuthStorage, getUserDetail, postAuthSendSmsBindMobile, postUserBindMobile } from '@/api'
import { hasToken, saveAuthSession } from '@/utils/session'
import { ref } from 'vue'
import { getUserDetail } from '@/api'
import { hasToken } from '@/utils/session'
import { useBindMobile } from '@/composables/useBindMobile'
definePage({
style: {
@@ -30,14 +31,22 @@ const userDesc = ref('')
const hasBoundMobile = ref(false)
const wxNickStorage = ref('')
const bindModalOpen = ref(false)
const bindPhone = ref('')
const bindCode = ref('')
const bindSending = ref(false)
const bindSubmitting = ref(false)
const bindCountingDown = ref(false)
const bindCountdown = ref(60)
let bindSmsTimer: ReturnType<typeof setInterval> | null = null
const {
bindVisible: bindModalOpen,
bindPhone,
bindCode,
bindSending,
bindSubmitting,
bindCountingDown,
bindCountdown,
isBindPhoneValid,
openBindModal,
closeBindModal,
onBindPhoneInput,
onBindCodeInput,
sendBindSms,
submitBindMobile,
} = useBindMobile(refreshUserCard)
const coopModalOpen = ref(false)
@@ -68,30 +77,10 @@ function loadWxNickFromStorage() {
}
}
function clearBindSmsTimer() {
if (bindSmsTimer) {
clearInterval(bindSmsTimer)
bindSmsTimer = null
}
function openBindModalFromProfile() {
openBindModal()
}
function startBindCountdown() {
clearBindSmsTimer()
bindCountingDown.value = true
bindCountdown.value = 60
bindSmsTimer = setInterval(() => {
if (bindCountdown.value > 0) {
bindCountdown.value--
}
else {
clearBindSmsTimer()
bindCountingDown.value = false
}
}, 1000)
}
const isBindPhoneValid = () => /^1[3-9]\d{9}$/.test(bindPhone.value)
async function refreshUserCard() {
isLogin.value = hasToken()
if (!isLogin.value) {
@@ -148,19 +137,6 @@ onShow(() => {
function handleUserTap() {
if (isLogin.value) {
uni.showActionSheet({
itemList: ['退出登录'],
success(res) {
if (res.tapIndex === 0) {
clearAuthStorage()
isLogin.value = false
nickname.value = ''
userDesc.value = ''
hasBoundMobile.value = false
uni.showToast({ title: '已退出', icon: 'none' })
}
},
})
return
}
uni.navigateTo({ url: '/pages/login' })
@@ -193,77 +169,8 @@ async function syncWxNickname() {
// #endif
}
function openBindModal() {
bindPhone.value = ''
bindCode.value = ''
bindModalOpen.value = true
}
function closeBindModal() {
bindModalOpen.value = false
clearBindSmsTimer()
bindCountingDown.value = false
}
function onBindPhoneInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
bindPhone.value = String(raw).replace(/\D/g, '').slice(0, 11)
}
function onBindCodeInput(e: { detail?: { value?: string } }) {
const raw = e.detail?.value ?? ''
bindCode.value = String(raw).replace(/\D/g, '').slice(0, 6)
}
async function sendBindSms() {
if (bindSending.value || bindCountingDown.value)
return
if (!isBindPhoneValid()) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
bindSending.value = true
try {
const res = await postAuthSendSmsBindMobile({ mobile: bindPhone.value }) as { code?: number }
if (res && res.code === 200) {
uni.showToast({ title: '验证码已发送', icon: 'none' })
startBindCountdown()
}
}
finally {
bindSending.value = false
}
}
async function submitBindMobile() {
if (!isBindPhoneValid()) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (bindCode.value.length < 6) {
uni.showToast({ title: '请输入 6 位验证码', icon: 'none' })
return
}
bindSubmitting.value = true
try {
const res = await postUserBindMobile({
mobile: bindPhone.value,
code: bindCode.value,
}) as { code?: number, data?: { accessToken: string, refreshAfter: number | string, accessExpire: number | string } }
if (res && res.code === 200 && res.data) {
saveAuthSession(res.data)
uni.showToast({ title: '绑定成功', icon: 'success' })
closeBindModal()
await refreshUserCard()
}
}
finally {
bindSubmitting.value = false
}
}
function goHistoryReport() {
uni.switchTab({ url: '/pages/report' })
uni.navigateTo({ url: '/pages/report' })
}
function goFreeValuation() {
@@ -296,11 +203,11 @@ function goLegalAuthorization() {
}
function goIllegalCode() {
uni.showToast({ title: '敬请期待', icon: 'none' })
uni.navigateTo({ url: '/pages/toolbox/query?key=jtwfcode' })
}
function goOilPrice() {
uni.showToast({ title: '敬请期待', icon: 'none' })
uni.navigateTo({ url: '/pages/toolbox/query?key=oilprice' })
}
function goHelp() {
@@ -319,10 +226,6 @@ function goSettings() {
function goServiceFallback() {
uni.showToast({ title: '请在微信小程序内使用在线客服', icon: 'none' })
}
onUnmounted(() => {
clearBindSmsTimer()
})
</script>
<template>
@@ -349,7 +252,7 @@ onUnmounted(() => {
</view>
<!-- 已登录未绑定手机 -->
<view v-if="isLogin && !hasBoundMobile" class="profile-actions">
<view class="action-btn" @tap.stop="openBindModal">
<view class="action-btn" @tap.stop="openBindModalFromProfile">
<view class="action-icon i-carbon-phone" />
<text class="action-text">绑定手机号</text>
</view>
@@ -406,20 +309,18 @@ onUnmounted(() => {
<!-- 区块3: 常用工具 -->
<view class="section">
<view class="section-title">常用工具</view>
<view class="tool-list">
<view class="tool-row" @tap="goOilPrice">
<view class="tool-row-icon-wrap" style="background: rgba(23,104,255,0.08)">
<view class="tool-row-icon i-carbon-gas-station" style="color: #1768ff" />
<view class="quick-grid">
<view class="quick-item" @tap="goOilPrice">
<view class="quick-icon-wrap" style="background: rgba(23,104,255,0.08)">
<view class="quick-icon i-carbon-gas-station" style="color: #1768ff" />
</view>
<text class="tool-row-name">实时油价查询</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">实时油价查询</text>
</view>
<view class="tool-row" @tap="goIllegalCode">
<view class="tool-row-icon-wrap" style="background: rgba(250,140,22,0.08)">
<view class="tool-row-icon i-carbon-search" style="color: #fa8c16" />
<view class="quick-item" @tap="goIllegalCode">
<view class="quick-icon-wrap" style="background: rgba(250,140,22,0.08)">
<view class="quick-icon i-carbon-search" style="color: #fa8c16" />
</view>
<text class="tool-row-name">违章代码查询</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">违章代码查询</text>
</view>
</view>
</view>
@@ -427,57 +328,52 @@ onUnmounted(() => {
<!-- 区块4: 服务与支持 -->
<view class="section">
<view class="section-title">服务与支持</view>
<view class="tool-list">
<button class="tool-row tool-row-btn no-border" open-type="contact" hover-class="tool-row-hover">
<view class="tool-row-icon-wrap" style="background: rgba(19,194,94,0.08)">
<view class="tool-row-icon i-carbon-chat" style="color: #13c25e" />
</view>
<view class="tool-row-text">
<text class="tool-row-name">在线客服</text>
<text class="tool-row-sub">周一至周日 9:00-20:00</text>
<view class="quick-grid">
<!-- #ifdef MP-WEIXIN -->
<button class="quick-item quick-item-btn" open-type="contact" hover-class="quick-item-hover">
<view class="quick-icon-wrap" style="background: rgba(19,194,94,0.08)">
<view class="quick-icon i-carbon-chat" style="color: #13c25e" />
</view>
<text class="quick-name">在线客服</text>
</button>
<view class="tool-row" @tap="goHelp">
<view class="tool-row-icon-wrap" style="background: rgba(114,46,209,0.08)">
<view class="tool-row-icon i-carbon-help" style="color: #722ed1" />
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<view class="quick-item" @tap="goServiceFallback">
<view class="quick-icon-wrap" style="background: rgba(19,194,94,0.08)">
<view class="quick-icon i-carbon-chat" style="color: #13c25e" />
</view>
<text class="tool-row-name">帮助中心</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">在线客服</text>
</view>
<view class="tool-row" @tap="goAbout">
<view class="tool-row-icon-wrap" style="background: rgba(78,89,105,0.08)">
<view class="tool-row-icon i-carbon-information" style="color: #4e5969" />
<!-- #endif -->
<view class="quick-item" @tap="goHelp">
<view class="quick-icon-wrap" style="background: rgba(114,46,209,0.08)">
<view class="quick-icon i-carbon-help" style="color: #722ed1" />
</view>
<text class="tool-row-name">关于我们</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">帮助中心</text>
</view>
</view>
</view>
<!-- 区块5: 法律条款 -->
<view class="section">
<view class="section-title">法律条款</view>
<view class="tool-list">
<view class="tool-row" @tap="goLegalUserAgreement">
<view class="tool-row-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="tool-row-icon i-carbon-document-blank" style="color: #86909c" />
<view class="quick-item" @tap="goAbout">
<view class="quick-icon-wrap" style="background: rgba(78,89,105,0.08)">
<view class="quick-icon i-carbon-information" style="color: #4e5969" />
</view>
<text class="tool-row-name">用户协议</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">关于我们</text>
</view>
<view class="tool-row" @tap="goLegalPrivacyPolicy">
<view class="tool-row-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="tool-row-icon i-carbon-security" style="color: #86909c" />
<view class="quick-item" @tap="goLegalUserAgreement">
<view class="quick-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="quick-icon i-carbon-document-blank" style="color: #86909c" />
</view>
<text class="tool-row-name">隐私政策</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">用户协议</text>
</view>
<view class="tool-row no-border" @tap="goLegalAuthorization">
<view class="tool-row-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="tool-row-icon i-carbon-certificate" style="color: #86909c" />
<view class="quick-item" @tap="goLegalPrivacyPolicy">
<view class="quick-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="quick-icon i-carbon-security" style="color: #86909c" />
</view>
<text class="tool-row-name">授权书</text>
<text class="tool-row-arrow"></text>
<text class="quick-name">隐私政策</text>
</view>
<view class="quick-item" @tap="goLegalAuthorization">
<view class="quick-icon-wrap" style="background: rgba(78,89,105,0.06)">
<view class="quick-icon i-carbon-certificate" style="color: #86909c" />
</view>
<text class="quick-name">授权书</text>
</view>
</view>
</view>
@@ -735,7 +631,7 @@ onUnmounted(() => {
.quick-icon-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -2,6 +2,7 @@
import { onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getQueryList } from '@/api'
import { hasToken } from '@/utils/session'
definePage({
style: {
@@ -124,6 +125,10 @@ async function loadList() {
}
onShow(() => {
if (!hasToken()) {
uni.redirectTo({ url: '/pages/login' })
return
}
void loadList()
})

View File

@@ -3,6 +3,7 @@ import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { getQueryDetailByOrderId, getQueryDetailByOrderNo } from '@/api'
import VehicleReportShell from '@/components/report/VehicleReportShell.vue'
import { parseEncryptedQueryReport } from '@/utils/queryReportParse'
import { normalizeVehicleQueryData } from '@/utils/vehicleReportNormalize'
definePage({
@@ -40,15 +41,16 @@ async function load() {
const res = orderId.value
? await getQueryDetailByOrderId(orderId.value)
: await getQueryDetailByOrderNo(orderNo.value)
if (res?.code === 200 && res.data) {
productName.value = res.data.product_name || '查询报告'
queryParams.value = res.data.query_params || {}
rows.value = normalizeVehicleQueryData(res.data.query_data || [])
const parsed = parseEncryptedQueryReport(res)
productName.value = parsed.productName || '查询报告'
if (parsed.ok) {
queryParams.value = parsed.queryParams
rows.value = parsed.rows
if (!rows.value.length)
errText.value = '暂无报告模块数据'
}
else {
errText.value = res?.msg || '加载失败'
errText.value = parsed.msg || '加载失败'
}
}
catch {

View File

@@ -20,5 +20,23 @@ function generateRandomIV() {
return CryptoJS.enc.Hex.parse(iv.map(b => b.toString(16).padStart(2, '0')).join(''))
}
/** 与 H5 `InquireForm.vue` 中 `aesEncrypt(..., key)` 使用的密钥一致 */
/** 与后端 `Encrypt.SecretKey`、H5 `VITE_INQUIRE_AES_KEY` 一致 */
export const QUERY_PAYLOAD_AES_HEX_KEY = 'ff83609b2b24fc73196aac3d3dfb874f'
/** AES-CBC 解密:密文为 IV(16 字节) + 密文,整体 Base64与 H5 `utils/crypto.js` 一致) */
export function aesDecrypt(base64CipherText, hexKey) {
const key = CryptoJS.enc.Hex.parse(hexKey)
const cipherParams = CryptoJS.enc.Base64.parse(base64CipherText)
const iv = cipherParams.clone().words.slice(0, 4)
const cipherText = cipherParams.clone().words.slice(4)
const decrypted = CryptoJS.AES.decrypt(
{ ciphertext: CryptoJS.lib.WordArray.create(cipherText) },
key,
{
iv: CryptoJS.lib.WordArray.create(iv),
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC,
},
)
return decrypted.toString(CryptoJS.enc.Utf8)
}

View File

@@ -0,0 +1,40 @@
import { aesDecrypt, QUERY_PAYLOAD_AES_HEX_KEY } from './crypto'
import { normalizeVehicleQueryData } from './vehicleReportNormalize'
/**
* 解析 /query/example、/query/orderNo 等接口返回体。
* 成功时 data 为 AES 加密字符串,需解密后取 query_data。
*/
export function parseEncryptedQueryReport(apiBody) {
if (!apiBody)
return { ok: false, msg: '无响应数据' }
if (apiBody.code === 200003)
return { ok: false, empty: true, msg: apiBody.msg || '暂无示例报告' }
if (apiBody.code !== 200 || apiBody.data == null || apiBody.data === '')
return { ok: false, msg: apiBody.msg || '加载失败' }
let payload = apiBody.data
if (typeof payload === 'string') {
try {
const plain = aesDecrypt(payload, QUERY_PAYLOAD_AES_HEX_KEY)
payload = JSON.parse(plain)
}
catch {
return { ok: false, msg: '报告数据解密失败' }
}
}
if (!payload || typeof payload !== 'object')
return { ok: false, msg: '报告数据格式错误' }
const rows = normalizeVehicleQueryData(payload.query_data || [])
return {
ok: true,
productName: payload.product_name || '',
queryParams: payload.query_params || {},
rows,
createTime: payload.create_time || null,
}
}

View File

@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import { loadEnv } from 'vite'
import { defineConfig } from 'vite'
import Components from '@uni-helper/vite-plugin-uni-components'
@@ -14,19 +15,21 @@ import { UniEcharts } from 'uni-echarts/vite'
import Uni from '@uni-helper/plugin-uni'
import UnoCSS from 'unocss/vite'
export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
strictPort: false,
// 与 tyc-webview-v2 一致H5 开发时将 /api/v1 代理到线上网关(本地可改 target
proxy: {
'/api/v1': {
// target: 'https://www.tianyuancha.cn',
target: 'http://127.0.0.1:8888',
changeOrigin: true,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const proxyTarget = env.VITE_API_PROXY_TARGET || env.VITE_API_BASE_URL?.replace(/\/api\/v1$/, '') || 'https://www.quannengcha.com'
return {
server: {
host: "0.0.0.0",
port: 5678,
strictPort: true,
proxy: {
"/api/v1": {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path,
},
},
},
},
resolve: {
alias: {
@@ -67,6 +70,7 @@ export default defineConfig({
"uni-echarts"
]
}
}
})