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,16 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/agentManageAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 颜色配置(根据产品名称映射)
const typeColors = {
小微企业: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
入职风险: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
家政风险: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
婚恋风险: { bg: 'bg-pink-100', text: 'text-pink-800', dot: 'bg-pink-500' },
贷前风险: { bg: 'bg-orange-100', text: 'text-orange-800', dot: 'bg-orange-500' },
租赁风险: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
个人风险: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
个人大数据: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
// 默认类型
default: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
}
// 新增点 4: 定义脱敏函数
function desen(value, type) {
// 如果值是空,直接返回空字符串,让外层的 || '-' 生效
if (!value) {
return ''
}
// 根据类型进行不同的替换
switch (type) {
case 'mobile': // 手机号保留前3位和后4位中间4位替换为 ****
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
case 'id_card':// 身份证保留前6位和后1位中间替换为 ********
if (value.length <= 7)
return value
return `${value.slice(0, 6)}********${value.slice(-1)}`
default:
return value // 其他类型不处理
}
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
// 获取颜色样式
function getReportTypeStyle(name) {
const color = typeColors[name] || typeColors.default
return `${color.bg} ${color.text}`
}
// 获取小圆点颜色
function getDotColor(name) {
return (typeColors[name] || typeColors.default).dot
}
// 获取金额颜色
function getAmountColor(item) {
// 如果净佣金为0或状态为已退款显示红色
if (item.net_amount <= 0 || item.status === 2) {
return 'text-red-500'
}
// 如果有部分退款,显示橙色
if (item.refunded_amount > 0) {
return 'text-orange-500'
}
// 正常情况显示绿色
return 'text-green-500'
}
// 获取金额前缀(+ 或 -
function getAmountPrefix(item) {
if (item.net_amount <= 0 || item.status === 2) {
return '-'
}
return '+'
}
// 获取状态文本
function getStatusText(item) {
if (item.status === 2 || item.net_amount <= 0) {
return '已退款'
}
if (item.status === 1) {
// 冻结中
if (item.refunded_amount > 0) {
return '冻结中(部分退款)'
}
return '冻结中'
}
if (item.status === 0) {
// 已结算
if (item.refunded_amount > 0) {
return '已结算(部分退款)'
}
return '已结算'
}
return '未知状态'
}
// 获取状态样式
function getStatusStyle(item) {
if (item.status === 2 || item.net_amount <= 0) {
return 'bg-red-100 text-red-800'
}
if (item.status === 1) {
// 冻结中
if (item.refunded_amount > 0) {
return 'bg-orange-100 text-orange-800'
}
return 'bg-yellow-100 text-yellow-800'
}
if (item.status === 0) {
// 已结算
if (item.refunded_amount > 0) {
return 'bg-blue-100 text-blue-800'
}
return 'bg-green-100 text-green-800'
}
return 'bg-gray-100 text-gray-800'
}
// 获取数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/commission?page=${page.value}&page_size=${pageSize.value}`,
).get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(() => {
getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="detail-scroll">
<view v-for="(item, index) in data.list" :key="index" class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm">
<view class="mb-2 flex items-center justify-between">
<!-- 修改点 1: 使用 desen 函数处理 mobile -->
<text class="text-sm text-gray-500">
{{ desen(item.query_params?.mobile, 'mobile') || '-' }}
</text>
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getReportTypeStyle(item.product_name)"
>
<text class="mr-1 h-2 w-2 rounded-full" :class="getDotColor(item.product_name)" />
{{ item.product_name }}
</text>
</view>
<view class="mb-2 flex items-center justify-between">
<text class="text-gray-700 font-medium">
直接收益
</text>
<view class="flex flex-col items-end">
<!-- 主金额显示净佣金 -->
<text :class="getAmountColor(item)" class="text-lg font-bold">
{{ getAmountPrefix(item) }}{{ (item.net_amount || 0).toFixed(2) }}
</text>
<!-- 如果有部分退款显示原始金额和已退金额 -->
<text v-if="item.refunded_amount > 0 && item.net_amount > 0" class="mt-1 text-xs text-gray-400">
原始 {{ item.amount.toFixed(2) }}已退 {{ item.refunded_amount.toFixed(2) }}
</text>
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ desen(item.query_params?.name) || '-' }}
</text>
<!-- 状态标签 -->
<text
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="getStatusStyle(item)"
>
{{ getStatusText(item) }}
</text>
</view>
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ item.create_time || '-' }}
</text>
</view>
<view class="mt-2 flex items-center">
<text class="text-sm text-gray-500">
订单号
</text>
<text class="text-sm text-gray-700 font-mono">
{{ item.order_id || '-' }}
</text>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
/* 列表项入场动画 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.detail-scroll {
height: calc(100vh - 110px);
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 类型映射配置
const typeConfig = {
descendant_promotion: {
chinese: '下级推广奖励',
color: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
},
descendant_upgrade_vip: {
chinese: '下级升级VIP奖励',
color: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
},
descendant_upgrade_svip: {
chinese: '下级升级SVIP奖励',
color: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
},
descendant_withdraw: {
chinese: '下级提现奖励',
color: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
},
default: {
chinese: '其他奖励',
color: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
},
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
// 类型转中文
function typeToChinese(type) {
return typeConfig[type]?.chinese || typeConfig.default.chinese
}
// 获取颜色样式
function getReportTypeStyle(type) {
const config = typeConfig[type] || typeConfig.default
return `${config.color.bg} ${config.color.text}`
}
// 获取小圆点颜色
function getDotColor(type) {
return typeConfig[type]?.color.dot || typeConfig.default.color.dot
}
// 获取数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/rewards?page=${page.value}&page_size=${pageSize.value}`,
).get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(() => {
getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="reward-scroll">
<view v-for="(item, index) in data.list" :key="index" class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm">
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ item.create_time || '-' }}
</text>
<text class="text-green-500 font-bold">
+{{ item.amount.toFixed(2) }}
</text>
</view>
<view class="flex items-center">
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getReportTypeStyle(item.type)"
>
<text class="mr-1 h-2 w-2 rounded-full" :class="getDotColor(item.type)" />
{{ typeToChinese(item.type) }}
</text>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
/* 保持原有样式不变 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.reward-scroll {
height: calc(100vh - 110px);
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/agentSerivceAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
definePage({ layout: 'default', auth: true })
// 报告类型选项
const reportOptions = [
{ text: '小微企业', value: 'companyinfo', id: 2 },
{ text: '贷前风险', value: 'preloanbackgroundcheck', id: 5 },
{ text: '个人大数据', value: 'personaldata', id: 27 },
{ text: '入职风险', value: 'backgroundcheck', id: 1 },
{ text: '家政风险', value: 'homeservice', id: 3 },
{ text: '婚恋风险', value: 'marriage', id: 4 },
// { text: "租赁风险", value: "rentalrisk", id: 6 },
// { text: "个人风险", value: "riskassessment", id: 7 },
]
// 状态管理
const showPicker = ref(false)
const selectedReport = ref(reportOptions[0])
const selectedReportText = ref(reportOptions[0].text)
const selectedReportId = ref(reportOptions[0].id)
const configData = ref({})
const productConfigData = ref({})
const priceIncreaseMax = ref(null)
const priceIncreaseAmountMax = ref(null)
const priceRatioMax = ref(null)
const rangeError = ref(false)
const ratioError = ref(false)
const increaseError = ref(false)
function showToast(message) {
if (!message)
return
uni.showToast({
title: message,
icon: 'none',
})
}
// 金额输入格式验证:确保最多两位小数
function validateDecimal(field) {
const value = configData.value[field]
if (value === null || value === undefined)
return
const numValue = Number(value)
if (isNaN(numValue)) {
configData.value[field] = null
return
}
const fixedValue = Number.parseFloat(numValue.toFixed(2))
configData.value[field] = fixedValue
if (field === 'price_increase_amount') {
if (fixedValue > priceIncreaseAmountMax.value) {
configData.value[field] = priceIncreaseAmountMax.value
showToast(`加价金额最大为${priceIncreaseAmountMax.value}`)
increaseError.value = true
setTimeout(() => {
increaseError.value = false
}, 2000)
}
else {
increaseError.value = false
}
// 当加价金额改变后,重新验证价格区间
validateRange()
}
}
// 价格区间验证(在 @blur 中调用)
function validateRange() {
console.log(
'configData.value.price_range_from',
configData.value.price_range_from,
)
console.log(
'configData.value.price_range_to',
configData.value.price_range_to,
)
if (
configData.value.price_range_from === null
|| configData.value.price_range_to === null
) {
rangeError.value = false
return
}
if (
isNaN(configData.value.price_range_from)
|| isNaN(configData.value.price_range_to)
) {
return
}
const additional = configData.value.price_increase_amount || 0
const minAllowed = Number.parseFloat(
(
Number(productConfigData.value.cost_price) + Number(additional)
).toFixed(2),
) // 使用成本价作为最小值
const maxAllowed = productConfigData.value.price_range_max // 使用产品配置中的最大价格作为最大值
if (configData.value.price_range_from < minAllowed) {
configData.value.price_range_from = minAllowed
showToast(`最低金额不能低于成本价 ${minAllowed}`)
rangeError.value = true
closeRangeError()
configData.value.price_range_to = Number.parseFloat(
(
Number(configData.value.price_range_from)
+ Number(priceIncreaseMax.value)
).toFixed(2),
)
return
}
if (configData.value.price_range_to < configData.value.price_range_from) {
showToast('最高金额不能低于最低金额')
if (
configData.value.price_range_from + priceIncreaseMax.value
> maxAllowed
) {
configData.value.price_range_to = maxAllowed
}
else {
configData.value.price_range_to
= configData.value.price_range_from + priceIncreaseMax.value
}
rangeError.value = true
closeRangeError()
return
}
const diff = Number.parseFloat(
(
configData.value.price_range_to - configData.value.price_range_from
).toFixed(2),
)
if (diff > priceIncreaseMax.value) {
showToast(`价格区间最大差值为${priceIncreaseMax.value}`)
configData.value.price_range_to
= configData.value.price_range_from + priceIncreaseMax.value
closeRangeError()
return
}
if (configData.value.price_range_to > maxAllowed) {
configData.value.price_range_to = maxAllowed
showToast(`最高金额不能超过 ${maxAllowed}`)
closeRangeError()
}
if (!rangeError.value) {
rangeError.value = false
}
}
// 收取比例验证(修改为保留两位小数,不再四舍五入取整)
function validateRatio() {
const value = configData.value.price_ratio
if (value === null || value === undefined)
return
const numValue = Number(value)
if (isNaN(numValue)) {
configData.value.price_ratio = null
ratioError.value = true
return
}
if (numValue > priceRatioMax.value) {
configData.value.price_ratio = priceRatioMax.value
showToast(`收取比例最大为${priceRatioMax.value}%`)
ratioError.value = true
setTimeout(() => {
ratioError.value = false
}, 1000)
}
else if (numValue < 0) {
configData.value.price_ratio = 0
ratioError.value = true
}
else {
configData.value.price_ratio = Number.parseFloat(numValue.toFixed(2))
ratioError.value = false
}
}
// 获取配置
async function getConfig() {
try {
const { data, error } = await useApiFetch(
`/agent/membership/user_config?product_id=${selectedReportId.value}`,
)
.get()
.json()
if (data.value?.code === 200) {
const respConfigData = data.value.data.agent_membership_user_config
configData.value = {
id: respConfigData.product_id,
price_range_from: respConfigData.price_range_from || null,
price_range_to: respConfigData.price_range_to || null,
price_ratio: respConfigData.price_ratio * 100 || null, // 转换为百分比
price_increase_amount:
respConfigData.price_increase_amount || null,
}
console.log('configData', configData.value)
// const respProductConfigData = data.value.data.product_config
productConfigData.value = data.value.data.product_config
// 设置动态限制值
priceIncreaseMax.value = data.value.data.price_increase_max
priceIncreaseAmountMax.value
= data.value.data.price_increase_amount
priceRatioMax.value = data.value.data.price_ratio * 100
}
}
catch (error) {
showToast('配置加载失败')
}
}
// 提交处理
async function handleSubmit() {
try {
if (!finalValidation()) {
return
}
// 前端数据转换
const submitData = {
product_id: configData.value.id,
price_range_from: configData.value.price_range_from || 0,
price_range_to: configData.value.price_range_to || 0,
price_ratio: (configData.value.price_ratio || 0) / 100, // 转换为小数
price_increase_amount: configData.value.price_increase_amount || 0,
}
console.log('submitData', submitData)
const { data, error } = await useApiFetch(
'/agent/membership/save_user_config',
)
.post(submitData)
.json()
if (data.value?.code === 200) {
setTimeout(() => {
showToast('保存成功')
}, 500)
getConfig()
}
}
catch (error) {
showToast('保存失败,请稍后重试')
}
}
// 最终验证函数
function finalValidation() {
// 校验最低金额不能为空且大于0
if (
!configData.value.price_range_from
|| configData.value.price_range_from <= 0
) {
showToast('最低金额不能为空')
return false
}
// 校验最高金额不能为空且大于0
if (
!configData.value.price_range_to
|| configData.value.price_range_to <= 0
) {
showToast('最高金额不能为空')
return false
}
// 校验收取比例不能为空且大于0
if (!configData.value.price_ratio || configData.value.price_ratio <= 0) {
showToast('收取比例不能为空')
return false
}
// 验证最低金额必须小于最高金额
if (configData.value.price_range_from >= configData.value.price_range_to) {
showToast('最低金额必须小于最高金额')
return false
}
// 验证价格区间差值不能超过最大允许差值
const finalDiff = Number.parseFloat(
(
configData.value.price_range_to - configData.value.price_range_from
).toFixed(2),
)
if (finalDiff > priceIncreaseMax.value) {
showToast(`价格区间最大差值为${priceIncreaseMax.value}`)
return false
}
// 验证最高金额不能超过产品配置中设定的上限
if (
configData.value.price_range_to
> productConfigData.value.price_range_max
) {
showToast(
`最高金额不能超过${productConfigData.value.price_range_max}`,
)
return false
}
// 验证最低金额不能低于成本价+加价金额(加价金额允许为空)
const additional = configData.value.price_increase_amount || 0
if (
configData.value.price_range_from
< productConfigData.value.cost_price + additional
) {
showToast(
`最低金额不能低于成本价${productConfigData.value.cost_price + additional
}`,
)
return false
}
return true
}
// 选择器确认
function onSelectReport(option) {
selectedReport.value = option
selectedReportText.value = option.text
selectedReportId.value = option.id
showPicker.value = false
// 重置错误状态
rangeError.value = false
ratioError.value = false
increaseError.value = false
getConfig()
}
function closeRangeError() {
setTimeout(() => {
rangeError.value = false
}, 2000)
}
onMounted(() => {
getConfig()
})
</script>
<template>
<div class="mx-auto max-w-3xl min-h-screen p-4">
<!-- 标题部分 -->
<div class="card mb-4 rounded-lg from-blue-500 to-blue-600 bg-gradient-to-r p-4 text-white shadow-lg">
<h1 class="mb-2 text-2xl font-extrabold">
专业报告定价配置
</h1>
<p class="opacity-90">
请选择报告类型并设置定价策略助您实现精准定价
</p>
</div>
<div class="mb-4">
<view class="card selector" @click="showPicker = true">
<view class="selector-label">
📝 选择报告
</view>
<view class="selector-value">
{{ selectedReportText }}
</view>
</view>
<wd-popup v-model="showPicker" position="bottom" custom-style="padding: 16px;">
<view class="popup-title">
选择报告类型
</view>
<view class="report-list">
<view
v-for="item in reportOptions"
:key="item.value"
class="report-item"
:class="{ active: selectedReportId === item.id }"
@click="onSelectReport(item)"
>
{{ item.text }}
</view>
</view>
</wd-popup>
</div>
<div v-if="selectedReportText" class="space-y-6">
<!-- 配置卡片 -->
<div class="card">
<!-- 当前报告标题 -->
<div class="mb-6 flex items-center">
<h2 class="text-xl text-gray-800 font-semibold">
{{ selectedReportText }}配置
</h2>
</div>
<!-- 显示当前产品的基础成本信息 -->
<div
v-if="productConfigData && productConfigData.cost_price"
class="mb-4 border border-gray-200 rounded-lg bg-gray-50 px-4 py-2 shadow-sm"
>
<div class="text-lg text-gray-700 font-semibold">
报告基础配置信息
</div>
<div class="mt-1 text-sm text-gray-600">
<div>
基础成本价<span class="font-medium">{{
productConfigData.cost_price
}}</span>
</div>
<!-- <div>区间起始价<span class="font-medium">{{ productConfigData.price_range_min }}</span> </div> -->
<div>
最高设定金额上限<span class="font-medium">{{
productConfigData.price_range_max
}}</span>
</div>
<div>
最高设定比例上限<span class="font-medium">{{
priceRatioMax
}}</span>
%
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider my-6">
成本策略配置
</div>
<!-- 加价金额 -->
<view class="custom-field" :class="{ 'field-error': increaseError }">
<text class="field-label">
🚀 加价金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_increase_amount"
type="number"
placeholder="0"
class="field-input"
@blur="validateDecimal('price_increase_amount')"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最大加价金额为{{ priceIncreaseAmountMax }}<br>
说明加价金额是在基础成本价上增加的额外费用决定下级报告的最低定价您将获得所有输入的金额利润
</div>
<!-- 分隔线 -->
<div class="section-divider my-6">
定价策略配置
</div>
<!-- 定价区间最低 -->
<view class="custom-field" :class="{ 'field-error': rangeError }">
<text class="field-label">
💰 最低金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_range_from"
type="number"
placeholder="0"
class="field-input"
@blur="
() => {
validateDecimal('price_range_from')
validateRange()
}
"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最低金额不能低于基础最低
{{ productConfigData?.price_range_min || 0 }} +
加价金额<br>
说明设定的最低金额为定价区间的起始值若下级设定的报告金额在区间内则区间内部分将按比例获得收益
</div>
<!-- 定价区间最高 -->
<view class="custom-field" :class="{ 'field-error': rangeError }">
<text class="field-label">
💰 最高金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_range_to"
type="number"
placeholder="0"
class="field-input"
@blur="
() => {
validateDecimal('price_range_to')
validateRange()
}
"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最高金额不能超过上限{{
productConfigData?.price_range_max || 0
}}和大于最低金额{{ priceIncreaseMax }}<br>
说明设定的最高金额为定价区间的结束值若下级设定的报告金额在区间内则区间内部分将按比例获得收益
</div>
<!-- 收取比例 -->
<view class="custom-field" :class="{ 'field-error': ratioError }">
<text class="field-label">
📈 收取比例
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_ratio"
type="number"
placeholder="0"
class="field-input"
@blur="validateRatio()"
>
<text class="field-unit">
%
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最大收取比例为{{ priceRatioMax }}%<br>
说明收取比例表示对定价区间内即报告金额超过最低金额小于最高金额的部分的金额按此比例进行利润分成
</div>
</div>
<!-- 保存按钮 -->
<wd-button
type="primary" block
class="h-12 rounded-xl from-blue-500 to-blue-600 bg-gradient-to-r text-white shadow-lg hover:from-blue-600 hover:to-blue-700"
@click="handleSubmit"
>
保存当前报告配置
</wd-button>
</div>
<!-- 未选择提示 -->
<div v-else class="py-12 text-center">
<text class="mb-4 block text-4xl text-gray-400">
</text>
<p class="text-gray-500">
请先选择需要配置的报告类型
</p>
</div>
</div>
</template>
<style scoped>
.selector {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.selector-label {
color: #2563eb;
font-weight: 600;
}
.selector-value {
color: #4b5563;
}
.popup-title {
font-weight: 600;
margin-bottom: 12px;
}
.report-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.report-item {
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-align: center;
color: #374151;
}
.report-item.active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.section-divider {
text-align: center;
color: #9ca3af;
font-size: 12px;
position: relative;
}
.section-divider::before,
.section-divider::after {
content: '';
position: absolute;
top: 50%;
width: 30%;
height: 1px;
background: #e5e7eb;
}
.section-divider::before {
left: 0;
}
.section-divider::after {
right: 0;
}
.custom-field {
margin-bottom: 12px;
}
.field-label {
display: block;
margin-bottom: 8px;
color: #4b5563;
font-weight: 500;
}
.field-input-wrap {
display: flex;
align-items: center;
background: #f9fafb;
border-radius: 8px;
padding: 8px 12px;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.field-input {
flex: 1;
border: none;
outline: none;
background: transparent;
}
.field-unit {
margin-left: 8px;
color: #3b82f6;
font-weight: 500;
}
.custom-field:focus-within .field-input-wrap {
border-color: #bfdbfe;
}
.field-error .field-input-wrap {
border-color: #fca5a5;
background: #fef2f2;
}
</style>

36
src/pages/agent-vip.vue Normal file
View File

@@ -0,0 +1,36 @@
<script setup>
import { openCustomerService } from '@/composables/useCustomerService'
definePage({ layout: 'default', auth: true })
function toVipApply() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
function toService() {
openCustomerService()
}
</script>
<template>
<view class="relative">
<image class="block w-full" src="/static/images/vip_bg.png" mode="widthFix" />
<view
class="absolute bottom-80 left-[50%] flex flex-col translate-x-[-50%] items-center gap-4"
>
<view
class="rounded-lg from-amber-500 to-amber-600 bg-gradient-to-r px-6 py-2 text-[24px] text-white font-bold shadow-[0_0_15px_rgba(255,255,255,0.3)] transition-transform active:scale-105"
@click="toVipApply"
>
<text>申请VIP代理</text>
</view>
<view
class="rounded-lg from-gray-900 via-black to-gray-900 bg-gradient-to-r px-4 py-2 text-[20px] text-white font-bold shadow-[0_0_15px_rgba(255,255,255,0.3)] transition-transform active:scale-105"
@click="toService"
>
<text>联系客服</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped></style>

232
src/pages/agent.vue Normal file
View File

@@ -0,0 +1,232 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
definePage({ layout: 'home' })
const agentStore = useAgentStore()
const { isAgent } = storeToRefs(agentStore)
const data = ref(null)
const dateRangeMap = {
today: 'today',
week: 'last7d',
month: 'last30d',
}
const dateTextMap = {
today: '今日',
week: '近7天',
month: '近1月',
}
const promoteDateOptions = [
{ label: '今日', value: 'today' },
{ label: '近7天', value: 'week' },
{ label: '近1月', value: 'month' },
]
const selectedPromoteDate = ref('today')
const teamDateOptions = [
{ label: '今日', value: 'today' },
{ label: '近7天', value: 'week' },
{ label: '近1月', value: 'month' },
]
const selectedTeamDate = ref('today')
const currentPromoteData = computed(() => {
const range = dateRangeMap[selectedPromoteDate.value]
return data.value?.direct_push?.[range] || { commission: 0, report: 0 }
})
const currentTeamData = computed(() => {
const range = dateRangeMap[selectedTeamDate.value]
return data.value?.active_reward?.[range] || {
sub_promote_reward: 0,
sub_upgrade_reward: 0,
sub_withdraw_reward: 0,
}
})
const promoteTimeText = computed(() => dateTextMap[selectedPromoteDate.value] || '今日')
const teamTimeText = computed(() => dateTextMap[selectedTeamDate.value] || '今日')
async function getData() {
try {
const { data: res, error } = await useApiFetch('/agent/revenue').get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
catch {
uni.showToast({ title: '网络错误', icon: 'none' })
}
}
onShow(() => {
if (isAgent.value) {
getData()
}
})
function goToPromoteDetail() {
uni.navigateTo({ url: '/pages/agent-promote-details' })
}
function goToRewardsDetail() {
uni.navigateTo({ url: '/pages/agent-rewards-details' })
}
function toWithdraw() {
uni.navigateTo({ url: '/pages/withdraw' })
}
function toWithdrawDetails() {
uni.navigateTo({ url: '/pages/withdraw-details' })
}
function toSubordinateList() {
uni.navigateTo({ url: '/pages/subordinate-list' })
}
</script>
<template>
<view class="safe-area-top min-h-screen p-4">
<view class="mb-4 rounded-xl from-blue-50/70 to-blue-100/50 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-3 flex items-center justify-between">
<text class="text-lg text-gray-800 font-bold">
余额
</text>
<text class="text-3xl text-blue-600 font-bold">
¥ {{ (data?.balance || 0).toFixed(2) }}
</text>
</view>
<view class="mb-2 text-sm text-gray-500">
累计收益¥ {{ (data?.total_earnings || 0).toFixed(2) }}
</view>
<view class="mb-1 text-sm text-gray-500">
待结账金额¥ {{ (data?.frozen_balance || 0).toFixed(2) }}
</view>
<view class="mb-6 text-xs text-gray-400">
待结账金额将在订单创建24小时后自动结账
</view>
<view class="grid grid-cols-2 gap-3">
<wd-button type="primary" block @click="toWithdraw">
提现
</wd-button>
<wd-button plain block @click="toWithdrawDetails">
提现记录
</wd-button>
</view>
</view>
<view class="mb-4 rounded-xl from-blue-50/40 to-cyan-50/50 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-4 flex items-center justify-between">
<text class="text-lg text-gray-800 font-bold">
直推报告收益
</text>
<view class="text-right">
<text class="text-2xl text-blue-600 font-bold">
¥ {{ (data?.direct_push?.total_commission || 0).toFixed(2) }}
</text>
<view class="mt-1 text-sm text-gray-500">
有效报告 {{ data?.direct_push?.total_report || 0 }}
</view>
</view>
</view>
<view class="grid grid-cols-3 mb-6 gap-2">
<view
v-for="item in promoteDateOptions"
:key="item.value"
class="rounded-full px-4 py-1 text-center text-sm transition-all"
:class="selectedPromoteDate === item.value ? 'bg-blue-500 text-white shadow-md' : 'border border-gray-200/50 bg-white/90 text-gray-600'"
@click="selectedPromoteDate = item.value"
>
{{ item.label }}
</view>
</view>
<view class="grid grid-cols-2 mb-6 gap-4">
<view class="rounded-lg bg-blue-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ promoteTimeText }}收益
</view>
<text class="mt-1 text-xl text-blue-600 font-bold">
¥ {{ currentPromoteData.commission?.toFixed(2) || '0.00' }}
</text>
</view>
<view class="rounded-lg bg-blue-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
有效报告
</view>
<text class="mt-1 text-xl text-blue-600 font-bold">
{{ currentPromoteData.report || 0 }}
</text>
</view>
</view>
<view class="flex items-center justify-between text-sm text-blue-500 font-semibold" @click="goToPromoteDetail">
<text>查看收益明细</text>
<text class="text-lg">
</text>
</view>
</view>
<view class="rounded-xl from-green-50/40 to-cyan-50/30 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-4">
<text class="text-lg text-gray-800 font-bold">
团队奖励
</text>
</view>
<view class="grid grid-cols-3 mb-6 gap-2">
<view
v-for="item in teamDateOptions"
:key="item.value"
class="rounded-full px-4 py-1 text-center text-sm transition-all"
:class="selectedTeamDate === item.value ? 'bg-green-500 text-white shadow-md' : 'border border-gray-200/50 bg-white/90 text-gray-600'"
@click="selectedTeamDate = item.value"
>
{{ item.label }}
</view>
</view>
<view class="grid grid-cols-1 mb-6 gap-2">
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级推广奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_promote_reward || 0).toFixed(2) }}
</text>
</view>
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级转化奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_upgrade_reward || 0).toFixed(2) }}
</text>
</view>
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级提现奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_withdraw_reward || 0).toFixed(2) }}
</text>
</view>
</view>
<view class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<wd-button plain block @click="goToRewardsDetail">
团队奖励明细
</wd-button>
<wd-button type="success" block @click="toSubordinateList">
查看我的下级
</wd-button>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
const q = query || {}
src.value = buildSitePathUrl('/app/authorization', {
id: String(q.id || ''),
})
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,472 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import AccountCancelAgreement from '@/components/AccountCancelAgreement.vue'
import useApiFetch from '@/composables/useApiFetch'
import { useAgentStore } from '@/stores/agentStore'
import { useUserStore } from '@/stores/userStore'
import { clearAuthStorage } from '@/utils/storage'
definePage({ layout: false, auth: true })
const userStore = useUserStore()
const agentStore = useAgentStore()
const { level } = storeToRefs(agentStore)
const revenueData = ref(null)
const loading = ref(true)
const showBalancePopup = ref(false)
const showSmsPopup = ref(false)
const cancelAccountCode = ref('')
const verificationCodeInputRef = ref(null)
/** 与 login.vue / LoginDialog.vue 一致的倒计时逻辑 */
const isCountingDown = ref(false)
const countdown = ref(60)
let smsTimer = null
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
function maskName(name) {
if (!name || name.length < 11)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(String(userStore.mobile || ''))
})
const isAgent = computed(() => agentStore.isAgent)
const hasWalletBalance = computed(() => {
const b = Number(revenueData.value?.balance ?? 0)
const f = Number(revenueData.value?.frozen_balance ?? 0)
return b > 0 || f > 0
})
const showBalanceWarning = computed(() => isAgent.value && hasWalletBalance.value)
const showVipLevelReminder = computed(() =>
isAgent.value && (level.value === 'VIP' || level.value === 'SVIP'),
)
const vipLevelLabel = computed(() => (level.value === 'SVIP' ? 'SVIP' : 'VIP'))
const showAnyReminder = computed(() => showBalanceWarning.value || showVipLevelReminder.value)
const canSubmitCancel = computed(() => cancelAccountCode.value.length === 6)
onLoad(async () => {
await userStore.fetchUserInfo()
if (!userStore.mobile) {
loading.value = false
toast('请先绑定手机号')
setTimeout(() => uni.navigateBack(), 1500)
return
}
try {
await agentStore.fetchAgentStatus()
if (agentStore.isAgent) {
const { data, error } = await useApiFetch('/agent/revenue').get().json()
if (!error.value && data.value?.code === 200)
revenueData.value = data.value.data
}
}
catch {
/* ignore */
}
finally {
loading.value = false
}
})
function onExit() {
uni.navigateBack()
}
function startCountdown() {
if (smsTimer) {
clearInterval(smsTimer)
}
isCountingDown.value = true
countdown.value = 60
smsTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(smsTimer)
smsTimer = null
isCountingDown.value = false
}
}, 1000)
}
function resetCountdown() {
if (smsTimer) {
clearInterval(smsTimer)
smsTimer = null
}
isCountingDown.value = false
countdown.value = 60
}
onUnmounted(() => {
resetCountdown()
})
function openSmsPopup() {
cancelAccountCode.value = ''
resetCountdown()
showSmsPopup.value = true
}
function closeSmsPopup() {
showSmsPopup.value = false
cancelAccountCode.value = ''
resetCountdown()
}
function onConfirmTap() {
if (showBalanceWarning.value) {
showBalancePopup.value = true
return
}
openSmsPopup()
}
function onBalanceContinue() {
showBalancePopup.value = false
openSmsPopup()
}
function onBalanceCancel() {
showBalancePopup.value = false
}
/** 与登录页 sendVerificationCode 一致:节流、校验手机号、成功后倒计时并聚焦验证码框 */
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!userStore.mobile) {
toast('请先绑定手机号')
return
}
try {
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: userStore.mobile, actionType: 'cancelAccount', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
toast('获取成功')
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
toast(data.value?.msg || '发送失败')
}
}
catch {
toast('发送失败')
}
}
function clearAuthAndGoHome() {
clearAuthStorage()
userStore.resetUser()
agentStore.resetAgent()
uni.reLaunch({ url: '/pages/index' })
}
async function submitCancelAccount() {
if (!canSubmitCancel.value) {
toast('请输入6位验证码')
return
}
const { data, error } = await useApiFetch('/user/cancelOut')
.post({ code: cancelAccountCode.value })
.json()
if (!error.value && data.value?.code === 200) {
uni.showToast({ title: '账号已注销', icon: 'success' })
closeSmsPopup()
clearAuthAndGoHome()
}
else {
toast(data.value?.msg || '注销失败')
}
}
</script>
<template>
<view class="page-cancel-root box-border flex flex-col bg-gray-50">
<view v-if="loading" class="flex flex-1 items-center justify-center py-20 text-gray-500">
加载中...
</view>
<view v-else class="min-h-0 flex flex-1 flex-col">
<view class="min-h-0 flex flex-1 flex-col">
<scroll-view
scroll-y
:show-scrollbar="true"
class="box-border min-h-0 flex-1 bg-white"
style="flex: 1; height: 0; width: 100%;"
>
<AccountCancelAgreement />
</scroll-view>
<view
v-if="showAnyReminder"
class="flex-shrink-0 border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-2"
>
<view
v-if="showBalanceWarning"
class="border border-amber-200 rounded-lg bg-amber-50 p-3 text-sm text-amber-900"
>
<text class="font-medium">
钱包提示
</text>
<text class="mt-1 block leading-relaxed">
检测到您为代理且账户仍有余额¥{{ (revenueData?.balance ?? 0).toFixed(2) }}或待结账金额¥{{ (revenueData?.frozen_balance ?? 0).toFixed(2) }}注销后将无法通过本账号提现请确认已了解风险
</text>
</view>
<view
v-if="showVipLevelReminder"
class="border border-violet-200 rounded-lg bg-violet-50 p-3 text-sm text-violet-900"
>
<text class="font-medium">
会员提示
</text>
<text class="mt-1 block leading-relaxed">
您当前为 {{ vipLevelLabel }} 代理会员注销后该账号下的代理身份与相关权益将按平台规则终止请确认已了解风险
</text>
</view>
</view>
</view>
<view class="cancel-footer flex-shrink-0 border-t border-gray-200 bg-white px-4 pt-3">
<view class="footer-btns flex gap-3">
<wd-button
class="footer-btn"
hairline
plain
block
type="info"
@click="onExit"
>
退出
</wd-button>
<wd-button
class="footer-btn"
type="error"
block
@click="onConfirmTap"
>
确认注销
</wd-button>
</view>
</view>
</view>
<!-- 有余额时的二次确认Wot -->
<wd-popup
v-model="showBalancePopup"
position="center"
round
:close-on-click-modal="true"
custom-style="width: 86%; max-width: 360px;"
@close="onBalanceCancel"
>
<view class="balance-popup p-5">
<view class="mb-2 text-center text-base text-gray-900 font-semibold">
确认注销
</view>
<view class="text-center text-sm text-gray-600 leading-relaxed">
您的代理账户仍有余额或待结账金额注销后将无法通过本账号提现确定继续注销
</view>
<view class="mt-5 flex gap-3">
<wd-button plain hairline type="info" block class="flex-1" @click="onBalanceCancel">
取消
</wd-button>
<wd-button type="error" block class="flex-1" @click="onBalanceContinue">
继续
</wd-button>
</view>
</view>
</wd-popup>
<!-- 短信验证与登录页表单风格一致 -->
<wd-popup
v-model="showSmsPopup"
position="bottom"
round
:safe-area-inset-bottom="true"
:style="{ maxHeight: '85vh' }"
:z-index="2000"
@close="closeSmsPopup"
>
<view class="sms-popup">
<view class="sms-popup-title">
<text class="sms-popup-title-text">
验证手机号
</text>
<wd-icon name="close" class="sms-popup-close" @click="closeSmsPopup" />
</view>
<view class="sms-popup-body">
<text class="sms-popup-desc">
将向 {{ maskName(userStore.mobile) }} 发送验证码请输入短信中的 6 位数字完成注销
</text>
<view class="sms-form-item">
<text class="sms-form-label">
验证码
</text>
<view class="sms-verification-wrap">
<wd-input
ref="verificationCodeInputRef"
v-model="cancelAccountCode"
class="sms-verification-input"
type="number"
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="sms-popup-actions mt-6 flex gap-3">
<wd-button plain hairline type="info" block class="flex-1" @click="closeSmsPopup">
取消
</wd-button>
<wd-button
type="error"
block
class="flex-1"
:disabled="!canSubmitCancel"
@click="submitCancelAccount"
>
确认注销
</wd-button>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.page-cancel-root {
height: 100%;
}
.cancel-footer {
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
/* 按钮区与屏幕底边留白,并叠加安全区 */
padding-bottom: calc(16px + constant(safe-area-inset-bottom));
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.footer-btn {
flex: 1;
min-width: 0;
}
.balance-popup {
box-sizing: border-box;
}
.sms-popup {
display: flex;
max-height: 85vh;
flex-direction: column;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 28%);
}
.sms-popup-title {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f1f5f9;
padding: 14px 16px;
}
.sms-popup-title-text {
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.sms-popup-close {
padding: 4px;
font-size: 22px;
color: #64748b;
}
.sms-popup-body {
overflow-y: auto;
padding: 16px 16px 24px;
padding-bottom: calc(24px + constant(safe-area-inset-bottom));
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.sms-popup-desc {
display: block;
margin-bottom: 1.25rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #64748b;
}
.sms-form-item {
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.sms-form-label {
flex-shrink: 0;
margin-right: 1rem;
min-width: 4rem;
font-size: 0.9375rem;
font-weight: 500;
color: #111827;
}
.sms-verification-wrap {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
}
.sms-verification-input {
width: 100%;
}
.sms-popup-actions {
padding-top: 0.25rem;
}
</style>
<style>
page {
height: 100%;
}
</style>

91
src/pages/help-detail.vue Normal file
View File

@@ -0,0 +1,91 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const currentHelp = ref({
title: '',
image: '',
images: null,
})
// 图片路径映射
const imageMap = {
report_calculation: '/image/help/report-calculation.jpg',
report_efficiency: '/image/help/report-efficiency.jpg',
report_cost: '/image/help/report-cost.jpg',
report_types: '/image/help/report-types.jpg',
report_push: '/image/help/report-push.jpg',
report_secret: ['/image/help/report-secret-1.jpg', '/image/help/report-secret-2.jpg'],
invite_earnings: '/image/help/invite-earnings.jpg',
direct_earnings: '/image/help/direct-earnings.jpg',
vip_guide: '/image/help/vip-guide.jpg',
}
// 标题映射
const titleMap = {
report_calculation: '推广报告的收益是如何计算的?',
report_efficiency: '报告推广效率飙升指南',
report_cost: '推广报告的成本是如何计算的?',
report_types: '赤眉有哪些大数据报告类型',
report_push: '如何推广报告',
report_secret: '报告推广秘籍大公开',
invite_earnings: '如何邀请下级成为代理',
direct_earnings: '如何成为赤眉代理',
vip_guide: '如何成为VIP代理和SVIP代理?',
}
onMounted(() => {
const id = route.query.id
if (id && titleMap[id]) {
currentHelp.value = {
title: titleMap[id],
image: Array.isArray(imageMap[id]) ? null : imageMap[id],
images: Array.isArray(imageMap[id]) ? imageMap[id] : null,
}
}
})
</script>
<template>
<view class="help-detail">
<view class="help-detail-title">{{ currentHelp.title }}</view>
<template v-if="Array.isArray(currentHelp.images)">
<image
v-for="(image, index) in currentHelp.images"
:key="index"
:src="image"
class="help-image"
mode="widthFix"
/>
</template>
<image
v-else-if="currentHelp.image"
:src="currentHelp.image"
class="help-image"
mode="widthFix"
/>
</view>
</template>
<style lang="scss" scoped>
.help-detail {
min-height: 100vh;
padding: 20px;
background-color: #fff;
.help-detail-title {
margin: 0 0 20px;
font-size: 22px;
color: #323233;
font-weight: 500;
}
.help-image {
width: 100%;
border-radius: 8px;
margin-bottom: 12px;
}
}
</style>

105
src/pages/help-guide.vue Normal file
View File

@@ -0,0 +1,105 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const router = useRouter()
const currentStepIndex = ref(0)
// 引导步骤数据
const guideSteps = {
report_guide: [
{
title: '第一步:进入直推报告页面',
image: '/image/help/report-step1.jpg',
},
{
title: '第二步:选择报告类型',
image: '/image/help/report-step2.jpg',
},
{
title: '第三步:填写推广信息',
image: '/image/help/report-step3.jpg',
},
{
title: '第四步:完成推广',
image: '/image/help/report-step4.jpg',
},
],
invite_guide: [
{
title: '第一步:进入邀请页面',
image: '/image/help/invite-step1.jpg',
},
{
title: '第二步:获取邀请码',
image: '/image/help/invite-step2.jpg',
},
{
title: '第三步:分享邀请链接',
image: '/image/help/invite-step3.jpg',
},
],
}
const currentGuide = computed(() => {
const id = route.query.id
return guideSteps[id] || []
})
const totalSteps = computed(() => currentGuide.value.length)
const currentStep = computed(() => currentGuide.value[currentStepIndex.value])
function handleImageClick() {
if (currentStepIndex.value < totalSteps.value - 1) {
currentStepIndex.value++
}
else {
// 最后一步,返回列表页
router.back()
}
}
function onClickLeft() {
router.back()
}
onMounted(() => {
const id = route.query.id
if (!guideSteps[id]) {
router.back()
}
})
</script>
<template>
<view class="help-guide">
<view class="guide-content" @click="handleImageClick">
<image :src="currentStep.image" class="guide-image" mode="widthFix" />
</view>
</view>
</template>
<style lang="scss" scoped>
.help-guide {
background-color: #666666;
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
.guide-content {
flex: 1;
height: calc(100vh - 46px);
overflow: hidden;
position: relative;
.guide-image {
width: 100%;
object-fit: contain;
display: block;
}
}
}
</style>

161
src/pages/help.vue Normal file
View File

@@ -0,0 +1,161 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from '@/composables/uni-router'
definePage({ layout: 'default' })
const router = useRouter()
const activeTab = ref('report')
const categories = [
{
title: '推广报告',
name: 'report',
items: [
{ id: 'report_guide', title: '直推报告页面引导', type: 'guide' },
{ id: 'invite_guide', title: '邀请下级页面引导', type: 'guide' },
{ id: 'direct_earnings', title: '如何成为赤眉代理' },
{ id: 'report_push', title: '如何推广报告' },
{ id: 'report_calculation', title: '推广报告的收益是如何计算的?' },
{ id: 'report_cost', title: '推广报告的成本是如何计算的?' },
{ id: 'report_efficiency', title: '报告推广效率飙升指南' },
{ id: 'report_secret', title: '报告推广秘籍大公开' },
{ id: 'report_types', title: '赤眉有哪些大数据报告类型' },
],
},
{
title: '邀请下级',
name: 'invite',
items: [
{ id: 'invite_earnings', title: '邀请下级赚取收益' },
],
},
{
title: '其他',
name: 'other',
items: [
{ id: 'vip_guide', title: '如何成为VIP代理和SVIP代理?' },
],
},
]
const currentCategory = computed(() => {
return categories.find(item => item.name === activeTab.value) || categories[0]
})
function goToDetail(id, type) {
if (type === 'guide') {
router.push({
path: '/help/guide',
query: { id },
})
}
else {
router.push({
path: '/help/detail',
query: { id },
})
}
}
</script>
<template>
<div class="help-center">
<view class="tab-bar">
<view
v-for="(category, index) in categories"
:key="index"
class="tab-item"
:class="{ active: activeTab === category.name }"
@click="activeTab = category.name"
>
{{ category.title }}
</view>
</view>
<wd-cell-group border class="help-list">
<wd-cell
v-for="item in currentCategory.items"
:key="item.id"
:title="item.title"
clickable
class="help-item"
@click="goToDetail(item.id, item.type)"
>
<template v-if="item.type === 'guide'" #label>
<text class="guide-tag">
引导指南
</text>
</template>
</wd-cell>
</wd-cell-group>
</div>
</template>
<style lang="scss" scoped>
.help-center {
min-height: 100vh;
background-color: #f7f8fa;
padding: 12px;
.tab-bar {
display: flex;
background: #fff;
border-radius: 10px;
padding: 4px;
gap: 6px;
}
.tab-item {
flex: 1;
text-align: center;
padding: 8px 0;
border-radius: 8px;
color: #666;
font-size: 14px;
}
.tab-item.active {
background: #2563eb;
color: #fff;
font-weight: 600;
}
.help-list {
margin-top: 12px;
border-radius: 12px;
overflow: hidden;
.guide-tag {
margin-top: 4px;
display: inline-flex;
padding: 2px 8px;
font-size: 12px;
border-radius: 10px;
color: #fff;
background-color: #2563eb;
}
}
}
.help-detail {
padding: 20px;
&-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
}
}
&-content {
white-space: pre-line;
line-height: 1.6;
color: #666;
}
}
</style>

140
src/pages/history-query.vue Normal file
View File

@@ -0,0 +1,140 @@
<script setup>
import { onMounted, ref } from 'vue'
definePage({ layout: 'default', auth: true })
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const reportList = ref([])
const loading = ref(false)
// 初始加载数据
async function fetchData() {
loading.value = true
const { data, error } = await useApiFetch(`query/list?page=${page.value}&page_size=${pageSize.value}`)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
total.value = data.value.data.total
reportList.value = data.value.data.list || []
}
}
loading.value = false
}
// 初始加载
onMounted(() => {
fetchData()
})
function onPageChange({ value }) {
page.value = value
fetchData()
}
function toDetail(item) {
if (item.query_state !== 'success')
return
uni.navigateTo({
url: `/pages/report-result-webview?order_id=${encodeURIComponent(String(item.order_id || ''))}`,
})
}
// 状态文字映射
function stateText(state) {
switch (state) {
case 'pending':
return '查询中'
case 'success':
return '查询成功'
case 'failed':
return '查询失败'
case 'refunded':
return '已退款'
default:
return '未知状态'
}
}
// 状态颜色映射
function statusClass(state) {
switch (state) {
case 'pending':
return 'status-pending'
case 'success':
return 'status-success'
case 'failed':
return 'status-failed'
case 'refunded':
return 'status-refunded'
default:
return ''
}
}
</script>
<template>
<view class="flex flex-col gap-4 p-4">
<view class="history-scroll">
<view
v-for="item in reportList" :key="item.id" class="relative mb-4 cursor-pointer rounded-lg bg-white p-4 shadow-sm"
@click="toDetail(item)"
>
<view class="flex flex-col">
<view class="mb-1 text-xl text-black">
{{ item.product_name }}
</view>
<view class="text-sm text-[#999999]">
{{ item.create_time }}
</view>
</view>
<view
class="absolute right-0 top-0 rounded-bl-lg rounded-tr-lg px-2 py-[1px] text-sm text-white font-medium"
:class="[statusClass(item.query_state)]"
>
{{ stateText(item.query_state) }}
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!reportList.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
.history-scroll {
height: calc(100vh - 120px);
}
.status-pending {
background-color: #1976d2;
color: white;
}
.status-success {
background-color: #1FBE5D;
color: white;
}
.status-failed {
background-color: #EB3C3C;
color: white;
}
.status-refunded {
background-color: #999999;
color: white;
}
</style>

