feat: 完善分销中心与团队明细接口,增强商户与帖子交互细节

这个提交包含在:
2026-10-24 15:43:24 +08:00
父节点 f179b90fb4
当前提交 6b86177b5f
共有 20 个文件被更改,包括 2205 次插入 和 757 次删除

查看文件

@@ -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'],

查看文件

@@ -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)
{

查看文件

@@ -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('支付方式不支持');
}

查看文件

@@ -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'],

查看文件

@@ -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;
}

查看文件

@@ -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';

查看文件

@@ -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',

查看文件

@@ -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))
}

查看文件

@@ -305,7 +305,7 @@ rm -rf /tmp/fcmp && cp -R "$OUTPUT_DIR" /tmp/fcmp
```js
// 'balance' 余额支付 / 'wxpay' 微信支付
defaultPayType: 'balance',
defaultPayType: 'wxpay',
```
配置好真实商户参数后改回 `'wxpay'` 即可,**各页面无需改动**(三个支付点都读这个值)。

查看文件

@@ -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 }),
/** 提现记录 */

查看文件

@@ -75,9 +75,7 @@ export const config = {
// 强制使用 mock(联调前本地联调可置 true)
forceMock: false,
// 默认支付方式:'balance' 余额支付 / 'wxpay' 微信支付
// ⚠️ 微信支付商户参数(mchId/apiKey)尚未配置,测试阶段默认走余额支付;
// 配置好真实商户参数后改回 'wxpay' 即可,无需改动各页面。
defaultPayType: 'balance',
defaultPayType: 'wxpay',
// 本地存储键
storageKeys: {
token: 'fc_token',

查看文件

@@ -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)

查看文件

@@ -153,7 +153,8 @@
"style": {
"navigationBarTitleText": "分销中心",
"navigationStyle": "custom",
"backgroundColor": "#f2fef7"
"backgroundColor": "#f2fef7",
"enablePullDownRefresh": true
}
},
{

查看文件

@@ -477,12 +477,12 @@
</view>
</view>
<!-- 隐藏的离屏 Canvas,用于高质量绘制海报(640 x 1020 逻辑分辨率) -->
<!-- 离屏高质量 Canvas(640 x 1000 逻辑分辨率,导出 2x 超清海报) -->
<canvas
canvas-id="postPosterCanvas"
id="postPosterCanvas"
class="detail__poster-canvas"
style="width: 640px; height: 1020px; position: fixed; left: -9999px; top: -9999px;"
style="width: 640px; height: 1000px; position: fixed; left: 2000px; top: 0; z-index: -999; opacity: 0; pointer-events: none;"
/>
</view>
</template>
@@ -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<String>}
*/
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: () => {

文件差异内容过多而无法显示 加载差异

查看文件

@@ -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

查看文件

@@ -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;
}
</style>

查看文件

@@ -84,14 +84,14 @@
</view>
</view>
<!-- 门店电话行 -->
<!-- 门店电话行(支持未解锁受保护提示) -->
<view v-if="merchant.contact" class="shop-detail__cell-row" @click="callPhone">
<view class="shop-detail__cell-left">
<image class="fc-icon fc-icon--sm" :src="iconUrl('phone')" mode="aspectFit" />
<text class="shop-detail__cell-txt">联系咨询:{{ merchant.contact }}</text>
</view>
<view class="shop-detail__call-link">
<text>立即拨打 ›</text>
<view class="shop-detail__call-link" :class="{ 'shop-detail__call-link--locked': !merchant.unlocked }">
<text>{{ merchant.unlocked ? '立即拨打 ›' : '受保护 · 去解锁 ›' }}</text>
</view>
</view>
@@ -155,9 +155,9 @@
<text>地图导航</text>
</view>
<view class="shop-detail__btn-call" @click="callPhone">
<view class="shop-detail__btn-call" :class="{ 'shop-detail__btn-call--locked': !merchant.unlocked }" @click="callPhone">
<image class="fc-icon fc-icon--xs" :src="iconUrl('phone')" mode="aspectFit" />
<text>联系店长</text>
<text>{{ merchant.unlocked ? '联系店长' : '联系店长 (受保护)' }}</text>
</view>
</view>
<view class="fc-safe-bottom"></view>
@@ -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' })

查看文件

@@ -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);

查看文件

@@ -433,7 +433,7 @@ export default {
lat: null,
lng: null,
plan: 'free',
payType: config.defaultPayType,
payType: config.defaultPayType || 'wxpay',
},
publish: {
mode: 'paid',