first commit

This commit is contained in:
2026-04-20 16:42:28 +08:00
commit c77780fa0e
365 changed files with 41599 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
<template>
<view class="box-border p-4 text-sm text-gray-800 leading-relaxed">
<view class="mb-4 text-center text-lg text-gray-900 font-semibold">
帐号注销协议
</view>
<text class="mb-3 block text-justify">
您在申请注销流程中点击同意前应当认真阅读帐号注销协议以下简称本协议特别提醒您当您成功提交注销申请后即表示您已充分阅读理解并接受本协议的全部内容阅读本协议的过程中如果您不同意相关任何条款请您立即停止帐号注销程序如您对本协议有任何疑问可联系我们的客服
</text>
<text class="mb-3 block text-justify">
1. 如果您仍欲继续注销帐号您的帐号需同时满足以下条件
</text>
<text class="mb-2 ml-2 block text-justify">
1帐号不在处罚状态中且能正常登录
</text>
<text class="mb-3 ml-2 block text-justify">
2帐号最近一个月内并无修改密码修改关联手机绑定手机记录
</text>
<text class="mb-3 block text-justify">
2. 您应确保您有权决定该帐号的注销事宜不侵犯任何第三方的合法权益如因此引发任何争议由您自行承担
</text>
<text class="mb-3 block text-justify">
3. 您理解并同意账号注销后我们无法协助您重新恢复前述服务请您在申请注销前自行备份您欲保留的本帐号信息和数据
</text>
<text class="mb-3 block text-justify">
4. 帐号注销后已绑定的手机号认证信息将会消失且无法注册
</text>
<text class="mb-3 block text-justify">
5. 注销帐号后您将无法再使用本帐号也将无法找回您帐号中及与帐号相关的任何内容或信息包括但不限于
</text>
<text class="mb-2 ml-2 block text-justify">
1您将无法继续使用该帐号进行登录
</text>
<text class="mb-2 ml-2 block text-justify">
2您帐号的个人资料和历史信息包含昵称头像消费记录查询报告等都将无法找回
</text>
<text class="mb-3 ml-2 block text-justify">
3您理解并同意注销帐号后您曾获得的充值余额贝壳币及其他虚拟财产等将视为您自愿主动放弃无法继续使用由此引起一切纠纷由您自行处理我们不承担任何责任
</text>
<text class="mb-3 block text-justify">
6. 在帐号注销期间如果您的帐号被他人投诉被国家机关调查或者正处于诉讼仲裁程序中我们有权自行终止您的帐号注销程序而无需另行得到您的同意
</text>
<text class="mb-3 block text-justify">
7. 请注意注销您的帐号并不代表本帐号注销前的帐号行为和相关责任得到豁免或减轻
</text>
</view>
</template>

View File

