diff --git a/App.vue b/App.vue index 7d04e9d..ac86430 100644 --- a/App.vue +++ b/App.vue @@ -12,6 +12,7 @@ import { } from '@/utils/theme' import { onLaunch, onShow } from '@dcloudio/uni-app' import { useStore } from '@/store' +import { handleAppLaunchScene } from '@/utils/distributionBind.js' const store = useStore() @@ -21,6 +22,7 @@ wx.onAppRoute(() => {}) onLaunch((val) => { initTheme() + handleAppLaunchScene(val) if (val?.query?.inviter) { storage.setInviter(val.query.inviter) } diff --git a/README.md b/README.md index 6ec9b08..8951d6f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,15 @@ # Lilishop(PickMall) 开源商城系统 -[![GitHub Stars](https://img.shields.io/github/stars/hongyehuicheng/lilishop.svg?style=social&logo=github)](https://github.com/hongyehuicheng/lilishop) +[![GitHub Stars](https://img.shields.io/github/stars/lilishop/lilishop.svg?style=social&logo=github)](https://github.com/lilishop/lilishop) [![Gitee Stars](https://gitee.com/beijing_hongye_huicheng/lilishop/badge/star.svg?theme=dark)](https://gitee.com/beijing_hongye_huicheng/lilishop) +[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE) +[![Vue](https://img.shields.io/badge/Vue-3-brightgreen.svg)](https://vuejs.org/) +[![uni-app](https://img.shields.io/badge/uni--app-Vue3-blue.svg)](https://uniapp.dcloud.net.cn/) +

简体中文 | English

-## 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 私有化部署。 @@ -36,7 +17,7 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开 - Java商城系统 - 开源商城系统 -- SpringBoot商城系统 +- Spring Boot 4商城系统 - SpringCloud微服务商城 - B2B2C商城源码 - 多商户商城系统 @@ -58,14 +39,15 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开 ### 1. 项目简介 **Lilishop** 是一款功能完善的B2B2C多商户商城系统,采用前后端分离架构,全端代码开源。 -后端基于 **SpringBoot4** 构建,具备高内聚、低耦合的特性,支持分布式部署。 +后端基于 **Spring Boot 4**(Java 21)构建,具备高内聚、低耦合的特性,支持分布式部署。 前端覆盖PC、H5、小程序和APP,基于 **Vue** 和 **uni-app** 开发。 - **官方网站**: - **官方文档**: -- **Gitee 仓库**: +- **Gitee 仓库**: - **GitHub 仓库**: +本仓库为买家端 **H5 / 小程序 / APP** 源码(`lilishop-uniapp`)。 --- @@ -103,7 +85,7 @@ LILISHOP 是基于 Spring Boot / Spring Cloud / Vue / Uniapp 开发的 Java 开 [**部署文档 -> 环境准备**](https://docs.pickmall.cn/deply/deply.html) #### 数据库初始化 -- **推荐方式**: 使用项目提供的 `docker-compose` 配置,可自动完成数据库(MySQL, Redis, Elasticsearch等)的部署与初始化。 +- **推荐方式**: 使用项目提供的 `docker-compose` 配置,可自动完成数据库(MySQL、Redis 等)的部署与初始化。 - **手动方式**: 如果您选择手动部署,SQL脚本位于以下地址。请确保获取与您代码版本一致的SQL文件。 [**数据库脚本 (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` - 部署、SQL、镜像与启动脚本:`docker` -All-In-One 仅用于本地体验、演示或验证,不代表生产高可用部署架构。生产部署应按实际访问量、稳定性、监控、备份和扩缩容要求拆分数据库、缓存、搜索、消息、任务调度、前端静态资源和后端服务。 +--- ### 6. 功能列表 [功能清单](https://bdwx2tfwwt.feishu.cn/sheets/FFQKscQKDhUx60to1k9cbMngnYb?from=from_copylink) - - - +--- ### 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://work.weixin.qq.com/kfid/kfc4d8dc24a73c15f44)** -- **微信交流1群(已满)** -- **微信交流2群**: +- **微信交流群**: ![微信群](https://lilishop-wechat.oss-cn-beijing.aliyuncs.com/wechat.jpg) + +--- + +### 9. 升级计划 + +以下功能将陆续在后续版本中发布:✅ 已完成 · 🔜 规划中 + +- ✅ 商品定时上下架 +- ✅ 商品分组 +- ✅ 会员分组 +- ✅ 商品虚拟销量 +- ✅ 专用发票(发票上传) +- ✅ 置顶评论 +- ✅ 客户等级 +- ✅ 购物卡 +- ✅ 腾讯云-云直播 +- ✅ 统计:营业概况、商品概况、会员概况、积分分析、储值分析、营销概况、分销统计 +- ✅ 经营报表(店铺业绩报表、商品同比环比报表、销售订单明细报表、商品销售汇总报表) +- ✅ 卡密商品 +- ✅ 二级分销 +- ✅ 定时任务管理 +- 🔜 主题色管理 +- 🔜 店铺结算汇总 +- 🔜 支付方式汇总 +- 🔜 结算台账 +- 🔜 钱包流水 +- 🔜 财务汇总 +- 🔜 微信小店对接 +- 🔜 抖店对接 diff --git a/api/distribution.js b/api/distribution.js index 9d2a946..af87571 100644 --- a/api/distribution.js +++ b/api/distribution.js @@ -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) { return http.request({ diff --git a/components/m-buy/goods.vue b/components/m-buy/goods.vue index 23debb1..a0ee8f8 100644 --- a/components/m-buy/goods.vue +++ b/components/m-buy/goods.vue @@ -250,6 +250,8 @@ const buy = (data: any) => { url: `/pages/order/fillorder?way=${data.cartType}&addr=${''}&parentOrder=${encodeURIComponent(JSON.stringify(parentOrder.value))}` }) } + }).finally(() => { + uni.hideLoading() }) } diff --git a/components/m-search-revision/m-search-revision.vue b/components/m-search-revision/m-search-revision.vue index 712d34f..f188d01 100644 --- a/components/m-search-revision/m-search-revision.vue +++ b/components/m-search-revision/m-search-revision.vue @@ -1,23 +1,23 @@ @@ -37,25 +37,25 @@ +watch(inputVal, (newVal) => { + newVal ? (isDelShow.value = true) : (isDelShow.value = false) +}) + +watch(() => props.modelValue, (newVal) => { + if (newVal !== inputVal.value) { + inputVal.value = newVal || '' + } +}) + +defineExpose({ + isShowSeachGoods, + inputVal, + clear, + setInputValue, +}) + diff --git a/pages/mine/distribution/bind.vue b/pages/mine/distribution/bind.vue index bcf48a0..14b3d40 100644 --- a/pages/mine/distribution/bind.vue +++ b/pages/mine/distribution/bind.vue @@ -4,32 +4,20 @@ + + diff --git a/pages/mine/distribution/invite.vue b/pages/mine/distribution/invite.vue index 1e13275..599a572 100644 --- a/pages/mine/distribution/invite.vue +++ b/pages/mine/distribution/invite.vue @@ -9,74 +9,81 @@ 重新加载 - - - - - - {{ inviteContext.memberName || '分销员' }} - - - {{ inviteContext.slogan }} - - - - {{ qrLoadingText }} - - - {{ qrError }} - 重新生成 - - - + + + - - 长按图片保存海报 + 保存海报 + 点击预览,长按或保存到相册 - + + + + + + + + + + diff --git a/pages/mine/distribution/join.vue b/pages/mine/distribution/join.vue index a4ebe8b..3e82b37 100644 --- a/pages/mine/distribution/join.vue +++ b/pages/mine/distribution/join.vue @@ -4,7 +4,16 @@ 暂未开放线上招募 - + + + + + + 邀请您加入 + {{ inviterName }} + + + 加入条件 - 满足以下条件后可申请成为分销员 + + {{ isUserLoggedIn ? '满足以下条件后可申请成为分销员' : '登录后可查看您的达标进度并申请成为分销员' }} + @@ -87,7 +98,14 @@ - + + + + 立即登录 + + + + @@ -148,8 +166,13 @@ import { ref, reactive, computed } from 'vue' import { onLoad, onShow } from '@dcloudio/uni-app' import { useStore } from '@/store' import { getThemeStyle } from '@/utils/theme' -import { getRecruitPage, getRecruitProgress, submitRecruitApplication } from '@/api/distribution' -import { tipsToLogin } from '@/utils/filters.js' +import storage from '@/utils/storage.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 themeStyle = computed(() => getThemeStyle(store.state.theme)) @@ -166,6 +189,9 @@ const agreementVisible = ref(false) const applyFormVisible = ref(false) const formInfoCompleted = ref(false) const fieldErrors = reactive>({}) +const inviterInfo = ref(null) + +const isUserLoggedIn = computed(() => !!storage.getAccessToken()) const plan = computed(() => pageInfo.value.plan || null) const fields = computed(() => pageInfo.value.fields || []) @@ -219,12 +245,25 @@ const isManualApply = computed(() => { 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(() => { - return !showApplyResult.value && conditionsMet.value && isManualApply.value + return isUserLoggedIn.value && !showApplyResult.value && conditionsMet.value && isManualApply.value }) 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(() => { @@ -250,10 +289,48 @@ const consumeCountOk = computed(() => { }) -onLoad((query) => { - recruitToken.value = query?.token || query?.distributionId || '' +onLoad(async (query) => { + 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(() => { loadPageData() }) @@ -265,10 +342,20 @@ function loadPageData() { submitApplicationStatus.value = '' formInfoCompleted.value = false Object.keys(fieldErrors).forEach((key) => delete fieldErrors[key]) - Promise.all([getRecruitPage(), getRecruitProgress()]) - .then(([pageRes, progressRes]) => { - pageInfo.value = pageRes.data?.result || {} - progress.value = progressRes.data?.result || null + const tasks: Promise[] = [getRecruitPage()] + if (isUserLoggedIn.value) { + tasks.push(getRecruitProgress()) + } + 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(() => { loading.value = false @@ -401,6 +488,50 @@ function submit() { 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 { background: #fff; border-radius: 16rpx; diff --git a/pages/mine/distribution/poster.vue b/pages/mine/distribution/poster.vue index 3b720e2..dcaabc5 100644 --- a/pages/mine/distribution/poster.vue +++ b/pages/mine/distribution/poster.vue @@ -9,74 +9,72 @@ 重新加载 - - - - - - {{ posterContext.memberName || '分销员' }} - - - {{ posterContext.slogan }} - - - - {{ qrLoadingText }} - - - {{ qrError }} - 重新生成 - - - + + + 长按保存分享 + + - + + + + + + - 长按图片保存海报 - - diff --git a/pages/mine/distribution/wallet-log.vue b/pages/mine/distribution/wallet-log.vue new file mode 100644 index 0000000..97fd5cc --- /dev/null +++ b/pages/mine/distribution/wallet-log.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/pages/mine/distribution/withdraw-apply.vue b/pages/mine/distribution/withdraw-apply.vue new file mode 100644 index 0000000..fabbbb8 --- /dev/null +++ b/pages/mine/distribution/withdraw-apply.vue @@ -0,0 +1,502 @@ + + + + + diff --git a/pages/order/myOrder.vue b/pages/order/myOrder.vue index ebd6cd7..06a7b22 100644 --- a/pages/order/myOrder.vue +++ b/pages/order/myOrder.vue @@ -292,18 +292,9 @@ function waitPay(val: any) { function pay(val: any) { if (val.sn) { - // #ifdef MP-WEIXIN - new LiLiWXPay({ - sn: val.sn, - price: val.flowPrice, - orderType: 'ORDER', - }).pay() - // #endif - // #ifndef MP-WEIXIN uni.navigateTo({ url: '/pages/cart/payment/payOrder?order_sn=' + val.sn, }) - // #endif } } diff --git a/pages/product/goods.vue b/pages/product/goods.vue index 34b3fe9..adfcb4a 100644 --- a/pages/product/goods.vue +++ b/pages/product/goods.vue @@ -319,6 +319,7 @@ import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vu import { onLoad, onShow } from '@dcloudio/uni-app' import { getGoods, getGoodsList, getMpScene } from '@/api/goods.js' import { recordDistributionGoodsVisit } from '@/api/distribution.js' +import { tryBindDistribution } from '@/utils/distributionBind.js' import * as API_trade from '@/api/trade.js' import * as API_Members from '@/api/members.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 - if (distId && goodsId && id) { + if (distId) { + await tryBindDistribution(distId) + } + if (distId && goodsId && id && storage.getAccessToken()) { recordDistributionGoodsVisit({ skuId: id, goodsId, diff --git a/utils/distributionBind.js b/utils/distributionBind.js new file mode 100644 index 0000000..add8060 --- /dev/null +++ b/utils/distributionBind.js @@ -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) +} diff --git a/utils/distributionPosterCanvas.ts b/utils/distributionPosterCanvas.ts new file mode 100644 index 0000000..658b534 --- /dev/null +++ b/utils/distributionPosterCanvas.ts @@ -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 { + 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' }) + }, + }) +} diff --git a/utils/storage.js b/utils/storage.js index 053390e..9004538 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -9,13 +9,13 @@ const FACE_LOGIN = isDev ? "face_login_dev" : "face_login"; const FINGER_LOGIN = isDev ? "finger_login_dev" : "finger_login"; const CART_BACKBTN = isDev ? "cart_backbtn_dev" : "cart_backbtn"; const AFTERSALE_DATA = isDev ? "aftersale_data_dev" : "aftersale_data"; -export default { - setInviter(val){ - uni.setStorageSync('inviter', val) - }, - getInviter(){ - return uni.getStorageSync('inviter'); - }, +export default { + setInviter(val){ + uni.setStorageSync('inviter', val) + }, + getInviter(){ + return uni.getStorageSync('inviter'); + }, //写入自动发券 setAutoCp(val){ @@ -84,6 +84,14 @@ export default { // 写入登录 setHasLogin(val) { uni.setStorageSync(HAS_LOGIN, val); + if (val) { + try { + const { flushPendingBind } = require("@/utils/distributionBind.js"); + flushPendingBind(); + } catch (e) { + // ignore + } + } }, // 获取是否登录 getHasLogin() {