179
src/pages/index.vue Normal file
View File

@@ -0,0 +1,179 @@
<script setup>
import bgIcon from '/static/images/bg_icon.png'
import bannerImg from '/static/images/index/banner_1.png'
import bannerImg2 from '/static/images/index/banner_2.png'
import bannerImg3 from '/static/images/index/banner_3.png'
import honestyBanner from '/static/images/index/banner_B.png'
import companyIcon from '/static/images/index/company_bg.png'
import housekeepingRiskIcon from '/static/images/index/housekeeping_risk_bg.png'
import loanCheckIcon from '/static/images/index/loan_check_bg.png'
import marriageRiskIcon from '/static/images/index/marriage_risk_bg.png'
import personalDataIcon from '/static/images/index/personal_data_bg.png'
import preLoanRiskIcon from '/static/images/index/preloan_risk_bg.png'
import rentalInfoBg from '/static/images/index/rentalinfo_bg.png'
import rightIcon from '/static/images/index/right.png'
import indexPromoteIcon from '/static/images/index/tgbg.png'
import indexMyReportIcon from '/static/images/index/wdbg.png'
import indexInvitationIcon from '/static/images/index/yqhy.png'
definePage({ type: 'home', layout: 'home' })
const banners = [bannerImg, bannerImg2, bannerImg3]
const services = [
{ title: '个人大数据', name: 'personalData', bg: personalDataIcon },
{ title: '婚恋风险', name: 'marriage', bg: marriageRiskIcon },
{ title: '入职背调', name: 'backgroundcheck', bg: preLoanRiskIcon },
]
const riskServices = [
{ title: '小微', name: 'companyinfo', bg: companyIcon },
{ title: '家政', name: 'homeservice', bg: housekeepingRiskIcon },
{ title: '贷前', name: 'preloanbackgroundcheck', bg: loanCheckIcon },
{ title: '诚信租赁', name: 'rentalinfo', bg: rentalInfoBg },
]
function toInquire(name) {
uni.navigateTo({
url: `/pages/inquire?feature=${encodeURIComponent(name)}`,
})
}
function toInvitation() {
uni.navigateTo({ url: '/pages/invitation' })
}
function toPromote() {
uni.navigateTo({ url: '/pages/promote' })
}
function toHistory() {
uni.navigateTo({ url: '/pages/history-query' })
}
</script>
<template>
<view class="box-border min-h-screen">
<view class="relative p-4">
<swiper
class="banner-swiper overflow-hidden rounded-xl" circular autoplay :interval="3000" indicator-dots
indicator-color="rgba(255,255,255,0.5)" indicator-active-color="#ffffff"
>
<swiper-item v-for="(item, index) in banners" :key="index">
<image :src="item" class="h-full w-full" mode="aspectFill" />
</swiper-item>
</swiper>
</view>
<view class="px-6">
<view class="grid grid-cols-3 gap-3">
<view class="flex flex-col items-center justify-center text-center" @click="toPromote">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexPromoteIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
推广报告
</text>
</view>
<view class="flex flex-col items-center justify-center text-center" @click="toInvitation">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexInvitationIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
邀请下级
</text>
</view>
<view class="flex flex-col items-center justify-center text-center" @click="toHistory">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexMyReportIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
我的报告
</text>
</view>
</view>
</view>
<view class="relative p-4 pt-0">
<view class="grid grid-cols-2 my-4 gap-4" style="grid-template-rows: repeat(2, 1fr);">
<view
v-for="(service, index) in services" :key="index"
class="relative min-h-18 flex flex-col rounded-xl px-4 py-2 shadow-lg"
:class="index === 0 ? 'row-span-2' : ''"
:style="`background: url(${service.bg}) no-repeat; background-size: 100% 100%; background-position: center;`"
@click="toInquire(service.name)"
>
<view class="min-h-18 flex items-end">
<!-- <text class="text-base text-gray-700 font-semibold">
{{ service.title }}
</text> -->
</view>
</view>
</view>
<scroll-view scroll-x class="risk-scroll my-4 px-1 pb-4 pt-2 -mx-1">
<view class="inline-flex gap-2">
<view
v-for="(service, index) in riskServices" :key="index"
class="relative h-24 w-[107px] flex-shrink-0 rounded-xl shadow-lg"
:style="`background: url(${service.bg}) no-repeat; background-size: 100% 100%; background-position: center;`"
@click="toInquire(service.name)"
>
<view class="h-full flex items-end px-2 py-2">
<!-- <text class="text-sm text-gray-700 font-semibold">
{{ service.title }}
</text> -->
</view>
</view>
</view>
</scroll-view>
<view class="mb-3 mt-6 flex items-center">
<view class="bg-primary h-5 w-1.5 rounded-xl" />
<text class="ml-2 text-lg text-gray-800">
诚信专栏
</text>
</view>
<view class="mt-4 overflow-hidden rounded-xl bg-white shadow-xl">
<image :src="honestyBanner" class="block w-full" mode="widthFix" />
</view>
<view
class="mt-4 box-border h-14 w-full flex items-center rounded-lg bg-white px-4 text-gray-700 shadow-lg"
@click="toHistory"
>
<view class="mr-4 h-full flex items-center justify-center">
<image class="h-10 w-10" :src="bgIcon" mode="aspectFit" />
</view>
<view class="flex-1">
<view class="text-gray-800">
我的历史查询记录
</view>
<view class="text-xs text-gray-500">
查询记录有效期为30天
</view>
</view>
<image :src="rightIcon" class="h-6 w-6" mode="aspectFit" />
</view>
</view>
</view>
</template>
<style scoped>
.banner-swiper {
height: 160px;
}
.risk-scroll {
white-space: nowrap;
}
</style>

