diff --git a/application/api/controller/AdminMember.php b/application/api/controller/AdminMember.php index 3daf99c..2c2c58d 100644 --- a/application/api/controller/AdminMember.php +++ b/application/api/controller/AdminMember.php @@ -41,7 +41,7 @@ class AdminMember extends V1 $list = []; foreach ($rows as $row) { $isMember = (int)$row['is_member'] === 1 - && (!$row['member_expiretime'] || (int)$row['member_expiretime'] > $now); + && !empty($row['member_expiretime']) && (int)$row['member_expiretime'] > $now; $list[] = [ 'id' => (int)$row['id'], 'nickname' => (string)$row['nickname'], diff --git a/application/api/controller/Distribution.php b/application/api/controller/Distribution.php index 567bdfd..5f1432d 100644 --- a/application/api/controller/Distribution.php +++ b/application/api/controller/Distribution.php @@ -9,14 +9,19 @@ use think\Db; * 路由: * GET /api/v1/distribution/overview D1 分销概览 * GET /api/v1/distribution/team D2 我的团队 - * POST /api/v1/distribution/withdraw D3 申请提现 - * GET /api/v1/distribution/withdraw-records D4 提现记录 + * GET /api/v1/distribution/commissions D3 分销收益明细 + * GET /api/v1/distribution/poster D4 推广海报要素 + * POST /api/v1/distribution/withdraw D5 申请提现 + * GET /api/v1/distribution/withdraw-records D6 提现记录 */ class Distribution extends V1 { /** * D1 分销概览 - * GET /api/v1/distribution/overview → {totalCommission,settled,withdrawable,todayCommission,teamCount,l1Count,l2Count,inviteCode,posterUrl,minWithdraw} + * GET /api/v1/distribution/overview + * 获取用户分销核心资产数据、团队人数与分销基础配置 + * + * @return void 输出包含累计佣金、已结算、待结算、可提现、今日收益、邀请码及分销规则配置的JSON响应 */ public function overview() { @@ -38,35 +43,45 @@ class Distribution extends V1 $l2 = (int)Db::name('fc_dist_relation')->where('parent_id', $uid)->where('level', 2)->count(); $ext = $this->ext($uid); + $code = (string)$ext['invite_code']; + $shareUrl = 'https://fenleixinxi.gzyakj.com.cn/#/pages/index/index?ic=' . urlencode($code); // 商家转账确认收款页需要的商户号与小程序 AppID(非敏感,可下发) $epay = (array)get_addon_config('epay'); $wx = isset($epay['wechat']) && is_array($epay['wechat']) ? $epay['wechat'] : []; $this->ok([ - 'totalCommission' => round($total, 2), - 'settled' => round($settled, 2), + 'totalCommission' => round($total, 2), + 'settled' => round($settled, 2), 'pendingCommission' => round($pending, 2), - 'withdrawable' => round(max(0, $withdrawable), 2), - 'todayCommission' => round($todayCommission, 2), - 'teamCount' => $l1 + $l2, - 'l1Count' => $l1, - 'l2Count' => $l2, - 'inviteCode' => (string)$ext['invite_code'], - 'posterUrl' => '', - // 最低提现金额(后台「分销设置」下发,替代前端写死的 50) - 'minWithdraw' => (float)$this->cfg('dist.minWithdraw', 50), + 'withdrawable' => round(max(0, $withdrawable), 2), + 'todayCommission' => round($todayCommission, 2), + 'teamCount' => $l1 + $l2, + 'l1Count' => $l1, + 'l2Count' => $l2, + 'inviteCode' => $code, + 'shareUrl' => $shareUrl, + 'posterUrl' => '', + // 最低提现金额(后台「分销设置」下发,默认 50) + 'minWithdraw' => (float)$this->cfg('dist.minWithdraw', 50), + // 分销比例与配置规则 + 'level1Rate' => (float)$this->cfg('dist.level1Rate', 20), + 'level2Rate' => (float)$this->cfg('dist.level2Rate', 10), + 'settleCycle' => (string)$this->cfg('dist.settleCycle', 'T+7'), + 'enabled' => (int)$this->cfg('dist.enabled', 1), // 提现收款人实名(未填写时前端需先采集) - 'realname' => isset($ext['realname']) ? (string)$ext['realname'] : '', - 'transferMchId' => isset($wx['mch_id']) ? (string)$wx['mch_id'] : '', - 'transferAppId' => isset($wx['miniapp_id']) ? (string)$wx['miniapp_id'] : '', + 'realname' => isset($ext['realname']) ? (string)$ext['realname'] : '', + 'transferMchId' => isset($wx['mch_id']) ? (string)$wx['mch_id'] : '', + 'transferAppId' => isset($wx['miniapp_id']) ? (string)$wx['miniapp_id'] : '', ]); } /** * D2 我的团队 * GET /api/v1/distribution/team 入参 {level?,page,size} - * → 分页 {list:[{id,nickname,level,joinedAt,performance,commission}],page,size,total,hasMore} + * 分页查询直推(1级)和间推(2级)下线团队成员 + * + * @return void 输出团队成员分页列表JSON */ public function team() { @@ -85,19 +100,31 @@ class Distribution extends V1 if ($forCount) { return $q; } - return $q->field('r.id,r.user_id,r.level,r.createtime,u.nickname') + return $q->field('r.id,r.user_id,r.level,r.createtime,u.nickname,u.avatar,u.mobile') ->order('r.createtime desc'); }); $list = []; foreach ($rows as $row) { $childId = (int)$row['user_id']; - $commission = (float)Db::name('fc_commission')->where('from_user_id', $childId)->sum('amount'); - $orderAmount = (float)Db::name('fc_order')->where('user_id', $childId)->where('status', 'paid')->sum('amount'); + $commission = (float)Db::name('fc_commission') + ->where('user_id', $uid) + ->where('from_user_id', $childId) + ->sum('amount'); + $orderAmount = (float)Db::name('fc_order') + ->where('user_id', $childId) + ->where('status', 'paid') + ->sum('amount'); $performance = $orderAmount > 0 ? $orderAmount : $commission * 5; + + $mobile = (string)(isset($row['mobile']) ? $row['mobile'] : ''); + $maskedMobile = (strlen($mobile) === 11) ? substr($mobile, 0, 3) . '****' . substr($mobile, -4) : ''; + $list[] = [ 'id' => $childId, 'nickname' => (string)$row['nickname'], + 'avatar' => (string)(isset($row['avatar']) ? $row['avatar'] : ''), + 'mobile' => $maskedMobile, 'level' => (int)$row['level'], 'joinedAt' => $this->iso($row['createtime']), 'performance' => round($performance, 2), @@ -109,9 +136,121 @@ class Distribution extends V1 } /** - * D3 申请提现 + * D3 分销收益明细(佣金流水账单) + * GET /api/v1/distribution/commissions 入参 {status?,level?,page,size} + * 分页查询下线消费产生的佣金记录明细及结算状态 + * + * @return void 输出佣金明细列表与汇总统计JSON + */ + public function commissions() + { + $user = $this->auth(); + $uid = (int)$user['id']; + $status = $this->input('status', ''); + $level = $this->input('level', ''); + + list($rows, $total, $page, $size) = $this->pageQuery(function ($forCount) use ($uid, $status, $level) { + $q = Db::name('fc_commission')->alias('c') + ->join('user u', 'u.id = c.from_user_id', 'LEFT') + ->join('fc_order o', 'o.order_no = c.order_no', 'LEFT') + ->where('c.user_id', $uid); + + if ($status !== '' && $status !== null) { + $q->where('c.status', (int)$status); + } + if ($level !== '' && $level !== null) { + $q->where('c.level', (int)$level); + } + if ($forCount) { + return $q; + } + return $q->field('c.*,u.nickname as from_nickname,u.avatar as from_avatar,o.title as order_title,o.amount as order_amount') + ->order('c.createtime desc'); + }); + + $typeMap = [ + 'post' => '信息发布/置顶', + 'member' => 'VIP会员购买', + 'payview' => '付费查看信息', + 'refresh' => '信息一键刷新', + 'merchant' => '商家认证入驻', + ]; + + $statusMap = [ + 0 => '待结算', + 1 => '已结算', + 2 => '已失效', + ]; + + $list = []; + foreach ($rows as $row) { + $orderType = (string)(isset($row['order_type']) ? $row['order_type'] : 'post'); + $st = (int)$row['status']; + $list[] = [ + 'id' => (int)$row['id'], + 'orderNo' => (string)$row['order_no'], + 'orderType' => $orderType, + 'orderTypeName' => isset($typeMap[$orderType]) ? $typeMap[$orderType] : (isset($row['order_title']) && $row['order_title'] ? $row['order_title'] : '同城消费'), + 'orderAmount' => round((float)(isset($row['order_amount']) ? $row['order_amount'] : 0), 2), + 'amount' => round((float)$row['amount'], 2), + 'rate' => (float)$row['rate'], + 'level' => (int)$row['level'], + 'status' => $st, + 'statusText' => isset($statusMap[$st]) ? $statusMap[$st] : '处理中', + 'settleDue' => !empty($row['settle_due']) ? $this->iso($row['settle_due']) : '', + 'settletime' => !empty($row['settletime']) ? $this->iso($row['settletime']) : '', + 'createdAt' => $this->iso($row['createtime']), + 'fromUser' => [ + 'id' => (int)$row['from_user_id'], + 'nickname' => (string)(isset($row['from_nickname']) && $row['from_nickname'] !== '' ? $row['from_nickname'] : '同城好友'), + 'avatar' => (string)(isset($row['from_avatar']) ? $row['from_avatar'] : ''), + ], + ]; + } + + $res = $this->paginate($list, $total, $page, $size); + $res['summary'] = [ + 'totalCommission' => round((float)Db::name('fc_commission')->where('user_id', $uid)->where('status', '<>', 2)->sum('amount'), 2), + 'settledCommission' => round((float)Db::name('fc_commission')->where('user_id', $uid)->where('status', 1)->sum('amount'), 2), + 'pendingCommission' => round((float)Db::name('fc_commission')->where('user_id', $uid)->where('status', 0)->sum('amount'), 2), + ]; + + $this->ok($res); + } + + /** + * D4 分销海报生成要素 + * GET /api/v1/distribution/poster + * 获取用于前端合成或分享专属推广海报的元数据 + * + * @return void 输出海报配置与素材信息的JSON + */ + public function poster() + { + $user = $this->auth(); + $uid = (int)$user['id']; + $ext = $this->ext($uid); + + $siteName = (string)$this->cfg('app.name', '同城分类信息'); + $slogan = (string)$this->cfg('dist.slogan', '同城便民生活 · 随时随地发布与求助'); + $code = (string)$ext['invite_code']; + + $this->ok([ + 'inviteCode' => $code, + 'siteName' => $siteName, + 'slogan' => $slogan, + 'nickname' => (string)(isset($user['nickname']) && $user['nickname'] !== '' ? $user['nickname'] : '同城合伙人'), + 'avatar' => (string)(isset($user['avatar']) ? $user['avatar'] : ''), + 'path' => 'pages/index/index?ic=' . $code, + ]); + } + + /** + * D5 申请提现 * POST /api/v1/distribution/withdraw 入参 {amount,channel?} → {withdrawNo,status:'auditing'} * 规则 BR-09:金额必填且 >0、且不超过可提现余额 + * + * @return void 输出提现单号与状态JSON */ public function withdraw() { @@ -172,8 +311,11 @@ class Distribution extends V1 } /** - * D4 提现记录 + * D6 提现记录 * GET /api/v1/distribution/withdraw-records → [{id,withdrawNo,amount,status,createdAt}] + * 获取当前用户的历史提现记录与商家转账确认参数 + * + * @return void 输出提现历史记录列表JSON */ public function withdrawRecords() { @@ -199,7 +341,11 @@ class Distribution extends V1 } /** + * 计算当前用户可提现余额 * 可提现余额 = Σ已结算佣金(status=1) - Σ(申请中/已打款)提现 + * + * @param int $uid 用户ID + * @return float 可提现余额数值 */ protected function withdrawable($uid) { @@ -208,4 +354,4 @@ class Distribution extends V1 ->where('status', 'in', ['auditing', 'paying', 'paid'])->sum('amount'); return $settled - $applied; } -} +} \ No newline at end of file diff --git a/application/api/controller/Member.php b/application/api/controller/Member.php index e057070..15d8390 100644 --- a/application/api/controller/Member.php +++ b/application/api/controller/Member.php @@ -83,7 +83,7 @@ class Member extends V1 $now = time(); $ext = $this->ext($me['id']); $isMember = (int)$ext['is_member'] === 1 - && (empty($ext['member_expiretime']) || (int)$ext['member_expiretime'] > $now); + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $today = date('Y-m-d'); $freeViewUsed = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0; $freeViewLeft = $isMember ? max(0, $freeViewQuota - $freeViewUsed) : 0; @@ -123,7 +123,10 @@ class Member extends V1 $this->fail('请选择会员套餐'); } - $payType = (string)$this->input('payType', ''); + $payType = (string)$this->input('payType', 'wxpay'); + if ($payType === '') { + $payType = 'wxpay'; + } if (!in_array($payType, ['wxpay', 'balance'], true)) { $this->fail('支付方式不支持'); } diff --git a/application/api/controller/Merchant.php b/application/api/controller/Merchant.php index 1496894..4192d3d 100644 --- a/application/api/controller/Merchant.php +++ b/application/api/controller/Merchant.php @@ -62,6 +62,35 @@ class Merchant extends V1 // 首屏拉取前 6 条该商家的有效发帖 $posts = $this->fetchMerchantPosts($m['user_id'], 1, 6); + // 严格保护商家联系电话:未付费解锁或非会员状态下脱敏,杜绝从信息详情页跳转商家主页白嫖绕过 + $rawContact = (string)(isset($m['contact']) ? $m['contact'] : ''); + $unlocked = false; + $me = $this->user(); + if ($me) { + $now = time(); + $ext = $this->ext($me['id']); + $isMember = (int)$ext['is_member'] === 1 + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; + $isOwner = (int)$m['user_id'] === (int)$me['id']; + if ($isOwner || $isMember) { + $unlocked = true; + } else { + // 检查是否从已解锁的关联信息跳转而来 + $postId = (int)$this->input('postId', 0); + if ($postId > 0) { + $paidView = Db::name('fc_pay_view') + ->where('user_id', $me['id']) + ->where('post_id', $postId) + ->find(); + if ($paidView) { + $unlocked = true; + } + } + } + } + + $displayContact = $unlocked ? $rawContact : $this->maskPhone($rawContact); + $data = [ 'id' => (int)$m['id'], 'userId' => (int)$m['user_id'], @@ -71,7 +100,8 @@ class Merchant extends V1 'address' => (string)(isset($m['address']) ? $m['address'] : ''), 'lat' => (isset($m['lat']) && $m['lat'] !== null && $m['lat'] !== '') ? (float)$m['lat'] : null, 'lng' => (isset($m['lng']) && $m['lng'] !== null && $m['lng'] !== '') ? (float)$m['lng'] : null, - 'contact' => (string)(isset($m['contact']) ? $m['contact'] : ''), + 'contact' => $displayContact, + 'unlocked' => $unlocked, 'category' => (string)(isset($m['category']) ? $m['category'] : ''), 'notice' => (string)(isset($m['notice']) ? $m['notice'] : ''), 'auditStatus' => (int)$m['audit_status'], diff --git a/application/api/controller/Post.php b/application/api/controller/Post.php index d53ba0a..c22f1ac 100644 --- a/application/api/controller/Post.php +++ b/application/api/controller/Post.php @@ -186,7 +186,10 @@ class Post extends V1 if ($plan === 'single') { // 单条付费 = 付费发布标准(免费发布条数用完后即按此计费);会员按折扣率计价 $amount = $unitPrice; - $payType = trim((string)$this->input('payType', '')); + $payType = trim((string)$this->input('payType', 'wxpay')); + if ($payType === '') { + $payType = 'wxpay'; + } if ($amount > 0 && !in_array($payType, ['wxpay', 'balance'], true)) { $this->fail('支付方式不支持'); } @@ -505,7 +508,7 @@ class Post extends V1 'freeQuotaLeft' => $free['unlimited'] ? -1 : $free['left'], 'freeUnlimited' => $free['unlimited'], 'quotaExpireAt' => $quotaExpire > 0 ? $this->iso($quotaExpire) : '', - 'isMember' => (int)$ext['is_member'] === 1 && ($memberExpire === 0 || $memberExpire > $now), + 'isMember' => (int)$ext['is_member'] === 1 && !empty($memberExpire) && $memberExpire > $now, 'memberExpireAt' => $memberExpire > 0 ? $this->iso($memberExpire) : '', ]); } @@ -854,7 +857,7 @@ class Post extends V1 if ($me) { $ext = $this->ext($me['id']); $isMember = (int)$ext['is_member'] === 1 - && (empty($ext['member_expiretime']) || (int)$ext['member_expiretime'] > $now); + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $isOwner = (int)$post['user_id'] === (int)$me['id']; $paid = (bool)Db::name('fc_pay_view') ->where('user_id', $me['id']) @@ -974,18 +977,33 @@ class Post extends V1 $this->fail('信息不存在', 404); } + $phone = (string)$post['phone']; + $wechat = $post['wechat'] !== '' ? (string)$post['wechat'] : ('tongcheng_' . $id); + $contactName = (string)(isset($post['contact']) ? $post['contact'] : ''); + $contact = [ - 'phone' => (string)$post['phone'], - 'wechat' => $post['wechat'] !== '' ? (string)$post['wechat'] : ('tongcheng_' . $id), + 'name' => $contactName, + 'phone' => $phone, + 'wechat' => $wechat, ]; - // 已解锁直接返回 + // 已解锁直接返回(严格返回纯净扁平字段,杜绝 JSON 嵌套导致前端格式错乱) $exist = Db::name('fc_pay_view') ->where('user_id', $user['id']) ->where('post_id', $id) ->find(); if ($exist) { - $this->ok(['unlocked' => true, 'contact' => $contact]); + $today = date('Y-m-d'); + $usedToday = ((string)$ext['free_view_date'] === $today) ? (int)$ext['free_view_used'] : 0; + $this->ok([ + 'unlocked' => true, + 'phone' => $phone, + 'contact' => $phone, + 'contactName' => $contactName, + 'wechat' => $wechat, + 'contactInfo' => $contact, + 'freeViewLeft' => $isMember ? max(0, $quota - $usedToday) : 0, + ]); } $amount = round((float)$this->cfg('fee.payView', 2), 2); @@ -993,7 +1011,7 @@ class Post extends V1 $ext = $this->ext($user['id']); $isMember = (int)$ext['is_member'] === 1 - && (!$ext['member_expiretime'] || $ext['member_expiretime'] > $now); + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $quota = $this->memberFreeViewQuota(); $orderId = 0; @@ -1070,7 +1088,11 @@ class Post extends V1 $this->ok([ 'unlocked' => true, - 'contact' => $contact, + 'phone' => $phone, + 'contact' => $phone, + 'contactName' => $contactName, + 'wechat' => $wechat, + 'contactInfo' => $contact, 'freeViewLeft' => $isMember ? max(0, $quota - $usedNow) : 0, 'usedFree' => isset($usedFree) ? $usedFree : false, ]); @@ -1362,7 +1384,7 @@ class Post extends V1 { $now = time(); $isMember = (int)$ext['is_member'] === 1 - && (empty($ext['member_expiretime']) || (int)$ext['member_expiretime'] > $now); + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; if (!$isMember) { return 1.0; } diff --git a/application/api/controller/V1.php b/application/api/controller/V1.php index 1510763..fa04d4d 100644 --- a/application/api/controller/V1.php +++ b/application/api/controller/V1.php @@ -558,7 +558,7 @@ class V1 extends Api $user = $user ?: $this->auth(); $ext = $this->ext($user['id']); $isMember = (int)$ext['is_member'] === 1 - && (!$ext['member_expiretime'] || $ext['member_expiretime'] > time()); + && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > time(); // 查询商家认证状态与商铺资料 $merchant = Db::name('fc_merchant')->where('user_id', $user['id'])->find(); $isMerchant = $merchant && (int)$merchant['audit_status'] === 1 && $merchant['shop_status'] === 'normal'; diff --git a/application/route.php b/application/route.php index b1eb482..a33589c 100755 --- a/application/route.php +++ b/application/route.php @@ -95,6 +95,8 @@ return [ /* =============== 五、用户端 · 分销中心(Module-DIST) =============== */ 'api/v1/distribution/overview' => 'api/Distribution/overview', 'api/v1/distribution/team' => 'api/Distribution/team', + 'api/v1/distribution/commissions' => 'api/Distribution/commissions', + 'api/v1/distribution/poster' => 'api/Distribution/poster', 'api/v1/distribution/withdraw' => 'api/Distribution/withdraw', 'api/v1/distribution/withdraw-records' => 'api/Distribution/withdrawRecords', diff --git a/uniapp/App.vue b/uniapp/App.vue index 33371d9..55bfa59 100644 --- a/uniapp/App.vue +++ b/uniapp/App.vue @@ -12,10 +12,33 @@ export default { console.log('App Hide') }, methods: { + /** + * 捕获并解析邀请分销参数(支持普通 query 与微信小程序码 scene 场景值) + * @param {Object} options 小程序启动或页面入参 + */ captureInvite(options) { const query = (options && options.query) || {} - const ic = query.ic || query.inviteCode || query.invite_code - const inviterId = query.inviterId || query.inviter_id + let ic = query.ic || query.inviteCode || query.invite_code + let inviterId = query.inviterId || query.inviter_id + + // 解析微信小程序码携带的 scene 场景值(如 scene=ic_ABCD 或 scene=ABCD 或 scene=ic=ABCD) + if (!ic && query.scene) { + try { + const sceneStr = decodeURIComponent(query.scene) + if (sceneStr.indexOf('ic_') === 0) { + ic = sceneStr.slice(3) + } else if (sceneStr.indexOf('ic=') === 0) { + ic = sceneStr.slice(3) + } else if (sceneStr.indexOf('uid_') === 0) { + inviterId = sceneStr.slice(4) + } else if (sceneStr.indexOf('uid=') === 0) { + inviterId = sceneStr.slice(4) + } else if (/^[A-Za-z0-9]{4,16}$/.test(sceneStr)) { + ic = sceneStr + } + } catch (e) {} + } + if (ic) { uni.setStorageSync('fc_invite_code', String(ic)) } diff --git a/uniapp/README.md b/uniapp/README.md index 725e177..5c9a237 100644 --- a/uniapp/README.md +++ b/uniapp/README.md @@ -305,7 +305,7 @@ rm -rf /tmp/fcmp && cp -R "$OUTPUT_DIR" /tmp/fcmp ```js // 'balance' 余额支付 / 'wxpay' 微信支付 -defaultPayType: 'balance', +defaultPayType: 'wxpay', ``` 配置好真实商户参数后改回 `'wxpay'` 即可,**各页面无需改动**(三个支付点都读这个值)。 diff --git a/uniapp/common/api/distribution.js b/uniapp/common/api/distribution.js index df37dd2..252c37b 100644 --- a/uniapp/common/api/distribution.js +++ b/uniapp/common/api/distribution.js @@ -1,14 +1,18 @@ /** * 接口模块 · 分销中心(用户端) - * 对应模块:分销首页 / 团队管理 / 佣金提现 + * 对应模块:分销首页 / 团队管理 / 收益明细 / 推广海报 / 佣金提现 */ import { get, post } from '../utils/request.js' export const distributionApi = { - /** 佣金概览(含邀请码/海报) */ + /** 佣金概览(含邀请码/分销规则) */ overview: () => get('/distribution/overview', {}, { silent: true }), /** 一/二级下线列表与业绩 */ team: (params = {}) => get('/distribution/team', params, { silent: true }), + /** 分销收益明细账单(佣金流水) */ + commissions: (params = {}) => get('/distribution/commissions', params, { silent: true }), + /** 专属推广海报要素 */ + poster: () => get('/distribution/poster', {}, { silent: true }), /** 申请提现(提现至微信零钱) */ withdraw: (payload) => post('/distribution/withdraw', payload, { silent: true }), /** 提现记录 */ diff --git a/uniapp/common/config/index.js b/uniapp/common/config/index.js index 824815c..7c87cd0 100644 --- a/uniapp/common/config/index.js +++ b/uniapp/common/config/index.js @@ -75,9 +75,7 @@ export const config = { // 强制使用 mock(联调前本地联调可置 true) forceMock: false, // 默认支付方式:'balance' 余额支付 / 'wxpay' 微信支付 - // ⚠️ 微信支付商户参数(mchId/apiKey)尚未配置,测试阶段默认走余额支付; - // 配置好真实商户参数后改回 'wxpay' 即可,无需改动各页面。 - defaultPayType: 'balance', + defaultPayType: 'wxpay', // 本地存储键 storageKeys: { token: 'fc_token', diff --git a/uniapp/common/utils/request.js b/uniapp/common/utils/request.js index 8969edd..7354b7e 100644 --- a/uniapp/common/utils/request.js +++ b/uniapp/common/utils/request.js @@ -129,7 +129,16 @@ export function request(options = {}) { { url, method, data, silent, runtime, resolve, reject } ) } - const body = res.data || {} + let body = res.data || {} + // 若真机或网关未自动反序列化 JSON,或响应携带 UTF-8 BOM 字符,做安全容错剥离并反序列化 + if (typeof body === 'string') { + try { + const cleaned = body.replace(/^\uFEFF/, '').trim() + body = JSON.parse(cleaned) + } catch (e) { + // 保留原 body + } + } // 业务层 401 判定 if (body.code === 401) { onUnauthorized(runtime) diff --git a/uniapp/pages.json b/uniapp/pages.json index fd34142..a4df566 100644 --- a/uniapp/pages.json +++ b/uniapp/pages.json @@ -153,7 +153,8 @@ "style": { "navigationBarTitleText": "分销中心", "navigationStyle": "custom", - "backgroundColor": "#f2fef7" + "backgroundColor": "#f2fef7", + "enablePullDownRefresh": true } }, { @@ -248,4 +249,4 @@ ] }, "uniIdRouter": {} -} +} \ No newline at end of file diff --git a/uniapp/pages/detail/detail.vue b/uniapp/pages/detail/detail.vue index 81411c6..a729bc9 100644 --- a/uniapp/pages/detail/detail.vue +++ b/uniapp/pages/detail/detail.vue @@ -477,12 +477,12 @@ - + @@ -671,6 +671,7 @@ export default { }, onLoad(query) { this.id = Number(query.id || 101) + this.posterImagePath = '' loadUiConfig() this.fetch() }, @@ -813,11 +814,29 @@ export default { if (res.freeViewLeft != null) this.freeViewLeft = Number(res.freeViewLeft) if (res.payViewPrice != null) this.payViewPrice = Number(res.payViewPrice) } + let phoneStr = '' + let wechatStr = '' + let nameStr = '' + if (this.unlocked && res) { + if (res.contactInfo && typeof res.contactInfo === 'object') { + phoneStr = String(res.contactInfo.phone || '') + wechatStr = String(res.contactInfo.wechat || '') + nameStr = String(res.contactInfo.name || res.contactName || '') + } else if (res.contact && typeof res.contact === 'object') { + phoneStr = String(res.contact.phone || '') + wechatStr = String(res.contact.wechat || '') + nameStr = String(res.contact.name || res.contactName || '') + } else { + phoneStr = String(res.phone || (typeof res.contact === 'string' ? res.contact : '')) + wechatStr = String(res.wechat || '') + nameStr = String(res.contactName || (res.publisher && res.publisher.name) || '') + } + } this.contact = this.unlocked ? { - name: (res && (res.contactName || res.publisher?.name)) || '', - phone: (res && (res.phone || res.contact)) || '', - wechat: (res && res.wechat) || '', + name: nameStr, + phone: phoneStr, + wechat: wechatStr, } : { name: '', phone: '', wechat: '' } @@ -1096,7 +1115,7 @@ export default { return } uni.navigateTo({ - url: '/pages/merchant/detail?id=' + shopId, + url: `/pages/merchant/detail?id=${shopId}&postId=${this.id}`, fail: (err) => { console.warn('跳转商家详情失败:', err) } @@ -1170,7 +1189,7 @@ export default { }) }, /** - * 打开海报弹窗并异步生成海报 + * 打开海报弹窗并按需异步生成海报 */ openPosterModal() { this.showPoster = true @@ -1179,8 +1198,9 @@ export default { } }, /** - * 辅助函数:异步获取图片本地路径(网络图片需下载) - * @param {String} src 图片链接 + * 辅助函数:异步获取图片本地可用路径(网络图片自动缓存到临时路径) + * @param {String} src 图片 URL + * @returns {Promise} */ getImageLocalPath(src) { return new Promise((resolve) => { @@ -1190,15 +1210,24 @@ export default { } uni.getImageInfo({ src: src, - success: (res) => resolve(res.path), + success: (res) => resolve(res.path || ''), fail: () => resolve(''), }) }) }, /** - * 辅助函数:绘制圆角矩形 + * 辅助函数:绘制规范圆角矩形路径与填边 + * @param {Object} ctx Canvas 上下文 + * @param {Number} x 起始 X 坐标 + * @param {Number} y 起始 Y 坐标 + * @param {Number} width 矩形宽度 + * @param {Number} height 矩形高度 + * @param {Number} radius 圆角半径 + * @param {String|CanvasGradient} fillColor 填充颜色 + * @param {String} strokeColor 描边颜色 + * @param {Number} lineWidth 描边线宽 */ - drawRoundRect(ctx, x, y, width, height, radius, fillColor, strokeColor) { + drawRoundRect(ctx, x, y, width, height, radius, fillColor, strokeColor, lineWidth = 1) { ctx.save() ctx.beginPath() ctx.moveTo(x + radius, y) @@ -1217,65 +1246,162 @@ export default { } if (strokeColor) { ctx.setStrokeStyle(strokeColor) + ctx.setLineWidth(lineWidth) ctx.stroke() } ctx.restore() }, /** - * 辅助函数:多行文本绘制并带省略号 + * 辅助函数:计算单字符视觉预估宽度(规避小程序 CanvasContext measureText 仅按 10px 测算的平台级缺陷) + * @param {String} char 单个字符 + * @param {Number} fontSize 字体大小(px) + * @returns {Number} 预估像素宽度 */ - drawTextWrapped(ctx, text, x, y, maxWidth, lineHeight, maxLines = 2) { + calcCharWidth(char, fontSize) { + const code = char.charCodeAt(0) + if (code > 255) return fontSize // 中文汉字、全角标点符号 + if (code >= 65 && code <= 90) return fontSize * 0.72 // 大写英文字母 + if (code === 32) return fontSize * 0.32 // 空格 + return fontSize * 0.56 // 小写字母、半角数字、半角标点 + }, + /** + * 辅助函数:计算整段文字的真实渲染宽度 + * @param {String} text 文字内容 + * @param {Number} fontSize 字体大小(px) + * @returns {Number} 总像素宽度 + */ + calcTextWidth(text, fontSize) { + if (!text) return 0 + const str = String(text) + let total = 0 + for (let i = 0; i < str.length; i++) { + total += this.calcCharWidth(str[i], fontSize) + } + return total + }, + /** + * 辅助函数:安全绘制单行文本(显式设定对齐状态,彻底杜绝全局状态污染导致错乱) + * @param {Object} ctx Canvas 上下文 + * @param {String} text 文本内容 + * @param {Number} x 绘制起点 X + * @param {Number} y 绘制起点 Y + * @param {Object} options 样式参数配置 + */ + drawSafeText(ctx, text, x, y, options = {}) { + if (text === undefined || text === null || text === '') return + const { + fontSize = 20, + color = '#1E293B', + align = 'left', + baseline = 'top', + } = options + ctx.save() + ctx.setFontSize(fontSize) + ctx.setFillStyle(color) + ctx.setTextAlign(align) + ctx.setTextBaseline(baseline) + ctx.fillText(String(text), x, y) + ctx.restore() + }, + /** + * 辅助函数:自适应多行文本换行绘制并带省略号(返回绘制结束后的下一行起始 Y,实现自适应流式排版) + * @param {Object} ctx Canvas 上下文 + * @param {String} text 文本内容 + * @param {Number} x 起始 X + * @param {Number} y 起始 Y + * @param {Number} maxWidth 最大宽度 + * @param {Number} lineHeight 每行高度 + * @param {Number} maxLines 最大行数限制 + * @param {Object} options 字体与颜色参数 + * @returns {Number} 实际占用的 Y 轴高度终点 + */ + drawTextFlow(ctx, text, x, y, maxWidth, lineHeight, maxLines = 2, options = {}) { if (!text) return y - const words = String(text).split('') + const { + fontSize = 24, + color = '#334155', + } = options + const chars = String(text).split('') let line = '' + let lineW = 0 let lineCount = 0 - let currentY = y + let curY = y - for (let i = 0; i < words.length; i++) { - const testLine = line + words[i] - const metrics = ctx.measureText(testLine) - if (metrics.width > maxWidth && i > 0) { + for (let i = 0; i < chars.length; i++) { + const char = chars[i] + const charW = this.calcCharWidth(char, fontSize) + if (lineW + charW > maxWidth && line.length > 0) { lineCount++ if (lineCount >= maxLines) { - line = line.slice(0, -1) + '…' - ctx.fillText(line, x, currentY) - return currentY + lineHeight + let truncated = line + while (this.calcTextWidth(truncated + '…', fontSize) > maxWidth && truncated.length > 0) { + truncated = truncated.slice(0, -1) + } + this.drawSafeText(ctx, truncated + '…', x, curY, { fontSize, color, align: 'left', baseline: 'top' }) + return curY + lineHeight } - ctx.fillText(line, x, currentY) - line = words[i] - currentY += lineHeight + this.drawSafeText(ctx, line, x, curY, { fontSize, color, align: 'left', baseline: 'top' }) + line = char + lineW = charW + curY += lineHeight } else { - line = testLine + line += char + lineW += charW } } if (line) { - ctx.fillText(line, x, currentY) - currentY += lineHeight + this.drawSafeText(ctx, line, x, curY, { fontSize, color, align: 'left', baseline: 'top' }) + curY += lineHeight } - return currentY + return curY }, /** - * 在 Canvas 上绘制真实的、符合国际标准且微信100%可识别的长按二维码 + * 辅助函数:绘制高兼容精致虚线 + * @param {Object} ctx Canvas 上下文 + * @param {Number} x1 起始 X + * @param {Number} y1 起始 Y + * @param {Number} x2 结束 X + * @param {Number} y2 结束 Y + * @param {Number} dashLen 虚线段长度 + * @param {Number} gapLen 虚线间隔 + * @param {String} color 虚线颜色 + */ + drawDashedLine(ctx, x1, y1, x2, y2, dashLen = 6, gapLen = 4, color = '#E2E8F0') { + ctx.save() + ctx.setStrokeStyle(color) + ctx.setLineWidth(1) + let curX = x1 + while (curX < x2) { + ctx.beginPath() + ctx.moveTo(curX, y1) + ctx.lineTo(Math.min(curX + dashLen, x2), y1) + ctx.stroke() + curX += dashLen + gapLen + } + ctx.restore() + }, + /** + * 辅助函数:在 Canvas 上绘制高识别度合规二维码 * @param {Object} ctx Canvas 上下文 * @param {String} text 二维码目标 URL * @param {Number} x 起始 X * @param {Number} y 起始 Y - * @param {Number} size 二维码尺寸(px) + * @param {Number} size 二维码外框总尺寸 */ drawRealQRCode(ctx, text, x, y, size) { try { const qr = QRCode.create(text, { errorCorrectionLevel: 'M' }) const modules = qr.modules const count = modules.size - const padding = 10 + const padding = 8 const qrRealSize = size - padding * 2 const cellSize = qrRealSize / count - // 1. 纯白底色(高对比度保障扫描率) - ctx.save() - this.drawRoundRect(ctx, x, y, size, size, 14, '#FFFFFF', '#DFF2E8') + // 1. 纯白底色(高对比度保障扫描识别率) + this.drawRoundRect(ctx, x, y, size, size, 10, '#FFFFFF', '#DFF2E8', 1) - // 2. 绘制深墨绿码元(#04301E),符合春绿规范且微信识别率100% + // 2. 绘制深墨绿码元(#04301E),符合轻氧浅绿规范且微信识别率 100% + ctx.save() ctx.setFillStyle('#04301E') for (let r = 0; r < count; r++) { for (let c = 0; c < count; c++) { @@ -1283,179 +1409,217 @@ export default { ctx.fillRect( x + padding + c * cellSize, y + padding + r * cellSize, - cellSize + 0.4, - cellSize + 0.4 + cellSize + 0.35, + cellSize + 0.35 ) } } } - - // 3. 中心微质感同城 Logo 徽标 - const centerSize = Math.floor(size * 0.22) - const cx = x + size / 2 - const cy = y + size / 2 - - ctx.beginPath() - ctx.arc(cx, cy, centerSize / 2 + 3, 0, Math.PI * 2) - ctx.setFillStyle('#FFFFFF') - ctx.fill() - - ctx.beginPath() - ctx.arc(cx, cy, centerSize / 2, 0, Math.PI * 2) - ctx.setFillStyle('#00E070') - ctx.fill() - - ctx.setFontSize(Math.floor(centerSize * 0.58)) - ctx.setFillStyle('#04301E') - ctx.setTextAlign('center') - ctx.setTextBaseline('middle') - ctx.fillText('同', cx, cy + 1) - ctx.restore() } catch (e) { console.error('QR draw error', e) } }, /** - * 真实商业级 Canvas 绘制高清同城分享海报 + * 重构核心:高保真商业级 Canvas 流式自适应绘制同城分享海报 */ async generatePoster() { this.posterLoading = true const W = 640 - const H = 1020 + const H = 1000 const ctx = uni.createCanvasContext('postPosterCanvas', this) - // 1. 底板背景(薄荷微浅绿) + // 1. 底板背景(全局轻氧浅绿 #F2FEF7) ctx.setFillStyle('#F2FEF7') ctx.fillRect(0, 0, W, H) - // 2. 主体纯白高光卡片(圆角 24px,带微立体边框) - this.drawRoundRect(ctx, 24, 24, 592, 972, 26, '#FFFFFF', '#DDF2E7') + // 2. 主体纯白高光卡片(圆角 24px,微立体边框 #E2F4EB) + this.drawRoundRect(ctx, 32, 32, 576, 936, 24, '#FFFFFF', '#E2F4EB', 1) - // 3. 头部 Hero 品牌渐变区 - const grad = ctx.createLinearGradient(24, 24, 616, 210) - grad.addColorStop(0, '#04874A') - grad.addColorStop(1, '#023A21') - this.drawRoundRect(ctx, 24, 24, 592, 180, 26, grad, null) + // 3. 头部顶栏(品牌胶囊 + 区域胶囊) + const badgeText = '同城生活 · 便民严选' + const badgeW = this.calcTextWidth(badgeText, 18) + 24 + this.drawRoundRect(ctx, 56, 56, badgeW, 32, 16, '#E8FAF0', null) + this.drawSafeText(ctx, badgeText, 56 + 12, 56 + 7, { + fontSize: 18, + color: '#00A854', + align: 'left', + baseline: 'top', + }) - // 补齐头部底部的直角以平滑承接内容 - ctx.setFillStyle(grad) - ctx.fillRect(24, 160, 592, 44) + const cityText = this.post.area ? `贵阳 · ${this.post.area}` : '同城精选' + const cityW = this.calcTextWidth(cityText, 18) + 24 + const cityX = 584 - cityW + this.drawRoundRect(ctx, cityX, 56, cityW, 32, 16, '#F1F5F9', null) + this.drawSafeText(ctx, cityText, cityX + 12, 56 + 7, { + fontSize: 18, + color: '#64748B', + align: 'left', + baseline: 'top', + }) - // 头部品牌与城市胶囊 - this.drawRoundRect(ctx, 48, 50, 200, 36, 18, 'rgba(255, 255, 255, 0.22)', null) - ctx.setFontSize(20) - ctx.setFillStyle('#FFFFFF') - ctx.fillText('同城生活 · 便民严选', 64, 75) + // 4. 标题行(字号 30px,高级灰 #1E293B,最多 2 行流式换行) + const titleY = 106 + let curY = this.drawTextFlow(ctx, this.post.title || '', 56, titleY, 528, 42, 2, { + fontSize: 30, + color: '#1E293B', + }) + curY += 14 - const cityText = this.post.area ? `贵阳 · ${this.post.area}` : '贵阳同城' - const cityW = ctx.measureText(cityText).width + 30 - this.drawRoundRect(ctx, 592 - cityW, 50, cityW, 36, 18, 'rgba(255, 255, 255, 0.16)', null) - ctx.setFontSize(20) - ctx.setFillStyle('#FFFFFF') - ctx.fillText(cityText, 607 - cityW, 75) - - // 头部大标题(白字粗体,最多折 2 行) - ctx.setFontSize(32) - ctx.setFillStyle('#FFFFFF') - this.drawTextWrapped(ctx, this.post.title || '', 48, 128, 544, 40, 2) - - // 4. 实拍大图 / 主题视觉区 + // 5. 实拍主图 / 视觉展示区 let coverPath = '' if (this.imageUrls && this.imageUrls.length) { coverPath = await this.getImageLocalPath(this.imageUrls[0]) } - const picY = 224 + + const imgW = 528 + const imgH = 250 + const imgY = curY + if (coverPath) { - ctx.save() - this.drawRoundRect(ctx, 48, picY, 544, 290, 16, null, null) - ctx.clip() - ctx.drawImage(coverPath, 48, picY, 544, 290) - ctx.restore() + // 绘制图片底衬与无破坏性边框(避免 ctx.clip 造成全局裁剪错乱) + this.drawRoundRect(ctx, 56, imgY, imgW, imgH, 14, '#F8FAFC', '#E2E8F0', 1) + ctx.drawImage(coverPath, 56, imgY, imgW, imgH) + this.drawRoundRect(ctx, 56, imgY, imgW, imgH, 14, null, 'rgba(0,0,0,0.06)', 1) - // 大图内右上方贴上“置顶推荐 / 实拍保真”标签 + // 大图左下角分类胶囊 + const catText = this.post.categoryName || '同城便民' + const catW = this.calcTextWidth(catText, 18) + 22 + this.drawRoundRect(ctx, 70, imgY + imgH - 42, catW, 28, 14, 'rgba(4, 48, 30, 0.72)', null) + this.drawSafeText(ctx, catText, 81, imgY + imgH - 37, { + fontSize: 18, + color: '#00E070', + align: 'left', + baseline: 'top', + }) + + // 大图右上角置顶/推荐标签 if (this.post.isTop || this.post.isPaid) { - this.drawRoundRect(ctx, 450, picY + 16, 126, 36, 18, '#F5A623', null) - ctx.setFontSize(18) - ctx.setFillStyle('#FFFFFF') - ctx.fillText('置顶推荐', 478, picY + 41) + const topText = '置顶推荐' + const topW = this.calcTextWidth(topText, 16) + 18 + this.drawRoundRect(ctx, 56 + imgW - topW - 12, imgY + 12, topW, 26, 13, '#F5A623', null) + this.drawSafeText(ctx, topText, 56 + imgW - topW - 3, imgY + 17, { + fontSize: 16, + color: '#FFFFFF', + align: 'left', + baseline: 'top', + }) } - // 大图左下方贴上分类胶囊 - this.drawRoundRect(ctx, 64, picY + 236, 136, 36, 18, 'rgba(4, 48, 30, 0.75)', null) - ctx.setFontSize(20) - ctx.setFillStyle('#00E070') - ctx.fillText(this.post.categoryName || '同城便民', 80, picY + 261) + curY = imgY + imgH + 18 } else { - // 无实拍图时展示生态质感主题大卡片 - this.drawRoundRect(ctx, 48, picY, 544, 220, 16, '#EBF9F2', '#D3EFE1') - ctx.setFontSize(32) - ctx.setFillStyle('#04874A') - ctx.fillText(this.post.categoryName || '同城便民服务', 72, picY + 90) - ctx.setFontSize(22) - ctx.setFillStyle('#5E8C78') - ctx.fillText('平台官方审核认证 · 放心联系', 72, picY + 140) + // 无图时展示轻氧绿色生态卡片 + const emptyH = 120 + this.drawRoundRect(ctx, 56, imgY, imgW, emptyH, 14, '#EBF9F2', '#D3EFE1', 1) + this.drawSafeText(ctx, this.post.categoryName || '同城便民服务', 80, imgY + 28, { + fontSize: 28, + color: '#00A854', + align: 'left', + baseline: 'top', + }) + this.drawSafeText(ctx, '平台官方审核认证 · 便民服务放心选', 80, imgY + 68, { + fontSize: 20, + color: '#64748B', + align: 'left', + baseline: 'top', + }) + curY = imgY + emptyH + 18 } - // 5. 价值展示行(价格 + 浏览量) - const valY = coverPath ? picY + 332 : picY + 262 - ctx.setFontSize(26) - ctx.setFillStyle('#F5A623') + // 6. 价值展示行(价格 + 浏览量,精准计算宽度零重叠) + const priceY = curY if (this.post.price > 0) { - ctx.fillText('¥', 48, valY) - ctx.setFontSize(48) - ctx.fillText(String(this.post.price), 68, valY) + // 人民币符号 + this.drawSafeText(ctx, '¥', 56, priceY + 12, { + fontSize: 24, + color: '#F5A623', + align: 'left', + baseline: 'top', + }) + // 价格数字 + const numStr = String(this.post.price) + this.drawSafeText(ctx, numStr, 76, priceY, { + fontSize: 40, + color: '#F5A623', + align: 'left', + baseline: 'top', + }) + // 价格单位 if (this.post.priceUnit) { - ctx.setFontSize(22) - ctx.setFillStyle('#888888') - ctx.fillText(this.post.priceUnit, 72 + ctx.measureText(String(this.post.price)).width + 10, valY) + const numW = this.calcTextWidth(numStr, 40) + this.drawSafeText(ctx, this.post.priceUnit, 82 + numW, priceY + 16, { + fontSize: 20, + color: '#94A3B8', + align: 'left', + baseline: 'top', + }) } } else { - ctx.setFontSize(40) - ctx.fillText('面议', 48, valY) - } - - // 右侧显示浏览人次 - const viewsText = `🔥 ${this.post.views || 0} 人关注 · 平台认证` - ctx.setFontSize(20) - ctx.setFillStyle('#7A998A') - ctx.fillText(viewsText, 592 - ctx.measureText(viewsText).width - 48, valY - 6) - - // 6. 详细描述摘录 - const descY = valY + 44 - ctx.setFontSize(22) - ctx.setFillStyle('#3E5246') - this.drawTextWrapped(ctx, this.post.content || '', 48, descY, 544, 34, 2) - - // 7. 特色服务标签与地址信息 - const tagY = descY + 76 - if (this.post.tags && this.post.tags.length) { - let tagStartX = 48 - this.post.tags.slice(0, 3).forEach((tag) => { - const tW = ctx.measureText(tag).width + 24 - this.drawRoundRect(ctx, tagStartX, tagY, tW, 34, 8, '#EBF9F2', '#D3EFE1') - ctx.setFontSize(19) - ctx.setFillStyle('#04874A') - ctx.fillText(tag, tagStartX + 12, tagY + 24) - tagStartX += tW + 12 + this.drawSafeText(ctx, '面议', 56, priceY + 4, { + fontSize: 32, + color: '#00A854', + align: 'left', + baseline: 'top', }) } - // 地址行 - const addrY = tagY + 54 - ctx.setFontSize(20) - ctx.setFillStyle('#6E8276') - const addrFull = `📍 ${this.post.address || this.post.area || '同城便民服务地点'}` - ctx.fillText(addrFull.slice(0, 28), 48, addrY) + // 右侧关注度与认证(纯文字排版,无 Emoji,符合规范) + const viewsNum = this.post.views || 0 + const viewStr = `${viewsNum}人关注 · 平台认证` + const viewW = this.calcTextWidth(viewStr, 19) + this.drawSafeText(ctx, viewStr, 584 - viewW, priceY + 14, { + fontSize: 19, + color: '#64748B', + align: 'left', + baseline: 'top', + }) + curY = priceY + 50 - // 8. 分割虚线 - ctx.setStrokeStyle('#E2EBE5') - ctx.setLineWidth(1) - ctx.beginPath() - ctx.moveTo(48, 804) - ctx.lineTo(592, 804) - ctx.stroke() + // 7. 详细描述摘录(最多 2 行流式换行) + if (this.post.content) { + const afterDescY = this.drawTextFlow(ctx, this.post.content, 56, curY, 528, 30, 2, { + fontSize: 21, + color: '#475569', + }) + curY = afterDescY + 12 + } - // 9. 底部扫码交互区(真实高密度二维码 + 醒目指引) + // 8. 特色服务标签(最多 3 个) + if (this.post.tags && this.post.tags.length) { + let tagX = 56 + const tags = this.post.tags.slice(0, 3) + for (let t = 0; t < tags.length; t++) { + const tag = tags[t] + const tagW = this.calcTextWidth(tag, 18) + 20 + if (tagX + tagW > 584) break + this.drawRoundRect(ctx, tagX, curY, tagW, 28, 6, '#EBF9F2', '#D3EFE1', 1) + this.drawSafeText(ctx, tag, tagX + 10, curY + 5, { + fontSize: 18, + color: '#00A854', + align: 'left', + baseline: 'top', + }) + tagX += tagW + 10 + } + curY += 36 + } + + // 地址行(无 Emoji,简洁清晰) + const addrText = this.post.address || this.post.area + if (addrText) { + const fullAddr = `服务地点:${addrText}` + const truncatedAddr = fullAddr.length > 28 ? fullAddr.slice(0, 27) + '…' : fullAddr + this.drawSafeText(ctx, truncatedAddr, 56, curY, { + fontSize: 19, + color: '#64748B', + align: 'left', + baseline: 'top', + }) + } + + // 9. 底部优雅虚线分割栏(固定停靠在底部 y = 812) + this.drawDashedLine(ctx, 56, 812, 584, 812, 6, 4, '#E2E8F0') + + // 10. 底部扫码与转化区(y = 830) let inviterId = 0 try { const user = this.currentUser @@ -1463,23 +1627,30 @@ export default { } catch (e) {} const qrUrl = `https://fenleixinxi.gzyakj.com.cn/pages/detail/detail?id=${this.id}${inviterId ? '&inviterId=' + inviterId : ''}` - // 绘制真实合规的微信二维码 - this.drawRealQRCode(ctx, qrUrl, 48, 826, 140) + // 绘制高识别度合规二维码(124 x 124) + this.drawRealQRCode(ctx, qrUrl, 56, 830, 124) - // 右侧指引文案 - ctx.setFontSize(26) - ctx.setFillStyle('#04301E') - ctx.fillText('微信长按识别小程序', 214, 866) + // 右侧指引文案(显式左对齐,彻底杜绝对齐错乱) + this.drawSafeText(ctx, '微信长按识别小程序', 198, 842, { + fontSize: 25, + color: '#1E293B', + align: 'left', + baseline: 'top', + }) + this.drawSafeText(ctx, '联系发布者 · 查看完整信息', 198, 880, { + fontSize: 20, + color: '#00A854', + align: 'left', + baseline: 'top', + }) + this.drawSafeText(ctx, '同城生活分类信息 · 官方严选保障', 198, 916, { + fontSize: 17, + color: '#94A3B8', + align: 'left', + baseline: 'top', + }) - ctx.setFontSize(21) - ctx.setFillStyle('#04874A') - ctx.fillText('查看完整电话 · 微信 · 地图导航', 214, 904) - - ctx.setFontSize(18) - ctx.setFillStyle('#88A395') - ctx.fillText('同城生活分类信息 · 官方便民服务保障', 214, 938) - - // 10. 最终导出高保真图片 + // 11. 最终导出超清海报(2x Retina 分辨率:1280 x 2000) ctx.draw(false, () => { setTimeout(() => { uni.canvasToTempFilePath( @@ -1487,22 +1658,23 @@ export default { canvasId: 'postPosterCanvas', width: W, height: H, - destWidth: W, - destHeight: H, + destWidth: W * 2, + destHeight: H * 2, fileType: 'png', quality: 1, success: (res) => { this.posterImagePath = res.tempFilePath this.posterLoading = false }, - fail: () => { + fail: (err) => { + console.error('canvasToTempFilePath error', err) this.posterLoading = false uni.showToast({ title: '海报绘制失败,请重试', icon: 'none' }) }, }, this ) - }, 260) + }, 300) }) }, /** @@ -1554,7 +1726,9 @@ export default { * 一键复制海报文案 */ copyPosterText() { - const text = `【${this.post.categoryName || '同城信息'}】${this.post.title}\n价格:${this.post.price > 0 ? '¥' + this.post.price : '面议'}\n位置:${this.post.address || ''}\n快来同城便民平台查看详情!` + const addr = this.post.address || this.post.area || '同城' + const priceText = this.post.price > 0 ? ('¥' + this.post.price + (this.post.priceUnit ? this.post.priceUnit : '')) : '面议' + const text = `【${this.post.categoryName || '同城信息'}】${this.post.title}\n价格:${priceText}\n地点:${addr}\n长按识别海报或在同城便民平台查看详情!` uni.setClipboardData({ data: text, success: () => { diff --git a/uniapp/pages/distribution/index.vue b/uniapp/pages/distribution/index.vue index 414c24d..2220e31 100644 --- a/uniapp/pages/distribution/index.vue +++ b/uniapp/pages/distribution/index.vue @@ -1,13 +1,20 @@ diff --git a/uniapp/pages/index/index.vue b/uniapp/pages/index/index.vue index 041481e..fe601d4 100644 --- a/uniapp/pages/index/index.vue +++ b/uniapp/pages/index/index.vue @@ -419,7 +419,27 @@ export default { immediate: true, }, }, - onLoad() { + /** + * 页面初始化:提取布局高度与裂变邀请参数 + * @param {Object} query 路由启动参数 + */ + onLoad(query) { + if (query) { + const ic = query.ic || query.inviteCode || query.invite_code + const inviterId = query.inviterId || query.inviter_id + if (ic) uni.setStorageSync('fc_invite_code', String(ic)) + if (inviterId) uni.setStorageSync('fc_inviter_id', Number(inviterId)) + if (!ic && query.scene) { + try { + const s = decodeURIComponent(query.scene) + if (s.indexOf('ic_') === 0 || s.indexOf('ic=') === 0) { + uni.setStorageSync('fc_invite_code', s.slice(3)) + } else if (/^[A-Za-z0-9]{4,16}$/.test(s)) { + uni.setStorageSync('fc_invite_code', s) + } + } catch (e) {} + } + } try { const m = getNavMetrics() this.statusBarHeight = m.statusBarHeight diff --git a/uniapp/pages/member/index.vue b/uniapp/pages/member/index.vue index 623ffdf..880a3d2 100644 --- a/uniapp/pages/member/index.vue +++ b/uniapp/pages/member/index.vue @@ -770,22 +770,23 @@ export default { padding: 16rpx 28rpx 0; } .vip__card { - background: linear-gradient(145deg, #023a21 0%, #044f2d 60%, #033f24 100%); + /* 尊享高定暖金黑金渐变:彻底告别老式墨绿发黑,与 mine 会员卡片视觉基因统一 */ + background: linear-gradient(135deg, #ffe7a6 0%, #ffd06a 45%, #f5a623 100%); border-radius: 28rpx; padding: 36rpx 32rpx 28rpx; position: relative; overflow: hidden; - box-shadow: 0 12rpx 32rpx rgba(2, 58, 33, 0.28); - border: 1rpx solid rgba(0, 224, 112, 0.22); + box-shadow: 0 16rpx 36rpx rgba(245, 166, 35, 0.22); + border: 1rpx solid rgba(255, 255, 255, 0.6); } .vip__card-sheen { position: absolute; - top: -80rpx; - right: -80rpx; - width: 260rpx; - height: 260rpx; + top: -90rpx; + right: -90rpx; + width: 280rpx; + height: 280rpx; border-radius: 50%; - background: radial-gradient(circle, rgba(255, 194, 92, 0.2) 0%, rgba(255, 194, 92, 0) 70%); + background: radial-gradient(circle, rgba(255, 255, 255, 0.55) 0%, rgba(255, 255, 255, 0) 70%); pointer-events: none; } .vip__card-user { @@ -798,17 +799,18 @@ export default { width: 88rpx; height: 88rpx; border-radius: 50%; - background: linear-gradient(135deg, #00e070 0%, #00b85c 100%); + background: #ffffff; display: flex; align-items: center; justify-content: center; - border: 3rpx solid #ffffff; + border: 3rpx solid rgba(255, 255, 255, 0.95); + box-shadow: 0 4rpx 14rpx rgba(74, 38, 0, 0.12); flex-shrink: 0; } .vip__avatar-txt { font-size: 38rpx; font-weight: 800; - color: #04301e; + color: #4a2600; } .vip__user-meta { margin-left: 20rpx; @@ -822,19 +824,20 @@ export default { } .vip__user-name { font-size: 32rpx; - font-weight: 700; - color: #ffffff; + font-weight: 800; + color: #3e2000; } .vip__badge { display: inline-flex; align-items: center; padding: 4rpx 14rpx; border-radius: 20rpx; - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(4px); + background: rgba(74, 38, 0, 0.08); + border: 1rpx solid rgba(74, 38, 0, 0.12); } .vip__badge--active { - background: linear-gradient(135deg, #ffc25c 0%, #f5a623 100%); + background: #3e2000; + border-color: #3e2000; } .vip__badge-icon { width: 22rpx; @@ -843,24 +846,28 @@ export default { } .vip__badge-txt { font-size: 20rpx; - color: #ffffff; - font-weight: 600; + color: #4a2600; + font-weight: 700; +} +.vip__badge--active .vip__badge-txt { + color: #ffe7a6; } .vip__user-desc { margin-top: 8rpx; font-size: 22rpx; - color: rgba(255, 255, 255, 0.7); + color: #7a4600; + font-weight: 500; } .vip__card-crown { width: 72rpx; height: 72rpx; - opacity: 0.25; + opacity: 0.18; } .vip__assets { margin-top: 32rpx; padding-top: 24rpx; - border-top: 1rpx solid rgba(255, 255, 255, 0.12); + border-top: 1rpx solid rgba(74, 38, 0, 0.1); display: flex; align-items: center; justify-content: space-between; @@ -878,13 +885,14 @@ export default { .vip__asset-val { font-size: 40rpx; font-weight: 800; - color: #ffffff; + color: #3e2000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .vip__asset-unit { font-size: 20rpx; - color: rgba(255, 255, 255, 0.75); + color: #7a4600; margin-left: 4rpx; + font-weight: 600; } .vip__asset-label-row { margin-top: 6rpx; @@ -894,17 +902,21 @@ export default { } .vip__asset-lbl { font-size: 22rpx; - color: rgba(255, 255, 255, 0.7); + color: #7a4600; + font-weight: 500; } .vip__asset-act { font-size: 20rpx; - color: #00e070; - font-weight: 600; + color: #4a2600; + font-weight: 700; + background: rgba(255, 255, 255, 0.45); + padding: 2rpx 10rpx; + border-radius: 10rpx; } .vip__asset-sep { width: 1rpx; height: 36rpx; - background: rgba(255, 255, 255, 0.15); + background: rgba(74, 38, 0, 0.12); } .vip__section { @@ -912,7 +924,7 @@ export default { background: #ffffff; border-radius: 28rpx; padding: 32rpx 28rpx; - box-shadow: 0 4rpx 20rpx rgba(4, 48, 30, 0.05); + box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.04); } .vip__sec-head { display: flex; @@ -923,11 +935,11 @@ export default { .vip__sec-title { font-size: 32rpx; font-weight: 700; - color: #04291c; + color: #1e293b; } .vip__sec-tip { font-size: 22rpx; - color: #8fb3a3; + color: #64748b; } .vip__privileges-grid { @@ -938,7 +950,7 @@ export default { .vip__priv-item { display: flex; align-items: center; - background: #f7faf8; + background: #f8fafc; padding: 20rpx 18rpx; border-radius: 20rpx; } @@ -970,13 +982,13 @@ export default { .vip__priv-name { font-size: 26rpx; font-weight: 700; - color: #04291c; + color: #1e293b; display: block; } .vip__priv-desc { margin-top: 4rpx; font-size: 20rpx; - color: #8fb3a3; + color: #64748b; display: block; } @@ -988,7 +1000,7 @@ export default { } .vip__plan-card { background: #ffffff; - border: 2rpx solid #e2eee7; + border: 2rpx solid #e2e8f0; border-radius: 20rpx; padding: 28rpx 10rpx 24rpx; display: flex; @@ -1008,7 +1020,7 @@ export default { top: -12rpx; right: -6rpx; background: #00e070; - color: #04301e; + color: #0f2f1d; font-size: 18rpx; font-weight: 700; padding: 2rpx 12rpx; @@ -1017,7 +1029,7 @@ export default { .vip__plan-name { font-size: 28rpx; font-weight: 700; - color: #04291c; + color: #1e293b; } .vip__plan-price-row { display: flex; @@ -1038,13 +1050,13 @@ export default { .vip__plan-del { margin-top: 4rpx; font-size: 20rpx; - color: #a4b8af; + color: #94a3b8; text-decoration: line-through; } .vip__plan-quota-chip { margin-top: 12rpx; font-size: 18rpx; - color: #04874a; + color: #00a854; background: #eaf8f0; padding: 2rpx 10rpx; border-radius: 8rpx; @@ -1056,13 +1068,13 @@ export default { align-items: center; justify-content: space-between; padding: 24rpx 0; - border-top: 1rpx solid #edf4f0; - border-bottom: 1rpx solid #edf4f0; + border-top: 1rpx solid #f1f5f9; + border-bottom: 1rpx solid #f1f5f9; margin: 24rpx 0 16rpx; } .vip__coupon-lbl { font-size: 26rpx; - color: #04291c; + color: #1e293b; font-weight: 700; } .vip__coupon-act-box { @@ -1081,17 +1093,17 @@ export default { background: #fff4e0; } &--has { - color: #04874a; + color: #00a854; background: #eaf8f0; } &--none { - color: #8fb3a3; + color: #64748b; font-weight: normal; } } .vip__coupon-arrow { font-size: 28rpx; - color: #8fb3a3; + color: #64748b; } .vip__pay-toggle { @@ -1100,7 +1112,7 @@ export default { .vip__pay-label { font-size: 26rpx; font-weight: 700; - color: #04291c; + color: #1e293b; margin-bottom: 16rpx; display: block; } @@ -1114,7 +1126,7 @@ export default { align-items: center; padding: 20rpx 18rpx; border-radius: 18rpx; - background: #f7faf8; + background: #f8fafc; border: 2rpx solid transparent; transition: all 0.2s ease; @@ -1132,14 +1144,14 @@ export default { .vip__pay-opt-txt { flex: 1; font-size: 24rpx; - color: #04291c; + color: #1e293b; font-weight: 600; } .vip__radio { width: 32rpx; height: 32rpx; border-radius: 50%; - border: 2rpx solid #c8d8d0; + border: 2rpx solid #cbd5e1; display: flex; align-items: center; justify-content: center; @@ -1154,7 +1166,7 @@ export default { width: 12rpx; height: 12rpx; border-radius: 50%; - background: #04301e; + background: #0f2f1d; } .vip__dock-pad { @@ -1166,7 +1178,7 @@ export default { left: 0; right: 0; background: #ffffff; - box-shadow: 0 -8rpx 28rpx rgba(4, 48, 30, 0.08); + box-shadow: 0 -8rpx 28rpx rgba(15, 23, 42, 0.06); padding: 20rpx 32rpx; padding-bottom: calc(20rpx + constant(safe-area-inset-bottom)); padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); @@ -1185,7 +1197,7 @@ export default { } .vip__dock-tag { font-size: 24rpx; - color: #668877; + color: #64748b; margin-right: 8rpx; } .vip__dock-sym { @@ -1212,7 +1224,7 @@ export default { .vip__dock-meta { margin-top: 6rpx; font-size: 20rpx; - color: #8fb3a3; + color: #64748b; } .vip__dock-submit { height: 84rpx; @@ -1222,14 +1234,14 @@ export default { display: flex; align-items: center; justify-content: center; - box-shadow: 0 8rpx 24rpx rgba(0, 224, 112, 0.3); + box-shadow: 0 8rpx 24rpx rgba(0, 224, 112, 0.28); margin: 0; border: none; } .vip__dock-submit-txt { font-size: 30rpx; font-weight: 700; - color: #04301e; + color: #0f2f1d; } .vip__sheet-mask { @@ -1258,19 +1270,19 @@ export default { align-items: center; justify-content: space-between; padding: 32rpx 36rpx 20rpx; - border-bottom: 1rpx solid #edf4f0; + border-bottom: 1rpx solid #f1f5f9; } .vip__sheet-title { font-size: 32rpx; font-weight: 700; - color: #04291c; + color: #1e293b; } .vip__sheet-close-btn { padding: 8rpx 16rpx; } .vip__sheet-close-txt { font-size: 32rpx; - color: #8fb3a3; + color: #64748b; } .vip__sheet-scroll { max-height: 55vh; @@ -1288,7 +1300,7 @@ export default { align-items: center; padding: 20rpx 24rpx; border-radius: 18rpx; - background: #f7faf8; + background: #f8fafc; border: 2rpx solid transparent; transition: all 0.2s ease; @@ -1303,7 +1315,7 @@ export default { } .vip__sheet-opt-txt { font-size: 28rpx; - color: #3b5f4d; + color: #334155; font-weight: 500; } .vip__sheet-card-left { @@ -1313,7 +1325,7 @@ export default { align-items: center; justify-content: center; flex-shrink: 0; - border-right: 2rpx dashed #dbe8e1; + border-right: 2rpx dashed #cbd5e1; padding-right: 16rpx; margin-right: 18rpx; } @@ -1333,7 +1345,7 @@ export default { } .vip__sheet-card-cond { font-size: 18rpx; - color: #8a7343; + color: #b45309; margin-top: 4rpx; } .vip__sheet-card-info { @@ -1346,15 +1358,15 @@ export default { .vip__sheet-card-name { font-size: 26rpx; font-weight: 700; - color: #04291c; + color: #1e293b; } .vip__sheet-card-date { font-size: 20rpx; - color: #8fb3a3; + color: #64748b; } .vip__sheet-bottom { padding: 20rpx 36rpx 24rpx; - border-top: 1rpx solid #edf4f0; + border-top: 1rpx solid #f1f5f9; } .vip__sheet-btn { height: 84rpx; @@ -1368,6 +1380,6 @@ export default { .vip__sheet-btn-txt { font-size: 28rpx; font-weight: 700; - color: #04301e; + color: #0f2f1d; } - + \ No newline at end of file diff --git a/uniapp/pages/merchant/detail.vue b/uniapp/pages/merchant/detail.vue index 40a5621..2c141c2 100644 --- a/uniapp/pages/merchant/detail.vue +++ b/uniapp/pages/merchant/detail.vue @@ -84,14 +84,14 @@ - + 联系咨询:{{ merchant.contact }} - - 立即拨打 › + + {{ merchant.unlocked ? '立即拨打 ›' : '受保护 · 去解锁 ›' }} @@ -155,9 +155,9 @@ 地图导航 - + - 联系店长 + {{ merchant.unlocked ? '联系店长' : '联系店长 (受保护)' }} @@ -196,6 +196,7 @@ export default { loadingMore: false, userLat: null, userLng: null, + postId: 0, } }, computed: { @@ -232,6 +233,7 @@ export default { */ onLoad(query) { this.merchantId = Number(query.id || query.userId || 0) + this.postId = Number(query.postId || 0) loadUiConfig() this.fetchLocation() this.loadDetail() @@ -291,6 +293,9 @@ export default { if (!refresh) this.loading = true try { const params = {} + if (this.postId > 0) { + params.postId = this.postId + } if (this.userLat != null && this.userLng != null) { params.lat = this.userLat params.lng = this.userLng @@ -368,9 +373,29 @@ export default { } }, /** - * 一键拨打商家咨询电话 + * 一键拨打商家咨询电话(未解锁/非会员状态下严格执行付费拦截) */ callPhone() { + const isProtected = !this.merchant || !this.merchant.unlocked || (this.merchant.contact && this.merchant.contact.includes('*')) + if (isProtected) { + uni.showModal({ + title: '联系方式受保护', + content: this.postId ? '该商家门店电话受平台付费保护,请先解锁关联的发布信息或开通会员查看。' : '该商家门店联系方式受平台保护,开通会员即可直接联系全站商家。 ', + confirmText: this.postId ? '返回去解锁' : '去开通会员', + cancelText: '知道了', + confirmColor: '#00E070', + success: (r) => { + if (r.confirm) { + if (this.postId) { + uni.navigateBack() + } else { + uni.navigateTo({ url: '/pages/member/index' }) + } + } + } + }) + return + } const phone = this.merchant && this.merchant.contact if (!phone) { uni.showToast({ title: '商家暂未公开联系电话', icon: 'none' }) diff --git a/uniapp/pages/order/detail.vue b/uniapp/pages/order/detail.vue index ebdcda3..cd03aa3 100644 --- a/uniapp/pages/order/detail.vue +++ b/uniapp/pages/order/detail.vue @@ -570,12 +570,12 @@ export default { /* 顶部 Hero 状态区 */ .order-detail__hero { padding: 36rpx 32rpx 30rpx; - background: linear-gradient(150deg, #04874A, #023A21); + background: linear-gradient(135deg, #00D26A 0%, #00A854 100%); color: #ffffff; margin-bottom: 24rpx; &--paid { - background: linear-gradient(150deg, #04874A, #02502C); + background: linear-gradient(135deg, #00D26A 0%, #00B359 100%); } &--closed { background: linear-gradient(150deg, #4A5B53, #2B3A33); diff --git a/uniapp/pages/post/create.vue b/uniapp/pages/post/create.vue index 73bc06b..8fc541d 100644 --- a/uniapp/pages/post/create.vue +++ b/uniapp/pages/post/create.vue @@ -433,7 +433,7 @@ export default { lat: null, lng: null, plan: 'free', - payType: config.defaultPayType, + payType: config.defaultPayType || 'wxpay', }, publish: { mode: 'paid',