@@ -0,0 +1,285 @@
<script setup>
import pcaData from '@/static/pca.json'
const props = defineProps({
ancestor: {
type: String,
required: true,
},
isLoggedIn: {
type: Boolean,
default: false,
},
userMobile: {
type: String,
default: '',
},
isSelf: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['submit', 'close'])
const show = defineModel('show')
const { ancestor, isLoggedIn, userMobile, isSelf } = toRefs(props)
const form = ref({
region: '',
mobile: '',
code: '',
})
const region = ref([])
const loadingSms = ref(false)
const isCountingDown = ref(false)
const isAgreed = ref(false)
const countdown = ref(60)
const mobileReadonly = computed(() => Boolean(isLoggedIn.value && userMobile.value))
function buildPcaColumns() {
return Object.entries(pcaData).map(([provinceName, cityMap]) => ({
label: provinceName,
value: provinceName,
children: Object.entries(cityMap).map(([cityName, districtList]) => ({
label: cityName,
value: cityName,
children: districtList.map(districtName => ({
label: districtName,
value: districtName,
})),
})),
}))
}
const provinceOptions = buildPcaColumns()
const columns = ref([provinceOptions])
function handleColumnChange({ selectedItem, resolve, finish }) {
const children = selectedItem?.children
if (children?.length) {
resolve(children)
return
}
finish()
}
function displayFormat(selectedItems) {
if (!selectedItems?.length)
return ''
return selectedItems.map(item => item.label).join('/')
}
function handleRegionConfirm({ value, selectedItems }) {
region.value = value || []
form.value.region = selectedItems?.map(item => item.label).join('/') || ''
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(form.value.mobile)
})
async function getSmsCode() {
if (!form.value.mobile) {
showToast({ message: '请输入手机号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '手机号格式不正确' })
return
}
loadingSms.value = true
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: form.value.mobile, actionType: 'agentApply', captchaVerifyParam: '' })
.json()
loadingSms.value = false
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
}
else {
showToast(data.value?.msg || '发送失败')
}
}
let timer = null
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
function submit() {
// 校验表单字段
if (!form.value.region) {
showToast({ message: '请选择地区' })
return
}
if (!form.value.mobile) {
showToast({ message: '请输入手机号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '手机号格式不正确' })
return
}
// 如果不是自己申请,则需要验证码
if (!isSelf.value && !form.value.code) {
showToast({ message: '请输入验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先阅读并同意用户协议及相关条款' })
return
}
// 触发父组件提交申请
emit('submit', form.value)
}
const maskName = computed(() => {
return (name) => {
return `${name.substring(0, 3)}****${name.substring(7)}`
}
})
function closePopup() {
emit('close')
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toAgentManageAgreement() {
uni.navigateTo({ url: '/pages/agent-manage-agreement' })
}
watch(
() => [isLoggedIn.value, userMobile.value],
([loggedIn, mobile]) => {
if (loggedIn && mobile) {
form.value.mobile = mobile
}
},
{ immediate: true },
)
</script>
<template>
<wd-popup v-model="show" destroy-on-close round position="bottom">
<view class="h-12 flex items-center justify-center font-semibold"
style="background-color: var(--van-theme-primary-light); color: var(--van-theme-primary);">
成为代理
</view>
<view v-if="ancestor" class="my-2 text-center text-xs" style="color: var(--van-text-color-2);">
{{ maskName(ancestor) }}邀您成为赤眉代理方
</view>
<view class="p-4">
<wd-col-picker v-model="region" class="agent-form-field" label-width="42px" label="地区" placeholder="请选择地区"
:columns="columns" :column-change="handleColumnChange" :display-format="displayFormat" :align-right="false"
custom-value-class="agent-col-picker-value" @confirm="handleRegionConfirm" />
<wd-input v-model="form.mobile" class="agent-form-field" label-width="42px" label="手机号" name="mobile"
placeholder="请输入手机号" :align-right="false" :readonly="mobileReadonly" :disabled="mobileReadonly" />
<!-- 获取验证码按钮 -->
<wd-input v-model="form.code" class="agent-form-field" label-width="42px" label="验证码" name="code"
placeholder="请输入验证码" :align-right="false" use-suffix-slot>
<template #suffix>
<button class="ml-2 flex-shrink-0 rounded-lg px-2 py-1 text-sm font-bold transition duration-300" :class="isCountingDown || !isPhoneNumberValid
? 'cursor-not-allowed bg-gray-300 text-gray-500'
: 'text-white hover:opacity-90'" :style="isCountingDown || !isPhoneNumberValid
? ''
: 'background-color: var(--van-theme-primary);'" :disabled="isCountingDown || !isPhoneNumberValid"
@click.stop="getSmsCode">
{{
isCountingDown ? `${countdown}s重新获取` : '获取验证码'
}}
</button>
</template>
</wd-input>
<!-- 同意条款的复选框 -->
<view class="p-4">
<view class="flex items-start">
<wd-checkbox v-model="isAgreed" name="agree" icon-size="16px" class="mr-2 flex-shrink-0" />
<view class="text-xs leading-tight" style="color: var(--van-text-color-2);">
我已阅读并同意
<a class="cursor-pointer hover:underline" style="color: var(--van-theme-primary);"
@click="toUserAgreement">用户协议</a>
<a class="cursor-pointer hover:underline" style="color: var(--van-theme-primary);"
@click="toAgentManageAgreement">推广方管理制度协议</a>
<view class="mt-1 text-xs" style="color: var(--van-text-color-2);">
点击勾选即代表您同意上述法律文书的相关条款并签署上述法律文书
</view>
<view class="mt-1 text-xs" style="color: var(--van-text-color-2);">
手机号未在本平台注册账号则申请后将自动生成账号
</view>
</view>
</view>
</view>
<view class="mt-4">
<wd-button type="primary" round block @click="submit">
提交申请
</wd-button>
</view>
<view class="mt-2">
<wd-button type="default" round block @click="closePopup">
取消
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style scoped>
:deep(.agent-form-field .wd-cell__title) {
flex: 0 0 42px !important;
max-width: 42px !important;
}
:deep(.agent-col-picker-value) {
display: flex !important;
justify-content: flex-start !important;
width: 100% !important;
text-align: left !important;
}
:deep(.agent-form-field .wd-cell__value) {
flex: 1;
min-width: 0;
justify-content: flex-start !important;
text-align: left !important;
}
:deep(.agent-form-field .wd-input__inner),
:deep(.agent-form-field .wd-col-picker__cell),
:deep(.agent-form-field .wd-col-picker__value),
:deep(.agent-form-field .wd-col-picker__text),
:deep(.agent-form-field .is-placeholder) {
text-align: left !important;
}
:deep(.agent-form-field .wd-col-picker__value) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,263 @@
<script setup>
import { computed, nextTick, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
import { setAuthSession } from '@/utils/storage'
const emit = defineEmits(['bind-success'])
const router = useRouter()
const dialogStore = useDialogStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
const phoneNumber = ref('')
const verificationCode = ref('')
const isCountingDown = ref(false)
const countdown = ref(60)
const isAgreed = ref(false)
const verificationCodeInputRef = ref(null)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
// 聚焦状态变量
const phoneFocused = ref(false)
const codeFocused = ref(false)
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canBind = computed(() => {
return (
isPhoneNumberValid.value
&& verificationCode.value.length === 6
&& isAgreed.value
)
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'bindMobile', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast(data.value?.msg || '发送失败')
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
async function handleBind() {
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
const { data, error } = await useApiFetch('/user/bindMobile')
.post({ mobile: phoneNumber.value, code: verificationCode.value })
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showToast({ message: '绑定成功' })
setAuthSession(data.value.data)
closeDialog()
await Promise.all([
agentStore.fetchAgentStatus(),
userStore.fetchUserInfo(),
])
// 发出绑定成功的事件
emit('bind-success')
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeBindPhone()
// 重置表单
phoneNumber.value = ''
verificationCode.value = ''
isAgreed.value = false
if (timer) {
clearInterval(timer)
}
}
function toUserAgreement() {
closeDialog()
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
closeDialog()
router.push(`/privacyPolicy`)
}
</script>
<template>
<view v-if="dialogStore.showBindPhone">
<wd-popup v-model="dialogStore.showBindPhone" round position="bottom" :style="{ height: '80%' }"
@close="closeDialog">
<view class="bind-phone-dialog">
<view class="title-bar">
<view class="font-bold">
绑定手机号码
</view>
<view class="mt-1 text-sm text-gray-500">
为使用完整功能请绑定手机号码
</view>
<view class="mt-1 text-sm text-gray-500">
如该微信号之前已绑定过手机号请输入已绑定的手机号
</view>
<wd-icon name="cross" class="close-icon" @click="closeDialog" />
</view>
<view class="px-8">
<view class="mb-8 pt-8 text-left">
<view class="flex flex-col items-center">
<image class="h-16 w-16 rounded-full shadow" src="/static/images/logo.png" alt="Logo" />
<view class="mt-4 text-3xl text-slate-700 font-bold">
赤眉
</view>
</view>
</view>
<view class="space-y-5">
<!-- 手机号输入 -->
<view class="input-container bg-blue-300/20" :class="[
phoneFocused ? 'focused' : '',
]">
<input v-model="phoneNumber" class="input-field" type="tel" placeholder="请输入手机号" maxlength="11"
@focus="phoneFocused = true" @blur="phoneFocused = false">
</view>
<!-- 验证码输入 -->
<view class="flex items-center justify-between">
<view class="input-container bg-blue-300/20" :class="[
codeFocused ? 'focused' : '',
]">
<input id="verificationCode" ref="verificationCodeInputRef" v-model="verificationCode"
class="input-field" placeholder="请输入验证码" maxlength="6" @focus="codeFocused = true"
@blur="codeFocused = false">
</view>
<button class="ml-2 flex-shrink-0 rounded-lg px-4 py-2 text-sm font-bold transition duration-300" :class="isCountingDown || !isPhoneNumberValid
? 'cursor-not-allowed bg-gray-300 text-gray-500'
: 'bg-blue-500 text-white hover:bg-blue-600'
" @click="sendVerificationCode">
{{
isCountingDown
? `${countdown}s重新获取`
: "获取验证码"
}}
</button>
</view>
<!-- 协议同意框 -->
<view class="flex items-start space-x-2">
<input v-model="isAgreed" type="checkbox" class="mt-1">
<text class="text-xs text-gray-400 leading-tight">
绑定手机号即代表您已阅读并同意
<a class="cursor-pointer text-blue-400" @click="toUserAgreement">
用户协议
</a>
<a class="cursor-pointer text-blue-400" @click="toPrivacyPolicy">
隐私政策
</a>
</text>
</view>
</view>
<button
class="mt-10 w-full rounded-full bg-blue-500 py-3 text-lg text-white font-bold transition duration-300"
:class="{ 'opacity-50 cursor-not-allowed': !canBind }" @click="handleBind">
确认绑定
</button>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.bind-phone-dialog {
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-size: cover;
height: 100%;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.close-icon {
font-size: 20px;
color: #666;
cursor: pointer;
}
.input-container {
border: 2px solid rgba(125, 211, 252, 0);
border-radius: 1rem;
transition: duration-200;
}
.input-container.focused {
border: 2px solid #3b82f6;
}
.input-field {
width: 100%;
padding: 1rem;
background: transparent;
border: none;
outline: none;
transition: border-color 0.3s ease;
}
</style>

View File

@@ -0,0 +1,262 @@
<script setup>
import * as echarts from 'echarts'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
score: {
type: Number,
required: true,
},
})
// 根据分数计算风险等级和颜色(分数越高越安全)
const riskLevel = computed(() => {
const score = props.score
if (score >= 75 && score <= 100) {
return {
level: '无任何风险',
color: '#52c41a',
gradient: [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#7fdb42' },
],
}
}
else if (score >= 50 && score < 75) {
return {
level: '风险指数较低',
color: '#faad14',
gradient: [
{ offset: 0, color: '#faad14' },
{ offset: 1, color: '#ffc53d' },
],
}
}
else if (score >= 25 && score < 50) {
return {
level: '风险指数较高',
color: '#fa8c16',
gradient: [
{ offset: 0, color: '#fa8c16' },
{ offset: 1, color: '#ffa940' },
],
}
}
else {
return {
level: '高风险警告',
color: '#f5222d',
gradient: [
{ offset: 0, color: '#f5222d' },
{ offset: 1, color: '#ff4d4f' },
],
}
}
})
// 评分解释文本(分数越高越安全)
const riskDescription = computed(() => {
const score = props.score
if (score >= 75 && score <= 100) {
return '根据综合分析,当前报告未检测到明显风险因素,各项指标表现正常,总体状况良好。'
}
else if (score >= 50 && score < 75) {
return '根据综合分析,当前报告存在少量风险信号,建议关注相关指标变化,保持警惕。'
}
else if (score >= 25 && score < 50) {
return '根据综合分析,当前报告风险指数较高,多项指标显示异常,建议进一步核实相关情况。'
}
else {
return '根据综合分析,当前报告显示高度风险状态,多项重要指标严重异常,请立即采取相应措施。'
}
})
const chartRef = ref(null)
let chartInstance = null
function initChart() {
if (!chartRef.value)
return
// 初始化ECharts实例
chartInstance = echarts.init(chartRef.value)
updateChart()
}
function updateChart() {
if (!chartInstance)
return
// 获取当前风险等级信息
const risk = riskLevel.value
// 配置项
const option = {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
radius: '100%',
center: ['50%', '80%'],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, risk.gradient),
shadowBlur: 6,
shadowColor: risk.color,
},
progress: {
show: true,
width: 20,
roundCap: true,
clip: false,
},
axisLine: {
roundCap: true,
lineStyle: {
width: 20,
color: [
[1, new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: `${risk.color}30`, // 使用风险颜色透明度20%
},
{
offset: 1,
color: `${risk.color}25`, // 使用风险颜色透明度10%
},
])],
],
},
},
axisTick: {
show: true,
distance: -30,
length: 6,
splitNumber: 10, // 每1分一个小刻度
lineStyle: {
color: risk.color,
width: 1,
opacity: 0.5,
},
},
splitLine: {
show: true,
distance: -36,
length: 12,
splitNumber: 9, // 9个大刻度100分分成9个区间
lineStyle: {
color: risk.color,
width: 2,
opacity: 0.5,
},
},
axisLabel: {
show: false,
},
anchor: {
show: false,
},
pointer: {
icon: 'triangle',
iconStyle: {
color: risk.color,
borderColor: risk.color,
borderWidth: 1,
},
offsetCenter: ['7%', '-67%'],
length: '10%',
width: 15,
},
detail: {
valueAnimation: true,
fontSize: 30,
fontWeight: 'bold',
color: risk.color,
offsetCenter: [0, '-25%'],
formatter(value) {
return `{value|${value}分}\n{level|${risk.level}}`
},
rich: {
value: {
fontSize: 30,
fontWeight: 'bold',
color: risk.color,
padding: [0, 0, 5, 0],
},
level: {
fontSize: 14,
fontWeight: 'normal',
color: risk.color,
padding: [5, 0, 0, 0],
},
},
},
data: [
{
value: props.score,
},
],
title: {
fontSize: 14,
color: risk.color,
offsetCenter: [0, '10%'],
formatter: risk.level,
},
},
],
}
// 使用配置项设置图表
chartInstance.setOption(option)
}
// 监听分数变化
watch(
() => props.score,
() => {
updateChart()
},
)
onMounted(() => {
initChart()
// 处理窗口大小变化
window.addEventListener('resize', () => {
if (chartInstance) {
chartInstance.resize()
}
})
})
// 在组件销毁前清理
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', chartInstance?.resize)
})
</script>
<template>
<view>
<view class="risk-description">
{{ riskDescription }}
</view>
<view ref="chartRef" :style="{ width: '100%', height: '200px' }" />
</view>
</template>
<style scoped>
.risk-description {
margin-bottom: 4px;
padding: 0 12px;
color: #666666;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
imageUrl: {
type: String,
default: '',
},
title: {
type: String,
default: '保存图片到相册',
},
})
const emit = defineEmits(['close'])
function close() {
emit('close')
}
// 自动关闭功能已禁用
// watch(() => props.show, (newVal) => {
// if (newVal && props.autoCloseDelay > 0) {
// setTimeout(() => {
// close();
// }, props.autoCloseDelay);
// }
// });
</script>
<template>
<view v-if="show" class="image-save-guide-overlay">
<view class="guide-content" @click.stop>
<!-- 关闭按钮 -->
<button class="close-button" @click="close">
<text class="close-icon">×</text>
</button>
<!-- 图片区域 -->
<view v-if="imageUrl" class="image-container">
<image :src="imageUrl" class="guide-image" />
</view>
<!-- 文字内容区域 -->
<view class="text-container">
<view class="guide-title">
{{ title }}
</view>
<view class="guide-instruction">
长按图片保存
</view>
</view>
</view>
</view>
</template>
<style scoped>
.image-save-guide-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
box-sizing: border-box;
}
.guide-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
color: #333;
text-align: center;
position: relative;
}
/* 关闭按钮 */
.close-button {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
z-index: 10;
}
.close-button:hover {
background: rgba(0, 0, 0, 0.2);
}
.close-icon {
font-size: 20px;
color: #666;
font-weight: bold;
line-height: 1;
}
/* 图片容器 */
.image-container {
width: 100%;
margin-bottom: 20px;
display: flex;
justify-content: center;
}
.guide-image {
max-width: 100%;
max-height: 60vh;
width: auto;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
object-fit: contain;
}
/* 文字容器 */
.text-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.guide-title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 4px;
}
.guide-instruction {
font-size: 16px;
color: #4a4a4a;
line-height: 1.4;
font-weight: 500;
}
/* 超小屏幕 (320px - 375px) */
@media (max-width: 375px) {
.image-save-guide-overlay {
padding: 12px;
}
.guide-content {
padding: 20px 16px;
max-width: 100%;
}
.guide-title {
font-size: 18px;
}
.guide-instruction {
font-size: 15px;
}
.close-button {
width: 28px;
height: 28px;
top: 10px;
right: 10px;
}
.close-icon {
font-size: 18px;
}
.guide-image {
max-height: 50vh;
}
}
/* 小屏幕 (376px - 414px) */
@media (min-width: 376px) and (max-width: 414px) {
.guide-content {
padding: 22px 18px;
}
.guide-title {
font-size: 19px;
}
}
/* 中等屏幕 (415px - 480px) */
@media (min-width: 415px) and (max-width: 480px) {
.guide-content {
padding: 24px 20px;
}
.guide-title {
font-size: 20px;
}
}
/* 大屏幕 (481px+) */
@media (min-width: 481px) {
.guide-content {
padding: 28px 24px;
max-width: 420px;
}
.guide-title {
font-size: 22px;
}
.guide-instruction {
font-size: 17px;
}
}
/* 横屏适配 */
@media (orientation: landscape) and (max-height: 500px) {
.guide-content {
padding: 16px 20px;
}
.image-container {
margin-bottom: 12px;
}
.guide-image {
max-height: 40vh;
}
.text-container {
gap: 8px;
}
.guide-title {
font-size: 18px;
margin-bottom: 2px;
}
.guide-instruction {
font-size: 15px;
}
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.guide-content {
background: rgba(30, 30, 30, 0.95);
color: #e5e5e5;
}
.guide-title {
color: #ffffff;
}
.guide-instruction {
color: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,842 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import BindPhoneDialog from '@/components/BindPhoneDialog.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import Payment from '@/components/Payment.vue'
import SectionTitle from '@/components/SectionTitle.vue'
import { useRouter } from '@/composables/uni-router'
import { useEnv } from '@/composables/useEnv'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import { aesEncrypt } from '@/utils/crypto'
import { setAuthSession } from '@/utils/storage'
// Props
const props = defineProps({
// 查询类型:'normal' | 'promotion'
type: {
type: String,
default: 'normal',
},
// 产品特征
feature: {
type: String,
required: true,
},
// 推广链接标识符(仅推广查询需要)
linkIdentifier: {
type: String,
default: '',
},
// 产品数据(从外部传入)
featureData: {
type: Object,
default: () => ({}),
},
})
// Emits
const emit = defineEmits(['submit-success'])
const PRODUCT_BACKGROUND_MAP = {
companyinfo: '/static/images/product/xwqy_inquire_bg.png',
preloanbackgroundcheck: '/static/images/product/dqfx_inquire_bg.png',
personalData: '/static/images/product/grdsj_inquire_bg.png',
marriage: '/static/images/product/marriage_inquire_bg.png',
homeservice: '/static/images/product/homeservice_inquire_bg.png',
backgroundcheck: '/static/images/product/backgroundcheck_inquire_bg.png',
rentalinfo: '/static/images/product/rentalinfo_inquire_bg.png',
}
const TRAPEZOID_BACKGROUND_MAP = {
marriage: '/static/images/report/title_inquire_bg_red.png',
homeservice: '/static/images/report/title_inquire_bg_green.png',
default: '/static/images/report/title_inquire_bg.png',
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const { feature } = toRefs(props)
function loadProductBackground(productType) {
return PRODUCT_BACKGROUND_MAP[productType] || ''
}
const router = useRouter()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { isWeChat } = useEnv()
// 响应式数据
const showPayment = ref(false)
const pendingPayment = ref(false)
const queryId = ref(null)
const productBackground = ref('')
const productMainImage = ref('')
const trapezoidBgImage = ref('')
const isCountingDown = ref(false)
const countdown = ref(60)
const verificationCodeInputRef = ref(null)
// 使用传入的featureData或创建响应式引用
const featureData = computed(() => props.featureData || {})
// 表单数据
const formData = reactive({
name: '',
idCard: '',
mobile: '',
verificationCode: '',
agreeToTerms: false,
})
// 计算属性
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(formData.mobile)
})
const isIdCardValid = computed(() => /^\d{17}[\dX]$/i.test(formData.idCard))
// 小微企业(companyinfo)暂不需要验证码
const needVerificationCode = computed(() => props.feature !== 'companyinfo')
const isLoggedIn = computed(() => userStore.isLoggedIn)
const buttonText = computed(() => {
return isLoggedIn.value ? '立即查询' : '前往登录'
})
const hasHeroImage = computed(() => Boolean(productBackground.value))
function loadTrapezoidBackground() {
trapezoidBgImage.value = TRAPEZOID_BACKGROUND_MAP[props.feature] || TRAPEZOID_BACKGROUND_MAP.default
}
// 牌匾背景图片样式
const trapezoidBgStyle = computed(() => {
if (trapezoidBgImage.value) {
return {
backgroundImage: `url(${trapezoidBgImage.value})`,
}
}
return {}
})
// 牌匾文字样式
const trapezoidTextStyle = computed(() => {
// homeservice 和 marriage 使用白色文字
if (props.feature === 'homeservice' || props.feature === 'marriage') {
return {
color: 'white',
}
}
// 其他情况使用默认字体色(不设置 color使用浏览器默认或继承
return {}
})
// 获取功能图标
function getFeatureIcon(apiId) {
const iconMap = {
JRZQ4AA8: '/static/inquire_icons/huankuanyali.svg', // 还款压力
QCXG7A2B: '/static/inquire_icons/mingxiacheliang.svg', // 名下车辆
BehaviorRiskScan: '/static/inquire_icons/fengxianxingwei.svg', // 风险行为扫描
IVYZ5733: '/static/inquire_icons/hunyinzhuangtai.svg', // 婚姻状态
PersonEnterprisePro: '/static/inquire_icons/renqiguanxi.svg', // 人企关系加强版
JRZQ0A03: '/static/inquire_icons/jiedaishenqing.svg', // 借贷申请记录
FLXG3D56: '/static/inquire_icons/jiedaiweiyue.svg', // 借贷违约失信
FLXG0V4B: '/static/inquire_icons/sifasheyu.svg', // 司法涉诉
JRZQ8203: '/static/inquire_icons/jiedaixingwei.svg', // 借贷行为记录
JRZQ09J8: '/static/inquire_icons/beijianguanrenyuan.svg', // 收入评估
JRZQ4B6C: '/static/inquire_icons/fengxianxingwei.svg', // 探针C风险评估
}
return iconMap[apiId] || '/static/inquire_icons/default.svg'
}
// 处理图标加载错误
function handleIconError(event) {
event.target.style.display = 'none'
}
// 获取卡片样式类
function getCardClass(index) {
const colorIndex = index % 4
const colorClasses = [
'bg-gradient-to-br from-blue-50 via-blue-25 to-white border-2 border-blue-200',
'bg-gradient-to-br from-green-50 via-green-25 to-white border-2 border-green-200',
'bg-gradient-to-br from-purple-50 via-purple-25 to-white border-2 border-purple-200',
'bg-gradient-to-br from-orange-50 via-orange-25 to-white border-2 border-orange-200',
]
return colorClasses[colorIndex]
}
// 方法
function validateField(field, value, validationFn, errorMessage) {
if (isHasInput(field) && !validationFn(value)) {
showToast({ message: errorMessage })
return false
}
return true
}
const defaultInput = ['name', 'idCard', 'mobile', 'verificationCode']
function isHasInput(input) {
return defaultInput.includes(input)
}
// 处理绑定手机号成功的回调
function handleBindSuccess() {
if (pendingPayment.value) {
pendingPayment.value = false
submitRequest()
}
}
// 处理登录成功的回调
function handleLoginSuccess() {
if (pendingPayment.value) {
pendingPayment.value = false
submitRequest()
}
}
// 处理输入框点击事件
async function handleInputClick() {
if (!isLoggedIn.value) {
// 非微信浏览器环境:未登录用户提示打开登录弹窗
if (!isWeChat.value) {
const { confirm } = await uni.showModal({
title: '提示',
content: '您需要登录后才能进行查询,是否立即登录?',
confirmText: '立即登录',
cancelText: '取消',
})
if (confirm)
dialogStore.openLogin()
}
}
else {
// 微信浏览器环境:已登录但检查是否需要绑定手机号
if (isWeChat.value && !userStore.mobile) {
dialogStore.openBindPhone()
}
}
}
function handleSubmit() {
// 非微信浏览器环境:检查登录状态
if (!isWeChat.value && !isLoggedIn.value) {
dialogStore.openLogin()
return
}
// 基本协议验证
if (!formData.agreeToTerms) {
showToast({ message: `请阅读并同意用户协议和隐私政策` })
return
}
if (
!validateField('name', formData.name, value => value, '请输入姓名')
|| !validateField(
'mobile',
formData.mobile,
() => isPhoneNumberValid.value,
'请输入有效的手机号',
)
|| !validateField(
'idCard',
formData.idCard,
() => isIdCardValid.value,
'请输入有效的身份证号码',
)
|| (needVerificationCode.value
&& !validateField(
'verificationCode',
formData.verificationCode,
value => value,
'请输入验证码',
))
) {
return
}
// 检查是否需要绑定手机号
if (!userStore.mobile) {
pendingPayment.value = true
dialogStore.openBindPhone()
}
else {
submitRequest()
}
}
async function submitRequest() {
const req = {
name: formData.name,
id_card: formData.idCard,
mobile: formData.mobile,
}
if (needVerificationCode.value) {
req.code = formData.verificationCode
}
const reqStr = JSON.stringify(req)
const inquireKey = import.meta.env.VITE_INQUIRE_AES_KEY
if (!inquireKey) {
throw new Error('缺少环境变量: VITE_INQUIRE_AES_KEY')
}
const encodeData = aesEncrypt(reqStr, inquireKey)
let apiUrl = ''
const requestData = { data: encodeData }
if (props.type === 'promotion') {
apiUrl = `/query/service_agent/${props.feature}`
requestData.agent_identifier = props.linkIdentifier
}
else {
apiUrl = `/query/service/${props.feature}`
}
const { data } = await useApiFetch(apiUrl)
.post(requestData)
.json()
if (data.value.code === 200) {
queryId.value = data.value.data.id
// 推广查询需要保存token
if (props.type === 'promotion') {
setAuthSession(data.value.data)
}
showPayment.value = true
emit('submit-success', data.value.data)
}
}
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('/auth/sendSms')
.post({ mobile: formData.mobile, actionType: 'query', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '验证码发送成功', type: 'success' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast({ message: data.value?.msg || '验证码发送失败,请重试' })
}
}
let timer = null
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
function toUserAgreement() {
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
router.push(`/privacyPolicy`)
}
function toAuthorization() {
router.push(`/authorization`)
}
function toExample() {
router.push(`/example?feature=${props.feature}`)
}
function toHistory() {
router.push('/historyQuery')
}
// 生命周期
onMounted(async () => {
loadBackgroundImage()
loadTrapezoidBackground()
})
// 加载背景图片
async function loadBackgroundImage() {
productBackground.value = loadProductBackground(props.feature)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
watch(feature, async () => {
loadBackgroundImage()
loadTrapezoidBackground()
})
</script>
<template>
<view class="inquire-bg relative min-h-screen">
<view v-if="hasHeroImage" class="hero-banner">
<image :src="productBackground" class="hero-banner-image" mode="widthFix" />
<view class="hero-banner-mask" />
<view class="hero-badge-wrap">
<view class="trapezoid-bg-image flex items-center justify-center" :style="trapezoidBgStyle">
<view class="whitespace-nowrap text-xl" :style="trapezoidTextStyle">
{{ featureData.product_name }}
</view>
</view>
</view>
</view>
<view class="content-wrap relative mx-4 min-h-screen pb-12" :class="{ 'with-hero': hasHeroImage }">
<view class="card-container">
<!-- 基本信息标题 -->
<view class="mb-6 flex items-center">
<SectionTitle title="基本信息" />
<view class="ml-auto flex cursor-pointer items-center text-gray-600" @click="toExample">
<image src="/static/images/report/slbg_inquire_icon.png" alt="示例报告" class="mr-1 h-4 w-4" />
<text class="">
示例报告
</text>
</view>
</view>
<!-- 表单输入区域 -->
<view class="mb-6 space-y-4">
<view class="flex items-center border-b border-gray-100 py-3">
<text for="name" class="w-20 text-gray-700 font-medium">
姓名
</text>
<input id="name" v-model="formData.name" type="text" placeholder="请输入正确的姓名"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<view class="flex items-center border-b border-gray-100 py-3">
<text for="idCard" class="w-20 text-gray-700 font-medium">
身份证号
</text>
<input id="idCard" v-model="formData.idCard" type="text" placeholder="请输入准确的身份证号"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<view class="flex items-center border-b border-gray-100 py-3">
<text for="mobile" class="w-20 text-gray-700 font-medium">
手机号
</text>
<input id="mobile" v-model="formData.mobile" type="tel" placeholder="请输入手机号"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<!-- 小微企业(companyinfo)暂不展示验证码 -->
<view v-if="needVerificationCode" class="flex items-center border-b border-gray-100 py-3">
<text for="verificationCode" class="w-20 text-gray-700 font-medium">
验证码
</text>
<input id="verificationCode" ref="verificationCodeInputRef" v-model="formData.verificationCode"
placeholder="请输入验证码" maxlength="6" class="flex-1 border-none outline-none" @click="handleInputClick">
<wd-button class="captcha-wd-btn" size="small" type="primary" plain
:disabled="isCountingDown || !isPhoneNumberValid" @click="sendVerificationCode">
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</view>
</view>
<!-- 协议同意 -->
<view class="agreement-wrap mb-6">
<wd-checkbox v-model="formData.agreeToTerms" shape="square" size="18px" />
<view class="agreement-text">
<text class="text-sm text-gray-500">
我已阅读并同意
</text>
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
<text class="agreement-link" @click="toAuthorization">
授权书
</text>
</view>
</view>
<!-- 查询按钮 -->
<button
class="bg-primary mb-4 mt-10 w-full flex items-center justify-center rounded-[48px] py-4 text-lg text-white font-medium"
@click="handleSubmit">
<text>{{ buttonText }}</text>
<text class="ml-4">
¥{{ featureData.sell_price }}
</text>
</button>
<!-- <view class="text-xs text-gray-500 leading-relaxed mt-8" v-html="featureData.description">
</view> -->
<!-- 免责声明 -->
<view class="mt-2 text-center text-xs text-gray-500 leading-relaxed">
为保证用户的隐私及数据安全查询结果生成30天后将自动删除
</view>
</view>
<!-- 报告包含内容 -->
<view v-if="featureData.features && featureData.features.length > 0" class="card mt-3">
<view class="mb-3 flex items-center text-base font-semibold" style="color: var(--van-text-color);">
<view class="mr-2 h-5 w-1 rounded-full"
style="background: linear-gradient(to bottom, var(--van-theme-primary), var(--van-theme-primary-dark));" />
报告包含内容
</view>
<view class="grid grid-cols-4 items-stretch gap-2">
<template v-for="(item, index) in featureData.features" :key="item.id">
<!-- FLXG0V4B 特殊处理显示8个独立的案件类型 -->
<template v-if="item.api_id === 'FLXG0V4B'">
<view v-for="(caseType, caseIndex) in [
{ name: '管辖案件', icon: 'beijianguanrenyuan.svg' },
{ name: '刑事案件', icon: 'xingshi.svg' },
{ name: '民事案件', icon: 'minshianjianguanli.svg' },
{ name: '失信被执行', icon: 'shixinren.svg' },
{ name: '行政案件', icon: 'xingzhengfuwu.svg' },
{ name: '赔偿案件', icon: 'yuepeichang.svg' },
{ name: '执行案件', icon: 'zhixinganjian.svg' },
{ name: '限高被执行', icon: 'xianzhigaoxiaofei.svg' },
]" :key="`${item.id}-${caseIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + caseIndex)">
<view class="mb-1 shrink-0">
<image :src="`/static/inquire_icons/${caseType.icon}`" :alt="caseType.name"
class="mx-auto h-6 w-6 drop-shadow-sm" @error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-0.5 text-xs font-medium leading-snug">
{{ caseType.name }}
</view>
</view>
</template>
<!-- DWBG8B4D 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'DWBG8B4D'">
<view v-for="(module, moduleIndex) in [
{ name: '要素核查', icon: 'beijianguanrenyuan.svg' },
{ name: '运营商核验', icon: 'mingxiacheliang.svg' },
{ name: '公安重点人员检验', icon: 'xingshi.svg' },
{ name: '逾期风险综述', icon: 'huankuanyali.svg' },
{ name: '法院曝光台信息', icon: 'sifasheyu.svg' },
{ name: '借贷评估', icon: 'jiedaishenqing.svg' },
{ name: '租赁风险评估', icon: 'jiedaixingwei.svg' },
{ name: '关联风险监督', icon: 'renqiguanxi.svg' },
{ name: '规则风险提示', icon: 'fengxianxingwei.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- CJRZQ5E9F 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'JRZQ5E9F'">
<view v-for="(module, moduleIndex) in [
{ name: '信用评分', icon: 'huankuanyali.svg' },
{ name: '贷款行为分析', icon: 'jiedaixingwei.svg' },
{ name: '机构分析', icon: 'jiedaishenqing.svg' },
{ name: '时间趋势分析', icon: 'zhixinganjian.svg' },
{ name: '风险指标详情', icon: 'fengxianxingwei.svg' },
{ name: '专业建议', icon: 'yuepeichang.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- PersonEnterprisePro/CQYGL3F8E 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'PersonEnterprisePro' || item.api_id === 'QYGL3F8E'">
<view v-for="(module, moduleIndex) in [
{ name: '投资企业记录', icon: 'renqiguanxi.svg' },
{ name: '高管任职记录', icon: 'beijianguanrenyuan.svg' },
{ name: '涉诉风险', icon: 'sifasheyu.svg' },
{ name: '对外投资历史', icon: 'renqiguanxi.svg' },
{ name: '融资历史', icon: 'huankuanyali.svg' },
{ name: '行政处罚', icon: 'xingzhengfuwu.svg' },
{ name: '经营异常', icon: 'fengxianxingwei.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- DWBG6A2C 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'DWBG6A2C'">
<view v-for="(module, moduleIndex) in [
{ name: '命中风险标注', icon: 'fengxianxingwei.svg' },
{ name: '公安重点人员核验', icon: 'beijianguanrenyuan.svg' },
{ name: '涉赌涉诈人员核验', icon: 'xingshi.svg' },
{ name: '风险名单', icon: 'jiedaiweiyue.svg' },
{ name: '历史借贷行为', icon: 'jiedaixingwei.svg' },
{ name: '近24个月放款情况', icon: 'jiedaishenqing.svg' },
{ name: '履约情况', icon: 'huankuanyali.svg' },
{ name: '历史逾期记录', icon: 'jiedaiweiyue.svg' },
{ name: '授信详情', icon: 'huankuanyali.svg' },
{ name: '租赁行为', icon: 'mingxiacheliang.svg' },
{ name: '关联风险监督', icon: 'renqiguanxi.svg' },
{ name: '法院风险信息', icon: 'sifasheyu.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- 其他功能正常显示 -->
<view v-else
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index)">
<view class="mb-1 flex shrink-0 items-center justify-center">
<image :src="getFeatureIcon(item.api_id)" :alt="item.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ item.name }}
</view>
</view>
</template>
</view>
<view class="mt-3 text-center">
<view class="inline-flex items-center border rounded-full px-3 py-1.5 transition-all"
style="background: linear-gradient(135deg, var(--van-theme-primary-light), rgba(255,255,255,0.8)); border-color: var(--van-theme-primary);">
<view class="mr-1.5 h-1.5 w-1.5 rounded-full" style="background-color: var(--van-theme-primary);" />
<text class="text-xs font-medium" style="color: var(--van-theme-primary);">
更多信息请解锁报告
</text>
</view>
</view>
</view>
<!-- 产品详情卡片 -->
<view class="card mt-4">
<view class="mb-4 text-xl font-bold" style="color: var(--van-text-color);">
{{ featureData.product_name }}
</view>
<view class="mb-4 flex items-start justify-between">
<view class="text-lg" style="color: var(--van-text-color-2);">
价格:
</view>
<view>
<view class="text-danger text-2xl font-semibold">
¥{{ featureData.sell_price }}
</view>
</view>
</view>
<image v-if="productMainImage" :src="productMainImage" alt="产品详情主图" class="mb-4 w-full rounded-lg" />
<view class="mb-4 leading-relaxed" style="color: var(--van-text-color-2);" v-html="featureData.description" />
<view class="text-danger mb-2 text-xs italic">
为保证用户的隐私以及数据安全查询的结果生成30天之后将自动清除。
</view>
</view>
</view>
<!-- 支付组件 -->
<Payment :id="queryId" v-model="showPayment" :data="featureData" type="query" @close="showPayment = false" />
<BindPhoneDialog @bind-success="handleBindSuccess" />
<LoginDialog @login-success="handleLoginSuccess" />
<!-- 历史查询按钮 - 仅推广查询且已登录时显示 -->
<view v-if="props.type === 'promotion' && isLoggedIn"
class="bg-primary fixed right-2 top-3/4 cursor-pointer rounded-xl px-4 py-2 text-sm text-white font-bold shadow active:bg-blue-500"
@click="toHistory">
历史查询
</view>
</view>
</template>
<style scoped>
/* 背景样式 */
.inquire-bg {
background-color: var(--color-primary-50);
min-height: 100vh;
position: relative;
}
.hero-banner {
position: relative;
overflow: hidden;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
background: linear-gradient(180deg, #eef4ff 0%, #dfe9ff 100%);
}
.hero-banner-image {
display: block;
width: 100%;
margin-top: -2px;
}
.hero-banner-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(20, 32, 64, 0.04) 0%, rgba(20, 32, 64, 0.1) 100%);
pointer-events: none;
}
.hero-badge-wrap {
position: absolute;
left: 50%;
bottom: 34px;
transform: translateX(-50%);
width: 160px;
display: flex;
justify-content: center;
z-index: 3;
}
.content-wrap.with-hero {
margin-top: -68px;
padding-top: 0;
z-index: 2;
}
/* 卡片样式优化 */
.card {
@apply shadow-lg rounded-xl p-6 transition-all hover:shadow-xl;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* 按钮悬停效果 */
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
/* 梯形背景图片样式 */
.trapezoid-bg-image {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
height: 44px;
width: 100%;
}
/* 卡片容器样式 */
.card-container {
background: white;
border-radius: 20px;
padding: 32px 16px;
box-shadow: 0px 0px 24px 0px #3F3F3F0F;
border: 1px solid rgba(255, 255, 255, 0.75);
}
.card-container input::placeholder {
color: #DDDDDD;
}
.captcha-wd-btn {
margin-left: 12px;
}
:deep(.captcha-wd-btn.wd-button) {
min-width: 96px;
}
:deep(.captcha-wd-btn.wd-button.is-small) {
padding: 0 10px;
}
.agreement-wrap {
display: flex;
align-items: flex-start;
gap: 10px;
}
:deep(.agreement-wrap .wd-checkbox) {
margin-top: 1px;
}
.agreement-text {
flex: 1;
font-size: 13px;
line-height: 1.7;
word-break: break-all;
}
.agreement-link {
color: #2563eb;
}
/* 功能标签样式 */
.feature-tag {
background-color: var(--color-primary-light);
color: var(--color-primary);
padding: 6px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
}
/* 功能标签圆点 */
.feature-dot {
width: 6px;
height: 6px;
background-color: var(--color-primary);
border-radius: 50%;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
// 接收 type 和 options props 以及 v-model
const props = defineProps({
type: {
type: String,
default: 'purple-pink', // 默认颜色渐变
},
options: {
type: Array,
required: true, // 动态传入选项
},
modelValue: {
type: String,
default: '', // v-model 绑定的值
},
})
const emit = defineEmits(['update:modelValue'])
// 选中内容绑定 v-model
const selected = ref(props.modelValue)
// 监听 v-model 的变化
watch(() => props.modelValue, (newValue) => {
selected.value = newValue
})
// 根据type动态生成分割线的类名
const lineClass = computed(() => {
// 统一使用主题色渐变
return 'bg-gradient-to-r from-red-600 via-red-500 to-red-700'
})
// 计算滑动线的位置和宽度
const slideLineStyle = computed(() => {
const index = props.options.findIndex(option => option.value === selected.value)
const buttonWidth = 100 / props.options.length
return {
width: `${buttonWidth}%`,
transform: `translateX(${index * 100}%)`,
}
})
// 选择选项函数
function selectOption(option) {
selected.value = option.value
// 触发 v-model 的更新
emit('update:modelValue', option.value)
}
</script>
<template>
<view class="relative flex">
<view
v-for="(option, index) in options"
:key="index"
class="flex-1 shrink-0 cursor-pointer py-2 text-center text-size-sm font-bold transition-transform duration-200 ease-in-out"
:class="{ 'text-gray-900': selected === option.value, 'text-gray-500': selected !== option.value }"
@click="selectOption(option)"
>
{{ option.label }}
</view>
<view
class="absolute bottom-0 h-[3px] rounded transition-all duration-300"
:style="slideLineStyle"
:class="lineClass"
/>
</view>
</template>
<style scoped>
/* 自定义样式 */
button {
outline: none;
border: none;
cursor: pointer;
}
button:focus {
outline: none;
}
</style>

42
src/components/LEmpty.vue Normal file
View File

@@ -0,0 +1,42 @@
<script setup>
const route = useRoute()
// 返回上一页逻辑
function goBack() {
route.goBack()
}
</script>
<template>
<view class="card flex flex-col items-center justify-center text-center">
<!-- 图片插画 -->
<image src="/static/images/empty.svg" alt="空状态" class="h-64 w-64" />
<!-- 提示文字 -->
<text class="mb-2 text-xl text-gray-700 font-semibold">
没有查询到相关结果
</text>
<text class="mb-2 text-sm text-gray-500 leading-relaxed">
订单已申请退款预计
<text class="font-medium" style="color: var(--van-theme-primary);">24小时内到账</text>
</text>
<text class="text-xs text-gray-400">
如果已到账您可以忽略本提示
</text>
<!-- 返回按钮 -->
<button
class="mt-4 rounded-lg px-6 py-2 text-white transition duration-300 ease-in-out"
style="background-color: var(--van-theme-primary);"
onmouseover="this.style.backgroundColor='var(--van-theme-primary-dark)'"
onmouseout="this.style.backgroundColor='var(--van-theme-primary)'"
@click="goBack"
>
返回上一页
</button>
</view>
</template>
<style scoped>
/* 你可以添加一些额外的样式(如果需要) */
</style>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
content: {
type: String,
required: true,
},
})
const isExpanded = ref(false)
</script>
<template>
<view class="l-remark-card my-[20px]">
<!-- 顶部连接点 -->
<view class="connection-line">
<image
src="data:image/png;base64,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"
alt="左链条" class="connection-chain left" />
<image
src="data:image/png;base64,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"
alt="右链条" class="connection-chain right" />
</view>
<view>
<wd-icon name="info-o" class="tips-icon" />
<text class="tips-title">温馨提示</text>
</view>
<view>
<wd-text rows="2" :content="content" expand-text="展开" collapse-text="收起" />
</view>
</view>
</template>
<style scoped>
.l-remark-card {
position: relative;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
border-radius: 0.75rem;
background-color: #ffffff;
padding: 24px;
}
.tips-card {
background: var(--van-theme-primary-light);
border-radius: 8px;
padding: 12px;
}
.tips-icon {
color: var(--van-theme-primary);
margin-right: 5px;
}
.tips-title {
font-weight: bold;
font-size: 16px;
}
.tips-content {
font-size: 14px;
color: #333;
}
/* 连接链条样式 */
.connection-line {
position: absolute;
top: -40px;
left: 0;
right: 0;
height: 60px;
z-index: 20;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
}
.connection-chain {
height: 60px;
object-fit: contain;
}
.connection-chain.left {
width: 80px;
margin-left: -10px;
}
.connection-chain.right {
width: 80px;
margin-right: -10px;
}
</style>

80
src/components/LTable.vue Normal file
View File

@@ -0,0 +1,80 @@
<script setup>
import { computed, onMounted } from 'vue'
// 接收表格数据和类型的 props
const props = defineProps({
data: {
type: Array,
required: true,
},
type: {
type: String,
default: 'purple-pink', // 默认渐变颜色
},
})
// 根据 type 设置不同的渐变颜色(偶数行)
const evenClass = computed(() => {
// 统一使用主题色浅色背景
return 'bg-red-50/40'
})
// 动态计算表头的背景颜色和文本颜色
const headerClass = computed(() => {
// 统一使用主题色浅色背景
return 'bg-red-100'
})
// 斑马纹样式,偶数行带颜色,奇数行没有颜色,且从第二行开始
function zebraClass(index) {
return index % 2 === 1 ? evenClass.value : ''
}
</script>
<template>
<view class="l-table overflow-x-auto">
<table
class="min-w-full border-collapse table-auto text-center text-size-xs"
>
<thead :class="headerClass">
<tr>
<!-- 插槽渲染表头 -->
<slot name="header" />
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in props.data"
:key="index"
:class="zebraClass(index)"
class="border-t"
>
<slot :row="row" />
</tr>
</tbody>
</table>
</view>
</template>
<style scoped>
/* 基础表格样式 */
th {
font-weight: bold;
padding: 12px;
text-align: left;
border: 1px solid #e5e7eb;
}
/* 表格行样式 */
td {
padding: 12px;
border: 1px solid #e5e7eb;
}
table {
width: 100%;
border-spacing: 0;
}
.l-table {
@apply rounded-xl;
overflow: hidden;
}
</style>

27
src/components/LTitle.vue Normal file
View File

@@ -0,0 +1,27 @@
<script setup>
// 接收 props
const props = defineProps({
title: String,
})
const titleClass = computed(() => {
// 统一使用主题色
return 'bg-primary'
})
</script>
<template>
<view class="relative">
<!-- 标题部分 -->
<view :class="titleClass" class="inline-block rounded-lg px-2 py-1 text-white font-bold shadow-md">
{{ title }}
</view>
<!-- 左上角修饰 -->
<view
class="absolute left-0 top-0 h-4 w-4 transform rounded-full bg-white shadow-md -translate-x-2 -translate-y-2"
/>
</view>
</template>
<style scoped></style>

View File

@@ -0,0 +1,356 @@
<script setup>
import { computed, nextTick, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import { setAuthSession } from '@/utils/storage'
const emit = defineEmits(['login-success'])
const dialogStore = useDialogStore()
const userStore = useUserStore()
const phoneNumber = ref('')
const verificationCode = ref('')
const password = ref('')
const isPasswordLogin = ref(false)
const isAgreed = ref(false)
const isCountingDown = ref(false)
const countdown = ref(60)
const verificationCodeInputRef = ref(null)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canLogin = computed(() => {
if (!isPhoneNumberValid.value)
return false
if (isPasswordLogin.value) {
return password.value.length >= 6
}
else {
return verificationCode.value.length === 6
}
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'login', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast({ message: data.value?.msg || '发送失败' })
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
async function handleLogin() {
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (isPasswordLogin.value) {
if (password.value.length < 6) {
showToast({ message: '密码长度不能小于6位' })
return
}
}
else {
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
performLogin()
}
async function performLogin() {
const { data, error } = await useApiFetch('/user/mobileCodeLogin')
.post({ mobile: phoneNumber.value, code: verificationCode.value })
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
setAuthSession(data.value.data)
await userStore.fetchUserInfo()
showToast({ message: '登录成功' })
closeDialog()
emit('login-success')
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeLogin()
phoneNumber.value = ''
verificationCode.value = ''
password.value = ''
isPasswordLogin.value = false
isAgreed.value = false
isCountingDown.value = false
countdown.value = 60
if (timer) {
clearInterval(timer)
}
}
function toUserAgreement() {
closeDialog()
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
closeDialog()
uni.navigateTo({ url: '/pages/privacy-policy' })
}
</script>
<template>
<wd-popup v-model="dialogStore.showLogin" round position="bottom" :style="{ maxHeight: '90vh' }" :z-index="2000"
@close="closeDialog">
<view class="login-dialog">
<view class="title-bar">
<view class="title-bar-text">
用户登录
</view>
<wd-icon name="close" class="close-icon" @click="closeDialog" />
</view>
<view class="login-dialog-scroll">
<view class="mb-6 pt-4">
<view class="flex flex-col items-center">
<image class="h-16 w-16 rounded-full shadow" src="/static/images/logo.png" alt="Logo" />
<view class="mt-4 text-3xl text-slate-700 font-bold">
赤眉
</view>
</view>
</view>
<view class="login-form">
<view class="form-item">
<text class="form-label">
手机号
</text>
<wd-input v-model="phoneNumber" class="phone-wd-input" type="number" placeholder="请输入手机号" maxlength="11"
no-border clearable />
</view>
<view v-if="!isPasswordLogin" class="form-item">
<text class="form-label">
验证码
</text>
<view class="verification-input-wrapper">
<wd-input ref="verificationCodeInputRef" v-model="verificationCode" class="verification-wd-input"
placeholder="请输入验证码" maxlength="6" no-border clearable>
<template #suffix>
<wd-button size="small" type="primary" plain :disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode">
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<view v-else class="form-item">
<text class="form-label">
密码
</text>
<wd-input v-model="password" class="phone-wd-input" type="text" show-password placeholder="请输入密码" no-border
clearable />
</view>
<view class="flex items-center justify-end py-1">
<text class="switch-login-type" @click="isPasswordLogin = !isPasswordLogin">
{{ isPasswordLogin ? '验证码登录' : '密码登录' }}
</text>
</view>
<view class="agreement-wrapper">
<wd-checkbox v-model="isAgreed" shape="square" size="18px" />
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
</text>
</view>
<view class="notice-text">
未注册手机号登录后将自动生成账号并且代表您已阅读并同意
</view>
<wd-button class="login-wd-btn" block type="primary" :disabled="!canLogin" @click="handleLogin">
</wd-button>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.login-dialog {
display: flex;
max-height: 90vh;
flex-direction: column;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.title-bar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
padding: 12px 16px;
}
.title-bar-text {
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.close-icon {
padding: 4px;
font-size: 20px;
color: #666;
}
.login-dialog-scroll {
flex: 1;
overflow-y: auto;
padding-right: 16px;
padding-left: 16px;
/* 与登录页一致:底部留白 + 安全区刘海屏、Home 条) */
padding-bottom: calc(24px + constant(safe-area-inset-bottom));
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
/* 对齐 pages/login.vue */
.login-form {
margin-top: 0.25rem;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
padding: 1.5rem 1.25rem;
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.1);
backdrop-filter: blur(4px);
}
.form-item {
margin-bottom: 1.25rem;
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
}
.form-label {
flex-shrink: 0;
margin-bottom: 0;
margin-right: 1rem;
min-width: 4rem;
font-size: 0.9375rem;
color: #111827;
font-weight: 500;
}
.phone-wd-input {
flex: 1;
}
.verification-input-wrapper {
display: flex;
flex: 1;
align-items: center;
}
.verification-wd-input {
width: 100%;
}
.agreement-wrapper {
display: flex;
align-items: center;
margin-top: 0.25rem;
margin-bottom: 1rem;
}
.agreement-text {
margin-left: 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
color: #6b7280;
}
.agreement-link {
color: #2563eb;
}
.notice-text {
margin-bottom: 1.25rem;
font-size: 0.6875rem;
line-height: 1.5;
color: #9ca3af;
}
.login-wd-btn {
letter-spacing: 0.25rem;
}
.switch-login-type {
font-size: 0.875rem;
color: #2563eb;
}
</style>

495
src/components/Payment.vue Normal file
View File

@@ -0,0 +1,495 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
id: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
})
const { isWeChat } = useEnv()
const isDev = import.meta.env.DEV
/** APP 原生端同时展示微信与支付宝;其它端沿用微信内仅微信、否则仅支付宝 */
const isAppClient = computed(() => {
try {
return uni.getSystemInfoSync().uniPlatform === 'app'
}
catch {
return false
}
})
const appName = import.meta.env.VITE_APP_NAME || 'App'
const appLogo = '/static/images/logo.png'
const wechatPayIcon = '/static/images/wechatpay.svg'
const alipayIcon = '/static/images/alipay.svg'
const show = defineModel()
const selectedPaymentMethod = ref(isWeChat.value ? 'wechat' : 'alipay')
const paymentDisplayTime = ref('')
function toFiniteNumber(value) {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
const payableAmount = computed(() => {
const candidates = [
props?.data?.sell_price,
props?.data?.price,
props?.data?.amount,
]
for (const item of candidates) {
const normalized = toFiniteNumber(item)
if (normalized !== null)
return normalized
}
return null
})
const displayAmount = computed(() => {
if (payableAmount.value === null)
return '--'
return payableAmount.value.toFixed(2)
})
const displayDiscountAmount = computed(() => {
if (payableAmount.value === null)
return '--'
return (payableAmount.value * 0.2).toFixed(2)
})
/** 支付弹窗展示用YYYY-MM-DD HH:mm:ss */
function formatPaymentTime(d = new Date()) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
function setDefaultPaymentMethod() {
if (isAppClient.value) {
selectedPaymentMethod.value = 'alipay'
return
}
selectedPaymentMethod.value = isWeChat.value ? 'wechat' : 'alipay'
}
onMounted(setDefaultPaymentMethod)
watch(show, (v) => {
if (v) {
setDefaultPaymentMethod()
paymentDisplayTime.value = formatPaymentTime()
}
})
const router = useRouter()
const discountPrice = ref(false)
/** APP 端 wxpay服务端返回的 prepay_data 对象,供 uni.requestPayment 使用 */
function normalizeWxAppOrderInfo(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
return null
return raw
}
async function getPayment() {
const { data, error } = await useApiFetch('/pay/payment')
.post({
id: props.id,
pay_method: selectedPaymentMethod.value,
pay_type: props.type,
})
.json()
if (!data.value || error.value)
return
if (data.value.code !== 200)
return
const respData = data.value.data
const prepayId = respData.prepay_id
const prepayData = respData.prepay_data
const orderNoFromResp = respData.order_no
if (prepayId === 'test_payment_success') {
if (selectedPaymentMethod.value === 'alipay' || selectedPaymentMethod.value === 'wechat') {
showToast({ message: '支付参数异常,请重试', type: 'fail' })
return
}
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
return
}
// APP 原生:支付宝 / 微信uni.requestPayment
if (isAppClient.value) {
if (selectedPaymentMethod.value === 'alipay') {
if (!prepayId || typeof prepayId !== 'string') {
showToast({ message: '支付宝下单参数异常', type: 'fail' })
return
}
uni.requestPayment({
provider: 'alipay',
orderInfo: prepayId,
success: () => {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
},
fail: (e) => {
const msg = (e && (e.errMsg || e.message)) || '支付未完成'
showToast({ message: String(msg), type: 'fail' })
},
})
return
}
if (selectedPaymentMethod.value === 'wechat') {
const orderInfo = normalizeWxAppOrderInfo(prepayData)
if (!orderInfo) {
showToast({ message: '微信支付参数异常', type: 'fail' })
return
}
uni.requestPayment({
provider: 'wxpay',
orderInfo,
success: () => {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
},
fail: (e) => {
const msg = (e && (e.errMsg || e.message)) || '支付未完成'
showToast({ message: String(msg), type: 'fail' })
},
})
return
}
}
if (selectedPaymentMethod.value === 'alipay') {
if (typeof document === 'undefined') {
showToast({ message: '当前环境不支持网页支付宝支付', type: 'fail' })
return
}
const prepayUrl = prepayId
const paymentForm = document.createElement('form')
paymentForm.method = 'POST'
paymentForm.action = prepayUrl
paymentForm.style.display = 'none'
document.body.appendChild(paymentForm)
paymentForm.submit()
show.value = false
return
}
const payload = prepayData
if (typeof WeixinJSBridge === 'undefined') {
showToast({ message: '请在微信内打开以完成支付', type: 'fail' })
return
}
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
payload,
(res) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
}
},
)
}
function onCancel() {
show.value = false
}
</script>
<template>
<wd-popup
v-model="show"
round
position="bottom"
:safe-area-inset-bottom="true"
:z-index="2000"
custom-style="max-height: 88vh;"
>
<view class="payment-popup">
<view class="payment-popup__header">
<text class="payment-popup__title">
支付
</text>
</view>
<view class="payment-popup__brand">
<image class="payment-popup__logo" :src="appLogo" mode="aspectFit" />
<text class="payment-popup__app-name">
{{ appName }}
</text>
</view>
<view class="payment-popup__meta">
<view class="payment-popup__row">
<text class="payment-popup__label">
支付项目
</text>
<text class="payment-popup__value">
{{ data.product_name }}
</text>
</view>
<view class="payment-popup__row">
<text class="payment-popup__label">
支付时间
</text>
<text class="payment-popup__value">
{{ paymentDisplayTime }}
</text>
</view>
</view>
<view class="payment-popup__amount-block">
<view class="payment-popup__amount-label">
应付金额
</view>
<view class="payment-popup__amount-price">
<view v-if="discountPrice" class="payment-popup__strike">
¥ {{ displayAmount }}
</view>
<view>
¥
{{
discountPrice
? displayDiscountAmount
: displayAmount
}}
</view>
</view>
<view v-if="discountPrice" class="payment-popup__discount-tip">
活动价2折优惠
</view>
</view>
<view class="payment-popup__methods">
<text class="payment-popup__section-label">
支付方式
</text>
<wd-radio-group v-model="selectedPaymentMethod" shape="dot" cell class="payment-radio-group">
<wd-radio v-if="isAppClient || isWeChat" value="wechat">
<view class="payment-radio-row">
<image class="payment-radio-row__pay-icon" :src="wechatPayIcon" mode="aspectFit" />
<text>
微信支付
</text>
</view>
</wd-radio>
<wd-radio v-if="isAppClient || !isWeChat" value="alipay">
<view class="payment-radio-row">
<image class="payment-radio-row__pay-icon" :src="alipayIcon" mode="aspectFit" />
<text>
支付宝支付
</text>
</view>
</wd-radio>
<wd-radio v-if="isDev" value="test">
<view class="payment-radio-row">
<wd-icon size="24" name="description" color="#ff976a" class="payment-radio-row__icon" />
<text>
开发环境测试支付
</text>
</view>
</wd-radio>
</wd-radio-group>
</view>
<view class="payment-popup__actions">
<!-- eslint-disable-next-line unocss/order-attributify -- 组件属性 block UnoCSS -->
<wd-button block round size="large" type="primary" custom-class="payment-btn-primary" @click="getPayment">
确认支付
</wd-button>
<!-- eslint-disable-next-line unocss/order-attributify -- 组件属性 block UnoCSS -->
<wd-button block round size="small" type="text" custom-class="payment-btn-cancel" @click="onCancel">
取消
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.payment-popup {
display: flex;
flex-direction: column;
max-height: 85vh;
padding: 32rpx 32rpx 24rpx;
box-sizing: border-box;
}
.payment-popup__header {
margin-bottom: 24rpx;
text-align: center;
}
.payment-popup__title {
font-size: 36rpx;
font-weight: 700;
color: #333;
}
.payment-popup__brand {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 28rpx;
}
.payment-popup__logo {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
}
.payment-popup__app-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.payment-popup__meta {
margin-bottom: 28rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #f7f8fa;
}
.payment-popup__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 12rpx 0;
font-size: 26rpx;
}
.payment-popup__row+.payment-popup__row {
border-top: 1rpx solid #ebedf0;
}
.payment-popup__label {
flex-shrink: 0;
color: #969799;
}
.payment-popup__value {
flex: 1;
text-align: right;
color: #323233;
word-break: break-all;
}
.payment-popup__amount-block {
margin-bottom: 28rpx;
text-align: center;
}
.payment-popup__amount-label {
margin-bottom: 8rpx;
font-size: 26rpx;
color: #969799;
}
.payment-popup__amount-price {
margin-top: 12rpx;
font-size: 48rpx;
font-weight: 700;
color: #ee0a24;
}
.payment-popup__strike {
margin-bottom: 8rpx;
font-size: 28rpx;
color: #969799;
text-decoration: line-through;
}
.payment-popup__discount-tip {
margin-top: 8rpx;
font-size: 24rpx;
color: #ee0a24;
}
.payment-popup__section-label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 600;
color: #323233;
}
.payment-popup__methods {
margin-bottom: 32rpx;
}
.payment-radio-group {
overflow: hidden;
border-radius: 16rpx;
}
.payment-radio-row {
display: flex;
flex-direction: row;
align-items: center;
}
.payment-radio-row__pay-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.payment-popup__actions {
margin-top: auto;
padding-top: 8rpx;
}
:deep(.payment-btn-primary) {
height: 96rpx !important;
font-size: 34rpx !important;
font-weight: 600;
}
:deep(.payment-btn-cancel) {
margin-top: 16rpx;
color: #969799 !important;
font-size: 28rpx !important;
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup>
const props = defineProps({
defaultPrice: {
type: Number,
default: 0,
},
productConfig: {
type: Object,
default: null,
},
})
const emit = defineEmits(['change'])
const { defaultPrice, productConfig } = toRefs(props)
const show = defineModel('show')
const price = ref(null)
const hasProductConfig = computed(() => {
return !!productConfig.value
})
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
watch(show, (visible) => {
if (!visible)
return
price.value = Number(defaultPrice.value || 0)
})
const costPrice = computed(() => {
if (!productConfig.value)
return 0.00
// 平台定价成本
let platformPricing = 0
platformPricing += productConfig.value.cost_price
if (price.value > productConfig.value.p_pricing_standard) {
platformPricing += (price.value - productConfig.value.p_pricing_standard) * productConfig.value.p_overpricing_ratio
}
if (productConfig.value.a_pricing_standard > platformPricing && productConfig.value.a_pricing_end > platformPricing && productConfig.value.a_overpricing_ratio > 0) {
if (price.value > productConfig.value.a_pricing_standard) {
if (price.value > productConfig.value.a_pricing_end) {
platformPricing += (productConfig.value.a_pricing_end - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
else {
platformPricing += (price.value - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
})
const promotionRevenue = computed(() => {
return safeTruncate(price.value - costPrice.value)
})
// 价格校验与修正逻辑
function validatePrice(currentPrice) {
if (!productConfig.value) {
return { newPrice: Number(defaultPrice.value || 0), message: '产品配置未就绪,请稍后再试' }
}
const min = productConfig.value.price_range_min
const max = productConfig.value.price_range_max
let newPrice = Number(currentPrice)
let message = ''
// 处理无效输入
if (Number.isNaN(newPrice)) {
newPrice = defaultPrice.value
return { newPrice, message: '输入无效,请输入价格' }
}
// 处理小数位数(兼容科学计数法)
try {
const priceString = newPrice.toString()
const [_, decimalPart = ''] = priceString.split('.')
// 当小数位数超过2位时处理
if (decimalPart.length > 2) {
newPrice = Number.parseFloat(safeTruncate(newPrice))
message = '价格已自动格式化为两位小数'
}
}
catch (e) {
console.error('价格格式化异常:', e)
}
// 范围校验(基于可能格式化后的值)
if (newPrice < min) {
message = `价格不能低于 ${min}`
newPrice = min
}
else if (newPrice > max) {
message = `价格不能高于 ${max}`
newPrice = max
}
return { newPrice, message }
}
function safeTruncate(num, decimals = 2) {
if (Number.isNaN(num) || !Number.isFinite(num))
return '0.00'
const factor = 10 ** decimals
const scaled = Math.trunc(num * factor)
const truncated = scaled / factor
return truncated.toFixed(decimals)
}
const isManualConfirm = ref(false)
function onConfirm() {
if (!hasProductConfig.value) {
showToast('产品配置未就绪,请稍后再试')
return
}
if (isManualConfirm.value)
return
const { newPrice, message } = validatePrice(price.value)
if (message) {
price.value = newPrice
showToast({ message })
}
else {
emit('change', price.value)
show.value = false
}
}
function onBlurPrice() {
const { newPrice, message } = validatePrice(price.value)
if (message) {
isManualConfirm.value = true
price.value = newPrice
showToast({ message })
}
setTimeout(() => {
isManualConfirm.value = false
}, 0)
}
</script>
<template>
<wd-popup v-model="show" destroy-on-close round position="bottom">
<view class="min-h-[500px] bg-gray-50 text-gray-600">
<view class="h-10 flex items-center justify-center bg-white text-lg font-semibold">
设置客户查询价
</view>
<view class="card m-4">
<view class="flex items-center justify-between">
<view class="text-lg">
客户查询价 ()
</view>
</view>
<view class="price-input-wrap border border-orange-100 rounded-xl bg-orange-50/40 px-2">
<wd-input
v-model="price"
type="number"
label="¥"
size="large"
label-width="34"
no-border
custom-input-class="price-input-inner"
custom-label-class="price-input-label"
:placeholder="`${productConfig?.price_range_min || 0} - ${productConfig?.price_range_max || 0}`"
@blur="onBlurPrice"
/>
</view>
<view class="mt-2 flex items-center justify-between">
<view>
推广收益为
<text class="text-orange-500">
{{ promotionRevenue }}
</text>
</view>
<view>
我的成本为
<text class="text-orange-500">
{{ costPrice }}
</text>
</view>
</view>
</view>
<view class="card m-4">
<view class="mb-2 text-lg">
收益与成本说明
</view>
<view>推广收益 = 客户查询价 - 我的成本</view>
<view>我的成本 = 提价成本 + 底价成本</view>
<view class="mt-1">
提价成本超过平台标准定价部分平台会收取部分成本价
</view>
<view>
设定范围
<text class="text-orange-500">
{{ productConfig.price_range_min }}
</text>
-
<text class="text-orange-500">
{{ productConfig.price_range_max }}
</text>
</view>
</view>
<view class="px-4 pb-4">
<wd-button class="w-full" round type="primary" size="large" @click="onConfirm">
确认
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style lang="scss" scoped>
.price-input-wrap {
box-shadow: inset 0 0 0 1px rgba(251, 146, 60, 0.08);
}
.price-input-wrap :deep(.price-input-label) {
color: #ea580c;
font-size: 22px;
font-weight: 700;
}
.price-input-wrap :deep(.price-input-inner) {
height: 56px;
font-size: 40px;
font-weight: 700;
color: #111827;
line-height: 56px;
text-align: left !important;
}
.price-input-wrap :deep(.wd-input__placeholder) {
font-size: 20px;
text-align: left !important;
}
</style>

1098
src/components/QRcode.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,408 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const router = useRouter()
const dialogStore = useDialogStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
// 表单数据
const realName = ref('')
const idCard = ref('')
const phoneNumber = ref('')
const verificationCode = ref('')
const isAgreed = ref(false)
// 倒计时相关
const isCountingDown = ref(false)
const countdown = ref(60)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
// 表单验证
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const isIdCardValid = computed(() => {
return /^(?:\d{15}|\d{17}[\dXx])$/.test(idCard.value)
})
const isRealNameValid = computed(() => {
return /^[\u4E00-\u9FA5]{2,}$/.test(realName.value)
})
const canSubmit = computed(() => {
return (
isPhoneNumberValid.value
&& isIdCardValid.value
&& isRealNameValid.value
&& verificationCode.value.length === 6
&& isAgreed.value
)
})
// 发送验证码
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'realName', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
}
else {
showToast(data.value?.msg || '发送失败')
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
// 提交实名认证
async function handleSubmit() {
if (!isRealNameValid.value) {
showToast({ message: '请输入有效的姓名' })
return
}
if (!isIdCardValid.value) {
showToast({ message: '请输入有效的身份证号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
const { data, error } = await useApiFetch('/agent/real_name')
.post({
name: realName.value,
id_card: idCard.value,
mobile: phoneNumber.value,
code: verificationCode.value,
})
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showToast({ message: '认证成功' })
agentStore.isRealName = true
await agentStore.fetchAgentStatus()
await userStore.fetchUserInfo()
closeDialog()
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeRealNameAuth()
realName.value = ''
idCard.value = ''
phoneNumber.value = ''
verificationCode.value = ''
isAgreed.value = false
if (timer) {
clearInterval(timer)
timer = null
}
isCountingDown.value = false
}
function toUserAgreement() {
closeDialog()
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
closeDialog()
router.push(`/privacyPolicy`)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<template>
<view v-if="dialogStore.showRealNameAuth" class="real-name-auth-dialog-box">
<wd-popup
v-model="dialogStore.showRealNameAuth"
round
position="bottom"
:style="{ maxHeight: '90vh' }"
@close="closeDialog"
>
<view
class="real-name-auth-dialog"
style="background: linear-gradient(135deg, var(--van-theme-primary-light), rgba(255,255,255,0.9));"
>
<view class="title-bar">
<view class="text-base font-bold sm:text-lg" style="color: var(--van-text-color);">
实名认证
</view>
<wd-icon name="cross" class="close-icon" style="color: var(--van-text-color-2);" @click="closeDialog" />
</view>
<view class="dialog-content">
<view class="dialog-inner px-4 pb-4 pt-2">
<view
class="auth-notice mb-4 rounded-xl p-3 sm:p-4"
style="background-color: var(--van-theme-primary-light); border: 1px solid rgba(162, 37, 37, 0.2);"
>
<view class="text-xs space-y-1.5 sm:text-sm sm:space-y-2" style="color: var(--van-text-color);">
<text class="font-medium" style="color: var(--van-theme-primary);">
实名认证说明
</text>
<text>1. 实名认证是提现的必要条件</text>
<text>2. 提现金额将转入您实名认证的银行卡账户</text>
<text>3. 请确保填写的信息真实有效否则将影响提现功能的使用</text>
<text>4. 认证信息提交后将无法修改请仔细核对</text>
</view>
</view>
<view class="real-name-form">
<view class="form-item">
<text class="form-label">
姓名
</text>
<wd-input
v-model="realName"
class="field-wd-input"
type="text"
placeholder="请输入真实姓名"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
身份证
</text>
<wd-input
v-model="idCard"
class="field-wd-input"
type="text"
placeholder="请输入身份证号"
maxlength="18"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
手机号
</text>
<wd-input
v-model="phoneNumber"
class="field-wd-input"
type="number"
placeholder="请输入手机号"
maxlength="11"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
验证码
</text>
<view class="verification-input-wrapper">
<wd-input
v-model="verificationCode"
class="verification-wd-input"
placeholder="请输入验证码"
maxlength="6"
no-border
clearable
>
<template #suffix>
<wd-button
size="small"
type="primary"
plain
:disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode"
>
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<view class="agreement-wrapper">
<wd-checkbox v-model="isAgreed" shape="square" size="18px" />
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
并确认以上信息真实有效将用于提现等资金操作
</text>
</view>
<wd-button
class="submit-wd-btn"
block
type="primary"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认认证
</wd-button>
</view>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.real-name-auth-dialog {
max-height: 90vh;
display: flex;
flex-direction: column;
}
.dialog-content {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--van-border-color);
flex-shrink: 0;
}
.close-icon {
font-size: 18px;
cursor: pointer;
}
@media (min-width: 640px) {
.close-icon {
font-size: 20px;
}
}
/* 与登录页 login.vue 表单卡片一致 */
.real-name-form {
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.08);
padding: 1.25rem 1rem 1.5rem;
backdrop-filter: blur(4px);
}
.form-item {
margin-bottom: 1rem;
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
min-height: 48px;
}
.form-label {
font-size: 0.9375rem;
color: #111827;
margin-right: 0.75rem;
font-weight: 500;
min-width: 3.25rem;
flex-shrink: 0;
}
.field-wd-input {
flex: 1;
}
.verification-input-wrapper {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.verification-wd-input {
width: 100%;
}
.agreement-wrapper {
display: flex;
align-items: flex-start;
margin-top: 1rem;
margin-bottom: 1rem;
}
.agreement-text {
font-size: 0.75rem;
color: #6b7280;
line-height: 1.5;
margin-left: 0.5rem;
flex: 1;
}
.agreement-link {
color: #2563eb;
cursor: pointer;
}
.submit-wd-btn {
margin-top: 0.25rem;
}
.real-name-auth-dialog-box {
position: relative;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
})
</script>
<template>
<view class="flex items-center">
<view class="flex items-center gap-2">
<view class="bg-primary h-5 w-1.5 rounded-xl" />
<view class="text-lg text-gray-800">
{{ title }}
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,146 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
orderId: {
type: String,
default: '',
},
orderNo: {
type: String,
default: '',
},
isExample: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const isLoading = ref(false)
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
showToast({
type: 'success',
message: '链接已复制到剪贴板',
position: 'bottom',
})
}
async function handleShare() {
if (isLoading.value || props.disabled)
return
// 如果是示例模式直接分享当前URL
if (props.isExample) {
try {
const currentUrl = window.location.href
await copyToClipboard(currentUrl)
showToast({
type: 'success',
message: '示例链接已复制到剪贴板',
position: 'bottom',
})
}
catch {
showToast({
type: 'fail',
message: '复制链接失败',
position: 'bottom',
})
}
return
}
// 优先使用 orderId如果没有则使用 orderNo
const orderIdentifier = props.orderId || props.orderNo
if (!orderIdentifier) {
showToast({
type: 'fail',
message: '缺少订单标识',
position: 'bottom',
})
return
}
isLoading.value = true
try {
// 根据实际使用的标识构建请求参数
const requestData = props.orderId
? { order_id: Number.parseInt(props.orderId) }
: { order_no: props.orderNo }
const { data, error } = await useApiFetch('/query/generate_share_link')
.post(requestData)
.json()
if (error.value) {
throw new Error(error.value)
}
if (data.value?.code === 200 && data.value.data?.share_link) {
const baseUrl = window.location.origin
const linkId = encodeURIComponent(data.value.data.share_link)
const fullShareUrl = `${baseUrl}/report/share/${linkId}`
try {
const { confirm } = await uni.showModal({
title: '分享链接已生成',
content: '链接将在7天后过期是否复制到剪贴板',
confirmText: '复制链接',
cancelText: '取消',
})
if (!confirm)
return
await copyToClipboard(fullShareUrl)
}
catch (dialogErr) {
console.error(dialogErr)
}
}
else {
throw new Error(data.value?.message || '生成分享链接失败')
}
}
catch (err) {
showToast({
type: 'fail',
message: err.message || '生成分享链接失败',
position: 'bottom',
})
}
finally {
isLoading.value = false
}
}
</script>
<template>
<view
class="bg-primary border-primary hover:bg-primary-600 flex cursor-pointer items-center justify-center border rounded-[40px] px-3 py-1 transition-colors duration-200"
:class="{ 'opacity-50 cursor-not-allowed': isLoading || disabled }" @click="handleShare"
>
<image src="/static/images/report/fx.png" alt="分享" class="mr-1 h-4 w-4" />
<text class="text-sm text-white font-medium">
{{ isLoading ? "生成中..." : (isExample ? "分享示例" : "分享报告") }}
</text>
</view>
</template>
<style lang="scss" scoped>
/* 样式已通过 Tailwind CSS 类实现 */
</style>

View File

@@ -0,0 +1,44 @@
<script setup>
// 透传所有属性和事件到 van-tabs
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<wd-tabs v-bind="$attrs" type="card" class="styled-tabs">
<slot />
</wd-tabs>
</template>
<style scoped>
/* van-tabs 卡片样式定制 - 仅用于此组件 */
.styled-tabs:deep(.van-tabs__line) {
background-color: var(--van-theme-primary) !important;
}
.styled-tabs:deep(.van-tabs__nav) {
gap: 10px;
}
.styled-tabs:deep(.van-tabs__nav--card) {
border: unset !important;
}
.styled-tabs:deep(.van-tab--card) {
color: #666666 !important;
border-right: unset !important;
background-color: #eeeeee !important;
border-radius: 8px !important;
}
.styled-tabs:deep(.van-tab--active) {
color: white !important;
background-color: var(--van-theme-primary) !important;
}
.styled-tabs:deep(.van-tabs__wrap) {
background-color: #ffffff !important;
padding: 9px 0;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
// 不需要额外的 props 或逻辑,只是一个简单的样式组件
</script>
<template>
<view class="title-banner">
<slot />
</view>
</template>
<style scoped>
.title-banner {
@apply mx-auto mt-2 w-64 py-1.5 text-center text-white font-bold text-lg relative rounded-2xl;
background: var(--color-primary);
border: 1px solid var(--color-primary-300);
background-image:
linear-gradient(45deg, transparent 25%, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.1) 50%, transparent 50%, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
background-size: 20px 20px;
background-position: 0 0;
position: relative;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup>
import LTitle from './LTitle.vue'
import ShareReportButton from './ShareReportButton.vue'
const props = defineProps({
reportParams: {
type: Object,
required: true,
},
reportDateTime: {
type: [String, null],
required: false,
default: null,
},
reportName: {
type: String,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
isShare: {
type: Boolean,
required: false,
default: false,
},
orderId: {
type: [String, Number],
required: false,
default: null,
},
orderNo: {
type: String,
required: false,
default: null,
},
})
// 脱敏函数
function maskValue(type, value) {
if (!value)
return value
if (type === 'name') {
// 姓名脱敏(保留首位)
if (value.length === 1) {
return '*' // 只保留一个字,返回 "*"
}
else if (value.length === 2) {
return `${value[0]}*` // 两个字,保留姓氏,第二个字用 "*" 替代
}
else {
return (
value[0]
+ '*'.repeat(value.length - 2)
+ value[value.length - 1]
) // 两个字以上,保留第一个和最后一个字,其余的用 "*" 替代
}
}
else if (type === 'id_card') {
// 身份证号脱敏保留前6位和最后4位
return value.replace(/^(.{6})\d+(.{4})$/, '$1****$2')
}
else if (type === 'mobile') {
if (value.length === 11) {
return `${value.substring(0, 3)}****${value.substring(7)}`
}
return value // 如果手机号不合法或长度不为 11 位,直接返回原手机号
}
return value
}
</script>
<template>
<view class="card" style="padding-left: 0; padding-right: 0; padding-bottom: 24px;">
<view class="flex flex-col gap-y-2">
<!-- 报告信息 -->
<view class="flex items-center justify-between py-2">
<LTitle title="报告信息" />
<!-- 分享按钮 -->
<ShareReportButton
v-if="!isShare" :order-id="orderId" :order-no="orderNo" :is-example="!orderId"
class="mr-4"
/>
</view>
<view class="mx-4 my-2 flex flex-col gap-2">
<view class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">报告时间</text>
<text class="text-gray-600">{{
reportDateTime
|| "2025-01-01 12:00:00"
}}</text>
</view>
<view v-if="!isEmpty" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">报告项目</text>
<text class="text-gray-600 font-bold">
{{ reportName }}</text>
</view>
</view>
<!-- 报告对象 -->
<template v-if="Object.keys(reportParams).length != 0">
<LTitle title="报告对象" />
<view class="mx-4 my-2 flex flex-col gap-2">
<!-- 姓名 -->
<view v-if="reportParams?.name" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">姓名</text>
<text class="text-gray-600">{{
maskValue(
"name",
reportParams?.name,
)
}}</text>
</view>
<!-- 身份证号 -->
<view v-if="reportParams?.id_card" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">身份证号</text>
<text class="text-gray-600">{{
maskValue(
"id_card",
reportParams?.id_card,
)
}}</text>
</view>
<!-- 手机号 -->
<view v-if="reportParams?.mobile" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">手机号</text>
<text class="text-gray-600">{{
maskValue(
"mobile",
reportParams?.mobile,
)
}}</text>
</view>
<!-- 验证卡片 -->
<view class="mt-4 flex flex-col gap-4">
<!-- 身份证检查结果 -->
<view class="flex flex-1 items-center border border-[#EEEEEE] rounded-lg bg-[#F9F9F9] px-4 py-3">
<view class="mr-4 h-11 w-11 flex items-center justify-center">
<image src="/static/images/report/sfz.png" alt="身份证" class="h-10 w-10 object-contain" />
</view>
<view class="flex-1">
<view class="text-lg text-gray-800 font-bold">
身份证检查结果
</view>
<view class="text-sm text-[#999999]">
身份证信息核验通过
</view>
</view>
<view class="ml-4 h-11 w-11 flex items-center justify-center">
<image src="/static/images/report/zq.png" alt="资金安全" class="h-10 w-10 object-contain" />
</view>
</view>
<!-- 手机号检测结果 -->
<!-- <view class="flex items-center px-4 py-3 flex-1 border border-[#EEEEEE] rounded-lg bg-[#F9F9F9]">
<view class="w-11 h-11 flex items-center justify-center mr-4">
<image src="/static/images/report/sjh.png" alt="手机号" class="w-10 h-10 object-contain" />
</view>
<view class="flex-1">
<view class="font-bold text-gray-800 text-lg">
手机号检测结果
</view>
<view class="text-sm text-[#999999]">
被查询人姓名与运营商提供的一致
</view>
<view class="text-sm text-[#999999]">
被查询人身份证与运营商提供的一致
</view>
</view>
<view class="w-11 h-11 flex items-center justify-center ml-4">
<image src="/static/images/report/zq.png" alt="资金安全" class="w-10 h-10 object-contain" />
</view>
</view> -->
</view>
</view>
</template>
</view>
</view>
</template>
<style scoped>
/* 组件样式已通过 Tailwind CSS 类实现 */
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
function toAgentVip() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
</script>
<template>
<view class="mb-4 w-full" @click="toAgentVip">
<image src="/static/images/vip_banner.png" mode="widthFix" class="w-full rounded-xl shadow-lg" alt="" />
</view>
</template>
<style lang="scss" scoped></style>