first commit

This commit is contained in:
2026-09-16 15:04:08 +08:00
commit 43d095ea0f
117 changed files with 31982 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
unpackage/
dist/
.hbuilderx/
.DS_Store
*.log
*.tmp
.idea/
.vscode/
+1
View File
@@ -0,0 +1 @@
18
+18
View File
@@ -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
View File
@@ -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>
+5385
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
View File
@@ -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>
<!-- 各页面自行渲染 CustomTabBarApp 不重复渲染 -->
</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>
+124
View File
@@ -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>
+163
View File
@@ -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>
+245
View File
@@ -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>
+31
View File
@@ -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>
+55
View File
@@ -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>
+160
View File
@@ -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>
+135
View File
@@ -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>
+102
View File
@@ -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>
+172
View File
@@ -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
View File
@@ -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
}
}
+69
View File
@@ -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"
}
+347
View File
@@ -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>
+376
View File
@@ -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>
+228
View File
@@ -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>
+356
View File
@@ -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>
+601
View File
@@ -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>
+488
View File
@@ -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>
+228
View File
@@ -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>
+611
View File
@@ -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>
+193
View File
@@ -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>
+149
View File
@@ -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">&#128269;</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>
+133
View File
@@ -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>
+200
View File
@@ -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>
+177
View File
@@ -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>
+214
View File
@@ -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>
+191
View File
@@ -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>
+526
View File
@@ -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">规则: 232字符支持中文字母数字_禁止空格及其它特殊符号</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>
+185
View File
@@ -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>
+197
View File
@@ -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>
+144
View File
@@ -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">&#10003;</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>
+188
View File
@@ -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>
+93
View File
@@ -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>
+139
View File
@@ -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>
+277
View File
@@ -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('方案BAgent生成)')
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' ? '方案BAgent生成)' : '平台设备'
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>
+176
View File
@@ -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>
+497
View File
@@ -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>
+126
View File
@@ -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>
+289
View File
@@ -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">&#10003;</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>
+198
View File
@@ -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">&#128230;</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>
+396
View File
@@ -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>
+203
View File
@@ -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>
+282
View File
@@ -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>
+222
View File
@@ -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>
+109
View File
@@ -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>
+506
View File
@@ -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>
+270
View File
@@ -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>
+269
View File
@@ -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>
+120
View File
@@ -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>
+658
View File
@@ -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>
+132
View File
@@ -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>
+188
View File
@@ -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>
+109
View File
@@ -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>
+330
View File
@@ -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>
+442
View File
@@ -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>
+246
View File
@@ -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>
+189
View File
@@ -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>
+229
View File
@@ -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">&#128269;</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>
+244
View File
@@ -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>
+161
View File
@@ -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>
+327
View File
@@ -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>
+291
View File
@@ -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>
+118
View File
@@ -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>
+191
View File
@@ -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>
+225
View File
@@ -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>
+139
View File
@@ -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">&#10003;</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>
+180
View File
@@ -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>
+164
View File
@@ -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>
+205
View File
@@ -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>
+139
View File
@@ -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>
+136
View File
@@ -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
View File
@@ -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": "我的" }
]
}
}
+536
View File
@@ -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>
+590
View File
@@ -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>
+511
View File
@@ -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>
+433
View File
@@ -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>
+329
View File
@@ -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>
+562
View File
@@ -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>
+402
View File
@@ -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>
+523
View File
@@ -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>
+516
View File
@@ -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>
+319
View File
@@ -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>
+431
View File
@@ -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>
+345
View File
@@ -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>
+259
View File
@@ -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>
+723
View File
@@ -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>
+533
View File
@@ -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>
+410
View File
@@ -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>
+787
View File
@@ -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>
+431
View File
@@ -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>
+213
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
# 图片资源目录
存放以下图片资源:
- logo.png - 平台logo80x80px
- 各种图标、默认占位图
- 运营海报、商品图片等
当前占位使用uView自带图标,后续替换为实际设计资源。
+11
View File
@@ -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