54
src/pages/inquire.vue Normal file
View File

@@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue'
import InquireForm from '@/components/InquireForm.vue'
definePage({ layout: 'default' })
const feature = ref('')
const IMMERSIVE_NAVBAR_ROUTE = 'pages/inquire'
// 获取产品信息
const featureData = ref({})
onLoad(async (query) => {
const q = query || {}
feature.value = String(q.feature || '')
if (q.out_trade_no) {
uni.navigateTo({
url: `/pages/report-result-webview?out_trade_no=${encodeURIComponent(String(q.out_trade_no))}`,
})
return
}
await getProduct()
})
async function getProduct() {
if (!feature.value)
return
const { data } = await useApiFetch(`/product/en/${feature.value}`)
.get()
.json()
if (data.value) {
featureData.value = data.value.data
}
}
onPageScroll((event) => {
uni.$emit('immersive-navbar-change', {
route: IMMERSIVE_NAVBAR_ROUTE,
solid: event.scrollTop > 120,
})
})
onUnload(() => {
uni.$emit('immersive-navbar-change', {
route: IMMERSIVE_NAVBAR_ROUTE,
solid: false,
})
})
</script>
<template>
<InquireForm type="normal" :feature="feature" :feature-data="featureData" />
</template>

View File

@@ -0,0 +1,202 @@
<script setup>
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { aesDecrypt } from '@/utils/crypto'
import { getToken, setAuthSession } from '@/utils/storage'
definePage({ layout: 'default' })
let intervalId = null
const POLL_INTERVAL_MS = 2000
const showApplyPopup = ref(false)
const route = useRoute()
const store = useAgentStore()
const userStore = useUserStore()
const { isLoggedIn, mobile } = storeToRefs(userStore)
const { status } = storeToRefs(store) // 响应式解构
const ancestor = ref('')
const isSelf = ref(false)
const isApplyPolling = ref(false)
function stopApplyStatusPolling() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
isApplyPolling.value = false
}
function startApplyStatusPolling() {
if (intervalId || status.value === 1 || status.value === 2)
return
isApplyPolling.value = true
intervalId = setInterval(async () => {
await store.fetchAgentStatus()
if (status.value === 1 || status.value === 2)
stopApplyStatusPolling()
}, POLL_INTERVAL_MS)
}
async function syncStatusAndUpdatePolling() {
await store.fetchAgentStatus()
if (status.value === 0 || status.value === 3) {
startApplyStatusPolling()
}
else {
stopApplyStatusPolling()
}
}
function agentApply() {
showApplyPopup.value = true
}
// 计算显示状态当isSelf为false时强制显示为3
const displayStatus = computed(() => {
// return isSelf.value ? status.value : 3;
return status.value
})
// 跳转到首页
function goToHome() {
stopApplyStatusPolling()
uni.reLaunch({ url: '/pages/index' })
}
onBeforeMount(async () => {
const channelKey = import.meta.env.VITE_INVITE_CHANNEL_KEY
if (!channelKey) {
uni.showToast({ title: '缺少邀请渠道配置', icon: 'none' })
return
}
if (route.name === 'invitationAgentApplySelf') {
isSelf.value = true
}
else {
const linkIdentifier = route.params.linkIdentifier
const decryptDataStr = aesDecrypt(
decodeURIComponent(linkIdentifier),
channelKey,
)
const decryptData = JSON.parse(decryptDataStr)
ancestor.value = decryptData.mobile
}
const token = getToken()
if (token) {
await syncStatusAndUpdatePolling()
}
})
async function submitApplication(formData) {
// 提交代理申请的数据
const { region, mobile, wechat_id, code } = formData
const postData = {
region,
mobile,
wechat_id,
code,
}
if (!isSelf.value) {
postData.ancestor = ancestor.value
}
const { data, error } = await useApiFetch('/agent/apply')
.post(postData)
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showApplyPopup.value = false
showToast({ message: '已提交申请' })
if (data.value.data.accessToken) {
setAuthSession(data.value.data)
}
if (getToken()) {
await syncStatusAndUpdatePolling()
}
}
else {
console.warn('申请失败', data.value)
}
}
}
watch(status, (nextStatus, prevStatus) => {
if (!isApplyPolling.value)
return
if ((prevStatus === 0 || prevStatus === 3) && nextStatus === 1) {
showToast({ message: '审核已通过' })
}
else if ((prevStatus === 0 || prevStatus === 3) && nextStatus === 2) {
showToast({ message: '审核未通过,请重新提交' })
}
})
onUnmounted(() => {
stopApplyStatusPolling()
})
</script>
<template>
<view class="min-h-screen bg-[#D1D6FF]">
<image class="block w-full" src="/static/images/invitation_agent_apply.png" mode="widthFix" />
<!-- 统一状态处理容器 -->
<view class="flex flex-col items-center justify-center">
<!-- 审核中状态 -->
<view v-if="displayStatus === 0" class="text-center">
<text class="text-xs text-gray-500">您的申请正在审核中</text>
<view class="mt-1 rounded-3xl bg-gray-200 p-1 shadow-xl">
<view class="rounded-3xl bg-gray-400 px-8 py-2 text-xl text-white font-bold shadow-lg opacity-90">
<text>审核进行中</text>
</view>
</view>
</view>
<!-- 审核通过状态 -->
<view v-if="displayStatus === 1" class="text-center">
<text class="text-xs text-gray-500">您已成为认证代理方</text>
<view class="mt-1 rounded-3xl bg-green-100 p-1 shadow-xl" @click="goToHome">
<view
class="rounded-3xl from-green-500 to-green-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>进入应用首页</text>
</view>
</view>
</view>
<!-- 审核未通过状态 -->
<view v-if="displayStatus === 2" class="text-center">
<text class="text-xs text-red-500">审核未通过请重新提交</text>
<view class="mt-1 rounded-3xl bg-red-100 p-1 shadow-xl" @click="agentApply">
<view
class="rounded-3xl from-red-500 to-red-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>重新提交申请</text>
</view>
</view>
</view>
<!-- 未申请状态包含邀请状态 -->
<view v-if="displayStatus === 3" class="text-center">
<text class="text-xs text-gray-500">
{{ isSelf ? '立即申请成为代理人' : '邀您注册代理人' }}
</text>
<view class="mt-1 rounded-3xl bg-gray-100 p-1 shadow-xl" @click="agentApply">
<view
class="rounded-3xl from-blue-500 to-blue-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>立即成为代理方</text>
</view>
</view>
</view>
</view>
</view>
<AgentApplicationForm
v-model:show="showApplyPopup"
:ancestor="ancestor"
:is-self="isSelf"
:is-logged-in="isLoggedIn"
:user-mobile="mobile"
@submit="submitApplication"
@close="showApplyPopup = false"
/>
</template>
<style lang="scss" scoped></style>

