mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp.git
synced 2026-09-20 11:52:02 +08:00
feat(distribution): 增强分销功能与界面优化
- 新增银行卡管理页面,支持用户查看与管理银行卡。 - 实现添加、删除及设置默认银行卡功能。 - 优化邀请与加入页面,展示邀请人信息并改进用户提示。 - 更新海报与二维码生成逻辑,提升用户体验。 - 重构多个组件,提高可读性与可维护性。
This commit is contained in:
2
App.vue
2
App.vue
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/utils/theme'
|
} from '@/utils/theme'
|
||||||
import { onLaunch, onShow } from '@dcloudio/uni-app'
|
import { onLaunch, onShow } from '@dcloudio/uni-app'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
|
import { handleAppLaunchScene } from '@/utils/distributionBind.js'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ wx.onAppRoute(() => {})
|
|||||||
|
|
||||||
onLaunch((val) => {
|
onLaunch((val) => {
|
||||||
initTheme()
|
initTheme()
|
||||||
|
handleAppLaunchScene(val)
|
||||||
if (val?.query?.inviter) {
|
if (val?.query?.inviter) {
|
||||||
storage.setInviter(val.query.inviter)
|
storage.setInviter(val.query.inviter)
|
||||||
}
|
}
|
||||||
|
|||||||
76
README.md
76
README.md
@@ -1,34 +1,15 @@
|
|||||||
# Lilishop(PickMall) 开源商城系统
|
# Lilishop(PickMall) 开源商城系统
|
||||||
|
|
||||||
[](https://github.com/hongyehuicheng/lilishop)
|
[](https://github.com/lilishop/lilishop)
|
||||||
[](https://gitee.com/beijing_hongye_huicheng/lilishop)
|
[](https://gitee.com/beijing_hongye_huicheng/lilishop)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://vuejs.org/)
|
||||||
|
[](https://uniapp.dcloud.net.cn/)
|
||||||
|
|
||||||
<p align="right">
|
<p align="right">
|
||||||
简体中文 | <a href="./README.en.md">English</a>
|
简体中文 | <a href="./README.en.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## All-In-One 一键启动
|
|
||||||
|
|
||||||
如果只是想快速体验完整商城,不需要分别部署后端、PC、商家端、运营端、IM Web、H5、MySQL 和 Redis。安装 Docker Desktop 后,可以直接下载 `docker` 仓库 `allinone-lite-mysql-redis` 分支的一键安装脚本,按提示输入 IP/域名、端口、密码和数据目录,即可启动单镜像单容器体验环境。
|
|
||||||
|
|
||||||
macOS / Linux:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://gitee.com/beijing_hongye_huicheng/docker/raw/allinone-lite-mysql-redis/install/install-lilishop.sh -o install-lilishop.sh
|
|
||||||
chmod +x install-lilishop.sh
|
|
||||||
./install-lilishop.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows PowerShell:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Invoke-WebRequest -Uri "https://gitee.com/beijing_hongye_huicheng/docker/raw/allinone-lite-mysql-redis/install/install-lilishop.ps1" -OutFile "install-lilishop.ps1"
|
|
||||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
|
||||||
.\install-lilishop.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
默认镜像:`ccr.ccs.tencentyun.com/lilishop/lilishop-all-in-one:lite`。启动后可访问买家 PC、商家端、运营端、IM Web 会话/消息页、uniapp H5 和后端 API。更多说明见 [docker/all-in-one 文档](https://gitee.com/beijing_hongye_huicheng/docker/tree/allinone-lite-mysql-redis/all-in-one)。
|
|
||||||
|
|
||||||
All-In-One `lite` 是本地体验和演示形态:一个容器内包含应用、前端资源、H5、MariaDB、Redis 和本地文件存储。它不是生产高可用架构,也不内置 Elasticsearch、RocketMQ、XXL-Job、Kibana、Logstash 或 RocketMQ Dashboard;搜索、消息和定时任务逻辑在 lite 环境中使用 MySQL / Redis / 本地调度兼容实现。
|
|
||||||
|
|
||||||
LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开源商城系统,支持 B2B2C 多商户商城、小程序商城、微服务商城、直播电商、分销返佣、秒杀活动、Docker 私有化部署。
|
LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开源商城系统,支持 B2B2C 多商户商城、小程序商城、微服务商城、直播电商、分销返佣、秒杀活动、Docker 私有化部署。
|
||||||
|
|
||||||
@@ -36,7 +17,7 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开
|
|||||||
|
|
||||||
- Java商城系统
|
- Java商城系统
|
||||||
- 开源商城系统
|
- 开源商城系统
|
||||||
- SpringBoot商城系统
|
- Spring Boot 4商城系统
|
||||||
- SpringCloud微服务商城
|
- SpringCloud微服务商城
|
||||||
- B2B2C商城源码
|
- B2B2C商城源码
|
||||||
- 多商户商城系统
|
- 多商户商城系统
|
||||||
@@ -58,14 +39,15 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开
|
|||||||
### 1. 项目简介
|
### 1. 项目简介
|
||||||
|
|
||||||
**Lilishop** 是一款功能完善的B2B2C多商户商城系统,采用前后端分离架构,全端代码开源。
|
**Lilishop** 是一款功能完善的B2B2C多商户商城系统,采用前后端分离架构,全端代码开源。
|
||||||
后端基于 **SpringBoot4** 构建,具备高内聚、低耦合的特性,支持分布式部署。
|
后端基于 **Spring Boot 4**(Java 21)构建,具备高内聚、低耦合的特性,支持分布式部署。
|
||||||
前端覆盖PC、H5、小程序和APP,基于 **Vue** 和 **uni-app** 开发。
|
前端覆盖PC、H5、小程序和APP,基于 **Vue** 和 **uni-app** 开发。
|
||||||
|
|
||||||
- **官方网站**: <https://pickmall.cn>
|
- **官方网站**: <https://pickmall.cn>
|
||||||
- **官方文档**: <https://docs.pickmall.cn>
|
- **官方文档**: <https://docs.pickmall.cn>
|
||||||
- **Gitee 仓库**: <https://gitee.com/beijing_hongye_huicheng/lilishop>
|
- **Gitee 仓库**: <https://gitee.com/beijing_hongye_huicheng/lilishop-uniapp>
|
||||||
- **GitHub 仓库**: <https://github.com/lilishop/lilishop>
|
- **GitHub 仓库**: <https://github.com/lilishop/lilishop>
|
||||||
|
|
||||||
|
本仓库为买家端 **H5 / 小程序 / APP** 源码(`lilishop-uniapp`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,7 +85,7 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开
|
|||||||
[**部署文档 -> 环境准备**](https://docs.pickmall.cn/deply/deply.html)
|
[**部署文档 -> 环境准备**](https://docs.pickmall.cn/deply/deply.html)
|
||||||
|
|
||||||
#### 数据库初始化
|
#### 数据库初始化
|
||||||
- **推荐方式**: 使用项目提供的 `docker-compose` 配置,可自动完成数据库(MySQL, Redis, Elasticsearch等)的部署与初始化。
|
- **推荐方式**: 使用项目提供的 `docker-compose` 配置,可自动完成数据库(MySQL、Redis 等)的部署与初始化。
|
||||||
- **手动方式**: 如果您选择手动部署,SQL脚本位于以下地址。请确保获取与您代码版本一致的SQL文件。
|
- **手动方式**: 如果您选择手动部署,SQL脚本位于以下地址。请确保获取与您代码版本一致的SQL文件。
|
||||||
[**数据库脚本 (Gitee)**](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql)
|
[**数据库脚本 (Gitee)**](https://gitee.com/beijing_hongye_huicheng/docker/tree/master/init/mysql)
|
||||||
|
|
||||||
@@ -120,15 +102,13 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开
|
|||||||
- H5 / 小程序 / APP:`lilishop-uniapp`
|
- H5 / 小程序 / APP:`lilishop-uniapp`
|
||||||
- 部署、SQL、镜像与启动脚本:`docker`
|
- 部署、SQL、镜像与启动脚本:`docker`
|
||||||
|
|
||||||
All-In-One 仅用于本地体验、演示或验证,不代表生产高可用部署架构。生产部署应按实际访问量、稳定性、监控、备份和扩缩容要求拆分数据库、缓存、搜索、消息、任务调度、前端静态资源和后端服务。
|
---
|
||||||
|
|
||||||
### 6. 功能列表
|
### 6. 功能列表
|
||||||
|
|
||||||
[功能清单](https://bdwx2tfwwt.feishu.cn/sheets/FFQKscQKDhUx60to1k9cbMngnYb?from=from_copylink)
|
[功能清单](https://bdwx2tfwwt.feishu.cn/sheets/FFQKscQKDhUx60to1k9cbMngnYb?from=from_copylink)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### 7. 开源与授权
|
### 7. 开源与授权
|
||||||
|
|
||||||
@@ -145,6 +125,34 @@ All-In-One 仅用于本地体验、演示或验证,不代表生产高可用部
|
|||||||
我们欢迎任何形式的交流与贡献。在提问前,请先查阅 [官方文档](https://docs.pickmall.cn/) ,并参考 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md) 以便高效沟通。
|
我们欢迎任何形式的交流与贡献。在提问前,请先查阅 [官方文档](https://docs.pickmall.cn/) ,并参考 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/master/README-zh_CN.md) 以便高效沟通。
|
||||||
|
|
||||||
- **[在线客服](https://work.weixin.qq.com/kfid/kfc4d8dc24a73c15f44)**
|
- **[在线客服](https://work.weixin.qq.com/kfid/kfc4d8dc24a73c15f44)**
|
||||||
- **微信交流1群(已满)**
|
- **微信交流群**:
|
||||||
- **微信交流2群**:
|
|
||||||

|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 升级计划
|
||||||
|
|
||||||
|
以下功能将陆续在后续版本中发布:✅ 已完成 · 🔜 规划中
|
||||||
|
|
||||||
|
- ✅ 商品定时上下架
|
||||||
|
- ✅ 商品分组
|
||||||
|
- ✅ 会员分组
|
||||||
|
- ✅ 商品虚拟销量
|
||||||
|
- ✅ 专用发票(发票上传)
|
||||||
|
- ✅ 置顶评论
|
||||||
|
- ✅ 客户等级
|
||||||
|
- ✅ 购物卡
|
||||||
|
- ✅ 腾讯云-云直播
|
||||||
|
- ✅ 统计:营业概况、商品概况、会员概况、积分分析、储值分析、营销概况、分销统计
|
||||||
|
- ✅ 经营报表(店铺业绩报表、商品同比环比报表、销售订单明细报表、商品销售汇总报表)
|
||||||
|
- ✅ 卡密商品
|
||||||
|
- ✅ 二级分销
|
||||||
|
- ✅ 定时任务管理
|
||||||
|
- 🔜 主题色管理
|
||||||
|
- 🔜 店铺结算汇总
|
||||||
|
- 🔜 支付方式汇总
|
||||||
|
- 🔜 结算台账
|
||||||
|
- 🔜 钱包流水
|
||||||
|
- 🔜 财务汇总
|
||||||
|
- 🔜 微信小店对接
|
||||||
|
- 🔜 抖店对接
|
||||||
|
|||||||
@@ -189,6 +189,15 @@ export function getRecruitPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 招募页邀请人信息(无需登录) */
|
||||||
|
export function getRecruitInviter(distributionId) {
|
||||||
|
return http.request({
|
||||||
|
url: `/distribution/distribution/recruit/inviter`,
|
||||||
|
method: Method.GET,
|
||||||
|
params: { distributionId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 提交招募申请 */
|
/** 提交招募申请 */
|
||||||
export function submitRecruitApplication(data) {
|
export function submitRecruitApplication(data) {
|
||||||
return http.request({
|
return http.request({
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ const buy = (data: any) => {
|
|||||||
url: `/pages/order/fillorder?way=${data.cartType}&addr=${''}&parentOrder=${encodeURIComponent(JSON.stringify(parentOrder.value))}`
|
url: `/pages/order/fillorder?way=${data.cartType}&addr=${''}&parentOrder=${encodeURIComponent(JSON.stringify(parentOrder.value))}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
uni.hideLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
382
pages/mine/distribution/bank-accounts.vue
Normal file
382
pages/mine/distribution/bank-accounts.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="themeStyle">
|
||||||
|
<view class="content">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-if="loading" class="state-wrap">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 账户列表 -->
|
||||||
|
<view v-else-if="accounts.length" class="account-list">
|
||||||
|
<view
|
||||||
|
class="account-item"
|
||||||
|
v-for="item in accounts"
|
||||||
|
:key="item.id"
|
||||||
|
@click="handleSelect(item)"
|
||||||
|
>
|
||||||
|
<view class="item-left">
|
||||||
|
<view v-if="isSelectMode" class="radio-circle" :class="{ active: item.id === selectedId }">
|
||||||
|
<view class="radio-dot" v-if="item.id === selectedId" />
|
||||||
|
</view>
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-name">{{ item.holderName }}</text>
|
||||||
|
<view class="item-meta">
|
||||||
|
<text class="item-bank">{{ item.bankName }}</text>
|
||||||
|
<text class="item-card">{{ maskCard(item.cardNo) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isSelectMode && item.isDefault" class="default-tag">默认</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-actions">
|
||||||
|
<view v-if="!isSelectMode && !item.isDefault" class="set-default-btn" @click.stop="handleSetDefault(item.id)">
|
||||||
|
<text class="set-default-text">设为默认</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-delete" @click.stop="handleDelete(item.id)">
|
||||||
|
<u-icon name="trash" color="#ccc" size="18" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else class="empty-wrap">
|
||||||
|
<text class="empty-text">暂无银行卡,请添加</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加账户按钮 -->
|
||||||
|
<view class="add-btn-wrap">
|
||||||
|
<view class="add-btn" @click="openAddPopup">+ 添加账户</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加账户 Popup -->
|
||||||
|
<u-popup v-model:show="addPopupVisible" mode="bottom" round="16">
|
||||||
|
<view class="popup-wrap">
|
||||||
|
<view class="popup-title">添加到账账户</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">姓名</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.holderName"
|
||||||
|
placeholder="请输入收款人姓名"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-divider" />
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">银行</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.bankName"
|
||||||
|
placeholder="如:中国工商银行"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-divider" />
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">卡号</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.cardNo"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入银行卡号"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="popup-actions">
|
||||||
|
<view class="popup-btn ghost" @click="addPopupVisible = false">取消</view>
|
||||||
|
<view class="popup-btn primary" :class="{ disabled: saveLoading }" @click="saveAccount">
|
||||||
|
{{ saveLoading ? '保存中...' : '保存' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</u-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
|
import { getBankCards, addBankCard, deleteBankCard, setDefaultBankCard } from '@/api/distribution'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
|
||||||
|
// 从 getStorageSync 读取上次选择的默认卡 id(仅选择模式使用)
|
||||||
|
const SELECTED_KEY = 'dist_selected_account_id'
|
||||||
|
|
||||||
|
const isSelectMode = ref(false)
|
||||||
|
|
||||||
|
const accounts = ref<any[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const addPopupVisible = ref(false)
|
||||||
|
const saveLoading = ref(false)
|
||||||
|
const form = ref({ holderName: '', bankName: '', cardNo: '' })
|
||||||
|
|
||||||
|
onLoad((options: any) => {
|
||||||
|
isSelectMode.value = options?.mode === 'select'
|
||||||
|
uni.setNavigationBarTitle({ title: isSelectMode.value ? '选择账户' : '到账账户' })
|
||||||
|
if (isSelectMode.value) {
|
||||||
|
selectedId.value = uni.getStorageSync(SELECTED_KEY) || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadAccounts()
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadAccounts() {
|
||||||
|
loading.value = true
|
||||||
|
getBankCards()
|
||||||
|
.then((res: any) => {
|
||||||
|
accounts.value = res?.data?.result || []
|
||||||
|
// 如果选择模式没有已选卡,默认选中 isDefault=true 的卡
|
||||||
|
if (isSelectMode.value && !selectedId.value && accounts.value.length) {
|
||||||
|
const def = accounts.value.find((a: any) => a.isDefault)
|
||||||
|
selectedId.value = def ? def.id : accounts.value[0].id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(item: any) {
|
||||||
|
if (!isSelectMode.value) return
|
||||||
|
selectedId.value = item.id
|
||||||
|
uni.setStorageSync(SELECTED_KEY, item.id)
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除账户',
|
||||||
|
content: '确认删除该银行卡?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
deleteBankCard(id)
|
||||||
|
.then(() => {
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
loadAccounts()
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg = err?.data?.message || '删除失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetDefault(id: string) {
|
||||||
|
setDefaultBankCard(id)
|
||||||
|
.then(() => {
|
||||||
|
uni.showToast({ title: '已设为默认', icon: 'success' })
|
||||||
|
loadAccounts()
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg = err?.data?.message || '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddPopup() {
|
||||||
|
form.value = { holderName: '', bankName: '', cardNo: '' }
|
||||||
|
addPopupVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAccount() {
|
||||||
|
if (!form.value.holderName.trim()) {
|
||||||
|
uni.showToast({ title: '请输入姓名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.bankName.trim()) {
|
||||||
|
uni.showToast({ title: '请输入银行名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.cardNo.trim().length === 0) {
|
||||||
|
uni.showToast({ title: '请输入银行卡号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveLoading.value = true
|
||||||
|
addBankCard({
|
||||||
|
holderName: form.value.holderName.trim(),
|
||||||
|
bankName: form.value.bankName.trim(),
|
||||||
|
cardNo: form.value.cardNo.trim(),
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
const newCard = res?.data?.result
|
||||||
|
addPopupVisible.value = false
|
||||||
|
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||||
|
loadAccounts()
|
||||||
|
// 如果是选择模式,自动选中刚添加的卡
|
||||||
|
if (isSelectMode.value && newCard?.id) {
|
||||||
|
selectedId.value = newCard.id
|
||||||
|
uni.setStorageSync(SELECTED_KEY, newCard.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg = err?.data?.message || '添加失败,请重试'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
saveLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskCard(cardNo: string) {
|
||||||
|
if (!cardNo || cardNo.length < 4) return cardNo
|
||||||
|
return '****' + cardNo.slice(-4)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content { padding: 24rpx; }
|
||||||
|
|
||||||
|
/* ── 状态 ──────────────────────────────────── */
|
||||||
|
.state-wrap {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.state-text { color: #999; font-size: 26rpx; }
|
||||||
|
|
||||||
|
/* ── 账户列表 ──────────────────────────────── */
|
||||||
|
.account-list { margin-bottom: 24rpx; }
|
||||||
|
|
||||||
|
.account-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-circle {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&.active { border-color: var(--theme-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-dot {
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name { font-size: 28rpx; font-weight: 500; color: #333; }
|
||||||
|
|
||||||
|
.item-meta { display: flex; align-items: center; gap: 12rpx; }
|
||||||
|
|
||||||
|
.item-bank { font-size: 24rpx; color: #666; }
|
||||||
|
.item-card { font-size: 24rpx; color: #999; }
|
||||||
|
|
||||||
|
.default-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: var(--theme-primary);
|
||||||
|
border: 1rpx solid var(--theme-primary);
|
||||||
|
border-radius: 6rpx;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-default-btn { padding: 4rpx 0; }
|
||||||
|
.set-default-text { font-size: 22rpx; color: #999; }
|
||||||
|
|
||||||
|
.item-delete { padding: 10rpx; }
|
||||||
|
|
||||||
|
/* ── 空状态 ────────────────────────────────── */
|
||||||
|
.empty-wrap { padding: 120rpx 0; text-align: center; }
|
||||||
|
.empty-text { font-size: 26rpx; color: #ccc; }
|
||||||
|
|
||||||
|
/* ── 添加按钮 ──────────────────────────────── */
|
||||||
|
.add-btn-wrap { padding-bottom: 40rpx; }
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 2rpx solid var(--theme-primary);
|
||||||
|
color: var(--theme-primary);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 弹窗 ──────────────────────────────────── */
|
||||||
|
.popup-wrap { padding: 32rpx 28rpx 48rpx; }
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item { display: flex; align-items: center; padding: 18rpx 0; }
|
||||||
|
|
||||||
|
.form-label { font-size: 28rpx; color: #333; width: 100rpx; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.form-input { flex: 1; font-size: 28rpx; color: #333; height: 64rpx; }
|
||||||
|
|
||||||
|
.form-placeholder { color: #ccc; font-size: 26rpx; }
|
||||||
|
|
||||||
|
.form-divider { height: 1rpx; background: #f5f5f5; }
|
||||||
|
|
||||||
|
.popup-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||||
|
|
||||||
|
.popup-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn.ghost { color: #666; background: #f5f5f5; }
|
||||||
|
.popup-btn.primary { color: #fff; background: var(--theme-primary); }
|
||||||
|
.popup-btn.disabled { opacity: 0.5; }
|
||||||
|
</style>
|
||||||
@@ -4,32 +4,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useStore } from '@/store'
|
import {
|
||||||
import storage from '@/utils/storage'
|
resolveDistributionIdFromScene,
|
||||||
import { getGoodsDistribution } from '@/api/distribution'
|
tryBindDistribution,
|
||||||
|
} from '@/utils/distributionBind.js'
|
||||||
const store = useStore()
|
|
||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
let distributionId = (options?.distributionId as string) || ''
|
let distributionId = (options?.distributionId as string) || ''
|
||||||
|
|
||||||
if (options?.scene) {
|
if (options?.scene) {
|
||||||
const scene = decodeURIComponent(String(options.scene))
|
distributionId = await resolveDistributionIdFromScene(String(options.scene))
|
||||||
const parts = scene.split(',')
|
|
||||||
if (parts[0] === 'bind' && parts[1]) {
|
|
||||||
distributionId = parts[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distributionId) {
|
if (distributionId) {
|
||||||
store.state.distributionId = distributionId
|
await tryBindDistribution(distributionId)
|
||||||
if (storage.getAccessToken()) {
|
|
||||||
try {
|
|
||||||
await getGoodsDistribution(distributionId)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('bind distribution failed', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uni.switchTab({ url: '/pages/tabbar/home/index' })
|
uni.switchTab({ url: '/pages/tabbar/home/index' })
|
||||||
|
|||||||
204
pages/mine/distribution/cash-history.vue
Normal file
204
pages/mine/distribution/cash-history.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="themeStyle">
|
||||||
|
<view v-if="cashLoading && !cashList.length" class="state-wrap">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!cashList.length" class="state-wrap">
|
||||||
|
<text class="state-text">暂无提现记录</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="log-list">
|
||||||
|
<view class="log-item" v-for="item in cashList" :key="item.id">
|
||||||
|
<view class="log-left">
|
||||||
|
<view class="log-sn">单号:{{ item.sn || item.id }}</view>
|
||||||
|
<view class="log-time">{{ formatTime(item.createTime) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="log-right">
|
||||||
|
<text class="log-amount log-amount-out">-{{ formatMoney(item.price) }}</text>
|
||||||
|
<view class="cash-status-tag" :class="cashStatusClass(item.distributionCashStatus)">
|
||||||
|
{{ cashStatusLabel(item.distributionCashStatus) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="list-footer">
|
||||||
|
<text v-if="cashFinished && cashList.length" class="footer-text">没有更多数据了</text>
|
||||||
|
<text v-else-if="cashLoadingMore" class="footer-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
|
import { cashLog } from '@/api/distribution'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
|
||||||
|
const cashList = ref<any[]>([])
|
||||||
|
const cashPage = ref(1)
|
||||||
|
const cashTotal = ref(0)
|
||||||
|
const cashLoading = ref(false)
|
||||||
|
const cashLoadingMore = ref(false)
|
||||||
|
const cashFinished = ref(false)
|
||||||
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
loadCashHistory(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
loadCashHistory(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadCashHistory(reset: boolean) {
|
||||||
|
if (!reset && (cashLoading.value || cashLoadingMore.value || cashFinished.value)) return
|
||||||
|
if (reset) {
|
||||||
|
cashLoading.value = true
|
||||||
|
cashPage.value = 1
|
||||||
|
cashFinished.value = false
|
||||||
|
} else {
|
||||||
|
cashLoadingMore.value = true
|
||||||
|
}
|
||||||
|
cashLog({ pageNumber: cashPage.value, pageSize: PAGE_SIZE, sort: 'createTime', order: 'desc' })
|
||||||
|
.then((res: any) => {
|
||||||
|
const result = res?.data?.result || {}
|
||||||
|
const records: any[] = result.records || []
|
||||||
|
cashTotal.value = Number(result.total || 0)
|
||||||
|
cashList.value = reset ? records : [...cashList.value, ...records]
|
||||||
|
if (records.length < PAGE_SIZE || cashList.value.length >= cashTotal.value) {
|
||||||
|
cashFinished.value = true
|
||||||
|
} else {
|
||||||
|
cashPage.value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
cashLoading.value = false
|
||||||
|
cashLoadingMore.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(val: number | string) {
|
||||||
|
return Number(val || 0).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
return String(value).replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CASH_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||||
|
APPLY: { label: '审核中', cls: 'status-pending' },
|
||||||
|
VIA_AUDITING: { label: '已通过', cls: 'status-success' },
|
||||||
|
FAIL_AUDITING: { label: '已驳回', cls: 'status-fail' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function cashStatusLabel(status: string) {
|
||||||
|
return CASH_STATUS_MAP[status]?.label || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function cashStatusClass(status: string) {
|
||||||
|
return CASH_STATUS_MAP[status]?.cls || ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
padding: 16rpx 24rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-wrap {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
padding-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-sn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
max-width: 360rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-amount {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-amount-out {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cash-status-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: #fff8e6;
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
background: #e8f8ec;
|
||||||
|
color: #3aaa52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-fail {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
padding: 12rpx 0 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,74 +9,81 @@
|
|||||||
<view class="retry-btn" @click="loadInviteCardSetting">重新加载</view>
|
<view class="retry-btn" @click="loadInviteCardSetting">重新加载</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="card" :class="{ 'card--has-bg': hasBackground }">
|
<template v-else>
|
||||||
<image
|
<view v-if="qrLoading || posterComposing" class="card state-card">
|
||||||
v-if="hasBackground"
|
<text class="state-text">{{ posterComposing ? '邀请卡合成中...' : qrLoadingText }}</text>
|
||||||
class="card-bg"
|
|
||||||
:src="backgroundUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="card-content">
|
|
||||||
<view v-if="showMemberInfo" class="member-info">
|
|
||||||
<image
|
|
||||||
v-if="inviteContext.memberAvatar"
|
|
||||||
class="member-avatar"
|
|
||||||
:src="avatarUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="member-name" :style="textStyle">{{ inviteContext.memberName || '分销员' }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="inviteContext.slogan" class="slogan" :style="textStyle">{{ inviteContext.slogan }}</view>
|
<view v-else-if="qrError || composeError" class="card state-card">
|
||||||
|
<text class="state-text">{{ composeError || qrError }}</text>
|
||||||
|
<view class="retry-btn" @click="retryGenerate">重新生成</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="qr-wrap" @click="previewQrCode">
|
|
||||||
<view v-if="qrLoading" class="qr-state">
|
|
||||||
<text class="qr-state-text">{{ qrLoadingText }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-else-if="qrError" class="qr-state">
|
|
||||||
<text class="qr-state-text">{{ qrError }}</text>
|
|
||||||
<view class="retry-btn small" @click.stop="loadQrCode">重新生成</view>
|
|
||||||
</view>
|
|
||||||
<image
|
<image
|
||||||
v-else-if="qrImage"
|
v-else-if="posterImage"
|
||||||
class="qr-image"
|
class="poster-image"
|
||||||
:src="qrImage"
|
:src="posterImage"
|
||||||
mode="aspectFit"
|
mode="widthFix"
|
||||||
show-menu-by-longpress
|
show-menu-by-longpress
|
||||||
@error="onQrImageError"
|
@click="previewPoster"
|
||||||
/>
|
/>
|
||||||
</view>
|
</template>
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="!pageLoading && !pageError" class="footer-actions">
|
<view v-if="posterImage" class="footer-actions">
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
|
<button class="btn primary share-btn" open-type="share">
|
||||||
<text class="btn-text">微信分享</text>
|
<text class="btn-text">微信分享</text>
|
||||||
</button>
|
</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<view class="hint-text">长按图片保存海报</view>
|
<view class="save-btn" @click="savePoster">保存海报</view>
|
||||||
|
<view class="hint-text">点击预览,长按或保存到相册</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="canvas-hide">
|
||||||
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
|
<canvas type="2d" id="inviteQrCanvas" class="qr-canvas" />
|
||||||
|
<canvas type="2d" id="inviteComposeCanvas" :style="composeCanvasStyle" />
|
||||||
|
<!-- #endif -->
|
||||||
|
<!-- #ifndef MP-WEIXIN -->
|
||||||
<canvas canvas-id="inviteQrCanvas" class="qr-canvas" />
|
<canvas canvas-id="inviteQrCanvas" class="qr-canvas" />
|
||||||
|
<canvas canvas-id="inviteComposeCanvas" id="inviteComposeCanvas" :style="composeCanvasStyle" />
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, getCurrentInstance } from 'vue'
|
import { ref, computed, getCurrentInstance } from 'vue'
|
||||||
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onLoad, onReady, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
import { getThemeStyle } from '@/utils/theme'
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
import { getInviteCard } from '@/api/distribution'
|
import { getInviteCard } from '@/api/distribution'
|
||||||
import { getMpCode } from '@/api/goods'
|
import { getMpCode } from '@/api/goods'
|
||||||
import { parseGoodsImageUrl } from '@/utils/filters.js'
|
import { parseGoodsImageUrl } from '@/utils/filters.js'
|
||||||
|
import {
|
||||||
|
POSTER_WIDTH,
|
||||||
|
POSTER_HEIGHT,
|
||||||
|
composeDistributionPoster,
|
||||||
|
savePosterToAlbum,
|
||||||
|
} from '@/utils/distributionPosterCanvas'
|
||||||
|
|
||||||
const DEFAULT_JOIN_PAGE = 'pages/mine/distribution/join'
|
const DEFAULT_JOIN_PAGE = 'pages/mine/distribution/join'
|
||||||
const QR_CANVAS_SIZE = 280
|
const QR_CANVAS_SIZE = 280
|
||||||
|
const COMPOSE_CANVAS_SELECTOR = 'inviteComposeCanvas'
|
||||||
|
|
||||||
|
function getCanvasPixelRatio() {
|
||||||
|
return wx.getWindowInfo?.().pixelRatio || wx.getDeviceInfo?.().pixelRatio || 2
|
||||||
|
}
|
||||||
|
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
const composeCanvasStyle = {
|
||||||
|
width: `${POSTER_WIDTH}px`,
|
||||||
|
height: `${POSTER_HEIGHT}px`,
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas2dNode: any = null
|
||||||
|
|
||||||
const pageLoading = ref(true)
|
const pageLoading = ref(true)
|
||||||
const pageLoadingText = ref('加载邀请卡设置...')
|
const pageLoadingText = ref('加载邀请卡设置...')
|
||||||
@@ -85,20 +92,24 @@ const qrLoading = ref(false)
|
|||||||
const qrLoadingText = ref('葵花码生成中...')
|
const qrLoadingText = ref('葵花码生成中...')
|
||||||
const qrError = ref('')
|
const qrError = ref('')
|
||||||
const qrImage = ref('')
|
const qrImage = ref('')
|
||||||
|
const posterImage = ref('')
|
||||||
|
const posterComposing = ref(false)
|
||||||
|
const composeError = ref('')
|
||||||
const inviteContext = ref<Record<string, any>>({})
|
const inviteContext = ref<Record<string, any>>({})
|
||||||
|
|
||||||
const showMemberInfo = computed(() => inviteContext.value.memberInfoVisible !== false)
|
|
||||||
const textStyle = computed(() => ({
|
|
||||||
color: inviteContext.value.textColor || '#1f2a44',
|
|
||||||
}))
|
|
||||||
const avatarUrl = computed(() => parseGoodsImageUrl(inviteContext.value.memberAvatar))
|
const avatarUrl = computed(() => parseGoodsImageUrl(inviteContext.value.memberAvatar))
|
||||||
const backgroundUrl = computed(() => parseGoodsImageUrl(inviteContext.value.backgroundImage))
|
const backgroundUrl = computed(() => parseGoodsImageUrl(inviteContext.value.backgroundImage))
|
||||||
const hasBackground = computed(() => !!backgroundUrl.value)
|
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
loadInviteCardSetting()
|
loadInviteCardSetting()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onReady(() => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
initCanvas2d()
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
const ctx = inviteContext.value
|
const ctx = inviteContext.value
|
||||||
const sharePage = normalizeSharePage(ctx.sharePage)
|
const sharePage = normalizeSharePage(ctx.sharePage)
|
||||||
@@ -106,7 +117,7 @@ onShareAppMessage(() => {
|
|||||||
return {
|
return {
|
||||||
title: ctx.slogan || '邀请您成为我的下级分销员',
|
title: ctx.slogan || '邀请您成为我的下级分销员',
|
||||||
path: `/${sharePage}${query}`,
|
path: `/${sharePage}${query}`,
|
||||||
imageUrl: qrImage.value || '',
|
imageUrl: posterImage.value || qrImage.value || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -134,7 +145,65 @@ function writeBase64ToTemp(base64: string): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportQrByCanvas(qrPath: string): Promise<string> {
|
function initCanvas2d() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (canvas2dNode) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni
|
||||||
|
.createSelectorQuery()
|
||||||
|
.in(instance?.proxy)
|
||||||
|
.select('#inviteQrCanvas')
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res: any[]) => {
|
||||||
|
const node = res?.[0]?.node
|
||||||
|
if (!node) {
|
||||||
|
reject(new Error('canvas 节点获取失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas2dNode = node
|
||||||
|
const dpr = getCanvasPixelRatio()
|
||||||
|
node.width = QR_CANVAS_SIZE * dpr
|
||||||
|
node.height = QR_CANVAS_SIZE * dpr
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportQrByCanvas2d(qrPath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = canvas2dNode
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const dpr = getCanvasPixelRatio()
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
|
||||||
|
|
||||||
|
const img = canvas.createImage()
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.canvasToTempFilePath(
|
||||||
|
{
|
||||||
|
canvas,
|
||||||
|
width: QR_CANVAS_SIZE,
|
||||||
|
height: QR_CANVAS_SIZE,
|
||||||
|
destWidth: QR_CANVAS_SIZE,
|
||||||
|
destHeight: QR_CANVAS_SIZE,
|
||||||
|
success: (file: any) => resolve(file.tempFilePath),
|
||||||
|
fail: () => reject(new Error('葵花码导出失败')),
|
||||||
|
},
|
||||||
|
instance?.proxy
|
||||||
|
)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('葵花码图片加载失败'))
|
||||||
|
img.src = qrPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportQrByCanvasLegacy(qrPath: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ctx = uni.createCanvasContext('inviteQrCanvas', instance?.proxy)
|
const ctx = uni.createCanvasContext('inviteQrCanvas', instance?.proxy)
|
||||||
ctx.setFillStyle('#ffffff')
|
ctx.setFillStyle('#ffffff')
|
||||||
@@ -159,17 +228,59 @@ function exportQrByCanvas(qrPath: string): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportQrByCanvas(qrPath: string): Promise<string> {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
await initCanvas2d()
|
||||||
|
return exportQrByCanvas2d(qrPath)
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
return exportQrByCanvasLegacy(qrPath)
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveQrDisplay(result: string) {
|
async function resolveQrDisplay(result: string) {
|
||||||
const tempPath = await writeBase64ToTemp(result)
|
const tempPath = await writeBase64ToTemp(result)
|
||||||
qrImage.value = await exportQrByCanvas(tempPath)
|
qrImage.value = await exportQrByCanvas(tempPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function composePoster() {
|
||||||
|
if (!qrImage.value) return
|
||||||
|
|
||||||
|
posterComposing.value = true
|
||||||
|
composeError.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = inviteContext.value
|
||||||
|
posterImage.value = await composeDistributionPoster(
|
||||||
|
COMPOSE_CANVAS_SELECTOR,
|
||||||
|
instance?.proxy || instance,
|
||||||
|
{
|
||||||
|
backgroundImage: backgroundUrl.value,
|
||||||
|
memberAvatar: avatarUrl.value,
|
||||||
|
memberName: ctx.memberName || '分销员',
|
||||||
|
slogan: ctx.slogan || '',
|
||||||
|
qrImage: qrImage.value,
|
||||||
|
textColor: ctx.textColor || '#1f2a44',
|
||||||
|
showMemberInfo: ctx.memberInfoVisible !== false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('compose invite card failed', error)
|
||||||
|
composeError.value = error?.message || '邀请卡合成失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
posterComposing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInviteCardSetting() {
|
async function loadInviteCardSetting() {
|
||||||
pageLoading.value = true
|
pageLoading.value = true
|
||||||
pageLoadingText.value = '加载邀请卡设置...'
|
pageLoadingText.value = '加载邀请卡设置...'
|
||||||
pageError.value = ''
|
pageError.value = ''
|
||||||
qrImage.value = ''
|
qrImage.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
qrError.value = ''
|
qrError.value = ''
|
||||||
|
composeError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getInviteCard()
|
const res = await getInviteCard()
|
||||||
@@ -210,6 +321,8 @@ async function loadQrCode() {
|
|||||||
qrLoadingText.value = '葵花码生成中...'
|
qrLoadingText.value = '葵花码生成中...'
|
||||||
qrError.value = ''
|
qrError.value = ''
|
||||||
qrImage.value = ''
|
qrImage.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
|
composeError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ctx = inviteContext.value
|
const ctx = inviteContext.value
|
||||||
@@ -222,6 +335,7 @@ async function loadQrCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await resolveQrDisplay(codeRes.data.result)
|
await resolveQrDisplay(codeRes.data.result)
|
||||||
|
await composePoster()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('load invite qr code failed', error)
|
console.error('load invite qr code failed', error)
|
||||||
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
|
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
|
||||||
@@ -230,18 +344,25 @@ async function loadQrCode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onQrImageError() {
|
function retryGenerate() {
|
||||||
qrError.value = '葵花码图片加载失败'
|
if (qrImage.value && composeError.value) {
|
||||||
qrImage.value = ''
|
composePoster()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadQrCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewQrCode() {
|
function previewPoster() {
|
||||||
if (!qrImage.value) return
|
if (!posterImage.value) return
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
current: qrImage.value,
|
current: posterImage.value,
|
||||||
urls: [qrImage.value],
|
urls: [posterImage.value],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savePoster() {
|
||||||
|
savePosterToAlbum(posterImage.value)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -262,7 +383,8 @@ function previewQrCode() {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn {
|
.retry-btn,
|
||||||
|
.save-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
padding: 12rpx 40rpx;
|
padding: 12rpx 40rpx;
|
||||||
@@ -272,107 +394,24 @@ function previewQrCode() {
|
|||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn.small {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
padding: 8rpx 28rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 720rpx;
|
aspect-ratio: 630 / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--has-bg {
|
.state-card {
|
||||||
background: transparent;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bg {
|
.poster-image {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 32rpx 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--has-bg .card-content {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
width: 96rpx;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slogan {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 0 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 32rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-state {
|
|
||||||
width: 420rpx;
|
|
||||||
height: 420rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
}
|
display: block;
|
||||||
|
|
||||||
.qr-state-text {
|
|
||||||
color: #999;
|
|
||||||
font-size: 26rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-image {
|
|
||||||
width: 420rpx;
|
|
||||||
height: 420rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-canvas {
|
|
||||||
position: fixed;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-actions {
|
.footer-actions {
|
||||||
@@ -416,10 +455,31 @@ function previewQrCode() {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.hint-text {
|
.hint-text {
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-hide {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-canvas {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,16 @@
|
|||||||
<view v-else-if="!plan">
|
<view v-else-if="!plan">
|
||||||
<view class="empty">暂未开放线上招募</view>
|
<view class="empty">暂未开放线上招募</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="page-body" :class="{ 'with-footer': showManualApply || isApplyPending }">
|
<view v-else class="page-body" :class="{ 'with-footer': showLoginFooter || showManualApply || isApplyPending }">
|
||||||
|
<!-- 邀请人信息(来自图文邀请卡) -->
|
||||||
|
<view class="inviter-header" v-if="showInviterHeader">
|
||||||
|
<image class="inviter-avatar" :src="inviterAvatar" mode="aspectFill" />
|
||||||
|
<view class="inviter-meta">
|
||||||
|
<view class="inviter-label">邀请您加入</view>
|
||||||
|
<view class="inviter-name">{{ inviterName }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 招募海报 -->
|
<!-- 招募海报 -->
|
||||||
<view class="section poster-section" v-if="showPoster">
|
<view class="section poster-section" v-if="showPoster">
|
||||||
<image
|
<image
|
||||||
@@ -22,7 +31,9 @@
|
|||||||
<!-- 加入条件 -->
|
<!-- 加入条件 -->
|
||||||
<view class="section" v-if="hasJoinCondition">
|
<view class="section" v-if="hasJoinCondition">
|
||||||
<view class="title">加入条件</view>
|
<view class="title">加入条件</view>
|
||||||
<view class="condition-tip">满足以下条件后可申请成为分销员</view>
|
<view class="condition-tip">
|
||||||
|
{{ isUserLoggedIn ? '满足以下条件后可申请成为分销员' : '登录后可查看您的达标进度并申请成为分销员' }}
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="condition-item" v-if="plan.requireSelfPurchaseAmount">
|
<view class="condition-item" v-if="plan.requireSelfPurchaseAmount">
|
||||||
<view class="condition-head">
|
<view class="condition-head">
|
||||||
@@ -87,7 +98,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="apply-footer-fixed" v-if="showManualApply || isApplyPending">
|
<view class="apply-footer-fixed" v-if="showLoginFooter">
|
||||||
|
<view class="apply-footer-card">
|
||||||
|
<view class="login-tip">登录后即可查看加入条件并申请成为分销员</view>
|
||||||
|
<view class="submit" @click="goLogin">立即登录</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="apply-footer-fixed" v-else-if="showManualApply || isApplyPending">
|
||||||
<view class="apply-footer-card">
|
<view class="apply-footer-card">
|
||||||
<view class="agree" v-if="showManualApply" @click="agreed = !agreed">
|
<view class="agree" v-if="showManualApply" @click="agreed = !agreed">
|
||||||
<checkbox :checked="agreed" @click.stop="agreed = !agreed" />
|
<checkbox :checked="agreed" @click.stop="agreed = !agreed" />
|
||||||
@@ -148,8 +166,13 @@ import { ref, reactive, computed } from 'vue'
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
import { getThemeStyle } from '@/utils/theme'
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
import { getRecruitPage, getRecruitProgress, submitRecruitApplication } from '@/api/distribution'
|
import storage from '@/utils/storage.js'
|
||||||
import { tipsToLogin } from '@/utils/filters.js'
|
import config from '@/config/config'
|
||||||
|
import { parseGoodsImageUrl, tipsToLogin } from '@/utils/filters.js'
|
||||||
|
import { resolveRecruitDistributionIdFromScene } from '@/utils/distributionBind.js'
|
||||||
|
import { getRecruitPage, getRecruitProgress, getRecruitInviter, submitRecruitApplication } from '@/api/distribution'
|
||||||
|
|
||||||
|
const defaultAvatar = config.defaultUserPhoto
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
@@ -166,6 +189,9 @@ const agreementVisible = ref(false)
|
|||||||
const applyFormVisible = ref(false)
|
const applyFormVisible = ref(false)
|
||||||
const formInfoCompleted = ref(false)
|
const formInfoCompleted = ref(false)
|
||||||
const fieldErrors = reactive<Record<string, string>>({})
|
const fieldErrors = reactive<Record<string, string>>({})
|
||||||
|
const inviterInfo = ref<any>(null)
|
||||||
|
|
||||||
|
const isUserLoggedIn = computed(() => !!storage.getAccessToken())
|
||||||
|
|
||||||
const plan = computed(() => pageInfo.value.plan || null)
|
const plan = computed(() => pageInfo.value.plan || null)
|
||||||
const fields = computed(() => pageInfo.value.fields || [])
|
const fields = computed(() => pageInfo.value.fields || [])
|
||||||
@@ -219,12 +245,25 @@ const isManualApply = computed(() => {
|
|||||||
return (plan.value?.applyMode || 'MANUAL') === 'MANUAL'
|
return (plan.value?.applyMode || 'MANUAL') === 'MANUAL'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showLoginFooter = computed(() => {
|
||||||
|
return !!plan.value && !isUserLoggedIn.value && !loading.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const showInviterHeader = computed(() => {
|
||||||
|
if (!recruitToken.value) return false
|
||||||
|
const info = inviterInfo.value
|
||||||
|
return !!(info?.memberName || info?.memberAvatar)
|
||||||
|
})
|
||||||
|
|
||||||
|
const inviterName = computed(() => inviterInfo.value?.memberName || '分销员')
|
||||||
|
const inviterAvatar = computed(() => parseGoodsImageUrl(inviterInfo.value?.memberAvatar) || defaultAvatar)
|
||||||
|
|
||||||
const showManualApply = computed(() => {
|
const showManualApply = computed(() => {
|
||||||
return !showApplyResult.value && conditionsMet.value && isManualApply.value
|
return isUserLoggedIn.value && !showApplyResult.value && conditionsMet.value && isManualApply.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const showAutoApplyTip = computed(() => {
|
const showAutoApplyTip = computed(() => {
|
||||||
return !showApplyResult.value && conditionsMet.value && plan.value?.applyMode === 'AUTO'
|
return isUserLoggedIn.value && !showApplyResult.value && conditionsMet.value && plan.value?.applyMode === 'AUTO'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showApplyForm = computed(() => {
|
const showApplyForm = computed(() => {
|
||||||
@@ -250,10 +289,48 @@ const consumeCountOk = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
onLoad((query) => {
|
onLoad(async (query) => {
|
||||||
recruitToken.value = query?.token || query?.distributionId || ''
|
let token = query?.token || query?.distributionId || ''
|
||||||
|
if (!token && query?.scene) {
|
||||||
|
token = await resolveRecruitDistributionIdFromScene(query.scene)
|
||||||
|
}
|
||||||
|
recruitToken.value = token ? String(token) : ''
|
||||||
|
if (recruitToken.value) {
|
||||||
|
loadInviterInfo(recruitToken.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function loadInviterInfo(distributionId: string) {
|
||||||
|
getRecruitInviter(distributionId).then((res) => {
|
||||||
|
if (res.data?.success) {
|
||||||
|
inviterInfo.value = res.data.result || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyProgress() {
|
||||||
|
return {
|
||||||
|
satisfied: false,
|
||||||
|
currentSelfPurchaseAmount: 0,
|
||||||
|
currentConsumeCount: 0,
|
||||||
|
purchaseGoodsSatisfied: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasConditionalJoin(planData: any) {
|
||||||
|
if (!planData || planData.joinConditionType !== 'CONDITIONAL') return false
|
||||||
|
return !!(planData.requireSelfPurchaseAmount || planData.requireConsumeCount || planData.requirePurchaseGoods)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.navigateTo({ url: '/pages/passport/wechatMPLogin' })
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
uni.navigateTo({ url: '/pages/passport/login' })
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
loadPageData()
|
loadPageData()
|
||||||
})
|
})
|
||||||
@@ -265,10 +342,20 @@ function loadPageData() {
|
|||||||
submitApplicationStatus.value = ''
|
submitApplicationStatus.value = ''
|
||||||
formInfoCompleted.value = false
|
formInfoCompleted.value = false
|
||||||
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
|
Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key])
|
||||||
Promise.all([getRecruitPage(), getRecruitProgress()])
|
const tasks: Promise<any>[] = [getRecruitPage()]
|
||||||
.then(([pageRes, progressRes]) => {
|
if (isUserLoggedIn.value) {
|
||||||
pageInfo.value = pageRes.data?.result || {}
|
tasks.push(getRecruitProgress())
|
||||||
progress.value = progressRes.data?.result || null
|
}
|
||||||
|
Promise.all(tasks)
|
||||||
|
.then((results) => {
|
||||||
|
const pageRes = results[0]
|
||||||
|
const pageResult = pageRes.data?.result || {}
|
||||||
|
pageInfo.value = pageResult
|
||||||
|
if (results.length > 1) {
|
||||||
|
progress.value = results[1].data?.result || emptyProgress()
|
||||||
|
} else {
|
||||||
|
progress.value = hasConditionalJoin(pageResult.plan) ? emptyProgress() : { satisfied: true }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -401,6 +488,50 @@ function submit() {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inviter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviter-avatar {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviter-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviter-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inviter-name {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tip {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
|
|||||||
@@ -9,74 +9,72 @@
|
|||||||
<view class="retry-btn" @click="loadPosterSetting">重新加载</view>
|
<view class="retry-btn" @click="loadPosterSetting">重新加载</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="card" :class="{ 'card--has-bg': hasBackground }">
|
<template v-else>
|
||||||
<image
|
<view v-if="qrLoading || posterComposing" class="card state-card">
|
||||||
v-if="hasBackground"
|
<text class="state-text">{{ posterComposing ? '海报合成中...' : qrLoadingText }}</text>
|
||||||
class="card-bg"
|
|
||||||
:src="backgroundUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="card-content">
|
|
||||||
<view v-if="showMemberInfo" class="member-info">
|
|
||||||
<image
|
|
||||||
v-if="posterContext.memberAvatar"
|
|
||||||
class="member-avatar"
|
|
||||||
:src="avatarUrl"
|
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="member-name" :style="textStyle">{{ posterContext.memberName || '分销员' }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="posterContext.slogan" class="slogan" :style="textStyle">{{ posterContext.slogan }}</view>
|
<view v-else-if="qrError || composeError" class="card state-card">
|
||||||
|
<text class="state-text">{{ composeError || qrError }}</text>
|
||||||
|
<view class="retry-btn" @click="retryGenerate">重新生成</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="qr-wrap" @click="previewQrCode">
|
|
||||||
<view v-if="qrLoading" class="qr-state">
|
|
||||||
<text class="qr-state-text">{{ qrLoadingText }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-else-if="qrError" class="qr-state">
|
|
||||||
<text class="qr-state-text">{{ qrError }}</text>
|
|
||||||
<view class="retry-btn small" @click.stop="loadQrCode">重新生成</view>
|
|
||||||
</view>
|
|
||||||
<image
|
<image
|
||||||
v-else-if="qrImage"
|
v-else-if="posterImage"
|
||||||
class="qr-image"
|
class="poster-image"
|
||||||
:src="qrImage"
|
:src="posterImage"
|
||||||
mode="aspectFit"
|
mode="widthFix"
|
||||||
show-menu-by-longpress
|
show-menu-by-longpress
|
||||||
@error="onQrImageError"
|
@click="previewPoster"
|
||||||
/>
|
/>
|
||||||
</view>
|
</template>
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="!pageLoading && !pageError" class="footer-actions">
|
<view v-if="posterImage" class="hint-fixed">长按保存分享</view>
|
||||||
|
|
||||||
|
<view class="canvas-hide">
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button class="btn primary share-btn" open-type="share" :disabled="!qrImage">
|
<canvas type="2d" id="qrCanvas" class="qr-canvas" />
|
||||||
<text class="btn-text">微信分享</text>
|
<canvas type="2d" id="posterComposeCanvas" :style="composeCanvasStyle" />
|
||||||
</button>
|
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<view class="hint-text">长按图片保存海报</view>
|
<!-- #ifndef MP-WEIXIN -->
|
||||||
</view>
|
|
||||||
|
|
||||||
<canvas canvas-id="qrCanvas" class="qr-canvas" />
|
<canvas canvas-id="qrCanvas" class="qr-canvas" />
|
||||||
|
<canvas canvas-id="posterComposeCanvas" id="posterComposeCanvas" :style="composeCanvasStyle" />
|
||||||
|
<!-- #endif -->
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, getCurrentInstance } from 'vue'
|
import { ref, computed, getCurrentInstance } from 'vue'
|
||||||
import { onLoad, onShareAppMessage } from '@dcloudio/uni-app'
|
import { onLoad, onReady, onShareAppMessage } from '@dcloudio/uni-app'
|
||||||
import { useStore } from '@/store'
|
import { useStore } from '@/store'
|
||||||
import { getThemeStyle } from '@/utils/theme'
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
import { getPromotionPoster } from '@/api/distribution'
|
import { getPromotionPoster } from '@/api/distribution'
|
||||||
import { getMpCode } from '@/api/goods'
|
import { getMpCode } from '@/api/goods'
|
||||||
import { parseGoodsImageUrl } from '@/utils/filters.js'
|
import { parseGoodsImageUrl } from '@/utils/filters.js'
|
||||||
|
import {
|
||||||
|
POSTER_WIDTH,
|
||||||
|
POSTER_HEIGHT,
|
||||||
|
composeDistributionPoster,
|
||||||
|
} from '@/utils/distributionPosterCanvas'
|
||||||
|
|
||||||
const DEFAULT_HOME_PAGE = 'pages/tabbar/home/index'
|
const DEFAULT_HOME_PAGE = 'pages/tabbar/home/index'
|
||||||
const QR_CANVAS_SIZE = 280
|
const QR_CANVAS_SIZE = 280
|
||||||
|
const COMPOSE_CANVAS_SELECTOR = 'posterComposeCanvas'
|
||||||
|
|
||||||
|
function getCanvasPixelRatio() {
|
||||||
|
return wx.getWindowInfo?.().pixelRatio || wx.getDeviceInfo?.().pixelRatio || 2
|
||||||
|
}
|
||||||
|
|
||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
const composeCanvasStyle = {
|
||||||
|
width: `${POSTER_WIDTH}px`,
|
||||||
|
height: `${POSTER_HEIGHT}px`,
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas2dNode: any = null
|
||||||
|
|
||||||
const pageLoading = ref(true)
|
const pageLoading = ref(true)
|
||||||
const pageLoadingText = ref('加载海报设置...')
|
const pageLoadingText = ref('加载海报设置...')
|
||||||
@@ -85,27 +83,28 @@ const qrLoading = ref(false)
|
|||||||
const qrLoadingText = ref('葵花码生成中...')
|
const qrLoadingText = ref('葵花码生成中...')
|
||||||
const qrError = ref('')
|
const qrError = ref('')
|
||||||
const qrImage = ref('')
|
const qrImage = ref('')
|
||||||
|
const posterImage = ref('')
|
||||||
|
const posterComposing = ref(false)
|
||||||
|
const composeError = ref('')
|
||||||
const posterContext = ref<Record<string, any>>({})
|
const posterContext = ref<Record<string, any>>({})
|
||||||
|
|
||||||
const showMemberInfo = computed(() => posterContext.value.memberInfoVisible !== false)
|
|
||||||
const textStyle = computed(() => ({
|
|
||||||
color: posterContext.value.textColor || '#1f2a44',
|
|
||||||
}))
|
|
||||||
const avatarUrl = computed(() => parseGoodsImageUrl(posterContext.value.memberAvatar))
|
|
||||||
const backgroundUrl = computed(() => parseGoodsImageUrl(posterContext.value.backgroundImage))
|
|
||||||
const hasBackground = computed(() => !!backgroundUrl.value)
|
|
||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
loadPosterSetting()
|
loadPosterSetting()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onReady(() => {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
initCanvas2d()
|
||||||
|
// #endif
|
||||||
|
})
|
||||||
|
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
const ctx = posterContext.value
|
const ctx = posterContext.value
|
||||||
const sharePage = normalizeSharePage(ctx.sharePage)
|
const sharePage = normalizeSharePage(ctx.sharePage)
|
||||||
return {
|
return {
|
||||||
title: ctx.slogan || '邀请你一起逛逛',
|
title: ctx.slogan || '邀请你一起逛逛',
|
||||||
path: `/${sharePage}`,
|
path: `/${sharePage}`,
|
||||||
imageUrl: qrImage.value || '',
|
imageUrl: posterImage.value || qrImage.value || '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,7 +132,65 @@ function writeBase64ToTemp(base64: string): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportQrByCanvas(qrPath: string): Promise<string> {
|
function initCanvas2d() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (canvas2dNode) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni
|
||||||
|
.createSelectorQuery()
|
||||||
|
.in(instance?.proxy)
|
||||||
|
.select('#qrCanvas')
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((res: any[]) => {
|
||||||
|
const node = res?.[0]?.node
|
||||||
|
if (!node) {
|
||||||
|
reject(new Error('canvas 节点获取失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas2dNode = node
|
||||||
|
const dpr = getCanvasPixelRatio()
|
||||||
|
node.width = QR_CANVAS_SIZE * dpr
|
||||||
|
node.height = QR_CANVAS_SIZE * dpr
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportQrByCanvas2d(qrPath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = canvas2dNode
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const dpr = getCanvasPixelRatio()
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
|
||||||
|
|
||||||
|
const img = canvas.createImage()
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, QR_CANVAS_SIZE, QR_CANVAS_SIZE)
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.canvasToTempFilePath(
|
||||||
|
{
|
||||||
|
canvas,
|
||||||
|
width: QR_CANVAS_SIZE,
|
||||||
|
height: QR_CANVAS_SIZE,
|
||||||
|
destWidth: QR_CANVAS_SIZE,
|
||||||
|
destHeight: QR_CANVAS_SIZE,
|
||||||
|
success: (file: any) => resolve(file.tempFilePath),
|
||||||
|
fail: () => reject(new Error('葵花码导出失败')),
|
||||||
|
},
|
||||||
|
instance?.proxy
|
||||||
|
)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
img.onerror = () => reject(new Error('葵花码图片加载失败'))
|
||||||
|
img.src = qrPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportQrByCanvasLegacy(qrPath: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ctx = uni.createCanvasContext('qrCanvas', instance?.proxy)
|
const ctx = uni.createCanvasContext('qrCanvas', instance?.proxy)
|
||||||
ctx.setFillStyle('#ffffff')
|
ctx.setFillStyle('#ffffff')
|
||||||
@@ -158,17 +215,59 @@ function exportQrByCanvas(qrPath: string): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportQrByCanvas(qrPath: string): Promise<string> {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
await initCanvas2d()
|
||||||
|
return exportQrByCanvas2d(qrPath)
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
return exportQrByCanvasLegacy(qrPath)
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveQrDisplay(result: string) {
|
async function resolveQrDisplay(result: string) {
|
||||||
const tempPath = await writeBase64ToTemp(result)
|
const tempPath = await writeBase64ToTemp(result)
|
||||||
qrImage.value = await exportQrByCanvas(tempPath)
|
qrImage.value = await exportQrByCanvas(tempPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function composePoster() {
|
||||||
|
if (!qrImage.value) return
|
||||||
|
|
||||||
|
posterComposing.value = true
|
||||||
|
composeError.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = posterContext.value
|
||||||
|
posterImage.value = await composeDistributionPoster(
|
||||||
|
COMPOSE_CANVAS_SELECTOR,
|
||||||
|
instance?.proxy || instance,
|
||||||
|
{
|
||||||
|
backgroundImage: backgroundUrl.value,
|
||||||
|
memberAvatar: avatarUrl.value,
|
||||||
|
memberName: ctx.memberName || '分销员',
|
||||||
|
slogan: ctx.slogan || '',
|
||||||
|
qrImage: qrImage.value,
|
||||||
|
textColor: ctx.textColor || '#1f2a44',
|
||||||
|
showMemberInfo: ctx.memberInfoVisible !== false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('compose poster failed', error)
|
||||||
|
composeError.value = error?.message || '海报合成失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
posterComposing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPosterSetting() {
|
async function loadPosterSetting() {
|
||||||
pageLoading.value = true
|
pageLoading.value = true
|
||||||
pageLoadingText.value = '加载海报设置...'
|
pageLoadingText.value = '加载海报设置...'
|
||||||
pageError.value = ''
|
pageError.value = ''
|
||||||
qrImage.value = ''
|
qrImage.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
qrError.value = ''
|
qrError.value = ''
|
||||||
|
composeError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getPromotionPoster()
|
const res = await getPromotionPoster()
|
||||||
@@ -209,6 +308,8 @@ async function loadQrCode() {
|
|||||||
qrLoadingText.value = '葵花码生成中...'
|
qrLoadingText.value = '葵花码生成中...'
|
||||||
qrError.value = ''
|
qrError.value = ''
|
||||||
qrImage.value = ''
|
qrImage.value = ''
|
||||||
|
posterImage.value = ''
|
||||||
|
composeError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ctx = posterContext.value
|
const ctx = posterContext.value
|
||||||
@@ -221,6 +322,7 @@ async function loadQrCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await resolveQrDisplay(codeRes.data.result)
|
await resolveQrDisplay(codeRes.data.result)
|
||||||
|
await composePoster()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('load qr code failed', error)
|
console.error('load qr code failed', error)
|
||||||
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
|
qrError.value = error?.message || '葵花码生成失败,请稍后重试'
|
||||||
@@ -229,18 +331,24 @@ async function loadQrCode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onQrImageError() {
|
function retryGenerate() {
|
||||||
qrError.value = '葵花码图片加载失败'
|
if (qrImage.value && composeError.value) {
|
||||||
qrImage.value = ''
|
composePoster()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadQrCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewQrCode() {
|
function previewPoster() {
|
||||||
if (!qrImage.value) return
|
if (!posterImage.value) return
|
||||||
uni.previewImage({
|
uni.previewImage({
|
||||||
current: qrImage.value,
|
current: posterImage.value,
|
||||||
urls: [qrImage.value],
|
urls: [posterImage.value],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avatarUrl = computed(() => parseGoodsImageUrl(posterContext.value.memberAvatar))
|
||||||
|
const backgroundUrl = computed(() => parseGoodsImageUrl(posterContext.value.backgroundImage))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -271,158 +379,43 @@ function previewQrCode() {
|
|||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn.small {
|
|
||||||
margin-top: 16rpx;
|
|
||||||
padding: 8rpx 28rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 720rpx;
|
aspect-ratio: 630 / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card--has-bg {
|
.state-card {
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-bg {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
padding: 32rpx 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--has-bg .card-content {
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-info {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
width: 96rpx;
|
|
||||||
height: 96rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-bottom: 16rpx;
|
|
||||||
border: 2rpx solid rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slogan {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 0 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrap {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-state {
|
|
||||||
width: 420rpx;
|
|
||||||
height: 420rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-state-text {
|
.poster-image {
|
||||||
color: #999;
|
|
||||||
font-size: 26rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-image {
|
|
||||||
width: 420rpx;
|
|
||||||
height: 420rpx;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-canvas {
|
|
||||||
position: fixed;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-actions {
|
|
||||||
margin-top: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
border-radius: 16rpx;
|
||||||
padding: 20rpx 0;
|
|
||||||
border-radius: 40rpx;
|
|
||||||
border: 1px solid $light-color;
|
|
||||||
color: $light-color;
|
|
||||||
font-size: 28rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background: $light-color;
|
|
||||||
color: #fff;
|
|
||||||
border-color: $light-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-btn {
|
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
line-height: normal;
|
|
||||||
font-size: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-btn::after {
|
.hint-fixed {
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
display: block;
|
|
||||||
padding: 20rpx 0;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
margin-top: 24rpx;
|
margin-top: 24rpx;
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-hide {
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-canvas {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
191
pages/mine/distribution/wallet-log.vue
Normal file
191
pages/mine/distribution/wallet-log.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="themeStyle">
|
||||||
|
<view v-if="walletLoading && !walletList.length" class="state-wrap">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!walletList.length" class="state-wrap">
|
||||||
|
<text class="state-text">暂无流水记录</text>
|
||||||
|
</view>
|
||||||
|
<view v-else class="log-list">
|
||||||
|
<view class="log-item" v-for="item in walletList" :key="item.id">
|
||||||
|
<view class="log-left">
|
||||||
|
<text class="log-title">{{ logTypeLabel(item.logType) }}</text>
|
||||||
|
<text class="log-time">{{ formatTime(item.createTime) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="log-right">
|
||||||
|
<text class="log-amount" :class="logAmountClass(item.logType)">
|
||||||
|
{{ logAmountSign(item.logType) }}{{ formatMoney(Math.abs(item.changeAmount)) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="list-footer">
|
||||||
|
<text v-if="walletFinished && walletList.length" class="footer-text">没有更多数据了</text>
|
||||||
|
<text v-else-if="walletLoadingMore" class="footer-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
|
import { getWalletLog } from '@/api/distribution'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
|
||||||
|
const walletList = ref<any[]>([])
|
||||||
|
const walletPage = ref(1)
|
||||||
|
const walletTotal = ref(0)
|
||||||
|
const walletLoading = ref(false)
|
||||||
|
const walletLoadingMore = ref(false)
|
||||||
|
const walletFinished = ref(false)
|
||||||
|
const PAGE_SIZE = 15
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
loadWalletLog(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
loadWalletLog(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadWalletLog(reset: boolean) {
|
||||||
|
if (!reset && (walletLoading.value || walletLoadingMore.value || walletFinished.value)) return
|
||||||
|
if (reset) {
|
||||||
|
walletLoading.value = true
|
||||||
|
walletPage.value = 1
|
||||||
|
walletFinished.value = false
|
||||||
|
} else {
|
||||||
|
walletLoadingMore.value = true
|
||||||
|
}
|
||||||
|
getWalletLog({ pageNumber: walletPage.value, pageSize: PAGE_SIZE })
|
||||||
|
.then((res: any) => {
|
||||||
|
const result = res?.data?.result || {}
|
||||||
|
const records: any[] = result.records || []
|
||||||
|
walletTotal.value = Number(result.total || 0)
|
||||||
|
walletList.value = reset ? records : [...walletList.value, ...records]
|
||||||
|
if (records.length < PAGE_SIZE || walletList.value.length >= walletTotal.value) {
|
||||||
|
walletFinished.value = true
|
||||||
|
} else {
|
||||||
|
walletPage.value += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
walletLoading.value = false
|
||||||
|
walletLoadingMore.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(val: number | string) {
|
||||||
|
return Number(val || 0).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
return String(value).replace('T', ' ').slice(0, 19)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_TYPE_MAP: Record<string, { label: string; income: boolean }> = {
|
||||||
|
COMMISSION_FREEZE: { label: '佣金待结算', income: false },
|
||||||
|
COMMISSION_SETTLE: { label: '销售员佣金', income: true },
|
||||||
|
COMMISSION_REFUND: { label: '佣金退回', income: false },
|
||||||
|
WITHDRAW_APPLY: { label: '提现申请', income: false },
|
||||||
|
WITHDRAW_APPROVE: { label: '提现到账', income: false },
|
||||||
|
WITHDRAW_REJECT: { label: '提现驳回', income: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTypeLabel(type: string) {
|
||||||
|
return LOG_TYPE_MAP[type]?.label || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAmountClass(type: string) {
|
||||||
|
return LOG_TYPE_MAP[type]?.income ? 'log-amount-in' : 'log-amount-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAmountSign(type: string) {
|
||||||
|
return LOG_TYPE_MAP[type]?.income ? '+' : '-'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
padding: 16rpx 24rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-wrap {
|
||||||
|
padding: 120rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
padding-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-amount {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-amount-in {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-amount-out {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-footer {
|
||||||
|
padding: 12rpx 0 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
502
pages/mine/distribution/withdraw-apply.vue
Normal file
502
pages/mine/distribution/withdraw-apply.vue
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page" :style="themeStyle">
|
||||||
|
<view class="content">
|
||||||
|
<!-- 到账账户 -->
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="account-row" @click="openAccountDrawer">
|
||||||
|
<text class="account-label">到账账户</text>
|
||||||
|
<view class="account-right">
|
||||||
|
<view v-if="selectedAccount" class="account-info">
|
||||||
|
<text class="account-bank">{{ selectedAccount.bankName }}</text>
|
||||||
|
<text class="account-card">{{ maskCard(selectedAccount.cardNo) }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="account-add-text">请添加</text>
|
||||||
|
<u-icon name="arrow-right" color="#bbb" size="14" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 提现金额 -->
|
||||||
|
<view class="section-card amount-card">
|
||||||
|
<text class="amount-label">提现金额</text>
|
||||||
|
<view class="amount-input-wrap">
|
||||||
|
<text class="amount-symbol">¥</text>
|
||||||
|
<input
|
||||||
|
class="amount-input"
|
||||||
|
type="digit"
|
||||||
|
v-model="amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
placeholder-class="amount-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="amount-divider" />
|
||||||
|
<view class="amount-hint-row">
|
||||||
|
<text class="amount-balance-hint">可提现余额 ¥{{ formatMoney(canRebate) }}</text>
|
||||||
|
<text class="amount-all" @click="fillAll">全部提现</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="errorMsg" class="amount-error">{{ errorMsg }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 提现按钮 -->
|
||||||
|
<view
|
||||||
|
class="submit-btn"
|
||||||
|
:class="{ 'submit-btn--disabled': !canSubmit }"
|
||||||
|
@click="submitWithdraw"
|
||||||
|
>{{ submitLoading ? '提交中...' : '提现' }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 选择账户抽屉 -->
|
||||||
|
<u-popup v-model:show="drawerVisible" mode="bottom" round="16">
|
||||||
|
<view class="drawer-wrap">
|
||||||
|
<view class="drawer-header">
|
||||||
|
<text class="drawer-title">选择账户</text>
|
||||||
|
<view class="drawer-close" @click="drawerVisible = false">
|
||||||
|
<u-icon name="close" color="#999" size="18" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="drawerLoading" class="drawer-state">
|
||||||
|
<text class="drawer-state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="allCards.length" class="drawer-list">
|
||||||
|
<view
|
||||||
|
class="drawer-item"
|
||||||
|
v-for="item in allCards"
|
||||||
|
:key="item.id"
|
||||||
|
@click="selectAccount(item)"
|
||||||
|
>
|
||||||
|
<view class="radio-circle" :class="{ active: selectedAccount?.id === item.id }">
|
||||||
|
<view class="radio-dot" v-if="selectedAccount?.id === item.id" />
|
||||||
|
</view>
|
||||||
|
<view class="drawer-item-info">
|
||||||
|
<text class="drawer-item-name">{{ item.holderName }}</text>
|
||||||
|
<view class="drawer-item-meta">
|
||||||
|
<text class="drawer-item-bank">{{ item.bankName }}</text>
|
||||||
|
<text class="drawer-item-card">{{ maskCard(item.cardNo) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="drawer-state">
|
||||||
|
<text class="drawer-state-text">暂无银行卡</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="drawer-add-btn" @click="openAddForm">+ 添加账户</view>
|
||||||
|
</view>
|
||||||
|
</u-popup>
|
||||||
|
|
||||||
|
<!-- 添加账户 Popup -->
|
||||||
|
<u-popup v-model:show="addPopupVisible" mode="bottom" round="16">
|
||||||
|
<view class="popup-wrap">
|
||||||
|
<view class="popup-title">添加到账账户</view>
|
||||||
|
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">姓名</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.holderName"
|
||||||
|
placeholder="请输入收款人姓名"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-divider" />
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">银行</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.bankName"
|
||||||
|
placeholder="如:中国工商银行"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="form-divider" />
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">卡号</text>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="form.cardNo"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入银行卡号"
|
||||||
|
placeholder-class="form-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="popup-actions">
|
||||||
|
<view class="popup-btn ghost" @click="addPopupVisible = false">取消</view>
|
||||||
|
<view
|
||||||
|
class="popup-btn primary"
|
||||||
|
:class="{ disabled: saveLoading }"
|
||||||
|
@click="saveAccount"
|
||||||
|
>{{ saveLoading ? '保存中...' : '保存' }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</u-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { useStore } from '@/store'
|
||||||
|
import { getThemeStyle } from '@/utils/theme'
|
||||||
|
import { distribution, cash, getBankCards, addBankCard } from '@/api/distribution'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const themeStyle = computed(() => getThemeStyle(store.state.theme))
|
||||||
|
|
||||||
|
const canRebate = ref(0)
|
||||||
|
const amount = ref('')
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const selectedAccount = ref<any>(null)
|
||||||
|
const allCards = ref<any[]>([])
|
||||||
|
const inited = ref(false)
|
||||||
|
|
||||||
|
// 抽屉
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const drawerLoading = ref(false)
|
||||||
|
|
||||||
|
// 添加账户弹窗
|
||||||
|
const addPopupVisible = ref(false)
|
||||||
|
const saveLoading = ref(false)
|
||||||
|
const form = ref({ holderName: '', bankName: '', cardNo: '' })
|
||||||
|
|
||||||
|
function loadInfo() {
|
||||||
|
distribution().then((res: any) => {
|
||||||
|
const d = res?.data?.result || {}
|
||||||
|
canRebate.value = Number(d.canRebate || 0)
|
||||||
|
if (!inited.value) {
|
||||||
|
const prefill = Math.min(canRebate.value, 9999)
|
||||||
|
amount.value = prefill > 0 ? prefill.toFixed(2) : ''
|
||||||
|
inited.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCards() {
|
||||||
|
drawerLoading.value = true
|
||||||
|
getBankCards()
|
||||||
|
.then((res: any) => {
|
||||||
|
allCards.value = res?.data?.result || []
|
||||||
|
if (!selectedAccount.value && allCards.value.length) {
|
||||||
|
const def = allCards.value.find((a: any) => a.isDefault) || allCards.value[0]
|
||||||
|
selectedAccount.value = def
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
drawerLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
loadInfo()
|
||||||
|
loadCards()
|
||||||
|
})
|
||||||
|
|
||||||
|
function openAccountDrawer() {
|
||||||
|
drawerVisible.value = true
|
||||||
|
loadCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAccount(item: any) {
|
||||||
|
selectedAccount.value = item
|
||||||
|
drawerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
form.value = { holderName: '', bankName: '', cardNo: '' }
|
||||||
|
addPopupVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAccount() {
|
||||||
|
if (!form.value.holderName.trim()) {
|
||||||
|
uni.showToast({ title: '请输入姓名', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.bankName.trim()) {
|
||||||
|
uni.showToast({ title: '请输入银行名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.cardNo.trim()) {
|
||||||
|
uni.showToast({ title: '请输入银行卡号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveLoading.value = true
|
||||||
|
addBankCard({
|
||||||
|
holderName: form.value.holderName.trim(),
|
||||||
|
bankName: form.value.bankName.trim(),
|
||||||
|
cardNo: form.value.cardNo.trim(),
|
||||||
|
})
|
||||||
|
.then((res: any) => {
|
||||||
|
const newCard = res?.data?.result
|
||||||
|
addPopupVisible.value = false
|
||||||
|
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||||
|
loadCards()
|
||||||
|
if (newCard?.id) {
|
||||||
|
selectedAccount.value = newCard
|
||||||
|
drawerVisible.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg = err?.data?.message || '添加失败,请重试'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
saveLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAll() {
|
||||||
|
const max = Math.min(canRebate.value, 9999)
|
||||||
|
amount.value = max.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg = computed(() => {
|
||||||
|
if (!amount.value) return ''
|
||||||
|
const price = Number(amount.value)
|
||||||
|
if (price < 1) return '最小提现金额为1元'
|
||||||
|
if (price > 9999) return '单次提现最多9999元'
|
||||||
|
if (price > canRebate.value) return '提现金额不能超过可提现余额'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
const price = Number(amount.value)
|
||||||
|
return !submitLoading.value && price >= 1 && price <= 9999 && price <= canRebate.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function submitWithdraw() {
|
||||||
|
if (!selectedAccount.value) {
|
||||||
|
uni.showToast({ title: '请先添加到账账户', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
const price = Number(amount.value)
|
||||||
|
submitLoading.value = true
|
||||||
|
cash({ price })
|
||||||
|
.then(() => {
|
||||||
|
uni.showToast({ title: '提现申请已提交', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
const msg = err?.data?.message || '提现失败,请重试'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(val: number) {
|
||||||
|
return Number(val || 0).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskCard(cardNo: string) {
|
||||||
|
if (!cardNo || cardNo.length < 4) return cardNo
|
||||||
|
return '****' + cardNo.slice(-4)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content { padding: 24rpx; }
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-label { font-size: 28rpx; color: #333; font-weight: 500; }
|
||||||
|
.account-right { display: flex; align-items: center; gap: 8rpx; }
|
||||||
|
.account-info { display: flex; align-items: center; gap: 10rpx; }
|
||||||
|
.account-bank { font-size: 24rpx; color: #999; }
|
||||||
|
.account-card { font-size: 24rpx; color: #999; }
|
||||||
|
.account-add-text { font-size: 26rpx; color: var(--theme-primary); }
|
||||||
|
|
||||||
|
.amount-card { padding: 32rpx; }
|
||||||
|
|
||||||
|
.amount-label { font-size: 26rpx; color: #999; display: block; margin-bottom: 20rpx; }
|
||||||
|
|
||||||
|
.amount-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-symbol {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
line-height: 80rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 60rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
background: transparent;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-placeholder { font-size: 60rpx; color: #ddd; font-weight: 300; }
|
||||||
|
|
||||||
|
.amount-divider { height: 1rpx; background: #f5f5f5; margin-bottom: 20rpx; }
|
||||||
|
|
||||||
|
.amount-hint-row { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
|
||||||
|
.amount-balance-hint { font-size: 24rpx; color: #999; }
|
||||||
|
.amount-all { font-size: 24rpx; color: var(--theme-primary); }
|
||||||
|
|
||||||
|
.amount-error { display: block; font-size: 24rpx; color: #f56c6c; margin-top: 16rpx; }
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: var(--theme-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx var(--theme-primary-30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn--disabled { opacity: 0.5; box-shadow: none; }
|
||||||
|
|
||||||
|
/* ── 选择账户抽屉 ──────────────────────────── */
|
||||||
|
.drawer-wrap { padding: 0 0 48rpx; }
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-title { font-size: 30rpx; font-weight: 600; color: #333; }
|
||||||
|
|
||||||
|
.drawer-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-list { max-height: 600rpx; overflow-y: auto; }
|
||||||
|
|
||||||
|
.drawer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-circle {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2rpx solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&.active { border-color: var(--theme-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-dot {
|
||||||
|
width: 22rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-item-name { font-size: 28rpx; font-weight: 500; color: #333; }
|
||||||
|
|
||||||
|
.drawer-item-meta { display: flex; align-items: center; gap: 12rpx; }
|
||||||
|
|
||||||
|
.drawer-item-bank { font-size: 24rpx; color: #666; }
|
||||||
|
.drawer-item-card { font-size: 24rpx; color: #999; }
|
||||||
|
|
||||||
|
.drawer-state { padding: 60rpx 0; text-align: center; }
|
||||||
|
.drawer-state-text { font-size: 26rpx; color: #ccc; }
|
||||||
|
|
||||||
|
.drawer-add-btn {
|
||||||
|
margin: 24rpx 32rpx 0;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 2rpx solid var(--theme-primary);
|
||||||
|
color: var(--theme-primary);
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 添加账户弹窗 ──────────────────────────── */
|
||||||
|
.popup-wrap { padding: 32rpx 28rpx 48rpx; }
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item { display: flex; align-items: center; padding: 18rpx 0; }
|
||||||
|
|
||||||
|
.form-label { font-size: 28rpx; color: #333; width: 100rpx; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.form-input { flex: 1; font-size: 28rpx; color: #333; height: 64rpx; }
|
||||||
|
|
||||||
|
.form-placeholder { color: #ccc; font-size: 26rpx; }
|
||||||
|
|
||||||
|
.form-divider { height: 1rpx; background: #f5f5f5; }
|
||||||
|
|
||||||
|
.popup-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
|
||||||
|
|
||||||
|
.popup-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn.ghost { color: #666; background: #f5f5f5; }
|
||||||
|
.popup-btn.primary { color: #fff; background: var(--theme-primary); }
|
||||||
|
.popup-btn.disabled { opacity: 0.5; }
|
||||||
|
</style>
|
||||||
@@ -292,18 +292,9 @@ function waitPay(val: any) {
|
|||||||
|
|
||||||
function pay(val: any) {
|
function pay(val: any) {
|
||||||
if (val.sn) {
|
if (val.sn) {
|
||||||
// #ifdef MP-WEIXIN
|
|
||||||
new LiLiWXPay({
|
|
||||||
sn: val.sn,
|
|
||||||
price: val.flowPrice,
|
|
||||||
orderType: 'ORDER',
|
|
||||||
}).pay()
|
|
||||||
// #endif
|
|
||||||
// #ifndef MP-WEIXIN
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: '/pages/cart/payment/payOrder?order_sn=' + val.sn,
|
url: '/pages/cart/payment/payOrder?order_sn=' + val.sn,
|
||||||
})
|
})
|
||||||
// #endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vu
|
|||||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||||
import { getGoods, getGoodsList, getMpScene } from '@/api/goods.js'
|
import { getGoods, getGoodsList, getMpScene } from '@/api/goods.js'
|
||||||
import { recordDistributionGoodsVisit } from '@/api/distribution.js'
|
import { recordDistributionGoodsVisit } from '@/api/distribution.js'
|
||||||
|
import { tryBindDistribution } from '@/utils/distributionBind.js'
|
||||||
import * as API_trade from '@/api/trade.js'
|
import * as API_trade from '@/api/trade.js'
|
||||||
import * as API_Members from '@/api/members.js'
|
import * as API_Members from '@/api/members.js'
|
||||||
import * as API_store from '@/api/store.js'
|
import * as API_store from '@/api/store.js'
|
||||||
@@ -518,7 +519,10 @@ async function init(id: any, goodsId: any, distributionId = "", shareId = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const distId = distributionId || store.state.distributionId
|
const distId = distributionId || store.state.distributionId
|
||||||
if (distId && goodsId && id) {
|
if (distId) {
|
||||||
|
await tryBindDistribution(distId)
|
||||||
|
}
|
||||||
|
if (distId && goodsId && id && storage.getAccessToken()) {
|
||||||
recordDistributionGoodsVisit({
|
recordDistributionGoodsVisit({
|
||||||
skuId: id,
|
skuId: id,
|
||||||
goodsId,
|
goodsId,
|
||||||
|
|||||||
190
utils/distributionBind.js
Normal file
190
utils/distributionBind.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* 分销海报 / 商品分享扫码绑客(统一入口)
|
||||||
|
*/
|
||||||
|
import storage from '@/utils/storage.js'
|
||||||
|
import store from '@/store'
|
||||||
|
import { getGoodsDistribution } from '@/api/distribution.js'
|
||||||
|
import { getMpScene } from '@/api/goods.js'
|
||||||
|
|
||||||
|
const PENDING_KEY = 'pending_distribution_id'
|
||||||
|
|
||||||
|
/** 本次冷启动内已尝试绑定的分销员 ID,避免重复请求 */
|
||||||
|
const launchBoundIds = new Set()
|
||||||
|
|
||||||
|
let flushing = false
|
||||||
|
|
||||||
|
export function clearPendingDistributionId() {
|
||||||
|
try {
|
||||||
|
uni.removeStorageSync(PENDING_KEY)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPendingDistributionId(distributionId) {
|
||||||
|
if (!distributionId) return
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(PENDING_KEY, String(distributionId))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('set pending distributionId failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingDistributionId() {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync(PENDING_KEY) || ''
|
||||||
|
} catch (e) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetLaunchBindCache() {
|
||||||
|
launchBoundIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析葵花码 scene:支持 shortLinkId 或明文 bind,{distributionId}
|
||||||
|
*/
|
||||||
|
export async function resolveDistributionIdFromScene(scene) {
|
||||||
|
if (!scene) return ''
|
||||||
|
const raw = decodeURIComponent(String(scene)).trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
if (raw.startsWith('bind,')) {
|
||||||
|
const id = raw.split(',')[1]
|
||||||
|
return id ? String(id).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getMpScene(raw)
|
||||||
|
const params = res?.data?.success ? String(res.data.result || '').trim() : ''
|
||||||
|
if (params.startsWith('bind,')) {
|
||||||
|
const id = params.split(',')[1]
|
||||||
|
return id ? String(id).trim() : ''
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('resolve distribution scene failed', error)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecruitIdFromParams(params) {
|
||||||
|
if (!params) return ''
|
||||||
|
const text = String(params).trim()
|
||||||
|
if (text.startsWith('recruit_')) {
|
||||||
|
const id = text.substring('recruit_'.length)
|
||||||
|
return id ? String(id).trim() : ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析图文邀请卡 scene:支持 shortLinkId 或明文 recruit_{distributionId}
|
||||||
|
*/
|
||||||
|
export async function resolveRecruitDistributionIdFromScene(scene) {
|
||||||
|
if (!scene) return ''
|
||||||
|
const raw = decodeURIComponent(String(scene)).trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const direct = parseRecruitIdFromParams(raw)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getMpScene(raw)
|
||||||
|
const params = res?.data?.success ? String(res.data.result || '').trim() : ''
|
||||||
|
return parseRecruitIdFromParams(params)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('resolve recruit scene failed', error)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEAR_PENDING_CODES = new Set([22000, 22001])
|
||||||
|
|
||||||
|
function shouldClearPendingOnFailure(res) {
|
||||||
|
const code = res?.data?.code
|
||||||
|
if (code != null && CLEAR_PENDING_CODES.has(Number(code))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const message = String(res?.data?.message || '')
|
||||||
|
if (message.includes('分销员不存在') || message.includes('分销功能关闭')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试绑定分销员:已登录直接请求;未登录写入 pending
|
||||||
|
*/
|
||||||
|
export async function tryBindDistribution(distributionId, options = {}) {
|
||||||
|
const id = String(distributionId || '').trim()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const force = options.force === true
|
||||||
|
if (!force && launchBoundIds.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
launchBoundIds.add(id)
|
||||||
|
|
||||||
|
if (!storage.getAccessToken()) {
|
||||||
|
setPendingDistributionId(id)
|
||||||
|
store.state.distributionId = id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getGoodsDistribution(id)
|
||||||
|
if (res?.data?.success) {
|
||||||
|
clearPendingDistributionId()
|
||||||
|
store.state.distributionId = id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (shouldClearPendingOnFailure(res)) {
|
||||||
|
clearPendingDistributionId()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('bind distribution failed', error)
|
||||||
|
const res = error?.data ? error : error?.response?.data ? { data: error.response.data } : null
|
||||||
|
if (res && shouldClearPendingOnFailure(res)) {
|
||||||
|
clearPendingDistributionId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录成功后补绑 pending
|
||||||
|
*/
|
||||||
|
export async function flushPendingBind() {
|
||||||
|
if (flushing) return
|
||||||
|
const pendingId = getPendingDistributionId()
|
||||||
|
if (!pendingId || !storage.getAccessToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushing = true
|
||||||
|
try {
|
||||||
|
launchBoundIds.delete(pendingId)
|
||||||
|
await tryBindDistribution(pendingId, { force: true })
|
||||||
|
} finally {
|
||||||
|
flushing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App onLaunch:有 scene 则解析绑客;无 scene 清除 pending
|
||||||
|
*/
|
||||||
|
export async function handleAppLaunchScene(options) {
|
||||||
|
resetLaunchBindCache()
|
||||||
|
|
||||||
|
const scene = options?.query?.scene
|
||||||
|
if (!scene) {
|
||||||
|
clearPendingDistributionId()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributionId = await resolveDistributionIdFromScene(scene)
|
||||||
|
if (!distributionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await tryBindDistribution(distributionId)
|
||||||
|
}
|
||||||
289
utils/distributionPosterCanvas.ts
Normal file
289
utils/distributionPosterCanvas.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
import DrawPoster from '@/js_sdk/u-draw-poster'
|
||||||
|
|
||||||
|
export const POSTER_WIDTH = 630
|
||||||
|
export const POSTER_HEIGHT = 1000
|
||||||
|
|
||||||
|
const QR_SIZE = 180
|
||||||
|
const AVATAR_SIZE = 86
|
||||||
|
const PADDING_H = 24
|
||||||
|
const MEMBER_LEFT = PADDING_H + 16
|
||||||
|
const BOTTOM_RATIO = 0.06
|
||||||
|
const MEMBER_ROW_GAP = 12
|
||||||
|
const NAME_FONT_SIZE = 30
|
||||||
|
const SLOGAN_FONT_SIZE = 24
|
||||||
|
const NAME_LINE_HEIGHT = 38
|
||||||
|
const SLOGAN_LINE_HEIGHT = 32
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
const st2 = (size: number) => size * 2
|
||||||
|
// #endif
|
||||||
|
// #ifndef MP-WEIXIN
|
||||||
|
const st2 = (size: number) => size
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
export interface DistributionPosterOptions {
|
||||||
|
backgroundImage?: string
|
||||||
|
memberAvatar?: string
|
||||||
|
memberName?: string
|
||||||
|
slogan?: string
|
||||||
|
qrImage: string
|
||||||
|
textColor?: string
|
||||||
|
showMemberInfo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImagePath(url: string) {
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('图片地址无效')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.startsWith('data:') ||
|
||||||
|
url.startsWith('wxfile://') ||
|
||||||
|
url.startsWith('http://tmp/') ||
|
||||||
|
url.startsWith('https://tmp/') ||
|
||||||
|
url.startsWith('file://')
|
||||||
|
) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
const res = await uni.downloadFile({ url })
|
||||||
|
if (res.statusCode === 200 && res.tempFilePath) {
|
||||||
|
return res.tempFilePath
|
||||||
|
}
|
||||||
|
throw new Error('图片下载失败,请检查图片域名配置')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageInfo(src: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.getImageInfo({
|
||||||
|
src,
|
||||||
|
success: (res) => resolve({ width: res.width, height: res.height }),
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcCoverBox(imgW: number, imgH: number, boxW: number, boxH: number) {
|
||||||
|
const scale = Math.max(boxW / imgW, boxH / imgH)
|
||||||
|
const width = imgW * scale
|
||||||
|
const height = imgH * scale
|
||||||
|
return {
|
||||||
|
x: (boxW - width) / 2,
|
||||||
|
y: (boxH - height) / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDrawPosterCache(selector: string) {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const page = pages[pages.length - 1] as any
|
||||||
|
if (!page) return
|
||||||
|
delete page[`#${selector}__dp`]
|
||||||
|
delete page[`${selector}__dp`]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitCanvasReady() {
|
||||||
|
await nextTick()
|
||||||
|
await sleep(150)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawCircleImage(ctx: any, src: string, x: number, y: number, size: number) {
|
||||||
|
const radius = size / 2
|
||||||
|
const cx = x + radius
|
||||||
|
const cy = y + radius
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(st2(cx), st2(cy), st2(radius), 0, Math.PI * 2)
|
||||||
|
ctx.clip()
|
||||||
|
await ctx.drawImage(src, st2(x), st2(y), st2(size), st2(size))
|
||||||
|
ctx.restore()
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
|
||||||
|
ctx.lineWidth = st2(2)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(st2(cx), st2(cy), st2(radius - 1), 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function composeDistributionPoster(
|
||||||
|
selector: string,
|
||||||
|
componentThis: any,
|
||||||
|
options: DistributionPosterOptions
|
||||||
|
): Promise<string> {
|
||||||
|
const {
|
||||||
|
backgroundImage = '',
|
||||||
|
memberAvatar = '',
|
||||||
|
memberName = '分销员',
|
||||||
|
slogan = '',
|
||||||
|
qrImage,
|
||||||
|
textColor = '#1f2a44',
|
||||||
|
showMemberInfo = true,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!qrImage) {
|
||||||
|
throw new Error('葵花码未生成')
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDrawPosterCache(selector)
|
||||||
|
await waitCanvasReady()
|
||||||
|
|
||||||
|
const dp = await DrawPoster.build({
|
||||||
|
selector,
|
||||||
|
componentThis,
|
||||||
|
loading: false,
|
||||||
|
debugging: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dp?.canvas || !dp?.ctx) {
|
||||||
|
throw new Error('画布初始化失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
dp.canvas.width = st2(POSTER_WIDTH)
|
||||||
|
dp.canvas.height = st2(POSTER_HEIGHT)
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
const localQr = await resolveImagePath(qrImage)
|
||||||
|
let localBg = ''
|
||||||
|
if (backgroundImage) {
|
||||||
|
try {
|
||||||
|
localBg = await resolveImagePath(backgroundImage)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('poster background download failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let localAvatar = ''
|
||||||
|
if (showMemberInfo && memberAvatar) {
|
||||||
|
try {
|
||||||
|
localAvatar = await resolveImagePath(memberAvatar)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('poster avatar download failed', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bottomOffset = POSTER_HEIGHT * BOTTOM_RATIO
|
||||||
|
const rowBottom = POSTER_HEIGHT - bottomOffset
|
||||||
|
const qrX = POSTER_WIDTH - PADDING_H - QR_SIZE
|
||||||
|
const qrY = rowBottom - QR_SIZE
|
||||||
|
|
||||||
|
await dp.draw((ctx: any) => {
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRect(st2(0), st2(0), st2(POSTER_WIDTH), st2(POSTER_HEIGHT))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (localBg) {
|
||||||
|
await dp.draw(async (ctx: any) => {
|
||||||
|
const { width: imgW, height: imgH } = await getImageInfo(localBg)
|
||||||
|
const fit = calcCoverBox(imgW, imgH, POSTER_WIDTH, POSTER_HEIGHT)
|
||||||
|
await ctx.drawImage(
|
||||||
|
localBg,
|
||||||
|
st2(fit.x),
|
||||||
|
st2(fit.y),
|
||||||
|
st2(fit.width),
|
||||||
|
st2(fit.height)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await dp.draw(async (ctx: any) => {
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillRoundRect(st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE), st2(12))
|
||||||
|
await ctx.drawImage(localQr, st2(qrX), st2(qrY), st2(QR_SIZE), st2(QR_SIZE))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (showMemberInfo) {
|
||||||
|
await dp.draw(async (ctx: any) => {
|
||||||
|
const textMaxWidth = qrX - MEMBER_LEFT - 16
|
||||||
|
const memberBlockHeight =
|
||||||
|
AVATAR_SIZE +
|
||||||
|
MEMBER_ROW_GAP +
|
||||||
|
NAME_LINE_HEIGHT +
|
||||||
|
(slogan ? MEMBER_ROW_GAP + SLOGAN_LINE_HEIGHT : 0)
|
||||||
|
const blockHeight = Math.max(memberBlockHeight, QR_SIZE)
|
||||||
|
const blockTop = rowBottom - blockHeight
|
||||||
|
|
||||||
|
const avatarX = MEMBER_LEFT
|
||||||
|
const avatarY = blockTop
|
||||||
|
const nameY = avatarY + AVATAR_SIZE + MEMBER_ROW_GAP
|
||||||
|
const sloganY = nameY + NAME_LINE_HEIGHT + MEMBER_ROW_GAP
|
||||||
|
|
||||||
|
if (localAvatar) {
|
||||||
|
await drawCircleImage(ctx, localAvatar, avatarX, avatarY, AVATAR_SIZE)
|
||||||
|
} else {
|
||||||
|
const radius = AVATAR_SIZE / 2
|
||||||
|
ctx.save()
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(st2(avatarX + radius), st2(avatarY + radius), st2(radius), 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'
|
||||||
|
ctx.lineWidth = st2(2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
|
ctx.fillStyle = textColor
|
||||||
|
ctx.font = `bold ${st2(NAME_FONT_SIZE)}px PingFang SC`
|
||||||
|
ctx.fillWarpText({
|
||||||
|
text: memberName,
|
||||||
|
maxWidth: st2(textMaxWidth),
|
||||||
|
x: st2(avatarX),
|
||||||
|
y: st2(nameY),
|
||||||
|
layer: 1,
|
||||||
|
lineHeight: st2(NAME_LINE_HEIGHT),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (slogan) {
|
||||||
|
ctx.font = `${st2(SLOGAN_FONT_SIZE)}px PingFang SC`
|
||||||
|
ctx.fillWarpText({
|
||||||
|
text: slogan,
|
||||||
|
maxWidth: st2(textMaxWidth),
|
||||||
|
x: st2(avatarX),
|
||||||
|
y: st2(sloganY),
|
||||||
|
layer: 1,
|
||||||
|
lineHeight: st2(SLOGAN_LINE_HEIGHT),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = await dp.createImagePath()
|
||||||
|
if (!path || path === '---stop createImagePath---') {
|
||||||
|
throw new Error('海报图片导出失败')
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePosterToAlbum(filePath: string) {
|
||||||
|
if (!filePath) return
|
||||||
|
uni.saveImageToPhotosAlbum({
|
||||||
|
filePath,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '已保存到相册', icon: 'none' })
|
||||||
|
},
|
||||||
|
fail: (err: any) => {
|
||||||
|
const errMsg = err?.errMsg || ''
|
||||||
|
if (errMsg.includes('auth deny') || errMsg.includes('authorize')) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '需要您授权保存图片到相册',
|
||||||
|
confirmText: '去设置',
|
||||||
|
success: (modalRes) => {
|
||||||
|
if (modalRes.confirm) {
|
||||||
|
uni.openSetting({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -84,6 +84,14 @@ export default {
|
|||||||
// 写入登录
|
// 写入登录
|
||||||
setHasLogin(val) {
|
setHasLogin(val) {
|
||||||
uni.setStorageSync(HAS_LOGIN, val);
|
uni.setStorageSync(HAS_LOGIN, val);
|
||||||
|
if (val) {
|
||||||
|
try {
|
||||||
|
const { flushPendingBind } = require("@/utils/distributionBind.js");
|
||||||
|
flushPendingBind();
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// 获取是否登录
|
// 获取是否登录
|
||||||
getHasLogin() {
|
getHasLogin() {
|
||||||
|
|||||||
Reference in New Issue
Block a user