first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
unpackage/
|
||||
dist/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tmp
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,18 @@
|
||||
# 2026-09-15 工作记录
|
||||
|
||||
## npm run dev:h5 报错修复
|
||||
|
||||
### 问题
|
||||
运行 时报错:
|
||||
|
||||
|
||||
### 原因
|
||||
nvm 默认 Node.js 版本为 **14.21.3**,但 CLI 依赖的 包使用了 (nullish coalescing assignment)运算符,该语法需要 **Node.js 15+**。
|
||||
|
||||
### 解决
|
||||
将 nvm 默认版本切换至 Node 18:
|
||||
|
||||
切换后 正常运行,H5 开发服务器监听在 **http://localhost:5174**。
|
||||
|
||||
### 建议
|
||||
- 在项目根目录添加 文件,内容为 ,确保后续协作时自动使用正确版本
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>全犀云CDN</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5385
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "quanxiyun-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "全犀云移动端",
|
||||
"scripts": {
|
||||
"dev:h5": "uni",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build",
|
||||
"build:mp-weixin": "uni build -p mp-weixin"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-components": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-h5": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-5010520260709002",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9",
|
||||
"pinia": "^2.0.36",
|
||||
"uview-plus": "^3.3.43"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-5010520260709002",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"rollup": "4.14.3",
|
||||
"vite": "5.2.8",
|
||||
"sass": "^1.77.6"
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
|
||||
onLaunch(() => {
|
||||
console.log('App Launch')
|
||||
|
||||
// 已登录用户:token 有效则直接跳转首页
|
||||
const token = uni.getStorageSync('token')
|
||||
const userInfo = uni.getStorageSync('userInfo')
|
||||
if (token && token !== 'dev-token' && userInfo) {
|
||||
const role = userInfo.role || 'c'
|
||||
if (role === 'c') {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
} else {
|
||||
const ROLE_HOME = {
|
||||
leader: 'pages/b-home/index',
|
||||
super_leader: 'pages/b-super-home/index',
|
||||
manager: 'pages/b-manager-home/index'
|
||||
}
|
||||
uni.reLaunch({ url: '/' + (ROLE_HOME[role] || 'pages/index/index') })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 开发模式:URL 带 ?dev=<role> 绕过登录
|
||||
const devRole = new URLSearchParams(window.location.search).get('dev')
|
||||
if (devRole) {
|
||||
uni.setStorageSync('token', 'dev-token')
|
||||
uni.setStorageSync('userRole', devRole)
|
||||
|
||||
const hash = window.location.hash.replace('#/', '').split('?')[0]
|
||||
if (hash && hash !== 'pages/index/index') {
|
||||
console.log('[dev] role=' + devRole + ' 已在目标页 ' + hash + ',跳过跳转')
|
||||
return
|
||||
}
|
||||
|
||||
const ROLE_HOME = {
|
||||
leader: 'pages/b-home/index',
|
||||
super_leader: 'pages/b-super-home/index',
|
||||
manager: 'pages/b-manager-home/index'
|
||||
}
|
||||
const target = ROLE_HOME[devRole] || 'pages/index/index'
|
||||
console.log('[dev] role=' + devRole + ' target=' + target)
|
||||
|
||||
if (devRole === 'c') {
|
||||
uni.switchTab({ url: '/' + target })
|
||||
} else {
|
||||
uni.reLaunch({ url: '/' + target })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 已在登录页则不再跳转,避免 H5 下 reLaunch 触发 TabBar 回落到首页
|
||||
const hash = window.location.hash.replace('#/', '').split('?')[0]
|
||||
if (hash && hash !== 'pages/login/index') {
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 各页面自行渲染 CustomTabBar,App 不重复渲染 -->
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/* 全局样式 */
|
||||
@import 'uview-plus/index.scss';
|
||||
|
||||
/* #ifdef H5 */
|
||||
/* 全局 reset — 对齐 C端.html(仅H5,小程序不支持*选择器) */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
page {
|
||||
background-color: #F2F4F8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.4rpx;
|
||||
}
|
||||
|
||||
/* #ifdef H5 */
|
||||
/* 手机适配容器 — 与 C端.html .app 一致:430px 居中,两侧深色背景(仅H5) */
|
||||
#app {
|
||||
max-width: 430px;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
background: #F2F4F8;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
box-shadow: 0 0 60px rgba(0,0,0,.12);
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* 通用工具类 */
|
||||
.flex { display: flex; }
|
||||
.flex-center { display: flex; align-items: center; justify-content: center; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
.flex-column { display: flex; flex-direction: column; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 安全区域适配 */
|
||||
.safe-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* 隐藏 uni-app 原生 <uni-tabbar>,使用各页面内的 CustomTabBar */
|
||||
uni-tabbar { display: none !important; }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<view class="b-top-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="b-top-bar-inner">
|
||||
<view class="b-top-left">
|
||||
<view class="b-top-dot"></view>
|
||||
<text class="b-top-title">{{ title }}</text>
|
||||
</view>
|
||||
<view class="b-top-right">
|
||||
<view v-if="showBell" class="b-top-icon" @click="$emit('bellClick')">
|
||||
<u-icon name="bell" size="20" color="#5A6072"></u-icon>
|
||||
<view v-if="dot" class="b-top-red-dot"></view>
|
||||
</view>
|
||||
<view v-if="showSettings" class="b-top-icon" @click="$emit('settingsClick')">
|
||||
<u-icon name="setting" size="20" color="#5A6072"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
showBell: { type: Boolean, default: false },
|
||||
showSettings: { type: Boolean, default: false },
|
||||
dot: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['bellClick', 'settingsClick'])
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
uni.getSystemInfo({
|
||||
success: (res) => {
|
||||
statusBarHeight.value = res.statusBarHeight || 20
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.b-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.b-top-bar-inner {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.b-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.b-top-dot {
|
||||
width: 10rpx;
|
||||
height: 40rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 6rpx;
|
||||
box-shadow: 0 0 16rpx rgba(22, 119, 255, 0.5);
|
||||
}
|
||||
|
||||
.b-top-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.b-top-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.b-top-icon {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.b-top-icon:active {
|
||||
background: #F3F5F9;
|
||||
}
|
||||
|
||||
.b-top-red-dot {
|
||||
position: absolute;
|
||||
top: 14rpx;
|
||||
right: 14rpx;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
background: #FF4D4F;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid #fff;
|
||||
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.4);
|
||||
animation: pulse-ring 1.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.5); }
|
||||
70% { box-shadow: 0 0 0 12rpx rgba(255, 77, 79, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<view class="custom-tabbar safe-bottom">
|
||||
<view
|
||||
v-for="(item, index) in tabList"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
:class="{ active: currentIndex === index }"
|
||||
@click="switchTab(index, item.pagePath)"
|
||||
>
|
||||
<view class="tab-icon">
|
||||
<text class="tab-icon-text" v-if="item.emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="24" :color="currentIndex === index ? '#1677ff' : '#999'"></u-icon>
|
||||
</view>
|
||||
<text class="tab-text" :style="{ color: currentIndex === index ? '#1677ff' : '#999' }">{{ item.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const props = defineProps({
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const currentIndex = ref(props.current)
|
||||
|
||||
watch(() => props.current, (val) => {
|
||||
currentIndex.value = val
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// C端 TabBar:用 emoji 做图标,避免 uView icon 名称不匹配
|
||||
const C_TABS = [
|
||||
{ pagePath: '/pages/index/index', text: '首页', emoji: '🏠' },
|
||||
{ pagePath: '/pages/devices/index', text: '设备', emoji: '🖥️' },
|
||||
{ pagePath: '/pages/revenue/index', text: '收益', emoji: '💰' },
|
||||
{ pagePath: '/pages/mall/index', text: '商城', emoji: '🛒' },
|
||||
{ pagePath: '/pages/profile/index', text: '我的', emoji: '👤' }
|
||||
]
|
||||
|
||||
// 普通团长 TabBar 配置
|
||||
const B_TABS = [
|
||||
{ pagePath: '/pages/b-home/index', text: '首页', emoji: '🏠' },
|
||||
{ pagePath: '/pages/b-team/index', text: '团队', emoji: '👥' },
|
||||
{ pagePath: '/pages/b-revenue/index', text: '收益', emoji: '💰' },
|
||||
{ pagePath: '/pages/b-profile/index', text: '我的', emoji: '👤' }
|
||||
]
|
||||
|
||||
// 超级团长 TabBar 配置
|
||||
const SUPER_B_TABS = [
|
||||
{ pagePath: '/pages/b-super-home/index', text: '首页', emoji: '🏠' },
|
||||
{ pagePath: '/pages/b-super-team/index', text: '团队', emoji: '👥' },
|
||||
{ pagePath: '/pages/b-super-revenue/index', text: '收益', emoji: '💰' },
|
||||
{ pagePath: '/pages/b-super-profile/index', text: '我的', emoji: '👤' }
|
||||
]
|
||||
|
||||
// 商务经理 TabBar 配置
|
||||
const MANAGER_TABS = [
|
||||
{ pagePath: '/pages/b-manager-home/index', text: '首页', emoji: '🏠' },
|
||||
{ pagePath: '/pages/b-manager-team/index', text: '团队', emoji: '👥' },
|
||||
{ pagePath: '/pages/b-manager-revenue/index', text: '收益', emoji: '💰' },
|
||||
{ pagePath: '/pages/b-manager-profile/index', text: '我的', emoji: '👤' }
|
||||
]
|
||||
|
||||
const tabList = computed(() => {
|
||||
const role = userStore.currentRole
|
||||
if (role === 'super_leader') {
|
||||
return SUPER_B_TABS
|
||||
}
|
||||
if (role === 'manager') {
|
||||
return MANAGER_TABS
|
||||
}
|
||||
if (role === 'leader') {
|
||||
return B_TABS
|
||||
}
|
||||
return C_TABS
|
||||
})
|
||||
|
||||
const switchTab = (index, url) => {
|
||||
// C端页面在 tabBar.list 中,用 switchTab 性能更好
|
||||
// B端页面不在 tabBar.list 中,只能用 reLaunch
|
||||
const role = userStore.currentRole
|
||||
if (role === 'c') {
|
||||
uni.switchTab({ url })
|
||||
} else {
|
||||
uni.reLaunch({ url })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 430px;
|
||||
height: 108rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 0.5px solid #EAEDF3;
|
||||
display: flex;
|
||||
box-shadow: 0 -2px 12px rgba(15, 18, 38, 0.04);
|
||||
z-index: 999;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
color: #BCC2CE;
|
||||
font-size: 20rpx;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active {
|
||||
color: #1677FF;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 44rpx;
|
||||
height: 6rpx;
|
||||
background: linear-gradient(90deg, #4096FF, #1677FF);
|
||||
border-radius: 0 0 6rpx 6rpx;
|
||||
box-shadow: 0 0 8px rgba(22, 119, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon { line-height: 1; }
|
||||
|
||||
.tab-icon-text { font-size: 40rpx; line-height: 1; transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
|
||||
.tab-item.active .tab-icon-text {
|
||||
transform: translateY(-2rpx) scale(1.05);
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 20rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
|
||||
.tab-item.active .tab-text {
|
||||
color: #1677FF;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<!--
|
||||
* 设备卡片组件 — 对齐 C端.html .device-card 样式
|
||||
* 左侧彩色状态条 + 名称/型号 + 来源标签 + 4格指标
|
||||
-->
|
||||
<template>
|
||||
<view class="device-card" :class="[statusClass, sourceClass]" @click="$emit('click', device)">
|
||||
<!-- 顶部行:名称 + 来源标签 + 状态 -->
|
||||
<view class="dc-top">
|
||||
<view class="dc-top-left">
|
||||
<text class="dc-name">{{ device.name || '未命名设备' }}</text>
|
||||
<text v-if="device.vipLevel" class="dc-vip">VIP{{ device.vipLevel }}</text>
|
||||
<text class="dc-source-tag" :class="isOwn ? 'dc-src-own' : 'dc-src-buy'">{{ isOwn ? '自有部署' : '平台购买' }}</text>
|
||||
</view>
|
||||
<text class="dc-status-badge" :class="statusBadgeClass">{{ statusConfig.label }}</text>
|
||||
</view>
|
||||
<text class="dc-model">{{ device.model || '' }}</text>
|
||||
|
||||
<!-- 4格指标 -->
|
||||
<view class="dc-stats">
|
||||
<view class="dc-stat">
|
||||
<text class="dc-stat-val">{{ device.bandwidth > 0 ? device.bandwidth : '—' }}</text>
|
||||
<text class="dc-stat-label">带宽 Mbps</text>
|
||||
</view>
|
||||
<!-- 信用分暂时隐藏
|
||||
<view class="dc-stat">
|
||||
<text class="dc-stat-val" :class="creditColor">{{ device.creditScore || '—' }}</text>
|
||||
<text class="dc-stat-label">信用分</text>
|
||||
</view>
|
||||
-->
|
||||
<view class="dc-stat">
|
||||
<text class="dc-stat-val">¥{{ formatMoney(device.todayRevenue || 0) }}</text>
|
||||
<text class="dc-stat-label">今日产出</text>
|
||||
</view>
|
||||
<view class="dc-stat">
|
||||
<text class="dc-stat-val">¥{{ formatMoney(device.totalRevenue || 0) }}</text>
|
||||
<text class="dc-stat-label">累计积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatMoney } from '@/utils/util'
|
||||
|
||||
const props = defineProps({
|
||||
device: { type: Object, required: true }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const isOwn = computed(() => props.device.source === 'own')
|
||||
|
||||
const statusConfig = computed(() => {
|
||||
const map = {
|
||||
running: { label: '运行中', cls: 'b-s' },
|
||||
stable: { label: '稳定期(70%)', cls: 'b-w' },
|
||||
deploying: { label: '运输中', cls: 'b-i' },
|
||||
maintaining: { label: '维修中', cls: 'b-d' },
|
||||
offline: { label: '已下线', cls: 'b-n' },
|
||||
abnormal: { label: '异常', cls: 'b-d' },
|
||||
frozen: { label: '已冻结', cls: 'b-n' },
|
||||
error: { label: '异常', cls: 'b-d' }
|
||||
}
|
||||
return map[props.device.status] || { label: '未知', cls: 'b-n' }
|
||||
})
|
||||
|
||||
const statusBadgeClass = computed(() => statusConfig.value.cls)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const s = props.device.status
|
||||
if (s === 'error' || s === 'abnormal' || s === 'maintaining') return 'd-danger'
|
||||
if (s === 'offline' || s === 'frozen' || s === 'deploying') return 'd-off'
|
||||
if (s === 'stable') return 'd-warn'
|
||||
return ''
|
||||
})
|
||||
|
||||
const sourceClass = computed(() => isOwn.value ? 'd-own' : '')
|
||||
|
||||
// 信用分暂时隐藏
|
||||
// const creditColor = computed(() => {
|
||||
// const score = props.device.creditScore || 0
|
||||
// if (score >= 95) return 'credit-high'
|
||||
// if (score >= 80) return 'credit-normal'
|
||||
// if (score >= 60) return 'credit-low'
|
||||
// return 'credit-bad'
|
||||
// })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$s: #00C4A7;
|
||||
$w: #FF8800;
|
||||
$d: #FF4D4F;
|
||||
$o: #722ED1;
|
||||
$p: #1677FF;
|
||||
$bg: #F7F9FC;
|
||||
$bg2: #F3F5F9;
|
||||
|
||||
.device-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid #EAEDF3;
|
||||
transition: all 0.2s;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: linear-gradient(180deg, $s, #5FE6CC);
|
||||
}
|
||||
|
||||
&.d-warn::before { background: linear-gradient(180deg, $w, #FFB84D); }
|
||||
&.d-off::before { background: linear-gradient(180deg, #BCC2CE, #D8DCE5); }
|
||||
&.d-danger::before { background: linear-gradient(180deg, $d, #FF7A7B); }
|
||||
|
||||
&.d-own {
|
||||
border-color: rgba(114, 46, 209, 0.1);
|
||||
&::before { background: linear-gradient(180deg, $o, #B37FEB); }
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
box-shadow: 0 4px 16px rgba(15, 18, 38, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// Top Row
|
||||
.dc-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.dc-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
}
|
||||
|
||||
.dc-vip {
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
font-weight: 500;
|
||||
background: #E6F4FF;
|
||||
color: $p;
|
||||
}
|
||||
|
||||
.dc-source-tag {
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4rpx;
|
||||
|
||||
&.dc-src-buy { background: #E6F4FF; color: $p; }
|
||||
&.dc-src-own { background: #F9F0FF; color: $o; }
|
||||
}
|
||||
|
||||
.dc-model {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
.dc-status-badge {
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 8rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.b-s { background: #E6FAF5; color: $s; }
|
||||
&.b-w { background: #FFF7E6; color: $w; }
|
||||
&.b-i { background: #E6F4FF; color: $p; }
|
||||
&.b-d { background: #FFF1F0; color: $d; }
|
||||
&.b-n { background: #F0F2F5; color: #6B7280; }
|
||||
}
|
||||
|
||||
// 4-Grid Stats
|
||||
.dc-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.dc-stat {
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, $bg, $bg2);
|
||||
border-radius: 10rpx;
|
||||
padding: 18rpx 4rpx;
|
||||
}
|
||||
|
||||
.device-card.d-own .dc-stat {
|
||||
background: linear-gradient(180deg, #F9F0FF, #FBF6FF);
|
||||
}
|
||||
|
||||
.dc-stat-val {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $p;
|
||||
font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-card.d-own .dc-stat-val {
|
||||
color: $o;
|
||||
}
|
||||
|
||||
.dc-stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.credit-high { color: $s !important; }
|
||||
.credit-normal { color: $s !important; }
|
||||
.credit-low { color: $w !important; }
|
||||
.credit-bad { color: $d !important; }
|
||||
|
||||
.device-card.d-own .credit-high,
|
||||
.device-card.d-own .credit-normal { color: $s !important; }
|
||||
.device-card.d-own .credit-low { color: $w !important; }
|
||||
.device-card.d-own .credit-bad { color: $d !important; }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
* 空状态组件
|
||||
* 用于列表无数据时的占位展示
|
||||
-->
|
||||
<template>
|
||||
<view class="empty-container">
|
||||
<u-empty :text="text" :mode="mode" :icon="icon" :margin-top="marginTop">
|
||||
<template v-if="$slots.default">
|
||||
<slot />
|
||||
</template>
|
||||
</u-empty>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: '暂无数据' },
|
||||
mode: { type: String, default: 'data' },
|
||||
icon: { type: String, default: '' },
|
||||
marginTop: { type: [Number, String], default: 100 }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
* 自定义导航栏 — 对齐 C端.html .top-bar
|
||||
* flex + gap 布局,不设固定左右宽度
|
||||
-->
|
||||
<template>
|
||||
<view class="nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="nav-content" :style="{ height: navBarHeight + 'px' }">
|
||||
<slot name="left">
|
||||
<u-icon v-if="showBack" name="arrow-left" size="20" color="#333" @click="handleBack" />
|
||||
</slot>
|
||||
<slot name="center">
|
||||
<view v-if="title" class="nav-title-box">
|
||||
<view v-if="!showBack" class="nav-dot"></view>
|
||||
<text class="nav-title">{{ title }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
<view class="nav-spacer" />
|
||||
<slot name="right" />
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ height: (statusBarHeight + navBarHeight) + 'px' }"></view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
showBack: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const statusBarHeight = ref(20)
|
||||
const navBarHeight = ref(44)
|
||||
|
||||
uni.getSystemInfo({ success: r => { statusBarHeight.value = r.statusBarHeight || 20 } })
|
||||
|
||||
const handleBack = () => uni.navigateBack()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-bar {
|
||||
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
|
||||
width: 100%; max-width: 430px; z-index: 999;
|
||||
background: rgba(255,255,255,.88);
|
||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: .5px solid #EAEDF3;
|
||||
}
|
||||
.nav-content {
|
||||
display: flex; align-items: center; gap: 12px; padding: 0 16px;
|
||||
}
|
||||
.nav-spacer { flex: 1; }
|
||||
.nav-title-box { display: flex; align-items: center; gap: 6px; }
|
||||
.nav-dot { width: 5px; height: 20px; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3px; box-shadow: 0 0 8px rgba(22,119,255,.5); flex-shrink: 0; }
|
||||
.nav-title { font-size: 17px; font-weight: 700; color: #0F1226; letter-spacing: .3px; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<!--
|
||||
* 最新动态列表 — B端共享组件
|
||||
* 普通团长 / 超级团长 / 商务经理 首页共用
|
||||
* @prop items - 动态数据数组 [{ emoji, bg, title, category, time }]
|
||||
* @prop role - 角色标识,预留后续按角色分权限展示
|
||||
* @event item-click - 点击动态项,传出 item 对象
|
||||
-->
|
||||
<template>
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最新动态</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id || idx"
|
||||
class="news-card"
|
||||
@click="$emit('item-click', item)"
|
||||
>
|
||||
<view class="nc-dot" :style="{ background: item.dotColor }"></view>
|
||||
<view class="nc-body">
|
||||
<text class="nc-title">{{ item.title }}</text>
|
||||
<view class="nc-meta">
|
||||
<text class="nc-cat" :style="{ color: item.dotColor }">{{ item.category }}</text>
|
||||
<text class="nc-time">{{ item.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const props = defineProps({
|
||||
role: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['item-click'])
|
||||
|
||||
const items = ref([])
|
||||
const dotColors = ['#1677FF', '#00C4A7', '#FF8800', '#722ED1', '#FF4D4F']
|
||||
|
||||
let fetchingNews = false
|
||||
|
||||
const fetchNews = async () => {
|
||||
if (fetchingNews) return
|
||||
fetchingNews = true
|
||||
try {
|
||||
const res = await request.get('/c/notices', { limit: 5, showLoading: false })
|
||||
if (res.data) {
|
||||
const list = Array.isArray(res.data) ? res.data : (res.data.items || [])
|
||||
items.value = list.map((item, i) => ({
|
||||
...item,
|
||||
dotColor: dotColors[i % dotColors.length],
|
||||
category: item.subtitle || '系统通知',
|
||||
time: item.created_at
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取最新动态失败', e)
|
||||
} finally {
|
||||
fetchingNews = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchNews() })
|
||||
|
||||
defineExpose({ fetchNews })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.section { margin-bottom: 32rpx; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
letter-spacing: 0.6rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(15,18,38,.06);
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:active { background: #F7F9FC; transform: scale(0.99); }
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 34rpx;
|
||||
top: 49rpx;
|
||||
bottom: -20rpx;
|
||||
width: 2rpx;
|
||||
background: #D8DCE4;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 11rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 0 6rpx rgba(22,119,255,.08);
|
||||
}
|
||||
|
||||
.nc-body { flex: 1; min-width: 0; }
|
||||
|
||||
.nc-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #0F1226;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nc-meta {
|
||||
margin-top: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.nc-cat {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nc-time {
|
||||
font-size: 22rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<!--
|
||||
* 订单卡片组件
|
||||
* 用于商城订单列表/待支付订单列表
|
||||
-->
|
||||
<template>
|
||||
<view class="order-card" @click="$emit('click', order)">
|
||||
<view class="oc-header">
|
||||
<text class="oc-order-no">订单号:{{ order.orderNo || '--' }}</text>
|
||||
<view class="oc-status-badge" :style="{ color: statusConfig.color, background: statusConfig.bg }">
|
||||
{{ statusConfig.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="oc-body">
|
||||
<image v-if="order.image" :src="order.image" class="oc-image" mode="aspectFill" />
|
||||
<view class="oc-info">
|
||||
<text class="oc-name">{{ order.productName || '--' }}</text>
|
||||
<text class="oc-spec" v-if="order.spec">{{ order.spec }}</text>
|
||||
<text class="oc-meta">{{ order.createTime || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="oc-footer">
|
||||
<text class="oc-amount-label">应付金额</text>
|
||||
<text class="oc-amount">¥{{ formatMoney(order.amount || 0) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ORDER_STATUS_MAP } from '@/utils/constant'
|
||||
import { formatMoney } from '@/utils/util'
|
||||
|
||||
const props = defineProps({
|
||||
order: { type: Object, required: true }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const statusConfig = computed(() => {
|
||||
return ORDER_STATUS_MAP[props.order.status] || { label: '未知', color: '#999', bg: '#f0f0f0' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 18, 38, 0.04);
|
||||
|
||||
&:active {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.oc-order-no {
|
||||
font-size: 24rpx;
|
||||
color: #8B91A1;
|
||||
}
|
||||
|
||||
.oc-status-badge {
|
||||
font-size: 22rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.oc-body {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.oc-image {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f5f7fa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oc-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.oc-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
}
|
||||
|
||||
.oc-spec {
|
||||
font-size: 24rpx;
|
||||
color: #5A6072;
|
||||
}
|
||||
|
||||
.oc-meta {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.oc-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16rpx;
|
||||
border-top: 1rpx solid #f5f7fa;
|
||||
}
|
||||
|
||||
.oc-amount-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B91A1;
|
||||
}
|
||||
|
||||
.oc-amount {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #FF8800;
|
||||
font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!--
|
||||
* 收益卡片组件
|
||||
* 用于展示今日/本月/累计等收益数据
|
||||
-->
|
||||
<template>
|
||||
<view class="revenue-card" @click="$emit('click')">
|
||||
<text class="rc-label">{{ label }}</text>
|
||||
<view class="rc-value-row">
|
||||
<text class="rc-value">¥{{ formatThousands(value) }}</text>
|
||||
<view v-if="trend !== undefined" class="rc-trend" :class="trendClass">
|
||||
<text class="rc-trend-arrow">{{ trendEmoji }}</text>
|
||||
<text>{{ Math.abs(trend) }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="sub" class="rc-sub">{{ sub }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: '' },
|
||||
value: { type: Number, default: 0 },
|
||||
trend: { type: Number, default: undefined },
|
||||
sub: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const trendClass = computed(() => {
|
||||
if (props.trend === undefined) return ''
|
||||
if (props.trend > 0) return 'trend-up'
|
||||
if (props.trend < 0) return 'trend-down'
|
||||
return 'trend-flat'
|
||||
})
|
||||
|
||||
const trendEmoji = computed(() => {
|
||||
if (props.trend > 0) return '↑'
|
||||
if (props.trend < 0) return '↓'
|
||||
return '→'
|
||||
})
|
||||
|
||||
const trendColor = computed(() => {
|
||||
if (props.trend > 0) return '#00C4A7'
|
||||
if (props.trend < 0) return '#FF4D4F'
|
||||
return '#909399'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.revenue-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 18, 38, 0.04);
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.rc-label {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.rc-value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.rc-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.rc-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.trend-up { color: #00C4A7; }
|
||||
&.trend-down { color: #FF4D4F; }
|
||||
&.trend-flat { color: #909399; }
|
||||
}
|
||||
|
||||
.rc-sub {
|
||||
display: block;
|
||||
font-size: 20rpx;
|
||||
color: #8B91A1;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 全局子页面导航工具
|
||||
* 封装 uni.navigateTo / navigateBack,提供统一的子页面跳转和数据传递
|
||||
*/
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
// 路由映射表:sub page name → actual route path
|
||||
const ROUTE_MAP = {
|
||||
// 设备模块
|
||||
'device-detail': '/pages-sub/device/detail',
|
||||
'device-monitor': '/pages-sub/device/monitor',
|
||||
'monitor': '/pages-sub/device/monitor',
|
||||
'device-revenue': '/pages-sub/device/revenue-detail',
|
||||
'revenue-detail': '/pages-sub/device/revenue-detail',
|
||||
'device-log': '/pages-sub/device/log',
|
||||
'log': '/pages-sub/device/log',
|
||||
'add-own-device': '/pages-sub/device/add-own',
|
||||
'own-voucher': '/pages-sub/device/own-voucher',
|
||||
'own-orders': '/pages-sub/device/own-orders',
|
||||
'own-order-detail': '/pages-sub/device/own-order-detail',
|
||||
'deploy-guide': '/pages-sub/device/deploy-guide',
|
||||
'docking-detail': '/pages-sub/device/docking-detail',
|
||||
'credit-detail': '/pages-sub/device/credit-detail',
|
||||
'diagnosis': '/pages-sub/device/diagnosis',
|
||||
'nat-test': '/pages-sub/device/nat-test',
|
||||
'deploy-progress': '/pages-sub/device/deploy-progress',
|
||||
'device-info': '/pages-sub/device/device-info',
|
||||
|
||||
// 商城模块
|
||||
'product-detail': '/pages-sub/mall/product-detail',
|
||||
'coupon-detail': '/pages-sub/mall/coupon-detail',
|
||||
'my-coupons': '/pages-sub/mall/my-coupons',
|
||||
'mall-orders': '/pages-sub/mall/order-list',
|
||||
'pending-payment': '/pages-sub/mall/pending-payment',
|
||||
'pending-order-detail': '/pages-sub/mall/pending-order-detail',
|
||||
'welfare': '/pages-sub/mall/welfare',
|
||||
|
||||
// 收益模块
|
||||
'points-log': '/pages-sub/revenue/points-log',
|
||||
'withdraw': '/pages-sub/revenue/withdraw',
|
||||
'leaderboard': '/pages-sub/revenue/leaderboard',
|
||||
'revenue-overview': '/pages-sub/revenue/overview',
|
||||
'calculator': '/pages-sub/revenue/calculator',
|
||||
'online-reward': '/pages-sub/revenue/online-reward',
|
||||
|
||||
// 用户模块
|
||||
'user-level': '/pages-sub/user/level',
|
||||
'level-rules': '/pages-sub/user/level-rules',
|
||||
'contracts': '/pages-sub/user/contracts',
|
||||
'contract-preview': '/pages-sub/user/contract-preview',
|
||||
'tickets': '/pages-sub/user/tickets',
|
||||
'ticket-detail': '/pages-sub/user/ticket-detail',
|
||||
'ticket-create': '/pages-sub/user/ticket-create',
|
||||
'profile-edit': '/pages-sub/user/profile-edit',
|
||||
'partner-apply': '/pages-sub/user/partner-apply',
|
||||
'invitation': '/pages-sub/user/invitation',
|
||||
'user-poster': '/pages-sub/user/poster',
|
||||
'realname-auth': '/pages-sub/user/realname-auth',
|
||||
'settings': '/pages-sub/user/settings',
|
||||
'promotion-center': '/pages-sub/user/promotion-center',
|
||||
'help-center': '/pages-sub/user/help-center',
|
||||
'wallet': '/pages-sub/user/wallet',
|
||||
|
||||
// B端推广模块
|
||||
'b-sub-promo-home': '/pages-sub/b-promotion/promo-home',
|
||||
'b-sub-leader-promo': '/pages-sub/b-promotion/leader-promo',
|
||||
'b-team': '/pages/b-team/index',
|
||||
'b-sub-poster': '/pages-sub/b-promotion/poster',
|
||||
'b-sub-poster-leader': '/pages-sub/b-promotion/leader-poster',
|
||||
'b-level': '/pages-sub/b-promotion/b-level',
|
||||
'b-sub-level': '/pages-sub/b-promotion/b-level',
|
||||
|
||||
// 商务经理推广模块
|
||||
'b-sub-manager-promo': '/pages-sub/b-promotion/manager-promo',
|
||||
'b-sub-manager-team': '/pages/b-manager-team/index',
|
||||
'b-sub-manager-poster': '/pages-sub/b-promotion/manager-poster',
|
||||
'b-sub-manager-level': '/pages-sub/b-promotion/manager-level',
|
||||
'b-sub-leader-detail': '/pages-sub/b-promotion/leader-detail',
|
||||
|
||||
// 内容模块
|
||||
'notifications': '/pages-sub/content/notices',
|
||||
'news-list': '/pages-sub/content/news-list',
|
||||
'news-detail': '/pages-sub/content/news-detail',
|
||||
'notice-detail': '/pages-sub/content/notice-detail',
|
||||
'docs': '/pages-sub/content/docs',
|
||||
'doc-detail': '/pages-sub/content/doc-detail',
|
||||
'software': '/pages-sub/content/software'
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开子页面
|
||||
* @param {string} name - 子页面标识名,对应 ROUTE_MAP 中的 key
|
||||
* @param {object} params - 传递给子页面的参数(会编码到URL query中)
|
||||
*/
|
||||
export function openSub(name, params = {}) {
|
||||
const route = ROUTE_MAP[name]
|
||||
if (!route) {
|
||||
console.warn(`[useNavigation] Unknown subpage: ${name}`)
|
||||
uni.showToast({ title: `页面 ${name} 未配置路由`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// 构建URL参数
|
||||
const queryParts = []
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryParts.push(`${key}=${encodeURIComponent(value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const url = queryParts.length > 0 ? `${route}?${queryParts.join('&')}` : route
|
||||
|
||||
uni.navigateTo({
|
||||
url,
|
||||
animationType: 'slide-in-right',
|
||||
animationDuration: 300,
|
||||
fail: (err) => {
|
||||
console.error(`[useNavigation] Failed to open ${name}:`, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭当前子页面,返回上一页
|
||||
*/
|
||||
export function closeSub() {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到TabBar页面
|
||||
*/
|
||||
export function switchTab(tabName) {
|
||||
const tabMap = {
|
||||
home: '/pages/index/index',
|
||||
devices: '/pages/devices/index',
|
||||
revenue: '/pages/revenue/index',
|
||||
mall: '/pages/mall/index',
|
||||
profile: '/pages/profile/index',
|
||||
'b-home': '/pages/b-home/index',
|
||||
'b-team': '/pages/b-team/index',
|
||||
'b-revenue': '/pages/b-revenue/index',
|
||||
'b-profile': '/pages/b-profile/index',
|
||||
'b-super-home': '/pages/b-super-home/index',
|
||||
'b-super-team': '/pages/b-super-team/index',
|
||||
'b-super-revenue': '/pages/b-super-revenue/index',
|
||||
'b-super-profile': '/pages/b-super-profile/index',
|
||||
'b-manager-home': '/pages/b-manager-home/index',
|
||||
'b-manager-team': '/pages/b-manager-team/index',
|
||||
'b-manager-revenue': '/pages/b-manager-revenue/index',
|
||||
'b-manager-profile': '/pages/b-manager-profile/index'
|
||||
}
|
||||
const url = tabMap[tabName]
|
||||
if (!url) return
|
||||
// C端页面在 tabBar.list 中,用 switchTab
|
||||
// B端页面不在 tabBar.list 中,需用 reLaunch
|
||||
if (tabName.startsWith('b-')) {
|
||||
uni.reLaunch({ url })
|
||||
} else {
|
||||
uni.switchTab({ url })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL参数中解析子页面传入的数据
|
||||
* 在子页面的 onLoad 中使用
|
||||
*/
|
||||
export function parseSubParams(options) {
|
||||
return options || {}
|
||||
}
|
||||
|
||||
export { ROUTE_MAP }
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import uviewPlus from 'uview-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(uviewPlus)
|
||||
app.use(pinia)
|
||||
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "全犀云",
|
||||
"appid": "__UNI__QUANXIYUN",
|
||||
"description": "全犀云移动端APP",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>"
|
||||
],
|
||||
"minSdkVersion": 21,
|
||||
"targetSdkVersion": 33
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wx25de044b93c55fa2",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true
|
||||
},
|
||||
"h5": {
|
||||
"title": "全犀云",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
},
|
||||
"devServer": {
|
||||
"port": 5174,
|
||||
"proxy": {
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:8000",
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/storage": {
|
||||
"target": "http://127.0.0.1:8000",
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vueVersion": "3"
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar title="团长等级权益" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Level Hero -->
|
||||
<view class="level-hero">
|
||||
<view class="lh-medal">
|
||||
<text class="lh-medal-icon">★</text>
|
||||
</view>
|
||||
<text class="lh-name">{{ currentLevelName }}</text>
|
||||
<text class="lh-sub">{{ isMaxLevel ? `已解锁${currentLevelName}权益,已达到最高等级` : `已解锁${currentLevelName}权益,距离下一等级仅差 ${targetRemain.toLocaleString()} 成长值` }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Current Level Rights -->
|
||||
<view class="card rights-card">
|
||||
<text class="card-title">当前等级权益</text>
|
||||
<view class="rights-grid">
|
||||
<view class="rights-item">
|
||||
<view class="ri-icon" style="background:#E6FAF5">
|
||||
<text class="ri-emoji">💰</text>
|
||||
</view>
|
||||
<text class="ri-text">直接推荐 C 端<text class="ri-highlight">{{ currentR1 }}% 即时收益</text></text>
|
||||
</view>
|
||||
<view class="rights-item">
|
||||
<view class="ri-icon" style="background:#E6F4FF">
|
||||
<text class="ri-emoji">🕐</text>
|
||||
</view>
|
||||
<text class="ri-text">直接推荐 C 端<text class="ri-highlight">{{ currentR2 }}% 长期激励</text></text>
|
||||
</view>
|
||||
<view class="rights-item">
|
||||
<view class="ri-icon" style="background:#F3E8FF">
|
||||
<text class="ri-emoji">🖼️</text>
|
||||
</view>
|
||||
<text class="ri-text">专属推广<text class="ri-highlight">海报 + 链接</text></text>
|
||||
</view>
|
||||
<view class="rights-item">
|
||||
<view class="ri-icon" style="background:#FFF7E6">
|
||||
<text class="ri-emoji">💳</text>
|
||||
</view>
|
||||
<text class="ri-text">基础<text class="ri-highlight">兑换额度</text></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Upgrade Progress -->
|
||||
<view class="card progress-card">
|
||||
<view class="pc-head">
|
||||
<text class="pc-title">{{ isMaxLevel ? '当前等级' : '下个等级' }}</text>
|
||||
<text class="pc-val">{{ displayGrowthValue.toLocaleString() }} / {{ targetThreshold.toLocaleString() }}</text>
|
||||
</view>
|
||||
<view class="pc-track">
|
||||
<view class="pc-fill" :style="{ width: progressPct + '%' }"></view>
|
||||
</view>
|
||||
<text v-if="isMaxLevel" class="pc-tip">已达到当前角色的最高等级</text>
|
||||
<text v-else class="pc-tip">还差 <text class="pc-delta">{{ targetRemain.toLocaleString() }} 成长值</text> 即可升级,解锁更高推广比例、专属活动优先权等权益</text>
|
||||
</view>
|
||||
|
||||
<!-- All Levels -->
|
||||
<view class="card all-levels-card">
|
||||
<text class="card-title" style="padding: 16rpx 0">所有等级权益</text>
|
||||
|
||||
<view class="al-row" v-for="(item, idx) in allLevels" :key="idx">
|
||||
<view class="al-badge" :class="'al-badge-lv' + item.lv">{{ item.badge }}</view>
|
||||
<view class="al-meta">
|
||||
<text class="al-name">{{ item.name }}</text>
|
||||
<text class="al-quota">{{ item.quota }}</text>
|
||||
</view>
|
||||
<view class="al-check" :class="item.status">
|
||||
<text v-if="item.status === 'ok'">✓</text>
|
||||
<text v-else-if="item.status === 'cur'">●</text>
|
||||
<text v-else>○</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="level-footer">成长值基于最近30天的有效数据计算</text>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const allLevels = ref([])
|
||||
const currentLevelName = ref('')
|
||||
const currentGrowthValue = ref(0)
|
||||
const targetThreshold = ref(0)
|
||||
const targetRemain = ref(0)
|
||||
const progressPct = ref(0)
|
||||
const currentR1 = ref(0)
|
||||
const currentR2 = ref(0)
|
||||
const displayGrowthValue = computed(() => targetThreshold.value > 0 ? Math.min(currentGrowthValue.value, targetThreshold.value) : currentGrowthValue.value)
|
||||
const isMaxLevel = computed(() => targetThreshold.value > 0 && currentGrowthValue.value >= targetThreshold.value)
|
||||
|
||||
const STATUS_MAP = {
|
||||
'VIP0': '团长LV0', 'VIP1': '团长LV1', 'VIP2': '团长LV2', 'VIP3': '团长LV3', 'VIP4': '团长LV4', 'VIP5': '团长LV5'
|
||||
}
|
||||
|
||||
const fetchLevels = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/level', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
currentLevelName.value = d.current_level_name || 'VIP1'
|
||||
currentGrowthValue.value = d.growth_value || 0
|
||||
targetThreshold.value = d.target_threshold || 0
|
||||
targetRemain.value = d.target_remain || 0
|
||||
progressPct.value = d.progress_pct || 0
|
||||
// 从 all_levels 中找到当前等级的 R1/R2
|
||||
const curLv = (d.all_levels || []).find(l => l.level === d.current_level)
|
||||
currentR1.value = curLv ? curLv.r1_rate : 0
|
||||
currentR2.value = curLv ? curLv.r2_rate : 0
|
||||
// 映射为前端结构
|
||||
const curIdx = (d.all_levels || []).findIndex(l => l.level === d.current_level)
|
||||
allLevels.value = (d.all_levels || []).map((l, i) => ({
|
||||
lv: parseInt(String(l.level).replace('VIP', '')) || 0,
|
||||
badge: STATUS_MAP[l.level] || l.level,
|
||||
name: l.name,
|
||||
quota: `${l.threshold?.toLocaleString() || 0} 成长值 · 直接推荐C端 ${l.r1_rate}% 即时 + ${l.r2_rate}% 长期`,
|
||||
status: l.level === d.current_level ? 'cur' : (i < curIdx ? 'ok' : 'future')
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取等级失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchLevels() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Level Hero */
|
||||
.level-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.level-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -80rpx;
|
||||
right: -60rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,215,0,.2), transparent 65%);
|
||||
}
|
||||
|
||||
.lh-medal {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700, #FFA500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20rpx;
|
||||
box-shadow: 0 8rpx 28rpx rgba(255,165,0,.45);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lh-medal-icon { font-size: 56rpx; color: #0F3460; }
|
||||
|
||||
.lh-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 8rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.lh-sub {
|
||||
font-size: 24rpx;
|
||||
opacity: .75;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
margin-bottom: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.card-title::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
/* Rights Grid */
|
||||
.rights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.rights-item {
|
||||
background: #F7F9FC;
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.ri-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-emoji { font-size: 28rpx; }
|
||||
|
||||
.ri-text { font-size: 22rpx; color: #5A6072; line-height: 1.4; flex: 1; }
|
||||
.ri-highlight { display: block; font-weight: 600; color: #0F1226; font-size: 24rpx; }
|
||||
|
||||
/* Progress */
|
||||
.progress-card .card-title { margin-bottom: 0; }
|
||||
.progress-card .card-title::before { background: linear-gradient(180deg, #FF8800, #FFB84D); }
|
||||
|
||||
.pc-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.pc-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: flex; align-items: center; gap: 10rpx; }
|
||||
.pc-title::before { content: ''; width: 6rpx; height: 24rpx; background: linear-gradient(180deg, #FF8800, #FFB84D); border-radius: 4rpx; }
|
||||
.pc-val { font-size: 24rpx; color: #FF8800; font-weight: 700; }
|
||||
|
||||
.pc-track {
|
||||
height: 12rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pc-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FF8800, #FFB84D);
|
||||
border-radius: 6rpx;
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.pc-tip { font-size: 22rpx; color: #8B91A1; line-height: 1.6; display: block; }
|
||||
.pc-delta { color: #FF8800; font-weight: 700; }
|
||||
|
||||
/* All Levels */
|
||||
.all-levels-card { padding: 12rpx 28rpx; }
|
||||
|
||||
.al-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.al-row:last-child { border-bottom: none; }
|
||||
|
||||
.al-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 160rpx;
|
||||
text-align: center;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.al-badge-lv0 { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); }
|
||||
.al-badge-lv1 { background: linear-gradient(135deg, #8B91A1, #BCC2CE); }
|
||||
.al-badge-lv2 { background: linear-gradient(135deg, #00C4A7, #5FE6CC); }
|
||||
.al-badge-lv3 { background: linear-gradient(135deg, #FF8800, #FFB84D); }
|
||||
.al-badge-lv4 { background: linear-gradient(135deg, #722ED1, #B37FEB); }
|
||||
.al-badge-lv5 { background: linear-gradient(135deg, #FFD700, #FFA500); color: #0F3460; }
|
||||
|
||||
.al-meta { flex: 1; }
|
||||
.al-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 4rpx; }
|
||||
.al-quota { font-size: 20rpx; color: #8B91A1; display: block; }
|
||||
|
||||
.al-check {
|
||||
flex-shrink: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.al-check.ok { background: #00C4A7; color: #fff; }
|
||||
.al-check.cur { background: linear-gradient(135deg, #FF8800, #FFB84D); color: #fff; box-shadow: 0 4rpx 12rpx rgba(255,136,0,.3); }
|
||||
.al-check.future { background: #F3F5F9; color: #BCC2CE; }
|
||||
|
||||
.level-footer {
|
||||
text-align: center;
|
||||
color: #8B91A1;
|
||||
font-size: 22rpx;
|
||||
padding: 16rpx 0 40rpx;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar :title="leaderData.name ? leaderData.name + ' · 详情' : '团长详情'" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Hero Card -->
|
||||
<view class="detail-hero">
|
||||
<view class="dh-top">
|
||||
<view class="dh-avatar" :style="{ background: avatarBg }">{{ leaderData.name ? leaderData.name.charAt(0) : '团' }}</view>
|
||||
<view class="dh-info">
|
||||
<text class="dh-name">{{ leaderData.name || '团长' }}</text>
|
||||
<text class="dh-sub">{{ leaderData.isSuper ? '超级团长 · 可查看直接推荐C端 + 直接推荐团长贡献数据' : '普通团长 · 仅可查看直接推荐C端贡献数据' }}</text>
|
||||
</view>
|
||||
<view v-if="leaderData.isSuper" class="super-tag">
|
||||
<text>超级团长</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="dh-stats">
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val">{{ leaderData.cCount || 0 }}</text>
|
||||
<text class="dhs-label">直接推荐C端</text>
|
||||
</view>
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val">{{ leaderData.cDevices || 0 }}</text>
|
||||
<text class="dhs-label">设备(台)</text>
|
||||
</view>
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val dhs-green">¥{{ leaderData.cAmount || '0' }}</text>
|
||||
<text class="dhs-label">C端总额</text>
|
||||
</view>
|
||||
<view v-if="leaderData.isSuper" class="dh-stat dh-stat-purple">
|
||||
<text class="dhs-val dhs-gold">{{ leaderData.lCount || 0 }}</text>
|
||||
<text class="dhs-label">直接推荐团长</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- C端用户列表 -->
|
||||
<view class="section-title-row">
|
||||
<text class="st-title-text">{{ leaderData.isSuper ? '直接推荐C端用户贡献(黄金池)' : '直接推荐C端用户贡献' }}</text>
|
||||
<text class="st-count">共 {{ leaderData.cCount || 0 }} 人</text>
|
||||
</view>
|
||||
|
||||
<view class="list-group">
|
||||
<view class="list-item" v-for="(user, idx) in cUserList" :key="idx">
|
||||
<view class="li-avatar" :style="{ background: user.avatarBg }">{{ user.avatar }}</view>
|
||||
<view class="li-body">
|
||||
<text class="li-name">{{ user.name }}</text>
|
||||
<view class="li-meta">
|
||||
<text class="li-meta-label">设备</text> <text class="li-meta-bold">{{ user.devices }}</text> 台
|
||||
<text class="li-sep">·</text>
|
||||
<text class="li-meta-label">消费</text> <text class="li-meta-red">¥{{ user.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-score">{{ user.contribution }}</text>
|
||||
<text class="li-score-label">贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="cUserList.length === 0" class="empty-hint">
|
||||
<text>暂无直接推荐C端用户</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 直接推荐团长列表 (仅超级团长显示) -->
|
||||
<view v-if="leaderData.isSuper && lUserList.length > 0" class="section-title-row" style="margin-top: 28rpx">
|
||||
<view class="st-title-text" style="display:flex;align-items:center;gap:8rpx">
|
||||
<text class="super-tag-sm">超级</text>
|
||||
<text>直接推荐团长贡献</text>
|
||||
</view>
|
||||
<text class="st-count">共 {{ leaderData.lCount || 0 }} 人</text>
|
||||
</view>
|
||||
|
||||
<view v-if="leaderData.isSuper" class="list-group">
|
||||
<view class="list-item" v-for="(l, idx) in lUserList" :key="idx">
|
||||
<view class="li-avatar" :style="{ background: 'linear-gradient(135deg, #722ED1, #B37FEB)' }">{{ l.avatar }}</view>
|
||||
<view class="li-body">
|
||||
<text class="li-name" style="display:flex;align-items:center;gap:8rpx">{{ l.name }}<text class="sub-tag">下级团长</text></text>
|
||||
<view class="li-meta">
|
||||
<text class="li-meta-label">设备</text> <text class="li-meta-bold">{{ l.devices }}</text> 台
|
||||
<text class="li-sep">·</text>
|
||||
<text class="li-meta-label">总金额</text> <text class="li-meta-red">¥{{ l.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-score" style="color:#722ED1">{{ l.contribution }}</text>
|
||||
<text class="li-score-label">贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Footer -->
|
||||
<view class="detail-footer">
|
||||
<text class="df-title">{{ leaderData.isSuper ? '该超级团长的直接推荐C端 + 直接推荐团长贡献数据' : '该团长的直接推荐C端贡献数据' }}</text>
|
||||
<text class="df-sub">{{ leaderData.isSuper ? '商务经理可获得其直接推荐C端 + 直接推荐团长贡献收益的分成' : '商务经理可获得该团长贡献收益的分成(仅C端)' }}</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { reactive, computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const leaderData = reactive({
|
||||
id: '', name: '', isSuper: false,
|
||||
cCount: 0, cDevices: 0, cAmount: '0',
|
||||
lCount: 0, lDevices: 0, lAmount: '0', contribution: 0
|
||||
})
|
||||
|
||||
const avatarBg = computed(() => {
|
||||
if (leaderData.isSuper) return 'linear-gradient(135deg, #722ED1, #B37FEB)'
|
||||
return 'linear-gradient(135deg, #1677FF, #4096FF)'
|
||||
})
|
||||
|
||||
const cUserList = ref([])
|
||||
const lUserList = ref([])
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
try {
|
||||
const res = await request.get('/c/manager/leader-detail', { id, showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
Object.assign(leaderData, {
|
||||
id: d.id, name: d.name, isSuper: d.is_super,
|
||||
cCount: d.c_count || 0, cDevices: d.c_devices || 0, cAmount: formatThousands(d.c_amount || 0),
|
||||
lCount: d.l_count || 0, lDevices: d.l_devices || 0, lAmount: formatThousands(d.l_amount || 0),
|
||||
contribution: d.contribution || 0
|
||||
})
|
||||
cUserList.value = (d.c_list || []).map(c => ({
|
||||
avatar: (c.name || '用')[0],
|
||||
avatarBg: 'linear-gradient(135deg, #1677FF, #4096FF)',
|
||||
name: c.name, devices: c.devices || 0,
|
||||
amount: formatThousands(c.amount || 0), contribution: c.contribution || 0
|
||||
}))
|
||||
lUserList.value = (d.l_list || []).map(l => ({
|
||||
avatar: (l.name || '团')[0],
|
||||
name: l.name, devices: l.devices || 0,
|
||||
amount: formatThousands(l.amount || 0), contribution: l.contribution || 0
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取团长详情失败', e) }
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.id) fetchDetail(options.id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.detail-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 24rpx rgba(13,43,110,.25);
|
||||
}
|
||||
|
||||
.dh-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.dh-avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dh-info { flex: 1; min-width: 0; }
|
||||
.dh-name { font-size: 32rpx; font-weight: 700; display: block; }
|
||||
.dh-sub { font-size: 22rpx; opacity: .75; display: block; margin-top: 4rpx; }
|
||||
|
||||
.super-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #722ED1, #B37FEB);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.super-tag-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #722ED1, #B37FEB);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dh-stats {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.dh-stat {
|
||||
flex: 1;
|
||||
padding: 16rpx 8rpx;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,.08);
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.dh-stat-purple {
|
||||
background: rgba(255,255,255,.14);
|
||||
}
|
||||
|
||||
.dhs-val {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dhs-green { color: #7FFFD4; }
|
||||
.dhs-gold { color: #FFD700; }
|
||||
|
||||
.dhs-label {
|
||||
font-size: 20rpx;
|
||||
opacity: .65;
|
||||
display: block;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.st-title-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.st-count {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.li-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
|
||||
.li-meta {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
display: flex;
|
||||
gap: 4rpx;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.6;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.li-meta-label { color: #8B91A1; }
|
||||
.li-meta-bold { color: #0F1226; font-weight: 600; }
|
||||
.li-meta-red { color: #FF4D4F; font-weight: 600; }
|
||||
.li-sep { color: #BCC2CE; }
|
||||
|
||||
.li-right { text-align: right; flex-shrink: 0; }
|
||||
.li-score { font-size: 26rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.li-score-label { font-size: 20rpx; color: #BCC2CE; display: block; }
|
||||
|
||||
.sub-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #00C4A7, #5FE6CC);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
padding: 48rpx;
|
||||
color: #8B91A1;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
background: linear-gradient(135deg, #F3E8FF, #FAF5FF);
|
||||
border-radius: 28rpx;
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.df-title {
|
||||
font-size: 24rpx;
|
||||
color: #1677FF;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.df-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<!--
|
||||
* 超级团长 — 分享海报
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="团长分享海报" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<view class="poster-canvas" v-if="posterUrl">
|
||||
<image :src="posterUrl" mode="widthFix" class="poster-img" />
|
||||
</view>
|
||||
<view class="poster-canvas poster-placeholder" v-else>
|
||||
<text class="poster-title">邀请团长 加入平台</text>
|
||||
<text class="poster-subtitle">
|
||||
每邀请一位团长<text class="subtitle-break">获得该团长贡献收益的 5%</text>
|
||||
</text>
|
||||
<text class="poster-name">团长·推广</text>
|
||||
<text class="poster-empty-tip">后台暂未上传海报图片</text>
|
||||
</view>
|
||||
|
||||
<view class="poster-actions">
|
||||
<view class="btn btn-primary" @click="savePoster">保存到相册</view>
|
||||
<!-- <view class="btn btn-outline" @click="sharePoster">分享</view> -->
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { closeSub } from '@/composables/useNavigation'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const posterUrl = ref('')
|
||||
|
||||
const fetchPoster = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/poster/generate', { type: 'l', showLoading: true })
|
||||
if (res.data) {
|
||||
posterUrl.value = res.data.url || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('生成海报失败', e)
|
||||
uni.showToast({ title: '海报生成失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoster() })
|
||||
|
||||
const savePoster = () => {
|
||||
if (!posterUrl.value) {
|
||||
uni.showToast({ title: '暂无海报图片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = posterUrl.value
|
||||
a.download = '分享海报.jpg'
|
||||
a.click()
|
||||
uni.showToast({ title: '已开始下载', icon: 'success' })
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '正在保存', mask: true })
|
||||
uni.downloadFile({
|
||||
url: posterUrl.value,
|
||||
success: (res) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '海报已保存到相册', icon: 'none' })
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const sharePoster = () => {
|
||||
uni.share({
|
||||
provider: 'weixin',
|
||||
type: 2,
|
||||
imageUrl: posterUrl.value,
|
||||
success: () => uni.showToast({ title: '分享成功', icon: 'none' }),
|
||||
fail: () => uni.showToast({ title: '分享取消', icon: 'none' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.poster-canvas {
|
||||
width: 100%;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 8rpx 40rpx rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.poster-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(135deg, #f97316 0%, #e11d48 50%, #7c3aed 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 40rpx;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
right: -60rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.3), transparent 60%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -80rpx;
|
||||
left: -40rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,0,0,.1), transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.poster-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 4rpx 16rpx rgba(0,0,0,.3);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.poster-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255,255,255,.95);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subtitle-break { display: block; }
|
||||
|
||||
.poster-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,.25);
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 36rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
backdrop-filter: blur(16rpx);
|
||||
}
|
||||
|
||||
.poster-empty-tip {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,.6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.poster-actions { display: flex; gap: 16rpx; }
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 26rpx 0;
|
||||
text-align: center;
|
||||
border-radius: 22rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f97316, #e11d48);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(249,115,22,.25);
|
||||
}
|
||||
|
||||
.btn-outline { background: #F3F5F9; color: #5A6072; }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<!--
|
||||
* 普通团长 — 分享中心 (对齐原型 Hero 卡片版)
|
||||
* 仅邀请C端用户,单推荐码,2个操作按钮
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="分享中心" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- 粉红 Hero (已对齐原型图) -->
|
||||
<view class="promo-hero">
|
||||
<view class="ph-heart">
|
||||
<text class="ph-heart-icon">♡</text>
|
||||
</view>
|
||||
<text class="ph-title">推荐好友,获取直接推荐奖励</text>
|
||||
<!-- <text class="ph-desc">
|
||||
每成功邀请一位好友加入全犀云平台,即可获得该好友年度收益的 <text class="ph-bold">3%</text> 作为直接推荐奖励
|
||||
</text> -->
|
||||
|
||||
<view class="ph-codes">
|
||||
<view class="ph-code-row">
|
||||
<text class="ph-code-label">我的推荐码</text>
|
||||
<text class="ph-code-val">{{ inviteCode }}</text>
|
||||
<view class="ph-copy-btn" @click="copyCode">复制</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 推广数据统计 -->
|
||||
<view class="promo-stats">
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.monthInvites }}</text>
|
||||
<text class="ps-lbl">本月邀请</text>
|
||||
</view>
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.totalInvites }}</text>
|
||||
<text class="ps-lbl">累计邀请</text>
|
||||
</view>
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.totalEarnings }}</text>
|
||||
<text class="ps-lbl">累计积分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="promo-actions">
|
||||
<view class="pa-big-btn" @click="copyLink">
|
||||
<view class="pabb-icon" style="background:#FFE4EC;color:#FF4D6D">
|
||||
<text class="pabb-emoji">🔗</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">复制C端邀请链接</text>
|
||||
<text class="pabb-sub">{{ inviteUrl }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pa-big-btn" @click="openSub('b-sub-poster')">
|
||||
<view class="pabb-icon" style="background:#F3E8FF;color:#7C3AED">
|
||||
<text class="pabb-emoji">🖼️</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">生成C端分享海报</text>
|
||||
<text class="pabb-sub">一键生成专属分享海报</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请成果 -->
|
||||
<view class="promo-result">
|
||||
<text class="pr-title">邀请成果</text>
|
||||
|
||||
<view class="pr-poster-action">
|
||||
<!-- <text class="pr-poster-hint">海报由后台运营统一制作,点击查看专属分享海报</text> -->
|
||||
<view class="pr-btn-primary" @click="openSub('b-sub-poster')">查看/生成专属海报</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请列表 -->
|
||||
<view class="pr-invite-list" v-if="inviteList.length > 0">
|
||||
<view class="pr-invite-item" v-for="(item, idx) in inviteList" :key="idx">
|
||||
<view class="pri-avatar" :style="{ background: item.avatarBg }">{{ item.avatar }}</view>
|
||||
<view class="pri-info">
|
||||
<text class="pri-name">{{ item.name }}</text>
|
||||
<text class="pri-meta">{{ item.meta }}</text>
|
||||
</view>
|
||||
<text class="pri-val">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-holder">
|
||||
<text class="empty-text">暂无推荐记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部动态安全区占位 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { openSub, closeSub } from '@/composables/useNavigation'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const BG_COLORS = ['#1677FF', '#00C4A7', '#FF8800', '#722ED1', '#FF4D4F']
|
||||
|
||||
const inviteCode = ref('')
|
||||
const inviteUrl = ref('')
|
||||
const stats = ref({ monthInvites: 0, totalInvites: 0, totalEarnings: '¥0' })
|
||||
const inviteList = ref([])
|
||||
|
||||
const fetchInviteCode = async () => {
|
||||
try {
|
||||
const [res, codeRes] = await Promise.all([
|
||||
request.get('/c/user/invitation', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
if (codeRes.data) {
|
||||
inviteCode.value = codeRes.data.c?.code || ''
|
||||
inviteUrl.value = codeRes.data.c?.url || ''
|
||||
}
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
// 统计
|
||||
const records = d.records || []
|
||||
stats.value = {
|
||||
monthInvites: d.monthly_invites || 0,
|
||||
totalInvites: d.direct_count || 0,
|
||||
totalEarnings: '¥' + formatThousands(d.total_earnings || 0)
|
||||
}
|
||||
// 邀请列表
|
||||
inviteList.value = records.map((r, idx) => ({
|
||||
name: r.name,
|
||||
avatar: (r.name || '用')[0],
|
||||
avatarBg: BG_COLORS[idx % BG_COLORS.length],
|
||||
meta: `${r.time.split(' ')[0] || r.time} · ${r.statusText}${r.deviceCount ? ' · 已贡献' + r.deviceCount + '台设备' : ''}`,
|
||||
value: r.earnings ?? 0
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取推荐码失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchInviteCode() })
|
||||
|
||||
const copyCode = () => {
|
||||
uni.setClipboardData({
|
||||
data: inviteCode.value,
|
||||
success: () => {
|
||||
uni.showToast({ title: '推荐码已复制:' + inviteCode.value, icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
uni.setClipboardData({
|
||||
data: inviteUrl.value || 'https://quanxi.cloud/register',
|
||||
success: () => {
|
||||
uni.showToast({ title: '邀请链接已复制', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content { flex: 1; height: 0; }
|
||||
.content-pad { padding: 24rpx; box-sizing: border-box; }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 20rpx);
|
||||
}
|
||||
|
||||
/* ================= 重点优化区域:粉色 Hero 卡片 ================= */
|
||||
.promo-hero {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #FF5A8A 0%, #FF7AA8 50%, #FF88A5 100%);
|
||||
border-radius: 36rpx;
|
||||
padding: 40rpx 32rpx 36rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12rpx 36rpx rgba(255, 90, 138, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100rpx;
|
||||
right: -100rpx;
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.2), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ph-heart {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.ph-heart-icon {
|
||||
font-size: 44rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ph-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 1rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ph-desc {
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 16rpx;
|
||||
display: block;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.ph-bold {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
margin: 0 4rpx;
|
||||
}
|
||||
|
||||
.ph-codes {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ph-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-radius: 24rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.ph-code-label {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.9;
|
||||
flex-shrink: 0;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
.ph-code-val {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
letter-spacing: 3rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
.ph-copy-btn {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 14rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
/* =============================================================== */
|
||||
|
||||
.promo-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16rpx; margin-bottom: 28rpx; }
|
||||
.ps-item { background: #fff; border-radius: 24rpx; padding: 20rpx 12rpx; text-align: center; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); }
|
||||
.ps-val { font-size: 36rpx; font-weight: 800; color: #FF4D6D; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.ps-lbl { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
|
||||
.promo-actions { display: flex; flex-direction: column; gap: 16rpx; margin-bottom: 28rpx; }
|
||||
.pa-big-btn {
|
||||
display: flex; align-items: center; gap: 24rpx; padding: 28rpx; background: #fff;
|
||||
border-radius: 28rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); transition: all 0.2s;
|
||||
&:active { transform: scale(.98); background: #FAFAFA; }
|
||||
}
|
||||
.pabb-icon { width: 84rpx; height: 84rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.pabb-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.pabb-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.pabb-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.pabb-sub { font-size: 22rpx; color: #8B91A1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: monospace; }
|
||||
|
||||
.promo-result { background: #fff; border-radius: 28rpx; padding: 28rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); margin-bottom: 16rpx; }
|
||||
.pr-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #FF5A8A, #FF99C0); border-radius: 4rpx; }
|
||||
}
|
||||
|
||||
.pr-poster-action { background: linear-gradient(135deg, #FFE4EC 0%, #FFF0F5 100%); border-radius: 24rpx; padding: 32rpx 28rpx; text-align: center; position: relative; overflow: hidden; margin-bottom: 20rpx;
|
||||
&::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 6rpx; background: linear-gradient(90deg, #FF5A8A, #FF99C0); }
|
||||
}
|
||||
.pr-poster-hint { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
.pr-btn-primary {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(22,119,255,.25);
|
||||
display: block;
|
||||
padding: 22rpx 0;
|
||||
text-align: center;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&:active { opacity: .9; }
|
||||
}
|
||||
|
||||
.pr-invite-list { margin-top: 20rpx; border-top: 1px solid #F3F5F9; }
|
||||
.pr-invite-item { display: flex; align-items: center; gap: 20rpx; padding: 20rpx 0; border-bottom: 1px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.pri-avatar { width: 72rpx; height: 72rpx; border-radius: 50%; color: #fff; font-weight: 700; font-size: 28rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.pri-info { flex: 1; min-width: 0; }
|
||||
.pri-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.pri-meta { font-size: 22rpx; color: #8B91A1; margin-top: 2rpx; display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.pri-val { font-size: 26rpx; font-weight: 700; color: #FF4D6D; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; flex-shrink: 0; }
|
||||
|
||||
.empty-holder { padding: 40rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 24rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,601 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar title="等级规则" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Current Level Hero -->
|
||||
<view class="lvl-hero">
|
||||
<view class="lvl-hero-top">
|
||||
<text class="lvl-hero-label">目前等级</text>
|
||||
<text class="lvl-hero-role">商务经理</text>
|
||||
</view>
|
||||
<view class="lvl-badge-lg">
|
||||
<text class="lvl-star">★</text>
|
||||
<text class="lvl-badge-text">{{ currentLevelName }}</text>
|
||||
</view>
|
||||
|
||||
<view class="lvl-progress-track">
|
||||
<view class="lvl-progress-fill" :style="{ width: progressPct + '%' }"></view>
|
||||
</view>
|
||||
<view class="lvl-stops">
|
||||
<text>VIP1</text>
|
||||
<text>VIP2</text>
|
||||
<text>VIP3</text>
|
||||
<text>VIP4</text>
|
||||
<text>VIP5</text>
|
||||
</view>
|
||||
<view class="lvl-progress-info">
|
||||
<text>已累计 <text class="lvl-progress-highlight">{{ currentGrowthValue.toLocaleString() }}</text> / {{ targetThreshold.toLocaleString() }} 成长值</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Current Benefits -->
|
||||
<view class="section-header">
|
||||
<text class="sh-title">目前权益</text>
|
||||
<text class="sh-sub">{{ currentLevelName }} 生效中</text>
|
||||
</view>
|
||||
|
||||
<view class="benefit-list">
|
||||
<view class="benefit-item benefit-orange">
|
||||
<view class="bi-tag tag-orange">分成</view>
|
||||
<text class="bi-text">团长直接推荐C端即时分成 <text class="bi-bold bi-bold-orange">{{ currentR1 }}%</text></text>
|
||||
</view>
|
||||
<view class="benefit-item benefit-blue">
|
||||
<view class="bi-tag tag-blue">长期</view>
|
||||
<text class="bi-text">团长直接推荐C端长期分成 <text class="bi-bold bi-bold-blue">{{ currentR2 }}%</text></text>
|
||||
</view>
|
||||
<view class="benefit-item benefit-green">
|
||||
<view class="bi-tag tag-green">下级</view>
|
||||
<text class="bi-text">超级团长的下级分成 <text class="bi-bold bi-bold-green">{{ currentSuperRatio }}%</text></text>
|
||||
</view>
|
||||
<view class="benefit-item benefit-purple">
|
||||
<view class="bi-tag tag-purple">特权</view>
|
||||
<text class="bi-text">专属商务经理素材中心 + 优先结算</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Next Level Conditions -->
|
||||
<view class="section-header" style="margin-top:28rpx">
|
||||
<text class="sh-title">下个等级等级条件</text>
|
||||
</view>
|
||||
|
||||
<view class="next-level-card">
|
||||
<view class="nl-header">
|
||||
<view class="nl-badge lv4-badge">
|
||||
<text class="nl-star-sm">★</text>
|
||||
<text>{{ nextLevelKey }}</text>
|
||||
</view>
|
||||
<text class="nl-gap">还差 <text class="nl-gap-val">{{ targetRemain.toLocaleString() }}</text> 成长值</text>
|
||||
</view>
|
||||
|
||||
<view class="nl-conditions">
|
||||
|
||||
<view class="nlc-item">
|
||||
<view class="nlc-icon" style="background:linear-gradient(135deg,#1677FF,#4096FF)">
|
||||
<text class="nlc-icon-text">¥</text>
|
||||
</view>
|
||||
<view class="nlc-body">
|
||||
<text class="nlc-title">累计成长值 ≥ {{ targetThreshold.toLocaleString() }}</text>
|
||||
<text class="nlc-desc">当前 <text class="nlc-bold">{{ currentGrowthValue.toLocaleString() }}</text> / {{ targetThreshold.toLocaleString() }}(已完成 {{ progressPct }}%)</text>
|
||||
<view class="nlc-bar"><view class="nlc-fill" :style="{ width: progressPct + '%', background: 'linear-gradient(90deg,#1677FF,#4096FF)' }"></view></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="nlc-item">
|
||||
<view class="nlc-icon" style="background:linear-gradient(135deg,#722ED1,#B37FEB)">
|
||||
<text class="nlc-icon-text">👥</text>
|
||||
</view>
|
||||
<view class="nlc-body">
|
||||
<text class="nlc-title">直属活跃团长 ≥ 8 人</text>
|
||||
<text class="nlc-desc">当前 <text class="nlc-bold" style="color:#722ED1">6</text> / 8(已完成 75%)</text>
|
||||
<view class="nlc-bar"><view class="nlc-fill" style="width:75%;background:linear-gradient(90deg,#722ED1,#B37FEB)"></view></view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- <view class="nlc-item">
|
||||
<view class="nlc-icon" style="background:linear-gradient(135deg,#00C4A7,#5FE6CC)">
|
||||
<text class="nlc-icon-text">✓</text>
|
||||
</view>
|
||||
<view class="nlc-body">
|
||||
<text class="nlc-title">超级团长 ≥ 5 人</text>
|
||||
<text class="nlc-desc">当前 <text class="nlc-bold" style="color:#00C4A7">3</text> / 5(已完成 60%)</text>
|
||||
<view class="nlc-bar"><view class="nlc-fill" style="width:60%;background:linear-gradient(90deg,#00C4A7,#5FE6CC)"></view></view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- All Level Journey -->
|
||||
<view class="section-header" style="margin-top:28rpx">
|
||||
<text class="sh-title">全部等级权益</text>
|
||||
<text class="sh-sub">商务经理体系</text>
|
||||
</view>
|
||||
|
||||
<view class="lvl-journey">
|
||||
<view class="lj-step">
|
||||
<view class="lj-line done"></view>
|
||||
<view class="lj-dot" style="background:linear-gradient(135deg,#D8DCE5,#A8AEC0);color:#fff">1</view>
|
||||
<text class="lj-label" style="color:#8B91A1">VIP1</text>
|
||||
<text class="lj-thresh">10,000</text>
|
||||
</view>
|
||||
<view class="lj-step">
|
||||
<view class="lj-line done"></view>
|
||||
<view class="lj-dot" style="background:linear-gradient(135deg,#00C4A7,#5FE6CC);color:#fff">2</view>
|
||||
<text class="lj-label" style="color:#00C4A7">VIP2</text>
|
||||
<text class="lj-thresh">30,000</text>
|
||||
</view>
|
||||
<view class="lj-step">
|
||||
<view class="lj-line done"></view>
|
||||
<view class="lj-dot current" style="background:linear-gradient(135deg,#FFD700,#FFA500);color:#0F3460">3</view>
|
||||
<text class="lj-label" style="color:#FF8800">VIP3</text>
|
||||
<text class="lj-thresh">60,000</text>
|
||||
</view>
|
||||
<view class="lj-step">
|
||||
<view class="lj-line"></view>
|
||||
<view class="lj-dot" style="background:linear-gradient(135deg,#722ED1,#B37FEB);color:#fff">4</view>
|
||||
<text class="lj-label" style="color:#8B91A1">VIP4</text>
|
||||
<text class="lj-thresh">100,000</text>
|
||||
</view>
|
||||
<view class="lj-step">
|
||||
<view class="lj-dot" style="background:linear-gradient(135deg,#FFD700,#FFA500);color:#0F3460">5</view>
|
||||
<text class="lj-label" style="color:#8B91A1">VIP5</text>
|
||||
<text class="lj-thresh">200,000</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
<!-- All Level Rules -->
|
||||
<view class="rule-list">
|
||||
<view class="rule-card rc-gray">
|
||||
<view class="rc-top">
|
||||
<text class="rc-badge" style="background:linear-gradient(135deg,#D8DCE5,#A8AEC0);color:#fff">VIP1</text>
|
||||
<text class="rc-name">银牌经理(10,000 成长值)</text>
|
||||
</view>
|
||||
<text class="rc-desc">团长直接推荐C端即时分成 2% · 长期分成 1% · 基础素材中心</text>
|
||||
</view>
|
||||
<view class="rule-card rc-green">
|
||||
<view class="rc-top">
|
||||
<text class="rc-badge" style="background:linear-gradient(135deg,#00C4A7,#5FE6CC);color:#fff">VIP2</text>
|
||||
<text class="rc-name">银牌经理+(30,000 成长值)</text>
|
||||
</view>
|
||||
<text class="rc-desc">团长直接推荐C端即时分成 3% · 长期分成 2% · 超级团长下级分成 0.5%</text>
|
||||
</view>
|
||||
<view class="rule-card rc-orange rc-current">
|
||||
<view class="rc-top">
|
||||
<text class="rc-badge" style="background:linear-gradient(135deg,#FF8800,#FFB84D);color:#fff">VIP3</text>
|
||||
<text class="rc-name">金牌经理(60,000 成长值)</text>
|
||||
<text class="rc-tag-current">当前</text>
|
||||
</view>
|
||||
<text class="rc-desc">团长直接推荐C端即时分成 <text class="rc-bold">5%</text> · 长期分成 <text class="rc-bold">3%</text> · 超级团长下级分成 <text class="rc-bold">1%</text> · 专属素材中心 + 优先结算</text>
|
||||
</view>
|
||||
<view class="rule-card rc-purple rc-future">
|
||||
<view class="rc-top">
|
||||
<text class="rc-badge" style="background:linear-gradient(135deg,#722ED1,#B37FEB);color:#fff">VIP4</text>
|
||||
<text class="rc-name">钻石经理(100,000 成长值)</text>
|
||||
</view>
|
||||
<text class="rc-desc">团长直接推荐C端即时分成 <text class="rc-bold">7%</text> · 长期分成 <text class="rc-bold">4%</text> · 超级团长下级分成 <text class="rc-bold">1.5%</text> · 季度激励</text>
|
||||
</view>
|
||||
<view class="rule-card rc-gold rc-future">
|
||||
<view class="rc-top">
|
||||
<text class="rc-badge" style="background:linear-gradient(135deg,#FFD700,#FFA500);color:#0F3460">VIP5</text>
|
||||
<text class="rc-name">王牌经理(200,000 成长值)</text>
|
||||
</view>
|
||||
<text class="rc-desc">团长直接推荐C端即时分成 <text class="rc-bold">10%</text> · 长期分成 <text class="rc-bold">5%</text> · 超级团长下级分成 <text class="rc-bold">2%</text> · 年度奖励 + 战略合作权益</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Degrade Rules -->
|
||||
<view class="degrade-card">
|
||||
<view class="dc-title">⚠ 等级调整规则</view>
|
||||
<text class="dc-desc">成长值基于最近30天的有效数据计算</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const currentLevelName = ref('')
|
||||
const currentGrowthValue = ref(0)
|
||||
const nextLevelKey = ref('')
|
||||
const targetThreshold = ref(0)
|
||||
const targetRemain = ref(0)
|
||||
const progressPct = ref(0)
|
||||
const currentR1 = ref(0)
|
||||
const currentR2 = ref(0)
|
||||
const currentSuperRatio = ref(0)
|
||||
const allLevels = ref([])
|
||||
const currentLevelKey = ref('')
|
||||
|
||||
const fetchLevels = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/level', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
currentLevelName.value = d.current_level_name || 'VIP1'
|
||||
currentLevelKey.value = d.current_level || ''
|
||||
currentGrowthValue.value = d.growth_value || 0
|
||||
nextLevelKey.value = d.next_level || ''
|
||||
targetThreshold.value = d.target_threshold || 0
|
||||
targetRemain.value = d.target_remain || 0
|
||||
progressPct.value = d.progress_pct || 0
|
||||
const curLv = (d.all_levels || []).find(l => l.level === d.current_level)
|
||||
currentR1.value = curLv ? curLv.r1_rate : 0
|
||||
currentR2.value = curLv ? curLv.r2_rate : 0
|
||||
currentSuperRatio.value = curLv ? (curLv.super_ratio || 0) : 0
|
||||
allLevels.value = d.all_levels || []
|
||||
}
|
||||
} catch (e) { console.error('获取等级失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchLevels() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Level Hero */
|
||||
.lvl-hero {
|
||||
background: linear-gradient(135deg, #1A1A2E 0%, #16213E 50%, #0F3460 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lvl-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -80rpx;
|
||||
right: -60rpx;
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,215,0,.2), transparent 65%);
|
||||
}
|
||||
|
||||
.lvl-hero-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lvl-hero-label { font-size: 24rpx; opacity: .65; }
|
||||
.lvl-hero-role { font-size: 22rpx; opacity: .5; }
|
||||
|
||||
.lvl-badge-lg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
background: linear-gradient(135deg, #FFD700, #FFA500);
|
||||
color: #0F3460;
|
||||
padding: 16rpx 44rpx;
|
||||
border-radius: 40rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(255,165,0,.4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.lvl-star { font-size: 28rpx; }
|
||||
.lvl-badge-text { font-size: 36rpx; font-weight: 800; }
|
||||
|
||||
.lvl-progress-track {
|
||||
height: 16rpx;
|
||||
background: rgba(255,255,255,.1);
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lvl-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FFD700, #FFA500, #FF8C00);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.lvl-stops {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 20rpx;
|
||||
opacity: .5;
|
||||
margin-top: 8rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lvl-progress-info {
|
||||
margin-top: 20rpx;
|
||||
font-size: 24rpx;
|
||||
opacity: .75;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lvl-progress-highlight { color: #7FFFD4; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; font-size: 28rpx; font-weight: 700; }
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.sh-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.sh-sub { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
/* Benefit List */
|
||||
.benefit-list {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.benefit-orange { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border-left: 6rpx solid #FF8800; }
|
||||
.benefit-blue { background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border-left: 6rpx solid #1677FF; }
|
||||
.benefit-green { background: linear-gradient(135deg, #E6FAF5, #F0FBF8); border-left: 6rpx solid #00C4A7; }
|
||||
.benefit-purple { background: linear-gradient(135deg, #F9F0FF, #FAF5FF); border-left: 6rpx solid #722ED1; }
|
||||
|
||||
.bi-tag {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-orange { background: #FF8800; }
|
||||
.tag-blue { background: #1677FF; }
|
||||
.tag-green { background: #00C4A7; }
|
||||
.tag-purple { background: #722ED1; }
|
||||
|
||||
.bi-text { font-size: 24rpx; color: #5A6072; flex: 1; }
|
||||
.bi-bold { font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; font-size: 28rpx; }
|
||||
.bi-bold-orange { color: #FF8800; }
|
||||
.bi-bold-blue { color: #1677FF; }
|
||||
.bi-bold-green { color: #00C4A7; }
|
||||
|
||||
/* Next Level */
|
||||
.next-level-card {
|
||||
background: linear-gradient(135deg, #F9F0FF, #FAF5FF);
|
||||
border: 1px solid #E9D8FF;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.nl-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.nl-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(114,46,209,.3);
|
||||
}
|
||||
|
||||
.lv4-badge { background: linear-gradient(135deg, #722ED1, #B37FEB); }
|
||||
|
||||
.nl-star-sm { font-size: 24rpx; }
|
||||
|
||||
.nl-gap { font-size: 22rpx; color: #8B91A1; flex: 1; }
|
||||
.nl-gap-val { color: #722ED1; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; font-size: 26rpx; font-weight: 700; }
|
||||
|
||||
.nl-conditions { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
|
||||
.nlc-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
background: #fff;
|
||||
padding: 20rpx;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.nlc-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nlc-icon-text { color: #fff; font-size: 28rpx; font-weight: 700; }
|
||||
|
||||
.nlc-body { flex: 1; }
|
||||
.nlc-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.nlc-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.nlc-bold { color: #1677FF; font-weight: 700; }
|
||||
|
||||
.nlc-bar {
|
||||
height: 8rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.nlc-fill { height: 100%; border-radius: 4rpx; }
|
||||
|
||||
/* Level Journey */
|
||||
.lvl-journey {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 12rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lj-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lj-line {
|
||||
position: absolute;
|
||||
top: 36rpx;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 6rpx;
|
||||
background: #F3F5F9;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.lj-line.done { background: linear-gradient(90deg, #FFD700, #FFA500); }
|
||||
|
||||
.lj-step:last-child .lj-line { display: none; }
|
||||
|
||||
.lj-dot {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 6rpx solid #fff;
|
||||
}
|
||||
|
||||
.lj-dot.current { box-shadow: 0 0 0 8rpx rgba(255,165,0,.2); }
|
||||
|
||||
.lj-label { font-size: 20rpx; font-weight: 600; display: block; margin-top: 12rpx; }
|
||||
.lj-thresh { font-size: 18rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
|
||||
/* Rule Cards */
|
||||
.rule-list { display: flex; flex-direction: column; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
|
||||
.rule-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
border-left: 6rpx solid #BCC2CE;
|
||||
}
|
||||
|
||||
.rc-gray { border-left-color: #D8DCE5; }
|
||||
.rc-green { border-left-color: #00C4A7; }
|
||||
.rc-orange { border-left-color: #FF8800; }
|
||||
.rc-purple { border-left-color: #722ED1; }
|
||||
.rc-gold { border-left-color: #FFD700; }
|
||||
.rc-current { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); }
|
||||
.rc-future { opacity: .85; }
|
||||
|
||||
.rc-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.rc-badge {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.rc-name { font-size: 26rpx; font-weight: 700; color: #0F1226; }
|
||||
|
||||
.rc-tag-current {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #E6FAF5;
|
||||
color: #00C4A7;
|
||||
}
|
||||
|
||||
.rc-desc { font-size: 22rpx; color: #5A6072; line-height: 1.6; display: block; }
|
||||
.rc-bold { font-weight: 700; color: #1677FF; }
|
||||
|
||||
/* Degrade Card */
|
||||
.degrade-card {
|
||||
background: linear-gradient(135deg, #FFF1F0, #FFF7F6);
|
||||
border: 1px solid #FFCCC7;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
border-left: 6rpx solid #FF4D4F;
|
||||
}
|
||||
|
||||
.dc-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #FF4D4F;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.dc-desc {
|
||||
font-size: 22rpx;
|
||||
color: #5A6072;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar title="商务经理分享海报" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<view class="poster-canvas" v-if="posterUrl">
|
||||
<image :src="posterUrl" mode="widthFix" class="poster-img" />
|
||||
</view>
|
||||
<view class="poster-canvas poster-placeholder" v-else>
|
||||
<text class="poster-title">邀请团长 加入我们</text>
|
||||
<text class="poster-subtitle">每邀请一位团长<text class="subtitle-break">获得该团长贡献收益的 3%</text></text>
|
||||
<text class="poster-name">商务经理·推广</text>
|
||||
<text class="poster-empty-tip">后台暂未上传海报图片</text>
|
||||
</view>
|
||||
|
||||
<view class="poster-actions">
|
||||
<view class="btn btn-primary" @click="savePoster">保存到相册</view>
|
||||
<!-- <view class="btn btn-outline" @click="sharePoster">分享</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const posterUrl = ref('')
|
||||
|
||||
const fetchPoster = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/poster/generate', { type: 'l', showLoading: true })
|
||||
if (res.data) { posterUrl.value = res.data.url || '' }
|
||||
} catch (e) {
|
||||
console.error('生成海报失败', e)
|
||||
uni.showToast({ title: '海报生成失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoster() })
|
||||
|
||||
const savePoster = () => {
|
||||
if (!posterUrl.value) { uni.showToast({ title: '暂无海报图片', icon: 'none' }); return }
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = posterUrl.value
|
||||
a.download = '分享海报.jpg'
|
||||
a.click()
|
||||
uni.showToast({ title: '已开始下载', icon: 'success' })
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '正在保存', mask: true })
|
||||
uni.downloadFile({
|
||||
url: posterUrl.value,
|
||||
success: (res) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '海报已保存到相册', icon: 'none' })
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const sharePoster = () => {
|
||||
uni.share({ provider: 'weixin', type: 2, imageUrl: posterUrl.value,
|
||||
success: () => uni.showToast({ title: '分享成功', icon: 'none' }),
|
||||
fail: () => uni.showToast({ title: '分享取消', icon: 'none' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.poster-empty-tip { font-size: 24rpx; color: rgba(255,255,255,.6); position: relative; z-index: 1; margin-top: 24rpx; }
|
||||
.poster-img { width: 100%; display: block; }
|
||||
.poster-canvas {
|
||||
width: 100%;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 8rpx 40rpx rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(135deg, #FF6B6B 0%, #FFD93D 50%, #6BCB77 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 40rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
right: -60rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.3), transparent 60%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -80rpx;
|
||||
left: -40rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,0,0,.1), transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.poster-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 4rpx 16rpx rgba(0,0,0,.3);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.poster-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255,255,255,.95);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subtitle-break {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.poster-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,.25);
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 36rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
backdrop-filter: blur(16rpx);
|
||||
}
|
||||
|
||||
.poster-qr {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-rows: repeat(6, 1fr);
|
||||
gap: 3rpx;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
.qr-dot {
|
||||
background: #0F3460;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-off {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.poster-qr-tag {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255,255,255,.95);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.poster-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 26rpx 0;
|
||||
text-align: center;
|
||||
border-radius: 22rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(22,119,255,.25);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #F3F5F9;
|
||||
color: #5A6072;
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,488 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar title="分享中心" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Blue Hero -->
|
||||
<view class="promo-hero">
|
||||
<text class="ph-label">您是商务经理</text>
|
||||
<text class="ph-title">VIP3 · 金牌经理</text>
|
||||
<text class="ph-desc">仅可邀团长(不支持邀请 C 端用户)</text>
|
||||
|
||||
<view class="ph-code-row">
|
||||
<text class="ph-code-label">我的推荐码</text>
|
||||
<text class="ph-code-val">{{ inviteCode }}</text>
|
||||
<view class="ph-copy-btn" @click="copyCode">复制</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享激励政策 -->
|
||||
<view class="policy-card">
|
||||
<view class="policy-title">
|
||||
<text class="pt-icon">📄</text>
|
||||
<text>分享激励政策</text>
|
||||
</view>
|
||||
|
||||
<view class="policy-item">
|
||||
<view class="pi-num">1</view>
|
||||
<text class="pi-text">商务经理仅可邀请团长,注册成功即建立推荐关系</text>
|
||||
</view>
|
||||
<view class="policy-item">
|
||||
<view class="pi-num">2</view>
|
||||
<text class="pi-text">获得团长直接推荐C端的 <text class="pi-bold">即时奖励分成</text> + <text class="pi-bold">长期奖励分成</text></text>
|
||||
</view>
|
||||
<view class="policy-item">
|
||||
<view class="pi-num">3</view>
|
||||
<text class="pi-text">若直接推荐团长后台开放了邀请团长权益(超级团长),您可获得其下级团长的奖励分成</text>
|
||||
</view>
|
||||
<view class="policy-item">
|
||||
<view class="pi-num">4</view>
|
||||
<text class="pi-text">推广关系长期有效,无时间限制</text>
|
||||
</view>
|
||||
<view class="policy-item" style="border-bottom:none">
|
||||
<view class="pi-num">5</view>
|
||||
<text class="pi-text">后台运营可调整奖励分成比例和邀请范围</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请团长链接 -->
|
||||
<view class="link-card">
|
||||
<view class="link-card-title">
|
||||
<text class="lct-icon">🔗</text>
|
||||
<text>邀请团长链接</text>
|
||||
</view>
|
||||
<view class="link-input">
|
||||
<text>{{ inviteUrl }}</text>
|
||||
</view>
|
||||
<view class="link-action">
|
||||
<view class="btn-copy" @click="copyLink">复制链接</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请团长海报 -->
|
||||
<view class="poster-card">
|
||||
<view class="poster-card-title">
|
||||
<text class="pct-icon">🖼️</text>
|
||||
<text>邀请团长海报</text>
|
||||
</view>
|
||||
<view class="poster-card-body">
|
||||
<view class="poster-thumb">
|
||||
<text>邀请团长<text class="thumb-break">加入我们</text></text>
|
||||
</view>
|
||||
<view class="poster-info">
|
||||
<text class="poster-info-text">每邀请一位团长,获得该团长直接推荐C端收益的奖励分成</text>
|
||||
<view class="btn-primary-sm" @click="openSub('b-sub-manager-poster')">查看/生成海报</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请数据 -->
|
||||
<view class="section-header">
|
||||
<text class="sh-title">邀请数据</text>
|
||||
<text class="sh-sub">共 {{ stats.leaderCount }} 位</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-grid">
|
||||
<view class="sg-item">
|
||||
<text class="sg-val">{{ stats.leaderCount }}</text>
|
||||
<text class="sg-label">邀请团长</text>
|
||||
</view>
|
||||
<view class="sg-item">
|
||||
<text class="sg-val" style="color:#00C4A7">{{ stats.totalCommission }}</text>
|
||||
<text class="sg-label">累计奖励分成(积分)</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-group">
|
||||
<view class="list-item" v-for="(item, idx) in inviteList" :key="idx">
|
||||
<view class="li-avatar" :style="{ background: item.avatarBg }">{{ item.avatar }}</view>
|
||||
<view class="li-body">
|
||||
<text class="li-name">{{ item.name }}</text>
|
||||
<text class="li-meta">注册 {{ item.date }} · 已贡献 <text class="li-meta-green">{{ item.contribution }}</text></text>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text v-if="item.isSuper" class="super-tag">超级</text>
|
||||
<text v-else class="active-tag">已激活</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const inviteCode = ref('')
|
||||
const inviteUrl = ref('')
|
||||
const stats = ref({ leaderCount: 0, totalCommission: '0' })
|
||||
const inviteList = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 推荐码
|
||||
const res = await request.get('/c/user/invitation-code', { showLoading: false })
|
||||
if (res.data) {
|
||||
inviteCode.value = res.data.leader?.code || ''
|
||||
inviteUrl.value = res.data.leader?.url || ''
|
||||
}
|
||||
// 下属团长列表
|
||||
const teamRes = await request.get('/c/manager/team', { showLoading: false })
|
||||
if (teamRes.data) {
|
||||
const d = teamRes.data
|
||||
stats.value = {
|
||||
leaderCount: d.stats?.leader_count || 0,
|
||||
totalCommission: formatThousands(d.stats?.total_contribution || 0)
|
||||
}
|
||||
inviteList.value = (d.leaders || []).map(l => ({
|
||||
avatar: (l.name || '团')[0],
|
||||
avatarBg: 'linear-gradient(135deg, #1677FF, #4096FF)',
|
||||
name: l.name, date: '--', contribution: formatThousands(l.contribution || 0), isSuper: l.is_super
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取推广数据失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
|
||||
const copyCode = () => {
|
||||
uni.setClipboardData({ data: inviteCode.value, success: () => {
|
||||
uni.showToast({ title: '推荐码已复制:' + inviteCode.value, icon: 'none' })
|
||||
}})
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
uni.setClipboardData({ data: inviteUrl.value || 'https://quanxi.cloud/register', success: () => {
|
||||
uni.showToast({ title: '链接已复制', icon: 'none' })
|
||||
}})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Blue Hero */
|
||||
.promo-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 36rpx 28rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 24rpx rgba(13,43,110,.25);
|
||||
}
|
||||
|
||||
.promo-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -50rpx;
|
||||
width: 260rpx;
|
||||
height: 260rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,215,0,.2), transparent 65%);
|
||||
}
|
||||
|
||||
.ph-label { font-size: 22rpx; opacity: .7; display: block; position: relative; z-index: 1; }
|
||||
.ph-title { font-size: 44rpx; font-weight: 800; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin: 8rpx 0; position: relative; z-index: 1; }
|
||||
.ph-desc { font-size: 24rpx; opacity: .85; line-height: 1.5; display: block; position: relative; z-index: 1; }
|
||||
|
||||
.ph-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(255,255,255,.15);
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
margin-top: 24rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.ph-code-label { font-size: 22rpx; opacity: .8; flex-shrink: 0; }
|
||||
.ph-code-val { font-size: 44rpx; font-weight: 800; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; letter-spacing: 4rpx; }
|
||||
.ph-copy-btn {
|
||||
background: rgba(255,255,255,.3);
|
||||
border: 1px solid rgba(255,255,255,.5);
|
||||
border-radius: 12rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Policy Card */
|
||||
.policy-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.policy-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pt-icon { font-size: 32rpx; }
|
||||
|
||||
.policy-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pi-num {
|
||||
flex-shrink: 0;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.pi-text { flex: 1; color: #5A6072; }
|
||||
.pi-bold { color: #1677FF; font-weight: 600; }
|
||||
|
||||
/* Link Card */
|
||||
.link-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.link-card-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.lct-icon { font-size: 28rpx; }
|
||||
|
||||
.link-input {
|
||||
background: #F3F5F9;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: #5A6072;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.link-action { display: flex; }
|
||||
|
||||
.btn-copy {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(22,119,255,.25);
|
||||
}
|
||||
|
||||
/* Poster Card */
|
||||
.poster-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.poster-card-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pct-icon { font-size: 28rpx; }
|
||||
|
||||
.poster-card-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.poster-thumb {
|
||||
width: 168rpx;
|
||||
height: 168rpx;
|
||||
background: linear-gradient(135deg, #0A1733, #1677FF);
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 24rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumb-break { display: block; }
|
||||
|
||||
.poster-info { flex: 1; }
|
||||
.poster-info-text { font-size: 24rpx; color: #5A6072; line-height: 1.6; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.btn-primary-sm {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
padding: 14rpx 28rpx;
|
||||
border-radius: 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(22,119,255,.25);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.sh-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.sh-sub { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.sg-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.sg-val {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
color: #1677FF;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sg-label {
|
||||
font-size: 20rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.li-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.li-meta { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 2rpx; }
|
||||
.li-meta-green { color: #00C4A7; font-weight: 600; }
|
||||
|
||||
.li-right { flex-shrink: 0; }
|
||||
|
||||
.super-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #722ED1, #B37FEB);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.active-tag {
|
||||
font-size: 20rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<!--
|
||||
* 普通团长 — 分享海报
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="C端分享海报" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<view class="poster-canvas" v-if="posterUrl">
|
||||
<image :src="posterUrl" mode="widthFix" class="poster-img" />
|
||||
</view>
|
||||
<view class="poster-canvas poster-placeholder" v-else>
|
||||
<text class="poster-title">推荐好友加入 一起赚收益</text>
|
||||
<text class="poster-subtitle">
|
||||
邀请C端用户<text class="subtitle-break">获得好友年度收益的直接推荐奖励</text>
|
||||
</text>
|
||||
<text class="poster-name">团长·推广</text>
|
||||
<text class="poster-empty-tip">后台暂未上传海报图片</text>
|
||||
</view>
|
||||
|
||||
<view class="poster-actions">
|
||||
<view class="btn btn-primary" @click="savePoster">保存到相册</view>
|
||||
<!-- <view class="btn btn-outline" @click="sharePoster">分享</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { closeSub } from '@/composables/useNavigation'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const posterUrl = ref('')
|
||||
|
||||
const fetchPoster = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/poster/generate', { showLoading: true })
|
||||
if (res.data) {
|
||||
posterUrl.value = res.data.url || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('生成海报失败', e)
|
||||
uni.showToast({ title: '海报生成失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoster() })
|
||||
|
||||
const savePoster = () => {
|
||||
if (!posterUrl.value) {
|
||||
uni.showToast({ title: '暂无海报图片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// H5 浏览器环境:触发浏览器下载
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = posterUrl.value
|
||||
a.download = '分享海报.jpg'
|
||||
a.click()
|
||||
uni.showToast({ title: '已开始下载', icon: 'success' })
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '正在保存', mask: true })
|
||||
uni.downloadFile({
|
||||
url: posterUrl.value,
|
||||
success: (res) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '海报已保存到相册', icon: 'none' })
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
const sharePoster = () => {
|
||||
uni.share({
|
||||
provider: 'weixin',
|
||||
type: 2,
|
||||
imageUrl: posterUrl.value,
|
||||
success: () => uni.showToast({ title: '分享成功', icon: 'none' }),
|
||||
fail: () => uni.showToast({ title: '分享取消', icon: 'none' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.poster-canvas {
|
||||
width: 100%;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 8rpx 40rpx rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.poster-img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 40rpx;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
right: -60rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.3), transparent 60%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -80rpx;
|
||||
left: -40rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,0,0,.1), transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.poster-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
text-shadow: 0 4rpx 16rpx rgba(0,0,0,.3);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.poster-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255,255,255,.95);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 28rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.subtitle-break { display: block; }
|
||||
|
||||
.poster-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,.25);
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 36rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 16rpx;
|
||||
backdrop-filter: blur(16rpx);
|
||||
}
|
||||
|
||||
.poster-empty-tip {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255,255,255,.6);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.poster-actions { display: flex; gap: 16rpx; }
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 26rpx 0;
|
||||
text-align: center;
|
||||
border-radius: 22rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba(22,119,255,.25);
|
||||
}
|
||||
|
||||
.btn-outline { background: #F3F5F9; color: #5A6072; }
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,611 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<NavBar title="分享中心" :showBack="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- 粉红 Hero -->
|
||||
<view class="promo-hero">
|
||||
<view class="ph-heart">
|
||||
<text class="ph-heart-icon">♡</text>
|
||||
</view>
|
||||
<text class="ph-title">推荐好友与团长,获取双重奖励</text>
|
||||
<text class="ph-desc">
|
||||
推荐好友得<text class="ph-bold">3%</text>直接推荐奖励 + 邀请团长得<text class="ph-bold">5%</text>团队贡献收益
|
||||
</text>
|
||||
|
||||
<!-- 双推荐码 -->
|
||||
<view class="ph-codes">
|
||||
<view class="ph-code-row">
|
||||
<text class="ph-code-label">C端推荐码</text>
|
||||
<text class="ph-code-val">{{ cCode }}</text>
|
||||
<view class="ph-copy-btn" @click="copyCode(cCode)">复制</view>
|
||||
</view>
|
||||
<view class="ph-code-row">
|
||||
<text class="ph-code-label">团长推荐码</text>
|
||||
<text class="ph-code-val">{{ ldrCode }}</text>
|
||||
<view class="ph-copy-btn" @click="copyCode(ldrCode)">复制</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 推广数据统计 -->
|
||||
<view class="promo-stats">
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.monthInvites }}</text>
|
||||
<text class="ps-lbl">本月邀请</text>
|
||||
</view>
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.totalInvites }}</text>
|
||||
<text class="ps-lbl">累计邀请</text>
|
||||
</view>
|
||||
<view class="ps-item">
|
||||
<text class="ps-val">{{ stats.totalEarnings }}</text>
|
||||
<text class="ps-lbl">累计积分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 双按钮操作栏 -->
|
||||
<view class="promo-actions">
|
||||
<view class="pa-section-label">
|
||||
<text class="pa-label-tag" :class="{ active: activeInviteType === 'c' }" style="background:#FFE4EC; color:#FF4D6D" @click="switchInviteType('c')">邀请C端</text>
|
||||
<text class="pa-label-tag" :class="{ active: activeInviteType === 'leader' }" style="background:#F9F0FF; color:#722ED1" @click="switchInviteType('leader')">邀请团长</text>
|
||||
</view>
|
||||
|
||||
<view class="pa-big-btn" v-if="activeInviteType === 'c'" @click="copyLink('c')">
|
||||
<view class="pabb-icon" style="background:#FFE4EC;color:#FF4D6D">
|
||||
<text class="pabb-emoji">🔗</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">复制C端邀请链接</text>
|
||||
<text class="pabb-sub">{{ cUrl }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pa-big-btn" v-if="activeInviteType === 'leader'" @click="copyLink('leader')">
|
||||
<view class="pabb-icon" style="background:#F9F0FF;color:#722ED1">
|
||||
<text class="pabb-emoji">👥</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">复制团长邀请链接</text>
|
||||
<text class="pabb-sub">{{ ldrUrl }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pa-big-btn" v-if="activeInviteType === 'c'" @click="openSub('b-sub-poster')">
|
||||
<view class="pabb-icon" style="background:#F3E8FF;color:#7C3AED">
|
||||
<text class="pabb-emoji">🖼️</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">生成C端分享海报</text>
|
||||
<text class="pabb-sub">邀请C端用户的海报</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
|
||||
<view class="pa-big-btn" v-if="activeInviteType === 'leader'" @click="openSub('b-sub-poster-leader')">
|
||||
<view class="pabb-icon" style="background:#E8F0FF;color:#1677FF">
|
||||
<text class="pabb-emoji">🖼️</text>
|
||||
</view>
|
||||
<view class="pabb-text">
|
||||
<text class="pabb-title">生成团长分享海报</text>
|
||||
<text class="pabb-sub">邀请团长的海报</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 邀请成果 -->
|
||||
<view class="promo-result">
|
||||
<text class="pr-title">邀请成果</text>
|
||||
|
||||
<!-- 海报预览 -->
|
||||
<!-- <view class="pr-poster-preview">
|
||||
<view class="pr-poster-thumb">
|
||||
<text class="pr-poster-label">全犀云</text>
|
||||
<view class="pr-poster-qr">
|
||||
<view class="pr-qr-grid">
|
||||
<text class="pr-qr-dot" v-for="i in 36" :key="i" :class="{ 'pr-qr-off': qrPattern[i-1] === 0 }"></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pr-poster-btns">
|
||||
<view class="btn btn-primary" @click="openSub('b-sub-poster')">查看/生成C端海报</view>
|
||||
<view class="btn btn-outline" @click="openSub('b-sub-poster-leader')">查看/生成团长海报</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 最近邀请列表 -->
|
||||
<view class="pr-invite-list" v-if="inviteList.length > 0">
|
||||
<view class="pr-invite-item" v-for="(item, idx) in inviteList" :key="idx">
|
||||
<view class="pri-avatar" :style="{ background: item.avatarBg }">{{ item.avatar }}</view>
|
||||
<view class="pri-info">
|
||||
<text class="pri-name">{{ item.name }}</text>
|
||||
<text class="pri-meta">{{ item.meta }}</text>
|
||||
</view>
|
||||
<text class="pri-val">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="currentRecordState.loading && inviteList.length === 0" class="pr-empty">加载中...</view>
|
||||
<view v-else-if="currentRecordState.loaded && inviteList.length === 0" class="pr-empty">暂无推荐记录</view>
|
||||
<view v-if="inviteList.length > 0 && currentRecordState.hasMore" class="load-more" @click="loadMoreRecords">
|
||||
{{ currentRecordState.loading ? '加载中...' : '加载更多' }}
|
||||
</view>
|
||||
<view v-else-if="inviteList.length > 0 && currentRecordState.loaded" class="load-more loaded-all">已加载全部</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const qrPattern = [1,0,1,1,0,1, 0,1,0,0,1,0, 1,0,1,0,1,0, 0,1,0,1,0,1, 1,0,1,0,0,1, 0,1,1,0,1,0]
|
||||
|
||||
const cCode = ref('')
|
||||
const cUrl = ref('')
|
||||
const ldrCode = ref('')
|
||||
const ldrUrl = ref('')
|
||||
const stats = ref({ monthInvites: 0, totalInvites: 0, totalEarnings: '0' })
|
||||
const activeInviteType = ref('c')
|
||||
const cInviteList = ref([])
|
||||
const leaderInviteList = ref([])
|
||||
const inviteList = computed(() => activeInviteType.value === 'c' ? cInviteList.value : leaderInviteList.value)
|
||||
const recordState = reactive({
|
||||
c: { page: 0, hasMore: true, loaded: false, loading: false },
|
||||
leader: { page: 0, hasMore: true, loaded: false, loading: false }
|
||||
})
|
||||
const currentRecordState = computed(() => recordState[activeInviteType.value])
|
||||
|
||||
const fetchInvitation = async () => {
|
||||
try {
|
||||
const [invitationRes, codesRes] = await Promise.all([
|
||||
request.get('/c/super-leader/invitation', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
if (codesRes.data) {
|
||||
cCode.value = codesRes.data.c?.code || ''
|
||||
cUrl.value = codesRes.data.c?.url || ''
|
||||
ldrCode.value = codesRes.data.leader?.code || ''
|
||||
ldrUrl.value = codesRes.data.leader?.url || ''
|
||||
}
|
||||
if (invitationRes.data) {
|
||||
const d = invitationRes.data
|
||||
stats.value = {
|
||||
monthInvites: d.monthly_invites?.total_count || 0,
|
||||
totalInvites: d.total_invites?.total_count || 0,
|
||||
totalEarnings: formatThousands(d.total_points?.total_points || 0)
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('获取超级团长双邀数据失败', e) }
|
||||
}
|
||||
|
||||
const formatRecord = (record, type, index) => ({
|
||||
name: record.name,
|
||||
avatar: (record.name || (type === 'c' ? '用' : '团'))[0],
|
||||
avatarBg: (type === 'c'
|
||||
? ['#1677FF', '#00C4A7', '#FF8800', '#722ED1', '#FF4D4F']
|
||||
: ['#722ED1', '#1677FF', '#00C4A7', '#FF8800', '#FF4D4F'])[index % 5],
|
||||
meta: type === 'c'
|
||||
? `${record.time} · C端 · ${record.status_text}`
|
||||
: `${record.time} · 团长 · 直属C端${record.invite_count || 0}人`,
|
||||
value: record.earnings > 0 ? `+${record.earnings}积分` : '--'
|
||||
})
|
||||
|
||||
const fetchInvitationRecords = async (type) => {
|
||||
const state = recordState[type]
|
||||
if (state.loading || !state.hasMore) return
|
||||
state.loading = true
|
||||
try {
|
||||
const res = await request.get('/c/super-leader/invitation-records', {
|
||||
type,
|
||||
page: state.page + 1,
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
const targetList = type === 'c' ? cInviteList : leaderInviteList
|
||||
const startIndex = targetList.value.length
|
||||
const newRecords = (res.data.list || []).map((record, index) => formatRecord(record, type, startIndex + index))
|
||||
targetList.value.push(...newRecords)
|
||||
state.page = res.data.current_page || state.page + 1
|
||||
state.hasMore = Boolean(res.data.has_more)
|
||||
state.loaded = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取邀请成果失败', e)
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchInviteType = (type) => {
|
||||
activeInviteType.value = type
|
||||
if (!recordState[type].loaded) fetchInvitationRecords(type)
|
||||
}
|
||||
|
||||
const loadMoreRecords = () => {
|
||||
fetchInvitationRecords(activeInviteType.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInvitation()
|
||||
fetchInvitationRecords('c')
|
||||
})
|
||||
|
||||
const copyCode = (code) => {
|
||||
uni.setClipboardData({ data: code, success: () => {
|
||||
uni.showToast({ title: '推荐码已复制:' + code, icon: 'none' })
|
||||
}})
|
||||
}
|
||||
|
||||
const copyLink = (type) => {
|
||||
const link = type === 'c' ? (cUrl.value || 'https://quanxi.cloud/register') : (ldrUrl.value || 'https://quanxi.cloud/leader')
|
||||
uni.setClipboardData({ data: link, success: () => {
|
||||
uni.showToast({ title: '邀请链接已复制', icon: 'none' })
|
||||
}})
|
||||
}
|
||||
|
||||
const showToast = (msg) => {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Pink Hero */
|
||||
.promo-hero {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, #FF5A8A 0%, #FF7AA8 50%, #FF99C0 100%);
|
||||
border-radius: 36rpx;
|
||||
padding: 48rpx 36rpx 44rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 48rpx rgba(255,90,138,.25);
|
||||
}
|
||||
|
||||
.promo-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -100rpx;
|
||||
right: -100rpx;
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.25), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.promo-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -60rpx;
|
||||
left: -60rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.18), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ph-heart {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.18);
|
||||
border: 4rpx solid rgba(255,255,255,.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 24rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.ph-heart-icon { font-size: 48rpx; }
|
||||
|
||||
.ph-title {
|
||||
font-size: 38rpx; /* 字号稍作微调(原40rpx),防止极端小屏上放不下 */
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
margin-bottom: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
/* --- 关键新增修复属性 --- */
|
||||
display: block; /* 强制独占一行,切断与副标题的连续行内排列 */
|
||||
width: 100%; /* 撑满整行 */
|
||||
}
|
||||
|
||||
.ph-desc {
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
opacity: .95;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 12rpx;
|
||||
|
||||
/* --- 关键新增修复属性 --- */
|
||||
display: block; /* 强制作为独立的块级文本段落 */
|
||||
width: 100%; /* 撑满容器 */
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap; /* 强制不换行 */
|
||||
}
|
||||
|
||||
.ph-bold {
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
margin: 0 4rpx;
|
||||
}
|
||||
.ph-codes {
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ph-code-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: rgba(255,255,255,.2);
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
}
|
||||
|
||||
.ph-code-label { font-size: 22rpx; opacity: .8; flex-shrink: 0; }
|
||||
.ph-code-val { font-size: 40rpx; font-weight: 800; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; letter-spacing: 4rpx; flex: 1; }
|
||||
.ph-copy-btn {
|
||||
background: rgba(255,255,255,.3);
|
||||
border: 1px solid rgba(255,255,255,.5);
|
||||
border-radius: 12rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.promo-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.ps-item {
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 20rpx 12rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.ps-val { font-size: 36rpx; font-weight: 800; color: #FF4D6D; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.ps-lbl { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
|
||||
/* Actions */
|
||||
.promo-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.pa-section-label {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.pa-label-tag {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.pa-label-tag.active {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2rpx currentColor;
|
||||
}
|
||||
|
||||
.pa-big-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx;
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pa-big-btn:active { transform: scale(.98); box-shadow: 0 8rpx 32rpx rgba(15,18,38,.06); }
|
||||
|
||||
.pabb-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pabb-emoji { font-size: 40rpx; line-height: 1; }
|
||||
|
||||
.pabb-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.pabb-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.pabb-sub { font-size: 22rpx; color: #8B91A1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-family: monospace; }
|
||||
|
||||
/* Result */
|
||||
.promo-result {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pr-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.pr-title::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #FF5A8A, #FF99C0);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
/* 海报预览已隐藏,对应样式暂不启用
|
||||
.pr-poster-preview {
|
||||
background: linear-gradient(135deg, #FFE4EC 0%, #FFF0F5 100%);
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.pr-poster-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6rpx;
|
||||
background: linear-gradient(90deg, #FF5A8A, #FF99C0);
|
||||
}
|
||||
|
||||
.pr-poster-thumb {
|
||||
width: 260rpx;
|
||||
height: 340rpx;
|
||||
background: linear-gradient(135deg, #FF5A8A, #FF99C0);
|
||||
border-radius: 20rpx;
|
||||
margin: 0 auto 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 12rpx 36rpx rgba(255,90,138,.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pr-poster-label {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.pr-poster-qr {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.pr-qr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-template-rows: repeat(6, 1fr);
|
||||
gap: 2rpx;
|
||||
padding: 6rpx;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
width: 92rpx;
|
||||
height: 92rpx;
|
||||
}
|
||||
|
||||
.pr-qr-dot {
|
||||
background: #0F3460;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pr-qr-off {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.pr-poster-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 22rpx 0;
|
||||
text-align: center;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary { background: linear-gradient(135deg, #1677FF, #4096FF); color: #fff; box-shadow: 0 4rpx 16rpx rgba(22,119,255,.25); }
|
||||
.btn-outline { background: #F3F5F9; color: #5A6072; }
|
||||
*/
|
||||
|
||||
/* Invite List */
|
||||
.pr-invite-list {
|
||||
margin-top: 20rpx;
|
||||
border-top: 1px solid #F3F5F9;
|
||||
}
|
||||
|
||||
.pr-invite-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
}
|
||||
|
||||
.pr-invite-item:last-child { border-bottom: none; }
|
||||
|
||||
.pri-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pri-info { flex: 1; min-width: 0; }
|
||||
.pri-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.pri-meta { font-size: 22rpx; color: #8B91A1; margin-top: 2rpx; display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.pri-val { font-size: 26rpx; font-weight: 700; color: #FF4D6D; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; flex-shrink: 0; }
|
||||
|
||||
.pr-empty { text-align: center; padding: 40rpx 0; font-size: 24rpx; color: #BCC2CE; }
|
||||
|
||||
.load-more { padding: 24rpx 0 8rpx; text-align: center; font-size: 24rpx; color: #1677FF; }
|
||||
.loaded-all { color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<!--
|
||||
* 文档详情(分类下的子文档列表+内容展示)
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar :title="pageTitle" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view v-if="currentDoc" class="doc-body">
|
||||
<!-- 分类介绍 -->
|
||||
<text class="db-desc">{{ currentDoc.title }}文档列表:</text>
|
||||
|
||||
<!-- 子文档列表(手风琴样式) -->
|
||||
<view v-for="(child, i) in currentDoc.children" :key="child.id" class="chapter-item">
|
||||
<view class="ci-header" @click="toggleChapter(i)">
|
||||
<text class="ci-title">{{ child.title }}</text>
|
||||
<text class="ci-arrow" :class="{ active: activeChapter === i }">›</text>
|
||||
<!-- <u-icon name="arrow-right" size="12" color="#ccc"></u-icon> -->
|
||||
</view>
|
||||
<view v-if="activeChapter === i" class="ci-content">
|
||||
<rich-text :nodes="child.content || '暂无内容'"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!currentDoc.children || currentDoc.children.length === 0" class="empty-state">
|
||||
<text>暂无文档内容</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Feedback -->
|
||||
<!-- <view class="feedback-card">
|
||||
<text class="fb-label">这篇文档对您有帮助吗?</text>
|
||||
<view class="fb-actions">
|
||||
<view class="fb-btn" @click="feedbackHelpful">
|
||||
<text class="fb-icon">👍</text>
|
||||
<text class="fb-text">有帮助</text>
|
||||
</view>
|
||||
<view class="fb-btn" @click="feedbackNotHelpful">
|
||||
<text class="fb-icon">👎</text>
|
||||
<text class="fb-text">没帮助</text>
|
||||
</view>
|
||||
<view class="fb-btn" @click="shareDoc">
|
||||
<text class="fb-icon">📤</text>
|
||||
<text class="fb-text">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</scroll-view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const pageTitle = ref('文档详情')
|
||||
const currentDoc = ref(null)
|
||||
const activeChapter = ref(0) // 默认展开第一个章节
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.title) {
|
||||
pageTitle.value = decodeURIComponent(params.title)
|
||||
}
|
||||
// 优先使用上级页面带过来的完整数据(包含children),无需重复请求接口
|
||||
if (appStore.currentHelpDoc) {
|
||||
currentDoc.value = appStore.currentHelpDoc
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const toggleChapter = (index) => {
|
||||
activeChapter.value = activeChapter.value === index ? -1 : index
|
||||
}
|
||||
|
||||
const feedbackHelpful = () => {
|
||||
// TODO: 对接接口 /api/docs/feedback
|
||||
uni.showToast({ title: '感谢您的反馈', icon: 'success' })
|
||||
}
|
||||
|
||||
const feedbackNotHelpful = () => {
|
||||
// TODO: 对接接口 /api/docs/feedback
|
||||
uni.showToast({ title: '感谢您的反馈,我们将持续优化', icon: 'none' })
|
||||
}
|
||||
|
||||
const shareDoc = () => {
|
||||
// TODO: 对接分享功能
|
||||
uni.showToast({ title: '分享', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.doc-body {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.db-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
padding: 24rpx 0;
|
||||
&:first-child { border-top: none; padding-top: 0; }
|
||||
}
|
||||
.ci-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ci-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
.ci-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #ccc;
|
||||
transition: transform 0.2s;
|
||||
&.active {
|
||||
transform: rotate(90deg);
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
.ci-content {
|
||||
padding-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 2;
|
||||
:deep(p) {
|
||||
margin-bottom: 16rpx;
|
||||
text-indent: 2em;
|
||||
}
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
margin: 12rpx 0;
|
||||
}
|
||||
}
|
||||
.empty-state {
|
||||
padding: 60rpx 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
.fb-label { font-size: 26rpx; color: #666; display: block; margin-bottom: 20rpx; }
|
||||
.fb-actions { display: flex; justify-content: center; gap: 30rpx; }
|
||||
.fb-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
.fb-icon { font-size: 40rpx; }
|
||||
.fb-text { font-size: 24rpx; color: #999; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!--
|
||||
* 帮助文档
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="帮助文档" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view class="page-content">
|
||||
|
||||
<!-- Search -->
|
||||
<!-- <view class="search-bar">
|
||||
<text class="sb-icon">🔍</text>
|
||||
<input class="sb-input" v-model="searchQuery" placeholder="搜索文档内容..." @confirm="handleSearch" />
|
||||
</view> -->
|
||||
|
||||
<scroll-view scroll-y class="docs-scroll">
|
||||
<view v-for="(doc, i) in filteredDocs" :key="i" class="doc-card" @click="goDetail(doc)">
|
||||
<view class="dc-icon-wrap" :style="{ background: doc.iconBg }">
|
||||
<text class="dc-icon">{{ doc.icon }}</text>
|
||||
</view>
|
||||
<view class="dc-info">
|
||||
<text class="dc-title">{{ doc.title }}</text>
|
||||
<text class="dc-desc">{{ doc.desc }}</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
getHelpList()
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
const searchQuery = ref('')
|
||||
const allDocs = ref([])
|
||||
const iconBgs = ['#dbeafe', '#d1fae5', '#fef3c7', '#fce7f3', '#ede9fe', '#fce7f3', '#e5e7eb', '#fff7e6', '#e6f4ff']
|
||||
|
||||
// 获取帮助文档列表
|
||||
const getHelpList = async () => {
|
||||
uni.showLoading({ title: '加载中...' })
|
||||
try {
|
||||
const res = await request.get('/c/help', { showLoading: false })
|
||||
if (res.data) {
|
||||
// 处理数据,补全默认值
|
||||
allDocs.value = res.data.map((item, i) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
desc: item.children && item.children.length ? `共${item.children.length}篇文档` : '暂无子文档',
|
||||
icon: item.icon || (['📚', '🚀', '🖥️', '💰', '🔧', '🌱', '❓'][i % 7] || '📖'),
|
||||
iconBg: iconBgs[i % iconBgs.length],
|
||||
children: item.children || []
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取帮助文档失败', e)
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDocs = computed(() => {
|
||||
if (!searchQuery.value.trim()) return allDocs.value
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return allDocs.value.filter(d => d.title.includes(q) || d.desc.includes(q))
|
||||
})
|
||||
|
||||
const handleSearch = () => {}
|
||||
const goDetail = (doc) => {
|
||||
// 把完整文档数据(包含children子章节)存入全局store,详情页直接使用,无需重复请求接口
|
||||
appStore.setCurrentHelpDoc(doc)
|
||||
openSub('doc-detail', { id: doc.id, title: doc.title })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 整个页面开启 Flex 纵向布局,充满屏幕 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 主内容区占用导航栏下方所有空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 搜索栏自然占位 */
|
||||
.search-bar {
|
||||
margin: 20rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 44rpx;
|
||||
padding: 0 30rpx;
|
||||
height: 72rpx;
|
||||
flex-shrink: 0; /* 防止压缩搜索框 */
|
||||
|
||||
.sb-icon { font-size: 28rpx; margin-right: 16rpx; }
|
||||
.sb-input { flex: 1; font-size: 26rpx; color: #333; }
|
||||
}
|
||||
|
||||
/* 列表自适应剩余高度,四周保持 24rpx 边距 */
|
||||
.docs-scroll {
|
||||
flex: 1;
|
||||
height: 0; /* 配合 flex: 1 的关键设置 */
|
||||
padding: 0 24rpx 24rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.dc-icon-wrap {
|
||||
width: 72rpx; height: 72rpx; border-radius: 36rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-right: 24rpx; flex-shrink: 0;
|
||||
}
|
||||
.dc-icon { font-size: 32rpx; }
|
||||
.dc-info { flex: 1; }
|
||||
.dc-title { font-size: 28rpx; font-weight: 500; color: #333; display: block; margin-bottom: 6rpx; }
|
||||
.dc-desc { font-size: 24rpx; color: #999; }
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
* 资讯详情
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="资讯详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="article-card">
|
||||
<text class="ac-title">{{ article.title }}</text>
|
||||
|
||||
<view class="ac-meta">
|
||||
<text class="am-category" :class="article.categoryClass">{{ article.category }}</text>
|
||||
<text class="am-date">{{ article.date }}</text>
|
||||
</view>
|
||||
|
||||
<view class="ac-divider"></view>
|
||||
|
||||
<view class="ac-body">
|
||||
<!-- 渲染后端返回的富文本content正文 -->
|
||||
<rich-text :nodes="article.content"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { normalizeRichTextImages } from '@/utils/richText.mjs'
|
||||
|
||||
const article = ref({
|
||||
title: '',
|
||||
category: '行业资讯',
|
||||
categoryClass: 'info',
|
||||
date: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const fetchNews = async (id) => {
|
||||
try {
|
||||
const res = await request.get(`/c/news/${id}`, { showLoading: false })
|
||||
if (res.data) {
|
||||
article.value = {
|
||||
title: res.data.title || '',
|
||||
category: res.data.category === 'notice' ? '平台通知' : '行业资讯',
|
||||
categoryClass: res.data.category === 'notice' ? 'notice' : 'info',
|
||||
date: res.data.created_at || '',
|
||||
content: normalizeRichTextImages(res.data.content || '')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取资讯详情失败', e)
|
||||
uni.showToast({ title: '获取资讯详情失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
fetchNews(params.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
.ac-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.ac-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.am-category {
|
||||
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 16rpx;
|
||||
&.info { background: #dbeafe; color: #2563eb; }
|
||||
&.notice { background: #fef3c7; color: #d97706; }
|
||||
}
|
||||
.am-date { font-size: 24rpx; color: #999; }
|
||||
.am-reads { font-size: 24rpx; color: #ccc; margin-left: auto; }
|
||||
|
||||
.ac-divider {
|
||||
height: 1rpx;
|
||||
background: #f0f0f0;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.ac-body {
|
||||
font-size: 28rpx;
|
||||
color: #444;
|
||||
line-height: 2;
|
||||
:deep(p) {
|
||||
margin-bottom: 24rpx;
|
||||
text-indent: 2em;
|
||||
}
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
margin: 16rpx 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<!--
|
||||
* 资讯列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="行业资讯" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view class="page-content">
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="tab-item"
|
||||
:class="{ active: currentTab === i }" @click="currentTab = i">
|
||||
<text class="ti-text">{{ tab }}</text>
|
||||
<view class="ti-line" v-if="currentTab === i"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="list-scroll">
|
||||
<view v-for="(news, i) in filteredNews" :key="i" class="news-card" @click="goDetail(news)">
|
||||
<view class="nc-image" :style="news.image ? {} : { background: news.bg || '#e5e7eb' }">
|
||||
<image v-if="news.image" :src="news.image" mode="aspectFill" class="nc-cover-img" />
|
||||
<text v-else class="nc-img-icon">{{ news.emoji || '📰' }}</text>
|
||||
</view>
|
||||
<view class="nc-info">
|
||||
<text class="nc-title">{{ news.title }}</text>
|
||||
<text class="nc-desc">{{ news.desc }}</text>
|
||||
<view class="nc-meta">
|
||||
<text class="nc-category" :class="news.categoryClass">{{ news.category }}</text>
|
||||
<text class="nc-date">{{ news.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="filteredNews.length === 0">
|
||||
<text class="es-text">暂无资讯</text>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const newsList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
getNewsList(1)
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const tabs = ['全部', '平台资讯', '平台通知']
|
||||
const currentTab = ref(0)
|
||||
|
||||
const getNewsList = async (pageNum = 1) => {
|
||||
uni.showLoading({ title: '加载中...' })
|
||||
try {
|
||||
const res = await request.get('/c/news', { page: pageNum, showLoading: false })
|
||||
if (res.data) {
|
||||
const items = (res.data.items || []).map((item, i) => ({
|
||||
...item,
|
||||
image: item.cover || '',
|
||||
desc: item.subtitle || '',
|
||||
date: item.created_at || '',
|
||||
category: item.category === 'notice' ? '平台通知' : '行业资讯',
|
||||
categoryClass: item.category === 'notice' ? 'notice' : 'info',
|
||||
bg: item.category === 'notice' ? 'linear-gradient(135deg, #f093fb, #f5576c)' : 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
emoji: item.category === 'notice' ? '🔧' : '📰'
|
||||
}))
|
||||
newsList.value = pageNum === 1 ? items : newsList.value.concat(items)
|
||||
page.value = res.data.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data.last_page || 1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取资讯列表失败', e)
|
||||
} finally {
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await getNewsList(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
const filteredNews = computed(() => {
|
||||
if (currentTab.value === 0) return newsList.value
|
||||
const cat = currentTab.value === 1 ? '行业资讯' : '平台通知'
|
||||
return newsList.value.filter(n => n.category === cat)
|
||||
})
|
||||
|
||||
const goDetail = (item) => {
|
||||
appStore.setCurrentNews(item)
|
||||
openSub('news-detail', { id: item.id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
padding: 0 30rpx;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
.ti-text { font-size: 28rpx; color: #666; }
|
||||
.active .ti-text { color: #3b82f6; font-weight: 600; }
|
||||
.ti-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #3b82f6;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
.nc-image {
|
||||
width: 100%;
|
||||
height: 300rpx;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.nc-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.nc-img-icon {
|
||||
font-size: 64rpx;
|
||||
}
|
||||
.nc-info { padding: 24rpx; }
|
||||
.nc-title { font-size: 30rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; line-height: 1.5; }
|
||||
.nc-desc { font-size: 26rpx; color: #666; display: block; margin-bottom: 16rpx; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.nc-meta { display: flex; align-items: center; gap: 16rpx; }
|
||||
.nc-category {
|
||||
font-size: 22rpx; padding: 2rpx 14rpx; border-radius: 16rpx;
|
||||
&.info { background: #dbeafe; color: #2563eb; }
|
||||
&.notice { background: #fef3c7; color: #d97706; }
|
||||
}
|
||||
.nc-date { font-size: 22rpx; color: #ccc; }
|
||||
.nc-reads { font-size: 22rpx; color: #ccc; margin-left: auto; }
|
||||
}
|
||||
|
||||
.empty-state { text-align: center; padding: 80rpx 0; .es-text { font-size: 28rpx; color: #ccc; } }
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<!--
|
||||
* B端通知详情 — 普通团长/超级团长/商务经理 共用
|
||||
-->
|
||||
<template xmlns="">
|
||||
<view class="sub-page">
|
||||
<nav-bar title="通知详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="article-card nd-rich-content">
|
||||
<text class="ac-title">{{ notice.title }}</text>
|
||||
<text class="ac-subtitle" v-if="notice.subtitle">{{ notice.subtitle }}</text>
|
||||
<view class="ac-meta">
|
||||
<text class="ac-cat">系统通知</text>
|
||||
<text class="ac-date">{{ notice.date }}</text>
|
||||
<!-- 相对时间暂不展示 -->
|
||||
</view>
|
||||
<view class="ac-divider"></view>
|
||||
<rich-text :nodes="notice.content" v-if="notice.content"></rich-text>
|
||||
<view class="ac-empty" v-else>
|
||||
<text class="ae-icon">📋</text>
|
||||
<text class="ae-text">暂无正文内容</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { normalizeRichTextImages } from '@/utils/richText.mjs'
|
||||
|
||||
const notice = ref({})
|
||||
|
||||
const fetchNotice = async (id) => {
|
||||
try {
|
||||
const res = await request.get(`/c/notices/${id}`, { showLoading: false })
|
||||
if (res.data) {
|
||||
notice.value = {
|
||||
title: res.data.title,
|
||||
subtitle: res.data.subtitle || '',
|
||||
date: (res.data.created_at || '').slice(0, 10),
|
||||
content: normalizeRichTextImages(res.data.content || '')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取通知详情失败', e)
|
||||
uni.showToast({ title: '获取通知详情失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
fetchNotice(params.id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx 32rpx 36rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(15,18,38,.05);
|
||||
|
||||
.ac-title {
|
||||
font-size: 35rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ac-subtitle {
|
||||
font-size: 25rpx;
|
||||
color: #8B91A1;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.ac-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.ac-cat {
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
font-weight: 500;
|
||||
background: #E6F4FF;
|
||||
color: #1677FF;
|
||||
}
|
||||
|
||||
.ac-date {
|
||||
font-size: 24rpx;
|
||||
color: #BCC2CE;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ac-time {
|
||||
font-size: 24rpx;
|
||||
color: #1677FF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ac-divider {
|
||||
height: 2px;
|
||||
background: #D8DCE4;
|
||||
margin: 28rpx 0;
|
||||
}
|
||||
|
||||
.ac-empty {
|
||||
text-align: center;
|
||||
padding: 80rpx 0 40rpx;
|
||||
.ae-icon { font-size: 64rpx; display: block; margin-bottom: 20rpx; }
|
||||
.ae-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 富文本全局样式(非 scoped,<rich-text> 原生渲染无法被 scoped 样式命中) -->
|
||||
<style lang="scss">
|
||||
.nd-rich-content {
|
||||
p {
|
||||
font-size: 28rpx;
|
||||
color: #444;
|
||||
line-height: 2;
|
||||
margin-bottom: 20rpx;
|
||||
text-indent: 2em;
|
||||
}
|
||||
h2 { font-size: 32rpx; font-weight: 600; color: #0F1226; margin: 24rpx 0 16rpx; line-height: 1.4; }
|
||||
h3 { font-size: 28rpx; font-weight: 600; color: #0F1226; margin: 20rpx 0 14rpx; line-height: 1.4; }
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: 12rpx;
|
||||
margin: 16rpx 0;
|
||||
display: block;
|
||||
}
|
||||
ul, ol { padding-left: 2em; margin-bottom: 20rpx; }
|
||||
li { font-size: 28rpx; color: #444; line-height: 2; }
|
||||
blockquote {
|
||||
border-left: 4rpx solid #1677FF;
|
||||
padding: 8rpx 0 8rpx 20rpx;
|
||||
margin: 20rpx 0;
|
||||
background: #F7F9FC;
|
||||
border-radius: 0 8rpx 8rpx 0;
|
||||
color: #666;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; margin: 20rpx 0; }
|
||||
th, td { border: 1rpx solid #EEF0F5; padding: 12rpx 16rpx; font-size: 26rpx; text-align: left; }
|
||||
th { background: #F7F9FC; font-weight: 600; color: #0F1226; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,214 @@
|
||||
<!--
|
||||
* 消息中心
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="消息中心" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view class="page-content">
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="tab-item"
|
||||
:class="{ active: currentTab === i }" @click="switchTab(i)">
|
||||
<text class="ti-text">{{ tab.name }}</text>
|
||||
<!-- TODO: 待实现用户消息已读状态后,再展示真实未读数量角标 -->
|
||||
<!-- <view class="ti-badge" v-if="tab.count">{{ tab.count }}</view> -->
|
||||
<view class="ti-line" v-if="currentTab === i"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="list-scroll">
|
||||
<view v-for="(n, i) in notices" :key="i" class="notice-card">
|
||||
<view class="nc-dot" :class="n.read ? 'read' : 'unread'"></view>
|
||||
<view class="nc-content">
|
||||
<view class="nc-top">
|
||||
<text class="nc-type" :class="n.typeClass">{{ n.type }}</text>
|
||||
<text class="nc-time">{{ n.time }}</text>
|
||||
</view>
|
||||
<text class="nc-title">{{ n.title }}</text>
|
||||
<text class="nc-desc">{{ n.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="notices.length === 0">
|
||||
<text class="es-text">暂无消息</text>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const typeMap = {
|
||||
system: '系统通知', revenue: '收益通知', device: '设备通知', activity: '活动通知'
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ name: '系统通知', key: 'system', count: 0 },
|
||||
{ name: '收益通知', key: 'revenue', count: 0 },
|
||||
{ name: '设备通知', key: 'device', count: 0 },
|
||||
{ name: '活动通知', key: 'activity', count: 0 }
|
||||
]
|
||||
const currentTab = ref(0)
|
||||
const notices = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
await fetchNotices(1)
|
||||
})
|
||||
|
||||
const fetchNotices = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/notices', {
|
||||
type: tabs[currentTab.value].key,
|
||||
page: pageNum,
|
||||
showLoading: false
|
||||
})
|
||||
const items = (res.data.items || []).map(item => ({
|
||||
...item,
|
||||
typeText: typeMap[item.type] || item.type,
|
||||
typeClass: item.type,
|
||||
desc: item.content ? item.content.replace(/<[^>]+>/g, '') : (item.subtitle || ''),
|
||||
time: item.created_at,
|
||||
read: false // 公开公告无已读状态
|
||||
}))
|
||||
notices.value = pageNum === 1 ? items : notices.value.concat(items)
|
||||
page.value = res.data.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data.last_page || 1)
|
||||
const counts = res.data.counts || {}
|
||||
tabs.forEach(t => { t.count = counts[t.key] || 0 })
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchNotices(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
const switchTab = (index) => {
|
||||
if (currentTab.value === index) return
|
||||
currentTab.value = index
|
||||
notices.value = []
|
||||
page.value = 1
|
||||
hasMore.value = false
|
||||
fetchNotices(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
padding: 0 20rpx;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
.ti-text { font-size: 26rpx; color: #666; }
|
||||
.active .ti-text { color: #3b82f6; font-weight: 600; }
|
||||
.ti-ti-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #3b82f6;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
.ti-badge {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 20rpx;
|
||||
min-width: 28rpx;
|
||||
height: 28rpx;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
border-radius: 14rpx;
|
||||
text-align: center;
|
||||
line-height: 28rpx;
|
||||
padding: 0 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.ti-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #3b82f6;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 16rpx;
|
||||
.nc-dot {
|
||||
width: 12rpx; height: 12rpx; border-radius: 6rpx;
|
||||
margin-top: 8rpx; margin-right: 16rpx; flex-shrink: 0;
|
||||
&.unread { background: #3b82f6; }
|
||||
&.read { background: #e5e7eb; }
|
||||
}
|
||||
.nc-content { flex: 1; }
|
||||
.nc-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||||
.nc-type {
|
||||
font-size: 22rpx; padding: 2rpx 14rpx; border-radius: 16rpx;
|
||||
&.system { background: #dbeafe; color: #2563eb; }
|
||||
&.revenue { background: #d1fae5; color: #059669; }
|
||||
&.device { background: #fef3c7; color: #d97706; }
|
||||
&.activity { background: #fce7f3; color: #db2777; }
|
||||
}
|
||||
.nc-time { font-size: 22rpx; color: #ccc; }
|
||||
.nc-title { font-size: 28rpx; font-weight: 500; color: #333; display: block; margin-bottom: 8rpx; }
|
||||
.nc-desc { font-size: 24rpx; color: #999; line-height: 1.6; display: block; }
|
||||
}
|
||||
|
||||
.empty-state { text-align: center; padding: 80rpx 0; .es-text { font-size: 28rpx; color: #ccc; } }
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<!--
|
||||
* 平台软件
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="平台软件" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Clients -->
|
||||
<text class="section-title">客户端下载</text>
|
||||
|
||||
<view class="client-card" v-for="(item, i) in downloads" :key="i">
|
||||
<view class="cc-left">
|
||||
<view class="cc-icon-wrap" :style="{ background: item.platform === 'windows' ? '#d1fae5' : '#dbeafe' }">
|
||||
<text class="cc-icon">{{ item.platform === 'windows' ? '🪟' : '🐧' }}</text>
|
||||
</view>
|
||||
<view class="cc-info">
|
||||
<text class="cc-name">{{ item.name }}</text>
|
||||
<text class="cc-version">v{{ item.version }}</text>
|
||||
<text class="cc-downloads">{{ formatThousands(item.download_count) }} 次下载</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="cc-btn" @click="downloadFile(item)">下载</view>
|
||||
</view>
|
||||
|
||||
<view v-if="downloads.length === 0" class="client-card" style="justify-content:center;">
|
||||
<text style="color:#999;font-size:26rpx;">暂无可用客户端</text>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Changelog -->
|
||||
<view class="changelog-card" v-if="allChangelogs.length">
|
||||
<text class="section-title">更新日志</text>
|
||||
|
||||
<view v-for="(log, i) in allChangelogs" :key="i" class="cl-item">
|
||||
<view class="cl-header">
|
||||
<text class="cl-version">{{ log.version }}</text>
|
||||
<text class="cl-date">{{ log.date }}</text>
|
||||
</view>
|
||||
<text class="cl-change">{{ log.changelog }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const downloads = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
await fetchDownloads(1)
|
||||
})
|
||||
|
||||
const fetchDownloads = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/downloads', { page: pageNum, showLoading: false })
|
||||
const items = res.data?.items || []
|
||||
downloads.value = pageNum === 1 ? items : downloads.value.concat(items)
|
||||
page.value = res.data?.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data?.last_page || 1)
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchDownloads(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const downloadFile = (item) => {
|
||||
if (item.file_path) {
|
||||
uni.showToast({ title: '开始下载 ' + item.name, icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: '暂无下载链接', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 合并所有软件的更新日志,按日期倒序
|
||||
const allChangelogs = computed(() => {
|
||||
const logs = []
|
||||
downloads.value.forEach(d => {
|
||||
if (d.changelogs && d.changelogs.length) {
|
||||
d.changelogs.forEach(v => logs.push({ ...v, software: d.name }))
|
||||
}
|
||||
})
|
||||
logs.sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||
return logs
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
min-height: 100vh;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
.page-content {
|
||||
height: calc(100vh - 44px);
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 16rpx;
|
||||
.cc-left { display: flex; align-items: center; gap: 20rpx; flex: 1; }
|
||||
.cc-icon-wrap {
|
||||
width: 72rpx; height: 72rpx; border-radius: 36rpx;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.cc-icon { font-size: 32rpx; }
|
||||
.cc-info { flex: 1; }
|
||||
.cc-name { font-size: 28rpx; font-weight: 500; color: #333; display: block; }
|
||||
.cc-version { font-size: 22rpx; color: #999; display: block; margin: 4rpx 0; }
|
||||
.cc-downloads { font-size: 22rpx; color: #999; }
|
||||
.cc-btn {
|
||||
padding: 12rpx 36rpx;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 26rpx;
|
||||
border-radius: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.changelog-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-top: 20rpx;
|
||||
.cl-item {
|
||||
margin-bottom: 24rpx;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
.cl-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.cl-version {
|
||||
font-size: 28rpx; font-weight: 600; color: #3b82f6;
|
||||
}
|
||||
.cl-date { font-size: 22rpx; color: #999; }
|
||||
.cl-change {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
padding: 6rpx 0;
|
||||
padding-left: 20rpx;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
background: #d1d5db;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,526 @@
|
||||
<!--
|
||||
* 添加自有设备 — 完整修复版(含 Step 1 下拉框 + 输入框绑定)
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="添加自有设备" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 介绍横幅 -->
|
||||
<view class="intro-banner">
|
||||
<text class="ib-emoji">🖥️</text>
|
||||
<text class="ib-title">自有设备部署</text>
|
||||
<text class="ib-desc">支持用户自主部署服务器、安装软件,通过设备ID和特征码绑定接入平台,独立运行、独立统计收益</text>
|
||||
</view>
|
||||
|
||||
<!-- Step 1: 设备信息 -->
|
||||
<view class="card">
|
||||
<view class="step-head">
|
||||
<text class="step-num">1</text>
|
||||
<text class="step-title">填写设备信息</text>
|
||||
</view>
|
||||
|
||||
<!-- 设备名称 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">设备名称 <text class="required">*</text></text>
|
||||
<input
|
||||
v-model.trim="formData.deviceName"
|
||||
class="form-input"
|
||||
placeholder="如:我的自有服务器-01"
|
||||
placeholder-class="input-placeholder"
|
||||
maxlength="32"
|
||||
/>
|
||||
<text class="form-tip">规则: 2‑32字符,支持中文、字母、数字、‑_,禁止空格及其它特殊符号</text>
|
||||
</view>
|
||||
|
||||
<!-- 设备ID / 型号 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">设备ID / 型号 <text class="required">*</text></text>
|
||||
<view class="input-row">
|
||||
<input
|
||||
v-model.trim="formData.deviceId"
|
||||
class="form-input flex-1"
|
||||
placeholder="请输入设备ID或型号"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<view class="camera-btn" @click="handleCameraRecognize">📷</view>
|
||||
</view>
|
||||
<text class="form-tip">支持手动输入或拍照识别设备铭牌(设备ID / SN / 型号等)</text>
|
||||
</view>
|
||||
|
||||
<!-- 对接方式 -->
|
||||
<view class="docking-section">
|
||||
<text class="docking-title">对接方式</text>
|
||||
<text class="docking-desc">对接方式说明(图文/视频)由后台运营编辑下发,包括方案介绍、适用场景、操作步骤等</text>
|
||||
<view class="docking-link" @click="openSub('docking-detail')">查看对接机制详情 ›</view>
|
||||
</view>
|
||||
|
||||
<!-- 特征码 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">特征码</text>
|
||||
<view class="input-row">
|
||||
<input
|
||||
v-model.trim="formData.featureCode"
|
||||
class="form-input flex-1"
|
||||
placeholder="请输入Agent生成的16位特征码"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
<view class="scan-btn" @click="handleScanCode">扫码</view>
|
||||
</view>
|
||||
<view class="feature-steps">
|
||||
<text class="fs-item">1. 在设备上安装全犀云Agent</text>
|
||||
<text class="fs-item">2. Agent自动采集硬件指纹生成特征码</text>
|
||||
<text class="fs-item">3. 复制特征码到此处录入并验证</text>
|
||||
</view>
|
||||
<text class="form-tip warn">特征码与设备硬件唯一绑定(CPU+MAC+磁盘序列号),换机即失效</text>
|
||||
</view>
|
||||
|
||||
<!-- 设备类型(已补回下拉框选择) -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">设备类型 <text class="required">*</text></text>
|
||||
<view class="custom-select" @click="showTypePicker = !showTypePicker">
|
||||
<text class="cs-val">{{ selectedTypeFee.name }}(¥{{ formatThousands(selectedTypeFee.fee) }})</text>
|
||||
<text class="cs-arrow">{{ showTypePicker ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 下拉菜单列表 -->
|
||||
<view class="cs-dropdown" v-if="showTypePicker">
|
||||
<view
|
||||
v-for="t in deviceTypes"
|
||||
:key="t.id"
|
||||
class="cs-option"
|
||||
:class="{ active: formData.selectedType === t.id }"
|
||||
@click="formData.selectedType = t.id; showTypePicker = false"
|
||||
>
|
||||
<text class="cso-name">{{ t.name }}</text>
|
||||
<text class="cso-fee">¥{{ formatThousands(t.fee) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="form-tip">设备类型由后台统一配置,工程师按您设备实际型号匹配最优方案</text>
|
||||
</view>
|
||||
|
||||
<!-- 部署位置 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">部署位置</text>
|
||||
<input
|
||||
v-model.trim="formData.location"
|
||||
class="form-input"
|
||||
placeholder="如:家庭、办公室、机房"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 2: 部署引导 -->
|
||||
<view class="card">
|
||||
<view class="step-head">
|
||||
<text class="step-num">2</text>
|
||||
<text class="step-title">部署引导</text>
|
||||
</view>
|
||||
<view class="guide-step">
|
||||
<text class="gs-num">1</text>
|
||||
<view class="gs-body">
|
||||
<text class="gs-title">下载部署脚本</text>
|
||||
<text class="gs-desc">SSH登录设备,执行安装命令</text>
|
||||
<view class="cmd-box" @click="copyCommand">
|
||||
<text class="cmd-text">curl -fsSL https://quanxi.cloud/install | bash</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="guide-step">
|
||||
<text class="gs-num">2</text>
|
||||
<view class="gs-body">
|
||||
<text class="gs-title">启动Agent服务</text>
|
||||
<text class="gs-desc">安装完成后Agent自动启动,并生成特征码</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="guide-step">
|
||||
<text class="gs-num">3</text>
|
||||
<view class="gs-body">
|
||||
<text class="gs-title">验证绑定</text>
|
||||
<text class="gs-desc">将特征码填入 Step 1,点击提交完成绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 3: 接口费明细 -->
|
||||
<view class="card">
|
||||
<view class="step-head">
|
||||
<text class="step-num">3</text>
|
||||
<text class="step-title">接口费(已选设备类型)</text>
|
||||
</view>
|
||||
<view class="type-options">
|
||||
<view class="type-item active">
|
||||
<view class="type-row">
|
||||
<view class="type-info">
|
||||
<text class="type-name">{{ selectedTypeFee.name }}</text>
|
||||
<text class="type-desc">{{ selectedTypeFee.desc }}</text>
|
||||
</view>
|
||||
<text class="type-fee">¥{{ formatThousands(selectedTypeFee.fee) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="fee-total">
|
||||
<text class="fee-total-label">应付接口费</text>
|
||||
<text class="fee-total-val">¥{{ formatThousands(selectedTypeFee.fee) }}</text>
|
||||
</view>
|
||||
<text class="fee-note">订单生成后请按公户信息打款并上传回执凭证,平台审核完成后开通部署权限;接口费按设备型号一次性收取,不含硬件费用</text>
|
||||
</view>
|
||||
|
||||
<!-- 自有设备权益 -->
|
||||
<view class="card">
|
||||
<text class="card-title">自有设备权益</text>
|
||||
<view class="info-row" v-for="b in benefits" :key="b.label">
|
||||
<text class="ir-label">{{ b.label }}</text>
|
||||
<text class="ir-val">{{ b.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 提交区域 -->
|
||||
<view class="submit-section">
|
||||
<view class="submit-btn" @click="handleSubmit">生成订单并公户打款</view>
|
||||
<text class="submit-tip">提交后生成订单,需通过公户打款完成支付</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区占位 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const showTypePicker = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
deviceName: '',
|
||||
deviceId: '',
|
||||
featureCode: '',
|
||||
location: '',
|
||||
selectedType: 0
|
||||
})
|
||||
|
||||
const deviceTypes = ref([])
|
||||
const submitting = ref(false)
|
||||
|
||||
const selectedTypeFee = computed(() => {
|
||||
return deviceTypes.value.find(t => t.id === formData.selectedType) || deviceTypes.value[0] || { name: '请选择', fee: 0 }
|
||||
})
|
||||
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/self-device-products', { showLoading: false })
|
||||
deviceTypes.value = (res.data || []).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
model: p.model,
|
||||
fee: p.service_price,
|
||||
desc: p.specs?.slice(0, 2).map(s => s.value).join(' / ') || ''
|
||||
}))
|
||||
if (deviceTypes.value.length > 0) {
|
||||
formData.selectedType = deviceTypes.value[0].id
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
onLoad(() => { loadProducts() })
|
||||
|
||||
const benefits = [
|
||||
{ label: '独立运行', value: '设备自主管理' },
|
||||
{ label: '独立统计', value: '收益单独核算' },
|
||||
{ label: '灵活部署', value: '支持多场景' },
|
||||
{ label: '加成权益', value: '可享专属活动权益' }
|
||||
]
|
||||
|
||||
// 扫码获取特征码
|
||||
const handleScanCode = () => {
|
||||
uni.scanCode({
|
||||
success: (res) => {
|
||||
if (res.result) {
|
||||
formData.featureCode = res.result
|
||||
uni.showToast({ title: '扫码识别成功', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 拍照识别铭牌
|
||||
const handleCameraRecognize = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['camera', 'album'],
|
||||
success: () => {
|
||||
uni.showLoading({ title: '识别铭牌中...' })
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
formData.deviceId = 'SN-2026-' + Math.floor(Math.random() * 899999 + 100000)
|
||||
uni.showToast({ title: '已自动识别设备ID', icon: 'success' })
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 复制安装命令
|
||||
const copyCommand = () => {
|
||||
uni.setClipboardData({
|
||||
data: 'curl -fsSL https://quanxi.cloud/install | bash',
|
||||
success: () => {
|
||||
uni.showToast({ title: '安装命令已复制', icon: 'success' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 设备名称命名规则
|
||||
const DEVICE_NAME_RE = /^[一-龥a-zA-Z0-9\-_]{2,32}$/
|
||||
|
||||
// 表单提交校验
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.deviceName) return uni.showToast({ title: '请输入设备名称', icon: 'none' })
|
||||
if (!DEVICE_NAME_RE.test(formData.deviceName)) return uni.showToast({ title: '设备名称不符合命名规则,请修改后重试', icon: 'none' })
|
||||
if (!formData.deviceId) return uni.showToast({ title: '请输入设备ID或型号', icon: 'none' })
|
||||
if (!formData.selectedType) return uni.showToast({ title: '请选择设备类型', icon: 'none' })
|
||||
if (submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/c/orders', {
|
||||
product_type: 'self_device_product',
|
||||
product_id: formData.selectedType,
|
||||
device_name: formData.deviceName,
|
||||
location: formData.location
|
||||
}, { showLoading: true })
|
||||
if (res.data && res.data.id) {
|
||||
uni.redirectTo({ url: '/pages-sub/device/own-voucher?id=' + res.data.id })
|
||||
}
|
||||
} catch (e) { /* 422已在拦截器处理 */ }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 40rpx);
|
||||
}
|
||||
|
||||
.intro-banner {
|
||||
background: linear-gradient(135deg, #0A1733, #0D2B6E, #1677FF);
|
||||
border-radius: 20rpx;
|
||||
padding: 36rpx;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -80rpx;
|
||||
right: -60rpx;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.1), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
.ib-emoji { font-size: 64rpx; display: block; position: relative; z-index: 1; }
|
||||
.ib-title { font-size: 32rpx; font-weight: 700; display: block; margin: 12rpx 0; position: relative; z-index: 1; }
|
||||
.ib-desc { font-size: 22rpx; opacity: .7; display: block; position: relative; z-index: 1; }
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
.step-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.step-num { width: 40rpx; height: 40rpx; border-radius: 50%; background: #1677FF; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; }
|
||||
.step-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
|
||||
.form-group { margin-bottom: 24rpx; }
|
||||
.form-label { font-size: 24rpx; color: #5A6072; display: block; margin-bottom: 12rpx; }
|
||||
.required { color: #FF4D4F; margin-left: 4rpx; }
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 2rpx solid #EAEDF3;
|
||||
border-radius: 12rpx;
|
||||
font-size: 26rpx;
|
||||
color: #0F1226;
|
||||
background: #F7F9FC;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-placeholder { color: #BCC2CE; font-size: 26rpx; }
|
||||
|
||||
/* 下拉框样式 */
|
||||
.custom-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
border: 2rpx solid #EAEDF3;
|
||||
border-radius: 12rpx;
|
||||
background: #F7F9FC;
|
||||
font-size: 26rpx;
|
||||
color: #0F1226;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:active { background: #EEF2F8; }
|
||||
}
|
||||
|
||||
.cs-arrow { font-size: 18rpx; color: #BCC2CE; }
|
||||
|
||||
.cs-dropdown {
|
||||
margin-top: 10rpx;
|
||||
border: 2rpx solid #EAEDF3;
|
||||
border-radius: 12rpx;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.cs-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
border-bottom: 2rpx solid #F3F5F9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&.active { background: #F0F7FF; }
|
||||
}
|
||||
|
||||
.cso-name { font-size: 26rpx; color: #0F1226; }
|
||||
.cs-option.active .cso-name { color: #1677FF; font-weight: 600; }
|
||||
.cso-fee { font-size: 24rpx; color: #FF4D4F; font-weight: 500; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
.flex-1 { flex: 1; min-width: 0; }
|
||||
.input-row { display: flex; gap: 16rpx; align-items: center; }
|
||||
|
||||
.scan-btn {
|
||||
background: #1677FF;
|
||||
color: #fff;
|
||||
height: 80rpx;
|
||||
line-height: 80rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 24rpx;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { opacity: .8; }
|
||||
}
|
||||
|
||||
.camera-btn {
|
||||
background: #F3F5F9;
|
||||
height: 80rpx;
|
||||
width: 80rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { background: #E6E8EE; }
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 20rpx;
|
||||
color: #8B91A1;
|
||||
margin-top: 10rpx;
|
||||
display: block;
|
||||
&.warn { color: #FF8800; }
|
||||
}
|
||||
|
||||
.docking-section { background: #F7F9FC; border-radius: 12rpx; padding: 20rpx; margin-bottom: 24rpx; }
|
||||
.docking-title { font-size: 24rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 8rpx; }
|
||||
.docking-desc { font-size: 22rpx; color: #8B91A1; display: block; line-height: 1.6; }
|
||||
.docking-link { font-size: 22rpx; color: #1677FF; display: block; margin-top: 10rpx; }
|
||||
|
||||
.feature-steps { background: #F7F9FC; border-radius: 12rpx; padding: 16rpx 20rpx; margin-top: 12rpx; }
|
||||
.fs-item { font-size: 22rpx; color: #5A6072; display: block; line-height: 1.8; }
|
||||
|
||||
.type-options { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.type-item {
|
||||
padding: 20rpx;
|
||||
border: 2rpx solid #EAEDF3;
|
||||
border-radius: 12rpx;
|
||||
transition: all .2s;
|
||||
&.active { border-color: #1677FF; background: #F0F7FF; }
|
||||
}
|
||||
|
||||
.type-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.type-info { flex: 1; }
|
||||
.type-name { font-size: 26rpx; font-weight: 600; display: block; color: #0F1226; }
|
||||
.type-desc { font-size: 20rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
.type-fee { font-size: 28rpx; font-weight: 700; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; white-space: nowrap; }
|
||||
|
||||
.fee-total { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #F0F7FF, #E6F4FF); border-radius: 12rpx; padding: 24rpx; margin-top: 20rpx; }
|
||||
.fee-total-label { font-size: 26rpx; font-weight: 600; color: #0F1226; }
|
||||
.fee-total-val { font-size: 36rpx; font-weight: 800; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.fee-note { font-size: 22rpx; color: #FF8800; display: block; margin-top: 16rpx; line-height: 1.5; }
|
||||
|
||||
.guide-step { display: flex; gap: 16rpx; padding: 16rpx 0; border-bottom: 2rpx solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.gs-num { width: 44rpx; height: 44rpx; border-radius: 50%; background: #E6F4FF; color: #1677FF; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; flex-shrink: 0; }
|
||||
.gs-body { flex: 1; }
|
||||
.gs-title { font-size: 26rpx; font-weight: 600; display: block; color: #0F1226; }
|
||||
.gs-desc { font-size: 22rpx; color: #8B91A1; margin: 4rpx 0 10rpx; display: block; }
|
||||
.cmd-box { background: #0F1226; border-radius: 10rpx; padding: 16rpx 20rpx; display: inline-block; }
|
||||
.cmd-text { font-family: 'Courier New', monospace; font-size: 22rpx; color: #5FE6CC; word-break: break-all; }
|
||||
|
||||
.info-row { display: flex; justify-content: space-between; padding: 18rpx 0; border-bottom: 2rpx solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 24rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 24rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
.submit-section { margin-top: 32rpx; }
|
||||
.submit-btn {
|
||||
background: linear-gradient(135deg, #1677FF, #0958D9);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
border-radius: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(22,119,255,.25);
|
||||
|
||||
&:active { opacity: .9; }
|
||||
}
|
||||
|
||||
.submit-tip { font-size: 20rpx; color: #BCC2CE; display: block; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<!--
|
||||
* 信用分 — 对齐 C端.html #sub-credit-detail (~line 3055)
|
||||
* 当前信用分仪表盘 + 五维度构成 + 等级说明
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="信用分" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<text class="sub-hint" v-if="deviceId">📡 单设备 · 由中控平台计算并推送</text>
|
||||
<text class="sub-hint" v-else>📊 全部设备信用分均值 · 点击设备详情查看单设备明细</text>
|
||||
|
||||
<!-- 当前信用分仪表盘 -->
|
||||
<view class="score-card">
|
||||
<view class="sc-glow"></view>
|
||||
<view class="sc-inner">
|
||||
<text class="sc-label">当前信用分</text>
|
||||
|
||||
<view class="sc-ring-wrap">
|
||||
<svg viewBox="0 0 120 120" class="sc-svg">
|
||||
<circle fill="none" stroke="#E6F4FF" stroke-width="10" cx="60" cy="60" r="52" />
|
||||
<circle stroke-dashoffset="33" cx="60" cy="60" stroke-width="10" stroke-dasharray="326.7"
|
||||
r="52" fill="none" stroke="url(#creditGrad)" stroke-linecap="round" />
|
||||
<defs>
|
||||
<linearGradient id="creditGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1677FF" />
|
||||
<stop offset="100%" stop-color="#00C4A7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<view class="sc-ring-center">
|
||||
<text class="src-score">{{ creditScore }}</text>
|
||||
<text class="src-total">/ 100</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="sc-badge">{{ scoreLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信用分构成(五维度)— 仅单设备模式展示 -->
|
||||
<view class="card" v-if="deviceId">
|
||||
<text class="card-title">信用分构成(五维度)</text>
|
||||
<view class="info-row" v-for="d in dimensions" :key="d.name">
|
||||
<text class="ir-label">{{ d.name }} (权重{{ d.weight }}%)</text>
|
||||
<text class="ir-val">{{ d.raw }} → {{ d.score }}分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card" v-else>
|
||||
<text class="card-title">信用分构成(五维度)</text>
|
||||
<view class="info-row" style="justify-content:center;padding:24rpx 0;">
|
||||
<text class="ir-label" style="color:#8B91A1;">查看单设备信用分明细请从设备详情页进入</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信用分等级说明 -->
|
||||
<view class="card">
|
||||
<text class="card-title">信用分等级说明</text>
|
||||
<view class="info-row" v-for="g in grades" :key="g.label">
|
||||
<text class="ir-label">{{ g.label }} ({{ g.range }})</text>
|
||||
<text class="ir-val" :style="{ color: g.color }">{{ g.bonus }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 40rpx;"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const creditScore = ref(96)
|
||||
const dimensions = ref([])
|
||||
const scoreLabel = ref('良好 · 质量良好')
|
||||
const deviceId = ref('')
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
deviceId.value = params.id || ''
|
||||
|
||||
if (deviceId.value) {
|
||||
// 单设备详情模式:从设备API获取
|
||||
await loadDeviceCredit(deviceId.value)
|
||||
} else {
|
||||
// 用户总览模式(从我的/首页进入,无 device_id):取全部设备均值
|
||||
await loadUserCredit()
|
||||
}
|
||||
})
|
||||
|
||||
const loadDeviceCredit = async (deviceId) => {
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + deviceId, { showLoading: false })
|
||||
const d = res.data
|
||||
creditScore.value = d.credit_score ?? 96
|
||||
dimensions.value = d.credit_dimensions || []
|
||||
const cs = creditScore.value
|
||||
scoreLabel.value = cs >= 95 ? '优秀 · 质量优秀' : cs >= 80 ? '良好 · 质量良好' : cs >= 60 ? '一般 · 质量一般' : '极差 · 待优化'
|
||||
} catch (e) { /* 设备不存在 */ }
|
||||
}
|
||||
|
||||
const loadUserCredit = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/profile', { showLoading: false })
|
||||
const cs = res.data?.avg_credit_score
|
||||
if (cs !== undefined && cs !== null) {
|
||||
creditScore.value = cs
|
||||
}
|
||||
dimensions.value = [
|
||||
{ name: '带宽达标率', weight: 30, raw: '--', score: '--' },
|
||||
{ name: '丢包率', weight: 25, raw: '--', score: '--' },
|
||||
{ name: '延迟', weight: 20, raw: '--', score: '--' },
|
||||
{ name: '上游回传', weight: 15, raw: '--', score: '--' },
|
||||
{ name: 'NAT稳定性', weight: 10, raw: '--', score: '--' },
|
||||
]
|
||||
const csVal = creditScore.value
|
||||
scoreLabel.value = csVal >= 95 ? '优秀 · 质量优秀' : csVal >= 80 ? '良好 · 质量良好' : csVal >= 60 ? '一般 · 质量一般' : '极差 · 待优化'
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const grades = ref([
|
||||
{ label: '优秀', range: '≥95', bonus: '收益加成 1.2x', color: '#00C4A7' },
|
||||
{ label: '良好', range: '80-94', bonus: '收益加成 1.0x', color: '#1677FF' },
|
||||
{ label: '一般', range: '60-79', bonus: '收益加成 0.8x', color: '#FF8800' },
|
||||
{ label: '极差', range: '<40', bonus: '暂停结算', color: '#FF4D4F' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { height: 100vh; display: flex; flex-direction: column; overflow: hidden; background: #F2F4F8; }
|
||||
.page-content { flex: 1; height: 0; padding: 0 24rpx 40rpx; }
|
||||
|
||||
.sub-hint { font-size: 20rpx; color: #BCC2CE; display: block; padding: 16rpx 0 12rpx; }
|
||||
|
||||
/* 仪表盘卡片 */
|
||||
.score-card {
|
||||
background: linear-gradient(135deg, #F0F7FF, #F0F7FF);
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 16rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sc-glow {
|
||||
position: absolute; top: -60rpx; right: -60rpx; width: 200rpx; height: 200rpx;
|
||||
border-radius: 50%; background: radial-gradient(circle, rgba(22,119,255,.12), transparent 70%);
|
||||
}
|
||||
.sc-inner { position: relative; z-index: 1; padding: 24rpx 0 28rpx; }
|
||||
.sc-label { font-size: 24rpx; color: #8B91A1; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.sc-ring-wrap {
|
||||
width: 240rpx; height: 240rpx; margin: 0 auto 20rpx; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.sc-svg {
|
||||
width: 100%; height: 100%; position: absolute; top: 0; left: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.sc-ring-center { display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.src-score { font-size: 72rpx; font-weight: 800; color: #1677FF; line-height: 1; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.src-total { font-size: 20rpx; color: #8B91A1; margin-top: 4rpx; }
|
||||
|
||||
.sc-badge {
|
||||
display: inline-block; font-size: 26rpx; font-weight: 600; padding: 8rpx 24rpx;
|
||||
border-radius: 32rpx; background: #E6FAF5; color: #00C4A7;
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card { background: #fff; border-radius: 16rpx; padding: 28rpx 24rpx; margin-bottom: 16rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
.info-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14rpx 0; border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.ir-label { font-size: 24rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 24rpx; color: #00C4A7; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!--
|
||||
* 部署引导 — 对齐 C端.html #sub-deploy-guide (~line 2596)
|
||||
* 进度条 + 设备概要 + 4步骤(特征码/网络检测/Agent安装/验证上线)
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="部署引导" :show-back="true" @back="closeSub()" />
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 进度条 -->
|
||||
<view class="progress-bar">
|
||||
<view class="pb-step done"><text>1</text></view><view class="pb-line done"></view>
|
||||
<view class="pb-step done"><text>2</text></view><view class="pb-line done"></view>
|
||||
<view class="pb-step active"><text>3</text></view><view class="pb-line"></view>
|
||||
<view class="pb-step"><text>4</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 设备概要 -->
|
||||
<view class="device-brief">
|
||||
<view class="db-left"><text class="db-emoji">🖥️</text></view>
|
||||
<view class="db-info"><text class="db-name">{{ deviceName }}</text><text class="db-id">ID: {{ deviceIdDisplay }} · 待部署</text></view>
|
||||
<text class="db-status">部署中</text>
|
||||
</view>
|
||||
|
||||
<!-- 步骤1: 特征码绑定 -->
|
||||
<view class="card">
|
||||
<view class="step-head"><text class="step-badge done">✓</text><text class="step-title">步骤1 · 特征码绑定</text><text class="step-tag active">方案B</text></view>
|
||||
<view class="check-list">
|
||||
<view class="check-item done" v-for="c in step1Checks" :key="c.label"><text class="ci-icon">✓</text><text class="ci-text">{{ c.label }}</text><text class="ci-status" :style="{ color: c.color }">{{ c.status }}</text></view>
|
||||
</view>
|
||||
<view class="step-link" @click="openSub('docking-detail')">了解双轨对接策略 ›</view>
|
||||
</view>
|
||||
|
||||
<!-- 步骤2: 网络环境检测 -->
|
||||
<view class="card">
|
||||
<view class="step-head"><text class="step-badge done">✓</text><text class="step-title">步骤2 · 网络环境检测</text></view>
|
||||
<view class="check-list">
|
||||
<view class="check-item" v-for="c in step2Checks" :key="c.label" :class="{ warn: c.warn, done: !c.warn }">
|
||||
<text class="ci-icon">{{ c.warn ? '!' : '✓' }}</text><text class="ci-text">{{ c.label }}</text><text class="ci-status" :style="{ color: c.color }">{{ c.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="result-banner">环境检测全部通过,可以继续部署</view>
|
||||
</view>
|
||||
|
||||
<!-- 步骤3: 安装Agent -->
|
||||
<view class="card">
|
||||
<view class="step-head"><text class="step-badge active">3</text><text class="step-title">步骤3 · 安装全犀云Agent</text></view>
|
||||
<text class="step-desc">Linux / macOS 一键安装</text>
|
||||
<view class="cmd-box"><text class="cmd-text">curl -fsSL https://quanxi.cloud/install | bash</text><view class="cmd-copy" @click="copyCmd">复制</view></view>
|
||||
<text class="step-desc" style="margin-top:16rpx">Windows 下载安装</text>
|
||||
<text class="file-link">QuanXiAgent_Setup_v2.1.0.exe · 下载</text>
|
||||
</view>
|
||||
|
||||
<!-- 步骤4: 验证上线 -->
|
||||
<view class="card">
|
||||
<view class="step-head"><text class="step-badge">4</text><text class="step-title">步骤4 · 验证绑定 & 启动收益</text></view>
|
||||
<view class="check-list">
|
||||
<view class="check-item done" v-for="c in step4Checks" :key="c"><text class="ci-icon">✓</text><text class="ci-text">{{ c }}</text></view>
|
||||
</view>
|
||||
<view class="earn-preview"><text class="ep-val">¥106.60</text><text class="ep-unit">/天</text><text class="ep-desc">自有设备 1.1x 加成 · 月产出约 ¥3,198</text></view>
|
||||
<view class="btn primary full" @click="goDevices">完成部署,上线收益</view>
|
||||
</view>
|
||||
|
||||
<!-- 帮助卡片 -->
|
||||
<view class="help-card">
|
||||
<text class="help-title">遇到问题?</text>
|
||||
<text class="help-item">技术支持:400-888-0000</text>
|
||||
<text class="help-item">在线客服:立即咨询</text>
|
||||
<text class="help-item">部署交流群:QQ群 123456789</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const deviceName = ref('我的自有服务器-01')
|
||||
const deviceIdDisplay = ref('QX-OWN-A10001')
|
||||
|
||||
const step1Checks = ref([
|
||||
{ label: '设备ID: QX-OWN-A10001', status: '已绑定', color: '#00C4A7' },
|
||||
{ label: '特征码: ****', status: '已验证', color: '#00C4A7' },
|
||||
{ label: '硬件指纹', status: '匹配', color: '#00C4A7' },
|
||||
{ label: '设备档案已创建', status: '完成', color: '#00C4A7' }
|
||||
])
|
||||
|
||||
const step2Checks = [
|
||||
{ label: '上行带宽: 1000 Mbps', status: '达标', color: '#00C4A7' },
|
||||
{ label: 'NAT类型: 全锥型', status: '优秀', color: '#00C4A7' },
|
||||
{ label: 'IPv6', status: '已连通', color: '#00C4A7' },
|
||||
{ label: 'UPnP', status: '正常', color: '#00C4A7' },
|
||||
{ label: '磁盘空间: 480GB SSD', status: '充足', color: '#00C4A7' },
|
||||
{ label: '网络延迟: 42ms', status: '优秀', color: '#00C4A7' }
|
||||
]
|
||||
|
||||
const step4Checks = [
|
||||
'Agent版本: v2.1.0 — 最新版',
|
||||
'设备连接 — 在线',
|
||||
'数据回传 — 已验证',
|
||||
'CDN调度 — 就绪'
|
||||
]
|
||||
|
||||
const copyCmd = () => {
|
||||
uni.setClipboardData({ data: 'curl -fsSL https://quanxi.cloud/install | bash' })
|
||||
uni.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
|
||||
const goDevices = () => {
|
||||
uni.showModal({
|
||||
title: '部署完成',
|
||||
content: '设备已上线,开始产生收益。',
|
||||
confirmText: '查看我的设备',
|
||||
cancelText: '留在本页',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.reLaunch({ url: '/pages/devices/index' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.deviceId) {
|
||||
// 审核通过后自动创建了设备,展示设备信息
|
||||
deviceIdDisplay.value = 'SELF-' + String(params.deviceId).padStart(3, '0')
|
||||
deviceName.value = '我的自有服务器'
|
||||
step1Checks.value[0].label = '设备ID: ' + deviceIdDisplay.value
|
||||
step1Checks.value[1].label = '特征码: SELF****'
|
||||
} else if (params.orderId) {
|
||||
deviceIdDisplay.value = '订单#' + params.orderId
|
||||
step1Checks.value[0].label = '订单号: ' + params.orderId
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.progress-bar { display: flex; align-items: center; gap: 0; margin-bottom: 24rpx; }
|
||||
.pb-step { width: 48rpx; height: 48rpx; border-radius: 50%; background: #EAEDF3; color: #BCC2CE; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; flex-shrink: 0;
|
||||
&.done { background: #00C4A7; color: #fff; }
|
||||
&.active { background: #1677FF; color: #fff; box-shadow: 0 0 16rpx rgba(22,119,255,.4); }
|
||||
}
|
||||
.pb-line { flex: 1; height: 4rpx; background: #EAEDF3; margin: 0 8rpx;
|
||||
&.done { background: #00C4A7; }
|
||||
}
|
||||
|
||||
.device-brief { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border-radius: 14rpx; padding: 24rpx; display: flex; align-items: center; gap: 16rpx; margin-bottom: 24rpx; }
|
||||
.db-emoji { font-size: 48rpx; }
|
||||
.db-info { flex: 1; }
|
||||
.db-name { font-size: 28rpx; font-weight: 600; display: block; }
|
||||
.db-id { font-size: 20rpx; color: #8B91A1; }
|
||||
.db-status { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 10rpx; background: #FFF7E6; color: #FF8800; font-weight: 600; }
|
||||
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.step-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.step-badge { width: 44rpx; height: 44rpx; border-radius: 50%; background: #EAEDF3; color: #BCC2CE; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700;
|
||||
&.done { background: #00C4A7; color: #fff; }
|
||||
&.active { background: #1677FF; color: #fff; }
|
||||
}
|
||||
.step-title { font-size: 28rpx; font-weight: 600; color: #0F1226; flex: 1; }
|
||||
.step-tag { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 10rpx;
|
||||
&.active { background: #E6FAF5; color: #00C4A7; }
|
||||
}
|
||||
.step-desc { font-size: 24rpx; color: #8B91A1; display: block; margin-bottom: 12rpx; }
|
||||
.step-link { font-size: 24rpx; color: #1677FF; display: block; text-align: center; padding: 12rpx 0; }
|
||||
|
||||
.check-list { display: flex; flex-direction: column; gap: 10rpx; }
|
||||
.check-item { display: flex; align-items: center; gap: 12rpx; padding: 12rpx 16rpx; background: #F7F9FC; border-radius: 10rpx; }
|
||||
.ci-icon { width: 36rpx; height: 36rpx; border-radius: 50%; background: #E6FAF5; color: #00C4A7; display: flex; align-items: center; justify-content: center; font-size: 20rpx; flex-shrink: 0; }
|
||||
.check-item.warn .ci-icon { background: #FFF1F0; color: #FF4D4F; }
|
||||
.ci-text { flex: 1; font-size: 24rpx; color: #0F1226; }
|
||||
.ci-status { font-size: 20rpx; font-weight: 500; }
|
||||
|
||||
.result-banner { background: #E6FAF5; color: #00C4A7; text-align: center; padding: 16rpx; border-radius: 10rpx; font-size: 22rpx; font-weight: 600; margin-top: 16rpx; }
|
||||
|
||||
.cmd-box { background: #0F1226; border-radius: 10rpx; padding: 16rpx; display: flex; align-items: center; gap: 16rpx; margin-bottom: 8rpx; }
|
||||
.cmd-text { flex: 1; font-family: 'Courier New', monospace; font-size: 20rpx; color: #5FE6CC; word-break: break-all; }
|
||||
.cmd-copy { background: rgba(255,255,255,.1); color: #fff; padding: 8rpx 16rpx; border-radius: 8rpx; font-size: 20rpx; white-space: nowrap; }
|
||||
.file-link { font-size: 24rpx; color: #1677FF; display: block; }
|
||||
|
||||
.earn-preview { background: linear-gradient(135deg, #E6FAF5, #F0FBF8); border-radius: 10rpx; padding: 24rpx; text-align: center; margin: 16rpx 0; }
|
||||
.ep-val { font-size: 56rpx; font-weight: 800; color: #00C4A7; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.ep-unit { font-size: 24rpx; color: #8B91A1; margin-left: 4rpx; }
|
||||
.ep-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
|
||||
.btn.primary.full { background: linear-gradient(135deg, #1677FF, #0958D9); color: #fff; text-align: center; padding: 22rpx 0; border-radius: 14rpx; font-size: 28rpx; font-weight: 600; }
|
||||
|
||||
.help-card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 40rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.help-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 12rpx; }
|
||||
.help-item { font-size: 24rpx; color: #5A6072; line-height: 1.8; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
* 部署进度 — 部署全流程时间线
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="部署进度" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 当前状态 -->
|
||||
<view class="status-summary">
|
||||
<view class="ss-icon running">
|
||||
<text>运</text>
|
||||
</view>
|
||||
<view class="ss-texts">
|
||||
<text class="ss-title">部署进行中</text>
|
||||
<text class="ss-desc">已完成 5/7 个阶段</text>
|
||||
</view>
|
||||
<text class="ss-percent">72%</text>
|
||||
</view>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<view class="progress-track">
|
||||
<view class="pt-fill" style="width: 72%;"></view>
|
||||
</view>
|
||||
|
||||
<!-- 部署阶段 -->
|
||||
<view class="section">
|
||||
<text class="section-title">部署阶段</text>
|
||||
<view class="timeline">
|
||||
<view v-for="(phase, i) in phases" :key="i" class="tl-item"
|
||||
:class="{ completed: phase.status === 'completed', active: phase.status === 'active', pending: phase.status === 'pending' }">
|
||||
<view class="tl-dot">
|
||||
<text v-if="phase.status === 'completed'" class="tl-check">✓</text>
|
||||
<text v-else-if="phase.status === 'active'" class="tl-num">{{ i + 1 }}</text>
|
||||
</view>
|
||||
<view class="tl-line" v-if="i < phases.length - 1"></view>
|
||||
<view class="tl-body">
|
||||
<view class="tlb-header">
|
||||
<text class="tl-title">{{ phase.title }}</text>
|
||||
<text class="tl-badge" :class="phase.status">{{ phase.badge }}</text>
|
||||
</view>
|
||||
<text class="tl-desc">{{ phase.desc }}</text>
|
||||
<text class="tl-time">{{ phase.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/device/deploy-progress/{id}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
// TODO: 对接接口 /api/device/deploy-progress
|
||||
|
||||
const phases = ref([
|
||||
{ title: '订单生成', desc: '自有设备订单已创建', time: '2026-02-01 09:00', status: 'completed', badge: '已完成' },
|
||||
{ title: '公户打款', desc: '对公转账已发放', time: '2026-02-01 15:30', status: 'completed', badge: '已完成' },
|
||||
{ title: '特征码绑定', desc: '特征码已生成并验证通过', time: '2026-02-02 10:00', status: 'completed', badge: '已完成' },
|
||||
{ title: '网络环境检测', desc: '6项网络检测全部通过', time: '2026-02-02 14:00', status: 'completed', badge: '已完成' },
|
||||
{ title: 'Agent部署', desc: 'Agent安装命令已下发', time: '2026-02-03 09:00', status: 'completed', badge: '已完成' },
|
||||
{ title: '验证绑定', desc: '设备在线验证中', time: '2026-02-03 14:00', status: 'active', badge: '进行中' },
|
||||
{ title: '启动收益', desc: '等待设备进入运行状态', time: '-', status: 'pending', badge: '待开始' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx 24rpx 60rpx; }
|
||||
|
||||
// Status summary
|
||||
.status-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
background: linear-gradient(135deg, #0A1733 0%, #1342B5 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.ss-icon {
|
||||
width: 56rpx; height: 56rpx; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; flex-shrink: 0;
|
||||
&.running { background: rgba(22,119,255,.3); }
|
||||
}
|
||||
.ss-texts { flex: 1; }
|
||||
.ss-title { font-size: 28rpx; font-weight: 700; display: block; }
|
||||
.ss-desc { font-size: 20rpx; opacity: .55; display: block; margin-top: 4rpx; }
|
||||
.ss-percent { font-size: 36rpx; font-weight: 800; color: #00C4A7; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
// Progress track
|
||||
.progress-track {
|
||||
height: 8rpx; background: rgba(255,255,255,.2); border-radius: 4rpx; overflow: hidden;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.pt-fill { height: 100%; background: linear-gradient(90deg, #00C4A7, #36DBC5); border-radius: 4rpx; }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 16rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Timeline
|
||||
.timeline { padding-left: 20rpx; }
|
||||
.tl-item { display: flex; gap: 24rpx; padding-bottom: 4rpx; position: relative; }
|
||||
.tl-dot {
|
||||
width: 36rpx; height: 36rpx; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20rpx; font-weight: 700; position: relative; z-index: 1; margin-top: 4rpx;
|
||||
}
|
||||
.completed .tl-dot { background: #00C4A7; }
|
||||
.active .tl-dot { background: #1677ff; box-shadow: 0 0 12rpx rgba(22,119,255,.4); }
|
||||
.pending .tl-dot { background: #f0f0f0; border: 2rpx solid #e0e0e0; }
|
||||
.tl-check { color: #fff; }
|
||||
.tl-num { color: #fff; }
|
||||
|
||||
.tl-line { position: absolute; left: 17rpx; top: 44rpx; bottom: 0; width: 2rpx; background: #e8e8ed; z-index: 0; }
|
||||
.completed .tl-line { background: #00C4A7; }
|
||||
|
||||
.tl-body { flex: 1; background: #fff; border-radius: 14rpx; padding: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.tlb-header { display: flex; align-items: center; gap: 10rpx; margin-bottom: 6rpx; }
|
||||
.tl-title { font-size: 26rpx; font-weight: 600; color: #0F1226; flex: 1; }
|
||||
.tl-badge { font-size: 20rpx; padding: 2rpx 12rpx; border-radius: 6rpx;
|
||||
&.completed { background: #e6faf5; color: #00C4A7; }
|
||||
&.active { background: #e6f4ff; color: #1677ff; }
|
||||
&.pending { background: #F2F4F8; color: #BCC2CE; }
|
||||
}
|
||||
.tl-desc { font-size: 22rpx; color: #8B91A1; display: block; }
|
||||
.tl-time { font-size: 20rpx; color: #BCC2CE; display: block; margin-top: 6rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!--
|
||||
* 设备详情 — Hero卡片 + 功能入口列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="设备详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Hero卡片 -->
|
||||
<view class="detail-hero">
|
||||
<view class="dh-header">
|
||||
<text class="dh-name">{{ device.name }}</text>
|
||||
<view class="dh-badges">
|
||||
<text class="dh-source" :class="device.source === 'own' ? 'source-own' : 'source-buy'">
|
||||
{{ device.source === 'own' ? '自有设备' : '平台购买' }}
|
||||
</text>
|
||||
<text class="dh-status" :style="{ color: statusCfg.color, background: statusCfg.bg }">
|
||||
{{ statusCfg.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="dh-model">{{ device.model }} · {{ device.deviceId }}</text>
|
||||
|
||||
<!-- 监控网格(4指标) -->
|
||||
<view class="dh-stats">
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val">{{ device.bandwidth }}</text>
|
||||
<text class="dhs-unit">Mbps</text>
|
||||
<text class="dhs-label">当前带宽</text>
|
||||
</view>
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val">¥{{ device.todayRevenue }}</text>
|
||||
<text class="dhs-label">今日产出</text>
|
||||
</view>
|
||||
<!-- 信用分暂时隐藏
|
||||
<view class="dh-stat" @click="openSub('credit-detail', { id: deviceId })">
|
||||
<text class="dhs-val" :class="creditColor">{{ device.creditScore }}</text>
|
||||
<text class="dhs-label">信用分 ›</text>
|
||||
</view>
|
||||
-->
|
||||
<view class="dh-stat">
|
||||
<text class="dhs-val">¥{{ device.totalRevenue }}</text>
|
||||
<text class="dhs-label">累计积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="detail-body">
|
||||
<!-- 功能入口卡片 -->
|
||||
<view class="link-cards">
|
||||
<view class="link-card" @click="goPage('revenue-detail')">
|
||||
<view class="lc-icon" style="background: #e6f4ff;"><u-icon name="list" size="18" color="#1677ff"></u-icon></view>
|
||||
<text class="lc-title">积分明细</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="goPage('log')">
|
||||
<view class="lc-icon" style="background: #f9f0ff;"><u-icon name="clock" size="18" color="#722ED1"></u-icon></view>
|
||||
<text class="lc-title">运行记录</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="goPage('monitor')">
|
||||
<view class="lc-icon" style="background: #e6faf5;"><text class="lc-emoji">📊</text></view>
|
||||
<text class="lc-title">实时监控</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="goPage('diagnosis')">
|
||||
<view class="lc-icon" style="background: #fff1f0;"><u-icon name="search" size="18" color="#FF4D4F"></u-icon></view>
|
||||
<text class="lc-title">诊断报告</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="goPage('nat-test')">
|
||||
<view class="lc-icon" style="background: #e6fffb;"><u-icon name="wifi" size="18" color="#13C2C2"></u-icon></view>
|
||||
<text class="lc-title">NAT检测</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="uni.showToast({title: '即将开放', icon: 'none'})">
|
||||
<view class="lc-icon" style="background: #f5f7fa;"><u-icon name="play-circle" size="18" color="#5A6072"></u-icon></view>
|
||||
<text class="lc-title">部署进度</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="link-card" @click="goPage('device-info')">
|
||||
<view class="lc-icon" style="background: #f0f2ff;"><u-icon name="info-circle" size="18" color="#1677ff"></u-icon></view>
|
||||
<text class="lc-title">设备信息</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { DEVICE_STATUS_MAP } from '@/utils/constant'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(true)
|
||||
const deviceId = ref('')
|
||||
const device = ref({})
|
||||
|
||||
const statusCfg = computed(() => DEVICE_STATUS_MAP[device.value.status] || { label: '未知', color: '#999', bg: '#f0f0f0' })
|
||||
// 信用分暂时隐藏
|
||||
// const creditColor = computed(() => (device.value.creditScore || 0) >= 80 ? 'credit-good' : 'credit-bad')
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + id, { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
device.value = {
|
||||
name: d.name,
|
||||
model: d.model,
|
||||
source: d.source,
|
||||
status: d.status,
|
||||
bandwidth: d.bandwidth,
|
||||
// 信用分暂时隐藏
|
||||
// creditScore: d.credit_score,
|
||||
todayRevenue: d.today_revenue,
|
||||
totalRevenue: d.total_revenue,
|
||||
deviceId: d.device_id,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取设备详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
deviceId.value = params.id || ''
|
||||
fetchDetail(params.id)
|
||||
})
|
||||
|
||||
const goPage = (page) => {
|
||||
openSub(page, { id: deviceId.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; height: 400rpx; }
|
||||
.loading-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
|
||||
.detail-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
padding: 36rpx 28rpx 28rpx; color: #fff; margin-bottom: 24rpx;
|
||||
}
|
||||
.dh-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.dh-name { font-size: 34rpx; font-weight: 700; }
|
||||
.dh-badges { display: flex; gap: 8rpx; }
|
||||
.dh-source { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx;
|
||||
&.source-buy { background: rgba(22,119,255,.3); color: #fff; }
|
||||
&.source-own { background: rgba(114,46,209,.3); color: #fff; }
|
||||
}
|
||||
.dh-status { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; }
|
||||
.dh-model { font-size: 22rpx; opacity: .6; display: block; margin: 10rpx 0 20rpx; }
|
||||
.dh-stats { display: flex; }
|
||||
.dh-stat { flex: 1; text-align: center; }
|
||||
.dhs-val { font-size: 32rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.dhs-unit { font-size: 18rpx; opacity: .6; margin-left: 4rpx; }
|
||||
.dhs-label { font-size: 20rpx; opacity: .55; display: block; }
|
||||
.credit-good { color: #7FFFD4; }
|
||||
.credit-bad { color: #FF7A7B; }
|
||||
|
||||
.detail-body { height: calc(100vh - 380rpx); padding: 24rpx; }
|
||||
|
||||
// Link Cards
|
||||
.link-cards { display: flex; flex-direction: column; gap: 0; background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.link-card {
|
||||
padding: 24rpx 20rpx; display: flex; align-items: center; gap: 16rpx;
|
||||
border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
.lc-icon { width: 56rpx; height: 56rpx; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.lc-title { flex: 1; font-size: 26rpx; color: #0F1226; font-weight: 500; }
|
||||
.lc-badge { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 8rpx; background: #E6FAF5; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!--
|
||||
* 设备信息 — 设备基本参数与运行指标
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="设备信息" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="info-card">
|
||||
<text class="card-title">基本信息</text>
|
||||
<view class="info-row"><text class="ir-label">设备ID</text><text class="ir-val">{{ info.deviceId }}</text></view>
|
||||
<view class="info-row"><text class="ir-label">型号</text><text class="ir-val">{{ info.model }}</text></view>
|
||||
<view class="info-row"><text class="ir-label">来源</text><text class="ir-val">{{ info.source }}</text></view>
|
||||
<view class="info-row" v-if="info.createdAt"><text class="ir-label">激活时间</text><text class="ir-val">{{ info.createdAt }}</text></view>
|
||||
<view class="info-row" v-if="info.lastUpdate"><text class="ir-label">最后遥测</text><text class="ir-val">{{ info.lastUpdate }}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="info-card">
|
||||
<text class="card-title">运行指标</text>
|
||||
<view class="info-row" v-if="info.cpu !== null"><text class="ir-label">CPU使用率</text><text class="ir-val">{{ info.cpu }}%</text></view>
|
||||
<view class="info-row" v-if="info.memory !== null"><text class="ir-label">内存使用率</text><text class="ir-val">{{ info.memory }}%</text></view>
|
||||
<view class="info-row" v-if="info.ssd !== null"><text class="ir-label">磁盘类型</text><text class="ir-val">{{ info.ssd || '--' }}</text></view>
|
||||
<view class="info-row" v-if="info.disk !== null"><text class="ir-label">磁盘使用率</text><text class="ir-val">{{ info.disk }}%</text></view>
|
||||
<view class="info-row" v-if="info.temperature !== null"><text class="ir-label">设备温度</text><text class="ir-val">{{ info.temperature }}°C</text></view>
|
||||
<view class="info-row" v-if="info.latency !== null"><text class="ir-label">网络延迟</text><text class="ir-val">{{ info.latency }}ms</text></view>
|
||||
<view class="info-row" v-if="info.packetLoss !== null"><text class="ir-label">丢包率</text><text class="ir-val">{{ info.packetLoss }}%</text></view>
|
||||
<view class="info-row" v-if="info.onlineHours !== null"><text class="ir-label">在线时长</text><text class="ir-val">{{ info.onlineHours }}h</text></view>
|
||||
<view class="info-row" v-if="info.bandwidth !== null"><text class="ir-label">当前带宽</text><text class="ir-val">{{ info.bandwidth }}Mbps</text></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const info = ref({
|
||||
deviceId: '--', model: '--', source: '--', createdAt: '', lastUpdate: '',
|
||||
cpu: null, memory: null, ssd: null, disk: null, temperature: null,
|
||||
latency: null, packetLoss: null, onlineHours: null, bandwidth: null,
|
||||
})
|
||||
|
||||
const parseNum = (s) => { const n = parseFloat(s); return isNaN(n) ? null : n }
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (!params.id) return
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + params.id, { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
const src = d.source === 'buy' ? '平台购买' : d.source === 'own' ? '自有设备' : d.source || '--'
|
||||
info.value = {
|
||||
deviceId: d.device_id || '--',
|
||||
model: d.model || '--',
|
||||
source: src,
|
||||
createdAt: d.created_at || '',
|
||||
lastUpdate: d.last_update || '',
|
||||
cpu: parseNum(d.cpu),
|
||||
memory: parseNum(d.memory),
|
||||
ssd: d.ssd || null,
|
||||
disk: parseNum(d.disk),
|
||||
temperature: parseNum(d.temperature),
|
||||
latency: parseNum(d.latency),
|
||||
packetLoss: parseFloat(d.packet_loss) || null,
|
||||
onlineHours: parseNum(d.online_hours),
|
||||
bandwidth: parseNum(d.bandwidth),
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取设备信息失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
.info-card {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx;
|
||||
margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04);
|
||||
}
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.info-row { display: flex; justify-content: space-between; padding: 14rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 26rpx; color: #8B91A1; }
|
||||
.ir-val { font-size: 26rpx; color: #0F1226; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
* 诊断报告 — 对齐 C端.html #sub-device-diagnosis (~line 5276)
|
||||
* 健康评分 + 8维度指标 + 优化建议 + 历史记录
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="诊断报告" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 诊断日期 + 综合健康评分 -->
|
||||
<view class="score-card">
|
||||
<view class="sc-header">
|
||||
<text class="sc-label">诊断日期</text>
|
||||
<text class="sc-date">{{ data.date }}</text>
|
||||
</view>
|
||||
<view class="sc-hero">
|
||||
<text class="sc-title">综合健康评分</text>
|
||||
<text class="sc-score">{{ data.score }}</text>
|
||||
<text class="sc-badge" :class="data.badge">{{ data.level }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 8维度诊断指标 -->
|
||||
<view class="card">
|
||||
<text class="card-title">8维度诊断指标</text>
|
||||
<view class="info-row" v-for="dim in data.dimensions" :key="dim.name">
|
||||
<text class="ir-label">{{ dim.name }}</text>
|
||||
<text class="ir-val" :style="{ color: dim.color }">{{ dim.value }} → {{ dim.level }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 优化建议 -->
|
||||
<view class="card" v-if="data.suggestions.length">
|
||||
<text class="card-title">优化建议</text>
|
||||
<view v-for="(s, i) in data.suggestions" :key="i" class="suggest-item">
|
||||
<text class="suggest-title">{{ s.title }}</text>
|
||||
<text class="suggest-body">{{ s.body }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史诊断记录 -->
|
||||
<view class="card">
|
||||
<text class="card-title">历史诊断记录</text>
|
||||
<view class="list-group">
|
||||
<view v-for="(h, i) in data.history" :key="i" class="list-item">
|
||||
<view class="li-body">
|
||||
<text class="li-title">{{ h.date }} 诊断报告</text>
|
||||
<text class="li-sub">综合评分 {{ h.score }} · {{ h.level }}</text>
|
||||
</view>
|
||||
<text class="badge-item" :class="h.level === '优秀' ? 'badge-good' : h.level === '良好' ? 'badge-good' : h.level === '一般' ? 'badge-warn' : 'badge-bad'">{{ h.level }}</text>
|
||||
</view>
|
||||
<view v-if="!data.history.length" class="list-empty">
|
||||
<text class="empty-text">暂无历史诊断记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const data = ref({
|
||||
date: '--', score: '--', level: '--', badge: 'b-s',
|
||||
dimensions: [], suggestions: [], history: [],
|
||||
})
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (!params.id) return
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + params.id + '/diagnosis', { showLoading: false })
|
||||
if (res.data) data.value = res.data
|
||||
} catch (e) {
|
||||
console.error('获取诊断报告失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
// 评分卡片
|
||||
.score-card {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx;
|
||||
box-shadow: 0 1px 3px rgba(15,18,38,.04);
|
||||
}
|
||||
.sc-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
|
||||
.sc-label { font-size: 26rpx; font-weight: 600; color: #0F1226; }
|
||||
.sc-date { font-size: 24rpx; color: #8B91A1; }
|
||||
.sc-hero {
|
||||
background: linear-gradient(135deg, #E6FAF5, #F0FBF8);
|
||||
border-radius: 10px; padding: 24rpx; text-align: center;
|
||||
}
|
||||
.sc-title { font-size: 22rpx; color: #8B91A1; display: block; }
|
||||
.sc-score { font-size: 64rpx; font-weight: 800; color: #00C4A7; display: block; margin: 8rpx 0; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.sc-badge { display: inline-block; padding: 4rpx 16rpx; border-radius: 8rpx; font-size: 22rpx; font-weight: 500;
|
||||
&.b-s { background: #E6FAF5; color: #00C4A7; }
|
||||
&.b-w { background: #FFF7E6; color: #FF8800; }
|
||||
&.b-d { background: #FFF1F0; color: #FF4D4F; }
|
||||
}
|
||||
|
||||
// 卡片
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
// Info rows
|
||||
.info-row { display: flex; justify-content: space-between; padding: 14rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; }
|
||||
|
||||
// 建议
|
||||
.suggest-item {
|
||||
background: #FFF7E6; border-left: 6rpx solid #FF8800; border-radius: 10px;
|
||||
padding: 20rpx; margin-bottom: 16rpx;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
.suggest-title { font-size: 24rpx; font-weight: 600; color: #FF8800; display: block; }
|
||||
.suggest-body { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
|
||||
// 列表
|
||||
.list-group { }
|
||||
.list-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.li-title { font-size: 26rpx; color: #0F1226; font-weight: 500; display: block; }
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.badge-item { padding: 4rpx 16rpx; border-radius: 8rpx; font-size: 20rpx; font-weight: 500;
|
||||
&.badge-good { background: #E6FAF5; color: #00C4A7; }
|
||||
&.badge-warn { background: #FFF7E6; color: #FF8800; }
|
||||
&.badge-bad { background: #FFF1F0; color: #FF4D4F; }
|
||||
}
|
||||
.list-empty { padding: 60rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 24rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<!--
|
||||
* 特征码对接详情 — 方案B/A双轨、验证流程、对比表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="特征码对接" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 状态卡 -->
|
||||
<view class="status-banner" :class="verified ? 'success' : ''">
|
||||
<text class="sb-icon">{{ verified ? '✓' : '!' }}</text>
|
||||
<view class="sb-texts">
|
||||
<text class="sb-title">{{ verified ? '已验证(方案B)' : '待验证' }}</text>
|
||||
<text class="sb-desc">Agent本地生成特征码,基于硬件指纹绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 硬件指纹信息 -->
|
||||
<view class="info-card">
|
||||
<text class="info-title">硬件指纹信息</text>
|
||||
<view class="info-rows">
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">设备名称</text>
|
||||
<text class="ir-value">{{ deviceName }}</text>
|
||||
</view>
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">对接方式</text>
|
||||
<text class="ir-value">{{ dockMethod }}</text>
|
||||
</view>
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">特征码</text>
|
||||
<text class="ir-value mono">{{ featureCode }}</text>
|
||||
</view>
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">生成方式</text>
|
||||
<text class="ir-value">本地Agent生成</text>
|
||||
</view>
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">绑定时间</text>
|
||||
<text class="ir-value">{{ bindTime }}</text>
|
||||
</view>
|
||||
<view class="ir-item">
|
||||
<text class="ir-label">验证状态</text>
|
||||
<text class="ir-value" :class="verified ? 'status-success' : ''">{{ verified ? '已验证通过' : '未验证' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 双轨策略 -->
|
||||
<view class="section-block">
|
||||
<text class="section-title">双轨策略</text>
|
||||
|
||||
<!-- 方案B -->
|
||||
<view class="strategy-card active">
|
||||
<view class="sc-badge">当前使用</view>
|
||||
<view class="sc-header">
|
||||
<text class="sc-name">方案B</text>
|
||||
<text class="sc-secure">安全等级 ★★★★★</text>
|
||||
</view>
|
||||
<view class="sc-steps">
|
||||
<view v-for="(s, i) in planBSteps" :key="i" class="scs-item">
|
||||
<text class="scs-num">{{ i + 1 }}</text>
|
||||
<text class="scs-text">{{ s }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 方案A -->
|
||||
<view class="strategy-card disabled">
|
||||
<view class="sc-badge" style="background:#f5f7fa;color:#BCC2CE;">二期上线</view>
|
||||
<view class="sc-header">
|
||||
<text class="sc-name" style="color:#BCC2CE;">方案A</text>
|
||||
<text class="sc-secure" style="color:#d9d9d9;">安全等级 ★★★★</text>
|
||||
</view>
|
||||
<view class="sc-steps">
|
||||
<view v-for="(s, i) in planASteps" :key="i" class="scs-item" style="opacity:.5">
|
||||
<text class="scs-num">{{ i + 1 }}</text>
|
||||
<text class="scs-text">{{ s }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通用验证流程 -->
|
||||
<view class="section-block">
|
||||
<text class="section-title">对接验证流程</text>
|
||||
<view class="flow-list">
|
||||
<view v-for="(f, i) in flowSteps" :key="i" class="fl-item">
|
||||
<view class="fl-num">{{ f.step }}</view>
|
||||
<view class="fl-body">
|
||||
<text class="fl-title">{{ f.title }}</text>
|
||||
<text class="fl-desc">{{ f.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 对比表 -->
|
||||
<view class="section-block">
|
||||
<text class="section-title">方案对比</text>
|
||||
<view class="compare-table">
|
||||
<view class="ct-row ct-header">
|
||||
<text class="ct-cell ct-col-item">对比项</text>
|
||||
<text class="ct-cell ct-col-b">方案B</text>
|
||||
<text class="ct-cell ct-col-a">方案A</text>
|
||||
</view>
|
||||
<view v-for="(row, i) in compareRows" :key="i" class="ct-row">
|
||||
<text class="ct-cell ct-col-item">{{ row.item }}</text>
|
||||
<text class="ct-cell ct-col-b highlight">{{ row.planB }}</text>
|
||||
<text class="ct-cell ct-col-a dim">{{ row.planA }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const deviceName = ref('--')
|
||||
const featureCode = ref('--')
|
||||
const dockMethod = ref('方案B(Agent生成)')
|
||||
const bindTime = ref('--')
|
||||
const verified = ref(false)
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + params.id, { showLoading: false })
|
||||
const d = res.data
|
||||
deviceName.value = d.name || d.device_id || '--'
|
||||
dockMethod.value = d.source === 'own' ? '方案B(Agent生成)' : '平台设备'
|
||||
bindTime.value = d.created_at || '--'
|
||||
verified.value = d.status === 'running'
|
||||
// 特征码用 device_id 脱敏展示
|
||||
if (d.device_id) {
|
||||
featureCode.value = d.device_id.substring(0, 4) + '****'
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const planBSteps = ref([
|
||||
'Agent安装后本地生成特征码',
|
||||
'特征码与硬件指纹绑定',
|
||||
'加密传输至服务器',
|
||||
'服务器端验证并激活',
|
||||
'绑定成功,进入运行态'
|
||||
])
|
||||
|
||||
const planASteps = ref([
|
||||
'手动录入硬件信息',
|
||||
'服务器生成特征码',
|
||||
'人工审核并下发',
|
||||
'设备端确认绑定',
|
||||
'激活上线'
|
||||
])
|
||||
|
||||
const flowSteps = ref([
|
||||
{ step: '01', title: '特征码生成', desc: '系统根据设备硬件信息生成唯一特征码' },
|
||||
{ step: '02', title: '本地上传', desc: 'Agent将特征码加密上传至服务器' },
|
||||
{ step: '03', title: '服务器验证', desc: '服务器对比硬件指纹与特征码一致性' },
|
||||
{ step: '04', title: '绑定确认', desc: '验证通过后,特征码与设备完成绑定' },
|
||||
{ step: '05', title: '激活上线', desc: '绑定成功,设备进入运行中状态' }
|
||||
])
|
||||
|
||||
const compareRows = ref([
|
||||
{ item: '特征码生成', planB: 'Agent端本地生成', planA: '服务器端生成' },
|
||||
{ item: '硬件绑定方式', planB: '硬件指纹自动绑定', planA: '手动录入信息' },
|
||||
{ item: '部署复杂度', planB: '低(自动完成)', planA: '高(需人工参与)' },
|
||||
{ item: '安全性', planB: '高(端到端加密)', planA: '中(人工审核)' },
|
||||
{ item: '上线速度', planB: '分钟级', planA: '小时级' },
|
||||
{ item: '适用场景', planB: '自有设备/新部署', planA: '存量设备迁移' },
|
||||
{ item: '二期支持', planB: '-', planA: '二期上线后支持' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx 24rpx 60rpx; }
|
||||
|
||||
// Status banner
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
&.success { background: linear-gradient(135deg, #e6faf5, #d4f5ed); }
|
||||
}
|
||||
.sb-icon {
|
||||
width: 56rpx; height: 56rpx; border-radius: 50%; background: #00C4A7;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28rpx; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.sb-title { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.sb-desc { font-size: 22rpx; color: #5A6072; display: block; margin-top: 4rpx; }
|
||||
|
||||
// Info card
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.info-title { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
.ir-item { display: flex; padding: 16rpx 0; border-bottom: 1rpx solid #f5f7fa; &:last-child { border: none; } }
|
||||
.ir-label { font-size: 24rpx; color: #8B91A1; width: 160rpx; flex-shrink: 0; }
|
||||
.ir-value { font-size: 24rpx; color: #0F1226; flex: 1;
|
||||
&.mono { font-family: 'Courier New', monospace; }
|
||||
&.status-success { color: #00C4A7; font-weight: 600; }
|
||||
}
|
||||
|
||||
// Section
|
||||
.section-block { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 16rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Strategy card
|
||||
.strategy-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
&.active { border-left: 6rpx solid #00C4A7; }
|
||||
&.disabled { border-left: 6rpx solid #e0e0e0; opacity: .7; }
|
||||
}
|
||||
.sc-badge { position: absolute; top: 16rpx; right: 16rpx; font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; background: #e6faf5; color: #00C4A7; }
|
||||
.sc-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
|
||||
.sc-name { font-size: 30rpx; font-weight: 700; color: #0F1226; }
|
||||
.sc-secure { font-size: 20rpx; color: #FF8800; }
|
||||
.sc-steps { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.scs-item { display: flex; align-items: center; gap: 12rpx; }
|
||||
.scs-num { width: 32rpx; height: 32rpx; border-radius: 50%; background: #e6faf5; color: #00C4A7; font-size: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.scs-text { font-size: 24rpx; color: #5A6072; }
|
||||
|
||||
// Flow list
|
||||
.flow-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.fl-item { display: flex; gap: 16rpx; background: #fff; border-radius: 14rpx; padding: 20rpx; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.fl-num { width: 48rpx; height: 48rpx; background: linear-gradient(135deg, #1677FF, #4096FF); border-radius: 10rpx; display: flex; align-items: center; justify-content: center; font-size: 22rpx; color: #fff; font-weight: 700; flex-shrink: 0; }
|
||||
.fl-body { flex: 1; }
|
||||
.fl-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.fl-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
|
||||
// Compare table
|
||||
.compare-table {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.ct-row { display: flex; padding: 18rpx 16rpx; border-bottom: 1rpx solid #f5f7fa; &:last-child { border: none; } }
|
||||
.ct-header { background: #fafafa; }
|
||||
.ct-cell { font-size: 22rpx; color: #5A6072; text-align: center;
|
||||
.ct-header & { font-weight: 600; color: #0F1226; }
|
||||
}
|
||||
.ct-col-item { flex: 1.2; text-align: left; }
|
||||
.ct-col-b { flex: 1; }
|
||||
.ct-col-a { flex: 1; }
|
||||
.highlight { color: #00C4A7; font-weight: 500; }
|
||||
.dim { color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!--
|
||||
* 运行记录 — 时间轴事件流
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="运行记录" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="section">
|
||||
<text class="section-title">事件时间线</text>
|
||||
|
||||
<view v-if="loading" class="empty-state">
|
||||
<text class="empty-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="events.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无运行记录</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="timeline">
|
||||
<view v-for="(event, i) in events" :key="i" class="tl-item">
|
||||
<view class="tl-node">
|
||||
<view class="tl-dot" :class="event.type"></view>
|
||||
<view class="tl-line" v-if="i < events.length - 1"></view>
|
||||
</view>
|
||||
<view class="tl-card">
|
||||
<text class="tl-time">{{ event.time }}</text>
|
||||
<text class="tl-content">{{ event.content }}</text>
|
||||
<text v-if="event.detail" class="tl-detail">{{ event.detail }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const events = ref([])
|
||||
const loading = ref(true)
|
||||
const deviceId = ref('')
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const fetchLogs = async (pageNum = 1) => {
|
||||
const res = await request.get('/c/devices/' + deviceId.value + '/logs', { page: pageNum, showLoading: pageNum === 1 })
|
||||
const items = res.data?.items || []
|
||||
events.value = pageNum === 1 ? items : events.value.concat(items)
|
||||
page.value = res.data?.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data?.last_page || 1)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchLogs(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (!params.id) return
|
||||
deviceId.value = params.id
|
||||
try {
|
||||
await fetchLogs(1)
|
||||
} catch (e) {
|
||||
console.error('获取运行记录失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 24rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Timeline
|
||||
.timeline { padding-left: 20rpx; }
|
||||
|
||||
.tl-item {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.tl-node {
|
||||
position: relative;
|
||||
width: 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 6rpx;
|
||||
}
|
||||
|
||||
.tl-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&.success {
|
||||
background: #00C4A7;
|
||||
box-shadow: 0 0 10rpx rgba(0, 196, 167, .45);
|
||||
}
|
||||
&.info {
|
||||
background: #1677ff;
|
||||
box-shadow: 0 0 10rpx rgba(22, 119, 255, .35);
|
||||
}
|
||||
&.warn {
|
||||
background: #FF8800;
|
||||
box-shadow: 0 0 10rpx rgba(255, 136, 0, .35);
|
||||
}
|
||||
}
|
||||
|
||||
.tl-line {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
width: 2rpx;
|
||||
flex: 1;
|
||||
min-height: calc(100% - 16rpx);
|
||||
background: #e8e8ed;
|
||||
}
|
||||
|
||||
.tl-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15, 18, 38, .04);
|
||||
}
|
||||
|
||||
.tl-time {
|
||||
font-size: 20rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.tl-content {
|
||||
font-size: 26rpx;
|
||||
color: #0F1226;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tl-detail {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.empty-state { padding: 120rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,497 @@
|
||||
<!--
|
||||
* 实时监控 — 对齐 C端.html #sub-device-monitor (~line 2151)
|
||||
* 设备状态 + 8指标网格 + 带宽趋势柱状图 + 收益趋势柱状图 + 网络质量/硬件/在线统计
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="实时监控" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 设备状态栏 -->
|
||||
<view class="status-card">
|
||||
<text class="sc-label">设备状态</text>
|
||||
<view class="sc-status">
|
||||
<view class="pulse-dot" v-if="data.status === 'running'"></view>
|
||||
<text class="sc-status-text">{{ data.statusText }}</text>
|
||||
</view>
|
||||
<view class="sc-stats">
|
||||
<text>在线 {{ data.onlineDays }}天</text>
|
||||
<text>高峰在线率 98.5%</text>
|
||||
<text>日均在线 {{ dailyAvg }}h</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 8指标网格 -->
|
||||
<view class="monitor-grid">
|
||||
<view v-for="m in metrics" :key="m.label" class="mg-card">
|
||||
<text class="mg-label">{{ m.label }}</text>
|
||||
<view class="mg-val-row">
|
||||
<text class="mg-val" :style="{ color: m.color }">{{ m.value }}</text>
|
||||
<text class="mg-unit" v-if="m.unit">{{ m.unit }}</text>
|
||||
</view>
|
||||
<text class="mg-note" :style="{ color: m.noteColor }">{{ m.note }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 带宽趋势 (24h) -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">带宽趋势 (24h)</text>
|
||||
<view class="legend">
|
||||
<view class="legend-item"><view class="legend-dot legend-up"></view><text>上行</text></view>
|
||||
<view class="legend-item"><view class="legend-dot legend-down"></view><text>下行</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="chart-wrap bw-chart-wrap">
|
||||
<canvas
|
||||
canvas-id="bwChart"
|
||||
id="bwChart"
|
||||
class="bw-canvas"
|
||||
@touchstart="onBwTouch"
|
||||
@touchmove="onBwTouch"
|
||||
@touchend="onBwTouchEnd"
|
||||
></canvas>
|
||||
<view class="chart-x-labels">
|
||||
<text v-for="(l, i) in bwLabelsX" :key="i">{{ l }}</text>
|
||||
</view>
|
||||
<view v-if="bwTooltip.visible" class="chart-tooltip" :style="{ left: bwTooltip.left + 'px', top: bwTooltip.top + 'px' }">
|
||||
<text class="tt-time">{{ bwTooltip.time }}</text>
|
||||
<text class="tt-val tt-up">↑ {{ bwTooltip.up }}</text>
|
||||
<text class="tt-val tt-down">↓ {{ bwTooltip.down }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益趋势 (24h) -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">收益趋势 (24h)</text>
|
||||
<text class="rev-today">¥{{ data.revToday }}</text>
|
||||
</view>
|
||||
<view class="chart-wrap rev-chart-wrap">
|
||||
<canvas
|
||||
canvas-id="revChart"
|
||||
id="revChart"
|
||||
class="rev-canvas"
|
||||
@touchstart="onRevTouch"
|
||||
@touchmove="onRevTouch"
|
||||
@touchend="onRevTouchEnd"
|
||||
></canvas>
|
||||
<view class="chart-x-labels">
|
||||
<text v-for="(l, i) in revLabelsX" :key="i">{{ l }}</text>
|
||||
</view>
|
||||
<view v-if="revTooltip.visible" class="chart-tooltip" :style="{ left: revTooltip.left + 'px', top: revTooltip.top + 'px' }">
|
||||
<text class="tt-time">{{ revTooltip.time }}</text>
|
||||
<text class="tt-val">¥{{ revTooltip.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 网络质量 -->
|
||||
<view class="card">
|
||||
<text class="card-title">网络质量</text>
|
||||
<view class="info-row" v-for="r in netQuality" :key="r.label">
|
||||
<text class="ir-label">{{ r.label }}</text>
|
||||
<text class="ir-val" :style="{ color: r.color }">{{ r.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 硬件状态 -->
|
||||
<view class="card">
|
||||
<text class="card-title">硬件状态</text>
|
||||
<view class="info-row" v-for="r in hardware" :key="r.label">
|
||||
<text class="ir-label">{{ r.label }}</text>
|
||||
<text class="ir-val">{{ r.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 在线统计 -->
|
||||
<view class="card">
|
||||
<text class="card-title">在线统计</text>
|
||||
<view class="info-row" v-for="r in onlineStats" :key="r.label">
|
||||
<text class="ir-label">{{ r.label }}</text>
|
||||
<text class="ir-val" :style="{ color: r.color }">{{ r.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const data = ref({
|
||||
status: 'running', statusText: '运行中', onlineDays: 0, onlineHours: 0,
|
||||
cpu: 0, memory: 0, disk: 0, temperature: 0, latency: 0, packetLoss: 0,
|
||||
bandwidth: 0, bwTarget: 100, bwPercent: 0,
|
||||
natType: '--', ssd: '--', filesystem: '--',
|
||||
bwTrend: { labels: [], up: [], down: [] },
|
||||
revTrend: { labels: [], values: [] },
|
||||
revToday: '0.00',
|
||||
})
|
||||
const loading = ref(true)
|
||||
|
||||
const parseNum = (s) => { const n = parseFloat(s); return isNaN(n) ? 0 : n }
|
||||
|
||||
const dailyAvg = computed(() => {
|
||||
const oh = data.value.onlineHours
|
||||
const days = Math.max(data.value.onlineDays, 1)
|
||||
return oh > 0 ? (oh / days).toFixed(1) : '--'
|
||||
})
|
||||
|
||||
const colorForUsage = (v) => v >= 95 ? '#FF4D4F' : v >= 80 ? '#FF8800' : '#1677FF'
|
||||
const noteForUsage = (v) => v >= 95 ? '偏高' : v >= 80 ? '偏高' : '正常'
|
||||
const noteColorForUsage = (v) => v >= 95 ? '#FF4D4F' : v >= 80 ? '#FF8800' : '#00C4A7'
|
||||
|
||||
const metrics = computed(() => {
|
||||
const d = data.value
|
||||
const cpu = d.cpu; const mem = d.memory; const disk = d.disk
|
||||
const bwPct = d.bwPercent; const pkt = d.packetLoss
|
||||
const temp = d.temperature; const lat = d.latency; const nat = d.natType
|
||||
return [
|
||||
{ label: 'CPU使用率', value: cpu, unit: '%', color: colorForUsage(cpu), note: noteForUsage(cpu), noteColor: noteColorForUsage(cpu) },
|
||||
{ label: '内存使用率', value: mem, unit: '%', color: colorForUsage(mem), note: noteForUsage(mem), noteColor: noteColorForUsage(mem) },
|
||||
{ label: '带宽利用率', value: bwPct, unit: '%', color: colorForUsage(bwPct), note: noteForUsage(bwPct), noteColor: noteColorForUsage(bwPct) },
|
||||
{ label: '丢包率', value: pkt, unit: '%', color: pkt < 1 ? '#00C4A7' : '#FF8800', note: pkt < 1 ? '优秀' : '偏高', noteColor: pkt < 1 ? '#00C4A7' : '#FF8800' },
|
||||
{ label: '磁盘使用率', value: disk, unit: '%', color: colorForUsage(disk), note: d.ssd, noteColor: '#8B91A1' },
|
||||
{ label: '设备温度', value: temp, unit: '°C', color: temp > 70 ? '#FF4D4F' : '#0F1226', note: temp > 70 ? '偏高' : '正常', noteColor: temp > 70 ? '#FF8800' : '#00C4A7' },
|
||||
{ label: '网络延迟', value: lat, unit: 'ms', color: lat < 50 ? '#00C4A7' : '#FF8800', note: lat < 50 ? '优秀' : '偏高', noteColor: lat < 50 ? '#00C4A7' : '#FF8800' },
|
||||
{ label: 'NAT类型', value: nat, unit: '', color: '#00C4A7', note: nat !== '--' ? '优秀' : '--', noteColor: nat !== '--' ? '#00C4A7' : '#8B91A1' },
|
||||
]
|
||||
})
|
||||
|
||||
const netQuality = computed(() => {
|
||||
const d = data.value
|
||||
const lat = d.latency; const bwPct = d.bwPercent; const pkt = d.packetLoss
|
||||
const nat = d.natType
|
||||
return [
|
||||
{ label: '平均延迟', value: lat + ' ms' + (lat < 50 ? ' · 优秀' : ''), color: lat < 50 ? '#00C4A7' : '#FF8800' },
|
||||
{ label: '带宽达标率', value: bwPct + '%', color: bwPct >= 90 ? '#00C4A7' : bwPct >= 70 ? '#FF8800' : '#FF4D4F' },
|
||||
{ label: '丢包率', value: pkt + '%' + (pkt < 1 ? ' · 优秀' : ''), color: pkt < 1 ? '#00C4A7' : '#FF8800' },
|
||||
{ label: 'NAT类型', value: nat + (nat !== '--' ? ' · 优秀' : ''), color: '#00C4A7' },
|
||||
{ label: 'NAT稳定性', value: '稳定', color: '#00C4A7' },
|
||||
{ label: 'UPnP状态', value: '可用', color: '#00C4A7' },
|
||||
{ label: 'IPv6', value: '已连通', color: '#00C4A7' },
|
||||
{ label: '上游回传', value: '正常', color: '#00C4A7' },
|
||||
]
|
||||
})
|
||||
|
||||
const hardware = computed(() => {
|
||||
const d = data.value
|
||||
return [
|
||||
{ label: '磁盘类型', value: d.ssd },
|
||||
{ label: '磁盘使用', value: d.disk + '%' + (d.ssd !== '--' ? ' / ' + d.ssd : '') },
|
||||
{ label: '文件系统', value: d.filesystem + (d.filesystem !== '--' ? ' · 优秀' : '') },
|
||||
{ label: '磁盘健康', value: '健康' },
|
||||
{ label: '设备温度', value: d.temperature + '°C' },
|
||||
]
|
||||
})
|
||||
|
||||
const onlineStats = computed(() => {
|
||||
const d = data.value
|
||||
return [
|
||||
{ label: '连续在线', value: d.onlineDays + ' 天', color: '#00C4A7' },
|
||||
{ label: '日均在线时长', value: dailyAvg.value + 'h · 优秀', color: '#00C4A7' },
|
||||
{ label: '高峰期在线率', value: '98.5% · 优秀', color: '#00C4A7' },
|
||||
{ label: '本月离线次数', value: '0 次', color: '#00C4A7' },
|
||||
]
|
||||
})
|
||||
|
||||
// ---- 柱状图数据 ----
|
||||
function buildBars(values) {
|
||||
const max = Math.max(...values, 1)
|
||||
return values.map(v => {
|
||||
const pct = max > 0 ? Math.round((v / max) * 100) : 0
|
||||
return { val: v, pct: Math.max(pct, 2) }
|
||||
})
|
||||
}
|
||||
|
||||
const revTooltip = reactive({ visible: false, left: 0, top: 0, time: '', value: 0 })
|
||||
|
||||
// ---- X轴标签(取5个采样点) ----
|
||||
function sampleLabels(labels) {
|
||||
if (!labels || !labels.length) return ['00:00', '06:00', '12:00', '18:00', '00:00']
|
||||
const n = labels.length
|
||||
const idx = [0, Math.floor(n / 4), Math.floor(n / 2), Math.floor(3 * n / 4), n - 1]
|
||||
return idx.map(i => labels[i] || '')
|
||||
}
|
||||
|
||||
const bwLabelsX = computed(() => sampleLabels(data.value.bwTrend?.labels))
|
||||
const revLabelsX = computed(() => sampleLabels(data.value.revTrend?.labels))
|
||||
|
||||
// ---- 带宽趋势双折线图(canvas)----
|
||||
const bwTooltip = reactive({ visible: false, left: 0, top: 0, time: '', up: 0, down: 0 })
|
||||
let bwChartRect = null
|
||||
|
||||
function drawBwChart() {
|
||||
const up = data.value.bwTrend?.up || []
|
||||
const down = data.value.bwTrend?.down || []
|
||||
if (!up.length && !down.length) return
|
||||
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#bwChart').boundingClientRect(rect => {
|
||||
if (!rect) return
|
||||
bwChartRect = rect
|
||||
const width = rect.width
|
||||
const height = rect.height
|
||||
const ctx = uni.createCanvasContext('bwChart')
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const allVals = [...up, ...down].filter(v => v > 0)
|
||||
const maxVal = Math.max(...allVals, 1)
|
||||
const n = Math.max(up.length, down.length)
|
||||
const padding = 8
|
||||
const stepX = n > 1 ? (width - padding * 2) / (n - 1) : 0
|
||||
|
||||
const drawLine = (vals, color) => {
|
||||
if (!vals.length) return
|
||||
ctx.beginPath()
|
||||
vals.forEach((v, i) => {
|
||||
const x = padding + stepX * i
|
||||
const y = height - padding - (v / maxVal) * (height - padding * 2)
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.setStrokeStyle(color)
|
||||
ctx.setLineWidth(2)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
drawLine(up, '#1677FF')
|
||||
drawLine(down, '#00C4A7')
|
||||
|
||||
ctx.draw()
|
||||
}).exec()
|
||||
}
|
||||
|
||||
function onBwTouch(e) {
|
||||
const touch = e.touches && e.touches[0]
|
||||
if (!touch || !bwChartRect) return
|
||||
const up = data.value.bwTrend?.up || []
|
||||
const down = data.value.bwTrend?.down || []
|
||||
const labels = data.value.bwTrend?.labels || []
|
||||
if (!up.length && !down.length) return
|
||||
|
||||
const width = bwChartRect.width
|
||||
const n = Math.max(up.length, down.length)
|
||||
const padding = 8
|
||||
const stepX = n > 1 ? (width - padding * 2) / (n - 1) : 0
|
||||
const idx = Math.round((touch.x - padding) / stepX)
|
||||
const i = Math.max(0, Math.min(idx, n - 1))
|
||||
|
||||
bwTooltip.visible = true
|
||||
bwTooltip.left = Math.max(8, Math.min(touch.x - 40, width - 88))
|
||||
bwTooltip.top = Math.max(0, touch.y - 64)
|
||||
bwTooltip.time = labels[i] || ''
|
||||
bwTooltip.up = up[i] || 0
|
||||
bwTooltip.down = down[i] || 0
|
||||
}
|
||||
|
||||
function onBwTouchEnd() {
|
||||
bwTooltip.visible = false
|
||||
}
|
||||
|
||||
// ---- 收益趋势折线图(canvas + 触摸悬浮)----
|
||||
let revChartRect = null
|
||||
|
||||
function drawRevChart() {
|
||||
const values = data.value.revTrend?.values || []
|
||||
if (!values.length) return
|
||||
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('#revChart').boundingClientRect(rect => {
|
||||
if (!rect) return
|
||||
revChartRect = rect
|
||||
const width = rect.width
|
||||
const height = rect.height
|
||||
const ctx = uni.createCanvasContext('revChart')
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const maxVal = Math.max(...values.filter(v => v > 0), 1)
|
||||
const n = values.length
|
||||
const padding = 8
|
||||
const stepX = n > 1 ? (width - padding * 2) / (n - 1) : 0
|
||||
|
||||
const drawPoint = (v, i) => {
|
||||
const x = padding + stepX * i
|
||||
const y = height - padding - (v / maxVal) * (height - padding * 2)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const p = drawPoint(v, i)
|
||||
if (i === 0) ctx.moveTo(p.x, p.y)
|
||||
else ctx.lineTo(p.x, p.y)
|
||||
})
|
||||
ctx.setStrokeStyle('#00C4A7')
|
||||
ctx.setLineWidth(2)
|
||||
ctx.stroke()
|
||||
|
||||
values.forEach((v, i) => {
|
||||
const p = drawPoint(v, i)
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 2.5, 0, 2 * Math.PI)
|
||||
ctx.setFillStyle('#00C4A7')
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
ctx.draw()
|
||||
}).exec()
|
||||
}
|
||||
|
||||
function onRevTouch(e) {
|
||||
const touch = e.touches && e.touches[0]
|
||||
if (!touch || !revChartRect) return
|
||||
const values = data.value.revTrend?.values || []
|
||||
const labels = data.value.revTrend?.labels || []
|
||||
if (!values.length) return
|
||||
|
||||
const width = revChartRect.width
|
||||
const n = values.length
|
||||
const padding = 8
|
||||
const stepX = n > 1 ? (width - padding * 2) / (n - 1) : 0
|
||||
const idx = Math.round((touch.x - padding) / stepX)
|
||||
const i = Math.max(0, Math.min(idx, n - 1))
|
||||
|
||||
revTooltip.visible = true
|
||||
revTooltip.left = Math.max(8, Math.min(touch.x - 40, width - 88))
|
||||
revTooltip.top = Math.max(0, touch.y - 64)
|
||||
revTooltip.time = labels[i] || ''
|
||||
revTooltip.value = values[i] || 0
|
||||
}
|
||||
|
||||
function onRevTouchEnd() {
|
||||
revTooltip.visible = false
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
const deviceId = params.id
|
||||
if (!deviceId) { loading.value = false; return }
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + deviceId + '/monitor', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
data.value = {
|
||||
status: d.status || 'running',
|
||||
statusText: d.status_text || '运行中',
|
||||
onlineDays: d.online_days || 0,
|
||||
onlineHours: parseNum(d.online_hours),
|
||||
cpu: parseNum(d.cpu),
|
||||
memory: parseNum(d.memory),
|
||||
disk: parseNum(d.disk),
|
||||
temperature: parseNum(d.temperature),
|
||||
latency: parseNum(d.latency),
|
||||
packetLoss: parseFloat(d.packet_loss) || 0,
|
||||
bandwidth: parseNum(d.bandwidth),
|
||||
bwTarget: parseNum(d.bw_target) || 100,
|
||||
bwPercent: parseNum(d.bw_percent),
|
||||
natType: d.nat_type || '--',
|
||||
ssd: d.ssd || '--',
|
||||
hdd: d.hdd || '--',
|
||||
filesystem: d.filesystem || '--',
|
||||
bwTrend: d.bw_trend || { labels: [], up: [], down: [] },
|
||||
revTrend: d.rev_trend || { labels: [], values: [] },
|
||||
revToday: d.rev_today || '0.00',
|
||||
}
|
||||
setTimeout(() => { drawBwChart(); drawRevChart() }, 100)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取实时监控数据失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
// 状态栏
|
||||
.status-card {
|
||||
background: linear-gradient(135deg, #E6FAF5, #F0FBF8);
|
||||
border-radius: 14rpx; padding: 24rpx; text-align: center; margin-bottom: 24rpx;
|
||||
}
|
||||
.sc-label { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.sc-status { display: flex; align-items: center; justify-content: center; gap: 10rpx; margin-bottom: 12rpx; }
|
||||
.sc-status-text { font-size: 32rpx; font-weight: 700; color: #00C4A7; }
|
||||
.pulse-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #00C4A7; animation: pulse 1.8s infinite; box-shadow: 0 0 0 0 rgba(0,196,167,.5); }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(0,196,167,.5); } 70% { box-shadow: 0 0 0 16rpx rgba(0,196,167,0); } 100% { box-shadow: 0 0 0 0 rgba(0,196,167,0); } }
|
||||
.sc-stats { display: flex; justify-content: center; gap: 28rpx; font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
// 指标网格
|
||||
.monitor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12rpx; margin-bottom: 24rpx; }
|
||||
.mg-card {
|
||||
background: #fff; border-radius: 14rpx; padding: 20rpx;
|
||||
box-shadow: 0 1px 3px rgba(15,18,38,.04);
|
||||
}
|
||||
.mg-label { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 4rpx; }
|
||||
.mg-val-row { display: flex; align-items: baseline; }
|
||||
.mg-val { font-size: 40rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; letter-spacing: -1px; }
|
||||
.mg-unit { font-size: 22rpx; color: #8B91A1; margin-left: 4rpx; }
|
||||
.mg-note { font-size: 22rpx; display: block; margin-top: 4rpx; }
|
||||
|
||||
// 卡片
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.live-badge { display: flex; align-items: center; gap: 6rpx; font-size: 20rpx; color: #00C4A7; background: #E6FAF5; padding: 4rpx 12rpx; border-radius: 20rpx; }
|
||||
.pulse-dot-sm { width: 10rpx; height: 10rpx; border-radius: 50%; background: #00C4A7; animation: pulse 1.8s infinite; }
|
||||
.rev-today { font-size: 28rpx; font-weight: 700; color: #00C4A7; }
|
||||
|
||||
// 图例
|
||||
.legend { display: flex; gap: 20rpx; }
|
||||
.legend-item { display: flex; align-items: center; gap: 6rpx; font-size: 20rpx; color: #8B91A1; }
|
||||
.legend-dot { width: 16rpx; height: 16rpx; border-radius: 50%; }
|
||||
.legend-up { background: #1677FF; }
|
||||
.legend-down { background: #00C4A7; }
|
||||
|
||||
// 带宽趋势 canvas + tooltip
|
||||
.bw-chart-wrap { position: relative; }
|
||||
.bw-canvas { width: 100%; height: 240rpx; }
|
||||
|
||||
// 收益趋势 canvas + tooltip
|
||||
.rev-chart-wrap { position: relative; }
|
||||
.rev-canvas { width: 100%; height: 240rpx; }
|
||||
.chart-tooltip {
|
||||
position: absolute; z-index: 10; pointer-events: none;
|
||||
background: rgba(15, 18, 38, 0.9); border-radius: 8rpx;
|
||||
padding: 8rpx 16rpx; display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.tt-time { font-size: 18rpx; color: #BCC2CE; }
|
||||
.tt-val { font-size: 24rpx; font-weight: 700; color: #5CDFC8; }
|
||||
.tt-up { color: #69B1FF; }
|
||||
.tt-down { color: #5CDFC8; }
|
||||
|
||||
// 柱状图
|
||||
.chart-wrap { width: 100%; padding: 8rpx 0; }
|
||||
.chart-wrap-sm { }
|
||||
.bar-chart {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
height: 200rpx; padding: 0 2rpx;
|
||||
}
|
||||
.chart-wrap-sm .bar-chart { height: 140rpx; }
|
||||
.bar-col {
|
||||
flex: 1; display: flex; align-items: flex-end; justify-content: center;
|
||||
height: 100%; padding: 0 1rpx;
|
||||
}
|
||||
.bar-fill {
|
||||
width: 100%; max-width: 20rpx; border-radius: 4rpx 4rpx 0 0;
|
||||
min-height: 4rpx; transition: height .3s ease;
|
||||
}
|
||||
.bar-blue { background: linear-gradient(180deg, #1677FF, #69B1FF); }
|
||||
.bar-green { background: linear-gradient(180deg, #00C4A7, #5CDFC8); }
|
||||
.chart-x-labels { display: flex; justify-content: space-between; padding: 8rpx 0 0; }
|
||||
.chart-x-labels text { font-size: 18rpx; color: #BCC2CE; }
|
||||
|
||||
// Info rows
|
||||
.info-row { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; color: #0F1226; }
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
* NAT检测 — 对齐 C端.html #sub-nat-test (~line 5325)
|
||||
* 当前NAT类型 + 类型说明 + 优化指南
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="NAT检测" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 当前NAT类型 Hero卡片 -->
|
||||
<view class="nat-hero-card">
|
||||
<text class="nh-label">当前NAT类型</text>
|
||||
<text class="nh-type">{{ data.nat_type.cn }}({{ data.nat_type.en }})</text>
|
||||
<text class="nh-desc" v-if="data.nat_type.cn !== '对称型'">最优秀的NAT类型 · 对所有CDN业务无限制</text>
|
||||
<text class="nh-desc" v-else>NAT类型较差,建议优化网络以提升收益</text>
|
||||
|
||||
<!-- 5种类型 badge 条 -->
|
||||
<view class="nh-badges">
|
||||
<text
|
||||
v-for="t in data.type_list"
|
||||
:key="t.cn"
|
||||
class="nh-badge"
|
||||
:class="{ active: t.cn === data.nat_type.cn }"
|
||||
>
|
||||
{{ t.cn }}{{ t.cn === data.nat_type.cn ? ' ●' : '' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- NAT类型说明 -->
|
||||
<view class="card">
|
||||
<text class="card-title">NAT类型说明</text>
|
||||
<view class="info-row" v-for="t in data.type_list" :key="t.cn">
|
||||
<text class="ir-label">{{ t.cn }}</text>
|
||||
<text class="ir-val" :style="{ color: t.color }">{{ t.level }} · 收益加成{{ t.multiplier }}x</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- NAT优化指南 -->
|
||||
<view class="card">
|
||||
<text class="card-title">NAT优化指南</text>
|
||||
<view class="guide-item">
|
||||
<text class="guide-title">① PPPoE拨号(推荐)</text>
|
||||
<text class="guide-body">将设备直接通过PPPoE拨号获取公网IP,可达到公网型NAT,最大化收益。联系运营商申请公网IP。</text>
|
||||
</view>
|
||||
<view class="guide-item">
|
||||
<text class="guide-title">② DMZ设置</text>
|
||||
<text class="guide-body">在路由器管理页面将设备内网IP设置为DMZ主机,开放所有端口。适用于无公网IP场景。</text>
|
||||
</view>
|
||||
<view class="guide-item">
|
||||
<text class="guide-title">③ UPNP设置</text>
|
||||
<text class="guide-body">在路由器管理页面开启UPNP功能,设备自动映射端口。适用于大部分家庭和小型企业网络。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 重新检测 -->
|
||||
<view class="card" v-if="false">
|
||||
<!-- 暂不展示重新检测按钮,等后续有真正检测逻辑再放开 -->
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const data = ref({
|
||||
nat_type: { cn: '--', en: '--', level: '--', color: '#8B91A1', multiplier: '--', raw: '--' },
|
||||
upnp: false,
|
||||
ipv6: false,
|
||||
type_list: [],
|
||||
})
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (!params.id) return
|
||||
try {
|
||||
const res = await request.get('/c/devices/' + params.id + '/nat-test', { showLoading: false })
|
||||
if (res.data) data.value = res.data
|
||||
} catch (e) {
|
||||
console.error('获取NAT检测数据失败', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
// NAT Hero 卡片
|
||||
.nat-hero-card {
|
||||
background: linear-gradient(135deg, #E6FAF5, #F0FBF8);
|
||||
border: 1px solid #B7EBDE; border-radius: 14rpx;
|
||||
padding: 32rpx 24rpx; text-align: center; margin-bottom: 24rpx;
|
||||
}
|
||||
.nh-label { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.nh-type { font-size: 36rpx; font-weight: 800; color: #00C4A7; display: block; margin-bottom: 8rpx; }
|
||||
.nh-desc { font-size: 22rpx; color: #8B91A1; display: block; }
|
||||
.nh-badges { display: flex; justify-content: center; gap: 10rpx; margin-top: 20rpx; flex-wrap: wrap; }
|
||||
.nh-badge {
|
||||
padding: 6rpx 16rpx; border-radius: 8rpx; font-size: 20rpx;
|
||||
background: #F7F9FC; color: #8B91A1;
|
||||
&.active { background: #00C4A7; color: #fff; }
|
||||
}
|
||||
|
||||
// 卡片
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
// Info rows
|
||||
.info-row { display: flex; justify-content: space-between; padding: 14rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; }
|
||||
|
||||
// 指南
|
||||
.guide-item {
|
||||
background: #F7F9FC; border-radius: 10px; padding: 20rpx; margin-bottom: 16rpx;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
.guide-title { font-size: 26rpx; font-weight: 600; color: #1677FF; display: block; }
|
||||
.guide-body { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<!--
|
||||
* 自有设备订单详情 — 基本信息 + 进度时间线 + 设备信息
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="订单详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Hero信息 -->
|
||||
<view class="hero-card">
|
||||
<view class="hc-order-row">
|
||||
<text class="hc-order-no">{{ order.orderId }}</text>
|
||||
<text class="hc-status" :style="{ color: statusCfg.color, background: statusCfg.bg }">{{ statusCfg.label }}</text>
|
||||
</view>
|
||||
<view class="hc-device-row">
|
||||
<text class="hc-device-name">{{ order.deviceName }}</text>
|
||||
<text class="hc-device-model">{{ order.deviceModel }}</text>
|
||||
</view>
|
||||
<view class="hc-amount-row">
|
||||
<text class="hc-amount-label">接口费用</text>
|
||||
<text class="hc-amount">¥{{ formatThousands(order.fee) }}</text>
|
||||
</view>
|
||||
<view class="hc-meta-row">
|
||||
<text class="hc-meta">{{ order.createTime }} 创建</text>
|
||||
<text class="hc-meta">{{ order.deviceType }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单进度时间线 -->
|
||||
<view class="section">
|
||||
<text class="section-title">订单进度</text>
|
||||
<view class="timeline">
|
||||
<view v-for="(step, i) in progressSteps" :key="i" class="tl-item"
|
||||
:class="{ completed: step.status === 'completed', active: step.status === 'active', pending: step.status === 'pending' }">
|
||||
<view class="tl-dot">
|
||||
<text v-if="step.status === 'completed'" class="tl-check">✓</text>
|
||||
<text v-else-if="step.status === 'active'" class="tl-num">{{ i + 1 }}</text>
|
||||
</view>
|
||||
<view class="tl-line" v-if="i < progressSteps.length - 1"></view>
|
||||
<view class="tl-body">
|
||||
<text class="tl-title">{{ step.title }}</text>
|
||||
<text class="tl-desc">{{ step.desc }}</text>
|
||||
<text class="tl-time">{{ step.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">设备信息</text>
|
||||
<view class="info-rows">
|
||||
<view class="ir-item" v-for="(item, i) in deviceInfo" :key="i">
|
||||
<text class="ir-label">{{ item.label }}</text>
|
||||
<text class="ir-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view v-if="showPayBtn" class="action-bar">
|
||||
<view class="action-btn primary" @click="onPay">立即支付</view>
|
||||
<view class="action-btn cancel" @click="onCancel">取消订单</view>
|
||||
</view>
|
||||
<view v-else-if="showDeployBtn" class="action-bar">
|
||||
<view class="action-btn primary" @click="onViewDeploy">查看部署指南</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
import { OWN_ORDER_STATUS_MAP } from '@/utils/constant'
|
||||
|
||||
const orderId = ref('')
|
||||
const order = ref({
|
||||
orderId: '',
|
||||
deviceName: '',
|
||||
deviceModel: '',
|
||||
deviceType: '',
|
||||
fee: 0,
|
||||
status: 'pending_pay',
|
||||
createTime: '',
|
||||
onlineDays: 0,
|
||||
totalEarn: 0
|
||||
})
|
||||
|
||||
// 状态配置
|
||||
const statusCfg = computed(() => OWN_ORDER_STATUS_MAP[order.value.status] || { label: '未知', color: '#909399', bg: '#f0f0f0' })
|
||||
|
||||
// 按钮可见性:待支付时显示支付+取消
|
||||
const showPayBtn = computed(() => order.value.status === 'pending_pay')
|
||||
// 技术对接/熟化中时显示部署指南
|
||||
const showDeployBtn = computed(() => order.value.status === 'integrating' || order.value.status === 'curing')
|
||||
|
||||
// 5 步时间线(绿=completed,蓝=active,灰=pending)
|
||||
const progressSteps = computed(() => {
|
||||
const s = order.value.status
|
||||
const steps = [
|
||||
{ title: '订单生成', desc: '自有设备订单已创建', time: order.value.createTime || '--', status: 'completed' },
|
||||
{ title: '公户打款', desc: '对公转账至公司账户', time: '', status: 'pending' },
|
||||
{ title: '后台审核', desc: '财务审核转账凭证', time: '', status: 'pending' },
|
||||
{ title: '技术对接', desc: '技术人员协助部署接入', time: '', status: 'pending' },
|
||||
{ title: '上线收益', desc: '设备上线开始产生收益', time: '', status: 'pending' }
|
||||
]
|
||||
|
||||
if (s === 'pending_pay') {
|
||||
steps[1].time = '待打款'
|
||||
steps[1].status = 'active'
|
||||
} else if (s === 'paid') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '审核中'
|
||||
steps[2].status = 'active'
|
||||
} else if (s === 'paid-ok') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'
|
||||
steps[2].status = 'completed'
|
||||
steps[3].time = '准备对接'
|
||||
steps[3].status = 'active'
|
||||
} else if (s === 'paid-fail') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '审核驳回'
|
||||
steps[2].status = 'active'
|
||||
} else if (s === 'integrating') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'
|
||||
steps[2].status = 'completed'
|
||||
steps[3].time = '对接中'
|
||||
steps[3].status = 'active'
|
||||
} else if (s === 'curing') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'
|
||||
steps[2].status = 'completed'
|
||||
steps[3].time = '已完成'
|
||||
steps[3].status = 'completed'
|
||||
steps[4].time = '熟化中(30天)'
|
||||
steps[4].status = 'active'
|
||||
} else if (s === 'running') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'
|
||||
steps[2].status = 'completed'
|
||||
steps[3].time = '已完成'
|
||||
steps[3].status = 'completed'
|
||||
steps[4].time = '已上线'
|
||||
steps[4].status = 'completed'
|
||||
} else if (s === 'fault' || s === 'repairing') {
|
||||
steps[1].time = '已打款'
|
||||
steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'
|
||||
steps[2].status = 'completed'
|
||||
steps[3].time = '已完成'
|
||||
steps[3].status = 'completed'
|
||||
steps[4].time = '已上线'
|
||||
steps[4].status = 'completed'
|
||||
}
|
||||
|
||||
return steps
|
||||
})
|
||||
|
||||
// 设备信息列表
|
||||
const deviceInfo = computed(() => [
|
||||
{ label: '设备名称', value: order.value.deviceName || '--' },
|
||||
{ label: '设备型号', value: order.value.deviceModel || '--' },
|
||||
{ label: '设备类型', value: order.value.deviceType || '--' },
|
||||
{ label: '订单编号', value: order.value.orderId || '--' },
|
||||
{ label: '创建时间', value: order.value.createTime || '--' },
|
||||
{ label: '在线天数', value: order.value.onlineDays + ' 天' },
|
||||
{ label: '累计积分', value: '¥' + formatThousands(order.value.totalEarn) }
|
||||
])
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
orderId.value = params.id
|
||||
// 从本地存储读取订单
|
||||
const orders = uni.getStorageSync('ownDeviceOrders') || []
|
||||
const found = orders.find(o => o.orderId === params.id)
|
||||
if (found) {
|
||||
order.value = found
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const onPay = () => {
|
||||
openSub('own-voucher', { orderId: order.value.orderId })
|
||||
}
|
||||
|
||||
const onViewDeploy = () => {
|
||||
openSub('deploy-guide', { orderId: order.value.orderId })
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
uni.showModal({
|
||||
title: '确认取消',
|
||||
content: '确定取消该订单吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const orders = uni.getStorageSync('ownDeviceOrders') || []
|
||||
const idx = orders.findIndex(o => o.orderId === orderId.value)
|
||||
if (idx !== -1) {
|
||||
orders[idx].status = 'paid-fail'
|
||||
uni.setStorageSync('ownDeviceOrders', orders)
|
||||
}
|
||||
uni.showToast({ title: '订单已取消', icon: 'success' })
|
||||
setTimeout(() => closeSub(), 800)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx 24rpx 160rpx; }
|
||||
|
||||
// Hero card
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #1342B5 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.hc-order-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||||
.hc-order-no { font-size: 24rpx; font-family: 'Courier New', monospace; opacity: .8; }
|
||||
.hc-status { font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 6rpx; }
|
||||
.hc-device-row { margin-bottom: 12rpx; }
|
||||
.hc-device-name { font-size: 34rpx; font-weight: 700; display: block; }
|
||||
.hc-device-model { font-size: 24rpx; opacity: .6; display: block; margin-top: 4rpx; }
|
||||
.hc-amount-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12rpx; }
|
||||
.hc-amount-label { font-size: 26rpx; opacity: .6; }
|
||||
.hc-amount { font-size: 44rpx; font-weight: 800; color: #00C4A7; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.hc-meta-row { display: flex; gap: 24rpx; }
|
||||
.hc-meta { font-size: 20rpx; opacity: .45; }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 16rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Timeline
|
||||
.timeline { padding-left: 20rpx; }
|
||||
.tl-item { display: flex; gap: 24rpx; padding-bottom: 4rpx; position: relative; }
|
||||
.tl-dot {
|
||||
width: 36rpx; height: 36rpx; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20rpx; font-weight: 700; position: relative; z-index: 1; margin-top: 4rpx;
|
||||
}
|
||||
.completed .tl-dot { background: #00C4A7; }
|
||||
.active .tl-dot { background: #1677ff; box-shadow: 0 0 12rpx rgba(22,119,255,.4); }
|
||||
.pending .tl-dot { background: #f0f0f0; border: 2rpx solid #e0e0e0; }
|
||||
.tl-check { color: #fff; }
|
||||
.tl-num { color: #fff; }
|
||||
.tl-line { position: absolute; left: 17rpx; top: 44rpx; bottom: 0; width: 2rpx; background: #e8e8ed; z-index: 0; }
|
||||
.completed .tl-line { background: #00C4A7; }
|
||||
.tl-body { flex: 1; background: #fff; border-radius: 14rpx; padding: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.tl-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.tl-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.tl-time { font-size: 20rpx; color: #BCC2CE; display: block; margin-top: 6rpx; }
|
||||
|
||||
// Info rows
|
||||
.info-rows { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.ir-item { display: flex; padding: 18rpx 24rpx; border-bottom: 1rpx solid #f5f7fa; &:last-child { border: none; } }
|
||||
.ir-label { font-size: 26rpx; color: #8B91A1; width: 160rpx; flex-shrink: 0; }
|
||||
.ir-value { font-size: 26rpx; color: #0F1226; }
|
||||
|
||||
// Action bar
|
||||
.action-bar { display: flex; gap: 16rpx; padding: 20rpx 0; }
|
||||
.action-btn { flex: 1; text-align: center; padding: 22rpx; border-radius: 14rpx; font-size: 28rpx; font-weight: 600;
|
||||
&.primary { background: linear-gradient(135deg, #1677FF, #4096FF); color: #fff; }
|
||||
&.cancel { background: #fff; color: #FF4D4F; border: 2rpx solid #FF4D4F; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<!--
|
||||
* 自有设备订单列表 — Tab筛选 + 订单卡片
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="自有设备订单" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- Tab筛选 -->
|
||||
<view class="filter-tabs">
|
||||
<scroll-view scroll-x class="ft-scroll" show-scrollbar="false">
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="ft-item"
|
||||
:class="{ active: currentTab === i }" @click="selectTab(i)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="order-list">
|
||||
<view v-if="filteredOrders.length === 0" class="empty-state">
|
||||
<text class="es-icon">📦</text>
|
||||
<text class="es-text">暂无订单</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(order, i) in filteredOrders" :key="i" class="order-card" @click="goDetail(order)">
|
||||
<view class="oc-header">
|
||||
<text class="oc-no">{{ order.orderId }}</text>
|
||||
<text class="oc-status" :style="{ color: getStatus(order.statusRaw).color, background: getStatus(order.statusRaw).bg }">{{ getStatus(order.statusRaw).label }}</text>
|
||||
</view>
|
||||
|
||||
<view class="oc-body">
|
||||
<view class="oc-row">
|
||||
<text class="oc-label">设备名称</text>
|
||||
<text class="oc-value">{{ order.deviceName }}</text>
|
||||
</view>
|
||||
<view class="oc-row">
|
||||
<text class="oc-label">支付方式</text>
|
||||
<text class="oc-value">{{ order.payMethod }}</text>
|
||||
</view>
|
||||
<view class="oc-row">
|
||||
<text class="oc-label">金额</text>
|
||||
<text class="oc-value amount">¥{{ formatThousands(order.amount) }}</text>
|
||||
</view>
|
||||
<view class="oc-row">
|
||||
<text class="oc-label">创建时间</text>
|
||||
<text class="oc-value time">{{ order.createTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const orders = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const loadOrders = async (pageNum = 1) => {
|
||||
try {
|
||||
const selectedStatus = tabs.value[currentTab.value].key
|
||||
const params = {
|
||||
type: 'self_device',
|
||||
page: pageNum,
|
||||
showLoading: false
|
||||
}
|
||||
if (selectedStatus !== 'all') {
|
||||
params.status = selectedStatus
|
||||
}
|
||||
const res = await request.get('/c/orders', params)
|
||||
const items = (res.data?.items || []).map(o => ({
|
||||
orderId: o.order_no,
|
||||
deviceName: o.name,
|
||||
amount: o.amount,
|
||||
payMethod: o.pay_method || '--',
|
||||
createTime: o.created_at || '',
|
||||
status: o.status,
|
||||
statusRaw: o.status_raw || o.status,
|
||||
statusColor: o.status_color || '#999',
|
||||
statusBg: o.status_bg || '#f0f0f0',
|
||||
id: o.id
|
||||
}))
|
||||
orders.value = pageNum === 1 ? items : orders.value.concat(items)
|
||||
page.value = res.data?.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data?.last_page || 1)
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await loadOrders(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
onShow(() => { loadOrders(1) })
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const currentTab = ref(0)
|
||||
|
||||
const tabs = ref([
|
||||
{ label: '全部', key: 'all' },
|
||||
{ label: '待支付', key: 'pending_pay' },
|
||||
{ label: '审核中', key: 'pending_audit' },
|
||||
{ label: '已通过', key: 'approved' },
|
||||
{ label: '已驳回', key: 'rejected' },
|
||||
{ label: '已取消', key: 'cancelled' }
|
||||
])
|
||||
|
||||
const selectTab = (index) => {
|
||||
if (currentTab.value === index) return
|
||||
currentTab.value = index
|
||||
loadOrders(1)
|
||||
}
|
||||
|
||||
// 获取状态:直接用后端返回的 status 字段作为标签
|
||||
const getStatus = (statusRaw) => {
|
||||
const map = {
|
||||
pending_pay: { label: '待支付', color: '#FF8800', bg: '#FFF7E6' },
|
||||
pending_audit: { label: '审核中', color: '#1677FF', bg: '#E6F4FF' },
|
||||
approved: { label: '已通过', color: '#00C4A7', bg: '#E6FAF5' },
|
||||
rejected: { label: '已驳回', color: '#FF4D4F', bg: '#FFF1F0' },
|
||||
cancelled: { label: '已取消', color: '#6B7280', bg: '#F0F2F5' },
|
||||
completed: { label: '已完成', color: '#00C4A7', bg: '#E6FAF5' }
|
||||
}
|
||||
return map[statusRaw] || { label: statusRaw, color: '#999', bg: '#f0f0f0' }
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
return orders.value
|
||||
})
|
||||
|
||||
const goDetail = (order) => {
|
||||
openSub('pending-order-detail', { id: order.id })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
// Filter tabs
|
||||
.filter-tabs {
|
||||
background: #fff;
|
||||
padding: 16rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
.ft-scroll { white-space: nowrap; }
|
||||
.ft-item {
|
||||
display: inline-block;
|
||||
font-size: 26rpx;
|
||||
color: #5A6072;
|
||||
padding: 12rpx 28rpx;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #F2F4F8;
|
||||
&.active { background: #1677ff; color: #fff; font-weight: 600; }
|
||||
}
|
||||
|
||||
// Order list
|
||||
.order-list { height: calc(100vh - 180rpx); padding: 20rpx 24rpx; }
|
||||
|
||||
// Empty
|
||||
.empty-state { text-align: center; padding: 120rpx 0; }
|
||||
.es-icon { font-size: 60rpx; display: block; }
|
||||
.es-text { font-size: 26rpx; color: #BCC2CE; display: block; margin-top: 12rpx; }
|
||||
|
||||
// Card
|
||||
.order-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.oc-header { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 24rpx; border-bottom: 1rpx solid #f5f7fa; }
|
||||
.oc-no { font-size: 24rpx; color: #8B91A1; font-family: 'Courier New', monospace; }
|
||||
.oc-status { font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 6rpx; }
|
||||
|
||||
.oc-body { padding: 16rpx 24rpx; }
|
||||
.oc-row { display: flex; padding: 10rpx 0; }
|
||||
.oc-label { font-size: 24rpx; color: #8B91A1; width: 140rpx; flex-shrink: 0; }
|
||||
.oc-value { font-size: 24rpx; color: #0F1226; flex: 1; &.amount { color: #FF4D4F; font-weight: 600; font-family: 'DIN Alternate', -apple-system, sans-serif; } &.time { color: #5A6072; } }
|
||||
|
||||
.oc-updated { font-size: 20rpx; color: #BCC2CE; }
|
||||
.oc-arrow { font-size: 28rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<!--
|
||||
* 凭证管理 — 订单信息 + 公户打款 + 上传凭证 + 进度
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="自有设备接口费支付" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 订单信息 -->
|
||||
<view class="order-info-card">
|
||||
<view class="oic-row">
|
||||
<text class="oic-label">订单编号</text>
|
||||
<view class="oic-value-row">
|
||||
<text class="oic-value mono">{{ order.orderNo }}</text>
|
||||
<text class="oic-copy" @click="copyOrderNo">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="oic-divider"></view>
|
||||
<view class="oic-row">
|
||||
<text class="oic-label">接口费用</text>
|
||||
<text class="oic-value amount">¥{{ formatThousands(order.amount) }}</text>
|
||||
</view>
|
||||
<view class="oic-row">
|
||||
<text class="oic-label">支付期限</text>
|
||||
<text class="oic-value warn">请在 2 小时内完成转账</text>
|
||||
</view>
|
||||
<view class="oic-row">
|
||||
<text class="oic-label">订单状态</text>
|
||||
<text class="oic-value status" :style="{ color: statusCfg.color }">{{ statusCfg.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 公司银行账户 -->
|
||||
<view class="bank-card">
|
||||
<text class="bank-title">对公收款账户</text>
|
||||
<view class="bank-rows">
|
||||
<view class="br-item">
|
||||
<text class="br-label">户名</text>
|
||||
<text class="br-value">{{ bankInfo.account_name || '--' }}</text>
|
||||
</view>
|
||||
<view class="br-item">
|
||||
<text class="br-label">开户行</text>
|
||||
<text class="br-value">{{ bankInfo.bank_name || '--' }}</text>
|
||||
</view>
|
||||
<view class="br-item">
|
||||
<text class="br-label">账号</text>
|
||||
<text class="br-value mono">{{ bankInfo.bank_card || '--' }}</text>
|
||||
</view>
|
||||
<view class="br-item">
|
||||
<text class="br-label">备注</text>
|
||||
<text class="br-value">{{ order.orderNo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传凭证 -->
|
||||
<view class="upload-card">
|
||||
<text class="upload-title">上传支付凭证</text>
|
||||
<text class="upload-hint">支持 JPG / JPEG / PNG 格式,单文件不超过10MB</text>
|
||||
|
||||
<view class="upload-area" @click="uploadedFile ? previewReceipt() : chooseFile()">
|
||||
<image v-if="uploadedFile" :src="uploadedFile.path" mode="aspectFit" class="receipt-preview"></image>
|
||||
<template v-else>
|
||||
<text class="ua-icon">+</text>
|
||||
<text class="ua-text">点击上传凭证文件</text>
|
||||
</template>
|
||||
</view>
|
||||
|
||||
<view v-if="uploadedFile" class="uploaded-file" @click="previewReceipt">
|
||||
<text class="uf-icon">📄</text>
|
||||
<view class="uf-info">
|
||||
<text class="uf-name">{{ uploadedFile.name }}</text>
|
||||
<text class="uf-time">{{ uploadedFile.time || '' }}</text>
|
||||
</view>
|
||||
<text class="uf-remove" @click.stop="uploadedFile = null">删除</text>
|
||||
</view>
|
||||
|
||||
<view class="upload-btn" @click="submitVoucher">
|
||||
<text class="ub-text">我已完成打款并上传凭证</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单进度 -->
|
||||
<view class="section">
|
||||
<text class="section-title">订单进度</text>
|
||||
<view class="timeline-compact">
|
||||
<view v-for="(step, i) in orderSteps" :key="i" class="tc-item"
|
||||
:class="{ completed: step.status === 'completed', active: step.status === 'active', pending: step.status === 'pending' }">
|
||||
<view class="tc-dot"></view>
|
||||
<view class="tc-line" v-if="i < orderSteps.length - 1"></view>
|
||||
<view class="tc-body">
|
||||
<text class="tc-title">{{ step.title }}</text>
|
||||
<text class="tc-time">{{ step.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands, copyText } from '@/utils/util'
|
||||
|
||||
const orderId = ref('')
|
||||
const order = ref({
|
||||
orderNo: '',
|
||||
name: '',
|
||||
amount: 0,
|
||||
status: 'pending_pay',
|
||||
createTime: ''
|
||||
})
|
||||
const uploadedFile = ref(null)
|
||||
const submitting = ref(false)
|
||||
const bankInfo = ref({
|
||||
account_name: '',
|
||||
bank_name: '',
|
||||
bank_card: ''
|
||||
})
|
||||
|
||||
const statusCfg = computed(() => {
|
||||
const map = {
|
||||
pending_pay: { label: '待支付', color: '#FF8800', bg: '#FFF7E6' },
|
||||
pending_audit: { label: '审核中', color: '#1677FF', bg: '#E6F4FF' },
|
||||
approved: { label: '已通过', color: '#00C4A7', bg: '#E6FAF5' },
|
||||
rejected: { label: '已驳回', color: '#FF4D4F', bg: '#FFF1F0' },
|
||||
completed: { label: '已完成', color: '#00C4A7', bg: '#E6FAF5' }
|
||||
}
|
||||
return map[order.value.status] || { label: order.value.status, color: '#999', bg: '#F0F0F0' }
|
||||
})
|
||||
|
||||
// 4 步时间线
|
||||
const orderSteps = computed(() => {
|
||||
const status = order.value.status
|
||||
const steps = [
|
||||
{ title: '订单生成', time: order.value.createTime || '--', status: 'completed' },
|
||||
{ title: '公户打款', time: '', status: '' },
|
||||
{ title: '后台审核', time: '', status: '' },
|
||||
{ title: '开通部署权限', time: '', status: '' }
|
||||
]
|
||||
if (status === 'pending_pay') {
|
||||
steps[1].time = '待打款'; steps[1].status = 'active'
|
||||
steps[2].status = 'pending'; steps[3].status = 'pending'
|
||||
} else if (status === 'pending_audit') {
|
||||
steps[1].time = '已打款'; steps[1].status = 'completed'
|
||||
steps[2].time = '审核中'; steps[2].status = 'active'
|
||||
steps[3].status = 'pending'
|
||||
} else if (status === 'approved' || status === 'completed') {
|
||||
steps[1].time = '已打款'; steps[1].status = 'completed'
|
||||
steps[2].time = '已通过'; steps[2].status = 'completed'
|
||||
steps[3].time = status === 'completed' ? '已开通' : '待开通'
|
||||
steps[3].status = status === 'completed' ? 'completed' : 'active'
|
||||
} else if (status === 'rejected') {
|
||||
steps[1].time = '已打款'; steps[1].status = 'completed'
|
||||
steps[2].time = '已驳回'; steps[2].status = 'rejected'
|
||||
steps[3].status = 'pending'
|
||||
}
|
||||
return steps
|
||||
})
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
request.get('/c/system-config/bank-info', { showLoading: false })
|
||||
.then(res => {
|
||||
if (res.data) bankInfo.value = res.data
|
||||
})
|
||||
.catch(() => {})
|
||||
if (params.id) {
|
||||
orderId.value = params.id
|
||||
try {
|
||||
const res = await request.get('/c/orders/' + params.id, { showLoading: false })
|
||||
if (res.data) {
|
||||
order.value = {
|
||||
orderNo: res.data.order_no || '',
|
||||
name: res.data.product_name || '--',
|
||||
amount: res.data.amount || 0,
|
||||
status: res.data.status || 'pending_pay',
|
||||
createTime: res.data.created_at || ''
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const copyOrderNo = () => {
|
||||
copyText(order.value.orderNo || '', '订单编号已复制')
|
||||
}
|
||||
|
||||
const validateReceiptFile = (file) => {
|
||||
const filePath = file?.path || ''
|
||||
const fileName = file?.name || filePath
|
||||
const extension = fileName.split(/[?#]/)[0].split('.').pop()?.toLowerCase()
|
||||
const mimeType = (file?.type || '').toLowerCase()
|
||||
const allowedExtensions = ['jpg', 'jpeg', 'png']
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png']
|
||||
|
||||
if (!allowedExtensions.includes(extension) && !allowedMimeTypes.includes(mimeType)) {
|
||||
uni.showToast({ title: '仅支持 JPG、JPEG、PNG 格式', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
if (Number(file?.size || 0) > 10 * 1024 * 1024) {
|
||||
uni.showToast({ title: '回执文件大小不能超过10MB', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const chooseFile = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempFile = res.tempFiles?.[0]
|
||||
const file = {
|
||||
path: tempFile?.path || res.tempFilePaths?.[0] || '',
|
||||
name: tempFile?.name || '',
|
||||
type: tempFile?.type || '',
|
||||
size: tempFile?.size || 0
|
||||
}
|
||||
if (!validateReceiptFile(file)) return
|
||||
uploadedFile.value = {
|
||||
name: file.name || 'receipt.jpg',
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
time: new Date().toLocaleString('zh-CN')
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err?.errMsg?.includes('cancel')) return
|
||||
uni.showToast({ title: '选择文件失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewReceipt = () => {
|
||||
if (uploadedFile.value?.path) {
|
||||
uni.previewImage({ urls: [uploadedFile.value.path] })
|
||||
}
|
||||
}
|
||||
|
||||
const submitVoucher = () => {
|
||||
if (!uploadedFile.value) {
|
||||
uni.showToast({ title: '请先上传凭证', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
|
||||
uni.uploadFile({
|
||||
url: request.baseURL + '/c/orders/' + orderId.value + '/upload-receipt',
|
||||
filePath: uploadedFile.value.path,
|
||||
name: 'receipt',
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + uni.getStorageSync('token')
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
let msg = '上传失败,请重试'
|
||||
try {
|
||||
const err = JSON.parse(res.data)
|
||||
msg = err.message || msg
|
||||
} catch (e) { /* keep default */ }
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
order.value.status = data.data?.status || 'approved'
|
||||
const deviceId = data.data?.device_id || ''
|
||||
// 对齐 C端.html submitOwnVoucher():弹窗提示后跳转部署引导
|
||||
uni.showModal({
|
||||
title: '审核通过',
|
||||
content: '审核通过,即将进入部署引导',
|
||||
showCancel: false,
|
||||
confirmText: '开始部署',
|
||||
success: () => {
|
||||
closeSub()
|
||||
setTimeout(() => {
|
||||
openSub('deploy-guide', { orderId: orderId.value, deviceId })
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
order.value.status = 'approved'
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: '上传失败,请重试', icon: 'none' })
|
||||
},
|
||||
complete: () => {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx 24rpx 60rpx; }
|
||||
|
||||
// Order info card
|
||||
.order-info-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #1342B5 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.oic-row { display: flex; justify-content: space-between; align-items: center; padding: 10rpx 0; }
|
||||
.oic-label { font-size: 24rpx; opacity: .6; }
|
||||
.oic-value-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.oic-value { font-size: 24rpx; &.mono { font-family: 'Courier New', monospace; } &.amount { font-size: 32rpx; font-weight: 700; } &.warn { color: #FF8800; } &.status { color: #FF8800; } }
|
||||
.oic-copy { font-size: 20rpx; color: #1677ff; background: rgba(22,119,255,.2); padding: 4rpx 12rpx; border-radius: 6rpx; }
|
||||
.oic-divider { height: 1rpx; background: rgba(255,255,255,.1); margin: 8rpx 0; }
|
||||
|
||||
// Bank card
|
||||
.bank-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.bank-title { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
.br-item { display: flex; padding: 14rpx 0; border-bottom: 1rpx solid #f5f7fa; &:last-child { border: none; } }
|
||||
.br-label { font-size: 24rpx; color: #8B91A1; width: 140rpx; flex-shrink: 0; }
|
||||
.br-value { font-size: 24rpx; color: #0F1226; flex: 1; &.mono { font-family: 'Courier New', monospace; } }
|
||||
|
||||
// Upload card
|
||||
.upload-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.upload-title { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.upload-hint { font-size: 22rpx; color: #8B91A1; display: block; margin: 8rpx 0 20rpx; }
|
||||
.upload-area {
|
||||
border: 2rpx dashed #d9d9d9;
|
||||
border-radius: 14rpx;
|
||||
padding: 48rpx 0;
|
||||
text-align: center;
|
||||
&:active { border-color: #1677ff; background: #f0f7ff; }
|
||||
}
|
||||
.receipt-preview { width: 200rpx; height: 200rpx; border-radius: 8rpx; }
|
||||
.ua-icon { font-size: 40rpx; color: #BCC2CE; display: block; }
|
||||
.ua-text { font-size: 24rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
|
||||
.uploaded-file { display: flex; align-items: center; gap: 12rpx; margin-top: 16rpx; padding: 16rpx; background: #F0F7FF; border: 1px solid #BAE0FF; border-radius: 10rpx; }
|
||||
.uf-icon { font-size: 36rpx; }
|
||||
.uf-info { flex: 1; min-width: 0; }
|
||||
.uf-name { font-size: 24rpx; color: #0F1226; font-weight: 500; display: block; }
|
||||
.uf-time { font-size: 20rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
.uf-remove { font-size: 22rpx; color: #FF4D4F; flex-shrink: 0; }
|
||||
|
||||
.upload-btn {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
border-radius: 14rpx;
|
||||
padding: 22rpx;
|
||||
text-align: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.ub-text { font-size: 28rpx; color: #fff; font-weight: 700; }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 16rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Compact timeline
|
||||
.timeline-compact { padding-left: 20rpx; }
|
||||
.tc-item { display: flex; gap: 20rpx; padding-bottom: 4rpx; position: relative; }
|
||||
.tc-dot { width: 16rpx; height: 16rpx; border-radius: 50%; margin-top: 8rpx; flex-shrink: 0; position: relative; z-index: 1; }
|
||||
.completed .tc-dot { background: #00C4A7; }
|
||||
.active .tc-dot { background: #1677ff; box-shadow: 0 0 8rpx rgba(22,119,255,.4); }
|
||||
.pending .tc-dot { background: #e0e0e0; }
|
||||
.tc-line { position: absolute; left: 7rpx; top: 28rpx; bottom: 0; width: 2rpx; background: #e8e8ed; }
|
||||
.completed .tc-line { background: #00C4A7; }
|
||||
.tc-body { flex: 1; padding-bottom: 24rpx; }
|
||||
.tc-title { font-size: 26rpx; color: #0F1226; font-weight: 500; display: block; }
|
||||
.tc-time { font-size: 20rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
.pending .tc-title { color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<!--
|
||||
* 积分明细 — 今日/本月/累计积分汇总 + 明细表格
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="积分明细" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 汇总行 -->
|
||||
<view class="summary-row">
|
||||
<view class="sr-item">
|
||||
<text class="sr-val">¥{{ todayRevenue }}</text>
|
||||
<text class="sr-label">今日产出</text>
|
||||
</view>
|
||||
<view class="sr-divider"></view>
|
||||
<view class="sr-item">
|
||||
<text class="sr-val">¥{{ monthRevenue }}</text>
|
||||
<text class="sr-label">本月产出</text>
|
||||
</view>
|
||||
<view class="sr-divider"></view>
|
||||
<view class="sr-item">
|
||||
<text class="sr-val">¥{{ totalRevenue }}</text>
|
||||
<text class="sr-label">累计积分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明细表格 -->
|
||||
<view class="section">
|
||||
<text class="section-title">积分明细</text>
|
||||
<view class="data-table">
|
||||
<view class="dt-row dt-header">
|
||||
<text class="dt-cell dt-col-date">日期</text>
|
||||
<text class="dt-cell dt-col-hours">时长</text>
|
||||
<text class="dt-cell dt-col-bw">带宽</text>
|
||||
<text class="dt-cell dt-col-factor">时段</text>
|
||||
<text class="dt-cell dt-col-factor">质量</text>
|
||||
<text class="dt-cell dt-col-rev">收益</text>
|
||||
</view>
|
||||
<view v-for="(row, i) in rows" :key="i" class="dt-row">
|
||||
<text class="dt-cell dt-col-date">{{ row.date }}</text>
|
||||
<text class="dt-cell dt-col-hours">{{ row.hours }}h</text>
|
||||
<text class="dt-cell dt-col-bw">{{ row.bw }}M</text>
|
||||
<text class="dt-cell dt-col-factor">{{ row.timeFactor }}</text>
|
||||
<text class="dt-cell dt-col-factor">{{ row.qualityFactor }}</text>
|
||||
<text class="dt-cell dt-col-rev revenue-text">¥{{ row.revenue }}</text>
|
||||
</view>
|
||||
<view v-if="rows.length === 0 && !loading" class="dt-empty">
|
||||
<text class="empty-text">暂无收益记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
<view class="load-more" v-else-if="rows.length > 0">
|
||||
<text class="lm-text">共 {{ rows.length }} 天收益记录</text>
|
||||
</view>
|
||||
|
||||
<!-- 底部提示 -->
|
||||
<view class="footer-note">
|
||||
<text class="fn-text">收益数据每30分钟更新一次</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const todayRevenue = ref(0)
|
||||
const monthRevenue = ref(0)
|
||||
const totalRevenue = ref(0)
|
||||
const rows = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
let deviceId = ''
|
||||
|
||||
const fetchRevenues = async (pageNum) => {
|
||||
const res = await request.get('/c/devices/' + deviceId + '/revenues', {
|
||||
page: pageNum,
|
||||
showLoading: pageNum === 1
|
||||
})
|
||||
const d = res.data
|
||||
todayRevenue.value = d.today_revenue || 0
|
||||
monthRevenue.value = d.month_revenue || 0
|
||||
totalRevenue.value = d.total_revenue || 0
|
||||
const newRows = (d.items || []).map(r => ({
|
||||
date: r.date || '',
|
||||
hours: r.hours || 0,
|
||||
bw: r.bandwidth || 0,
|
||||
timeFactor: r.time_factor || '1.0x',
|
||||
qualityFactor: r.quality_factor || '1.0x',
|
||||
revenue: r.revenue || 0
|
||||
}))
|
||||
if (pageNum === 1) {
|
||||
rows.value = newRows
|
||||
} else {
|
||||
rows.value = rows.value.concat(newRows)
|
||||
}
|
||||
page.value = pageNum
|
||||
hasMore.value = d.has_more || false
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchRevenues(page.value + 1)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (!params.id) return
|
||||
deviceId = params.id
|
||||
try {
|
||||
await fetchRevenues(1)
|
||||
} catch (e) {
|
||||
console.error('获取积分明细失败', e)
|
||||
uni.showToast({ title: '加载失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
// Summary row
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, #0A1733 0%, #1342B5 100%);
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.sr-item { flex: 1; text-align: center; }
|
||||
.sr-val {
|
||||
font-size: 34rpx; font-weight: 700; color: #00C4A7;
|
||||
font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
display: block;
|
||||
}
|
||||
.sr-label { font-size: 22rpx; color: rgba(255,255,255,.6); display: block; margin-top: 6rpx; }
|
||||
.sr-divider { width: 1rpx; height: 48rpx; background: rgba(255,255,255,.15); }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 28rpx; font-weight: 700; color: #0F1226; margin-bottom: 16rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Data table
|
||||
.data-table {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04);
|
||||
}
|
||||
.dt-row {
|
||||
display: flex;
|
||||
padding: 20rpx 16rpx;
|
||||
border-bottom: 1rpx solid #f5f7fa;
|
||||
&:last-child { border: none; }
|
||||
&.dt-header { background: #fafafa; }
|
||||
}
|
||||
.dt-cell {
|
||||
font-size: 22rpx; color: #5A6072; text-align: center;
|
||||
.dt-header & { font-weight: 600; color: #0F1226; font-size: 22rpx; }
|
||||
}
|
||||
.dt-col-date { width: 150rpx; flex-shrink: 0; }
|
||||
.dt-col-hours { width: 80rpx; flex-shrink: 0; }
|
||||
.dt-col-bw { flex: 1; }
|
||||
.dt-col-factor { width: 110rpx; flex-shrink: 0; }
|
||||
.dt-col-rev { width: 140rpx; flex-shrink: 0; }
|
||||
.revenue-text { font-weight: 600; color: #00C4A7; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
// Footer note
|
||||
.footer-note { text-align: center; padding: 20rpx 0 60rpx; }
|
||||
.fn-text { font-size: 22rpx; color: #BCC2CE; }
|
||||
|
||||
// Load more & empty
|
||||
.load-more { text-align: center; padding: 24rpx 0; }
|
||||
.lm-text { font-size: 22rpx; color: #BCC2CE; }
|
||||
.dt-empty { padding: 80rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<!--
|
||||
* 加成券详情 — 对齐 C端.html #sub-coupon-detail (~line 3174)
|
||||
* Hero卡片 + 参数网格 + 效果对比 + 设备绑定 + 支付模式切换 + 购买按钮
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="加成券详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="page-content">
|
||||
<!-- Hero卡片 -->
|
||||
<view class="cp-hero" :style="{ background: coupon.heroBg }">
|
||||
<view class="cp-badge"><text>⚡ 收益加成券</text></view>
|
||||
<text class="cp-mult">{{ coupon.multiplier }}x</text>
|
||||
<text class="cp-name">设备收益{{ coupon.multiplier }}倍加成 · 限时生效</text>
|
||||
<view class="cp-countdown">
|
||||
<view class="cp-cd-item"><text class="cp-cd-num">{{ coupon.days }}</text><text class="cp-cd-label">天有效期</text></view>
|
||||
<view class="cp-cd-item"><text class="cp-cd-num">{{ coupon.days * 24 }}</text><text class="cp-cd-label">小时加成</text></view>
|
||||
<view class="cp-cd-item"><text class="cp-cd-num">+{{ ((coupon.multiplierVal - 1) * 100).toFixed(0) }}%</text><text class="cp-cd-label">收益提升</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 参数网格 -->
|
||||
<view class="card">
|
||||
<view class="cp-params">
|
||||
<view class="cp-param"><view class="cpp-icon" style="background:#FFF7E6"><text>📊</text></view><text class="cpp-val">{{ coupon.multiplier }}x</text><text class="cpp-label">加成倍数</text></view>
|
||||
<view class="cp-param"><view class="cpp-icon" style="background:#E6F4FF"><text>📅</text></view><text class="cpp-val">{{ coupon.days }}天</text><text class="cpp-label">有效期</text></view>
|
||||
<view class="cp-param"><view class="cpp-icon" style="background:#E6FAF5"><text>✅</text></view><text class="cpp-val">{{ coupon.scope }}</text><text class="cpp-label">适用范围</text></view>
|
||||
<view class="cp-param"><view class="cpp-icon" style="background:#FFF1F0"><text>🔒</text></view><text class="cpp-val">{{ coupon.limit }}</text><text class="cpp-label">限购规则</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加成效果说明 -->
|
||||
<view class="card">
|
||||
<text class="card-title">⚡ 加成效果说明</text>
|
||||
<view class="cp-effect">
|
||||
<view class="cpe-compare">
|
||||
<view class="cpe-box before"><text class="cpe-label">无加成券</text><text class="cpe-val">¥{{ formatThousands(coupon.baselineM) }}</text><text class="cpe-unit">/月</text></view>
|
||||
<text class="cpe-arrow">→</text>
|
||||
<view class="cpe-box after"><text class="cpe-label">含加成券</text><text class="cpe-val">¥{{ formatThousands(coupon.boostedM) }}</text><text class="cpe-unit">/月</text></view>
|
||||
</view>
|
||||
<text class="cpe-note">以 QH-500 Edge 为例,额外多赚 ¥{{ formatThousands(coupon.benefit) }},设备成本缩短 {{ coupon.paybackShort }} 天</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 绑定设备 -->
|
||||
<view class="card">
|
||||
<text class="card-title">🖥️ 绑定设备</text>
|
||||
<text class="card-desc">购买后选择1台设备绑定,加成期间该设备收益按{{ coupon.multiplier }}x计算</text>
|
||||
<view class="cp-device-list">
|
||||
<view class="cpd-item" v-for="d in devices" :key="d.id" :class="{ selected: selectedDevice === d.id }" @click="selectedDevice = d.id">
|
||||
<view class="cpd-radio"><view v-if="selectedDevice === d.id" class="cpd-radio-dot"></view></view>
|
||||
<view class="cpd-info"><text class="cpd-name">{{ d.name }}</text><text class="cpd-model">{{ d.model }}</text></view>
|
||||
<text class="cpd-earn">¥{{ d.daily }} → ¥{{ d.dailyBoosted }}/天</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 使用规则 -->
|
||||
<view class="card">
|
||||
<text class="card-title">使用规则</text>
|
||||
<text class="rule-item">· 加成券购买后需在7天内绑定设备,过期失效</text>
|
||||
<text class="rule-item">· 每台设备同时仅可生效一张加成券</text>
|
||||
<text class="rule-item">· 有效期从绑定当日开始计算</text>
|
||||
<text class="rule-item">· 加成期设备下线,加成期顺延</text>
|
||||
<text class="rule-item">· 收益加成在每日0:30结算时生效</text>
|
||||
<text class="rule-item">· 绑定后不可更换设备,解绑后券失效</text>
|
||||
</view>
|
||||
|
||||
<!-- 支付方式 + 价格 -->
|
||||
<view class="price-card">
|
||||
<text class="pc-price-now">{{ coupon.priceText }}</text>
|
||||
<text v-if="coupon.oldPrice" class="pc-price-old">{{ coupon.oldPrice }}</text>
|
||||
|
||||
<view class="pay-modes">
|
||||
<view v-for="m in payModes" :key="m.key" class="pay-mode" :class="{ active: payMode === m.key }" @click="payMode = m.key">
|
||||
{{ m.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pay-detail" v-if="payMode === 'mix'">
|
||||
<text>现金部分 ¥{{ coupon.cashPart }} + 积分部分 {{ coupon.pointsPart }}积分</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bottom-action">
|
||||
<view v-if="payMode === 'points'" class="btn primary" @click="handleBuy">{{ coupon.points_exchange || 0 }}积分兑换</view>
|
||||
<view v-else-if="payMode === 'cash'" class="btn primary" @click="handleBuy">¥{{ coupon.price || 0 }} 立即购买</view>
|
||||
<view v-else class="btn primary" @click="handleBuy">¥{{ coupon.price || 0 }} + {{ coupon.points_exchange || 0 }}积分 立即兑换</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 40rpx;"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const payMode = ref('points')
|
||||
const selectedDevice = ref(0)
|
||||
const payModes = [{ key: 'points', label: '纯积分' }, { key: 'mix', label: '积分+现金' }, { key: 'cash', label: '纯现金' }]
|
||||
const loading = ref(true)
|
||||
|
||||
// 倍率渐变色映射
|
||||
const MULT_GRADIENTS = {
|
||||
'1.5': 'linear-gradient(135deg, #FF8800 0%, #FFA940 40%, #FFB84D 100%)',
|
||||
'2': 'linear-gradient(135deg, #FF4D4F 0%, #FF6B35 40%, #FF8C5A 100%)',
|
||||
'1.2': 'linear-gradient(135deg, #1677FF 0%, #4096FF 40%, #69B1FF 100%)',
|
||||
'1.1': 'linear-gradient(135deg, #00C4A7 0%, #5FE6CC 40%, #8CF5E0 100%)',
|
||||
}
|
||||
const defaultHeroBg = 'linear-gradient(135deg, #FF8800 0%, #FFA940 40%, #FFB84D 100%)'
|
||||
|
||||
const coupon = ref({})
|
||||
|
||||
// 价格文案
|
||||
const couponPriceText = computed(() => {
|
||||
if (!coupon.value.multiplier) return ''
|
||||
const pts = coupon.value.points_exchange || 0
|
||||
const cash = coupon.value.price || 0
|
||||
if (cash > 0 && pts > 0) return '¥' + cash + ' + ' + pts + '积分'
|
||||
if (cash > 0) return '¥' + cash
|
||||
return pts + '积分'
|
||||
})
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await request.get('/c/coupon-products/' + id, { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
const mult = d.multiplier || '1'
|
||||
coupon.value = {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
multiplier: d.multiplier,
|
||||
multiplierVal: d.multiplier_val,
|
||||
days: d.days,
|
||||
scope: d.scope || '全部型号',
|
||||
limit: d.limit || '1次/月',
|
||||
heroBg: MULT_GRADIENTS[mult] || defaultHeroBg,
|
||||
baselineM: d.baseline_monthly,
|
||||
boostedM: d.boosted_monthly,
|
||||
benefit: d.benefit,
|
||||
paybackShort: d.payback_short,
|
||||
priceText: couponPriceText.value,
|
||||
cashPrice: d.price,
|
||||
cashPart: d.price,
|
||||
pointsPart: d.points_exchange,
|
||||
points_exchange: d.points_exchange,
|
||||
price: d.price
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取加成券详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 用户设备列表(用于绑定选择)
|
||||
const devices = ref([])
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/devices', { showLoading: false })
|
||||
if (res.data) {
|
||||
const mult = coupon.value.multiplierVal || 1
|
||||
devices.value = (res.data || []).map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
model: d.model,
|
||||
daily: Math.round((d.today_revenue || d.bandwidth * 0.15) || 30),
|
||||
dailyBoosted: Math.round(((d.today_revenue || d.bandwidth * 0.15) || 30) * mult)
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取设备列表失败', e) }
|
||||
}
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!coupon.value.id) return
|
||||
try {
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
const payload = {
|
||||
product_type: 'coupon_product',
|
||||
product_id: coupon.value.id
|
||||
}
|
||||
if (selectedDevice.value) {
|
||||
payload.device_id = selectedDevice.value
|
||||
}
|
||||
const res = await request.post('/c/orders', payload, { showLoading: false })
|
||||
uni.hideLoading()
|
||||
if (res.data && res.data.id) {
|
||||
openSub('pending-order-detail', { id: res.data.id })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('创建订单失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.payMode && ['points', 'mix', 'cash'].includes(params.payMode)) {
|
||||
payMode.value = params.payMode
|
||||
}
|
||||
fetchDetail(params.id).then(() => fetchDevices())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; height: 400rpx; }
|
||||
.loading-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
|
||||
.cp-hero { border-radius: 14rpx; padding: 36rpx 24rpx; color: #fff; margin-bottom: 24rpx; position: relative; overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(255,136,0,.2);
|
||||
&::after { content: ''; position: absolute; top: -80rpx; right: -60rpx; width: 320rpx; height: 320rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,.15), transparent 65%); }
|
||||
}
|
||||
.cp-badge { display: inline-flex; align-items: center; background: rgba(255,255,255,.2); border-radius: 24rpx; padding: 6rpx 20rpx; font-size: 22rpx; font-weight: 600; position: relative; z-index: 1; }
|
||||
.cp-mult { font-size: 88rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; position: relative; z-index: 1; line-height: 1; margin: 16rpx 0 8rpx; }
|
||||
.cp-name { font-size: 26rpx; font-weight: 600; opacity: .9; position: relative; z-index: 1; display: block; }
|
||||
.cp-countdown { display: flex; gap: 16rpx; margin-top: 28rpx; position: relative; z-index: 1; }
|
||||
.cp-cd-item { flex: 1; background: rgba(255,255,255,.18); border-radius: 16rpx; padding: 14rpx; text-align: center; }
|
||||
.cp-cd-num { font-size: 36rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.cp-cd-label { font-size: 18rpx; opacity: .8; }
|
||||
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
.card-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 16rpx; }
|
||||
|
||||
.cp-params { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.cp-param { background: #F7F9FC; border-radius: 14rpx; padding: 20rpx; text-align: center; }
|
||||
.cpp-icon { width: 64rpx; height: 64rpx; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; margin: 0 auto 12rpx; font-size: 28rpx; }
|
||||
.cpp-val { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.cpp-label { font-size: 20rpx; color: #8B91A1; }
|
||||
|
||||
.cp-effect { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border: 1px solid #FFE0B0; border-radius: 10rpx; padding: 20rpx; }
|
||||
.cpe-compare { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
|
||||
.cpe-box { flex: 1; border-radius: 10rpx; padding: 16rpx; text-align: center;
|
||||
&.before { background: #F3F5F9; }
|
||||
&.after { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border: 1px solid #FFE0B0; }
|
||||
}
|
||||
.cpe-label { font-size: 20rpx; color: #8B91A1; display: block; }
|
||||
.cpe-val { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.cpe-unit { font-size: 20rpx; color: #8B91A1; }
|
||||
.cpe-arrow { color: #00C4A7; font-weight: 700; font-size: 32rpx; }
|
||||
.cpe-note { font-size: 22rpx; color: #8B91A1; text-align: center; }
|
||||
|
||||
.cp-device-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.cpd-item { display: flex; align-items: center; gap: 16rpx; padding: 16rpx; border: 2rpx solid #EAEDF3; border-radius: 10rpx; transition: all .2s;
|
||||
&.selected { border-color: #FF8800; background: #FFF7E6; }
|
||||
}
|
||||
.cpd-radio { width: 36rpx; height: 36rpx; border-radius: 50%; border: 2rpx solid #BCC2CE; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.cpd-item.selected .cpd-radio { border-color: #FF8800; background: #FF8800; }
|
||||
.cpd-radio-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #fff; }
|
||||
.cpd-info { flex: 1; }
|
||||
.cpd-name { font-size: 26rpx; font-weight: 600; display: block; }
|
||||
.cpd-model { font-size: 20rpx; color: #8B91A1; }
|
||||
.cpd-earn { font-size: 24rpx; font-weight: 700; color: #FF8800; font-family: 'DIN Alternate', -apple-system, sans-serif; white-space: nowrap; }
|
||||
|
||||
.rule-item { font-size: 22rpx; color: #5A6072; line-height: 1.8; display: block; }
|
||||
|
||||
.price-card { background: #fff; border-radius: 14rpx; padding: 28rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); text-align: center; }
|
||||
.pc-price-now { font-size: 48rpx; font-weight: 800; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.pc-price-old { font-size: 24rpx; color: #BCC2CE; text-decoration: line-through; display: block; margin-top: 4rpx; }
|
||||
.pay-modes { display: flex; gap: 0; background: #F3F5F9; border-radius: 10rpx; padding: 6rpx; margin: 20rpx 0 12rpx; }
|
||||
.pay-mode { flex: 1; text-align: center; padding: 14rpx 0; font-size: 22rpx; font-weight: 500; color: #8B91A1; border-radius: 8rpx;
|
||||
&.active { background: #fff; color: #1677FF; font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
|
||||
}
|
||||
.pay-detail { font-size: 22rpx; color: #5A6072; }
|
||||
|
||||
.bottom-action { padding: 0 0 40rpx; }
|
||||
.btn.primary { background: linear-gradient(135deg, #FF8800, #FFB84D); color: #fff; text-align: center; padding: 24rpx 0; border-radius: 14rpx; font-size: 30rpx; font-weight: 600; }
|
||||
</style>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!--
|
||||
* 我的加成券 — 对齐 C端.html #sub-my-coupons (~line 3332)
|
||||
* 汇总Hero + 3Tab(未使用/使用中/已过期) + 券卡片列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="我的加成券" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 汇总Hero -->
|
||||
<view class="mc-summary">
|
||||
<text class="mcs-label">可用加成券</text>
|
||||
<text class="mcs-count">{{ summary.unused }}<text class="mcs-unit"> 张</text></text>
|
||||
<view class="mcs-stats">
|
||||
<view class="mcs-stat"><text class="mcs-stat-num">{{ summary.unused }}</text><text class="mcs-stat-label">未使用</text></view>
|
||||
<view class="mcs-stat"><text class="mcs-stat-num">{{ summary.using }}</text><text class="mcs-stat-label">使用中</text></view>
|
||||
<view class="mcs-stat"><text class="mcs-stat-num">{{ summary.expired }}</text><text class="mcs-stat-label">已过期</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tabs -->
|
||||
<view class="mc-tabs">
|
||||
<view v-for="t in tabs" :key="t.key" class="mc-tab" :class="{ active: activeTab === t.key }" @click="activeTab = t.key">
|
||||
{{ t.label }}<text class="mc-tab-badge" v-if="t.count">{{ t.count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 未使用 -->
|
||||
<view v-if="activeTab === 'unused'">
|
||||
<view class="mc-card" v-for="c in unusedCoupons" :key="c.id">
|
||||
<view class="mcc-top">
|
||||
<view class="mcc-icon" :style="{ background: c.iconBg }">{{ c.multiplier }}x</view>
|
||||
<view class="mcc-info">
|
||||
<text class="mcc-name">收益加成券 {{ c.multiplier }}x</text>
|
||||
<text class="mcc-meta">{{ c.desc }}</text>
|
||||
</view>
|
||||
<text class="mcc-status usable">未使用</text>
|
||||
</view>
|
||||
<view class="mcc-body">
|
||||
<view class="mcc-params">
|
||||
<view class="mcc-param"><text class="mcp-val">{{ c.multiplier }}x</text><text class="mcp-label">加成倍数</text></view>
|
||||
<view class="mcc-param"><text class="mcp-val">{{ c.days }}天</text><text class="mcp-label">有效期</text></view>
|
||||
<view class="mcc-param"><text class="mcp-val">{{ c.scope }}</text><text class="mcp-label">适用范围</text></view>
|
||||
</view>
|
||||
<text class="mcc-buy-info">{{ c.boughtAt }} · 剩余 {{ c.remainTime }}</text>
|
||||
<view class="mcc-actions">
|
||||
<view class="mcc-btn mcc-btn-secondary">使用规则</view>
|
||||
<view class="mcc-btn mcc-btn-primary" @click="openSub('coupon-detail')">去使用</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 使用中 -->
|
||||
<view v-if="activeTab === 'using'">
|
||||
<view class="mc-card" v-for="c in usingCoupons" :key="c.id">
|
||||
<view class="mcc-top">
|
||||
<view class="mcc-icon" :style="{ background: c.iconBg }">{{ c.multiplier }}x</view>
|
||||
<view class="mcc-info">
|
||||
<text class="mcc-name">收益加成券 {{ c.multiplier }}x</text>
|
||||
<text class="mcc-meta">绑定: {{ c.deviceName }}</text>
|
||||
</view>
|
||||
<text class="mcc-status active">使用中</text>
|
||||
</view>
|
||||
<view class="mcc-body">
|
||||
<view class="mcc-device"><text>🖥️ {{ c.deviceName }} · {{ c.deviceModel }}</text></view>
|
||||
<text class="mcc-earn">日产出 ¥{{ c.dailyEarn }}</text>
|
||||
<view class="mcc-progress"><view class="mcc-progress-fill" :style="{ width: c.progress + '%', background: c.progressColor }"></view></view>
|
||||
<view class="mcc-progress-info"><text>已使用 {{ c.usedDays }}天</text><text>剩余 {{ c.remainDays }}天</text></view>
|
||||
<view class="mcc-actions">
|
||||
<view class="mcc-btn mcc-btn-secondary">积分明细</view>
|
||||
<view class="mcc-btn mcc-btn-secondary" style="color:#FF4D4F">解绑设备</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已过期 -->
|
||||
<view v-if="activeTab === 'expired'">
|
||||
<view class="mc-card expired" v-for="c in expiredCoupons" :key="c.id">
|
||||
<view class="mcc-top">
|
||||
<view class="mcc-icon" :style="{ background: c.iconBg, filter: 'grayscale(1)' }">{{ c.multiplier }}x</view>
|
||||
<view class="mcc-info">
|
||||
<text class="mcc-name">收益加成券 {{ c.multiplier }}x</text>
|
||||
<text class="mcc-meta">已过期 {{ c.expiredAt }}</text>
|
||||
</view>
|
||||
<text class="mcc-status expired">已过期</text>
|
||||
</view>
|
||||
<view class="mcc-body" v-if="c.boundDevice">
|
||||
<text class="mcc-bind-note">曾绑定: {{ c.boundDevice }} · 产生收益 ¥{{ formatThousands(c.generatedRev) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="expired-more">仅显示最近3条过期记录 · 查看全部</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const MULT_BG = {
|
||||
'2': 'linear-gradient(135deg, #FF4D4F, #FF6B35)', '1.5': 'linear-gradient(135deg, #FF8800, #FFB84D)',
|
||||
'1.2': 'linear-gradient(135deg, #1677FF, #4096FF)', '1.1': 'linear-gradient(135deg, #00C4A7, #5FE6CC)',
|
||||
}
|
||||
|
||||
const activeTab = ref('unused')
|
||||
const tabs = [
|
||||
{ key: 'unused', label: '未使用', count: 0 },
|
||||
{ key: 'using', label: '使用中', count: 0 },
|
||||
{ key: 'expired', label: '已过期', count: 0 }
|
||||
]
|
||||
const summary = ref({ unused: 0, using: 0, expired: 0 })
|
||||
const unusedCoupons = ref([])
|
||||
const usingCoupons = ref([])
|
||||
const expiredCoupons = ref([])
|
||||
|
||||
const mapCoupon = (c) => {
|
||||
const mult = String(c.multiplier || '1')
|
||||
return {
|
||||
...c,
|
||||
days: c.duration_days,
|
||||
scope: '全部型号',
|
||||
iconBg: MULT_BG[mult] || MULT_BG['1.5'],
|
||||
desc: `有效期${c.duration_days}天 · 购买于 ${c.created_at}`,
|
||||
boughtAt: '购买于 ' + c.created_at,
|
||||
remainTime: '--',
|
||||
deviceName: c.device_name || '--',
|
||||
deviceModel: '--',
|
||||
dailyEarn: '--',
|
||||
usedDays: c.activated_at ? Math.ceil((Date.now() - new Date(c.activated_at).getTime()) / 86400000) : 0,
|
||||
remainDays: c.expires_at ? Math.max(0, Math.ceil((new Date(c.expires_at).getTime() - Date.now()) / 86400000)) : 0,
|
||||
progress: c.activated_at && c.expires_at ? Math.min(100, Math.round((Date.now() - new Date(c.activated_at).getTime()) / (new Date(c.expires_at).getTime() - new Date(c.activated_at).getTime()) * 100)) : 0,
|
||||
progressColor: 'linear-gradient(90deg, #1677FF, #4096FF)',
|
||||
expiredAt: c.expires_at || '--',
|
||||
boundDevice: c.device_name || '',
|
||||
generatedRev: '--'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCoupons = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/my-coupons', { showLoading: false })
|
||||
const d = res.data
|
||||
summary.value = d.summary || { unused: 0, using: 0, expired: 0 }
|
||||
tabs[0].count = summary.value.unused
|
||||
tabs[1].count = summary.value.using
|
||||
tabs[2].count = summary.value.expired
|
||||
unusedCoupons.value = (d.unused || []).map(mapCoupon)
|
||||
usingCoupons.value = (d.using || []).map(mapCoupon)
|
||||
expiredCoupons.value = (d.expired || []).map(mapCoupon)
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
onLoad((options) => { const params = parseSubParams(options); fetchCoupons() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.mc-summary { background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #5B2C8E 100%); border-radius: 14rpx; padding: 28rpx 24rpx; color: #fff; margin-bottom: 24rpx; position: relative; overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(13,43,110,.25);
|
||||
&::after { content: ''; position: absolute; top: -60rpx; right: -40rpx; width: 240rpx; height: 240rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,136,0,.18), transparent 65%); }
|
||||
}
|
||||
.mcs-label { font-size: 24rpx; opacity: .7; position: relative; z-index: 1; }
|
||||
.mcs-count { font-size: 60rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; position: relative; z-index: 1; }
|
||||
.mcs-unit { font-size: 26rpx; opacity: .7; }
|
||||
.mcs-stats { display: flex; gap: 0; margin-top: 28rpx; position: relative; z-index: 1; }
|
||||
.mcs-stat { flex: 1; text-align: center; position: relative;
|
||||
&:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 1rpx; height: 48rpx; background: rgba(255,255,255,.15); }
|
||||
}
|
||||
.mcs-stat-num { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.mcs-stat-label { font-size: 20rpx; opacity: .7; }
|
||||
|
||||
.mc-tabs { display: flex; background: #fff; border-radius: 10rpx; padding: 6rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.mc-tab { flex: 1; text-align: center; padding: 14rpx 0; font-size: 24rpx; font-weight: 600; color: #8B91A1; border-radius: 8rpx; position: relative;
|
||||
&.active { background: #FF8800; color: #fff; }
|
||||
}
|
||||
.mc-tab-badge { font-size: 20rpx; margin-left: 4rpx; }
|
||||
.mc-tab.active .mc-tab-badge { background: rgba(255,255,255,.25); color: #fff; }
|
||||
.mc-tab:not(.active) .mc-tab-badge { background: #F3F5F9; color: #8B91A1; }
|
||||
|
||||
.mc-card { background: #fff; border-radius: 14rpx; margin-bottom: 20rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); overflow: hidden;
|
||||
&.expired { opacity: .55; }
|
||||
}
|
||||
.mcc-top { display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
|
||||
.mcc-icon { width: 96rpx; height: 96rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 30rpx; font-weight: 800; color: #fff; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.mcc-info { flex: 1; min-width: 0; }
|
||||
.mcc-name { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.mcc-meta { font-size: 22rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
.mcc-status { font-size: 20rpx; font-weight: 600; padding: 6rpx 16rpx; border-radius: 20rpx; flex-shrink: 0;
|
||||
&.usable { background: #E6FAF5; color: #00C4A7; }
|
||||
&.active { background: #FFF7E6; color: #FF8800; }
|
||||
&.expired { background: #F3F5F9; color: #BCC2CE; }
|
||||
}
|
||||
|
||||
.mcc-body { padding: 0 28rpx 28rpx; }
|
||||
.mcc-params { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.mcc-param { background: #F7F9FC; border-radius: 16rpx; padding: 14rpx 12rpx; text-align: center; }
|
||||
.mcp-val { font-size: 24rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; color: #0F1226; display: block; }
|
||||
.mcp-label { font-size: 18rpx; color: #8B91A1; }
|
||||
.mcc-buy-info { font-size: 20rpx; color: #BCC2CE; display: block; margin-bottom: 16rpx; }
|
||||
.mcc-actions { display: flex; gap: 16rpx; }
|
||||
.mcc-btn { flex: 1; padding: 16rpx 0; text-align: center; font-size: 24rpx; font-weight: 600; border-radius: 16rpx; }
|
||||
.mcc-btn-primary { background: #FF8800; color: #fff; }
|
||||
.mcc-btn-secondary { background: #F3F5F9; color: #5A6072; }
|
||||
|
||||
.mcc-device { background: #FFF7E6; border-radius: 8rpx; padding: 12rpx 16rpx; font-size: 22rpx; color: #FF8800; margin-bottom: 12rpx; }
|
||||
.mcc-earn { font-size: 30rpx; font-weight: 700; color: #FF8800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; margin-bottom: 12rpx; }
|
||||
.mcc-progress { height: 8rpx; background: #F3F5F9; border-radius: 4rpx; overflow: hidden; margin-bottom: 8rpx; }
|
||||
.mcc-progress-fill { height: 100%; border-radius: 4rpx; }
|
||||
.mcc-progress-info { display: flex; justify-content: space-between; font-size: 20rpx; color: #8B91A1; margin-bottom: 16rpx; }
|
||||
|
||||
.mcc-bind-note { font-size: 22rpx; color: #8B91A1; display: block; }
|
||||
|
||||
.expired-more { text-align: center; font-size: 22rpx; color: #BCC2CE; display: block; padding: 20rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
* 商城订单 — 对齐 C端.html #sub-mall-orders (~line 4516)
|
||||
* 标签切换 + 订单卡片列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="我的订单" :show-back="true" @back="closeSub()" />
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="tabs">
|
||||
<view v-for="t in tabs" :key="t.key" class="tab" :class="{ active: activeTab === t.key }" @click="switchTab(t.key)">{{ t.label }}</view>
|
||||
</view>
|
||||
<view class="order-card" v-for="o in orders" :key="o.id" @click="goOrder(o)">
|
||||
<view class="oc-top">
|
||||
<text class="oc-name">{{ o.name }}</text>
|
||||
<text class="oc-status" :style="{ color: o.status_color, background: o.status_bg }">{{ o.status }}</text>
|
||||
</view>
|
||||
<text class="oc-no">订单号: {{ o.order_no }}</text>
|
||||
<text class="oc-price">¥{{ formatThousands(o.amount) }}</text>
|
||||
<text class="oc-pay">{{ o.pay_method }}</text>
|
||||
</view>
|
||||
<view v-if="orders.length === 0" class="empty">暂无订单</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'server', label: '服务器' },
|
||||
{ key: 'coupon', label: '加成券' },
|
||||
{ key: 'self_device', label: '自有设备' },
|
||||
{ key: 'pending_pay', label: '待支付' },
|
||||
{ key: 'cancelled', label: '已取消' }
|
||||
]
|
||||
|
||||
const orders = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const fetchOrders = async (pageNum = 1) => {
|
||||
try {
|
||||
const params = {}
|
||||
if (activeTab.value !== 'all') {
|
||||
params.type = activeTab.value
|
||||
}
|
||||
params.page = pageNum
|
||||
const res = await request.get('/c/orders', { showLoading: false, ...params })
|
||||
if (res.data) {
|
||||
const items = res.data.items || []
|
||||
orders.value = pageNum === 1 ? items : orders.value.concat(items)
|
||||
page.value = res.data.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data.last_page || 1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取订单列表失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const switchTab = (key) => {
|
||||
activeTab.value = key
|
||||
fetchOrders(1)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchOrders(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
const goOrder = (o) => {
|
||||
openSub('pending-order-detail', { id: o.id })
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.tab) activeTab.value = params.tab
|
||||
fetchOrders(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
.tabs { display: flex; background: #fff; border-radius: 10rpx; padding: 6rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.tab { flex: 1; text-align: center; padding: 14rpx 0; font-size: 24rpx; font-weight: 600; color: #8B91A1; border-radius: 8rpx;
|
||||
&.active { background: #1677FF; color: #fff; }
|
||||
}
|
||||
.order-card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.oc-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||||
.oc-name { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.oc-status { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 10rpx; font-weight: 500; }
|
||||
.oc-no { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.oc-price { font-size: 36rpx; font-weight: 700; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.oc-pay { font-size: 20rpx; color: #BCC2CE; margin-top: 4rpx; display: block; }
|
||||
.empty { text-align: center; padding: 80rpx 0; font-size: 26rpx; color: #BCC2CE; }
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
<!--
|
||||
* 待支付详情
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="订单详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- 自定义Toast -->
|
||||
<!-- #ifdef H5 -->
|
||||
<Teleport to="body">
|
||||
<view v-if="toastVisible" class="custom-toast">
|
||||
<text>{{ toastMsg }}</text>
|
||||
</view>
|
||||
</Teleport>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef H5 -->
|
||||
<view v-if="toastVisible" class="custom-toast">
|
||||
<text>{{ toastMsg }}</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="page-body">
|
||||
<!-- 订单信息 -->
|
||||
<view class="order-hero">
|
||||
<view class="oh-status">
|
||||
<u-icon name="clock" size="20" :color="statusIconColor"></u-icon>
|
||||
<text class="oh-status-text" :style="{ color: statusIconColor }">{{ statusLabel }}</text>
|
||||
</view>
|
||||
<view class="oh-timer" v-if="orderStatus === 'pending_pay' && countdown > 0">
|
||||
<text class="oh-timer-label">请在 2 小时内完成支付</text>
|
||||
<text class="oh-timer-val">{{ countdownText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 订单信息卡片 -->
|
||||
<view class="section">
|
||||
<text class="section-title">订单信息</text>
|
||||
<view class="info-list">
|
||||
<view class="info-row">
|
||||
<text class="info-label">订单编号</text>
|
||||
<text class="info-value">{{ orderNo }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">创建时间</text>
|
||||
<text class="info-value">{{ createTime }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">商品名称</text>
|
||||
<text class="info-value">{{ productName }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">应付金额</text>
|
||||
<text class="info-value price">{{ amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 银行转账信息 -->
|
||||
<view class="section">
|
||||
<text class="section-title">银行转账信息</text>
|
||||
<view class="bank-card">
|
||||
<view class="bank-row">
|
||||
<text class="bank-label">户名</text>
|
||||
<text class="bank-value">{{ bankInfo.account_name }}</text>
|
||||
<view class="bank-copy" @click="copyText(bankInfo.account_name)">
|
||||
</view>
|
||||
</view>
|
||||
<view class="bank-row">
|
||||
<text class="bank-label">账号</text>
|
||||
<text class="bank-value">{{ bankInfo.bank_card }}</text>
|
||||
<view class="bank-copy" @click="copyText(bankInfo.bank_card)">
|
||||
</view>
|
||||
</view>
|
||||
<view class="bank-row">
|
||||
<text class="bank-label">开户行</text>
|
||||
<text class="bank-value">{{ bankInfo.bank_name }}</text>
|
||||
<view class="bank-copy" @click="copyText(bankInfo.bank_name)">
|
||||
</view>
|
||||
</view>
|
||||
<view class="bank-row">
|
||||
<text class="bank-label">备注</text>
|
||||
<input class="bank-input" v-model="remarkText" :disabled="!canEdit" />
|
||||
<view class="bank-copy" @click="copyText(remarkText)">
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 上传回执 -->
|
||||
<view class="section">
|
||||
<text class="section-title">打款回执</text>
|
||||
<template v-if="orderStatus === 'pending_pay' || orderStatus === 'rejected'">
|
||||
<text class="upload-tip">支持 JPG / JPEG / PNG 格式,大小不超过10MB</text>
|
||||
<view class="upload-area" @click="receiptPath ? previewReceipt() : uploadReceipt()">
|
||||
<image v-if="receiptPath" :src="receiptPath" mode="aspectFit" class="receipt-preview"></image>
|
||||
<template v-else>
|
||||
<u-icon name="plus" size="32" color="#1677ff"></u-icon>
|
||||
<text class="upload-text">点击上传回执</text>
|
||||
</template>
|
||||
</view>
|
||||
<text v-if="uploaded" class="upload-success">已选择回执,<text class="re-select" @click="uploadReceipt">重新选择</text></text>
|
||||
</template>
|
||||
<template v-else-if="hasReceipt">
|
||||
<view class="receipt-done">
|
||||
<u-icon name="checkmark-circle" size="24" color="#00C4A7"></u-icon>
|
||||
<text class="receipt-done-text">回执已上传,可点击查看</text>
|
||||
</view>
|
||||
<image :src="receiptPath" mode="widthFix" class="receipt-img" @click="previewReceipt"></image>
|
||||
</template>
|
||||
<text v-else class="upload-tip">未上传回执</text>
|
||||
</view>
|
||||
|
||||
<!-- 进度时间线 -->
|
||||
<view class="section">
|
||||
<text class="section-title">订单进度</text>
|
||||
<view class="progress-timeline">
|
||||
<view class="pt-step" v-for="(step, i) in steps" :key="i" :class="{ done: step.done, current: step.current }">
|
||||
<view class="pt-dot">
|
||||
<view class="pt-dot-inner"></view>
|
||||
</view>
|
||||
<view class="pt-line" v-if="i < steps.length - 1"></view>
|
||||
<view class="pt-content">
|
||||
<text class="pt-title">{{ step.title }}</text>
|
||||
<text class="pt-desc">{{ step.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: calc(150rpx + env(safe-area-inset-bottom));"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="bb-btn outline" @click="goBack">
|
||||
<text>返回</text>
|
||||
</view>
|
||||
<view v-if="orderStatus === 'pending_pay' || orderStatus === 'rejected'" class="bb-btn primary" :class="{ disabled: submitting }" @click="submitReceipt">
|
||||
<text>{{ submitting ? '上传中...' : '上传打款回执' }}</text>
|
||||
</view>
|
||||
<view v-else class="bb-btn primary" @click="goOrderList">
|
||||
<text>查看我的订单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands, copyText } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
import { hasUploadedReceipt } from '@/utils/order.mjs'
|
||||
|
||||
const orderId = ref('')
|
||||
const orderNo = ref('')
|
||||
const receiptPath = ref('')
|
||||
const uploaded = ref(false)
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
const countdown = ref(0)
|
||||
const remarkText = ref('')
|
||||
const bankInfo = ref({ account_name: '', bank_name: '', bank_card: '' })
|
||||
|
||||
const toastVisible = ref(false)
|
||||
const toastMsg = ref('')
|
||||
|
||||
const createTime = ref('')
|
||||
const productName = ref('')
|
||||
const amount = ref('')
|
||||
const orderStatus = ref('pending_pay')
|
||||
const hasReceipt = computed(() => hasUploadedReceipt(receiptPath.value))
|
||||
|
||||
const steps = ref([
|
||||
{ title: '订单已创建', desc: '订单生成,等待付款', done: false, current: false },
|
||||
{ title: '上传回执单', desc: '请上传银行转账回执', done: false, current: false },
|
||||
{ title: '财务确认收款', desc: '财务人员审核打款记录', done: false, current: false },
|
||||
{ title: '支付成功', desc: '订单完成,权益生效', done: false, current: false }
|
||||
])
|
||||
|
||||
const showToast = (msg) => {
|
||||
toastMsg.value = msg
|
||||
toastVisible.value = true
|
||||
setTimeout(() => { toastVisible.value = false }, 2000)
|
||||
}
|
||||
|
||||
const computeSteps = (status) => {
|
||||
const map = {
|
||||
pending_pay: [true, false, false, false],
|
||||
pending_audit: [true, true, false, false],
|
||||
approved: [true, true, true, true],
|
||||
completed: [true, true, true, true],
|
||||
rejected: [true, false, false, false],
|
||||
cancelled: [true, false, false, false],
|
||||
}
|
||||
const done = map[status] || [true, false, false, false]
|
||||
let firstPendingFound = false
|
||||
steps.value.forEach((s, i) => {
|
||||
s.done = done[i]
|
||||
if (done[i]) {
|
||||
s.current = false
|
||||
} else if (!firstPendingFound) {
|
||||
s.current = true
|
||||
firstPendingFound = true
|
||||
} else {
|
||||
s.current = false
|
||||
}
|
||||
if (i === 1 && status === 'rejected') {
|
||||
steps.value[i].title = '回执被驳回'
|
||||
steps.value[i].desc = '请重新上传打款回执'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fetchOrder = async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await request.get('/c/orders/' + id, { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
orderNo.value = d.order_no
|
||||
remarkText.value = d.order_no || ''
|
||||
productName.value = d.product_name
|
||||
amount.value = '¥' + formatThousands(d.amount)
|
||||
createTime.value = d.created_at || ''
|
||||
orderStatus.value = d.status
|
||||
if (d.pay_proof) {
|
||||
receiptPath.value = d.pay_proof
|
||||
uploaded.value = true
|
||||
}
|
||||
computeSteps(d.status)
|
||||
if (d.created_at) {
|
||||
const created = new Date(d.created_at).getTime()
|
||||
const deadline = created + 2 * 3600 * 1000
|
||||
countdown.value = Math.max(0, Math.floor((deadline - Date.now()) / 1000))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取订单详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBankInfo = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/system-config/bank-info', { showLoading: false })
|
||||
if (res.data) {
|
||||
bankInfo.value = res.data
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
orderId.value = params.id || ''
|
||||
fetchOrder(params.id)
|
||||
fetchBankInfo()
|
||||
})
|
||||
|
||||
const countdownText = computed(() => {
|
||||
const h = Math.floor(countdown.value / 3600)
|
||||
const m = Math.floor((countdown.value % 3600) / 60)
|
||||
const s = countdown.value % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const STATUS_MAP = {
|
||||
pending_pay: { label: '待支付', color: '#FF8800' },
|
||||
pending_audit: { label: '审核中', color: '#1677FF' },
|
||||
approved: { label: '已通过', color: '#00C4A7' },
|
||||
rejected: { label: '已驳回', color: '#FF4D4F' },
|
||||
cancelled: { label: '已取消', color: '#6B7280' },
|
||||
}
|
||||
const canEdit = computed(() => orderStatus.value === 'pending_pay' || orderStatus.value === 'rejected')
|
||||
const statusLabel = computed(() => STATUS_MAP[orderStatus.value]?.label || '待支付')
|
||||
const statusIconColor = computed(() => STATUS_MAP[orderStatus.value]?.color || '#FF8800')
|
||||
|
||||
let timer = setInterval(() => {
|
||||
if (countdown.value > 0) {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0 && orderStatus.value === 'pending_pay') {
|
||||
orderStatus.value = 'cancelled'
|
||||
computeSteps('cancelled')
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
onUnmounted(() => { clearInterval(timer) })
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const validateReceiptFile = (file) => {
|
||||
const filePath = file?.path || ''
|
||||
const fileName = file?.name || filePath
|
||||
const extension = fileName.split(/[?#]/)[0].split('.').pop()?.toLowerCase()
|
||||
const mimeType = (file?.type || '').toLowerCase()
|
||||
const allowedExtensions = ['jpg', 'jpeg', 'png']
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png']
|
||||
|
||||
if (!allowedExtensions.includes(extension) && !allowedMimeTypes.includes(mimeType)) {
|
||||
uni.showToast({ title: '仅支持 JPG、JPEG、PNG 格式', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
if (Number(file?.size || 0) > 10 * 1024 * 1024) {
|
||||
uni.showToast({ title: '回执文件大小不能超过10MB', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const uploadReceipt = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
const tempFile = res.tempFiles?.[0]
|
||||
const file = {
|
||||
path: tempFile?.path || res.tempFilePaths?.[0] || '',
|
||||
name: tempFile?.name || '',
|
||||
type: tempFile?.type || '',
|
||||
size: tempFile?.size || 0
|
||||
}
|
||||
if (!validateReceiptFile(file)) return
|
||||
receiptPath.value = file.path
|
||||
uploaded.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const submitReceipt = () => {
|
||||
if (submitting.value) return
|
||||
if (!uploaded.value || !receiptPath.value) {
|
||||
uni.showToast({ title: '请先选择打款回执图片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
uni.uploadFile({
|
||||
url: request.baseURL + '/c/orders/' + orderId.value + '/upload-receipt',
|
||||
filePath: receiptPath.value,
|
||||
name: 'receipt',
|
||||
header: {
|
||||
'Authorization': 'Bearer ' + uni.getStorageSync('token')
|
||||
},
|
||||
success: (res) => {
|
||||
uni.hideLoading()
|
||||
if (res.statusCode !== 200) {
|
||||
submitting.value = false
|
||||
let msg = '上传失败'
|
||||
try { const err = JSON.parse(res.data); msg = err.message || msg } catch (e) {}
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
orderStatus.value = data.data?.status || 'pending_audit'
|
||||
computeSteps(orderStatus.value)
|
||||
const msg = data.message || '提交成功,等待审核'
|
||||
showToast(msg)
|
||||
} catch (e) {
|
||||
submitting.value = false
|
||||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
submitting.value = false
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '网络错误,请重试', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewReceipt = () => {
|
||||
if (!receiptPath.value) return
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (/^https?:\/\//i.test(receiptPath.value)) {
|
||||
uni.showLoading({ title: '加载中...' })
|
||||
uni.downloadFile({
|
||||
url: receiptPath.value,
|
||||
success: (res) => {
|
||||
uni.hideLoading()
|
||||
if (res.statusCode !== 200) {
|
||||
uni.showToast({ title: '回执图片加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.previewImage({ current: res.tempFilePath, urls: [res.tempFilePath] })
|
||||
},
|
||||
fail: (err) => {
|
||||
uni.hideLoading()
|
||||
console.error('下载回执图片失败', err)
|
||||
uni.showToast({ title: '回执图片加载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// #endif
|
||||
|
||||
uni.previewImage({ current: receiptPath.value, urls: [receiptPath.value] })
|
||||
}
|
||||
|
||||
const goOrderList = () => {
|
||||
closeSub()
|
||||
setTimeout(() => {
|
||||
openSub('mall-orders', { tab: 'all' })
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; height: 400rpx; }
|
||||
.loading-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
|
||||
.page-body { padding: 16rpx 24rpx; }
|
||||
|
||||
// 自定义Toast
|
||||
.custom-toast {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.78); border-radius: 36rpx;
|
||||
padding: 20rpx 44rpx; z-index: 9999;
|
||||
text { color: #fff; font-size: 26rpx; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.order-hero {
|
||||
background: #fff; border-radius: 14rpx; padding: 32rpx 24rpx; margin-bottom: 16rpx;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.oh-status { display: flex; align-items: center; gap: 10rpx; }
|
||||
.oh-status-text { font-size: 28rpx; font-weight: 600; color: #FF8800; }
|
||||
.oh-timer { text-align: right; }
|
||||
.oh-timer-label { font-size: 20rpx; color: #8B91A1; display: block; margin-bottom: 4rpx; }
|
||||
.oh-timer-val { font-size: 28rpx; font-weight: 700; color: #FF4D4D; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
.section { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.section-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
.info-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.info-label { font-size: 24rpx; color: #8B91A1; }
|
||||
.info-value { font-size: 24rpx; color: #0F1226; font-weight: 500;
|
||||
&.price { color: #FF4D4D; font-weight: 700; font-size: 28rpx; }
|
||||
}
|
||||
|
||||
.bank-card { background: #fafafa; border-radius: 12rpx; padding: 20rpx; }
|
||||
.bank-row { display: flex; align-items: center; gap: 12rpx; padding: 12rpx 0; }
|
||||
.bank-label { font-size: 24rpx; color: #8B91A1; width: 80rpx; flex-shrink: 0; }
|
||||
.bank-value { font-size: 24rpx; color: #0F1226; flex: 1; }
|
||||
.bank-input { font-size: 24rpx; color: #0F1226; flex: 1; height: 48rpx; padding: 0; background: transparent; }
|
||||
.bank-input[disabled] { color: #8B91A1; }
|
||||
.bank-copy { flex-shrink: 0; padding: 4rpx; }
|
||||
|
||||
.upload-tip { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 16rpx; }
|
||||
.upload-area {
|
||||
border: 2rpx dashed #d0d5dd; border-radius: 12rpx; padding: 40rpx;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12rpx;
|
||||
}
|
||||
.upload-text { font-size: 24rpx; color: #1677ff; }
|
||||
.receipt-preview { width: 200rpx; height: 200rpx; border-radius: 8rpx; }
|
||||
.upload-success { font-size: 22rpx; color: #00C4A7; display: block; margin-top: 12rpx; text-align: center; }
|
||||
.re-select { color: #1677ff; text-decoration: underline; }
|
||||
.receipt-done { display: flex; align-items: center; gap: 10rpx; margin-bottom: 16rpx; }
|
||||
.receipt-done-text { font-size: 24rpx; color: #00C4A7; font-weight: 500; }
|
||||
.receipt-img { width: 100%; border-radius: 12rpx; margin-top: 12rpx; }
|
||||
|
||||
.progress-timeline { padding-left: 20rpx; }
|
||||
.pt-step { position: relative; padding-bottom: 32rpx; display: flex; gap: 20rpx; }
|
||||
.pt-dot {
|
||||
width: 32rpx; height: 32rpx; border-radius: 50%; background: #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2rpx;
|
||||
.done & { background: #00C4A7; }
|
||||
.current & { background: #1677ff; }
|
||||
}
|
||||
.pt-dot-inner { width: 12rpx; height: 12rpx; border-radius: 50%; background: #fff; }
|
||||
.pt-line {
|
||||
position: absolute; left: 15rpx; top: 36rpx; width: 2rpx;
|
||||
height: calc(100% - 36rpx); background: #f0f0f0;
|
||||
.done & { background: #00C4A7; }
|
||||
}
|
||||
.pt-content { flex: 1; }
|
||||
.pt-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block;
|
||||
.done & { color: #00C4A7; }
|
||||
.current & { color: #1677ff; }
|
||||
}
|
||||
.pt-desc { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: 100rpx; background: #fff; display: flex; align-items: center;
|
||||
padding: 0 24rpx; gap: 16rpx; border-top: 1rpx solid #f0f0f0; z-index: 100;
|
||||
}
|
||||
.bb-btn {
|
||||
flex: 1; height: 72rpx; border-radius: 36rpx; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 26rpx; font-weight: 600;
|
||||
&.outline { border: 2rpx solid #1677ff; color: #1677ff; }
|
||||
&.primary { background: linear-gradient(135deg, #1677ff, #0D5BD6); color: #fff; }
|
||||
&.disabled { opacity: 0.6; pointer-events: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<!--
|
||||
* 立即购买 — 确认订单
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="确认订单" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view class="page-body">
|
||||
<!-- 产品信息 -->
|
||||
<view class="product-summary">
|
||||
<view class="ps-image">
|
||||
<text class="ps-emoji">🖥️</text>
|
||||
</view>
|
||||
<view class="ps-info">
|
||||
<text class="ps-name">{{ productName }}</text>
|
||||
<text class="ps-spec">{{ productSpec }}</text>
|
||||
<text class="ps-price">{{ totalAmount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备绑定信息(仅加成券) -->
|
||||
<view class="section" v-if="productType === 'coupon' && deviceName">
|
||||
<text class="section-title">设备绑定</text>
|
||||
<view class="device-binding">
|
||||
<view class="db-row">
|
||||
<text class="db-label">绑定设备</text>
|
||||
<text class="db-value">{{ deviceName }}</text>
|
||||
</view>
|
||||
<view class="db-row">
|
||||
<text class="db-label">设备编号</text>
|
||||
<text class="db-value">{{ deviceId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 费用明细 -->
|
||||
<view class="section">
|
||||
<text class="section-title">费用明细</text>
|
||||
<view class="fee-row" v-for="(item, i) in feeItems" :key="i">
|
||||
<text class="fee-label">{{ item.label }}</text>
|
||||
<text class="fee-value">{{ item.value }}</text>
|
||||
</view>
|
||||
<view class="fee-divider"></view>
|
||||
<view class="fee-row total">
|
||||
<text class="fee-label">应付总额</text>
|
||||
<text class="fee-value total">{{ totalAmount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 支付方式 -->
|
||||
<view class="section">
|
||||
<text class="section-title">支付方式</text>
|
||||
<view class="payment-methods">
|
||||
<view
|
||||
v-for="(method, i) in payMethods"
|
||||
:key="i"
|
||||
class="pm-item"
|
||||
:class="{ selected: selectedPayMethod === i }"
|
||||
@click="selectedPayMethod = i"
|
||||
>
|
||||
<view class="pm-left">
|
||||
<text class="pm-emoji">{{ method.emoji }}</text>
|
||||
<text class="pm-text">{{ method.name }}</text>
|
||||
</view>
|
||||
<view class="pm-radio">
|
||||
<view class="pm-radio-dot" v-if="selectedPayMethod === i"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<view style="height: calc(140rpx + env(safe-area-inset-bottom));"></view>
|
||||
</view>
|
||||
|
||||
<!-- 底部 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="bb-col">
|
||||
<text class="bb-label">应付</text>
|
||||
<text class="bb-amount">{{ totalAmount }}</text>
|
||||
</view>
|
||||
<view class="bb-pay-btn" @click="confirmPay">
|
||||
<text class="bb-pay-text">确认支付</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const productId = ref('')
|
||||
const productType = ref('server')
|
||||
const payMode = ref('cash')
|
||||
const deviceId = ref('')
|
||||
const deviceName = ref('')
|
||||
const selectedPayMethod = ref(0)
|
||||
|
||||
const payMethods = [
|
||||
{ name: '银行转账', emoji: '💳' },
|
||||
{ name: '结算账户', emoji: '📇' }
|
||||
]
|
||||
|
||||
const couponPrices = {
|
||||
'1.5x': { cash: 999, points: 9999, mixCash: 399, mixPoints: 500, old: 1299 },
|
||||
'1.2x': { cash: 199, points: 1999, mixCash: 99, mixPoints: 200, old: 269 },
|
||||
'1.1x': { cash: 49, points: 999, mixCash: 0, mixPoints: 999, old: 69 }
|
||||
}
|
||||
|
||||
const deviceMap = {
|
||||
'D3201': '深圳-福田-01',
|
||||
'D3208': '深圳-南山-02',
|
||||
'D2150': '广州-天河-01',
|
||||
'D1099': '上海-浦东-03'
|
||||
}
|
||||
|
||||
const productName = computed(() => {
|
||||
if (productType.value === 'coupon') return '1.5x 收益加成券'
|
||||
return 'QH-500 Edge 边缘服务器'
|
||||
})
|
||||
|
||||
const productSpec = computed(() => {
|
||||
if (productType.value === 'coupon') return '30天有效期 | 全部型号适用'
|
||||
return '1U / 64GB / 2TB NVMe / 双万兆'
|
||||
})
|
||||
|
||||
const feeItems = computed(() => {
|
||||
if (productType.value === 'coupon') {
|
||||
const t = couponPrices['1.5x']
|
||||
switch (payMode.value) {
|
||||
case 'cash':
|
||||
return [{ label: '加成券费用', value: `¥${formatThousands(t.cash)}` }]
|
||||
case 'points':
|
||||
return [{ label: '积分费用', value: `¥${formatThousands(t.points)}积分` }]
|
||||
case 'mix':
|
||||
return [
|
||||
{ label: '现金部分', value: `¥${formatThousands(t.mixCash)}` },
|
||||
{ label: '积分部分', value: `${formatThousands(t.mixPoints)}积分` }
|
||||
]
|
||||
default:
|
||||
return [{ label: '加成券费用', value: `¥${formatThousands(t.cash)}` }]
|
||||
}
|
||||
}
|
||||
return [
|
||||
{ label: '硬件费', value: '¥25,000' },
|
||||
{ label: '服务费(安装调试 + 1年维保)', value: '¥3,000' }
|
||||
]
|
||||
})
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
if (productType.value === 'coupon') {
|
||||
const t = couponPrices['1.5x']
|
||||
switch (payMode.value) {
|
||||
case 'cash':
|
||||
return '¥' + formatThousands(t.cash)
|
||||
case 'points':
|
||||
return formatThousands(t.points) + '积分'
|
||||
case 'mix':
|
||||
return `¥${formatThousands(t.mixCash)} + ${t.mixPoints}积分`
|
||||
default:
|
||||
return '¥' + formatThousands(t.cash)
|
||||
}
|
||||
}
|
||||
return '¥28,000'
|
||||
})
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
productId.value = params.id || ''
|
||||
productType.value = params.type || 'server'
|
||||
payMode.value = params.payMode || 'cash'
|
||||
deviceId.value = params.deviceId || ''
|
||||
|
||||
// 根据设备ID获取设备名称
|
||||
if (deviceId.value && deviceMap[deviceId.value]) {
|
||||
deviceName.value = deviceMap[deviceId.value]
|
||||
}
|
||||
|
||||
// TODO: 对接接口 - 根据 params.id 获取商品信息
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const confirmPay = () => {
|
||||
// TODO: 对接接口 /api/orders/create
|
||||
const orderId = 'ORD-' + Date.now().toString(36).toUpperCase()
|
||||
uni.showToast({ title: '订单已生成', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
openSub('pending-order-detail', { id: orderId })
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
|
||||
.page-body { padding: 16rpx 24rpx; }
|
||||
|
||||
// 产品摘要
|
||||
.product-summary {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx;
|
||||
display: flex; gap: 20rpx; align-items: center;
|
||||
}
|
||||
.ps-image {
|
||||
width: 120rpx; height: 120rpx; background: #E6F4FF; border-radius: 12rpx;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ps-emoji { font-size: 48rpx; line-height: 1; }
|
||||
.ps-info { flex: 1; }
|
||||
.ps-name { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.ps-spec { font-size: 22rpx; color: #8B91A1; display: block; margin: 6rpx 0 8rpx; }
|
||||
.ps-price { font-size: 24rpx; color: #FF4D4D; font-weight: 500; }
|
||||
.ps-price-num { font-size: 36rpx; font-weight: 700; }
|
||||
|
||||
// 设备绑定
|
||||
.device-binding { background: #fafafa; border-radius: 12rpx; padding: 20rpx; }
|
||||
.db-row { display: flex; align-items: center; padding: 8rpx 0; }
|
||||
.db-label { font-size: 24rpx; color: #8B91A1; width: 120rpx; flex-shrink: 0; }
|
||||
.db-value { font-size: 24rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
// 区块
|
||||
.section { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.section-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
// 费用明细
|
||||
.fee-row { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0;
|
||||
&.total { padding-top: 20rpx; }
|
||||
}
|
||||
.fee-label { font-size: 24rpx; color: #5A6072; }
|
||||
.fee-value { font-size: 24rpx; color: #0F1226; font-weight: 500;
|
||||
&.total { font-size: 32rpx; color: #FF4D4D; font-weight: 700; }
|
||||
}
|
||||
.fee-divider { height: 1rpx; background: #f0f0f0; margin: 8rpx 0; }
|
||||
|
||||
// 支付方式
|
||||
.payment-methods { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.pm-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20rpx; border-radius: 12rpx; border: 2rpx solid #f0f0f0;
|
||||
&.selected { border-color: #1677ff; background: #f0f7ff; }
|
||||
}
|
||||
.pm-left { display: flex; align-items: center; gap: 12rpx; }
|
||||
.pm-emoji { font-size: 32rpx; line-height: 1; }
|
||||
.pm-text { font-size: 26rpx; color: #0F1226; }
|
||||
.pm-radio {
|
||||
width: 32rpx; height: 32rpx; border-radius: 50%; border: 2rpx solid #d0d5dd;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
.selected & { border-color: #1677ff; }
|
||||
}
|
||||
.pm-radio-dot { width: 18rpx; height: 18rpx; border-radius: 50%; background: #1677ff; }
|
||||
|
||||
// 底部栏
|
||||
.bottom-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
height: 100rpx; background: #fff; display: flex; align-items: center;
|
||||
padding: 0 24rpx; border-top: 1rpx solid #f0f0f0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.bb-label { font-size: 22rpx; color: #8B91A1; }
|
||||
.bb-amount { font-size: 36rpx; font-weight: 700; color: #FF4D4D; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.bb-pay-btn {
|
||||
flex: 1; height: 72rpx; background: linear-gradient(135deg, #1677ff, #0D5BD6);
|
||||
border-radius: 36rpx; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bb-pay-text { font-size: 30rpx; font-weight: 600; color: #fff; }
|
||||
</style>
|
||||
@@ -0,0 +1,269 @@
|
||||
<!--
|
||||
* 商品详情 — 对齐 C端.html #sub-product-detail (~line 3102)
|
||||
* 图片区 + 标签 + 价格拆分 + 技术参数 + 收益预估 + 购买流程 + 底部操作栏
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="商品详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="page-content">
|
||||
<!-- 商品图片区 -->
|
||||
<view class="pc-img" :style="product.image ? {} : { background: product.imgBg }">
|
||||
<image v-if="product.image" :src="product.image" mode="aspectFill" class="pc-cover-img" />
|
||||
<text v-else class="pc-img-emoji">{{ product.emoji }}</text>
|
||||
<text class="pc-top-badge left" v-if="product.topBadge">{{ product.topBadge }}</text>
|
||||
<text class="pc-top-badge right">{{ product.stockBadge }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 商品信息 -->
|
||||
<view class="info-card">
|
||||
<view class="pc-tags">
|
||||
<text v-for="t in product.tags" :key="t.text" class="pc-tag" :class="t.cls">{{ t.text }}</text>
|
||||
</view>
|
||||
<text class="pc-name">{{ product.name }}</text>
|
||||
<text class="pc-desc">{{ product.desc }}</text>
|
||||
|
||||
<!-- 价格行 -->
|
||||
<view class="pc-price-row">
|
||||
<text class="pc-price">¥{{ formatThousands(product.price) }}</text>
|
||||
<text class="pc-points">可用 {{ formatThousands(product.pointsUse) }} 积分抵扣</text>
|
||||
</view>
|
||||
|
||||
<!-- 价格拆分 -->
|
||||
<view class="price-split">
|
||||
<view class="ps-item"><text class="ps-val">¥{{ formatThousands(product.hwPrice) }}</text><text class="ps-label">硬件费</text></view>
|
||||
<text class="ps-op">+</text>
|
||||
<view class="ps-item"><text class="ps-val">¥{{ formatThousands(product.svcPrice) }}</text><text class="ps-label">服务费</text></view>
|
||||
<text class="ps-op">=</text>
|
||||
<view class="ps-item"><text class="ps-val">¥{{ formatThousands(product.price) }}</text><text class="ps-label">总价</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 服务费说明 -->
|
||||
<view class="svc-note">
|
||||
<text class="svc-note-title">📌 服务费说明</text>
|
||||
<text class="svc-note-text">前:收益的90%归您,10%平台服务费</text>
|
||||
<text class="svc-note-text">后:收益的40%归您,60%平台服务费</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 技术参数 -->
|
||||
<view class="card">
|
||||
<text class="card-title">技术参数</text>
|
||||
<view class="info-row" v-for="s in product.specs" :key="s.label">
|
||||
<text class="ir-label">{{ s.label }}</text>
|
||||
<text class="ir-val">{{ s.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益预估 -->
|
||||
<view class="card" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<text class="card-title">收益预估</text>
|
||||
<view class="earn-grid">
|
||||
<view class="earn-item">
|
||||
<text class="earn-val">¥{{ product.dailyEarn }}</text>
|
||||
<text class="earn-unit">/天</text>
|
||||
<text class="earn-label">日产出</text>
|
||||
</view>
|
||||
<view class="earn-item">
|
||||
<text class="earn-val">¥{{ formatThousands(product.monthlyEarn) }}</text>
|
||||
<text class="earn-unit">/月</text>
|
||||
<text class="earn-label">月产出</text>
|
||||
</view>
|
||||
<view class="earn-item">
|
||||
<text class="earn-val earn-green">{{ product.paybackDays }}</text>
|
||||
<text class="earn-unit">天</text>
|
||||
<text class="earn-label">设备成本收益周期</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="card-link">使用积分计算器详细测算 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 购买流程 -->
|
||||
<view class="card">
|
||||
<text class="card-title">购买流程</text>
|
||||
<view class="steps">
|
||||
<view class="step done"><view class="step-num">1</view><text class="step-label">下单支付</text></view>
|
||||
<view class="step"><view class="step-num">2</view><text class="step-label">签署合同</text></view>
|
||||
<view class="step"><view class="step-num">3</view><text class="step-label">运输安装</text></view>
|
||||
<view class="step"><view class="step-num">4</view><text class="step-label">上线收益</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: calc(140rpx + env(safe-area-inset-bottom));"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="bb-btn warn" @click="uni.showToast({ title: '即将开放', icon: 'none' })"><text>收益计算</text></view>
|
||||
<view class="bb-btn primary" @click="handleBuy"><text>立即购买</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const product = ref({})
|
||||
const loading = ref(true)
|
||||
|
||||
const fetchDetail = async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await request.get('/c/server-products/' + id, { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
const image = d.images && d.images.length > 0 ? d.images[0] : null
|
||||
const stock = d.stock || 0
|
||||
product.value = {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
desc: d.subtitle || '',
|
||||
// 图片
|
||||
image: image,
|
||||
imgBg: 'linear-gradient(135deg, #0D2B6E, #1B66E8)',
|
||||
emoji: '🖥️',
|
||||
// 徽章
|
||||
topBadge: (d.sales > 100) ? '热销' : '',
|
||||
stockBadge: stock <= 0 ? '已售罄' : (stock <= 30 ? '仅剩' + stock + '台' : '现货充足'),
|
||||
// 标签
|
||||
tags: d.tags || [],
|
||||
// 价格
|
||||
price: d.price,
|
||||
stock: stock,
|
||||
pointsUse: d.points_max_discount || d.points_exchange || 0,
|
||||
hwPrice: d.device_price,
|
||||
svcPrice: d.service_price,
|
||||
// 收益
|
||||
dailyEarn: d.daily_earn,
|
||||
monthlyEarn: d.monthly_earn,
|
||||
paybackDays: d.payback_days,
|
||||
calcModel: d.device_model || '',
|
||||
// 技术参数
|
||||
specs: d.specs || []
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取商品详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg) => { uni.showToast({ title: msg, icon: 'none' }) }
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!product.value.id) return
|
||||
if (Number(product.value.stock) <= 0) {
|
||||
uni.showToast({ title: '商品已售罄', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
const res = await request.post('/c/orders', {
|
||||
product_type: 'server_product',
|
||||
product_id: product.value.id
|
||||
}, { showLoading: false })
|
||||
uni.hideLoading()
|
||||
if (res.data && res.data.id) {
|
||||
openSub('pending-order-detail', { id: res.data.id })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.hideLoading()
|
||||
console.error('创建订单失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
fetchDetail(params.id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.pc-img { height: 400rpx; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; border-radius: 14rpx; margin-bottom: 24rpx;
|
||||
&::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,.4), transparent 60%); }
|
||||
}
|
||||
.pc-img-emoji { font-size: 100rpx; position: relative; z-index: 1; }
|
||||
.pc-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.loading-state { display: flex; align-items: center; justify-content: center; height: 400rpx; }
|
||||
.loading-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
.pc-top-badge { position: absolute; top: 20rpx; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 8rpx; font-weight: 600;
|
||||
&.left { left: 20rpx; background: rgba(255,255,255,.9); color: #0F1226; }
|
||||
&.right { right: 20rpx; background: rgba(0,0,0,.4); color: #fff; }
|
||||
}
|
||||
|
||||
.info-card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.pc-tags { display: flex; gap: 8rpx; margin-bottom: 12rpx; }
|
||||
.pc-tag { font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 8rpx; font-weight: 500;
|
||||
&.t-blue { background: #E6F4FF; color: #1677FF; }
|
||||
&.t-green { background: #E6FAF5; color: #00C4A7; }
|
||||
&.t-orange { background: #FFF7E6; color: #FF8800; }
|
||||
}
|
||||
.pc-name { font-size: 36rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.pc-desc { font-size: 24rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
.pc-price-row { display: flex; align-items: baseline; gap: 16rpx; margin: 20rpx 0; }
|
||||
.pc-price { font-size: 48rpx; font-weight: 800; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.pc-points { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
.price-split { display: flex; align-items: center; gap: 12rpx; background: #F7F9FC; border-radius: 10rpx; padding: 20rpx; margin-bottom: 20rpx; }
|
||||
.ps-item { flex: 1; text-align: center; }
|
||||
.ps-val { font-size: 28rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.ps-label { font-size: 20rpx; color: #8B91A1; }
|
||||
.ps-op { font-size: 28rpx; color: #BCC2CE; font-weight: 700; }
|
||||
|
||||
.svc-note { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border: 1px solid #FFE0B0; border-radius: 10rpx; padding: 20rpx; }
|
||||
.svc-note-title { font-size: 24rpx; font-weight: 600; color: #FF8800; display: block; margin-bottom: 8rpx; }
|
||||
.svc-note-text { font-size: 22rpx; color: #5A6072; line-height: 1.6; display: block; }
|
||||
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
.card-link { font-size: 22rpx; color: #1677FF; display: block; text-align: center; margin-top: 12rpx; }
|
||||
|
||||
.info-row { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 24rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 24rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
.earn-grid { display: flex; background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border-radius: 10rpx; padding: 16rpx 0; }
|
||||
.earn-item { flex: 1; text-align: center; position: relative;
|
||||
&:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 15%; height: 70%; width: 1rpx; background: #BAE0FF; }
|
||||
}
|
||||
.earn-val { font-size: 30rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.earn-unit { font-size: 20rpx; color: #8B91A1; }
|
||||
.earn-label { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 2rpx; }
|
||||
.earn-green { color: #00C4A7 !important; }
|
||||
|
||||
.steps { display: flex; }
|
||||
.step { flex: 1; text-align: center; position: relative;
|
||||
&::after { content: ''; position: absolute; top: 12px; left: 50%; width: 100%; height: 2rpx; background: #EAEDF3; z-index: 0; }
|
||||
&:last-child::after { display: none; }
|
||||
&.done::after { background: #00C4A7; }
|
||||
}
|
||||
.step-num { width: 48rpx; height: 48rpx; border-radius: 50%; background: #EAEDF3; color: #8B91A1; display: inline-flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: 700; position: relative; z-index: 1; }
|
||||
.step.done .step-num { background: linear-gradient(135deg, #00C4A7, #5FE6CC); color: #fff; }
|
||||
.step-label { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 6rpx; }
|
||||
.step.done .step-label { color: #00C4A7; }
|
||||
|
||||
.bottom-bar { position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); width: 100%; max-width: 430px; background: rgba(255,255,255,.96); backdrop-filter: blur(16px); padding: 16rpx 24rpx; display: flex; gap: 16rpx; z-index: 10; border-top: 0.5px solid #EAEDF3; padding-bottom: calc(16rpx + env(safe-area-inset-bottom, 0px)); }
|
||||
.bb-btn { flex: 1; padding: 22rpx 0; text-align: center; border-radius: 40rpx; font-size: 26rpx; font-weight: 600;
|
||||
&.outline { background: #F3F5F9; color: #5A6072; }
|
||||
&.warn { background: linear-gradient(135deg, #FF8800, #FFB84D); color: #fff; }
|
||||
&.primary { background: linear-gradient(135deg, #1677FF, #0958D9); color: #fff; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
* 福利商城 — 积分兑换商品列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="福利商城" :show-back="true" @back="closeSub()" />
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar"><text class="search-placeholder">搜索福利商品</text></view>
|
||||
|
||||
<!-- 分类图标 -->
|
||||
<view class="cat-row">
|
||||
<view v-for="(cat, i) in categories" :key="i" class="cat-item">
|
||||
<text class="cat-emoji">{{ cat.emoji }}</text>
|
||||
<text>{{ cat.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 秒杀专区 -->
|
||||
<view class="section">
|
||||
<view class="flash-header">
|
||||
<text class="section-title">秒杀专区</text>
|
||||
<text class="countdown">剩余 02:35:18</text>
|
||||
</view>
|
||||
<scroll-view scroll-x class="flash-scroll" :show-scrollbar="false">
|
||||
<view v-for="item in flashItems" :key="item.id" class="flash-card">
|
||||
<view class="flash-img" :style="{ background: item.bg }"></view>
|
||||
<text class="flash-name">{{ item.name }}</text>
|
||||
<view class="flash-prices">
|
||||
<text class="fp-integral">{{ item.points }}积分</text>
|
||||
<text class="fp-original">¥{{ item.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 推荐商品 -->
|
||||
<view class="section">
|
||||
<text class="section-title">为你推荐</text>
|
||||
<view class="goods-grid">
|
||||
<view v-for="item in goods" :key="item.id" class="goods-card">
|
||||
<view class="goods-img" :style="{ background: item.bg }"></view>
|
||||
<text class="goods-name">{{ item.name }}</text>
|
||||
<text class="goods-price">{{ item.points }}积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/welfare/items
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const categories = ref([
|
||||
{ name: '数码配件', emoji: '🔌', color: '#1677ff' },
|
||||
{ name: '品牌周边', emoji: '🎁', color: '#FF8800' },
|
||||
{ name: '限时优惠', emoji: '⚡', color: '#FF4D4F' },
|
||||
{ name: '积分兑换', emoji: '🎟️', color: '#00C4A7' },
|
||||
{ name: '全部商品', emoji: '📦', color: '#722ED1' }
|
||||
])
|
||||
|
||||
const flashItems = ref([
|
||||
{ id: 1, name: '全犀云T恤', points: 158, price: 158, bg: 'linear-gradient(135deg, #667eea, #764ba2)' },
|
||||
{ id: 2, name: 'CAT6网线', points: 68, price: 68, bg: 'linear-gradient(135deg, #4facfe, #00f2fe)' }
|
||||
])
|
||||
|
||||
const goods = ref([
|
||||
{ id: 1, name: '全犀云品牌T恤', points: 158, bg: 'linear-gradient(135deg, #667eea, #764ba2)' },
|
||||
{ id: 2, name: 'CAT6万兆网线', points: 68, bg: 'linear-gradient(135deg, #4facfe, #00f2fe)' },
|
||||
{ id: 3, name: '机房监控摄像头', points: 499, bg: 'linear-gradient(135deg, #43e97b, #38f9d7)' },
|
||||
{ id: 4, name: 'KVM切换器', points: 899, bg: 'linear-gradient(135deg, #fa709a, #fee140)' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88rpx); padding: 24rpx; }
|
||||
|
||||
.search-bar { background: #fff; border-radius: 28rpx; padding: 16rpx 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.search-placeholder { font-size: 26rpx; color: #BCC2CE; }
|
||||
|
||||
.cat-row { display: flex; gap: 0; justify-content: space-between; margin-bottom: 24rpx; }
|
||||
.cat-item { display: flex; flex-direction: column; align-items: center; gap: 8rpx; font-size: 20rpx; color: #5A6072; }
|
||||
|
||||
.section { margin-bottom: 24rpx; }
|
||||
.section-title {
|
||||
font-size: 30rpx; font-weight: 700; color: #0F1226; display: flex; align-items: center; gap: 10rpx; margin-bottom: 20rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 26rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
.flash-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx;
|
||||
.section-title { margin-bottom: 0; }
|
||||
}
|
||||
.countdown { font-size: 24rpx; color: #FF4D4F; font-weight: 600; }
|
||||
|
||||
.flash-scroll { white-space: nowrap; }
|
||||
.flash-card { display: inline-block; width: 220rpx; margin-right: 16rpx; }
|
||||
.flash-img { width: 220rpx; height: 160rpx; border-radius: 14rpx; margin-bottom: 12rpx; }
|
||||
.flash-name { font-size: 24rpx; color: #0F1226; display: block; margin-bottom: 6rpx; }
|
||||
.flash-prices { display: flex; align-items: center; gap: 8rpx; }
|
||||
.fp-integral { font-size: 28rpx; font-weight: 700; color: #722ED1; }
|
||||
.fp-original { font-size: 20rpx; color: #BCC2CE; text-decoration: line-through; }
|
||||
|
||||
.goods-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.goods-card { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 2rpx 8rpx rgba(15,18,38,.04); }
|
||||
.goods-img { width: 100%; height: 200rpx; }
|
||||
.goods-name { font-size: 24rpx; color: #0F1226; padding: 12rpx 16rpx 4rpx; display: block; }
|
||||
.goods-price { font-size: 28rpx; font-weight: 700; color: #722ED1; padding: 0 16rpx 16rpx; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,658 @@
|
||||
<!--
|
||||
* 积分计算器 — 完整的收益预估与回本分析
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="积分计算器" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="calc-body">
|
||||
<!-- Step 1: 设备型号 -->
|
||||
<view class="calc-section">
|
||||
<view class="cs-header">
|
||||
<text class="cs-step">Step 1</text>
|
||||
<text class="cs-title">选择设备型号</text>
|
||||
</view>
|
||||
<view class="device-options">
|
||||
<view
|
||||
v-for="(model, i) in deviceModels"
|
||||
:key="i"
|
||||
class="do-item"
|
||||
:class="{ selected: selectedModel === i }"
|
||||
@click="selectModel(i)"
|
||||
>
|
||||
<text class="do-name">{{ model.name }}</text>
|
||||
<text class="do-multiplier">{{ model.multiplier }}x 收益系数</text>
|
||||
<text class="do-price">¥{{ formatThousands(model.price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 2: 带宽滑块 -->
|
||||
<view class="calc-section">
|
||||
<view class="cs-header">
|
||||
<text class="cs-step">Step 2</text>
|
||||
<text class="cs-title">设置带宽</text>
|
||||
</view>
|
||||
<view class="slider-area">
|
||||
<view class="sa-header">
|
||||
<text class="sa-label">峰值带宽</text>
|
||||
<text class="sa-value">{{ bandwidth }}<text class="sa-unit">Mbps</text></text>
|
||||
</view>
|
||||
<slider
|
||||
:min="100"
|
||||
:max="5000"
|
||||
:step="100"
|
||||
:value="bandwidth"
|
||||
@change="onBandwidthChange"
|
||||
activeColor="#1677ff"
|
||||
block-size="16"
|
||||
block-color="#1677ff"
|
||||
backgroundColor="#e8edf3"
|
||||
/>
|
||||
<view class="sa-range">
|
||||
<text class="sa-range-text">100Mbps</text>
|
||||
<text class="sa-range-text">5000Mbps</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 3: 服务类型 -->
|
||||
<view class="calc-section">
|
||||
<view class="cs-header">
|
||||
<text class="cs-step">Step 3</text>
|
||||
<text class="cs-title">服务类型</text>
|
||||
</view>
|
||||
<view class="service-pills">
|
||||
<view
|
||||
v-for="(svc, i) in services"
|
||||
:key="i"
|
||||
class="sp-pill"
|
||||
:class="{ active: selectedService === i }"
|
||||
@click="selectedService = i"
|
||||
>
|
||||
<text class="sp-name">{{ svc.name }}</text>
|
||||
<text class="sp-multiplier">{{ svc.multiplier }}x</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Step 4: 区域选择 -->
|
||||
<view class="calc-section">
|
||||
<view class="cs-header">
|
||||
<text class="cs-step">Step 4</text>
|
||||
<text class="cs-title">部署区域</text>
|
||||
</view>
|
||||
<view class="region-selector">
|
||||
<view class="rs-row">
|
||||
<text class="rs-label">省份</text>
|
||||
<view class="rs-picker" @click="showProvincePicker = true">
|
||||
<text class="rs-value" :class="{ placeholder: !selectedProvince }">
|
||||
{{ selectedProvince || '请选择省份' }}
|
||||
</text>
|
||||
<text class="arrow-char">↓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="rs-row">
|
||||
<text class="rs-label">城市</text>
|
||||
<view class="rs-picker" @click="showCityPicker = true">
|
||||
<text class="rs-value" :class="{ placeholder: !selectedCity }">
|
||||
{{ selectedCity || '请选择城市' }}
|
||||
</text>
|
||||
<text class="arrow-char">↓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="rs-coefficient">
|
||||
<text class="rsc-label">区域系数</text>
|
||||
<text class="rsc-value">{{ regionCoefficient.toFixed(2) }}x</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 费用参数 -->
|
||||
<view class="calc-section">
|
||||
<text class="cs-title">费用参数</text>
|
||||
<view class="cost-params">
|
||||
<view class="cp-row" v-for="cost in costParams" :key="cost.label">
|
||||
<text class="cp-label">{{ cost.label }}</text>
|
||||
<text class="cp-value">¥{{ formatMoney(cost.value) }}</text>
|
||||
</view>
|
||||
<view class="cp-row total">
|
||||
<text class="cp-label">总计</text>
|
||||
<text class="cp-value total">¥{{ formatCalculatedMoney(totalCost) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 结果Hero -->
|
||||
<view class="result-hero">
|
||||
<view class="rh-header">
|
||||
<text class="rh-title">预估产出</text>
|
||||
</view>
|
||||
<view class="rh-grid">
|
||||
<view class="rh-item">
|
||||
<text class="rh-val">¥{{ formatCalculatedMoney(dailyEarning) }}</text>
|
||||
<text class="rh-unit">/天</text>
|
||||
<text class="rh-label">日产出参考</text>
|
||||
</view>
|
||||
<view class="rh-item">
|
||||
<text class="rh-val">¥{{ formatCalculatedMoney(monthlyEarning) }}</text>
|
||||
<text class="rh-unit">/月</text>
|
||||
<text class="rh-label">月产出参考</text>
|
||||
</view>
|
||||
<view class="rh-item">
|
||||
<text class="rh-val">¥{{ formatCalculatedMoney(annualEarning) }}</text>
|
||||
<text class="rh-unit">/年</text>
|
||||
<text class="rh-label">年产出参考</text>
|
||||
</view>
|
||||
<view class="rh-item">
|
||||
<text class="rh-val">{{ roi }}%</text>
|
||||
<text class="rh-label">参考产出率</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 回本分析 -->
|
||||
<view class="calc-section">
|
||||
<text class="cs-title">设备成本周期分析</text>
|
||||
<view class="payback-cards">
|
||||
<view class="pb-card residual">
|
||||
<text class="pb-card-label">含残值</text>
|
||||
<text class="pb-card-val">{{ residualPaybackDays }}<text class="pb-card-unit">天</text></text>
|
||||
<text class="pb-card-desc">设备残值按30%计算</text>
|
||||
</view>
|
||||
<view class="pb-card no-residual">
|
||||
<text class="pb-card-label">不含残值</text>
|
||||
<text class="pb-card-val">{{ noResidualPaybackDays }}<text class="pb-card-unit">天</text></text>
|
||||
<text class="pb-card-desc">纯收益</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pb-diff">
|
||||
<text>含残值可缩短 <text class="pb-diff-highlight">{{ paybackDiff }}天</text> 设备成本</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加成券对比 -->
|
||||
<view class="calc-section">
|
||||
<text class="cs-title">加成券效果对比</text>
|
||||
<view class="coupon-compare">
|
||||
<view class="cc-row">
|
||||
<view class="cc-col">
|
||||
<text class="cc-label">无券</text>
|
||||
<text class="cc-value gray">¥{{ formatCalculatedMoney(monthlyEarning) }}</text>
|
||||
</view>
|
||||
<view class="cc-vs">VS</view>
|
||||
<view class="cc-col">
|
||||
<text class="cc-label">1.5x加成</text>
|
||||
<text class="cc-value purple">¥{{ formatCalculatedMoney(monthlyWithCoupon) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="cc-diff">使用加成券每月多收益 ¥{{ formatCalculatedMoney(couponBenefit) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 12个月趋势 -->
|
||||
<view class="calc-section">
|
||||
<text class="cs-title">12个月产出趋势</text>
|
||||
<view class="trend-placeholder">
|
||||
<view class="tp-bar" v-for="(bar, i) in monthlyTrend" :key="i" :style="{ height: bar + '%' }">
|
||||
<view class="tp-bar-fill" :class="{ highlight: i === 0 }"></view>
|
||||
</view>
|
||||
<view class="tp-labels">
|
||||
<text class="tp-label" v-for="(m, i) in monthLabels" :key="i">{{ m }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 第一年明细 -->
|
||||
<!-- 第一年积分明细 -->
|
||||
<view class="calc-section">
|
||||
<text class="cs-title">第一年产出明细</text>
|
||||
<view class="year-breakdown">
|
||||
<view class="yb-row" v-for="item in yearBreakdown" :key="item.label">
|
||||
<text class="yb-label">{{ item.label }}</text>
|
||||
<text class="yb-value" :class="item.type">¥{{ formatCalculatedMoney(item.value) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<view class="bottom-action">
|
||||
<view class="ba-btn" @click="goMall">
|
||||
<u-icon name="shop" size="18" color="#fff"></u-icon>
|
||||
<text>前往商城选购</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 40rpx;"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 省份选择器 -->
|
||||
<u-picker
|
||||
:show="showProvincePicker"
|
||||
:columns="[provinces]"
|
||||
title="选择省份"
|
||||
@confirm="onProvinceConfirm"
|
||||
@cancel="showProvincePicker = false"
|
||||
></u-picker>
|
||||
|
||||
<!-- 城市选择器 -->
|
||||
<u-picker
|
||||
:show="showCityPicker"
|
||||
:columns="[currentCities]"
|
||||
title="选择城市"
|
||||
@confirm="onCityConfirm"
|
||||
@cancel="showCityPicker = false"
|
||||
></u-picker>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed, reactive, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, switchTab, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const formatCalculatedMoney = (amount) => {
|
||||
const value = Number(amount) || 0
|
||||
return formatMoney((Math.round((value + Number.EPSILON) * 100) / 100).toFixed(2))
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// calcState — single source of truth for all earnings calculations
|
||||
// ============================================
|
||||
const calcState = reactive({
|
||||
model: '500', // '500' | '200' | '100' | 'own'
|
||||
mult: 1.5, // model multiplier
|
||||
cost: 28000, // total cost (upfront)
|
||||
hwCost: 25000, // hardware cost
|
||||
svcCost: 3000, // service cost
|
||||
accessCost: 0, // interface fee (own devices)
|
||||
bw: 1000, // bandwidth Mbps
|
||||
timeWeight: 1.3, // service type coefficient
|
||||
region: 1.3, // region coefficient
|
||||
quality: 1.2, // quality coefficient (信用分)
|
||||
hours: 24 // effective hours per day
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Province / City data
|
||||
// ============================================
|
||||
const regionCoefficientMap = {
|
||||
'北京': 1.3, '上海': 1.3, '广州': 1.3, '深圳': 1.3,
|
||||
'杭州': 1.2, '南京': 1.2, '成都': 1.2, '武汉': 1.2, '重庆': 1.2, '天津': 1.2,
|
||||
'东莞': 1.1, '佛山': 1.1, '宁波': 1.1, '苏州': 1.1, '无锡': 1.1,
|
||||
'厦门': 1.1, '福州': 1.1, '青岛': 1.1, '济南': 1.1, '西安': 1.1,
|
||||
'长沙': 1.1, '郑州': 1.1, '合肥': 1.1
|
||||
}
|
||||
|
||||
const cityMap = {
|
||||
'北京': ['北京'], '上海': ['上海'], '广东': ['广州', '深圳', '东莞', '佛山'],
|
||||
'浙江': ['杭州', '宁波', '温州'], '江苏': ['南京', '苏州', '无锡'],
|
||||
'四川': ['成都', '绵阳'], '湖北': ['武汉', '宜昌'], '湖南': ['长沙', '株洲'],
|
||||
'福建': ['福州', '厦门', '泉州'], '山东': ['济南', '青岛'],
|
||||
'河南': ['郑州', '洛阳'], '河北': ['石家庄', '保定', '廊坊'],
|
||||
'辽宁': ['沈阳', '大连'], '陕西': ['西安'], '重庆': ['重庆'],
|
||||
'天津': ['天津'], '安徽': ['合肥'], '江西': ['南昌'],
|
||||
'广西': ['南宁'], '云南': ['昆明'], '贵州': ['贵阳'],
|
||||
'山西': ['太原'], '吉林': ['长春'], '黑龙江': ['哈尔滨'],
|
||||
'海南': ['海口'], '甘肃': ['兰州'], '内蒙古': ['呼和浩特'],
|
||||
'宁夏': ['银川'], '新疆': ['乌鲁木齐'], '西藏': ['拉萨'], '青海': ['西宁']
|
||||
}
|
||||
|
||||
const provinces = Object.keys(cityMap)
|
||||
|
||||
// ============================================
|
||||
// UI state — bridges template refs to calcState
|
||||
// ============================================
|
||||
const selectedModel = ref(0)
|
||||
const bandwidth = computed(() => calcState.bw)
|
||||
const selectedService = ref(2) // 视频点播 (index 2)
|
||||
const selectedProvince = ref('')
|
||||
const selectedCity = ref('')
|
||||
const showProvincePicker = ref(false)
|
||||
const showCityPicker = ref(false)
|
||||
|
||||
// ============================================
|
||||
// Device / Service definitions
|
||||
// ============================================
|
||||
const deviceModels = [
|
||||
{ name: 'QH-500 Edge', multiplier: 1.5, price: 28000, hardware: 25000, service: 3000, install: 0, rack: 800, power: 400, maintenance: 300 },
|
||||
{ name: 'QH-200 Pro', multiplier: 1.2, price: 18000, hardware: 16000, service: 2000, install: 0, rack: 600, power: 300, maintenance: 200 },
|
||||
{ name: 'QH-100', multiplier: 1.0, price: 9800, hardware: 8800, service: 1000, install: 0, rack: 400, power: 200, maintenance: 150 },
|
||||
{ name: '自有设备', multiplier: 1.0, price: 0, hardware: 0, service: 500, install: 2000, rack: 800, power: 400, maintenance: 300 }
|
||||
]
|
||||
|
||||
const services = [
|
||||
{ name: '网页加速', multiplier: 1.0 },
|
||||
{ name: '下载加速', multiplier: 1.2 },
|
||||
{ name: '视频点播', multiplier: 1.3 },
|
||||
{ name: '直播加速', multiplier: 1.5 },
|
||||
{ name: '全站加速', multiplier: 1.4 }
|
||||
]
|
||||
|
||||
const monthLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
|
||||
// ============================================
|
||||
// Watchers — sync template-level refs to calcState
|
||||
// ============================================
|
||||
watch(selectedModel, (idx) => {
|
||||
const key = ['500', '200', '100', 'own'][idx]
|
||||
const d = deviceModels[idx]
|
||||
calcState.model = key
|
||||
calcState.mult = d.multiplier
|
||||
calcState.cost = d.price
|
||||
calcState.hwCost = d.hardware
|
||||
calcState.svcCost = d.service
|
||||
calcState.accessCost = idx === 3 ? d.install : 0
|
||||
})
|
||||
|
||||
watch(selectedService, (idx) => {
|
||||
calcState.timeWeight = services[idx].multiplier
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Derived computed properties
|
||||
// ============================================
|
||||
const regionCoefficient = computed(() => calcState.region)
|
||||
|
||||
const currentCities = computed(() => {
|
||||
return cityMap[selectedProvince.value] || ['请先选择省份']
|
||||
})
|
||||
|
||||
// 日收益公式: bw * timeWeight * quality * region * mult * hours * 0.01
|
||||
const dailyEarning = computed(() => {
|
||||
return calcState.bw * calcState.timeWeight * calcState.quality * calcState.region * calcState.mult * calcState.hours * 0.01
|
||||
})
|
||||
|
||||
const monthlyEarning = computed(() => dailyEarning.value * 30)
|
||||
const annualEarning = computed(() => dailyEarning.value * 365)
|
||||
|
||||
// 回本周期 (不含残值)
|
||||
const noResidualPaybackDays = computed(() => {
|
||||
if (dailyEarning.value <= 0) return 0
|
||||
return Math.ceil(calcState.cost / dailyEarning.value)
|
||||
})
|
||||
|
||||
// 回本周期 (含残值): 5年折旧=60个月, 残值按40%计算
|
||||
const residualPaybackDays = computed(() => {
|
||||
const depPerDay = calcState.cost / (60 * 30)
|
||||
const effectiveDaily = dailyEarning.value - depPerDay
|
||||
if (effectiveDaily <= 0) return 0
|
||||
return Math.ceil((calcState.cost * 0.6) / effectiveDaily)
|
||||
})
|
||||
|
||||
const paybackDiff = computed(() => {
|
||||
const a = noResidualPaybackDays.value
|
||||
const b = residualPaybackDays.value
|
||||
return Math.max(0, a - b)
|
||||
})
|
||||
|
||||
const roi = computed(() => {
|
||||
if (calcState.cost <= 0) return 0
|
||||
const totalAnnualCost = 18000 // 年运营成本(机房+电费+运维)
|
||||
const depreciation = calcState.cost / 3 // 3年折旧
|
||||
return Math.round((annualEarning.value - calcState.cost - totalAnnualCost - depreciation) / calcState.cost * 100)
|
||||
})
|
||||
|
||||
const totalCost = computed(() => {
|
||||
const d = deviceModels[selectedModel.value]
|
||||
return calcState.hwCost + calcState.svcCost + calcState.accessCost + d.install + (d.rack + d.power + d.maintenance) * 12
|
||||
})
|
||||
|
||||
const costParams = computed(() => {
|
||||
const d = deviceModels[selectedModel.value]
|
||||
const items = [
|
||||
{ label: '硬件费', value: calcState.hwCost },
|
||||
{ label: '服务费', value: calcState.svcCost }
|
||||
]
|
||||
if (calcState.model === 'own') {
|
||||
items.push({ label: '接口费', value: calcState.accessCost })
|
||||
}
|
||||
items.push(
|
||||
{ label: '安装调试费', value: d.install },
|
||||
{ label: '机房月租', value: d.rack },
|
||||
{ label: '月电费', value: d.power },
|
||||
{ label: '月运维费', value: d.maintenance }
|
||||
)
|
||||
return items
|
||||
})
|
||||
|
||||
const monthlyWithCoupon = computed(() => monthlyEarning.value * 1.5)
|
||||
const couponBenefit = computed(() => monthlyWithCoupon.value - monthlyEarning.value)
|
||||
|
||||
const monthlyTrend = computed(() => {
|
||||
const base = monthlyEarning.value / 100
|
||||
return monthLabels.map((_, i) => {
|
||||
const growth = 1 + (i * 0.02)
|
||||
const seasonal = i >= 5 && i <= 8 ? 1.15 : 1.0
|
||||
return Math.min(Math.round(base * growth * seasonal / 30 * 10) / 10, 100)
|
||||
})
|
||||
})
|
||||
|
||||
const yearBreakdown = computed(() => {
|
||||
const annual = annualEarning.value
|
||||
const d = deviceModels[selectedModel.value]
|
||||
const opCost = d.rack * 12 + d.power * 12 + d.maintenance * 12
|
||||
const dep = d.price * 0.2
|
||||
return [
|
||||
{ label: '毛收入', value: annual, type: 'income' },
|
||||
{ label: '运营成本', value: opCost, type: 'cost' },
|
||||
{ label: '折旧(20%残值率)', value: dep, type: 'cost' },
|
||||
{ label: '净收入', value: annual - opCost - dep, type: 'profit' }
|
||||
]
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// Interactive methods
|
||||
// ============================================
|
||||
|
||||
// --- Model selection ---
|
||||
const selectCalcModel = (model) => {
|
||||
const idx = ['500', '200', '100', 'own'].indexOf(model)
|
||||
if (idx === -1) return
|
||||
selectedModel.value = idx
|
||||
}
|
||||
|
||||
const selectModel = (i) => {
|
||||
selectedModel.value = i
|
||||
}
|
||||
|
||||
// --- Bandwidth ---
|
||||
const updateCalcBw = (val) => {
|
||||
calcState.bw = val
|
||||
}
|
||||
|
||||
const onBandwidthChange = (e) => {
|
||||
calcState.bw = e.detail.value
|
||||
}
|
||||
|
||||
// --- Service type ---
|
||||
const selectCalcSvc = (coefficient) => {
|
||||
calcState.timeWeight = coefficient
|
||||
const idx = services.findIndex(s => s.multiplier === coefficient)
|
||||
if (idx !== -1) selectedService.value = idx
|
||||
}
|
||||
|
||||
// --- Province / City cascade ---
|
||||
const onProvinceChange = (province) => {
|
||||
selectedProvince.value = province
|
||||
selectedCity.value = ''
|
||||
const cities = cityMap[province] || []
|
||||
if (cities.length > 0) {
|
||||
calcState.region = regionCoefficientMap[cities[0]] || 1.0
|
||||
} else {
|
||||
calcState.region = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
const onCityChange = (city) => {
|
||||
selectedCity.value = city
|
||||
calcState.region = regionCoefficientMap[city] || 1.0
|
||||
}
|
||||
|
||||
const onProvinceConfirm = (e) => {
|
||||
onProvinceChange(e.value[0])
|
||||
showProvincePicker.value = false
|
||||
}
|
||||
|
||||
const onCityConfirm = (e) => {
|
||||
onCityChange(e.value[0])
|
||||
showCityPicker.value = false
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Navigation
|
||||
// ============================================
|
||||
const goBack = () => closeSub()
|
||||
const goMall = () => { switchTab('mall') }
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.model) {
|
||||
const idx = deviceModels.findIndex(m => m.name.includes(params.model))
|
||||
if (idx !== -1) selectedModel.value = idx
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
|
||||
.calc-body { padding: 16rpx 24rpx; }
|
||||
|
||||
// 计算区块
|
||||
.calc-section {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx;
|
||||
}
|
||||
.cs-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.cs-step { font-size: 20rpx; font-weight: 700; color: #1677ff; background: #E6F4FF; padding: 4rpx 12rpx; border-radius: 6rpx; }
|
||||
.cs-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
|
||||
// 设备选项
|
||||
.device-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12rpx; }
|
||||
.do-item {
|
||||
border: 2rpx solid #f0f0f0; border-radius: 12rpx; padding: 20rpx; text-align: center;
|
||||
&:active { background: #f8f9fa; }
|
||||
&.selected { border-color: #1677ff; background: #F0F7FF; }
|
||||
}
|
||||
.do-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.do-multiplier { font-size: 20rpx; color: #FF8800; display: block; margin: 6rpx 0; }
|
||||
.do-price { font-size: 24rpx; color: #1677ff; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
// 滑块
|
||||
.slider-area { padding: 0 8rpx; }
|
||||
.sa-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
|
||||
.sa-label { font-size: 24rpx; color: #5A6072; }
|
||||
.sa-value { font-size: 44rpx; font-weight: 700; color: #1677ff; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.sa-unit { font-size: 22rpx; color: #1677ff; font-weight: 500; margin-left: 4rpx; }
|
||||
.sa-range { display: flex; justify-content: space-between; margin-top: 8rpx; }
|
||||
.sa-range-text { font-size: 20rpx; color: #8B91A1; }
|
||||
|
||||
// 服务类型
|
||||
.service-pills { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.sp-pill {
|
||||
padding: 14rpx 28rpx; border-radius: 30rpx; border: 2rpx solid #e8edf3; display: flex; align-items: center; gap: 8rpx;
|
||||
&.active { border-color: #1677ff; background: #F0F7FF; }
|
||||
}
|
||||
.sp-name { font-size: 24rpx; color: #0F1226; }
|
||||
.sp-multiplier { font-size: 20rpx; color: #1677ff; font-weight: 700; }
|
||||
|
||||
// 区域选择
|
||||
.region-selector { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.rs-row { display: flex; align-items: center; gap: 16rpx; }
|
||||
.rs-label { font-size: 24rpx; color: #5A6072; width: 80rpx; flex-shrink: 0; }
|
||||
.rs-picker {
|
||||
flex: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16rpx 20rpx; background: #fafafa; border-radius: 10rpx;
|
||||
}
|
||||
.rs-value { font-size: 24rpx; color: #0F1226; &.placeholder { color: #8B91A1; } }
|
||||
.rs-coefficient { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 16rpx; background: #F0F7FF; border-radius: 10rpx; }
|
||||
.rsc-label { font-size: 22rpx; color: #5A6072; }
|
||||
.rsc-value { font-size: 28rpx; font-weight: 700; color: #1677ff; }
|
||||
|
||||
// 费用参数
|
||||
.cost-params { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.cp-row { display: flex; justify-content: space-between; align-items: center;
|
||||
&.total { padding-top: 16rpx; border-top: 2rpx solid #f0f0f0; margin-top: 8rpx; }
|
||||
}
|
||||
.cp-label { font-size: 24rpx; color: #5A6072; }
|
||||
.cp-value { font-size: 24rpx; color: #0F1226; font-weight: 500;
|
||||
&.total { font-size: 32rpx; color: #FF4D4D; font-weight: 700; }
|
||||
}
|
||||
|
||||
// 结果Hero
|
||||
.result-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
border-radius: 14rpx; padding: 32rpx 24rpx; margin-bottom: 16rpx; color: #fff;
|
||||
}
|
||||
.rh-title { font-size: 28rpx; font-weight: 600; margin-bottom: 20rpx; display: block; }
|
||||
.rh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; }
|
||||
.rh-item { text-align: center; }
|
||||
.rh-val { font-size: 40rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: inline; }
|
||||
.rh-unit { font-size: 20rpx; opacity: 0.6; margin-left: 4rpx; }
|
||||
.rh-label { font-size: 20rpx; opacity: 0.55; display: block; margin-top: 4rpx; }
|
||||
|
||||
// 回本分析
|
||||
.payback-cards { display: flex; gap: 16rpx; }
|
||||
.pb-card {
|
||||
flex: 1; border-radius: 12rpx; padding: 24rpx; text-align: center;
|
||||
&.residual { background: linear-gradient(135deg, #F0F7FF, #E6F4FF); }
|
||||
&.no-residual { background: #fafafa; }
|
||||
}
|
||||
.pb-card-label { font-size: 22rpx; color: #5A6072; display: block; margin-bottom: 8rpx; }
|
||||
.pb-card-val { font-size: 40rpx; font-weight: 800; color: #1677ff; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.pb-card-unit { font-size: 22rpx; font-weight: 500; }
|
||||
.pb-card-desc { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.pb-diff { text-align: center; margin-top: 12rpx; font-size: 24rpx; color: #5A6072; }
|
||||
.pb-diff-highlight { color: #1677ff; font-weight: 700; }
|
||||
|
||||
// 加成券对比
|
||||
.coupon-compare {
|
||||
background: #F9F0FF; border-radius: 12rpx; padding: 24rpx;
|
||||
border: 1rpx solid #E8D5FF;
|
||||
}
|
||||
.cc-row { display: flex; align-items: center; }
|
||||
.cc-col { flex: 1; text-align: center; }
|
||||
.cc-label { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 4rpx; }
|
||||
.cc-value { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
&.gray { color: #8B91A1; }
|
||||
&.purple { color: #722ED1; }
|
||||
}
|
||||
.cc-vs { width: 60rpx; text-align: center; font-size: 22rpx; font-weight: 700; color: #722ED1; }
|
||||
.cc-diff { display: block; text-align: center; font-size: 22rpx; color: #5A6072; margin-top: 12rpx; }
|
||||
|
||||
// 趋势图占位
|
||||
.trend-placeholder {
|
||||
display: flex; align-items: flex-end; gap: 12rpx; height: 200rpx; padding: 20rpx 0 40rpx; position: relative;
|
||||
}
|
||||
.tp-bar { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.tp-bar-fill {
|
||||
width: 100%; height: 100%; background: linear-gradient(180deg, #1677ff, #0D5BD6); border-radius: 4rpx 4rpx 0 0;
|
||||
opacity: 0.6;
|
||||
&.highlight { opacity: 1; }
|
||||
}
|
||||
.tp-labels { position: absolute; bottom: 0; left: 0; right: 0; display: flex; gap: 12rpx; }
|
||||
.tp-label { flex: 1; text-align: center; font-size: 18rpx; color: #8B91A1; }
|
||||
|
||||
// 第一年明细
|
||||
.year-breakdown { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.yb-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.yb-label { font-size: 24rpx; color: #5A6072; }
|
||||
.yb-value { font-size: 24rpx; font-weight: 700;
|
||||
&.income { color: #00C4A7; }
|
||||
&.cost { color: #FF4D4D; }
|
||||
&.profit { color: #1677ff; }
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.bottom-action { padding: 16rpx 0; }
|
||||
.ba-btn {
|
||||
height: 80rpx; background: linear-gradient(135deg, #FF8800, #FF6600); border-radius: 40rpx;
|
||||
display: flex; align-items: center; justify-content: center; gap: 10rpx;
|
||||
color: #fff; font-size: 28rpx; font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!--
|
||||
* 排行榜 — 对齐 C端.html #sub-leaderboard (~line 3885)
|
||||
* 用户榜/设备榜 Tab + 时间范围筛选 + 排名列表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="活跃排行" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 榜Tab切换 -->
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: rankType === 'user' }" @click="rankType = 'user'">用户榜</view>
|
||||
<view class="tab" :class="{ active: rankType === 'device' }" @click="rankType = 'device'">设备榜</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<view class="time-btns">
|
||||
<view v-for="t in timeRanges" :key="t.key" class="time-btn" :class="{ active: timeRange === t.key }" @click="timeRange = t.key">
|
||||
{{ t.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 排行列表 -->
|
||||
<view class="card rank-list">
|
||||
<view class="lb-item" v-for="(item, i) in rankList" :key="i">
|
||||
<view class="lb-rank" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</view>
|
||||
<view class="lb-info">
|
||||
<text class="lb-name">{{ item.name }}</text>
|
||||
</view>
|
||||
<text class="lb-val">¥{{ formatThousands(item.revenue) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的排名 -->
|
||||
<!-- <view class="my-rank-card">
|
||||
<text class="mr-label">我的排名</text>
|
||||
<view class="mr-row">
|
||||
<text class="mr-rank">第 {{ myRank }} 名</text>
|
||||
<text class="mr-val">¥{{ formatThousands(myRevenue) }}</text>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const rankType = ref('user')
|
||||
const timeRange = ref('total')
|
||||
const rankList = ref([])
|
||||
|
||||
const timeRanges = [
|
||||
{ key: 'total', label: '总榜' },
|
||||
{ key: 'month', label: '月榜' },
|
||||
{ key: 'week', label: '周榜' },
|
||||
{ key: 'day', label: '日榜' }
|
||||
]
|
||||
|
||||
const fetchRank = async () => {
|
||||
const type = rankType.value
|
||||
const period = timeRange.value
|
||||
try {
|
||||
const res = await request.get(`/c/leaderboard/${type === 'user' ? 'users' : 'devices'}`, {
|
||||
period,
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
rankList.value = res.data.map(item => ({
|
||||
name: type === 'user' ? (item.nickname || '') : (item.device_id || ''),
|
||||
revenue: type === 'user' ? (item.value3 || '0') : (item.value1 || '0')
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取排行榜失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换类型或周期时重新拉取
|
||||
watch([rankType, timeRange], () => { fetchRank() })
|
||||
|
||||
const myRank = ref('--')
|
||||
const myRevenue = ref('--')
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.tab) rankType.value = params.tab
|
||||
fetchRank()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.tabs { display: flex; border-bottom: 0.5px solid #EAEDF3; margin-bottom: 24rpx; }
|
||||
.tab { flex: 1; text-align: center; padding: 22rpx 0; font-size: 28rpx; font-weight: 500; color: #8B91A1; position: relative;
|
||||
&.active { color: #1677FF; font-weight: 600;
|
||||
&::after { content: ''; position: absolute; bottom: -0.5px; left: 50%; transform: translateX(-50%); width: 44rpx; height: 5rpx; background: linear-gradient(90deg, #4096FF, #1677FF); border-radius: 2px; box-shadow: 0 0 12rpx rgba(22,119,255,.4); }
|
||||
}
|
||||
}
|
||||
|
||||
.time-btns { display: flex; gap: 12rpx; margin-bottom: 24rpx; }
|
||||
.time-btn { padding: 10rpx 28rpx; border-radius: 32rpx; font-size: 24rpx; font-weight: 500; background: #fff; border: 1px solid #EAEDF3; color: #5A6072;
|
||||
&.active { background: linear-gradient(135deg, #1677FF, #4096FF); color: #fff; border-color: transparent; box-shadow: 0 4rpx 16rpx rgba(22,119,255,.3); }
|
||||
}
|
||||
|
||||
.card { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.rank-list { margin-bottom: 24rpx; }
|
||||
.lb-item { display: flex; align-items: center; gap: 20rpx; padding: 22rpx 24rpx; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.lb-rank { width: 56rpx; height: 56rpx; border-radius: 50%; background: #F3F5F9; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 24rpx; flex-shrink: 0; color: #5A6072; font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
&.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; box-shadow: 0 4rpx 16rpx rgba(255,165,0,.4); }
|
||||
&.silver { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); color: #fff; box-shadow: 0 4rpx 16rpx rgba(168,174,192,.4); }
|
||||
&.bronze { background: linear-gradient(135deg, #CD7F32, #A0522D); color: #fff; box-shadow: 0 4rpx 16rpx rgba(160,82,45,.4); }
|
||||
}
|
||||
.lb-info { flex: 1; display: flex; align-items: center; gap: 10rpx; }
|
||||
.lb-name { font-size: 26rpx; font-weight: 500; color: #0F1226; }
|
||||
.lb-vip { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 8rpx; background: #E6F4FF; color: #1677FF; font-weight: 500; }
|
||||
.lb-val { font-weight: 700; color: #1677FF; font-size: 28rpx; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
.my-rank-card { background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border: 1px solid #BAE0FF; border-radius: 14rpx; padding: 24rpx; }
|
||||
.mr-label { font-size: 24rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mr-row { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.mr-rank { font-size: 28rpx; font-weight: 600; color: #1677FF; }
|
||||
.mr-val { font-size: 32rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<!--
|
||||
* 在线激励
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="在线激励" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="reward-body">
|
||||
<!-- 本周进度 -->
|
||||
<view class="progress-hero">
|
||||
<view class="ph-header">
|
||||
<text class="ph-title">本周在线时长</text>
|
||||
<view class="ph-rate" :class="progressRate >= 100 ? 'achieved' : 'pending'">
|
||||
<text>{{ progressRate >= 100 ? '已达标' : progressRate + '%' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ph-progress">
|
||||
<view class="php-bar">
|
||||
<view class="php-fill" :style="{ width: Math.min(progressRate, 100) + '%' }"></view>
|
||||
</view>
|
||||
<view class="php-labels">
|
||||
<text class="php-label actual">{{ actualHours }}h</text>
|
||||
<text class="php-label target">目标 {{ targetHours }}h</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ph-diff" v-if="diffHours >= 0">
|
||||
<text class="ph-diff-text">还差 {{ diffHours }}h 达标,加油!</text>
|
||||
</view>
|
||||
<view class="ph-diff achieved" v-else>
|
||||
<u-icon name="checkmark-circle" size="14" color="#00C4A7"></u-icon>
|
||||
<text class="ph-diff-text achieved">已超额完成 {{ Math.abs(diffHours) }}h</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 奖励规则 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">奖励规则</text>
|
||||
<text class="section-link">更多规则</text>
|
||||
</view>
|
||||
<view class="reward-rules">
|
||||
<view
|
||||
v-for="(rule, i) in rewardRules"
|
||||
:key="i"
|
||||
class="rr-card"
|
||||
:class="{ achieved: rule.achieved }"
|
||||
>
|
||||
<view class="rr-icon" :class="{ achieved: rule.achieved }">
|
||||
<u-icon :name="rule.achieved ? 'checkmark' : 'clock'" :color="rule.achieved ? '#00C4A7' : '#8B91A1'" size="14"></u-icon>
|
||||
</view>
|
||||
<view class="rr-body">
|
||||
<text class="rr-condition">{{ rule.condition }}</text>
|
||||
<text class="rr-reward">{{ rule.reward }}</text>
|
||||
</view>
|
||||
<view class="rr-badge" v-if="rule.achieved" style="color: #00C4A7; background: #E6FAF5;">已达成</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<view class="section">
|
||||
<text class="section-title">历史记录</text>
|
||||
<view class="history-list">
|
||||
<view class="history-item" v-for="(record, i) in history" :key="i">
|
||||
<view class="hi-week">
|
||||
<text class="hi-week-label">{{ record.week }}</text>
|
||||
</view>
|
||||
<view class="hi-body">
|
||||
<text class="hi-hours">在线 {{ record.hours }}h</text>
|
||||
<view class="hi-progress-bar">
|
||||
<view class="hi-progress-fill" :style="{ width: (record.hours / 168 * 100) + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hi-reward" :class="{ earned: record.reward > 0 }">
|
||||
{{ record.reward > 0 ? '+' + record.reward : '未达标' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view style="height: 40rpx;"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const targetHours = 168
|
||||
const actualHours = ref(164)
|
||||
|
||||
const rewardRules = [
|
||||
{ condition: '周在线时长 ≥ 168小时', reward: '+¥50 奖励', achieved: false },
|
||||
{ condition: '周在线时长 ≥ 160小时', reward: '+¥30 奖励', achieved: true },
|
||||
{ condition: '周在线时长 ≥ 140小时', reward: '+¥15 奖励', achieved: true },
|
||||
{ condition: '月在线时长 ≥ 680小时', reward: '+¥200 奖励', achieved: false }
|
||||
]
|
||||
|
||||
const history = [
|
||||
{ week: '第4周', hours: 168, reward: 50 },
|
||||
{ week: '第3周', hours: 172, reward: 50 },
|
||||
{ week: '第2周', hours: 158, reward: 30 },
|
||||
{ week: '第1周', hours: 145, reward: 15 }
|
||||
]
|
||||
|
||||
const progressRate = computed(() => Math.round(actualHours.value / targetHours * 100))
|
||||
const diffHours = computed(() => targetHours - actualHours.value)
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/revenue/online-reward
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
|
||||
.reward-body { padding: 16rpx 24rpx; }
|
||||
|
||||
// 进度Hero
|
||||
.progress-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
border-radius: 14rpx; padding: 32rpx 24rpx; margin-bottom: 16rpx; color: #fff;
|
||||
}
|
||||
.ph-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
.ph-title { font-size: 28rpx; font-weight: 600; }
|
||||
.ph-rate {
|
||||
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
&.pending { background: rgba(255,255,255,0.15); }
|
||||
&.achieved { background: rgba(0, 196, 167, 0.2); color: #00C4A7; }
|
||||
}
|
||||
.ph-progress { margin-bottom: 16rpx; }
|
||||
.php-bar { height: 20rpx; background: rgba(255,255,255,0.15); border-radius: 10rpx; overflow: hidden; margin-bottom: 8rpx; }
|
||||
.php-fill { height: 100%; background: linear-gradient(90deg, #00C4A7, #7FFFD4); border-radius: 10rpx; }
|
||||
.php-labels { display: flex; justify-content: space-between; }
|
||||
.php-label { font-size: 22rpx; opacity: 0.7;
|
||||
&.actual { font-weight: 700; opacity: 1; }
|
||||
}
|
||||
.ph-diff { display: flex; align-items: center; gap: 8rpx; padding-top: 12rpx; border-top: 1rpx solid rgba(255,255,255,0.1);
|
||||
&.achieved { }
|
||||
}
|
||||
.ph-diff-text { font-size: 22rpx; opacity: 0.8;
|
||||
&.achieved { color: #00C4A7; }
|
||||
}
|
||||
|
||||
// 区块
|
||||
.section { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.section-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.section-header .section-title { margin-bottom: 0; }
|
||||
.section-link { font-size: 22rpx; color: #1677ff; }
|
||||
|
||||
// 奖励规则
|
||||
.reward-rules { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.rr-card {
|
||||
display: flex; align-items: center; gap: 16rpx; padding: 16rpx 20rpx;
|
||||
background: #fafafa; border-radius: 12rpx;
|
||||
&.achieved { background: #f0faf8; }
|
||||
}
|
||||
.rr-icon {
|
||||
width: 40rpx; height: 40rpx; border-radius: 50%; background: #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
&.achieved { background: #E6FAF5; }
|
||||
}
|
||||
.rr-body { flex: 1; }
|
||||
.rr-condition { font-size: 24rpx; color: #0F1226; display: block; }
|
||||
.rr-reward { font-size: 22rpx; color: #00C4A7; font-weight: 600; display: block; margin-top: 4rpx; }
|
||||
.rr-badge { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; flex-shrink: 0; }
|
||||
|
||||
// 历史记录
|
||||
.history-list { display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.history-item { display: flex; align-items: center; gap: 16rpx; padding: 12rpx 0; }
|
||||
.hi-week { width: 80rpx; flex-shrink: 0; }
|
||||
.hi-week-label { font-size: 22rpx; color: #5A6072; }
|
||||
.hi-body { flex: 1; }
|
||||
.hi-hours { font-size: 22rpx; color: #0F1226; display: block; margin-bottom: 6rpx; }
|
||||
.hi-progress-bar { height: 10rpx; background: #f0f0f0; border-radius: 5rpx; overflow: hidden; }
|
||||
.hi-progress-fill { height: 100%; background: #1677ff; border-radius: 5rpx; }
|
||||
.hi-reward { font-size: 22rpx; color: #8B91A1; font-weight: 600; width: 80rpx; text-align: right;
|
||||
&.earned { color: #00C4A7; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
* 积分概览 — 对齐 C端.html #sub-revenue-overview (~line 4133)
|
||||
* 30天趋势图 + 4格仪表板 + 收益构成表
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="积分概览" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 收益趋势图 -->
|
||||
<view class="chart-box">
|
||||
<text class="chart-title">近30天收益趋势</text>
|
||||
<view class="chart-area">
|
||||
<view class="chart-bars">
|
||||
<view v-for="(d, i) in trendData" :key="i" class="chart-bar-wrap">
|
||||
<view class="chart-bar" :style="{ height: (d.value / maxTrend * 100) + '%', background: i >= trendData.length - 1 ? 'linear-gradient(180deg, #1677FF, #4096FF)' : 'linear-gradient(180deg, #BAE0FF, #E6F4FF)' }"></view>
|
||||
<text class="chart-bar-label" v-if="i % 5 === 0">{{ d.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 4格仪表板 -->
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card" v-for="(item, index) in monitorData" :key="item.label">
|
||||
<text class="mc-title">{{ item.label }}</text>
|
||||
<text class="mc-val" :style="{ color: index === 0 ? '#1677FF' : '#0F1226' }">{{ item.value }}</text>
|
||||
<text v-if="item.trend" class="mc-trend" :style="{ color: item.trendColor }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益构成 -->
|
||||
<view class="card">
|
||||
<text class="card-title">收益构成</text>
|
||||
<view class="info-row" v-for="item in breakdown" :key="item.label">
|
||||
<text class="ir-label">{{ item.label }}</text>
|
||||
<text class="ir-val">¥{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const maxTrend = ref(200)
|
||||
|
||||
const trendData = ref([])
|
||||
const monitorData = ref([])
|
||||
const breakdown = ref([])
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-overview', { showLoading: false })
|
||||
const d = res.data
|
||||
|
||||
trendData.value = (d.trend || []).map(t => ({ date: t.date.slice(5), value: t.value }))
|
||||
const maxVal = Math.max(...trendData.value.map(t => t.value), 1)
|
||||
maxTrend.value = Math.ceil(maxVal * 1.3)
|
||||
|
||||
monitorData.value = (d.monitor || []).map(m => ({
|
||||
...m,
|
||||
value: '¥' + (m.value ?? 0),
|
||||
trend: m.trend ? (m.trend > 0 ? '↑ ' + m.trend + '%' : '↓ ' + Math.abs(m.trend) + '%') : undefined,
|
||||
trendColor: m.trend > 0 ? '#00C4A7' : m.trend < 0 ? '#FF4D4F' : undefined
|
||||
}))
|
||||
|
||||
breakdown.value = d.breakdown || []
|
||||
} catch (e) { /* 忽略 */ }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
// Chart
|
||||
.chart-box { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); position: relative; overflow: hidden;
|
||||
&::before { content: ''; position: absolute; inset: 0; background-image: linear-gradient(rgba(22,119,255,.04) 1px, transparent 1px), linear-gradient(90deg, rgba(22,119,255,.04) 1px, transparent 1px); background-size: 40rpx 40rpx; }
|
||||
}
|
||||
.chart-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 24rpx; position: relative; z-index: 1; }
|
||||
.chart-area { position: relative; z-index: 1; }
|
||||
.chart-bars { display: flex; align-items: flex-end; gap: 4rpx; height: 240rpx; padding-bottom: 40rpx; position: relative; }
|
||||
.chart-bar-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 200rpx; }
|
||||
.chart-bar { width: 100%; border-radius: 4rpx 4rpx 0 0; min-height: 4rpx; }
|
||||
.chart-bar-label { position: absolute; bottom: 0; font-size: 18rpx; color: #BCC2CE; }
|
||||
|
||||
// Monitor Grid
|
||||
.monitor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; margin-bottom: 24rpx; }
|
||||
.monitor-card { background: #fff; border-radius: 14rpx; padding: 28rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); text-align: center; position: relative; overflow: hidden;
|
||||
&::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4rpx; background: linear-gradient(90deg, transparent, #1677FF, transparent); opacity: 0.4; }
|
||||
}
|
||||
.mc-title { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mc-val { font-size: 48rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mc-trend { font-size: 20rpx; margin-top: 6rpx; display: block; }
|
||||
|
||||
// Breakdown
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); }
|
||||
.card-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 16rpx 0; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.ir-label { font-size: 24rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 24rpx; font-weight: 500; color: #0F1226; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<!--
|
||||
* 积分明细 — 对齐 C端.html #sub-points-log
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="积分明细" :show-back="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 积分Hero -->
|
||||
<view class="pts-hero">
|
||||
<view class="ph-top">
|
||||
<view class="ph-left">
|
||||
<text class="ph-label">可用兑换积分</text>
|
||||
<text class="ph-amount">¥{{ withdrawableAmount }}</text>
|
||||
<text class="ph-tip">1积分 = ¥1.00 · 最低兑换 ¥100</text>
|
||||
</view>
|
||||
<view class="ph-action">
|
||||
<view class="ph-btn" @click="openSub('withdraw')">兑换</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pts-grid">
|
||||
<view class="pts-cell" v-for="item in ptsSummary" :key="item.label">
|
||||
<view class="pc-label">
|
||||
<text class="pc-label-icon">{{ item.icon }}</text>
|
||||
<text>{{ item.label }}</text>
|
||||
</view>
|
||||
<text class="pc-val" :style="{ color: item.color }">{{ item.amount }}</text>
|
||||
<text class="pc-sub">{{ item.sub }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 积分状态卡片 -->
|
||||
<view class="status-card">
|
||||
<view class="status-header">
|
||||
<text class="status-title">积分状态</text>
|
||||
<text class="status-time">更新于 {{ updateTime }}</text>
|
||||
</view>
|
||||
<view class="status-row">
|
||||
<view class="status-item">
|
||||
<text class="si-val" style="color:#00C4A7">¥{{ statusCard.withdrawable }}</text>
|
||||
<text class="si-label">可兑换</text>
|
||||
</view>
|
||||
<view class="status-item">
|
||||
<text class="si-val" style="color:#FF8800">¥{{ statusCard.frozen }}</text>
|
||||
<text class="si-label">冻结中</text>
|
||||
</view>
|
||||
<view class="status-item">
|
||||
<text class="si-val" style="color:#6B7280">¥{{ statusCard.withdrawn }}</text>
|
||||
<text class="si-label">已兑换</text>
|
||||
</view>
|
||||
<view class="status-item">
|
||||
<text class="si-val" style="color:#FFD666">¥{{ statusCard.gift }}</text>
|
||||
<text class="si-label">活动赠送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分类Tab -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="t in tabs"
|
||||
:key="t.key"
|
||||
class="tab"
|
||||
:class="{ active: activeTab === t.key }"
|
||||
@click="switchTab(t.key)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 积分流水列表 -->
|
||||
<view class="list-card">
|
||||
<view v-if="allList.length > 0">
|
||||
<view class="pts-item" v-for="(item, i) in allList" :key="i">
|
||||
<view class="pts-icon" :style="{ background: item.iconBg }">
|
||||
<text class="pts-emoji">{{ item.icon }}</text>
|
||||
</view>
|
||||
<view class="pts-body">
|
||||
<text class="pts-title">{{ item.title }}</text>
|
||||
<text class="pts-sub">{{ item.sub }}</text>
|
||||
</view>
|
||||
<view class="pts-right">
|
||||
<text class="pts-amount" :style="{ color: item.amountColor }">{{ item.amount }}</text>
|
||||
<text class="pts-status" :style="{ background: item.statusBg, color: item.statusColor }">{{ item.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-text">暂无相关积分记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
|
||||
const activeTab = ref('all')
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'device_earning', label: '设备产出' },
|
||||
{ key: 'gift', label: '活动赠送' },
|
||||
{ key: 'withdraw', label: '提现' }
|
||||
]
|
||||
|
||||
const withdrawableAmount = ref(0)
|
||||
const updateTime = ref('--')
|
||||
const statusCard = ref({
|
||||
withdrawable: 0,
|
||||
frozen: 0,
|
||||
withdrawn: 0,
|
||||
gift: 0
|
||||
})
|
||||
|
||||
const ptsSummary = ref([
|
||||
{ icon: '💻', amount: '¥0', label: '设备产出积分', sub: '累计 · 产出积分', color: '#00C4A7' },
|
||||
{ icon: '⚡', amount: '¥0', label: '活动赠送积分', sub: '累计 · 不可兑换 · 限时', color: '#FFD666' },
|
||||
{ icon: '⏳', amount: '¥0', label: '冻结积分', sub: '兑换审核中 · 1-3工作日', color: '#FF8800' },
|
||||
{ icon: '💳', amount: '¥0', label: '已兑换积分', sub: '累计 · 已发放', color: '#6B7280' }
|
||||
])
|
||||
|
||||
const allList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const typeMap = {
|
||||
device_earning: { icon: '💻', iconBg: '#E6FAF5', amountColor: '#00C4A7', statusText: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
commission: { icon: '🤝', iconBg: '#E6F4FF', amountColor: '#00C4A7', statusText: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
gift: { icon: '🎁', iconBg: '#FFFBE6', amountColor: '#FFD666', statusText: '不可兑换', statusBg: '#FFFBE6', statusColor: '#FF8800' },
|
||||
withdraw: { icon: '💳', iconBg: '#F0F2F5', amountColor: '#6B7280', statusText: '已发放', statusBg: '#F0F2F5', statusColor: '#6B7280' },
|
||||
withdraw_refund: { icon: '↩️', iconBg: '#E6FAF5', amountColor: '#00C4A7', statusText: '已退回', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
frozen: { icon: '⏳', iconBg: '#FFF7E6', amountColor: '#FF8800', statusText: '审核中', statusBg: '#FFF7E6', statusColor: '#FF8800' },
|
||||
unfrozen: { icon: '🔓', iconBg: '#E6FAF5', amountColor: '#00C4A7', statusText: '已解冻', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
signin: { icon: '🎁', iconBg: '#FFFBE6', amountColor: '#FFD666', statusText: '已入账', statusBg: '#FFFBE6', statusColor: '#FF8800' },
|
||||
}
|
||||
|
||||
const withdrawalStatusMap = {
|
||||
pending: { statusText: '待审核', statusBg: '#FFF7E6', statusColor: '#FF8800' },
|
||||
approved: { statusText: '待打款', statusBg: '#E6F4FF', statusColor: '#1677FF' },
|
||||
paid: { statusText: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
rejected: { statusText: '已拒绝', statusBg: '#FFF1F0', statusColor: '#FF4D4F' },
|
||||
}
|
||||
|
||||
const switchTab = (key) => {
|
||||
activeTab.value = key
|
||||
fetchData(key, 1)
|
||||
}
|
||||
|
||||
const fetchData = async (type = 'all', pageNum = 1) => {
|
||||
try {
|
||||
// 并行请求:流水明细 + 积分汇总
|
||||
const [logRes, summaryRes] = await Promise.all([
|
||||
request.get('/c/user/revenue-points-log', { type, page: pageNum, showLoading: false }),
|
||||
request.get('/c/user/revenue-summary', { showLoading: false })
|
||||
])
|
||||
|
||||
// 流水列表
|
||||
const items = logRes.data.items || []
|
||||
const newList = items.map(item => {
|
||||
const tm = typeMap[item.type] || typeMap.device_earning
|
||||
const withdrawalStatus = ['withdraw', 'withdraw_refund'].includes(item.type)
|
||||
? withdrawalStatusMap[item.withdrawal_status] || (item.type === 'withdraw_refund' ? withdrawalStatusMap.rejected : null)
|
||||
: null
|
||||
return {
|
||||
...item,
|
||||
rtype: item.type,
|
||||
icon: tm.icon,
|
||||
iconBg: tm.iconBg,
|
||||
title: item.type_text || item.remark || '收益记录',
|
||||
// sub: (item.date || item.created_at || '') + (item.remark ? ' · ' + item.remark : ''),
|
||||
sub: item.date,
|
||||
amount: '¥' + item.points,
|
||||
amountColor: item.points >= 0 ? tm.amountColor : '#FF4D4F',
|
||||
status: withdrawalStatus?.statusText || tm.statusText,
|
||||
statusBg: withdrawalStatus?.statusBg || tm.statusBg,
|
||||
statusColor: withdrawalStatus?.statusColor || tm.statusColor
|
||||
}
|
||||
})
|
||||
allList.value = pageNum === 1 ? newList : allList.value.concat(newList)
|
||||
page.value = pageNum
|
||||
hasMore.value = logRes.data.current_page < logRes.data.last_page
|
||||
|
||||
// 底部4格汇总(来自流水接口)
|
||||
const s = logRes.data.summary || {}
|
||||
const fmt = (v) => '¥' + v
|
||||
ptsSummary.value = [
|
||||
{ icon: '💻', amount: fmt(s.device_earning || 0), label: '设备产出积分', sub: '累计 · 产出积分', color: '#00C4A7' },
|
||||
{ icon: '⚡', amount: fmt(s.gift || 0), label: '活动赠送积分', sub: '累计 · 不可兑换 · 限时', color: '#FFD666' },
|
||||
{ icon: '⏳', amount: fmt(s.frozen || 0), label: '冻结积分', sub: '兑换审核中 · 1-3工作日', color: '#FF8800' },
|
||||
{ icon: '💳', amount: fmt(s.withdraw || 0), label: '已兑换积分', sub: '累计 · 已发放', color: '#6B7280' }
|
||||
]
|
||||
|
||||
// Hero 可用兑换 + 状态卡片(来自汇总接口)
|
||||
if (summaryRes.data) {
|
||||
const d = summaryRes.data
|
||||
withdrawableAmount.value = d.withdrawable_points || 0
|
||||
statusCard.value = {
|
||||
withdrawable: d.withdrawable_points || 0,
|
||||
frozen: d.frozen_points || 0,
|
||||
withdrawn: s.withdraw || 0,
|
||||
gift: d.gift_points || 0
|
||||
}
|
||||
// 4格汇总(来自流水接口,无数据则用默认值)
|
||||
// 更新时间
|
||||
const now = new Date()
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
updateTime.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchData(activeTab.value, page.value + 1)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => { fetchData('all', 1) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 40rpx);
|
||||
}
|
||||
|
||||
/* Hero 区域 */
|
||||
.pts-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 14rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 48rpx rgba(13, 43, 110, 0.25);
|
||||
&::after { content: ''; position: absolute; top: -60rpx; right: -40rpx; width: 280rpx; height: 280rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.1), transparent 65%); }
|
||||
}
|
||||
.ph-top { display: flex; justify-content: space-between; align-items: flex-start; position: relative; z-index: 1; margin-bottom: 24rpx; }
|
||||
.ph-left { flex: 1; }
|
||||
.ph-label { font-size: 24rpx; opacity: 0.65; display: block; }
|
||||
.ph-amount { font-size: 64rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; margin: 4rpx 0; background: linear-gradient(180deg, #fff, #E6F4FF); -webkit-background-clip: text; background-clip: text; }
|
||||
.ph-tip { font-size: 20rpx; opacity: 0.5; display: block; }
|
||||
.ph-action { flex-shrink: 0; }
|
||||
.ph-btn {
|
||||
background: #fff;
|
||||
color: #1677FF;
|
||||
padding: 14rpx 32rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
.pts-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; position: relative; z-index: 1; }
|
||||
.pts-cell { background: rgba(255,255,255,0.08); border-radius: 10rpx; padding: 16rpx 8rpx; text-align: center; border: 1px solid rgba(255,255,255,0.06); }
|
||||
.pc-label { font-size: 18rpx; opacity: 0.7; display: flex; align-items: center; justify-content: center; gap: 4rpx; margin-bottom: 4rpx; }
|
||||
.pc-label-icon { font-size: 16rpx; line-height: 1; }
|
||||
.pc-val { font-size: 24rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; margin-bottom: 2rpx; }
|
||||
.pc-sub { font-size: 16rpx; opacity: 0.5; display: block; line-height: 1.3; }
|
||||
|
||||
/* 状态卡片 */
|
||||
.status-card { background: #fff; border-radius: 14rpx; padding: 24rpx; margin-bottom: 20rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.status-title { font-size: 26rpx; font-weight: 600; color: #0F1226; }
|
||||
.status-time { font-size: 20rpx; color: #BCC2CE; }
|
||||
.status-row { display: flex; }
|
||||
.status-item { flex: 1; text-align: center; }
|
||||
.si-val { font-size: 28rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; margin-bottom: 4rpx; }
|
||||
.si-label { font-size: 20rpx; color: #8B91A1; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 0.5px solid #EAEDF3; margin-bottom: 20rpx; }
|
||||
.tab {
|
||||
flex: 1; text-align: center; padding: 22rpx 0; font-size: 26rpx; font-weight: 500;
|
||||
color: #8B91A1; position: relative; transition: color 0.2s;
|
||||
&.active {
|
||||
color: #1677FF; font-weight: 600;
|
||||
&::after { content: ''; position: absolute; bottom: -0.5px; left: 50%; transform: translateX(-50%); width: 44rpx; height: 5rpx; background: linear-gradient(90deg, #4096FF, #1677FF); border-radius: 2px; box-shadow: 0 0 12rpx rgba(22, 119, 255, 0.4); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表 */
|
||||
.list-card { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); margin-bottom: 24rpx; }
|
||||
.pts-item { display: flex; align-items: center; gap: 20rpx; padding: 24rpx; border-bottom: 0.5px solid #F3F5F9; &:last-child { border-bottom: none; } }
|
||||
.pts-icon { width: 72rpx; height: 72rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.pts-emoji { font-size: 32rpx; line-height: 1; }
|
||||
.pts-body { flex: 1; min-width: 0; }
|
||||
.pts-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.pts-sub { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.pts-right { text-align: right; flex-shrink: 0; }
|
||||
.pts-amount { font-size: 30rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.pts-status { font-size: 18rpx; padding: 2rpx 12rpx; border-radius: 16rpx; display: inline-block; margin-top: 8rpx; }
|
||||
|
||||
.empty-state { padding: 80rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
.load-more { padding: 8rpx 0 32rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<!--
|
||||
* 积分兑换 — 对齐 C端.html
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="积分兑换" :show-back="true" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 积分余额Hero -->
|
||||
<view class="balance-hero">
|
||||
<text class="bh-label">可兑换积分余额</text>
|
||||
<text class="bh-amount">¥{{ formatThousands(balance) }}</text>
|
||||
<text class="bh-rate">1积分 = ¥1.00 · 服务费{{ feeRatePercent }}% · 最低{{ minAmount }}积分起提</text>
|
||||
<view class="bh-all-btn" @click="withdrawAll">全部兑换</view>
|
||||
</view>
|
||||
|
||||
<!-- 兑换表单 -->
|
||||
<view class="form-card">
|
||||
<!-- 金额输入 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">兑换金额</text>
|
||||
<view class="amount-input-wrap">
|
||||
<text class="ai-symbol">¥</text>
|
||||
<input
|
||||
type="number"
|
||||
class="ai-input"
|
||||
v-model="withdrawAmount"
|
||||
maxlength="10"
|
||||
@input="onAmountInput"
|
||||
placeholder="请输入兑换金额(最低100)"
|
||||
placeholder-style="color: #BCC2CE; font-weight: 400;"
|
||||
/>
|
||||
</view>
|
||||
<view class="amount-quick">
|
||||
<text
|
||||
v-for="q in quickAmounts"
|
||||
:key="q"
|
||||
class="aq-chip"
|
||||
:class="{ active: Number(withdrawAmount) === q }"
|
||||
@click="setQuickAmount(q)"
|
||||
>{{ q === 'all' ? '全部' : '¥' + formatThousands(q) }}</text>
|
||||
</view>
|
||||
<text v-if="errorMsg" class="error-tip">{{ errorMsg }}</text>
|
||||
<text v-else class="amount-tip">当前可兑换积分余额 ¥{{ formatThousands(balance) }},预计1-3个工作日发放</text>
|
||||
</view>
|
||||
|
||||
<!-- 收款账户 -->
|
||||
<view class="form-item">
|
||||
<text class="form-label">收款账户</text>
|
||||
<picker :range="accountTypes" :value="selectedAccountType" @change="onAccountTypeChange" class="form-picker">
|
||||
<view class="picker-display">
|
||||
<text>{{ accountTypes[selectedAccountType] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="account-card" v-if="walletCard.bank_name">
|
||||
<text class="ac-bank">{{ walletCard.bank_name }}</text>
|
||||
<text class="ac-card">{{ walletCard.bank_card }}</text>
|
||||
<text class="ac-name">{{ walletCard.account_name }}</text>
|
||||
<text class="ac-edit" @click="openSub('wallet')">修改 ›</text>
|
||||
</view>
|
||||
<view class="account-empty" v-else @click="openSub('wallet')">
|
||||
<text class="ae-text">尚未绑定{{ accountTypes[selectedAccountType] }},点击设置 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 费用预估 -->
|
||||
<view class="fee-card">
|
||||
<view class="fee-row">
|
||||
<text class="fee-label">兑换金额</text>
|
||||
<text class="fee-val">¥{{ formatThousands(Number(withdrawAmount) || 0) }}</text>
|
||||
</view>
|
||||
<view class="fee-row">
|
||||
<text class="fee-label">服务费({{ feeRatePercent }}%)</text>
|
||||
<text class="fee-val">-¥{{ formatMoney(calcFee()) }}</text>
|
||||
</view>
|
||||
<view class="fee-divider"></view>
|
||||
<view class="fee-row total">
|
||||
<text class="fee-label">实际发放金额</text>
|
||||
<text class="fee-val total">¥{{ formatThousands(calcActual()) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<view class="submit-btn" :class="{ disabled: !canSubmit }" @click="handleWithdraw">
|
||||
<text>确认兑换</text>
|
||||
</view>
|
||||
|
||||
<!-- 兑换记录预览 -->
|
||||
<view class="record-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近兑换记录</text>
|
||||
<!-- <text class="section-link" @click="openSub('points-log')">查看全部 ›</text> -->
|
||||
</view>
|
||||
<view class="record-list">
|
||||
<view class="record-item" v-for="(record, i) in records" :key="i">
|
||||
<view class="ri-left">
|
||||
<text class="ri-amount">-¥{{ formatThousands(record.amount) }}</text>
|
||||
<text class="ri-time">{{ record.time }}</text>
|
||||
</view>
|
||||
<view class="ri-status" :style="{ color: record.statusColor, background: record.statusBg }">
|
||||
{{ record.statusLabel }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const balance = ref(0)
|
||||
const withdrawAmount = ref('')
|
||||
const errorMsg = ref('')
|
||||
const selectedAccountType = ref(0)
|
||||
const walletData = ref({ personal: {}, company: {} })
|
||||
const minAmount = ref(100)
|
||||
const feeRate = ref(0.005)
|
||||
const feeRatePercent = computed(() => Number((feeRate.value * 100).toFixed(10)))
|
||||
const records = ref([])
|
||||
const submitting = ref(false)
|
||||
const recordPage = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: '待审核', color: '#FF8800', bg: '#FFF7E6' },
|
||||
approved: { label: '已通过', color: '#1677FF', bg: '#E6F4FF' },
|
||||
paid: { label: '已发放', color: '#00C4A7', bg: '#E6FAF5' },
|
||||
rejected: { label: '已拒绝', color: '#FF4D4F', bg: '#FFF1F0' }
|
||||
}
|
||||
|
||||
const quickAmounts = [1000, 5000, 10000, 'all']
|
||||
|
||||
const accountTypes = ['个人收款账户', '公户收款账户']
|
||||
|
||||
const fetchRecords = async (pageNum = 1) => {
|
||||
const res = await request.get('/c/withdraw/records', { page: pageNum, showLoading: false })
|
||||
const items = (res.data?.items || []).map(r => ({
|
||||
...r,
|
||||
statusLabel: statusMap[r.status]?.label || r.status,
|
||||
statusColor: statusMap[r.status]?.color || '#6B7280',
|
||||
statusBg: statusMap[r.status]?.bg || '#F0F2F5',
|
||||
time: r.created_at || ''
|
||||
}))
|
||||
records.value = pageNum === 1 ? items : records.value.concat(items)
|
||||
recordPage.value = res.data?.current_page || pageNum
|
||||
hasMore.value = recordPage.value < (res.data?.last_page || 1)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchRecords(recordPage.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
const onAccountTypeChange = (e) => {
|
||||
selectedAccountType.value = Number(e.detail.value)
|
||||
}
|
||||
|
||||
const walletCard = computed(() => {
|
||||
const w = walletData.value
|
||||
if (selectedAccountType.value === 0) {
|
||||
// 个人收款账户
|
||||
return {
|
||||
bank_name: w.personal?.bank_name || '',
|
||||
bank_card: w.personal?.bank_card || '',
|
||||
account_name: w.personal?.name || '',
|
||||
}
|
||||
}
|
||||
// 公户收款账户
|
||||
return {
|
||||
bank_name: w.company?.bank_name || '',
|
||||
bank_card: w.company?.bank_card || '',
|
||||
account_name: w.company?.name || '',
|
||||
}
|
||||
})
|
||||
|
||||
const fetchWallet = async () => {
|
||||
const res = await request.get('/c/wallet', { showLoading: false })
|
||||
if (res.data) {
|
||||
walletData.value = res.data
|
||||
}
|
||||
}
|
||||
|
||||
onShow(async () => {
|
||||
try { await fetchWallet() } catch (e) { /* 忽略 */ }
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [revRes, cfgRes] = await Promise.all([
|
||||
request.get('/c/user/revenue-summary', { showLoading: false }),
|
||||
request.get('/c/withdraw/config', { showLoading: false }),
|
||||
fetchRecords(1),
|
||||
])
|
||||
balance.value = Number(revRes.data?.withdrawable_points) || 0
|
||||
if (cfgRes.data) {
|
||||
minAmount.value = cfgRes.data.min_amount || 100
|
||||
feeRate.value = (cfgRes.data.fee_rate || 0.5) / 100
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const amount = Number(withdrawAmount.value)
|
||||
const card = walletCard.value
|
||||
return !isNaN(amount) && amount >= minAmount.value && amount <= balance.value && amount > 0
|
||||
&& !errorMsg.value && !submitting.value
|
||||
&& card.bank_name && card.bank_card && card.account_name
|
||||
})
|
||||
|
||||
const setQuickAmount = (q) => {
|
||||
if (q === 'all') {
|
||||
withdrawAmount.value = String(Math.floor(balance.value))
|
||||
} else {
|
||||
withdrawAmount.value = q.toString()
|
||||
}
|
||||
validateAmount()
|
||||
}
|
||||
|
||||
const withdrawAll = () => {
|
||||
withdrawAmount.value = String(Math.floor(balance.value))
|
||||
validateAmount()
|
||||
}
|
||||
|
||||
const onAmountInput = (e) => {
|
||||
let val = e.detail.value.replace(/\D/g, '').slice(0, 10)
|
||||
val = val.replace(/^0+(?=\d)/, '')
|
||||
withdrawAmount.value = val
|
||||
validateAmount()
|
||||
return val
|
||||
}
|
||||
|
||||
const validateAmount = () => {
|
||||
const amount = Number(withdrawAmount.value)
|
||||
if (!withdrawAmount.value) { errorMsg.value = ''; return }
|
||||
if (isNaN(amount) || amount <= 0) { errorMsg.value = '请输入有效的兑换金额'; return }
|
||||
if (amount < minAmount.value) { errorMsg.value = `最低兑换金额为${minAmount.value}积分`; return }
|
||||
if (amount > balance.value) { errorMsg.value = '兑换金额不能超过可用积分余额'; return }
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
const calcFee = () => {
|
||||
const amount = Number(withdrawAmount.value) || 0
|
||||
return Math.max(Math.round(amount * feeRate.value * 100) / 100, amount > 0 ? 0.1 : 0)
|
||||
}
|
||||
|
||||
const calcActual = () => {
|
||||
const actual = Math.max((Number(withdrawAmount.value) || 0) - calcFee(), 0)
|
||||
return Math.round((actual + Number.EPSILON) * 100) / 100
|
||||
}
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
validateAmount()
|
||||
if (!canSubmit.value) {
|
||||
if (errorMsg.value) uni.showToast({ title: errorMsg.value, icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.showModal({
|
||||
title: '确认兑换',
|
||||
content: `确认兑换 ¥${formatThousands(Number(withdrawAmount.value))},实际发放 ¥${formatThousands(calcActual())}?`,
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const card = walletCard.value
|
||||
await request.post('/c/withdraw', {
|
||||
amount: Number(withdrawAmount.value),
|
||||
bank_name: card.bank_name,
|
||||
bank_account: card.bank_card,
|
||||
account_name: card.account_name,
|
||||
account_type: selectedAccountType.value === 0 ? 'personal' : 'company',
|
||||
}, { showLoading: false })
|
||||
uni.showToast({ title: '兑换申请已提交', icon: 'success' })
|
||||
setTimeout(() => { uni.navigateBack() }, 1500)
|
||||
} catch (e) { /* 422错误已在拦截器处理 */ }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.safe-bottom {
|
||||
height: calc(env(safe-area-inset-bottom) + 40rpx);
|
||||
}
|
||||
|
||||
/* 积分余额Hero */
|
||||
.balance-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 14rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 48rpx rgba(13, 43, 110, 0.25);
|
||||
&::after { content: ''; position: absolute; top: -60rpx; right: -40rpx; width: 280rpx; height: 280rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.1), transparent 65%); }
|
||||
}
|
||||
.bh-label { font-size: 24rpx; opacity: 0.7; position: relative; z-index: 1; display: block; }
|
||||
.bh-amount { font-size: 64rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; position: relative; z-index: 1; display: block; margin: 8rpx 0; background: linear-gradient(180deg, #fff, #E6F4FF); -webkit-background-clip: text; background-clip: text; }
|
||||
.bh-rate { font-size: 20rpx; opacity: 0.6; position: relative; z-index: 1; display: block; }
|
||||
.bh-all-btn {
|
||||
position: absolute; right: 28rpx; top: 32rpx; z-index: 1;
|
||||
background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.25);
|
||||
padding: 8rpx 20rpx; border-radius: 36rpx; font-size: 22rpx; backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 表单卡片 */
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
}
|
||||
.form-item { margin-bottom: 28rpx; }
|
||||
.form-label { font-size: 26rpx; font-weight: 500; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
/* 金额输入 */
|
||||
.amount-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 16rpx;
|
||||
border-bottom: 2rpx solid #EAEDF3;
|
||||
}
|
||||
.ai-symbol { font-size: 44rpx; font-weight: 700; color: #0F1226; margin-right: 12rpx; }
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
height: 72rpx;
|
||||
line-height: 72rpx;
|
||||
}
|
||||
.amount-quick { display: flex; gap: 16rpx; margin: 24rpx 0 12rpx; flex-wrap: wrap; }
|
||||
.aq-chip {
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #F3F5F9;
|
||||
font-size: 24rpx;
|
||||
color: #5A6072;
|
||||
&.active { background: #E6F4FF; color: #1677FF; font-weight: 600; }
|
||||
}
|
||||
.error-tip { font-size: 22rpx; color: #FF4D4F; display: block; margin-top: 8rpx; }
|
||||
.amount-tip { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 8rpx; }
|
||||
|
||||
/* 兑换方式 picker */
|
||||
.form-picker { width: 100%; display: block; margin-bottom: 16rpx; }
|
||||
.picker-display {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #F7F9FC; border: 1px solid #EAEDF3; border-radius: 10rpx;
|
||||
padding: 22rpx 24rpx; font-size: 26rpx; color: #0F1226;
|
||||
}
|
||||
.picker-arrow { font-size: 18rpx; color: #BCC2CE; }
|
||||
|
||||
/* 收款账户卡片 */
|
||||
.account-card {
|
||||
background: #F7F9FC; border: 1px solid #EAEDF3; border-radius: 10rpx;
|
||||
padding: 24rpx; position: relative;
|
||||
}
|
||||
.ac-bank { font-size: 24rpx; color: #5A6072; display: block; }
|
||||
.ac-card { font-size: 30rpx; font-weight: 600; color: #0F1226; display: block; margin: 8rpx 0; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
|
||||
.ac-name { font-size: 24rpx; color: #8B91A1; display: block; }
|
||||
.ac-edit { position: absolute; right: 24rpx; top: 50%; transform: translateY(-50%); font-size: 24rpx; color: #1677FF; }
|
||||
.account-empty {
|
||||
background: #F7F9FC; border: 2rpx dashed #D0D5DD; border-radius: 10rpx;
|
||||
padding: 32rpx; text-align: center;
|
||||
}
|
||||
.ae-text { font-size: 26rpx; color: #1677FF; }
|
||||
|
||||
/* 费用卡片 */
|
||||
.fee-card {
|
||||
background: #FFF7E6;
|
||||
border-radius: 10rpx;
|
||||
padding: 20rpx;
|
||||
border: 1rpx solid #FFE0B0;
|
||||
}
|
||||
.fee-row { display: flex; justify-content: space-between; align-items: center; padding: 8rpx 0; }
|
||||
.fee-row.total { padding-top: 16rpx; }
|
||||
.fee-label { font-size: 24rpx; color: #5A6072; }
|
||||
.fee-val { font-size: 24rpx; color: #0F1226; font-weight: 500;
|
||||
&.total { font-size: 32rpx; color: #00C4A7; font-weight: 700; }
|
||||
}
|
||||
.fee-divider { height: 1rpx; background: #FFE0B0; margin: 8rpx 0; }
|
||||
|
||||
/* 提交按钮 */
|
||||
.submit-btn {
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #1677FF, #0958D9);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6rpx 20px rgba(22, 119, 255, 0.25);
|
||||
&.disabled { background: #BCC2CE; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* 兑换记录 */
|
||||
.record-section { margin-bottom: 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; padding: 0 4rpx; }
|
||||
.section-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.section-link { font-size: 22rpx; color: #1677FF; }
|
||||
.load-more { text-align: center; padding: 24rpx 0 8rpx; font-size: 24rpx; color: #1677FF; }
|
||||
.record-list { background: #fff; border-radius: 14rpx; padding: 0 24rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.record-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 0; border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.ri-amount { font-size: 28rpx; font-weight: 600; color: #0F1226; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.ri-time { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.ri-status { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<!--
|
||||
* 合同预览
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="合同预览" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="contract-card">
|
||||
<text class="cc-title">{{ contract.title }}</text>
|
||||
<text class="cc-number">合同编号:{{ contract.number }}</text>
|
||||
|
||||
<view class="cc-parties">
|
||||
<view class="cp-item">
|
||||
<text class="cp-label">甲方</text>
|
||||
<text class="cp-value">{{ contract.partyA }}</text>
|
||||
</view>
|
||||
<view class="cp-item">
|
||||
<text class="cp-label">乙方</text>
|
||||
<text class="cp-value">{{ contract.partyB }}</text>
|
||||
</view>
|
||||
<view class="cp-item">
|
||||
<text class="cp-label">签订日期</text>
|
||||
<text class="cp-value">{{ contract.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="cc-divider"></view>
|
||||
|
||||
<view class="cc-clauses">
|
||||
<text class="cc-section-title">合同条款</text>
|
||||
<view v-for="(cl, i) in clauses" :key="i" class="clause-item">
|
||||
<text class="cl-title">{{ cl.title }}</text>
|
||||
<text class="cl-body">{{ cl.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Risk Notice -->
|
||||
<view class="risk-notice">
|
||||
<view class="rn-header">
|
||||
<text class="rn-icon">!</text>
|
||||
<text class="rn-title">风险提示</text>
|
||||
</view>
|
||||
<text class="rn-desc">
|
||||
请仔细阅读合同条款,特别是关于付款方式、违约责任、争议解决等条款。如有疑问,请咨询平台客服或法律顾问后再签署。
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-bar" v-if="contractStatus === 'pending'">
|
||||
<view class="ab-btn ab-btn--outline" @click="closeSub()">返回</view>
|
||||
<view class="ab-btn ab-btn--primary" @click="handleSign">确认签署</view>
|
||||
</view>
|
||||
<view class="signed-badge" v-else-if="contractStatus === 'signed'">
|
||||
<text class="sb-icon">✓</text>
|
||||
<text class="sb-text">该合同已签署</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const contractStatus = ref('')
|
||||
const contractId = ref('')
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
contractId.value = params.id
|
||||
// TODO: 对接接口 /api/user/contracts/{id}
|
||||
}
|
||||
if (params.status) {
|
||||
contractStatus.value = params.status
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const contract = ref({
|
||||
title: '设备购买合同',
|
||||
number: 'QH-CG-2026-001',
|
||||
partyA: '全犀云(甲方)',
|
||||
partyB: '深圳智算科技有限公司(乙方)',
|
||||
date: '2026-07-01'
|
||||
})
|
||||
|
||||
const clauses = [
|
||||
{
|
||||
title: '第一条 合同标的',
|
||||
content: '乙方向甲方购买全犀云边缘计算设备QH-500 Edge,数量10台,单价人民币15,000元/台。'
|
||||
},
|
||||
{
|
||||
title: '第二条 付款方式',
|
||||
content: '合同签订后3个工作日内支付合同总金额的50%作为预付款;设备到货验收合格后5个工作日内支付剩余50%。'
|
||||
},
|
||||
{
|
||||
title: '第三条 交付与验收',
|
||||
content: '甲方应在收到预付款后15个工作日内完成发货。乙方应在收到货物后7个工作日内完成验收。'
|
||||
},
|
||||
{
|
||||
title: '第四条 售后服务',
|
||||
content: '设备质保期为验收合格后12个月。质保期内因产品本身质量问题导致的故障,甲方免费维修或更换。'
|
||||
},
|
||||
{
|
||||
title: '第五条 违约责任',
|
||||
content: '任何一方违反本合同约定,应向守约方支付合同总金额20%的违约金。因违约造成的损失超过违约金的,还应赔偿差额部分。'
|
||||
}
|
||||
]
|
||||
|
||||
const handleSign = () => {
|
||||
uni.showModal({
|
||||
title: '确认签署',
|
||||
content: '请确认已仔细阅读并同意以上合同条款',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const apiRes = await request.put('/c/contracts/' + contractId.value + '/sign', {}, { showLoading: true })
|
||||
contractStatus.value = 'signed'
|
||||
uni.showToast({ title: apiRes.message || '签署成功', icon: 'success' })
|
||||
setTimeout(() => closeSub(), 1500)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '签署失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
min-height: 100vh;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.page-content {
|
||||
height: calc(100vh - 44px);
|
||||
padding: 20rpx 30rpx calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.contract-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.cc-title { font-size: 34rpx; font-weight: 700; color: #333; display: block; text-align: center; margin-bottom: 12rpx; }
|
||||
.cc-number { font-size: 24rpx; color: #999; display: block; text-align: center; margin-bottom: 30rpx; }
|
||||
|
||||
.cc-parties {
|
||||
.cp-item {
|
||||
display: flex;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.cp-label { width: 140rpx; font-size: 26rpx; color: #999; }
|
||||
.cp-value { flex: 1; font-size: 26rpx; color: #333; }
|
||||
}
|
||||
|
||||
.cc-divider { height: 1rpx; background: #e5e7eb; margin: 24rpx 0; }
|
||||
|
||||
.cc-section-title { font-size: 30rpx; font-weight: 600; color: #333; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
.clause-item {
|
||||
margin-bottom: 24rpx;
|
||||
.cl-title { font-size: 28rpx; font-weight: 500; color: #333; display: block; margin-bottom: 8rpx; }
|
||||
.cl-body { font-size: 26rpx; color: #666; line-height: 1.8; }
|
||||
}
|
||||
|
||||
.risk-notice {
|
||||
background: #fef3c7;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-top: 20rpx;
|
||||
.rn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.rn-icon {
|
||||
width: 36rpx; height: 36rpx; border-radius: 18rpx;
|
||||
background: #f59e0b; color: #fff;
|
||||
text-align: center; line-height: 36rpx;
|
||||
font-size: 22rpx; font-weight: 700;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
.rn-title { font-size: 28rpx; font-weight: 500; color: #92400e; }
|
||||
.rn-desc { font-size: 24rpx; color: #a16207; line-height: 1.7; }
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 20rpx 30rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
|
||||
.ab-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
border-radius: 44rpx;
|
||||
font-weight: 500;
|
||||
&--outline {
|
||||
border: 2rpx solid #e5e7eb;
|
||||
color: #666;
|
||||
}
|
||||
&--primary {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.signed-badge {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex; align-items: center; justify-content: center; gap: 12rpx;
|
||||
padding: 24rpx 30rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05);
|
||||
}
|
||||
.sb-icon {
|
||||
width: 40rpx; height: 40rpx; border-radius: 50%;
|
||||
background: #00C4A7; color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22rpx; font-weight: 700;
|
||||
}
|
||||
.sb-text { font-size: 28rpx; color: #059669; font-weight: 500; }
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
* 合同管理
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="合同管理" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<view class="page-content">
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="tab-item"
|
||||
:class="{ active: currentTab === i }" @click="currentTab = i">
|
||||
<text class="ti-text">{{ tab }}</text>
|
||||
<view class="ti-line" v-if="currentTab === i"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="list-scroll">
|
||||
<view v-for="(c, i) in filteredContracts" :key="i" class="contract-card" @click="goPreview(c)">
|
||||
<view class="cc-header">
|
||||
<text class="cc-title">{{ c.title }}</text>
|
||||
<text class="cc-status" :class="c.statusClass">{{ c.statusText }}</text>
|
||||
</view>
|
||||
<text class="cc-number">合同编号:{{ c.number }}</text>
|
||||
<view class="cc-meta">
|
||||
<text class="cc-date">{{ c.date }}</text>
|
||||
<text class="cc-amount">{{ formatMoney(c.amount) }}元</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-state" v-if="filteredContracts.length === 0">
|
||||
<text class="es-text">暂无合同</text>
|
||||
</view>
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney } from '@/utils/util'
|
||||
|
||||
const tabs = ['全部', '待签署', '已签署']
|
||||
const currentTab = ref(0)
|
||||
const contracts = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const statusMap = {
|
||||
signed: { text: '已签署', cls: 'signed' },
|
||||
pending: { text: '待签署', cls: 'pending' },
|
||||
expired: { text: '已过期', cls: 'expired' },
|
||||
terminated: { text: '已终止', cls: 'terminated' },
|
||||
}
|
||||
|
||||
const fetchContracts = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/contracts', { page: pageNum, showLoading: false })
|
||||
const items = (res.data?.items || []).map(c => ({
|
||||
...c,
|
||||
number: c.contract_no,
|
||||
statusText: c.status_text || (statusMap[c.status]?.text || c.status),
|
||||
statusClass: statusMap[c.status]?.cls || '',
|
||||
date: c.sign_date || ''
|
||||
}))
|
||||
contracts.value = pageNum === 1 ? items : contracts.value.concat(items)
|
||||
page.value = res.data?.current_page || pageNum
|
||||
hasMore.value = page.value < (res.data?.last_page || 1)
|
||||
} catch (e) {
|
||||
contracts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore.value || loadingMore.value) return
|
||||
loadingMore.value = true
|
||||
try { await fetchContracts(page.value + 1) } finally { loadingMore.value = false }
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
fetchContracts(1)
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const filteredContracts = computed(() => {
|
||||
if (currentTab.value === 0) return contracts.value
|
||||
if (currentTab.value === 1) return contracts.value.filter(c => c.status === 'pending')
|
||||
if (currentTab.value === 2) return contracts.value.filter(c => c.status === 'signed')
|
||||
return []
|
||||
})
|
||||
|
||||
const goPreview = (item) => {
|
||||
openSub('contract-preview', { id: item.id, status: item.status })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 整个页面满屏,开启 Flex 纵向布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
.load-more { text-align: center; padding: 24rpx 0 40rpx; font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
/* 主内容区自动填满导航栏下方的所有剩余空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部 Tab 恢复为自然文档流,取消 fixed 定位 */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
padding: 0 30rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
position: relative;
|
||||
}
|
||||
.ti-text { font-size: 28rpx; color: #666; }
|
||||
.active .ti-text { color: #3b82f6; font-weight: 600; }
|
||||
.ti-line {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #3b82f6;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表容器自适应剩余高度,并添加四周 24rpx 内边距 */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
height: 0; /* 配合 flex: 1 解决 scroll-view 高度计算问题 */
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contract-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.cc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.cc-title { font-size: 30rpx; font-weight: 600; color: #333; }
|
||||
.cc-status {
|
||||
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
&.signed { background: #d1fae5; color: #059669; }
|
||||
&.pending { background: #fef3c7; color: #d97706; }
|
||||
}
|
||||
.cc-number { font-size: 24rpx; color: #999; display: block; margin-bottom: 16rpx; }
|
||||
.cc-meta { display: flex; justify-content: space-between; }
|
||||
.cc-date { font-size: 24rpx; color: #999; }
|
||||
.cc-amount { font-size: 26rpx; color: #ef4444; font-weight: 500; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
.es-text { font-size: 28rpx; color: #ccc; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<!--
|
||||
* 帮助中心
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="帮助中心" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- 1. 顶部搜索框:作为 Flex 子项自适应,取消定位偏移 -->
|
||||
<!-- <view class="search-bar">
|
||||
<text class="sb-icon">🔍</text>
|
||||
<input class="sb-input" v-model="searchQuery" placeholder="搜索问题..." @confirm="handleSearch" />
|
||||
</view> -->
|
||||
|
||||
<!-- 2. 列表内容区域:弹性占满剩余空间 -->
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Quick Categories -->
|
||||
<view class="quick-grid">
|
||||
<view v-for="(cat, i) in categories" :key="i" class="qg-item" @click="goCategory(cat)">
|
||||
<view class="qg-icon" :style="{ background: cat.bg }">
|
||||
<text class="qg-emoji">{{ cat.icon }}</text>
|
||||
</view>
|
||||
<text class="qg-label">{{ cat.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- FAQ -->
|
||||
<view class="faq-card">
|
||||
<text class="section-title">常见问题</text>
|
||||
<view v-for="(faq, i) in faqList" :key="i" class="faq-item" @click="toggleFaq(i)">
|
||||
<view class="fi-header">
|
||||
<text class="fi-q">{{ faq.q }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="fi-body" v-if="faq.open">
|
||||
<text class="fi-a">{{ faq.a }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Contact -->
|
||||
<!-- <view class="contact-card">
|
||||
<text class="cc-title">没有找到答案?</text>
|
||||
<text class="cc-desc">联系在线客服,我们将尽快为您解答</text>
|
||||
<view class="cc-btn" @click="contactSupport">在线客服</view>
|
||||
</view> -->
|
||||
|
||||
</scroll-view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/help/categories
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const categories = [
|
||||
{ icon: '🖥', label: '设备相关', bg: '#dbeafe', key: 'device' },
|
||||
{ icon: '💰', label: '收益问题', bg: '#d1fae5', key: 'revenue' },
|
||||
{ icon: '🏦', label: '兑换问题', bg: '#fef3c7', key: 'withdraw' },
|
||||
{ icon: '🌐', label: '网络问题', bg: '#ede9fe', key: 'network' }
|
||||
]
|
||||
|
||||
const faqList = ref([
|
||||
{
|
||||
q: '如何绑定设备?',
|
||||
a: '购买设备后,在"我的设备"页面点击"添加设备",扫描设备上的二维码即可完成绑定。绑定前请确保设备已通电并连接网络。',
|
||||
open: false
|
||||
},
|
||||
{
|
||||
q: '收益如何计算?',
|
||||
a: '收益根据设备的在线时长、带宽贡献和节点质量综合计算。系统每天凌晨统计前一天的收益数据并计入账户积分余额。',
|
||||
open: false
|
||||
},
|
||||
{
|
||||
q: '兑换多久发放?',
|
||||
a: '兑换申请提交后,平台将在1-3个工作日内处理。兑换金额将转入您绑定的支付宝或收款账户账户。首次兑换需完成实名认证。',
|
||||
open: false
|
||||
},
|
||||
{
|
||||
q: '设备需要24小时在线吗?',
|
||||
a: '建议保持设备24小时在线以获得稳定收益。设备离线期间不会产生收益。如设备需要关机,建议在操作前先通过APP停止任务。',
|
||||
open: false
|
||||
},
|
||||
{
|
||||
q: '如何升级用户等级?',
|
||||
a: '用户等级根据活跃度自动升级。活跃度通过自身消费和直接推荐C端消费累积。等级越高,享受的权益越多,包括收益加成、直接推荐奖励等。',
|
||||
open: false
|
||||
}
|
||||
])
|
||||
|
||||
const handleSearch = () => {
|
||||
// TODO: 对接搜索接口 /api/help/search
|
||||
if (searchQuery.value.trim()) {
|
||||
uni.showToast({ title: '搜索: ' + searchQuery.value, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const goCategory = (cat) => {
|
||||
openSub('docs', { category: cat.key })
|
||||
}
|
||||
|
||||
const toggleFaq = (i) => {
|
||||
faqList.value[i].open = !faqList.value[i].open
|
||||
}
|
||||
|
||||
const contactSupport = () => {
|
||||
// TODO: 对接客服接口
|
||||
uni.showToast({ title: '正在连接客服...', icon: 'none' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全屏垂直 Flex 布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部搜索栏组件 */
|
||||
.search-bar {
|
||||
margin: 20rpx 24rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 44rpx;
|
||||
padding: 0 30rpx;
|
||||
height: 72rpx;
|
||||
flex-shrink: 0; /* 防止被压缩 */
|
||||
.sb-icon { font-size: 28rpx; margin-right: 16rpx; }
|
||||
.sb-input { flex: 1; font-size: 26rpx; color: #333; }
|
||||
}
|
||||
|
||||
/* 列表自适应占据剩余高度 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.qg-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.qg-icon {
|
||||
width: 64rpx; height: 64rpx; border-radius: 32rpx;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 12rpx;
|
||||
}
|
||||
.qg-emoji { font-size: 28rpx; }
|
||||
.qg-label { font-size: 24rpx; color: #333; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.faq-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.faq-item {
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.fi-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
.fi-q { font-size: 28rpx; color: #333; flex: 1; padding-right: 20rpx; }
|
||||
.fi-body {
|
||||
padding-bottom: 24rpx;
|
||||
}
|
||||
.fi-a {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
background: #f9fafb;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 40rpx 30rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
.cc-title { font-size: 30rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
|
||||
.cc-desc { font-size: 26rpx; color: #999; display: block; margin-bottom: 24rpx; }
|
||||
.cc-btn {
|
||||
display: inline-block;
|
||||
padding: 20rpx 60rpx;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 40rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!--
|
||||
* 推荐有礼
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="推荐有礼" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Invite Code Card -->
|
||||
<view class="invite-card">
|
||||
<view class="ic-header">
|
||||
<text class="ic-title">我的推荐码</text>
|
||||
<view class="ic-code-wrap">
|
||||
<text class="ic-code">{{ inviteCode }}</text>
|
||||
<text class="ic-copy" @click="doCopyCode">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ic-divider"></view>
|
||||
<view class="ic-share-link">
|
||||
<text class="isl-label">分享链接</text>
|
||||
<view class="isl-row">
|
||||
<text class="isl-url">{{ inviteUrl }}</text>
|
||||
<text class="isl-copy" @click="doCopyLink">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ic-actions">
|
||||
<view class="ica-item" @click="generatePoster">
|
||||
<text class="ica-icon">🖼</text>
|
||||
<text class="ica-label">生成海报</text>
|
||||
</view>
|
||||
<!-- <view class="ica-item" @click="showQRCode">-->
|
||||
<!-- <text class="ica-icon">📱</text>-->
|
||||
<!-- <text class="ica-label">二维码</text>-->
|
||||
<!-- </view>-->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Stats -->
|
||||
<view class="stats-card">
|
||||
<view v-for="(s, i) in stats" :key="i" class="sc-item">
|
||||
<text class="sc-value">{{ s.value }}</text>
|
||||
<text class="sc-label">{{ s.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Invite Records -->
|
||||
<view class="records-card">
|
||||
<text class="section-title">推荐记录</text>
|
||||
<view v-for="(r, i) in records" :key="i" class="rc-item">
|
||||
<view class="rc-avatar">{{ r.name[0] }}</view>
|
||||
<view class="rc-info">
|
||||
<text class="rc-name">{{ r.name }}</text>
|
||||
<text class="rc-time">{{ r.time }}</text>
|
||||
</view>
|
||||
<view class="rc-status">
|
||||
<text class="rc-tag" :class="r.statusClass">{{ r.statusText }}</text>
|
||||
<text class="rc-earn">+{{ formatMoney(r.earnings) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Earnings -->
|
||||
<!-- TODO: 预估产出 — 业务规则待定 -->
|
||||
<!-- <view class="earnings-card">
|
||||
<text class="section-title">预估产出</text>
|
||||
<view class="ec-grid">
|
||||
<view class="ec-item">
|
||||
<text class="ec-value">--</text>
|
||||
<text class="ec-label">即时收益(元)</text>
|
||||
</view>
|
||||
<view class="ec-divider"></view>
|
||||
<view class="ec-item">
|
||||
<text class="ec-value">--</text>
|
||||
<text class="ec-label">长期激励(元/月)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatMoney, formatThousands, copyText } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const inviteCode = ref('')
|
||||
const inviteUrl = ref('')
|
||||
const records = ref([])
|
||||
const stats = reactive([
|
||||
{ value: '--', label: '直接推荐' },
|
||||
{ value: '--', label: '间接推荐' },
|
||||
{ value: '--', label: '总收益(元)' }
|
||||
])
|
||||
const fetchInvitation = async () => {
|
||||
try {
|
||||
const [res, codeRes] = await Promise.all([
|
||||
request.get('/c/user/invitation', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
if (codeRes.data) {
|
||||
inviteCode.value = codeRes.data.c?.code || ''
|
||||
inviteUrl.value = codeRes.data.c?.url || ''
|
||||
}
|
||||
if (res.data) {
|
||||
stats[0].value = (res.data.direct_count || 0) + '人'
|
||||
stats[1].value = (res.data.indirect_count || 0) + '人'
|
||||
stats[2].value = formatThousands(res.data.total_earnings || 0)
|
||||
records.value = res.data.records || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取邀请数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
fetchInvitation()
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const doCopyCode = () => copyText(inviteCode.value, '推荐码已复制')
|
||||
const doCopyLink = () => copyText(inviteUrl.value, '链接已复制')
|
||||
|
||||
const generatePoster = () => {
|
||||
openSub('user-poster')
|
||||
}
|
||||
|
||||
const showQRCode = () => {
|
||||
uni.showToast({ title: '请使用生成海报功能', icon: 'none' })
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
min-height: 100vh;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.page-content {
|
||||
height: calc(100vh - 44px);
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.invite-card {
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
.ic-title { font-size: 26rpx; opacity: 0.85; }
|
||||
.ic-code-wrap { display: flex; align-items: center; margin-top: 12rpx; }
|
||||
.ic-code { font-size: 52rpx; font-weight: 700; letter-spacing: 8rpx; }
|
||||
.ic-copy {
|
||||
font-size: 24rpx; color: #fff; background: rgba(255,255,255,0.2);
|
||||
padding: 6rpx 20rpx; border-radius: 20rpx; margin-left: 20rpx;
|
||||
}
|
||||
.ic-divider { height: 1rpx; background: rgba(255,255,255,0.2); margin: 24rpx 0; }
|
||||
.isl-label { font-size: 24rpx; opacity: 0.8; display: block; margin-bottom: 12rpx; }
|
||||
.isl-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.isl-url { font-size: 24rpx; opacity: 0.9; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.isl-copy { font-size: 24rpx; color: #fff; background: rgba(255,255,255,0.2); padding: 6rpx 20rpx; border-radius: 20rpx; margin-left: 16rpx; flex-shrink: 0; }
|
||||
.ic-actions { display: flex; gap: 30rpx; margin-top: 24rpx; }
|
||||
.ica-item { display: flex; align-items: center; }
|
||||
.ica-icon { font-size: 32rpx; margin-right: 8rpx; }
|
||||
.ica-label { font-size: 26rpx; opacity: 0.9; }
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
padding: 30rpx 0;
|
||||
margin-bottom: 20rpx;
|
||||
.sc-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-right: 1rpx solid #f0f0f0;
|
||||
&:last-child { border-right: none; }
|
||||
}
|
||||
.sc-value { font-size: 36rpx; font-weight: 700; color: #333; display: block; }
|
||||
.sc-label { font-size: 24rpx; color: #999; display: block; margin-top: 8rpx; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.records-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.rc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.rc-avatar {
|
||||
width: 64rpx; height: 64rpx; border-radius: 32rpx;
|
||||
background: #dbeafe; color: #3b82f6;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28rpx; font-weight: 600; margin-right: 20rpx;
|
||||
}
|
||||
.rc-info { flex: 1; }
|
||||
.rc-name { font-size: 28rpx; color: #333; display: block; }
|
||||
.rc-time { font-size: 22rpx; color: #999; display: block; margin-top: 4rpx; }
|
||||
.rc-status { text-align: right; }
|
||||
.rc-tag {
|
||||
font-size: 22rpx; display: inline-block; padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
&.done { background: #f0f0f0; color: #999; }
|
||||
&.earned { background: #d1fae5; color: #059669; }
|
||||
}
|
||||
.rc-earn { font-size: 26rpx; color: #059669; font-weight: 500; display: block; margin-top: 4rpx; }
|
||||
}
|
||||
|
||||
.earnings-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.ec-grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.ec-item { flex: 1; text-align: center; }
|
||||
.ec-value { font-size: 40rpx; font-weight: 700; color: #059669; display: block; }
|
||||
.ec-label { font-size: 24rpx; color: #999; display: block; margin-top: 8rpx; }
|
||||
.ec-divider { width: 1rpx; height: 60rpx; background: #e5e7eb; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<!--
|
||||
* 等级规则 — 对齐 C端.html #sub-level-rules (~line 4981)
|
||||
* 活跃度公式 + 等级条件 + 等级维持等级调整 + 权益发放方式
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="升级与等级维持规则" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 活跃度计算公式 -->
|
||||
<view class="formula-card">
|
||||
<text class="fc-title">📐 活跃度计算公式</text>
|
||||
<view class="formula-box">
|
||||
<text class="formula">活跃度 = 自身消费 × 0.4 + 直接推荐C端消费 × 0.2</text>
|
||||
</view>
|
||||
<text class="fc-desc">消费指购买服务器、加成券、自有设备接口费等订单金额</text>
|
||||
</view>
|
||||
|
||||
<!-- 等级条件 -->
|
||||
<view class="card">
|
||||
<text class="card-title">等级条件 · VIP3 → VIP4</text>
|
||||
|
||||
<!-- 基础条件 -->
|
||||
<view class="cond-block">
|
||||
<text class="cond-label">基础条件(必须达成)</text>
|
||||
<view class="cond-item">
|
||||
<view class="cond-icon ok"><text>✓</text></view>
|
||||
<view class="cond-body">
|
||||
<text class="cond-name">累计积分 ≥ 200,000</text>
|
||||
<view class="cond-bar"><view class="cond-fill" style="width:78%"></view></view>
|
||||
<text class="cond-progress">156,800 / 200,000 (78%)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 次要条件 -->
|
||||
<view class="cond-block">
|
||||
<text class="cond-label">次要条件(至少满足3项)</text>
|
||||
<view class="cond-item" v-for="c in secondaryConds" :key="c.name">
|
||||
<view class="cond-icon" :class="c.met ? 'ok' : 'pending'">
|
||||
<text>{{ c.met ? '✓' : '○' }}</text>
|
||||
</view>
|
||||
<view class="cond-body">
|
||||
<text class="cond-name">{{ c.name }}</text>
|
||||
<text class="cond-val">{{ c.current }} / {{ c.target }} {{ c.unit }}</text>
|
||||
</view>
|
||||
<text class="cond-tag" :class="c.met ? 'met' : 'unmet'">{{ c.met ? '已达标' : '未达标' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="cond-note">当前已满足 3/4 项次要条件 ✓</text>
|
||||
</view>
|
||||
|
||||
<!-- 等级维持与等级调整 -->
|
||||
<view class="rule-cards">
|
||||
<view class="rule-card info">
|
||||
<text class="rule-title">📌 等级维持规则</text>
|
||||
<text class="rule-desc">VIP3 季度考核,任意满足2项即等级维持:</text>
|
||||
<text class="rule-item">· 季度收益 ≥ ¥15,000</text>
|
||||
<text class="rule-item">· 设备在线率 ≥ 85%</text>
|
||||
<text class="rule-item">· 新增设备 ≥ 1台</text>
|
||||
</view>
|
||||
<view class="rule-card warn">
|
||||
<text class="rule-title">⚠️ 等级调整规则</text>
|
||||
<text class="rule-desc">连续2个评估周期未满足等级维持条件,将自动等级调整至 VIP2。</text>
|
||||
<text class="rule-desc">等级调整后30天内达到等级条件可恢复原等级。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 权益发放方式 -->
|
||||
<view class="card">
|
||||
<text class="card-title">权益发放方式</text>
|
||||
<view class="dist-grid">
|
||||
<view class="dist-item">
|
||||
<view class="dist-icon" style="background:#E6FAF5"><text class="dist-emoji">⚡</text></view>
|
||||
<text class="dist-name">直接发放户</text>
|
||||
<text class="dist-desc">收益加成 · 直接推荐奖励</text>
|
||||
<text class="dist-detail">自动发放,每日结算</text>
|
||||
</view>
|
||||
<view class="dist-item">
|
||||
<view class="dist-icon" style="background:#FFF7E6"><text class="dist-emoji">📋</text></view>
|
||||
<text class="dist-name">公示发放</text>
|
||||
<text class="dist-desc">加成券 · 优先工单</text>
|
||||
<text class="dist-detail">每月1日公示,统一发放</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
const secondaryConds = [
|
||||
{ name: '在线设备数 ≥ 3台', current: '3', target: '3', unit: '台', met: true },
|
||||
{ name: '注册时长 ≥ 6个月', current: '7', target: '6', unit: '个月', met: true },
|
||||
// 信用分暂时隐藏
|
||||
// { name: '信用分 ≥ 80', current: '96', target: '80', unit: '分', met: true },
|
||||
{ name: '邀请人数 ≥ 5人', current: '3', target: '5', unit: '人', met: false }
|
||||
]
|
||||
|
||||
onLoad((options) => { const params = parseSubParams(options) })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
// Formula Card
|
||||
.formula-card { background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border: 1px solid #BAE0FF; border-radius: 14rpx; padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.fc-title { font-size: 28rpx; font-weight: 700; color: #1677FF; display: block; margin-bottom: 16rpx; }
|
||||
.formula-box { background: #0F1226; border-radius: 10rpx; padding: 20rpx; margin-bottom: 12rpx; }
|
||||
.formula { font-size: 24rpx; color: #5FE6CC; font-family: 'Courier New', monospace; line-height: 1.6; }
|
||||
.fc-desc { font-size: 22rpx; color: #8B91A1; line-height: 1.5; }
|
||||
|
||||
// Card
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04); margin-bottom: 24rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
|
||||
// Conditions
|
||||
.cond-block { margin-bottom: 24rpx; }
|
||||
.cond-label { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 12rpx; }
|
||||
.cond-item { display: flex; align-items: center; gap: 16rpx; padding: 16rpx; background: #F7F9FC; border-radius: 10rpx; margin-bottom: 10rpx; }
|
||||
.cond-icon { width: 40rpx; height: 40rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 22rpx;
|
||||
&.ok { background: #E6FAF5; color: #00C4A7; }
|
||||
&.pending { background: #F3F5F9; color: #BCC2CE; }
|
||||
}
|
||||
.cond-body { flex: 1; }
|
||||
.cond-name { font-size: 24rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.cond-val { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.cond-bar { height: 4rpx; background: #F3F5F9; border-radius: 2rpx; margin-top: 8rpx; }
|
||||
.cond-fill { height: 100%; background: linear-gradient(90deg, #1677FF, #4096FF); border-radius: 2rpx; }
|
||||
.cond-progress { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.cond-tag { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 16rpx; font-weight: 600;
|
||||
&.met { background: #E6FAF5; color: #00C4A7; }
|
||||
&.unmet { background: #F3F5F9; color: #BCC2CE; }
|
||||
}
|
||||
.cond-note { font-size: 22rpx; color: #00C4A7; }
|
||||
|
||||
// Rules
|
||||
.rule-cards { display: flex; flex-direction: column; gap: 16rpx; margin-bottom: 24rpx; }
|
||||
.rule-card { border-radius: 10rpx; padding: 20rpx; border-left: 6rpx solid;
|
||||
&.info { background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border-left-color: #1677FF; }
|
||||
&.warn { background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border-left-color: #FF8800; }
|
||||
}
|
||||
.rule-title { font-size: 26rpx; font-weight: 700; color: #0F1226; display: block; margin-bottom: 8rpx; }
|
||||
.rule-desc { font-size: 22rpx; color: #5A6072; line-height: 1.6; display: block; }
|
||||
.rule-item { font-size: 22rpx; color: #5A6072; line-height: 1.8; display: block; }
|
||||
|
||||
// Distribution
|
||||
.dist-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.dist-item { text-align: center; padding: 20rpx; background: #F7F9FC; border-radius: 14rpx; }
|
||||
.dist-icon { width: 72rpx; height: 72rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin: 0 auto 12rpx; }
|
||||
.dist-emoji { font-size: 36rpx; line-height: 1; }
|
||||
.dist-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.dist-desc { font-size: 20rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
.dist-detail { font-size: 18rpx; color: #BCC2CE; display: block; margin-top: 4rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,327 @@
|
||||
<!--
|
||||
* 用户等级页
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="用户等级" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Hero -->
|
||||
<view class="hero-card">
|
||||
<view class="hc-badge">{{ currentLevel }}</view>
|
||||
<text class="hc-title">{{ currentLevelName }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Progress -->
|
||||
<!-- <view class="progress-card">-->
|
||||
<!-- <text class="pc-title">距离下一级还差</text>-->
|
||||
<!-- <text class="pc-amount">{{ formatThousands(targetRemain) }}</text>-->
|
||||
<!-- <view class="pc-bar-wrap">-->
|
||||
<!-- <view class="pc-bar" :style="{ width: progressPct + '%' }"></view>-->
|
||||
<!-- </view>-->
|
||||
<!-- <text class="pc-progress-text">{{ progressPct }}%</text>-->
|
||||
<!-- </view>-->
|
||||
|
||||
<!-- Current Level Benefits -->
|
||||
<!-- <view class="benefits-card">-->
|
||||
<!-- <text class="section-title">{{ currentLevel }} 专属权益</text>-->
|
||||
<!-- <view class="benefits-grid">-->
|
||||
<!-- <view v-for="(b, i) in benefits" :key="i" class="bg-item">-->
|
||||
<!-- <view class="bg-icon" :style="{ background: b.iconBg }">-->
|
||||
<!-- <text class="bg-icon-text">{{ b.icon }}</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- <text class="bg-label">{{ b.label }}</text>-->
|
||||
<!-- <text class="bg-desc">{{ b.desc }}</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
|
||||
<!-- Roadmap -->
|
||||
<!-- <view class="roadmap-card">-->
|
||||
<!-- <text class="section-title">等级成长路线</text>-->
|
||||
<!-- <view class="roadmap-list">-->
|
||||
<!-- <view v-for="(lv, i) in levels" :key="i" class="rl-item"-->
|
||||
<!-- :class="{ current: lv.level === currentLevel }">-->
|
||||
<!-- <view class="rl-left">-->
|
||||
<!-- <view class="rl-badge" :class="'vip-badge-' + lv.level">-->
|
||||
<!-- VIP{{ lv.level }}-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
<!-- <view class="rl-center">-->
|
||||
<!-- <text class="rl-name">{{ lv.name }}</text>-->
|
||||
<!-- <text class="rl-cond">消费满 ¥{{ formatThousands(lv.requirement) }}</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- <view class="rl-right">-->
|
||||
<!-- <text class="rl-tag" v-if="lv.level === currentLevel">当前</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
|
||||
<!-- All Levels Comparison -->
|
||||
<!-- <view class="comparison-card">-->
|
||||
<!-- <text class="section-title">全部等级对比</text>-->
|
||||
<!-- <view v-for="(lv, i) in levels" :key="i" class="lv-card"-->
|
||||
<!-- :class="{ active: lv.level === currentLevel }">-->
|
||||
<!-- <view class="lv-header">-->
|
||||
<!-- <text class="lv-badge">VIP{{ lv.level }}</text>-->
|
||||
<!-- <text class="lv-name">{{ lv.name }}</text>-->
|
||||
<!-- <text class="lv-req">消费满 ¥{{ formatThousands(lv.requirement) }}</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- <view class="lv-benefits">-->
|
||||
<!-- <text v-for="(b, j) in lv.benefits" :key="j" class="lv-benefit">- {{ b }}</text>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
<!-- </view>-->
|
||||
|
||||
<!-- 等级规则入口 -->
|
||||
<!-- <view class="rules-entry" @click="openSub('level-rules')">-->
|
||||
<!-- <text>查看完整等级规则与权益说明</text>-->
|
||||
<!-- <u-icon name="arrow-right" size="12" color="#ccc"></u-icon-->
|
||||
<!-- </view>-->
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const currentLevel = ref('VIP0')
|
||||
const currentLevelName = ref('注册用户')
|
||||
const totalSpent = ref(0)
|
||||
const targetRemain = ref(0)
|
||||
const targetThreshold = ref(0)
|
||||
const progressPct = ref(0)
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
await fetchLevel()
|
||||
})
|
||||
|
||||
const fetchLevel = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/level', { showLoading: false })
|
||||
const d = res.data
|
||||
currentLevel.value = d.current_level || 'VIP0'
|
||||
currentLevelName.value = d.current_level_name || '注册用户'
|
||||
totalSpent.value = d.total_spent || 0
|
||||
targetRemain.value = d.target_remain || 0
|
||||
targetThreshold.value = d.target_threshold || 0
|
||||
progressPct.value = d.progress_pct || 0
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const milestones = computed(() => {
|
||||
const ms = []
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
ms.push({ level: i, amount: 0 })
|
||||
}
|
||||
return ms
|
||||
})
|
||||
|
||||
const benefits = [
|
||||
{ icon: '⬆', iconBg: '#3b82f6', label: '收益加成', desc: '1.1x基础收益' },
|
||||
{ icon: '👥', iconBg: '#10b981', label: '直接推荐奖励', desc: '3%直接推荐收益' },
|
||||
{ icon: '🎫', iconBg: '#f59e0b', label: '加成券', desc: '每月2张加成券' },
|
||||
{ icon: '🎯', iconBg: '#ef4444', label: '优先工单', desc: '专属优先处理' }
|
||||
]
|
||||
|
||||
const levels = [
|
||||
{ level: 1, name: '注册用户', requirement: 0, benefits: ['基础收益', '工单支持'] },
|
||||
{ level: 2, name: '铜牌伙伴', requirement: 10000, benefits: ['1.05x收益加成', '1%直接推荐奖励', '工单支持'] },
|
||||
{ level: 3, name: '成长合作伙伴', requirement: 50000, benefits: ['1.1x收益加成', '3%直接推荐奖励', '每月2张加成券', '优先工单'] },
|
||||
{ level: 4, name: '高级合作伙伴', requirement: 200000, benefits: ['1.2x收益加成', '5%直接推荐奖励', '每月5张加成券', '专属客服', '优先工单'] },
|
||||
{ level: 5, name: '战略合作伙伴', requirement: 500000, benefits: ['1.5x收益加成', '8%直接推荐奖励', '每月10张加成券', '专属客服', '优先工单', '线下活动邀请'] }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
view, text, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #1A1A2E 0%, #16213E 50%, #0F3460 100%);
|
||||
border-radius: 20rpx; padding: 40rpx; text-align: center; color: #fff; margin-bottom: 20rpx;
|
||||
position: relative; overflow: hidden;
|
||||
&::after { content: ''; position: absolute; top: -60rpx; right: -40rpx; width: 200rpx; height: 200rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,215,0,.2), transparent 65%); }
|
||||
.hc-badge {
|
||||
display: inline-block; background: linear-gradient(135deg, #FFD700, #FFA500); color: #0F3460;
|
||||
border-radius: 40rpx; padding: 10rpx 36rpx; font-size: 30rpx; font-weight: 800; margin-bottom: 16rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(255,165,0,.4); position: relative; z-index: 1;
|
||||
}
|
||||
.hc-title { font-size: 36rpx; font-weight: 600; display: block; margin-bottom: 12rpx; position: relative; z-index: 1; }
|
||||
.hc-company { font-size: 26rpx; opacity: 0.85; display: block; margin-bottom: 8rpx; position: relative; z-index: 1; }
|
||||
.hc-id { font-size: 22rpx; opacity: 0.6; position: relative; z-index: 1; }
|
||||
}
|
||||
|
||||
.progress-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.pc-title { font-size: 26rpx; color: #666; }
|
||||
.pc-amount { font-size: 48rpx; font-weight: 700; color: #3b82f6; display: block; margin: 10rpx 0; }
|
||||
.pc-bar-wrap {
|
||||
height: 12rpx;
|
||||
background: #e5e7eb;
|
||||
border-radius: 6rpx;
|
||||
margin: 20rpx 0 10rpx;
|
||||
position: relative;
|
||||
.pc-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
}
|
||||
.pc-labels {
|
||||
position: relative;
|
||||
height: 40rpx;
|
||||
.pc-marker {
|
||||
position: absolute;
|
||||
font-size: 20rpx;
|
||||
color: #ccc;
|
||||
transform: translateX(-50%);
|
||||
&.active { color: #3b82f6; font-weight: 600; }
|
||||
}
|
||||
}
|
||||
.pc-progress-text { font-size: 24rpx; color: #999; display: block; text-align: center; margin-top: 10rpx; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.benefits-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.benefits-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
.bg-item {
|
||||
width: calc(50% - 10rpx);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
.bg-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
.bg-icon-text { font-size: 32rpx; }
|
||||
}
|
||||
.bg-label { font-size: 28rpx; font-weight: 500; color: #333; }
|
||||
.bg-desc { font-size: 22rpx; color: #999; margin-top: 4rpx; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roadmap-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.roadmap-list {
|
||||
.rl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
&.current { background: #f0f7ff; margin: 0 -20rpx; padding: 24rpx 20rpx; border-radius: 12rpx; }
|
||||
}
|
||||
.rl-left { margin-right: 20rpx; }
|
||||
.rl-badge {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
background: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
&.vip-badge-3 { background: #dbeafe; color: #3b82f6; }
|
||||
&.vip-badge-4 { background: #ede9fe; color: #8b5cf6; }
|
||||
&.vip-badge-5 { background: #fef3c7; color: #f59e0b; }
|
||||
}
|
||||
.rl-center { flex: 1; }
|
||||
.rl-name { font-size: 28rpx; font-weight: 500; color: #333; display: block; }
|
||||
.rl-cond { font-size: 22rpx; color: #999; }
|
||||
.rl-tag {
|
||||
font-size: 22rpx; color: #3b82f6; background: #dbeafe;
|
||||
padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comparison-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.lv-card {
|
||||
border: 2rpx solid #e5e7eb;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
&.active {
|
||||
border-color: #3b82f6;
|
||||
background: #f8faff;
|
||||
}
|
||||
.lv-header { margin-bottom: 16rpx; }
|
||||
.lv-badge {
|
||||
font-size: 28rpx; font-weight: 700; color: #3b82f6;
|
||||
background: #dbeafe; padding: 4rpx 20rpx; border-radius: 20rpx;
|
||||
display: inline-block; margin-right: 12rpx;
|
||||
}
|
||||
.lv-name { font-size: 28rpx; font-weight: 500; color: #333; }
|
||||
.lv-req { font-size: 22rpx; color: #999; display: block; margin-top: 8rpx; }
|
||||
.lv-benefit { font-size: 24rpx; color: #666; display: block; padding: 4rpx 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.rules-entry {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8rpx;
|
||||
padding: 28rpx 0 40rpx; font-size: 24rpx; color: #1677FF;
|
||||
}
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<!--
|
||||
* 拓展伙伴申请
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="拓展伙伴申请" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 已提交申请的审核状态 -->
|
||||
<view v-if="application.id" class="status-card">
|
||||
<view class="sc-header">
|
||||
<text class="sc-title">申请状态</text>
|
||||
<text class="sc-status" :class="statusClass">{{ statusText }}</text>
|
||||
</view>
|
||||
<view class="sc-info">
|
||||
<text class="sc-row">角色:{{ roleLabelMap[application.role] || '未知' }}</text>
|
||||
<text class="sc-row">企业:{{ application.company_name }}</text>
|
||||
<text class="sc-row">提交时间:{{ application.created_at }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 申请表单 -->
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>角色选择</text>
|
||||
<picker :range="roleOptions" range-key="label" :disabled="readonly" @change="onRoleChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text" :class="{ placeholder: !form.role }">{{ roleLabelMap[form.role] || '请选择角色' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<!-- 省 -->
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>所在省份</text>
|
||||
<picker :range="provinceNames" :disabled="readonly" @change="onProvinceChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text" :class="{ placeholder: !selectedProvince }">{{ selectedProvince || '请选择省份' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<!-- 市 -->
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>所在城市</text>
|
||||
<picker :range="cityNames" :disabled="readonly || isMunicipalitySelected || cityNames.length === 0" @change="onCityChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text" :class="{ placeholder: !selectedCity }">{{ selectedCity || '请选择城市' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<!-- 区县 -->
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>所在区县</text>
|
||||
<picker :range="districtNames" :disabled="readonly || districtNames.length === 0" @change="onDistrictChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text" :class="{ placeholder: !selectedDistrict }">{{ selectedDistrict || '请选择区县' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>企业名称</text>
|
||||
<input class="fi-input" v-model="form.companyName" :disabled="readonly" placeholder="请输入企业名称" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text>联系电话</text>
|
||||
<input class="fi-input" v-model="form.phone" :disabled="readonly" type="phone" placeholder="请输入联系电话" />
|
||||
</view>
|
||||
<view class="form-item form-item--textarea">
|
||||
<text class="fi-label"><text class="required">*</text>申请说明</text>
|
||||
<textarea class="fi-textarea" v-model="form.description" :disabled="readonly" placeholder="请描述您的资源优势及合作意向" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!application.id || application.status === 'rejected'" class="submit-btn" @click="handleSubmit">提交申请</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const application = ref({})
|
||||
const submitting = ref(false)
|
||||
|
||||
const readonly = computed(() => application.value.status === 'pending' || application.value.status === 'approved')
|
||||
|
||||
const statusMap = { pending: '待审核', approved: '已通过', rejected: '已驳回' }
|
||||
const statusClassMap = { pending: 'status-pending', approved: 'status-approved', rejected: 'status-rejected' }
|
||||
const statusText = computed(() => statusMap[application.value.status] || '')
|
||||
const statusClass = computed(() => statusClassMap[application.value.status] || '')
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '合伙人', value: 'partner' },
|
||||
{ label: '代理', value: 'agent' },
|
||||
{ label: '省级合伙人', value: 'province_partner' },
|
||||
{ label: '市级合伙人', value: 'city_partner' }
|
||||
]
|
||||
const roleLabelMap = roleOptions.reduce((map, item) => {
|
||||
map[item.value] = item.label
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const form = reactive({
|
||||
role: '',
|
||||
region: '',
|
||||
companyName: '',
|
||||
phone: '',
|
||||
description: '',
|
||||
region_id: null,
|
||||
})
|
||||
|
||||
// 地区联动
|
||||
const provinces = ref([])
|
||||
const cities = ref([])
|
||||
const districts = ref([])
|
||||
const selectedProvince = ref('')
|
||||
const selectedCity = ref('')
|
||||
const selectedDistrict = ref('')
|
||||
const selectedRegionId = ref(null)
|
||||
|
||||
const provinceNames = computed(() => provinces.value.map(p => p.name))
|
||||
const cityNames = computed(() => cities.value.map(c => c.name))
|
||||
const districtNames = computed(() => districts.value.map(d => d.name))
|
||||
const municipalityNames = ['北京市', '天津市', '上海市', '重庆市']
|
||||
const isMunicipalitySelected = computed(() => municipalityNames.includes(selectedProvince.value))
|
||||
|
||||
const getCities = (province) => {
|
||||
const children = province?.children || []
|
||||
if (!municipalityNames.includes(province?.name)) return children
|
||||
|
||||
return [{
|
||||
name: province.name,
|
||||
children: children.map(city => city.children?.[0] || city)
|
||||
}]
|
||||
}
|
||||
|
||||
const fetchRegionTree = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/regions/tree', { showLoading: false })
|
||||
provinces.value = res.data || []
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const onProvinceChange = (e) => {
|
||||
const idx = e.detail.value
|
||||
const p = provinces.value[idx]
|
||||
const provinceCities = getCities(p)
|
||||
selectedProvince.value = p.name
|
||||
cities.value = provinceCities
|
||||
selectedCity.value = municipalityNames.includes(p.name) ? p.name : ''
|
||||
selectedDistrict.value = ''
|
||||
districts.value = municipalityNames.includes(p.name) ? (provinceCities[0]?.children || []) : []
|
||||
selectedRegionId.value = null
|
||||
}
|
||||
|
||||
const onCityChange = (e) => {
|
||||
const idx = e.detail.value
|
||||
const c = cities.value[idx]
|
||||
selectedCity.value = c.name
|
||||
districts.value = c.children || []
|
||||
selectedDistrict.value = ''
|
||||
selectedRegionId.value = null
|
||||
}
|
||||
|
||||
const onDistrictChange = (e) => {
|
||||
const idx = e.detail.value
|
||||
const d = districts.value[idx]
|
||||
selectedDistrict.value = d.name
|
||||
selectedRegionId.value = d.id
|
||||
}
|
||||
|
||||
// 根据 region_id 在地区树中反查省/市/区,并恢复联动数据
|
||||
const restoreByRegionId = (regionId) => {
|
||||
for (const p of provinces.value) {
|
||||
const provinceCities = getCities(p)
|
||||
for (const c of provinceCities) {
|
||||
const d = (c.children || []).find(d => Number(d.id) === Number(regionId))
|
||||
if (d) {
|
||||
selectedProvince.value = p.name
|
||||
selectedCity.value = c.name
|
||||
selectedDistrict.value = d.name
|
||||
cities.value = provinceCities
|
||||
districts.value = c.children || []
|
||||
selectedRegionId.value = d.id
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMyApplication = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/partner/apply', { showLoading: false })
|
||||
const list = res.data || []
|
||||
if (list.length > 0) {
|
||||
const app = list[0]
|
||||
application.value = app
|
||||
form.role = app.role || ''
|
||||
form.companyName = app.company_name || ''
|
||||
form.phone = app.contact_phone || ''
|
||||
form.description = app.description || ''
|
||||
if (app.region_id) {
|
||||
restoreByRegionId(app.region_id)
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const initData = async () => {
|
||||
await fetchRegionTree()
|
||||
await fetchMyApplication()
|
||||
}
|
||||
|
||||
onLoad(() => { initData() })
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const onRoleChange = (e) => {
|
||||
form.role = roleOptions[e.detail.value]?.value || ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting.value) return
|
||||
if (!form.role) return uni.showToast({ title: '请选择角色', icon: 'none' })
|
||||
if (!selectedProvince.value) return uni.showToast({ title: '请选择省份', icon: 'none' })
|
||||
if (!selectedCity.value) return uni.showToast({ title: '请选择城市', icon: 'none' })
|
||||
if (!selectedDistrict.value) return uni.showToast({ title: '请选择区县', icon: 'none' })
|
||||
if (!form.companyName.trim()) return uni.showToast({ title: '请填写企业名称', icon: 'none' })
|
||||
if (!form.phone.trim()) return uni.showToast({ title: '请填写联系电话', icon: 'none' })
|
||||
if (!form.description.trim()) return uni.showToast({ title: '请填写申请说明', icon: 'none' })
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/c/partner/apply', {
|
||||
role: form.role,
|
||||
region_id: selectedRegionId.value,
|
||||
company_name: form.companyName.trim(),
|
||||
contact_phone: form.phone.trim(),
|
||||
description: form.description.trim(),
|
||||
}, { showLoading: true })
|
||||
application.value = res.data
|
||||
uni.showToast({ title: '提交成功,等待审核', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 44px); padding: 20rpx 30rpx; }
|
||||
|
||||
.status-card {
|
||||
background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx;
|
||||
box-shadow: 0 1px 3px rgba(15,18,38,.04);
|
||||
}
|
||||
.sc-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
|
||||
.sc-title { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
.sc-status { font-size: 22rpx; padding: 6rpx 16rpx; border-radius: 20rpx; font-weight: 500;
|
||||
&.status-pending { background: #fef3c7; color: #d97706; }
|
||||
&.status-approved { background: #d1fae5; color: #059669; }
|
||||
&.status-rejected { background: #fee2e2; color: #dc2626; }
|
||||
}
|
||||
.sc-info { display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.sc-row { font-size: 24rpx; color: #5A6072; }
|
||||
|
||||
.form-card { background: #fff; border-radius: 16rpx; padding: 0 30rpx; }
|
||||
.form-item { display: flex; align-items: center; padding: 30rpx 0; border-bottom: 1rpx solid #f0f0f0;
|
||||
&:last-child { border-bottom: none; }
|
||||
&--textarea { align-items: flex-start; }
|
||||
}
|
||||
.fi-label { font-size: 28rpx; color: #333; white-space: nowrap; flex-shrink: 0; width: 160rpx; }
|
||||
.fi-input { flex: 1; text-align: right; font-size: 28rpx; color: #333; }
|
||||
.fi-input[disabled] { color: #999; }
|
||||
.required { color: #FF4D4F; margin-right: 2rpx; }
|
||||
.fi-picker { flex: 1; display: flex; justify-content: flex-end; align-items: center; }
|
||||
.fp-text { font-size: 28rpx; color: #333; &.placeholder { color: #999; } }
|
||||
.fp-arrow { margin-left: 12rpx; color: #ccc; font-size: 28rpx; }
|
||||
.fi-textarea { flex: 1; height: 160rpx; font-size: 26rpx; color: #333; padding: 10rpx 0; }
|
||||
.fi-textarea[disabled] { color: #999; }
|
||||
.submit-btn {
|
||||
margin: 60rpx 30rpx; height: 88rpx; line-height: 88rpx; text-align: center;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb); color: #fff;
|
||||
font-size: 32rpx; border-radius: 44rpx; font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
* C端用户 — 分享海报
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="分享海报" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="content-pad">
|
||||
<view class="poster-canvas" v-if="posterUrl">
|
||||
<image :src="posterUrl" mode="widthFix" class="poster-img" />
|
||||
</view>
|
||||
<view class="poster-canvas poster-placeholder" v-else>
|
||||
<text class="poster-title">推荐好友加入 一起赚收益</text>
|
||||
<text class="poster-subtitle">邀请C端用户<text class="subtitle-break">获得好友年度收益的直接推荐奖励</text></text>
|
||||
<text class="poster-name">{{ userName }}</text>
|
||||
<text class="poster-empty-tip">生成中...</text>
|
||||
</view>
|
||||
|
||||
<view class="poster-actions">
|
||||
<view class="btn btn-primary" @click="savePoster">保存到相册</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { closeSub } from '@/composables/useNavigation'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const posterUrl = ref('')
|
||||
const userName = ref('')
|
||||
|
||||
const fetchPoster = async () => {
|
||||
try {
|
||||
const loginUser = uni.getStorageSync('userInfo') || {}
|
||||
userName.value = loginUser.nickname || '用户'
|
||||
|
||||
const res = await request.get('/c/poster/generate', { showLoading: true })
|
||||
if (res.data && res.data.url) {
|
||||
posterUrl.value = res.data.url
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取海报失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchPoster() })
|
||||
|
||||
const savePoster = () => {
|
||||
if (!posterUrl.value) {
|
||||
uni.showToast({ title: '海报正在生成中...', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// #ifdef H5
|
||||
const a = document.createElement('a')
|
||||
a.href = posterUrl.value
|
||||
a.download = '分享海报.jpg'
|
||||
a.click()
|
||||
uni.showToast({ title: '已开始下载', icon: 'success' })
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: '正在保存', mask: true })
|
||||
uni.downloadFile({
|
||||
url: posterUrl.value,
|
||||
success: (res) => {
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: res.tempFilePath,
|
||||
success: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '海报已保存到相册', icon: 'success' })
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '保存失败,请检查相册权限', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { height: 100vh; display: flex; flex-direction: column; background: #F2F4F8; overflow: hidden; }
|
||||
.page-content { flex: 1; height: 0; }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.poster-canvas { width: 100%; border-radius: 28rpx; overflow: hidden; margin-bottom: 24rpx; box-shadow: 0 8rpx 40rpx rgba(0,0,0,.12); }
|
||||
.poster-img { width: 100%; display: block; }
|
||||
|
||||
.poster-placeholder {
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #ec4899 100%);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: flex-end;
|
||||
padding: 40rpx; position: relative;
|
||||
&::before { content: ''; position: absolute; top: 40rpx; right: -60rpx; width: 320rpx; height: 320rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,.3), transparent 60%); }
|
||||
&::after { content: ''; position: absolute; bottom: -80rpx; left: -40rpx; width: 240rpx; height: 240rpx; border-radius: 50%; background: radial-gradient(circle, rgba(0,0,0,.1), transparent 60%); }
|
||||
}
|
||||
.poster-title { font-size: 48rpx; font-weight: 800; color: #fff; text-shadow: 0 4rpx 16rpx rgba(0,0,0,.3); text-align: center; position: relative; z-index: 1; margin-bottom: 16rpx; }
|
||||
.poster-subtitle { font-size: 26rpx; color: rgba(255,255,255,.95); text-align: center; position: relative; z-index: 1; margin-bottom: 28rpx; line-height: 1.6; }
|
||||
.subtitle-break { display: block; }
|
||||
.poster-name { font-size: 32rpx; font-weight: 700; color: #fff; background: rgba(0,0,0,.25); padding: 12rpx 28rpx; border-radius: 36rpx; position: relative; z-index: 1; margin-bottom: 16rpx; backdrop-filter: blur(16rpx); }
|
||||
.poster-empty-tip { font-size: 24rpx; color: rgba(255,255,255,.6); position: relative; z-index: 1; margin-top: 24rpx; }
|
||||
|
||||
.poster-actions { display: flex; gap: 16rpx; }
|
||||
.btn { flex: 1; padding: 26rpx 0; text-align: center; border-radius: 22rpx; font-size: 28rpx; font-weight: 600; }
|
||||
.btn-primary { background: linear-gradient(135deg, #1677FF, #4096FF); color: #fff; box-shadow: 0 4rpx 16rpx rgba(22,119,255,.25); }
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<!--
|
||||
* 企业信息编辑
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="企业信息" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- 1. 表单滚动区域:自适应剩余高度 -->
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="fi-label">企业名称</text>
|
||||
<input class="fi-input" v-model="form.companyName" placeholder="请输入企业名称" />
|
||||
</view>
|
||||
<view class="form-item disabled">
|
||||
<text class="fi-label">手机号</text>
|
||||
<input class="fi-input" v-model="form.phone" type="phone" disabled />
|
||||
</view>
|
||||
<view class="form-item disabled">
|
||||
<text class="fi-label">VIP等级</text>
|
||||
<input class="fi-input" v-model="form.vipLevel" disabled placeholder="VIP等级" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label">实名认证状态</text>
|
||||
<view class="fi-right">
|
||||
<text class="auth-badge">{{ authStatus.text }}</text>
|
||||
<text class="fi-arrow" @click="goAuth">去认证</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item form-item--switch">
|
||||
<text class="fi-label">排行榜展示</text>
|
||||
<switch class="fi-switch" :checked="form.showRanking" @change="form.showRanking = !form.showRanking" color="#3b82f6" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 2. 底部固定操作栏(支持 iOS 底部安全区) -->
|
||||
<view class="bottom-bar">
|
||||
<view class="save-btn" @click="handleSave">保存</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { openSub, closeSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const form = reactive({
|
||||
companyName: '',
|
||||
phone: '',
|
||||
vipLevel: '',
|
||||
showRanking: true
|
||||
})
|
||||
|
||||
const authStatus = reactive({
|
||||
verified: false,
|
||||
text: '待认证'
|
||||
})
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/profile', { showLoading: true })
|
||||
const d = res.data
|
||||
form.companyName = d.nickname || ''
|
||||
form.phone = d.phone || ''
|
||||
form.vipLevel = d.level || ''
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '获取信息失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { fetchProfile() })
|
||||
|
||||
const goAuth = () => {
|
||||
uni.showToast({ title: '即将开放', icon: 'none' })
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.companyName.trim()) {
|
||||
uni.showToast({ title: '请输入企业名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await request.put('/c/profile', { nickname: form.companyName })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
uni.$emit('profileUpdated')
|
||||
setTimeout(() => closeSub(), 800)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.response?.data?.message || '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全屏垂直 Flex 布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 列表/表单内容自适应撑满剩余空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
&:last-child { border-bottom: none; }
|
||||
&.disabled { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.fi-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
width: 180rpx;
|
||||
}
|
||||
|
||||
.fi-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fi-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
font-size: 20rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
color: #FF8800;
|
||||
background: #FFF7E6;
|
||||
}
|
||||
|
||||
.fi-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-item--switch {
|
||||
.fi-label { width: auto; }
|
||||
}
|
||||
|
||||
.fi-switch {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
/* 底部固定保存按钮 */
|
||||
.bottom-bar {
|
||||
background: #fff;
|
||||
padding: 20rpx 24rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
border-radius: 44rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
* C端分享中心
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="分享中心" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Hero -->
|
||||
<view class="hero-card">
|
||||
<view class="hc-content">
|
||||
<text class="hc-title">推荐好友获取直接推荐奖励</text>
|
||||
<text class="hc-desc">每邀请一位好友注册并消费,您可获得最高8%的直接推荐收益</text>
|
||||
</view>
|
||||
<view class="hc-decoration">
|
||||
<text class="hc-emoji">🎉</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Invite Code -->
|
||||
<view class="invite-card">
|
||||
<text class="ic-label">我的推荐码</text>
|
||||
<view class="ic-row">
|
||||
<text class="ic-code">{{ inviteCode }}</text>
|
||||
<text class="ic-copy" @click="doCopyCode">复制</text>
|
||||
</view>
|
||||
<view class="ic-share-link">
|
||||
<text class="isl-url">{{ inviteUrl }}</text>
|
||||
<text class="isl-copy" @click="doCopyLink">复制链接</text>
|
||||
</view>
|
||||
<view class="ic-actions">
|
||||
<view class="ica-item" @click="generatePoster">
|
||||
<text class="ica-icon">🖼</text>
|
||||
<text class="ica-text">生成海报</text>
|
||||
</view>
|
||||
<view class="ica-item" @click="shareWechat">
|
||||
<text class="ica-icon">💬</text>
|
||||
<text class="ica-text">微信分享</text>
|
||||
</view>
|
||||
<view class="ica-item" @click="shareMore">
|
||||
<text class="ica-icon">📤</text>
|
||||
<text class="ica-text">更多</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<view class="stats-grid">
|
||||
<view class="sg-item">
|
||||
<text class="sg-value">{{ stats.direct }}</text>
|
||||
<text class="sg-label">直接推荐人数</text>
|
||||
</view>
|
||||
<view class="sg-item">
|
||||
<text class="sg-value">{{ stats.team }}</text>
|
||||
<text class="sg-label">团队人数</text>
|
||||
</view>
|
||||
<view class="sg-item">
|
||||
<text class="sg-value">{{ stats.total }}</text>
|
||||
<text class="sg-label">总收益</text>
|
||||
</view>
|
||||
<view class="sg-item">
|
||||
<text class="sg-value">{{ stats.month }}</text>
|
||||
<text class="sg-label">本月产出</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Share Tips -->
|
||||
<view class="tips-card">
|
||||
<text class="section-title">推广技巧</text>
|
||||
<view v-for="(tip, i) in tips" :key="i" class="tip-item">
|
||||
<text class="tip-num">{{ i + 1 }}</text>
|
||||
<text class="tip-text">{{ tip }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { copyText, formatThousands } from '@/utils/util'
|
||||
|
||||
const inviteCode = ref('')
|
||||
const inviteUrl = ref('')
|
||||
const stats = ref({ direct: 0, team: 0, total: '¥0', month: '¥0' })
|
||||
|
||||
onLoad(async (options) => {
|
||||
const params = parseSubParams(options)
|
||||
try {
|
||||
const [res, codeRes] = await Promise.all([
|
||||
request.get('/c/user/invitation', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
const d = res.data
|
||||
inviteCode.value = codeRes.data?.c?.code || ''
|
||||
inviteUrl.value = codeRes.data?.c?.url || ('https://quanxi.cloud/reg?invite=' + inviteCode.value)
|
||||
stats.value = {
|
||||
direct: d.direct_count || 0,
|
||||
team: (d.direct_count || 0) + (d.indirect_count || 0),
|
||||
total: '¥' + formatThousands(d.total_earnings || 0),
|
||||
month: '¥' + formatThousands(d.monthly_earnings || 0)
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const doCopyCode = () => copyText(inviteCode.value, '推荐码已复制')
|
||||
const doCopyLink = () => copyText(inviteUrl.value, '链接已复制')
|
||||
|
||||
const generatePoster = () => {
|
||||
uni.showToast({ title: '海报生成中', icon: 'none' })
|
||||
}
|
||||
const shareWechat = () => {
|
||||
uni.showToast({ title: '分享', icon: 'none' })
|
||||
}
|
||||
const shareMore = () => {
|
||||
uni.showToast({ title: '分享', icon: 'none' })
|
||||
}
|
||||
|
||||
const tips = [
|
||||
'将邀请链接分享到朋友圈或微信群,让更多朋友了解全犀云',
|
||||
'向好友详细说明CDN共享的收益模式,帮助TA快速上手',
|
||||
'邀请有设备资源的朋友加入,能够获得更高的长期激励'
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
min-height: 100vh;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.page-content {
|
||||
height: calc(100vh - 44px);
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #ec4899, #f472b6);
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx 30rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
.hc-content { flex: 1; }
|
||||
.hc-title { font-size: 34rpx; font-weight: 700; color: #fff; display: block; margin-bottom: 12rpx; }
|
||||
.hc-desc { font-size: 24rpx; color: rgba(255,255,255,0.85); line-height: 1.6; }
|
||||
.hc-decoration { margin-left: 20rpx; }
|
||||
.hc-emoji { font-size: 72rpx; }
|
||||
}
|
||||
|
||||
.invite-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.ic-label { font-size: 26rpx; color: #666; display: block; margin-bottom: 12rpx; }
|
||||
.ic-row { display: flex; align-items: center; margin-bottom: 16rpx; }
|
||||
.ic-code { font-size: 44rpx; font-weight: 700; color: #ec4899; letter-spacing: 6rpx; }
|
||||
.ic-copy {
|
||||
font-size: 24rpx; color: #ec4899; border: 2rpx solid #ec4899;
|
||||
padding: 6rpx 24rpx; border-radius: 24rpx; margin-left: 20rpx;
|
||||
}
|
||||
.ic-share-link {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #f9fafb; border-radius: 12rpx; padding: 16rpx 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.isl-url { font-size: 24rpx; color: #999; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.isl-copy { font-size: 24rpx; color: #3b82f6; flex-shrink: 0; margin-left: 12rpx; }
|
||||
.ic-actions { display: flex; gap: 30rpx; }
|
||||
.ica-item { display: flex; align-items: center; gap: 8rpx; }
|
||||
.ica-icon { font-size: 28rpx; }
|
||||
.ica-text { font-size: 26rpx; color: #666; }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.sg-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.sg-value { font-size: 36rpx; font-weight: 700; color: #ec4899; display: block; margin-bottom: 8rpx; }
|
||||
.sg-label { font-size: 24rpx; color: #999; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.tips-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.tip-num {
|
||||
width: 36rpx; height: 36rpx; border-radius: 18rpx;
|
||||
background: #fce7f3; color: #ec4899;
|
||||
text-align: center; line-height: 36rpx;
|
||||
font-size: 22rpx; font-weight: 600;
|
||||
margin-right: 16rpx; flex-shrink: 0;
|
||||
}
|
||||
.tip-text { font-size: 26rpx; color: #666; line-height: 1.6; flex: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
* 实名认证
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="实名认证" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Auth Status -->
|
||||
<view class="status-card">
|
||||
<view class="sc-icon-wrap">
|
||||
<text class="sc-icon verified">✓</text>
|
||||
</view>
|
||||
<text class="sc-status">已认证</text>
|
||||
<text class="sc-desc">您的身份信息已通过实名认证</text>
|
||||
</view>
|
||||
|
||||
<!-- ID Card Info -->
|
||||
<view class="info-card">
|
||||
<text class="section-title">身份信息</text>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">姓名</text>
|
||||
<text class="ir-value">*峰</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">证件号码</text>
|
||||
<text class="ir-value">440************876</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">证件类型</text>
|
||||
<text class="ir-value">中国大陆身份证</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">认证时间</text>
|
||||
<text class="ir-value">2026-03-15 10:30</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Face Recognition -->
|
||||
<view class="info-card">
|
||||
<text class="section-title">人脸识别</text>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">活体检测</text>
|
||||
<text class="ir-value verified">已通过</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">识别时间</text>
|
||||
<text class="ir-value">2026-03-15 10:32</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Enterprise Info -->
|
||||
<view class="info-card">
|
||||
<text class="section-title">企业信息</text>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">企业名称</text>
|
||||
<text class="ir-value">深圳智算科技有限公司</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">统一社会信用代码</text>
|
||||
<text class="ir-value">914403************</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="ir-label">企业认证</text>
|
||||
<text class="ir-value verified">已认证</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/user/realname-auth
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page {
|
||||
min-height: 100vh;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
.page-content {
|
||||
height: calc(100vh - 44px);
|
||||
padding: 20rpx 30rpx 40rpx;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: linear-gradient(135deg, #059669, #10b981);
|
||||
border-radius: 20rpx;
|
||||
padding: 50rpx 30rpx;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
.sc-icon-wrap {
|
||||
width: 96rpx; height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
background: rgba(255,255,255,0.25);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 16rpx;
|
||||
}
|
||||
.sc-icon { font-size: 48rpx; font-weight: 700; }
|
||||
.sc-status { font-size: 36rpx; font-weight: 600; display: block; margin-bottom: 8rpx; }
|
||||
.sc-desc { font-size: 26rpx; opacity: 0.85; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.ir-label { font-size: 28rpx; color: #666; }
|
||||
.ir-value { font-size: 28rpx; color: #333; font-weight: 500; }
|
||||
.ir-value.verified { color: #10b981; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!--
|
||||
* 设置
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="设置" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Settings List -->
|
||||
<view class="settings-card">
|
||||
<view class="s-item" @click="goAccountSecurity">
|
||||
<view class="si-left">
|
||||
<text class="si-icon">🔒</text>
|
||||
<text class="si-label">账号安全</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="s-item" @click="goNotificationSettings">
|
||||
<view class="si-left">
|
||||
<text class="si-icon">🔔</text>
|
||||
<text class="si-label">通知设置</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="s-item" @click="goPrivacySettings">
|
||||
<view class="si-left">
|
||||
<text class="si-icon">🛡</text>
|
||||
<text class="si-label">隐私设置</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="s-item" @click="clearCache">
|
||||
<view class="si-left">
|
||||
<text class="si-icon">🗑</text>
|
||||
<text class="si-label">清除缓存</text>
|
||||
</view>
|
||||
<view class="si-right">
|
||||
<text class="si-cache-size">{{ cacheSize }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- About -->
|
||||
<view class="settings-card">
|
||||
<view class="s-item">
|
||||
<view class="si-left">
|
||||
<text class="si-icon">ℹ</text>
|
||||
<text class="si-label">当前版本</text>
|
||||
</view>
|
||||
<text class="si-value">v2.1.0</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</scroll-view>
|
||||
|
||||
<!-- 退出按钮放在 scroll-view 外面,避免被遮挡/事件吞掉 -->
|
||||
<view class="logout-btn" @click="handleLogout">退出登录</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// 估算缓存大小
|
||||
try {
|
||||
const info = uni.getStorageInfoSync()
|
||||
cacheSize.value = info ? Math.ceil((info.currentSize || 0) / 1024) + 'MB' : '0MB'
|
||||
} catch (e) { cacheSize.value = '0MB' }
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const cacheSize = ref('--')
|
||||
|
||||
const goAccountSecurity = () => {
|
||||
uni.showToast({ title: '账号安全(开发中)', icon: 'none' })
|
||||
}
|
||||
const goNotificationSettings = () => {
|
||||
uni.showToast({ title: '通知设置(开发中)', icon: 'none' })
|
||||
}
|
||||
const goPrivacySettings = () => {
|
||||
uni.showToast({ title: '隐私设置(开发中)', icon: 'none' })
|
||||
}
|
||||
const clearCache = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定清除缓存吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
try { uni.clearStorageSync() } catch (e) {}
|
||||
cacheSize.value = '0MB'
|
||||
uni.showToast({ title: '缓存已清除', icon: 'success' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全屏垂直 Flex 布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 列表/表单内容自适应撑满剩余空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.s-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.si-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.si-icon { font-size: 32rpx; }
|
||||
.si-label { font-size: 28rpx; color: #333; }
|
||||
.si-right { display: flex; align-items: center; gap: 12rpx; }
|
||||
.si-cache-size { font-size: 26rpx; color: #999; }
|
||||
.si-value { font-size: 26rpx; color: #999; }
|
||||
|
||||
.logout-btn {
|
||||
margin: 0 24rpx 24rpx;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
color: #ef4444;
|
||||
font-size: 32rpx;
|
||||
border-radius: 44rpx;
|
||||
font-weight: 500;
|
||||
border: 2rpx solid #fee2e2;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!--
|
||||
* 新建工单
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="新建工单" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- 1. 表单滚动区域:自适应剩余高度 -->
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<text class="fi-label">工单类型</text>
|
||||
<picker :range="types" @change="onTypeChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text">{{ form.type || '请选择工单类型' }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label">关联设备</text>
|
||||
<picker :range="devices" @change="onDeviceChange">
|
||||
<view class="fi-picker">
|
||||
<text class="fp-text">{{ form.device || '请选择设备(可选)' }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label">工单标题</text>
|
||||
<input class="fi-input" v-model="form.title" placeholder="请输入工单标题" />
|
||||
</view>
|
||||
<view class="form-item form-item--textarea">
|
||||
<text class="fi-label">问题描述</text>
|
||||
<textarea class="fi-textarea" v-model="form.description" placeholder="请详细描述您的问题或需求" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 2. 底部固定提交按钮栏(支持底部安全区) -->
|
||||
<view class="bottom-bar">
|
||||
<view class="submit-btn" @click="handleSubmit">提交工单</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 - 预填工单信息
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const types = ['安装', '维修', '售后', '投诉建议']
|
||||
const devices = ['深圳-福田-01 (QH-500 Edge)', '上海-浦东-01 (QH-300 Lite)', '北京-朝阳-01 (QH-500 Edge)']
|
||||
|
||||
const form = reactive({
|
||||
type: '',
|
||||
device: '',
|
||||
title: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const onTypeChange = (e) => { form.type = types[e.detail.value] }
|
||||
const onDeviceChange = (e) => { form.device = devices[e.detail.value] }
|
||||
|
||||
const handleSubmit = () => {
|
||||
// TODO: 对接接口 /api/tickets/create
|
||||
uni.showToast({ title: '工单提交成功', icon: 'success' })
|
||||
setTimeout(() => closeSub(), 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全屏垂直 Flex 布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 表达滚动区自适应填充 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
&:last-child { border-bottom: none; }
|
||||
&--textarea { align-items: flex-start; }
|
||||
}
|
||||
|
||||
.fi-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
width: 150rpx;
|
||||
}
|
||||
|
||||
.fi-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fi-picker {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fp-text { font-size: 28rpx; color: #999; }
|
||||
|
||||
.fi-textarea {
|
||||
flex: 1;
|
||||
height: 200rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
/* 底部固定操作栏 */
|
||||
.bottom-bar {
|
||||
background: #fff;
|
||||
padding: 20rpx 24rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
border-radius: 44rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
* 工单详情
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="工单详情" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- Ticket Info -->
|
||||
<view class="info-card">
|
||||
<view class="ic-header">
|
||||
<text class="ic-id">{{ ticket.id }}</text>
|
||||
<text class="ic-status" :class="ticket.statusClass">{{ ticket.statusText }}</text>
|
||||
</view>
|
||||
<text class="ic-title">{{ ticket.title }}</text>
|
||||
|
||||
<view class="ic-details">
|
||||
<view class="icd-row">
|
||||
<text class="icd-label">工单类型</text>
|
||||
<text class="icd-value">{{ ticket.type }}</text>
|
||||
</view>
|
||||
<view class="icd-row">
|
||||
<text class="icd-label">关联设备</text>
|
||||
<text class="icd-value">{{ ticket.device }}</text>
|
||||
</view>
|
||||
<view class="icd-row">
|
||||
<text class="icd-label">处理目标</text>
|
||||
<text class="icd-value">{{ ticket.target }}</text>
|
||||
</view>
|
||||
<view class="icd-row">
|
||||
<text class="icd-label">处理人</text>
|
||||
<text class="icd-value">{{ ticket.assignee }}</text>
|
||||
</view>
|
||||
<view class="icd-row">
|
||||
<text class="icd-label">创建时间</text>
|
||||
<text class="icd-value">{{ ticket.createTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Processing Log -->
|
||||
<view class="log-card">
|
||||
<text class="section-title">处理日志</text>
|
||||
<view class="timeline">
|
||||
<view v-for="(log, i) in logs" :key="i" class="tl-item">
|
||||
<view class="tl-dot" :class="{ current: i === 0 }"></view>
|
||||
<view class="tl-line" v-if="i < logs.length - 1"></view>
|
||||
<view class="tl-content">
|
||||
<text class="tl-action">{{ log.action }}</text>
|
||||
<text class="tl-operator">{{ log.operator }}</text>
|
||||
<text class="tl-time">{{ log.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
if (params.id) {
|
||||
// TODO: 对接接口 /api/user/tickets/{id}
|
||||
}
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const ticket = ref({
|
||||
id: 'WO-001',
|
||||
title: '设备安装工单',
|
||||
type: '安装',
|
||||
device: '深圳-福田-01 (QH-500 Edge)',
|
||||
target: '完成设备上架及网络配置',
|
||||
assignee: '张工 (运维组)',
|
||||
status: 'processing',
|
||||
statusText: '处理中',
|
||||
statusClass: 'processing',
|
||||
createTime: '2026-07-25 14:30'
|
||||
})
|
||||
|
||||
const logs = [
|
||||
{
|
||||
action: '当前处理:运维人员已到达现场,正在进行设备上架',
|
||||
operator: '张工',
|
||||
time: '2026-07-26 10:30'
|
||||
},
|
||||
{
|
||||
action: '运维人员已接单,正在前往现场',
|
||||
operator: '张工',
|
||||
time: '2026-07-26 09:00'
|
||||
},
|
||||
{
|
||||
action: '工单已分配至运维组',
|
||||
operator: '系统',
|
||||
time: '2026-07-25 15:00'
|
||||
},
|
||||
{
|
||||
action: '您提交了安装工单',
|
||||
operator: '深圳智算科技',
|
||||
time: '2026-07-25 14:30'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全屏垂直 Flex 布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 占用所有剩余屏幕空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
.ic-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.ic-id { font-size: 30rpx; font-weight: 700; color: #333; }
|
||||
.ic-status {
|
||||
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
&.pending { background: #fef3c7; color: #d97706; }
|
||||
&.processing { background: #dbeafe; color: #2563eb; }
|
||||
}
|
||||
.ic-title { font-size: 28rpx; color: #666; display: block; margin-bottom: 24rpx; }
|
||||
|
||||
.icd-row {
|
||||
display: flex;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.icd-label { width: 140rpx; font-size: 26rpx; color: #999; flex-shrink: 0; }
|
||||
.icd-value { flex: 1; font-size: 26rpx; color: #333; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 30rpx;
|
||||
.tl-item {
|
||||
position: relative;
|
||||
padding-bottom: 30rpx;
|
||||
&:last-child { padding-bottom: 0; }
|
||||
}
|
||||
.tl-dot {
|
||||
position: absolute;
|
||||
left: -30rpx;
|
||||
top: 6rpx;
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #d1d5db;
|
||||
&.current { background: #3b82f6; width: 20rpx; height: 20rpx; border-radius: 10rpx; left: -32rpx; top: 4rpx; }
|
||||
}
|
||||
.tl-line {
|
||||
position: absolute;
|
||||
left: -23rpx;
|
||||
top: 22rpx;
|
||||
width: 2rpx;
|
||||
height: calc(100% - 22rpx);
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.tl-content { }
|
||||
.tl-action { font-size: 26rpx; color: #333; display: block; line-height: 1.6; }
|
||||
.tl-operator { font-size: 22rpx; color: #999; display: block; margin-top: 6rpx; }
|
||||
.tl-time { font-size: 20rpx; color: #ccc; display: block; margin-top: 4rpx; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!--
|
||||
* 工单管理
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="工单管理" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<!-- 1. 列表主体:使用 flex: 1 撑满剩余高度 -->
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<view v-for="(t, i) in tickets" :key="i" class="ticket-card" @click="goDetail(t)">
|
||||
<view class="tc-top">
|
||||
<text class="tc-id">{{ t.id }}</text>
|
||||
<text class="tc-status" :class="t.statusClass">{{ t.statusText }}</text>
|
||||
</view>
|
||||
<text class="tc-title">{{ t.title }}</text>
|
||||
<view class="tc-meta">
|
||||
<text class="tc-type">{{ t.type }}</text>
|
||||
<text class="tc-time">{{ t.time }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 2. 底部固定操作栏:脱离滚动区,随时可点击 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="create-btn" @click="goCreate">
|
||||
<text class="cb-icon">+</text>
|
||||
<text class="cb-text">创建工单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { openSub, closeSub, parseSubParams } from '@/composables/useNavigation'
|
||||
|
||||
onLoad((options) => {
|
||||
const params = parseSubParams(options)
|
||||
// TODO: 对接接口 /api/user/tickets
|
||||
})
|
||||
|
||||
const goBack = () => closeSub()
|
||||
|
||||
const tickets = [
|
||||
{
|
||||
id: 'WO-001',
|
||||
title: '设备安装工单',
|
||||
type: '安装',
|
||||
status: 'processing',
|
||||
statusText: '处理中',
|
||||
statusClass: 'processing',
|
||||
time: '2026-07-25 14:30'
|
||||
},
|
||||
{
|
||||
id: 'WO-005',
|
||||
title: '维修申请',
|
||||
type: '维修',
|
||||
status: 'pending',
|
||||
statusText: '待处理',
|
||||
statusClass: 'pending',
|
||||
time: '2026-07-26 09:15'
|
||||
}
|
||||
]
|
||||
|
||||
const goDetail = (item) => {
|
||||
openSub('ticket-detail', { id: item.id })
|
||||
}
|
||||
|
||||
const goCreate = () => {
|
||||
openSub('ticket-create')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 1. 外层撑满页面,采用 Flex 纵向布局 */
|
||||
.sub-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 2. 列表区域自动填充剩余空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0; /* 配合 flex: 1 解决 scroll-view 撑高问题 */
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ticket-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.tc-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.tc-id { font-size: 28rpx; font-weight: 600; color: #333; }
|
||||
.tc-status {
|
||||
font-size: 22rpx; padding: 4rpx 16rpx; border-radius: 20rpx;
|
||||
&.pending { background: #fef3c7; color: #d97706; }
|
||||
&.processing { background: #dbeafe; color: #2563eb; }
|
||||
}
|
||||
.tc-title { font-size: 28rpx; color: #333; display: block; margin-bottom: 16rpx; }
|
||||
.tc-meta { display: flex; justify-content: space-between; }
|
||||
.tc-type { font-size: 24rpx; color: #999; }
|
||||
.tc-time { font-size: 24rpx; color: #999; }
|
||||
}
|
||||
|
||||
/* 3. 底部固定按钮区域(兼容 iOS 底部安全距离) */
|
||||
.bottom-bar {
|
||||
background: #fff;
|
||||
padding: 20rpx 24rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
border-radius: 44rpx;
|
||||
color: #fff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
|
||||
.cb-icon { font-size: 36rpx; margin-right: 12rpx; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
* 收款账户管理 — 个人收款账户 + 公户收款账户
|
||||
-->
|
||||
<template>
|
||||
<view class="sub-page">
|
||||
<nav-bar title="收款账户" :show-back="true" @back="closeSub()" />
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- 个人收款账户 -->
|
||||
<view class="card">
|
||||
<text class="card-title">个人收款账户</text>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 开户行</text>
|
||||
<input class="fi-input" v-model="form.personal_bank_name" placeholder="如:招商银行深圳科技园支行" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 收款账户号</text>
|
||||
<input class="fi-input" v-model="form.personal_bank_card" placeholder="请输入收款账户号" maxlength="19" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 持卡人姓名</text>
|
||||
<input class="fi-input" v-model="form.personal_name" placeholder="如:张三" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 公户收款账户 -->
|
||||
<view class="card">
|
||||
<text class="card-title">公户收款账户</text>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 开户行</text>
|
||||
<input class="fi-input" v-model="form.company_bank_name" placeholder="如:中国工商银行深圳南山支行" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 收款账户号</text>
|
||||
<input class="fi-input" v-model="form.company_bank_card" placeholder="请输入收款账户号" maxlength="19" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="fi-label"><text class="required">*</text> 公司名称</text>
|
||||
<input class="fi-input" v-model="form.company_name" placeholder="如:深圳某某科技有限公司" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="save-btn" @click="handleSave">保存</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import request from '@/utils/request'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { closeSub } from '@/composables/useNavigation'
|
||||
|
||||
const form = ref({
|
||||
personal_bank_name: '',
|
||||
personal_bank_card: '',
|
||||
personal_name: '',
|
||||
company_bank_name: '',
|
||||
company_bank_card: '',
|
||||
company_name: '',
|
||||
})
|
||||
|
||||
onLoad(async () => {
|
||||
try {
|
||||
const res = await request.get('/c/wallet', { showLoading: false })
|
||||
if (res.data) {
|
||||
form.value.personal_bank_name = res.data.personal?.bank_name || ''
|
||||
form.value.personal_bank_card = res.data.personal?.bank_card || ''
|
||||
form.value.personal_name = res.data.personal?.name || ''
|
||||
form.value.company_bank_name = res.data.company?.bank_name || ''
|
||||
form.value.company_bank_card = res.data.company?.bank_card || ''
|
||||
form.value.company_name = res.data.company?.name || ''
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
const rules = [
|
||||
{ key: 'personal_bank_name', label: '个人开户行' },
|
||||
{ key: 'personal_bank_card', label: '个人收款账户号' },
|
||||
{ key: 'personal_name', label: '持卡人姓名' },
|
||||
{ key: 'company_bank_name', label: '公户开户行' },
|
||||
{ key: 'company_bank_card', label: '公户收款账户号' },
|
||||
{ key: 'company_name', label: '公司名称' },
|
||||
]
|
||||
for (const r of rules) {
|
||||
if (!form.value[r.key]?.trim()) {
|
||||
uni.showToast({ title: '请填写' + r.label, icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
personal_bank_name: form.value.personal_bank_name.trim(),
|
||||
personal_bank_card: form.value.personal_bank_card.trim(),
|
||||
personal_name: form.value.personal_name.trim(),
|
||||
company_bank_name: form.value.company_bank_name.trim(),
|
||||
company_bank_card: form.value.company_bank_card.trim(),
|
||||
company_name: form.value.company_name.trim(),
|
||||
}
|
||||
try {
|
||||
await request.put('/c/wallet', payload, { showLoading: false })
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => closeSub(), 1500)
|
||||
} catch (e) { /* 422 拦截器已处理 */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-page { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { padding: 24rpx; }
|
||||
|
||||
.card {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx;
|
||||
margin-bottom: 24rpx; box-shadow: 0 1px 3px rgba(15,18,38,.04);
|
||||
}
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 24rpx; }
|
||||
|
||||
.form-item { margin-bottom: 24rpx; &:last-child { margin-bottom: 0; } }
|
||||
.fi-label { font-size: 24rpx; color: #5A6072; display: block; margin-bottom: 12rpx; }
|
||||
.required { color: #FF4D4F; }
|
||||
.fi-input {
|
||||
background: #F7F9FC; border: 1px solid #EAEDF3; border-radius: 10rpx;
|
||||
padding: 20rpx 24rpx; font-size: 28rpx; color: #0F1226; width: 100%; box-sizing: border-box;
|
||||
height: 88rpx; line-height: 48rpx;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: linear-gradient(135deg, #1677FF, #0958D9);
|
||||
border-radius: 44rpx; height: 88rpx; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 30rpx; font-weight: 600; margin: 32rpx 0 60rpx;
|
||||
box-shadow: 0 6rpx 20px rgba(22, 119, 255, 0.25);
|
||||
&:active { opacity: .85; }
|
||||
}
|
||||
</style>
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登录",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "全犀云",
|
||||
"backgroundColor": "#f5f7fa",
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/devices/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "我的设备",
|
||||
"backgroundColor": "#f5f7fa",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/revenue/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "收益",
|
||||
"backgroundColor": "#f5f7fa",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "商城",
|
||||
"backgroundColor": "#f5f7fa",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "我的",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-home/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "普通团长首页",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-team/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "普通团长团队",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-revenue/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "普通团长收益",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "普通团长我的",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-super-home/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "超级团长首页",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-super-team/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "超级团长团队",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-super-revenue/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "超级团长收益",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-super-profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "超级团长我的",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-manager-home/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "商务经理首页",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-manager-team/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "商务经理团队",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-manager-revenue/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "商务经理收益",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/b-manager-profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "商务经理我的",
|
||||
"backgroundColor": "#f5f7fa"
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "pages-sub/device",
|
||||
"pages": [
|
||||
{ "path": "detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "设备详情" } },
|
||||
{ "path": "monitor", "style": { "navigationStyle": "custom", "navigationBarTitleText": "实时监控" } },
|
||||
{ "path": "revenue-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "积分明细" } },
|
||||
{ "path": "log", "style": { "navigationStyle": "custom", "navigationBarTitleText": "运行记录" } },
|
||||
{ "path": "add-own", "style": { "navigationStyle": "custom", "navigationBarTitleText": "添加自有设备" } },
|
||||
{ "path": "own-voucher", "style": { "navigationStyle": "custom", "navigationBarTitleText": "凭证管理" } },
|
||||
{ "path": "own-orders", "style": { "navigationStyle": "custom", "navigationBarTitleText": "自有设备订单" } },
|
||||
{ "path": "own-order-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "订单详情" } },
|
||||
{ "path": "deploy-guide", "style": { "navigationStyle": "custom", "navigationBarTitleText": "部署引导" } },
|
||||
{ "path": "docking-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "特征码对接" } },
|
||||
{ "path": "credit-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "信用分详情" } },
|
||||
{ "path": "diagnosis", "style": { "navigationStyle": "custom", "navigationBarTitleText": "诊断报告" } },
|
||||
{ "path": "nat-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "NAT检测" } },
|
||||
{ "path": "deploy-progress", "style": { "navigationStyle": "custom", "navigationBarTitleText": "部署进度" } },
|
||||
{ "path": "device-info", "style": { "navigationStyle": "custom", "navigationBarTitleText": "设备信息" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/mall",
|
||||
"pages": [
|
||||
{ "path": "product-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商品详情" } },
|
||||
{ "path": "coupon-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "加成券详情" } },
|
||||
{ "path": "my-coupons", "style": { "navigationStyle": "custom", "navigationBarTitleText": "我的加成券" } },
|
||||
{ "path": "order-list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商城订单" } },
|
||||
{ "path": "pending-payment", "style": { "navigationStyle": "custom", "navigationBarTitleText": "立即购买" } },
|
||||
{ "path": "pending-order-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "待支付详情" } },
|
||||
{ "path": "welfare", "style": { "navigationStyle": "custom", "navigationBarTitleText": "福利商城" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/revenue",
|
||||
"pages": [
|
||||
{ "path": "points-log", "style": { "navigationStyle": "custom", "navigationBarTitleText": "积分明细" } },
|
||||
{ "path": "withdraw", "style": { "navigationStyle": "custom", "navigationBarTitleText": "积分兑换" } },
|
||||
{ "path": "leaderboard", "style": { "navigationStyle": "custom", "navigationBarTitleText": "排行榜" } },
|
||||
{ "path": "overview", "style": { "navigationStyle": "custom", "navigationBarTitleText": "积分概览" } },
|
||||
{ "path": "calculator", "style": { "navigationStyle": "custom", "navigationBarTitleText": "积分计算器" } },
|
||||
{ "path": "online-reward", "style": { "navigationStyle": "custom", "navigationBarTitleText": "在线激励" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/user",
|
||||
"pages": [
|
||||
{ "path": "level", "style": { "navigationStyle": "custom", "navigationBarTitleText": "用户等级" } },
|
||||
{ "path": "level-rules", "style": { "navigationStyle": "custom", "navigationBarTitleText": "等级规则" } },
|
||||
{ "path": "contracts", "style": { "navigationStyle": "custom", "navigationBarTitleText": "合同管理" } },
|
||||
{ "path": "contract-preview", "style": { "navigationStyle": "custom", "navigationBarTitleText": "合同预览" } },
|
||||
{ "path": "tickets", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工单管理" } },
|
||||
{ "path": "ticket-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "工单详情" } },
|
||||
{ "path": "ticket-create", "style": { "navigationStyle": "custom", "navigationBarTitleText": "新建工单" } },
|
||||
{ "path": "profile-edit", "style": { "navigationStyle": "custom", "navigationBarTitleText": "企业信息" } },
|
||||
{ "path": "partner-apply", "style": { "navigationStyle": "custom", "navigationBarTitleText": "拓展伙伴申请" } },
|
||||
{ "path": "invitation", "style": { "navigationStyle": "custom", "navigationBarTitleText": "推荐有礼" } },
|
||||
{ "path": "poster", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享海报" } },
|
||||
{ "path": "realname-auth", "style": { "navigationStyle": "custom", "navigationBarTitleText": "实名认证" } },
|
||||
{ "path": "settings", "style": { "navigationStyle": "custom", "navigationBarTitleText": "设置" } },
|
||||
{ "path": "promotion-center", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享中心" } },
|
||||
{ "path": "help-center", "style": { "navigationStyle": "custom", "navigationBarTitleText": "帮助中心" } },
|
||||
{ "path": "wallet", "style": { "navigationStyle": "custom", "navigationBarTitleText": "收款账户" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/content",
|
||||
"pages": [
|
||||
{ "path": "notices", "style": { "navigationStyle": "custom", "navigationBarTitleText": "消息中心" } },
|
||||
{ "path": "news-list", "style": { "navigationStyle": "custom", "navigationBarTitleText": "行业资讯" } },
|
||||
{ "path": "news-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "资讯详情" } },
|
||||
{ "path": "notice-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "通知详情" } },
|
||||
{ "path": "docs", "style": { "navigationStyle": "custom", "navigationBarTitleText": "帮助文档" } },
|
||||
{ "path": "doc-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "文档详情" } },
|
||||
{ "path": "software", "style": { "navigationStyle": "custom", "navigationBarTitleText": "平台软件" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "pages-sub/b-promotion",
|
||||
"pages": [
|
||||
{ "path": "promo-home", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享中心" } },
|
||||
{ "path": "leader-promo", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享中心" } },
|
||||
{ "path": "poster", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享海报" } },
|
||||
{ "path": "leader-poster", "style": { "navigationStyle": "custom", "navigationBarTitleText": "超级团长分享海报" } },
|
||||
{ "path": "b-level", "style": { "navigationStyle": "custom", "navigationBarTitleText": "等级权益" } },
|
||||
{ "path": "manager-promo", "style": { "navigationStyle": "custom", "navigationBarTitleText": "分享中心" } },
|
||||
{ "path": "manager-poster", "style": { "navigationStyle": "custom", "navigationBarTitleText": "商务经理分享海报" } },
|
||||
{ "path": "manager-level", "style": { "navigationStyle": "custom", "navigationBarTitleText": "经理等级权益" } },
|
||||
{ "path": "leader-detail", "style": { "navigationStyle": "custom", "navigationBarTitleText": "团长详情" } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "全犀云",
|
||||
"navigationBarBackgroundColor": "#ffffff",
|
||||
"backgroundColor": "#f5f7fa",
|
||||
"navigationStyle": "custom"
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#999999",
|
||||
"selectedColor": "#1677ff",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{ "pagePath": "pages/index/index", "text": "首页" },
|
||||
{ "pagePath": "pages/devices/index", "text": "设备" },
|
||||
{ "pagePath": "pages/revenue/index", "text": "收益" },
|
||||
{ "pagePath": "pages/mall/index", "text": "商城" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- 自定义顶部导航栏 -->
|
||||
<BTopBar
|
||||
title="全犀云"
|
||||
:showBell="true"
|
||||
@bellClick="openSub('notifications')"
|
||||
/>
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<!-- Hero Card -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-grid-bg"></view>
|
||||
<view class="hero-row1">
|
||||
<text class="hero-greeting">{{ greetingText }},{{ userName }}</text>
|
||||
<view class="hero-level-chip" @click="openSub('b-level')">
|
||||
<text class="chip-star">★</text>
|
||||
<text>{{ dashboard.level_name }}</text>
|
||||
<text class="chip-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hero-label">总收益积分</text>
|
||||
<view class="hero-amount-row">
|
||||
<text class="hero-amount">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hero-unit">累计已结算</text>
|
||||
</view>
|
||||
<view class="hero-rate">
|
||||
<text class="rate-tag">积分说明</text>
|
||||
<text class="rate-text">1元 = 1积分</text>
|
||||
</view>
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">总积分</text>
|
||||
<text class="hs-val">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hs-sub">累计激励金</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">待生效积分</text>
|
||||
<text class="hs-val">{{ dashboard.frozen_points ?? 0 }}</text>
|
||||
<text class="hs-sub">T+1生效</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">总金额</text>
|
||||
<text class="hs-val">¥{{ dashboard.total_amount ?? 0 }}</text>
|
||||
<text class="hs-sub">C端消费总额</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Notice Bar -->
|
||||
<view class="notice-bar" @click="openSub('notifications')">
|
||||
<view class="nb-icon">
|
||||
<u-icon name="volume" size="14" color="#FF8800"></u-icon>
|
||||
</view>
|
||||
<text class="nb-text">{{ noticeText }}</text>
|
||||
<text class="nb-more">更多 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">数据概览</text>
|
||||
</view>
|
||||
<view class="monitor-grid-v2">
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#E6F4FF,#F0F7FF)">
|
||||
<text class="mg-title">总积分</text>
|
||||
<text class="mg-val" style="color:#1677FF">{{ formatThousands(dashboard.total_points || 0) }}</text>
|
||||
<text class="mg-trend">累计激励金</text>
|
||||
</view>
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#FFF7E6,#FFFBF0)">
|
||||
<text class="mg-title">待生效积分</text>
|
||||
<text class="mg-val" style="color:#FF8800">{{ formatThousands(dashboard.frozen_points || 0) }}</text>
|
||||
<text class="mg-trend">T+1生效</text>
|
||||
</view>
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#E6F4FF,#F0F7FF)">
|
||||
<text class="mg-title">总金额</text>
|
||||
<text class="mg-val" style="color:#1677FF">¥{{ formatThousands(dashboard.total_amount || 0) }}</text>
|
||||
<text class="mg-trend">C端消费总额</text>
|
||||
</view>
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#F9F0FF,#FAF5FF)">
|
||||
<text class="mg-title">活跃C端</text>
|
||||
<text class="mg-val" style="color:#722ED1">{{ dashboard.active_c_count || 0 }}</text>
|
||||
<text class="mg-trend">7日内活跃</text>
|
||||
</view>
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#FFF1F0,#FFF5F5)">
|
||||
<text class="mg-title">设备总数</text>
|
||||
<text class="mg-val">{{ dashboard.device_count || 0 }}<text class="mg-unit">台</text></text>
|
||||
<text class="mg-trend">已支付订单</text>
|
||||
</view>
|
||||
<view class="mg-card" style="background:linear-gradient(135deg,#E6FAF5,#F0FBF8)">
|
||||
<text class="mg-title">本月新增C端</text>
|
||||
<text class="mg-val" style="color:#00C4A7">{{ dashboard.new_c_count || 0 }}</text>
|
||||
<text class="mg-trend">本月注册</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">快捷操作</text>
|
||||
</view>
|
||||
<view class="quick-grid-4">
|
||||
<view class="quick-item" @click="openSub('b-sub-leader-promo')">
|
||||
<view class="qi-icon" style="background:#FFE4EC">
|
||||
<text class="qi-emoji">❤️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享中心</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="openSub('b-team')">
|
||||
<view class="qi-icon" style="background:#E6F4FF">
|
||||
<text class="qi-emoji">👥</text>
|
||||
</view>
|
||||
<text class="qi-label">我的团队</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="openSub('b-sub-poster')">
|
||||
<view class="qi-icon" style="background:#F9F0FF">
|
||||
<text class="qi-emoji">🖼️</text>
|
||||
</view>
|
||||
<text class="qi-label">生成海报</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="switchTab('b-revenue')">
|
||||
<view class="qi-icon" style="background:#E6FAF5">
|
||||
<text class="qi-emoji">💰</text>
|
||||
</view>
|
||||
<text class="qi-label">积分明细</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 直推用户贡献榜 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">直推用户贡献榜</text>
|
||||
<text class="section-more" @click="switchTab('b-team')">全部 ›</text>
|
||||
</view>
|
||||
<view class="card leaderboard-card">
|
||||
<view v-if="leaderboard.length === 0" class="lb-empty">暂无直属C端用户</view>
|
||||
<view class="lb-item" v-for="(item, idx) in leaderboard" :key="idx">
|
||||
<view class="lb-rank" :class="['gold', 'silver', 'bronze'][idx] || ''">{{ item.rank }}</view>
|
||||
<text class="lb-name">{{ item.name }}</text>
|
||||
<text class="lb-val">¥{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新动态 -->
|
||||
<NewsFeed ref="newsFeedRef" role="leader" @item-click="handleNewsClick" />
|
||||
|
||||
<!-- Bottom safe area -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="0"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
import NewsFeed from '@/components/NewsFeed.vue'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const noticeText = ref('')
|
||||
|
||||
const fetchNotice = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/notices', { limit: 1, showLoading: false })
|
||||
const items = res.data?.items || res.data || []
|
||||
noticeText.value = items[0]?.title || ''
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const userName = computed(() => {
|
||||
const info = uni.getStorageSync('userInfo')
|
||||
return info?.nickname || '用户'
|
||||
})
|
||||
|
||||
const greetingText = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 6 && hour < 12) return '上午好'
|
||||
if (hour >= 12 && hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const dashboard = ref({
|
||||
total_points: 0,
|
||||
frozen_points: 0,
|
||||
total_amount: 0,
|
||||
active_c_count: 0,
|
||||
device_count: 0,
|
||||
new_c_count: 0,
|
||||
level_name: 'VIP1'
|
||||
})
|
||||
const leaderboard = ref([])
|
||||
|
||||
let fetchingDashboard = false
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
if (fetchingDashboard) return
|
||||
fetchingDashboard = true
|
||||
try {
|
||||
const res = await request.get('/c/leader/dashboard', { showLoading: false })
|
||||
if (res.data) {
|
||||
dashboard.value = res.data
|
||||
leaderboard.value = res.data.leaderboard || []
|
||||
}
|
||||
} catch (e) { console.error('获取团长首页失败', e) }
|
||||
finally { fetchingDashboard = false }
|
||||
}
|
||||
|
||||
const handleNewsClick = (item) => {
|
||||
openSub('notice-detail', { id: item.id })
|
||||
}
|
||||
|
||||
const newsFeedRef = ref(null)
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([
|
||||
fetchDashboard(),
|
||||
fetchNotice(),
|
||||
newsFeedRef.value?.fetchNews?.()
|
||||
]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchDashboard(), fetchNotice()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Hero Card */
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
padding: 44rpx 36rpx 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
}
|
||||
|
||||
.hero-grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 60rpx 60rpx;
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
-webkit-mask-image: -webkit-linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 8rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-greeting {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.hero-level-chip {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, rgba(255,215,0,.95), rgba(255,165,0,.95));
|
||||
color: #0F3460;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(255,165,0,.45);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chip-star { font-size: 20rpx; }
|
||||
.chip-arrow { opacity: 0.7; }
|
||||
|
||||
.hero-label {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.55;
|
||||
margin-bottom: 12rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.hero-amount-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
font-size: 76rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2rpx;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(180deg, #fff, #E6F4FF);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero-unit {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.55;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hero-rate {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255,255,255,.7);
|
||||
margin-top: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rate-tag {
|
||||
background: rgba(255,255,255,.15);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
|
||||
.rate-text { opacity: 0.7; }
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 2rpx;
|
||||
margin-top: 44rpx;
|
||||
background: rgba(255,255,255,.1);
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
background: rgba(255,255,255,.07);
|
||||
padding: 26rpx 20rpx;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.hero-stat:active { background: rgba(255,255,255,.14); }
|
||||
|
||||
.hs-label { font-size: 22rpx; opacity: 0.65; display: block; }
|
||||
.hs-val { font-size: 38rpx; font-weight: 700; margin-top: 4rpx; display: block; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
.hs-sub { font-size: 20rpx; opacity: 0.55; margin-top: 2rpx; display: block; }
|
||||
.hs-sub.up { color: #7FFFD4; opacity: 1; }
|
||||
|
||||
/* Notice Bar */
|
||||
.notice-bar {
|
||||
background: #fff;
|
||||
margin: 28rpx 24rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
font-size: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: linear-gradient(180deg, #FF8800, #FFB84D);
|
||||
}
|
||||
|
||||
.nb-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6, #FFE7BA);
|
||||
border-radius: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255,136,0,.15);
|
||||
}
|
||||
|
||||
.nb-text { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #5A6072; font-size: 24rpx; }
|
||||
.nb-more { color: #BCC2CE; font-size: 22rpx; flex-shrink: 0; }
|
||||
|
||||
/* Section */
|
||||
.section { margin: 32rpx 0 0; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 32rpx; font-weight: 700; color: #0F1226; display: flex; align-items: center; gap: 12rpx; }
|
||||
.section-title::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; }
|
||||
.section-more { font-size: 24rpx; color: #8B91A1; }
|
||||
|
||||
/* Monitor Grid V2 (3 cols) */
|
||||
.monitor-grid-v2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mg-card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.mg-title { font-size: 20rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mg-val { font-size: 40rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mg-trend { font-size: 18rpx; color: #8B91A1; display: block; margin-top: 6rpx; }
|
||||
.mg-unit { font-size: 24rpx; font-weight: 400; color: #8B91A1; margin-left: 2rpx; }
|
||||
|
||||
/* Quick Grid 4 */
|
||||
.quick-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-item:active { transform: scale(0.95); box-shadow: 0 8rpx 32rpx rgba(15,18,38,.06); }
|
||||
|
||||
.qi-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
/* Leaderboard */
|
||||
.leaderboard-card { padding: 0; }
|
||||
.lb-empty { padding: 40rpx 32rpx; text-align: center; font-size: 24rpx; color: #BCC2CE; }
|
||||
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
}
|
||||
|
||||
.lb-item + .lb-item { border-top: 1rpx solid #F3F5F9; }
|
||||
|
||||
.lb-rank {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #F3F5F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 24rpx;
|
||||
flex-shrink: 0;
|
||||
color: #5A6072;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.lb-rank.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; box-shadow: 0 4rpx 16rpx rgba(255,165,0,.4); }
|
||||
.lb-rank.silver { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); color: #fff; box-shadow: 0 4rpx 16rpx rgba(168,174,192,.4); }
|
||||
.lb-rank.bronze { background: linear-gradient(135deg, #CD7F32, #A0522D); color: #fff; box-shadow: 0 4rpx 16rpx rgba(160,82,45,.4); }
|
||||
|
||||
.lb-name { flex: 1; font-size: 26rpx; font-weight: 500; }
|
||||
.lb-val { font-weight: 700; color: #1677FF; font-size: 28rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
/* Card */
|
||||
.card { background: #fff; border-radius: 28rpx; padding: 32rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); }
|
||||
|
||||
/* Bottom Spacer */
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 200rpx);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- 自定义顶部导航栏 -->
|
||||
<BTopBar
|
||||
title="全犀云"
|
||||
:showBell="true"
|
||||
@bellClick="openSub('notifications')"
|
||||
/>
|
||||
|
||||
<!-- 滚动区域:使用 flex:1 + height:0 实现精准防遮挡布局 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="page-content"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Hero Card -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-grid-bg"></view>
|
||||
<view class="hero-row1">
|
||||
<text class="hero-greeting">{{ greetingText }},{{ userName }}</text>
|
||||
<view class="hero-level-chip" @click="openSub('b-sub-manager-level')">
|
||||
<text class="chip-star">★</text>
|
||||
<text>{{ dashboard.level_name }}</text>
|
||||
<text class="chip-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hero-label">总收益积分</text>
|
||||
<view class="hero-amount-row">
|
||||
<text class="hero-amount">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hero-unit">累计已结算</text>
|
||||
</view>
|
||||
<view class="hero-rate">
|
||||
<text class="rate-tag">积分说明</text>
|
||||
<text class="rate-text">1元 = 1积分</text>
|
||||
</view>
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">下属团长</text>
|
||||
<text class="hs-val">{{ dashboard.leader_count || 0 }}<text class="hs-val-unit"> 人</text></text>
|
||||
<text class="hs-sub">商务经理下属</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">团长直接推荐C端</text>
|
||||
<text class="hs-val">{{ dashboard.total_c_count || 0 }}<text class="hs-val-unit"> 人</text></text>
|
||||
<text class="hs-sub">下属团长直接推荐</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">本月积分</text>
|
||||
<text class="hs-val">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hs-sub">累计激励金</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Notice Bar -->
|
||||
<view class="notice-bar" @click="openSub('notifications')">
|
||||
<view class="nb-icon">
|
||||
<text class="nb-icon-emoji">📢</text>
|
||||
</view>
|
||||
<text class="nb-text">{{ noticeText }}</text>
|
||||
<text class="nb-more">更多 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">数据概览</text>
|
||||
</view>
|
||||
<view class="monitor-grid">
|
||||
<view
|
||||
v-for="(item, idx) in monitorList"
|
||||
:key="idx"
|
||||
class="mg-item"
|
||||
:style="{ background: item.bg }"
|
||||
>
|
||||
<text class="mg-label">{{ item.title }}</text>
|
||||
<text class="mg-val" :style="{ color: item.valColor || '#0F1226' }">
|
||||
{{ item.value }}<text v-if="item.unit" class="mg-unit">{{ item.unit }}</text>
|
||||
</text>
|
||||
<text class="mg-trend" :style="{ color: item.trendColor || '#8B91A1' }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">快捷操作</text>
|
||||
</view>
|
||||
<view class="quick-grid">
|
||||
<view
|
||||
v-for="(item, idx) in quickActions"
|
||||
:key="idx"
|
||||
class="quick-item"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<view class="qi-icon" :style="{ background: item.bg }">
|
||||
<text class="qi-emoji">{{ item.emoji }}</text>
|
||||
</view>
|
||||
<text class="qi-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 直属团长贡献榜 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">直属团长贡献榜</text>
|
||||
<text class="section-more" @click="switchTab('b-manager-team')">全部 ›</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view v-for="(item, idx) in leaderboard" :key="idx" class="lb-item">
|
||||
<view class="lb-rank" :class="['gold', 'silver', 'bronze'][idx] || ''">{{ item.rank }}</view>
|
||||
<text class="lb-name">{{ item.name }}</text>
|
||||
<text class="lb-val">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新动态 -->
|
||||
<NewsFeed ref="newsFeedRef" role="manager" @item-click="handleNewsClick" />
|
||||
|
||||
<!-- 底部安全区与 TabBar 占位 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="0"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import NewsFeed from '@/components/NewsFeed.vue'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const noticeText = ref('')
|
||||
|
||||
const fetchNotice = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/notices', { limit: 1, showLoading: false })
|
||||
const items = res.data?.items || res.data || []
|
||||
noticeText.value = items[0]?.title || ''
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const userName = computed(() => {
|
||||
const info = uni.getStorageSync('userInfo')
|
||||
return info?.nickname || '用户'
|
||||
})
|
||||
|
||||
const greetingText = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h >= 6 && h < 12) return '早上好'
|
||||
if (h >= 12 && h < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const dashboard = ref({
|
||||
total_points: 0, frozen_points: 0, total_amount: 0,
|
||||
active_leader_count: 0, device_count: 0,
|
||||
leader_count: 0, total_c_count: 0, level_name: 'VIP1'
|
||||
})
|
||||
|
||||
const leaderboard = ref([])
|
||||
|
||||
const monitorList = ref([
|
||||
{ title: '总积分', value: '0', trend: '累计激励金', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '待生效积分', value: '0', trend: 'T+1生效', bg: 'linear-gradient(135deg,#FFF7E6,#FFFBF0)', valColor: '#FF8800' },
|
||||
{ title: '活跃团长', value: '0', trend: '7日内活跃', bg: 'linear-gradient(135deg,#E6FAF5,#F0FBF8)' },
|
||||
{ title: '活跃C端', value: '0', trend: '团长直接推荐', bg: 'linear-gradient(135deg,#F3E8FF,#FAF5FF)', valColor: '#722ED1' },
|
||||
{ title: '设备总数', value: '0', unit: '台', trend: '已支付订单', bg: 'linear-gradient(135deg,#FFF1F0,#FFF5F5)' },
|
||||
{ title: '总金额', value: '¥0', trend: 'C端消费总额', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' }
|
||||
])
|
||||
|
||||
const quickActions = ref([
|
||||
{ label: '分享中心', emoji: '❤️', bg: '#FFE4E6', type: 'sub', target: 'b-sub-manager-promo' },
|
||||
{ label: '我的团队', emoji: '👥', bg: '#E6F4FF', type: 'tab', target: 'b-manager-team' },
|
||||
{ label: '生成海报', emoji: '🖼️', bg: '#F3E8FF', type: 'sub', target: 'b-sub-manager-poster' },
|
||||
{ label: '积分明细', emoji: '💰', bg: '#E6FAF5', type: 'tab', target: 'b-manager-revenue' }
|
||||
])
|
||||
|
||||
const handleAction = (item) => {
|
||||
if (item.type === 'sub') { openSub(item.target) }
|
||||
else if (item.type === 'tab') { switchTab(item.target) }
|
||||
}
|
||||
|
||||
let fetchingDashboard = false
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
if (fetchingDashboard) return
|
||||
fetchingDashboard = true
|
||||
try {
|
||||
const res = await request.get('/c/manager/dashboard', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
dashboard.value = d
|
||||
monitorList.value = [
|
||||
{ title: '总积分', value: formatThousands(d.total_points || 0), trend: '累计激励金', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '待生效积分', value: formatThousands(d.frozen_points || 0), trend: 'T+1生效', bg: 'linear-gradient(135deg,#FFF7E6,#FFFBF0)', valColor: '#FF8800' },
|
||||
{ title: '活跃团长', value: String(d.active_leader_count || 0), trend: '/ ' + (d.leader_count || 0) + ' 下属', bg: 'linear-gradient(135deg,#E6FAF5,#F0FBF8)' },
|
||||
{ title: '活跃C端', value: String(d.total_c_count || 0), trend: '团长直接推荐', bg: 'linear-gradient(135deg,#F3E8FF,#FAF5FF)', valColor: '#722ED1' },
|
||||
{ title: '设备总数', value: String(d.device_count || 0), unit: '台', trend: '已支付订单', bg: 'linear-gradient(135deg,#FFF1F0,#FFF5F5)' },
|
||||
{ title: '总金额', value: '¥' + formatThousands(d.total_amount || 0), trend: 'C端消费总额', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' }
|
||||
]
|
||||
leaderboard.value = d.leaderboard || []
|
||||
}
|
||||
} catch (e) { console.error('获取经理首页失败', e) }
|
||||
finally { fetchingDashboard = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchDashboard(), fetchNotice()])
|
||||
})
|
||||
|
||||
const newsFeedRef = ref(null)
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([
|
||||
fetchDashboard(),
|
||||
fetchNotice(),
|
||||
newsFeedRef.value?.fetchNews?.()
|
||||
]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
const handleNewsClick = (item) => { openSub('notice-detail', { id: item.id }) }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根节点采用 Flex 布局 */
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
/* 滚动区域填满剩余高度 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 160rpx);
|
||||
}
|
||||
|
||||
/* Hero Card */
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
padding: 40rpx 32rpx 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -160rpx;
|
||||
right: -120rpx;
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(64,150,255,.25), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 80rpx;
|
||||
left: -100rpx;
|
||||
width: 320rpx;
|
||||
height: 320rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,196,167,.18), transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 60rpx 60rpx;
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 8rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-greeting {
|
||||
font-size: 24rpx;
|
||||
opacity: .7;
|
||||
letter-spacing: .6rpx;
|
||||
}
|
||||
|
||||
.hero-level-chip {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, rgba(255,215,0,.95), rgba(255,165,0,.95));
|
||||
color: #0F3460;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(255,165,0,.45);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-star { font-size: 22rpx; }
|
||||
.chip-arrow { opacity: .7; }
|
||||
|
||||
.hero-label {
|
||||
font-size: 24rpx;
|
||||
opacity: .55;
|
||||
margin-bottom: 12rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-amount-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
font-size: 76rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2rpx;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(180deg, #fff, #E6F4FF);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-unit {
|
||||
font-size: 26rpx;
|
||||
opacity: .55;
|
||||
font-weight: 400;
|
||||
margin-left: 12rpx;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
.hero-rate {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255,255,255,.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
margin-top: 4rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rate-tag {
|
||||
background: rgba(255,255,255,.15);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
|
||||
.rate-text { opacity: .7; }
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 2rpx;
|
||||
margin-top: 40rpx;
|
||||
background: rgba(255,255,255,.1);
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
background: rgba(255,255,255,.07);
|
||||
padding: 26rpx 20rpx;
|
||||
backdrop-filter: blur(20rpx);
|
||||
-webkit-backdrop-filter: blur(20rpx);
|
||||
transition: background .2s;
|
||||
position: relative;
|
||||
|
||||
&:active { background: rgba(255,255,255,.14); }
|
||||
}
|
||||
|
||||
.hs-label { font-size: 22rpx; opacity: .65; display: block; }
|
||||
.hs-val { font-size: 38rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin-top: 4rpx; }
|
||||
.hs-val-unit { font-size: 24rpx; font-weight: 400; }
|
||||
.hs-sub { font-size: 20rpx; opacity: .55; display: block; margin-top: 2rpx; }
|
||||
.hs-sub.up { color: #7FFFD4; opacity: 1; }
|
||||
|
||||
/* Notice Bar */
|
||||
.notice-bar {
|
||||
background: #fff;
|
||||
margin: 24rpx 24rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
font-size: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: linear-gradient(180deg, #FF8800, #FFB84D);
|
||||
}
|
||||
}
|
||||
|
||||
.nb-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6, #FFE7BA);
|
||||
border-radius: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255,136,0,.15);
|
||||
}
|
||||
|
||||
.nb-icon-emoji { font-size: 24rpx; }
|
||||
.nb-text { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #5A6072; }
|
||||
.nb-more { color: #BCC2CE; font-size: 22rpx; flex-shrink: 0; }
|
||||
|
||||
/* Section */
|
||||
.section { padding: 0 24rpx; margin-top: 24rpx; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 24rpx;
|
||||
color: #8B91A1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
/* Monitor Grid */
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mg-item {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.mg-label { font-size: 20rpx; color: #8B91A1; display: block; }
|
||||
.mg-val { font-size: 44rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin: 8rpx 0; }
|
||||
.mg-unit { font-size: 24rpx; font-weight: 400; color: #8B91A1; margin-left: 2rpx; }
|
||||
.mg-trend { font-size: 18rpx; color: #8B91A1; display: block; }
|
||||
|
||||
/* Quick Grid */
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all .2s;
|
||||
|
||||
&:active { transform: scale(.95); box-shadow: 0 4rpx 16rpx rgba(15,18,38,.06); }
|
||||
}
|
||||
|
||||
.qi-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 0 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
/* Leaderboard */
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx 0;
|
||||
|
||||
&+& { border-top: 1rpx solid #F3F5F9; }
|
||||
}
|
||||
|
||||
.lb-rank {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #F3F5F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 24rpx;
|
||||
flex-shrink: 0;
|
||||
color: #5A6072;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
|
||||
&.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; box-shadow: 0 4rpx 16rpx rgba(255,165,0,.4); }
|
||||
&.silver { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); color: #fff; box-shadow: 0 4rpx 16rpx rgba(168,174,192,.4); }
|
||||
&.bronze { background: linear-gradient(135deg, #CD7F32, #A0522D); color: #fff; box-shadow: 0 4rpx 16rpx rgba(160,82,45,.4); }
|
||||
}
|
||||
|
||||
.lb-name { flex: 1; font-size: 26rpx; font-weight: 500; color: #0F1226; }
|
||||
.lb-val { font-weight: 700; color: #1677FF; font-size: 28rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
</style>
|
||||
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="我的" :showSettings="true" @settingsClick="openSub('settings')" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<!-- Profile Card -->
|
||||
<view class="profile-card">
|
||||
<view class="pc-banner"></view>
|
||||
<view class="pc-body">
|
||||
<view class="pc-avatar-row">
|
||||
<view class="pc-avatar">
|
||||
<text>{{ (profile.name || '用')[0] }}</text>
|
||||
<view class="pc-avatar-badge">
|
||||
<text class="pc-avatar-check">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pc-info">
|
||||
<text class="pc-name">{{ profile.name }}</text>
|
||||
<view class="pc-tags">
|
||||
<view class="pc-level-chip" @click="openSub('b-sub-manager-level')">
|
||||
<text class="pc-chip-star">★</text>
|
||||
<text>{{ profile.level }}</text>
|
||||
<text class="pc-chip-arrow">›</text>
|
||||
</view>
|
||||
<text class="pc-id">ID: {{ profile.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="pc-progress" @click="openSub('b-sub-manager-level')">
|
||||
<view class="pc-progress-header">
|
||||
<text class="pc-progress-label">等级进度 · {{ profile.isMaxLevel ? profile.nextLevelName : '距离' + profile.nextLevelName }}</text>
|
||||
<text class="pc-progress-val">{{ profile.displayGrowthValue.toLocaleString() }} / {{ profile.targetPoints.toLocaleString() }} 成长值</text>
|
||||
</view>
|
||||
<view class="pc-progress-bar">
|
||||
<view class="pc-progress-fill" :style="{ width: profile.levelProgress + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享工具 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">分享工具</text>
|
||||
</view>
|
||||
<view class="quick-grid">
|
||||
<view class="quick-item" @click="openSub('b-sub-manager-promo')">
|
||||
<view class="qi-icon" style="background:#FFE4E6">
|
||||
<text class="qi-emoji">❤️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享中心</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="switchTab('b-manager-team')">
|
||||
<view class="qi-icon" style="background:#E6F4FF">
|
||||
<text class="qi-emoji">👥</text>
|
||||
</view>
|
||||
<text class="qi-label">我的团队</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="openSub('b-sub-manager-poster')">
|
||||
<view class="qi-icon" style="background:#F3E8FF">
|
||||
<text class="qi-emoji">🖼️</text>
|
||||
</view>
|
||||
<text class="qi-label">生成海报</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="switchTab('b-manager-revenue')">
|
||||
<view class="qi-icon" style="background:#E6FAF5">
|
||||
<text class="qi-emoji">💰</text>
|
||||
</view>
|
||||
<text class="qi-label">积分明细</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 业务中心 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">业务中心</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="switchTab('b-manager-team')">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">👥</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">推荐记录</text>
|
||||
<text class="li-sub">{{ inviteSub }}</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="switchTab('b-manager-revenue')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💰</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">积分动态</text>
|
||||
<text class="li-sub">即时积分+长期积分明细</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('withdraw')">
|
||||
<view class="li-icon" style="background:#FFF7E6">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">兑换记录</text>
|
||||
<text class="li-sub">积分余额·兑换·兑换记录</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('b-sub-manager-level')">
|
||||
<view class="li-icon" style="background:#F3E8FF">
|
||||
<text class="li-emoji">⭐</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">等级规则</text>
|
||||
<text class="li-sub">等级条件·奖励比例</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">🪪</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">实名认证</text>
|
||||
<text class="li-sub">身份证·人脸识别·企业资质</text>
|
||||
</view>
|
||||
<text class="li-badge li-badge-amber">待认证</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('wallet')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">收款账户</text>
|
||||
<text class="li-sub">管理个人/公户收款账户</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('help-center')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">❓</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">帮助中心</text>
|
||||
<text class="li-sub">FAQ·在线客服·常见问题</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设置 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">设置</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="openSub('settings')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">⚙️</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">账号设置</text>
|
||||
<text class="li-sub">安全·通知·隐私</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" @click="handleLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<custom-tab-bar :current="3"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const refreshing = ref(false)
|
||||
|
||||
const loginUser = uni.getStorageSync('userInfo') || {}
|
||||
const profile = ref({
|
||||
name: loginUser.nickname || '用户',
|
||||
id: 'B' + (loginUser.id || '--'),
|
||||
level: 'VIP1',
|
||||
levelProgress: 0, currentGrowthValue: 0, displayGrowthValue: 0, isMaxLevel: false, nextLevelName: '--', targetPoints: 0
|
||||
})
|
||||
|
||||
const inviteSub = ref('下属团长0人 · 团长直接推荐0人')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/manager/profile', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
profile.value.level = d.level || 'VIP1'
|
||||
profile.value.levelProgress = d.level_progress || 0
|
||||
profile.value.currentGrowthValue = d.growth_value || 0
|
||||
profile.value.nextLevelName = d.next_level_name || '--'
|
||||
profile.value.targetPoints = d.target_points || 0
|
||||
profile.value.displayGrowthValue = profile.value.targetPoints > 0 ? Math.min(profile.value.currentGrowthValue, profile.value.targetPoints) : profile.value.currentGrowthValue
|
||||
profile.value.isMaxLevel = profile.value.targetPoints > 0 && profile.value.currentGrowthValue >= profile.value.targetPoints
|
||||
inviteSub.value = `下属团长${d.direct_leader_count || 0}人 · 团长直接推荐${d.total_c_count || 0}人`
|
||||
}
|
||||
} catch (e) { console.error('获取经理信息失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
fetchData().finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.safe-bottom-spacer { height: calc(140rpx + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* Profile Card */
|
||||
.profile-card {
|
||||
margin: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 36rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pc-banner {
|
||||
height: 160rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6 0%, #FFFBF0 100%);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,136,0,.08), transparent 70%);
|
||||
}
|
||||
}
|
||||
|
||||
.pc-body {
|
||||
padding: 0 32rpx 8rpx;
|
||||
margin-top: -80rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pc-avatar-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.pc-avatar {
|
||||
width: 136rpx;
|
||||
height: 136rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF8800, #FFB84D);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 32rpx rgba(255,136,0,.35);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pc-avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -4rpx;
|
||||
right: -4rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: #00C4A7;
|
||||
border: 4rpx solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pc-avatar-check {
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pc-info { flex: 1; padding-bottom: 8rpx; }
|
||||
|
||||
.pc-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #0F1226;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pc-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pc-level-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
box-shadow: 0 4rpx 12rpx rgba(22,119,255,.3);
|
||||
}
|
||||
|
||||
.pc-chip-star { font-size: 20rpx; }
|
||||
.pc-chip-arrow { opacity: .7; }
|
||||
|
||||
.pc-id { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
.pc-progress {
|
||||
padding: 28rpx 32rpx 12rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pc-progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.pc-progress-label { font-size: 24rpx; color: #8B91A1; }
|
||||
.pc-progress-val { font-size: 24rpx; color: #FF8800; font-weight: 600; }
|
||||
|
||||
.pc-progress-bar {
|
||||
height: 8rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pc-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FF8800, #FFB84D);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section { padding: 0 24rpx; margin-top: 24rpx; }
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick Grid */
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all .2s;
|
||||
|
||||
&:active { transform: scale(.96); box-shadow: 0 4rpx 16rpx rgba(15,18,38,.06); }
|
||||
}
|
||||
|
||||
.qi-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 12rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
|
||||
.qi-label { font-size: 22rpx; color: #5A6072; }
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 20rpx;
|
||||
transition: background .15s;
|
||||
|
||||
&+& { border-top: 1px solid #F3F5F9; }
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
|
||||
.li-icon {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-emoji { font-size: 36rpx; }
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.li-sub { font-size: 24rpx; color: #8B91A1; display: block; margin-top: 4rpx; }
|
||||
|
||||
.li-badge {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.li-badge-green { background: #E6FAF5; color: #00C4A7; }
|
||||
.li-badge-amber { background: #FFF7E6; color: #D97706; }
|
||||
|
||||
/* Logout */
|
||||
.logout-btn {
|
||||
margin-top: 40rpx;
|
||||
background: #fff;
|
||||
border: 2rpx solid #FF4D4F;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
color: #FF4D4F;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="收益积分" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<view class="content-pad">
|
||||
<!-- Revenue Hero -->
|
||||
<view class="rev-hero">
|
||||
<text class="rh-label">可兑换收益积分</text>
|
||||
<text class="rh-amount">{{ withdrawable }}</text>
|
||||
<view class="rh-btns">
|
||||
<view class="rh-btn rh-btn-primary" @click="openSub('withdraw')">兑换</view>
|
||||
<view class="rh-btn rh-btn-secondary" @click="showToast('积分明细加载中')">积分明细</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Monitor Grid -->
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card" v-for="(item, idx) in monitorData" :key="idx">
|
||||
<text class="mc-title">{{ item.label }}</text>
|
||||
<text class="mc-val" :style="{ color: item.color || '#0F1226' }">{{ item.value }}</text>
|
||||
<text class="mc-trend" v-if="item.trend" :style="{ color: item.trendColor || '#8B91A1' }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Source Card -->
|
||||
<view class="card">
|
||||
<text class="card-title">收益积分来源构成 <text class="card-title-tag">商务经理·下属团长贡献</text></text>
|
||||
|
||||
<view class="source-header">
|
||||
<text class="source-header-bar"></text>
|
||||
<text class="source-header-text">下属团长贡献</text>
|
||||
</view>
|
||||
|
||||
<view class="info-row" v-for="(item, idx) in sourceData" :key="idx">
|
||||
<text class="ir-label" :style="{ color: item.color }">{{ item.label }}</text>
|
||||
<text class="ir-val" :style="{ color: item.color }">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Detail Card -->
|
||||
<view class="card">
|
||||
<view class="card-header-row">
|
||||
<text class="card-title">收益积分明细</text>
|
||||
<!-- <view class="filter-tabs">
|
||||
<text class="ft-item active">全部</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view v-if="filteredList.length === 0" class="empty-holder">
|
||||
<text class="empty-text">暂无相关收益记录</text>
|
||||
</view>
|
||||
|
||||
<view class="pts-list">
|
||||
<view class="pts-item" v-for="(item, idx) in filteredList" :key="idx">
|
||||
<view class="pts-icon" :style="{ background: item.iconBg }">
|
||||
<text class="pts-icon-emoji">{{ item.icon }}</text>
|
||||
</view>
|
||||
<view class="pts-body">
|
||||
<text class="pts-title">{{ item.title }}</text>
|
||||
<text class="pts-sub">{{ item.date }} · {{ item.desc }}</text>
|
||||
</view>
|
||||
<view class="pts-right">
|
||||
<text class="pts-amount" :style="{ color: item.amountColor }">{{ item.amount }}</text>
|
||||
<text class="pts-status" :style="{ background: item.statusBg, color: item.statusColor }">{{ item.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="2"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const withdrawable = ref('0')
|
||||
|
||||
const sourceData = ref([{ label: '下属团长激励金收益', value: '0', color: '#00C4A7' }])
|
||||
|
||||
const monitorData = ref([
|
||||
{ label: '今日积分', value: '0', color: '#1677FF', trend: null },
|
||||
{ label: '本月积分', value: '0' }, { label: '昨日积分', value: '0' }, { label: '历史总积分', value: '0' }
|
||||
])
|
||||
|
||||
const revList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const filteredList = computed(() => revList.value)
|
||||
|
||||
const typeMap = {
|
||||
commission: { icon: '💰', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已发放', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
unfrozen: { icon: '🔓', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已解冻', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
withdraw: { icon: '💳', iconBg: '#F0F2F5', amountColor: '#6B7280', status: '已发放', statusBg: '#F0F2F5', statusColor: '#6B7280' },
|
||||
gift: { icon: '🎁', iconBg: '#FFFBE6', amountColor: '#FFD666', status: '不可兑换', statusBg: '#FFFBE6', statusColor: '#FF8800' }
|
||||
}
|
||||
|
||||
const withdrawalStatusMap = {
|
||||
pending: { status: '待审核', statusBg: '#FFF7E6', statusColor: '#FF8800' },
|
||||
approved: { status: '待打款', statusBg: '#E6F4FF', statusColor: '#1677FF' },
|
||||
paid: { status: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
rejected: { status: '已拒绝', statusBg: '#FFF1F0', statusColor: '#FF4D4F' }
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/manager/revenue-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
withdrawable.value = formatThousands(d.withdrawable || 0)
|
||||
monitorData.value = (d.monitor || []).map(m => ({
|
||||
...m,
|
||||
color: m.label === '今日积分' ? '#1677FF' : undefined,
|
||||
value: formatThousands(m.value || 0)
|
||||
}))
|
||||
sourceData.value = (d.source || []).map(s => ({
|
||||
...s,
|
||||
color: '#00C4A7',
|
||||
value: formatThousands(s.value || 0)
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取收益汇总失败', e) }
|
||||
}
|
||||
|
||||
const fetchDetail = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-points-log', { types: 'commission,unfrozen,withdraw,gift', page: pageNum, showLoading: false })
|
||||
if (res.data) {
|
||||
const newList = (res.data.items || []).map(r => {
|
||||
const tm = typeMap[r.type] || typeMap.commission
|
||||
const withdrawalStatus = r.type === 'withdraw' ? withdrawalStatusMap[r.withdrawal_status] : null
|
||||
return {
|
||||
icon: tm.icon,
|
||||
iconBg: tm.iconBg,
|
||||
title: r.remark || r.type_text || '积分记录',
|
||||
date: r.date || '',
|
||||
desc: r.type_text || '收益积分',
|
||||
amount: (r.points || 0) >= 0 ? `+${formatMoney(r.points || 0)}` : formatMoney(r.points || 0),
|
||||
amountColor: (r.points || 0) >= 0 ? tm.amountColor : '#FF4D4F',
|
||||
status: withdrawalStatus?.status || tm.status,
|
||||
statusBg: withdrawalStatus?.statusBg || tm.statusBg,
|
||||
statusColor: withdrawalStatus?.statusColor || tm.statusColor
|
||||
}
|
||||
})
|
||||
revList.value = pageNum === 1 ? newList : revList.value.concat(newList)
|
||||
page.value = pageNum
|
||||
hasMore.value = res.data.current_page < res.data.last_page
|
||||
}
|
||||
} catch (e) { console.error('获取积分明细失败', e) }
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchDetail(page.value + 1)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([fetchSummary(), fetchDetail(1)]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => { fetchSummary(); fetchDetail(1) })
|
||||
|
||||
const showToast = (msg) => { uni.showToast({ title: msg, icon: 'none' }) }
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
.safe-bottom-spacer { height: calc(140rpx + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* Revenue Hero */
|
||||
.rev-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 24rpx rgba(13,43,110,.25);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.1), transparent 65%);
|
||||
}
|
||||
}
|
||||
|
||||
.rh-label { font-size: 24rpx; opacity: .65; position: relative; z-index: 1; display: block; }
|
||||
|
||||
.rh-amount {
|
||||
font-size: 68rpx;
|
||||
font-weight: 800;
|
||||
margin: 8rpx 0 28rpx;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: linear-gradient(180deg, #fff, #E6F4FF);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.rh-btns {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rh-btn {
|
||||
padding: 14rpx 32rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rh-btn-primary {
|
||||
background: rgba(255,255,255,.18);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(16rpx);
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
}
|
||||
|
||||
.rh-btn-secondary {
|
||||
background: rgba(255,255,255,.08);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(16rpx);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
}
|
||||
|
||||
/* Monitor Grid */
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4rpx;
|
||||
background: linear-gradient(90deg, transparent, #1677FF, transparent);
|
||||
opacity: .4;
|
||||
}
|
||||
}
|
||||
|
||||
.mc-title { font-size: 22rpx; color: #8B91A1; display: block; }
|
||||
.mc-val { font-size: 48rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin-top: 8rpx; }
|
||||
.mc-trend { font-size: 20rpx; display: block; margin-top: 6rpx; }
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
margin-bottom: 20rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-title-tag {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
font-weight: 400;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
/* Source */
|
||||
.source-header {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #722ED1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin: 8rpx 0 12rpx;
|
||||
}
|
||||
|
||||
.source-header-bar {
|
||||
display: inline-block;
|
||||
width: 6rpx;
|
||||
height: 24rpx;
|
||||
background: linear-gradient(180deg, #722ED1, #B37FEB);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.source-header-text { flex: 1; }
|
||||
|
||||
/* Info Row */
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
|
||||
&+& { border-top: 1px solid #F3F5F9; }
|
||||
}
|
||||
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; color: #0F1226; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
/* Card Header Row */
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.card-title { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
background: #F3F5F9;
|
||||
border-radius: 14rpx;
|
||||
padding: 6rpx;
|
||||
}
|
||||
|
||||
.ft-item {
|
||||
padding: 10rpx 24rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
color: #8B91A1;
|
||||
border-radius: 12rpx;
|
||||
transition: all .2s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #1677FF;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* Points List */
|
||||
.pts-list { display: flex; flex-direction: column; }
|
||||
|
||||
.pts-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.pts-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pts-icon-emoji { font-size: 36rpx; }
|
||||
|
||||
.pts-body { flex: 1; min-width: 0; }
|
||||
.pts-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.pts-sub { font-size: 22rpx; color: #BCC2CE; display: block; margin-top: 4rpx; }
|
||||
|
||||
.pts-right { text-align: right; flex-shrink: 0; }
|
||||
.pts-amount { font-size: 30rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.pts-status { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 20rpx; display: inline-block; margin-top: 6rpx; }
|
||||
.load-more { padding: 24rpx 0 8rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
/* Empty */
|
||||
.empty-holder { text-align: center; padding: 64rpx 0; }
|
||||
.empty-text { font-size: 24rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="团队" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<view class="content-pad">
|
||||
<!-- Team Hero -->
|
||||
<view class="team-hero">
|
||||
<view class="th-main">
|
||||
<view class="th-main-left">
|
||||
<text class="th-label">下属团长人数</text>
|
||||
<text class="th-val">{{ teamStats.leader_count }}<text class="th-val-unit"> 人</text></text>
|
||||
</view>
|
||||
<view class="th-main-right">
|
||||
<text class="th-label">团队总贡献积分</text>
|
||||
<text class="th-val-green">{{ formatThousands(teamStats.total_contribution || 0) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="th-stats">
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.total_c_count }}</text>
|
||||
<text class="ths-label">团长直接推荐C端</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.device_count }}</text>
|
||||
<text class="ths-label">设备总数(台)</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ formatThousands(teamStats.total_spend || 0) }}</text>
|
||||
<text class="ths-label">总消费金额</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Section Title -->
|
||||
<view class="section-title-row">
|
||||
<text class="st-title">直接合作伙伴 <text class="st-count">{{ leaderList.length }}</text> 人</text>
|
||||
<text class="st-hint">超级团长带 <text class="st-hint-bold">紫</text> 标签,可点击查看下级</text>
|
||||
</view>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<view class="summary-card">
|
||||
<text class="sc-title">直接合作伙伴贡献收益汇总</text>
|
||||
<view class="sc-tags">
|
||||
<text class="sc-tag" style="background:#E6F4FF;color:#1677FF">直接合作伙伴 {{ leaderList.length }}人</text>
|
||||
<text class="sc-tag" style="background:#F3F5F9;color:#5A6072">设备总数 {{ teamStats.device_count }}台</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Leader List -->
|
||||
<view class="list-group">
|
||||
<view class="list-item" v-for="(item, idx) in leaderList" :key="idx"
|
||||
@click="openLeaderDetail(item)">
|
||||
<view class="li-avatar" :style="{ background: item.avatarBg }">{{ item.avatar }}</view>
|
||||
<view class="li-body">
|
||||
<view class="li-name-row">
|
||||
<text class="li-name">{{ item.name }}</text>
|
||||
<text v-if="item.isSuper" class="super-tag">超级团长</text>
|
||||
</view>
|
||||
<view class="li-sub">
|
||||
<text class="li-sub-label">直接推荐C端</text> <text class="li-sub-bold" style="color:#1677FF">{{ item.cCount }}</text>人
|
||||
<text class="li-sub-sep">·</text>
|
||||
<text class="li-sub-label">设备</text> <text class="li-sub-bold" style="color:#1677FF">{{ item.cDevices }}</text>台
|
||||
<text class="li-sub-sep">·</text>
|
||||
<text class="li-sub-label">总金额</text> <text class="li-sub-red">¥{{ item.cAmount }}</text>
|
||||
</view>
|
||||
<view v-if="item.isSuper" class="li-sub li-sub-purple">
|
||||
<text class="li-sub-purple-dot">▎</text>直接推荐团长 <text class="li-sub-bold" style="color:#722ED1">{{ item.lCount }}</text>人
|
||||
<text class="li-sub-sep">·</text>
|
||||
<text class="li-sub-label">设备</text> <text class="li-sub-bold" style="color:#722ED1">{{ item.lDevices }}</text>台
|
||||
<text class="li-sub-sep">·</text>
|
||||
<text class="li-sub-label">总金额</text> <text class="li-sub-purple-val">¥{{ item.lAmount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-score">{{ item.contribution }}</text>
|
||||
<text class="li-score-label">贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="1"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
const leaderList = ref([])
|
||||
const teamStats = ref({ leader_count: 0, total_contribution: 0, device_count: 0, total_spend: 0, total_c_count: 0 })
|
||||
|
||||
const fetchTeam = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/manager/team', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
teamStats.value = d.stats || teamStats.value
|
||||
leaderList.value = (d.leaders || []).map(l => ({
|
||||
id: l.id, name: l.name, avatar: l.avatar || (l.name || '团')[0],
|
||||
avatarBg: l.is_super ? 'linear-gradient(135deg, #722ED1, #B37FEB)' : 'linear-gradient(135deg, #1677FF, #4096FF)',
|
||||
isSuper: l.is_super, cCount: l.c_count, cDevices: l.c_devices,
|
||||
cAmount: formatThousands(l.c_amount || 0), lCount: l.l_count,
|
||||
lDevices: l.l_devices, lAmount: formatThousands(l.l_amount || 0), contribution: l.contribution
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取经理团队失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchTeam() })
|
||||
|
||||
const openLeaderDetail = (item) => {
|
||||
openSub('b-sub-leader-detail', { id: item.id })
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
fetchTeam().finally(() => { refreshing.value = false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
.safe-bottom-spacer { height: calc(140rpx + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* Team Hero */
|
||||
.team-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8rpx 24rpx rgba(13,43,110,.25);
|
||||
}
|
||||
|
||||
.th-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.th-label { font-size: 24rpx; opacity: .65; display: block; }
|
||||
|
||||
.th-val {
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.th-val-unit { font-size: 26rpx; font-weight: 400; opacity: .7; }
|
||||
|
||||
.th-main-right { text-align: right; }
|
||||
|
||||
.th-val-green {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #7FFFD4;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.th-stats {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ths-item {
|
||||
flex: 1;
|
||||
padding: 16rpx;
|
||||
text-align: center;
|
||||
background: rgba(255,255,255,.08);
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.ths-val { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.ths-label { font-size: 20rpx; opacity: .65; display: block; margin-top: 2rpx; }
|
||||
|
||||
/* Section Title Row */
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.st-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #0F1226;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(180deg, #1677FF, #4096FF);
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.st-count { color: #1677FF; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
.st-hint { font-size: 22rpx; color: #8B91A1; }
|
||||
.st-hint-bold { color: #722ED1; font-weight: 600; }
|
||||
|
||||
/* Summary Card */
|
||||
.summary-card {
|
||||
background: linear-gradient(135deg, #F3E8FF, #FAF5FF);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.sc-title { font-size: 26rpx; color: #1677FF; font-weight: 600; line-height: 1.6; display: block; margin-bottom: 12rpx; }
|
||||
.sc-tags { display: flex; gap: 12rpx; justify-content: center; }
|
||||
.sc-tag {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
transition: background .15s;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
|
||||
.li-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
|
||||
.li-name-row { display: flex; align-items: center; gap: 12rpx; }
|
||||
.li-name { font-size: 28rpx; font-weight: 600; color: #0F1226; }
|
||||
|
||||
.super-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #722ED1, #B37FEB);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.li-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8B91A1;
|
||||
display: flex;
|
||||
gap: 4rpx;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.6;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.li-sub-purple { color: #722ED1; }
|
||||
|
||||
.li-sub-label { color: #8B91A1; }
|
||||
.li-sub-bold { font-weight: 600; color: #0F1226; }
|
||||
.li-sub-red { color: #FF4D4F; font-weight: 600; }
|
||||
.li-sub-purple-dot { opacity: .7; }
|
||||
.li-sub-purple-val { color: #722ED1; font-weight: 600; }
|
||||
.li-sub-sep { color: #BCC2CE; }
|
||||
|
||||
.li-right { text-align: right; flex-shrink: 0; }
|
||||
.li-score { font-size: 26rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.li-score-label { font-size: 20rpx; color: #BCC2CE; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,562 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="我的" :showBell="false" :showSettings="true" @settingsClick="openSub('settings')" />
|
||||
|
||||
<!-- 采用 flex: 1 动态填充剩余高度,完美适配各种刘海屏与状态栏 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="page-content"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
|
||||
<!-- Profile Header Card -->
|
||||
<view class="profile-card">
|
||||
<view class="profile-banner"></view>
|
||||
<view class="profile-body">
|
||||
<view class="profile-avatar-row">
|
||||
<view class="profile-avatar">
|
||||
<text class="avatar-text">{{ profile.name ? profile.name.charAt(0) : '团' }}</text>
|
||||
<view class="avatar-badge">
|
||||
<text class="avatar-check">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-info">
|
||||
<text class="profile-name">{{ profile.name }}</text>
|
||||
<view class="profile-tags">
|
||||
<text class="tag-leader">仅可邀请C端</text>
|
||||
</view>
|
||||
<view class="profile-meta">
|
||||
<view class="level-badge" @click="openSub('b-level')">
|
||||
<text class="level-star">★</text>
|
||||
<text>{{ profile.level }}</text>
|
||||
<text class="level-arrow">›</text>
|
||||
</view>
|
||||
<text class="profile-id">ID: {{ profile.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="profile-progress" @click="openSub('b-level')">
|
||||
<view class="pp-head">
|
||||
<text class="pp-label">{{ profile.isMaxLevel ? profile.nextLevelName + '合作伙伴' : '距离' + profile.nextLevelName + '合作伙伴' }}</text>
|
||||
<text class="pp-val">{{ profile.displayGrowthValue.toLocaleString() }} / {{ profile.targetPoints.toLocaleString() }} 成长值</text>
|
||||
</view>
|
||||
<view class="pp-track">
|
||||
<view class="pp-fill" :style="{ width: profile.levelProgress + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享工具 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">分享工具</text>
|
||||
</view>
|
||||
<view class="quick-grid-4">
|
||||
<view class="quick-item" @click="openSub('b-sub-leader-promo')">
|
||||
<view class="qi-icon" style="background:#FFE4EC">
|
||||
<text class="qi-emoji">❤️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享中心</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="copyLink">
|
||||
<view class="qi-icon" style="background:#E6F4FF">
|
||||
<text class="qi-emoji">🔗</text>
|
||||
</view>
|
||||
<text class="qi-label">分享链接</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="openSub('b-sub-poster')">
|
||||
<view class="qi-icon" style="background:#F9F0FF">
|
||||
<text class="qi-emoji">🖼️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享海报</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<view class="qi-icon" style="background:#FFF7E6">
|
||||
<text class="qi-emoji">📋</text>
|
||||
</view>
|
||||
<text class="qi-label">关联订单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 业务中心 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">业务中心</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="switchTab('b-team')">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">👥</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">推荐记录</text>
|
||||
<text class="li-sub">{{ inviteSub }}</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="switchTab('b-revenue')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💰</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">积分动态</text>
|
||||
<text class="li-sub">即时积分+长期积分明细</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('withdraw')">
|
||||
<view class="li-icon" style="background:#FFF7E6">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">兑换记录</text>
|
||||
<text class="li-sub">积分余额·兑换·兑换记录</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('b-level')">
|
||||
<view class="li-icon" style="background:#F9F0FF">
|
||||
<text class="li-emoji">⭐</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">等级规则</text>
|
||||
<text class="li-sub">等级条件·奖励比例</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">🛡️</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">实名认证</text>
|
||||
<text class="li-sub">身份证·人脸识别·企业资质</text>
|
||||
</view>
|
||||
<text class="badge badge-amber">待认证</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('wallet')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">收款账户</text>
|
||||
<text class="li-sub">管理个人/公户收款账户</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('help-center')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">❓</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">帮助中心</text>
|
||||
<text class="li-sub">FAQ·在线客服·常见问题</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设置 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">设置</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="openSub('settings')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">⚙️</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">账号设置</text>
|
||||
<text class="li-sub">安全·通知·隐私</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" @click="handleLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 安全底部距离占位 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="3"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const refreshing = ref(false)
|
||||
|
||||
const loginUser = uni.getStorageSync('userInfo') || {}
|
||||
const profile = ref({
|
||||
name: loginUser.nickname || '用户',
|
||||
id: 'B' + (loginUser.id || '--'),
|
||||
level: 'VIP0',
|
||||
levelProgress: 0,
|
||||
currentGrowthValue: 0,
|
||||
displayGrowthValue: 0,
|
||||
isMaxLevel: false,
|
||||
nextLevelName: '--',
|
||||
targetPoints: 0,
|
||||
verified: false
|
||||
})
|
||||
|
||||
const inviteSub = ref('直接推荐0人 · 间接推荐0人')
|
||||
const inviteUrl = ref('')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [profileRes, inviteRes, codeRes] = await Promise.all([
|
||||
request.get('/c/profile', { showLoading: false }),
|
||||
request.get('/c/user/invitation', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
if (profileRes.data) {
|
||||
const d = profileRes.data
|
||||
profile.value.level = d.current_level_name || '注册团长'
|
||||
profile.value.levelProgress = d.vip_progress || 0
|
||||
profile.value.currentGrowthValue = d.growth_value || 0
|
||||
profile.value.nextLevelName = d.next_level_name || '--'
|
||||
profile.value.targetPoints = d.next_threshold || 0
|
||||
profile.value.displayGrowthValue = profile.value.targetPoints > 0 ? Math.min(profile.value.currentGrowthValue, profile.value.targetPoints) : profile.value.currentGrowthValue
|
||||
profile.value.isMaxLevel = profile.value.targetPoints > 0 && profile.value.currentGrowthValue >= profile.value.targetPoints
|
||||
profile.value.verified = d.verified || false
|
||||
}
|
||||
if (inviteRes.data) {
|
||||
const inv = inviteRes.data
|
||||
inviteSub.value = `直接推荐${inv.direct_count || 0}人 · 间接推荐${inv.indirect_count || 0}人`
|
||||
}
|
||||
inviteUrl.value = codeRes.data?.c?.url || ''
|
||||
} catch (e) { console.error('获取团长信息失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
|
||||
const copyLink = () => {
|
||||
const link = inviteUrl.value || ''
|
||||
if (!link) {
|
||||
uni.showToast({ title: '暂无分享链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: link,
|
||||
success: () => uni.showToast({ title: '分享链接已复制', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
const showToast = (msg) => {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
fetchData().finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
uni.$emit('pageShow')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 统一全局盒模型计算 */
|
||||
view, text, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0; /* 触发弹性压缩,保证多端 100% 充满屏幕 */
|
||||
}
|
||||
|
||||
/* Profile Card */
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: 36rpx;
|
||||
margin: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
height: 160rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6 0%, #FFFBF0 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-banner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,136,0,.08), transparent 70%);
|
||||
}
|
||||
|
||||
.profile-body {
|
||||
padding: 0 32rpx 8rpx;
|
||||
margin-top: -80rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 136rpx;
|
||||
height: 136rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF8800, #FFB84D);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 32rpx rgba(255,136,0,.35);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-text { position: relative; z-index: 1; }
|
||||
|
||||
.avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -4rpx;
|
||||
right: -4rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: #00C4A7;
|
||||
border: 4rpx solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-check { color: #fff; font-size: 20rpx; font-weight: 700; }
|
||||
|
||||
.profile-info { flex: 1; padding-bottom: 8rpx; }
|
||||
.profile-name { font-size: 36rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
|
||||
.profile-tags { margin-top: 4rpx; }
|
||||
.tag-leader {
|
||||
display: inline-block;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #FFF7E6;
|
||||
color: #FF8800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, rgba(255,215,0,.95), rgba(255,165,0,.95));
|
||||
color: #0F3460;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(255,165,0,.45);
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.level-star { font-size: 18rpx; }
|
||||
.level-arrow { opacity: 0.7; }
|
||||
|
||||
.profile-id { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
/* Progress */
|
||||
.profile-progress {
|
||||
padding: 28rpx 32rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.pp-label { font-size: 24rpx; color: #8B91A1; }
|
||||
.pp-val { font-size: 24rpx; color: #FF8800; font-weight: 600; }
|
||||
|
||||
.pp-track {
|
||||
height: 8rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pp-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FF8800, #FFB84D);
|
||||
border-radius: 4rpx;
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section { margin: 32rpx 0 0; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 32rpx; font-weight: 700; color: #0F1226; display: flex; align-items: center; gap: 12rpx; }
|
||||
.section-title::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; }
|
||||
|
||||
/* Quick Grid 4 */
|
||||
.quick-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-item:active { transform: scale(0.95); }
|
||||
|
||||
.qi-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.list-item + .list-item { border-top: 1rpx solid #F3F5F9; }
|
||||
.list-item:active { background: #F8F9FC; }
|
||||
|
||||
.li-icon {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.transparent-holder {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.li-emoji { font-size: 36rpx; line-height: 1; }
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 2rpx; }
|
||||
|
||||
.badge { display: inline-block; padding: 6rpx 16rpx; border-radius: 10rpx; font-size: 22rpx; font-weight: 500; }
|
||||
.badge-green { background: #E6FAF5; color: #00C4A7; flex-shrink: 0; }
|
||||
.badge-amber { background: #FFF7E6; color: #D97706; flex-shrink: 0; }
|
||||
|
||||
/* Logout */
|
||||
.logout-btn {
|
||||
margin-top: 40rpx;
|
||||
padding: 26rpx;
|
||||
text-align: center;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #FF4D4F;
|
||||
color: #FF4D4F;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
background: #fff;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:active { background: #FFF1F0; opacity: 0.85; }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- BTopBar 自带/内部包含导航栏与状态栏占位 -->
|
||||
<BTopBar title="收益积分" :showBell="false" />
|
||||
|
||||
<!-- 采用 flex: 1 自动填充剩余空间,规避写死 calc(100vh - 88px) 导致的不同机型高矮不一问题 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="page-content"
|
||||
:scroll-top="scrollTop"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Revenue Hero -->
|
||||
<view class="rev-hero">
|
||||
<text class="rh-label">可兑换收益积分</text>
|
||||
<text class="rh-amount">{{ withdrawable }}</text>
|
||||
<view class="rh-btns">
|
||||
<view class="rh-btn rh-btn-primary" @click="openSub('withdraw')">兑换</view>
|
||||
<view class="rh-btn rh-btn-secondary" @click="scrollToDetail">积分明细</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Monitor Grid -->
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card" v-for="(item, idx) in monitorData" :key="idx">
|
||||
<text class="mc-title">{{ item.label }}</text>
|
||||
<text class="mc-val" :style="{ color: item.color || '#0F1226' }">{{ item.value }}</text>
|
||||
<text v-if="item.trend" class="mc-trend" :style="{ color: item.trendColor || '#8B91A1' }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Source Card -->
|
||||
<view class="card rev-source-card">
|
||||
<text class="card-title">收益积分来源构成 <text class="card-title-sub">团长·激励金收益</text></text>
|
||||
<view class="info-row" v-for="(item, idx) in sourceData" :key="idx">
|
||||
<text class="ir-label">{{ item.label }}</text>
|
||||
<text class="ir-val" :style="{ color: item.color || '#0F1226' }">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Detail Card -->
|
||||
<view class="card rev-detail-card" id="revenue-detail">
|
||||
<view class="rd-head">
|
||||
<text class="card-title">收益积分明细</text>
|
||||
<!-- <view class="pts-tabs">
|
||||
<text class="pts-tab" :class="{ active: activeTab === 'all' }" @click="activeTab = 'all'">全部</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<!-- 增加列表为空时的缺省提示 -->
|
||||
<view v-if="filteredRevList.length === 0" class="empty-holder">
|
||||
<text class="empty-text">暂无相关收益记录</text>
|
||||
</view>
|
||||
|
||||
<view class="pts-item" v-for="(item, idx) in filteredRevList" :key="idx">
|
||||
<view class="pts-icon" :style="{ background: item.iconBg }">
|
||||
<text class="pts-emoji">{{ item.icon }}</text>
|
||||
</view>
|
||||
<view class="pts-body">
|
||||
<text class="pts-title">{{ item.title }}</text>
|
||||
<text class="pts-sub">{{ item.sub }}</text>
|
||||
</view>
|
||||
<view class="pts-right">
|
||||
<text class="pts-amount" :style="{ color: item.amountColor }">{{ item.amount }}</text>
|
||||
<text class="pts-status" :style="{ background: item.statusBg, color: item.statusColor }">{{ item.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 底部防遮挡占位(匹配底部 CustomTabBar 高度与全面屏底部安全区) -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="2"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const withdrawable = ref('0')
|
||||
const activeTab = ref('all')
|
||||
|
||||
const monitorData = ref([
|
||||
{ label: '今日积分', value: '0', color: '#1677FF', trend: null },
|
||||
{ label: '本月积分', value: '0' },
|
||||
{ label: '昨日积分', value: '0' },
|
||||
{ label: '历史总积分', value: '0' }
|
||||
])
|
||||
|
||||
const sourceData = ref([
|
||||
{ label: '激励金收益', value: '0', color: '#00C4A7' }
|
||||
])
|
||||
|
||||
const revList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const typeMap = {
|
||||
commission: { icon: '💰', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已发放', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
unfrozen: { icon: '🔓', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已解冻', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
withdraw: { icon: '💳', iconBg: '#F0F2F5', amountColor: '#6B7280', status: '已发放', statusBg: '#F0F2F5', statusColor: '#6B7280' },
|
||||
gift: { icon: '🎁', iconBg: '#FFFBE6', amountColor: '#FFD666', status: '不可兑换', statusBg: '#FFFBE6', statusColor: '#FF8800' }
|
||||
}
|
||||
|
||||
const withdrawalStatusMap = {
|
||||
pending: { status: '待审核', statusBg: '#FFF7E6', statusColor: '#FF8800' },
|
||||
approved: { status: '待打款', statusBg: '#E6F4FF', statusColor: '#1677FF' },
|
||||
paid: { status: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
rejected: { status: '已拒绝', statusBg: '#FFF1F0', statusColor: '#FF4D4F' }
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/leader/revenue-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
withdrawable.value = formatThousands(d.withdrawable || 0)
|
||||
monitorData.value = (d.monitor || []).map(m => ({
|
||||
...m,
|
||||
color: m.label === '今日积分' ? '#1677FF' : undefined,
|
||||
value: formatThousands(m.value || 0)
|
||||
}))
|
||||
sourceData.value = (d.source || []).map(s => ({
|
||||
...s,
|
||||
color: '#00C4A7',
|
||||
value: formatThousands(s.value || 0)
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取收益汇总失败', e) }
|
||||
}
|
||||
|
||||
const fetchDetail = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-points-log', { types: 'commission,unfrozen,withdraw,gift', page: pageNum, showLoading: false })
|
||||
if (res.data) {
|
||||
const newList = (res.data.items || []).map(r => {
|
||||
const tm = typeMap[r.type] || typeMap.commission
|
||||
const withdrawalStatus = r.type === 'withdraw' ? withdrawalStatusMap[r.withdrawal_status] : null
|
||||
return {
|
||||
rtype: r.type,
|
||||
icon: tm.icon,
|
||||
iconBg: tm.iconBg,
|
||||
title: r.remark || r.type_text || '积分记录',
|
||||
sub: `${r.date || ''} · ${r.type_text || '收益积分'}`,
|
||||
amount: (r.points || 0) >= 0 ? `+${formatMoney(r.points || 0)}` : formatMoney(r.points || 0),
|
||||
amountColor: (r.points || 0) >= 0 ? tm.amountColor : '#FF4D4F',
|
||||
status: withdrawalStatus?.status || tm.status,
|
||||
statusBg: withdrawalStatus?.statusBg || tm.statusBg,
|
||||
statusColor: withdrawalStatus?.statusColor || tm.statusColor
|
||||
}
|
||||
})
|
||||
revList.value = pageNum === 1 ? newList : revList.value.concat(newList)
|
||||
page.value = pageNum
|
||||
hasMore.value = res.data.current_page < res.data.last_page
|
||||
}
|
||||
} catch (e) { console.error('获取积分明细失败', e) }
|
||||
}
|
||||
|
||||
const filteredRevList = computed(() => {
|
||||
if (activeTab.value === 'all') return revList.value
|
||||
return revList.value.filter(item => item.rtype === activeTab.value)
|
||||
})
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchDetail(page.value + 1)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToDetail = () => {
|
||||
uni.createSelectorQuery().select('#revenue-detail').boundingClientRect().exec((res) => {
|
||||
if (res && res[0]) {
|
||||
// 先取当前滚动位置,再加上目标元素在视口中的偏移
|
||||
uni.createSelectorQuery().select('.page-content').scrollOffset().exec((info) => {
|
||||
const current = (info && info[0]) ? info[0].scrollTop : 0
|
||||
scrollTop.value = 0
|
||||
setTimeout(() => {
|
||||
scrollTop.value = current + res[0].top - 100
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showToast = (msg) => {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([fetchSummary(), fetchDetail(1)]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSummary()
|
||||
fetchDetail(1)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
uni.$emit('pageShow')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全局盒模型与容器 Flex 布局改造 */
|
||||
view, text, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0; /* 触发 flex 压缩,保证在不同尺寸机型上 100% 正确填满剩余高度 */
|
||||
}
|
||||
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Revenue Hero */
|
||||
.rev-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 48rpx rgba(13,43,110,.25);
|
||||
}
|
||||
|
||||
.rev-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.1), transparent 65%);
|
||||
}
|
||||
|
||||
.rev-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 48rpx 48rpx;
|
||||
mask-image: linear-gradient(180deg, #000, transparent);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rh-label { font-size: 24rpx; opacity: 0.65; position: relative; z-index: 1; display: block; }
|
||||
.rh-amount { font-size: 68rpx; font-weight: 800; margin: 8rpx 0 28rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; position: relative; z-index: 1; display: block; color: #fff; }
|
||||
|
||||
.rh-btns { display: flex; gap: 16rpx; position: relative; z-index: 1; }
|
||||
.rh-btn {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 36rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
.rh-btn-primary { background: rgba(255,255,255,.2); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.3); }
|
||||
.rh-btn-secondary { background: rgba(255,255,255,.1); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.15); }
|
||||
|
||||
/* Monitor Grid */
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.monitor-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4rpx;
|
||||
background: linear-gradient(90deg, transparent, #1677FF, transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.mc-title { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mc-val { font-size: 48rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mc-trend { font-size: 20rpx; display: block; margin-top: 6rpx; }
|
||||
|
||||
/* Cards */
|
||||
.card { background: #fff; border-radius: 28rpx; padding: 32rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); margin-bottom: 16rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
.card-title-sub { font-size: 22rpx; color: #BCC2CE; font-weight: 400; margin-left: 8rpx; }
|
||||
|
||||
/* Info Row */
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 24rpx 0; }
|
||||
.info-row + .info-row { border-top: 1rpx solid #F3F5F9; }
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
/* Revenue Detail */
|
||||
.rev-detail-card .card-title { margin-bottom: 0; }
|
||||
.rd-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
|
||||
.pts-tabs { display: flex; gap: 0; background: #F3F5F9; border-radius: 20rpx; padding: 6rpx; }
|
||||
.pts-tab { padding: 10rpx 24rpx; font-size: 22rpx; font-weight: 500; color: #8B91A1; border-radius: 16rpx; transition: all 0.2s; }
|
||||
.pts-tab.active { background: #fff; color: #1677FF; font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
|
||||
|
||||
/* Empty Holder */
|
||||
.empty-holder {
|
||||
padding: 60rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 24rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
|
||||
/* PTS Item */
|
||||
.pts-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
}
|
||||
|
||||
.pts-item:last-child { border-bottom: none; }
|
||||
|
||||
.pts-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pts-emoji { font-size: 32rpx; }
|
||||
|
||||
.pts-body { flex: 1; min-width: 0; }
|
||||
.pts-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pts-sub { font-size: 22rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
|
||||
.pts-right { text-align: right; flex-shrink: 0; }
|
||||
.pts-amount { font-size: 30rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.pts-status { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 20rpx; display: inline-block; margin-top: 6rpx; }
|
||||
.load-more { padding: 24rpx 0 8rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- 自定义顶部导航栏 -->
|
||||
<BTopBar
|
||||
title="全犀云"
|
||||
:showBell="true"
|
||||
@bellClick="openSub('notifications')"
|
||||
/>
|
||||
|
||||
<!-- 滚动区域:采用 flex:1 + height:0 弹性自适应 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="page-content"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Hero Card -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-grid-bg"></view>
|
||||
<view class="hero-row1">
|
||||
<text class="hero-greeting">{{ greetingText }},{{ userName }}</text>
|
||||
<view class="hero-level-chip" @click="openSub('b-level')">
|
||||
<text class="chip-star">★</text>
|
||||
<text>{{ dashboard.level_name }}</text>
|
||||
<text class="chip-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="hero-label">总收益积分</text>
|
||||
<view class="hero-amount-row">
|
||||
<text class="hero-amount">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hero-unit">累计已结算</text>
|
||||
</view>
|
||||
<view class="hero-rate">
|
||||
<text class="rate-tag">积分说明</text>
|
||||
<text class="rate-text">1元 = 1积分</text>
|
||||
</view>
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">直接推荐C端</text>
|
||||
<text class="hs-val">{{ dashboard.direct_c_count || 0 }}<text class="hs-unit"> 人</text></text>
|
||||
<text class="hs-sub">团长直接推荐</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">设备总数</text>
|
||||
<text class="hs-val">{{ dashboard.device_count || 0 }}<text class="hs-unit"> 台</text></text>
|
||||
<text class="hs-sub">直接推荐C端设备</text>
|
||||
</view>
|
||||
<view class="hero-stat">
|
||||
<text class="hs-label">本月积分</text>
|
||||
<text class="hs-val">{{ dashboard.total_points ?? 0 }}</text>
|
||||
<text class="hs-sub">累计激励金</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Notice Bar -->
|
||||
<view class="notice-bar" @click="openSub('notifications')">
|
||||
<view class="nb-icon">
|
||||
<u-icon name="volume" size="14" color="#FF8800"></u-icon>
|
||||
</view>
|
||||
<text class="nb-text">{{ noticeText }}</text>
|
||||
<text class="nb-more">更多 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">数据概览</text>
|
||||
</view>
|
||||
<view class="monitor-grid-v2">
|
||||
<view v-for="(item, idx) in monitorList" :key="idx" class="mg-card" :style="{ background: item.bg }">
|
||||
<text class="mg-title">{{ item.title }}</text>
|
||||
<text class="mg-val" :style="{ color: item.valColor || '#0F1226' }">
|
||||
{{ item.value }}<text v-if="item.unit" class="mg-unit">{{ item.unit }}</text>
|
||||
</text>
|
||||
<text class="mg-trend" :style="{ color: item.trendColor || '#8B91A1' }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">快捷操作</text>
|
||||
</view>
|
||||
<view class="quick-grid-4">
|
||||
<view v-for="(item, idx) in quickActions" :key="idx" class="quick-item" @click="handleAction(item)">
|
||||
<view class="qi-icon" :style="{ background: item.bg }">
|
||||
<text class="qi-emoji">{{ item.emoji }}</text>
|
||||
</view>
|
||||
<text class="qi-label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 直推用户贡献榜 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">直推用户贡献榜</text>
|
||||
<text class="section-more" @click="switchTab('b-super-team')">全部 ›</text>
|
||||
</view>
|
||||
<view class="card leaderboard-card">
|
||||
<view v-if="leaderboard.length === 0" class="lb-empty">暂无直属C端用户</view>
|
||||
<view class="lb-item" v-for="(item, idx) in leaderboard" :key="idx">
|
||||
<view class="lb-rank" :class="['gold', 'silver', 'bronze'][idx] || ''">{{ item.rank }}</view>
|
||||
<text class="lb-name">{{ item.name }}</text>
|
||||
<text class="lb-val">¥{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最新动态 -->
|
||||
<NewsFeed ref="newsFeedRef" role="super_leader" @item-click="handleNewsClick" />
|
||||
|
||||
<!-- 底部安全区垫高 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="0"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
import NewsFeed from '@/components/NewsFeed.vue'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const noticeText = ref('')
|
||||
|
||||
const fetchNotice = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/notices', { limit: 1, showLoading: false })
|
||||
const items = res.data?.items || res.data || []
|
||||
noticeText.value = items[0]?.title || ''
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
const userName = computed(() => {
|
||||
const info = uni.getStorageSync('userInfo')
|
||||
return info?.nickname || '用户'
|
||||
})
|
||||
|
||||
const greetingText = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 6 && hour < 12) return '上午好'
|
||||
if (hour >= 12 && hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
const dashboard = ref({
|
||||
total_points: 0, frozen_points: 0, total_amount: 0,
|
||||
active_c_count: 0, device_count: 0, new_c_count: 0,
|
||||
active_leader_count: 0, direct_c_count: 0, level_name: 'VIP1'
|
||||
})
|
||||
|
||||
const leaderboard = ref([])
|
||||
|
||||
const monitorList = ref([
|
||||
{ title: '总积分', value: '0', trend: '累计激励金', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '待生效积分', value: '0', trend: 'T+1生效', bg: 'linear-gradient(135deg,#FFF7E6,#FFFBF0)', valColor: '#FF8800' },
|
||||
{ title: '总金额', value: '¥0', trend: 'C端消费总额', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '活跃C端', value: '0', trend: '7日内活跃', bg: 'linear-gradient(135deg,#F3E8FF,#FAF5FF)', valColor: '#722ED1' },
|
||||
{ title: '设备总数', value: '0', unit: '台', trend: '已支付订单', bg: 'linear-gradient(135deg,#FFF1F0,#FFF5F5)' },
|
||||
{ title: '活跃团长', value: '0', trend: '7日内活跃', bg: 'linear-gradient(135deg,#E6FAF5,#F0FBF8)', valColor: '#722ED1' }
|
||||
])
|
||||
|
||||
// 快捷操作列表配置化
|
||||
const quickActions = ref([
|
||||
{ label: '分享中心', emoji: '❤️', bg: '#FFE4EC', type: 'sub', target: 'b-sub-promo-home' },
|
||||
{ label: '我的团队', emoji: '👥', bg: '#E6F4FF', type: 'tab', target: 'b-super-team' },
|
||||
{ label: '生成海报', emoji: '🖼️', bg: '#F3E8FF', type: 'sub', target: 'b-sub-poster-leader' },
|
||||
{ label: '积分明细', emoji: '💰', bg: '#E6FAF5', type: 'tab', target: 'b-super-revenue' }
|
||||
])
|
||||
|
||||
const handleAction = (item) => {
|
||||
if (item.type === 'sub') { openSub(item.target) }
|
||||
else if (item.type === 'tab') { switchTab(item.target) }
|
||||
}
|
||||
|
||||
let fetchingDashboard = false
|
||||
|
||||
const fetchDashboard = async () => {
|
||||
if (fetchingDashboard) return
|
||||
fetchingDashboard = true
|
||||
try {
|
||||
const res = await request.get('/c/super-leader/dashboard', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
dashboard.value = d
|
||||
monitorList.value = [
|
||||
{ title: '总积分', value: formatThousands(d.total_points || 0), trend: '累计激励金', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '待生效积分', value: formatThousands(d.frozen_points || 0), trend: 'T+1生效', bg: 'linear-gradient(135deg,#FFF7E6,#FFFBF0)', valColor: '#FF8800' },
|
||||
{ title: '总金额', value: '¥' + formatThousands(d.total_amount || 0), trend: 'C端消费总额', bg: 'linear-gradient(135deg,#E6F4FF,#F0F7FF)', valColor: '#1677FF' },
|
||||
{ title: '活跃C端', value: String(d.active_c_count || 0), trend: '7日内活跃', bg: 'linear-gradient(135deg,#F3E8FF,#FAF5FF)', valColor: '#722ED1' },
|
||||
{ title: '设备总数', value: String(d.device_count || 0), unit: '台', trend: '已支付订单', bg: 'linear-gradient(135deg,#FFF1F0,#FFF5F5)' },
|
||||
{ title: '活跃团长', value: String(d.active_leader_count || 0), trend: '7日内活跃', bg: 'linear-gradient(135deg,#E6FAF5,#F0FBF8)', valColor: '#722ED1' }
|
||||
]
|
||||
leaderboard.value = d.leaderboard || []
|
||||
}
|
||||
} catch (e) { console.error('获取超级团长首页失败', e) }
|
||||
finally { fetchingDashboard = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchDashboard(), fetchNotice()])
|
||||
})
|
||||
|
||||
const handleNewsClick = (item) => {
|
||||
openSub('notice-detail', { id: item.id })
|
||||
}
|
||||
|
||||
const newsFeedRef = ref(null)
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([
|
||||
fetchDashboard(),
|
||||
fetchNotice(),
|
||||
newsFeedRef.value?.fetchNews?.()
|
||||
]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 根节点采用 Flex 布局 */
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
/* 滚动区域自动填满剩余空间 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
padding: 44rpx 36rpx 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
}
|
||||
|
||||
.hero-grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 60rpx 60rpx;
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 8rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-greeting {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.hero-level-chip {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, rgba(255,215,0,.95), rgba(255,165,0,.95));
|
||||
color: #0F3460;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(255,165,0,.45);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chip-star { font-size: 20rpx; }
|
||||
.chip-arrow { opacity: 0.7; }
|
||||
|
||||
.hero-label {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.55;
|
||||
margin-bottom: 12rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 0.6rpx;
|
||||
}
|
||||
|
||||
.hero-amount-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
font-size: 76rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2rpx;
|
||||
line-height: 1;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(180deg, #fff, #E6F4FF);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.hero-unit {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.55;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hero-rate {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255,255,255,.7);
|
||||
margin-top: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rate-tag {
|
||||
background: rgba(255,255,255,.15);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 18rpx;
|
||||
}
|
||||
|
||||
.rate-text { opacity: 0.7; }
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 2rpx;
|
||||
margin-top: 44rpx;
|
||||
background: rgba(255,255,255,.1);
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
background: rgba(255,255,255,.07);
|
||||
padding: 26rpx 20rpx;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.hero-stat:active { background: rgba(255,255,255,.14); }
|
||||
|
||||
.hs-label { font-size: 22rpx; opacity: 0.65; display: block; }
|
||||
.hs-val { font-size: 38rpx; font-weight: 700; margin-top: 4rpx; display: block; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
.hs-unit { font-size: 24rpx; font-weight: 400; opacity: 0.7; }
|
||||
.hs-sub { font-size: 20rpx; opacity: 0.55; margin-top: 2rpx; display: block; }
|
||||
.hs-sub.up { color: #7FFFD4; opacity: 1; }
|
||||
|
||||
.notice-bar {
|
||||
background: #fff;
|
||||
margin: 28rpx 24rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
font-size: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-bar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: linear-gradient(180deg, #FF8800, #FFB84D);
|
||||
}
|
||||
|
||||
.nb-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6, #FFE7BA);
|
||||
border-radius: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255,136,0,.15);
|
||||
}
|
||||
|
||||
.nb-text { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #5A6072; font-size: 24rpx; }
|
||||
.nb-more { color: #BCC2CE; font-size: 22rpx; flex-shrink: 0; }
|
||||
|
||||
.section { margin: 32rpx 0 0; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 32rpx; font-weight: 700; color: #0F1226; display: flex; align-items: center; gap: 12rpx; }
|
||||
.section-title::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; }
|
||||
.section-more { font-size: 24rpx; color: #8B91A1; }
|
||||
|
||||
.monitor-grid-v2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.mg-card {
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 16rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.mg-title { font-size: 20rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mg-val { font-size: 40rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mg-trend { font-size: 18rpx; color: #8B91A1; display: block; margin-top: 6rpx; }
|
||||
.mg-unit { font-size: 24rpx; font-weight: 400; color: #8B91A1; margin-left: 2rpx; }
|
||||
|
||||
.quick-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-item:active { transform: scale(0.95); box-shadow: 0 8rpx 32rpx rgba(15,18,38,.06); }
|
||||
|
||||
.qi-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
.leaderboard-card { padding: 0; }
|
||||
.lb-empty { padding: 40rpx 32rpx; text-align: center; font-size: 24rpx; color: #BCC2CE; }
|
||||
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 22rpx 28rpx;
|
||||
}
|
||||
|
||||
.lb-item + .lb-item { border-top: 1rpx solid #F3F5F9; }
|
||||
|
||||
.lb-rank {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #F3F5F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 24rpx;
|
||||
flex-shrink: 0;
|
||||
color: #5A6072;
|
||||
font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
.lb-rank.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; box-shadow: 0 4rpx 16rpx rgba(255,165,0,.4); }
|
||||
.lb-rank.silver { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); color: #fff; box-shadow: 0 4rpx 16rpx rgba(168,174,192,.4); }
|
||||
.lb-rank.bronze { background: linear-gradient(135deg, #CD7F32, #A0522D); color: #fff; box-shadow: 0 4rpx 16rpx rgba(160,82,45,.4); }
|
||||
|
||||
.lb-name { flex: 1; font-size: 26rpx; font-weight: 500; }
|
||||
.lb-val { font-weight: 700; color: #1677FF; font-size: 28rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
.card { background: #fff; border-radius: 28rpx; padding: 32rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 160rpx);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="我的" :showBell="false" :showSettings="true" @settingsClick="openSub('settings')" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<!-- Profile Header Card -->
|
||||
<view class="profile-card">
|
||||
<view class="profile-banner"></view>
|
||||
<view class="profile-body">
|
||||
<view class="profile-avatar-row">
|
||||
<view class="profile-avatar">
|
||||
<text class="avatar-text">{{ (profile.name || '用')[0] }}</text>
|
||||
<view class="avatar-badge">
|
||||
<text class="avatar-check">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-info">
|
||||
<text class="profile-name">{{ profile.name }}</text>
|
||||
<view class="profile-tags">
|
||||
<text class="tag-leader-promo">已开放邀请团长</text>
|
||||
</view>
|
||||
<view class="profile-meta">
|
||||
<view class="level-badge" @click="openSub('b-level')">
|
||||
<text class="level-star">★</text>
|
||||
<text>{{ profile.level }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<text class="profile-id">ID: {{ profile.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-progress" @click="openSub('b-level')">
|
||||
<view class="pp-head">
|
||||
<text class="pp-label">{{ profile.isMaxLevel ? profile.nextLevelName + '合作伙伴' : '距离' + profile.nextLevelName + '合作伙伴' }}</text>
|
||||
<text class="pp-val">{{ profile.displayGrowthValue.toLocaleString() }} / {{ profile.targetPoints.toLocaleString() }} 成长值</text>
|
||||
</view>
|
||||
<view class="pp-track">
|
||||
<view class="pp-fill" :style="{ width: profile.levelProgress + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享工具 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">分享工具</text>
|
||||
</view>
|
||||
<view class="quick-grid-4">
|
||||
<view class="quick-item" @click="openSub('b-sub-promo-home')">
|
||||
<view class="qi-icon" style="background:#FFE4EC">
|
||||
<text class="qi-emoji">❤️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享中心</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="copyLink">
|
||||
<view class="qi-icon" style="background:#E6F4FF">
|
||||
<text class="qi-emoji">🔗</text>
|
||||
</view>
|
||||
<text class="qi-label">分享链接</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="openSub('b-sub-poster-leader')">
|
||||
<view class="qi-icon" style="background:#F9F0FF">
|
||||
<text class="qi-emoji">🖼️</text>
|
||||
</view>
|
||||
<text class="qi-label">分享海报</text>
|
||||
</view>
|
||||
<view class="quick-item" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<view class="qi-icon" style="background:#FFF7E6">
|
||||
<text class="qi-emoji">📋</text>
|
||||
</view>
|
||||
<text class="qi-label">关联订单</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 业务中心 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">业务中心</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="switchTab('b-super-team')">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">👥</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">推荐记录</text>
|
||||
<text class="li-sub">{{ inviteSub }}</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="switchTab('b-super-revenue')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💰</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">积分动态</text>
|
||||
<text class="li-sub">即时积分+长期激励+团队贡献收益</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('withdraw')">
|
||||
<view class="li-icon" style="background:#FFF7E6">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">兑换记录</text>
|
||||
<text class="li-sub">积分余额·兑换·兑换记录</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('b-level')">
|
||||
<view class="li-icon" style="background:#F9F0FF">
|
||||
<text class="li-emoji">⭐</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">等级规则</text>
|
||||
<text class="li-sub">等级条件·奖励比例</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="uni.showToast({ title: '即将开放', icon: 'none' })">
|
||||
<view class="li-icon" style="background:#E6F4FF">
|
||||
<text class="li-emoji">🛡️</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">实名认证</text>
|
||||
<text class="li-sub">身份证·人脸识别·企业资质</text>
|
||||
</view>
|
||||
<text class="badge badge-amber">待认证</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('wallet')">
|
||||
<view class="li-icon" style="background:#E6FAF5">
|
||||
<text class="li-emoji">💳</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">收款账户</text>
|
||||
<text class="li-sub">管理个人/公户收款账户</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
<view class="list-item" @click="openSub('help-center')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">❓</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">帮助中心</text>
|
||||
<text class="li-sub">FAQ·在线客服·常见问题</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设置 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">设置</text>
|
||||
</view>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="openSub('settings')">
|
||||
<view class="li-icon" style="background:#F0F2F5">
|
||||
<text class="li-emoji">⚙️</text>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">账号设置</text>
|
||||
<text class="li-sub">安全·通知·隐私</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
<view class="logout-btn" @click="handleLogout">
|
||||
<text>退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="3"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const refreshing = ref(false)
|
||||
|
||||
const loginUser = uni.getStorageSync('userInfo') || {}
|
||||
const profile = ref({
|
||||
name: loginUser.nickname || '用户',
|
||||
id: 'B' + (loginUser.id || '--'),
|
||||
level: 'VIP1',
|
||||
levelProgress: 0,
|
||||
currentGrowthValue: 0,
|
||||
displayGrowthValue: 0,
|
||||
isMaxLevel: false,
|
||||
nextLevelName: '--',
|
||||
targetPoints: 0
|
||||
})
|
||||
|
||||
const inviteSub = ref('直接推荐C端0人 · 直接推荐团长0人')
|
||||
const inviteUrl = ref('')
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [profileRes, inviteRes] = await Promise.all([
|
||||
request.get('/c/super-leader/profile', { showLoading: false }),
|
||||
request.get('/c/user/invitation-code', { showLoading: false })
|
||||
])
|
||||
if (profileRes.data) {
|
||||
const d = profileRes.data
|
||||
profile.value.level = d.level || 'VIP1'
|
||||
profile.value.levelProgress = d.level_progress || 0
|
||||
profile.value.currentGrowthValue = d.growth_value || 0
|
||||
profile.value.nextLevelName = d.next_level_name || '--'
|
||||
profile.value.targetPoints = d.target_points || 0
|
||||
profile.value.displayGrowthValue = profile.value.targetPoints > 0 ? Math.min(profile.value.currentGrowthValue, profile.value.targetPoints) : profile.value.currentGrowthValue
|
||||
profile.value.isMaxLevel = profile.value.targetPoints > 0 && profile.value.currentGrowthValue >= profile.value.targetPoints
|
||||
inviteSub.value = `直接推荐C端${d.direct_c_count || 0}人 · 直接推荐团长${d.direct_leader_count || 0}人`
|
||||
}
|
||||
if (inviteRes.data) {
|
||||
inviteUrl.value = inviteRes.data.leader?.url || ''
|
||||
}
|
||||
} catch (e) { console.error('获取超级团长信息失败', e) }
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
const link = inviteUrl.value || ''
|
||||
if (!link) {
|
||||
uni.showToast({ title: '暂无分享链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: link,
|
||||
success: () => uni.showToast({ title: '分享链接已复制', icon: 'none' })
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
|
||||
const showToast = (msg) => { uni.showToast({ title: msg, icon: 'none' }) }
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
fetchData().finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onShow(() => { uni.$emit('pageShow') })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: 36rpx;
|
||||
margin: 24rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
height: 160rpx;
|
||||
background: linear-gradient(135deg, #FFF7E6 0%, #FFFBF0 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-banner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,136,0,.08), transparent 70%);
|
||||
}
|
||||
|
||||
.profile-body {
|
||||
padding: 0 32rpx 8rpx;
|
||||
margin-top: -80rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.profile-avatar-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 28rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 136rpx;
|
||||
height: 136rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF8800, #FFB84D);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 52rpx;
|
||||
font-weight: 700;
|
||||
border: 6rpx solid #fff;
|
||||
box-shadow: 0 8rpx 32rpx rgba(255,136,0,.35);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar-text { position: relative; z-index: 1; }
|
||||
|
||||
.avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -4rpx;
|
||||
right: -4rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: #00C4A7;
|
||||
border: 4rpx solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-check { color: #fff; font-size: 20rpx; font-weight: 700; }
|
||||
|
||||
.profile-info { flex: 1; padding-bottom: 8rpx; }
|
||||
.profile-name { font-size: 36rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
|
||||
.profile-tags { margin-top: 4rpx; display: flex; gap: 12rpx; flex-wrap: wrap; }
|
||||
|
||||
.tag-leader-promo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
font-size: 20rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(135deg, #722ED1, #B37FEB);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2rpx 8rpx rgba(114,46,209,.3);
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, rgba(255,215,0,.95), rgba(255,165,0,.95));
|
||||
color: #0F3460;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(255,165,0,.45);
|
||||
}
|
||||
|
||||
.level-star { font-size: 18rpx; }
|
||||
|
||||
.profile-id { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
.profile-progress {
|
||||
padding: 28rpx 32rpx 12rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.pp-label { font-size: 24rpx; color: #8B91A1; }
|
||||
.pp-val { font-size: 24rpx; color: #FF8800; font-weight: 600; }
|
||||
|
||||
.pp-track {
|
||||
height: 8rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pp-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #FF8800, #FFB84D);
|
||||
border-radius: 4rpx;
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.section { margin: 32rpx 0 0; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 32rpx; font-weight: 700; color: #0F1226; display: flex; align-items: center; gap: 12rpx; }
|
||||
.section-title::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; }
|
||||
|
||||
.quick-grid-4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 8rpx 20rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-item:active { transform: scale(0.95); }
|
||||
|
||||
.qi-icon {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14rpx;
|
||||
}
|
||||
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.list-item + .list-item { border-top: 1rpx solid #F3F5F9; }
|
||||
.list-item:active { background: #F3F5F9; }
|
||||
|
||||
.li-icon {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
border-radius: 22rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-emoji { font-size: 36rpx; line-height: 1; }
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 2rpx; }
|
||||
|
||||
.badge { display: inline-block; padding: 6rpx 16rpx; border-radius: 10rpx; font-size: 22rpx; font-weight: 500; }
|
||||
.badge-green { background: #E6FAF5; color: #00C4A7; flex-shrink: 0; }
|
||||
.badge-amber { background: #FFF7E6; color: #D97706; flex-shrink: 0; }
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 40rpx;
|
||||
padding: 26rpx;
|
||||
text-align: center;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #FF4D4F;
|
||||
color: #FF4D4F;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.logout-btn:active { background: #FFF1F0; }
|
||||
|
||||
.safe-bottom-spacer { height: calc(env(safe-area-inset-bottom) + 200rpx); }
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="收益积分" :showBell="false" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Revenue Hero -->
|
||||
<view class="rev-hero">
|
||||
<text class="rh-label">可兑换收益积分</text>
|
||||
<text class="rh-amount">{{ withdrawable }}</text>
|
||||
<view class="rh-btns">
|
||||
<view class="rh-btn rh-btn-primary" @click="openSub('withdraw')">兑换</view>
|
||||
<view class="rh-btn rh-btn-secondary" @click="showToast('积分明细加载中')">积分明细</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Monitor Grid -->
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card" v-for="(item, idx) in monitorData" :key="idx">
|
||||
<text class="mc-title">{{ item.label }}</text>
|
||||
<text class="mc-val" :style="{ color: item.color || '#0F1226' }">{{ item.value }}</text>
|
||||
<text v-if="item.trend" class="mc-trend" :style="{ color: item.trendColor || '#8B91A1' }">{{ item.trend }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Source Card -->
|
||||
<view class="card rev-source-card">
|
||||
<text class="card-title">收益积分来源构成 <text class="card-title-sub">团长·激励金收益</text></text>
|
||||
<view class="info-row" v-for="(item, idx) in sourceData" :key="idx">
|
||||
<text class="ir-label" :style="{ color: item.color }">{{ item.label }}</text>
|
||||
<text class="ir-val" :style="{ color: item.color }">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Revenue Detail Card -->
|
||||
<view class="card rev-detail-card">
|
||||
<view class="rd-head">
|
||||
<text class="card-title">收益积分明细</text>
|
||||
</view>
|
||||
|
||||
<view class="pts-item" v-for="(item, idx) in filteredRevList" :key="idx">
|
||||
<view class="pts-icon" :style="{ background: item.iconBg }">
|
||||
<text class="pts-emoji">{{ item.icon }}</text>
|
||||
</view>
|
||||
<view class="pts-body">
|
||||
<text class="pts-title">{{ item.title }}</text>
|
||||
<text class="pts-sub">{{ item.sub }}</text>
|
||||
</view>
|
||||
<view class="pts-right">
|
||||
<text class="pts-amount" :style="{ color: item.amountColor }">{{ item.amount }}</text>
|
||||
<text class="pts-status" :style="{ background: item.statusBg, color: item.statusColor }">{{ item.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="2"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { formatMoney, formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const withdrawable = ref('0')
|
||||
const activeTab = ref('all')
|
||||
|
||||
const sourceData = ref([
|
||||
{ label: '直接推荐C端总收益', value: '0', color: '#00C4A7' },
|
||||
{ label: '直接推荐团长贡献收益', value: '0', color: '#722ED1' }
|
||||
])
|
||||
|
||||
const monitorData = ref([
|
||||
{ label: '今日积分', value: '0', color: '#1677FF', trend: null },
|
||||
{ label: '本月积分', value: '0' },
|
||||
{ label: '昨日积分', value: '0' },
|
||||
{ label: '历史总积分', value: '0' }
|
||||
])
|
||||
|
||||
const revList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
const typeMap = {
|
||||
commission: { icon: '💰', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已发放', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
unfrozen: { icon: '🔓', iconBg: '#E6FAF5', amountColor: '#00C4A7', status: '已解冻', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
withdraw: { icon: '💳', iconBg: '#F0F2F5', amountColor: '#6B7280', status: '已发放', statusBg: '#F0F2F5', statusColor: '#6B7280' },
|
||||
gift: { icon: '🎁', iconBg: '#FFFBE6', amountColor: '#FFD666', status: '不可兑换', statusBg: '#FFFBE6', statusColor: '#FF8800' }
|
||||
}
|
||||
|
||||
const withdrawalStatusMap = {
|
||||
pending: { status: '待审核', statusBg: '#FFF7E6', statusColor: '#FF8800' },
|
||||
approved: { status: '待打款', statusBg: '#E6F4FF', statusColor: '#1677FF' },
|
||||
paid: { status: '已入账', statusBg: '#E6FAF5', statusColor: '#00C4A7' },
|
||||
rejected: { status: '已拒绝', statusBg: '#FFF1F0', statusColor: '#FF4D4F' }
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/super-leader/revenue-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
withdrawable.value = formatThousands(d.withdrawable || 0)
|
||||
monitorData.value = (d.monitor || []).map(m => ({
|
||||
...m,
|
||||
color: m.label === '今日积分' ? '#1677FF' : undefined,
|
||||
value: formatThousands(m.value || 0)
|
||||
}))
|
||||
sourceData.value = (d.source || []).map((s, index) => ({
|
||||
...s,
|
||||
color: ['#00C4A7', '#722ED1'][index],
|
||||
value: formatThousands(s.value || 0)
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取收益汇总失败', e) }
|
||||
}
|
||||
|
||||
const fetchDetail = async (pageNum = 1) => {
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-points-log', { types: 'commission,unfrozen,withdraw,gift', page: pageNum, showLoading: false })
|
||||
if (res.data) {
|
||||
const newList = (res.data.items || []).map(r => {
|
||||
const tm = typeMap[r.type] || typeMap.commission
|
||||
const withdrawalStatus = r.type === 'withdraw' ? withdrawalStatusMap[r.withdrawal_status] : null
|
||||
return {
|
||||
icon: tm.icon,
|
||||
iconBg: tm.iconBg,
|
||||
title: r.remark || r.type_text || '积分记录',
|
||||
sub: `${r.date || ''} · ${r.type_text || '收益积分'}`,
|
||||
amount: (r.points || 0) >= 0 ? `+${formatMoney(r.points || 0)}` : formatMoney(r.points || 0),
|
||||
amountColor: (r.points || 0) >= 0 ? tm.amountColor : '#FF4D4F',
|
||||
status: withdrawalStatus?.status || tm.status,
|
||||
statusBg: withdrawalStatus?.statusBg || tm.statusBg,
|
||||
statusColor: withdrawalStatus?.statusColor || tm.statusColor
|
||||
}
|
||||
})
|
||||
revList.value = pageNum === 1 ? newList : revList.value.concat(newList)
|
||||
page.value = pageNum
|
||||
hasMore.value = res.data.current_page < res.data.last_page
|
||||
}
|
||||
} catch (e) { console.error('获取积分明细失败', e) }
|
||||
}
|
||||
|
||||
const filteredRevList = computed(() => revList.value)
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchDetail(page.value + 1)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg) => { uni.showToast({ title: msg, icon: 'none' }) }
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([fetchSummary(), fetchDetail(1)]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => { fetchSummary(); fetchDetail(1) })
|
||||
|
||||
onShow(() => { uni.$emit('pageShow') })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
.rev-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 48rpx rgba(13,43,110,.25);
|
||||
}
|
||||
|
||||
.rev-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -60rpx;
|
||||
right: -40rpx;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,.1), transparent 65%);
|
||||
}
|
||||
|
||||
.rev-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 48rpx 48rpx;
|
||||
mask-image: linear-gradient(180deg, #000, transparent);
|
||||
-webkit-mask-image: linear-gradient(180deg, #000, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rh-label { font-size: 24rpx; opacity: 0.65; position: relative; z-index: 1; display: block; }
|
||||
.rh-amount { font-size: 68rpx; font-weight: 800; margin: 8rpx 0 28rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; position: relative; z-index: 1; display: block; color: #fff; }
|
||||
|
||||
.rh-btns { display: flex; gap: 16rpx; position: relative; z-index: 1; }
|
||||
.rh-btn { padding: 12rpx 28rpx; border-radius: 36rpx; font-size: 24rpx; font-weight: 600; }
|
||||
.rh-btn-primary { background: rgba(255,255,255,.18); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.25); }
|
||||
.rh-btn-secondary { background: rgba(255,255,255,.1); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.15); }
|
||||
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.monitor-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4rpx;
|
||||
background: linear-gradient(90deg, transparent, #1677FF, transparent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.mc-title { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mc-val { font-size: 48rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mc-trend { font-size: 20rpx; display: block; margin-top: 6rpx; }
|
||||
|
||||
.card { background: #fff; border-radius: 28rpx; padding: 32rpx; box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04); margin-bottom: 16rpx; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 20rpx; }
|
||||
.card-title-sub { font-size: 22rpx; color: #BCC2CE; font-weight: 400; margin-left: 8rpx; }
|
||||
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 24rpx 0; }
|
||||
.info-row + .info-row { border-top: 1rpx solid #F3F5F9; }
|
||||
.ir-label { font-size: 26rpx; color: #5A6072; }
|
||||
.ir-val { font-size: 26rpx; font-weight: 500; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
|
||||
.rev-detail-card .card-title { margin-bottom: 0; }
|
||||
.rd-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24rpx; }
|
||||
|
||||
.pts-tabs { display: flex; gap: 0; background: #F3F5F9; border-radius: 20rpx; padding: 6rpx; }
|
||||
.pts-tab { padding: 10rpx 24rpx; font-size: 22rpx; font-weight: 500; color: #8B91A1; border-radius: 16rpx; transition: all 0.2s; }
|
||||
.pts-tab.active { background: #fff; color: #1677FF; font-weight: 600; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
|
||||
|
||||
.pts-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1px solid #F3F5F9;
|
||||
}
|
||||
|
||||
.pts-item:last-child { border-bottom: none; }
|
||||
|
||||
.pts-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pts-emoji { font-size: 32rpx; }
|
||||
|
||||
.pts-body { flex: 1; min-width: 0; }
|
||||
.pts-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.pts-sub { font-size: 22rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
|
||||
.pts-right { text-align: right; flex-shrink: 0; }
|
||||
.pts-amount { font-size: 30rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.pts-status { font-size: 20rpx; padding: 4rpx 16rpx; border-radius: 20rpx; display: inline-block; margin-top: 6rpx; }
|
||||
.load-more { padding: 24rpx 0 8rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
.safe-bottom-spacer { height: calc(env(safe-area-inset-bottom) + 200rpx); }
|
||||
</style>
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="团队" :showBell="false" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true"
|
||||
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Team Stats Hero -->
|
||||
<view class="team-hero">
|
||||
<view class="team-hero-top">
|
||||
<view class="th-left">
|
||||
<text class="th-label">直接推荐C端总数</text>
|
||||
<text class="th-val">{{ teamStats.userCount }}<text class="th-unit"> 人</text></text>
|
||||
</view>
|
||||
<view class="th-right">
|
||||
<text class="th-label">团队总贡献积分</text>
|
||||
<text class="th-val2">{{ teamStats.totalContribution }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="team-hero-stats">
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.directCUsers }}</text>
|
||||
<text class="ths-label">团长直接推荐C端</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.deviceCount }}</text>
|
||||
<text class="ths-label">设备总数(台)</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.totalSpend }}</text>
|
||||
<text class="ths-label">总消费金额</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val" style="color:#B37FEB">{{ teamStats.leaderContribution }}</text>
|
||||
<text class="ths-label">团长团队贡献</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view class="tab" :class="{ active: activeTeamTab === 'c' }" @click="switchTeamTab('c')">
|
||||
直接推荐C端 ({{ teamStats.userCount }})
|
||||
</view>
|
||||
<view class="tab" :class="{ active: activeTeamTab === 'leader' }" @click="switchTeamTab('leader')">
|
||||
直接推荐团长 ({{ teamStats.leaderCount }})
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- C端列表 -->
|
||||
<view v-show="activeTeamTab === 'c'">
|
||||
<view class="summary-card">
|
||||
<text class="summary-title">直接推荐C端用户·贡献收益汇总</text>
|
||||
<view class="summary-badges">
|
||||
<text class="badge badge-blue">直接推荐C端 {{ teamStats.userCount }}人</text>
|
||||
<text class="badge badge-gray">设备总数 {{ teamStats.deviceCount }}台</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-group">
|
||||
<view v-if="teamMembers.length === 0" class="empty-holder">
|
||||
<text class="empty-text">暂无下属成员记录</text>
|
||||
</view>
|
||||
<view class="list-item" v-for="(member, idx) in teamMembers" :key="idx">
|
||||
<view class="member-avatar" :style="{ background: member.avatarBg }">
|
||||
{{ member.avatar }}
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">{{ member.name }}</text>
|
||||
<text class="li-sub">由团长"{{ member.promoter }}"直接推荐 · {{ member.devices }}台设备</text>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-points">{{ formatThousands(member.points) }}</text>
|
||||
<text class="li-sub-label">贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="teamMembers.length > 0 && memberState.c.hasMore" class="load-more"
|
||||
@click="loadMoreMembers('c')">
|
||||
<text>{{ memberState.c.loading ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 团长列表 -->
|
||||
<view v-show="activeTeamTab === 'leader'">
|
||||
<view class="summary-card leader-summary">
|
||||
<text class="summary-title" style="color:#722ED1">直接推荐团长·团队贡献收益汇总</text>
|
||||
<view class="summary-badges">
|
||||
<text class="badge badge-purple">直接推荐团长 {{ teamStats.leaderCount }}人</text>
|
||||
<text class="badge badge-purple">团队C端 {{ teamStats.leaderTeamCCount }}人</text>
|
||||
<text class="badge badge-purple">设备 {{ teamStats.leaderTeamDeviceCount }}台</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list-group">
|
||||
<view v-if="leaderMembers.length === 0" class="empty-holder">
|
||||
<text class="empty-text">暂无下属团长记录</text>
|
||||
</view>
|
||||
<view class="list-item leader-item" v-for="(item, idx) in leaderMembers" :key="idx">
|
||||
<view class="member-avatar" :style="{ background: item.avatarBg }">
|
||||
{{ item.avatar }}
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title" style="color:#722ED1">{{ item.name }}</text>
|
||||
<text class="li-sub">{{ item.desc }}</text>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-points" style="color:#722ED1">{{ formatThousands(item.points) }}</text>
|
||||
<text class="li-sub-label">团队贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="leaderMembers.length > 0 && memberState.leader.hasMore" class="load-more"
|
||||
@click="loadMoreMembers('leader')">
|
||||
<text>{{ memberState.leader.loading ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="1"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const activeTeamTab = ref('c')
|
||||
|
||||
const teamStats = ref({
|
||||
userCount: 0, totalContribution: '0', directCUsers: 0,
|
||||
deviceCount: 0, totalSpend: '0', leaderContribution: '0',
|
||||
leaderCount: 0, leaderTeamCCount: 0, leaderTeamDeviceCount: 0
|
||||
})
|
||||
|
||||
const teamMembers = ref([])
|
||||
const leaderMembers = ref([])
|
||||
const memberState = reactive({
|
||||
c: { page: 0, hasMore: true, loading: false, loaded: false },
|
||||
leader: { page: 0, hasMore: true, loading: false, loaded: false }
|
||||
})
|
||||
|
||||
const AVATAR_BGS = [
|
||||
'linear-gradient(135deg,#1677FF,#4096FF)',
|
||||
'linear-gradient(135deg,#00C4A7,#5FE6CC)',
|
||||
'linear-gradient(135deg,#FFD700,#FFA500)',
|
||||
'linear-gradient(135deg,#FF4D4F,#FF7A7B)',
|
||||
'linear-gradient(135deg,#722ED1,#B37FEB)'
|
||||
]
|
||||
|
||||
const fetchTeam = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/super-leader/team', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
teamStats.value = {
|
||||
userCount: d.stats?.user_count || 0,
|
||||
totalContribution: formatThousands(d.stats?.total_contribution || 0),
|
||||
directCUsers: d.stats?.direct_c_users || 0,
|
||||
deviceCount: d.stats?.device_count || 0,
|
||||
totalSpend: formatThousands(d.stats?.total_spend || 0),
|
||||
leaderContribution: formatThousands(d.stats?.leader_contribution || 0),
|
||||
leaderCount: d.stats?.leader_count || 0,
|
||||
leaderTeamCCount: d.stats?.leader_team_c_count || 0,
|
||||
leaderTeamDeviceCount: d.stats?.leader_team_device_count || 0
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('获取超级团长团队失败', e) }
|
||||
}
|
||||
|
||||
const getMemberList = (type) => type === 'c' ? teamMembers : leaderMembers
|
||||
|
||||
const resetMembers = (type) => {
|
||||
getMemberList(type).value = []
|
||||
Object.assign(memberState[type], { page: 0, hasMore: true, loading: false, loaded: false })
|
||||
}
|
||||
|
||||
const fetchTeamMembers = async (type, reset = false) => {
|
||||
if (reset) resetMembers(type)
|
||||
const state = memberState[type]
|
||||
if (state.loading || !state.hasMore) return
|
||||
|
||||
state.loading = true
|
||||
try {
|
||||
const res = await request.get('/c/super-leader/team-members', {
|
||||
type,
|
||||
page: state.page + 1,
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
const target = getMemberList(type)
|
||||
const startIndex = target.value.length
|
||||
const nextList = (res.data.list || []).map((member, idx) => {
|
||||
if (type === 'c') {
|
||||
return {
|
||||
name: member.name,
|
||||
avatar: member.avatar || (member.name || '用')[0],
|
||||
avatarBg: AVATAR_BGS[(startIndex + idx) % AVATAR_BGS.length],
|
||||
promoter: member.promoter || '--',
|
||||
devices: member.device_count || 0,
|
||||
points: member.contribution || 0
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: member.name,
|
||||
avatar: member.avatar || (member.name || '用')[0],
|
||||
avatarBg: 'linear-gradient(135deg,#722ED1,#B37FEB)',
|
||||
desc: `邀请加入 ${member.invite_count || 0}人`,
|
||||
points: member.contribution || 0
|
||||
}
|
||||
})
|
||||
target.value.push(...nextList)
|
||||
state.page = res.data.current_page || state.page + 1
|
||||
state.hasMore = Boolean(res.data.has_more)
|
||||
state.loaded = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取超级团长团队成员失败', e)
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchTeamTab = (type) => {
|
||||
activeTeamTab.value = type
|
||||
if (!memberState[type].loaded) fetchTeamMembers(type)
|
||||
}
|
||||
|
||||
const loadMoreMembers = (type) => fetchTeamMembers(type)
|
||||
|
||||
onMounted(() => {
|
||||
fetchTeam()
|
||||
fetchTeamMembers('c')
|
||||
})
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
resetMembers('c')
|
||||
resetMembers('leader')
|
||||
Promise.all([fetchTeam(), fetchTeamMembers(activeTeamTab.value)])
|
||||
.finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onShow(() => { uni.$emit('pageShow') })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container { min-height: 100vh; background: #F2F4F8; }
|
||||
.page-content { height: calc(100vh - 88px); }
|
||||
.content-pad { padding: 24rpx; }
|
||||
.empty-holder { padding: 60rpx 0; text-align: center; }
|
||||
.empty-text { font-size: 24rpx; color: #BCC2CE; }
|
||||
|
||||
.team-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.team-hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.th-label { font-size: 24rpx; opacity: 0.65; display: block; }
|
||||
.th-val { font-size: 56rpx; font-weight: 800; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
.th-unit { font-size: 26rpx; font-weight: 400; opacity: 0.7; }
|
||||
.th-val2 { font-size: 40rpx; font-weight: 700; color: #7FFFD4; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin-top: 4rpx; }
|
||||
|
||||
.team-hero-stats {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ths-item {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,.08);
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ths-val { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.ths-label { font-size: 20rpx; opacity: 0.65; display: block; margin-top: 4rpx; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1rpx solid #EAEDF3;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 22rpx 0;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #8B91A1;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #1677FF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 44rpx;
|
||||
height: 5rpx;
|
||||
background: linear-gradient(90deg, #4096FF, #1677FF);
|
||||
border-radius: 4rpx;
|
||||
box-shadow: 0 0 12rpx rgba(22,119,255,.4);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: linear-gradient(135deg, #F9F0FF, #FAF5FF);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.summary-card.leader-summary {
|
||||
background: linear-gradient(135deg, #F9F0FF, #FAF5FF);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 26rpx;
|
||||
color: #1677FF;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.summary-badges {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 10rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-blue { background: #E6F4FF; color: #1677FF; }
|
||||
.badge-gray { background: #F3F5F9; color: #5A6072; }
|
||||
.badge-purple { background: #F9F0FF; color: #722ED1; }
|
||||
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.list-item + .list-item { border-top: 1rpx solid #F3F5F9; }
|
||||
|
||||
.leader-item {
|
||||
border-left: 6rpx solid #722ED1;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
|
||||
.li-right { text-align: right; flex-shrink: 0; }
|
||||
.li-points { font-size: 26rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.li-sub-label { font-size: 20rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
.load-more { padding: 24rpx 0; text-align: center; font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
.safe-bottom-spacer { height: calc(env(safe-area-inset-bottom) + 200rpx); }
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<BTopBar title="团队" :showBell="false" />
|
||||
|
||||
<!-- 采用 flex: 1 动态自适应屏幕剩余高度 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="page-content"
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view class="content-pad">
|
||||
|
||||
<!-- Team Stats Hero -->
|
||||
<view class="team-hero">
|
||||
<view class="team-hero-top">
|
||||
<view class="th-left">
|
||||
<text class="th-label">下属用户总数</text>
|
||||
<text class="th-val">{{ teamStats.userCount }}<text class="th-unit"> 人</text></text>
|
||||
</view>
|
||||
<view class="th-right">
|
||||
<text class="th-label">团队总贡献积分</text>
|
||||
<text class="th-val2">{{ teamStats.totalContribution }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="team-hero-stats">
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.directPush }}</text>
|
||||
<text class="ths-label">团长直接推荐C端</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.deviceCount }}</text>
|
||||
<text class="ths-label">设备总数(台)</text>
|
||||
</view>
|
||||
<view class="ths-item">
|
||||
<text class="ths-val">{{ teamStats.totalSpend }}</text>
|
||||
<text class="ths-label">总消费金额</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab -->
|
||||
<view class="tabs">
|
||||
<view class="tab active">直接推荐C端 ({{ teamMembers.length }})</view>
|
||||
</view>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<view class="summary-card">
|
||||
<text class="summary-title">直接推荐C端用户·贡献收益汇总</text>
|
||||
<view class="summary-badges">
|
||||
<text class="badge badge-blue">直接推荐C端 {{ teamMembers.length }}人</text>
|
||||
<text class="badge badge-gray">设备总数 {{ teamStats.deviceCount }}台</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- User List -->
|
||||
<view class="list-group">
|
||||
<!-- 缺省状态处理 -->
|
||||
<view v-if="teamMembers.length === 0" class="empty-holder">
|
||||
<text class="empty-text">暂无下属成员记录</text>
|
||||
</view>
|
||||
|
||||
<view class="list-item" v-for="(member, idx) in teamMembers" :key="idx">
|
||||
<view class="member-avatar" :style="{ background: member.avatarBg }">
|
||||
{{ member.avatar }}
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">{{ member.name }}</text>
|
||||
<text class="li-sub">由团长"{{ member.promoter }}"直接推荐 · {{ member.devices }}台设备</text>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text class="li-points">{{ formatThousands(member.points) }}</text>
|
||||
<text class="li-sub-label">贡献积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区占位(兼容 CustomTabBar 高度) -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="1"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import BTopBar from '@/components/BTopBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import request from '@/utils/request'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
const teamStats = ref({
|
||||
userCount: 0,
|
||||
totalContribution: '0',
|
||||
directPush: 0,
|
||||
deviceCount: 0,
|
||||
totalSpend: '0'
|
||||
})
|
||||
|
||||
const teamMembers = ref([])
|
||||
|
||||
const AVATAR_BGS = [
|
||||
'linear-gradient(135deg,#1677FF,#4096FF)'
|
||||
]
|
||||
|
||||
const fetchTeam = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/leader/team', { showLoading: false })
|
||||
if (res.data) {
|
||||
const d = res.data
|
||||
teamStats.value = {
|
||||
userCount: d.stats?.user_count || 0,
|
||||
totalContribution: formatThousands(d.stats?.total_contribution || 0),
|
||||
directPush: d.stats?.direct_push || 0,
|
||||
deviceCount: d.stats?.device_count || 0,
|
||||
totalSpend: formatThousands(d.stats?.total_spend || 0)
|
||||
}
|
||||
teamMembers.value = (d.members || []).map((m, idx) => ({
|
||||
name: m.name,
|
||||
avatar: m.avatar || (m.name || '用')[0],
|
||||
avatarBg: AVATAR_BGS[idx % AVATAR_BGS.length],
|
||||
promoter: m.promoter || '--',
|
||||
devices: m.device_count || 0,
|
||||
points: m.contribution || 0
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取团队数据失败', e) }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchTeam() })
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
fetchTeam().finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
uni.$emit('pageShow')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全局盒模型规则[cite: 7] */
|
||||
view, text, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0; /* 触发 flex 压缩,保证跨端自适应不溢出 */
|
||||
}
|
||||
|
||||
.content-pad { padding: 24rpx; }
|
||||
|
||||
/* Team Hero */
|
||||
.team-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 12rpx 36rpx rgba(13,43,110,.2);
|
||||
}
|
||||
|
||||
.team-hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.th-label { font-size: 24rpx; opacity: 0.65; display: block; }
|
||||
.th-val { font-size: 56rpx; font-weight: 800; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; }
|
||||
.th-unit { font-size: 26rpx; font-weight: 400; opacity: 0.7; }
|
||||
.th-val2 { font-size: 40rpx; font-weight: 700; color: #7FFFD4; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; margin-top: 4rpx; }
|
||||
|
||||
.team-hero-stats {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ths-item {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,.08);
|
||||
border-radius: 20rpx;
|
||||
padding: 16rpx;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.ths-val { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.ths-label { font-size: 20rpx; opacity: 0.65; display: block; margin-top: 4rpx; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1rpx solid #EAEDF3;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 22rpx 0;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1677FF;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 44rpx;
|
||||
height: 5rpx;
|
||||
background: linear-gradient(90deg, #4096FF, #1677FF);
|
||||
border-radius: 4rpx;
|
||||
box-shadow: 0 0 12rpx rgba(22,119,255,.4);
|
||||
}
|
||||
|
||||
/* Summary Card */
|
||||
.summary-card {
|
||||
background: linear-gradient(135deg, #F9F0FF, #FAF5FF);
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx;
|
||||
text-align: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 26rpx;
|
||||
color: #1677FF;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.summary-badges {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 10rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-blue { background: #E6F4FF; color: #1677FF; }
|
||||
.badge-gray { background: #F3F5F9; color: #5A6072; }
|
||||
|
||||
/* List Group */
|
||||
.list-group {
|
||||
background: #fff;
|
||||
border-radius: 28rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 6rpx rgba(15,18,38,.04);
|
||||
}
|
||||
|
||||
.empty-holder {
|
||||
padding: 60rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 24rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
gap: 24rpx;
|
||||
transition: background 0.15s;
|
||||
&:active { background: #F8F9FC; }
|
||||
}
|
||||
|
||||
.list-item + .list-item { border-top: 1rpx solid #F3F5F9; }
|
||||
|
||||
.member-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #0F1226;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
|
||||
.li-right { text-align: right; flex-shrink: 0; }
|
||||
.li-points { font-size: 26rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.li-sub-label { font-size: 20rpx; color: #BCC2CE; display: block; margin-top: 2rpx; }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<!--
|
||||
* 设备管理页 — 对齐 C端.html #page-devices
|
||||
* 汇总卡片 + 筛选Tab + 设备列表 + 自有设备订单入口
|
||||
-->
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<nav-bar title="我的设备" :show-back="false">
|
||||
<template #right>
|
||||
<text class="device-count">共 {{ stats.total }} 台</text>
|
||||
</template>
|
||||
</nav-bar>
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<!-- 设备来源汇总卡片 -->
|
||||
<view class="dev-summary">
|
||||
<view class="ds-card ds-buy" :class="{ active: filterTab === 1 }" @click="switchTab(1)">
|
||||
<text class="ds-label">平台购买</text>
|
||||
<text class="ds-count">{{ stats.platform }}<text class="ds-unit">台</text></text>
|
||||
<text class="ds-rev">累计积分 <text class="ds-rev-num">¥{{ formatThousands(stats.buyRevenue) }}</text></text>
|
||||
</view>
|
||||
<view class="ds-card ds-own" :class="{ active: filterTab === 2 }" @click="switchTab(2)">
|
||||
<text class="ds-label">自有部署</text>
|
||||
<text class="ds-count own-count">{{ stats.self }}<text class="ds-unit">台</text></text>
|
||||
<text class="ds-rev">累计积分 <text class="ds-rev-num">¥{{ formatThousands(stats.ownRevenue) }}</text></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选 Tab + 添加按钮 (分离布局) -->
|
||||
<view class="filter-row">
|
||||
<view class="tabs">
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="tab" :class="{ active: filterTab === i }" @click="switchTab(i)">{{ tab }}</view>
|
||||
</view>
|
||||
<button class="add-own-btn" @click="openSub('add-own-device')">+ 添加自有设备</button>
|
||||
</view>
|
||||
|
||||
<!-- 自有设备专属 Header (仅在查看“自有设备”或“全部”时根据需要展示) -->
|
||||
<view v-if="filterTab === 2" class="own-device-header">
|
||||
<text class="own-device-title">我的自有设备</text>
|
||||
<text class="own-device-link" @click="openSub('own-orders')">全部订单</text>
|
||||
</view>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<view class="device-list">
|
||||
<device-card
|
||||
v-for="device in filteredDevices"
|
||||
:key="device.id"
|
||||
:device="device"
|
||||
@click="openSub('device-detail', { id: device.id })"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-if="filteredDevices.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无相关设备</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view class="load-more" v-if="hasMore" @click="loadMore">
|
||||
<text class="lm-text">{{ loadingMore ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="1"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import DeviceCard from '@/components/DeviceCard.vue'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const filterTab = ref(0)
|
||||
const tabs = ['全部', '平台购买', '自有设备']
|
||||
|
||||
const deviceList = ref([])
|
||||
const page = ref(1)
|
||||
const hasMore = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
||||
// 顶部汇总
|
||||
const stats = reactive({ total: 0, running: 0, platform: 0, self: 0, buyRevenue: 0, ownRevenue: 0 })
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/device-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
stats.total = res.data.total || 0
|
||||
stats.running = res.data.running || 0
|
||||
stats.platform = res.data.platform || 0
|
||||
stats.self = res.data.self || 0
|
||||
stats.buyRevenue = res.data.buy_revenue || 0
|
||||
stats.ownRevenue = res.data.own_revenue || 0
|
||||
}
|
||||
} catch (e) { console.error('获取设备汇总失败', e) }
|
||||
}
|
||||
|
||||
const fetchDevices = async (pageNum = 1) => {
|
||||
try {
|
||||
const types = ['', 'platform', 'self']
|
||||
const res = await request.get('/c/devices', {
|
||||
page: pageNum,
|
||||
type: types[filterTab.value],
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
// API 返回 snake_case,转换为 DeviceCard 需要的 camelCase
|
||||
const newList = (res.data.list || []).map(d => ({
|
||||
id: d.id,
|
||||
device_id: d.device_id,
|
||||
name: d.name,
|
||||
model: d.model,
|
||||
source: d.source, // buy | own(后端已映射)
|
||||
status: d.status,
|
||||
bandwidth: d.bandwidth || 0,
|
||||
// 信用分暂时隐藏
|
||||
// creditScore: d.credit_score || 0,
|
||||
vipLevel: d.vip_level || 0,
|
||||
todayRevenue: d.today_revenue || 0,
|
||||
totalRevenue: d.total_revenue || 0
|
||||
}))
|
||||
deviceList.value = pageNum === 1 ? newList : deviceList.value.concat(newList)
|
||||
page.value = pageNum
|
||||
hasMore.value = res.data.has_more || false
|
||||
}
|
||||
} catch (e) { console.error('获取设备列表失败', e) }
|
||||
}
|
||||
|
||||
const filteredDevices = computed(() => {
|
||||
if (filterTab.value === 0) return deviceList.value
|
||||
if (filterTab.value === 1) return deviceList.value.filter(d => d.source === 'buy')
|
||||
return deviceList.value.filter(d => d.source === 'own')
|
||||
})
|
||||
|
||||
const switchTab = (index) => {
|
||||
if (filterTab.value === index) return
|
||||
filterTab.value = index
|
||||
fetchDevices(1)
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
await fetchDevices(page.value + 1)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([fetchDevices(1), fetchSummary()]).finally(() => {
|
||||
refreshing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
fetchDevices(1)
|
||||
fetchSummary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #F2F4F8;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.device-count { font-size: 24rpx; color: #8B91A1; }
|
||||
|
||||
// Summary Cards
|
||||
.dev-summary { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
|
||||
.ds-card {
|
||||
flex: 1; background: #fff; border-radius: 10rpx; padding: 20rpx 24rpx;
|
||||
box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); position: relative; overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
&::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 6rpx; }
|
||||
&.active { transform: translateY(-2rpx); box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); }
|
||||
}
|
||||
.ds-buy::before { background: linear-gradient(180deg, #1677FF, #4096FF); }
|
||||
.ds-own::before { background: linear-gradient(180deg, #722ED1, #B37FEB); }
|
||||
.ds-label { font-size: 20rpx; color: #8B91A1; display: block; }
|
||||
.ds-count { font-size: 36rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; color: #0F1226; display: block; margin: 4rpx 0; }
|
||||
.own-count { color: #722ED1; }
|
||||
.ds-unit { font-size: 22rpx; font-weight: 400; color: #8B91A1; margin-left: 4rpx; }
|
||||
.ds-rev { font-size: 20rpx; color: #8B91A1; }
|
||||
.ds-rev-num { font-weight: 600; color: #5A6072; }
|
||||
.load-more { padding: 24rpx 0 36rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
|
||||
// Filter Row & Tabs
|
||||
.filter-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
border-bottom: 0.5px solid #EAEDF3;
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
.tabs { display: flex; gap: 32rpx; }
|
||||
.tab {
|
||||
padding: 12rpx 0; font-size: 26rpx; font-weight: 500;
|
||||
color: #8B91A1; position: relative; transition: color 0.2s;
|
||||
&.active {
|
||||
color: #1677FF; font-weight: 600;
|
||||
&::after {
|
||||
content: ''; position: absolute; bottom: -8rpx; left: 50%; transform: translateX(-50%);
|
||||
width: 32rpx; height: 5rpx; background: linear-gradient(90deg, #4096FF, #1677FF);
|
||||
border-radius: 2px; box-shadow: 0 0 12rpx rgba(22, 119, 255, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
.add-own-btn {
|
||||
margin: 0;
|
||||
background: #722ED1; color: #fff; font-size: 22rpx; font-weight: 600;
|
||||
border-radius: 28rpx; padding: 0 20rpx; height: 52rpx; line-height: 52rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(114, 46, 209, 0.25); border: none;
|
||||
&::after { border: none; }
|
||||
&:active { background: #5B1FAE; }
|
||||
}
|
||||
|
||||
// Own Device Header
|
||||
.own-device-header { display: flex; justify-content: space-between; align-items: center; padding: 0 4rpx; margin-bottom: 16rpx; }
|
||||
.own-device-title { font-size: 24rpx; color: #8B91A1; }
|
||||
.own-device-link { font-size: 24rpx; color: #1677FF; font-weight: 600; }
|
||||
|
||||
// Empty
|
||||
.empty-state { text-align: center; padding: 80rpx 0; }
|
||||
.empty-text { font-size: 26rpx; color: #BCC2CE; }
|
||||
</style>
|
||||
@@ -0,0 +1,723 @@
|
||||
<!--
|
||||
* C端首页 — 对齐 C端.html #page-home
|
||||
* Hero + 公告 + 快捷入口 + 设备列表 + 明星设备 + 排行榜 + 行业资讯
|
||||
-->
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="top-bar-inner">
|
||||
<view class="top-bar-logo">
|
||||
<view class="logo-dot"></view>
|
||||
<text class="logo-text">全犀云</text>
|
||||
</view>
|
||||
<view class="top-bar-search">
|
||||
<input class="search-input" v-model="searchKeyword" placeholder="搜索设备" confirm-type="search" @confirm="handleSearch" />
|
||||
<text v-if="searchKeyword" class="search-clear" @click="clearSearch">✕</text>
|
||||
<text class="search-btn" @click="handleSearch">🔍</text>
|
||||
</view>
|
||||
<view class="top-bar-action" @click="openSub('notifications')">
|
||||
<u-icon name="bell" size="20" color="#5A6072"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 滚动区域:采用 flex:1 + height:0 自动填满剩余空间 -->
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<!-- ============================================ -->
|
||||
<!-- Hero收益卡片 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="hero-card">
|
||||
<view class="hero-grid-bg"></view>
|
||||
<text class="hero-greeting">{{ greeting }},{{ userCompany }}</text>
|
||||
<view class="hero-row">
|
||||
<view>
|
||||
<text class="hero-label">累计积分</text>
|
||||
<view class="hero-amount">
|
||||
<text class="amount-num">¥{{ stats.totalPoints }}</text>
|
||||
<text class="amount-unit">≈ {{ stats.totalPoints }} 积分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-stats">
|
||||
<view class="hero-stat" @click="switchTab('devices')">
|
||||
<text class="hs-label">我的设备</text>
|
||||
<text class="hs-val">{{ deviceStats.total }} 台</text>
|
||||
<text class="hs-sub">{{ deviceStats.running }} 运行中</text>
|
||||
</view>
|
||||
<view class="hero-stat" @click="switchTab('revenue')">
|
||||
<text class="hs-label">今日产出</text>
|
||||
<text class="hs-val">¥{{ stats.todayPoints }}</text>
|
||||
<text class="hs-sub up"><!-- TODO: vs 昨日 --></text>
|
||||
</view>
|
||||
<!-- 信用分暂时隐藏
|
||||
<view class="hero-stat" @click="openSub('credit-detail')">
|
||||
<text class="hs-label">信用分</text>
|
||||
<text class="hs-val">{{ heroCreditScore }} <text class="hs-grade">{{ heroCreditGrade }}</text></text>
|
||||
<text class="hs-sub">全部设备均值</text>
|
||||
</view>
|
||||
-->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 公告栏 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="notice-bar" @click="openSub('notifications')">
|
||||
<view class="nb-icon-box">
|
||||
<text class="nb-emoji">📢</text>
|
||||
</view>
|
||||
<text class="nb-text">{{ noticeText }}</text>
|
||||
<text class="nb-more">更多 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 快捷功能网格(4项独立卡片) -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<view class="quick-grid">
|
||||
<view v-for="(item, i) in quickGrid" :key="i" class="quick-item" @click="handleQuick(item)">
|
||||
<view class="qi-icon" :style="{ background: item.bg }">
|
||||
<text v-if="item.emoji" class="qi-emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="22" :color="item.color"></u-icon>
|
||||
</view>
|
||||
<text class="qi-label">{{ item.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 我的设备区域 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">我的设备</text>
|
||||
<view class="section-more" @click="switchTab('devices')">
|
||||
全部 {{ deviceStats.total }}台 ›
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<device-card
|
||||
v-for="device in deviceList"
|
||||
:key="device.id"
|
||||
:device="device"
|
||||
@click="openSub('device-detail', { id: device.id })"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 明星设备(横向滚动产品卡) -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">明星设备</text>
|
||||
<view class="section-more" @click="switchTab('mall')">
|
||||
进入商城 ›
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x enable-flex class="star-scroll" :show-scrollbar="false">
|
||||
<view class="star-row">
|
||||
<view
|
||||
v-for="(item, i) in starDevices"
|
||||
:key="i"
|
||||
class="star-card"
|
||||
@click="openSub('product-detail', { id: item.id })"
|
||||
>
|
||||
<view class="sc-img" :style="item.image ? {} : { background: item.bg }">
|
||||
<image v-if="item.image" :src="item.image" mode="aspectFill" class="sc-cover-img" />
|
||||
<text v-else class="sc-img-icon">{{ item.emoji }}</text>
|
||||
</view>
|
||||
<view class="sc-body">
|
||||
<text class="sc-name">{{ item.name }}</text>
|
||||
<text class="sc-specs">{{ item.specs }}</text>
|
||||
<text class="sc-price">¥{{ formatThousands(item.price) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 排行榜 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">排行榜</text>
|
||||
<view class="section-more" @click="openSub('leaderboard')">
|
||||
完整榜单 ›
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 药丸样式 Tab -->
|
||||
<view class="home-lb-tabs">
|
||||
<view class="home-lb-tab" :class="{ active: rankTab === 'user' }" @click="switchRankTab('user')">用户收益榜</view>
|
||||
<view class="home-lb-tab" :class="{ active: rankTab === 'device' }" @click="switchRankTab('device')">设备收益榜</view>
|
||||
</view>
|
||||
|
||||
<view class="lb-list">
|
||||
<view class="lb-item" v-for="(item, i) in rankList" :key="i">
|
||||
<view class="lb-rank" :class="i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''">{{ i + 1 }}</view>
|
||||
<text class="lb-name">{{ item.name }}</text>
|
||||
<text class="lb-val">¥{{ formatThousands(item.revenue) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 行业资讯 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">行业资讯</text>
|
||||
<view class="section-more" @click="openSub('news-list')">
|
||||
更多 ›
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-for="(news, i) in newsList" :key="news.id" class="news-card" @click="openNewsDetail(news)">
|
||||
<view class="nc-img" :style="news.cover ? {} : { background: news.bg }">
|
||||
<image v-if="news.cover" :src="news.cover" mode="aspectFill" class="nc-cover-img" />
|
||||
<text v-else class="nc-img-icon">{{ news.emoji }}</text>
|
||||
</view>
|
||||
<view class="nc-body">
|
||||
<text class="nc-title">{{ news.title }}</text>
|
||||
<text v-if="news.subtitle" class="nc-subtitle">{{ news.subtitle }}</text>
|
||||
<view class="nc-meta">
|
||||
<text class="nc-cat">{{ news.cat }}</text>
|
||||
<text>{{ news.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区遮挡占位 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="0"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
import DeviceCard from '@/components/DeviceCard.vue'
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const statusBarHeight = ref(20)
|
||||
uni.getSystemInfo({ success: r => { statusBarHeight.value = r.statusBarHeight || 20 } })
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 6 && hour < 12) return '上午好'
|
||||
if (hour >= 12 && hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
}
|
||||
|
||||
const greeting = ref(getGreeting())
|
||||
const userCompany = ref(uni.getStorageSync('userInfo')?.nickname || '用户')
|
||||
const refreshing = ref(false)
|
||||
|
||||
// 收益统计
|
||||
const stats = reactive({ totalPoints: 0, todayPoints: 0 })
|
||||
// 设备统计
|
||||
const deviceStats = reactive({ total: 0, running: 0, platform: 0, self: 0 })
|
||||
// 信用分暂时隐藏
|
||||
// const heroCreditScore = ref('--')
|
||||
// const heroCreditGrade = ref('')
|
||||
const noticeText = ref('')
|
||||
|
||||
const fetchNotices = async () => {
|
||||
try {
|
||||
const info = uni.getStorageSync('userInfo')
|
||||
if (info?.role && info.role !== 'c') return
|
||||
const res = await request.get('/c/notices', { limit: 1, showLoading: false })
|
||||
const items = res.data?.items || res.data || []
|
||||
if (items.length > 0) {
|
||||
noticeText.value = items[0].title
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// 信用分暂时隐藏
|
||||
// const fetchProfileCredit = async () => {
|
||||
// try {
|
||||
// const res = await request.get('/c/profile', { showLoading: false })
|
||||
// const cs = res.data?.avg_credit_score
|
||||
// if (cs && cs > 0) {
|
||||
// heroCreditScore.value = cs
|
||||
// heroCreditGrade.value = cs >= 95 ? 'A' : cs >= 80 ? 'B' : cs >= 60 ? 'C' : 'D'
|
||||
// }
|
||||
// } catch (e) { /* 忽略 */ }
|
||||
// }
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
stats.totalPoints = res.data.cumulative_points ?? 0
|
||||
stats.todayPoints = res.data.today_points ?? 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取收益统计失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDeviceStats = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/device-summary', { showLoading: false })
|
||||
if (res.data) {
|
||||
Object.assign(deviceStats, res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取设备统计失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷功能网格
|
||||
const quickGrid = ref([
|
||||
{ code: 'invitation', name: '邀请', color: '#FF4D6D', bg: '#FFE4E6', icon: 'heart' },
|
||||
{ code: 'tickets', name: '工单', color: '#6B7280', bg: '#F0F2F5', icon: 'order' },
|
||||
{ code: 'docs', name: '帮助文档', color: '#00C4A7', bg: '#E6FAF5', icon: 'bookmark' },
|
||||
{ code: 'calculator', name: '积分计算器', color: '#1677FF', bg: '#E6F4FF', icon: 'calculator', emoji: '🧮' }
|
||||
])
|
||||
|
||||
// 设备列表(从 API 获取)
|
||||
const deviceList = ref([])
|
||||
// 首页最多展示的设备数
|
||||
const HOME_DEVICE_LIMIT = 3
|
||||
|
||||
const fetchDeviceList = async (keyword = '') => {
|
||||
try {
|
||||
const res = await request.get('/c/devices', { keyword, showLoading: false })
|
||||
if (res.data) {
|
||||
// API 返回 snake_case,转换为 DeviceCard 需要的 camelCase
|
||||
deviceList.value = (res.data || []).slice(0, HOME_DEVICE_LIMIT).map(d => ({
|
||||
id: d.id,
|
||||
device_id: d.device_id,
|
||||
name: d.name,
|
||||
model: d.model,
|
||||
source: d.source, // buy | own
|
||||
status: d.status,
|
||||
bandwidth: d.bandwidth,
|
||||
// 信用分暂时隐藏
|
||||
// creditScore: d.credit_score,
|
||||
vipLevel: d.vip_level,
|
||||
todayRevenue: d.today_revenue,
|
||||
totalRevenue: d.total_revenue
|
||||
}))
|
||||
}
|
||||
return deviceList.value.length
|
||||
} catch (e) {
|
||||
console.error('获取设备列表失败', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 明星设备(横向滚动推荐,最多5条)
|
||||
const starDevices = ref([])
|
||||
// 明星设备渐变色备选(无图片时使用)
|
||||
const STAR_GRADIENTS = [
|
||||
{ bg: 'linear-gradient(135deg, #0D2B6E, #1342B5)', emoji: '🖥️' },
|
||||
{ bg: 'linear-gradient(135deg, #1342B5, #1B66E8)', emoji: '💻' },
|
||||
{ bg: 'linear-gradient(135deg, #1B66E8, #4096FF)', emoji: '🖥️' },
|
||||
{ bg: 'linear-gradient(135deg, #0A1733, #0D2B6E)', emoji: '🖥️' },
|
||||
{ bg: 'linear-gradient(135deg, #1B66E8, #1890FF)', emoji: '💻' }
|
||||
]
|
||||
|
||||
const fetchStarDevices = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/star-products', { showLoading: false })
|
||||
if (res.data) {
|
||||
starDevices.value = (res.data || []).map((item, i) => ({
|
||||
...item,
|
||||
bg: STAR_GRADIENTS[i]?.bg || STAR_GRADIENTS[0].bg,
|
||||
emoji: STAR_GRADIENTS[i]?.emoji || '🖥️'
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取明星设备失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 排行榜
|
||||
const rankTab = ref('user')
|
||||
const userRankList = ref([])
|
||||
const deviceRankList = ref([])
|
||||
const rankList = ref([])
|
||||
|
||||
const fetchUserRank = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/leaderboard/users', { limit: 5, showLoading: false })
|
||||
if (res.data) {
|
||||
userRankList.value = res.data.map(item => ({
|
||||
name: item.nickname || '',
|
||||
revenue: item.value3 || '0'
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取用户排行榜失败', e) }
|
||||
}
|
||||
|
||||
const fetchDeviceRank = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/leaderboard/devices', { limit: 5, showLoading: false })
|
||||
if (res.data) {
|
||||
deviceRankList.value = res.data.map(item => ({
|
||||
name: item.device_id || '',
|
||||
revenue: item.value1 || '0'
|
||||
}))
|
||||
}
|
||||
} catch (e) { console.error('获取设备排行榜失败', e) }
|
||||
}
|
||||
|
||||
const switchRankTab = (type) => {
|
||||
rankTab.value = type
|
||||
rankList.value = type === 'user' ? userRankList.value : deviceRankList.value
|
||||
}
|
||||
|
||||
// 行业资讯
|
||||
const newsList = ref([])
|
||||
const newsLoading = ref(false)
|
||||
|
||||
// 获取行业资讯列表
|
||||
const getNewsList = async () => {
|
||||
if (newsLoading.value) return
|
||||
newsLoading.value = true
|
||||
try {
|
||||
const res = await request.get('/c/news', { limit: 5, showLoading: false })
|
||||
if (res.data) {
|
||||
// 处理数据,补全默认值
|
||||
newsList.value = res.data.map((item, i) => ({
|
||||
...item,
|
||||
cat: item.category === 'notice' ? '平台通知' : '行业资讯',
|
||||
date: item.created_at,
|
||||
bg: item.category === 'notice' ? 'linear-gradient(135deg, #f093fb, #f5576c)' : 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
emoji: item.category === 'notice' ? '🔧' : '📰'
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取资讯失败', e)
|
||||
} finally {
|
||||
newsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuick = (item) => {
|
||||
if (item.code === 'calculator' || item.code === 'tickets') {
|
||||
uni.showToast({ title: '即将开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
openSub(item.code)
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
// fetchProfileCredit(), // 信用分暂时隐藏
|
||||
fetchNotices(),
|
||||
getNewsList(),
|
||||
fetchUserRank(),
|
||||
fetchDeviceRank(),
|
||||
fetchStats(),
|
||||
fetchDeviceStats(),
|
||||
fetchDeviceList(),
|
||||
fetchStarDevices()
|
||||
])
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
rankList.value = rankTab.value === 'user' ? userRankList.value : deviceRankList.value
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchNotices(), getNewsList(), fetchUserRank(), fetchDeviceRank(), fetchStats(), fetchDeviceStats(), fetchDeviceList(), fetchStarDevices()]).then(() => {
|
||||
rankList.value = rankTab.value === 'user' ? userRankList.value : deviceRankList.value
|
||||
})
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const openNewsDetail = (news) => {
|
||||
// 把完整资讯数据(包含content正文)存入全局store,详情页直接读取
|
||||
appStore.setCurrentNews(news)
|
||||
openSub('news-detail', { id: news.id })
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
const count = await fetchDeviceList(keyword)
|
||||
if (keyword && count === 0) {
|
||||
uni.showToast({ title: '未找到匹配设备', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 清空关键词后恢复首页默认设备列表
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
fetchDeviceList()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
// Top Bar
|
||||
.top-bar {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,.88);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: .5px solid #EAEDF3;
|
||||
z-index: 99;
|
||||
}
|
||||
.top-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 0 32rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
.top-bar-logo { display: flex; align-items: center; gap: 10rpx; flex-shrink: 0; }
|
||||
.logo-dot { width: 10rpx; height: 40rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; box-shadow: 0 0 16rpx rgba(22, 119, 255, 0.5); }
|
||||
.logo-text { font-size: 32rpx; font-weight: 700; color: #0F1226; letter-spacing: 1rpx; }
|
||||
|
||||
.top-bar-search {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
background: #F3F5F9;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 28rpx;
|
||||
gap: 12rpx;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 24rpx;
|
||||
color: #0F1226;
|
||||
}
|
||||
.search-clear {
|
||||
font-size: 22rpx;
|
||||
color: #BCC2CE;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
&:active { color: #8B91A1; }
|
||||
}
|
||||
.search-btn {
|
||||
font-size: 28rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 4rpx;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
}
|
||||
.top-bar-action {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
// 弹性伸缩主内容区
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Hero Card
|
||||
.hero-card {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
padding: 44rpx 36rpx 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
}
|
||||
|
||||
.hero-grid-bg {
|
||||
position: absolute; inset: 0;
|
||||
background-image: linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px);
|
||||
background-size: 60rpx 60rpx;
|
||||
mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 30%, #000 70%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-greeting {
|
||||
font-size: 24rpx; opacity: 0.7; position: relative; z-index: 1; letter-spacing: 0.6rpx;
|
||||
}
|
||||
.hero-row { display: flex; justify-content: space-between; align-items: flex-end; margin-top: 12rpx; position: relative; z-index: 1; }
|
||||
.hero-label { font-size: 24rpx; opacity: 0.55; letter-spacing: 0.6rpx; }
|
||||
.hero-amount { display: flex; align-items: baseline; gap: 8rpx; margin-top: 4rpx; }
|
||||
.amount-num {
|
||||
font-size: 64rpx; font-weight: 800; letter-spacing: -2rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif;
|
||||
background: linear-gradient(180deg, #fff, #E6F4FF); -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.amount-unit { font-size: 24rpx; opacity: 0.55; margin-left: 8rpx; }
|
||||
|
||||
.hero-stats {
|
||||
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 2rpx;
|
||||
margin-top: 44rpx; background: rgba(255,255,255,.1); border-radius: 28rpx 28rpx 0 0; overflow: hidden; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-stat {
|
||||
background: rgba(255,255,255,.07); padding: 26rpx 20rpx; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); transition: background 0.2s;
|
||||
&:active { background: rgba(255,255,255,.14); }
|
||||
}
|
||||
.hs-label { font-size: 22rpx; opacity: 0.65; display: block; }
|
||||
.hs-val { font-size: 38rpx; font-weight: 700; margin-top: 4rpx; font-family: 'DIN Alternate', 'Helvetica Neue', sans-serif; display: block; }
|
||||
.hs-sub { font-size: 20rpx; opacity: 0.55; margin-top: 2rpx; display: block; }
|
||||
.hs-sub.up { color: #7FFFD4; opacity: 1; }
|
||||
.hs-grade { font-size: 22rpx; opacity: 0.7; }
|
||||
|
||||
// Notice Bar
|
||||
.notice-bar {
|
||||
background: #fff; margin: 28rpx 24rpx; padding: 22rpx 24rpx; border-radius: 10rpx;
|
||||
display: flex; align-items: center; gap: 20rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); position: relative; overflow: hidden;
|
||||
&::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 6rpx; background: linear-gradient(180deg, #FF8800, #FFB84D); }
|
||||
}
|
||||
.nb-icon-box { width: 44rpx; height: 44rpx; background: linear-gradient(135deg, #FFF7E6, #FFE7BA); border-radius: 12rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 4rpx 12rpx rgba(255, 136, 0, 0.15); }
|
||||
.nb-emoji { font-size: 26rpx; line-height: 1; }
|
||||
.nb-text { flex: 1; font-size: 24rpx; color: #5A6072; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.nb-more { font-size: 22rpx; color: #BCC2CE; flex-shrink: 0; }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 32rpx; padding: 0 24rpx; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.section-title {
|
||||
font-size: 32rpx; font-weight: 700; color: #0F1226; letter-spacing: 0.6rpx;
|
||||
display: flex; align-items: center; gap: 10rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
.section-more { font-size: 24rpx; color: #8B91A1; display: flex; align-items: center; gap: 4rpx; }
|
||||
|
||||
// Quick Grid (4 individual cards)
|
||||
.quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16rpx; }
|
||||
.quick-item {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx 8rpx 20rpx; text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); position: relative; overflow: hidden; transition: all 0.2s;
|
||||
&::after { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.9), transparent); }
|
||||
&:active { transform: scale(0.95); box-shadow: 0 4px 16px rgba(15, 18, 38, 0.06); }
|
||||
}
|
||||
.qi-icon { width: 80rpx; height: 80rpx; border-radius: 22rpx; display: flex; align-items: center; justify-content: center; margin: 0 auto 14rpx; position: relative;
|
||||
&::before { content: ''; position: absolute; inset: 0; border-radius: 22rpx; background: linear-gradient(135deg, rgba(255,255,255,0.5), transparent); opacity: 0.6; }
|
||||
}
|
||||
.qi-emoji { font-size: 40rpx; line-height: 1; position: relative; z-index: 1; }
|
||||
.qi-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
|
||||
// Star Devices — 横向滑轨
|
||||
.star-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
.star-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
padding: 4rpx 24rpx 8rpx;
|
||||
}
|
||||
.star-card {
|
||||
flex-shrink: 0;
|
||||
width: 280rpx;
|
||||
margin-right: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 14rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
transition: all 0.2s;
|
||||
&:last-child { margin-right: 24rpx; }
|
||||
&:active { transform: scale(0.97); box-shadow: 0 4px 16px rgba(15, 18, 38, 0.06); }
|
||||
}
|
||||
.sc-img {
|
||||
height: 160rpx; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;
|
||||
&::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.45), transparent 60%); }
|
||||
}
|
||||
.sc-img-icon { font-size: 64rpx; position: relative; z-index: 1; }
|
||||
.sc-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.sc-body { padding: 20rpx 24rpx; }
|
||||
.sc-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sc-specs { font-size: 20rpx; color: #8B91A1; margin: 4rpx 0 10rpx; display: block; }
|
||||
.sc-price { font-size: 32rpx; font-weight: 700; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
|
||||
// Leaderboard
|
||||
.home-lb-tabs { display: flex; gap: 12rpx; margin-bottom: 24rpx; }
|
||||
.home-lb-tab {
|
||||
padding: 12rpx 28rpx; border-radius: 32rpx; font-size: 24rpx; font-weight: 500;
|
||||
background: #fff; border: 1px solid #EAEDF3; color: #5A6072; transition: all 0.2s;
|
||||
&.active {
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF); color: #fff; border-color: transparent;
|
||||
box-shadow: 0 4rpx 16rpx rgba(22, 119, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.lb-list { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.lb-item { display: flex; align-items: center; gap: 20rpx; padding: 22rpx 24rpx; border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.lb-rank {
|
||||
width: 56rpx; height: 56rpx; border-radius: 50%; background: #F3F5F9; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 24rpx; flex-shrink: 0; color: #5A6072; font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
&.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; box-shadow: 0 4rpx 16rpx rgba(255, 165, 0, 0.4); }
|
||||
&.silver { background: linear-gradient(135deg, #D8DCE5, #A8AEC0); color: #fff; box-shadow: 0 4rpx 16rpx rgba(168, 174, 192, 0.4); }
|
||||
&.bronze { background: linear-gradient(135deg, #CD7F32, #A0522D); color: #fff; box-shadow: 0 4rpx 16rpx rgba(160, 82, 45, 0.4); }
|
||||
}
|
||||
.lb-name { flex: 1; font-size: 26rpx; font-weight: 500; color: #0F1226; }
|
||||
.lb-val { font-weight: 700; color: #1677FF; font-size: 28rpx; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
|
||||
// News Cards
|
||||
.news-card {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
margin-bottom: 16rpx; display: flex; gap: 20rpx; transition: all 0.2s;
|
||||
&:active { background: #F7F9FC; transform: scale(0.99); }
|
||||
}
|
||||
.nc-img {
|
||||
width: 140rpx; height: 110rpx; border-radius: 10rpx; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;
|
||||
&::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.45), transparent 60%); pointer-events: none; z-index: 1; }
|
||||
}
|
||||
.nc-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
.nc-img-icon { font-size: 48rpx; position: relative; z-index: 1; }
|
||||
.nc-body { flex: 1; min-width: 0; }
|
||||
.nc-title { font-size: 26rpx; font-weight: 500; color: #0F1226; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.nc-subtitle { font-size: 22rpx; color: #5A6072; line-height: 1.4; margin-top: 8rpx; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nc-meta { font-size: 20rpx; color: #BCC2CE; margin-top: 10rpx; display: flex; gap: 16rpx; }
|
||||
.nc-cat { color: #1677FF; }
|
||||
|
||||
// Spacer
|
||||
.safe-bottom-spacer {
|
||||
height: calc(env(safe-area-inset-bottom) + 160rpx);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,533 @@
|
||||
<!--
|
||||
* 登录页 — 与 C端.html 认证流程完全一致
|
||||
* SSO检查 → 登录 → 注册(新用户)→ 首页
|
||||
-->
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- ==================== SSO检查 ==================== -->
|
||||
<view v-if="step === 'sso'" class="auth-overlay hero-bg">
|
||||
<view class="auth-content">
|
||||
<view style="text-align:center; position:relative; z-index:1">
|
||||
<view class="auth-logo-icon">
|
||||
<u-icon name="home" size="32" color="#fff"></u-icon>
|
||||
</view>
|
||||
<text class="auth-brand">全犀云</text>
|
||||
<view class="auth-spinner">
|
||||
<view class="spinner-dot"></view>
|
||||
<view class="spinner-dot"></view>
|
||||
<view class="spinner-dot"></view>
|
||||
<view class="spinner-dot"></view>
|
||||
</view>
|
||||
<text class="auth-text">正在验证身份...</text>
|
||||
<text class="auth-sub">首次访问需注册 · 已注册用户将自动登录</text>
|
||||
<view class="sso-btn" @click="ssoCheckComplete">点击继续</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ==================== 登录 ==================== -->
|
||||
<view v-if="step === 'login'" class="auth-overlay hero-bg">
|
||||
<view class="auth-content">
|
||||
<view style="position:relative; z-index:1; width:100%">
|
||||
<!-- 头部 -->
|
||||
<view class="auth-header">
|
||||
<view class="auth-logo-icon-sm">
|
||||
<u-icon name="home" size="28" color="#fff"></u-icon>
|
||||
</view>
|
||||
<text class="auth-title">欢迎回来</text>
|
||||
<text class="auth-desc">登录全犀云边缘宽带共享平台</text>
|
||||
</view>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<view class="auth-card">
|
||||
<!-- 手机号 + 获取验证码(同行) -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">手机号</text>
|
||||
<view class="phone-row">
|
||||
<input v-model="loginForm.phone" type="tel" placeholder="请输入手机号" maxlength="11" class="auth-input" style="flex:1" />
|
||||
<view class="code-btn" :class="{ disabled: codeCountdown > 0 || !loginForm.phone }" @click="sendLoginCode">
|
||||
{{ codeCountdown > 0 ? codeCountdown + 's' : '获取验证码' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">验证码</text>
|
||||
<input v-model="loginForm.code" type="text" placeholder="请输入6位验证码" maxlength="6" class="auth-input code-input" />
|
||||
</view>
|
||||
|
||||
<!-- 协议勾选 -->
|
||||
<view class="agree-row">
|
||||
<view class="agree-box" :class="{ checked: authFlow.agreed }" @click="toggleAgree">
|
||||
<text v-if="authFlow.agreed" class="agree-check">✓</text>
|
||||
</view>
|
||||
<text class="agree-text">
|
||||
我已阅读并同意 <text class="agree-link" @click.stop="showAgreement">《用户协议》</text> 和 <text class="agree-link" @click.stop="showAgreement">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" @click="doLogin">登 录</view>
|
||||
<text class="auth-footnote">未注册用户验证码登录后将进入注册流程(需填写推荐码)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ==================== 注册 ==================== -->
|
||||
<view v-if="step === 'register'" class="auth-overlay hero-bg">
|
||||
<view class="auth-content">
|
||||
<view style="position:relative; z-index:1; width:100%">
|
||||
<view class="auth-header">
|
||||
<view class="auth-logo-icon-sm">
|
||||
<u-icon name="account" size="28" color="#fff"></u-icon>
|
||||
</view>
|
||||
<text class="auth-title">完成注册</text>
|
||||
<text class="auth-desc">填写以下信息完成账号创建</text>
|
||||
</view>
|
||||
|
||||
<view class="auth-card">
|
||||
<!-- 手机号(登录带入,只读) -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">手机号</text>
|
||||
<input v-model="regForm.phone" type="tel" placeholder="请输入手机号" maxlength="11" class="auth-input readonly" :disabled="true" />
|
||||
</view>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">验证码</text>
|
||||
<view class="phone-row">
|
||||
<input v-model="regForm.code" type="text" placeholder="请输入6位验证码" maxlength="6" class="auth-input code-input" :class="{ readonly: regSource === 'login-fallback' }" :disabled="regSource === 'login-fallback'" style="flex:1" />
|
||||
<view v-if="regSource !== 'login-fallback'" class="code-btn" :class="{ disabled: regCodeCountdown > 0 }" @click="sendRegCode">
|
||||
{{ regCodeCountdown > 0 ? regCodeCountdown + 's' : '获取验证码' }}
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="regSource === 'login-fallback'" class="fallback-tip">登录环节已通过手机号与验证码校验,请直接填写推荐码完成注册</text>
|
||||
</view>
|
||||
|
||||
<!-- 推荐码(必填) -->
|
||||
<view class="form-group">
|
||||
<text class="form-label">推荐码(推荐码) <text class="required">*</text></text>
|
||||
<input
|
||||
v-model="regForm.inviteCode"
|
||||
type="text"
|
||||
placeholder="请输入推荐人推荐码"
|
||||
class="auth-input"
|
||||
:class="{ readonly: inviteCodeReadonly }"
|
||||
:disabled="inviteCodeReadonly"
|
||||
/>
|
||||
<text class="form-hint">注册需填写推荐码(必填)。扫描推荐人海报二维码进入时将自动填入,填写后与推荐人建立推荐关系</text>
|
||||
</view>
|
||||
|
||||
<!-- 协议勾选 -->
|
||||
<view class="agree-row">
|
||||
<view class="agree-box" :class="{ checked: authFlow.agreed }" @click="toggleAgree">
|
||||
<text v-if="authFlow.agreed" class="agree-check">✓</text>
|
||||
</view>
|
||||
<text class="agree-text">
|
||||
我已阅读并同意 <text class="agree-link" @click.stop="showAgreement">《用户协议》</text> 和 <text class="agree-link" @click.stop="showAgreement">《隐私政策》</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="submit-btn" @click="doRegister">注 册</view>
|
||||
<text class="auth-footnote">填写推荐码完成注册,将与推荐人建立推荐关系</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ==================== 用户协议弹窗 ==================== -->
|
||||
<view v-if="agreementVisible" class="agreement-overlay" :class="{ show: agreementVisible }" @click.self="closeAgreement">
|
||||
<view class="agreement-dialog" :class="{ show: agreementVisible }">
|
||||
<view class="agreement-header">
|
||||
<text class="agreement-title">用户协议与隐私政策</text>
|
||||
<text class="agreement-close-icon" @click="closeAgreement">✕</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="agreement-body" style="height: 500rpx;">
|
||||
<view class="agreement-section">
|
||||
<text class="agreement-h2">一、服务条款</text>
|
||||
<text class="agreement-p">欢迎使用全犀云边缘宽带共享平台。本协议是您与全犀云平台之间关于使用全犀云服务所订立的协议。</text>
|
||||
<text class="agreement-p">1. 您在使用全犀云服务时,应遵守中华人民共和国相关法律法规。</text>
|
||||
<text class="agreement-p">2. 您应保证所提供的企业及个人信息真实、准确、完整。</text>
|
||||
<text class="agreement-p">3. 平台有权根据业务需要调整服务内容和收费标准,并提前公告通知。</text>
|
||||
</view>
|
||||
<view class="agreement-section">
|
||||
<text class="agreement-h2">二、隐私政策</text>
|
||||
<text class="agreement-p">全犀云重视您的隐私保护。我们将按照法律法规要求,采取相应的安全保护措施保护您的个人信息。</text>
|
||||
<text class="agreement-p">1. 我们收集的信息包括:企业信息、联系人信息、设备信息、收益数据等。</text>
|
||||
<text class="agreement-p">2. 我们不会将您的个人信息出售或分享给第三方。</text>
|
||||
<text class="agreement-p">3. 您有权查询、更正、删除您的个人信息。</text>
|
||||
</view>
|
||||
<view class="agreement-section">
|
||||
<text class="agreement-h2">三、收益与结算</text>
|
||||
<text class="agreement-p">1. 设备收益根据实际带宽贡献量和服务质量综合计算。</text>
|
||||
<text class="agreement-p">2. 收益积分按日结算,T+1日发放至账户。</text>
|
||||
<text class="agreement-p">3. 积分兑换需满足最低兑换门槛,服务费按平台规则执行。</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="agreement-footer">
|
||||
<view class="agree-btn" @click="acceptAgreement">同意并继续</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
// TODO: 后端接口就绪后启用
|
||||
// import request from '@/utils/request'
|
||||
// import { setToken } from '@/utils/auth'
|
||||
|
||||
// ============================================
|
||||
// 状态 — 与 C端.html authFlow 完全对应
|
||||
// ============================================
|
||||
const step = ref('sso')
|
||||
const authFlow = reactive({ ssoChecked: false, phone: '', agreed: false })
|
||||
const pendingAuthAction = ref(null) // 'login' | 'register'
|
||||
|
||||
// TODO: 上线前移除固定手机号和验证码
|
||||
const loginForm = reactive({ phone: '17703209369', code: '123456' })
|
||||
const regForm = reactive({ phone: '17703209369', code: '123456', inviteCode: '' })
|
||||
const inviteCodeReadonly = ref(false)
|
||||
const regSource = ref('') // 'login-fallback' | 'invite-scan'
|
||||
|
||||
const codeCountdown = ref(0)
|
||||
const regCodeCountdown = ref(0)
|
||||
const loginLoading = ref(false)
|
||||
const regLoading = ref(false)
|
||||
const agreementVisible = ref(false)
|
||||
|
||||
// ============================================
|
||||
// onLoad — 扫码邀请入口
|
||||
// ============================================
|
||||
onLoad((options) => {
|
||||
// 开发模式:URL 带 ?dev=<role> 绕过登录
|
||||
if (options.dev) {
|
||||
uni.setStorageSync('token', 'dev-token')
|
||||
uni.setStorageSync('userRole', options.dev)
|
||||
const target = options.target || 'pages/index/index'
|
||||
uni.reLaunch({ url: '/' + decodeURIComponent(target) })
|
||||
return
|
||||
}
|
||||
|
||||
if (options.code) {
|
||||
regForm.inviteCode = options.code
|
||||
inviteCodeReadonly.value = true
|
||||
regSource.value = 'invite-scan'
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// SSO检查
|
||||
// ============================================
|
||||
const ssoCheckComplete = () => {
|
||||
authFlow.ssoChecked = true
|
||||
step.value = 'login'
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 协议 — 与 C端.html showAgreeAndContinue 对应
|
||||
// ============================================
|
||||
const toggleAgree = () => {
|
||||
authFlow.agreed = !authFlow.agreed
|
||||
}
|
||||
|
||||
const showAgreement = () => {
|
||||
agreementVisible.value = true
|
||||
}
|
||||
|
||||
const closeAgreement = () => {
|
||||
agreementVisible.value = false
|
||||
pendingAuthAction.value = null
|
||||
}
|
||||
|
||||
const acceptAgreement = () => {
|
||||
authFlow.agreed = true
|
||||
agreementVisible.value = false
|
||||
// 同意后自动继续之前的操作
|
||||
if (pendingAuthAction.value === 'login') {
|
||||
pendingAuthAction.value = null
|
||||
doLogin()
|
||||
} else if (pendingAuthAction.value === 'register') {
|
||||
pendingAuthAction.value = null
|
||||
doRegister()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 登录 — 与 C端.html doLogin 完全对应
|
||||
// ============================================
|
||||
const sendLoginCode = () => {
|
||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
authFlow.phone = loginForm.phone
|
||||
// TODO: 对接接口 POST /auth/send-code { phone: loginForm.phone }
|
||||
// await request.post('/auth/send-code', { phone: loginForm.phone }, { showLoading: false })
|
||||
// 接口返回成功后:
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||||
startCountdown('login')
|
||||
}
|
||||
|
||||
const doLogin = () => {
|
||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loginForm.code.length !== 6) {
|
||||
uni.showToast({ title: '请输入6位验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!authFlow.agreed) {
|
||||
pendingAuthAction.value = 'login'
|
||||
showAgreement()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 对接接口 POST /auth/login { phone, code }
|
||||
// const res = await request.post('/auth/login', { phone: loginForm.phone, code: loginForm.code })
|
||||
// setToken(res.data.token)
|
||||
// const user = res.data.user
|
||||
// if (user.role === 'c' || user.role === 'c_user') { uni.switchTab({ url: '/pages/index/index' }) }
|
||||
// 后端返回"用户不存在"时 → goRegister()
|
||||
|
||||
// Mock: 模拟新用户 → 进入注册
|
||||
goRegister()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 转到注册 — 登录时后端返回"用户不存在"
|
||||
// ============================================
|
||||
const goRegister = () => {
|
||||
authFlow.entry = 'login-fallback'
|
||||
regSource.value = 'login-fallback'
|
||||
regForm.phone = loginForm.phone
|
||||
regForm.code = loginForm.code
|
||||
step.value = 'register'
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 注册 — 与 C端.html doRegister 完全对应
|
||||
// ============================================
|
||||
const sendRegCode = () => {
|
||||
if (regSource.value === 'login-fallback') return
|
||||
if (!/^1[3-9]\d{9}$/.test(regForm.phone)) {
|
||||
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// TODO: 对接接口 POST /auth/send-code { phone: regForm.phone }
|
||||
// await request.post('/auth/send-code', { phone: regForm.phone }, { showLoading: false })
|
||||
uni.showToast({ title: '验证码已发送', icon: 'success' })
|
||||
startCountdown('reg')
|
||||
}
|
||||
|
||||
const doRegister = () => {
|
||||
// 推荐码格式校验:6-8位纯数字,1开头(C端LDR码)或2开头(B端MGR码),可被多人使用、长期有效
|
||||
if (!regForm.inviteCode || !/^[12]\d{5,7}$/.test(regForm.inviteCode)) {
|
||||
uni.showToast({ title: '推荐码格式不正确(6-8位数字,1或2开头)', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!authFlow.agreed) {
|
||||
pendingAuthAction.value = 'register'
|
||||
showAgreement()
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 正确注册流程(按顺序):
|
||||
// 1. POST /auth/send-code { phone } — 发送验证码
|
||||
// 2. POST /auth/verify-code { phone, code } — 校验验证码
|
||||
// 3. POST /auth/check-invite-code { invite_code } — 校验推荐码是否存在(LDR码1开头/MGR码2开头,6-8位,可多人使用)
|
||||
// 4. POST /auth/login { phone, code, invite_code } — 完成注册并登录
|
||||
// 后端若返回"推荐码不存在",前端拦截:uni.showToast({ title: '推荐码不存在', icon: 'none' })
|
||||
|
||||
// Mock: 注册成功
|
||||
uni.setStorageSync('userRole', 'c')
|
||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||
uni.setStorageSync('token', 'mock-token-' + Date.now())
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 工具
|
||||
// ============================================
|
||||
const startCountdown = (type) => {
|
||||
const ref = type === 'login' ? codeCountdown : regCodeCountdown
|
||||
ref.value = 60
|
||||
const timer = setInterval(() => {
|
||||
ref.value--
|
||||
if (ref.value <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page { min-height: 100vh; position: relative; }
|
||||
|
||||
// Auth overlay — 与 C端.html .auth-overlay 完全一致
|
||||
.auth-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
min-height: 100vh;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 35%, #1342B5 65%, #1B66E8 100%);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
width: 100%;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
|
||||
// 头部Logo — 与 C端.html SSO logo 一致
|
||||
.auth-logo-icon {
|
||||
width: 128rpx; height: 128rpx;
|
||||
border-radius: 32rpx;
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 40rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(22, 119, 255, 0.35);
|
||||
}
|
||||
|
||||
.auth-logo-icon-sm {
|
||||
width: 112rpx; height: 112rpx;
|
||||
border-radius: 28rpx;
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 32rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(22, 119, 255, 0.35);
|
||||
}
|
||||
|
||||
.auth-brand { font-size: 44rpx; font-weight: 700; color: #fff; display: block; text-align: center; margin-bottom: 24rpx; }
|
||||
|
||||
// SSO Spinner — 与 C端.html .auth-spinner 一致
|
||||
.auth-spinner { display: flex; gap: 10rpx; justify-content: center; margin: 0 auto 40rpx; }
|
||||
.spinner-dot {
|
||||
width: 16rpx; height: 16rpx; border-radius: 50%;
|
||||
background: rgba(255,255,255,.6);
|
||||
animation: bounce 1.2s infinite ease-in-out;
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: .15s; }
|
||||
&:nth-child(3) { animation-delay: .3s; }
|
||||
&:nth-child(4) { animation-delay: .45s; }
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: scale(.6); opacity: .3; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
.auth-text { font-size: 28rpx; color: rgba(255,255,255,.85); display: block; text-align: center; margin-bottom: 8rpx; }
|
||||
.auth-sub { font-size: 24rpx; color: rgba(255,255,255,.55); display: block; text-align: center; }
|
||||
|
||||
.sso-btn {
|
||||
margin: 56rpx auto 0; width: 400rpx; text-align: center;
|
||||
padding: 22rpx 0; border-radius: 40rpx;
|
||||
background: rgba(255,255,255,.18); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,.25);
|
||||
color: #fff; font-size: 28rpx; font-weight: 500;
|
||||
}
|
||||
|
||||
// Auth header — 与 C端.html 登录/注册头部一致
|
||||
.auth-header { text-align: center; margin-bottom: 32rpx; }
|
||||
.auth-title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
|
||||
.auth-desc { font-size: 24rpx; color: rgba(255,255,255,.55); margin-top: 8rpx; display: block; }
|
||||
|
||||
// Auth card — 与 C端.html .auth-form-card 一致
|
||||
.auth-card {
|
||||
width: 100%; background: rgba(255,255,255,.08);
|
||||
border-radius: 24rpx; padding: 36rpx 28rpx;
|
||||
backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 24rpx; }
|
||||
.form-label { font-size: 26rpx; color: rgba(255,255,255,.7); display: block; margin-bottom: 12rpx; }
|
||||
.required { color: #FF4D4F; }
|
||||
|
||||
// Input — 与 C端.html .form-input 一致
|
||||
.auth-input {
|
||||
width: 100%; height: 80rpx; background: rgba(255,255,255,.1);
|
||||
border: 1px solid rgba(255,255,255,.15); border-radius: 12rpx;
|
||||
padding: 0 24rpx; font-size: 30rpx; color: #fff;
|
||||
box-sizing: border-box;
|
||||
&::placeholder { color: rgba(255,255,255,.3); }
|
||||
&.readonly { background: rgba(255,255,255,.06); color: rgba(255,255,255,.6); }
|
||||
}
|
||||
.code-input { letter-spacing: 8rpx; text-align: center; font-size: 36rpx; }
|
||||
|
||||
// Phone row — 手机号+按钮同行
|
||||
.phone-row { display: flex; gap: 16rpx; }
|
||||
|
||||
.code-btn {
|
||||
white-space: nowrap; flex-shrink: 0; padding: 0 28rpx;
|
||||
height: 80rpx; line-height: 80rpx; border-radius: 16rpx;
|
||||
background: rgba(255,255,255,.15); color: #fff;
|
||||
font-size: 24rpx; text-align: center; transition: opacity .2s;
|
||||
&.disabled { opacity: 0.5; pointer-events: none; }
|
||||
}
|
||||
|
||||
.fallback-tip { font-size: 22rpx; color: #00C4A7; margin-top: 8rpx; display: block; }
|
||||
|
||||
// Agreement — 与 C端.html 协议勾选一致
|
||||
.agree-row { display: flex; align-items: flex-start; gap: 16rpx; margin: 28rpx 0 20rpx; }
|
||||
.agree-box {
|
||||
width: 36rpx; height: 36rpx; flex-shrink: 0; margin-top: 2rpx;
|
||||
border-radius: 10rpx; border: 2px solid rgba(255,255,255,.25);
|
||||
display: flex; align-items: center; justify-content: center; transition: all .2s;
|
||||
&.checked { background: #1677FF; border-color: #1677FF; }
|
||||
}
|
||||
.agree-check { color: #fff; font-size: 24rpx; font-weight: 700; }
|
||||
.agree-text { flex: 1; font-size: 24rpx; color: rgba(255,255,255,.5); line-height: 1.5; }
|
||||
.agree-link { color: #4096FF; text-decoration: underline; }
|
||||
|
||||
.form-hint { font-size: 22rpx; color: rgba(255,255,255,.35); margin-top: 8rpx; display: block; line-height: 1.5; }
|
||||
|
||||
// Submit
|
||||
.submit-btn {
|
||||
width: 100%; height: 88rpx; margin-top: 8rpx;
|
||||
background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
border-radius: 16rpx; color: #fff; font-size: 30rpx; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 12rpx 40rpx rgba(22, 119, 255, 0.35);
|
||||
&:active { transform: scale(.97); }
|
||||
}
|
||||
|
||||
.auth-footnote { font-size: 22rpx; color: rgba(255,255,255,.3); text-align: center; display: block; margin-top: 24rpx; line-height: 1.6; }
|
||||
|
||||
// Agreement dialog — 底部滑入,与 C端.html 协议弹窗一致
|
||||
.agreement-overlay {
|
||||
position: fixed; inset: 0; z-index: 300;
|
||||
background: rgba(0,0,0,0); display: flex; align-items: flex-end;
|
||||
transition: background .3s ease;
|
||||
pointer-events: none;
|
||||
&.show { background: rgba(0,0,0,.5); pointer-events: auto; }
|
||||
}
|
||||
.agreement-dialog {
|
||||
width: 100%; background: #fff; border-radius: 24rpx 24rpx 0 0;
|
||||
max-height: 55vh; display: flex; flex-direction: column;
|
||||
transform: translateY(100%);
|
||||
transition: transform .3s cubic-bezier(.4, 0, .2, 1);
|
||||
&.show { transform: translateY(0); }
|
||||
}
|
||||
.agreement-header { display: flex; justify-content: space-between; align-items: center; padding: 32rpx; border-bottom: 1rpx solid #f0f0f0; }
|
||||
.agreement-title { font-size: 32rpx; font-weight: 600; color: #0F1226; }
|
||||
.agreement-body {
|
||||
flex: 1; padding: 32rpx;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.agreement-section { margin-bottom: 32rpx; }
|
||||
.agreement-h2 { font-size: 28rpx; font-weight: 600; color: #0F1226; margin-bottom: 16rpx; display: block; word-break: break-all; }
|
||||
.agreement-p { font-size: 26rpx; color: #5A6072; line-height: 1.8; margin-bottom: 12rpx; word-break: break-all; white-space: normal; }
|
||||
.agreement-footer { padding: 24rpx 32rpx; border-top: 1rpx solid #f0f0f0; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); }
|
||||
.agree-btn { width: 100%; height: 88rpx; background: linear-gradient(135deg, #1677FF, #4096FF); border-radius: 16rpx; color: #fff; font-size: 30rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<!--
|
||||
* C端登录页 — 手机号+验证码登录,新用户推荐码注册
|
||||
-->
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<!-- 顶部蓝色渐变区:Logo -->
|
||||
<view class="hero-area">
|
||||
<view class="logo-wrap">
|
||||
<image src="/static/quanxiyun-logo.png" mode="widthFix" class="logo-img" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 下方白色表单卡片 -->
|
||||
<view class="form-section">
|
||||
<view class="form-card">
|
||||
<view class="form-title">欢迎回来</view>
|
||||
<view class="form-sub">请输入手机号登录</view>
|
||||
|
||||
<!-- 手机号 -->
|
||||
<view class="field">
|
||||
<view class="field-box">
|
||||
<text class="field-prefix">+86</text>
|
||||
<input v-model="phone" type="tel" placeholder="请输入手机号" maxlength="11"
|
||||
class="field-input" :disabled="step === 'invite'" @input="handlePhoneInput" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 验证码 -->
|
||||
<view class="field" v-if="step === 'code'">
|
||||
<view class="field-row">
|
||||
<view class="field-box" style="flex:1;">
|
||||
<input v-model="code" type="text" placeholder="请输入6位验证码" maxlength="6"
|
||||
class="field-input" @confirm="doLogin" />
|
||||
</view>
|
||||
<view class="sms-btn" :class="{ disabled: codeCountdown > 0 }" @click="sendCode">
|
||||
{{ codeCountdown > 0 ? codeCountdown + 's' : '重新获取' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 推荐码 -->
|
||||
<view class="field" v-if="step === 'invite'">
|
||||
<view class="field-box">
|
||||
<input v-model="inviteCode" type="text" placeholder="请输入推荐人推荐码" class="field-input" @confirm="doRegister" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<view v-if="step === 'phone'" class="main-btn" @click="sendCode" :class="{ loading: sending }">
|
||||
{{ sending ? '发送中...' : '获取验证码' }}
|
||||
</view>
|
||||
<view v-if="step === 'code'" class="main-btn" @click="doLogin" :class="{ loading: loading }">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</view>
|
||||
<view v-if="step === 'invite'" class="main-btn" @click="doRegister" :class="{ loading: loading }">
|
||||
{{ loading ? '注册中...' : '注 册' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 测试账号 -->
|
||||
<!-- <view class="quick-login" v-if="quickUsers.length">
|
||||
<text class="ql-label">测试账号(跳过验证码直接登录)</text>
|
||||
<view class="ql-list">
|
||||
<text v-for="u in quickUsers" :key="u.phone" class="ql-item" @click="quickLogin(u)">{{ u.label }}</text>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue'
|
||||
import {onLoad} from '@dcloudio/uni-app'
|
||||
import request from '@/utils/request'
|
||||
import {useUserStore} from '@/store/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const inviteCode = ref('')
|
||||
const step = ref('phone') // phone → code → invite
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const codeCountdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
const codeBtnText = ref('获取验证码')
|
||||
const sentCode = ref('') // 开发阶段展示验证码
|
||||
|
||||
const quickUsers = ref([
|
||||
{phone: '13600000001', label: 'C端-张三'},
|
||||
{phone: '13700000001', label: '团长-赵六'},
|
||||
{phone: '13700000002', label: '超级团长-钱七'},
|
||||
{phone: '13900000001', label: '经理-张经理'},
|
||||
])
|
||||
|
||||
const handlePhoneInput = () => {
|
||||
if (step.value !== 'code') return
|
||||
step.value = 'phone'
|
||||
code.value = ''
|
||||
codeCountdown.value = 0
|
||||
codeBtnText.value = '获取验证码'
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
// 扫码海报带推荐码 → 预填推荐码框(新用户无 token 不会走下方自动登录)
|
||||
if (options.code) {
|
||||
inviteCode.value = options.code
|
||||
}
|
||||
|
||||
// 已有有效 token → 跳过登录直进首页
|
||||
const savedToken = uni.getStorageSync('token')
|
||||
const savedInfo = uni.getStorageSync('userInfo')
|
||||
if (savedToken && savedToken !== 'dev-token' && savedInfo) {
|
||||
const role = savedInfo.role || 'c'
|
||||
if (role === 'c') {
|
||||
uni.switchTab({url: '/pages/index/index'})
|
||||
} else {
|
||||
const homeMap = {
|
||||
leader: '/pages/b-home/index',
|
||||
super_leader: '/pages/b-super-home/index',
|
||||
manager: '/pages/b-manager-home/index'
|
||||
}
|
||||
uni.reLaunch({url: '/' + (homeMap[role] || 'pages/index/index')})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (options.dev) {
|
||||
uni.setStorageSync('token', 'dev-token')
|
||||
uni.setStorageSync('userRole', options.dev)
|
||||
uni.reLaunch({url: '/' + (options.target || 'pages/index/index')})
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.value.trim())) {
|
||||
uni.showToast({title: '请输入正确的手机号', icon: 'none'})
|
||||
return
|
||||
}
|
||||
if (sending.value || codeCountdown.value > 0) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await request.post('/c/auth/send-code', {phone: phone.value.trim()}, {showLoading: false})
|
||||
sentCode.value = res.data?.code || ''
|
||||
uni.showToast({title: '验证码已发送', icon: 'success'})
|
||||
step.value = 'code'
|
||||
startCountdown()
|
||||
} catch (e) { /* 错误已在拦截器处理 */
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const doLogin = async () => {
|
||||
if (code.value.length !== 6) {
|
||||
uni.showToast({title: '请输入6位验证码', icon: 'none'})
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/c/auth/login', {
|
||||
phone: phone.value.trim(), code: code.value
|
||||
}, {showLoading: false})
|
||||
if (res.data && res.data.is_new) {
|
||||
step.value = 'invite'
|
||||
} else if (res.data && res.data.token) {
|
||||
loginSuccess(res.data)
|
||||
}
|
||||
} catch (e) { /* 错误已在拦截器处理 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
const doRegister = async () => {
|
||||
const ic = inviteCode.value.trim()
|
||||
if (!ic || ic.length < 6) {
|
||||
uni.showToast({title: '请输入有效的推荐码', icon: 'none'})
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.post('/c/auth/register', {
|
||||
phone: phone.value.trim(), code: code.value, invite_code: ic
|
||||
}, {showLoading: false})
|
||||
if (res.data && res.data.token) {
|
||||
loginSuccess(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.data?.message || ''
|
||||
if (msg === '验证码失效') {
|
||||
step.value = 'code'
|
||||
code.value = ''
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试账号快速登录(跳过验证码)
|
||||
const quickLogin = async (u) => {
|
||||
phone.value = u.phone
|
||||
try {
|
||||
const res = await request.post('/c/auth/login', {
|
||||
phone: u.phone, code: '000000'
|
||||
}, {showLoading: false})
|
||||
if (res.data && res.data.is_new) {
|
||||
uni.showToast({title: '测试账号不存在,请先执行 seeder', icon: 'none'})
|
||||
} else if (res.data && res.data.token) {
|
||||
loginSuccess(res.data)
|
||||
}
|
||||
} catch (e) { /* 422 验证码错误 → 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
const loginSuccess = (data) => {
|
||||
userStore.setToken(data.token)
|
||||
userStore.setUserInfo(data.user || {})
|
||||
uni.showToast({title: '登录成功', icon: 'success'})
|
||||
setTimeout(() => {
|
||||
const role = data.user?.role || 'c'
|
||||
const homeMap = {
|
||||
c: '/pages/index/index',
|
||||
leader: '/pages/b-home/index',
|
||||
super_leader: '/pages/b-super-home/index',
|
||||
manager: '/pages/b-manager-home/index'
|
||||
}
|
||||
uni.reLaunch({url: homeMap[role] || '/pages/index/index'})
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const startCountdown = () => {
|
||||
codeCountdown.value = 60
|
||||
codeBtnText.value = '60s'
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
codeBtnText.value = codeCountdown.value + 's'
|
||||
if (codeCountdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
codeBtnText.value = '重新发送'
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-area {
|
||||
width: 100%;
|
||||
padding: 260rpx 60rpx 80rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-wrap {
|
||||
width: 480rpx;
|
||||
border-radius: 48rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 480rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
width: 100%;
|
||||
padding: 0 32rpx 20rpx;
|
||||
margin-top: -30rpx;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 40rpx 36rpx 32rpx;
|
||||
box-shadow: 0 12rpx 48rpx rgba(37, 99, 235, 0.10);
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 42rpx;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.form-sub {
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.field { margin-bottom: 20rpx; }
|
||||
|
||||
.field-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #F8FAFC;
|
||||
border-radius: 14rpx;
|
||||
height: 96rpx;
|
||||
padding: 0 28rpx;
|
||||
border: 2rpx solid #E2E8F0;
|
||||
}
|
||||
|
||||
.field-prefix {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
padding-right: 24rpx;
|
||||
margin-right: 24rpx;
|
||||
border-right: 2rpx solid #E2E8F0;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #0F172A;
|
||||
height: 96rpx;
|
||||
&::placeholder { color: #B0BBD0; }
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.sms-btn {
|
||||
flex-shrink: 0;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #EFF6FF;
|
||||
color: #2563EB;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
&.disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
|
||||
.invite-tip {
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
margin-top: 8rpx;
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
width: 100%;
|
||||
height: 100rpx;
|
||||
margin-top: 12rpx;
|
||||
background: linear-gradient(135deg, #2563EB, #4F8AF7);
|
||||
border-radius: 52rpx;
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 32rpx rgba(37, 99, 235, 0.25);
|
||||
letter-spacing: 6rpx;
|
||||
&:active { transform: scale(0.97); }
|
||||
&.loading { opacity: 0.6; pointer-events: none; }
|
||||
}
|
||||
|
||||
.quick-login {
|
||||
margin-top: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.ql-label {
|
||||
font-size: 22rpx;
|
||||
color: #B0BBD0;
|
||||
display: block;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
.ql-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
.ql-item {
|
||||
font-size: 22rpx;
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #fff;
|
||||
color: #64748B;
|
||||
border: 1px solid #E2E8F0;
|
||||
&:active { background: #F1F5F9; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,787 @@
|
||||
<!--
|
||||
* 商城页 — 对齐 C端.html #page-mall
|
||||
* 服务器 / 加成券 / 福利商城 三个Tab
|
||||
-->
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<!-- Top Bar — 对齐 BTopBar + index 结构 -->
|
||||
<view class="top-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="top-bar-inner">
|
||||
<view class="top-bar-logo">
|
||||
<view class="logo-dot"></view>
|
||||
<text class="logo-text">商城</text>
|
||||
</view>
|
||||
<view class="top-bar-search">
|
||||
<input class="search-input" v-model="searchKeyword" placeholder="搜索服务器" confirm-type="search" @confirm="handleSearch" />
|
||||
<text v-if="searchKeyword" class="search-clear" @click="clearSearch">✕</text>
|
||||
<text class="search-btn" @click="handleSearch">🔍</text>
|
||||
</view>
|
||||
<view class="top-bar-action" @click="openSub('mall-orders')">
|
||||
<text>🛒</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 动态占位高度(状态栏 + 导航栏88rpx/44px) -->
|
||||
<view :style="{ height: (statusBarHeight + 44) + 'px' }" class="top-placeholder"></view>
|
||||
|
||||
<!-- Tabs (C端.html .tabs style) -->
|
||||
<view class="mall-tabs-bar">
|
||||
<view class="tabs">
|
||||
<!-- <view v-for="(tab, i) in tabs" :key="i" class="tab" :class="{ active: currentTab === i }" @click="currentTab = i">{{ tab.name }}</view>-->
|
||||
<view v-for="(tab, i) in tabs" :key="i" class="tab" :class="{ active: currentTab === i }" @click="handleTabClick(i)">{{ tab.name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 主体内容区域 (Flex 弹性填充) -->
|
||||
<view class="mall-body">
|
||||
<!-- ============================================ -->
|
||||
<!-- Tab 1: 服务器 -->
|
||||
<!-- ============================================ -->
|
||||
<scroll-view v-show="currentTab === 0" scroll-y class="mall-content" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<!-- Product Cards -->
|
||||
<view class="product-card" v-for="product in serverProducts" :key="product.id" @click="openSub('product-detail', { id: product.id })">
|
||||
<!-- Image Area -->
|
||||
<view class="pc-img" :style="product.image ? {} : { background: product.imgBg }">
|
||||
<image v-if="product.image" :src="product.image" mode="aspectFill" class="pc-cover-img" />
|
||||
<text v-else class="pc-img-emoji">{{ product.emoji }}</text>
|
||||
<text class="pc-badge-top-left" v-if="product.topBadge">{{ product.topBadge }}</text>
|
||||
<text class="pc-badge-top-right" :style="{ color: product.stockBadgeColor || '#fff' }">{{ product.stockBadge }}</text>
|
||||
</view>
|
||||
<view class="pc-body">
|
||||
<!-- Tags -->
|
||||
<view class="pc-tags">
|
||||
<text v-for="(tag, idx) in product.tags" :key="idx" class="pc-tag-pill" :class="tag.cls">{{ tag.text }}</text>
|
||||
</view>
|
||||
<!-- Name + Rating -->
|
||||
<text class="pc-name">{{ product.name }}</text>
|
||||
<view class="pc-rating">
|
||||
<text class="pcr-stars">{{ product.stars }}</text>
|
||||
<text class="pcr-score">{{ product.score }}</text>
|
||||
<text class="pcr-count">({{ product.reviewCount }}评价)</text>
|
||||
</view>
|
||||
<!-- Earnings Grid -->
|
||||
<view class="pc-earnings">
|
||||
<view class="pce-item">
|
||||
<text class="pce-val">¥{{ product.dailyEarn }}<text class="pce-unit">/天</text></text>
|
||||
<text class="pce-label">日产出</text>
|
||||
</view>
|
||||
<view class="pce-item">
|
||||
<text class="pce-val">¥{{ formatThousands(product.monthlyEarn) }}<text class="pce-unit">/月</text></text>
|
||||
<text class="pce-label">月产出</text>
|
||||
</view>
|
||||
<view class="pce-item">
|
||||
<text class="pce-val">{{ product.paybackDays }}<text class="pce-unit">天</text></text>
|
||||
<text class="pce-label">设备成本周期</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Specs -->
|
||||
<view class="pc-specs">
|
||||
<text v-for="(spec, sIdx) in product.specs" :key="sIdx" class="pc-spec">{{ spec }}</text>
|
||||
</view>
|
||||
<!-- Stock Bar -->
|
||||
<view class="pc-stock-bar">
|
||||
<view class="pcsb-fill" :style="{ width: product.stockPercent + '%', background: product.stockColor }"></view>
|
||||
</view>
|
||||
<view class="pc-stock-info">
|
||||
<text>已售 {{ product.sold }}台</text>
|
||||
<text>库存 {{ product.stock }}</text>
|
||||
</view>
|
||||
<!-- Bottom Bar -->
|
||||
<view class="pc-bottom-bar">
|
||||
<view class="pcb-left">
|
||||
<text class="pcb-price">¥{{ formatThousands(product.price) }}<text class="pcb-unit"> 起</text></text>
|
||||
</view>
|
||||
<view class="pcb-right">
|
||||
<text class="pcb-sold">月销{{ product.sold }}台</text>
|
||||
<view class="pcb-btn">立即购买</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Price Breakdown -->
|
||||
<view class="pc-price-breakdown">
|
||||
<text>硬件费 ¥{{ formatThousands(product.hardwarePrice) }} + 服务费 ¥{{ formatThousands(product.servicePrice) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多状态 -->
|
||||
<view class="load-more" v-if="serverHasMore" @click="loadMoreServers">
|
||||
<text class="lm-text">{{ serverLoading ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
<view v-else-if="!serverHasMore && serverProducts.length > 0" class="load-more-tip">— 已加载全部 —</view>
|
||||
|
||||
<!-- Flash Sale Card(暂隐藏,后续启用) -->
|
||||
<template v-if="false">
|
||||
<view class="product-card flash-sale" @click="openSub('product-detail', { id: 'flash-200' })">
|
||||
<view class="pc-img flash-img-bg">
|
||||
<text class="pc-img-emoji">⚡</text>
|
||||
<text class="pc-badge-top-left flash-discount">-30%</text>
|
||||
<text class="pc-badge-top-right flash-label">秒杀</text>
|
||||
</view>
|
||||
<view class="pc-body">
|
||||
<view class="pc-tags">
|
||||
<text class="pc-tag-pill t-orange">限时</text>
|
||||
<text class="pc-tag-pill t-blue">1.2x</text>
|
||||
</view>
|
||||
<text class="pc-name">QH-200 Pro 秒杀款</text>
|
||||
<view class="pc-flash-price-row">
|
||||
<view class="pcb-left">
|
||||
<text class="pcb-price flash-price">¥12,600</text>
|
||||
<text class="pcb-old">¥18,000</text>
|
||||
</view>
|
||||
<view class="pcb-btn flash-btn">立即抢</view>
|
||||
</view>
|
||||
<view class="pc-flash-countdown">
|
||||
<view class="flash-pulse"></view>
|
||||
<text>剩余 02:35:18</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Tab 2: 加成券 -->
|
||||
<!-- ============================================ -->
|
||||
<scroll-view v-show="currentTab === 1" scroll-y class="mall-content">
|
||||
<view v-for="coupon in couponProducts" :key="coupon.id" class="mcp-card" @click="openSub('coupon-detail', { id: coupon.id, payMode: coupon.payMode })">
|
||||
<view class="mcpc-top">
|
||||
<view class="mcpc-icon" :style="{ background: coupon.iconBg }">{{ coupon.multiplier }}x</view>
|
||||
<view class="mcpc-info">
|
||||
<text class="mcpc-name">收益加成券 {{ coupon.multiplier }}x</text>
|
||||
<view class="mcpc-tags">
|
||||
<text v-for="(tag, idx) in coupon.tags" :key="idx" class="mcpc-tag" :style="{ background: tag.bg, color: tag.color }">{{ tag.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mcpc-price-area">
|
||||
<text class="mcpc-price">{{ coupon.priceText }}</text>
|
||||
<text v-if="coupon.oldPrice" class="mcpc-old-price">{{ coupon.oldPrice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="mcpc-body">
|
||||
<!-- Params -->
|
||||
<view class="mcpc-params">
|
||||
<view class="mcpc-param">
|
||||
<text class="v">{{ coupon.multiplier }}x</text>
|
||||
<text class="l">加成倍数</text>
|
||||
</view>
|
||||
<view class="mcpc-param">
|
||||
<text class="v">{{ coupon.days }}天</text>
|
||||
<text class="l">有效期</text>
|
||||
</view>
|
||||
<view class="mcpc-param">
|
||||
<text class="v">{{ coupon.scope }}</text>
|
||||
<text class="l">适用范围</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Earnings Comparison -->
|
||||
<view class="mcpc-earnings">
|
||||
<text class="mcpc-earnings-title">💡 月产出预估</text>
|
||||
<view class="mcpc-earnings-compare">
|
||||
<view class="mcpc-earnings-box before">
|
||||
<text class="v">¥{{ formatThousands(coupon.baselineMonthly) }}</text>
|
||||
<text class="l">基础收益</text>
|
||||
</view>
|
||||
<text class="mcpc-earnings-arrow">→</text>
|
||||
<view class="mcpc-earnings-box after">
|
||||
<text class="v">¥{{ formatThousands(coupon.boostedMonthly) }}</text>
|
||||
<text class="l">加成后</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Bottom -->
|
||||
<view class="mcpc-bottom">
|
||||
<view class="mcpc-buy" :class="{ exchange: coupon.isExchange }">{{ coupon.btnText }}</view>
|
||||
<text class="mcpc-sold">月销 {{ coupon.sold }}张</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多状态 -->
|
||||
<view class="load-more" v-if="couponHasMore" @click="loadMoreCoupons">
|
||||
<text class="lm-text">{{ couponLoading ? '加载中...' : '加载更多' }}</text>
|
||||
</view>
|
||||
<view v-else-if="!couponHasMore && couponProducts.length > 0" class="load-more-tip">— 已加载全部 —</view>
|
||||
|
||||
<!-- Tips -->
|
||||
<view class="cp-rules-card">
|
||||
<text class="cp-rules-title">💡 加成券使用说明</text>
|
||||
<text class="cp-rule-item">· 加成券购买后需绑定到具体设备方可生效</text>
|
||||
<text class="cp-rule-item">· 每台设备同时仅可生效一张加成券</text>
|
||||
<text class="cp-rule-item">· 有效期从绑定当日开始计算,过期失效</text>
|
||||
<text class="cp-rule-item">· 收益加成在每日0:30结算时生效</text>
|
||||
</view>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Tab 3: 福利商城 -->
|
||||
<!-- ============================================ -->
|
||||
<scroll-view v-show="currentTab === 2" scroll-y class="mall-content">
|
||||
<!-- Category Icons (5-column) -->
|
||||
<view class="welfare-cats">
|
||||
<view v-for="(cat, i) in welfareCats" :key="i" class="wcat-item">
|
||||
<view class="wcat-icon" :style="{ background: cat.bg }">
|
||||
<text class="wcat-emoji">{{ cat.emoji }}</text>
|
||||
</view>
|
||||
<text class="wcat-label">{{ cat.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Flash Sale Section -->
|
||||
<view class="welfare-section">
|
||||
<view class="ws-header">
|
||||
<text class="ws-title">限时优惠</text>
|
||||
<view class="ws-countdown">
|
||||
<view class="flash-pulse-sm"></view>
|
||||
<text>02:35:18</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-x class="ws-flash-scroll" :show-scrollbar="false">
|
||||
<view v-for="item in flashItems" :key="item.id" class="ws-flash-item" @click="openSub('welfare', { id: item.id })">
|
||||
<view class="ws-flash-img" :style="{ background: item.bg }">
|
||||
<text class="ws-flash-emoji">{{ item.emoji }}</text>
|
||||
</view>
|
||||
<text class="ws-flash-name">{{ item.name }}</text>
|
||||
<view class="ws-flash-prices">
|
||||
<text class="ws-flash-price">¥{{ item.price }}</text>
|
||||
<text class="ws-flash-old">¥{{ item.oldPrice }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- Recommended Products -->
|
||||
<view class="welfare-section">
|
||||
<text class="ws-title">为你推荐</text>
|
||||
</view>
|
||||
<view class="welfare-grid">
|
||||
<view v-for="item in welfareItems" :key="item.id" class="welfare-item" @click="openSub('welfare', { id: item.id })">
|
||||
<view class="wi-img" :style="{ background: item.bg }">
|
||||
<text class="wi-emoji">{{ item.emoji }}</text>
|
||||
</view>
|
||||
<view class="wi-body">
|
||||
<text class="wi-name">{{ item.name }}</text>
|
||||
<text class="wi-price">¥{{ item.price }}</text>
|
||||
<text v-if="item.oldPrice" class="wi-old">¥{{ item.oldPrice }}</text>
|
||||
<text class="wi-sold" v-if="item.sold">已售 {{ item.sold }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="3"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const statusBarHeight = ref(20)
|
||||
uni.getSystemInfo({
|
||||
success: r => { statusBarHeight.value = r.statusBarHeight || 20 }
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const handleSearch = async () => {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
await fetchServerProducts(true)
|
||||
if (keyword && serverProducts.value.length === 0) {
|
||||
uni.showToast({ title: '未找到匹配服务器', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// 清空关键词后恢复全部服务器商品
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
fetchServerProducts(true)
|
||||
}
|
||||
|
||||
const currentTab = ref(0)
|
||||
|
||||
const handleTabClick = (i) => {
|
||||
if (i > 0) {
|
||||
uni.showToast({ title: '即将开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
currentTab.value = i
|
||||
}
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ name: '服务器' },
|
||||
{ name: '加成券' },
|
||||
{ name: '福利商城' }
|
||||
]
|
||||
|
||||
// 渐变色备选(无图片时使用)
|
||||
const SERVER_GRADIENTS = [
|
||||
'linear-gradient(135deg, #0D2B6E, #1B66E8)',
|
||||
'linear-gradient(135deg, #1342B5, #4096FF)',
|
||||
'linear-gradient(135deg, #1B66E8, #5B8DEF)',
|
||||
'linear-gradient(135deg, #0A1733, #0D2B6E)',
|
||||
'linear-gradient(135deg, #1342B5, #1890FF)'
|
||||
]
|
||||
const SERVER_EMOJIS = ['🖥️', '💻', '🖥️', '🖥️', '💻']
|
||||
|
||||
// Server Products — 分页加载
|
||||
const serverProducts = ref([])
|
||||
const serverPage = ref(1)
|
||||
const serverHasMore = ref(true)
|
||||
const serverLoading = ref(false)
|
||||
|
||||
const fetchServerProducts = async (isRefresh = false) => {
|
||||
if (serverLoading.value) return
|
||||
if (!isRefresh && !serverHasMore.value) return
|
||||
|
||||
if (isRefresh) {
|
||||
serverPage.value = 1
|
||||
}
|
||||
serverLoading.value = true
|
||||
|
||||
try {
|
||||
const res = await request.get('/c/server-products', {
|
||||
page: serverPage.value,
|
||||
keyword: searchKeyword.value.trim(),
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
const { list, has_more } = res.data
|
||||
const mapped = (list || []).map((d, i) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
image: d.image,
|
||||
imgBg: SERVER_GRADIENTS[i % SERVER_GRADIENTS.length],
|
||||
emoji: SERVER_EMOJIS[i % SERVER_EMOJIS.length],
|
||||
topBadge: i === 0 ? '热销' : (i === 1 ? '新品' : ''),
|
||||
stockBadge: (d.stock > 0 && d.stock <= 30) ? '仅剩' + d.stock + '台' : '现货充足',
|
||||
stockBadgeColor: (d.stock > 0 && d.stock <= 30) ? '#FF4D4F' : '#00C4A7',
|
||||
tags: d.tags || [],
|
||||
stars: '★★★★★',
|
||||
score: '4.8',
|
||||
reviewCount: d.sales || 0,
|
||||
dailyEarn: d.daily_earn,
|
||||
monthlyEarn: d.monthly_earn,
|
||||
paybackDays: d.payback_days,
|
||||
specs: d.specs || [],
|
||||
stockPercent: d.stock_percent,
|
||||
stockColor: d.stock_percent > 50 ? 'linear-gradient(90deg, #00C4A7, #5FE6CC)' : 'linear-gradient(90deg, #FF8800, #FFB84D)',
|
||||
sold: d.sales,
|
||||
stock: d.stock > 0 ? d.stock + '/' + (d.stock + d.sales) : '0',
|
||||
price: d.price,
|
||||
hardwarePrice: d.device_price,
|
||||
servicePrice: d.service_price
|
||||
}))
|
||||
|
||||
if (isRefresh) {
|
||||
serverProducts.value = mapped
|
||||
} else {
|
||||
serverProducts.value = [...serverProducts.value, ...mapped]
|
||||
}
|
||||
serverHasMore.value = has_more
|
||||
serverPage.value++
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取服务器商品失败', e)
|
||||
} finally {
|
||||
serverLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreServers = () => {
|
||||
fetchServerProducts()
|
||||
}
|
||||
|
||||
// Coupon Products — 分页加载
|
||||
const couponProducts = ref([])
|
||||
const couponPage = ref(1)
|
||||
const couponHasMore = ref(true)
|
||||
const couponLoading = ref(false)
|
||||
|
||||
const fetchCouponProducts = async (isRefresh = false) => {
|
||||
if (couponLoading.value) return
|
||||
if (!isRefresh && !couponHasMore.value) return
|
||||
|
||||
if (isRefresh) {
|
||||
couponPage.value = 1
|
||||
}
|
||||
couponLoading.value = true
|
||||
|
||||
try {
|
||||
const res = await request.get('/c/coupon-products', {
|
||||
page: couponPage.value,
|
||||
showLoading: false
|
||||
})
|
||||
if (res.data) {
|
||||
const { list, has_more } = res.data
|
||||
// 倍率对应的渐变色
|
||||
const multiplierGradients = {
|
||||
'1.5': 'linear-gradient(135deg, #FF8800, #FFB84D)',
|
||||
'2': 'linear-gradient(135deg, #FF4D4F, #FF6B35)',
|
||||
'1.2': 'linear-gradient(135deg, #1677FF, #4096FF)',
|
||||
'1.1': 'linear-gradient(135deg, #00C4A7, #5FE6CC)',
|
||||
}
|
||||
const defaultGradient = 'linear-gradient(135deg, #1677FF, #4096FF)'
|
||||
|
||||
const mapped = (list || []).map(d => {
|
||||
const isExchange = d.pay_mode !== 'cash'
|
||||
const isCash = d.pay_mode !== 'points'
|
||||
|
||||
// 价格文案
|
||||
let priceText = ''
|
||||
if (isCash && isExchange) {
|
||||
priceText = '¥' + d.price + ' + ' + d.points_exchange + '积分'
|
||||
} else if (isCash) {
|
||||
priceText = '¥' + d.price
|
||||
} else {
|
||||
priceText = d.points_exchange + '积分'
|
||||
}
|
||||
|
||||
// 按钮文案
|
||||
let btnText = ''
|
||||
if (isCash && isExchange) {
|
||||
btnText = d.price + ' + ' + d.points_exchange + '积分'
|
||||
} else if (isCash) {
|
||||
btnText = '立即购买'
|
||||
} else {
|
||||
btnText = d.points_exchange + '积分兑换'
|
||||
}
|
||||
|
||||
return {
|
||||
id: d.id,
|
||||
multiplier: d.multiplier,
|
||||
days: d.days,
|
||||
scope: d.scope,
|
||||
iconBg: multiplierGradients[d.multiplier] || defaultGradient,
|
||||
tags: d.tags || [],
|
||||
priceText: priceText,
|
||||
oldPrice: '',
|
||||
baselineMonthly: d.baseline_monthly,
|
||||
boostedMonthly: d.boosted_monthly,
|
||||
btnText: btnText,
|
||||
isExchange: isExchange,
|
||||
sold: d.sales,
|
||||
payMode: d.pay_mode
|
||||
}
|
||||
})
|
||||
|
||||
if (isRefresh) {
|
||||
couponProducts.value = mapped
|
||||
} else {
|
||||
couponProducts.value = [...couponProducts.value, ...mapped]
|
||||
}
|
||||
couponHasMore.value = has_more
|
||||
couponPage.value++
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取加成券商品失败', e)
|
||||
} finally {
|
||||
couponLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreCoupons = () => {
|
||||
fetchCouponProducts()
|
||||
}
|
||||
|
||||
// Welfare Categories
|
||||
const welfareCats = ref([
|
||||
{ name: '数码配件', emoji: '🔌', bg: '#E6F4FF' },
|
||||
{ name: '品牌周边', emoji: '🎁', bg: '#E6FAF5' },
|
||||
{ name: '限时优惠', emoji: '⚡', bg: '#FFF7E6' },
|
||||
{ name: '积分兑换', emoji: '🎟️', bg: '#F9F0FF' },
|
||||
{ name: '全部商品', emoji: '📦', bg: '#F0F2F5' }
|
||||
])
|
||||
|
||||
// Flash Items
|
||||
const flashItems = ref([
|
||||
{ id: 1, name: '万兆网线', price: 68, oldPrice: 158, bg: 'linear-gradient(135deg, #667eea, #764ba2)', emoji: '🔌' },
|
||||
{ id: 2, name: '显示器支架', price: 239, oldPrice: 459, bg: 'linear-gradient(135deg, #4facfe, #00f2fe)', emoji: '🖥️' },
|
||||
{ id: 3, name: '品牌T恤', price: 79, oldPrice: 158, bg: 'linear-gradient(135deg, #f093fb, #f5576c)', emoji: '👕' }
|
||||
])
|
||||
|
||||
// Welfare Items
|
||||
const welfareItems = ref([
|
||||
{ id: 1, name: '全犀云品牌T恤', price: 158, oldPrice: 258, sold: '1,258', bg: 'linear-gradient(135deg, #667eea, #764ba2)', emoji: '👕' },
|
||||
{ id: 2, name: 'CAT6万兆网线', price: 68, sold: '3,260', bg: 'linear-gradient(135deg, #4facfe, #00f2fe)', emoji: '🔌' },
|
||||
{ id: 3, name: '机房监控摄像头', price: 499, sold: '856', bg: 'linear-gradient(135deg, #43e97b, #38f9d7)', emoji: '📹' },
|
||||
{ id: 4, name: 'KVM切换器 4口', price: 899, oldPrice: 1299, sold: '432', bg: 'linear-gradient(135deg, #fa709a, #fee140)', emoji: '🖥️' }
|
||||
])
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true
|
||||
Promise.all([fetchServerProducts(true), fetchCouponProducts(true)]).finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchServerProducts(true)
|
||||
fetchCouponProducts(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 全局盒子尺寸计算 */
|
||||
view, text, input, scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F2F4F8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top Bar — 对齐 BTopBar + index 结构 */
|
||||
.top-bar {
|
||||
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
|
||||
width: 100%; max-width: 430px; z-index: 999;
|
||||
background: rgba(255,255,255,.88);
|
||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: .5px solid #EAEDF3;
|
||||
}
|
||||
.top-bar-inner { display: flex; align-items: center; gap: 12rpx; padding: 0 32rpx; height: 88rpx; }
|
||||
.top-bar-logo { display: flex; align-items: center; gap: 10rpx; flex-shrink: 0; }
|
||||
.logo-dot { width: 10rpx; height: 40rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 4rpx; box-shadow: 0 0 16rpx rgba(22,119,255,.5); }
|
||||
.logo-text { font-size: 32rpx; font-weight: 700; color: #0F1226; letter-spacing: 1rpx; }
|
||||
.top-bar-search {
|
||||
flex: 1; height: 64rpx; background: #F3F5F9; border-radius: 32rpx;
|
||||
display: flex; align-items: center; padding: 0 28rpx; gap: 12rpx;
|
||||
|
||||
.search-input { flex: 1; height: 100%; font-size: 24rpx; color: #0F1226; }
|
||||
.search-clear {
|
||||
font-size: 22rpx; color: #BCC2CE; width: 36rpx; height: 36rpx;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
&:active { color: #8B91A1; }
|
||||
}
|
||||
.search-btn {
|
||||
font-size: 28rpx; flex-shrink: 0; padding: 4rpx;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
}
|
||||
.top-bar-action {
|
||||
width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; flex-shrink: 0; font-size: 36rpx; line-height: 1;
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
|
||||
.top-placeholder { flex-shrink: 0; }
|
||||
|
||||
/* Tabs */
|
||||
.mall-tabs-bar { background: #fff; padding: 0 24rpx; flex-shrink: 0; }
|
||||
.tabs { display: flex; border-bottom: 0.5px solid #EAEDF3; }
|
||||
.tab {
|
||||
flex: 1; text-align: center; padding: 22rpx 0; font-size: 28rpx; font-weight: 500;
|
||||
color: #8B91A1; position: relative; transition: color 0.2s;
|
||||
&.active {
|
||||
color: #1677FF; font-weight: 600;
|
||||
&::after { content: ''; position: absolute; bottom: -0.5px; left: 50%; transform: translateX(-50%); width: 44rpx; height: 5rpx; background: linear-gradient(90deg, #4096FF, #1677FF); border-radius: 2px; box-shadow: 0 0 12rpx rgba(22, 119, 255, 0.4); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 主体容器 Flex 撑满 */
|
||||
.mall-body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.mall-content { height: 100%; padding: 24rpx; }
|
||||
.safe-bottom-spacer { height: calc(140rpx + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* ========================
|
||||
Product Card (Server)
|
||||
======================== */
|
||||
.product-card {
|
||||
background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
margin-bottom: 20rpx; transition: all 0.2s;
|
||||
&:active { transform: scale(0.99); box-shadow: 0 4px 16px rgba(15, 18, 38, 0.06); }
|
||||
}
|
||||
.pc-img {
|
||||
height: 280rpx; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
&::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4), transparent 60%); }
|
||||
}
|
||||
.pc-img-emoji { font-size: 80rpx; position: relative; z-index: 1; }
|
||||
.pc-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.pc-badge-top-left { position: absolute; top: 20rpx; left: 20rpx; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 8rpx; background: rgba(255,255,255,0.9); color: #0F1226; font-weight: 600; }
|
||||
.pc-badge-top-right { position: absolute; top: 20rpx; right: 20rpx; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 8rpx; background: rgba(0,0,0,0.4); font-weight: 500; }
|
||||
|
||||
.pc-body { padding: 20rpx 28rpx; }
|
||||
.pc-tags { display: flex; flex-wrap: wrap; gap: 8rpx; margin-bottom: 12rpx; }
|
||||
.pc-tag-pill {
|
||||
font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 8rpx; font-weight: 500;
|
||||
&.t-blue { background: #E6F4FF; color: #1677FF; }
|
||||
&.t-green { background: #E6FAF5; color: #00C4A7; }
|
||||
&.t-orange { background: #FFF7E6; color: #FF8800; }
|
||||
&.t-gray { background: #F0F2F5; color: #6B7280; }
|
||||
}
|
||||
.pc-name { font-size: 30rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
|
||||
.pc-rating { display: flex; align-items: center; gap: 8rpx; margin: 8rpx 0 16rpx; }
|
||||
.pcr-stars { color: #FF8800; font-size: 22rpx; letter-spacing: 2rpx; }
|
||||
.pcr-score { font-size: 22rpx; font-weight: 600; color: #0F1226; }
|
||||
.pcr-count { font-size: 20rpx; color: #BCC2CE; }
|
||||
|
||||
.pc-earnings { display: flex; background: linear-gradient(135deg, #E6F4FF, #F0F7FF); border-radius: 10rpx; padding: 16rpx 0; margin-bottom: 16rpx; }
|
||||
.pce-item { flex: 1; text-align: center; position: relative;
|
||||
&:not(:last-child)::after { content: ''; position: absolute; right: 0; top: 15%; height: 70%; width: 1rpx; background: #BAE0FF; }
|
||||
}
|
||||
.pce-val { font-size: 30rpx; font-weight: 700; color: #1677FF; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.pce-unit { font-size: 20rpx; font-weight: 400; color: #8B91A1; }
|
||||
.pce-label { font-size: 20rpx; color: #8B91A1; margin-top: 2rpx; display: block; }
|
||||
|
||||
.pc-specs { display: flex; gap: 8rpx; flex-wrap: wrap; margin-bottom: 16rpx; }
|
||||
.pc-spec { font-size: 20rpx; color: #5A6072; background: #F5F7FA; padding: 6rpx 14rpx; border-radius: 6rpx; }
|
||||
|
||||
.pc-stock-bar { height: 8rpx; background: #F0F2F5; border-radius: 4rpx; overflow: hidden; margin-bottom: 8rpx; }
|
||||
.pcsb-fill { height: 100%; border-radius: 4rpx; }
|
||||
.pc-stock-info { display: flex; justify-content: space-between; font-size: 20rpx; color: #8B91A1; margin-bottom: 20rpx; }
|
||||
|
||||
.pc-bottom-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||||
.pcb-left { display: flex; align-items: baseline; gap: 12rpx; }
|
||||
.pcb-price { font-size: 44rpx; font-weight: 700; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; }
|
||||
.pcb-unit { font-size: 24rpx; font-weight: 400; }
|
||||
.pcb-old { font-size: 24rpx; color: #BCC2CE; text-decoration: line-through; }
|
||||
.pcb-right { display: flex; align-items: center; gap: 12rpx; }
|
||||
.pcb-sold { font-size: 22rpx; color: #8B91A1; }
|
||||
.pcb-btn { background: linear-gradient(135deg, #1677FF, #0958D9); color: #fff; font-size: 24rpx; font-weight: 600; padding: 14rpx 32rpx; border-radius: 40rpx; box-shadow: 0 4rpx 16rpx rgba(22, 119, 255, 0.25); }
|
||||
|
||||
.pc-price-breakdown { font-size: 20rpx; color: #8B91A1; }
|
||||
|
||||
/* Flash Sale Card */
|
||||
.flash-sale { border: 4rpx solid #FFB84D; }
|
||||
.flash-img-bg { background: linear-gradient(135deg, #FFF7E6, #FFE0B0) !important; }
|
||||
.flash-discount { background: #FF4D4F !important; color: #fff !important; }
|
||||
.flash-label { background: #FFF7E6 !important; color: #FF8800 !important; border: 1px solid #FFB84D; }
|
||||
.pc-flash-price-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
|
||||
.flash-price { color: #FF4D4F !important; }
|
||||
.flash-btn { background: linear-gradient(135deg, #FF4D4F, #FF6B35) !important; box-shadow: 0 4rpx 16rpx rgba(255, 77, 79, 0.3) !important; }
|
||||
.pc-flash-countdown { display: flex; align-items: center; gap: 8rpx; font-size: 20rpx; color: #FF4D4F; }
|
||||
.flash-pulse {
|
||||
width: 12rpx; height: 12rpx; border-radius: 50%; background: #FF4D4F;
|
||||
animation: pulse-ring 1.2s infinite;
|
||||
}
|
||||
@keyframes pulse-ring {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.5); }
|
||||
70% { box-shadow: 0 0 0 12rpx rgba(255, 77, 79, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); }
|
||||
}
|
||||
|
||||
/* Load More */
|
||||
.load-more { padding: 24rpx 0 36rpx; text-align: center; }
|
||||
.lm-text { font-size: 24rpx; color: #1677FF; }
|
||||
.load-more-tip {
|
||||
text-align: center;
|
||||
padding: 28rpx 0;
|
||||
font-size: 22rpx;
|
||||
color: #BCC2CE;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Coupon Card (mcp-card)
|
||||
======================== */
|
||||
.mcp-card {
|
||||
background: #fff; border-radius: 14rpx; margin-bottom: 20rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
overflow: hidden; transition: all 0.15s;
|
||||
&:active { transform: scale(0.99); }
|
||||
}
|
||||
.mcpc-top { display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
|
||||
.mcpc-icon {
|
||||
width: 104rpx; height: 104rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; font-size: 32rpx; font-weight: 800; color: #fff; font-family: 'DIN Alternate', -apple-system, sans-serif;
|
||||
}
|
||||
.mcpc-info { flex: 1; min-width: 0; }
|
||||
.mcpc-name { font-size: 28rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.mcpc-tags { display: flex; gap: 8rpx; margin-top: 8rpx; flex-wrap: wrap; }
|
||||
.mcpc-tag { font-size: 18rpx; padding: 2rpx 12rpx; border-radius: 16rpx; font-weight: 600; }
|
||||
.mcpc-price-area { text-align: right; flex-shrink: 0; }
|
||||
.mcpc-price { font-size: 40rpx; font-weight: 800; color: #FF4D4F; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.mcpc-old-price { font-size: 22rpx; color: #BCC2CE; text-decoration: line-through; }
|
||||
|
||||
.mcpc-body { padding: 0 28rpx 28rpx; }
|
||||
.mcpc-params { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-bottom: 20rpx; }
|
||||
.mcpc-param { background: #F7F9FC; border-radius: 16rpx; padding: 16rpx 12rpx; text-align: center; }
|
||||
.mcpc-param .v { font-size: 26rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; color: #0F1226; display: block; }
|
||||
.mcpc-param .l { font-size: 18rpx; color: #8B91A1; margin-top: 2rpx; display: block; }
|
||||
|
||||
.mcpc-earnings {
|
||||
background: linear-gradient(135deg, #FFF7E6, #FFFBF0); border: 1px solid #FFE0B0;
|
||||
border-radius: 10rpx; padding: 20rpx; margin-bottom: 20rpx;
|
||||
}
|
||||
.mcpc-earnings-title { font-size: 20rpx; color: #FF8800; font-weight: 600; display: flex; align-items: center; gap: 8rpx; margin-bottom: 16rpx; }
|
||||
.mcpc-earnings-compare { display: flex; align-items: center; gap: 12rpx; }
|
||||
.mcpc-earnings-box { flex: 1; text-align: center; }
|
||||
.mcpc-earnings-box .v { font-size: 30rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.mcpc-earnings-box.before .v { color: #5A6072; }
|
||||
.mcpc-earnings-box.after .v { color: #FF8800; }
|
||||
.mcpc-earnings-box .l { font-size: 18rpx; color: #8B91A1; }
|
||||
.mcpc-earnings-arrow { color: #00C4A7; font-weight: 700; font-size: 28rpx; }
|
||||
|
||||
.mcpc-bottom { display: flex; align-items: center; gap: 16rpx; }
|
||||
.mcpc-buy { flex: 1; background: #FF8800; color: #fff; text-align: center; padding: 20rpx 0; border-radius: 10rpx; font-size: 26rpx; font-weight: 600; }
|
||||
.mcpc-buy.exchange { background: #1677FF; }
|
||||
.mcpc-sold { font-size: 22rpx; color: #8B91A1; white-space: nowrap; }
|
||||
|
||||
/* Coupon Rules */
|
||||
.cp-rules-card { background: #fff; border-radius: 14rpx; padding: 28rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.cp-rules-title { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; margin-bottom: 16rpx; }
|
||||
.cp-rule-item { font-size: 22rpx; color: #5A6072; line-height: 1.8; display: block; }
|
||||
|
||||
/* ========================
|
||||
Welfare Mall
|
||||
======================== */
|
||||
.welfare-cats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16rpx; margin-bottom: 32rpx; }
|
||||
.wcat-item { display: flex; flex-direction: column; align-items: center; gap: 8rpx; }
|
||||
.wcat-icon { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.wcat-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.wcat-label { font-size: 22rpx; color: #0F1226; }
|
||||
|
||||
.welfare-section { margin-bottom: 32rpx; }
|
||||
.ws-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.ws-title { font-size: 32rpx; font-weight: 700; color: #0F1226; }
|
||||
.ws-countdown { display: flex; align-items: center; gap: 6rpx; font-size: 24rpx; color: #FF4D4F; font-weight: 600; }
|
||||
.flash-pulse-sm { width: 12rpx; height: 12rpx; border-radius: 50%; background: #FF4D4F; animation: pulse-ring 1.2s infinite; }
|
||||
|
||||
.ws-flash-scroll { white-space: nowrap; }
|
||||
.ws-flash-item { display: inline-block; width: 220rpx; margin-right: 20rpx; text-align: center; }
|
||||
.ws-flash-img { width: 220rpx; height: 180rpx; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 12rpx; }
|
||||
.ws-flash-emoji { font-size: 56rpx; }
|
||||
.ws-flash-name { font-size: 24rpx; color: #0F1226; display: block; margin-bottom: 4rpx; }
|
||||
.ws-flash-prices { display: flex; justify-content: center; gap: 12rpx; }
|
||||
.ws-flash-price { font-size: 28rpx; font-weight: 700; color: #FF4D4F; }
|
||||
.ws-flash-old { font-size: 20rpx; color: #BCC2CE; text-decoration: line-through; }
|
||||
|
||||
/* Welfare Grid */
|
||||
.welfare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.welfare-item { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.wi-img { height: 240rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.wi-emoji { font-size: 64rpx; }
|
||||
.wi-body { padding: 16rpx 20rpx 20rpx; }
|
||||
.wi-name { font-size: 24rpx; color: #0F1226; display: block; margin-bottom: 4rpx; }
|
||||
.wi-price { font-size: 32rpx; font-weight: 700; color: #FF8800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.wi-old { font-size: 20rpx; color: #BCC2CE; text-decoration: line-through; margin-left: 8rpx; }
|
||||
.wi-sold { font-size: 18rpx; color: #BCC2CE; margin-top: 4rpx; display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,431 @@
|
||||
<!--
|
||||
* 个人中心页 — 对齐 C端.html #page-profile
|
||||
* 企业信息 + VIP等级 + 功能入口 + 设置
|
||||
-->
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<nav-bar title="我的" :show-back="false">
|
||||
<template #right>
|
||||
<view class="settings-btn" @click="openSub('settings')">
|
||||
<u-icon name="setting" size="20" color="#333"></u-icon>
|
||||
</view>
|
||||
</template>
|
||||
</nav-bar>
|
||||
|
||||
<scroll-view scroll-y class="page-content">
|
||||
<!-- ============================================ -->
|
||||
<!-- Header卡片(浅蓝渐变背景) -->
|
||||
<!-- ============================================ -->
|
||||
<view class="profile-hero">
|
||||
<view class="ph-banner">
|
||||
<view class="ph-avatar-row">
|
||||
<view class="ph-avatar">
|
||||
<text class="ph-avatar-text">云</text>
|
||||
</view>
|
||||
<view class="ph-info">
|
||||
<text class="ph-name">{{ userInfo.companyName }}</text>
|
||||
<view class="ph-tags">
|
||||
<text class="ph-tag vip">{{ userInfo.vipLevel }}</text>
|
||||
<text class="ph-id">ID: {{ userInfo.userId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ph-edit" @click="openSub('profile-edit')">
|
||||
<u-icon name="edit-pen" size="16" color="#1677ff"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 4格统计 -->
|
||||
<view class="ph-stats">
|
||||
<view class="ph-stat" @click="switchTab('devices')">
|
||||
<text class="phs-val">{{ userInfo.deviceCount }}</text>
|
||||
<text class="phs-label">设备</text>
|
||||
</view>
|
||||
<view class="ph-stat" @click="openSub('points-log')">
|
||||
<text class="phs-val">{{ userInfo.totalPoints }}</text>
|
||||
<text class="phs-label">累计积分</text>
|
||||
</view>
|
||||
|
||||
<!-- <view class="ph-stat" @click="openSub('credit-detail')">
|
||||
<text class="phs-val">{{ userInfo.creditScore }}</text>
|
||||
<text class="phs-label">信用分</text>
|
||||
</view> -->
|
||||
|
||||
<!-- 信用分暂时隐藏 -->
|
||||
<!-- <view class="ph-stat" @click="uni.showToast({ title:'信用分功能开发中', icon:'none' })">
|
||||
<text class="phs-val">--</text>
|
||||
<text class="phs-label">信用分</text>
|
||||
</view> -->
|
||||
|
||||
<view class="ph-stat" @click="openSub('user-level')">
|
||||
<text class="phs-val vip">{{ userInfo.vipLevel }}</text>
|
||||
<text class="phs-label">用户等级</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- VIP进度条 -->
|
||||
<view class="vip-progress" @click="openSub('user-level')">
|
||||
<view class="vp-header">
|
||||
<text class="vp-label">等级进度 · {{ userInfo.isMaxLevel ? userInfo.nextVipLevel : '距离 ' + userInfo.nextVipLevel }}</text>
|
||||
<text class="vp-pct">{{ formatThousands(userInfo.displayGrowthValue) }} / {{ formatThousands(userInfo.nextLevelPoints) }}</text>
|
||||
</view>
|
||||
<view class="vp-bar">
|
||||
<view class="vp-fill" :style="{ width: userInfo.vipProgress + '%' }"></view>
|
||||
</view>
|
||||
<!-- <view class="vp-details">
|
||||
<text class="vp-detail-text">基础: 累计积分 ✓ 达标中</text>
|
||||
<text class="vp-detail-text green">次要: {{ userInfo.subConditions }}</text>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 常用功能(4格)— 对齐 C端.html -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<text class="section-title">常用功能</text>
|
||||
<view class="quick-row">
|
||||
<!-- <view v-for="(item, i) in quickActions" :key="i" class="quick-cell" @click="openSub(item.route)"> -->
|
||||
<view v-for="(item, i) in quickActions" :key="i" class="quick-cell" @click="handleClick(item)">
|
||||
<view class="qc-icon" :style="{ background: item.bg }">
|
||||
<text v-if="item.emoji" class="qc-emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="22" :color="item.color"></u-icon>
|
||||
</view>
|
||||
<text class="qc-label">{{ item.name }}</text>
|
||||
<text v-if="item.badge" class="qc-badge">{{ item.badge }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 业务中心 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<text class="section-title">业务中心</text>
|
||||
<view class="list-group">
|
||||
<view v-for="(item, i) in businessItems" :key="i" class="list-item" @click="handleClick(item)">
|
||||
<view class="li-icon" :style="{ background: item.bg }">
|
||||
<text v-if="item.emoji" class="li-emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="20" :color="item.color"></u-icon>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">{{ item.name }}</text>
|
||||
<text class="li-sub">{{ item.desc }}</text>
|
||||
</view>
|
||||
<view class="li-right">
|
||||
<text v-if="item.badge" class="li-badge" :style="{ color: item.badgeColor, background: item.badgeBg }">{{ item.badge }}</text>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 工具与支持 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<text class="section-title">工具与支持</text>
|
||||
<view class="tool-grid">
|
||||
<view v-for="(item, i) in toolItems" :key="i" class="tool-item" @click="handleClick(item)">
|
||||
<view class="ti-icon" :style="{ background: item.bg }">
|
||||
<text v-if="item.emoji" class="ti-emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="22" :color="item.color"></u-icon>
|
||||
</view>
|
||||
<view class="ti-body">
|
||||
<text class="ti-name">{{ item.name }}</text>
|
||||
<text class="ti-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
<text v-if="item.badge" class="ti-badge">{{ item.badge }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- 通用设置 -->
|
||||
<!-- ============================================ -->
|
||||
<view class="section">
|
||||
<text class="section-title">通用</text>
|
||||
<view class="list-group">
|
||||
<view class="list-item" @click="openSub('settings')">
|
||||
<view class="li-icon" style="background: #F0F2F5;">
|
||||
<u-icon name="setting" size="20" color="#5A6072"></u-icon>
|
||||
</view>
|
||||
<view class="li-body">
|
||||
<text class="li-title">设置</text>
|
||||
<text class="li-sub">账号安全与偏好设置</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <view class="bottom-links">
|
||||
<text class="bottom-link" @click="showToast('隐私政策')">隐私政策</text>
|
||||
<text class="bottom-link" @click="showToast('用户协议')">用户协议</text>
|
||||
<text class="bottom-link" @click="showToast('关于全犀云')">关于全犀云 v1.0.0</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="logout-btn" @click="handleLogout">退出登录</view>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
<custom-tab-bar :current="4"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
console.log('=== PROFILE PAGE SCRIPT LOADED ===')
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import { openSub, switchTab } from '@/composables/useNavigation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const userStore = useUserStore()
|
||||
console.log('[profile] userStore:', userStore)
|
||||
console.log('[profile] userStore.logout:', typeof userStore.logout)
|
||||
|
||||
const loginUser = uni.getStorageSync('userInfo') || {}
|
||||
const userInfo = reactive({
|
||||
companyName: loginUser.nickname || '用户',
|
||||
avatar: loginUser.avatar || '',
|
||||
vipLevel: 'VIP0',
|
||||
userId: loginUser.id ? 'C' + String(loginUser.id).padStart(8, '0') : '--',
|
||||
deviceCount: '--',
|
||||
totalPoints: '--',
|
||||
// 信用分暂时隐藏
|
||||
// creditScore: '--',
|
||||
currentGrowthValue: 0,
|
||||
displayGrowthValue: 0,
|
||||
isMaxLevel: false,
|
||||
nextVipLevel: 'VIP1',
|
||||
nextLevelPoints: 0,
|
||||
vipProgress: 0,
|
||||
subConditions: '--'
|
||||
})
|
||||
|
||||
const fetchProfileStats = async () => {
|
||||
try {
|
||||
const [profileRes, ptsRes] = await Promise.all([
|
||||
request.get('/c/profile', { showLoading: false }),
|
||||
request.get('/c/user/revenue-summary', { showLoading: false })
|
||||
])
|
||||
if (profileRes.data) {
|
||||
const d = profileRes.data
|
||||
userInfo.companyName = d.nickname || '用户'
|
||||
userInfo.vipLevel = d.level || 'VIP0'
|
||||
userInfo.userId = d.id ? 'C' + String(d.id).padStart(8, '0') : '--'
|
||||
userInfo.deviceCount = d.device_count ?? '--'
|
||||
// 信用分暂时隐藏
|
||||
// userInfo.creditScore = d.avg_credit_score > 0 ? d.avg_credit_score : '--'
|
||||
userInfo.currentGrowthValue = d.growth_value || 0
|
||||
userInfo.nextVipLevel = d.next_level || 'VIP1'
|
||||
userInfo.nextLevelPoints = d.next_threshold || 0
|
||||
userInfo.displayGrowthValue = userInfo.nextLevelPoints > 0 ? Math.min(userInfo.currentGrowthValue, userInfo.nextLevelPoints) : userInfo.currentGrowthValue
|
||||
userInfo.isMaxLevel = userInfo.nextLevelPoints > 0 && userInfo.currentGrowthValue >= userInfo.nextLevelPoints
|
||||
userInfo.vipProgress = d.vip_progress || 0
|
||||
if (d.nickname) {
|
||||
uni.setStorageSync('userInfo', { ...loginUser, nickname: d.nickname, level: d.level || 'VIP0', id: d.id })
|
||||
}
|
||||
}
|
||||
if (ptsRes.data) {
|
||||
userInfo.totalPoints = ptsRes.data.cumulative_points ?? 0
|
||||
}
|
||||
} catch (e) { console.error('获取我的页面统计失败', e) }
|
||||
}
|
||||
|
||||
onShow(() => { fetchProfileStats() })
|
||||
|
||||
const handleClick = (item) => {
|
||||
if (['my-coupons', 'realname-auth', 'tickets', 'contracts', 'calculator'].includes(item.route)) {
|
||||
uni.showToast({ title: '即将开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
openSub(item.route)
|
||||
}
|
||||
|
||||
// 常用功能 — 对齐 C端.html: 订单/工单/计算器/加成券
|
||||
const quickActions = ref([
|
||||
{ name: '订单', icon: 'shopping-cart', color: '#1677FF', bg: '#E6F4FF', route: 'mall-orders' },
|
||||
{ name: '工单', emoji: '📦', color: '#6B7280', bg: '#F0F2F5', route: 'tickets' },
|
||||
{ name: '计算器', emoji: '🧮', color: '#00C4A7', bg: '#E6FAF5', route: 'calculator' },
|
||||
{ name: '加成券', emoji: '⚡', color: '#FF8800', bg: '#FFF7E6', route: 'my-coupons', badge: '' }
|
||||
])
|
||||
|
||||
// 业务中心 — 对齐 C端.html 6项
|
||||
const businessItems = ref([
|
||||
{ name: '企业信息', desc: '查看和编辑企业资质', icon: 'account', color: '#1677FF', bg: '#E6F4FF', route: 'profile-edit', badge: '待认证', badgeColor: '#FF8800', badgeBg: '#FFF7E6' },
|
||||
{ name: '合同管理', desc: '查看已签署和待签署的合同', icon: 'file-text', color: '#00C4A7', bg: '#E6FAF5', route: 'contracts' },
|
||||
{ name: '推荐有礼', desc: '推荐好友加入,获得推荐激励', icon: 'heart', color: '#FF4D6D', bg: '#FFF1F0', route: 'invitation', badge: '活动', badgeColor: '#FF4D4F', badgeBg: '#FFF1F0' },
|
||||
{ name: '实名认证', desc: '身份证·人脸识别·企业资质', icon: 'lock', color: '#1677FF', bg: '#E6F4FF', route: 'realname-auth', badge: '待认证', badgeColor: '#FF8800', badgeBg: '#FFF7E6' },
|
||||
{ name: '帮助中心', desc: 'FAQ·在线客服·常见问题', icon: 'question-circle', color: '#6B7280', bg: '#F0F2F5', route: 'help-center' },
|
||||
{ name: '收款账户', desc: '管理个人/公户收款账户', emoji: '💳', color: '#00C4A7', bg: '#E6FAF5', route: 'wallet' },
|
||||
{ name: '积分明细', desc: '4类积分收支明细 · 账目透明', icon: 'order', color: '#00C4A7', bg: '#E6FAF5', route: 'points-log' }
|
||||
])
|
||||
|
||||
// 工具与支持 — 对齐 C端.html 2x3=6项
|
||||
const toolItems = ref([
|
||||
{ name: '帮助文档', desc: '教程与FAQ', icon: 'bookmark', color: '#1677FF', bg: '#E6F4FF', route: 'docs' },
|
||||
{ name: '平台软件', desc: 'Agent下载', emoji: '📥', color: '#6B7280', bg: '#F0F2F5', route: 'software' },
|
||||
{ name: '积分计算器', desc: '设备成本参考', emoji: '🧮', color: '#00C4A7', bg: '#E6FAF5', route: 'calculator' },
|
||||
{ name: '拓展伙伴', desc: '申请加入', emoji: '🤝', color: '#FF8800', bg: '#FFF7E6', route: 'partner-apply' },
|
||||
{ name: '自有设备部署', desc: '设备接入', icon: 'server', emoji: '🖥️', color: '#722ED1', bg: '#F9F0FF', route: 'add-own-device' },
|
||||
{ name: '我的加成券', desc: '可用加成券', emoji: '⚡', color: '#FF8800', bg: '#FFF7E6', route: 'my-coupons', badge: '' }
|
||||
])
|
||||
|
||||
const handleLogout = () => {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.settings-btn { width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
// Profile Hero
|
||||
.profile-hero { margin-bottom: 32rpx; }
|
||||
.ph-banner {
|
||||
background: linear-gradient(135deg, #E6F4FF, #F0F7FF);
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 32rpx 24rpx 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ph-avatar-row { display: flex; align-items: center; gap: 20rpx; margin-bottom: 28rpx; }
|
||||
.ph-avatar {
|
||||
width: 88rpx; height: 88rpx; background: linear-gradient(135deg, #1677FF, #4096FF);
|
||||
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 8rpx 32rpx rgba(22, 119, 255, 0.3);
|
||||
}
|
||||
.ph-avatar-text { font-size: 36rpx; font-weight: 700; color: #fff; }
|
||||
|
||||
.ph-info { flex: 1; }
|
||||
.ph-name { font-size: 32rpx; font-weight: 700; color: #0F1226; display: block; }
|
||||
.ph-tags { display: flex; gap: 12rpx; margin-top: 6rpx; align-items: center; }
|
||||
.ph-tag { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; font-weight: 500;
|
||||
&.vip { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; }
|
||||
}
|
||||
.ph-id { font-size: 20rpx; color: #8B91A1; }
|
||||
|
||||
.ph-edit { width: 56rpx; height: 56rpx; background: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
// Stats
|
||||
.ph-stats { display: flex; margin-bottom: 24rpx; }
|
||||
.ph-stat { flex: 1; text-align: center; }
|
||||
.phs-val { font-size: 34rpx; font-weight: 700; color: #0F1226; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block;
|
||||
&.vip { color: #FF8800; }
|
||||
}
|
||||
.phs-label { font-size: 22rpx; color: #8B91A1; display: block; margin-top: 2rpx; }
|
||||
|
||||
// VIP Progress
|
||||
.vip-progress { background: #fff; border-radius: 14rpx; padding: 24rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.vp-header { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
|
||||
.vp-label { font-size: 24rpx; color: #5A6072; }
|
||||
.vp-pct { font-size: 24rpx; color: #1677FF; font-weight: 600; }
|
||||
.vp-bar { height: 10rpx; background: #F0F0F0; border-radius: 5rpx; margin-bottom: 16rpx; }
|
||||
.vp-fill { height: 100%; background: linear-gradient(90deg, #FFD700, #FFA500); border-radius: 5rpx; }
|
||||
.vp-details { display: flex; flex-direction: column; gap: 6rpx; }
|
||||
.vp-detail-text { font-size: 20rpx; color: #8B91A1; display: block;
|
||||
&.green { color: #00C4A7; }
|
||||
}
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 32rpx; }
|
||||
.section-title {
|
||||
font-size: 32rpx; font-weight: 700; color: #0F1226; letter-spacing: 0.6rpx;
|
||||
display: flex; align-items: center; gap: 10rpx; margin-bottom: 20rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
|
||||
// Quick Row
|
||||
.quick-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16rpx; }
|
||||
.quick-cell { display: flex; flex-direction: column; align-items: center; gap: 10rpx; position: relative; }
|
||||
.qc-icon { width: 88rpx; height: 88rpx; border-radius: 24rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.qc-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.qc-label { font-size: 22rpx; color: #0F1226; font-weight: 500; }
|
||||
.qc-badge { position: absolute; top: -4rpx; right: 16rpx; background: #FF4D4F; color: #fff; font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 16rpx; font-weight: 600; }
|
||||
|
||||
// List Group
|
||||
.list-group { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.list-item {
|
||||
display: flex; align-items: center; padding: 24rpx; gap: 16rpx; border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
.li-icon { width: 68rpx; height: 68rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.li-emoji { font-size: 36rpx; line-height: 1; }
|
||||
.li-body { flex: 1; min-width: 0; }
|
||||
.li-title { font-size: 28rpx; font-weight: 500; color: #0F1226; display: block; }
|
||||
.li-sub { font-size: 22rpx; color: #8B91A1; margin-top: 2rpx; display: block; }
|
||||
.li-right { display: flex; align-items: center; gap: 12rpx; }
|
||||
.li-badge { font-size: 20rpx; padding: 2rpx 12rpx; border-radius: 8rpx; }
|
||||
|
||||
// Tool Grid
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16rpx; }
|
||||
.tool-item {
|
||||
background: #fff; border-radius: 14rpx; padding: 24rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
position: relative;
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
.ti-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 16rpx; }
|
||||
.ti-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.ti-body { position: relative; }
|
||||
.ti-name { font-size: 26rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.ti-desc { font-size: 20rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
.ti-badge { position: absolute; top: -8rpx; right: 0; background: #FF4D4F; color: #fff; font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 16rpx; font-weight: 600; }
|
||||
|
||||
// Bottom
|
||||
.bottom-links { display: flex; justify-content: center; gap: 32rpx; padding: 32rpx 0; }
|
||||
.bottom-link { font-size: 22rpx; color: #8B91A1; }
|
||||
|
||||
.logout-btn {
|
||||
margin-top: 40rpx;
|
||||
padding: 26rpx;
|
||||
text-align: center;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #FF4D4F;
|
||||
color: #FF4D4F;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
background: #fff;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:active { background: #FFF1F0; opacity: 0.85; }
|
||||
|
||||
.safe-bottom-spacer {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<!--
|
||||
* 积分中心页 — 对齐 C端.html #page-revenue
|
||||
* Hero + 积分分类 + 快捷入口
|
||||
-->
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<nav-bar title="积分中心" :show-back="false" />
|
||||
|
||||
<scroll-view scroll-y class="page-content" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
|
||||
<!-- Hero卡片(rev-hero深蓝渐变) -->
|
||||
<view class="rev-hero">
|
||||
<text class="rh-label">可用兑换积分</text>
|
||||
<text class="rh-amount">¥{{ formatThousands(withdrawablePoints) }}</text>
|
||||
<view class="rh-btns">
|
||||
<view class="rh-btn" @click="openSub('withdraw')">兑换</view>
|
||||
<view class="rh-btn secondary" @click="openSub('points-log')">积分明细</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 积分状态总览 -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">积分状态总览</text>
|
||||
<text class="section-more" @click="openSub('points-log')">查看明细 ›</text>
|
||||
</view>
|
||||
<view class="points-row">
|
||||
<view class="point-card" v-for="(item, i) in pointsList" :key="i" :style="{ background: pointStyles[i].background }">
|
||||
<text class="pc-num" :style="{ color: pointStyles[i].color }">¥{{ item.amount }}</text>
|
||||
<text class="pc-label">{{ item.label }}</text>
|
||||
<text class="pc-sub" :style="{ color: pointStyles[i].color }">{{ item.sub }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益监控网格 -->
|
||||
<view class="section">
|
||||
<text class="section-title">收益监控</text>
|
||||
<view class="monitor-grid">
|
||||
<view class="monitor-card" v-for="(item, i) in monitorList" :key="i">
|
||||
<text class="mc-title">{{ item.label }}</text>
|
||||
<text class="mc-val" :style="{ color: i === 0 ? '#1677FF' : '#0F1226' }">¥{{ item.value }}</text>
|
||||
<view v-if="item.trend !== undefined && item.trend !== null" class="mc-trend" :class="item.trend > 0 ? 'up' : item.trend < 0 ? 'down' : ''">
|
||||
<text>{{ item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : '' }} {{ Math.abs(item.trend) }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<view class="section">
|
||||
<text class="section-title">快捷功能</text>
|
||||
<view class="entry-cards">
|
||||
<view class="entry-card" v-for="(item, i) in entryList" :key="i" @click="handleEntry(item)">
|
||||
<view class="ec-icon" :style="{ background: item.bg }">
|
||||
<text v-if="item.emoji" class="ec-emoji">{{ item.emoji }}</text>
|
||||
<u-icon v-else :name="item.icon" size="22" :color="item.color"></u-icon>
|
||||
</view>
|
||||
<view class="ec-body">
|
||||
<text class="ec-title">{{ item.title }}</text>
|
||||
<text class="ec-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 安全底部距离占位,避免被底部导航遮挡 -->
|
||||
<view class="safe-bottom-spacer"></view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
<custom-tab-bar :current="2"></custom-tab-bar>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
|
||||
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue'
|
||||
|
||||
|
||||
import { formatThousands } from '@/utils/util'
|
||||
import { openSub } from '@/composables/useNavigation'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const refreshing = ref(false)
|
||||
const withdrawablePoints = ref(0)
|
||||
const pointsList = ref([])
|
||||
const monitorList = ref([])
|
||||
const pointStyles = [
|
||||
{ color: '#00C4A7', background: 'linear-gradient(135deg, #E6FAF5, #F0FBF8)' },
|
||||
{ color: '#FF8800', background: 'linear-gradient(135deg, #FFF7E6, #FFFBF0)' },
|
||||
{ color: '#FF4D4F', background: 'linear-gradient(135deg, #FFF1F0, #FFF5F4)' },
|
||||
{ color: '#6B7280', background: 'linear-gradient(135deg, #F0F2F5, #F5F7FA)' }
|
||||
]
|
||||
|
||||
const entryList = ref([
|
||||
{ route: 'calculator', title: '积分计算器', desc: '预估设备成本与收益', bg: '#f9f0ff', icon: 'calculator', emoji: '🧮', color: '#722ED1' },
|
||||
{ route: 'online-reward', title: '在线激励', desc: '本周在线达标·领取奖励', bg: '#fff7e6', icon: 'clock', color: '#FF8800' },
|
||||
{ route: 'leaderboard', title: '活跃排行', desc: '查看你的排名', bg: '#fff1f0', icon: 'trophy', emoji: '🏆', color: '#FF4D4F' },
|
||||
{ route: 'points-log', title: '积分概览', desc: '积分明细与趋势分析', bg: '#e6f4ff', icon: 'chart', emoji: '📊', color: '#1677FF' }
|
||||
])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await request.get('/c/user/revenue-summary', { showLoading: false })
|
||||
const d = res.data
|
||||
withdrawablePoints.value = d.withdrawable_points || 0
|
||||
pointsList.value = d.points_breakdown || []
|
||||
monitorList.value = d.monitor || []
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
|
||||
const handleEntry = (item) => {
|
||||
if (item.route === 'calculator') {
|
||||
uni.showToast({ title: '即将开放', icon: 'none' })
|
||||
return
|
||||
}
|
||||
openSub(item.route)
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
refreshing.value = true
|
||||
await fetchData()
|
||||
refreshing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #F2F4F8;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
// Hero (rev-hero) — 对齐 B端 b-revenue
|
||||
.rev-hero {
|
||||
background: linear-gradient(135deg, #0A1733 0%, #0D2B6E 50%, #1677FF 100%);
|
||||
border-radius: 28rpx; padding: 40rpx 32rpx; color: #fff; margin-bottom: 24rpx;
|
||||
position: relative; overflow: hidden; box-shadow: 0 16rpx 48rpx rgba(13, 43, 110, 0.25);
|
||||
&::after { content: ''; position: absolute; top: -60rpx; right: -40rpx; width: 280rpx; height: 280rpx; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.1), transparent 65%); }
|
||||
&::before { content: ''; position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.03) 1px, transparent 1px); background-size: 48rpx 48rpx; mask-image: linear-gradient(180deg, #000, transparent); pointer-events: none; }
|
||||
}
|
||||
.rh-label { font-size: 24rpx; opacity: 0.65; position: relative; z-index: 1; display: block; }
|
||||
.rh-amount { font-size: 68rpx; font-weight: 800; margin: 8rpx 0 28rpx; font-family: 'DIN Alternate', -apple-system, sans-serif; position: relative; z-index: 1; display: block; color: #fff; }
|
||||
.rh-btns { display: flex; gap: 16rpx; position: relative; z-index: 1; }
|
||||
.rh-btn { padding: 12rpx 28rpx; border-radius: 36rpx; font-size: 24rpx; font-weight: 600; }
|
||||
.rh-btn:first-child { background: rgba(255,255,255,.18); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.25); }
|
||||
.rh-btn.secondary { background: rgba(255,255,255,.1); color: #fff; backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,.15); }
|
||||
|
||||
// Section
|
||||
.section { margin-bottom: 32rpx; }
|
||||
.section-title {
|
||||
font-size: 32rpx; font-weight: 700; color: #0F1226; letter-spacing: 0.6rpx;
|
||||
display: flex; align-items: center; gap: 10rpx; margin-bottom: 20rpx;
|
||||
&::before { content: ''; width: 6rpx; height: 28rpx; background: linear-gradient(180deg, #1677FF, #4096FF); border-radius: 3rpx; }
|
||||
}
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
|
||||
.section-more { font-size: 24rpx; color: #8B91A1; }
|
||||
|
||||
// Points Row (4 fixed columns)
|
||||
.points-row { display: flex; gap: 12rpx; }
|
||||
.point-card {
|
||||
flex: 1; min-width: 0; border-radius: 10rpx; padding: 20rpx 8rpx; text-align: center;
|
||||
}
|
||||
.pc-num { font-size: 32rpx; font-weight: 800; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; }
|
||||
.pc-label { font-size: 20rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
.pc-sub { font-size: 18rpx; margin-top: 4rpx; display: block; font-weight: 500; }
|
||||
|
||||
// Monitor Grid
|
||||
.monitor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; }
|
||||
.monitor-card {
|
||||
background: #fff; border-radius: 14rpx; padding: 28rpx; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04);
|
||||
text-align: center; position: relative; overflow: hidden;
|
||||
&::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4rpx; background: linear-gradient(90deg, transparent, #1677FF, transparent); opacity: 0.4; }
|
||||
}
|
||||
.mc-title { font-size: 22rpx; color: #8B91A1; display: block; margin-bottom: 8rpx; }
|
||||
.mc-val { font-size: 48rpx; font-weight: 700; font-family: 'DIN Alternate', -apple-system, sans-serif; display: block; letter-spacing: -1rpx; }
|
||||
.mc-trend { font-size: 20rpx; margin-top: 6rpx;
|
||||
&.up { color: #00C4A7; }
|
||||
&.down { color: #FF4D4F; }
|
||||
}
|
||||
|
||||
// Entry Cards
|
||||
.entry-cards { background: #fff; border-radius: 14rpx; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 18, 38, 0.04); }
|
||||
.entry-card {
|
||||
display: flex; align-items: center; padding: 28rpx 24rpx; gap: 24rpx; border-bottom: 0.5px solid #F3F5F9;
|
||||
&:last-child { border-bottom: none; }
|
||||
&:active { background: #F3F5F9; }
|
||||
}
|
||||
.ec-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.ec-emoji { font-size: 40rpx; line-height: 1; }
|
||||
.ec-body { flex: 1; }
|
||||
.ec-title { font-size: 28rpx; font-weight: 600; color: #0F1226; display: block; }
|
||||
.ec-desc { font-size: 22rpx; color: #8B91A1; margin-top: 4rpx; display: block; }
|
||||
|
||||
.safe-bottom-spacer {height: calc(140rpx + env(safe-area-inset-bottom));}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
# 图片资源目录
|
||||
|
||||
存放以下图片资源:
|
||||
- logo.png - 平台logo(80x80px)
|
||||
- 各种图标、默认占位图
|
||||
- 运营海报、商品图片等
|
||||
|
||||
当前占位使用uView自带图标,后续替换为实际设计资源。
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 140">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1677ff"/>
|
||||
<stop offset="100%" style="stop-color:#4096FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="140" height="140" rx="28" fill="url(#bg)"/>
|
||||
<text x="70" y="78" text-anchor="middle" font-size="36" font-weight="700" fill="#fff" font-family="sans-serif">全犀</text>
|
||||
<rect x="42" y="90" width="56" height="4" rx="2" fill="rgba(255,255,255,.5)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 551 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user