67
src/pages/invitation.vue Normal file
View File

@@ -0,0 +1,67 @@
<script setup>
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useAgentStore } from '@/stores/agentStore'
import { aesEncrypt } from '@/utils/crypto'
definePage({ layout: 'default', auth: true })
const agentStore = useAgentStore()
const { mobile, agentID } = storeToRefs(agentStore)
const showQRcode = ref(false)
const linkIdentifier = ref('')
function encryptIdentifire(id, phone) {
const channelKey = import.meta.env.VITE_INVITE_CHANNEL_KEY
if (!channelKey) {
uni.showToast({ title: '缺少邀请渠道配置', icon: 'none' })
return
}
const linkIdentifierJSON = {
agentID: id,
mobile: phone,
}
const linkIdentifierStr = JSON.stringify(linkIdentifierJSON)
const encodeData = aesEncrypt(
linkIdentifierStr,
channelKey,
)
linkIdentifier.value = encodeURIComponent(encodeData)
}
onMounted(async () => {
try {
if (!agentID.value)
await agentStore.fetchAgentStatus()
encryptIdentifire(agentID.value || '', mobile.value || '')
}
catch (e) {
console.error('[invitation] 初始化失败', e)
uni.showToast({ title: '加载代理信息失败', icon: 'none' })
}
})
</script>
<template>
<view class="relative min-h-screen w-full pb-[calc(3rem+env(safe-area-inset-bottom))]">
<image
class="block w-full"
src="/static/images/invitation.png"
mode="widthFix"
/>
<view
class="fixed inset-x-0 bottom-0 z-10 min-h-12 flex items-center justify-center rounded-t-xl from-orange-500 to-orange-300 bg-gradient-to-t px-4 pb-[env(safe-area-inset-bottom)] pt-2 text-base text-white font-bold shadow-xl"
@click="showQRcode = true"
>
<text>立即邀请好友</text>
</view>
</view>
<QRcode
v-model:show="showQRcode"
mode="invitation"
:link-identifier="linkIdentifier"
/>
</template>
<style lang="scss" scoped></style>

297
src/pages/login.vue Normal file
View File

@@ -0,0 +1,297 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
definePage({ layout: false })
const phoneNumber = ref('')
const verificationCode = ref('')
const isAgreed = ref(false)
const isCountingDown = ref(false)
const countdown = ref(60)
const redirectUrl = ref('')
let timer = null
const userStore = useUserStore()
const agentStore = useAgentStore()
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canLogin = computed(() => {
if (!isPhoneNumberValid.value)
return false
return verificationCode.value.length === 6
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
toast('请输入有效的手机号')
return
}
try {
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'login', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
toast('获取成功')
startCountdown()
return
}
toast(data.value?.msg || '发送失败')
}
catch {
toast('发送失败')
}
}
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) {
toast('请输入有效的手机号')
return
}
if (verificationCode.value.length !== 6) {
toast('请输入有效的验证码')
return
}
if (!isAgreed.value) {
toast('请先同意用户协议')
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) {
uni.setStorageSync('token', data.value.data.accessToken)
uni.setStorageSync('refreshAfter', data.value.data.refreshAfter)
uni.setStorageSync('accessExpire', data.value.data.accessExpire)
await userStore.fetchUserInfo()
await agentStore.fetchAgentStatus()
uni.reLaunch({ url: redirectUrl.value || '/pages/index' })
}
else {
toast(data.value.msg || '登录失败')
}
}
else {
toast('登录失败')
}
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
uni.navigateTo({ url: '/pages/privacy-policy' })
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
function onClickLeft() {
uni.reLaunch({ url: '/pages/index' })
}
onLoad((query) => {
const raw = String(query?.redirect || '')
if (!raw)
return
try {
redirectUrl.value = decodeURIComponent(raw)
}
catch {
redirectUrl.value = raw
}
})
</script>
<template>
<view class="login-layout">
<wd-navbar title="用户登录" safe-area-inset-top left-arrow placeholder fixed @click-left="onClickLeft" />
<view class="login relative z-10 px-4">
<view class="mb-8 pt-20 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="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 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>
<!-- 提示文字 -->
<view class="notice-text">
未注册手机号登录后将自动生成账号并且代表您已阅读并同意
</view>
<!-- 登录按钮 -->
<wd-button class="login-wd-btn" block type="primary" :disabled="!canLogin" @click="handleLogin">
</wd-button>
</view>
</view>
</view>
</template>
<style scoped>
.login-layout {
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-height: 100vh;
position: relative;
padding-bottom: 24px;
}
/* 登录表单 */
.login-form {
margin-top: 0.5rem;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.1);
padding: 1.5rem 1.25rem;
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 {
font-size: 0.9375rem;
color: #111827;
margin-bottom: 0;
margin-right: 1rem;
font-weight: 500;
min-width: 4rem;
flex-shrink: 0;
}
.phone-wd-input {
flex: 1;
}
/* 验证码输入 */
.verification-input-wrapper {
display: flex;
align-items: center;
flex: 1;
}
.verification-wd-input {
width: 100%;
}
/* 协议同意 */
.agreement-wrapper {
display: flex;
align-items: center;
margin-top: 1.25rem;
margin-bottom: 1rem;
}
.agreement-text {
font-size: 0.75rem;
color: #6b7280;
line-height: 1.4;
margin-left: 0.5rem;
}
.agreement-link {
color: #2563eb;
cursor: pointer;
text-decoration: none;
}
/* 提示文字 */
.notice-text {
font-size: 0.6875rem;
color: #9ca3af;
line-height: 1.5;
margin-bottom: 1.25rem;
}
/* 登录按钮 */
.login-wd-btn {
letter-spacing: 0.25rem;
}
</style>

360
src/pages/me.vue Normal file
View File

@@ -0,0 +1,360 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, onBeforeMount, ref, watch } from 'vue'
import { openCustomerService } from '@/composables/useCustomerService'
import { useEnv } from '@/composables/useEnv'
import { useAgentStore } from '@/stores/agentStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import headShot from '/static/images/head_shot.webp'
definePage({ layout: 'home' })
const agentStore = useAgentStore()
const userStore = useUserStore()
const dialogStore = useDialogStore()
const { isAgent, level, ExpiryTime } = storeToRefs(agentStore)
const { userAvatar, isLoggedIn, mobile } = storeToRefs(userStore)
const { isWeChat } = useEnv()
const levelNames = {
'normal': '普通代理',
'': '普通代理',
'VIP': 'VIP代理',
'SVIP': 'SVIP代理',
}
const levelText = {
'normal': '基础代理特权',
'': '基础代理特权',
'VIP': '高级代理特权',
'SVIP': '尊享代理特权',
}
const levelGradient = computed(() => ({
border: {
'normal': '',
'': '',
'VIP': '',
'SVIP': '',
}[level.value],
badge: {
'normal': 'bg-gradient-to-r from-gray-500 to-gray-600',
'': 'bg-gradient-to-r from-gray-500 to-gray-600',
'VIP': 'bg-gradient-to-r from-yellow-500 to-amber-600',
'SVIP': 'bg-gradient-to-r from-purple-500 to-pink-500',
}[level.value],
text: {
'normal': 'text-gray-600',
'': 'text-gray-600',
'VIP': 'text-amber-600',
'SVIP': 'text-purple-600',
}[level.value],
}))
function maskName(name) {
if (!name || name.length < 11)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
function toHistory() {
uni.navigateTo({ url: '/pages/history-query' })
}
function toPromote() {
uni.navigateTo({ url: '/pages/promote' })
}
function toInvitation() {
uni.navigateTo({ url: '/pages/invitation' })
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
uni.navigateTo({ url: '/pages/privacy-policy' })
}
function redirectToLogin() {
uni.navigateTo({ url: '/pages/login' })
}
function handleLogout() {
uni.removeStorageSync('token')
uni.removeStorageSync('refreshAfter')
uni.removeStorageSync('accessExpire')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('agentInfo')
// 重置状态
userStore.resetUser()
agentStore.resetAgent()
uni.reLaunch({ url: '/pages/index' })
}
function goCancelAccountPage() {
if (!mobile.value) {
uni.showToast({ title: '请先绑定手机号', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/cancel-account' })
}
function toService() {
openCustomerService()
}
function toVipConfig() {
uni.navigateTo({ url: '/pages/agent-vip-config' })
}
function toVipRenewal() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
function formatExpiryTime(expiryTimeStr) {
if (!expiryTimeStr)
return '未知'
// 假设expiryTimeStr格式是 "YYYY-MM-DD HH:MM:SS"
// 只返回日期部分 "YYYY-MM-DD"
return expiryTimeStr.split(' ')[0]
}
/** 与 bdrp-mini `/static/image/shot_*.png` 一致,资源放在 `src/static/image/` */
function getDefaultAvatar() {
if (!isAgent.value)
return headShot
const normalizedLevel = String(level.value || '').toUpperCase()
switch (normalizedLevel) {
case 'NORMAL':
case 'normal':
case '':
return '/static/image/shot_nonal.png'
case 'VIP':
return '/static/image/shot_vip.png'
case 'SVIP':
return '/static/image/shot_svip.png'
default:
return headShot
}
}
/** 用户头像 URLstore 已 resolve默认图为本地资源加载失败时回退到 headShot */
const avatarDisplay = ref(headShot)
function syncAvatarDisplay() {
avatarDisplay.value = userAvatar.value || getDefaultAvatar()
}
watch([userAvatar, isAgent, level], syncAvatarDisplay, { immediate: true })
function onAvatarError() {
avatarDisplay.value = headShot
}
function showBindPhoneDialog() {
dialogStore.openBindPhone()
}
onBeforeMount(() => {
// 获取存储的用户和代理信息
const userInfo = uni.getStorageSync('userInfo')
if (userInfo) {
try {
const parsedUserInfo = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
userStore.updateUserInfo(parsedUserInfo)
}
catch (e) {
console.error('解析用户信息失败', e)
}
}
const agentInfo = uni.getStorageSync('agentInfo')
if (agentInfo) {
try {
const parsedAgentInfo = typeof agentInfo === 'string' ? JSON.parse(agentInfo) : agentInfo
agentStore.updateAgentInfo(parsedAgentInfo)
}
catch (e) {
console.error('解析代理信息失败', e)
}
}
})
</script>
<template>
<view class="box-border min-h-screen">
<view class="flex flex-col p-4 space-y-6">
<!-- 用户信息卡片 -->
<view
class="group profile-section relative flex items-center gap-4 rounded-xl bg-white p-6 transition-all hover:shadow-xl"
@click="!isLoggedIn ? redirectToLogin() : null">
<view class="relative">
<!-- 头像容器添加overflow-hidden解决边框问题 -->
<view class="overflow-hidden rounded-full p-0.5" :class="levelGradient.border">
<image :src="avatarDisplay" mode="aspectFill" alt="User Avatar"
class="h-24 w-24 border-4 border-white rounded-full" @error="onAvatarError" />
</view>
<!-- 代理标识 -->
<view v-if="isAgent" class="absolute -bottom-2 -right-2">
<view class="flex items-center justify-center rounded-full px-3 py-1 text-xs text-white font-bold shadow-sm"
:class="levelGradient.badge">
{{ levelNames[level] }}
</view>
</view>
</view>
<view class="space-y-1">
<view class="text-2xl text-gray-800 font-bold">
{{
!isLoggedIn
? "点击登录"
: mobile
? maskName(mobile)
: isWeChat
? "微信用户"
: "未绑定手机号"
}}
</view>
<!-- 手机号绑定提示 -->
<template v-if="isLoggedIn && !mobile">
<view class="cursor-pointer text-sm text-blue-500 hover:underline" @click.stop="showBindPhoneDialog">
点击绑定手机号码
</view>
</template>
<view v-if="isAgent" class="text-sm font-medium" :class="levelGradient.text">
🎖 {{ levelText[level] }}
</view>
</view>
</view>
<VipBanner v-if="isAgent && (level === 'normal' || level === '')" />
<!-- 功能菜单 -->
<view>
<view class="overflow-hidden rounded-xl bg-white shadow-sm">
<template v-if="isAgent && ['VIP', 'SVIP'].includes(level)">
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-purple-50"
@click="toVipConfig">
<view class="flex items-center gap-3">
<image src="/static/images/me/dlbgpz.png" class="h-6 w-6" alt="代理报告配置" />
<text class="text-purple-700 font-medium">
代理报告配置
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-amber-50"
@click="toVipRenewal">
<view class="flex items-center gap-3">
<image src="/static/images/me/xfhy.png" class="h-6 w-6" alt="代理会员" />
<view class="flex flex-col items-start">
<text class="text-amber-700 font-medium">
续费代理会员
</text>
<text class="text-xs text-gray-500">
有效期至 {{ formatExpiryTime(ExpiryTime) }}
</text>
</view>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
</template>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toPromote">
<view class="flex items-center gap-3">
<image src="/static/images/index/tgbg.png" class="h-6 w-6" alt="推广报告" />
<text class="text-gray-700 font-medium">
推广报告
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toInvitation">
<view class="flex items-center gap-3">
<image src="/static/images/index/yqhy.png" class="h-6 w-6" alt="邀请下级" />
<text class="text-gray-700 font-medium">
邀请下级
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toHistory">
<view class="flex items-center gap-3">
<image src="/static/images/index/wdbg.png" class="h-6 w-6" alt="我的报告" />
<text class="text-gray-700 font-medium">
我的报告
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toUserAgreement">
<view class="flex items-center gap-3">
<image src="/static/images/me/yhxy.png" class="h-6 w-6" alt="用户协议" />
<text class="text-gray-700 font-medium">
用户协议
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toPrivacyPolicy">
<view class="flex items-center gap-3">
<image src="/static/images/me/yszc.png" class="h-6 w-6" alt="隐私政策" />
<text class="text-gray-700 font-medium">
隐私政策
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view class="w-full flex items-center justify-between px-6 py-4 transition-colors hover:bg-blue-50"
@click="toService">
<view class="flex items-center gap-3">
<image src="/static/images/me/lxkf.png" class="h-6 w-6" alt="联系客服" />
<text class="text-gray-700 font-medium">
联系客服
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view v-if="isLoggedIn && mobile"
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-red-50"
@click="goCancelAccountPage">
<view class="flex items-center gap-3">
<image src="/static/images/me/cancelAccount.svg" class="h-6 w-6" alt="注销账号" />
<text class="text-gray-700 font-medium">
注销账号
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view v-if="isLoggedIn && !isWeChat"
class="w-full flex items-center justify-between px-6 py-4 transition-colors hover:bg-red-50"
@click="handleLogout">
<view class="flex items-center gap-3">
<image src="/static/images/me/tcdl.png" class="h-6 w-6" alt="退出登录" />
<text class="text-gray-700 font-medium">
退出登录
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped></style>

210
src/pages/not-found.vue Normal file
View File

@@ -0,0 +1,210 @@
<script setup>
import { onMounted } from 'vue'
import { useSEO } from '@/composables/useSEO'
definePage({ layout: false })
// SEO优化
const { updateSEO } = useSEO()
onMounted(() => {
const origin = import.meta.env.VITE_SITE_ORIGIN
if (!origin)
throw new Error('缺少环境变量: VITE_SITE_ORIGIN')
const siteName = import.meta.env.VITE_SEO_SITE_NAME
if (!siteName)
throw new Error('缺少环境变量: VITE_SEO_SITE_NAME')
updateSEO({
title: `404 - 页面未找到 | ${siteName}`,
description: `抱歉,您访问的页面不存在。${siteName}专业大数据风险管控平台,提供大数据风险报告查询、婚姻状况查询、个人信用评估等服务。`,
keywords: `404, 页面未找到, ${siteName}, 大数据风险管控`,
url: `${origin}/404`,
})
})
</script>
<template>
<div class="not-found">
<div class="not-found-content">
<h1>404</h1>
<h2>页面未找到</h2>
<p>抱歉您访问的页面不存在或已被移除</p>
<div class="suggestions">
<h3>您可以尝试</h3>
<ul>
<li>
<router-link to="/">
返回首页
</router-link>
</li>
<li>
<router-link to="/help">
查看帮助中心
</router-link>
</li>
<li>
<router-link to="/service">
联系客服
</router-link>
</li>
</ul>
</div>
<div class="actions">
<router-link to="/" class="home-link">
返回首页
</router-link>
<router-link to="/help" class="help-link">
帮助中心
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.not-found-content {
background: white;
padding: 60px 40px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 600px;
width: 100%;
}
.not-found h1 {
font-size: 120px;
color: #667eea;
margin: 0 0 20px 0;
font-weight: bold;
line-height: 1;
}
.not-found h2 {
font-size: 32px;
color: #333;
margin: 0 0 20px 0;
font-weight: 600;
}
.not-found p {
font-size: 18px;
color: #666;
margin-bottom: 30px;
line-height: 1.6;
}
.suggestions {
margin: 30px 0;
text-align: left;
}
.suggestions h3 {
font-size: 20px;
color: #333;
margin-bottom: 15px;
text-align: center;
}
.suggestions ul {
list-style: none;
padding: 0;
margin: 0;
}
.suggestions li {
margin: 10px 0;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.suggestions li:last-child {
border-bottom: none;
}
.suggestions a {
color: #667eea;
text-decoration: none;
font-size: 16px;
transition: color 0.3s;
}
.suggestions a:hover {
color: #5a6fd8;
text-decoration: underline;
}
.actions {
margin-top: 40px;
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.home-link, .help-link {
display: inline-block;
padding: 15px 30px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 50px;
transition: all 0.3s ease;
min-width: 140px;
}
.home-link {
color: #fff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.home-link:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.help-link {
color: #667eea;
background: white;
border: 2px solid #667eea;
}
.help-link:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
}
@media (max-width: 768px) {
.not-found-content {
padding: 40px 20px;
}
.not-found h1 {
font-size: 80px;
}
.not-found h2 {
font-size: 24px;
}
.actions {
flex-direction: column;
align-items: center;
}
.home-link, .help-link {
width: 100%;
max-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,549 @@
<script setup>
import {
computed,
onBeforeMount,
onBeforeUnmount,
ref,
} from 'vue'
import { openCustomerService } from '@/composables/useCustomerService'
import { useAgentStore } from '@/stores/agentStore'
import { useUserStore } from '@/stores/userStore'
definePage({ layout: 'default' })
const orderNo = ref('')
const agentStore = useAgentStore()
const userStore = useUserStore()
// 状态变量
const isLoading = ref(true)
const paymentResult = ref(null)
const paymentType = ref('')
const paymentStatus = ref('')
const isApiError = ref(false)
const pollingInterval = ref(null)
const pollingCount = ref(0)
const maxPollingCount = 30 // 最大轮询次数
const baseInterval = 2000 // 基础轮询间隔2秒
// 计算属性
const statusText = computed(() => {
if (isApiError.value) {
return '系统繁忙'
}
switch (paymentStatus.value) {
case 'pending':
return '正在支付'
case 'failed':
return '支付失败'
case 'closed':
return '订单已关闭'
default:
return '处理中'
}
})
const statusMessage = computed(() => {
if (isApiError.value) {
return '系统正在维护或网络繁忙,请稍后再试,或联系客服确认订单状态。'
}
switch (paymentStatus.value) {
case 'pending':
return '您的订单正在支付,请稍后'
case 'failed':
return '支付未成功,您可以返回重新支付,或联系客服确认详情。'
case 'closed':
return '订单已关闭,如有疑问请联系客服。'
default:
return '系统正在处理您的订单,如有疑问请联系客服。'
}
})
// 状态图标
const getStatusIcon = computed(() => {
if (isApiError.value) {
return '⚠️'
}
return paymentStatus.value === 'pending' ? '⏳' : '❌'
})
// 状态颜色
const getStatusColor = computed(() => {
if (isApiError.value) {
return '#ff9800' // 橙色警告
}
return paymentStatus.value === 'pending' ? '#ff976a' : '#ee0a24'
})
// 环形样式类
const getRingClass = computed(() => {
if (isApiError.value) {
return 'api-error-ring'
}
return {
'pending-ring': paymentStatus.value === 'pending',
'failed-ring': paymentStatus.value === 'failed',
'closed-ring': paymentStatus.value === 'closed',
}
})
// 状态文本样式
const getStatusTextClass = computed(() => {
if (isApiError.value) {
return 'text-amber-600'
}
return {
'text-orange-500': paymentStatus.value === 'pending',
'text-red-500':
paymentStatus.value === 'failed'
|| paymentStatus.value === 'closed',
}
})
// 消息文本样式
const getMessageClass = computed(() => {
if (isApiError.value) {
return 'text-amber-800'
}
return {
'text-orange-700': paymentStatus.value === 'pending',
'text-red-700':
paymentStatus.value === 'failed'
|| paymentStatus.value === 'closed',
}
})
// 计算轮询间隔时间(渐进式增加)
const getPollingInterval = computed(() => {
// 每5次轮询增加1秒最大间隔10秒
const increment = Math.floor(pollingCount.value / 5)
return Math.min(baseInterval + increment * 1000, 10000)
})
// 检查支付状态
async function checkPaymentStatus() {
if (pollingCount.value >= maxPollingCount) {
// 超过最大轮询次数,停止轮询
stopPolling()
return
}
try {
const { data, error } = await useApiFetch(`/pay/check`)
.post({
order_no: orderNo.value,
})
.json()
if (data.value && !error.value) {
paymentResult.value = data.value.data
paymentType.value = data.value.data.type || ''
const newStatus = data.value.data.status || ''
// 状态发生变化时更新
if (paymentStatus.value !== newStatus) {
paymentStatus.value = newStatus
// 对于查询类型,如果状态是已支付或已退款,直接跳转
if (
paymentType.value === 'query'
&& (newStatus === 'paid' || newStatus === 'refunded')
) {
stopPolling()
uni.redirectTo({
url: `/pages/report-result-webview?orderNo=${encodeURIComponent(orderNo.value)}`,
})
return
}
// 如果状态不是 pending停止轮询
if (newStatus !== 'pending') {
stopPolling()
}
}
}
else {
console.error('API调用失败:', error.value)
// 不要立即停止轮询,继续尝试
}
}
catch (err) {
console.error('验证支付状态失败:', err)
// 不要立即停止轮询,继续尝试
}
finally {
pollingCount.value++
isLoading.value = false
}
}
// 开始轮询
function startPolling() {
if (pollingInterval.value)
return
pollingCount.value = 0
const poll = () => {
checkPaymentStatus()
if (
paymentStatus.value === 'pending'
&& pollingCount.value < maxPollingCount
) {
pollingInterval.value = setTimeout(poll, getPollingInterval.value)
}
}
poll()
}
// 停止轮询
function stopPolling() {
if (pollingInterval.value) {
clearTimeout(pollingInterval.value)
pollingInterval.value = null
}
}
// 在组件挂载前验证支付结果
onLoad(async (query) => {
const q = query || {}
orderNo.value = String(q.out_trade_no || q.orderNo || '')
if (!orderNo.value) {
uni.reLaunch({ url: '/pages/index' })
return
}
// 首次检查支付状态
await checkPaymentStatus()
// 如果状态是 pending开始轮询
if (paymentStatus.value === 'pending') {
startPolling()
}
})
// 组件卸载前清理轮询
onBeforeUnmount(() => {
stopPolling()
})
// 处理导航逻辑
function handleNavigation() {
if (paymentType.value === 'agent_vip') {
// 跳转到代理会员页面
uni.switchTab({ url: '/pages/agent' })
agentStore.fetchAgentStatus()
userStore.fetchUserInfo()
}
else {
// 跳转到查询结果页面
uni.redirectTo({
url: `/pages/report-result-webview?orderNo=${encodeURIComponent(orderNo.value)}`,
})
}
}
// 返回首页
function goHome() {
uni.reLaunch({ url: '/pages/index' })
}
// 联系客服
function contactService() {
openCustomerService()
}
// 暴露方法和数据供父组件或路由调用
defineExpose({
paymentResult,
paymentType,
paymentStatus,
handleNavigation,
stopPolling, // 暴露停止轮询方法
})
</script>
<template>
<div class="payment-result-container flex flex-col items-center p-6">
<!-- 加载动画验证支付结果时显示 -->
<div v-if="isLoading" class="w-full">
<div class="flex flex-col items-center justify-center py-10">
<view class="loading-spinner" />
<p class="mt-4 text-lg text-gray-600">
正在处理支付结果...
</p>
</div>
</div>
<!-- 支付结果展示 -->
<div v-else class="w-full">
<!-- 支付成功 -->
<div v-if="paymentStatus === 'paid'" class="success-result">
<div class="success-animation mb-6">
<text class="status-icon">
</text>
<div class="success-ring" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
支付成功
</h1>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
</div>
<div v-if="paymentType === 'agent_vip'" class="mb-4 text-center text-gray-600">
恭喜你成为高级代理会员享受更多权益
</div>
<div class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="handleNavigation">
{{
paymentType === "agent_vip"
? "查看会员权益"
: "查看查询结果"
}}
</wd-button>
</div>
</div>
<!-- 退款状态 -->
<div v-else-if="paymentStatus === 'refunded'" class="refund-result">
<div v-if="paymentType === 'query'" class="success-animation mb-6">
<text class="status-icon">
</text>
<div class="success-ring" />
</div>
<div v-else class="info-animation mb-6">
<text class="status-icon">
</text>
<div class="info-ring" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
{{ paymentType === "query" ? "已处理" : "订单已退款" }}
</h1>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div class="mb-4 flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">订单状态</span>
<span class="text-blue-600">已退款</span>
</div>
</div>
<div v-if="paymentType === 'query'" class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="handleNavigation">
查看查询结果
</wd-button>
</div>
<div v-else class="message-box mb-6 rounded-lg bg-blue-50 p-4">
<p class="text-center text-blue-800">
您的代理会员费用已退款如有疑问请联系客服
</p>
</div>
<div v-if="paymentType === 'agent_vip'" class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="contactService">
联系客服
</wd-button>
</div>
</div>
<!-- 其他状态待支付失败关闭 -->
<div v-else class="other-result">
<div class="info-animation mb-6">
<text class="status-icon" :style="{ color: getStatusColor }">
{{ getStatusIcon }}
</text>
<div class="info-ring" :class="getRingClass" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
{{ statusText }}
</h1>
<!-- 添加轮询状态提示 -->
<div v-if="paymentStatus === 'pending'" class="mb-4 text-center text-gray-500">
<p>正在等待支付结果请稍候...</p>
<p class="mt-1 text-sm">
已等待
{{
Math.floor(
(pollingCount * getPollingInterval) / 1000,
)
}}
</p>
</div>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div v-if="!isApiError" class="mb-4 flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">订单状态</span>
<span :class="getStatusTextClass">{{
statusText
}}</span>
</div>
</div>
<div class="message-box mb-6 rounded-lg bg-blue-50 p-4">
<p class="text-center" :class="getMessageClass">
{{ statusMessage }}
</p>
</div>
<div class="action-buttons grid grid-cols-2 gap-4">
<wd-button block type="info" class="rounded-lg" @click="goHome">
返回首页
</wd-button>
<wd-button block type="primary" class="rounded-lg" @click="contactService">
联系客服
</wd-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.payment-result-container {
min-height: 80vh;
background-color: #f8f9fa;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #dbeafe;
border-top-color: #3b82f6;
border-radius: 9999px;
animation: spin 0.9s linear infinite;
}
.status-icon {
font-size: 56px;
line-height: 1;
z-index: 2;
}
.success-animation,
.info-animation {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 2rem auto;
}
.success-ring,
.info-ring,
.pending-ring,
.failed-ring,
.closed-ring,
.api-error-ring {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.success-ring {
border: 2px solid #07c160;
}
.info-ring {
border: 2px solid #1989fa;
}
.pending-ring {
border: 2px solid #ff976a;
}
.failed-ring,
.closed-ring {
border: 2px solid #ee0a24;
}
.api-error-ring {
border: 2px solid #ff9800;
/* 橙色警告 */
}
@keyframes pulse {
0% {
transform: scale(0.95);
opacity: 0.8;
}
70% {
transform: scale(1.1);
opacity: 0.3;
}
100% {
transform: scale(0.95);
opacity: 0.8;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.success-result,
.refund-result,
.other-result {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/privacyPolicy')
})
</script>
<template>
<web-view :src="src" />
</template>

225
src/pages/promote.vue Normal file
View File

@@ -0,0 +1,225 @@
<script setup>
import PriceInputPopup from '@/components/PriceInputPopup.vue'
import VipBanner from '@/components/VipBanner.vue'
definePage({ layout: 'default' })
const showPricePicker = ref(false)
const pickerProductConfig = ref(null)
const productConfig = ref(null)
const linkIdentifier = ref('')
const showQRcode = ref(false)
const promotionForm = ref(null)
const loadingConfig = ref(false)
const generating = ref(false)
const formData = ref({
productType: '',
clientPrice: null,
})
const availableReportTypes = computed(() => {
if (!productConfig.value?.length)
return []
return productConfig.value
.map(item => ({
id: item.product_id,
label: item.product_name || `产品${item.product_id}`,
value: item.product_en || '',
}))
.filter(item => !!item.value)
})
const costPrice = computed(() => {
if (!pickerProductConfig.value)
return '0.00'
let platformPricing = 0
platformPricing += pickerProductConfig.value.cost_price
if (formData.value.clientPrice > pickerProductConfig.value.p_pricing_standard) {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.p_pricing_standard) * pickerProductConfig.value.p_overpricing_ratio
}
if (pickerProductConfig.value.a_pricing_standard > platformPricing && pickerProductConfig.value.a_pricing_end > platformPricing && pickerProductConfig.value.a_overpricing_ratio > 0) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_standard) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_end) {
platformPricing += (pickerProductConfig.value.a_pricing_end - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
}
else {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
})
const promotionRevenue = computed(() => {
return safeTruncate(formData.value.clientPrice - costPrice.value)
})
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)
return (scaled / factor).toFixed(decimals)
}
function selectProductType(reportTypeValue) {
const reportType = availableReportTypes.value.find(item => item.id === reportTypeValue || item.value === reportTypeValue)
if (!reportType)
return
formData.value.productType = reportType.value
const matchedConfig = productConfig.value?.find(item => item.product_id === reportType.id)
if (!matchedConfig) {
pickerProductConfig.value = null
formData.value.clientPrice = null
uni.showToast({ title: '该报告暂不可推广', icon: 'none' })
return
}
pickerProductConfig.value = matchedConfig
formData.value.clientPrice = matchedConfig.cost_price
}
function onConfirmType(e) {
const nextValue = e?.value?.[0] ?? e?.selectedOptions?.[0]?.value
if (nextValue)
selectProductType(nextValue)
}
function onPriceChange(price) {
formData.value.clientPrice = price
}
function getPricePayload() {
return safeTruncate(Number(formData.value.clientPrice))
}
function openPricePicker() {
if (!pickerProductConfig.value) {
uni.showToast({ title: '请先选择报告类型', icon: 'none' })
return
}
showPricePicker.value = true
}
async function getPromoteConfig() {
loadingConfig.value = true
try {
const { data, error } = await useApiFetch('/agent/product_config').get().json()
if (data.value && !error.value && data.value.code === 200) {
const list = data.value.data.AgentProductConfig || []
productConfig.value = list
const availableType = availableReportTypes.value[0]
if (availableType) {
selectProductType(availableType.value)
}
else {
pickerProductConfig.value = null
formData.value.productType = ''
formData.value.clientPrice = null
uni.showToast({ title: '暂无可推广产品', icon: 'none' })
}
return
}
uni.showToast({ title: data.value?.msg || '获取配置失败', icon: 'none' })
}
catch {
uni.showToast({ title: '获取配置失败', icon: 'none' })
}
finally {
loadingConfig.value = false
}
}
async function generatePromotionCode() {
if (generating.value)
return
try {
await promotionForm.value.validate()
}
catch {
return
}
generating.value = true
try {
const { data, error } = await useApiFetch('/agent/generating_link')
.post({ product: formData.value.productType, price: getPricePayload() })
.json()
if (data.value && !error.value && data.value.code === 200) {
linkIdentifier.value = data.value.data.link_identifier
showQRcode.value = true
}
else {
uni.showToast({ title: data.value?.msg || '生成推广码失败', icon: 'none' })
}
}
catch {
uni.showToast({ title: '生成推广码失败', icon: 'none' })
}
finally {
generating.value = false
}
}
onMounted(() => {
getPromoteConfig()
})
</script>
<template>
<view class="promote min-h-screen p-4">
<view class="card mb-4 from-orange-200 to-orange-200/80 !bg-gradient-to-b">
<view class="text-lg text-orange-500 font-bold">
直推用户查询
</view>
<view class="mt-1 text-orange-400 font-bold">
自定义价格赚取差价
</view>
<view class="mt-6 rounded-xl bg-orange-100 px-4 py-2 text-gray-600">
在下方 自定义价格 处选择报告类型设置客户查询价即可立即推广
</view>
</view>
<VipBanner />
<view class="card mb-4">
<view class="mb-2 text-xl font-semibold">
生成推广码
</view>
<wd-form ref="promotionForm" :model="formData">
<wd-cell-group border>
<wd-picker v-model="formData.productType" label="报告类型" label-width="100px" title="选择报告类型"
:columns="[availableReportTypes]" prop="productType" placeholder="请选择报告类型"
:rules="[{ required: true, message: '请选择报告类型' }]" @confirm="onConfirmType" />
<wd-input v-model="formData.clientPrice" label="客户查询价" label-width="100px" placeholder="请输入价格"
prop="clientPrice" clickable readonly suffix-icon="arrow-right"
:rules="[{ required: true, message: '请输入客户查询价' }]" @click="openPricePicker" />
</wd-cell-group>
<view class="my-2 flex items-center justify-between text-sm text-gray-500">
<view>
推广收益为 <text class="text-orange-500">
{{ promotionRevenue }}
</text>
</view>
<view>
我的成本为 <text class="text-orange-500">
{{ costPrice }}
</text>
</view>
</view>
</wd-form>
<wd-button type="primary" block :loading="generating || loadingConfig"
:disabled="loadingConfig || !pickerProductConfig" @click="generatePromotionCode">
点击立即推广
</wd-button>
</view>
<PriceInputPopup v-model:show="showPricePicker" :default-price="formData.clientPrice"
:product-config="pickerProductConfig" @change="onPriceChange" />
<QRcode v-model:show="showQRcode" :link-identifier="linkIdentifier" />
</view>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue'
import InquireForm from '@/components/InquireForm.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import { useRoute } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const linkIdentifier = ref('')
const feature = ref('')
const featureData = ref({})
onLoad(async (query) => {
const q = query || {}
if (q.out_trade_no) {
uni.navigateTo({
url: `/pages/report-result-webview?out_trade_no=${encodeURIComponent(String(q.out_trade_no))}`,
})
return
}
await getProduct()
})
async function getProduct() {
linkIdentifier.value = route.params.linkIdentifier
const { data: agentLinkData, error: agentLinkError } = await useApiFetch(
`/agent/link?link_identifier=${linkIdentifier.value}`,
)
.get()
.json()
if (agentLinkData.value && !agentLinkError.value) {
if (agentLinkData.value.code === 200) {
feature.value = agentLinkData.value.data.product_en
featureData.value = agentLinkData.value.data
// 确保 FLXG0V4B 排在首位
if (
featureData.value.features
&& featureData.value.features.length > 0
) {
featureData.value.features.sort((a, b) => {
if (a.api_id === 'FLXG0V4B')
return -1
if (b.api_id === 'FLXG0V4B')
return 1
return 0
})
}
}
}
}
</script>
<template>
<InquireForm type="promotion" :feature="feature" :link-identifier="linkIdentifier" :feature-data="featureData" />
<LoginDialog />
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildReportUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
src.value = buildReportUrl('example', {
feature: String(query?.feature || ''),
})
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { useReportWebview } from '@/composables/useReportWebview'
import { ensureCurrentPageAccess } from '@/composables/useNavigationAuthGuard'
definePage({ layout: false })
const { buildReportUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
const q = query || {}
src.value = buildReportUrl('report', {
orderNo: String(q.orderNo || ''),
order_id: String(q.order_id || ''),
out_trade_no: String(q.out_trade_no || ''),
feature: String(q.feature || ''),
})
})
onShow(() => {
ensureCurrentPageAccess('redirect')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildReportShareUrl } = useReportWebview()
const src = ref('')
onLoad((query = {}) => {
const linkIdentifier = String(query?.linkIdentifier || '')
src.value = buildReportShareUrl(linkIdentifier)
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,353 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from '@/composables/uni-router'
import useApiFetch from '@/composables/useApiFetch'
definePage({ layout: 'default', auth: true })
const route = useRoute()
const loading = ref(false)
const page = ref(1)
const pageSize = 8
const total = ref(0)
const rewardDetails = ref([])
const userInfo = ref({})
const summary = ref({})
const statistics = ref([])
// 获取收益列表
async function fetchRewardDetails() {
if (loading.value)
return
loading.value = true
const { data, error } = await useApiFetch(
`/agent/subordinate/contribution/detail?subordinate_id=${route.params.id}&page=${page.value}&page_size=${pageSize}`,
)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
if (page.value === 1) {
// 更新用户信息
userInfo.value = {
createTime: data.value.data.create_time,
level: data.value.data.level_name || '普通',
mobile: data.value.data.mobile,
}
// 更新汇总数据
summary.value = {
totalReward: data.value.data.total_earnings,
totalContribution: data.value.data.total_contribution,
totalOrders: data.value.data.total_orders,
}
// 设置默认的统计类型
statistics.value = [
{
type: 'descendant_promotion',
amount: 0,
count: 0,
description: '推广奖励',
},
{
type: 'cost',
amount: 0,
count: 0,
description: '成本贡献',
},
{
type: 'pricing',
amount: 0,
count: 0,
description: '定价贡献',
},
{
type: 'descendant_withdraw',
amount: 0,
count: 0,
description: '提现收益',
},
{
type: 'descendant_upgrade_vip',
amount: 0,
count: 0,
description: '转化VIP奖励',
},
{
type: 'descendant_upgrade_svip',
amount: 0,
count: 0,
description: '转化SVIP奖励',
},
]
// 如果有统计数据,更新对应的值
if (data.value.data.stats) {
const stats = data.value.data.stats
// 更新推广奖励
const platformStat = statistics.value.find(s => s.type === 'descendant_promotion')
if (platformStat) {
platformStat.amount = stats.descendant_promotion_amount || 0
platformStat.count = stats.descendant_promotion_count || 0
}
// 更新成本贡献
const costStat = statistics.value.find(s => s.type === 'cost')
if (costStat) {
costStat.amount = stats.cost_amount || 0
costStat.count = stats.cost_count || 0
}
// 更新定价贡献
const pricingStat = statistics.value.find(s => s.type === 'pricing')
if (pricingStat) {
pricingStat.amount = stats.pricing_amount || 0
pricingStat.count = stats.pricing_count || 0
}
// 更新提现收益
const withdrawStat = statistics.value.find(s => s.type === 'descendant_withdraw')
if (withdrawStat) {
withdrawStat.amount = stats.descendant_withdraw_amount || 0
withdrawStat.count = stats.descendant_withdraw_count || 0
}
// 更新转化VIP奖励
const conversionVipStat = statistics.value.find(s => s.type === 'descendant_upgrade_vip')
if (conversionVipStat) {
conversionVipStat.amount = stats.descendant_upgrade_vip_amount || 0
conversionVipStat.count = stats.descendant_upgrade_vip_count || 0
}
// 更新转化SVIP奖励
const conversionSvipStat = statistics.value.find(s => s.type === 'descendant_upgrade_svip')
if (conversionSvipStat) {
conversionSvipStat.amount = stats.descendant_upgrade_svip_amount || 0
conversionSvipStat.count = stats.descendant_upgrade_svip_count || 0
}
}
rewardDetails.value = []
}
total.value = data.value.data.total || 0
// 处理列表数据
if (data.value.data.list) {
const list = data.value.data.list
rewardDetails.value = list
}
else {
rewardDetails.value = []
}
}
}
loading.value = false
}
function onPageChange({ value }) {
page.value = value
fetchRewardDetails()
}
onMounted(() => {
fetchRewardDetails()
})
// 获取收益类型样式
function getRewardTypeClass(type) {
const typeMap = {
descendant_promotion: 'bg-blue-100 text-blue-600',
cost: 'bg-green-100 text-green-600',
pricing: 'bg-purple-100 text-purple-600',
descendant_withdraw: 'bg-yellow-100 text-yellow-600',
descendant_upgrade_vip: 'bg-red-100 text-red-600',
descendant_upgrade_svip: 'bg-orange-100 text-orange-600',
}
return typeMap[type] || 'bg-gray-100 text-gray-600'
}
// 获取收益类型图标
function getRewardTypeIcon(type) {
const iconMap = {
descendant_promotion: 'gift',
cost: 'gold-coin',
pricing: 'balance-pay',
descendant_withdraw: 'cash-back-record',
descendant_upgrade_vip: 'fire',
descendant_upgrade_svip: 'fire',
}
return iconMap[type] || 'balance-o'
}
// 获取收益类型描述
function getRewardTypeDescription(type) {
const descriptionMap = {
descendant_promotion: '推广奖励',
cost: '成本贡献',
pricing: '定价贡献',
descendant_withdraw: '提现收益',
descendant_upgrade_vip: '转化VIP奖励',
descendant_upgrade_svip: '转化SVIP奖励',
}
return descriptionMap[type] || '未知类型'
}
// 格式化时间
function formatTime(timeStr) {
if (!timeStr)
return '-'
const date = new Date(timeStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 格式化金额
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
</script>
<template>
<div class="reward-detail">
<!-- 用户信息卡片 -->
<div class="p-4">
<div class="mb-4 rounded-xl bg-white p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="text-xl text-gray-800 font-semibold">
{{ userInfo.mobile }}
</div>
<span class="rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-600 font-medium">
{{ userInfo.level }}代理
</span>
</div>
</div>
<div class="mb-4 text-sm text-gray-500">
成为下级代理时间{{ formatTime(userInfo.createTime) }}
</div>
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总推广单量
</div>
<div class="text-xl text-blue-600 font-semibold">
{{ summary.totalOrders }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总收益
</div>
<div class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(summary.totalReward) }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总贡献
</div>
<div class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(summary.totalContribution) }}
</div>
</div>
</div>
</div>
<!-- 贡献统计卡片 -->
<div class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<div class="mb-3 text-base text-gray-800 font-medium">
贡献统计
</div>
<div class="grid grid-cols-2 gap-3">
<div
v-for="item in statistics"
:key="item.type"
class="flex items-center rounded-lg p-2"
:class="getRewardTypeClass(item.type).split(' ')[0]"
>
<wd-icon
:name="getRewardTypeIcon(item.type)"
class="mr-2 text-lg"
:class="getRewardTypeClass(item.type).split(' ')[1]"
/>
<div class="flex-1">
<div class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
{{ item.description }}
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
{{ item.count }}
</div>
<div class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<!-- 贡献记录列表 -->
<div class="text-base text-gray-800 font-medium">
贡献记录
</div>
<div class="detail-scroll p-4">
<div v-if="rewardDetails.length === 0" class="py-8 text-center text-gray-500">
暂无贡献记录
</div>
<div v-for="item in rewardDetails" v-else :key="item.id" class="reward-item">
<div class="mb-3 border-b border-gray-200 pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<wd-icon
:name="getRewardTypeIcon(item.type)"
class="text-lg"
:class="getRewardTypeClass(item.type).split(' ')[1]"
/>
<div>
<div class="text-gray-800 font-medium">
{{ getRewardTypeDescription(item.type) }}
</div>
<div class="text-xs text-gray-500">
{{ formatTime(item.create_time) }}
</div>
</div>
</div>
<div class="text-right">
<div class="text-base font-semibold" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="py-3 text-center text-sm text-gray-400">
加载中...
</div>
</div>
<div class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.reward-detail {
min-height: 100vh;
background-color: #f5f5f5;
}
.reward-item {
transition: transform 0.2s;
}
.reward-item:active {
transform: scale(0.98);
}
.detail-scroll {
min-height: 50vh;
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup>
import { onMounted, ref } from 'vue'
import useApiFetch from '@/composables/useApiFetch'
definePage({ layout: 'default', auth: true })
const subordinates = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = 8
// 计算统计数据
const statistics = ref({
totalSubordinates: 0,
})
// 获取下级列表
async function fetchSubordinates() {
if (loading.value)
return
loading.value = true
const { data, error } = await useApiFetch(`/agent/subordinate/list?page=${page.value}&page_size=${pageSize}`)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
statistics.value.totalSubordinates = data.value.data.total
subordinates.value = data.value.data.list || []
}
}
loading.value = false
}
function onPageChange({ value }) {
page.value = value
fetchSubordinates()
}
// 格式化金额
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
// 获取等级标签样式
function getLevelClass(level) {
switch (level) {
case 'SVIP':
return 'bg-purple-100 text-purple-600'
case 'VIP':
return 'bg-blue-100 text-blue-600'
default:
return 'bg-gray-100 text-gray-600'
}
}
// 查看详情
function viewDetail(item) {
uni.navigateTo({
url: `/pages/subordinate-detail?id=${encodeURIComponent(String(item.id))}`,
})
}
onMounted(() => {
fetchSubordinates()
})
</script>
<template>
<div class="subordinate-list">
<!-- 顶部统计卡片 -->
<div class="p-4 pb-0">
<div class="rounded-xl bg-white p-4 shadow-sm">
<div class="flex items-center justify-center">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
下级总数
</div>
<div class="text-2xl text-blue-600 font-semibold">
{{ statistics.totalSubordinates }}
</div>
</div>
</div>
</div>
</div>
<div class="subordinate-scroll p-4">
<div v-for="(item, index) in subordinates" :key="item.id" class="subordinate-item">
<div class="mb-4 flex flex-col rounded-xl bg-white p-5 shadow-sm">
<!-- 顶部信息 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div
class="h-6 w-6 flex items-center justify-center rounded-full bg-blue-100 text-sm text-blue-600 font-medium"
>
{{ index + 1 }}
</div>
<div class="text-xl text-gray-800 font-semibold">
{{ item.mobile }}
</div>
<span class="rounded-full px-3 py-1 text-sm font-medium" :class="[getLevelClass(item.level)]">
{{ item.level ? item.level : '普通' }}代理
</span>
</div>
</div>
<!-- 加入时间 -->
<div class="mb-5 text-sm text-gray-500">
成为下级代理时间{{ item.create_time }}
</div>
<!-- 数据统计 -->
<div class="grid grid-cols-3 mb-5 gap-6">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总推广单量
</div>
<div class="text-xl text-blue-600 font-semibold">
{{ item.total_orders }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总收益
</div>
<div class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(item.total_earnings) }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总贡献
</div>
<div class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(item.total_contribution) }}
</div>
</div>
</div>
<!-- 查看详情按钮 -->
<div class="flex justify-end">
<button
class="inline-flex items-center rounded-full from-blue-500 to-blue-400 bg-gradient-to-r px-4 py-2 text-sm text-white shadow-sm transition-all duration-200 hover:shadow-md"
@click="viewDetail(item)"
>
<wd-icon name="view" custom-class="mr-1.5" />
查看详情
</button>
</div>
</div>
</div>
<div v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</div>
<div v-else-if="!subordinates.length" class="py-4 text-center text-sm text-gray-400">
暂无下级代理
</div>
</div>
<div class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="statistics.totalSubordinates"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</div>
</div>
</template>
<style scoped>
.subordinate-list {
min-height: 100vh;
background-color: #f5f5f5;
}
.subordinate-scroll {
height: calc(100vh - 180px);
}
.subordinate-item {
transition: transform 0.2s;
}
.subordinate-item:active {
transform: scale(0.98);
}
button {
transition: all 0.2s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/userAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,213 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 状态映射配置
const statusConfig = {
1: {
chinese: '处理中',
color: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
dot: 'bg-yellow-500',
amount: 'text-yellow-500',
},
},
2: {
chinese: '提现成功',
color: {
bg: 'bg-green-100',
text: 'text-green-800',
dot: 'bg-green-500',
amount: 'text-green-500',
},
},
3: {
chinese: '提现失败',
color: {
bg: 'bg-red-100',
text: 'text-red-800',
dot: 'bg-red-500',
amount: 'text-red-500',
},
},
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
const pageCount = computed(() => {
const total = Number(data.value.total || 0)
return total > 0 ? Math.ceil(total / pageSize.value) : 1
})
// 账户脱敏处理
function maskName(name) {
if (!name || typeof name !== 'string')
return ''
if (name.length <= 7)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
// 银行卡号脱敏处理
function maskBankCard(cardNo) {
if (!cardNo || typeof cardNo !== 'string')
return ''
const card = cardNo.replace(/\s/g, '')
if (card.length <= 8)
return card
return `${card.substring(0, 4)} **** **** ${card.substring(card.length - 4)}`
}
// 状态转中文
function statusToChinese(status) {
return statusConfig[status]?.chinese || '未知状态'
}
// 获取状态样式
function getStatusStyle(status) {
const config = statusConfig[status] || {}
return `${config.color?.bg || 'bg-gray-100'} ${
config.color?.text || 'text-gray-800'
}`
}
// 获取小圆点颜色
function getDotColor(status) {
return statusConfig[status]?.color.dot || 'bg-gray-500'
}
// 获取金额颜色
function getAmountColor(status) {
return statusConfig[status]?.color.amount || 'text-gray-500'
}
// 获取当前页数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/withdrawal?page=${page.value}&page_size=${pageSize.value}`,
)
.get()
.json()
if (res.value?.code === 200 && !error.value)
data.value = res.value.data
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(async () => {
page.value = 1
await getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="withdraw-content">
<view
v-for="(item, index) in data.list"
:key="index"
class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm"
>
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{
item.create_time || "-"
}}
</text>
<text class="font-bold" :class="getAmountColor(item.status)">
{{ item.amount.toFixed(2) }}
</text>
</view>
<view class="mb-2 flex items-center">
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getStatusStyle(item.status)"
>
<text
class="mr-1 h-2 w-2 rounded-full"
:class="getDotColor(item.status)"
/>
{{ statusToChinese(item.status) }}
</text>
</view>
<view class="text-xs text-gray-500">
<view v-if="item.withdraw_type === 1 && item.payee_account">
收款账户{{ maskName(item.payee_account) }}
</view>
<view v-if="item.withdraw_type === 2">
<view v-if="item.bank_card_no">
银行卡号{{ maskBankCard(item.bank_card_no) }}
</view>
<view v-if="item.bank_name">
开户支行{{ item.bank_name }}
</view>
<view v-if="item.payee_name">
收款人{{ item.payee_name }}
</view>
</view>
<view v-if="item.remark">
备注{{ item.remark }}
</view>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-10 text-center text-sm text-gray-400">
暂无提现记录
</view>
</view>
<view class="pagination-wrap">
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
<text class="mt-1 block text-center text-xs text-gray-400">
{{ pageCount }}
</text>
</view>
</view>
</template>
<style scoped>
/* 保持原有样式不变 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.withdraw-content {
min-height: calc(100vh - 180px);
}
.pagination-wrap {
position: sticky;
bottom: 0;
z-index: 10;
border-top: 1px solid #f1f5f9;
background: #fff;
padding: 8px 12px 12px;
}
</style>

556
src/pages/withdraw.vue Normal file
View File

@@ -0,0 +1,556 @@
<script setup>
import { computed, onBeforeMount, ref } from 'vue'
import RealNameAuthDialog from '@/components/RealNameAuthDialog.vue'
definePage({ layout: 'default', auth: true })
const agentStore = useAgentStore()
const dialogStore = useDialogStore()
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
// ========== 提现方式配置开关 ==========
// 可以通过环境变量控制
// 配置选项(建议其中一种):
// - 'alipay'
// - 'bankcard'
// - 'both'
// - 'alipay,bankcard'
// - JSON 数组字符串: ["alipay","bankcard"]
const WITHDRAW_CONFIG = import.meta.env.VITE_WITHDRAW_METHODS
if (!WITHDRAW_CONFIG) {
throw new Error('缺少环境变量: VITE_WITHDRAW_METHODS')
}
// 解析配置:支持字符串和数组格式
function getWithdrawMethods() {
if (Array.isArray(WITHDRAW_CONFIG))
return WITHDRAW_CONFIG
if (typeof WITHDRAW_CONFIG === 'string') {
// 支持 JSON 数组字符串
if (WITHDRAW_CONFIG.trim().startsWith('[')) {
const parsed = JSON.parse(WITHDRAW_CONFIG)
if (Array.isArray(parsed))
return parsed.map(String)
}
if (WITHDRAW_CONFIG === 'alipay')
return ['alipay']
if (WITHDRAW_CONFIG === 'bankcard')
return ['bankcard']
if (WITHDRAW_CONFIG === 'both')
return ['alipay', 'bankcard']
// 支持逗号分隔的字符串: "alipay,bankcard" 或 "alipay" 或 "bankcard"
return WITHDRAW_CONFIG.split(',').map(s => s.trim()).filter(Boolean)
}
throw new Error(`VITE_WITHDRAW_METHODS 无法解析: ${WITHDRAW_CONFIG}`)
}
const enabledMethods = getWithdrawMethods()
const showAlipay = enabledMethods.includes('alipay')
const showBankcard = enabledMethods.includes('bankcard')
const showTabs = showAlipay && showBankcard // 只有两种都显示时才显示 tabs
// 标签页管理
function getInitialTab() {
// 如果只显示一种方式,直接设置为该方式
if (showAlipay && !showBankcard)
return 'alipay'
if (showBankcard && !showAlipay)
return 'bankcard'
// 如果两种都显示,默认支付宝
return 'alipay'
}
const activeTab = ref(getInitialTab())
// 状态管理
const status = ref(null)
const failMsg = ref('')
const isSubmitting = ref(false)
const showStatusPopup = ref(false)
const showTaxConfirmPopup = ref(false)
// 税务
const taxFreeAmount = ref(0)
const usedExemptionAmount = ref(0)
const remainingExemptionAmount = ref(0)
const taxRate = ref(0)
// 共用数据
const amount = ref(0)
const availableAmount = ref(null)
// 计算扣税金额和实际到账金额
const taxAmount = computed(() => {
if (!amount.value)
return 0
const withdrawAmount = Number(amount.value)
return withdrawAmount * 0.06
})
const actualAmount = computed(() => {
if (!amount.value)
return 0
return Number(amount.value) - taxAmount.value
})
// 样式配置
const statusButtonColor = {
1: '#dbeafe',
2: '#10b981',
3: '#ef4444',
}
const statusMessages = {
1: activeTab.value === 'alipay' ? '提现申请处理中,请稍后再查询结果' : '提现申请已提交,等待管理员审核',
2: '提现成功',
3: '提现失败',
}
// 表单数据 - 支付宝
const alipayAccount = ref('')
const alipayRealName = ref('')
// 表单数据 - 银行卡
const bankCardNo = ref('')
const bankName = ref('')
const bankCardInfo = ref({
bank_card_no: '',
bank_name: '',
payee_name: '',
id_card: '',
})
// 标签页切换
function onTabChange(name) {
activeTab.value = name
if (name === 'bankcard') {
loadBankCardInfo()
}
resetForm()
}
// 加载银行卡信息
async function loadBankCardInfo() {
try {
const { data, error } = await useApiFetch('/agent/withdrawal/bank-card/info')
.get()
.json()
if (data.value?.code === 200 && !error.value) {
bankCardInfo.value = data.value.data
// 自动填充历史银行卡信息
if (data.value.data.bank_card_no) {
bankCardNo.value = data.value.data.bank_card_no
}
if (data.value.data.bank_name) {
bankName.value = data.value.data.bank_name
}
}
}
catch (err) {
console.error('加载银行卡信息失败', err)
}
}
// 格式化身份证号显示前6位和后4位
function formatIdCard(idCard) {
if (!idCard)
return ''
if (idCard.length <= 10)
return idCard
return `${idCard.substring(0, 6)}****${idCard.substring(idCard.length - 4)}`
}
async function getData() {
const { data: res, error } = await useApiFetch('/agent/revenue')
.get()
.json()
if (res.value?.code === 200 && !error.value) {
availableAmount.value = res.value.data.balance
}
}
onBeforeMount(() => {
getData()
getTax()
// 根据配置加载对应的数据
if (showBankcard && (activeTab.value === 'bankcard' || !showTabs)) {
loadBankCardInfo()
}
})
function validateForm() {
if (activeTab.value === 'alipay') {
// 支付宝提现验证
if (!alipayRealName.value.trim()) {
toast('请输入账户实名姓名')
return false
}
if (!/^[\u4E00-\u9FA5]{2,4}$/.test(alipayRealName.value)) {
toast('请输入2-4位中文姓名')
return false
}
if (!alipayAccount.value.trim()) {
toast('请输入支付宝账号')
return false
}
}
else {
// 银行卡提现验证
if (!bankCardNo.value.trim()) {
toast('请输入银行卡号')
return false
}
const cardNo = String(bankCardNo.value).replace(/\s/g, '')
if (!/^\d{16,19}$/.test(cardNo)) {
toast('银行卡号格式不正确请输入16-19位数字')
return false
}
if (!bankName.value.trim()) {
toast('请输入开户支行')
return false
}
}
const amountNum = Number(amount.value)
if (!amount.value || Number.isNaN(amountNum)) {
toast('请输入有效金额')
return false
}
if (amountNum < 50) {
toast('提现金额不能低于50元')
return false
}
if (amountNum > availableAmount.value) {
toast('超过可提现金额')
return false
}
return true
}
// 打开实名认证对话框
function openRealNameAuth() {
dialogStore.openRealNameAuth()
}
// 获取税务
async function getTax() {
const { data, error } = await useApiFetch('/agent/withdrawal/tax/exemption')
.get()
.json()
if (data.value?.code === 200 && !error.value) {
taxFreeAmount.value = data.value.data.total_exemption_amount
usedExemptionAmount.value = data.value.data.used_exemption_amount
remainingExemptionAmount.value = data.value.data.remaining_exemption_amount
taxRate.value = data.value.data.tax_rate
}
}
async function handleSubmit() {
// 检查实名认证状态
if (!agentStore.isRealName) {
toast('请先完成实名认证')
openRealNameAuth()
return
}
// 先进行表单验证
if (!validateForm())
return
// 显示税务确认弹窗
showTaxConfirmPopup.value = true
}
// 确认提现
async function confirmWithdraw() {
isSubmitting.value = true
try {
let apiUrl, requestData
if (activeTab.value === 'alipay') {
// 支付宝提现
apiUrl = '/agent/withdrawal'
requestData = {
payee_account: alipayAccount.value,
amount: amount.value,
payee_name: alipayRealName.value,
}
}
else {
// 银行卡提现
apiUrl = '/agent/withdrawal/bank-card'
requestData = {
bank_card_no: String(bankCardNo.value).replace(/\s/g, ''),
bank_name: bankName.value,
amount: amount.value,
}
}
const { data } = await useApiFetch(apiUrl)
.post(requestData)
.json()
if (data.value?.code === 200) {
status.value = data.value.data.status
showTaxConfirmPopup.value = false
showStatusPopup.value = true
if (status.value === 3) {
failMsg.value = data.value.data.fail_msg || '提现失败'
}
getData()
}
else {
toast(data.value?.msg || '提现失败')
}
}
catch (err) {
toast('提现失败,请重试')
console.error('提现失败', err)
}
finally {
isSubmitting.value = false
}
}
// 弹窗操作
function handlePopupAction() {
if (status.value === 3) {
showStatusPopup.value = false
resetForm()
}
else {
showStatusPopup.value = false
if (status.value === 2)
resetPage()
}
}
// 填充最大金额
function fillMaxAmount() {
amount.value = availableAmount.value
}
// 重置页面
function resetForm() {
status.value = null
if (activeTab.value === 'alipay') {
alipayAccount.value = ''
alipayRealName.value = ''
}
else {
// 银行卡提现不清空,保留历史信息
// bankCardNo.value = "";
// bankName.value = "";
}
amount.value = ''
}
function resetPage() {
resetForm()
// 可以跳转到提现记录页面
}
</script>
<template>
<view class="withdraw-page min-h-screen from-blue-50/30 to-gray-50 bg-gradient-to-b p-4">
<view v-if="!agentStore.isRealName" class="mb-4 rounded-xl bg-amber-50 p-5 shadow">
<view class="mb-2 flex items-center text-amber-700">
<text class="mr-2">
</text>
<text class="text-lg font-bold">
未完成实名认证
</text>
</view>
<text class="mb-4 block text-sm text-amber-700/90">
根据相关规定提现功能需要完成实名认证后才能使用提现金额将转入您实名认证的账户中
</text>
<wd-button block type="warning" @click="openRealNameAuth">
立即实名认证
</wd-button>
</view>
<view class="rounded-xl bg-white p-4 shadow">
<view v-if="showTabs" class="mb-4 flex gap-2">
<wd-button size="small" :type="activeTab === 'alipay' ? 'primary' : 'info'" @click="onTabChange('alipay')">
支付宝提现
</wd-button>
<wd-button size="small" :type="activeTab === 'bankcard' ? 'primary' : 'info'" @click="onTabChange('bankcard')">
银行卡提现
</wd-button>
</view>
<view v-if="(activeTab === 'alipay' && showAlipay) || (showAlipay && !showBankcard)" class="mb-4 rounded-xl bg-blue-50/60 p-4">
<text class="mb-3 block text-base text-blue-700 font-semibold">
支付宝提现
</text>
<wd-input v-model="alipayAccount" label="支付宝账号" clearable placeholder="请输入支付宝账号" />
<text class="mb-3 mt-1 block text-xs text-gray-500">
可填写支付宝账户绑定的手机号
</text>
<wd-input v-model="alipayRealName" label="实名姓名" clearable placeholder="请输入支付宝认证姓名" />
<text class="mt-1 block text-xs text-gray-500">
请填写支付宝账户认证的真实姓名
</text>
</view>
<view v-if="(activeTab === 'bankcard' && showBankcard) || (showBankcard && !showAlipay)" class="mb-4 rounded-xl bg-blue-50/60 p-4">
<text class="mb-3 block text-base text-blue-700 font-semibold">
银行卡提现
</text>
<view v-if="bankCardInfo.payee_name" class="mb-3 rounded-lg bg-blue-100/70 p-3 text-sm text-gray-700">
<text class="mb-1 block">
姓名{{ bankCardInfo.payee_name }}
</text>
<text class="block">
身份证号{{ formatIdCard(bankCardInfo.id_card) }}
</text>
<text class="mt-2 block text-xs text-amber-600">
提示银行卡信息需与实名认证信息一致
</text>
</view>
<wd-input v-model="bankCardNo" label="银行卡号" clearable type="number" placeholder="请输入银行卡号16-19位" />
<text class="mb-3 mt-1 block text-xs text-gray-500">
请输入16-19位银行卡号
</text>
<wd-input v-model="bankName" label="开户支行" clearable placeholder="请输入开户支行" />
<text class="mt-1 block text-xs text-gray-500">
例如中国工商银行XX支行
</text>
</view>
<view class="mb-2 text-sm text-gray-700">
提现金额
</view>
<view class="mb-3 flex items-center gap-2">
<wd-input v-model.number="amount" class="flex-1" type="number" placeholder="请输入提现金额" />
<wd-button size="small" type="primary" plain @click="fillMaxAmount">
全部提现
</wd-button>
</view>
<text class="mb-4 block text-sm text-gray-600">
可提现金额<text class="text-blue-600 font-semibold">
¥{{ availableAmount || 0 }}
</text>
</text>
<view class="mb-4 rounded-xl bg-blue-50 p-3">
<text class="mb-1 block text-sm text-blue-700 font-medium">
提现须知
</text>
<text class="block text-xs text-gray-600">
· 每日限提现1次最低50元
</text>
<text class="block text-xs text-gray-600">
· 提现收取6%税收
</text>
<text class="block text-xs text-gray-600">
· {{ activeTab === 'alipay' ? '到账时间24小时内' : '到账时间:管理员审核后手动转账' }}
</text>
</view>
<view class="mb-4 rounded-xl bg-amber-50 p-3">
<text class="mb-1 block text-sm text-amber-700 font-medium">
税收说明
</text>
<text class="block text-xs text-gray-600">
根据相关规定提现时将统一收取6%的税收
</text>
<text class="block text-xs text-amber-600">
税率标准统一按6%收取
</text>
<text class="block text-xs text-amber-600">
适用范围所有提现金额
</text>
</view>
<wd-button block type="primary" :loading="isSubmitting" @click="handleSubmit">
立即提现
</wd-button>
</view>
<view v-if="showTaxConfirmPopup" class="modal-mask">
<view class="modal-card">
<text class="mb-2 block text-center text-lg font-semibold">
提现确认
</text>
<text class="mb-4 block text-center text-sm text-gray-500">
请确认以下提现信息
</text>
<view class="mb-4 rounded-lg bg-gray-50 p-3 text-sm">
<view class="mb-1 flex justify-between">
<text>提现金额</text><text>¥{{ amount }}</text>
</view>
<view class="mb-1 flex justify-between">
<text>税收</text><text class="text-red-500">
-¥{{ taxAmount.toFixed(2) }}
</text>
</view>
<view class="flex justify-between border-t pt-2">
<text>实际到账</text><text class="text-blue-600 font-semibold">
¥{{ actualAmount.toFixed(2) }}
</text>
</view>
</view>
<view class="flex gap-2">
<wd-button plain block @click="showTaxConfirmPopup = false">
取消
</wd-button>
<wd-button block type="primary" :loading="isSubmitting" @click="confirmWithdraw">
确认提现
</wd-button>
</view>
</view>
</view>
<view v-if="showStatusPopup" class="modal-mask">
<view class="modal-card">
<text class="mb-2 block text-center text-3xl">
{{ status === 1 ? '⏳' : status === 2 ? '✅' : '❌' }}
</text>
<text class="mb-1 block text-center text-lg font-semibold" :class="status === 2 ? 'text-green-600' : status === 3 ? 'text-red-600' : 'text-blue-600'">
{{ statusMessages[status] }}
</text>
<text v-if="status === 2" class="mb-2 block text-center text-sm text-gray-600">
{{ activeTab === 'alipay' ? `已向 ${alipayAccount} 转账` : '提现申请已通过管理员将手动转账' }}
</text>
<text v-if="status === 3" class="mb-2 block text-center text-sm text-red-500">
{{ failMsg }}
</text>
<view v-if="status === 1" class="mb-3 mt-2 h-2 w-full overflow-hidden rounded-full bg-blue-100">
<view class="h-full w-3/5 rounded-full bg-blue-500" />
</view>
<wd-button block :custom-style="`background:${statusButtonColor[status]};color:${status === 1 ? '#2563eb' : '#fff'}`" @click="handlePopupAction">
{{ status === 1 ? '知道了' : status === 2 ? '完成' : '重新提现' }}
</wd-button>
</view>
</view>
</view>
<RealNameAuthDialog />
</template>
<style scoped>
.modal-mask {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
padding: 20px;
}
.modal-card {
width: 100%;
max-width: 360px;
border-radius: 16px;
background: #fff;
padding: 20px;
}
</style>