Compare commits

...

338 Commits

Author SHA1 Message Date
1de3146c3a feat(card-diy): 2.19.10 C 端消费 detail 对齐 + 区块独立样式字段
- mini.vue: 8 detail 字段消费(三列行/复制范围外观/QR 多行/URL 行/hero 徽章)
  + rowsBlockStyle/qrBlockCustomStyle computed(与操作区 actsBlockStyle 同款模式)
  + rowsFinalStyle/qrBlockFinalStyle 合并独立块样式
- show.vue/self.vue: miniConfig 透传 14 字段(8 detail + 6 独立样式)
- 测试 ×3: 2.19.10 契约锁(配置层/透传/面板/预览消费/反例: layoutCards 已移除)

前端 7 套 123 项全绿
2026-10-24 17:03:57 +08:00
e98d3ba0e5 fix(diy-card): 团队名片墙列表项同修数组绑定 — itemFinalStyle 合并(对象/对象数组同雷)
diy-card-list.vue 列表项 :style="[itemStyle, {animationDelay}]" 数组绑定
按 2026-10-24 操作区同根因预防性消除(uni-app 静默丢弃数组绑定), 合并为
itemFinalStyle(index) 单对象绑定; 扫描全部 5 个卡片组件, 数组绑定清零
2026-10-24 14:05:44 +08:00
8617bb0395 fix(diy-card): 操作区独立样式改单绑定 — uni-app 静默丢弃数组绑定(2.18 缺陷)
2026-10-22 线上问题(qrBlockStyle)同根因漏网: 4 个操作区分支
:style="[actsBlockStyle, cornerClearStyle]" 数组绑定被 uni-app 运行时
静默丢弃(style 属性整体为 null) → 操作区独立成块(actsBlockBg/Radius/Pad)
与 corner 让位在 C 端全部失效, 后端配置了也不生效。

- mini.vue: 新增 actsBlockFinalStyle 合并 computed(块样式字符串 +
  cornerClearStyle.marginTop), 4 分支(上/下 x grid/chips)改单绑定
- 契约锁死: style-v2/ab-visual 数组绑定反例 + 单绑定断言
- H5 实测(8080 diy2699, 配置 #F7F8FA/12/12): 修复前 computed
  transparent/0/0 → 修复后 rgb(247,248,250)/12px/12px 与配置一致

验证: 名片 7 套件全绿 + 三回归 79/41/56
2026-10-24 14:03:28 +08:00
4b99bcdd7e fix(diy-card): 修复 merge 拼接残留的多余闭合括号 — detailBtnStyle/emptyBtnStyle 掉出 computed
684 行多余 '},' 使 computed 对象提前闭合, detailBtnStyle/emptyBtnStyle 成为
根级孤儿键(Vue 静默忽略) → 发名片大按钮无主题色背景、空态按钮无样式,
控制台警告 Property or method "detailBtnStyle" is not defined。

发现途径: H5 实测(8080 diy 2699 页) console 捕获, 修复后警告清零。
验证: 名片 7 套件全绿
2026-10-24 12:02:24 +08:00
bd691d2d30 feat(diy-card): Show 组件弃自绘版式恢复 diy-card-mini 基件(方案A 用户定稿)
决策依据: 后端 design.html 面板预览按 mini 基件渲染消费全部 53 字段,
方案A 下 C 端与编辑器预览逐字段一致(用户要求取与后端预览最接近的方案)。

- diy-card-show.vue: 恢复 2.19.3 末版 mini 基件实现(236 行, miniConfig
  全字段透传 含字号7/设计资产3/二维码6/宫格图层全套), 弃自绘版式(+888 行)
- 测试口径切换: qr-style 10 个/ab-visual 2 个/detail-jump 1 个「show: 自绘…」
  实现细节断言 → 改写为 miniConfig 全字段透传锁(防 merge 冲突解决再丢透传)
- 背景: 今晨两次 merge 保留自绘线丢弃透传致 27 字段 C 端失效(字段审核报告)

验证: 名片 7 套件全绿 + 全量仅剩 card-admin/card-api 存量失败(非本次) + 三回归 79/41/56
2026-10-24 11:53:34 +08:00
ba7988765a fix(diy-card): 修复 merge 冲突解决丢失的二维码 qrSrc 消费 + 断言口径对齐
- mini.vue: bottom 档展示挂点被 merge 拼回旧直连 card.qr_url → 恢复 qrSrc
  (自定义二维码图优先); 分享抽屉 ss-qr 保留名片原码(分享扫码语义, 非回归)
- style-v2: doesNotMatch 收紧为 qr-img 展示挂点, 新增 ss-qr 原码语义锁
- qr-style: qrStyle/qrSize 断言放宽为 chips/slide 绑定形态(v-model 与
  @click/field: 两种形态均算生效, 与现面板实现对齐)

背景: 今晨 10:56/11:28 两次 merge 在 diy-card-show.vue/mini.vue 冲突解决时
保留自绘版式线, 丢弃 2.19.1 miniConfig 透传(见字段审核报告, 待产品决策)
2026-10-24 11:42:02 +08:00
b36e1d0b5c Merge branch 'feat/camcard' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into feat/camcard
# Conflicts:
#	components-diy/diy-card-show.vue
#	tests/diy-card-style-v2.test.js
2026-10-24 11:28:34 +08:00
8d8c90d9b5 chore:需要同步remote 一样分支的最新代码 2026-10-24 11:12:07 +08:00
f64b00c5f5 test(diy-card): 按自绘架构收敛 show.vue 的契约断言到 self.vue
diy-card-show.vue 自 2026-10-21 起改为自绘(不再经 miniConfig 复用
diy-card-mini 通用布局), 配置由 computed 直读 this.c, 不持有 v 数据对象;
故将假定 show.vue 走 miniConfig/v 对象架构的契约断言(2.17 字号透传、
2.19 actVis/actLabels/actOrder/customActs 透传)收敛到仍走该架构的
diy-card-self.vue。diy-card-mini.vue 的 acts 合成与远端特性保持不变。
2026-10-24 11:04:44 +08:00
a08d0c2026 Merge remote-tracking branch 'origin/feat/camcard' into feat/camcard
# Conflicts:
#	components-diy/diy-card-mini.vue
#	components-diy/diy-card-show.vue
#	tests/diy-card-ab-visual.test.js
#	tests/diy-card-qr-style.test.js
2026-10-24 10:56:20 +08:00
f4998d1582 test(diy-card): 面板颜色占位与预览同链契约(2.19.7) 2026-10-24 10:21:03 +08:00
7d1349f336 test(diy-card): 线稿图标 mask+currentColor 契约(2.19.6) — 5 类禁固定色 background-image 2026-10-24 10:15:10 +08:00
549a98204a test(diy-card): QR 选图委托契约(js-qr-media + 零内联 openAlbum) 2026-10-24 09:56:58 +08:00
5845474be3 feat(diy-card): 自定义操作扩至 8 类(2.19.3) — sms/map/link 白名单 + doAct 分支 + map 地址回落 2026-10-24 09:49:49 +08:00
1b6114ef58 feat(diy-card): 默认操作删除+恢复(2.19.2) — normalizeActDeleted + acts 双重过滤 + 透传 2026-10-24 09:42:32 +08:00
6977b914d3 test(diy-card): 锁死资源引用版本参数契约(6 条 ?v={$version} + 零无版本引用) 2026-10-24 09:18:40 +08:00
0acd9e0e7b test(diy-card): 锁死快捷操作面板存量组件防炸契约(v-if 守卫/零裸读/防御式写回) 2026-10-24 09:00:58 +08:00
a26b9d6601 feat(diy-card): 快捷操作 2.17~2.19.1 — 5 动作/图标自定义/图层式管理/素材图标库/次序调整
- common.js: ACT_GRID_ICONS 结构化{glyph|css} + save 紫档 + WAP_LINE_ICONS(SVG 线稿
  data URI, 自写 b64ascii) + CARD_ICON_LIB 素材库 30 枚(与后端三源同步) +
  normalizeActVis/ActLabels/CustomActs/ActOrder + libIconBg 实时上色
- mini.vue: 宫格图标优先级 img > 素材库 > SVG 线稿 > 首字; acts computed 重构
  (actVis 显隐过滤 + actLabels 改名 + actOrder 次序 + order 外扩展类型尾接 +
  customActs 尾接 + 四 cfg 字段合并); 操作区双位置 + 独立成块样式 + qrSrc 自定义图
- show/self.vue: doAct 补 save 分支(goSaveContact 资料白名单下发) + miniConfig
  透传 actVis/actLabels/actOrder/customActs/qrImage 等 2.17~2.19 字段
- 测试: diy-card 7 套件 111 项全绿(style-v2 22 项含 svg 快照/素材库三源同步/
  template 配平/嵌套结构反例)
2026-10-24 08:48:28 +08:00
ff530cd6a2 chore:二维码外观 2026-10-24 08:39:32 +08:00
0b3a49f0d1 feat(card): save-contact 原生页新增 action=mail 分支——web-view 内 mailto 死链的复制兜底
- onLoad action=mail 时: uni.setClipboardData 复制邮箱(showToast:false) +
  自定义 toast「邮箱已复制,去邮件 App 粘贴发送」+ 1.4s 后 navigateBack
  返回 wap 名片页
- 复制失败时把邮箱原文带进 toast, 便于用户手动抄写
- 与后端 _mini_bridge.html 双环境分发配套(backend ebf355a)
2026-10-23 15:28:47 +08:00
c9d77e901d chore:我的名片组件二维码外观正常 2026-10-23 15:17:25 +08:00
0c0a6a1cb8 feat(card-diy): 四组件字号定制化消费 + 契约测试(12 项)
- entry: 主/副标题与按钮字号内联消费(titleSize/subSize/btnSize, px→rpx ×2),
  文字箭头拆 btnArrowStyle(比按钮大一档=28rpx)
- list: 拆 gridNameStyle/scrollNameStyle/metaStyle/scrollMetaStyle, 横滑与
  人数徽标按 -1px 派生保持历史渲染; infoStyle 保持纯色(箭头/空态共用)
- mini(Show/Self 基件): name/cardTitle/sub/label/value/act/btn 七项字号内联,
  拆 cardTitleStyle/rowLabelStyle/detailTextStyle; labelStyle 保持纯色给图标/qr tip
- self: miniConfig 透传 7 个字号字段
- 新增 tests/diy-card-font-size.test.js: 三层口径契约(diy_view 默认值 ↔
  design.html 面板/重置 ↔ vue 消费) + 派生关系 + 颜色 value 兜底 + 反例自测

验证: 契约 12/12 绿; diy-card 全套测试 0 失败; E2E 截图目检通过
2026-10-23 14:21:36 +08:00
a595a4ae1b chore:微信小程序分享正常 2026-10-23 14:01:20 +08:00
ecc81b9b31 chore:我的名片组件发送名片正常 2026-10-23 10:44:03 +08:00
8348cbb179 chore:我的名片组件,隐藏我的名片组件的下面登录后即可展示我的名片隐藏, 2026-10-23 10:21:25 +08:00
cb454db326 chore:我的名片样式 2026-10-23 09:59:12 +08:00
887c1848a4 chore:精选名片名片高度变化时这段距离恒定改拉伸终点 2026-10-23 09:43:03 +08:00
f9277e8644 chore:精选名片蓝色背景 2026-10-23 09:27:10 +08:00
41d07843e7 chore:我的名片的快捷按钮样式 2026-10-23 09:04:03 +08:00
5f4a0ecea1 chore:精选名片的快捷按钮样式改变 2026-10-23 09:01:20 +08:00
f26d11f38a chore:我的名片组件网站的图标显示正常 2026-10-22 18:31:46 +08:00
efa7c736f9 chore:主题渐变饰条正常 2026-10-22 18:25:00 +08:00
3fb5b0b92e chore:我的名片发送名片样式正常 2026-10-22 16:43:42 +08:00
e11aa27279 chore:我的名片组件的所有快捷按钮样式正常 2026-10-22 16:18:09 +08:00
31f67c109b chore:我的名片快捷按钮样式 2026-10-22 16:01:32 +08:00
df9db2e2ed chore:精选名片发送名片正常 2026-10-22 14:38:09 +08:00
9b33fb7719 Merge branch 'feat/camcard' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into feat/camcard 2026-10-22 14:26:32 +08:00
6c2619a33c chore:开启隐藏精选名片的资料行图标可控 2026-10-22 14:20:44 +08:00
d64e989c25 feat(diy-card): 添加链接选择功能,优化名片发送按钮文本 2026-10-22 14:14:42 +08:00
3bcf0c8c87 chore:二维码微调值可以正常调动 2026-10-22 11:26:43 +08:00
3d144a7492 chore:二维码的尺寸可以调 2026-10-22 10:52:47 +08:00
b294fc3640 chore:“发送名片”按钮功能正常 2026-10-22 09:53:27 +08:00
6f9ac29aa6 chore:二维码位置和后台同步 2026-10-21 18:16:49 +08:00
a1d8ee8d7a chore:二维码精选名片后台控制开启显示 2026-10-21 17:32:30 +08:00
d605f4c9ea feat(goods-detail): 商品详情页主图/视频按后台「媒体显示顺序」切换
后台商品「高级设置」新增 media_order(1=主图在前 2=视频在前), 前端按其决定
商品详情页媒体区默认展示哪一个, 以及底部「图片/视频」切换按钮的排列顺序.

改动:
- goods-detail-view 组件:
  - 新增 applyMediaOrder() 按 media_order 决定默认展示主图还是视频
  - 新增 mediaOrderKey 计算属性 + watcher: 只在「媒体显示顺序/视频地址」
    变化时重新应用, 避免用户手动切到「视频」后被详情数据深层变更打回
  - 新增 mediaTabs 计算属性: 切换按钮顺序跟随配置
  - 切换按钮改用 switchMediaTab(), 切回主图时暂停视频
- common/js/goods_detail_base.js: 父级那行 switchMedia 赋值原本是死代码
  (父级无此 data 字段, 无模板读取), 改成与新规则一致, 避免两处规则打架
2026-10-21 16:35:47 +08:00
724e7ac134 chore:精选名片组件可以支持头像开启关闭 2026-10-21 16:05:59 +08:00
37cd3c2f30 chore:新增精选名片组件,样式 2026-10-21 15:53:15 +08:00
d188737a6c Merge branch 'feat/camcard' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into feat/camcard 2026-10-21 14:32:15 +08:00
57a67bd61b chore:名片样式 2026-10-21 14:30:13 +08:00
f1bd606e0b fix(diy-card): 审计修复——corner 档头部水平让位动态化 + self 首载骨架屏
- 修复 P1: corner 档自定义 qrSize>120 时静态 padding-right:180rpx
  让位不足, 浮层压住头部姓名区 → 新增 cornerHeadStyle 按浮层实际
  宽度(尺寸+衬底+右偏移20+呼吸20)动态反算, 与 cornerClearStyle
  垂直让位同口径; 废弃静态 CSS 值(负断言锁定)
- 修复: self.vue 已登录首次加载三分支都不渲染(loading 且无 card)
  → 白屏闪烁; 改 v-else-if="loading || card" 挂上 mini 骨架屏
- 契约测试: 新增 cornerHeadStyle 断言 + resolveQrSize 三处口径 +
  静态让位废弃负断言; 3 项变异验证全抓住; 四文件 67 项全绿
2026-10-21 14:09:56 +08:00
805bbb9598 feat(diy-card): 4.0 头像开关 + 二维码四档外观/自定义尺寸/微调位移/提示开关
- common.js: QR_STYLES 四档枚举 + normalizeQrStyle(未知回落 card) +
  resolveQrSize(自定义 px×2>档位自动 rpx) + qrTransform(位移经
  translate, WXSS 不支持 calc)
- mini.vue 基件: 头像可隐藏(默认显示, 兼容历史配置); qrBlockStyle
  统一四档外观(plain 原图/card 白卡/theme 主题色/gradient 渐变,
  多彩只装饰外围衬底, 码黑白保可扫); cornerClearStyle 按实际尺寸
  反算让位(自定义大尺寸/关 tip 自动跟随); 扫码提示可关
- show/self: 6 新字段透传; ab-visual 测试 corner 断言改锚动态让位
- 新增 qr-style 契约测试 20 项(纯函数真值 + 基件 + 透传 + 两个
  design.html 面板控件绑定与预览消费 + 反例), 9 项变异验证全抓住
- 前端四测试文件共 66 项全绿; E2E 143 项; 三回归 79/41/56 全绿
2026-10-21 12:28:18 +08:00
8bdd9f1291 fix(diy-card): 修复两处 WXSS 不兼容 calc() + MP 兼容契约测试
微信小程序 WXSS 不支持 calc(), 两处布局在小程序上会失效:

- mini.vue corner 档: max-width: calc(100% - 170rpx) →
  .mini-head { padding-right: 180rpx }(固定值, 浮层宽 120+偏移 20+呼吸 20)
- list.vue 网格: itemStyle width calc(50% - gap/2) →
  布局盒 50% 宽 + 左右各 half-gap padding, 视觉移入内层 .item-card
  (border/圆角/padding 随之下移, 布局盒透明只算 gutter)
- 新增 tests/diy-card-mp-compat.test.js 20 项: 全组件禁 calc()/var(--)
  (先剥注释再断言) + template 标签配平(栈式匹配, 兼容插槽嵌套 template)
  + 反例自测

注: 渐变角度 linear-gradient(deg,...) 有生产先例(detail/enterprise/
forward 均在生产小程序运行), 不在禁项; 真机渲染需 HBuilderX 构建验证。
验证: 46/46(三个名片测试文件) + npm test 仅剩两个存量失败
2026-10-21 11:58:35 +08:00
a841af8f5b feat(diy-card): A+B 视觉升级(资料行图标/可点 + 二维码四档任意位)
配合后端 f031476, 组件内直接复刻参考图视觉, 功能仍归详情页(路径A):

- common.js: rowIconFor(field→iconfont glyph, 无字形退化空串) +
  rowActOf(href 派生 tel/mail/copy, 空 href 不可点) +
  normalizeQrPosition/QR_SIZES(四档归一化+尺寸表)
- mini.vue: 资料行图标列(与文字标签列同宽, 值列左缘对齐) +
  行点击 emit row-act; 二维码四档渲染:
  rows 行下(默认) / head 头部右侧(三段 flex 零遮挡) /
  corner 右上浮层(白底衬底+Logo 避让+资料行让位 108rpx) /
  bottom 主按钮上方; 二维码尺寸随档位(180/120/120/180)
- show/self: miniConfig 透传 showRowIcons/rowTap/qrPosition,
  模板挂 @row-act="doAct" 复用既有动作分发
- 新增 tests/diy-card-ab-visual.test.js 13 项(真函数+源码断言,
  7 项变异验证全抓住)

node --test tests/diy-card-ab-visual.test.js → 13/13
2026-10-21 11:44:37 +08:00
35137ae814 test(diy-card): 路径A链路契约测试(组件卡跳完整名片页)
参考图(名片卡+二维码+发名片)的完整功能在详情页, 组件靠「查看完整名片」
全宽主按钮把用户送过去。固化为 13 项断言(先剥注释再断言, 7 项变异验证):

- show/self: miniConfig 透传 showDetailBtn/detailBtnText
- show/self: 模板挂 @detail="goDetail"; goDetail 跳
  /pages_tool/card/detail 带 card_id + 空 id 守卫
- mini 基件: showDetailBtn flagOn 默认开 + mini-detail emit('detail')
- detail.vue: 错误信封带 wap_url → fallbackToPublicPage
  (H5 直跳/非 H5 走 share 中转 web-view, 游客不死路)
- 反例: 虚构字段/错误落点必须查不到

node --test tests/diy-card-detail-jump.test.js → 13/13
2026-10-21 11:24:50 +08:00
48a17a59f7 feat(diy-card): 名片 DIY 组件 2.0 视觉与功能升级(原生渲染, 禁 webview)
- diy-card-common.js(新): 设计 token 层 —— 颜色/字阶/间距/圆角/阴影/动效/
  触达尺寸 + px2rpx/flagOn/initialOf/avatarBg/themeGradient/visibleRows 助手
- diy-card-mini.vue(新): 共享名片卡基件 —— 封面+遮罩/渐变饰条/Logo/主题色
  头像(首字兜底)/资料行折叠(showRows 总开关+maxRows 上限+展开收起)/快捷操作
  芯片(首个主动作实心, 72rpx 触达)/二维码/详情按钮/骨架屏 shimmer/badge 插槽
- diy-card-entry.vue: 三预设(gradient/solid/minimal)×两布局(left/center)×
  图标装饰(内置字形/图片)×按钮四变体(solid/ghost/link/none)
- diy-card-list.vue: 三布局(grid 双列/list/scroll 横滑)+骨架屏+逐项 fade-up
  入场+标题行(计数/更多)+企业徽章+空态
- diy-card-show.vue / diy-card-self.vue: 改为基件包装; self 管登录态
  (hasToken watch 刷新)+兜底(fallbackMode)+空态 CTA+「我的名片」角标
- 修复死配置: 面板「显示资料行」showRows 此前被 C 端丢弃, 现由基件消费
  (历史配置无此字段时默认显示, 兼容 1.0)
- 规范: 禁 CSS 变量, 动态样式走 $util.objToStyle, rpx 尺寸
2026-10-21 09:56:51 +08:00
cc96e2ca13 Merge branch 'dev/1.0' into feat/camcard 2026-10-21 09:24:29 +08:00
d0fe005c4a diy: 名片 DIY 组件原生渲染(禁 webview) + diy-group 分发
- 新增 diy-card-entry/list/show/self.vue(easycom 自动加载):
  头像/资料行/快捷操作原生渲染, tel 走 makePhoneCall,
  wx/mail/web 走复制, 点击卡片跳 pages_tool/card/detail
- diy-card-self 登录态自适应: hasToken watch 自动刷新,
  未登录兜底(企业卡/指定卡/登录引导)由后端 /card/api/diy/mycard 决定
- diy-group.vue: DiyCard* 四个 componentName 分发分支
2026-10-21 09:13:56 +08:00
c9a4673203 chore:已经登录了企业名片刷新不会出现登录提示 2026-10-20 15:17:54 +08:00
bdd0935fab chore:新增一个企业微信名片 2026-10-20 14:07:05 +08:00
9d3cd2b65e chore:titel改为名片 2026-10-20 10:06:16 +08:00
22b33c45ad chore:名片删除按钮功能正常 2026-10-20 08:48:50 +08:00
699787528c feat(card): PAGE_CARD_ENTERPRISE 页面链接映射——企业名片(后端 2.11.94 link 注册配套)
路径约定 /pages_tool/card/enterprise(页面由前端开发, 数据走
/card/api/enterprise/* 企业名片 API)。diy 链接管理的企业名片
节点已在后端 lucky_link 注册(name=CARD_ENTERPRISE)。
2026-10-19 18:09:57 +08:00
a2bf8d6c25 chore:增加名片删除按钮 2026-10-19 16:58:04 +08:00
8635f9795f chore:title改成名片 2026-10-19 16:44:02 +08:00
166450f6ce chore:浏览 2026-10-19 15:28:02 +08:00
b4509e3092 chore: 2026-10-19 15:18:30 +08:00
8fa16d9b14 chore:保留test 2026-10-19 10:12:44 +08:00
9c9d1a412f Merge remote-tracking branch 'origin/dev/1.0' into feat/camcard 2026-10-19 09:55:43 +08:00
1f87b23e7b merge dev/1.0 into feat/camcard: resolve card list/detail conflicts keeping feat/camcard implementation 2026-10-19 09:55:21 +08:00
421dad516c chore:名片选择样式最后两个调换颜色 2026-10-19 09:46:11 +08:00
ab8dff4b93 chore:微信小程序名片样式选择颜色对应 2026-10-19 09:41:58 +08:00
8999dd3f25 chore:微信小程序 名片眼视光可以前端调节 2026-10-19 09:16:08 +08:00
91ce0b582b chore:名片的全部页面下面删除提交申请 2026-10-19 09:00:38 +08:00
7daa3afdc2 chore:微信小程序分享名片给好友正常 2026-10-17 17:22:57 +08:00
21b3247547 chore:
微信小程序正常名片
2026-10-17 17:02:22 +08:00
7909cf2b22 test:导出脚本增加零漏拷/零多拷硬保证
质疑"某个目录有没有被打进 zip"时, 原先只能人工逐目录核对。现在把这个检查做进
脚本, 每次导出都跑:

copyDir 记录每个被复制文件在 zip 中应有的条目名, verifyZipFile 把它与实际 zip
条目做**逐个双向比对** —— 少一个报"漏拷"并列出文件名, 多一个报"多拷"。这样
漏拷会当场失败, 而不是等在别的电脑上解压缩时才发现。

自检输出新增一行, 例如:
  自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / ...

同时把 copyDir 的条目名拼接由"从 nameOfParent 反推"改为显式传 relBase, 去掉
原先那段绕的 split/map 逻辑。

新增 3 条测试(漏拷拒绝 / 多拷拒绝 / 清单吻合时放行且 README.txt 豁免), 本
测试文件 19/19 通过, npm test 全量 17 个测试文件通过。
2026-10-17 16:13:09 +08:00
33cd21cf78 chore:待跟进和已完成页面正常 2026-10-17 14:51:29 +08:00
336b4d21da test:鸿蒙工程包导出增加产出自检与单元测试
手写 ZIP 编码器的典型 bug 都是静默的 —— 包能生成、看着正常, 但某些机器上
解不开或解出畸形路径。开发期已真实踩过两次(中央目录偏移漏算文件数据长度、
localOffset 记成数据区起点), 两次都只有靠 unzip -t 才发现, 因此加两层保障。

一、脚本内产出自检 (每次导出都跑)
新增 verifyZipFile(), 把写好的 zip 重新解析一遍, 任一项不符直接抛错、不输出
「完成」。刻意用独立解析实现, 不复用写入端逻辑, 避免自证其说:
- EOCD / 中央目录: 签名、条目数、偏移与长度边界一致
- localOffset: 每条必须真的指向本地文件头, 且文件名与中央目录一致
- CRC: 逐条解压后重算 CRC32 比对
- 条目名: 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8
- 内容断言: 必需文件齐全; node_modules/build/.preview 等排除项不得混入

二、单元测试 tests/pack-harmony-project.test.js (16 条)
正向用例写夹具目录打包再独立解析还原, 校验内容/目录/中文名/零字节/空目录/
可压缩与不可压缩内容; 反向用例故意篡改 zip 断言自检能抓到 —— 反向用例一旦
失效自检就形同虚设, 故必须保持通过。

过程中自检抓到自身一条误判: 排除校验原先用子串匹配, 把合法源码
entry/src/ohosTest/ets/test/Ability.test.ets 当成 .test 构建产物。已改为按
路径分段精确匹配, 并加了一条反向用例锁定。

同时修掉两个隐患: stats 是模块级累加器(同进程重复调用会翻倍), main() 原先
无条件执行(require 脚本就会跑一遍打包), 现加 require.main 守卫并导出内部
实现供测试使用。

已验证: 本测试文件 16/16 通过, npm test 全量 17 个测试文件通过, 实打包
2.99MiB 包自检通过且 unzip -t 交叉验证无错误。
2026-10-17 14:42:28 +08:00
77b1a3372c feat:一键导出鸿蒙工程包,支持换机编译
把 stub-app-projects/mp-harmony 中编译所需的文件导成 zip, 供没有本仓库/没有
HBuilderX 的电脑解压后直接用 DevEco 编译上传。

用法:
  npm run pack:harmony                        # 默认: 含证书, 不含 node_modules
  npm run pack:harmony -- --no-certs          # 不含签名证书
  npm run pack:harmony -- --with-node-modules # 含 hvigor/node_modules(320MB), 离线机
  npm run pack:harmony -- --out <dir>        # 自定义输出目录

输出 unpackage/dist/harmony-export/mp-harmony-project-*.zip, 内含一层 mp-harmony/。

实现要点:
- 全程只读源目录: 先复制到暂存目录再压缩, 不修改 stub 下任何文件
- 采用「全量复制 - 排除清单」而非白名单, 避免漏掉新增必需文件
- 包含 ascf/ascf_src (uniapp 编译产物快照), 目标机因此无需本仓库和 HBuilderX
- 包含 cert-profile 签名证书, 否则 hvigor 无法签名, 产不出可上传的 .app
- 排除 node_modules(320M)/oh_modules/build/.preview(22M)/.idea/.hvigor/local.properties

ZIP 由纯 Node 实现 (zlib + 手写 ZIP 头), 未用系统命令, 因为本机两种方案都产出
不合规 zip 且失败是静默的:
- PowerShell Compress-Archive / ZipFile::CreateFromDirectory 写反斜杠条目名, 非
  ZIP 规范, macOS/Linux 解压出畸形路径
- 本机 tar 是 GNU tar 1.35, 不支持创建 zip 且 exit 0, try/catch 拦不住

已验证: 解压后骨架齐全(AppScope/build-profile/entry/ascf_src/证书/README),
排除项均不存在, unzip -t 无错误, 中文文件名改 ASCII 规避工具编码差异。
2026-10-17 14:19:54 +08:00
373db1fb73 docs:修正鸿蒙产物构成描述(1 HAP + 5 HSP)
原描述「共 6 个 HAP」不准确。module.json5 中 type: "entry" 编成 HAP,
type: "shared" 编成 HSP(共享包, 动态下发), 本工程实际是 1 个 HAP + 5 个 HSP。

同步修正:
- 两步流程描述改为「逐个 module 编译出包, 再打成 APP」, 产物路径扩展名
  改为 {hap|hsp}
- 包列表新增扩展名列, 标明哪些是 .hap、哪些是 .hsp
- 单模块调试说明改为「只产出分包、不打 APP」

另补充实测体积: 主包 entry-default.hap 约 2.37 MiB(超官方单包 2MB 限制),
总包约 6.39 MiB(在 10MB 以内)。
2026-10-17 13:55:54 +08:00
d4457e58d4 chore:交换名片框的颜色要随着后台更改名片样式的颜色改变而改变 2026-10-17 12:00:31 +08:00
550ec72bb7 fix:麦克风权限 Permissions 类型用法修正
上一提交写成 Permissions.MICROPHONE 导致 CompileArkTS 失败:
  'Permissions' only refers to a type, but is being used as a value here

Permissions 在 @ohos.permissions 中是 export type 字符串联合类型, 不是枚举,
只能用在类型位置。改为类型标注 + 权限名字面量:

  const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE';

注意 abilityAccessCtrl 里的 SwitchType.MICROPHONE(=1) 是开关类型枚举,
与权限名无关, 容易误用。

readme 4.3.3 补充该类型坑说明。
2026-10-17 11:53:44 +08:00
1276a44b6b chore:名片导航栏颜色改成白色”名片“这两个字改成黑色 2026-10-17 11:51:10 +08:00
9086f1665c feat:鸿蒙元服务接入麦克风运行时权限申请
MICROPHONE 是 user_grant 权限, 上一提交补了 module.json5 的 reason 只解决
了编译校验, 运行时仍需弹窗申请, 否则录音功能不可用。

在 EntryAbility.onCreate 中调用 abilityAccessCtrl.requestPermissionsFromUser。

关键点:
- AscfUIAbility 父类已定义 onCreate, 重写时必须调 super.onCreate(want,
  launchParam), 否则跳过 ascf runtime 初始化导致元服务白屏
- 选 onCreate 而非按需触发: 权限状态在 JS 侧 (uni.getRecorderManager) 读不到,
  项目无现成 JS<->ArkTS 通道; 且 requestPermissionsFromUser 对已授权应用
  直接 resolve 不弹窗, 实际只有首次启动打扰用户一次
- 降级: 未授权不影响浏览/下单/卡片, 仅语音不可用, 前端 voice-panel 会
  showToast 提示

readme 4.3.3 补充该实现与踩坑说明, 4.3.4 移除已解决项。
2026-10-17 11:46:57 +08:00
a9f3c7acc2 docs:清理文档中的本机绝对路径
绝对路径与本机目录绑定,换机器即失效,改为相对表述。

- docs/theme-multi-platform.md:删掉 cd 到绝对路径的命令行,
  改为注释说明「在项目根目录(含 package.json)下执行」
- docs/i18n-audit-report.md:审计范围改为「项目根目录」
- docs/ai-agent-integration.md:适用前端改为「本仓库」;
  WB Cloud SDK 协议约束矩阵原指向外部 refs 仓的绝对路径,
  改为相对文件名并标注「WB Cloud SDK 参考仓内」
2026-10-17 11:35:44 +08:00
53b5b725a5 docs:鸿蒙元服务发布独立为 4.3 章节
原先作为 4.1.2 嵌在「小程序发布」下,实际产出的是 atomicService 元服务包,
与小程序流程差异较大,拆为独立的 4.3 章节,排在 4.2 快应用之后。

新增 4.3.2 DevEco Studio 编译与产物:说明 assembleApp 是两步串联
(先编译 HAP,再打包成 APP),列出 HAP / APP 产物路径与 pack.info 中
6 个 HAP 的构成,并注明元服务上架 AppGallery Connect 上传的是 .app。

新增 4.3.3 常见错误:hvigor 00303218 Configuration Error 的成因与修法
(user_grant 权限必须同时声明 reason 和 usedScene),附 user_grant /
system_grant 权限速查表。

新增 4.3.4 待完善:运行时权限申请未接入、release 签名仍指向 debug 证书。
2026-10-17 11:35:38 +08:00
fe3f059f58 chore:我的人脉--添加--可以正常保存 2026-10-17 11:24:08 +08:00
add384916c fix:鸿蒙元服务麦克风权限补充 reason 字段
ohos.permission.MICROPHONE 是 user_grant 权限,必须同时声明
reason 和 usedScene,缺失导致 hvigor 报 00303218 配置错误、
entry:default@PreBuild 失败。

补充 reason 引用,并新增 permission_microphone_reason 字符串资源。
其余 INTERNET / GET_NETWORK_INFO / VIBRATE 均为 system_grant,无需改动。
2026-10-17 11:23:36 +08:00
f2997f5401 chore:前端名片样式和后台名片样式更改同步 2026-10-17 11:15:47 +08:00
4439df7c9b chore:名片样式上面的标语删除 2026-10-16 16:49:18 +08:00
5290271017 chore:名片样式和后端同步 2026-10-16 16:41:13 +08:00
d358bb55ba chore:认领码正常 2026-10-16 10:52:27 +08:00
2f750dbb99 chore:点击交换名片中的同意按钮,进去到对方名片,左上角返回按钮,返回到名片首页 2026-10-15 15:41:28 +08:00
591ce742a9 chore:交换名片正常 2026-10-15 14:59:59 +08:00
f41057a9ad chore:名片删人脉解锁重新申请+收藏 2026-10-15 11:11:09 +08:00
5a009130e6 chore:名片样式第一个背景 2026-10-15 10:18:04 +08:00
e8fc1aca06 chore:对方与我交换名片,我这里弹出交换名片框,点击有一人请求与您交换名片,进入到图片中的页面,删除红色框中的“示例交换”四个字 2026-10-15 09:16:59 +08:00
e60b3e2e52 chore:名片样式的名片模板第一个模板 2026-10-14 16:53:03 +08:00
022a8bb5bb chore:和后端发的文档同步 2026-10-14 16:29:56 +08:00
6309a5bb4b feat: 优化名片详情加载逻辑,处理未登录和卡片不存在的情况,新增公开页跳转功能 2026-10-14 13:50:28 +08:00
67af165c14 chore:交换名片样式更改 2026-10-14 11:24:23 +08:00
36dfbfed67 chore:名片样式新增 2026-10-14 09:52:22 +08:00
e56b91970c feat: 申请页状态条(Q7 闭环前端)— 已有名片/审核中隐藏表单, 被拒可重新提交
配合商家后台 2.4.0 申请闭环:
- onShow 回查 /card/api/apply/status: 已有名片或申请审核中 → 状态条替代表单
- 被拒 → 显示未通过原因 + 「重新申请」入口, 表单恢复可编辑
- 状态未回来前不渲染表单, 避免闪一下再消失

本地配置 .local.config.js 不入库(按约定排除)。
2026-10-14 09:03:36 +08:00
4650d9ea30 chore:编辑资料,名片样式,切换名片的中间有一个竖着的分割线, 2026-10-14 08:56:34 +08:00
52f50eb91e chore:名片交换名片忽略和同意删除多余的圈 2026-10-14 08:49:27 +08:00
b3f6ff7517 feat: 名片夹运营闭环——申请状态条/需修改标/申请角标(配合商家后台 2.4.0)
- list.vue 顶部申请状态条: my_apply 待审(审核中)/被拒(可重新申请), 点按进申请页
- 被打回名片: 「需修改」红标, 点击弹层显示商家修改建议(review_note)
- 申请审批建卡: 头像「申」角标(from_apply)
- 数据源: /card/api/card/lists 增 my_apply + need_fix 派生字段
2026-10-13 19:26:04 +08:00
d0d17fd916 chore:名片新增交换名片 2026-10-13 17:53:38 +08:00
9b540ecfad chore:蓝色背景弧度向上移动,分享二维码改成名片样式 2026-10-13 16:17:49 +08:00
389d87b82c chore:名片样式更改 2026-10-13 15:39:56 +08:00
c9a58a5ddf fix: 认领双凭证——领取码/card_id 同值双传, 后端按领取码优先解析 2026-10-13 15:22:59 +08:00
53810114ed feat: 员工端申请名片入口与申请页(配合商家后台审批闭环)
- list.vue 空态新增「申请」入口(仅无名片时显示)
- 新增 apply.vue: 姓名+手机号提交 /card/api/apply/submit
- pages.json 注册 card/apply 路由
2026-10-13 14:58:59 +08:00
f2619ae3b6 Merge branch 'feat/camcard' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into feat/camcard 2026-10-13 10:50:59 +08:00
15b0d2a18a chore:接口 2026-10-13 10:38:08 +08:00
1f44f11248 feat: 更新名片云 API 文档,补充商家端架构说明及接口细节 2026-10-13 10:37:47 +08:00
6e3818dff6 feat: 添加新建名片按钮,更新空状态提示信息 2026-10-13 10:29:07 +08:00
f22892968f feat: 更新名片云 API 文档,移除 Telegram 和 Messenger 独立字段,统一为社交媒体账号 2026-10-13 10:22:15 +08:00
8c5ed50f7a fix: 修复 ns-empty 组件的 isIndex 属性绑定 2026-10-13 08:38:12 +08:00
8f82d97c95 Merge remote-tracking branch 'remotes/origin/dev/1.0' into feat/camcard 2026-10-12 18:07:01 +08:00
10ed9792a4 Merge branch 'temp221' into dev/1.0 2026-10-12 17:51:13 +08:00
fc407935d2 chore :悬浮按钮自定义 2026-10-12 17:45:04 +08:00
b92f474ac8 feat(card): 员工端「我的人脉」页 + 与潜在客户导航互链
- 新增 contacts.vue: 搜索/来源筛选/列表卡(来源徽标)/编辑/转潜在客户/删除
- 新增 contact-edit.vue: 新增编辑复用, 姓名红星必填, 其余(选填)
- leads.vue 顶部互链「我的人脉 ›」(导航合并第一步)
- pages.json 注册两页; PAGE_KEY_MAP 补 PAGE_CARD_CONTACTS
- card-platform.test.js 追加 4 测试(25/25)
2026-10-12 17:35:16 +08:00
0859765efb feat(card): 为必填字段添加红星标识,优化表单提示信息 2026-10-12 16:08:44 +08:00
8cf70b617d feat(card): 更新名片夹相关文本和导航,优化用户体验 2026-10-12 15:56:40 +08:00
df5be6045b common\js\floating-button.js
common\js\startup-ad.js
components\ns-mp-navbar\ns-mp-navbar.vue
2026-10-12 15:41:28 +08:00
571452cd30 chore:悬浮按钮可以自定义调节 2026-10-12 15:37:38 +08:00
053cb00826 feat(card): 个人中心新增「我的名片夹」固定入口 + 平台导航 page_key 测试
- member/index.vue: 非装修分支兜底入口, 跳转 /pages_tool/card/list
- card-platform.test.js: PAGE_KEY_MAP 含 PAGE_CARD_LIST/PAGE_CARD_LEADS 断言对齐
2026-10-12 15:27:06 +08:00
9e6dc8665f feat(card): add leads and share pages with platform-specific handling
- Implemented leads.vue for managing lead statuses and displaying lead information.
- Added share.vue for sharing card links with platform-specific web-view handling for WeChat Mini Programs, HarmonyOS, and H5.
- Introduced tests for cross-platform compatibility, ensuring proper handling of conditional compilation and API usage across WeChat, H5, and HarmonyOS.
- Ensured that sharing functionality correctly encodes and decodes URLs for different platforms.
2026-10-12 14:32:20 +08:00
a957679957 feat:新增名片夹列表和详情页面,支持搜索和认领功能 2026-10-12 11:31:25 +08:00
b49fa378e7 Merge branch 'temp221' into dev/1.0 2026-10-10 17:34:41 +08:00
1c0444b1ec chore:微信小程序真机广告 正常 2026-10-10 17:31:02 +08:00
09909f0dcf chore:真机上显示广告正常 2026-10-10 16:02:20 +08:00
b14032149e chore:启动广告,后台开启首次弹出,第一次进入小程序后会弹出广告,
后台开启每次弹出,每次重新进入小程序都会弹出广告
2026-10-10 11:35:55 +08:00
447f7cd7b5 chore:启动广告,后台开启首次弹出,每一次重新进入小程序后都会弹出广告 2026-10-10 11:20:36 +08:00
a5a94e31cc Merge branch 'temp221' into dev/1.0 2026-10-10 09:04:48 +08:00
dd15ff8ea4 chore:智能客服的欢迎语标题可自定义调节 2026-10-10 09:04:09 +08:00
3ab78f5369 chore:AI智能题目可以自定义调节颜色大小,字号 2026-10-09 18:29:02 +08:00
e223e58425 chore:输入框切换正常 2026-10-09 18:17:03 +08:00
5ef5f275f0 chore:微信小程序的语音输入切换到文字输入不会闪 2026-10-09 18:10:31 +08:00
236e56f4e9 chore: 2026-10-09 17:01:16 +08:00
d170e53082 Merge branch 'temp221' into dev/1.0 2026-10-09 16:57:33 +08:00
e7dd6aff62 chore:微信小程序的AI智能客服标题和图片之间有距离 2026-10-09 16:56:05 +08:00
8488aa39bc chore:输入框高度一致 2026-10-09 16:33:42 +08:00
817d2081d7 chore:输入框语音输入时,输入框样式修改 2026-10-09 16:21:27 +08:00
0a0ce88d82 chore:输入框样式突出 2026-10-09 14:45:08 +08:00
34c0df5320 chore:快捷问题,标题,换一换按钮,图片之间空隙变大 2026-10-09 14:34:14 +08:00
c36d8dbce6 chore:悬浮按钮可以后台自定义调节 2026-10-09 10:46:40 +08:00
356a6d2bbf chore:真机用户隐私保护的“同意并继续”按钮显示正常 2026-10-09 09:16:18 +08:00
3b354992e3 chore:悬浮按钮可以自定义调节 2026-10-08 17:59:37 +08:00
fe0381a298 chore:微信小程序pdf正常打开 2026-10-08 17:26:16 +08:00
733c33a41c chore:新加源代码pages_tool/files/detail.vue,文件列表链接正常打开PDF 2026-10-08 17:07:10 +08:00
e8ff0e9be4 chore:快捷问题框浮起来 2026-10-08 09:22:36 +08:00
0faecf3603 chore:AI智能欢迎语的快捷问题的框改椭圆 2026-10-08 09:11:30 +08:00
e9c8bac60b chore:微信小程序正常 2026-09-30 17:02:26 +08:00
a72e1b733d chore:隐藏欢迎语页面的时间 2026-09-30 16:40:27 +08:00
132ecbb127 chore:随机生成快捷问题的图标 2026-09-30 16:36:15 +08:00
5173b5d38e chore:支持换一换按钮应用 2026-09-30 16:15:07 +08:00
4172da6201 chore:更新欢迎语和快捷问题的样式,点击输入框隐藏欢迎语整体 2026-09-30 16:02:20 +08:00
53be343159 chore:所有个更多功能正常 2026-09-30 15:26:14 +08:00
1de5f925ee chore:添加文件的图标正常 2026-09-30 14:48:38 +08:00
95b5484629 chore:新对话改成黑色,网址改成黑色 2026-09-30 14:36:56 +08:00
454bc3b522 chore:修改更多功能样式 2026-09-30 14:21:43 +08:00
1223ec9202 chore:隐藏更多功能中的语音 2026-09-30 12:01:17 +08:00
4f75347103 chore:语音输入图标正常 2026-09-30 11:58:34 +08:00
6e7dbdde94 chore:修改语音输入样式 2026-09-30 11:53:40 +08:00
04b5ed06a3 chore:修改语音输入图标 2026-09-30 11:37:04 +08:00
0507d600a0 chore:扬声器图标修改为黑色圆点 + 两道声波 2026-09-30 11:34:14 +08:00
cf902e0440 chore:语音输入图标更改 2026-09-30 11:30:26 +08:00
f555b8a946 chore:智能用户和回答都字号都25,不加粗 2026-09-30 10:49:08 +08:00
ce705d9d61 chore:文件图标正常 2026-09-30 10:01:17 +08:00
5f4dc370f8 chore:语音气泡正常 2026-09-30 09:28:22 +08:00
a978858254 chore:语音输入正常 2026-09-29 17:25:14 +08:00
10e6402ad6 chore:语音输入有返回 2026-09-29 16:46:59 +08:00
bf5e32df2b chore:修改包大小 2026-09-29 14:16:57 +08:00
9b0a2ce9b6 feat: 添加components-diy\diy-webview.vue 2026-09-29 11:16:08 +08:00
954e8cad0b chore: 重新整理依赖及revert lang.js 2026-09-29 11:12:58 +08:00
166a284359 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
#	common/js/ai-service.js
#	common/js/media-upload.js
#	docs/ai-agent-integration.md
#	pages_tool/ai-chat/ai-chat-message.vue
#	pages_tool/ai-chat/components/ai-chat-tools-panel.vue
#	pages_tool/ai-chat/index.vue
2026-09-29 11:02:53 +08:00
5efae5c38b feat(ai-chat): 增加自动定位权限提示及处理逻辑,优化用户体验 2026-09-28 13:57:46 +08:00
4168c24fd6 feat(ai-chat): 增强位置消息处理,支持透传位置、时间、页面上下文,优化用户体验 2026-09-28 13:48:56 +08:00
762401777b feat(ai-chat): 增强多媒体消息处理,支持多条语音和视频同时发送,优化消息气泡显示逻辑 2026-09-28 11:30:40 +08:00
24c4e40df9 Merge remote-tracking branch 'remotes/origin/temp221' into temp221 2026-09-28 11:12:05 +08:00
c58734fcda feat(ai-chat): 增强语音消息处理,支持多条语音同时发送及中央播放面板 2026-09-28 11:10:26 +08:00
bbc9fdd6ea feat(ai-chat): 动态切换输入框 placeholder 文案,支持语音和媒体附件提示 2026-09-28 10:18:10 +08:00
4cd21ade51 fix(ai-chat): 修复 v-if/v-else 配对问题,优化输入框和语音图标布局对称性 2026-09-28 10:16:09 +08:00
0799e47233 feat(ai-chat): 在流式回答中添加"思考中..."指示,优化操作栏显示逻辑 2026-09-28 10:05:52 +08:00
306f2f7ff7 feat(voice-input): 改进音频消息处理,支持音频时长和指令文本,优化后端交互 2026-09-28 09:33:57 +08:00
fff10a2e19 feat(media-upload): 增加全量音视频格式支持,确保与后端一致 2026-09-24 18:03:08 +08:00
9d990e6aac feat: implement voice input feature with press-and-hold gesture
- Added new language strings for voice input functionality in English and Chinese.
- Updated manifest to include microphone permission for voice recording.
- Enhanced ai-chat-message component to support voice input with visual feedback for recording and cancellation gestures.
- Integrated voice recorder functionality in ai-chat-voice-panel, allowing users to record audio with a maximum duration of 60 seconds.
- Implemented audio handling logic to upload recorded audio files and display them in the chat interface.
- Added error handling for voice recording failures and user notifications.
- Updated styles for improved UI/UX during voice recording interactions.
2026-09-24 17:47:37 +08:00
74468e2c9d feat(ai-chat): 暂时隐藏模型选择器,统一使用 Auto 模式 2026-09-24 17:31:15 +08:00
f40c4b2a69 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-24 17:05:26 +08:00
2dae3adf66 chore:网页容器H5显示正常 2026-09-24 17:00:14 +08:00
e079afc0a2 feat(media-upload): 增加自愈机制,自动切换 STS 直传以应对后端策略变化 2026-09-24 16:27:33 +08:00
85b3e4db47 chore:AI回答的文字只有网址是蓝色,其他回答是黑色的 2026-09-24 13:59:24 +08:00
0197224c75 chore:H5所有气泡都统一 2026-09-24 11:59:50 +08:00
70f8fb867d feat(chat-ui): 增加聊天 UI 配置的本地持久化和预加载功能,优化首帧渲染体验 2026-09-24 11:10:21 +08:00
86b87eeb9d feat(ai-chat): 聊天 UI 配置 V3 — 名称/头像/时间配置项消费
- CHAT_UI_DEFAULTS 同步后端 4 新键 (show_name/show_avatar/avatar/time_format/time_humanize)
- 标题与 AI 头像按开关显隐, 自定义头像 (素材库 pic_path / URL) 优先于内置
- formatTime 支持 auto(智能分段)/full(完整) 与 humanizeTime 相对时间优先 (刚刚/N分钟前/N小时前/昨天/前天/M月D日/Y年M月D日)
- i18n 中英各+5 键
2026-09-24 10:30:28 +08:00
297b545e55 fix(media-upload): alioss 直传 endpoint 缺 oss- 前缀修复
- sts.region 是简写 (cn-hangzhou), 真实 OSS endpoint 须完整形式 (oss-cn-hangzhou)
- 优先使用后端 sts.oss_url (OssProviderRegistry::buildDirectEndpoint 已拼好), 兜底 oss-${region} 拼接
2026-09-24 10:29:55 +08:00
3e2ae9b7ca chore 2026-09-24 08:51:52 +08:00
2016b87b6e chore:回答问题视口随文字增长一直是贴着最新一句往上走。 2026-09-23 17:12:11 +08:00
a42ad4ebb3 chore:新增AI智能问题是否合并回答/单独回答 2026-09-23 16:25:39 +08:00
158750b933 chore:删除AI回答后下面出现修改图标的英文 2026-09-23 15:26:36 +08:00
3abe65de2a chore:H5的气泡修改 2026-09-23 15:22:19 +08:00
c0e432cc22 chore:微信小程序真机可以自定义复制 2026-09-23 15:04:27 +08:00
f8115f47fd chore:AI修改新对话按钮图标,搜索记录按钮向上移动,删除AI回答后的AI名称 2026-09-23 14:13:54 +08:00
0ee97f902d chore:跳转文章列表链接可以分类 2026-09-23 11:15:47 +08:00
3b996485e8 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0
# Conflicts:
#	components-diy/diy-image-nav.vue
2026-09-22 15:44:27 +08:00
d18df73849 fix(diy-image-nav): 修复小程序端水平滚动条始终显示/滑动时显示不生效 2026-09-22 15:22:15 +08:00
66b56387e6 chore:滑动图片后台上传图片自适应 2026-09-22 15:15:24 +08:00
bac5583ecb 强制合并 2026-09-22 10:52:56 +08:00
3079cf4ba5 chore:点赞图标正常 2026-09-22 10:39:01 +08:00
aefb0c1550 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-21 08:53:52 +08:00
235adb5ebd chore:优化工具调用的可见性控制,确保与思考过程开关一致;统一欢迎语字段名,移除冗余信息;兼容旧数据格式,提升数据处理的健壮性 2026-09-20 18:25:59 +08:00
744981ed93 chore:添加 chat_ui_config 支持,优化前端 UI 行为开关;实现附件类型动态控制,确保与后端配置同步 2026-09-20 18:01:43 +08:00
942d85de19 chore:要实现后台开启双语模式,微信小程序真机切换英文模式,此时显示的是英文,后台关闭双语模式,微信小程序刷新重新进入要是中文模式 2026-09-20 17:58:16 +08:00
ae5921aa90 fix(lang): 恢复 common 和 components 节点校验 2026-09-20 16:15:48 +08:00
c104f6c443 chore: 忽略 .trae 相关文件 2026-09-20 16:15:19 +08:00
747a15f16e chore: 忽略 .workbuddy 目录 2026-09-20 16:14:42 +08:00
ee20c214de chore:优化服务配置缓存逻辑,减少不必要的 API 调用;修复欢迎语加载逻辑,避免重复推送欢迎气泡;调整快捷问题样式以兼容低版本环境 2026-09-20 15:45:12 +08:00
b93752d58c 更新lang 2026-09-20 15:27:45 +08:00
619e1dc9ef Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0
# Conflicts:
#	components-diy/diy-category-item.vue
#	components-diy/diy-category.vue
2026-09-20 15:25:15 +08:00
8fd68033ed chore:添加欢迎语配套快捷问题功能,支持用户快速发送常见问题;优化数据格式处理与样式 2026-09-20 15:07:43 +08:00
500328ef55 chore :真机可以支持测试 2026-09-20 14:27:51 +08:00
62b4b44f88 chore :真机可以支持测试 2026-09-20 14:06:28 +08:00
6fa0603783 fix(diy-search): 支持卡片底板内边距/外边距/圆角/四边样式后台字段 2026-09-20 10:39:49 +08:00
9d599d5f47 chore:后台更改卡片底板内边距,四边样式颜色,前端和后台同步 2026-09-20 10:33:28 +08:00
2b1f8a4fe2 chore:修复清除会话的 URL 格式,确保使用 camelCase,避免 404 错误;优化 agent 切换时的会话 ID 管理 2026-09-19 16:25:16 +08:00
34c712db32 chore: 同步搜索框与滑动图片相关5个文件到 dev/1.0 2026-09-19 16:03:13 +08:00
7a81045290 Merge branch 'temp221' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-19 15:26:53 +08:00
67ce0f8b39 chore:更新 AI 服务会话管理逻辑,优化用户状态切换时的 key 迁移与会话隔离,确保 H5 端 elicitResponse 请求正常处理 2026-09-19 15:26:38 +08:00
01df6c9116 chore:后台调节滑动图片组件的左右边距,前端运行出来和后台一致 2026-09-19 15:01:22 +08:00
480a109114 chore:商品分类模板1更改,只有「加载完成 且 该二级分类下商品列表非空」才渲染,否则标题 + 网格整块都不显示。 2026-09-18 17:44:03 +08:00
509fe203f7 chore:商品分类模板5 更改完成 2026-09-18 17:28:51 +08:00
0f9cc1087f chore:商品分类模板5 2026-09-18 16:44:14 +08:00
0f24f47048 chore:滑动图片的上下左间距可以后台自定义调节 2026-09-18 15:26:05 +08:00
a3d2ee562e chore:新增商品分类模板5 2026-09-18 13:53:47 +08:00
1b1704f7c6 chore:滑动图片是水平滑动滚动条根据后台显示,AI智能点赞svg 2026-09-18 09:58:15 +08:00
8b2670bf31 chore:客服信息组件转英文模式显示全部英文 2026-09-17 18:19:30 +08:00
e66c91acfc chore:【搜索框】的样式设置中的“背景颜色”可以控制搜索框的"背景颜色"和搜索框的“上下左右边距”的颜色 2026-09-17 15:09:42 +08:00
29699a66fd chore:AI智能输入框样式改变 2026-09-17 14:21:28 +08:00
3b4051c775 chore:上传图片可以支持3张,正常显示 2026-09-17 09:48:49 +08:00
fffb5dae18 chore:用户的消息,图片在上文字在下 2026-09-17 09:11:59 +08:00
8cfc144ba9 fix(frontend): 端用户附件走 inline base64 路径, 避开 MediaGuard SSRF 闸门
PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16)
PR-WB-MP-INBOUND-MEDIA 配套

症状: 用户在前端上传图片成功后 (OSS URL 已返回), AI Agent 仍回复
"我这边并没有收到或看到任何图片附件".

根因链路:
1. backend Upload.php::buildPublicUrl 用 request()->domain() 拼 URL
   (LAN 部署返回 http://192.168.x.x:8050/upload/..., 公网部署返回公网域名)
2. 前端把 upload 响应直接透给 AI chat attachments[] 字段
3. backend MediaGuard::urlAllowed 把 192.168.x.x 当内网 IP
   (FILTER_FLAG_NO_PRIV_RANGE) 拒绝 → attachments 整批丢
4. 静默失败: MediaGuard 在 '白名单过滤后空集' 时返回 ok=true + media=[]
   → Kefu.php 不 return error, 业务继续走
5. WB 收到 0 个 media block → Agent 看不到附件

修复:
- media-upload.js 新增 fileToBase64(filePath, opts) helper
  - H5: fetch(blobUrl) → blob → FileReader.readAsDataURL → strip data: 前缀
  - MP-WEIXIN/APP-PLUS: uni.getFileSystemManager().readFileSync(base64)
  - SIZE_LIMIT_INLINE_BASE64 = 5MB, 与后端 INLINE_MEDIA_LIMIT 对齐
  - 超限 / 失败返回 null, 调用方退回 URL 路径
- ai-chat-message.vue sendMessage 改为 await Promise.all 模式:
  - 对每个 pending item 调 fileToBase64(tempPath) 拿 base64
  - 把 data + encoding='base64' 注入 attachment
  - enrichedPendingAttachments 同时用于 readyAttachments + userMessage.attachments
  - 复用同一份 base64, 不重复读 file

后端接收 data 字段后整条链路已对齐:
- MessageMedia::normalizeInput 直接透传 data 字段
- MediaGuard::check data 非空时无条件放行, 不参与 URL 校验
- WbAgentOsClient::buildMediaBlocks (A 路) 路径 1 优先 data + image/audio
- WbAcpClient::buildMediaBlocks (B 路) data 非空也走 inline base64
- inline_media_sanity.php 39 个断言全部覆盖 (test 3.1, 3.2, 4.1, 4.2, 7.6)

测试:
- E2E test_inline_base64.py 10/10 通过
  - mock 上传返回 LAN URL 模拟 MediaGuard 拦截场景
  - 抓 /api/kefu/chat POST body, 验证 attachments[].data 是有效 PNG base64
  - magic bytes 89504E47 验证通过
- 既有 test_ai_chat_upload.py 20/20 通过 (无回归)

边界:
- >5MB 图片 fileToBase64 返回 null, 退回 URL 路径 (buildMediaBlocks 走
  路径 2 fetchInline / 路径 3 resource_link); LAN 部署下仍可能被 MediaGuard
  拦截, 但 5MB 以下的图片(主流微信/支付宝截图等)不受影响
- H5/MINIProgram/APP 三端都加了 #ifdef 编译条件
2026-09-16 17:55:49 +08:00
065fba7cf8 fix(media-upload): H5 pathToFile 修复 multipart filename 缺扩展名
PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)

诊断: 直接打后端 (curl) 验证后端 ext_rule 完全 OK,
所有 jpg/png/heic 标准 mime 都返回 code:0, 只有 filename=空
(无扩展名) 才会被拒. 所以问题在前端.

定位: common/js/media-upload.js pathToFile() H5 分支:
  原代码 filePath.split('/').pop() 对 blob:http://xxx/uuid
  取出 'uuid' (无扩展名)
  → new File([blob], 'uuid', ...) 没有 .jpg/.png 后缀
  → multipart Content-Disposition: filename='uuid'
  → 后端 fileExt 规则检查 'uuid' 不在白名单 → 拒绝

修复: 提取 extractExt() 到模块顶层 (之前内联在 uploadViaSts 里),
pathToFile 调用 extractExt(filePath, blob.type) 三段兜底:
  1) filePath 最后一段正则
  2) filePath.name 兜底
  3) blob.type mimeHint 反查 EXT_MIME_MAP → png/jpg/heic 等

修复后 multipart filename = 'test-img1.png' (有扩展名),
后端 ext_rule 通过, 上传成功.

E2E 验证 (verify_filename2.py):
  - 修复前: filename='test-img2' → 后端拒绝
  - 修复后: filename='test-img2.png' → 后端接受

教训: PR-FRONTEND-MEDIA-UPLOAD-FIX 加 extractExt 时只修了 STS 路径,
PHP 路径漏修; H5 blob URL + multipart 是双盲点 (前端不知道后端需要
filename 扩展名, 后端拿到 filename 才检查). 任何 file.toBlob() 或
URL.createObjectURL 出来的 file 都要在 File 构造时补扩展名.
2026-09-16 17:32:03 +08:00
f2cb69cb06 fix(ai-chat): rename _pendingIdSeq → pendingIdSeq (Vue 2 strips underscore-prefix data)
PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16)

Bug: Vue 2 在组件实例化时过滤掉 _ 和 $ 开头的数据属性.
`_pendingIdSeq: 0` 在 data() 里定义但 this._pendingIdSeq === undefined.
onImagePicked 中 `++this._pendingIdSeq` → NaN, 所有 pendingImage 的
localId 都是 NaN, Vue 'Duplicate keys detected: NaN' 警告.

E2E 实测发现 (test_ai_chat_upload.py 20/20 通过):
- pendingImages 应有 3 项 ✓
- 每张图 localId 独立递增 (1, 2, 3) ✓
- 3 张图独立上传成功 ✓
- DOM 渲染 3 个 .pending-item + 3 个 uni-image 缩略图 ✓
- canSendPendingImage computed = true → .top-send-btn 出现 ✓
- removePendingImage(localId) → 剩 2 张 ✓
- 失败场景: uploadError 有值 + pending-retry-delete 出现 ✓

修复: data 里改 pendingIdSeq: 0, 代码里改 ++this.pendingIdSeq
教训: Vue 2 data() 里禁止用 _ 或 $ 前缀的属性名.
2026-09-16 17:14:11 +08:00
fe2d3dcfe2 fix(ai-chat): 全面修复前端图片附件 4 个 P0 + 3 个 P1 + 3 个 P2 bug
PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16)

配合后端 PR-WB-MP-UPLOAD-EXT (HEIC/AVIF/BMP/TIFF 双层修复), 前端用户上传链路全面打通:

**4 个 P0 (阻塞用户上传)**:
- pendingImage 单数 → pendingImages 数组: 选 9 张图只发 1 张 → 全部一起发
  - 每个图独立 localId 独立上传独立失败
  - 失败给删除按钮 (用户不再卡死)
- MP-WEIXIN 不压缩 HEIC → H5+MP-WEIXIN 双端都压缩
  - 微信 chooseImage 默认 compressed 不支持 HEIC, 必须本地 uni.compressImage
- getFileSize 失败兜底 0 → 失败抛错, 探测完全失败时兜底走 STS
  - 0 > 2MB = false 让 auto 模式永远走 PHP 中转, 大文件 OOM
  - 现: totallyUnknown → 兜底 STS
- guessMime 缺 HEIC 等 → 与后端 guessMimeFromPath 对齐 26 个 mime

**3 个 P1 (业务级)**:
- selectFile 微信小程序死按钮 → 加 #ifdef MP-WEIXIN 分支 (uni.chooseMessageFile/wx.chooseMessageFile)
  - 兼容检测两个 API 哪个可用
- 上传失败后 pendingImage 永远卡住 → 失败时给 删除 按钮 (pending-retry-delete)
- URL.createObjectURL 永不释放 → upload 完成后 (成功/失败都) revokeObjectURL

**3 个 P2 (优化)**:
- filePath.split('.').pop() 在 blob URL 翻车 → 用 lastSegment + 正则提取 + 兜底 'bin'
- STS MP-WEIXIN size 永远 0 → 用已知 detectedSize + pathToFile 再探测
- H5 压缩阈值 10MB → 3MB

**新增**:
- tests/media-upload-fix.test.js (12 个单测全过): guessMime 现代格式 / extractExt blob URL / decideSts 兜底 STS 决策
2026-09-16 16:52:49 +08:00
78973c99a3 chore: 2026-09-16 16:38:34 +08:00
171190c1cd chore:合并 2026-09-16 11:52:28 +08:00
7952f5a722 feat(ai-chat): frontend 媒体上传/播放全栈接通 (PR-FRONTEND-MEDIA-UPLOAD 2026-09-16)
P0 — 新建 common/js/media-upload.js (12KB)
    - uploadImage(filePath) / uploadMedia(filePath, kind) 落地
    - 此前 ai-chat-tools-panel.vue 引用但文件从未入库, 选图立即抛 "Cannot find module"
    - 12 cloud provider 分流: 4-field STS (alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) vs single-URL signed PUT (huaweiobs/volcengine/awsoss/minio)
    - 端点必须 addon namespace: /<addon>/api/upload/{image|file|ossSts}, 普通 /api/kefu/upload/* 不命中 InitRoute
    - 补 docs 设计稿漏写的 `import store from '@/store/index.js'` (H5 上传需带 token)

  P1 — ai-chat-message.vue 用户附件全支持
    - P1.1 filter 不再丢 file/audio/video 消息
    - P1.2 用户气泡加 type==='file' 分支: 文件图标 + 名称 + 大小 + 上传状态
    - P1.3 sendMessage type 不再强制把 audio/video 折成 file

  P2 — AI 侧 audio 播放引擎接通
    - P2.1 previewAttachment audio 分支 → 跳 file-preview
    - P2.2 音频气泡接 uni.createInnerAudioContext: data 加 innerAudioCtx/_innerAudioSrc, 改写 playAudio/pauseAudio, beforeDestroy 必 destroy, slider 区分 change (显示) / changeend (实际 seek)
    - P2.3 file-preview.vue 加 image-viewer + audio-viewer 两个分支;inferFileType 识别 jpg/png/mp3/wav 等扩展

  P3 — sendMessageWithContent 透传 attachments 参数
    - 默认 attachments = [], 传给 sendStreamMessage/sendNormalMessage
    - tools panel 选完文件 push 到 attachments → 链到 send

  lang — zh-cn + en-us file-preview 加 4 项
    - audioUrlNotExist / audioDefaultTitle / audioPlaying / audioPaused

  docs — docs/ai-agent-integration.md (跨 PR 累积同步, 2149 行)
    - 版本基线提到 PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后
    - 加 §3.1.5 attachments.data 字段协议 (PR-WB-MP-INLINE-MEDIA)
    - 12 cloud provider 替换原"阿里云"单点 (PR-OSS-REGISTRY)
    - media-upload.js 章节补 fileToBase64 (PR-FRONTEND-MEDIA-UPLOAD)
    - 上传策略表 backend/oss_sts/auto 全替换为"云存储"措辞

  对齐后端:
    - PR-WB-MP-INLINE-MEDIA (a707b90): media data 字段
    - PR-OSS-REGISTRY (12 provider): OssProviderRegistry
    - PR-FRONTEND-MEDIA-UPLOAD (本 commit): 前端落地

  兼容性:
    - 后端未升级到 PR-WB-MP-INLINE-MEDIA 之前, attachments 不带 data 字段, 走 URL 路径 (退化到老行为, 不破)
    - 后端 oss_sts 单点 alioss 时, securityToken 不带 -SIGNED 后缀, 走 4-field STS 分支 (与 PR-OSS-REGISTRY 之前兼容)

  回归:
    - node --check media-upload.js OK
    - lang zh ESM import OK, 输出 8 个 key 全对
    - ai-chat-message.vue 11 处 PR-FRONTEND-MEDIA-UPLOAD 标记, file-preview.vue 9 处
    - 旧 chat 路径不带 attachments 走默认 [], 不破坏现有响应结构

  安全约束: 不涉及 nginx 层防盗链 / 限速
2026-09-16 11:42:18 +08:00
3f29116345 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-16 11:15:23 +08:00
3f99a1b9e4 chore:AI回答显示思考... 2026-09-16 11:14:53 +08:00
7a5de9a44f chore:微信小程序也已显示思考过程 2026-09-16 08:44:19 +08:00
b4a8415a38 chore:AI智能回答没有气泡 2026-09-15 16:52:43 +08:00
e2573cbaef chore:商品分类模板2的二级分类下拉按钮删除白色背景 2026-09-15 16:25:38 +08:00
ca772295ba chore: 商品分类模板1/2/3/4/99显示一级分类下全部二级分类商品 2026-09-15 11:11:47 +08:00
0925d28eb0 chore:后台商品分类模板可以显示全部商品 2026-09-15 10:51:50 +08:00
782a26f446 chore:AI智能显示思考过程 2026-09-14 17:49:30 +08:00
278645230a chore:回答行距变小,加上气泡,隐藏气泡下面的"..." 2026-09-14 08:57:45 +08:00
21ee661cef chore:AI智能回答特殊文字有特殊显示 2026-09-12 14:43:57 +08:00
3fffab81c8 chore:可以复制回答 2026-09-12 11:03:34 +08:00
6993582592 chore:AI智能客服改成AI智能 2026-09-12 09:25:30 +08:00
429487b1d1 chore:微信小程序真机智能客服正常 2026-09-12 09:20:51 +08:00
d15724dd2f chore:用户气泡样式 2026-09-11 17:34:43 +08:00
4cb34bf28e chore:用户可以自定义选择复制的文字 2026-09-11 17:14:42 +08:00
2daaa258a0 chore:AI智能可以复制语句 2026-09-11 14:43:23 +08:00
3892fb517f chore:隐藏智能体的任务栏 2026-09-11 10:10:52 +08:00
659308e116 chore:任务栏的图标小程序正常 2026-09-11 09:29:56 +08:00
bc127a45e8 chore:输入框样式 2026-09-11 09:15:12 +08:00
596e7cdb03 chore:输入框的更多功能加上关闭按钮 2026-09-10 17:20:10 +08:00
f09fffb7ad chore:点进智能AI页面,输入框移动到底部 2026-09-10 16:43:51 +08:00
a01587fc68 chore:修改智能AI页面 2026-09-10 16:36:25 +08:00
04cd90078b chore:后台开启商品分类模板一的三级分类按钮,如果切换一级标题时三级分类标题没有商品,就不要显示没有商品的三级标题, 2026-09-10 11:50:38 +08:00
45015cc781 chore:后台开启商品分类模板一的三级分类按钮,运行出来后图片中红色框中分类标题面的白色背景删除 2026-09-10 11:39:39 +08:00
fce62e988f chore: 2026-09-10 10:03:12 +08:00
c5f91be93b chore: 2026-09-09 17:56:54 +08:00
d0960da1d8 chore:商品分类模板1的以及分类和右侧商品上面名称栏对齐 2026-09-09 09:53:58 +08:00
8443cadba9 chore:商品分类模板2,4加购按钮显示正常 2026-09-07 16:52:27 +08:00
9739eea32a chore: 2026-09-07 15:55:24 +08:00
90c77096aa chore:商品分类模板1,2,3,4,99全部正常 2026-09-07 14:56:32 +08:00
1deb805d29 chore:商品分类样式1,2可以切换视图 2026-09-07 12:00:44 +08:00
b079442cfa chore:商品分类的1,2,3,4,99视图可以切换 2026-09-07 11:09:28 +08:00
88a0153f90 chore:商品分类的划线价在价格下面 2026-09-07 10:24:40 +08:00
50cd3081e6 chore:加购商品时购物车出现在页面下面 2026-09-07 09:56:03 +08:00
f37633525f chore:商品分类样式2 点击加购商品时去结算会出现 2026-09-05 18:23:29 +08:00
1893f6d884 chore:商品分类样式2 2026-09-05 17:37:44 +08:00
661b33b6b6 chore:商品分类样式2信这个购物车功能 2026-09-05 17:19:11 +08:00
1d8bcf7d73 chore:微信小程序表单图片和后台一致 2026-09-05 11:54:29 +08:00
aaa4faeb57 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-05 10:55:36 +08:00
2288746182 chore:微信小程序和后台表单样式一致 2026-09-05 10:54:22 +08:00
56d6d17b6a refactor(diy-form.vue): 优化横幅裁剪逻辑,调整背景位置处理以适应uni-app H5编译器 2026-09-04 18:16:13 +08:00
53e885164d refactor(diy-form.vue): 优化横幅图片处理,支持裁剪比例和手动裁栏框配置 2026-09-04 17:38:09 +08:00
e82355cf9b chore: 2026-09-04 15:39:44 +08:00
16f2053927 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 14:42:29 +08:00
0fbff1004c refactor(ns-newform.vue): 增加小程序环境修复,优化CSS变量定义和使用 2026-09-04 14:41:04 +08:00
75690ba6cc Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 13:49:14 +08:00
0506e765a9 chore: 2026-09-04 13:48:44 +08:00
acab1971ab refactor(ns-newform.vue): 优化字段样式,支持字段级控件尺寸和样式动态设置 2026-09-04 13:48:00 +08:00
f8797b9529 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 09:24:54 +08:00
11cc4f6319 refactor(ns-newform.vue): 优化字段样式,支持组件独立的间距和内边距设置 2026-09-04 09:13:25 +08:00
fc5c7e682c refactor(ns-newform.vue): 优化必填星号样式,动态计算颜色和间距 2026-09-03 17:59:22 +08:00
b2211edeab refactor(formdata.vue): update default styles and config merging logic
- Adjusted the merging order of form configuration to prioritize frontend defaults when backend configurations are absent.
- Clarified comments regarding the purpose of new visual styles and their application.
- Modified the hideInnerBanner logic to ensure consistent behavior with the updated form styles.
2026-09-03 17:50:11 +08:00
cd1ba1fbe2 chore:后台页面管理编辑-平台入驻核心权益-新增Tab页,前端运行出来可以正常滑动,Tab页正常显示 2026-09-03 16:27:30 +08:00
d2ad7b4108 chore:后台选择电话留言模式下,购物车库存销量隐藏 2026-09-03 11:22:21 +08:00
b4c47fcc26 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	components-diy/diy-form.vue
2026-09-02 11:58:46 +08:00
b31c133d8e chore:表单修改边距 2026-09-02 11:44:19 +08:00
2787e73126 chore:优化表单风格逻辑,支持显式和隐式判定,确保兼容性 2026-09-02 11:14:52 +08:00
aafd888596 chore:表单横幅缩小 2026-09-02 10:51:46 +08:00
ff3c2a0d9b chore:97数字跳动的显示正常 2026-09-01 18:26:36 +08:00
e7ad9a3cb5 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-09-01 16:29:29 +08:00
fc2b77135b chore:更新本地配置注释以提高可读性 2026-09-01 16:29:18 +08:00
33f54eb63c chore:后台数字跳动可以自定义文字大小颜色 2026-09-01 15:56:30 +08:00
a026adaec7 chore:表单样式修改 2026-09-01 09:24:09 +08:00
278d8c94f9 chore:表单样式修改表单浮于图片上方 2026-09-01 08:54:26 +08:00
f66b70c087 chore:推荐码分享按钮上移 2026-08-31 15:40:14 +08:00
c1407a84bb chore:H5登录正常 2026-08-29 18:22:10 +08:00
6ffbae0f33 chore:表单弹窗正常 2026-08-29 17:44:26 +08:00
f6038575cc chore:表单弹窗正常 2026-08-29 16:04:20 +08:00
5cdafb6f7f chore:推荐码立即分享跳转页面正常 2026-08-28 17:49:56 +08:00
7ebbdaf996 chore:微信小程序的表单多选可以垂直对齐 2026-08-28 14:13:32 +08:00
ecb0789780 chore:表单样式更新 2026-08-28 11:58:23 +08:00
e693e4273c chore:表单样式更新 2026-08-28 10:22:24 +08:00
e3171ef7ad chore:H5正常 2026-08-26 18:13:00 +08:00
3ca1ca8cd7 chore:推荐码样式新增 2026-08-26 11:44:39 +08:00
fcbbb05afe chore:表单正常 2026-08-25 18:06:35 +08:00
dd94e53d81 chore:表单提交正常 2026-08-25 17:39:03 +08:00
b8ff67aaa3 chore:提交次数 2026-08-25 11:18:43 +08:00
40a6f739d0 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
2026-08-24 09:56:36 +08:00
58a5a7d535 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp59 2026-08-24 09:31:08 +08:00
0e8db6f821 chore:PDF名字可以自定义 2026-08-22 16:04:33 +08:00
e6a45f6b7d chore:PDF有显示了 2026-08-22 15:55:00 +08:00
1b7cc4b679 chore:打开PDF右边新加一个分享按钮 2026-08-22 13:59:06 +08:00
876f639bb5 chore:红色按钮不乱飘了 2026-08-20 18:26:59 +08:00
02c9fa602e chore:购物车按钮正常 2026-08-20 17:51:48 +08:00
b5b27039ee feat(form): add captcha and form behavior support
实现表单行为相关功能,新增验证码输入模块,支持表单登录校验、提交次数限制、表单开放状态判断,补充多语言文案,优化空状态展示逻辑
2026-08-20 15:55:41 +08:00
b038175f07 Merge branch 'gaibao-2821' into dev/1.0 2026-07-14 08:47:48 +08:00
f18577a46c chore:切换左侧一级分类时,右侧会自动选中第一个二级分类按钮(如"离型纸")
如果用户之前点击过某个二级分类(如"离型膜"),切换一级分类后会自动选中同名的二级分类
2026-07-13 18:05:36 +08:00
9915fb9bb1 chore:切换左侧一级分类时,右侧默认选中第一个按钮 2026-07-13 18:00:48 +08:00
bb34e32f87 chore:删除按钮上方的“什么行业” 2026-07-13 17:26:58 +08:00
45e997adae chore:实现左侧一级分类点击哪个行业,右面就只显示这个行业,不显示其他的行业 2026-07-13 17:21:14 +08:00
2e4f11d108 chore:增加分类按钮,把商品列表的商品放在按钮下面 2026-07-13 17:14:15 +08:00
124 changed files with 41067 additions and 6435 deletions

3
.gitignore vendored
View File

@@ -5,8 +5,9 @@
/iconfont-preview.html
.codebuddy
.workbuddy
.vscode
.trae
.trae*
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch

View File

@@ -68,7 +68,7 @@ const localDevConfig = ({
uniacid: 11,
domain: 'http://localhost:8050/',
},
'100': {
'100': {// 口译教学
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -76,15 +76,15 @@ const localDevConfig = ({
uniacid: 1,
domain: 'https://5g.aigc-quickapp.com/',
},
'115': {
'115': {// 低压断路器
uniacid: 115,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-11': {
'5g-11': {// 铝箔袋包装
uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-206': {
'5g-206': {// 气体探测
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -92,19 +92,19 @@ const localDevConfig = ({
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
'47': { // 修改弹窗
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
},
'145': { // 表单链接转义问题
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-213': { // 单图组点击链接无法跳转
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-4': { // 公告点击链接无法跳转
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -116,11 +116,15 @@ const localDevConfig = ({
uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-237': { // 商品分类,没有立即咨询文字
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/',
},
'2847': { //
'5g-248': { //中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'2847': { // 家居装饰品
uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/',
},
@@ -128,7 +132,7 @@ const localDevConfig = ({
uniacid: 2482,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2485': { // 验证电子表单前后端数据一致性
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/',
},
@@ -152,11 +156,7 @@ const localDevConfig = ({
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -164,7 +164,7 @@ const localDevConfig = ({
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-95': { // 商品分类,没有立即咨询文字
'5g-95': { // 加热器 ,商品分类,没有立即咨询文字
uniacid: 95,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -185,6 +185,48 @@ const localDevConfig = ({
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'5g-59': { // 脱模剂粘合剂乐瑞固Lorechem
uniacid: 59,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-204': { // 热固性塑料,公告不跳转
uniacid: 204,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-351': { // 洗车机,商品详情的销量,购物车,库存隐藏
uniacid: 351,
domain: 'https://5g.aigc-quickapp.com/',
},
'2709': { // 中央厨房
uniacid: 2709,
domain: 'https://xcx.aigc-quickapp.com/',
},
'192.168.8.118-325': { // 测试平台
uniacid: 325,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'2505': { // 煤矿钻机
uniacid: 2505,
domain: 'https://xcx.aigc-quickapp.com/',
},
'3007': { // 钎焊材料
uniacid: 3007,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-317': { // 电磁兼容,模板显示不全商品
uniacid: 317,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-312': { // 天然植物功效活性物
uniacid: 312,
domain: 'https://5g.aigc-quickapp.com/',
},
'192.168.8.113-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.113:8050/',
// domain: 'http://localhost:8050/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -197,8 +239,19 @@ const localDevConfig = ({
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['5g-351']; //洗车机
// })['5g-204']; //热固性塑料
// })['5g-59']; //合成孔径雷达
// })['2709']; //中央厨房
// })['3007']; //钎焊材料
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
})['2811']; // 本地
// })['2811']; // 本地
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['2811']; // 本地
// })['5g-248']; // 中瑞 工业称重系统垃圾吊电子秤,首页,图片链接部分的PDF文档打不开
// })['2881']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

27
App.vue
View File

@@ -5,6 +5,15 @@
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
import aiService from 'common/js/ai-service.js';
import { markNeedStartupAd } from 'common/js/startup-ad.js';
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
let _appHasShown = false;
import {
Weixin
@@ -463,6 +472,14 @@
if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
try {
if (aiService && typeof aiService.onUserChange === 'function') {
aiService.onUserChange();
}
} catch (e) { /* ignore */ }
// 查询购物车信息
this.$store.dispatch('getCartNumber');
}
@@ -491,6 +508,16 @@
},
onShow: function(options) {
// #ifdef MP
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
if (_appHasShown) {
try { Lang.onAppReentry(); } catch (e) {}
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
try { markNeedStartupAd(); } catch (e) {}
}
_appHasShown = true;
// 自动授权登录
this.getAuthInfo();
if (this.$store.state.token) {

View File

@@ -1225,6 +1225,7 @@
}
.popup-layer {
position: relative;
background: #fff;
.head-wrap {
font-size: $font-size-toolbar;

View File

@@ -461,3 +461,16 @@
.icon-shuqing:before {
content: "\e09b";
}
/* 邮箱信封图标(2026-10-22, 名片资料行方案1落地):
字体包中暂无信封字形, 以内联 SVG 背景替代(content 置空避免渲染豆腐块)。
颜色取资料行文字标签默认色 #999999(与电话/地址图标观感一致);
后续若 iconfont 字库补充了同名码点字形, 删除本条覆盖即可无缝切换 */
.icon-youxiang:before {
content: "";
display: inline-block;
width: 30rpx;
height: 30rpx;
vertical-align: middle;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3Cpath d='m2 7 10 6 10-6'/%3E%3C/svg%3E") center/contain no-repeat;
}

View File

@@ -13,10 +13,54 @@ let currentConversationId = null;
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口
// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_id';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
// 老 key: ai_conversation_id_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 uniacid × userId × agent 三维隔离)
// 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理)
export const CONVERSATION_KEY_PREFIX = 'ai_conv_';
const LEGACY_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
let siteId = 0;
let userId = 'anon';
try {
siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0;
} catch (e) { /* ignore */ }
try {
userId = getUserId() || 'anon';
} catch (e) { /* ignore */ }
return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID);
}
// 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底
function migrateLegacyConversationKey(agentId) {
try {
const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
const legacy = uni.getStorageSync(legacyKey);
const newKey = getConversationKey(agentId);
if (legacy && !uni.getStorageSync(newKey)) {
uni.setStorageSync(newKey, legacy);
}
} catch (e) { /* ignore */ }
}
// 兼容旧代码: 单 key 别名(指向默认 agent) — 给老代码引用,值是动态计算的
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
let eventHandlers = { onEvent: null };
function dispatchEvent(onEvent, event, data) {
if (typeof onEvent === 'function') {
try { onEvent(event, data); }
catch (e) { console.error('[AI Service] onEvent handler threw:', e); }
}
}
/**
* PR-7: agent_id -> 后端 addon 路由映射
@@ -65,14 +109,25 @@ function getDomainParams() {
};
}
// 初始化时从本地读取会话ID
// 初始化时从本地读取会话ID(按当前 agent 隔离)
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key
// H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串
// (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick
// 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值
try {
const saved = uni.getStorageSync(CONVERSATION_KEY);
if (saved) {
currentConversationId = saved;
}
setTimeout(() => {
try {
migrateLegacyConversationKey(currentAgentId);
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) {
currentConversationId = saved;
}
} catch (e) {
console.warn('读取会话ID失败:', e);
}
}, 0);
} catch (e) {
console.warn('读取会话ID失败:', e);
console.warn('初始化会话ID任务派发失败:', e);
}
const GUEST_ID_KEY = 'ai_guest_user_id';
@@ -98,6 +153,28 @@ function getUserId() {
return guestId;
}
/**
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
*/
function findConfigUuid(obj, depth) {
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
var keys;
try { keys = Object.keys(obj); } catch (e) { return ''; }
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
}
for (var j = 0; j < keys.length; j++) {
var v = obj[keys[j]];
if (v && typeof v === 'object') {
var found = findConfigUuid(v, (depth || 0) + 1);
if (found) return found;
}
}
return '';
}
/**
* 获取当前 AI Agent。
*/
@@ -198,41 +275,99 @@ function ensureWebSocketConnected() {
// 以下为聊天消息响应,只在有活跃消息时处理
if (!wsMessageActive) return;
if (data.event === 'message') {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
// 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
var onEvt = eventHandlers.onEvent;
switch (data.event) {
case 'message': {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
} else if (data.event === 'message_end' || data.event === 'done') {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(独立函数,不使用 this)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(CONVERSATION_KEY, conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id);
dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id });
break;
}
case 'tool_call':
dispatchEvent(onEvt, 'tool_call', data.tool_call || {});
break;
case 'question':
dispatchEvent(onEvt, 'question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(按当前 agent 隔离)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
}
}
}
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
var endPayload = {
conversation_id: convId,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
};
wsMessageActive = false;
wsMessageContent = '';
dispatchEvent(onEvt, 'message_end', endPayload);
if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
if (wsMessageResolve) wsMessageResolve(endPayload);
break;
}
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
wsMessageActive = false;
wsMessageContent = '';
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
} else {
// 兜底日志:打印所有未识别的消息类型
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
case 'error':
dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' });
break;
case 'ping':
// keep-alive, 忽略
break;
default:
// 兜底: 老格式 data.data.content
if (data.data && data.data.content) {
var defText = data.data.content;
wsMessageContent += defText;
if (wsMessageOnChunk) wsMessageOnChunk(defText);
dispatchEvent(onEvt, 'message', { content: defText });
} else {
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
@@ -289,6 +424,64 @@ function disconnectWebSocket() {
}
// #endif
// PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存
// 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费;
// TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效
var _serviceConfigCache = null;
var _serviceConfigCacheAt = 0;
var SERVICE_CONFIG_CACHE_TTL = 60 * 1000;
function resetServiceConfigCache() {
_serviceConfigCache = null;
_serviceConfigCacheAt = 0;
}
// PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值
// 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应
// 改任何一项必须同步后端常量, 否则前后端默认分叉
//
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20):
// ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG),
// 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时,
// 模型选择器仍可显示 Auto。
// 数据源 (后端维护): https://docs.openclaw.ai/providers
// 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。
const CHAT_UI_DEFAULTS = {
ui_show_thinking_process: true,
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
ui_input_max_length: 500,
ui_available_models: ['Auto'],
ui_enable_feedback: true,
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_assistant_name: '',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
ui_show_assistant_name: true,
ui_show_assistant_avatar: true,
ui_assistant_avatar: '',
ui_show_message_time: true,
ui_time_format: 'auto',
ui_time_humanize: false
};
// chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步);
// 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它
var _chatUiConfigCache = null;
var _chatUiConfigCacheAt = 0;
function mergeChatUiConfig(remote) {
// remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底
// 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖
var merged = {};
Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) {
if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) {
merged[k] = remote[k];
} else {
merged[k] = CHAT_UI_DEFAULTS[k];
}
});
return merged;
}
export default {
/**
* 获取uniacid
@@ -329,6 +522,10 @@ export default {
response_mode: 'blocking',
user: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (options && options.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
// 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素
context: (options && options.context) || null,
...getDomainParams()
},
header: {
@@ -362,48 +559,106 @@ export default {
* 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket
*/
async sendStreamMessage(message, onChunk, onComplete) {
async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
// #ifdef MP
try {
await ensureWebSocketConnected();
} catch (e) {
if (onComplete) onComplete({ error: e.message });
throw e;
// WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
// 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
return this._fallbackToHttp(message, onEvent, onComplete, opts);
}
wsMessageContent = '';
wsMessageActive = true;
wsMessageOnChunk = onChunk || null;
wsMessageOnComplete = onComplete || null;
eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
// onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null);
return new Promise((resolve, reject) => {
wsMessageResolve = resolve;
wsMessageReject = reject;
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
})
});
try {
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: opts.attachments || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...getDomainParams()
})
});
} catch (sendErr) {
console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
// 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
this._fallbackToHttp(message, onEvent, onComplete, opts)
.then((res) => resolve(res))
.catch((err) => reject(err));
}
});
// #endif
},
/**
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
*/
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
try {
const result = await this.sendMessage(message, {
attachments: (opts && opts.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context
context: (opts && opts.context) || null
});
const content = result && result.content ? result.content : '';
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
const fallbackPayload = {
content,
conversation_id: result.conversationId,
thoughts: result.thoughts || '',
tokens: result.tokens || null
};
if (content || result.thoughts) {
if (typeof onEvent === 'function') {
if (result.thoughts) onEvent('thought', { content: result.thoughts });
onEvent('message', { content });
onEvent('message_end', fallbackPayload);
} else {
if (onComplete) onComplete(fallbackPayload);
}
return fallbackPayload;
}
const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
if (onComplete) onComplete({ error: emptyErr.message });
throw emptyErr;
} catch (err) {
console.error('[AI Service] MP HTTP 降级请求也失败:', err);
if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
throw err;
}
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onChunk, onComplete) {
async sendHttpStream(message, onEvent, onComplete, opts = {}) {
// #ifdef H5
try {
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
// PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25),
// resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。
// nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。
// 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。
var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/');
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -419,6 +674,9 @@ export default {
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (opts && opts.attachments) || [],
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
context: (opts && opts.context) || null,
...getDomainParams()
})
});
@@ -432,6 +690,7 @@ export default {
let buffer = '';
let content = '';
let conversationId = '';
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
while (true) {
const { done, value } = await reader.read();
@@ -444,54 +703,90 @@ export default {
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
try {
const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
if (data.event === 'message_end' || data.event === 'done') {
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
}
}
} catch (e) {
console.warn('解析失败:', e, line);
}
}
}
}
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
if (jsonStr) {
if (!trimmed.startsWith('data:')) continue;
const jsonStr = trimmed.slice(5).trim();
if (!jsonStr || jsonStr === '[DONE]') continue;
try {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
// PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558)
// 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."}
// 这种响应没 event 字段, 不应进 switch 的 default (会污染 console)
if (data && typeof data === 'object' && !Array.isArray(data) &&
data.code !== undefined && data.code !== 0 && !data.event) {
console.warn('[AI SSE] 后端错误响应:', data.code, data.message);
fire('error', { code: data.code, message: data.message || '请求失败' });
break;
}
// 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段),
// 不是事件, 静默跳过
if (Array.isArray(data)) {
continue;
}
switch (data.event) {
case 'message': {
var text = data.answer || data.text || data.content || '';
if (!text && data.data) text = data.data.content || data.data.answer || '';
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
fire('thought', { content: text, message_id: data.message_id });
break;
}
content += text;
fire('message', { content: text, message_id: data.message_id });
break;
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
fire('thought', { content: thoughtText2, message_id: data.message_id });
break;
}
case 'tool_call':
fire('tool_call', data.tool_call || {});
break;
case 'question':
fire('question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
const cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) conversationId = cid;
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
fire('message_end', {
conversation_id: cid,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
});
break;
}
case 'error':
fire('error', { code: data.code || -1, message: data.message || 'unknown' });
break;
default:
// B 路 Dify 把思考塞 answer 里 (> 前缀)
if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) {
fire('thought', { content: data.answer });
} else if (data.event === 'ping') {
// keep-alive
} else {
console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
console.warn('解析失败:', e, line);
}
} catch (e) {
console.warn('最后 buffer 解析失败:', e);
}
}
@@ -516,7 +811,9 @@ export default {
success: true,
content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
conversationId: response.data?.conversation_id,
messageId: response.data?.message_id
messageId: response.data?.message_id,
thoughts: response.data?.thoughts || '',
tokens: response.data?.tokens || null
};
} else {
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
@@ -532,25 +829,57 @@ export default {
/**
* 设置并持久化会话ID
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
*/
setConversationId(id) {
if (id && id !== currentConversationId) {
currentConversationId = id;
try {
uni.setStorageSync(CONVERSATION_KEY, id);
migrateLegacyConversationKey(currentAgentId);
uni.setStorageSync(getConversationKey(currentAgentId), id);
} catch (e) {
console.error('保存会话ID失败:', e);
}
}
},
/**
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子
* App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId
* 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug
*
* 调用时机:
* - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次
* - 任何 setMemberInfo 提交后调一次
* 行为:
* - 比对新老 user_id,不同时移除"旧 user"维度的 storage key
* - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建)
* - 记录 last_user_id 用于下次对比
*/
onUserChange() {
try {
const newUid = String(getUserId() || 'anon');
const oldUid = uni.getStorageSync('ai_last_user_id');
if (oldUid && oldUid !== newUid) {
// 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算)
const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0)
+ '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID);
try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ }
}
uni.setStorageSync('ai_last_user_id', newUid);
currentConversationId = null;
} catch (e) {
console.warn('[ai-service] onUserChange failed:', e && e.message);
}
},
/**
* 清除会话ID
*/
clearConversationId() {
currentConversationId = null;
try {
uni.removeStorageSync(CONVERSATION_KEY);
uni.removeStorageSync(getConversationKey(currentAgentId));
} catch (e) {
console.error('清除会话ID失败:', e);
}
@@ -612,6 +941,12 @@ export default {
* PR-7: URL 按 agent_id 路由
*/
async getServiceConfig() {
// PR-FIX (2026-09-20): 命中缓存直接返回
const nowTs = Date.now();
if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)');
return _serviceConfigCache;
}
try {
const params = {
url: resolveApiUrl('info'),
@@ -632,6 +967,18 @@ export default {
async: false,
});
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
const cfg = response && response.data;
const uuid = findConfigUuid(cfg, 0);
if (uuid) {
uni.setStorageSync('current_config_uuid', uuid);
console.log('[AI Service] 已缓存 config_uuid:', uuid);
} else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
}
// PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试)
_serviceConfigCache = response.data;
_serviceConfigCacheAt = Date.now();
return response.data;
} catch (error) {
console.error('[AI Service] getServiceConfig error:', error);
@@ -639,14 +986,75 @@ export default {
}
},
/**
* PR-FIX (2026-09-20): 清除服务配置缓存
* 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口)
*/
clearServiceConfigCache() {
resetServiceConfigCache();
_chatUiConfigCache = null;
_chatUiConfigCacheAt = 0;
},
/**
* PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关
* 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底
* 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用
* 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新)
*/
async getChatUiConfig(force) {
const nowTs = Date.now();
if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
return _chatUiConfigCache;
}
try {
// 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段
// 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache
const serviceData = await this.getServiceConfig();
const remote = (serviceData && serviceData.chat_ui_config) || null;
const merged = mergeChatUiConfig(remote);
_chatUiConfigCache = merged;
_chatUiConfigCacheAt = Date.now();
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时
// getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁)
try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ }
return merged;
} catch (e) {
console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e);
// 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试
return Object.assign({}, CHAT_UI_DEFAULTS);
}
},
/**
* PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存)
* 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用,
* 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。
* 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过)
* 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新
*/
getChatUiConfigSync() {
try {
const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG');
if (cached && typeof cached === 'object' && Object.keys(cached).length) {
return mergeChatUiConfig(cached);
}
} catch (e) { /* 存储读取失败按无缓存处理 */ }
return null;
},
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
// PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase
// ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换
// 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists
// 改 'clearConversation' → /api/kefu/clearConversation → 200
const response = await http.sendRequest({
url: resolveApiUrl('clear-conversation'),
url: resolveApiUrl('clearConversation'),
data: {
agent_id: getAgentId(),
conversation_id: conversationId,
@@ -661,6 +1069,69 @@ export default {
}
},
/**
* 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
* @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers }
* @returns {Promise<boolean>}
*/
async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
try {
await ensureWebSocketConnected();
} catch (e) {
console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message);
return false;
}
wsSocketTask.send({
data: JSON.stringify({
action: 'elicit_response',
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
token: store.state.token || 'test_token'
})
});
return true;
// #endif
// #ifdef H5
// PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
// 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
// (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
// 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse()
// (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱)
// 必须用 resolveAddonName() 拿 addon 全名直接拼 URL
try {
const addon = resolveAddonName(currentAgentId); // aikefu / aiagent
const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response';
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Token': store.state.token || 'test_token'
},
body: JSON.stringify({
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
user_id: getUserId(),
token: store.state.token || 'test_token'
})
});
const data = await resp.json();
return !!(data && (data.code === 0 || data.success));
} catch (e) {
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
return false;
}
// #endif
},
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
@@ -805,15 +1276,73 @@ export default {
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
*
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
*
* @param {string} agentId
* @param {object} opts { silent: bool } true = 不回调, 默认 false
*/
setAgentId(agentId) {
setAgentId(agentId, opts) {
var oldAgentId = currentAgentId;
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
// PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存
// 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话
var agentChanged = oldAgentId && oldAgentId !== agentId;
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved && !agentChanged) currentConversationId = saved;
else if (saved && agentChanged) {
// agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回
try {
const oldKey = getConversationKey(oldAgentId);
if (oldKey) uni.removeStorageSync(oldKey);
} catch (e) {}
}
} catch (e) { /* ignore */ }
},
/**
* 获取当前 agent 的会话存储 key (per-agent 隔离)
*/
getConversationKey(agentId) {
return getConversationKey(agentId);
},
/**
* 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选)
*/
getAllConversations() {
const out = {};
try {
const info = uni.getStorageInfoSync();
(info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0)
.forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); });
} catch (e) { /* ignore */ }
return out;
},
/**
* 获取当前 config_uuid (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/
getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
},
/**
* 获取用户 ID (媒体上传字段用)
*/
getUserId() {
return getUserId();
},
/**

567
common/js/card-api.js Normal file
View File

@@ -0,0 +1,567 @@
/**
* 名片云(card addon)API 收口层
*
* 单一事实来源:后端《名片云(card addon)API 对接文档》CARD_API_REFERENCE.md(2026-10-13)
* 文档与源码如有出入,以源码为准;URL 路径一个字符都不能变,只做收口不做改名。
*
* 收口要求(来自后端 checklist):
* 1. 所有 /card/api/* URL 必须集中在本层,页面不得再直连字面量;
* 2. 统一走项目请求工具(自动带 token / uniacid),任何接口不传 site_id;
* 3. 失败分支统一 toast(message);「不存在」类错误按"内容不存在或已删除"提示;
* 4. card/update 只传编辑过的键(键存在才写、空串=清空、键缺席=不擦除);
* 5. 详情/列表渲染富化字段用 *_list 解码键,不解析原始 JSON 串;
* 6. 编辑页对 locked_fields 命中的字段渲染只读;
* 7. recognize 成功后检查 deduped,为 true 时提示"已存在同手机号名片";
* 8. 分页参数 page_size 不超过 100;
* 9. 名片路由参数用 card_id(字符串),人脉/线索用 id(数字),不要混用。
*
* 用法(页面内 this.$cardApi):
* this.$cardApi.cardLists({ data: { page: 1 }, success: res => {}, fail: () => {} })
*
* 注意:本层只做"URL 收口 + 通道选择",不做业务加工;
* 业务字典(状态/来源/字段白名单)随端口导出,供各页面复用。
*/
import Http from './http.js';
/** 所有端点路径(与后端文档逐一比对,改动需两边同步) */
export const CARD_API_URL = {
// === 名片夹 /card/api/card/* ===
CARD_LISTS: '/card/api/card/lists',
CARD_DETAIL: '/card/api/card/detail',
CARD_UPDATE: '/card/api/card/update',
CARD_FAVORITE: '/card/api/card/toggleFavorite', // 收藏(双向 toggle, 后端 2.9.12)
CARD_CREATE: '/card/api/card/create', // 新建数字名片(后端源码已有, 文档未列; 以源码为准)
CARD_CLAIM: '/card/api/card/claim',
CARD_RECOGNIZE: '/card/api/card/recognize',
CARD_RETRY: '/card/api/card/retry',
CARD_RECOGNIZE_BATCH: '/card/api/card/recognizeBatch',
CARD_DELETE: '/card/api/card/delete', // 删除自己的名片(软删, 2.11.91)
// === 跟进完成态 2.11.3 /card/api/card/* ===
CARD_FOLLOW_DONE: '/card/api/card/followDone',
// === 访客轨迹 2.11.4 /card/api/card/* ===
CARD_VIEWS: '/card/api/card/views',
// === 员工建卡申请 2.11.15 /card/api/apply/* ===
APPLY_SUBMIT: '/card/api/apply/submit',
APPLY_STATUS: '/card/api/apply/status',
// === 人脉库 /card/api/contact/* ===
CONTACT_LISTS: '/card/api/contact/lists',
CONTACT_ADD: '/card/api/contact/add',
CONTACT_INFO: '/card/api/contact/info',
CONTACT_UPDATE: '/card/api/contact/update',
CONTACT_DELETE: '/card/api/contact/delete',
CONTACT_TO_LEAD: '/card/api/contact/toLead',
// === 潜在客户 /card/api/lead/* ===
LEAD_LISTS: '/card/api/lead/lists',
LEAD_SET_STATUS: '/card/api/lead/setStatus',
// === 交换名片 /card/api/exchange/* ===
EXCHANGE_LISTS: '/card/api/exchange/lists',
EXCHANGE_AGREE: '/card/api/exchange/agree',
EXCHANGE_IGNORE: '/card/api/exchange/ignore',
// === 名片设计(员工端可选样式, 后端 v2) /card/api/design/* ===
// 预设列表必须与商家后台 CardTemplatePresets 同源(避免前端自建漂移)
// ⚠ v2 路径无 /card/ 段: 初版 /card/api/card/design/* 在本框架 action 单段路由下 404
CARD_DESIGN_PRESETS: '/card/api/design/presets', // 预设列表(同源后台 CardTemplatePresets)
CARD_DESIGN_SAVE: '/card/api/design/save', // 应用预设/保存本卡样式
// === 企业名片 /card/api/enterprise/* (2.13.6) ===
// 业务模型: 企业卡片 owner_id=0(店铺级), 同一时刻仅 1 张 is_active=1 生效;
// 名片区三模式 header_mode: official(指定/默认官方企业卡) / self(员工自选个人卡) / staff(员工轮播)
ENTERPRISE_DETAIL: '/card/api/enterprise/detail', // GET ?card_id= 不传=当前生效卡
ENTERPRISE_SAVE: '/card/api/enterprise/save', // POST {card_id?,fields?,design?,header_mode?,official_card_id?}
ENTERPRISE_MINE: '/card/api/enterprise/mine', // GET 自己名下企业卡列表
ENTERPRISE_ACTIVATE: '/card/api/enterprise/activate', // POST {card_id} 切换生效(互斥)
ENTERPRISE_CHOOSE_SELF: '/card/api/enterprise/chooseSelf', // POST {card_id} 员工自选展示个人卡
ENTERPRISE_DELETE: '/card/api/enterprise/delete' // POST {card_id} 删候选卡(生效卡禁删)
};
/**
* 社交媒体类型白名单:必须与后端 CardRichFields::SOCIAL_TYPES 保持一致(增减需两边同步)。
* type 非法或 value 为空的条目会被后端静默丢弃。
* ⚠️ 2026-10-13:telegram / messenger 由独立标量字段软下线,统一并入本数组;
* 旧数据后端读取时自动合并进 socials_list(见 CARD_API_REFERENCE.md 3.3)。
*/
export const SOCIAL_TYPES = [
{ key: 'alipay', label: '支付宝' },
{ key: 'douyin', label: '抖音' },
{ key: 'wxvideo', label: '视频号' },
{ key: 'xiaohongshu', label: '小红书' },
{ key: 'weibo', label: '微博' },
{ key: 'kuaishou', label: '快手' },
{ key: 'bilibili', label: '哔哩哔哩' },
{ key: 'qq', label: 'QQ' },
{ key: 'linkedin', label: 'LinkedIn' },
{ key: 'facebook', label: 'Facebook' },
{ key: 'instagram', label: 'Instagram' },
{ key: 'x', label: 'X' },
{ key: 'telegram', label: 'Telegram' },
{ key: 'messenger', label: 'Messenger' },
{ key: 'other', label: '其他' }
];
/** 富化字段上限:与后端 CardRichFields::MAX_* 一致 */
export const RICH_FIELD_LIMITS = {
MAX_MOBILES: 5,
MAX_SOCIALS: 10,
MAX_CUSTOM: 10
};
/** 线索跟进状态:与后端 Lead::STATUSES 一致(0-3,其它值报「参数不合法」) */
export const LEAD_STATUS = [
{ value: 0, label: '新客户' },
{ value: 1, label: '已联系' },
{ value: 2, label: '已成交' },
{ value: 3, label: '无效' }
];
/** 人脉来源:与后端 Contact::SOURCES 一致(scan/exchange 暂无产生入口) */
export const CONTACT_SOURCES = [
{ value: 'manual', label: '手工录入' },
{ value: 'lead_in', label: '潜在客户转入' },
{ value: 'scan', label: '扫描' },
{ value: 'exchange', label: '交换' }
];
/** 名片来源:与后端 relation.source_type 一致 */
export const CARD_SOURCE_TYPES = [
{ value: 'manual', label: '手动创建' },
{ value: 'manual_batch', label: '批量制作' },
{ value: 'scan', label: '扫描识别' },
{ value: 'import_csv', label: 'CSV 导入' },
{ value: 'import_xlsx', label: 'Excel 导入' },
{ value: 'clone', label: '克隆' },
{ value: 'api', label: '接口创建' }
];
/**
* 名片编辑标量字段:与后端 BusinessCardFieldNormalizer 输出对齐。
* ⚠️ 2026-10-13:telegram / messenger 已软下线为独立标量(传了也被忽略),
* 统一走 fields.socials 数组按类型添加;读取时旧值自动合并进 socials_list。
*/
export const CARD_FIELDS = [
'name', 'company', 'job_title', 'avatar',
'mobile', 'phone', 'email', 'website', 'wechat',
'country', 'province', 'city', 'address', 'postal_code',
'bio'
];
/** 人脉可更新白名单:与后端 Contact::EDITABLE_FIELDS 一致 */
export const CONTACT_FIELDS = [
'name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark', 'status'
];
const CardApi = {
url: CARD_API_URL,
/* ================= 名片夹 ================= */
/**
* 我的名片列表
* @param {Object} p { page, page_size(<=100), keyword, success, fail }
* 返回 data: { list, count, page_count }
*/
cardLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_LISTS }, p || {}));
},
/**
* 卡片详情
* @param {Object} p { card_id, success, fail }
* 返回 data: { relation, source, share_list }
*/
cardDetail(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DETAIL }, p || {}));
},
/**
* 更新卡片(normalize 语义:键存在才写;空串=清空;键缺席=不擦除)
* 局部更新时只传用户实际编辑过的键。走 JSON body(fields 为嵌套对象)。
* @param {Object} p { card_id, fields, success, fail }
*/
cardUpdate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_UPDATE }, p || {}));
},
/**
* 新建数字名片(商家端批量制作/单张新建入口)
* 入参:`fields`(与 card/update 同结构,`name` 必填)+ 可选 `owner_id`(指定归属员工,0=未归属待认领)
* 返回 `data`:`{ card_id, ...relation }`(前端用 data.card_id 跳详情)
* ⚠️ 该端点后端源码已实现但旧文档未列出,按「文档与源码出入以源码为准」收口。
*/
cardCreate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_CREATE }, p || {}));
},
/**
* 认领名片(商家后台建的卡未归属 owner_id=0,员工认领到自己名下)
* 入参(二选一, claim_code 优先): { data: { claim_code } } 或 { data: { card_id } }
* 成功 data.claimed: true=新认领, false=早已是自己的(幂等)
* 文档: card_claim_api.md(POST /card/api/card/claim,token 自动带)
* @param {Object} p { data: { claim_code | card_id }, success, fail }
*/
cardClaim(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_CLAIM }, p || {}));
},
/**
* 切换收藏(双向 toggle,幂等可逆:未收藏→置1,已收藏→置0)
* 直接用返回值 data.is_favorite 回填 UI,前端不自行判断状态。
* @param {Object} p { data: { card_id }, success, fail }
* 返回 data: { card_id, is_favorite: 0|1 }
* 拒绝(message 直接 toast): 卡不存在 / 非自己名下卡(统一「名片不存在」) / 参数不合法 / 未登录
*/
cardToggleFavorite(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_FAVORITE }, p || {}));
},
/**
* 删除自己的名片(软删, 2.11.91)
* 文档: CARD_DELETE_API.md(POST /card/api/card/delete,token 自动带,不传 site_id)
* 通道: 与 cardClaim/cardFollowDone 同款 sendRequest(表单编码);只需 card_id 一个字段
* 交互: 调用前页面必须已做二次确认;成功后 toast「已删除」并刷新列表(接口不再返回该卡)
* 口径: 「卡片不存在」是故意模糊(他人/已删/不存在同一文案),前端统一按 message toast,不特殊处理
* @param {Object} p { data: { card_id }, success, fail }
*/
cardDelete(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DELETE }, p || {}));
},
/**
* 识别建卡
* @param {Object} p { media_path, media_type?, source_type?, success, fail }
* 返回 data: { source_id, relation_id, card_id, deduped, fields }
* deduped=true 表示站点内已有同手机号名片,未新建,前端提示"已存在同名片"
*/
cardRecognize(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE }, p || {}));
},
/**
* 识别失败重试(仅本站 + status=failed 的来源可重试,有次数上限)
* @param {Object} p { source_id, success, fail }
*/
cardRetry(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_RETRY }, p || {}));
},
/**
* 批量识别(1~10 条,逐项独立成败)
* @param {Object} p { items: [{ source_type?, media_type?, media_path, media_key? }], success, fail }
* 返回 data: { items: [{ ok, message, data }], ok, failed }
*/
cardRecognizeBatch(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_RECOGNIZE_BATCH }, p || {}));
},
/**
* 标记跟进完成(2.11.3 跟进闭环)
* 行级归属: 只能标记自己名下的名片(与收藏/认领同一套口径)
* 成功 data: { card_id, follow_up_done: 1 }
* 失败 message 直接 toast: 卡片不存在 / 只能标记自己名下的名片
* @param {Object} p { data: { card_id }, success, fail }
*/
cardFollowDone(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_FOLLOW_DONE }, p || {}));
},
/**
* 访客轨迹(2.11.4 谁看了我)
* 入参(全部可选): { data: { card_id?, page?, page_size?(<=50, 建议20) } }
* 不传 card_id = 当前员工名下全部名片; 成功 data: { list, total, page, page_size,
* summary: { today_pv, today_uv, total_pv, total_uv } }
* @param {Object} p { data: {...}, success, fail }
*/
cardViews(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_VIEWS }, p || {}));
},
/* ================= 人脉库 ================= */
/**
* 人脉列表
* @param {Object} p { page, page_size(<=100), keyword, source?, status?, success, fail }
* 返回 data: { list, count, page_count }
*/
contactLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_LISTS }, p || {}));
},
/**
* 新增人脉
* @param {Object} p { fields: { name(必填), company?, ... }, success, fail }
*/
contactAdd(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_ADD }, p || {}));
},
/**
* 人脉详情(编辑页回填;id 为数字自增主键,注意与名片 card_id 字符串不同)
* @param {Object} p { id, success, fail }
*/
contactInfo(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_INFO }, p || {}));
},
/**
* 更新人脉(兼容 fields 对象或顶层平铺字段)
* @param {Object} p { id, fields?, ...平铺字段, success, fail }
*/
contactUpdate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CONTACT_UPDATE }, p || {}));
},
/**
* 删除人脉(真删不可恢复,前端需二次确认弹窗)
* @param {Object} p { id, success, fail }
*/
contactDelete(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_DELETE }, p || {}));
},
/**
* 人脉 → 潜在客户(不删人脉行;同员工同手机号已转过则报错)
* @param {Object} p { id, success, fail }
*/
contactToLead(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CONTACT_TO_LEAD }, p || {}));
},
/* ================= 潜在客户 ================= */
/**
* 线索列表(自己名下名片收到的线索 + 自己从人脉转入的线索)
* @param {Object} p { page, page_size(<=100), status?(0-3), keyword, success, fail }
* 返回 data: { list, count, page_count }
*/
leadLists(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_LISTS }, p || {}));
},
/**
* 更新跟进状态(status 只能是 0/1/2/3)
* @param {Object} p { id, status, success, fail }
* 返回 data: { id, status }
*/
leadSetStatus(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.LEAD_SET_STATUS }, p || {}));
},
/* ================= 交换名片 ================= */
/**
* 待处理交换请求列表(返回 status=0;已同意/已忽略员工端不可见)
* @param {Object} p { page, page_size(<=100), success, fail }
* 返回 data: { list:[{ id, from_card_id, name, avatar, job_title, company,
* create_time, create_time_txt, status }], count, page_count }
* ⚠️ 详情页红点计数技巧: lists?page=1&page_size=1 取 count 即可
* ⚠️ 后端契约要求 lists 走 GET(分页在 query); 而 Http.sendRequest 是
* 「带 data 就发 POST」的语义, 所以这里把分页拼进 url、不传 data,
* 否则会被发成 POST 导致接口不匹配、列表恒为空。
*/
exchangeLists(p) {
const opt = Object.assign({}, p || {});
const src = opt.data || opt;
const page = src.page || 1;
const pageSize = src.page_size || 20;
delete opt.data;
delete opt.page;
delete opt.page_size;
opt.url = CARD_API_URL.EXCHANGE_LISTS + '?page=' + page + '&page_size=' + pageSize;
Http.sendRequest(opt);
},
/**
* 同意交换(后端自动双方建人脉,前端无需再调 contact/add)
* 幂等: 同侧已存在同手机号人脉时 receiver_created=0,仍是成功,不当失败
* @param {Object} p { data: { id }, success, fail }
* 返回 data: { id, status:1, receiver_created, initiator_created }
*/
exchangeAgree(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_AGREE }, p || {}));
},
/**
* 忽略交换(仅本方隐藏 status=2,对方不发通知、可再次发起)
* @param {Object} p { data: { id }, success, fail }
* 返回 data: { id, status:2 }
*/
exchangeIgnore(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.EXCHANGE_IGNORE }, p || {}));
},
/**
* 同意交换后「校验 + 兜底」建人脉(页面点「同意」后必调;幂等,可安全重复调用)。
* 背景: 后端声明 CardExchange::agree 会双向互存人脉(我方人脉收对方名片 / 对方人脉收我方名片),
* 但实测出现「同意成功、对方名片却没进我的人脉」, 故前端不再只信 receiver_created,
* 统一在同意后按「对方名片编号」校验一次: 我的人脉里没有同手机号记录才补建。
* 流程: 取对方名片详情 → 按手机号查我的人脉 → 不存在则以 source=exchange 新建人脉。
* 幂等: 同手机号人脉已存在(含后端已建)时 created=0, 与后台已建不会重复。
* @param {Object} p { fromCardId, success, fail }
* 回调 done(data): { code, created, existed } created=1 新建 / 0 已存在或无需建;
* existed = 命中的同手机号人脉行(未命中为 null), 供页面提示「人脉中已有该联系人」。
*/
ensureExchangeContact(p) {
const self = this;
const done = p.success, fail = p.fail;
const finish = (d) => { typeof done === 'function' && done(d); };
const fromCardId = p.fromCardId;
if (!fromCardId) { finish({ code: 0, created: 0 }); return; }
this.cardDetail({
data: { card_id: fromCardId },
success: res => {
if (res.code != 0) { typeof fail === 'function' && fail(res); return; }
const r = (res.data && (res.data.relation || res.data)) || {};
const mobile = r.mobile || '';
const fields = {
name: r.name || '',
company: r.company || '',
job_title: r.job_title || '',
mobile: mobile,
source: 'exchange'
};
// 富化字段仅在对方有名片值时带上(空串=清空语义, 新建人脉时不传空键)
if (r.wechat) fields.wechat = r.wechat;
if (r.email) fields.email = r.email;
if (r.website) fields.website = r.website;
if (r.address) fields.address = r.address;
const add = () => self.contactAdd({
data: { source: 'exchange', fields: fields },
success: res2 => {
// code!=0(如手机号已存在被拒): 交换已同意成功, 兜底不打扰用户, 按无需新建收口
const ok = res2 && res2.code == 0;
finish({ code: ok ? 0 : ((res2 && res2.code) || 0), created: ok ? 1 : 0, raw: res2 });
},
fail: err => { typeof fail === 'function' && fail(err); }
});
// 按手机号去重: 已存在同手机号人脉则不重复建(兼容后台已建 / 重复同意)
if (mobile) {
self.contactLists({
data: { page: 1, page_size: 20, keyword: mobile },
success: res3 => {
const d3 = res3.data || {};
const list = Array.isArray(d3) ? d3 : (d3.list || []);
const hit = list.find(c => (c.mobile || '') === mobile);
// 命中: 对方已在我的人脉里(可能显示为旧姓名), 不覆盖用户维护过的数据, 只回传命中行
if (hit) { finish({ code: 0, created: 0, existed: hit }); return; }
add();
},
fail: () => add()
});
} else {
add();
}
},
fail: err => { typeof fail === 'function' && fail(err); }
});
},
/* ================= 员工建卡申请 2.11.15 ================= */
/**
* 提交建卡申请(空态申请 → 商家审批 → 自动建卡归属)
* 约束: 同一会员同时只允许一条待审申请(重复提交后端会拒)
* 入参: { data: { name, mobile } }
* @param {Object} p { data: { name, mobile }, success, fail }
*/
applySubmit(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_SUBMIT }, p || {}));
},
/**
* 查询我的建卡申请状态(进入名片夹/空态时调用或轮询)
* 成功 data: { status: 0 待审/1 已同意/2 已拒绝, status_text, note, has_card }
* @param {Object} p { success, fail }
*/
applyStatus(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.APPLY_STATUS }, p || {}));
},
/* ================= 名片设计(员工端可选样式) ================= */
/**
* 拉取名片预设列表(与商家后台 CardTemplatePresets 同一份, 同源)
* 成功 data: { list:[{ key, name, desc, thumb,
* design:{ theme_color, text_color, bg_color, layout, cover, font_family,
* font_scale, card_radius, header_gradient_end, header_gradient_angle, quick_style } }] }
* @param {Object} p { success, fail }
*/
cardDesignPresets(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.CARD_DESIGN_PRESETS }, p || {}));
},
/**
* 应用预设 / 保存本卡样式(员工端选择后台预设后落库)
* 入参(包在 data 里): { card_id, preset_key } —— 后端按 preset_key 取 CardTemplatePresets
* 的 design 合并进本卡 lucky_card_design(非空覆盖), 返回 { code:0 }
* @param {Object} p { data: { card_id, preset_key }, success, fail }
*/
cardDesignSave(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.CARD_DESIGN_SAVE }, p || {}));
},
/* ================= 企业名片 2.13.6 ================= */
/**
* 企业名片详情(核心)
* GET ?card_id= 不传返回当前生效企业名片;传候选卡 id 可预览(is_active=0)
* 成功 data: { card_id, is_active, is_owner, fields:{name,company,job_title,mobile,email,
* website,address,wechat,avatar,bio,name_en,company_en,job_title_en,bio_en},
* design:{theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id},
* header:{mode,official_card_id,official,self,staff} }
* 渲染: 名片区按 header.mode 取 official/self/staff 数据, 内容区用 fields+design.canvas
* 失败「企业名片不存在」→ 前端空态引导去管理端创建
* @param {Object} p { data: { card_id? }, success, fail }
*/
enterpriseDetail(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_DETAIL }, p || {}));
},
/**
* 保存企业名片(后台/会员;owner_id=0 的卡会员不可改)
* @param {Object} p { data: { card_id?, fields?, design?, header_mode?, official_card_id? }, success, fail }
*/
enterpriseSave(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_SAVE }, p || {}));
},
/**
* 我的企业名片列表(自己名下)
* 成功 data: { list: [{ card_id, name, is_active }] }
* @param {Object} p { success, fail }
*/
enterpriseMine(p) {
Http.sendRequest(Object.assign({ url: CARD_API_URL.ENTERPRISE_MINE }, p || {}));
},
/**
* 切换生效企业名片(互斥:全店仅 1 张 is_active=1,仅能切自己名下的卡)
* @param {Object} p { data: { card_id }, success, fail }
*/
enterpriseActivate(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_ACTIVATE }, p || {}));
},
/**
* 员工自选展示名片(self 模式):card_id 必须是自己名下的 contact 卡
* 选择按会员存储;detail 的 header.self.chosen=1 表示已自选
* @param {Object} p { data: { card_id }, success, fail }
*/
enterpriseChooseSelf(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_CHOOSE_SELF }, p || {}));
},
/**
* 删除企业名片(会员端;生效卡不可删,删除候选卡会清理官方名片引用)
* @param {Object} p { data: { card_id }, success, fail }
*/
enterpriseDelete(p) {
Http.sendJson(Object.assign({ url: CARD_API_URL.ENTERPRISE_DELETE }, p || {}));
}
};
export default CardApi;

View File

@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
}
// #endif
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
export default {
/**
@@ -80,6 +80,10 @@ export default {
//api请求地址
baseUrl: domain,
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
aikefuConfigUuid: aikefuConfigUuid,
// 图片域名
imgDomain: domain,

View File

@@ -2,6 +2,7 @@ import WxMap from 'common/js/map-wx-jssdk.js';
import Config from '@/common/js/config.js';
import util from '@/common/js/util.js';
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
let systemInfo = util.getDeviceInfo();
@@ -297,25 +298,84 @@ export default {
data: {},
async: false
});
this.adv = res.value
// 弹框形式,首次弹出 1,每次弹出 0
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
let advData = (res && (res.data != null ? res.data : res.value)) || {};
this.adv = advData;
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
if (this.adv.advshow == 1) {
setTimeout(() => {
if (res.value.advtype == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
setTimeout(async () => {
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
await this._waitPrivacyResolved();
if (!this.$refs.uniPopupWindow) return;
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
// 先判定本次是否需要弹出, 避免无谓的图片预加载
let shouldOpen = false;
let isFirstType = false;
if (this.adv.advtype == 1) {
// 首次弹出: 从未展示过才弹
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if (popwindow_count == '' || popwindow_count == 0) {
shouldOpen = true;
isFirstType = true;
}
} else if (res.value.advtype == 0) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
} else {
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
if (consumeNeedStartupAd() || isColdLaunchPending()) {
shouldOpen = true;
}
}
if (!shouldOpen) return;
// 关键: 广告图片是网络图, 而 uni-popup 用 v-if 在 open() 时才渲染 slot,
// 图片要等弹窗打开后才开始请求/解码, 真机网络慢时就会"一截一截"逐步显示。
// 先把所有广告图片预加载进本地缓存, 全部就绪后再 open, 使弹窗一次性完整出现。
await this._preloadAdvImages(this.adv.list);
if (!this.$refs.uniPopupWindow) return;
this.$refs.uniPopupWindow.open();
if (isFirstType) {
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
}, 500);
}
},
/**
* 等待隐私协议流程结束后再弹启动广告
* - 鸿蒙元服务: 隐私由系统托管, 应用层不弹自建弹窗, 直接放行
* - 已同意过(storage privacyAgreed): 无需等待, 直接放行
* - 未同意: 监听 PrivacyManager 广播的 privacy:agreed / privacy:disagreed,
* 用户点击隐私协议(同意/拒绝)关闭弹窗后放行
* - 兜底超时: 隐私协议因规则未弹等情况永不 emit 时也要弹广告, 避免卡死
* @param {number} [maxWait=15000]
* @returns {Promise<void>}
*/
_waitPrivacyResolved(maxWait = 15000) {
// 鸿蒙元服务: 隐私协议由系统托管, 应用层不弹自建隐私弹窗, 无需等待
// #ifdef MP-HARMONY
return Promise.resolve();
// #endif
// 已经同意过 → 隐私弹窗不会再出现, 直接放行
if (uni.getStorageSync('privacyAgreed')) return Promise.resolve();
return new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
uni.$off('privacy:agreed', finish);
uni.$off('privacy:disagreed', finish);
clearTimeout(timer);
resolve();
};
uni.$on('privacy:agreed', finish);
uni.$on('privacy:disagreed', finish);
// 兜底: 隐私协议因规则未弹 / 一直未处理时, 超时后也要弹广告
const timer = setTimeout(finish, maxWait);
});
},
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
@@ -542,9 +602,46 @@ export default {
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
}
},
/**
* 预加载启动广告图片
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
* 使弹窗一次性完整呈现。
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
* @returns {Promise<void>}
*/
_preloadAdvImages(list, timeout = 4000) {
if (!list || !list.length) return Promise.resolve();
const tasks = list.map((item) => {
return new Promise((resolve) => {
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
if (!url) return resolve();
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(finish, timeout);
try {
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
uni.getImageInfo({
src: url,
success: finish,
fail: finish
});
} catch (e) {
finish();
}
});
});
return Promise.all(tasks).then(() => {});
},
closePopupWindow() {
this.$refs.uniPopupWindow.close();
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
},
closeCollectPopupWindow() {
this.$refs.collectPopupWindow.close();

View File

@@ -68,6 +68,10 @@ export default {
hover_nav_bg_color: '#FFFC',
hover_nav_text_color: '#000',
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
hover_nav_phone_font_size: '36rpx', // 电话按钮内图标字号基准
// ===== DIY 分类页字号 token =====
diy_category_tree_select_size: '28rpx',

View File

@@ -0,0 +1,134 @@
/**
* 浮动侧边栏配置消费 (字段来源: /api/config/init → data.floating_button)
* 纯函数模块, 无副作用, 可直接单测
*/
/** 归一化路由: 'pages/index/index' → '/pages/index/index' */
export function normalizeRoute(route) {
if (!route) return '';
return String(route).charAt(0) === '/' ? String(route) : '/' + String(route);
}
/** 取当前页面路由 (兼容 H5 / 小程序; route 已含分包 root, 如 'pages_goods/detail') */
export function getCurrentRoute() {
try {
const pages = getCurrentPages();
const cur = pages[pages.length - 1];
return normalizeRoute((cur && (cur.route || (cur.$page && cur.$page.path))) || '');
} catch (e) {
return '';
}
}
/**
* 前缀命中判定
* '/pages/index' 命中 '/pages/index' 与 '/pages/index/xxx'
* '/pages_tool/' 命中 '/pages_tool/xxx' (前缀自带尾 /)
* '/pages/order/payment' 只命中它自己及其子路径
*/
export function hitPrefix(route, prefix) {
if (!route || !prefix) return false;
const p = prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/';
return route === prefix || route.indexOf(p) === 0;
}
/** 任一前缀命中 */
function hitAny(route, prefixes) {
if (!Array.isArray(prefixes)) return false;
for (let i = 0; i < prefixes.length; i++) {
if (hitPrefix(route, prefixes[i])) return true;
}
return false;
}
/** 找出路由命中的页面分组 key (未命中返回 null) */
export function matchPageGroup(route, prefixMap) {
if (!prefixMap) return null;
const keys = Object.keys(prefixMap);
for (let i = 0; i < keys.length; i++) {
if (hitAny(normalizeRoute(route), prefixMap[keys[i]])) return keys[i];
}
return null;
}
/**
* 侧边栏在当前页面是否显示 —— 唯一判定入口
*
* 优先级: 总开关 → 强制隐藏 → 显示范围
* 1) fb 不存在 (老缓存响应) → fallbackVisible (默认 true, 保持老行为)
* 2) enabled != 1 → false
* 3) 命中 force_hidden_prefixes 任意前缀 → false (最高优先级)
* 4) 命中 custom_pages / page_prefixes._custom → true
* 5) 命中 pages[组] == 1 → true
* 6) 都不命中 (如 /pages_goods/*) → false
*/
export function shouldShowOnPage(fb, route, fallbackVisible) {
if (!fb || typeof fb !== 'object') return fallbackVisible !== false;
if (Number(fb.enabled) !== 1) return false;
const r = normalizeRoute(route);
if (!r) return false;
// 强制隐藏 — 优先级最高
if (matchPageGroup(r, fb.force_hidden_prefixes)) return false;
// 自定义前缀优先判定
if (hitAny(r, fb.custom_pages) || hitAny(r, (fb.page_prefixes || {})['_custom'])) return true;
// 预设分组
const group = matchPageGroup(r, fb.page_prefixes);
if (group) return Number((fb.pages || {})[group]) === 1;
return false;
}
/**
* 当前页面要渲染的按钮列表 (已按 button_order 排序 + 显隐过滤)
* 返回 ['lang','kefu','phone'] 的子集, 空数组 = 整个侧边栏不渲染
* 返回 null = 配置缺失, 由调用方决定老行为
*/
export function resolveVisibleButtons(fb) {
if (!fb || typeof fb !== 'object') return null;
const order = Array.isArray(fb.button_order) ? fb.button_order : ['lang', 'kefu', 'phone'];
const map = fb.buttons || {};
const out = [];
for (let i = 0; i < order.length; i++) {
const k = order[i];
if (Number(map[k]) === 1) out.push(k);
}
return out;
}
/**
* 语言按钮显示文案
* @param {Object} fb data.floating_button
* @param {Array} langList [{ value:'zh-cn', name:'CN' }, ...]
* @param {Number} currentIdx 当前语言下标
*/
export function resolveLangButtonText(fb, langList, currentIdx) {
const list = Array.isArray(langList) ? langList : [];
if (!list.length) return '';
const style = (fb && fb.lang_switch && fb.lang_switch.style) || 'target';
if (style === 'text') {
const t = (fb && fb.lang_switch && fb.lang_switch.text) || '';
if (t) return t; // 空文案兜底走 target
}
if (style === 'current' || list.length < 2) {
return (list[currentIdx] && list[currentIdx].name) || '';
}
// target: 显示「点下去会切到的那个」语言
// 2 种语言 → 另一个; >2 种 → 取第一个不是当前的那个
let targetIdx = 0;
for (let i = 0; i < list.length; i++) {
if (i !== currentIdx) { targetIdx = i; break; }
}
return list[targetIdx] ? (list[targetIdx].name || '') : '';
}
/** 语言切换按钮是否可见 (含老「双语模式」fallback) */
export function isLangButtonVisible(fb, shopInfo, langCount) {
if (Number(langCount) < 2) return false;
if (fb && fb.lang_switch) return Number(fb.lang_switch.enabled) === 1;
return !!(shopInfo && shopInfo.ischina); // 老行为兜底
}

View File

@@ -115,7 +115,12 @@ export default {
}
//媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
// 媒体显示顺序以后台商品「高级设置 → 媒体显示顺序」media_order 为准
// (1=主图在前 2=视频在前); 真正渲染媒体区的是 goods-detail-view 组件,
// 组件内部按同一规则切换, 这里保持一致避免两处规则打架
if (this.goodsSkuDetail.video_url) {
this.switchMedia = (Number(this.goodsSkuDetail.media_order) === 2) ? "video" : "img";
}
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");

View File

@@ -272,6 +272,59 @@ export default {
}
});
},
/**
* JSON body 请求通道
*
* 为什么需要: sendRequest 走表单编码, 嵌套数组(如 fields.mobiles)的序列化
* 格式在各端不一致(uni 序列化器的平台差异, 小程序/H5 产出不同),
* 后端 input('fields/a') 依赖 PHP 方括号语法, 踩坑风险高。
* 本方法统一 JSON.stringify + application/json —— TP6 Request
* 原生解析 JSON body(getInputData), 且字符串 body 各端原样发送, 零歧义。
* token/uniacid/app_type 与 sendRequest 一样自动补齐。
*/
sendJson(params) {
if (!Config.baseUrl) {
uni.showToast({
title: getLang().lang('http.baseUrlNotConfigured'),
'icon': 'none',
'duration': 10000
});
return;
}
const url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/');
const data = {
app_type: appType,
app_type_name: appTypeName,
token: store.state.token || '',
uniacid: uni.getStorageSync('uniacid') || Config.uniacid
};
if (params.data != undefined) Object.assign(data, params.data);
const store_info = store.state.globalStoreInfo;
if (store_info) data.store_id = store_info.store_id;
console.log('[HTTP][REQUEST] sendJson', url);
uni.request({
url: url,
method: params.method || 'POST',
header: Object.assign({ 'content-type': 'application/json' }, params.header || {}),
data: JSON.stringify(data),
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
console.log('[HTTP][RESPONSE] sendJson', url, 'statusCode=', res.statusCode);
let r = res.data;
if (typeof r === 'string') {
try { r = JSON.parse(r); } catch (e) { /* 保持原样 */ }
}
typeof params.success == 'function' && params.success(r);
},
fail: (res) => {
console.error('[HTTP][FAIL] sendJson', url, JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
upload(params) {
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
uni.uploadFile({

View File

@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
const version = String(payload.version || '');
const sign = payload.sign || '';
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
console.warn(
'[lang-loader] bundle tree INCOMPLETE. ' +
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
' lang=' + lang +
' appType=' + appType +
' uniacid=' + uniacid +
' version=' + version +
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
);
}
// 4) 签名校验(可选)
const secret = langConfig.remote.signSecret || '';
const signValid = validateSign(tree, version, sign, secret);

View File

@@ -628,6 +628,26 @@ function loadLangPackSync(lang, path) {
* 获得当前本地语言
* @returns
*/
/**
* 语言切换开关钩子(由后台 shop_info.ischina 决定,经 store/index.js 的 setShopInfo 写入)。
*
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischina
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
*
* 行为:
* - 后台关闭双语/小程序(ischina 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
* - 后台开启双语(ischina 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
*/
function setLangSwitchEnabled(enabled) {
if (!enabled) {
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
invalidateStorageCache('lang');
try { uni.removeStorageSync('lang'); } catch (e) {}
}
}
function getCurrentLocale() {
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
const val = cachedStorageGet('lang', null);
@@ -973,6 +993,13 @@ export default {
*/
getCurrentLocale,
/**
* 语言切换功能开关(后台 shop_info.ischina 决定)。
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
*/
setLangSwitchEnabled,
/**
* 从后端数据中提取当前语言的文案(向后兼容)
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
@@ -1054,6 +1081,13 @@ export default {
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
let isComplete1 = isBundleComplete(result.tree);
if (!isComplete1) {
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
try {
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
', source=' + (result && result.source) +
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
} catch (e) {}
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
delete remoteTreeCache[_preloadMk];
result = await loadBundleTree({ lang, appType, uniacid, force: true });
@@ -1064,6 +1098,13 @@ export default {
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
let isComplete2 = isBundleComplete(result.tree);
if (!isComplete2) {
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
try {
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
', source=' + (result && result.source) +
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
} catch (e) {}
throw new Error('lang bundle unusable after force retry');
}
@@ -1103,10 +1144,14 @@ export default {
setRemoteStatus('fail', { reason: _reason, lang });
bootCompleted = true;
emitUpdated(lang);
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
return {
ok: true,
source: 'cache',
status: 'fail',
status: 'ok',
degraded: true,
reason: _errMsg || 'fallback to cache',
lang,
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
@@ -1176,6 +1221,42 @@ export default {
loadOverrides(lang);
},
/**
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
* - remote-only 模式下远端 bundle 不会被重新拉取
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
* storage 的 locale 重新读取并合并,不再沿用旧值。
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
*/
onAppReentry() {
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
try {
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
} catch (e) {}
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
invalidateStorageCache();
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
invalidateLangCaches();
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
try {
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
', freshUniacid=' + getCurrentUniacid());
} catch (e) {}
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
if (IS_REMOTE_ONLY) {
this.boot();
} else {
this.refresh(true);
}
},
/**
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
* @param {(status:string, detail:object) => void} fn

768
common/js/media-upload.js Normal file
View File

@@ -0,0 +1,768 @@
// common/js/media-upload.js
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
//
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
// "Cannot find module '@/common/js/media-upload.js'". 此次按
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
//
// 调用方:
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
//
// 上传策略:
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
//
// 端点:
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
import aiService from './ai-service.js'
import Config from './config.js'
import store from '@/store/index.js'
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
const EXT_MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip', rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
pdf: 'application/pdf', txt: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
/**
* 上传图片 (type=image)
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
*/
export async function uploadImage(filePath, opts = {}) {
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
const config = await aiService.getServiceConfig()
const strategy = config?.upload_strategy || 'backend'
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
let compressedPath = filePath
let detectedSize = 0
try {
detectedSize = await getFileSize(filePath)
} catch (e) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
console.warn('[media-upload] getFileSize failed:', e)
}
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
// #ifdef H5 || MP-WEIXIN
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
try {
const compressed = await uni.compressImage({
src: filePath,
quality: 80,
compressedWidth: 2048,
compressedHeight: 2048,
})
compressedPath = compressed.tempFilePath || filePath
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
} catch (e) {
console.warn('[media-upload] compressImage failed, use original:', e)
}
}
// #endif
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
// fileSizeFinal = 探测失败的兜底:
// - 第一次探测失败(原始大小未知) → 保守走 STS
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
let fileSizeFinal = detectedSize
if (compressedPath !== filePath) {
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
fileSizeFinal = detectedSize
}
}
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
if (finalUseSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
}
try {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
} catch (e) {
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
if (e && e.code === 'OSS_STS_REQUIRED') {
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
}
throw e
}
}
/**
* 上传 audio / video / file
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
* 未配云存储 (backend) → 直接 PHP 中转
* @param {string} filePath
* @param {'audio'|'video'|'file'} kind
* @param {{name?:string, mime?:string, size?:number}} [opts]
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
*/
export async function uploadMedia(filePath, kind, opts = {}) {
if (!['audio', 'video', 'file'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`)
}
const addon = aiService.getAddonName()
const config = await aiService.getServiceConfig()
const agentId = aiService.getAgentId()
const hints = { name: opts.name || '', mime: opts.mime || '' }
const size = opts.size || 0
const strategy = (config && config.upload_strategy) || 'backend'
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
const doPhpUpload = async (fallbackErr) => {
let phpRes = null
try {
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
} catch (err) {
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
}
if (!phpRes || !phpRes.url) {
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
}
return Object.assign({}, phpRes, { kind })
}
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
if (strategy === 'backend') {
try {
return await doPhpUpload()
} catch (e) {
if (e && e.code === 'OSS_STS_REQUIRED') {
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
}
throw e
}
}
try {
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
} catch (e) {
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
try {
return await doPhpUpload(e)
} catch (e2) {
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
throw e2
}
}
}
/**
* PHP 中转 (multipart/form-data)
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
*/
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ (kind === 'image' ? 'image' : 'file')
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.uploadFile({
url: Config.baseUrl + url,
filePath,
name: 'file',
formData: {
kind,
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
success: (res) => {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (data.code === 0 || data.success) {
resolve({
url: data.data.url,
mime: data.data.mime,
size: data.data.size || knownSize || 0,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
})
} else {
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
const err = new Error(data.data || data.msg || data.message || '上传失败')
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
reject(err)
}
} catch (e) {
reject(new Error('解析响应失败: ' + e.message))
}
},
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
})
})
// #endif
// #ifdef H5
const form = new FormData()
const file = await pathToFile(filePath, kind)
form.append('file', file)
form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId)
else {
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
const uuid = aiService.getCurrentConfigUuid?.() || ''
form.append('config_uuid', uuid)
form.append('config_uid', uuid)
form.append('agent_id', agentId)
}
form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
method: 'POST',
headers: { 'token': store.state.token || '' },
body: form,
})
const data = await res.json()
if (data.code === 0 || data.success) {
return {
url: data.data.url,
mime: data.data.mime,
size: data.data.size || knownSize || 0,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
}
}
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
if (data.oss_sts_required === true) {
const err = new Error(data.data || data.msg || data.message || '上传失败')
err.code = 'OSS_STS_REQUIRED'
throw err
}
throw new Error(data.msg || data.message || '上传失败')
// #endif
}
/**
* STS 直传 (拿临时凭证 → 直传云存储)
*
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
* 标记, 不带 Authorization
*
* 后端 stsResp.data.data 可能字段:
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
*/
/**
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
* 1) filePath 最后一段正则匹配 .xxx
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
*
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
*/
function extractExt(filePath, mimeHint = '', nameHint = '') {
let ext = 'bin'
if (typeof filePath === 'string') {
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
if (m) ext = m[1].toLowerCase()
}
if (ext === 'bin' && filePath && filePath.name) {
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
if (m2) ext = m2[1].toLowerCase()
}
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
if (ext === 'bin' && nameHint) {
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
if (m3) ext = m3[1].toLowerCase()
}
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
if (ext === 'bin' && mimeHint) {
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
if (m === mimeHint) { ext = e; break }
}
}
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
return ext
}
/**
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
*
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
*
* 端点: POST /<addon>/api/upload/ossPresign
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
*/
/**
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
*/
function uniRequest(opts) {
return new Promise((resolve, reject) => {
uni.request(Object.assign({}, opts, {
success: resolve,
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
}))
})
}
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
let mimeHint = hints.mime || ''
let nameHint = hints.name || ''
let probeFile = null
// #ifdef H5
try {
probeFile = await pathToFile(filePath, kind)
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
} catch (e) {
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
console.warn('[media-upload] presign probe failed:', e && e.message)
}
// #endif
const ext = extractExt(filePath, mimeHint, nameHint)
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
const contentType = mimeHint || guessMime(finalExt)
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
? nameHint
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
const preResp = await uniRequest({
url: Config.baseUrl + preUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
filename,
content_type: contentType,
uniacid: aiService.getUniacid(),
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
},
})
if (preResp.statusCode === 404) {
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
err.code = 'PRESIGN_UNSUPPORTED'
throw err
}
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
}
const pre = preResp.data.data
const uploadUrl = pre.upload_url
const finalContentType = pre.content_type || contentType
let uploadedSize = knownSize || 0
// #ifdef H5
if (probeFile && probeFile.size) uploadedSize = probeFile.size
// #endif
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
// #ifdef H5
const file = probeFile || await pathToFile(filePath, kind)
const ossRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': finalContentType },
body: file,
})
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
// #endif
// #ifdef MP-WEIXIN
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath,
success: (res) => {
uni.request({
url: uploadUrl,
method: 'PUT',
header: { 'Content-Type': finalContentType },
data: res.data,
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
? resolve()
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
fail: reject,
})
},
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
})
})
// #endif
return {
url: (pre.public_url || uploadUrl.split('?')[0]),
mime: finalContentType,
size: uploadedSize,
name: (pre.object_key || filename).split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider: pre.provider || 'alioss',
}
}
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
const providerHint = (config && config.oss_provider) || ''
if (!providerHint || providerHint === 'alioss') {
try {
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
} catch (e) {
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
// 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
const stsResp = await uniRequest({
url: Config.baseUrl + stsUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
})
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
}
const sts = stsResp.data.data
const provider = sts.provider || 'alioss' // 老后端默认 alioss
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
const now = new Date()
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
.replace('{site_id}', sts.site_id || '0')
.replace('{agent_id}', agentId || '0')
.replace('{yyyy}', now.getFullYear().toString())
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
.replace('{rand}', uuid)
+ '.' + ext
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
const isSingleUrlMode = typeof sts.securityToken === 'string'
&& /-(SIGNED)$/.test(sts.securityToken)
let ossUrl, headers
if (isSingleUrlMode) {
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
ossUrl = sts.accessKeyId
headers = { 'Content-Type': guessMime(ext) }
} else if (provider === 'qiniu') {
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
headers = {
'Authorization': 'UpToken ' + sts.accessKeyId,
'Content-Type': guessMime(ext),
}
} else {
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
const aliossTpl = sts.oss_url
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
const tpl = (provider === 'alioss') ? aliossTpl
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
ossUrl = `${tpl}/${key}`
headers = {
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
'x-oss-security-token': sts.securityToken,
'Content-Type': guessMime(ext),
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
let uploadedSize = knownSize || 0
// #ifdef H5
try {
const probe = await pathToFile(filePath, kind)
if (probe && probe.size) uploadedSize = probe.size
} catch (e) {}
// #endif
// 3. 直传云存储
// #ifdef MP-WEIXIN
await new Promise((resolve, reject) => {
uni.uploadFile({
url: ossUrl,
filePath,
name: 'file',
formData: {},
header: headers,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
else reject(new Error(`${provider} PUT ${res.statusCode}`))
},
fail: reject,
})
})
// #endif
// #ifdef H5
const file = await pathToFile(filePath, kind)
const ossRes = await fetch(ossUrl, {
method: 'PUT',
headers,
body: file,
})
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
// #endif
// 4. 返回公网 URL
return {
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
mime: guessMime(ext),
size: uploadedSize,
name: key.split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider,
}
}
async function getFileSize(filePath) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.getFileInfo({
filePath,
success: (res) => resolve(res.size || 0),
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
})
})
// #endif
// #ifdef H5
try {
const res = await fetch(filePath)
const blob = await res.blob()
return blob.size
} catch (e) {
throw new Error('getFileSize fetch failed: ' + e.message)
}
// #endif
}
async function pathToFile(filePath, kind) {
// #ifdef H5
const res = await fetch(filePath)
const blob = await res.blob()
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
const rawName = filePath.split('/').pop() || `${kind}.bin`
const ext = extractExt(filePath, blob.type)
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
const mime = blob.type || guessMime(ext)
return new File([blob], finalName, { type: mime })
// #endif
}
/**
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
* 而不是路径 ②/③ (URL fetch / resource_link).
*
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
*
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
*
* 返回: { data: '<bare-base64>', size, mime } | null
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
*
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
* @param {object} [opts]
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
*/
export async function fileToBase64(filePath, opts = {}) {
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
if (!filePath) return null
// #ifdef H5
try {
const res = await fetch(filePath)
if (!res.ok) throw new Error('fetch failed: ' + res.status)
const blob = await res.blob()
if (blob.size > maxBytes) {
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
const dataUri = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('FileReader failed'))
reader.readAsDataURL(blob)
})
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
return { data: m[2], size: blob.size, mime: m[1] }
} catch (e) {
console.warn('[media-upload] fileToBase64 H5 失败:', e)
return null
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
try {
const fsm = uni.getFileSystemManager()
// 1. 探测大小, 超限直接返回 null
let size = 0
try {
const stat = fsm.statSync(filePath)
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
size = (stat && (stat.size || stat.fileSize)) || 0
} catch (e) {
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
}
if (size > maxBytes) {
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
const data = fsm.readFileSync(filePath, 'base64')
return { data, size: size || 0, mime: opts.mimeHint || '' }
} catch (e) {
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
return null
}
// #endif
}
function guessMime(ext) {
return EXT_MIME_MAP[ext] || 'application/octet-stream'
}
export default { uploadImage, uploadMedia }

View File

@@ -199,7 +199,25 @@ export default {
* 全局默认 onShareAppMessage:
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
*/
onShareAppMessage() {
onShareAppMessage(options) {
// 名片「发送名片」按钮(open-type="share")触发: dataset 携带名片信息 → 精准转发该名片
// (覆盖默认 cfg 逻辑; 微信小程序无法在组件内直接定义转发, 只能由页面 onShareAppMessage 提供)
if (
options &&
options.from === 'button' &&
options.target &&
options.target.dataset &&
options.target.dataset.cardId
) {
const d = options.target.dataset;
const name = d.cardName || '我的名片';
const img = (this.$util && this.$util.img ? this.$util.img(d.cardQr) : d.cardQr) || '';
return {
title: name + ' 的电子名片',
path: '/pages_tool/card/detail?card_id=' + d.cardId,
imageUrl: img
};
}
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
// (主防线是 wx.hideShareMenu,这里只是兜底)

30
common/js/startup-ad.js Normal file
View File

@@ -0,0 +1,30 @@
// 启动广告弹出信号
// 用于判定"当前是否需要弹出启动广告":
// - 小程序冷启动(首次进入): 由模块级 _coldLaunchedShown 标记判定, 整个 app 进程只记一次
// - 小程序重新进入(从后台切回 / 微信"重新进入小程序"菜单):
// 由 App.onShow 在"非首次展示"时调用 markNeedStartupAd() 置位
// 信号消费一次即清除, 不落地到持久化存储, 因此无需清缓存即可每次重新进入都弹出。
// 关键: 切换 Tab 回到首页只触发"页面级 onShow", 不会触发 App.onShow, 也不会重置
// 模块级冷启动标记, 因此不会在 Tab 切换时误弹。
let _needStartupAd = false;
let _coldLaunchedShown = false;
// App 层检测到"重新进入小程序"时调用, 标记本次需要弹出启动广告
export function markNeedStartupAd() {
_needStartupAd = true;
}
// 页面层读取并消费该信号(消费后自动复位)
export function consumeNeedStartupAd() {
const v = _needStartupAd;
_needStartupAd = false;
return v;
}
// 是否为本 app 进程的首次冷启动(只返回一次 true, 之后恒为 false)
export function isColdLaunchPending() {
if (_coldLaunchedShown) return false;
_coldLaunchedShown = true;
return true;
}

View File

@@ -62,7 +62,13 @@ export default {
},
...{
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
hover_nav_text_color: '#000' // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
hover_nav_text_color: '#000', // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
// ===== 逐按钮字号(与 hover_nav_lang/kefu/phone 的 bg/text/size 同约定, 基准 36rpx) =====
// 未单独配置时回退到上面的 hover_nav_font_size(全局旋钮)
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
hover_nav_phone_font_size: '36rpx' // 电话按钮内图标字号基准
},
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====

View File

@@ -200,6 +200,17 @@ export const PAGE_KEY_MAP = {
// === 电子名片 ===
PAGE_CONTACT_CARD: '/pages/contact/contact',
// === 名片云(card 插件) ===
PAGE_CARD_LIST: '/pages_tool/card/list',
PAGE_CARD_LEADS: '/pages_tool/card/leads',
PAGE_CARD_CONTACTS: '/pages_tool/card/contacts',
PAGE_CARD_ENTERPRISE: '/pages_tool/card/enterprise',
// === 名片云 · 商家端(后台接口就绪一部分, 先上有接口的页面) ===
PAGE_CARD_ADMIN_MANAGE: '/pages_tool/card/admin/manage',
PAGE_CARD_ADMIN_CONTACTS: '/pages_tool/card/admin/contacts',
PAGE_CARD_ADMIN_LEADS: '/pages_tool/card/admin/leads',
// === VR ===
PAGE_VR_INDEX: '/pages_tool/vr/index',
@@ -1254,6 +1265,21 @@ export default {
link.wap_url = resolvedUrl;
}
// ===== 仅微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页 =====
// 命中: page_key=PAGE_FILES_DETAIL / name=FILES_xx / wap_url 指向 files/detail
// 且能提取到 files_id。拉取或打开失败时自动降级回原详情页(openDiyFileDirect 内部处理)。
// #ifdef MP-WEIXIN
if (link && (link.page_key === 'PAGE_FILES_DETAIL'
|| (link.name && /^FILES_\d+$/i.test(String(link.name)))
|| String(link.wap_url || '').indexOf('files/detail') !== -1)) {
const filesId = this._extractFilesIdFromLink(link);
if (filesId) {
this.openDiyFileDirect(link, filesId);
return;
}
}
// #endif
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
@@ -1427,6 +1453,123 @@ export default {
});
}
},
/**
* 从装修"文件"链接中提取 files_id(仅微信小程序直开链路使用)。
* 兼容三种位置: 顶层字段(files_id/file_id/id) / page_params(JSON字符串或对象) / wap_url query。
* @returns {String} 拿不到返回 ''
*/
_extractFilesIdFromLink(link) {
if (!link) return '';
let id = link.files_id || link.file_id || link.id || '';
if (!id) {
const params = _parsePageParams(link.page_params);
id = params.files_id || params.file_id || params.id || '';
}
if (!id) {
const url = String(link.wap_url || '');
const qIdx = url.indexOf('?');
if (qIdx !== -1) {
url.slice(qIdx + 1).split('&').forEach(kv => {
if (id) return;
const eq = kv.indexOf('=');
const k = eq === -1 ? kv : kv.slice(0, eq);
if (k === 'files_id' || k === 'file_id' || k === 'id') {
id = eq === -1 ? '' : kv.slice(eq + 1);
}
});
}
}
return id === '' || id === undefined || id === null ? '' : String(id);
},
/**
* 微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页。
* 链路: /api/files/getfile(files_url 已由后端 img() 补全域名)
* → downloadFile → saveFile 用文件名重命名 → openDocument(原生 PDF 阅读器)
* 任一环节失败 → 降级跳原详情页(files/detail), 保持旧体验兜底。
* @param {Object} link 装修链接对象(用于取标题)
* @param {String} filesId 文件 id
*/
openDiyFileDirect(link, filesId) {
const fallback = () => {
this.redirectTo(PAGE_KEY_MAP.PAGE_FILES_DETAIL, {
files_id: filesId,
title: link.title || '',
enable_set_title: link.enable_set_title ? 1 : 0,
page_params: Object.assign({}, _parsePageParams(link.page_params), { files_id: filesId })
});
};
const safeTitle = String(link.title || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_');
Http.sendRequest({
url: '/api/files/getfile',
data: { files_id: filesId },
noRetry: true,
silentError: true,
success: res => {
if (!res || res.code !== 0 || !res.data || !res.data.files_url) {
fallback();
return;
}
let url = String(res.data.files_url);
// 相对路径补全域名($util.img 对含逗号路径会按多图截断, 规避)
if (!/^https?:\/\//i.test(url) && url.indexOf(',') === -1) {
url = this.img(url);
}
if (!/^https?:\/\//i.test(url)) {
fallback();
return;
}
const rawTitle = String(res.data.files_title || safeTitle || 'file');
const title = rawTitle.replace(/[\\/:*?"<>|\r\n\t]/g, '_');
const matched = url.split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
const ext = matched ? matched[1].toLowerCase() : 'pdf';
uni.showLoading({ title: Lang.lang('files.list.loading') || 'Loading', mask: true });
uni.downloadFile({
url,
success: dres => {
if (dres.statusCode !== 200) {
uni.hideLoading();
fallback();
return;
}
const fs = uni.getFileSystemManager();
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
fs.saveFile({
tempFilePath: dres.tempFilePath,
filePath: targetPath,
success: r => {
uni.hideLoading();
uni.openDocument({
filePath: r.savedFilePath,
fileType: ext,
showMenu: true,
fail: () => fallback(),
complete: () => {
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) { }
}
});
},
fail: () => {
// saveFile 失败(配额等), 直接用临时文件打开兜底
uni.hideLoading();
uni.openDocument({
filePath: dres.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => fallback()
});
}
});
},
fail: () => {
uni.hideLoading();
fallback();
}
});
},
fail: () => fallback()
});
},
/**
* 获取默认图
*/
@@ -1644,59 +1787,118 @@ export default {
},
/**
* 获取小程序分享内容数据
*
* 重要:本函数保证永远 resolve(绝不 reject)。
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
*/
getMpShare(path) {
//如果没有特别指定 则获取当前页面的路由
// 如果没有特别指定 则获取当前页面的路由
if (!path) {
let route = this.getCurrentRoute();
path = route.path;
if (path == '/pages_tool/member/index') {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
resolve({})
});
}
}
return new Promise((resolve, reject) => {
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
const buildFallback = (p) => {
const fallbackPath = p || '/pages/index/index';
const fallback = {
title: '小程序分享',
path: fallbackPath,
imageUrl: ''
};
const appMessage = {
title: fallback.title,
path: fallback.path,
imageUrl: fallback.imageUrl,
success: () => {},
fail: () => {}
};
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
const timeLine = {
title: fallback.title,
query: fallback.path,
imageUrl: fallback.imageUrl
};
return { appMessage, timeLine, _fallback: true, query };
};
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
const buildFromConfig = (shareConfig) => {
const appMessage = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl || '',
success: () => {},
fail: () => {}
};
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
? shareConfig.path.split('?')[1]
: '';
const timeLine = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl || ''
};
return { appMessage, timeLine, query };
};
const fallback = buildFallback(path);
return new Promise((resolve) => {
Http.sendRequest({
url: "/weapp/api/weapp/share",
data: {
path: path
},
success: res => {
if (res.code >= 0) {
let shareConfig = res.data.data;
if (shareConfig) {
//分享给好友
let appMessageData = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl,
success: res => {
},
fail: res => {
try {
if (!res || typeof res !== 'object') {
resolve(fallback);
return;
}
if (res.code >= 0) {
// 响应可能是两种形态:
// A) { code, message, data: { title, path, imageUrl } }
// B) { code, message, permission:{...}, data: null }
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
const outer = res.data !== undefined ? res.data : res;
let shareConfig = null;
if (outer && typeof outer === 'object') {
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
shareConfig = outer.data;
} else if (outer.title || outer.path) {
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
shareConfig = outer;
}
}
//分享到朋友圈
let query = '';
if (shareConfig.path.indexOf('?') > 0) {
query = shareConfig.path.split('?')[1];
if (shareConfig) {
resolve(buildFromConfig(shareConfig));
} else {
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
resolve(fallback);
}
let timeLineData = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl,
}
resolve({
appMessage: appMessageData,
timeLine: timeLineData,
})
} else {
reject(res.data);
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
resolve(fallback);
}
} else {
reject(res.data)
} catch (e) {
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
resolve(fallback);
}
}
},
fail: () => {
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
resolve(fallback);
},
complete: () => {}
});
})
},

819
common/js/voice-recorder.js Normal file
View File

@@ -0,0 +1,819 @@
/**
* voice-recorder.js — 跨端录音抽象层
*
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
*
* PR-FRONTEND-VOICE-WAV (2026-09-25)
* H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor
* 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz).
* 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制;
* 大多数 STT / 后端 Agent 框架无法解析 webm/opus.
* 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃.
*
* 平台分支:
* - H5 : navigator.mediaDevices.getUserMedia → AudioContext
* → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV.
* 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突).
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
* 走 onFrameRecorded 推 progress 事件.
* uni-app format 参数可指定 wav/mp3/aac.
*
* 统一事件:
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
* error → Error 录音失败
*
* 统一结果 (stop() 解析):
* {
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
* blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理)
* fileName?: string, // H5: voice_<ts>.wav; MP 按 uni 约定
* duration: number, // ms
* mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format
* size: number, // bytes
* cancelled: false
* }
*
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
*/
const DEFAULT_MAX_DURATION_MS = 60 * 1000
// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager()
// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报
// "operateRecorder:fail:audio is recording, don't start"。
// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后
// 才允许下一个实例 start()。
let _singletonIdlePromise = Promise.resolve()
let _singletonIdleResolver = null
let _singletonRec = null
let _singletonBusyInstance = null
function _getSingletonRecorder() {
if (!_singletonRec) {
_singletonRec = uni.getRecorderManager()
}
return _singletonRec
}
let _singletonIdleTimer = null
function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
if (_singletonIdleTimer) {
clearTimeout(_singletonIdleTimer)
_singletonIdleTimer = null
}
if (_singletonIdleResolver) {
// 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁
_singletonIdleResolver()
_singletonIdleResolver = null
}
_singletonIdlePromise = new Promise((resolve) => {
_singletonIdleResolver = resolve
})
_singletonBusyInstance = instance
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
// 避免死锁导致后续所有 start() 永远挂起。
_singletonIdleTimer = setTimeout(() => {
console.warn('[voice-recorder:singleton] idle timeout, force release')
_setSingletonIdle(instance)
}, Math.min(maxDurationMs + 5000, 10000))
}
function _setSingletonIdle(instance) {
if (_singletonIdleTimer) {
clearTimeout(_singletonIdleTimer)
_singletonIdleTimer = null
}
if (_singletonBusyInstance && _singletonBusyInstance !== instance) {
// 不是当前占用者释放, 忽略
return
}
if (_singletonIdleResolver) {
_singletonIdleResolver()
_singletonIdleResolver = null
}
_singletonIdlePromise = Promise.resolve()
_singletonBusyInstance = null
}
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
const H5_SAMPLE_RATE = 16000
const H5_NUM_CHANNELS = 1
const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096
/**
* 构造带 code 字段的 Error
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
* - message: 兜底英文 message, 用于开发/调试或未识别 code
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
*/
function _err(code, message) {
const e = new Error(message || code)
e.code = code
return e
}
/**
* 检测当前运行环境 (避免编译器把所有分支都打进去)
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
*/
function isH5() {
// #ifdef H5
return true
// #endif
// #ifndef H5
return false
// #endif
}
function isMpOrApp() {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
return true
// #endif
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
return false
// #endif
}
/**
* 由 mime 反推文件扩展名
*/
function mimeToExt(mime) {
if (!mime) return 'wav'
if (mime.indexOf('wav') !== -1) return 'wav'
if (mime.indexOf('webm') !== -1) return 'webm'
if (mime.indexOf('ogg') !== -1) return 'ogg'
if (mime.indexOf('mp4') !== -1) return 'm4a'
if (mime.indexOf('mpeg') !== -1) return 'mp3'
if (mime.indexOf('aac') !== -1) return 'aac'
return 'audio'
}
/**
* 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array
*/
function mergeFloat32Arrays(arrays) {
let totalLength = 0
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length
const out = new Float32Array(totalLength)
let offset = 0
for (let i = 0; i < arrays.length; i++) {
out.set(arrays[i], offset)
offset += arrays[i].length
}
return out
}
/**
* 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头).
* 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出.
*
* 参考 RIFF/WAVE 规范:
* https://docs.fileformat.com/audio/wav/
* http://soundfile.sapp.org/doc/WaveFormat/
*/
function encodeWavPcm16LE(samples, sampleRate, numChannels) {
const bytesPerSample = 2 // 16-bit
const dataSize = samples.length * numChannels * bytesPerSample
const buffer = new ArrayBuffer(44 + dataSize)
const view = new DataView(buffer)
// RIFF chunk descriptor (12 bytes)
writeAscii(view, 0, 'RIFF')
view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8
writeAscii(view, 8, 'WAVE')
// fmt subchunk (24 bytes)
writeAscii(view, 12, 'fmt ')
view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16
view.setUint16(20, 1, true) // AudioFormat: 1 = PCM
view.setUint16(22, numChannels, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate
view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign
view.setUint16(34, 16, true) // BitsPerSample
// data subchunk (8 + N bytes)
writeAscii(view, 36, 'data')
view.setUint32(40, dataSize, true)
// PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出)
let offset = 44
for (let i = 0; i < samples.length; i++) {
let s = samples[i]
if (s > 1) s = 1
else if (s < -1) s = -1
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF)
view.setInt16(offset, int16, true)
offset += 2
}
return buffer
}
function writeAscii(view, offset, str) {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i))
}
}
/**
* 创建录音器实例。
*
* @param {object} [opts]
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
* @param {number} [opts.sampleRate=16000]
* @param {number} [opts.numberOfChannels=1]
* @param {number} [opts.encodeBitRate=48000]
*
* @returns {VoiceRecorder}
*/
export function createVoiceRecorder(opts = {}) {
return new VoiceRecorder(opts)
}
class VoiceRecorder {
constructor(opts) {
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差,
// 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。
this._format = opts.format || 'wav'
this._sampleRate = opts.sampleRate || 16000
this._channels = opts.numberOfChannels || 1
this._bitRate = opts.encodeBitRate || 48000
this._listeners = { progress: [], error: [] }
this._isRecording = false
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
this._startTs = 0
this._id = Math.random().toString(36).slice(2, 8)
this._autoStopTimer = null
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
// 平台相关句柄
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
this._uniRec = null // uni.getRecorderManager() 返回值
console.log(`[voice-recorder:${this._id}] created`)
}
on(event, cb) {
if (!this._listeners[event]) this._listeners[event] = []
this._listeners[event].push(cb)
}
off(event, cb) {
const arr = this._listeners[event]
if (!arr) return
const idx = arr.indexOf(cb)
if (idx !== -1) arr.splice(idx, 1)
}
_emit(event, payload) {
const arr = this._listeners[event]
if (!arr) return
for (const cb of arr.slice()) {
try { cb(payload) } catch (e) { /* swallow */ }
}
}
get isRecording() { return this._isRecording }
/**
* 开始录音。
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
*/
start() {
if (this._isRecording) {
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
}
if (this._isStarting) {
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
}
this._isStarting = true
let p = null
if (isH5()) {
p = this._startH5()
} else if (isMpOrApp()) {
p = this._startUni()
} else {
this._isStarting = false
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
}
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
p.catch((e) => {
this._isStarting = false
return Promise.reject(e)
})
return p
}
/**
* 停止录音并返回产物。
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
*/
stop() {
console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult })
if (!this._isRecording) {
// PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"——
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
if (this._isStarting) {
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
return new Promise((resolve) => {
this._pendingStop = true
this._stopResolve = resolve
})
}
// onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果
if (this._earlyStopResult) {
const r = this._earlyStopResult
this._earlyStopResult = null
console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`)
return Promise.resolve(r)
}
console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`)
return Promise.resolve(null)
}
if (isH5()) return this._stopH5()
return this._stopUni()
}
/**
* 中断录音并丢弃产物。
*/
cancel() {
if (this._isStarting) {
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
// 等 onStart 触发后立即 stop(), 产物被丢弃。
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
return new Promise((resolve) => {
this._pendingStop = true
this._stopResolve = () => resolve()
})
}
if (!this._isRecording) {
this._clearAutoStopTimer()
return Promise.resolve()
}
if (isH5()) return this._cancelH5()
return this._cancelUni()
}
// ===================== H5 实现 =====================
// PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM,
// 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus
// 导致后端 STT / Agent framework 无法解析的问题.
//
// 关键设计:
// 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入
// (实际由 AudioContext sampleRate 参数保证)
// 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发
// onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声
// 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突)
// 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)),
// 否则下一帧会覆盖导致 PCM 数据丢失
_startH5() {
return new Promise((resolve, reject) => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
return
}
const Ctx = window.AudioContext || window.webkitAudioContext
if (!Ctx) {
reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available'))
return
}
const buffers = []
let stream = null
let audioCtx = null
let source = null
let processor = null
let analyser = null
let silentGain = null
let rafId = null
// 清理 H5 资源 (失败 / 异常路径统一调用)
const cleanup = () => {
try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ }
try { if (processor) processor.disconnect() } catch (e) { /* noop */ }
try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ }
try { if (source) source.disconnect() } catch (e) { /* noop */ }
try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ }
try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ }
try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ }
}
navigator.mediaDevices.getUserMedia({
audio: {
channelCount: H5_NUM_CHANNELS,
// 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准
sampleRate: H5_SAMPLE_RATE,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
})
.then((s) => {
stream = s
audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE })
// 麦克风 → ScriptProcessor → 静音 GainNode → destination
// (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess;
// 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声)
source = audioCtx.createMediaStreamSource(stream)
const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE
processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS)
processor.onaudioprocess = (e) => {
const channelData = e.inputBuffer.getChannelData(0)
// 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失
buffers.push(new Float32Array(channelData))
}
silentGain = audioCtx.createGain()
silentGain.gain.value = 0
source.connect(processor)
processor.connect(silentGain)
silentGain.connect(audioCtx.destination)
// AnalyserNode 并行采振幅 (驱动波形动画)
analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const freqBuf = new Uint8Array(analyser.frequencyBinCount)
const tick = () => {
if (!this._isRecording) return
try {
analyser.getByteFrequencyData(freqBuf)
let sum = 0
for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i]
const level = Math.min(1, sum / (freqBuf.length * 255))
this._emit('progress', { level, duration: Date.now() - this._startTs })
} catch (e) { /* noop */ }
rafId = requestAnimationFrame(tick)
}
tick()
this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup }
this._isRecording = true
this._startTs = Date.now()
this._setAutoStopTimer()
resolve()
})
.catch((err) => {
// 麦克风权限拒绝 / 设备不可用
cleanup()
reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable'))
})
})
}
_stopH5() {
return new Promise((resolve) => {
const h5 = this._h5
if (!h5) { resolve(null); return }
// 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer)
try { h5.cleanup() } catch (e) { /* noop */ }
// 2) 合并 PCM 帧缓冲
const sampleRate = h5.sampleRate || H5_SAMPLE_RATE
const merged = mergeFloat32Arrays(h5.buffers || [])
// 3) 编码标准 WAV (PCM 16-bit LE mono)
const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS)
const blob = new Blob([wavBuffer], { type: 'audio/wav' })
this._h5 = null
this._isRecording = false
this._clearAutoStopTimer()
if (merged.length === 0) {
// 没有采到任何 PCM (例如用户没说话或立即 stop)
resolve(null)
return
}
const fileName = `voice_${Date.now()}.wav`
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
? URL.createObjectURL(blob)
: ''
resolve({
filePath: blobUrl,
blob,
fileName,
duration: Date.now() - this._startTs,
mime: 'audio/wav',
size: blob.size,
cancelled: false
})
})
}
_cancelH5() {
return new Promise((resolve) => {
const h5 = this._h5
if (!h5) {
this._isRecording = false
this._clearAutoStopTimer()
resolve()
return
}
try { h5.cleanup() } catch (e) { /* noop */ }
this._h5 = null
this._isRecording = false
this._clearAutoStopTimer()
resolve()
})
}
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
_startUni() {
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
return _singletonIdlePromise.then(() => {
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
return this._startUni()
}
_setSingletonBusy(this, this._maxDuration)
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
return this._doStartUni()
})
}
_doStartUni() {
return new Promise((resolve, reject) => {
let rec = null
try {
rec = _getSingletonRecorder()
} catch (e) {
_setSingletonIdle(this)
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
return
}
if (!rec) {
_setSingletonIdle(this)
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
return
}
this._uniRec = rec
this._isStarting = true
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
rec.onStart(() => {
console.log(`[voice-recorder:${this._id}] onStart fired`)
this._isRecording = true
this._isStarting = false
this._startTs = Date.now()
this._setAutoStopTimer()
if (this._pendingStop) {
// 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回
console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`)
this._pendingStop = false
try { rec.stop() } catch (e) { /* onStop 自会兜底 */ }
}
resolve()
})
rec.onStop((res) => {
// res = { tempFilePath, duration, fileSize }
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
this._clearStopFallbackTimer()
this._uniRec = null
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
// 避免播放器/后端因后缀与容器不符而失败。
const tempPath = (res && res.tempFilePath) || ''
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
const ext = rawExt.toLowerCase()
const mime = ext === 'm4a' ? 'audio/mp4'
: ext === 'aac' ? 'audio/aac'
: ext === 'mp3' ? 'audio/mpeg'
: ext === 'wav' ? 'audio/wav'
: (this._format === 'wav' ? 'audio/wav'
: this._format === 'aac' ? 'audio/aac'
: 'audio/mpeg')
// PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration
// 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。
const realDuration = this._startTs ? Date.now() - this._startTs : 0
const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0
const duration = reportedDuration > 0 ? reportedDuration : realDuration
const result = {
filePath: tempPath,
blob: null,
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
duration,
mime,
size: res.fileSize || 0,
cancelled: false
}
if (this._stopResolve) {
const r = this._stopResolve
this._stopResolve = null
r(result)
} else if (result.filePath && result.duration > 0) {
// PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来
this._earlyStopResult = result
}
_setSingletonIdle(this)
})
rec.onError((err) => {
this._clearStopFallbackTimer()
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message)
this._uniRec = null
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
this._emit('error', e)
if (this._stopResolve) {
const r = this._stopResolve
this._stopResolve = null
r(null)
} else {
// 启动阶段的 reject
reject(e)
}
_setSingletonIdle(this)
})
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
if (typeof rec.onFrameRecorded === 'function') {
rec.onFrameRecorded((res) => {
if (!res || !res.frameBuffer) return
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
try {
const view = new DataView(res.frameBuffer)
let sum = 0
const len = res.frameBuffer.byteLength
const step = Math.max(2, Math.floor(len / 256))
for (let i = 0; i < len; i += step) {
const v = view.getInt16(i, true)
sum += Math.abs(v)
}
const avg = sum / Math.max(1, Math.floor(len / step))
const level = Math.min(1, avg / 16384)
this._emit('progress', { level, duration: Date.now() - this._startTs })
} catch (e) { /* noop */ }
})
}
try {
rec.start({
// PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration
// 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。
duration: this._maxDuration,
sampleRate: this._sampleRate,
numberOfChannels: this._channels,
encodeBitRate: this._bitRate,
format: this._format
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
// 可能导致录音被提前中断。
})
} catch (e) {
this._clearStopFallbackTimer()
this._uniRec = null
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
_setSingletonIdle(this)
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
if (this._stopResolve && this._pendingStop) {
const r = this._stopResolve
this._stopResolve = null
this._pendingStop = false
r(null)
return
}
reject(e)
}
})
}
_stopUni() {
return new Promise((resolve) => {
console.log(`[voice-recorder:${this._id}] _stopUni called`)
const rec = this._uniRec
if (!rec) { resolve(null); return }
this._earlyStopResult = null
this._stopResolve = resolve
this._clearStopFallbackTimer()
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
this._stopFallbackTimer = setTimeout(() => {
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
this._stopResolve = null
this._pendingStop = false
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
this._uniRec = null
_setSingletonIdle(this)
resolve(null)
}, 3000)
try { rec.stop() } catch (e) {
this._clearStopFallbackTimer()
this._stopResolve = null
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
this._uniRec = null
_setSingletonIdle(this)
resolve(null)
}
})
}
_cancelUni() {
return new Promise((resolve) => {
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
this._earlyStopResult = null
const rec = this._uniRec
if (!rec) {
// 挂起的 stop 需要一并了结, 避免松手后卡在 pending
if (this._stopResolve && this._pendingStop) {
const r = this._stopResolve
this._stopResolve = null
this._pendingStop = false
r(null)
}
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
_setSingletonIdle(this)
resolve()
return
}
this._stopResolve = () => resolve()
this._pendingStop = false
this._clearStopFallbackTimer()
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
this._stopFallbackTimer = setTimeout(() => {
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
this._stopResolve = null
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
this._uniRec = null
_setSingletonIdle(this)
resolve()
}, 3000)
try { rec.stop() } catch (e) {
this._clearStopFallbackTimer()
this._isRecording = false
this._isStarting = false
this._clearAutoStopTimer()
this._uniRec = null
_setSingletonIdle(this)
resolve()
}
})
}
// ===================== 工具 =====================
_setAutoStopTimer() {
this._clearAutoStopTimer()
this._autoStopTimer = setTimeout(() => {
// 自动停止 (上限到达)
if (this._isRecording) {
this.stop().catch(() => { /* swallow */ })
}
}, this._maxDuration)
}
_clearAutoStopTimer() {
if (this._autoStopTimer) {
clearTimeout(this._autoStopTimer)
this._autoStopTimer = null
}
}
_clearStopFallbackTimer() {
if (this._stopFallbackTimer) {
clearTimeout(this._stopFallbackTimer)
this._stopFallbackTimer = null
}
}
}
export default createVoiceRecorder

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,290 @@
<template>
<view data-component-name="diy-card-entry" class="diy-card-entry" :class="'entry-' + stylePreset" :style="wrapStyle" hover-class="entry-press" @tap="goLink">
<!-- 图标装饰(可选) -->
<view v-if="showIcon" class="entry-icon" :style="iconWrapStyle">
<image v-if="iconType === 'image' && iconImage" class="icon-img" :src="$util.img(iconImage)" mode="aspectFit" />
<text v-else class="icon-glyph">{{ iconGlyph || '💼' }}</text>
</view>
<view class="entry-body">
<text class="entry-title" :style="titleStyle">{{ value.title || '扫名片 · 谈合作' }}</text>
<text v-if="value.subtitle" class="entry-sub" :style="subStyle">{{ value.subtitle }}</text>
</view>
<!-- 按钮(可选; link 模式为文字箭头) -->
<view v-if="buttonStyle !== 'none' && value.btnText" :class="['entry-btn', 'btn-' + buttonStyle]" :style="btnStyle" hover-class="btn-press" hover-stay-time="60">
<text class="entry-btn-text" :style="btnTextStyle">{{ value.btnText }}</text>
<text v-if="buttonStyle === 'link'" class="btn-arrow" :style="btnArrowStyle">›</text>
</view>
</view>
</template>
<script>
// 数字名片 · 名片入口横幅(装修组件)
// 预设: gradient(渐变) / solid(纯色+主题饰条) / minimal(透明轻量)
// 布局: left / center; 按钮: solid / ghost / link / none
import { CardTokens, px2rpx, flagOn, num } from './diy-card-common.js';
export default {
name: 'diy-card-entry',
props: {
value: { type: Object }
},
computed: {
v() {
return this.value || {};
},
stylePreset() {
const p = String(this.v.stylePreset || 'gradient');
return ['gradient', 'solid', 'minimal'].includes(p) ? p : 'gradient';
},
isCenter() {
return String(this.v.layout || 'left') === 'center';
},
showIcon() {
return flagOn(this.v.showIcon, false);
},
iconType() {
return String(this.v.iconType || 'icon') === 'image' ? 'image' : 'icon';
},
iconImage() {
return this.v.iconImage || '';
},
iconGlyph() {
return this.v.iconGlyph || '名';
},
buttonStyle() {
const b = String(this.v.buttonStyle || 'solid');
return ['solid', 'ghost', 'link', 'none'].includes(b) ? b : 'solid';
},
isGradient() {
return this.stylePreset === 'gradient';
},
isMinimal() {
return this.stylePreset === 'minimal';
},
themeColor() {
return this.v.bgColorStart || CardTokens.color.primary;
},
wrapStyle() {
const v = this.v;
const radius = px2rpx(num(v.borderRadius, CardTokens.radius.md)) + 'rpx';
const pad = px2rpx(num(v.padding, CardTokens.space.lg)) + 'rpx';
if (this.isMinimal) {
return this.$util.objToStyle({
background: 'transparent',
borderRadius: radius,
padding: pad,
boxShadow: 'none',
});
}
if (this.isGradient) {
return this.$util.objToStyle({
background: 'linear-gradient(135deg, ' + (v.bgColorStart || CardTokens.color.primary) + ' 0%, ' + (v.bgColorEnd || '#1E40AF') + ' 100%)',
borderRadius: radius,
padding: pad,
minHeight: '120rpx',
});
}
// solid: 主题色底 + 左侧亮饰条
return this.$util.objToStyle({
background: v.bgColorStart || CardTokens.color.primary,
borderRadius: radius,
padding: pad,
borderLeft: '8rpx solid ' + (v.bgColorEnd || 'rgba(255,255,255,0.35)'),
minHeight: '120rpx',
});
},
iconWrapStyle() {
return this.$util.objToStyle({
background: this.isGradient || !this.isMinimal ? 'rgba(255,255,255,0.18)' : 'rgba(47,107,255,0.10)',
});
},
titleStyle() {
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.text : '#FFFFFF'),
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
});
},
subStyle() {
return this.$util.objToStyle({
color: this.v.textColor || (this.isMinimal ? CardTokens.color.textSub : '#FFFFFF'),
fontSize: px2rpx(num(this.v.subSize, 12)) + 'rpx',
opacity: this.isMinimal ? 1 : 0.85,
});
},
btnStyle() {
const v = this.v;
const radius = px2rpx(CardTokens.radius.pill) + 'rpx';
const pad = '0 ' + px2rpx(CardTokens.space.xl) + 'rpx';
if (this.buttonStyle === 'ghost') {
return this.$util.objToStyle({
border: '2rpx solid ' + (v.accentColor || '#FFEB3B'),
borderRadius: radius,
padding: pad,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
});
}
if (this.buttonStyle === 'link') {
return this.$util.objToStyle({ padding: '0 0 0 ' + px2rpx(CardTokens.space.sm) + 'rpx' });
}
return this.$util.objToStyle({
background: v.accentColor || '#FFEB3B',
borderRadius: radius,
padding: pad,
height: px2rpx(CardTokens.touch.chipHeight) + 'rpx',
});
},
btnTextStyle() {
const v = this.v;
const fontSize = px2rpx(num(this.v.btnSize, 12)) + 'rpx';
if (this.buttonStyle === 'ghost') {
return this.$util.objToStyle({ color: v.accentColor || '#FFEB3B', fontSize: fontSize });
}
if (this.buttonStyle === 'link') {
return this.$util.objToStyle({ color: v.textColor || CardTokens.color.primary, fontSize: fontSize });
}
return this.$util.objToStyle({ color: '#333333', fontSize: fontSize });
},
/**
* 文字箭头按钮的箭头: 仅 link 档渲染, 颜色与该档按钮文字一致;
* 字号比按钮文字略大一档(默认 14px = 28rpx, 与历史渲染一致)
*/
btnArrowStyle() {
return this.$util.objToStyle({
color: this.v.textColor || CardTokens.color.primary,
fontSize: px2rpx(num(this.v.btnSize, 12) + 2) + 'rpx',
});
},
},
methods: {
goLink() {
const v = this.v;
let url = '';
if (v.linkMode === 'contacts') {
url = '/pages_tool/card/contacts';
} else if (v.linkMode === 'custom') {
url = String(v.customUrl || '').trim();
if (url !== '' && url.indexOf('/') !== 0) {
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
return;
}
} else if (v.linkMode === 'link') {
// 平台统一「选择链接」: link 是个对象({name,title,wap_url,page_key,page_params}),
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
const link = v.link && typeof v.link === 'object' ? v.link : null;
if (!link || !link.name) {
uni.showToast({ title: '请先选择链接', icon: 'none' });
return;
}
this.$util.diyRedirectTo(link);
return;
} else {
url = '/pages_tool/card/list';
}
if (url === '') {
uni.showToast({ title: '未配置跳转路径', icon: 'none' });
return;
}
this.$util.redirectTo(url);
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-entry {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
overflow: hidden;
&.entry-center {
justify-content: center;
text-align: center;
.entry-body {
align-items: center;
}
}
.entry-icon {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.icon-img {
width: 100%;
height: 100%;
display: block;
}
.icon-glyph {
font-size: 48rpx;
}
}
.entry-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
.entry-title {
font-size: 32rpx;
font-weight: 700;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.entry-sub {
font-size: 24rpx;
line-height: 1.5;
margin-top: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.entry-btn {
flex-shrink: 0;
margin-left: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.entry-btn-text {
font-size: 24rpx;
font-weight: 600;
}
.btn-arrow {
font-size: 28rpx;
margin-left: 2rpx;
}
}
}
.entry-press {
transform: scale(0.985);
opacity: 0.92;
}
.btn-press {
opacity: 0.72;
}
</style>

View File

@@ -0,0 +1,568 @@
<template>
<view data-component-name="diy-card-list" class="diy-card-list">
<view class="card-wall" :style="wallStyle">
<!-- 标题行: 标题 + 数量 + 查看全部 -->
<view v-if="showTitle || showCount || showMore" class="wall-head">
<text v-if="showTitle" class="wall-title" :style="titleStyle">{{ value.title || '团队名片' }}</text>
<text v-if="showCount && list.length" class="wall-count" :style="countStyle">{{ list.length }} 人</text>
<view class="head-spacer"></view>
<text v-if="showMore" class="wall-more" :style="moreStyle" hover-class="btn-press" @tap="goMore">{{ moreText || '查看全部' }} ›</text>
</view>
<!-- 骨架屏 -->
<view v-if="loading && !list.length" class="wall-skeleton">
<view v-for="n in skeletonCount" :key="'sk' + n" class="sk-item">
<view class="sk sk-avatar"></view>
<view class="sk-col">
<view class="sk sk-line" style="width:50%"></view>
<view class="sk sk-line" style="width:70%;margin-top:14rpx;"></view>
</view>
</view>
</view>
<!-- 横滑布局 -->
<scroll-view v-else-if="layout === 'scroll' && list.length" class="wall-scroll" scroll-x show-scrollbar="false">
<view v-for="(item, index) in list" :key="item.card_id || index" class="scroll-item fade-up" :style="scrollItemStyle" hover-class="btn-press" @tap="goDetail(item)">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<text class="scroll-name" :style="scrollNameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="infoLine(item)" class="scroll-meta" :style="scrollMetaStyle">{{ infoLine(item) }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
</scroll-view>
<!-- 网格/列表布局 -->
<view v-else-if="list.length" :class="['wall-grid', layout === 'list' ? 'is-list' : 'is-grid']">
<view v-for="(item, index) in list" :key="item.card_id || index" class="wall-item fade-up"
:style="itemFinalStyle(index)" hover-class="btn-press" @tap="goDetail(item)">
<!-- 内层卡片: 布局盒(wall-item)只负责 50% 宽+左右 gutter, 视觉在此层
(WXSS 不支持 calc(), 不能用 calc(50% - gap/2)) -->
<view class="item-card">
<view class="item-avatar" :style="avatarStyle(item)">
<image v-if="item.avatar" class="avatar-img" :src="$util.img(item.avatar)" mode="aspectFill" />
<text v-else class="avatar-initial">{{ initialOf(item.name) }}</text>
</view>
<view class="item-info">
<view class="name-row">
<text class="item-name" :style="gridNameStyle">{{ item.name || '未命名名片' }}</text>
<text v-if="isEnterprise(item)" class="item-badge" :style="badgeStyle(item)">企业</text>
</view>
<text v-if="infoLine(item)" class="item-meta" :style="metaStyle">{{ infoLine(item) }}</text>
</view>
<text v-if="showArrow" class="item-arrow" :style="infoStyle">›</text>
</view>
</view>
</view>
<!-- 空态 -->
<view v-else-if="!loading" class="wall-empty" :style="infoStyle">
<text class="empty-icon">🗂️</text>
<text class="empty-tip">{{ cardIdsConfigured ? '暂无可展示的名片' : '商家还未挑选名片' }}</text>
</view>
</view>
</view>
</template>
<script>
// 数字名片 · 团队名片墙(装修组件)
// 布局: grid(1/2 列) / list(整行) / scroll(横滑); 骨架屏 + 逐项入场 + 按压反馈
import { CardTokens, px2rpx, flagOn, num, initialOf, avatarBg } from './diy-card-common.js';
export default {
name: 'diy-card-list',
props: {
value: { type: Object }
},
data() {
return {
list: [],
loading: false,
};
},
computed: {
v() {
return this.value || {};
},
cardIds() {
return String(this.v.cardIds || '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
.slice(0, 20)
.join(',');
},
cardIdsConfigured() {
return String(this.v.cardIds || '').trim() !== '';
},
layout() {
const l = String(this.v.layout || 'grid');
return ['grid', 'list', 'scroll'].includes(l) ? l : 'grid';
},
columns() {
return Number(this.v.columns) === 1 ? 1 : 2;
},
showTitle() {
return String(this.v.title || '').trim() !== '';
},
showCount() {
return flagOn(this.v.showCount, false);
},
showMore() {
return flagOn(this.v.showMore, false);
},
moreText() {
return this.v.moreText || '';
},
showArrow() {
return flagOn(this.v.showArrow, true);
},
skeletonCount() {
return this.layout === 'list' ? 3 : this.columns * 2;
},
wallStyle() {
return this.$util.objToStyle({
background: this.v.bgColor || CardTokens.color.bg,
borderRadius: px2rpx(num(this.v.borderRadius, CardTokens.radius.md)) + 'rpx',
padding: px2rpx(num(this.v.padding, CardTokens.space.md)) + 'rpx',
});
},
titleStyle() {
return this.$util.objToStyle({
color: this.v.titleColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.titleSize, 16)) + 'rpx',
});
},
/** 网格/列表卡片姓名: 字号可配(默认 14px = 28rpx) */
gridNameStyle() {
return this.$util.objToStyle({
color: this.v.nameColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.nameSize, 14)) + 'rpx',
});
},
/** 横滑竖卡姓名: 比网格小一档(默认 13px = 26rpx, 与历史渲染一致) */
scrollNameStyle() {
return this.$util.objToStyle({
color: this.v.nameColor || CardTokens.color.text,
fontSize: px2rpx(num(this.v.nameSize, 14) - 1) + 'rpx',
});
},
/** 网格/列表卡片信息行: 字号可配(默认 11px = 22rpx) */
metaStyle() {
return this.$util.objToStyle({
color: this.v.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
});
},
/** 横滑竖卡信息行: 比网格小一档(默认 10px = 20rpx, 与历史渲染一致) */
scrollMetaStyle() {
return this.$util.objToStyle({
color: this.v.infoColor || CardTokens.color.textSub,
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
});
},
/** infoColor 纯色(箭头/空态用, 不带字号) */
infoStyle() {
return this.$util.objToStyle({ color: this.v.infoColor || CardTokens.color.textSub });
},
countStyle() {
return this.$util.objToStyle({
color: CardTokens.color.primary,
background: 'rgba(47,107,255,0.08)',
// 人数徽标比信息行小一档(默认 10px = 20rpx, 与历史渲染一致)
fontSize: px2rpx(num(this.v.infoSize, 11) - 1) + 'rpx',
});
},
moreStyle() {
return this.$util.objToStyle({
color: CardTokens.color.textLabel,
fontSize: px2rpx(num(this.v.infoSize, 11)) + 'rpx',
});
},
itemStyle() {
const gap = num(this.v.gap, 10);
const isGrid = this.layout !== 'list' && this.columns !== 1;
const style = { marginBottom: px2rpx(gap) + 'rpx' };
if (isGrid) {
// WXSS 不支持 calc(): 50% 宽 + 左右各 half-gap 作 gutter(视觉在内层 item-card)
style.width = '50%';
style.paddingLeft = px2rpx(gap / 2) + 'rpx';
style.paddingRight = px2rpx(gap / 2) + 'rpx';
} else {
style.width = '100%';
}
return this.$util.objToStyle(style);
},
scrollItemStyle() {
return this.$util.objToStyle({
width: '260rpx',
marginRight: px2rpx(num(this.v.gap, 10)) + 'rpx',
});
},
},
watch: {
'value.cardIds'() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
/**
* 列表项最终样式: itemStyle + 逐项入场动画延迟 合并输出单对象。
* uni-app 运行时会静默丢弃 :style="[A, B]" 数组绑定(2026-10-24 mini.vue
* 操作区同根因修复的先例), 数组形态禁止回归
*/
itemFinalStyle(index) {
return Object.assign({}, this.itemStyle, { animationDelay: (index * 60) + 'ms' });
},
initialOf,
avatarStyle(item) {
return this.$util.objToStyle({ background: avatarBg(item) });
},
badgeStyle(item) {
return this.$util.objToStyle({ background: avatarBg(item) });
},
isEnterprise(item) {
return item && item.card_type === 'enterprise';
},
infoLine(item) {
const showJob = flagOn(this.v.showJob, true);
const showCompany = flagOn(this.v.showCompany, true);
const parts = [];
if (showJob && item.job_title) parts.push(item.job_title);
if (showCompany && item.company) parts.push(item.company);
return parts.join(' · ');
},
fetchData() {
if (this.cardIds === '') {
this.list = [];
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/cards',
data: { card_ids: this.cardIds },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.list = res.data.list || [];
}
},
fail: () => {
this.loading = false;
},
});
},
goDetail(item) {
if (!item || !item.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: item.card_id });
},
goMore() {
const mode = String(this.v.moreLinkMode || 'list');
let url = '/pages_tool/card/list';
if (mode === 'contacts') {
url = '/pages_tool/card/contacts';
} else if (mode === 'custom') {
url = String(this.v.moreLinkUrl || '').trim();
if (url === '' || url.indexOf('/') !== 0) {
uni.showToast({ title: '自定义路径需以 / 开头', icon: 'none' });
return;
}
} else if (mode === 'link') {
// 平台统一「选择链接」: moreLink 是个对象({name,title,wap_url,page_key,page_params}),
// 走 $util.diyRedirectTo 由平台统一处理(内部页/外链/小程序/拨号 等)
const link = this.v.moreLink && typeof this.v.moreLink === 'object' ? this.v.moreLink : null;
if (!link || !link.name) {
uni.showToast({ title: '请先选择链接', icon: 'none' });
return;
}
this.$util.diyRedirectTo(link);
return;
}
this.$util.redirectTo(url);
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-list {
width: 100%;
.card-wall {
width: 100%;
box-sizing: border-box;
.wall-head {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.wall-title {
font-size: 32rpx;
font-weight: 700;
}
.wall-count {
font-size: 20rpx;
margin-left: 12rpx;
padding: 2rpx 12rpx;
border-radius: 999rpx;
}
.head-spacer {
flex: 1;
}
.wall-more {
font-size: 22rpx;
padding: 8rpx 0 8rpx 16rpx;
}
}
/* 网格/列表 */
.wall-grid {
&.is-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.wall-item {
box-sizing: border-box;
display: flex;
/* 视觉卡片层: 布局盒只算宽度与 gutter, 不画外观 */
.item-card {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 20rpx;
overflow: hidden;
}
.item-avatar {
flex-shrink: 0;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.avatar-img {
width: 100%;
height: 100%;
display: block;
}
.avatar-initial {
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
}
.item-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
display: flex;
flex-direction: column;
.name-row {
display: flex;
align-items: center;
.item-name {
font-size: 28rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
flex-shrink: 0;
margin-left: 10rpx;
font-size: 18rpx;
color: #ffffff;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
.item-meta {
font-size: 22rpx;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-arrow {
flex-shrink: 0;
margin-left: 8rpx;
font-size: 32rpx;
}
}
}
/* 横滑 */
.wall-scroll {
width: 100%;
white-space: nowrap;
.scroll-item {
display: inline-flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
border: 2rpx solid #f0f0f0;
border-radius: 16rpx;
padding: 24rpx 16rpx;
position: relative;
.item-avatar {
width: 104rpx;
height: 104rpx;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.avatar-img {
width: 100%;
height: 100%;
display: block;
}
.avatar-initial {
font-size: 42rpx;
font-weight: 700;
color: #ffffff;
}
}
.scroll-name {
font-size: 26rpx;
font-weight: 600;
margin-top: 12rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scroll-meta {
font-size: 20rpx;
margin-top: 4rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
position: absolute;
top: 12rpx;
right: 12rpx;
font-size: 18rpx;
color: #ffffff;
padding: 2rpx 10rpx;
border-radius: 6rpx;
}
}
}
/* 空态 */
.wall-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
.empty-icon {
font-size: 56rpx;
}
.empty-tip {
font-size: 24rpx;
margin-top: 12rpx;
}
}
/* 骨架屏 */
.wall-skeleton {
.sk-item {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.sk-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
flex-shrink: 0;
}
.sk-col {
flex: 1;
margin-left: 16rpx;
}
}
}
}
.sk {
background: linear-gradient(90deg, #f2f3f5 25%, #e8eaee 37%, #f2f3f5 63%);
background-size: 400rpx 100%;
animation: sk-shimmer 1.2s linear infinite;
border-radius: 8rpx;
&.sk-line {
height: 24rpx;
}
}
}
.fade-up {
animation: card-fade-up 240ms ease both;
}
.btn-press {
opacity: 0.72;
transform: scale(0.98);
}
@keyframes sk-shimmer {
0% {
background-position: -200rpx 0;
}
100% {
background-position: 200rpx 0;
}
}
@keyframes card-fade-up {
from {
opacity: 0;
transform: translateY(12rpx);
}
to {
opacity: 1;
transform: none;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,392 @@
<template>
<view data-component-name="diy-card-self" class="diy-card-self">
<!-- 未登录 + 登录引导兜底 -->
<view v-if="!hasToken && fallbackMode === 'none'" class="self-login" :style="boxStyle" @tap="goLogin" hover-class="btn-press">
<text class="login-tip" :style="nameStyle">{{ value.loginTip || '登录后这里展示我的名片' }}</text>
<view class="login-btn" :style="loginBtnStyle">
<text class="login-btn-text">{{ value.loginBtnText || '去登录' }}</text>
</view>
</view>
<!-- 名片卡(已登录=本人名片 / 未登录=兜底名片); loading 时基件出骨架屏;
「我的名片」角标已按 2026-10-23 参考图定稿移除 -->
<diy-card-mini
v-else-if="loading || card"
:card="card"
:config="miniConfig"
:loading="loading"
@act="doAct"
@row-act="doAct"
@detail="goDetail"
>
</diy-card-mini>
<!-- 未登录提示条已按 2026-10-23 用户要求移除(不再显示「登录后即可展示我的名片」) -->
<!-- 没有任何名片可显示(非登录引导模式; 登录引导已由第一个分支承接) -->
<view v-else-if="!loading && !card" class="self-empty" :style="boxStyle">
<text class="empty-icon">🪪</text>
<text class="empty-tip" :style="subTextStyle">{{ value.noCardTip || '登录后即可展示我的名片' }}</text>
<view class="empty-btn" :style="loginBtnStyle" hover-class="btn-press" @tap="goCardList">
<text class="empty-btn-text">{{ value.noCardBtnText || '去看看名片' }}</text>
</view>
</view>
</view>
</template>
<script>
// 数字名片 · 我的名片(装修组件)
// 运行时判定: 已登录 → 本人名片(optionalAuth); 未登录 → 配置的兜底名片
// fallbackMode: enterprise=店铺企业名片 / card=指定 fallbackCardId / none=登录引导
// 视觉基件 diy-card-mini; 本文件管登录态/兜底/空态 CTA
export default {
name: 'diy-card-self',
props: {
value: { type: Object }
},
data() {
return {
card: null,
isSelf: false,
loading: false,
};
},
computed: {
hasToken() {
try {
return !!(this.$store && this.$store.state && this.$store.state.token);
} catch (e) {
return false;
}
},
fallbackMode() {
const m = String((this.value || {}).fallbackMode || 'enterprise');
return ['enterprise', 'card', 'none'].includes(m) ? m : 'enterprise';
},
miniConfig() {
const v = this.value || {};
return {
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
// 字号定制(px 口径, 缺省时基件按历史默认值渲染)
nameSize: v.nameSize,
cardTitleSize: v.cardTitleSize,
subSize: v.subSize,
labelSize: v.labelSize,
valueSize: v.valueSize,
actSize: v.actSize,
btnSize: v.btnSize,
showQuick: v.showQuick,
showDetailBtn: v.showDetailBtn,
detailBtnText: v.detailBtnText,
// 2.0 新增视觉开关
useCover: v.useCover,
showGradient: v.showGradient,
showLogo: v.showLogo,
showShadow: v.showShadow,
showRows: v.showRows,
maxRows: v.maxRows,
showQr: v.showQr,
qrTip: v.qrTip,
// 3.0 A+B 视觉升级
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
headStyle: v.headStyle,
headNameColor: v.headNameColor,
headSubColor: v.headSubColor,
actsLayout: v.actsLayout,
actGridColorMode: v.actGridColorMode,
showRowsTitle: v.showRowsTitle,
rowsTitle: v.rowsTitle,
showCopyBtn: v.showCopyBtn,
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
actsPosition: v.actsPosition,
actsBlockBg: v.actsBlockBg,
actsBlockRadius: v.actsBlockRadius,
actsBlockPad: v.actsBlockPad,
actIcons: v.actIcons,
qrImage: v.qrImage,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actDeleted: v.actDeleted,
actLabels: v.actLabels,
actOrder: v.actOrder,
customActs: v.customActs,
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
showRowLabels: v.showRowLabels,
copyBtnScope: v.copyBtnScope,
copyBtnLook: v.copyBtnLook,
qrTipEm: v.qrTipEm,
qrTipSub: v.qrTipSub,
qrUrlShow: v.qrUrlShow,
badgeShow: v.badgeShow,
badgeText: v.badgeText,
rowsBlockBg: v.rowsBlockBg,
rowsBlockRadius: v.rowsBlockRadius,
rowsBlockPad: v.rowsBlockPad,
qrBlockBg: v.qrBlockBg,
qrBlockRadius: v.qrBlockRadius,
qrBlockPad: v.qrBlockPad,
};
},
boxStyle() {
const v = this.value || {};
return this.$util.objToStyle({
background: v.bgColor || '#FFFFFF',
borderRadius: this.px2rpx(v.borderRadius || 16) + 'rpx',
padding: this.px2rpx(v.padding || 16) + 'rpx',
boxShadow: '0 4rpx 16rpx rgba(15,23,42,0.06)',
});
},
nameStyle() {
return this.$util.objToStyle({ color: (this.value || {}).nameColor || '#1F2329' });
},
subTextStyle() {
return this.$util.objToStyle({ color: (this.value || {}).infoColor || '#8A8F99' });
},
loginBtnStyle() {
return this.$util.objToStyle({
background: (this.card || {}).theme_color || '#2F6BFF',
height: '80rpx',
borderRadius: '999rpx',
padding: '0 48rpx',
});
},
},
watch: {
hasToken() {
this.fetchData();
},
'value.fallbackMode'() {
this.fetchData();
},
'value.fallbackCardId'() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
px2rpx(v) {
const n = Number(v);
return isNaN(n) ? 0 : Math.round(n * 2);
},
fetchData() {
if (!this.hasToken && this.fallbackMode === 'none') {
this.card = null;
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/mycard',
data: {
fallback_mode: this.fallbackMode,
fallback_card_id: String((this.value || {}).fallbackCardId || '').trim(),
},
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.card = res.data.card || null;
this.isSelf = res.data.is_self == 1;
} else {
this.card = null;
this.isSelf = false;
}
},
fail: () => {
this.loading = false;
this.card = null;
this.isSelf = false;
},
});
},
goLogin() {
this.$util.redirectTo('/pages_tool/login/login');
},
goCardList() {
this.$util.redirectTo('/pages_tool/card/list');
},
doAct(act) {
if (!act) return;
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
if (act.type === 'save') {
this.goSaveContact();
return;
}
// map(导航): value 留空回落名片地址(2.19.3)
if (act.type === 'map' && !act.value && this.card && this.card.address) {
act = Object.assign({}, act, { value: this.card.address });
}
if (!act.value) return;
if (act.type === 'tel') {
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
} else if (act.type === 'wx') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
});
} else if (act.type === 'mail') {
// H5/公众号: 直接调起系统邮件客户端(满足「点击邮箱发邮件」)
// #ifdef H5
window.location.href = 'mailto:' + act.value;
// #endif
// 小程序/App(非 H5): 微信沙箱不支持 mailto 拉起, 复制地址并引导去邮件 App 粘贴
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '邮箱已复制,请前往邮件App粘贴发送', icon: 'none' }),
});
// #endif
} else if (act.type === 'web') {
// 网站行点击 → 跳转到该网址
// H5/公众号: 直接在本页打开(微信内置浏览器同)
// #ifdef H5
window.location.href = act.value;
// #endif
// 小程序/App(非 H5): 经 web-view 承载页在微信内打开(需微信后台配置业务域名)
// #ifndef H5
const title = (this.card && this.card.name) || '网站';
uni.navigateTo({
url: '/pages_tool/webview/webview?src=' + encodeURIComponent(act.value) +
'&title=' + encodeURIComponent(title) + '&enable_set_title=1',
fail: () => uni.showToast({ title: '打开网站失败', icon: 'none' }),
});
// #endif
} else if (act.type === 'sms') {
// #ifdef H5
window.location.href = 'sms:' + act.value;
// #endif
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '号码已复制,请到短信粘贴', icon: 'none' }),
});
// #endif
} else if (act.type === 'map') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '地址已复制,可粘贴到地图导航', icon: 'none' }),
});
} else if (act.type === 'link') {
var isHttp = act.value.indexOf('http://') === 0 || act.value.indexOf('https://') === 0;
if (isHttp) {
// #ifdef H5
window.location.href = act.value;
// #endif
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' }),
});
// #endif
} else {
this.$util.redirectTo(act.value, {});
}
} else {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
});
}
},
goDetail(card) {
if (!card || !card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
/**
* 存通讯录(2.17.1): 与 diy-card-show 同构 —— 字段全部从 card 组装
* (rows 已过 shouldShow 白名单), 键名与 save-contact.vue onLoad 的 keys 一一对应。
*/
goSaveContact() {
const c = this.card || {};
const map = {};
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
if (r && r.field) map[r.field] = String(r.value || '');
});
// #ifdef H5
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
});
return;
// #endif
this.$util.redirectTo('/pages_tool/card/save-contact', {
name: c.name || '',
mobile: map.mobile || '',
company: c.company || '',
job_title: c.job_title || '',
email: map.email || '',
website: map.website || '',
address: map.address || '',
remark: map.remark || '',
wechat: map.wechat || '',
});
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-self {
width: 100%;
position: relative;
.self-login,
.self-empty {
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx 24rpx;
.empty-icon {
font-size: 64rpx;
}
.login-tip,
.empty-tip {
font-size: 28rpx;
font-weight: 600;
text-align: center;
}
.login-btn,
.empty-btn {
margin-top: 28rpx;
display: flex;
align-items: center;
justify-content: center;
.login-btn-text,
.empty-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
}
}
}
.btn-press {
opacity: 0.72;
transform: scale(0.98);
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<view data-component-name="diy-card-show" class="diy-card-show">
<diy-card-mini
:card="card"
:config="miniConfig"
:loading="loading"
empty-tip="商家还未选择名片"
@act="doAct"
@row-act="doAct"
@detail="goDetail"
></diy-card-mini>
</view>
</template>
<script>
// 数字名片 · 精选名片(装修组件)
// 视觉基件 diy-card-mini(封面/渐变/Logo/主题色/折叠/芯片/二维码), 本文件只管数据
export default {
name: 'diy-card-show',
props: {
value: { type: Object }
},
data() {
return {
card: null,
loading: false,
};
},
computed: {
miniConfig() {
const v = this.value || {};
return {
bgColor: v.bgColor,
nameColor: v.nameColor,
infoColor: v.infoColor,
labelColor: v.labelColor,
borderRadius: v.borderRadius,
padding: v.padding,
// 字号定制(px 口径, 缺省时基件按历史默认值渲染) —— 2.17 补齐:
// 此前 show 漏透传 7 个字号字段(面板配置不生效), 与 self 对齐
nameSize: v.nameSize,
cardTitleSize: v.cardTitleSize,
subSize: v.subSize,
labelSize: v.labelSize,
valueSize: v.valueSize,
actSize: v.actSize,
btnSize: v.btnSize,
showQuick: v.showQuick,
showDetailBtn: v.showDetailBtn,
detailBtnText: v.detailBtnText,
// 2.0 新增视觉开关
useCover: v.useCover,
showGradient: v.showGradient,
showLogo: v.showLogo,
showShadow: v.showShadow,
showRows: v.showRows,
maxRows: v.maxRows,
showQr: v.showQr,
qrTip: v.qrTip,
// 3.0 A+B 视觉升级
showRowIcons: v.showRowIcons,
rowTap: v.rowTap,
qrPosition: v.qrPosition,
// 3.1 头像开关 + 二维码样式/微调
showAvatar: v.showAvatar,
qrStyle: v.qrStyle,
qrSize: v.qrSize,
qrOffsetX: v.qrOffsetX,
qrOffsetY: v.qrOffsetY,
qrTipShow: v.qrTipShow,
// 2.17 双形态: 渐变头部 / 宫格快捷操作 / 资料区标题 / 复制按钮
headStyle: v.headStyle,
headNameColor: v.headNameColor,
headSubColor: v.headSubColor,
actsLayout: v.actsLayout,
actGridColorMode: v.actGridColorMode,
showRowsTitle: v.showRowsTitle,
rowsTitle: v.rowsTitle,
showCopyBtn: v.showCopyBtn,
// 2.18: 操作区位置/独立块样式/每按钮图标/自定义二维码图
actsPosition: v.actsPosition,
actsBlockBg: v.actsBlockBg,
actsBlockRadius: v.actsBlockRadius,
actsBlockPad: v.actsBlockPad,
actIcons: v.actIcons,
qrImage: v.qrImage,
// 2.19: 默认动作显隐/文案 + 自定义动作(基件 acts computed 合成)
actVis: v.actVis,
actDeleted: v.actDeleted,
actLabels: v.actLabels,
actOrder: v.actOrder,
customActs: v.customActs,
// 2.19.10 detail 版式对齐(三列行/复制范围外观/qrTip 富文本/URL 行/hero 徽章/分卡)
showRowLabels: v.showRowLabels,
copyBtnScope: v.copyBtnScope,
copyBtnLook: v.copyBtnLook,
qrTipEm: v.qrTipEm,
qrTipSub: v.qrTipSub,
qrUrlShow: v.qrUrlShow,
badgeShow: v.badgeShow,
badgeText: v.badgeText,
rowsBlockBg: v.rowsBlockBg,
rowsBlockRadius: v.rowsBlockRadius,
rowsBlockPad: v.rowsBlockPad,
qrBlockBg: v.qrBlockBg,
qrBlockRadius: v.qrBlockRadius,
qrBlockPad: v.qrBlockPad,
};
},
},
watch: {
'value.cardId'() {
this.fetchData();
},
},
mounted() {
this.fetchData();
},
methods: {
fetchData() {
const cardId = String((this.value || {}).cardId || '').trim();
if (cardId === '') {
this.card = null;
return;
}
this.loading = true;
this.$api.sendRequest({
url: '/card/api/diy/card',
data: { card_id: cardId },
success: res => {
this.loading = false;
this.card = (res.code == 0 && res.data && res.data.card) ? res.data.card : null;
},
fail: () => {
this.loading = false;
this.card = null;
},
});
},
doAct(act) {
if (!act) return;
// save(存通讯录): value 为空, 字段由本组件从 card 组装(白名单口径)
if (act.type === 'save') {
this.goSaveContact();
return;
}
// map(导航): value 留空回落名片地址(2.19.3)
if (act.type === 'map' && !act.value && this.card && this.card.address) {
act = Object.assign({}, act, { value: this.card.address });
}
if (!act.value) return;
if (act.type === 'tel') {
uni.makePhoneCall({ phoneNumber: act.value, fail: () => {} });
} else if (act.type === 'wx') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '微信号已复制', icon: 'none' }),
});
} else if (act.type === 'mail') {
// #ifdef H5
window.location.href = 'mailto:' + act.value;
// #endif
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '邮箱已复制', icon: 'none' }),
});
// #endif
} else if (act.type === 'sms') {
// #ifdef H5
window.location.href = 'sms:' + act.value;
// #endif
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '号码已复制,请到短信粘贴', icon: 'none' }),
});
// #endif
} else if (act.type === 'map') {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '地址已复制,可粘贴到地图导航', icon: 'none' }),
});
} else if (act.type === 'link') {
var isHttp = act.value.indexOf('http://') === 0 || act.value.indexOf('https://') === 0;
if (isHttp) {
// #ifdef H5
window.location.href = act.value;
// #endif
// #ifndef H5
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' }),
});
// #endif
} else {
this.$util.redirectTo(act.value, {});
}
} else {
uni.setClipboardData({
data: act.value,
success: () => uni.showToast({ title: '已复制', icon: 'none' }),
});
}
},
goDetail(card) {
if (!card || !card.card_id) return;
this.$util.redirectTo('/pages_tool/card/detail', { card_id: card.card_id });
},
/**
* 存通讯录(2.17.1): 字段全部从 card 组装 —— rows 已过 shouldShow
* 白名单(商家隐藏的字段不下发也不落通讯录, 隐私口径与后端一致),
* name/company/job_title 为公开头部字段直接取。
* 键名与 save-contact.vue onLoad 的 keys 一一对应。
*/
goSaveContact() {
const c = this.card || {};
const map = {};
(Array.isArray(c.rows) ? c.rows : []).forEach(r => {
if (r && r.field) map[r.field] = String(r.value || '');
});
// #ifdef H5
// H5 无通讯录 API: 复制资料文本兜底(小程序端走 save-contact 原生页)
const text = [c.name, map.mobile, map.email, c.company, c.job_title].filter(Boolean).join(' ');
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '名片资料已复制', icon: 'none' }),
});
return;
// #endif
this.$util.redirectTo('/pages_tool/card/save-contact', {
name: c.name || '',
mobile: map.mobile || '',
company: c.company || '',
job_title: c.job_title || '',
email: map.email || '',
website: map.website || '',
address: map.address || '',
remark: map.remark || '',
wechat: map.wechat || '',
});
},
},
};
</script>
<style lang="scss" scoped>
.diy-card-show {
width: 100%;
}
</style>

View File

@@ -6,7 +6,7 @@
</view>
<block v-if="value.template == 99">
<view class="second-category-btns">
<scroll-view scroll-x="true" class="second-category-btns" :scroll-with-animation="true">
<view class="second-category-btn"
v-for="(one, oneIndex) in category.child_list"
:key="oneIndex"
@@ -14,7 +14,7 @@
@tap="selectSecondCategory(one)">
{{ one.category_name }}
</view>
</view>
</scroll-view>
<view class="category-goods-list" v-if="selectedSecondCategory">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" :data-template="value.template" v-if="secondCategoryGoodsList.length">
<view class="goods-item" v-for="(item, index) in secondCategoryGoodsList" :key="index">
@@ -34,6 +34,11 @@
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap">
<view class="consult-now-btn-wrap" v-if="item.isinformation == 1">
@@ -52,12 +57,13 @@
</view>
</block>
<!-- 模板1: 后台配置三级分类 + 有子分类时, 按二级分类分别显示各自的商品(图 f325d5a779 样式) -->
<!-- 模板1: 后台配置三级分类 + 有子分类时, 按二级分类分别显示各自的商品(图 f325d5a779 样式)
只渲染 filteredGroupedChildren(过滤掉无商品的二级分类), 没商品时整块(标题+网格)都不显示 -->
<block v-else-if="isGroupedDisplay">
<block v-for="(one, oneIndex) in category.child_list" :key="oneIndex">
<block v-for="(one, oneIndex) in filteredGroupedChildren" :key="one.category_id">
<view :class="['category-title', 'category-title-with-lines']">{{ one.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list double-column show" v-if="template1ChildGoodsList[one.category_id] && template1ChildGoodsList[one.category_id].length">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1ChildGoodsList[one.category_id] && template1ChildGoodsList[one.category_id].length">
<view class="goods-item" v-for="(item, index) in template1ChildGoodsList[one.category_id]" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(one.category_id, index)" show-menu-by-longpress />
@@ -75,6 +81,11 @@
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
@@ -82,19 +93,17 @@
</view>
</view>
</view>
<view class="category-goods-empty" v-else-if="!template1ChildLoading[one.category_id]">
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
<view class="tips">暂无商品</view>
</view>
<!-- filteredGroupedChildren 已保证加载完成且 length > 0, 此处无需再渲染空状态 -->
</view>
</block>
</block>
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式)
模板5 不走这里: 它有专属分支 value.template == 5(后台"展示分类"决定渲染商品网格或二级分类网格) -->
<view v-else-if="value.template == 1">
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list double-column show" v-if="template1GoodsList.length">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
@@ -112,6 +121,11 @@
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
@@ -126,8 +140,51 @@
</view>
</view>
<!-- 非模板1, 2级分类: 显示二级分类网格,点击跳转到列表页 -->
<block v-else-if="categoryLevel == 2">
<!-- 模板5 专属: 后台"展示分类"决定右侧渲染内容(对应图2/图3)
二级分类(value.level == 2) → 一级分类标题 + 商品网格(图2)
三级分类(value.level == 3) → 一级分类标题 + 二级分类网格(图3) -->
<block v-else-if="value.template == 5">
<block v-if="value.level == 2">
<view :class="['category-title', '']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
</view>
<view class="info-wrap">
<view class="name-wrap" @tap.stop="toDetail(item)">
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
</view>
<view class="price-wrap">
<view class="discount-price">
<block v-if="item.isinformation == 0">
<text class="unit price-style small">{{ $lang('common.currencySymbol') }}</text>
<text class="price price-style large">{{ parseFloat(showPrice(item)).toFixed(2).split('.')[0] }}</text>
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
</view>
</view>
</view>
</view>
<view class="category-goods-empty" v-else-if="!template1Loading">
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
<view class="tips">暂无商品</view>
</view>
</view>
</block>
<block v-else>
<!-- 三级分类(及更深层级): 一级分类标题 + 二级分类网格(图3) -->
<view :class="['category-title', '']">{{ category.category_name }}</view>
<view class="category-list">
<view class="category-item" v-for="(one, oneIndex) in category.child_list" :key="oneIndex"
@@ -139,28 +196,14 @@
</view>
</view>
</block>
<block v-else-if="categoryLevel == 3">
<block v-for="(one, oneIndex) in category.child_list" :key="oneIndex">
<view :class="['category-title', '']">{{ one.category_name }}</view>
<view class="category-list">
<view class="category-item" v-for="(two, twoIndex) in one.child_list" :key="twoIndex"
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: two.category_id })">
<view class="img-box">
<image :src="categoryImg(two.image)" mode="aspectFit" @error="categoryImgError(3, oneIndex, twoIndex)" show-menu-by-longpress />
</view>
<view class="name">{{ two.category_name }}</view>
</view>
</view>
</block>
</block>
</block>
</block>
<!-- 模板1: 无子分类时(外层块不进入), 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
<view v-else-if="value.template == 1 && type == 'category'">
<view v-else-if="(value.template == 1 || value.template == 5) && type == 'category'">
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list double-column show" v-if="template1GoodsList.length">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
@@ -178,6 +221,11 @@
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
@@ -295,29 +343,29 @@
<image :src="$util.img('public/uniapp/index/discount.png')"
mode="widthFix" show-menu-by-longpress />
</view>
<!-- 模板4: 删除线价格(图片样式) -->
<!-- 模板2/4: 删除线价格(放在现价下方, 样式见 price-wrap 的 delete-price 规则) -->
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="showMarketPrice(item) && value.template == 4">
v-if="showMarketPrice(item) && (value.template == 2 || value.template == 4)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap">
</view>
<view class="footer-wrap">
<view class="pro-info">
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="showMarketPrice(item) && value.template != 4">
v-if="showMarketPrice(item) && value.template != 4 && value.template != 2">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="consult-now-btn-wrap" v-if="item.isinformation == 1 && value.template != 4">
<view class="consult-now-btn-wrap" v-if="item.isinformation >= 1">
<view class="consult-now-btn" @tap.stop="sendToAIChat(item)">
{{ $lang('components-diy.diy-category-item.consultNow') }}
</view>
</view>
<!-- 模板4: 右侧+按钮/择规格按钮(图片样式) -->
<view class="right-wrap" v-if="value.template == 4">
<!-- 风格2/模板4: 右侧+按钮/择规格按钮 -->
<view class="right-wrap" v-if="(value.template == 2 || value.template == 4) && showAddCart && item.isinformation == 0">
<block v-if="item.is_virtual">
<view class="color-base-bg select-sku" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.buyNow') }}</view>
</block>
@@ -435,13 +483,13 @@
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="consult-now-btn-wrap" v-if="item.isinformation == 1 && value.template != 4">
<view class="consult-now-btn-wrap" v-if="item.isinformation >= 1">
<view class="consult-now-btn" @tap.stop="sendToAIChat(item)">
{{ $lang('components-diy.diy-category-item.consultNow') }}
</view>
</view>
<view class="right-wrap" v-if="value.template == 2 || value.template == 4">
<view class="right-wrap" v-if="(value.template == 2 || value.template == 4) && showAddCart && item.isinformation == 0">
<block v-if="item.is_virtual">
<view class="color-base-bg select-sku" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.buyNow') }}</view>
</block>
@@ -530,13 +578,14 @@ export default {
mixins: [DiyMinx],
data() {
return {
type: 'goods',
level: 3,
categoryId: -1,
loading: false,
goodsList: [],
pageIndex: 0,
pageSize: 10,
// 已加载完成的分类ID, 用于避免重复拉取同一分类的商品
_loadedGoodsKey: null,
totalPage: 1,
touchstartPosition: 0,
scrollType: 'top',
@@ -559,6 +608,14 @@ export default {
};
},
computed: {
// 后台开关:加购按钮 = cartBtn; 未下发 cartBtn 时兼容旧字段 quickBuy
// 后台开启就显示, 关闭就不显示
showAddCart() {
const t = this.getDiyToggle(['showAddCart', 'show_add_cart', 'cartBtn', 'cart_btn']);
if (t !== undefined) return t;
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
return legacy !== undefined ? legacy : false;
},
/**
* 当前分类层级 (参考 template 2 的 goodsLevel 模式)
* 优先级: 数据层级 > 后台配置 > 兜底
@@ -590,11 +647,36 @@ export default {
* 三级分类 (value.level == 3) → true → 按二级分类分别显示各自商品
*/
isGroupedDisplay() {
// 仅模板1 走"按二级分类分组显示商品"; 模板5 的层级由 value.level 直接决定(见下方 value.template == 5 分支)
return this.value.template == 1 && this.value.level == 3;
},
/**
* 模板1三级分类: 只渲染"有商品"的二级分类
* 无商品的二级分类整块(标题 + 网格)都不渲染; 加载中的二级分类也暂不渲染(避免闪烁)
* 判定时机: 该二级分类加载完成(template1ChildLoading[id] === false)且商品列表非空
*/
filteredGroupedChildren() {
if (!this.isGroupedDisplay) return [];
if (!this.category || !this.category.child_list || !this.category.child_list.length) return [];
return this.category.child_list.filter(one => {
// 还在加载中: 暂不渲染(避免闪烁)
if (this.template1ChildLoading[one.category_id]) return false;
// 加载完成: 只有该二级分类下有商品时才显示, 无商品则整块隐藏
const list = this.template1ChildGoodsList[one.category_id];
return !!(list && list.length);
});
},
/**
* 渲染类型: 模板1/99 用分类树卡片(category), 其它模板用商品卡片(goods)
* 用计算属性而非 created() 里的一次性赋值:
* 页面下拉刷新/装修数据更新时 value.template 可能变化, 组件被 Vue 复用不会重建,
* 若 type 不跟随更新, 模板1 会误走 goods 分支, 导致"资讯类商品=否"的商品不显示立即咨询按钮
*/
type() {
return this.value.template == 1 || this.value.template == 5 || this.value.template == 99 ? 'category' : 'goods';
},
},
created() {
this.type = this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
if (this.index == this.select && this.pageIndex == 0) this.getGoodsList();
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
if (this.value.template == 99 && this.index == this.select && this.category.child_list && this.category.child_list.length > 0) {
@@ -613,11 +695,21 @@ export default {
// 模板1:根据后台"展示分类"配置 + 数据层级决定加载方式
// 三级分类模式 (isGroupedDisplay) + 有二级分类时,按二级分类分别加载各自商品
// 其他情况(二级分类模式 / 无二级分类),直接加载当前一级分类下的所有商品
if (this.value.template == 1 && this.category && this.category.category_id) {
// 模板5 渲染规则:
// 二级分类(value.level == 2) → 商品网格(图2), 需要加载该一级分类下所有商品(合并其二级分类)
// 三级分类(value.level == 3) → 二级分类网格(图3), 静态网格无需加载商品
// 若该一级分类下没有子分类(兜底), 仍需要加载商品
const template5NeedGoods = this.value.template == 5 &&
(this.value.level == 2 ||
!(this.category && this.category.child_list && this.category.child_list.length));
if ((this.value.template == 1 || template5NeedGoods) && this.category && this.category.category_id) {
if (this.isGroupedDisplay && this.category.child_list && this.category.child_list.length) {
this.category.child_list.forEach(child => {
this.getTemplate1ChildGoods(child.category_id);
});
} else if (this.category.child_list && this.category.child_list.length) {
// 二级分类模式: 合并所有二级分类的商品, 避免只查一级分类ID时漏掉子分类商品
this.loadTemplate1Level2Goods();
} else {
this.getTemplate1Goods(this.category.category_id);
}
@@ -685,6 +777,8 @@ export default {
}
// 模板1:切换一级分类时重新加载该分类下的商品
// 三级分类模式 + 有二级分类时,按二级分类分别重新加载,否则直接加载当前分类
// 注意: 模板5 是"全部分类纵向平铺", 商品由 created 首次加载 + 父级链式懒加载提供,
// 滚动时 select 会随 listenScroll 变化, 若此处也重载会导致滚动中反复清空/重复请求, 故排除 5
if (this.value.template == 1 && this.index == this.select && this.category && this.category.category_id) {
this.template1GoodsList = [];
this.template1ChildGoodsList = {};
@@ -693,6 +787,9 @@ export default {
this.category.child_list.forEach(child => {
this.getTemplate1ChildGoods(child.category_id);
});
} else if (this.category.child_list && this.category.child_list.length) {
// 二级分类模式: 切换一级分类时重新合并所有二级分类商品
this.loadTemplate1Level2Goods();
} else {
this.getTemplate1Goods(this.category.category_id);
}
@@ -723,78 +820,162 @@ export default {
},
methods: {
/**
* 查询商品列表
* 拉取某分类下的全部商品(按后台实际数量)
* 后端 pageByCategory 存在分页上限, 这里自动翻页直到最后一页再合并返回,
* 保证分类模板(1/2/3/4/99)显示该分类下的全部商品, 而不是只显示第一页
* @param {Number} categoryId 分类ID
* @param {String} order 排序字段
* @param {String} sort 排序方向
* @returns {Promise<Array>}
*/
getGoodsList() {
if (this.type != 'goods' || this.loading || this.pageIndex >= this.totalPage) return;
getCategoryGoodsAll(categoryId, order = '', sort = '') {
return new Promise(resolve => {
const MAX_PAGE = 200; // 安全上限, 防止后台 page 参数失效时死循环
const all = [];
let prevLen = 0;
const fetchPage = page => {
if (page > MAX_PAGE) {
resolve(all);
return;
}
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: categoryId,
page: page,
// 每次按较大 page_size 取, 减少请求次数; 实际每页条数由后台决定
page_size: 100,
order: order,
sort: sort
},
success: res => {
if (res.code == 0 && res.data && res.data.list && res.data.list.length) {
const list = res.data.list;
all.push(...list);
const pageCount = res.data.page_count;
// 后台返回了可靠的 page_count 时, 以它为准停止翻页
if (typeof pageCount === 'number' && pageCount >= 1 && page >= pageCount) {
resolve(all);
return;
}
// 自适应翻页: 不信任 page_count 时也按"上一页是否满页"判断
// 当前页条数 >= 上一页条数 → 可能还有下一页, 继续拉取
// 当前页比上一页少 → 已是末页(不满), 停止
if (page === 1 || list.length >= prevLen) {
prevLen = list.length;
fetchPage(page + 1);
} else {
resolve(all);
}
} else {
// 当前页无数据, 全部商品已取完
resolve(all);
}
},
fail: () => resolve(all)
});
};
fetchPage(1);
});
},
/**
* 拉取某分类及其所有二级子分类下的全部商品(自动翻页 + 合并去重)
* 解决后端 pageByCategory 只返回"直接"挂在当前分类的商品, 漏掉子分类商品的问题,
* 保证一级分类下的所有二级分类商品都能全部显示
* @param {Object} category 分类对象(含 child_list)
* @param {Number} categoryId 当前分类ID
* @param {String} order 排序字段
* @param {String} sort 排序方向
* @returns {Promise<Array>}
*/
getCategoryGoodsWithChildrenAll(category, categoryId, order = '', sort = '') {
const ids = [categoryId];
if (category && category.child_list && category.child_list.length) {
category.child_list.forEach(child => {
if (child.category_id) ids.push(child.category_id);
});
}
const promises = ids.map(id => this.getCategoryGoodsAll(id, order, sort));
return Promise.all(promises).then(lists => {
const all = [];
const seen = new Set();
lists.forEach(list => {
list.forEach(item => {
const key = item.sku_id || item.goods_id;
if (key && !seen.has(key)) {
seen.add(key);
all.push(item);
}
});
});
return all;
});
},
/**
* 查询商品列表(模板 2/3/4)
* 一次性拉取该分类下的全部商品, 不再依赖滚动分页
*/
getGoodsList() {
if (this.type != 'goods' || this.loading) return;
// 已加载过该分类时, 避免 scrolltolower 重复拉取
const categoryId = this.categoryId != -1 ? (this.value.template == 4 && this.currentOneCategory && this.currentOneCategory.child_list ? this.currentOneCategory.child_list[this.categoryId].category_id : this.category.child_list[this.categoryId].category_id)
: (this.value.template == 4 && this.currentOneCategory ? this.currentOneCategory.category_id : this.category.category_id);
if (this._loadedGoodsKey === categoryId) return;
this.loading = true;
this.pageIndex++;
if (this.$refs.loadingCover && this.pageIndex == 1 && this.loadType == 'part') {
setTimeout(() => {
this.$refs.loadingCover.show();
});
}
this.goodsList = [];
let order = '';
let sort = '';
if (this.value.sortWay) {
if (this.value.sortWay == 'default') {
// 综合排序
order = '';
sort = '';
} else if (this.value.sortWay == 'sales') {
// 销量排序
order = 'sale_num';
sort = 'desc';
} else if (this.value.sortWay == 'price') {
// 价格排序
order = 'discount_price';
sort = 'desc';
} else if (this.value.sortWay == 'news') {
// 上架时间排序
order = 'create_time';
sort = 'desc';
}
}
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
page: this.pageIndex,
page_size: this.pageSize,
category_id: this.categoryId != -1 ? (this.value.template == 4 && this.currentOneCategory && this.currentOneCategory.child_list ? this.currentOneCategory.child_list[this.categoryId].category_id : this.category.child_list[this.categoryId].category_id)
: (this.value.template == 4 && this.currentOneCategory ? this.currentOneCategory.category_id : this.category.category_id),
order: order,
sort: sort
},
success: res => {
if (res.code == 0 && res.data) {
if (this.pageIndex == 1) this.goodsList = [];
//切换英文标题
if (uni.getStorageSync("lang") == 'en-us') {
// res.data.list.map(item => {
// item.goods_name = item.en_goods_name;
// return item;
// });
}
this.goodsList = this.goodsList.concat(res.data.list);
this.loadGoodsCartNum();
this.totalPage = res.data.page_count;
this.loading = false;
if (this.$refs.loadingCover && this.loadType == 'part') this.$refs.loadingCover.hide();
setTimeout(() => {
if (this.loadType == 'all') this.$emit('loadfinish', this.pageIndex == 1 ? this.index : -1);
const query = uni.createSelectorQuery().in(this);
query
.select('.goods-list')
.boundingClientRect(data => {
if (data && data.height < this.contentWrapHeight) this
.scrollType = 'none';
})
.exec();
}, 500);
}
}
if (this.$refs.loadingCover && this.loadType == 'part') {
setTimeout(() => {
this.$refs.loadingCover.show();
});
}
// 模板2/3/4: 未选中具体二级分类时, 后端 pageByCategory 只返回直接挂在一级分类的商品,
// 需合并其下所有二级子分类商品, 才能显示全; 选中具体二级分类时, 只查该分类
let goodsPromise;
if (this.value.template == 4 && this.categoryId == -1 && this.currentOneCategory && this.currentOneCategory.child_list && this.currentOneCategory.child_list.length) {
// 模板4: 当前一级分类在 currentOneCategory, 合并其下所有二级分类商品
goodsPromise = this.getCategoryGoodsWithChildrenAll(this.currentOneCategory, categoryId, order, sort);
} else if ((this.value.template == 2 || this.value.template == 3) && this.categoryId == -1 && this.category && this.category.child_list && this.category.child_list.length) {
goodsPromise = this.getCategoryGoodsWithChildrenAll(this.category, categoryId, order, sort);
} else {
goodsPromise = this.getCategoryGoodsAll(categoryId, order, sort);
}
goodsPromise.then(list => {
this.goodsList = list;
this._loadedGoodsKey = categoryId;
this.loadGoodsCartNum();
this.loading = false;
if (this.$refs.loadingCover && this.loadType == 'part') this.$refs.loadingCover.hide();
setTimeout(() => {
if (this.loadType == 'all') this.$emit('loadfinish', this.index);
const query = uni.createSelectorQuery().in(this);
query
.select('.goods-list')
.boundingClientRect(data => {
if (data && data.height < this.contentWrapHeight) this
.scrollType = 'none';
})
.exec();
}, 500);
});
},
/**
@@ -931,6 +1112,10 @@ export default {
this.$forceUpdate();
// 立即同步购物车数量/金额到 store: 底部结算栏按 cartNumber 显隐,
// 若只等 800ms 防抖 + 接口回调才计算, 会出现"点了加购但结算栏没弹出"
this.$store.dispatch('cartCalculate');
this.timeout[data.sku_id] = setTimeout(() => {
this.cartEditRepeat = true;
this.$api.sendRequest({
@@ -979,6 +1164,9 @@ export default {
}
this.$forceUpdate();
// 同上: 减购后立即重算, 减到 0 时结算栏同步消失
this.$store.dispatch('cartCalculate');
// 防止点击太快
if (cart && cart.cart_id == 0) {
return;
@@ -1159,23 +1347,12 @@ export default {
sort = this.value.sortType == 'desc' ? 'desc' : 'asc';
}
}
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: categoryId,
page: 1,
page_size: this.pageSize,
order: order,
sort: sort
},
success: res => {
this.loading = false;
if (res.code == 0 && res.data && res.data.list) {
this.secondCategoryGoodsList = res.data.list;
} else {
this.secondCategoryGoodsList = [];
}
}
this.getCategoryGoodsAll(categoryId, order, sort).then(list => {
this.loading = false;
this.secondCategoryGoodsList = list;
}).catch(() => {
this.loading = false;
this.secondCategoryGoodsList = [];
});
},
/**
@@ -1195,25 +1372,38 @@ export default {
*/
getTemplate1Goods(categoryId) {
this.template1Loading = true;
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: categoryId,
page: 1,
page_size: 10
},
success: res => {
this.template1Loading = false;
if (res.code == 0 && res.data && res.data.list) {
this.template1GoodsList = res.data.list;
} else {
this.template1GoodsList = [];
}
},
fail: () => {
this.template1Loading = false;
this.template1GoodsList = [];
}
this.getCategoryGoodsAll(categoryId).then(list => {
this.template1GoodsList = list;
this.template1Loading = false;
}).catch(() => {
this.template1GoodsList = [];
this.template1Loading = false;
});
},
/**
* 模板1二级分类模式: 加载当前一级分类下所有二级分类的商品并合并
* 避免只按一级分类ID查询时, 后端不返回子分类商品, 导致商品显示不全
*/
loadTemplate1Level2Goods() {
this.template1Loading = true;
const promises = this.category.child_list.map(child => this.getCategoryGoodsAll(child.category_id));
Promise.all(promises).then(lists => {
const all = [];
const seen = new Set();
lists.forEach(list => {
list.forEach(item => {
const key = item.sku_id || item.goods_id;
if (key && !seen.has(key)) {
seen.add(key);
all.push(item);
}
});
});
this.template1GoodsList = all;
this.template1Loading = false;
}).catch(() => {
this.template1GoodsList = [];
this.template1Loading = false;
});
},
/**
@@ -1223,25 +1413,12 @@ export default {
*/
getTemplate1ChildGoods(categoryId) {
this.$set(this.template1ChildLoading, categoryId, true);
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: categoryId,
page: 1,
page_size: 10
},
success: res => {
this.$set(this.template1ChildLoading, categoryId, false);
if (res.code == 0 && res.data && res.data.list) {
this.$set(this.template1ChildGoodsList, categoryId, res.data.list);
} else {
this.$set(this.template1ChildGoodsList, categoryId, []);
}
},
fail: () => {
this.$set(this.template1ChildLoading, categoryId, false);
this.$set(this.template1ChildGoodsList, categoryId, []);
}
this.getCategoryGoodsAll(categoryId).then(list => {
this.$set(this.template1ChildLoading, categoryId, false);
this.$set(this.template1ChildGoodsList, categoryId, list);
}).catch(() => {
this.$set(this.template1ChildLoading, categoryId, false);
this.$set(this.template1ChildGoodsList, categoryId, []);
});
},
/**
@@ -1326,6 +1503,9 @@ export default {
font-size: 30rpx;
font-weight: 500;
letter-spacing: 2rpx;
// 删除标题行整块的白色块, 背景设为透明, 让上层网格的 #f5f5f5 透出,
// 标题行与下方 .template1-goods-wrap 灰色融为一体, 只保留居中的文字与两侧短横线
background: transparent !important;
&::before,
&::after {
@@ -1349,42 +1529,42 @@ export default {
}
}
// 分类网格(模板5 / legacy 商品分类): 白底卡片 + 3 列 图片 + 名称
// 与 Branch_5cfee417 的商品分类保持一致
.category-list {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
background: transparent;
display: flex;
flex-wrap: wrap;
background: #fff;
margin: 0 20rpx;
border-radius: 0;
padding: 0;
gap: 20rpx 0;
border-radius: 10rpx;
padding: 10rpx 0;
.category-item {
width: 100% !important;
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
padding: 16rpx;
box-sizing: border-box;
.img-box {
width: 70% !important;
width: 80%;
height: 0;
padding-bottom: 70%;
padding-top: 80%;
margin: 0 auto;
overflow: hidden;
margin-bottom: 16rpx;
margin-bottom: 20rpx;
position: relative;
background: transparent !important;
border-radius: 0 !important;
background: transparent;
image {
position: absolute;
width: 100% !important;
height: 100% !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
width: 100%;
height: 100%;
left: 0;
top: 0;
transform: none;
}
}
@@ -1399,7 +1579,6 @@ export default {
font-size: var(--diy-category-item-name-size, 24rpx);
}
// 模板1选中状态
&.selected {
.img-box {
border: 2rpx solid var(--base-color, #FF6B35);
@@ -1430,6 +1609,18 @@ export default {
overflow: hidden;
margin-bottom: 20rpx;
// 划线价放在现价正下方(左边与现价对齐, 间距 6rpx)
.price-wrap {
.delete-price {
margin-left: 0;
margin-top: 6rpx;
width: 100%;
display: inline-flex;
align-items: baseline;
text-decoration: line-through;
}
}
.footer-wrap-template1 {
padding: 16rpx 4rpx 0rpx;
display: flex;
@@ -1651,8 +1842,8 @@ export default {
}
}
/* 单列布局 */
&.single-column {
/* 单列布局(不包含模板2/4,模板2/4使用模板4卡片样式) */
&.single-column:not([data-template='2']):not([data-template='4']) {
flex-direction: column;
.goods-item {
@@ -1975,13 +2166,13 @@ export default {
margin-top: 12rpx;
}
// 价格区域: 包含当前价格 + 删除线价格(同行)
// 价格区域: 现价在上, 划线价在下方
.price-wrap {
display: flex;
align-items: baseline;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
flex-wrap: wrap;
.discount-price {
display: inline-flex;
@@ -2003,9 +2194,10 @@ export default {
}
}
// 删除线价格: 在当前价格右边
// 删除线价格: 在现价下方
.delete-price {
margin-left: 16rpx;
margin-left: 0;
margin-top: 6rpx;
display: inline-flex;
align-items: baseline;
text-decoration: line-through;
@@ -2033,7 +2225,10 @@ export default {
}
}
// ===================== 模板4 商品分类样式(单列卡片式: 左图右文 + 右侧+按钮) =====================
// ===================== 模板2/4 商品分类样式(支持右上角视图切换) =====================
// 单列视图: 左图右文横向卡片 + 右侧+按钮
// 双列视图: 上下结构宫格卡片, 每行两个
&[data-template='2'],
&[data-template='4'] {
// 整体灰色背景让卡片有层次
background: #f4f6fa;
@@ -2042,19 +2237,64 @@ export default {
.goods-item {
display: flex;
flex-direction: row;
align-items: stretch;
width: 100% !important;
max-width: 100%;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
margin: 0 0 20rpx;
padding: 20rpx;
box-sizing: border-box;
position: relative;
}
// 单列视图: 整行横向卡片
&.single-column .goods-item {
flex-direction: row;
width: 100% !important;
padding: 20rpx;
}
// 双列视图: 每行两个纵向卡片
&.double-column .goods-item {
flex-direction: column;
width: calc(50% - 10rpx) !important;
padding: 0;
.goods-img {
width: 100%;
height: 0;
padding-top: 100%;
margin-right: 0;
border-radius: 12rpx 12rpx 0 0;
image {
border-radius: 12rpx 12rpx 0 0;
}
}
.info-wrap {
padding: 16rpx 16rpx 20rpx;
}
// 竖卡片下价格不再给右侧按钮留位
.price-wrap {
margin-top: auto;
padding-right: 0;
}
// 加购/选规格按钮从绝对定位改为跟在价格下方右侧, 避免遮挡价格
.footer-wrap {
position: static;
right: auto;
bottom: auto;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 12rpx;
padding: 0 16rpx;
}
}
// 商品图片: 左侧正方形
.goods-img {
width: 200rpx;
@@ -2263,6 +2503,16 @@ export default {
}
}
}
// 模板2/4: 划线价放在现价正下方(price-wrap 为 flex wrap, 划线价占满一行即换到现价下一行, 左边与现价对齐)
&[data-template='2'],
&[data-template='4'] {
.price-wrap .delete-price {
margin-left: 0;
margin-top: 6rpx;
width: 100%;
}
}
}
.categoty-goods-wrap {
@@ -2279,18 +2529,28 @@ export default {
.screen-category-wrap {
display: flex;
align-items: center;
position: relative;
.icon-unfold {
font-size: 24rpx;
position: absolute;
right: 15rpx;
top: 0;
z-index: 2;
width: 70rpx;
height: 70rpx;
line-height: 62rpx;
text-align: center;
font-size: 36rpx;
color: #999;
padding: 0 0 0 20rpx;
padding: 0;
}
}
.screen-category {
flex: 1;
width: 0;
padding: 0 0 20rpx 0;
padding: 0 70rpx 20rpx 0;
white-space: nowrap;
height: 60rpx;
@@ -2415,10 +2675,7 @@ export default {
// ===================== 模板99 商品分类样式 =====================
.second-category-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
white-space: nowrap;
padding: 20rpx 20rpx 20rpx 24rpx;
height: 100rpx;
background: #fff;
@@ -2426,6 +2683,7 @@ export default {
box-sizing: border-box;
.second-category-btn {
display: inline-block;
padding: 10rpx 24rpx;
background: #f5f5f5;
color: #666;
@@ -2436,6 +2694,7 @@ export default {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
&.selected {
background: #FF4646;
@@ -2524,6 +2783,8 @@ export default {
display: flex;
align-items: baseline;
justify-content: space-between;
// 划线价占满一行, 换行到现价正下方
flex-wrap: wrap;
.discount-price {
color: #FF6B35;
@@ -2534,6 +2795,15 @@ export default {
font-size: 22rpx;
}
}
.delete-price {
margin-left: 0;
margin-top: 6rpx;
width: 100%;
display: inline-flex;
align-items: baseline;
text-decoration: line-through;
}
}
.footer-wrap {

View File

@@ -1,6 +1,8 @@
<template>
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
:style="{ height: 'calc(100vh - ' + tabBarHeight + ')' }">
:style="{ height: 'calc(100vh - var(--window-top, 0px) - ' + tabBarHeight + ')' }">
<!-- 高度说明: 100vh - H5顶部导航栏(--window-top, 小程序为0) - 底部导航高度(tabBarHeight),
保证底部购物车结算栏始终落在可视区内, 点击加购后无需向下翻即可看到 -->
<!-- 模板4: 顶部搜索框(图片样式) -->
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
@tap.stop="onClickSearch()">
@@ -120,7 +122,7 @@
{ select: select == index },
{ 'border-bottom': value.template == 4 && select + 1 === index },
{ 'border-top': value.template == 4 && select - 1 === index },
{ 'first-category-item': index == 0 && value.template == 99 }
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) }
]" @tap.stop="switchOneCategory(index)">
<view class="">{{ item.category_name }}</view>
</view>
@@ -150,11 +152,44 @@
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
</view>
</scroll-view>
</scroll-view>
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
<!-- 模板5 + 二级分类(value.level==2): 单面板切换 - 右侧只显示"当前选中"的一级分类,
点左侧分类切换, 其它分类隐藏(display:none); 每个一级分类: 标题 + 商品网格 -->
<scroll-view scroll-y="true" class="content-wrap"
v-else-if="value.template == 5 && value.level == 2"
:refresher-enabled="true" refresher-default-style="none"
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
</view>
</scroll-view>
<!-- 模板5 + 三级分类(value.level!=2): 左侧分类树 + 右侧纵向平铺"全部一级分类",
每个一级分类: 标题 + 二级分类网格; 左侧点击分类用 scroll-into-view 定位,
滚动时由 listenScroll 反推并高亮左侧分类 -->
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 5"
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
:refresher-enabled="true" refresher-default-style="none"
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore"
@scroll="listenScroll" @touchstart="touchStart">
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
:id="'category-' + index">
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</view>
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
</scroll-view>
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
@@ -191,9 +226,16 @@
</view>
</view>
<!-- 模板4: 底部购物车栏 -->
<!-- 风格2/模板4: 底部购物车栏
1) 不用 position:fixed: 小程序/H5/鸿蒙下 fixed 受页面避让与父级 transform 影响,
会"跟在内容后面"只有滚到底才看得见。
.category-page-wrap 是固定高度(calc(100vh - tabBarHeight))的纵向 flex 容器,
content-box 为 flex:1 height:0, 把结算栏作为普通子项排在末尾,
无论右侧商品列表怎么滚动, 结算栏始终固定显示在底部
2) cartNumber > 0: 只在购物车里有商品时才出现(点加购即出现, 减到 0 即消失),
商品加购后会立即 dispatch cartCalculate 同步 cartNumber, 不依赖接口回调 -->
<view class="cart-box cart-box-template-4"
v-if="value.template == 4 && value.quickBuy && storeToken && categoryTree && categoryTree.length">
v-if="(value.template == 2 || value.template == 4) && showCartSettlement && storeToken && cartNumber > 0 && categoryTree && categoryTree.length">
<view class="left-wrap">
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
@tap.stop="$util.redirectTo('/pages_goods/cart')">
@@ -272,8 +314,8 @@ export default {
created() {
this.getCategoryTree();
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
// 模板4: 默认单列列表布局(图片样式)
if (this.value.template == 4) {
// 模板2/4: 默认单列列表布局(左图右文卡片), 点右上角图标切换为双列宫格
if (this.value.template == 2 || this.value.template == 4) {
this.isList = true;
}
},
@@ -299,6 +341,7 @@ export default {
})
.exec();
if (this.value.template == 1) this.getHeightArea(-1);
if (this.value.template == 5) this.getHeightArea(-1);
}, 500);
},
watch: {
@@ -308,6 +351,9 @@ export default {
if (nval === false && oval === true) {
this.$nextTick(() => {
if (!this.heightArea.length) this.getHeightArea(-1);
// 分类渲染完成后重新对齐到当前选中的一级分类顶部,
// 避免首屏第一个分类的标题被顶出可视区
if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select);
});
}
}
@@ -317,6 +363,20 @@ export default {
let money = parseFloat(this.cartMoney).toFixed(2);
return money.split('.');
},
// 后台开关:快捷购买(显示购物车及结算按钮) = quickBuy; 加购按钮 = cartBtn
// 后台开启哪个就显示哪个, 关闭哪个就不显示哪个; 未下发 cartBtn 时兼容旧字段 quickBuy
showCartSettlement() {
const t = this.getDiyToggle(['showCartSettlement', 'show_cart_settlement']);
if (t !== undefined) return t;
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
return legacy !== undefined ? legacy : false;
},
showAddCart() {
const t = this.getDiyToggle(['showAddCart', 'show_add_cart', 'cartBtn', 'cart_btn']);
if (t !== undefined) return t;
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
return legacy !== undefined ? legacy : false;
},
// 转换字符数值为真正的数值
navbarHeight() {
// #ifdef APP-PLUS || H5
@@ -470,16 +530,16 @@ export default {
});
},
/**
* 全局查询所有有商品的二级分类, 按名称去重, 汇总成虚拟分类供 template99 渲染按钮
* 只要任何商品属于该二级分类, 按钮就显示; 切换一级分类时按钮不变(一直显示)
* 新增的二级分类只要有商品也会自动包含
* 汇总所有二级分类供 template99 渲染按钮(按名称去重)
* 注意: 保留"无商品"的二级分类也显示出来, 不再按是否有商品过滤,
* 避免一级分类下 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
*/
loadGlobalSecondCategoryBtns() {
if (!this.categoryTree || !this.categoryTree.length) {
this.template99Category = { category_name: '', child_list: [] };
return;
}
// 收集所有二级分类(保留原始顺序, 不去重, 查询所有同名后再去重)
// 收集所有二级分类(保留原始顺序, 不去重, 汇总后去重)
const allSecond = [];
this.categoryTree.forEach(one => {
if (one.child_list && one.child_list.length) {
@@ -492,45 +552,19 @@ export default {
this.template99Category = { category_name: '', child_list: [] };
return;
}
let pending = allSecond.length;
const results = [];
const finishOne = () => {
pending--;
if (pending <= 0) {
results.sort((a, b) => a._origIndex - b._origIndex);
// 按名称去重, 同名只保留第一个有商品的(只要任一同名有商品就显示)
const deduped = [];
const seenNames = {};
results.forEach(item => {
if (!seenNames[item.category_name]) {
seenNames[item.category_name] = true;
deduped.push(item);
}
});
this.template99Category = {
category_name: '',
child_list: deduped
};
// 按名称去重, 同名只保留第一个, 保持原始顺序
const deduped = [];
const seenNames = {};
allSecond.forEach(item => {
if (!seenNames[item.category_name]) {
seenNames[item.category_name] = true;
deduped.push(item);
}
};
allSecond.forEach(two => {
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: two.category_id,
page: 1,
page_size: 1
},
success: res => {
if (res && res.code == 0 && res.data && res.data.list && res.data.list.length > 0) {
results.push(two);
}
},
complete: () => {
finishOne();
}
});
});
this.template99Category = {
category_name: '',
child_list: deduped
};
},
/**
* 切换一级分类
@@ -539,8 +573,30 @@ export default {
switchOneCategory(index) {
if (index >= this.categoryTree.length) return;
this.select = index;
this.categoryId = 'category-' + index;
this.scrollLock = true;
this.alignToCategory(index);
},
/**
* 把右侧滚动区定位到指定一级分类的顶部
*
* uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动,
* 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写;
* 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0)
* 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区
* (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。
* 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。
* @param {Number} index 一级分类下标
*/
alignToCategory(index) {
const target = 'category-' + index;
if (this.categoryId === target) {
this.categoryId = '';
this.$nextTick(() => {
this.categoryId = target;
});
} else {
this.categoryId = target;
}
},
saveSecondCategoryName(name) {
this.lastSecondCategoryName = name;
@@ -562,7 +618,10 @@ export default {
break;
}
}
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
// 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载,
// 并补上 heightArea[this.select] 越界保护
if (this.value.template != 1 && this.value.loadType == 'all' &&
this.heightArea[this.select] && this.heightArea[this.select][1] -
scrollTop - contentWrapHeight < 300) {
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
}
@@ -652,7 +711,8 @@ export default {
* @param {Object} top
*/
addCartPoint(left, top) {
if (this.value.template != 2 && !this.value.quickBuy) return;
if (this.value.template != 2 && this.value.template != 4) return;
if (!this.showCartSettlement) return;
let key = new Date().getTime();
this.$set(this.carIconList, key, {
@@ -1107,7 +1167,8 @@ export default {
// 模板4 底部购物车栏(图片样式)
.cart-box-template-4 {
height: 110rpx;
padding: 0 30rpx;
width: 100%;
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #f0f0f0;
position: relative;
@@ -1211,11 +1272,6 @@ export default {
}
}
.cart-box {
position: relative;
z-index: 2;
}
::v-deep .template-four {
position: relative;
z-index: 1;
@@ -1422,6 +1478,111 @@ export default {
}
}
// 风格2 复用模板4 的底部购物车栏样式
.category-template-2 {
// 模板4 底部购物车栏(图片样式)
.cart-box-template-4 {
height: 110rpx;
width: 100%;
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #f0f0f0;
position: relative;
z-index: 10;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
.left-wrap {
display: flex;
align-items: center;
}
.cart-icon {
width: 90rpx;
height: 90rpx;
position: relative;
margin-top: -10rpx;
.iconfont {
color: var(--btn-text-color, #fff);
width: 80rpx;
height: 80rpx;
background-color: $base-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
}
.num {
position: absolute;
top: 0;
right: 0;
transform: translate(20%, 0);
display: inline-block;
box-sizing: border-box;
color: #fff;
line-height: 1.2;
text-align: center;
font-size: 24rpx;
padding: 0 8rpx;
min-width: 32rpx;
border-radius: 18rpx;
background-color: var(--price-color, #FF6B35);
border: 2rpx solid #fff;
}
}
.price {
margin-left: 16rpx;
display: flex;
align-items: baseline;
line-height: 1;
.title {
color: #333;
font-size: 28rpx;
}
.unit,
.money {
color: var(--price-color, #FF6B35);
font-weight: bold;
}
.unit {
font-size: 24rpx;
}
.money {
font-size: 36rpx;
}
}
.right-wrap {
display: flex;
align-items: center;
}
.settlement-btn {
margin: 0;
width: 220rpx;
font-weight: bold;
border-radius: 50rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
background: $base-color;
color: var(--btn-text-color, #fff);
&::after {
border: none;
}
}
}
}
.content-wrap .categoty-goods-wrap .goods-list {
margin-top: 30rpx;
}
@@ -1459,7 +1620,10 @@ export default {
}
.content-box {
background: #fff;
// 模板1 + 三级分类: 标题行 (离型纸 ----) 所在整片右半边需要去掉白色,
// 把 .content-box 也改为透明, 让右侧不再被强制白; 再让 .right-flex-wrap 显式
// 染 #f5f5f5, 与左侧 tree-wrap 默认色一致, 标题行只显示文字+短横线.
background: transparent;
.tree-wrap {
width: 170rpx;
@@ -1467,10 +1631,10 @@ export default {
}
.right-flex-wrap {
background: #fff;
background: #f5f5f5;
.content-wrap {
background: #fff;
background: transparent;
}
}
}
@@ -1488,10 +1652,31 @@ export default {
padding: 0;
}
// 穿透到子组件 diy-category-item: 模板1标题行定高, 与左侧第一个一级分类等高对齐
::v-deep .category-title-with-lines {
height: 100rpx;
box-sizing: border-box;
padding: 30rpx 0 20rpx;
}
// 模板1: 左侧第一个一级分类定高, 与右侧标题行(如"离型纸 ----")垂直对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
overflow: hidden;
view {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
::v-deep .img-box {
background: transparent !important;
@@ -1551,6 +1736,134 @@ export default {
}
}
// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) =====================
.category-template-5 {
.search-box {
padding: 20rpx 24rpx;
background: #fff;
.search-content {
background-color: #f5f5f5;
border-radius: 40rpx;
input {
background: #f5f5f5;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 26rpx;
color: #333;
}
.iconfont {
right: 16rpx;
color: #89899a;
font-size: 32rpx;
}
}
}
.content-box {
// 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致
background: transparent;
.tree-wrap {
width: 170rpx;
background-color: #fff;
}
.right-flex-wrap {
background: #f5f5f5;
.content-wrap {
background: transparent;
}
// 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致)
.child-category {
width: 100%;
box-sizing: border-box;
}
}
}
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
::v-deep .category-adv {
padding: 0 20rpx;
margin-top: 0;
}
// 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明)
// 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐
::v-deep .category-title-with-lines {
height: 100rpx;
box-sizing: border-box;
padding: 30rpx 0 20rpx;
}
// 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
overflow: hidden;
view {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称),
// 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
&.border-top,
&.border-bottom {
border-radius: 0;
}
&.select {
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
&::before {
content: ' ';
width: 6rpx;
height: 32rpx;
background: var(--base-color, #FF6B35);
display: block;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 0 6rpx 6rpx 0;
}
}
}
}
// ===================== 模板99 商品分类样式 =====================
.category-template-99 {
.search-box {

View File

@@ -1,36 +1,25 @@
<template>
<template>
<view :style="componentStyle" data-component-name="diy-digit">
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
:scroll-x="value.showStyle == 'singleSlide'">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index"
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
:style="{ width: (100 / value.rowCount + '%') + ';' }">
<view style="display:flex;">
<view :style="{
'line-height': '1.2;',
'font-size': (value.font.titlesize * 2 + 'rpx') + ';',
'font-weight': '600;',
'color': value.font.titlecolor + ';'
}">
:style="itemStyle">
<view :style="innerWrapStyle">
<view :style="innerRowStyle">
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
:decimals="getvalue(item.title)" decimal="."></uv-count-to>
<text :style="{
'margin-left': '4rpx;',
'font-size': (value.font.unitsize * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.unitcolor + ';'
}">{{ item.unit }}</text>
:decimals="getvalue(item.title)" decimal="."
:color="value.font.titlecolor"
:fontSize="titleFontSize"
:bold="isBold(value.font.weight)"></uv-count-to>
<text :style="unitStyle">{{ item.unit }}</text>
</view>
</view>
<view class="graphic-text">
<text :style="{
'font-size': (value.font.descsize * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.desccolor + ';'
}">{{ item.desc }}</text>
<view class="graphic-text">
<text :style="descStyle">{{ item.desc }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<ns-login ref="login"></ns-login>
@@ -54,10 +43,20 @@ export default {
pageWidth: '',
indicatorDots: false,
swiperCurrent: 0
// #ifndef MP
,windowWidth: 375
// #endif
}
},
created() {
// 组件创建时的逻辑
// #ifndef MP
try {
let info = uni.getSystemInfoSync();
this.windowWidth = info.windowWidth || info.screenWidth || 375;
if (this.windowWidth > 960) this.windowWidth = 375;
} catch (e) { }
// #endif
},
watch: {
componentRefresh(newValue) {
@@ -65,6 +64,42 @@ export default {
}
},
computed: {
// 全部返回字符串:WeChat 小程序的 style="{{obj}}" 对象绑定不会被运行时正确解析为内联 CSS
//(参考 diy-graphic-nav.vue:那里同样用字符串拼接 style="{{'width:...%'}}",能正常生效)。
// 字符串既能被 MP 直接当 cssText,也能被 H5 的 CSSOM 正确解析。
itemStyle() {
return 'width:' + (100 / this.value.rowCount) + '%;';
},
innerWrapStyle() {
return 'display:flex;justify-content:center;align-items:center;width:100%;';
},
innerRowStyle() {
return 'display:flex;align-items:baseline;justify-content:center;width:100%;line-height:1.2;';
},
unitStyle() {
// #ifdef MP
return 'margin-left:4rpx;font-size:' + (this.value.font.unitsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
// #endif
// #ifndef MP
return 'margin-left:' + this.rpxToPx(4) + 'px;font-size:' + this.rpxToPx((Number(this.value.font.unitsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.unitcolor + ';';
// #endif
},
descStyle() {
// #ifdef MP
return 'font-size:' + (this.value.font.descsize * 2) + 'rpx;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
// #endif
// #ifndef MP
return 'font-size:' + this.rpxToPx((Number(this.value.font.descsize) || 12) * 2) + 'px;font-weight:' + this.value.font.weight + ';color:' + this.value.font.desccolor + ';';
// #endif
},
titleFontSize() {
// #ifdef MP
return this.value.font.titlesize || 22;
// #endif
// #ifndef MP
return this.rpxToPx((Number(this.value.font.titlesize) || 22) * 2);
// #endif
},
componentStyle() {
let style = '';
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
@@ -83,11 +118,25 @@ export default {
}
},
methods: {
// #ifndef MP
// rpx -> px:H5 行内样式里的 rpx 不生效,统一换算成 px
rpxToPx(rpx) {
let num = Number(rpx) || 0;
if (!num) return 0;
return Math.round((num * (this.windowWidth || 375) / 750) * 100) / 100;
},
// #endif
// 获取小数位数
getvalue(value) {
return value % 1 !== 0 ? 2 : 0;
},
// 是否加粗
isBold(weight) {
return weight === 'bold' || (weight && Number(weight) >= 600);
},
// 页面跳转
redirectTo(item) {
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -257,4 +306,5 @@ export default {
}
}
}
</style>

View File

@@ -6,24 +6,63 @@
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle">
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" />
</view>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'horizontal'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
<view v-if="value && value.show_banner && value.form_banner" class="form-banner is-card-overlap" :style="bannerStyle + bannerBleedStyle">
<!-- Banner 图片高度策略(与商家端预览 .preview-banner height:auto 对齐):
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
显式配置 bannerHeight/banner_height 或 bannerCropRatio → 切 aspectFill 居中裁剪;
显式配置 bannerCropBox → 切 widthFix + image 内联 transform translate/缩放(手动裁栏框);
裁剪位置(bannerCropPosition):uni-app image 不支持 object-position,
通过 .banner-img-crop 容器的 transform translateY 控制(top/居中/底部)。 -->
<view class="banner-img-wrap" :class="bannerImgWrapClass" :style="bannerImgWrapStyle">
<view class="banner-img-crop" :class="'crop-' + bannerCropPosition">
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" :style="bannerImgStyle" />
</view>
</view>
</view>
<!-- 卡片浮起模式:tip / 提交按钮位置可配(仅 card 风格生效)
- tip_position: outside(默认)= 独立白卡负 margin 压住 banner 底部;inside = 内容卡内
- submit_position: inside(默认)= 提交按钮在内容卡内;outside = 卡片下方独立区域 -->
<template v-if="hasBannerMode">
<!-- tip 独立卡(outside,默认):负 margin 压 banner + 白底 + 圆角 + 阴影,视觉参照商家端预览 .preview-tip -->
<view v-if="value && value.show_tip && value.form_tip && tipPosition === 'outside'" class="form-tip-card" :style="tipCardStyle">{{ value.form_tip }}</view>
<!-- 内容卡:tipPosition !== 'outside'(tip 关闭或 tip 在卡内)时由本卡接管负 margin 浮起 -->
<view class="form-card" :style="cardWrapStyle">
<!-- tip 卡内(inside):无独立卡视觉,用户配置的 bg/border/shadow 仍生效 -->
<view v-if="value && value.show_tip && value.form_tip && tipPosition !== 'outside'" class="form-tip" :style="tipStyle">{{ value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<!-- 提交按钮(inside,默认):在内容卡内 -->
<view v-if="showSubmit && submitPosition !== 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</view>
<!-- 提交按钮独立区(outside):卡片下方,无卡背景 -->
<view v-if="showSubmit && submitPosition === 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</template>
<!-- 无 banner 时仍按旧布局直接渲染 tip / ns-newform / submit,避免影响其他使用方(diy-group / diy-article 等) -->
<template v-else>
<!-- 提示语 -->
<view v-if="value && value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value && value.form_tip }}</view>
<!-- 表单组件 -->
<ns-newform
:data="formComponents"
:customAttr="value"
:mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig"
ref="nsNewform"
></ns-newform>
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</template>
</view>
</template>
@@ -37,6 +76,9 @@
import DiyMinx from './minx.js'
import NsNewform from '@/components/ns-newform/ns-newform.vue'
// 横幅裁剪比例白名单(与后端 form.js syncFormConfigToCSS 保持一致)
const BANNER_CROP_RATIOS = { '16:9': 16/9, '4:3': 4/3, '1:1': 1, '3:4': 3/4 }
export default {
name: 'diy-from',
components: { NsNewform },
@@ -58,6 +100,14 @@ export default {
type: Boolean,
default: true
},
// 父级容器左右内边距(rpx)。卡片模式下让 banner 图片突破这个内边距左右铺满(如 system-form-wrap 的 padding-x)。
// 仅在 hasBannerMode=true 时生效;默认 0(不铺满)。
// 引入原因:formdata.vue 的 .system-form-wrap 默认 padding: 0 50rpx,banner 图片被左右各 50rpx 收缩,
// 后台配 form_banner 后图片应跨越屏幕宽度,而不是被容器内缩。
containerInset: {
type: Number,
default: 0
},
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
onSubmit: {
@@ -108,24 +158,292 @@ export default {
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
});
},
// 提示语样式:padding-x、font-size、color、bg-color、border-bottom(与后端 .form-preview-tip 对齐)
// Banner 图片高度模式(2026-09-03 对齐商家端预览):
// 商家端预览 .preview-banner 是 max-width:100%; height:auto —— 按图片原始比例完整显示。
// 此前 H5 固定 180rpx + aspectFill 居中裁剪,导致横幅高度不一/图片内容被裁缺失。
// 现在:无显式配置 → widthFix 自适应(与后端 height:auto 等价);
// 显式配置 bannerHeight / banner_height → 固定高度 + aspectFill 裁剪(用户主动选择);
// 显式配置 bannerCropRatio → 按比例锁高度,比例+高度都设时高度优先(保持数字 rpx 的语义);
// 显式配置 bannerCropBox → 手动裁栏框(widthFix + image transform,最高优先级,独享遮罩)。
hasBannerHeight() {
const cfg = this.mergedFormConfig || {};
const raw = cfg.bannerHeight !== undefined ? cfg.bannerHeight : cfg.banner_height;
const n = parseInt(raw);
return !isNaN(n) && n > 0;
},
bannerImgMode() {
// 手动裁栏框优先(widthFix + transform);
// 显式选择比例(16:9/4:3/...)时用 aspectFill 填满比例容器;
// 仅配置高度 + 原比例时与商家端预览一致:widthFix 自适应,容器限制最大高度,图片完整显示不裁剪。
if (this.hasManualCrop) return 'widthFix';
return this.bannerCropRatio ? 'aspectFill' : 'widthFix';
},
// Banner 图片包裹层高度:
// - 比例模式 / 手动框:输出固定 height,配合 aspectFill 裁剪;
// - 仅高度 + 原比例:输出 max-height,让图片按原始比例完整显示,高度不超过设定值。
bannerImgWrapStyle() {
if (!this.hasBannerHeight) return '';
let h = parseInt(this.mergedFormConfig.bannerHeight !== undefined ? this.mergedFormConfig.bannerHeight : this.mergedFormConfig.banner_height);
if (h < 40) h = 40;
if (h > 1200) h = 1200;
if (this.bannerCropRatio || this.hasManualCrop) {
return this.$util.objToStyle({ height: h + 'rpx' });
}
return this.$util.objToStyle({ 'max-height': h + 'rpx' });
},
// 裁剪比例:读双键(camel + snake);合法值 16:9/4:3/1:1/3:4;空 = 不启用
bannerCropRatio() {
const cfg = this.mergedFormConfig || {};
const raw = cfg.bannerCropRatio !== undefined ? cfg.bannerCropRatio : cfg.banner_crop_ratio;
return (raw && BANNER_CROP_RATIOS[raw]) ? raw : '';
},
// 裁剪位置:top/center/bottom(默认 center);通过 CSS object-position 控制(uni-app H5/MP 容器配合 transform 实现)
bannerCropPosition() {
const cfg = this.mergedFormConfig || {};
const raw = cfg.bannerCropPosition !== undefined ? cfg.bannerCropPosition : cfg.banner_crop_position;
return (raw === 'top' || raw === 'bottom') ? raw : 'center';
},
// 手动裁栏框:读双键;返回 {x,y,w,h} 0..1 比例或 null
// 三优先级:手动框 > 比例/位置 > 高度/位置(与后端 syncFormConfigToCSS 对齐)
bannerCropBox() {
const cfg = this.mergedFormConfig || {};
const raw = cfg.bannerCropBox !== undefined ? cfg.bannerCropBox : cfg.banner_crop_box;
if (!raw) return null;
try {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (obj && typeof obj.x === 'number' && typeof obj.y === 'number' && typeof obj.w === 'number' && typeof obj.h === 'number' && obj.w > 0 && obj.h > 0) {
return obj;
}
} catch (e) { return null; }
return null;
},
// 手动裁栏框激活时:渲染模式切 widthFix(保留全图)+ 内层 img 用 transform 移动/缩放显示裁栏区域
hasManualCrop() { return !!this.bannerCropBox; },
// 手动裁栏框下 image 内联样式:放大到原图尺寸,按 box 坐标 translate 显示
// 用 rpx 转换(MP 原生 rpx / H5 换算 px)
bannerImgStyle() {
if (!this.hasManualCrop) return '';
const b = this.bannerCropBox;
const scaleX = 1 / b.w;
const scaleY = 1 / b.h;
const tx = -b.x * 100;
const ty = -b.y * 100;
return this.$util.objToStyle({
position: 'absolute',
left: 0,
top: 0,
width: (scaleX * 100) + '%',
height: (scaleY * 100) + '%',
maxWidth: 'none',
transform: 'translate(' + tx + '%, ' + ty + '%)',
});
},
// 手动裁栏框下容器需要绝对定位 + 隐藏溢出
bannerImgWrapClass() {
const isAspectFill = this.bannerImgMode === 'aspectFill';
return {
'is-fixed-height': isAspectFill,
'has-crop': isAspectFill,
'has-manual-crop': this.hasManualCrop,
'has-height-limit': this.hasBannerHeight && !isAspectFill && !this.hasManualCrop
};
},
// 表单风格:优先读显式 form_style(classic / card),未设置时回退到隐式判定(看 show_banner && form_banner)
// - classic:经典风格,平铺无 banner 卡片
// - card :卡片浮起风格,banner + 卡片浮起 + 容器内缩
// 注意:必须读 this.formCfg(原始 formConfig 对象)而不是 mergedFormConfig(白名单组装对象,不含 form_style 键)
// 双键兼容:formdata 链路 camel(formStyle,api splitFormConfig 转换);DIY 链路 snake(form_style,design.js 直存)
// 老表单无 form_style 字段时 → undefined → 走隐式判定,与原行为完全一致,零迁移风险
hasBannerMode() {
const v = this.value || {};
const fc = this.formCfg || {};
const style = fc.formStyle !== undefined ? fc.formStyle : fc.form_style;
if (style === 'card') return true;
if (style === 'classic') return false;
return !!(v.show_banner && v.form_banner);
},
// tip 提示语位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
// outside=独立白卡浮起压 banner(默认);inside=内容卡内(无独立卡视觉)
tipPosition() {
if (!this.hasBannerMode) return 'outside';
return this.mergedFormConfig.tipPosition === 'inside' ? 'inside' : 'outside';
},
// 提交按钮位置(仅 card 风格消费;classic 恒 'outside' 维持原结构)
// inside=内容卡内(默认);outside=卡片下方独立区域
submitPosition() {
if (!this.hasBannerMode) return 'outside';
return this.mergedFormConfig.submitPosition === 'outside' ? 'outside' : 'inside';
},
// 卡片容器内联样式:margin-top(负偏移+下移补偿)+ 左右外缩 + border-radius + box-shadow + background + border。
// clamp 与默认值对齐后端 form.js syncFormConfigToCSS:overlap 0-200(默认48) / radius 0-40(默认24) / sidePad 0-60(默认30);
// margin-top = -overlap + overlap_top(后端 --card-overlap 公式 (-overlap+overlapTop)/2 为 px,rpx 不除 2)
// tip 独立卡(outside + showTip)带队浮起时,本卡只留正常间距(后端预览 draggable margin 10px=20rpx)
// sidePad 语义(2026-09-03 与后端统一):卡片距屏幕左右距离(对齐后端 --card-side-pad,默认 30rpx=15px)。
// formdata 链路(containerInset>0)容器已有 padding,卡片负 margin 破壳后再缩 sidePad(与 banner bleed 同思路);
// DIY 链路(无 containerInset)直接 margin = sidePad。
// 卡内内容缩进由 ns-newform .form-wrap 的 formPaddingX 承担(与后端 .draggable-wrap padding: form-padding-x 同构)。
cardWrapStyle() {
const cfg = this.mergedFormConfig || {};
const parentInset = this._num(this.containerInset, 0);
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
// overlap:负值向上压住 banner 底部;overlap_top 为下移补偿(可正可负,叠加生效)
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
if (overlap > 200) overlap = 200;
const overlapTop = this._num(cfg.cardOverlapTop, 0);
// banner 实际渲染时才用负 margin 压住它;banner 被隐藏(如 hideInnerBanner / 配置缺失)时
// 负 margin 会把卡片(含 tip)整体推出屏幕顶部,必须收回 0
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
// tip 独立卡存在时由 tip 卡带队压 banner,内容卡只留正常间距(20rpx ≈ 后端预览 10px)
const tipLeads = this.tipPosition === 'outside' && !!(this.value && this.value.show_tip && this.value.form_tip);
// 卡片圆角:缺省 24rpx(浮起卡片的圆角,比外层卡片的 16rpx 更明显,突出浮起感)
let radius = this._num(cfg.cardRadius, 24);
if (radius < 0) radius = 0;
if (radius > 40) radius = 40;
// 阴影:缺省中等阴影(与设计方案一致)—— 由本容器承担浮起感
const shadow = cfg.cardShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)';
// 卡片底色:纯白,与设计方案一致;可由后台 formConfig.cardBgColor 覆盖
const bgColor = cfg.cardBgColor || '#ffffff';
// 边框:宽度 rpx(0-20),留空/0 = 无边框
let borderWidth = this._num(cfg.cardBorderWidth, 0);
if (borderWidth < 0) borderWidth = 0;
if (borderWidth > 20) borderWidth = 20;
const style = {
marginTop: (bannerVisible ? (tipLeads ? 20 : (-overlap + overlapTop)) : 0) + 'rpx',
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
borderRadius: radius + 'rpx',
boxShadow: shadow,
backgroundColor: bgColor
};
if (borderWidth > 0) {
style['border'] = borderWidth + 'rpx ' + (cfg.cardBorderStyle || 'solid') + ' ' + (cfg.cardBorderColor || 'transparent');
}
return this.$util.objToStyle(style);
},
// tip 独立卡样式(card + outside):负 margin 压 banner + 左右外缩 + 白底 + 圆角 + 阴影 + 细边框。
// 视觉参照后端预览 .form-style-card .preview-tip(margin: var(--card-overlap) var(--card-side-pad) 0;白底 12px 圆角 柔和阴影 左对齐)
// 用户配置的 tip 四态属性(bg/border/radius/shadow/padding/align)在此全部生效,仅默认值不同(独立卡默认白底卡视觉)
// sidePad 语义与 cardWrapStyle 一致:卡片距屏幕左右距离(默认 30rpx;formdata 链路破壳 sidePad-parentInset)
tipCardStyle() {
const cfg = this.mergedFormConfig || {};
const bannerVisible = !!(this.value && this.value.show_banner && this.value.form_banner);
let overlap = Math.abs(this._num(cfg.cardOverlap, 48));
if (overlap > 200) overlap = 200;
const overlapTop = this._num(cfg.cardOverlapTop, 0);
const parentInset = this._num(this.containerInset, 0);
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : sidePad;
const padT = this._num(cfg.tipPaddingTop, 32);
const padR = this._num(cfg.tipPaddingRight, 32);
const padB = this._num(cfg.tipPaddingBottom, 32);
const padL = this._num(cfg.tipPaddingLeft, 32);
const style = {
marginTop: (bannerVisible ? (-overlap + overlapTop) : 0) + 'rpx',
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#1f2937',
'background-color': cfg.tipBgColor || '#ffffff',
'border-radius': `${this._num(cfg.tipRadius, 24)}rpx`,
'box-shadow': cfg.tipShadow || '0 8rpx 24rpx rgba(0,0,0,0.08)',
'text-align': cfg.tipAlign || 'left'
};
// 边框:独立卡默认细边框(2rpx ≈ 后端预览 1px);配置 0 = 无边框
const bw = this._num(cfg.tipBorderWidth, 2);
if (bw > 0) {
style['border'] = `${bw}rpx ${cfg.tipBorderStyle || 'solid'} ${cfg.tipBorderColor || 'rgba(0,0,0,0.06)'}`;
}
return this.$util.objToStyle(style);
},
// Banner 容器破壳样式:仅在卡片模式下 + 父级传了 containerInset>0 时启用,让 banner 突破父容器 padding 跨越屏幕宽度。
// 计算逻辑:负 margin-left/right + 等量扩宽 width,使 banner 元素左/右边界对齐到屏幕边缘。
// 父级未传 containerInset 或传 0 时返回空样式,banner 保持原尺寸(零外溢)。
bannerBleedStyle() {
const inset = this._num(this.containerInset, 0);
if (!this.hasBannerMode || inset <= 0) return '';
const expand = inset * 2;
return this.$util.objToStyle({
marginLeft: -inset + 'rpx',
marginRight: -inset + 'rpx',
width: 'calc(100% + ' + expand + 'rpx)'
});
},
// 提示语样式(classic 平铺 / card+inside 卡内共用):与后端 edit.html 内联 .preview-tip 对齐
// - 四边内边距:未配置时经典态 上/下=rowPaddingY(4) 左右=tipPaddingX(14);卡片态统一 32rpx
// - 边框:classic 用 border-bottom(宽度默认 0;旧数据仅配 color → 2rpx 底边);
// card 用全边框(配置宽度才输出)
// - 圆角 / 阴影(rpx 字符串) / 对齐(card 态默认左对齐,经典态居中)
// - 背景色:''=未配置 → 透明(独立卡白底兜底已挪到 tipCardStyle,卡内 tip 无独立卡视觉)
tipStyle() {
const cfg = this.mergedFormConfig || {};
const card = this.hasBannerMode;
const padT = this._num(cfg.tipPaddingTop, card ? 32 : this._num(cfg.rowPaddingY, 4));
const padR = this._num(cfg.tipPaddingRight, card ? 32 : this._num(cfg.tipPaddingX, 14));
const padB = this._num(cfg.tipPaddingBottom, card ? 32 : this._num(cfg.rowPaddingY, 4));
const padL = this._num(cfg.tipPaddingLeft, card ? 32 : this._num(cfg.tipPaddingX, 14));
const style = {
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.tipPaddingX, 14)}rpx`,
padding: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#000000',
'background-color': cfg.tipBgColor || 'transparent'
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
'background-color': cfg.tipBgColor || 'transparent',
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
};
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度
if (cfg.tipBorderColor) {
// 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
const bw = this._num(cfg.tipBorderWidth, 0);
if (bw > 0) {
const bs = cfg.tipBorderStyle || 'solid';
const bc = cfg.tipBorderColor || 'rgba(0,0,0,0.06)';
if (card) {
style['border'] = `${bw}rpx ${bs} ${bc}`;
} else {
style['border-bottom'] = `${bw}rpx ${bs} ${bc}`;
}
} else if (!card && cfg.tipBorderColor) {
style['border-bottom'] = `2rpx solid ${cfg.tipBorderColor}`;
}
// 圆角(rpx)
if (cfg.tipRadius !== undefined && cfg.tipRadius !== null && cfg.tipRadius !== '') {
style['border-radius'] = `${this._num(cfg.tipRadius, 0)}rpx`;
}
// 阴影:后端存 rpx 单位字符串(如 '0 4rpx 12rpx rgba(...)'),uniapp H5/MP 运行时支持 rpx
if (cfg.tipShadow) {
style['box-shadow'] = cfg.tipShadow;
}
return this.$util.objToStyle(style);
},
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
submitWrapStyle() {
const cfg = this.mergedFormConfig || {};
if (this.hasBannerMode && this.submitPosition !== 'outside') {
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `24rpx 24rpx 40rpx`
});
}
if (this.hasBannerMode) {
// card + outside:卡片下方独立区域 —— 与后端 .preview-submit-wrap(card 模式)同构:
// 左右破壳对齐卡片缩进(sidePad - parentInset),水平内缩 formPaddingX,
// 上下 padding 36rpx/48rpx(后端 18px/24px),背景白底(后端 .preview-submit-wrap 亦为白底)
const parentInset = this._num(this.containerInset, 0);
let sidePad = this._num(cfg.cardSidePad, 30);
if (sidePad < 0) sidePad = 0;
if (sidePad > 60) sidePad = 60;
const sideMargin = parentInset > 0 ? (sidePad - parentInset) : 0;
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
marginLeft: sideMargin + 'rpx',
marginRight: sideMargin + 'rpx',
padding: `36rpx ${this._num(cfg.formPaddingX, 14)}rpx 48rpx`,
'background-color': cfg.formBgColor || '#ffffff'
});
}
return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
@@ -149,7 +467,7 @@ export default {
width: widthPct + '%',
padding: `${submitPaddingY}rpx 0`,
color: cfg.submitColor || '#ffffff',
'background-color': cfg.submitBgColor || '#ff4544',
'background-color': cfg.submitBgColor || '#3b82f6',
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
});
@@ -184,12 +502,33 @@ export default {
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
return fc[snake];
};
// 多别名取值:后台不同版本 / 不同序列化路径对同一配置项可能用不同键名,
// 这里按优先级取第一个有效值,避免白名单裁剪时把后台配置丢掉(丢了下层永远读不到)
const pickAny = function(keys) {
for (var i = 0; i < keys.length; i++) {
var kv = fc[keys[i]];
if (kv !== undefined && kv !== null && kv !== '') return kv;
}
return undefined;
};
// 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
const base = {
formPaddingX: pick('formPaddingX', 'form_padding_x'),
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
bannerHeight: pick('bannerHeight', 'banner_height'),
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'),
// 横幅裁剪位置(top/center/bottom)+ 比例(16:9/4:3/1:1/3:4)+ 手动裁栏框 JSON(2026-09-04 方案 C 抽屉配套)
bannerCropPosition: pick('bannerCropPosition', 'banner_crop_position'),
bannerCropRatio: pick('bannerCropRatio', 'banner_crop_ratio'),
bannerCropBox: pick('bannerCropBox', 'banner_crop_box'),
tipPaddingX: pick('tipPaddingX', 'tip_padding_x'),
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
// 行内上下边距:兼容 row_padding_y / rowPaddingY / row_padding / rowPadding 等键名
rowPaddingY: (function() {
var v = pick('rowPaddingY', 'row_padding_y');
if (v !== undefined && v !== null && v !== '') return v;
return pickAny(['rowPadding', 'row_padding', 'rowPaddingVertical', 'row_padding_vertical', 'row_padding_top', 'rowPaddingTop']);
})(),
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
labelGap: pick('labelGap', 'label_gap'),
formBgColor: pick('formBgColor', 'form_bg_color'),
@@ -205,6 +544,8 @@ export default {
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
requiredColor: pick('requiredColor', 'required_color'),
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
requiredPosition: pick('requiredPosition', 'required_position'),
checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
@@ -225,21 +566,71 @@ export default {
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
submitSize: pick('submitSize', 'submit_size'),
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
// 提示语
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color')
};
// 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_color'),
// 边框:宽度(rpx)/线型;未配置宽度时保留旧逻辑(仅 color 配置 → 底边 2rpx)
tipBorderWidth: pick('tipBorderWidth', 'tip_border_width'),
tipBorderStyle: pick('tipBorderStyle', 'tip_border_style'),
// 独立样式:圆角 / 四边内边距 / 阴影 / 对齐('' 与 null 均走 delete → 用各自态的兜底默认)
tipRadius: pick('tipRadius', 'tip_radius'),
tipPaddingTop: pick('tipPaddingTop', 'tip_padding_top'),
tipPaddingRight: pick('tipPaddingRight', 'tip_padding_right'),
tipPaddingBottom: pick('tipPaddingBottom', 'tip_padding_bottom'),
tipPaddingLeft: pick('tipPaddingLeft', 'tip_padding_left'),
tipShadow: pick('tipShadow', 'tip_shadow'),
tipAlign: pick('tipAlign', 'tip_align'),
// 卡片浮起(仅 show_banner && form_banner 启用)
cardOverlap: pick('cardOverlap', 'card_overlap'),
// 额外下移补偿(rpx,可正可负,与 overlap 叠加:margin-top = -overlap + overlap_top)
cardOverlapTop: pick('cardOverlapTop', 'card_overlap_top'),
cardRadius: pick('cardRadius', 'card_radius'),
cardShadow: pick('cardShadow', 'card_shadow'),
cardSidePad: pick('cardSidePad', 'card_side_pad'),
cardBgColor: pick('cardBgColor', 'card_bg_color'),
// 卡片边框(宽度 rpx / CSS 线型 / 颜色;留空 = 无边框)
cardBorderWidth: pick('cardBorderWidth', 'card_border_width'),
cardBorderStyle: pick('cardBorderStyle', 'card_border_style'),
cardBorderColor: pick('cardBorderColor', 'card_border_color'),
// 位置控制(仅 card 风格生效;outside/inside 见 tipPosition/submitPosition computed)
tipPosition: pick('tipPosition', 'tip_position'),
submitPosition: pick('submitPosition', 'submit_position')
};
// 移除 undefined / 空字符串(保留 0/false)
Object.keys(base).forEach(function(k) {
if (base[k] === undefined || base[k] === null || base[k] === '') {
delete base[k];
}
});
// 前端视觉默认:仅作兜底(后端未配置的字段才生效)。
// 注意合并顺序:base 在后 —— 后台 formConfig 显式配置的值(如 submit_bg_color / input_radius)
// 必须覆盖前端默认,否则商家端样式配置完全不生效(曾因此导致提交按钮恒为蓝色)。
const styleDefaults = {
formLayout: 'vertical',
formPaddingX: 28,
rowPaddingY: 24,
labelGap: 20,
labelFontSize: 32,
labelFontWeight: 700,
labelColor: '#1f2937',
inputHeight: 84,
inputRadius: 16,
inputBgColor: '#ffffff',
inputBorderColor: '#e5e7eb',
inputFocusBorderColor: '#3b82f6',
inputFocusBgColor: '#ffffff',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
checkedColor: '#3b82f6',
showRowDivider: false,
submitColor: '#ffffff',
submitBgColor: '#3b82f6'
};
// 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform
const result = Object.assign({}, base);
const result = Object.assign({}, styleDefaults, base);
const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
if (termBlock && typeof termBlock === 'object') {
Object.keys(termBlock).forEach(function(platform) {
@@ -368,12 +759,139 @@ export default {
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
margin: 0;
// banner 图片作为卡片上方的"底图":建立层叠上下文,让 .form-card 通过 z-index 浮在其上。
// 仅在卡片浮起模式(.is-card-overlap)启用,不影响其它使用方(diy-group 等)的旧布局。
&.is-card-overlap {
position: relative;
z-index: 1;
}
// 横幅图片容器:默认高度由 image widthFix 按图片比例自适应(与商家端预览 height:auto 对齐);
// .is-fixed-height(显式配置 bannerHeight)时高度由 bannerImgWrapStyle 内联写入,
// 配合 mode="aspectFill" 居中裁剪填充。
.banner-img-wrap {
width: 100%;
overflow: hidden;
position: relative;
// 默认作为 flex 容器,支持 widthFix + max-height 模式下的顶部/居中/底部对齐
.banner-img-crop {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
&.crop-center {
justify-content: center;
}
&.crop-bottom {
justify-content: flex-end;
}
}
&.is-fixed-height {
.banner-img {
height: 100%;
}
}
// 启用 aspectFill 裁剪时,包装一层 .banner-img-crop 承接裁剪位置。
// 重要:uni-app image 在 H5 端固定转译为 <div style="background-image:url; background-size:cover; background-position:center center;"></div>,
// 内层 div 的 background-position 是运行时生成的 inline 样式,普通 CSS 覆盖不了,必须 !important;
// (不能用 .banner-img 的 translateY 实现位置:image 高度被钉死为容器 100% 后 transform 一去一回相互抵消,是零位移)
// - crop-top → background-position: center top(显示图片顶部区域)
// - crop-center → 默认 center center(不覆盖)
// - crop-bottom → background-position: center bottom(显示图片底部区域)
&.has-crop {
.banner-img-crop {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.banner-img {
// 撑满 wrap,让内层 background-image div 拿到确定高度(uni-image H5 端转译依赖)
height: 100%;
position: absolute;
left: 0;
top: 0;
width: 100%;
}
// uni-app H5 编译器会给样式追加 [data-v-xxx](即使未写 scoped),
// 而 uni-image 运行时 JS 生成的内层 div 没有该属性 → 必须 ::v-deep 把属性选择器挪到 .banner-img 上
.banner-img-crop.crop-top .banner-img ::v-deep div {
background-position: center top !important;
}
.banner-img-crop.crop-bottom .banner-img ::v-deep div {
background-position: center bottom !important;
}
}
// 启用手动裁栏框(bannerCropBox 非空)时:image 用 transform 显示裁栏部分,原图按 box 缩放 + 平移
&.has-manual-crop {
.banner-img-crop {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.banner-img {
// 手动框下由 bannerImgStyle 内联 width/height/transform 驱动,不固定 100%
height: auto;
position: absolute;
left: 0;
top: 0;
}
}
}
.banner-img {
width: 100%;
display: block;
}
}
// 卡片容器:浮在 banner 图片上方(参考设计左图)。
// 仅在 hasBannerMode=true 时渲染(见 template v-if);
// 阴影 + margin-top(负偏移)+ 圆角 + 白底 全部由 cardWrapStyle 内联写入(读取后台 formConfig.card*)。
// 关键:卡片必须自带白底并 z-index 高于 banner,才能真正"盖住"banner 图片底部形成浮起;
// 父级容器(formdata.vue 的 .system-form-wrap)虽也是白底,但它是 banner 的祖先、背景会被 banner 覆盖,
// 因此本容器的白底不可省略(与 .system-form-wrap 同色,视觉上从卡片向下自然延续到页面底部)。
.form-card {
// 定位上下文 + 高于 banner 的层级:让卡片盖住 banner 底部(banner z-index:1)
position: relative;
z-index: 2;
// 兜底白底 / 圆角 / 阴影:与后端未配置时内联写入的默认值一致;后台配置时由内联 cardWrapStyle 覆盖
background-color: #ffffff;
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
// 关键:裁剪卡内子元素(ns-newform 白底 / submit 区)到圆角内。
// tip 关闭时本卡带队负 margin 压 banner,若无 overflow:hidden,
// 卡内第一个子元素的方形背景会盖住卡片上圆角(banner 图片在圆角外的部分被遮)。
// 商家端预览 .draggable-wrap 同样有 overflow:hidden(form.css 两处同步)。
overflow: hidden;
// 兜底 padding-bottom:让卡片与后面内容(如推荐码、其它 diy 组件)有间距
padding-bottom: 24rpx;
}
// tip 独立卡(card + tip_position=outside):浮在 banner 上方的独立白卡。
// margin/padding/白底/圆角/阴影/边框 全部由 tipCardStyle 内联写入;
// 必须自带 z-index 高于 banner(z-index:1)才能真正"盖住"banner 底部形成浮起。
.form-tip-card {
position: relative;
z-index: 2;
box-sizing: border-box;
line-height: 1.5;
word-break: break-all;
}
.form-tip {
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
padding: 0;

View File

@@ -64,6 +64,13 @@
<diy-rich-text :value="item"></diy-rich-text>
</template>
<!-- #ifdef H5 -->
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
<!-- 网页容器 -->
<diy-webview :value="item"></diy-webview>
</template>
<!-- #endif -->
<template v-if="item.componentName == 'HorzLine'">
<!-- 辅助线 -->
<diy-horz-line :value="item"></diy-horz-line>
@@ -163,6 +170,22 @@
<!-- 自定义推荐码组件 -->
<diy-recommend-code :value="item"></diy-recommend-code>
</template>
<template v-else-if="item.componentName == 'DiyCardEntry'">
<!-- 数字名片 · 名片入口横幅 -->
<diy-card-entry :value="item"></diy-card-entry>
</template>
<template v-else-if="item.componentName == 'DiyCardList'">
<!-- 数字名片 · 团队名片墙 -->
<diy-card-list :value="item"></diy-card-list>
</template>
<template v-else-if="item.componentName == 'DiyCardShow'">
<!-- 数字名片 · 精选名片 -->
<diy-card-show :value="item"></diy-card-show>
</template>
<template v-else-if="item.componentName == 'DiyCardSelf'">
<!-- 数字名片 · 我的名片(登录态自适应) -->
<diy-card-self :value="item"></diy-card-self>
</template>
<template v-if="item.componentName == 'GoodsBrand'">
<!-- 商品品牌 -->
<diy-goods-brand :value="item"></diy-goods-brand>
@@ -377,14 +400,41 @@ export default {
: index + 1;
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
? newItem.componentBgColor
: newItem.pageBgColor;
newItem.pageStyle = `background-color:${wrapBgColor};`;
if (newItem.margin) {
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
}
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
const rawMargin = newItem.margin;
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const pickMargin = (objKey, flatKey) => {
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
return toMarginNum(objMargin[objKey]);
}
return toMarginNum(newItem[flatKey]);
};
const marginTopNum = pickMargin('top', 'marginTop');
const marginBottomNum = pickMargin('bottom', 'marginBottom');
const marginBothNum = pickMargin('both', 'marginBoth');
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
}
return newItem;

View File

@@ -1,38 +1,52 @@
<template>
<view :style="componentStyle" data-component-name="diy-image-nav">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
:scroll-x="value.showStyle == 'singleSlide'">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
style="margin-right: 28rpx;">
<!-- 图片部分 -->
<view v-if="value.mode != 'text'" class="image-img" :style="{
'font-size': (value.imageSize * 2 + 'rpx') + ';',
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}">
<image v-if="item.link.wap_url" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
<image v-else :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'height': (item.imgHeight / 2 + 'rpx') + ';'
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
</view>
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
<view class="image-nav-wrap">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
:style="imageSpacingStyle"
:scroll-x="value.showStyle == 'singleSlide'"
:show-scrollbar="showScrollbarAttr"
:enhanced="enhancedScrollbar"
@scroll="onSlideScroll">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
:style="imageItemStyle">
<!-- 图片部分 -->
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
<view v-if="value.mode != 'text'" class="image-img" :style="imageBoxStyle(item)">
<image v-if="item.link.wap_url" class="image-content" :style="imageInnerStyle(item)"
mode="aspectFit"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
</view>
<!-- 文字部分 -->
<text class="image-text" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'font-size': (value.font.size * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.color + ';'
}">{{ item.title }}</text>
<!-- 文字部分 -->
<text class="image-text" :style="{
'width': (item.imgWidth / 2 + 'rpx') + ';',
'font-size': (value.font.size * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.color + ';'
}">{{ item.title }}</text>
</view>
</view>
</scroll-view>
<!-- #ifdef MP -->
<!-- 小程序端自定义水平滚动条: 原生 show-scrollbar 被忽略, 这里自绘以对齐后台/ H5 -->
<view v-if="showCustomScrollbar" class="img-nav-scrollbar">
<view class="img-nav-scrollbar-thumb" :style="scrollbarThumbStyle"></view>
</view>
</scroll-view>
<!-- #endif -->
</view>
<ns-login ref="login"></ns-login>
</view>
</template>
@@ -60,7 +74,12 @@ export default {
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
slideStartX: 0,
slideStartScrollLeft: 0,
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
slideScrolling: false,
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
mpScroll: { viewW: 0, contentW: 0, left: 0 }
}
},
created() {
@@ -75,23 +94,94 @@ export default {
var self = this;
this.$nextTick(function() {
self.bindSlideMouseEvents();
self.bindSlideScrollbar();
});
// #endif
// 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定)
// 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度
// → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。
// #ifdef MP
var selfMp = this;
this.$nextTick(function() {
selfMp.measureScrollbar();
});
this._mpMeasureTimers = [300, 1000].map(function(delay) {
return setTimeout(function() {
selfMp.measureScrollbar();
}, delay);
});
// #endif
},
beforeDestroy() {
// #ifdef H5
this.unbindSlideMouseEvents();
this.unbindSlideScrollbar();
// #endif
// #ifdef MP
if (this._mpMeasureTimers) {
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
this._mpMeasureTimers = null;
}
// #endif
},
watch: {
componentRefresh(newValue) {
// 监听组件刷新
},
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
'value.list'(newVal, oldVal) {
// #ifdef MP
if (newVal !== oldVal) {
var self = this;
this.$nextTick(function() {
self.measureScrollbar();
});
}
// #endif
}
},
computed: {
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
componentBgColor() {
const v = this.value || {};
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
const asColor = val => {
if (Array.isArray(val)) val = val[0];
if (typeof val !== 'string') return '';
const s = val.trim();
if (!s) return '';
// 排除图片/URL 等非颜色值
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
return s;
};
const sources = [v];
['style', 'config', 'setting', 'data'].forEach(k => {
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
});
const candidates = [
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
'bgcolor', 'backgroundcolor', 'background',
'pagebgcolor'
];
for (let c = 0; c < candidates.length; c++) {
for (let s = 0; s < sources.length; s++) {
for (const key in sources[s]) {
if (norm(key) === candidates[c]) {
const color = asColor(sources[s][key]);
if (color) return color;
}
}
}
}
return '';
},
componentStyle() {
let style = '';
style += 'background-color:' + this.value.componentBgColor + ';';
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
@@ -123,9 +213,446 @@ export default {
isIndicatorDots() {
return this.value.carousel.type != 'hide' &&
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
},
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
scrollBarMode() {
const v = this.value || {};
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
// 从配置对象中取"滚动条"字段的原始值
const pickRaw = obj => {
if (!obj || typeof obj != 'object') return undefined;
for (const key in obj) {
const k = norm(key);
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
const val = obj[key];
if (val && typeof val === 'object') {
// 子配置对象形态: { scrollBar: { type: 'always' } }
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
}
if (val !== undefined && val !== null && val !== '') return val;
}
}
return undefined;
};
let raw = pickRaw(v);
// 嵌套容器兜底
if (raw === undefined) {
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
for (let i = 0; i < containers.length; i++) {
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
raw = pickRaw(v[containers[i]]);
if (raw !== undefined) break;
}
}
}
if (raw === undefined || raw === null || raw === '') return 'slide';
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
if (raw === 0 || raw === '0') return 'slide';
if (raw === 1 || raw === '1') return 'always';
if (raw === 2 || raw === '2') return 'never';
const map = {
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
always: 'always', alwaysdisplay: 'always', show: 'always',
never: 'never', none: 'never', hidden: 'never', hide: 'never',
// 后台直出中文枚举时兜底
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
};
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
return hit || 'slide';
},
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
scrollBarClass() {
const cls = ['scrollbar-' + this.scrollBarMode];
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
return cls;
},
// 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为,
// 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。
enhancedScrollbar() {
// #ifndef MP
return this.scrollBarMode != 'never';
// #endif
// #ifdef MP
return false;
// #endif
},
showScrollbarAttr() {
// #ifndef MP
return this.scrollBarMode != 'never';
// #endif
// #ifdef MP
return false;
// #endif
},
// 小程序端自定义滚动条的"是否显示"
showCustomScrollbar() {
// #ifdef MP
if (this.scrollBarMode === 'never') return false;
// 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致
if (this.value.showStyle != 'singleSlide') return false;
// 内容没有溢出(无需横向滚动)则不显示
if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false;
// 滑动时显示: 仅在滚动中显示
if (this.scrollBarMode === 'slide') return this.slideScrolling;
// 始终显示: 一直显示
return true;
// #endif
},
// 小程序端自定义滚动条 thumb 的内联样式(width + 位移)
scrollbarThumbStyle() {
// #ifdef MP
const viewW = this.mpScroll.viewW;
const contentW = this.mpScroll.contentW;
const left = this.mpScroll.left;
if (!viewW || !contentW || contentW <= viewW) return 'display:none;';
const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW));
const maxLeft = contentW - viewW;
const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0;
const thumbLeft = ratio * (viewW - thumbW);
return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);';
// #endif
},
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
imageSpacingConfig() {
const v = this.value || {};
const RATIO = 2; // 后台数值 → rpx 换算比例
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
const toRpx = val => {
if (val === undefined || val === null || val === '') return null;
const n = Number(val);
if (isNaN(n)) return null;
return Math.max(0, n) * RATIO;
};
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
const sources = [v];
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
.forEach(k => {
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
});
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
const pick = (exact, fuzzy, nested) => {
for (let s = 0; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
if (exact.indexOf(norm(key)) != -1) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
for (let s = 0; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
const k = norm(key);
if (fuzzy.some(f => k.indexOf(f) != -1)) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
for (let s = 1; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
if (nested.indexOf(norm(key)) != -1) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
return null;
};
return {
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
horizontal: pick(
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
),
// 图片上间距 —— 后台现用命名 imageSpacingTop
paddingTop: pick(
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
),
// 图片下间距 —— 后台现用命名 imageSpacingBottom
paddingBottom: pick(
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
)
};
},
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
imageSpacingStyle() {
const cfg = this.imageSpacingConfig;
let style = '';
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
return style;
},
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
imageItemStyle() {
const horizontal = this.imageSpacingConfig.horizontal;
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
}
},
methods: {
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
// <image> 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。
// 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位):
// ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2
// ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2
// ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2
// ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值)
//
// 落地结构: 外层 .image-img(view) 与 <image> 都按解析出的尺寸渲染, 两端一致;
// 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。
rpxVal(val) {
// 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px)
if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val);
return val + 'rpx';
},
resolveItemImgSize(item) {
const valid = v => {
if (v === undefined || v === null || v === '') return null;
const n = Number(v);
return (!isNaN(n) && n > 0) ? n : null;
};
// ① 每一项图片自身尺寸
const iw = valid(item && item.imgWidth);
const ih = valid(item && item.imgHeight);
if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 };
if (iw !== null) return { width: iw / 2, height: iw / 2 };
if (ih !== null) return { width: ih / 2, height: ih / 2 };
// ② 组件级 imgWidth / imgHeight
const v = this.value || {};
const ciw = valid(v.imgWidth);
const cih = valid(v.imgHeight);
if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 };
if (ciw !== null) return { width: ciw / 2, height: ciw / 2 };
if (cih !== null) return { width: cih / 2, height: cih / 2 };
// ③ 组件级"图片尺寸"
const is = valid(v.imageSize);
if (is !== null) return { width: is * 2, height: is * 2 };
// ④ 安全兜底
return { width: 120, height: 120 };
},
// 图片外层占位盒样式(决定图片最终尺寸)
//
// ⚠️ 必须返回"样式字符串", 不能返回对象!
// 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象),
// 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效:
// · 只有 <image> 时 → 图片回退到默认 320x240(看起来"被撑到最大")
// · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示)
// diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。
imageBoxStyle(item) {
const size = this.resolveItemImgSize(item);
const imageSize = Number(this.value && this.value.imageSize);
const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height;
return 'font-size:' + this.rpxVal(font) + ';'
+ 'width:' + this.rpxVal(size.width) + ';'
+ 'height:' + this.rpxVal(size.height) + ';';
},
// 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示)
// max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸,
// 保证图片不会超出后台配置的大小。
imageInnerStyle(item) {
const size = this.resolveItemImgSize(item);
const w = this.rpxVal(size.width);
const h = this.rpxVal(size.height);
return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';';
},
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
bindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollBound) return;
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
var el = this.getSlideContainer();
if (!el) return;
this._slideScrollEl = el;
this._slideScrollBound = true;
var self = this;
this._onSlideScroll = function() {
if (self.scrollBarMode != 'slide') return;
if (!self.slideScrolling) self.slideScrolling = true;
clearTimeout(self._slideScrollTimer);
self._slideScrollTimer = setTimeout(function() {
self.slideScrolling = false;
}, 800);
};
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
// #endif
},
unbindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollEl && this._onSlideScroll) {
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
}
clearTimeout(this._slideScrollTimer);
this._slideScrollEl = null;
this._onSlideScroll = null;
this._slideScrollBound = false;
this.slideScrolling = false;
// #endif
},
// 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用)
// · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类)
// · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘)
onSlideScroll(e) {
if (this.scrollBarMode === 'slide') {
if (!this.slideScrolling) this.slideScrolling = true;
clearTimeout(this._slideScrollTimer);
this._slideScrollTimer = setTimeout(() => {
this.slideScrolling = false;
}, 800);
}
// #ifdef MP
// 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定)
if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar();
// scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回),
// 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确
if (e && e.detail && typeof e.detail.scrollWidth === 'number'
&& e.detail.scrollWidth > this.mpScroll.contentW) {
this.mpScroll.contentW = e.detail.scrollWidth;
}
let left = 0;
if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft;
this.mpScroll.left = left;
// #endif
},
// 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb)
//
// ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因:
// 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中,
// 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它),
// 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定
// "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。
// 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖:
// ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距)
// ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底
// ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底
measureScrollbar() {
// #ifdef MP
if (!uni || !uni.createSelectorQuery) return;
var self = this;
var query = uni.createSelectorQuery().in(this);
query.select('.image-nav').fields({ size: true, scrollOffset: true });
query.selectAll('.image-nav-item').boundingClientRect();
query.exec(function(res) {
var view = res && res[0];
if (!view) return;
var viewW = view.width || 0;
var items = res && res[1];
// ① 原生 scrollWidth
var contentW = view.scrollWidth || 0;
// ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定)
if (contentW <= viewW && items && items.length) {
var minLeft = Infinity;
var maxRight = -Infinity;
for (var i = 0; i < items.length; i++) {
var r = items[i];
if (!r) continue;
if (r.left < minLeft) minLeft = r.left;
if (r.right > maxRight) maxRight = r.right;
}
if (maxRight > minLeft) {
var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx
var spanW = (maxRight - minLeft) + padPx;
if (spanW > contentW) contentW = spanW;
}
}
// ③ 数据推算兜底
if (!contentW) contentW = self.estimateContentWidthPx();
self.mpScroll.viewW = viewW;
// 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失)
if (contentW > 0) self.mpScroll.contentW = contentW;
else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0;
self.mpScroll.left = view.scrollLeft || 0;
});
// #endif
},
// 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底
estimateContentWidthPx() {
// #ifdef MP
const list = (this.value && this.value.list) || [];
if (!list.length) return 0;
const cfg = this.imageSpacingConfig;
const gap = cfg.horizontal === null ? 28 : cfg.horizontal;
let totalRpx = 32; // 容器左右 padding(16rpx × 2)
for (let i = 0; i < list.length; i++) {
if (this.value.mode == 'text') {
const w = Number(list[i] && list[i].imgWidth);
totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0;
} else {
totalRpx += this.resolveItemImgSize(list[i]).width;
}
totalRpx += gap;
}
let winW = 375;
try {
if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375;
} catch (e) {}
return totalRpx * winW / 750;
// #endif
},
// 页面跳转
redirectTo(link) {
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -153,14 +680,10 @@ export default {
// #ifdef H5
// 防止重复绑定
if (this._slideEventsBound) return;
var outerEl = this.getSlideOuterEl();
var innerEl = this.getSlideContainer();
if (!outerEl || !innerEl) return;
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
this._slideOuterEl = outerEl;
this._slideEl = innerEl;
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
this._slideEventsBound = true;
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
document.addEventListener('mousedown', this.onSlideMouseDown, true);
@@ -183,50 +706,79 @@ export default {
this._slideEventsBound = false;
// #endif
},
getSlideContainer() {
// 本组件实例的根元素(<view data-component-name="diy-image-nav">)
//
// ⚠️ 必须用 this.$el 限定在"本实例"内查找元素!
// 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'),
// 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质),
// document.querySelector 永远只返回页面上的**第一个**实例 ——
// 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置,
// 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。
getSlideRootEl() {
// #ifdef H5
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
// 嵌套结构:
// <uni-scroll-view> ← 自定义元素
// <div class="uni-scroll-view"> ← 第1层,visible
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
// 真正能响应 scrollLeft 的是第 2 层 div。
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
// 找 overflowX:auto 的 div.uni-scroll-view
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
for (var i = 0; i < candidates.length; i++) {
var s = getComputedStyle(candidates[i]).overflowX;
if (s === 'auto' || s === 'scroll') {
return candidates[i];
}
}
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
var el = this.$el;
if (el && el.nodeType === 1) return el;
return null;
// #endif
},
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
getSlideOuterEl() {
// #ifdef H5
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
if (!componentRoot) return null;
var sc = componentRoot.querySelector('uni-scroll-view');
return sc || null;
return this.getSlideRootEl();
// #endif
},
getSlideContainer() {
// #ifdef H5
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
// 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找:
// ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠)
// ② 其次 overflow-x: auto/scroll
// ③ 再退化为最后一个 .uni-scroll-view
// ④ 最后退化到 .uni-scroll-view-content 的父元素
var root = this.getSlideRootEl();
if (!root) return null;
var candidates = root.querySelectorAll('.uni-scroll-view');
var i;
for (i = 0; i < candidates.length; i++) {
if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i];
}
for (i = 0; i < candidates.length; i++) {
var s = getComputedStyle(candidates[i]).overflowX;
if (s === 'auto' || s === 'scroll') return candidates[i];
}
if (candidates.length > 0) return candidates[candidates.length - 1];
var content = root.querySelector('.uni-scroll-view-content');
if (content && content.parentElement) return content.parentElement;
return root.querySelector('uni-scroll-view');
// #endif
},
// 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里)
ensureSlideEls() {
// #ifdef H5
var root = this.getSlideRootEl();
if (!root) return false;
this._slideOuterEl = root;
var el = this.getSlideContainer();
if (!el) return false;
this._slideEl = el;
// 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器,
// 这里补绑一次(否则"滑动时显示滚动条"也会失效)。
if (!this._slideScrollBound) this.bindSlideScrollbar();
return true;
// #endif
},
onSlideMouseDown(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
// document 级别监听,只处理落在容器内的事件
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
// document 级别监听,只处理落在"本实例"容器内的事件
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
if (!this._slideOuterEl) return;
if (!this.ensureSlideEls()) return;
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
this.slideIsDragging = true;
this.slideStartX = x;
@@ -240,7 +792,7 @@ export default {
onSlideMouseMove(e) {
// #ifdef H5
if (!this.slideIsDragging) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
var x = (e.clientX != null) ? e.clientX : 0;
var deltaX = x - this.slideStartX;
@@ -267,7 +819,7 @@ export default {
// #ifdef H5
if (!this._suppressNextClick) return;
if (!e.target) return;
var outer = this._slideOuterEl;
var outer = this._slideOuterEl || this.getSlideOuterEl();
if (!outer || !outer.contains(e.target)) return;
this._suppressNextClick = false;
if (e.preventDefault) e.preventDefault();
@@ -278,13 +830,13 @@ export default {
onSlideWheel(e) {
// #ifdef H5
if (this.value.showStyle != 'singleSlide') return;
if (!this._slideOuterEl) return;
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内
if (!this.ensureSlideEls()) return;
var rect = this._slideOuterEl.getBoundingClientRect();
if (!rect) return;
var x = e.clientX, y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
var el = this.getSlideContainer();
var el = this._slideEl;
if (!el) return;
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
var dx = e.deltaX || 0;
@@ -301,6 +853,36 @@ export default {
</script>
<style lang="scss" scoped>
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
.image-nav-wrap {
position: relative;
width: 100%;
}
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
.img-nav-scrollbar {
position: absolute;
left: 0;
right: 0;
bottom: 2rpx;
height: 4rpx;
background-color: rgba(0, 0, 0, 0.12);
border-radius: 2rpx;
z-index: 2;
pointer-events: none;
overflow: hidden;
}
.img-nav-scrollbar-thumb {
position: absolute;
left: 0;
top: 0;
height: 4rpx;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 2rpx;
transition: transform 0.08s linear;
}
.image-nav {
padding: 16rpx;
box-sizing: border-box;
@@ -327,17 +909,72 @@ export default {
// #ifdef H5
overflow-x: auto;
overflow-y: hidden;
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge legacy
&::-webkit-scrollbar {
display: none;
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
cursor: grab;
&:active {
cursor: grabbing;
}
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
::v-deep .uni-scroll-view {
overflow-x: auto;
overflow-y: hidden;
}
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
user-select: none;
-webkit-user-select: none;
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
-webkit-overflow-scrolling: touch;
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge legacy
&::-webkit-scrollbar {
display: none;
}
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
::v-deep .uni-scroll-view {
scrollbar-width: none;
-ms-overflow-style: none;
}
::v-deep .uni-scroll-view::-webkit-scrollbar {
display: none;
}
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
&.scrollbar-always,
&.scrollbar-slide.is-scrolling {
scrollbar-width: thin; // Firefox
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
&::-webkit-scrollbar {
display: block;
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25);
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
::v-deep .uni-scroll-view {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
}
::v-deep .uni-scroll-view::-webkit-scrollbar {
display: block;
height: 4px;
}
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25);
border-radius: 2px;
}
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
background-color: transparent;
}
}
// #endif
}
@@ -402,6 +1039,15 @@ export default {
align-items: center;
justify-content: center;
font-size: 90rpx;
overflow: hidden;
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
.image-content {
max-width: 100%;
max-height: 100%;
}
.tag {
position: absolute;

View File

@@ -6,13 +6,18 @@
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
</view>
<view class="fui-list-inner">
<view class="title" style="height: 75rpx;">
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
</view>
<view class="title">
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
<!-- #ifndef H5 -->
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
<!-- #endif -->
</view>
</view>
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
</view>
@@ -35,15 +40,31 @@ export default {
data() {
return {
loading: true,
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
mpTitleSize: 0,
mpDescSize: 0
};
},
created() {
// this.getDataList();
},
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
mounted() {
this.$nextTick(this.fitOneLineText)
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
// this.getDataList();
this.$nextTick(this.fitOneLineText)
},
value: {
deep: true,
handler() {
this.$nextTick(this.fitOneLineText)
}
}
},
computed: {
@@ -66,9 +87,130 @@ export default {
current: img,
urls: [img]
})
},
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
// #ifdef H5
fitOneLineText() {
this.$nextTick(() => {
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
if (!root) return
const nodes = root.querySelectorAll('.title-text, .subtitle')
nodes.forEach((el) => {
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
el.style.fontSize = ''
let size = parseFloat(window.getComputedStyle(el).fontSize)
const MIN_PX = 10 // 下限 10px, 再小可读性太差
const target = el.clientWidth - 3
let guard = 0
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
size -= 0.5
el.style.fontSize = size + 'px'
guard++
}
})
})
},
// #endif
// #ifndef H5
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
fitOneLineText() {
uni.createSelectorQuery().in(this)
.select('.fui-list-inner').boundingClientRect()
.select('.measure-title').boundingClientRect()
.select('.measure-desc').boundingClientRect()
.exec((rects) => {
if (!rects || !rects[0]) return
const sys = uni.getSystemInfoSync()
const pxPerRpx = (sys.windowWidth || 375) / 750
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
const avail = rects[0].width - 16 * pxPerRpx
if (avail <= 0) return
// 标题: 默认 28rpx, 最小 16rpx
if (rects[1] && rects[1].width > avail) {
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
} else {
this.mpTitleSize = 0
}
// 描述: 默认 24rpx, 最小 16rpx
if (rects[2] && rects[2].width > avail) {
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
} else {
this.mpDescSize = 0
}
})
},
// #endif
titleStyle() {
// #ifndef H5
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
// #endif
// #ifdef H5
return ''
// #endif
},
descStyle() {
// #ifndef H5
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
// #endif
// #ifdef H5
return ''
// #endif
}
}
};
</script>
<style lang="scss"></style>
<style lang="scss">
// 客服展示:标题/描述需要完整展示(中英文均不截断)
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
.diy-kefu {
.fui-list-group {
.fui-list {
align-items: center;
.fui-list-inner {
overflow: hidden;
min-width: 0;
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
padding-right: 8rpx;
box-sizing: border-box;
.title {
height: auto;
line-height: 1.4;
.title-text {
display: block;
font-size: 28rpx;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
.measure-text {
position: absolute;
left: -9999rpx;
top: 0;
white-space: nowrap;
visibility: hidden;
}
}
}
}
}
</style>

View File

@@ -12,10 +12,10 @@
<!-- #ifdef MP -->
<block
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
</block>
<view class="nickname" v-else>
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
<view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
<!-- <text class="icondiy icon-system-huangguan"></text> -->
@@ -39,7 +39,7 @@
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
</block>
<view class="nickname" v-else>
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
}}</text>
<view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
@@ -69,7 +69,7 @@
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
</view>
<view class="info">
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
</view>
@@ -367,6 +367,26 @@ export default {
isDisabled() {
if (this.nickName.length > 0) return false;
return true;
},
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
nameStyle() {
let name = '';
if (this.memberInfo && this.memberInfo.nickname) {
name = this.memberInfo.nickname;
} else {
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
}
if (!name) return '';
let units = 0;
for (let i = 0; i < name.length; i++) {
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
}
if (units <= 10) return ''; // 短标题走默认 38rpx
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
return 'font-size:' + size + 'rpx;';
}
},
methods: {

View File

@@ -137,8 +137,8 @@ export default {
},
computed: {
warpCss() {
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
obj += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';

View File

@@ -10,8 +10,8 @@
@error="imgError(index)" show-menu-by-longpress />
</view>
<view class="info-wrap">
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
<text class="desc">{{ item.desc }}</text>
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
<view class="read-wrap"></view>
</view>
</view>
@@ -179,6 +179,25 @@ export default {
this.toDetail(item);
}
})
},
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
oneLineFontStyle(text, defaultSize, minSize) {
if (!text) return '';
let units = 0;
for (let i = 0; i < text.length; i++) {
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
}
if (units <= 12) return '';
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
return 'font-size:' + size + 'rpx;';
},
titleStyle(item) {
return this.oneLineFontStyle(item.merch_name, 30, 20);
},
descStyle(item) {
return this.oneLineFontStyle(item.desc, 24, 20);
}
}
};
@@ -296,13 +315,19 @@ export default {
}
.info-wrap {
flex: 1;
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
// flex: 1;
// display: flex;
// flex-direction: column;
// justify-content: space-between;
.desc {
color: #888
color: #888;
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
@@ -311,8 +336,9 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
-webkit-box-orient: vertical;
white-space: nowrap;
font-size: 30rpx;
line-height: 1.5;
}

View File

@@ -87,41 +87,25 @@
<!-- ============ 推荐码卡片:仅在非 consent 且非首次加载时渲染 ============ -->
<block v-if="!showConsent && !(loadingCode && !code)">
<!-- ============ 预设 1:Gradient 渐变风格(默认)全部内联样式 ============ -->
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 + 已获得推荐码标签 -->
<view v-if="stylePreset === 'gradient'" :style="$util.objToStyle({ width: 'calc(100% - 28rpx)', boxSizing: 'border-box', marginTop: '24rpx', marginLeft: 'auto', marginRight: 'auto', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 -->
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
<text style="font-size:30rpx;">🔑</text>
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
</view>
<!-- 推荐码区域:浅蓝虚线框 + 右侧蓝色方形复制按钮 -->
<view style="margin-top:24rpx;display:flex;align-items:center;gap:16rpx;position:relative;z-index:1;">
<view style="flex:1;display:flex;align-items:center;justify-content:center;height:124rpx;background:#F0F7FF;border:4rpx dashed #3B82F6;border-radius:24rpx;box-sizing:border-box;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(20, Math.min(40, Number(value.codeFontSize) || 28))) + 'px', fontWeight: '900', color: value.codeColor || '#2563EB', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'HK-8888' }}</text>
</view>
<view v-if="value.showCopyButton !== false" @tap="copyRecommendCode" style="flex-shrink:0;width:96rpx;height:96rpx;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#3B82F6 0%,#2563EB 100%);border-radius:20rpx;box-shadow:0 4rpx 12rpx rgba(37,99,235,0.3);">
<text style="font-size:44rpx;color:#FFFFFF;">📋</text>
</view>
</view>
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
<!-- 推荐码居中显示 -->
<view style="text-align:center;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
</view>
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
<text style="font-size:22rpx;">📋</text>
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
</view>
</view>
</view>
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
<button v-if="value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
</view>
</button>
<view v-else-if="value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
</view>
<!-- 提示文字 -->
<view style="margin-top:16rpx;display:flex;align-items:flex-start;gap:8rpx;position:relative;z-index:1;">
<text style="font-size:24rpx;color:#F59E0B;flex-shrink:0;">💡</text>
<text style="font-size:24rpx;color:#999999;line-height:1.5;flex:1;">{{ value.tipText || '复制推荐码分享给合作企业,对方填表时填入即可绑定' }}</text>
</view>
<!-- showLink -->
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
@@ -135,6 +119,29 @@
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
<!-- 分享推荐链接按钮:深蓝渐变,左侧图标 + 文字,右侧“复制链接”小按钮(独立于白色卡片外) -->
<button v-if="stylePreset === 'gradient' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:24rpx auto 20rpx auto;width:calc(100% - 28rpx);text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
<view style="display:flex;align-items:center;gap:12rpx;">
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ value.buttonText || '分享推荐链接给企业' }}</text>
</view>
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
</view>
</view>
</button>
<view v-else-if="stylePreset === 'gradient' && value.showShareButton !== false" @tap="onShareClick" style="margin:24rpx auto 20rpx auto;width:calc(100% - 28rpx);">
<view style="display:flex;align-items:center;justify-content:space-between;width:100%;height:100rpx;background:linear-gradient(90deg,#3B82F6 0%,#1E40AF 100%);border-radius:20rpx;padding:0 24rpx;box-shadow:0 4rpx 16rpx rgba(29,78,216,0.35);box-sizing:border-box;">
<view style="display:flex;align-items:center;gap:12rpx;">
<text style="font-size:36rpx;color:#FFFFFF;">📤</text>
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">{{ nc.buttonText || '分享推荐链接给企业' }}</text>
</view>
<view @tap.stop="copyLink" style="flex-shrink:0;height:52rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,0.2);border-radius:26rpx;padding:0 20rpx;">
<text style="font-size:24rpx;color:#FFFFFF;">复制链接</text>
</view>
</view>
</view>
<!-- ============ 预设 2:Minimal 极简风格(全部内联样式) ============ -->
<view v-if="stylePreset === 'minimal'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: 'linear-gradient(90deg,#FFFFFF 0%,#FFF8F8 30%,#FFF5F5 60%,#FFF0F0 100%)', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(255,107,107,0.08)' })">
@@ -323,6 +330,56 @@
<text style="font-size:22rpx;color:#999;margin-top:8rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
<!-- ============ 预设 5(样式5):直接采用用户提供的代码,原样新增 ============ -->
<view v-if="stylePreset === 'style5'" :style="$util.objToStyle({ width: '100%', boxSizing: 'border-box', marginTop: '24rpx', background: '#FFFFFF', borderRadius: px2rpx(value.borderRadius || 8) + 'px', padding: px2rpx(value.padding || 16) + 'px ' + px2rpx(value.padding || 16) + 'px', position: 'relative', overflow: 'hidden', boxShadow: '0 4rpx 16rpx rgba(0,0,0,0.06)' })">
<!-- 标题行 + 已获得推荐码标签 -->
<view style="display:flex;align-items:center;gap:12rpx;position:relative;z-index:1;">
<text :style="$util.objToStyle({ fontSize: px2rpx(value.titleFontSize || 17) + 'px', fontWeight: '800', color: value.titleColor || '#1A1A1A' })">{{ value.title || '我的推荐码' }}</text>
<view style="display:inline-flex;align-items:center;background:#52C41A;border-radius:16rpx;padding:4rpx 14rpx;gap:4rpx;">
<text style="font-size:18rpx;color:#FFFFFF;">✓</text>
<text style="font-size:20rpx;color:#FFFFFF;font-weight:600;">已获得推荐码</text>
</view>
</view>
<!-- 推荐码+复制按钮区域:浅米黄色背景,圆角紧凑 -->
<view style="margin-top:24rpx;background:#FFF8F0;border-radius:16rpx;padding:32rpx 20rpx 24rpx;position:relative;z-index:1;">
<!-- 推荐码居中显示 -->
<view style="text-align:center;">
<text :style="$util.objToStyle({ fontSize: px2rpx(Math.max(16, Math.min(48, (Number(value.codeFontSize) || 28) * 0.93))) + 'px', fontWeight: '900', color: value.codeColor || '#DC143C', letterSpacing: '2rpx', fontFamily: 'Courier New, monospace' })">{{ code || value.placeholderCode || 'ABCD1234' }}</text>
</view>
<!-- 复制推荐码按钮:白色小按钮,红边框,在推荐码下方居中 -->
<view v-if="value.showCopyButton !== false" style="margin-top:16rpx;text-align:center;">
<view @tap="copyRecommendCode" :style="$util.objToStyle(copyBtnStyleObj({display:'inline-flex',alignItems:'center',borderRadius:'15px',padding:'0 16px',height:'30px',gap:'4px'}))">
<text style="font-size:22rpx;">📋</text>
<text :style="$util.objToStyle(copyBtnStyleObj({}, true))">{{ nc.copyButtonText || '复制推荐码' }}</text>
</view>
</view>
</view>
<!-- 立即分享链接按钮:黄橙渐变,胶囊形圆角,紧凑布局
固定走 linear-gradient,不读 diyData.buttonBgColor(避免被后端默认色覆盖)
H5 端用 view 触发(避免 button 默认样式),小程序/APP 端保留 button 以触发 open-type=share -->
<button v-if="stylePreset === 'style5' && value.showShareButton !== false && !isH5Platform" open-type="share" @tap="onShareClick" style="position:relative;left:0;right:0;margin:16rpx 0 0 0;width:100%;text-align:left;background:transparent;border:0;padding:0;line-height:normal;display:block;box-sizing:border-box;">
<view style="display:block;width:100%;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ value.buttonText || '立即分享' }}</text>
</view>
</button>
<view v-else-if="stylePreset === 'style5' && value.showShareButton !== false" @tap="onShareClick" style="margin-top:16rpx;width:100%;">
<view style="display:block;height:80rpx;line-height:80rpx;background:linear-gradient(90deg,#FFC107 0%,#FF8F00 100%);border-radius:40rpx;box-shadow:0 4rpx 12rpx rgba(255,143,0,0.3);box-sizing:border-box;text-align:center;">
<text style="font-size:28rpx;font-weight:700;color:#FFFFFF;">📤 {{ nc.buttonText || '立即分享' }}</text>
</view>
</view>
<!-- showLink -->
<view v-if="isFeatureOn(value.showLink)" style="margin-top:20rpx;text-align:center;position:relative;z-index:1;">
<text @tap="copyLink" style="font-size:22rpx;color:#999;text-decoration:underline;">{{ formatShareLink }}</text>
</view>
<!-- showQR -->
<view v-if="isFeatureOn(value.showQR)" style="margin-top:20rpx;display:flex;flex-direction:column;align-items:center;position:relative;z-index:1;">
<view :style="$util.objToStyle({ width: px2rpx(nc.qrSize || 200) + 'px', height: px2rpx(nc.qrSize || 200) + 'px', background: nc.qrBoxBackground || 'transparent', border: nc.qrBoxBorderWidth && nc.qrBoxBorderColor ? (px2rpx(nc.qrBoxBorderWidth) + 'px solid ' + nc.qrBoxBorderColor) : 'none', borderRadius: px2rpx(nc.qrBoxBorderRadius || 0) + 'px', padding: px2rpx(nc.qrBoxPadding || 0) + 'px', boxSizing: 'border-box' })">
<image :src="qrcodeRenderUrl" mode="aspectFit" style="width:100%;height:100%;display:block;" />
</view>
<text style="font-size:22rpx;color:#999;margin-top:12rpx;">{{ value.posterQrTip || '扫码进入并填写入驻信息' }}</text>
</view>
</view>
</block>
</view>
<!-- 登录组件占位(ns-login 全局 mixin 已注册) -->
@@ -382,6 +439,7 @@ function rcFallbackCopy(text, cb) {
/* =========================== 组件本体 =========================== */
import DiyMinx from './minx.js';
import golbalConfig from '@/common/js/golbalConfig.js';
import { extractInternalAppPath } from '@/common/js/util-navigate.js';
export default {
name: 'DiyRecommendCode',
mixins: [DiyMinx, golbalConfig],
@@ -429,6 +487,13 @@ export default {
qrBoxBackground: '', // 容器背景色;空=透明
qrBoxPadding: 0, // 容器内边距(rpx)
/* ============== 分享目标配置(后台可配,避免写死 pages_tool/form/formdata) ============== */
// H5 完整分享链接模板,必须包含 {code} 占位符;为空时走默认兜底链接
customShareLink: '',
// 微信小程序分享路径模板,必须包含 {code} 占位符;为空时自动从 customShareLink 还原
// 示例:'/pages_tool/index/diy?id=123&recommend_code={code}'
miniProgramPath: '',
/* ============== 兼容旧字段(不破坏之前商家已经配置过的卡片) ============== */
// 外间距
marginTop: 20, marginBottom: 20,
@@ -478,7 +543,7 @@ export default {
},
stylePreset() {
const s = String(this.nc.stylePreset || 'gradient').toLowerCase();
if (['minimal', 'card', 'bookgold', 'horizontal'].includes(s)) return s;
if (['minimal', 'card', 'bookgold', 'horizontal', 'style5'].includes(s)) return s;
return 'gradient';
},
requireConsentComputed() {
@@ -651,9 +716,13 @@ export default {
return 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=0&data=' + url;
},
formatShareLink() {
// 读取后台配置完整带{code}的模板
const template = this.nc.customShareLink || "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
// 把模板里 {code} 整体替换成 {推荐码}
// 读取后台配置完整带 {code} 的 H5 链接模板,未配置时兜底到原表单链接
// 兼容后台传对象 { wap_url, url } 或字符串的情况
let template = this._pickShareLinkTemplate(this.nc.customShareLink);
if (!template) {
template = "http://192.168.8.118/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code={code}";
}
// 把模板里 {code} 整体替换成当前推荐码
return template.replace(/\{code\}/g, this.code);
},
},
@@ -768,13 +837,13 @@ export default {
*/
onShareTimeline() {
const v = this.nc;
// 从H5分享链接中提取完整查询参数,与分享给好友保持一致
// 从小程序分享路径中提取完整查询参数,与分享给好友保持一致
var query = '';
try {
var shareLink = this.formatShareLink;
var queryIndex = shareLink.indexOf('?');
var miniPath = this.extractMiniProgramPath();
var queryIndex = miniPath.indexOf('?');
if (queryIndex >= 0) {
query = shareLink.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
query = miniPath.substring(queryIndex + 1); // id=13&uniacid=1&recommend_code=XXX
}
} catch(e) {}
if (!query && this.code) query = 'recommend_code=' + encodeURIComponent(this.code);
@@ -1248,20 +1317,55 @@ onShareClick() {
});
},
extractMiniProgramPath() {
// 从H5分享链接中提取完整查询参数,保持小程序分享与H5链接参数一致
// H5链接如: http://xxx/hwappx/common/pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
// 小程序路径如: /pages_tool/form/formdata?id=13&uniacid=1&recommend_code=XXX
// 1) 优先使用后台单独配置的小程序路径模板(支持 {code} 占位符)
const pathTemplate = this._pickShareLinkTemplate(this.nc.miniProgramPath);
if (pathTemplate) {
return this._ensureRecommendCode(pathTemplate.replace(/\{code\}/g, this.code || ''));
}
// 2) 否则从 H5 分享链接(后台「从平台链接中选择」或「自定义 URL」)中还原小程序内部路径
const shareLink = this.formatShareLink;
let miniPath = '';
try {
var queryIndex = shareLink.indexOf('?');
if (queryIndex >= 0) {
var query = shareLink.substring(queryIndex); // ?id=13&uniacid=1&recommend_code=XXX
return '/pages_tool/form/formdata' + query;
}
// 强制按内部链接还原:截取 /pages 或 /pages_xxx 起始段
miniPath = extractInternalAppPath(shareLink, true);
} catch(e) {}
// 兜底:只带recommend_code
if (!this.code) return '/pages_tool/form/formdata';
return '/pages_tool/form/formdata?recommend_code=' + encodeURIComponent(this.code);
// 如果是相对路径且未被上面识别,也视为内部路径
if (!miniPath && shareLink && shareLink.charAt(0) === '/') {
miniPath = shareLink;
}
// 兜底:原表单页面
if (!miniPath) {
miniPath = '/pages_tool/form/formdata';
}
return this._ensureRecommendCode(miniPath);
},
/**
* 确保小程序路径携带 recommend_code 参数
*/
_ensureRecommendCode(miniPath) {
if (!this.code) return miniPath;
try {
const hasCode = /[?&]recommend_code=/.test(miniPath);
if (hasCode) return miniPath;
const sep = miniPath.indexOf('?') >= 0 ? '&' : '?';
return miniPath + sep + 'recommend_code=' + encodeURIComponent(this.code);
} catch(e) { return miniPath; }
},
/**
* 统一解析后台配置的链接字段:支持字符串 / { wap_url } / { url }
*/
_pickShareLinkTemplate(raw) {
if (!raw) return '';
if (typeof raw === 'string') return raw.trim();
if (raw && typeof raw === 'object') {
if (typeof raw.wap_url === 'string' && raw.wap_url.trim()) return raw.wap_url.trim();
if (typeof raw.url === 'string' && raw.url.trim()) return raw.url.trim();
}
return '';
},
_persistRecommendInfo() {
try {

View File

@@ -1,6 +1,8 @@
<template>
<view data-component-name="diy-search" class="diy-search">
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
<!-- 卡片底板: 搜索框背后一圈底板, 与整体区域背景相互独立; 后台清空则透明(无底板) -->
<view class="search-board" :style="boardLayerStyle">
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
@tap.stop="handlerSearchClick">
<block v-if="[1, 2].includes(value.searchStyle)">
@@ -13,20 +15,20 @@
<view class="search-content" :style="inputStyle">
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
:placeholderStyle="placeholderStyle" />
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
</view>
</block>
<block v-if="value.searchStyle == 3">
<view class="search-content" :style="inputStyle"
@tap.stop="handlerSearchClick">
<text class="iconfont icon-sousuo3"
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
@tap.stop="handlerSearchClick"
:placeholderStyle="placeholderStyle" />
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
<text class="search-content-btn" @tap.stop="handlerSearchClick"
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
@@ -41,6 +43,7 @@
@tap.stop="handlerRedirectToClick(value.searchLink)"></diy-icon>
</block>
</view>
</view>
</view>
<!-- 解决fixed定位后导航栏塌陷的问题 -->
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
@@ -114,20 +117,90 @@ export default {
}
return obj;
},
// 搜索行框(紧包搜索输入框的一行, 含左侧 logo/图标): 背景色优先取后台"行框背景色",
// 未配置时回退到组件背景色(componentBgColor), 保持旧装修视觉; 兼容嵌套字段命名
searchWrapCss() {
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
var rowBg = this._probe([
'formBgColor', 'rowBgColor', 'lineBgColor', 'searchBoxBgColor', 'searchRowBgColor',
'boxBgColor', 'rowBackground', 'lineBackground', 'searchLineBgColor'
]) || this.value.componentBgColor || '';
if (rowBg) obj += 'background-color:' + rowBg + ';';
obj += 'text-align:' + this.value.textAlign + ';';
obj += this._radiusStyle(['formBorderRadius', 'rowRadius', 'rowAngle', 'lineRadius', 'searchBoxRadius', 'boxRadius', 'rowRound']);
obj += this._boxSpacingStyle('rowPadding', 'rowPaddingTop', 'rowPaddingBottom', 'rowPaddingLeft', 'rowPaddingRight');
obj += this._boxSpacingStyle('rowMargin', 'rowMarginTop', 'rowMarginBottom', 'rowMarginLeft', 'rowMarginRight', 'margin');
// 后台"搜索行框-内边距/外边距"真实字段 formPadding/formMargin 为带单位 shorthand(如 "0px")
obj += this._cssShorthand(['formPadding', 'rowPaddingStr'], 'padding');
obj += this._cssShorthand(['formMargin', 'rowMarginStr'], 'margin');
// 后台"搜索行框-四边样式"真实字段 formBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
obj += this._borderStyle('formBorder');
return obj;
},
// 搜索输入框: 背景色优先取后台"输入框背景色"(含旧字段 elementBgColor), 文本色同步 textColor;
// 高度/内边距/圆角(框体样式)取后台字段, 兼容嵌套命名与缺省
inputStyle() {
var obj = '';
obj += 'background-color:' + this.value.elementBgColor + ';';
if (this.value.borderType == 2) {
obj += 'border-radius:' + '40rpx;';
var inputBg = this._probe([
'elementBgColor', 'inputBgColor', 'searchInputBgColor', 'inputBackground'
]) || this.value.elementBgColor || '';
if (inputBg) obj += 'background-color:' + inputBg + ';';
if (this.value.textColor) obj += 'color:' + this.value.textColor + ';';
obj += this._boxSpacingStyle('inputPadding', 'inputPaddingTop', 'inputPaddingBottom', 'inputPaddingLeft', 'inputPaddingRight');
// 后台"输入框-内边距"真实字段 searchPadding 为带单位 shorthand(如 "0px")
obj += this._cssShorthand(['searchPadding', 'inputPaddingStr'], 'padding');
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
if (h !== null) obj += 'height:' + (h * 2) + 'rpx;';
// 框体样式(圆形): 新字段 round/circle/圆形 → 全圆角; 旧字段 borderType==2 → 40rpx
var shape = this._probe(['boxStyle', 'inputShape', 'frameStyle', 'borderShape', 'searchShape']);
if (shape && ['round', 'circle', '圆形', '1'].indexOf(String(shape).toLowerCase()) !== -1) {
obj += 'border-radius:999rpx;';
} else if (this.value.borderType == 2) {
obj += 'border-radius:40rpx;';
}
return obj;
},
// 卡片底板: 搜索框背后一圈底板; 后台"底板背景色/卡片底版"真实字段名为 containerBgColor(历史命名亦兼容)
boardLayerStyle() {
var obj = '';
// 探测候选名尽量覆盖后台各种命名(驼峰/snake_case/下划线都会被 _norm 归一化匹配)
var bg = this._probe([
'containerBgColor', 'containerBackground', 'containerColor',
'cardBgColor', 'boardBgColor', 'cardBackground', 'boardBackground',
'baseBgColor', 'panelBgColor', 'plateBgColor', 'bottomBoardBgColor', 'boardColor',
'backgroundColor', 'bgColor', 'background_color', 'bg_color', 'plateColor',
'bottomBgColor', 'basePlateBgColor', 'basePlateColor', 'cardPlateBgColor', 'cardBottomBgColor'
]);
if (bg) {
obj += 'background-color:' + bg + ';';
// 设了底板背景色却没单独配置内边距时, 搜索框会完全盖住底板 → 改了"没反应";
// 因此未配置 boardPadding 时给默认内边距, 让底板颜色作为卡片显出来
var hasBoardPad = this._probeNum(['boardPadding']) !== null
|| this._probeNum(['boardPaddingTop']) !== null
|| this._probeNum(['boardPaddingBottom']) !== null
|| this._probeNum(['boardPaddingLeft']) !== null
|| this._probeNum(['boardPaddingRight']) !== null
|| this._probe(['containerPadding']) !== '';
if (!hasBoardPad) obj += 'padding:16rpx 12rpx;';
}
obj += this._radiusStyle(['containerBorderRadius', 'boardRadius', 'boardAngle', 'cardRadius', 'baseRadius', 'boardRound']);
obj += this._boxSpacingStyle('boardPadding', 'boardPaddingTop', 'boardPaddingBottom', 'boardPaddingLeft', 'boardPaddingRight');
// 后台"底板-内边距"真实字段 containerPadding 为带单位 shorthand(如 "27px 10px 31px")
obj += this._cssShorthand(['containerPadding', 'boardPaddingStr'], 'padding');
obj += this._boxSpacingStyle('boardMargin', 'boardMarginTop', 'boardMarginBottom', 'boardMarginLeft', 'boardMarginRight', 'margin');
// 后台"底板-外边距"真实字段 containerMargin 为带单位 shorthand(如 "0px")
obj += this._cssShorthand(['containerMargin', 'boardMarginStr'], 'margin');
// 后台"底板-四边样式"真实字段 containerBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
obj += this._borderStyle('containerBorder');
return obj;
},
magnifierBg() {
return this._probe(['magnifierBgColor', 'searchIconBgColor', 'iconBgColor', 'magnifierBackground']) || '';
},
inputElStyle() {
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
return h !== null ? { height: (h * 2) + 'rpx' } : {};
},
placeholderStyle() {
var obj = '';
if (this.value.textColor) {
@@ -192,6 +265,93 @@ export default {
this.setModuleLocationFn();
},
methods: {
// —— 后台字段名探测(兼容多种命名 / 嵌套容器) ——
_norm(k) {
return String(k == null ? '' : k).toLowerCase().replace(/[_\-]/g, '');
},
// 在 value 自身及常见嵌套容器(style/config/setting/board/card/row/input/...)中按候选字段名探测,
// 命中即返回原值(含 transparent); 未命中返回 ''
_probe(candidates) {
var self = this;
var cands = candidates.map(function (c) { return self._norm(c); });
var v = this.value || {};
var sources = [v];
['style', 'config', 'setting', 'styleSetting', 'board', 'card', 'row', 'line', 'input', 'element', 'search', 'box', 'margin', 'padding']
.forEach(function (k) {
var o = v[k];
if (o && typeof o === 'object' && !Array.isArray(o)) sources.push(o);
});
for (var i = 0; i < sources.length; i++) {
var src = sources[i];
if (!src || typeof src !== 'object') continue;
var keys = Object.keys(src);
for (var j = 0; j < keys.length; j++) {
if (cands.indexOf(self._norm(keys[j])) !== -1) {
var val = src[keys[j]];
if (val !== undefined && val !== null && val !== '') return String(val);
}
}
}
return '';
},
_probeNum(candidates) {
var s = this._probe(candidates);
var n = parseFloat(s);
return isNaN(n) ? null : n;
},
// 后台带单位 shorthand 字段(如 "27px 10px 31px" / "0px"): 换算为 rpx(数值×2, 与组件内数值字段口径一致)后输出 prop:value;
// 未命中候选名返回 ''。无 px 单位的纯数字/其他单位原样透传
_cssShorthand(candidates, prop) {
var raw = this._probe(candidates);
if (raw === '') return '';
var s = String(raw).replace(/(-?\d+(?:\.\d+)?)px/g, function (m, n) {
return (parseFloat(n) * 2) + 'rpx';
});
return prop + ':' + s + ';';
},
// 后台四边独立边框字段: {prefix}{Top,Right,Bottom,Left}{Show,Color,Width,Style}
// Show 为 true/1 才输出该边; 输出格式 border-xxx: 宽(rpx×2) 线型 颜色
_borderStyle(prefix) {
var sides = ['top', 'right', 'bottom', 'left'];
var obj = '';
for (var i = 0; i < sides.length; i++) {
var s = sides[i];
var show = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Show']);
if (show === '' || show === 'false' || show === '0') continue;
var color = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Color']);
var width = this._probeNum([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Width']);
var style = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Style']);
if (width === null && !color) continue;
obj += 'border-' + s + ':' + ((width === null ? 0 : width) * 2) + 'rpx ' + (style || 'solid') + ' ' + (color || 'transparent') + ';';
}
return obj;
},
// 圆角: round/circle/圆形 → 全圆角; 数值 → 半径(rpx×2)
_radiusStyle(cands) {
var raw = this._probe(cands);
if (raw === '') return '';
var lower = String(raw).toLowerCase();
if (['round', 'circle', '圆形'].indexOf(lower) !== -1) return 'border-radius:999rpx;';
var n = parseFloat(raw);
if (!isNaN(n)) return 'border-radius:' + (n * 2) + 'rpx;';
return '';
},
// 内/外边距: 单值字段 → 四边; 否则上下左右独立字段。kind 默认 padding
_boxSpacingStyle(single, top, bottom, left, right, kind) {
kind = kind || 'padding';
var singleV = this._probeNum([single]);
if (singleV !== null) return kind + ':' + (singleV * 2) + 'rpx;';
var s = '';
var t = this._probeNum([top]);
var b = this._probeNum([bottom]);
var l = this._probeNum([left]);
var r = this._probeNum([right]);
if (t !== null) s += kind + '-top:' + (t * 2) + 'rpx;';
if (b !== null) s += kind + '-bottom:' + (b * 2) + 'rpx;';
if (l !== null) s += kind + '-left:' + (l * 2) + 'rpx;';
if (r !== null) s += kind + '-right:' + (r * 2) + 'rpx;';
return s;
},
search() {
this.$util.redirectTo('/pages_tool/goods/search');
},
@@ -264,6 +424,11 @@ export default {
overflow: hidden;
}
.search-board {
width: 100%;
box-sizing: border-box;
}
.fixed {
position: fixed;
left: 0;

View File

@@ -5,15 +5,18 @@
<!-- 顶部标签位置:标签导航 + 指示器 + 内容区域 -->
<template v-if="mergedValue.tabPosition === 'top'">
<!-- 标签导航 -->
<view class="tab-nav" :style="[tabNavStyle]">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
:class="{ active: activeTab === idx }"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
:class="{ active: activeTab === idx }" :id="getNavItemId(idx)"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
</view>
</view>
</view>
</scroll-view>
<!-- 指示器 -->
<view v-if="mergedValue.showIndicator" class="tab-indicator" :style="[
@@ -74,30 +77,36 @@
]"></view>
<!-- 标签导航 -->
<view class="tab-nav" :style="[tabNavStyle]">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
</view>
</view>
</template>
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
<template v-else-if="mergedValue.tabPosition === 'left'">
<view class="tab-layout-horizontal">
<!-- 左侧标签导航 -->
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
<scroll-view class="tab-nav" :style="[tabNavStyle]" scroll-x
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
<view class="tab-nav-inner" :style="[tabNavInnerStyle]">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item"
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
:id="getNavItemId(idx)"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
</view>
</view>
</scroll-view>
</template>
<!-- 左侧标签位置:标签导航 + 指示器 + 内容区域 (水平布局) -->
<template v-else-if="mergedValue.tabPosition === 'left'">
<view class="tab-layout-horizontal">
<!-- 左侧标签导航 -->
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
:id="getNavItemId(idx)"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
</view>
</scroll-view>
<!-- 左侧指示器 -->
<view v-if="mergedValue.showIndicator" class="tab-indicator tab-indicator-vertical" :style="[
@@ -161,15 +170,17 @@
]"></view>
<!-- 右侧标签导航 -->
<view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]">
<scroll-view class="tab-nav tab-nav-vertical" :style="[tabNavStyle]" scroll-y
:scroll-into-view="scrollIntoId" :scroll-with-animation="true">
<view v-for="(tab, idx) in mergedValue.tabs" :key="getTabKey(tab, idx)" class="tab-item tab-item-vertical"
:class="[{ active: activeTab === idx }, { 'hover-animation': mergedValue.animation }]"
:id="getNavItemId(idx)"
:style="[tabItemStyle, activeTab === idx ? tabItemActiveStyle : {}, tabItemHoverStyle]" @tap="switchTab(idx)">
<text class="tab-text" :style="[tabTextStyle, activeTab === idx ? tabTextActiveStyle : {}, mergedValue.animation ? tabTextHoverStyle : {}]">
{{ getTabTitle(tab.title) }}
</text>
</view>
</view>
</scroll-view>
</view>
</template>
</view>
@@ -208,6 +219,7 @@ export default {
data() {
return {
activeTab: this.value?.activeTabIndex ?? 0, // 设置当前激活的标签索引
scrollIntoId: '', // scroll-view 滚动定位的导航项 id
indicatorVars: { // 指示器CSS变量
top: '',
left: '',
@@ -229,6 +241,8 @@ export default {
// 初始化指示器位置
this.$nextTick(() => {
this.updateIndicatorPosition();
// 初始化时将激活标签滚动到可视区域
this.syncScrollIntoId();
});
},
@@ -417,14 +431,6 @@ export default {
// 对于水平布局(顶部和底部),设置固定高度
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
style.height = this.formatStyleValue(this.mergedValue.tabHeight, 24);
// 应用对齐方式
if (this.mergedValue.navAlign === 'start') {
style.justifyContent = 'flex-start';
} else if (this.mergedValue.navAlign === 'center') {
style.justifyContent = 'center';
} else if (this.mergedValue.navAlign === 'end') {
style.justifyContent = 'flex-end';
}
}
// 对于垂直布局(左侧和右侧),不设置固定高度,让它自适应内容
else {
@@ -451,6 +457,24 @@ export default {
return style;
},
// 导航栏内层容器样式(与后端版本一致)
tabNavInnerStyle() {
let style = {};
// 仅水平布局(顶部和底部)应用对齐方式
if (this.mergedValue.tabPosition === 'top' || this.mergedValue.tabPosition === 'bottom') {
if (this.mergedValue.navAlign === 'start') {
style.justifyContent = 'flex-start';
} else if (this.mergedValue.navAlign === 'center') {
style.justifyContent = 'center';
} else if (this.mergedValue.navAlign === 'end') {
style.justifyContent = 'flex-end';
}
}
return style;
},
// 标签项样式(与后端版本一致)
tabItemStyle() {
let style = {
@@ -601,9 +625,10 @@ export default {
// 监听器
watch: {
// 监听激活标签变化,更新指示器
// 监听激活标签变化,更新指示器并滚动导航
activeTab: {
handler() {
this.syncScrollIntoId();
this.$nextTick(() => {
this.updateIndicatorPosition();
});
@@ -724,6 +749,16 @@ export default {
getTabKey(tab, idx) {
return `tab-${tab?.id ?? idx}`;
},
// 获取导航项的 DOM id(用于 scroll-view 的 scroll-into-view 定位)
getNavItemId(idx) {
return `tab-nav-item-${idx}`;
},
// 将当前激活标签滚动到导航栏可视区域
syncScrollIntoId() {
this.$nextTick(() => {
this.scrollIntoId = this.getNavItemId(this.activeTab);
});
},
// 更新指示器位置(与后端版本一致)
updateIndicatorPosition() {
this.$nextTick(() => {
@@ -1034,8 +1069,6 @@ export default {
// ===== 标签导航栏样式 =====
.tab-nav {
display: flex;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid #e0e0e0;
overflow-x: auto;
@@ -1049,6 +1082,16 @@ export default {
}
}
// 导航栏内层容器:使用 inline-flex 让内容自然撑开宽度,保证小程序 scroll-view 可横向滚动
.tab-nav-inner {
display: inline-flex;
align-items: center;
width: max-content;
min-width: 100%;
height: 100%;
box-sizing: border-box;
}
// 垂直导航栏
.tab-nav-vertical {
flex-direction: column;
@@ -1068,6 +1111,7 @@ export default {
justify-content: center;
height: 100%;
user-select: none;
flex-shrink: 0; // 防止标签在 scroll-view 内被压缩,保证可完整展示并横向滑动
&.active {
color: var(--active-color, #339af0);

View File

@@ -0,0 +1,499 @@
<template>
<view data-component-name="diy-webview" class="diy-webview" :style="containerStyle" @tap="handleTap">
<template v-if="src">
<!-- #ifdef H5 -->
<iframe
v-if="!lazyPending"
class="diy-webview-iframe"
:src="src"
:scrolling="scrollMode"
referrerpolicy="no-referrer"
frameborder="0"
allow="fullscreen"
loading="lazy"
@load="handleIframeLoad"
@error="handleIframeError"
></iframe>
<!-- 后台配置的占位图 / 懒加载未进入视口时的展示 -->
<image
v-if="placeholderUrl && (lazyPending || !loaded)"
class="diy-webview-placeholder"
:src="placeholderUrl"
mode="aspectFill"
></image>
<!-- 加载中遮罩:iframe 触发 load 前展示,避免白屏观感 -->
<view v-if="!loaded && !loadError && !lazyPending" class="webview-loading" :style="errorStyle">
<view class="webview-loading-spinner"></view>
<text class="webview-loading-text">网页加载中...</text>
</view>
<!-- H5 下 iframe 内部点击不会冒泡到父容器,提供角标按钮保证"点击跳转"可用 -->
<view class="webview-open-chip" @tap.stop="mpGo">
<text class="iconfont icon-fuzhilianjie"></text>
<text class="webview-open-chip-text">{{ mpGoText }}</text>
</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<!-- 小程序/APP:内嵌 web-view 会整页覆盖原生层,改为卡片展示 + 点击跳转 -->
<view class='webview-mp-go' :style='mpGoStyle' @tap.stop='mpGo'>
<text class='iconfont icon-fuzhilianjie'></text>
<text class='webview-mp-go-text'>{{ mpGoText }}</text>
</view>
<!-- #endif -->
<!-- 加载失败兜底(如目标站禁止被嵌入/内核过旧加载失败):可点击直接打开 -->
<view v-if="loadError" class="webview-error" :style="errorStyle" @tap.stop="mpGo">
<text class="iconfont icon-fuzhilianjie"></text>
<text class="error-text">{{ errorText }}</text>
<text class="error-open">点击此处打开网页</text>
</view>
</template>
<view v-else class="webview-empty" :style="emptyStyle">
<text class="iconfont icon-fuzhilianjie"></text>
<text class="empty-text">请配置网页链接</text>
</view>
</view>
</template>
<script>
// 网页/WebView 组件:后台配置 URL 后嵌入展示,H5 使用 iframe,小程序/APP 使用原生 web-view
import DiyMinx from './minx.js'
export default {
name: 'diy-webview',
props: {
value: {
type: Object,
default: () => ({})
}
},
data() {
return {
src: '',
loaded: false,
loadError: false,
// 后台开启"懒加载"时,iframe 需等容器滚动进入视口后才真正渲染
lazyPending: false
};
},
mixins: [DiyMinx],
created() {
this.initWebview();
},
mounted() {
this._mountedFlag = true;
// #ifdef H5
if (this.lazyPending) this._setupLazyObserver();
// #endif
},
beforeDestroy() {
if (this._observer) {
try { this._observer.disconnect(); } catch (e) {}
this._observer = null;
}
this._clearLoadTimer();
},
watch: {
value: {
immediate: true,
deep: true,
handler(newVal) {
if (newVal) {
this.initWebview();
}
}
}
},
computed: {
containerStyle() {
let style = '';
// 后台高度为 px 值,与项目其他组件一致 ×2 转 rpx(diy-horz-blank 同款换算)
const height = Number(this.value?.height) || 300;
style += `height:${height * 2}rpx;`;
// 组件角度 round 时按后台圆角值渲染(×2 rpx)
if (this.value?.componentAngle === 'round') {
const topR = Number(this.value?.topAroundRadius) || 0;
const bottomR = Number(this.value?.bottomAroundRadius) || 0;
style += `border-radius:${topR * 2}rpx ${topR * 2}rpx ${bottomR * 2}rpx ${bottomR * 2}rpx;`;
}
if (this.value?.bgColor) {
style += `background:${this.value.bgColor};`;
}
if (this.value?.marginTop) {
style += `margin-top:${this.value.marginTop}rpx;`;
}
if (this.value?.marginBottom) {
style += `margin-bottom:${this.value.marginBottom}rpx;`;
}
if (this.value?.padding) {
style += `padding:${this.value.padding}rpx;`;
}
return style;
},
emptyStyle() {
let style = '';
if (this.value?.bgColor) {
style += `background:${this.value.bgColor};`;
}
return style;
},
errorStyle() {
let style = '';
if (this.value?.bgColor) {
style += `background:${this.value.bgColor};`;
}
return style;
},
mpGoText() {
// 注意:this.$lang(key) 在 key 不存在时返回 key 字符串本身,不能仅靠 this.$lang 是否存在判断。
// 取到 key 与传入 key 不同才视为命中语言包,否则用 fallback。
const t = this.$lang && this.$lang('components-diy.diy-webview.mpGo');
return (t && t !== 'components-diy.diy-webview.mpGo') ? t : '点击查看网页内容';
},
mpGoStyle() {
let style = '';
if (this.value && this.value.bgColor) {
style += 'background:' + this.value.bgColor + ';';
}
return style;
},
errorText() {
const t = this.$lang && this.$lang('components-diy.diy-webview.loadError');
return (t && t !== 'components-diy.diy-webview.loadError') ? t : '网页加载失败,请检查链接';
},
// iframe 滚动条开关(后台"滚动"配置:true 显示 / false 隐藏)
scrollMode() {
const s = this.value?.scrolling;
return (s === false || s === 'false' || s === 0) ? 'no' : 'yes';
},
// 占位图(后台 imageUrl,iframe 加载完成前展示)
placeholderUrl() {
const url = this.value?.imageUrl;
return url ? (this.$util.img ? this.$util.img(url) : url) : '';
}
},
methods: {
initWebview() {
const config = this.value || {};
this.loadError = false;
this.loaded = false;
// 调试用:把后台下发的完整 value 对象打印到控制台,方便定位 URL 字段名
console.log('[diy-webview] value:', JSON.stringify(config));
// 兼容后台可能使用的多种链接字段名/嵌套结构
const url = this.extractUrl(config);
// 懒加载:开启时等容器进入视口再渲染 iframe
this.lazyPending = !!(config.lazyRender || config.lazyLoad);
// #ifdef H5
// 数据热更新时(后台重新保存)若尚未进入过视口,需重建观察器(仅在挂载后才可建立)
if (this.lazyPending && this._mountedFlag) this._setupLazyObserver();
// #endif
// 优先使用后台直接下发的 url;若配置的是接口来源(source=api),则通过接口拉取
if (config.source === 'api' && config.apiUrl) {
this.loadUrlFromApi(config.apiUrl);
} else if (url) {
this.src = this.buildSrc(url);
console.log('[diy-webview] src:', this.src);
this._startLoadTimer();
} else {
this.src = '';
console.warn('[diy-webview] 未找到有效链接字段');
}
},
// 在格式化后的 URL 基础上,拼接后台配置的"附加参数"与"携带登录态"(token / member_id)
buildSrc(url) {
let src = this.formatUrl(url);
const config = this.value || {};
const extra = [];
// 附加参数(后台 urlParams 字段,格式 "a=1&b=2",多组参数用 & 连接)
const rawExtra = config.urlParams || config.extraParams || config.appendParams || config.additionalParams || config.params;
if (rawExtra && typeof rawExtra === 'string') {
extra.push(rawExtra.replace(/^[?&]+/, ''));
}
// 携带登录态(后台 carryToken 字段:none 不携带 / token / member_id)
const loginState = config.carryToken || config.loginState || config.login_state;
if (loginState === 'token') {
const token = (this.$store && this.$store.state && this.$store.state.token) || '';
if (token) extra.push('token=' + encodeURIComponent(token));
} else if (loginState === 'member_id') {
const memberInfo = (this.$store && this.$store.state && this.$store.state.memberInfo) || {};
const memberId = memberInfo.member_id || memberInfo.memberId || '';
if (memberId) extra.push('member_id=' + memberId);
}
if (extra.length) {
src += (src.indexOf('?') !== -1 ? '&' : '?') + extra.filter(Boolean).join('&');
}
return src;
},
// #ifdef H5
// 懒加载:容器滚动进入视口(含预加载缓冲 200px)后才渲染 iframe
_setupLazyObserver() {
try {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
this._observer = uni.createIntersectionObserver(this, { thresholds: [0] });
this._observer.relativeToViewport({ bottom: 200 }).observe('.diy-webview', () => {
this.lazyPending = false;
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
});
} catch (e) {
// 观察器异常时直接渲染,避免内容永远空白
this.lazyPending = false;
}
},
// #endif
extractUrl(config) {
if (!config || typeof config !== 'object') return '';
// 优先读取常见字段名(兼容后台不同命名习惯)
let direct = config.url || config.link || config.linkUrl || config.pageUrl || config.webUrl || config.web_url || config.externalUrl || config.targetUrl || config.src || config.href || config.path || config.address;
// 兼容装修链接选择器下发的对象形态:link = { wap_url / url / h5_url ... }
if (direct && typeof direct === 'object') {
direct = direct.wap_url || direct.h5_url || direct.url || direct.app_url || direct.link || '';
}
if (direct && typeof direct === 'string') return direct.trim();
// 兼容嵌套结构:如 value.value.url / value.config.url / value.data.url
for (const key of ['value', 'config', 'data', 'content', 'params']) {
if (config[key] && typeof config[key] === 'object') {
const nested = this.extractUrl(config[key]);
if (nested) return nested;
}
}
return '';
},
formatUrl(url) {
if (!url || typeof url !== 'string') return '';
let formatted = url.trim();
// 如果后台把 URL 编码存储,做一次解码
if (formatted.indexOf('%') !== -1) {
try { formatted = decodeURIComponent(formatted); } catch (e) {}
}
// 支持以 http:// 或 https:// 开头的完整地址
if (/^https?:\/\//i.test(formatted)) return formatted;
// 支持相对路径或站点内链接,自动补全
return this.$util.img ? this.$util.img(formatted) : formatted;
},
loadUrlFromApi(apiUrl) {
this.$api.sendRequest({
url: apiUrl,
success: res => {
if (res.code == 0 && res.data) {
const data = typeof res.data === 'string' ? { url: res.data } : res.data;
this.src = this.formatUrl(data.url || data.link || '');
}
},
fail: () => {
this.src = '';
}
});
},
updateNavbarTitle(e) {
// 参照 pages-tool/webview.vue:当 web-view 页面标题更新时,可同步设置导航栏标题
const title = e?.detail?.title || e?.title || '';
if (title) {
uni.setNavigationBarTitle({ title });
}
this.$emit('titleUpdate', e);
},
// 点击网页容器整体 → 跳转到配置的链接(走统一 diyRedirectTo,外链自动进 web-view / 新窗口)
handleTap() {
if (this.src) this.mpGo();
},
mpGo() {
if (!this.src) return;
if (this.$util && this.$util.diyRedirectTo) {
this.$util.diyRedirectTo({
wap_url: this.src,
title: (this.value && this.value.title) || (this.value && this.value.name) || '网页',
name: (this.value && this.value.name) ? this.value.name : '网页'
});
} else {
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(this.src) });
}
},
// 注意:方法名不能叫 onLoad/onError —— uni-app 会把组件里的 onLoad 当页面
// 生命周期提升出 methods,导致 @load="onLoad" 渲染时报 handler undefined。
handleIframeLoad(e) {
this.loaded = true;
this.loadError = false;
this._clearLoadTimer();
this.$emit('load', e);
},
handleIframeError(e) {
this.loadError = true;
this.loaded = true;
this._clearLoadTimer();
console.error('[diy-webview] 加载失败:', this.src, e);
this.$emit('error', e);
},
// 加载超时兜底:部分浏览器内核(如 HBuilderX 内置浏览器的老 CEF 内核)对跨域 iframe
// 请求失败时不会触发 error 事件,用定时器兜住,避免容器一直白屏
_startLoadTimer() {
this._clearLoadTimer();
// #ifdef H5
this._loadTimer = setTimeout(() => {
if (!this.loaded) {
this.loadError = true;
console.warn('[diy-webview] 加载超时(10s),显示兜底内容:', this.src);
}
}, 10000);
// #endif
},
_clearLoadTimer() {
if (this._loadTimer) {
clearTimeout(this._loadTimer);
this._loadTimer = null;
}
},
onMessage(e) {
// 接收 web-view / iframe 发来的消息,可用于与后台数据交互
this.$emit('message', e);
// 同时通过事件总线广播,方便页面级监听
this.__$emitEvent({
eventName: 'diy-webview-message',
data: {
componentName: this.value?.componentName,
moduleIndex: this.value?.moduleIndex,
message: e?.detail?.data || e?.data
}
});
}
}
};
</script>
<style lang="scss">
.diy-webview {
width: 100%;
position: relative;
overflow: hidden;
box-sizing: border-box;
.diy-webview-iframe,
.diy-webview-native {
width: 100%;
height: 100%;
display: block;
border: none;
}
/* 占位图 / 懒加载未进入视口时的展示 */
.diy-webview-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* H5 角标跳转按钮:iframe 内部点击不冒泡,用它兜底"点击跳转" */
.webview-open-chip {
position: absolute;
right: 16rpx;
bottom: 16rpx;
z-index: 3;
display: flex;
align-items: center;
padding: 8rpx 20rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 22rpx;
line-height: 1;
.iconfont {
font-size: 24rpx;
margin-right: 6rpx;
}
}
/* 加载中遮罩 */
.webview-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f7f8fa;
color: #999;
font-size: 24rpx;
}
.webview-loading-spinner {
width: 44rpx;
height: 44rpx;
border: 4rpx solid #e5e6eb;
border-top-color: #576b95;
border-radius: 50%;
margin-bottom: 16rpx;
animation: webview-spin 0.8s linear infinite;
}
@keyframes webview-spin {
to {
transform: rotate(360deg);
}
}
.webview-empty,
.webview-mp-go {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #576B95;
font-size: 26rpx;
.iconfont {
font-size: 48rpx;
margin-bottom: 12rpx;
}
}
.webview-error {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #999;
font-size: 26rpx;
.iconfont {
font-size: 48rpx;
margin-bottom: 12rpx;
}
}
.webview-error {
position: absolute;
top: 0;
left: 0;
z-index: 2;
background: rgba(255, 255, 255, 0.96);
color: #ff6b35;
.error-open {
margin-top: 16rpx;
font-size: 24rpx;
color: #576b95;
text-decoration: underline;
}
}
}
</style>

View File

@@ -1,5 +1,56 @@
export default {
methods: {
/**
* 读取后台装修下发的布尔开关
* 依次取 names 中的字段, 兼容 1/0、"1"/"0"、true/false、"true"/"false" 等值类型
* 命中返回 true/false; 所有字段都未下发时返回 undefined (由调用方决定兜底)
*
* 匹配顺序:
* 1. 精确命中 (showAddCart / show_add_cart ...)
* 2. 忽略大小写、下划线、中划线、空格的差异 (showaddcart === showAddCart === show_add_cart)
* —— 后台实际字段名与前端约定写法不一致时, 这一步兜底
* 3. 常见嵌套容器 (value.style / value.config / value.setting / value.data)
*/
getDiyToggle(names) {
const v = this.value || {};
const parse = val => {
if (val === undefined || val === null || val === '') return undefined;
if (val === true || val === 1 || val === '1' || val === 'true') return true;
if (val === false || val === 0 || val === '0' || val === 'false') return false;
return !!val;
};
const pick = obj => {
if (!obj || typeof obj != 'object') return undefined;
// 1. 精确命中
let i, res;
for (i = 0; i < names.length; i++) {
res = parse(obj[names[i]]);
if (res !== undefined) return res;
}
// 2. 忽略大小写/下划线等差异
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
const wanted = names.map(norm);
for (const key in obj) {
if (wanted.indexOf(norm(key)) != -1) {
res = parse(obj[key]);
if (res !== undefined) return res;
}
}
return undefined;
};
const root = pick(v);
if (root !== undefined) return root;
// 3. 嵌套容器兜底
const containers = ['style', 'config', 'setting', 'data'];
for (let i = 0; i < containers.length; i++) {
const res = pick(v[containers[i]]);
if (res !== undefined) return res;
}
return undefined;
},
// 异步触发事件
async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)

View File

@@ -1,9 +1,10 @@
<template>
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
<view>
<view v-if="pageCount === 1 || need" class="fixed-box"
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
:style="[customContainerStyle, {
height: containerHeight,
width: containerWidth,
bottom: containerBottom,
right: containerRight,
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
@@ -11,18 +12,18 @@
}]">
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
<view
v-if="isLanguageSwitchEnabled && fixBtnShow"
class="btn-item common-bg"
:style="btnItemStyle"
v-if="isLanguageSwitchEnabled && hasLangButton"
class="btn-item common-bg btn-lang"
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
@click="handleLangButtonClick"
>
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
<view class="lang-btn-text">{{ langButtonText }}</view>
</view>
<!-- 微信官方客服按钮-->
<button
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
class="btn-item common-bg"
:style="btnItemStyle"
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
class="btn-item common-bg btn-kefu"
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
open-type="contact"
>
<text class="icox icox-kefu" v-if="!kefuimg"></text>
@@ -30,9 +31,9 @@
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
<view
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
class="btn-item common-bg"
:style="btnItemStyle"
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
class="btn-item common-bg btn-kefu"
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
@click="handleUnifiedKefuClick"
>
<image
@@ -48,9 +49,9 @@
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
<view
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
class="btn-item common-bg"
:style="btnItemStyle"
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
class="btn-item common-bg btn-kefu"
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
@click="handleUnifiedKefuClick"
>
<image
@@ -66,9 +67,9 @@
<!-- 小程序系统客服按钮 -->
<button
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
class="btn-item common-bg"
:style="btnItemStyle"
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
class="btn-item common-bg btn-kefu"
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
open-type="contact"
>
<text class="icox icox-kefu" v-if="!kefuimg"></text>
@@ -76,9 +77,9 @@
<!-- 电话按钮 -->
<view
v-if="fixBtnShow"
class="btn-item common-bg"
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
v-if="hasPhoneButton"
class="btn-item common-bg btn-phone"
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
@click="call"
>
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
@@ -109,6 +110,10 @@
<script>
import { createCustomerService } from '@/common/js/customer-service.js';
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
import {
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
resolveLangButtonText, isLangButtonVisible,
} from '@/common/js/floating-button.js';
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
// key: 语言 value,value: 显示短码(按钮上展示)
@@ -161,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
return prefix.toUpperCase();
}
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
let cachedAvoidBottom = null;
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
// /pages_goods/category 商品分类
// /pages_tool/contact/contact 电子名片(名片)
const H5_HIDDEN_ROUTES = [
'/pages_goods/category',
'/pages_tool/contact/contact',
];
export default {
name: 'hover-nav',
mixins: [i18nLazyMixin],
@@ -170,7 +189,7 @@ export default {
data() {
return {
pageCount: 0,
fixBtnShow: true,
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
shopInfo: null,
currentLangIndex: 0,
langIndexMap: {},
@@ -190,6 +209,12 @@ export default {
layoutTopBottom: 272, // --top-btn-bottom
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
layoutFloatingWidth: 100, // --floating-btn-width
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
noticeRect: null, // {top,bottom,height} 视口坐标(px)
winW: 0, // 视口宽(px)
winH: 0, // 视口高(px)
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
i18n: {
@@ -237,32 +262,75 @@ export default {
customButtonStyle() {
return this.shopInfo?.floatingButton?.button || {};
},
// ============================================================
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
// ============================================================
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
fbConfig() {
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
},
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
hiddenOnRoute() {
const r = this.currentRoute;
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
},
// 总开关 + 强制隐藏 + 页面范围 判定结果
fbVisible() {
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
},
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
visibleButtons() {
return resolveVisibleButtons(this.fbConfig);
},
hasAnyButton() {
const b = this.visibleButtons;
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
},
hasLangButton() {
const b = this.visibleButtons;
return b ? b.indexOf('lang') !== -1
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
},
hasKefuButton() {
const b = this.visibleButtons;
return b ? b.indexOf('kefu') !== -1 : true;
},
hasPhoneButton() {
const b = this.visibleButtons;
return b ? b.indexOf('phone') !== -1 : true;
},
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
langButtonText() {
if (!this.fbConfig) return this.currentLangDisplayName;
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
},
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
orderIndex() {
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
? this.fbConfig.button_order
: ['lang', 'kefu', 'phone'];
const map = {};
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
return map;
},
// 所有 .btn-item 的 inline-style 兜底:
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
btnItemStyle() {
return {
width: '72rpx',
height: '72rpx',
'min-width': '72rpx',
'min-height': '72rpx',
'max-width': '72rpx',
'max-height': '72rpx',
'aspect-ratio': '1',
// border-radius 保留作 clip-path 不支持时的 fallback
'border-radius': '50%',
'flex-shrink': '0',
'flex-grow': '0',
'align-self': 'center',
'box-sizing': 'border-box',
padding: '0',
margin: '14rpx 0',
overflow: 'hidden',
};
},
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
// hover_nav_lang_btn_size / hover_nav_lang_size
// hover_nav_kefu_btn_size / hover_nav_kefu_size
// hover_nav_phone_btn_size / hover_nav_phone_size
// 未配置时回退 72rpx(与原行为一致)。
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
unreadCount() {
const customerService = createCustomerService(this);
return customerService.getUnreadCount();
@@ -296,53 +364,101 @@ export default {
return customerService.hasValidKefuConfig();
},
//根据显示的按钮数量动态计算容器高度
// 计算明细(buttonCount 按钮场景):
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
// 总高度 (rpx):buttonCount × 100 + 40
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
renderedBtnSizes() {
const b = this.visibleButtons;
if (b && b.length === 0) return [];
const sizes = [];
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
sizes.push(this.btnSizeRpx('lang'));
}
if (this.hasKefuButton && this.hasValidKefuConfig) {
sizes.push(this.btnSizeRpx('kefu'));
// 小程序系统客服与客服主按钮并存时, 模板会多渲染一个按钮
if (this.showWeappSystemKefu && !this.isWeappOfficialKefu) {
sizes.push(this.btnSizeRpx('kefu'));
}
}
if (this.hasPhoneButton) {
sizes.push(this.btnSizeRpx('phone'));
}
return sizes;
},
// 根据显示的按钮数量与各自尺寸动态计算容器高度
// 每个按钮槽位 = 按钮尺寸 + 14rpx(上 margin) + 14rpx(下 margin)
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
containerHeightNum() {
const sizes = this.renderedBtnSizes;
if (sizes.length === 0) return 0;
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
},
containerHeight() {
if (!this.fixBtnShow) return '320rpx';
let buttonCount = 1;
if (this.isLanguageSwitchEnabled) buttonCount++;
if (this.showWeappSystemKefu) buttonCount++;
buttonCount++;
const totalRpx = buttonCount * 100 + 40;
return `${totalRpx}rpx`;
return this.rpxStr(this.containerHeightNum);
},
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
containerWidthNum() {
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
},
containerWidth() {
return this.rpxStr(this.containerWidthNum);
},
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
// containerBottom = Top 顶部边界 + space
// ------------------------------------------------------------
// space 是经验调优值(非数学推导),手调结果:
// H5 : 18 * 5 = 90rpx
// MP-WEIXIN: 18 * 1 = 18rpx
// 为什么 H5 反而大、微信反而小:
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
// 18rpx 就能有清晰的呼吸感。
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
// space 两端统一为 18rpx:
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
// ------------------------------------------------------------
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
// 两者会被安全区同步上推,space 保持不变
containerBottom() {
if (!this.fixBtnShow) return '240rpx';
let space = 18 * 5;
// #ifdef MP-WEIXIN
space = 18 * 1;
// #endif
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
const space = 18;
const base = this.layoutTopBottom + this.layoutTopSize + space;
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
const rect = this.noticeRect;
let target = null;
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
let scrollY = 0;
try {
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
scrollY = window.pageYOffset || 0;
} else if (typeof document !== 'undefined' && document.documentElement) {
scrollY = document.documentElement.scrollTop || 0;
}
} catch (e) { scrollY = 0; }
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
const docBottom = rect.bottom + scrollY;
const distFromBottom = (this.winH - docBottom) * scaleInv;
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
const below = distFromBottom - gap - this.containerHeightNum;
if (Number.isFinite(below)) {
target = Math.max(0, below);
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
}
}
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
return this.rpxStr(target === null ? base : target);
},
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
@@ -357,9 +473,10 @@ export default {
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
// center 对齐不仅中心精准,突出量也更小
containerRight() {
if (!this.fixBtnShow) return '0rpx';
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
return `${offset}rpx`;
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
return this.rpxStr(offset);
},
},
watch: {
@@ -420,9 +537,14 @@ export default {
}
}
},
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
onShow() {
this.refreshRoute();
},
created() {
this.initLanguage();
this.pageCount = getCurrentPages().length;
this.refreshRoute();
// 三源兜底(store / 同步 storage / 异步 storage),
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
@@ -512,6 +634,23 @@ export default {
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
this.updateViewport();
this.measureNotice();
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
this._noticeTimers = [
setTimeout(() => this.measureNotice(), 300),
setTimeout(() => this.measureNotice(), 800),
];
// #ifdef H5
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
this._scrollHandler = this._onScroll.bind(this);
window.addEventListener('resize', this._scrollHandler);
// #endif
},
beforeDestroy() {
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
@@ -520,9 +659,163 @@ export default {
this._origParent.appendChild(this.$el);
this._origParent = null;
}
// 清理公告栏避让的滚动/缩放监听与定时器
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler);
window.removeEventListener('resize', this._scrollHandler);
this._scrollHandler = null;
}
if (this._noticeTimers) {
this._noticeTimers.forEach((t) => clearTimeout(t));
this._noticeTimers = null;
}
if (this._scrollRAF) {
cancelAnimationFrame(this._scrollRAF);
this._scrollRAF = null;
}
// #endif
},
methods: {
// ============================================================
// rpx 换算工具(内联样式单位跨端兼容的关键)
// ------------------------------------------------------------
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
// 小程序端保持 rpx 原样输出。
// ============================================================
// #ifdef H5
rpxNum(n) {
const n2 = Number(n) || 0;
try {
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
return (n2 * w) / 750;
} catch (e) {
return n2 / 2; // window 异常时按 375px 视口兜底
}
},
rpxStr(n) {
return this.rpxNum(n) + 'px';
},
// #endif
// #ifndef H5
rpxNum(n) {
return Number(n) || 0;
},
rpxStr(n) {
return (Number(n) || 0) + 'rpx';
},
// #endif
// ============================================================
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
// ------------------------------------------------------------
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
// containerBottom 据此把按钮放到公告栏底边之下。
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
// ============================================================
// 更新视口尺寸(换算 rpx 用)
updateViewport() {
try {
if (typeof window !== 'undefined' && window.innerWidth) {
this.winW = window.innerWidth;
this.winH = window.innerHeight;
} else {
const info = uni.getSystemInfoSync();
this.winW = info.windowWidth || 375;
this.winH = info.windowHeight || 667;
}
} catch (e) {
this.winW = this.winW || 375;
this.winH = this.winH || 667;
}
},
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
measureNotice() {
try {
const q = uni.createSelectorQuery();
q.select('.diy-notice').boundingClientRect((rect) => {
if (rect && rect.height > 0) {
this.noticeRect = {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
};
} else {
this.noticeRect = null; // 无公告栏 → 基础定位
}
}).exec();
} catch (e) {
this.noticeRect = null;
}
},
// #ifdef H5
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
_onScroll() {
if (this._scrollRAF) return;
this._scrollRAF = requestAnimationFrame(() => {
this._scrollRAF = null;
this.updateViewport();
this.measureNotice();
});
},
// #endif
// 读取逐按钮尺寸 token(rpx 数值):
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
btnSizeRpx(type) {
let theme = null;
try {
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
} catch (e) { theme = null; }
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
if (theme && typeof theme === 'object') {
for (let i = 0; i < keys.length; i++) {
const raw = theme[keys[i]];
if (raw === undefined || raw === null || raw === '') continue;
const n = parseFloat(raw);
if (Number.isFinite(n) && n > 0) return n;
}
}
return 72;
},
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
btnItemStyle(type) {
const s = this.rpxStr(this.btnSizeRpx(type));
return {
width: s,
height: s,
'min-width': s,
'min-height': s,
'max-width': s,
'max-height': s,
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
'--btn-size': s,
'aspect-ratio': '1',
// border-radius 保留作 clip-path 不支持时的 fallback
'border-radius': '50%',
'flex-shrink': '0',
'flex-grow': '0',
'align-self': 'center',
'box-sizing': 'border-box',
padding: '0',
margin: this.rpxStr(14) + ' 0',
overflow: 'hidden',
};
},
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
refreshRoute() {
this.currentRoute = getCurrentRoute();
this.pageCount = getCurrentPages().length;
},
// #ifdef H5
/**
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
@@ -534,9 +827,13 @@ export default {
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
const root = document.documentElement;
const computed = getComputedStyle(root);
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
const getNum = (name, fallback) => {
const v = parseFloat(computed.getPropertyValue(name));
return Number.isFinite(v) ? v : fallback;
if (!Number.isFinite(v) || scale <= 0) return fallback;
return v / scale; // px → 设计稿 rpx 值
};
this.layoutTopRight = getNum('--top-btn-right', 46);
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
@@ -821,8 +1118,13 @@ export default {
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
width: var(--floating-btn-width, 100rpx);
box-sizing: border-box;
transition: 0.3s;
overflow: hidden;
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
overflow: visible;
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
margin-bottom: var(--window-bottom);
}
@@ -830,6 +1132,9 @@ export default {
.btn-item {
display: flex;
justify-content: center;
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
align-items: center;
text-align: center;
flex-direction: column;
line-height: 1;
@@ -864,22 +1169,42 @@ export default {
background-color: var(--hover-nav-bg-color);
}
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
.btn-lang {
background-color: var(--hover-nav-lang-bg-color, var(--hover-nav-bg-color));
color: var(--hover-nav-lang-text-color, var(--hover-nav-text-color));
}
.btn-kefu {
background-color: var(--hover-nav-kefu-bg-color, var(--hover-nav-bg-color));
color: var(--hover-nav-kefu-text-color, var(--hover-nav-text-color));
}
.btn-phone {
background-color: var(--hover-nav-phone-bg-color, var(--hover-nav-bg-color));
color: var(--hover-nav-phone-text-color, var(--hover-nav-text-color));
}
.btn-item text {
font-size: 28rpx;
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
font-size: var(--hover-nav-font-size, 36rpx);
}
.iconfont,
.icox {
font-size: 36rpx;
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
font-size: var(--hover-nav-font-size, 36rpx);
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
* 直接固定 72x72 更稳。 */
width: 72rpx;
height: 72rpx;
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
width: var(--btn-size, 72rpx);
height: var(--btn-size, 72rpx);
margin: 0;
border-radius: 50%;
padding: 0;
@@ -897,9 +1222,17 @@ export default {
0 -1rpx 0 rgba(0, 0, 0, 0.18);
}
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
.iconfont.icon-dianhua {
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
}
.icox.icox-kefu {
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
}
.iconfont text,
.icox text {
font-size: 36rpx;
font-size: var(--hover-nav-font-size, 36rpx);
font-weight: bold;
}
@@ -911,7 +1244,8 @@ export default {
}
.ai-icon {
font-size: 40rpx;
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
width: 100%;
height: 100%;
display: flex;
@@ -930,7 +1264,8 @@ export default {
justify-content: center;
width: 100%;
height: 100%;
font-size: 24rpx;
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
line-height: 1;
font-weight: 600;
letter-spacing: 0;

View File

@@ -4,8 +4,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box">
<input
@@ -22,8 +22,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
<view class="order-cell flex-box textarea" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box">
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
@@ -48,11 +48,11 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<view :class="['box', 'box-flex']">
<text v-if="item.val != ''">{{ item.val }}</text>
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
<text class="iconfont icon-right"></text>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
</picker>
@@ -60,8 +60,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
@@ -80,8 +80,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
@@ -100,8 +100,8 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box img-boxs">
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
@@ -116,23 +116,23 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box box-flex">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<view :class="['box', 'box-flex']">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -157,23 +157,23 @@
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box box-flex">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<view :class="['box', 'box-flex']">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)">
<view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker>
<text class="iconfont icon-right"></text>
</view>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
<view class="order-cell flex-box" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view class="box date-boxs">
<view class="date-box">
@@ -196,20 +196,20 @@
</view>
<view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
<view class="order-cell box-flex" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<view class="order-cell" :style="rowCellStyle">
<view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text>
</view>
<view :class="['box', 'box-flex']">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
</view>
</pick-regions>
<text class="iconfont icon-right"></text>
</view>
</view>
<view class="box">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)">
<view class="select-address " :class="{ empty: !item.val, 'color-tip': !item.val }">
{{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
</view>
</pick-regions>
</view>
<text class="iconfont icon-right"></text>
</view>
</view>
</block>
</view>
@@ -263,22 +263,22 @@
config() {
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
const defaults = {
formPaddingX: 0,
formPaddingX: 28,
formBgColor: '#ffffff',
rowPaddingY: 20,
rowPaddingY: 24,
rowMinHeight: 60,
labelGap: 16,
inputHeight: 60,
inputRadius: 0,
labelWidthMode: 'fixed', // 旧版 width: 80vw 用 fixed 180rpx 等价替代
labelGap: 20,
inputHeight: 84,
inputRadius: 16,
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
labelFixedWidth: 180,
labelFontSize: 28,
labelFontSize: 32,
labelColor: '#1f2937',
labelFontWeight: 600,
labelFontWeight: 700,
labelAutoMaxWidth: 200,
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#909399',
checkedColor: '#3b82f6',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-newform 对齐)
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
@@ -404,13 +404,10 @@
'min-height': this.$util.rpx(this.config.rowMinHeight)
});
},
// 标签宽度:根据 labelWidthMode 决定;fixed 用 --label-fixed-width,auto 用 maxLabelWidth
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
nameWidthStyle() {
const cfg = this.config;
if (cfg.labelWidthMode === 'fixed') {
return { width: this.$util.rpx(cfg.labelFixedWidth) };
}
return this.maxLabelWidth ? { width: this.$util.rpx(this.maxLabelWidth) } : { width: 'auto' };
return {};
},
// 输入框样式(input / textarea 共用)
inputStyle() {
@@ -948,31 +945,31 @@
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
.form-wrap {
--form-padding-x: 0rpx;
--form-padding-x: 28rpx;
--form-bg-color: #ffffff;
--row-padding-y: 20rpx;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 16rpx;
--input-height: 60rpx;
--input-radius: 0rpx;
--label-gap: 20rpx;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--label-font-weight: 600;
--label-font-weight: 700;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #ff4544;
--placeholder-color: #909399;
--checked-color: #3b82f6;
--placeholder-color: #9ca3af;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 44rpx;
--textarea-line-height: 48rpx;
// 输入框视觉增强(与 ns-newform 对齐)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--show-row-divider: 1;
--show-row-divider: 0;
}
.order-wrap {
@@ -990,7 +987,8 @@
.order-cell {
display: flex;
align-items: center;
flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
align-items: flex-start;
background: #fff;
position: relative;
min-height: var(--row-min-height, 60rpx);
@@ -1023,18 +1021,23 @@
}
.name {
width: var(--label-fixed-width, 180rpx);
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--label-gap, 16rpx);
display: flex;
align-items: center;
min-height: var(--input-height, 60rpx);
line-height: 1.5;
margin-bottom: var(--label-gap, 20rpx);
margin-right: 0;
}
.tit {
text-align: left;
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
font-weight: var(--label-font-weight, 600);
font-weight: var(--label-font-weight, 700);
text {
font-size: var(--label-font-size, $font-size-base);
@@ -1044,15 +1047,17 @@
.required {
color: var(--required-color, #ef4444);
font-size: var(--label-font-size, 28rpx);
margin-left: 4rpx;
width: 14rpx;
margin-right: 6rpx;
font-weight: bold;
text-align: left;
display: inline-block;
vertical-align: middle;
}
.box {
flex: 1;
padding: 0 10rpx;
width: 100%;
padding: 0;
line-height: inherit;
text-align: left;
@@ -1064,26 +1069,27 @@
color: var(--label-color, #1f2937);
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
// 避免 placeholder / 文字下半部分被裁切
line-height: var(--input-height, 60rpx);
height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
border-radius: var(--input-radius, 0rpx);
line-height: var(--input-height, 84rpx);
height: var(--input-height, 84rpx);
min-height: var(--input-height, 84rpx);
border-radius: var(--input-radius, 16rpx);
box-sizing: border-box;
padding: 0 12rpx;
width: 100%;
padding: 0 24rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
background: var(--input-bg-color, #ffffff);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
&::placeholder {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
@@ -1091,9 +1097,9 @@
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
.placeholder {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
line-height: var(--input-height, 60rpx);
line-height: var(--input-height, 84rpx);
}
::v-deep textarea,
@@ -1101,36 +1107,36 @@
font-size: var(--label-font-size, $font-size-base);
width: 100%;
color: var(--label-color, #1f2937);
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
// 真正做到了"rows 改 → 高度自动改"。
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
line-height: var(--textarea-line-height, 48rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 48rpx) + 26rpx);
text-align: left;
border-radius: var(--input-radius, 0rpx);
border-radius: var(--input-radius, 16rpx);
box-sizing: border-box;
padding: 12rpx;
padding: 12rpx 24rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
background: var(--input-bg-color, #ffffff);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
border-color: var(--input-focus-border-color, #3b82f6);
}
}
.uni-input {
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
line-height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
line-height: var(--input-height, 84rpx);
min-height: var(--input-height, 84rpx);
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 0rpx);
background: var(--input-bg-color, #ffffff);
border-radius: var(--input-radius, 16rpx);
transition: all 0.3s ease;
}
@@ -1140,26 +1146,36 @@
}
.color-tip {
color: var(--placeholder-color, #909399);
color: var(--placeholder-color, #9ca3af);
}
checkbox-group {
display: flex;
flex-wrap: wrap;
// 让原生 picker / 自定义 pick-regions 撑满父容器(纵向布局下 label 在上、控件在下)
picker,
.pick-regions {
display: block;
width: 100%;
min-width: 0;
}
// 在 box-flex(带右侧箭头)场景下,picker 不应占满,留出右侧箭头空间
&.box-flex {
picker,
.pick-regions {
width: auto;
}
.iconfont.icon-right {
flex-shrink: 0;
margin-left: 8rpx;
line-height: 1;
}
}
checkbox-group,
radio-group {
display: flex;
flex-wrap: wrap;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
width: 100%;
}
label {
@@ -1169,6 +1185,39 @@
margin-right: 30rpx;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
// 注意:option-layout-* 是 .box 自身的类,必须用 & 嵌套,否则编译成 .box .option-layout-* 会选不中
&.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
label {
width: 100%;
margin-right: 0;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
}
&.option-layout-wrap {
checkbox-group,
radio-group {
flex-direction: row;
align-items: center;
}
label {
margin-right: 30rpx;
margin-bottom: 0;
}
}
&.img-boxs {
display: flex;
align-items: center;
@@ -1244,10 +1293,11 @@
align-items: center;
line-height: 1;
.iconfont {
font-size: 32rpx;
margin-right: 10rpx;
}
.iconfont {
font-size: 40rpx;
margin-right: 10rpx;
color: #d1d5db;
}
}
}
@@ -1261,10 +1311,11 @@
align-items: center;
line-height: 1;
.iconfont {
font-size: 32rpx;
margin-right: 10rpx;
}
.iconfont {
font-size: 40rpx;
margin-right: 10rpx;
color: #d1d5db;
}
}
}

View File

@@ -325,14 +325,69 @@ export default {
// #ifdef MP-WEIXIN
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
// "privacy permission is not authorized or buttonId is invalid"。
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
this.ensureWxPrivacyAuthorized(() => {
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
});
});
// #endif
},
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
ensureWxPrivacyAuthorized(done) {
try {
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
// 低版本基础库无隐私 API, 直接放行
done();
return;
}
wx.getPrivacySetting({
success: (res) => {
if (!res || !res.needAuthorization) {
// 已授权或无隐私指引配置, 直接放行
done();
return;
}
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
const onAgreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
done();
};
const onDisagreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
};
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
uni.$on('privacy:agreed', onAgreed);
uni.$on('privacy:disagreed', onDisagreed);
popup.showPrivacyPopup();
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
// 无 privacy-popup 实例时退回微信官方隐私弹窗
wx.requirePrivacyAuthorize({
success: () => done(),
fail: () => { }
});
}
},
fail: () => done()
});
} catch (e) {
done();
}
},
// 跳转去登录页
toLoginH5Page(mode = 'navigateTo') {
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
@@ -614,8 +669,24 @@ export default {
}
});
} else {
const errMsg = (e.detail && e.detail.errMsg) || '';
// #ifdef MP-WEIXIN
// 隐私协议未授权时 getPhoneNumber 会直接失败:
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
if (errMsg.indexOf('privacy') !== -1) {
this.$util.showToast({
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
});
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
popup.showPrivacyPopup();
}
return;
}
// #endif
this.$util.showToast({
title: e.detail.errMsg
title: errMsg
})
}
},

View File

@@ -0,0 +1,247 @@
<template>
<!--
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
使用前提:
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
-->
<view
v-if="visible"
class="ns-mp-navbar"
:class="{ 'is-fixed': fixed }"
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
>
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
<view class="ns-mp-navbar__side">
<view
v-if="showBack && canBack"
class="ns-mp-navbar__back"
hover-class="ns-mp-navbar__back--hover"
hover-stay-time="80"
@tap="onBack"
>
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
</view>
<slot name="left"></slot>
</view>
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
<slot name="right"></slot>
</view>
</view>
</view>
</template>
<script>
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
let systemInfo = {};
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
try {
systemInfo = uni.getSystemInfoSync() || {};
} catch (e) {
systemInfo = {};
}
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
try {
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
} catch (e) {}
// #endif
export default {
name: 'ns-mp-navbar',
props: {
// 中间标题
title: {
type: String,
default: ''
},
// 导航栏背景色
bgColor: {
type: String,
default: '#ffffff'
},
// 文字 / 图标颜色
color: {
type: String,
default: '#000000'
},
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
backText: {
type: String,
default: '返回'
},
// 是否显示返回按钮
showBack: {
type: Boolean,
default: true
},
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
fixed: {
type: Boolean,
default: false
},
// 无页面栈时(如分享直接进入)跳转的首页地址
homePath: {
type: String,
default: '/pages/index/index'
},
// 自定义返回逻辑
customBack: {
type: Function,
default: null
},
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
showOnH5: {
type: Boolean,
default: false
}
},
data() {
return {
statusBarHeight: systemInfo.statusBarHeight || 20,
navBarHeight: 44,
visible: true,
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
canBack: true
};
},
computed: {
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
navbarHeight() {
return this.statusBarHeight + this.navBarHeight;
}
},
created() {
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
// #ifdef MP
const statusBarHeight = this.statusBarHeight;
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
// 兜底:胶囊信息异常时退回 44
this.navBarHeight = h > 0 ? h : 44;
// #endif
// #ifdef H5
this.visible = this.showOnH5;
// #endif
},
mounted() {
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
this.canBack = pages.length > 1;
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
},
methods: {
onBack() {
if (typeof this.customBack === 'function') {
this.customBack();
return;
}
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
if (pages.length > 1) {
uni.navigateBack();
return;
}
// 顶层页面(分享 / 扫码直接进入):回首页
uni.switchTab({
url: this.homePath,
fail: () => {
uni.reLaunch({ url: this.homePath });
}
});
}
}
};
</script>
<style scoped lang="scss">
.ns-mp-navbar {
width: 100%;
box-sizing: border-box;
}
.ns-mp-navbar.is-fixed {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 990;
}
.ns-mp-navbar__inner {
position: relative;
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
}
.ns-mp-navbar__side {
position: relative;
z-index: 1;
display: flex;
align-items: center;
height: 100%;
min-width: 160rpx;
padding-left: 20rpx;
box-sizing: border-box;
}
.ns-mp-navbar__side--right {
justify-content: flex-end;
padding-left: 0;
padding-right: 20rpx;
}
.ns-mp-navbar__back {
display: flex;
align-items: center;
padding: 10rpx 20rpx 10rpx 0;
}
.ns-mp-navbar__back--hover {
opacity: 0.5;
}
.ns-mp-navbar__arrow {
font-size: 34rpx;
line-height: 1;
}
.ns-mp-navbar__text {
margin-left: 2rpx;
font-size: 30rpx;
line-height: 1;
}
.ns-mp-navbar__title {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 160rpx;
font-size: 32rpx;
line-height: 1.2;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -281,18 +281,18 @@ export default {
// #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => {
// 检查是否已同意
if (!PrivacyManager.needShow()) {
// 已同意, 直接放行
resolve({ event: 'agree' })
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') {
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, 直接放行避免 API 卡死
resolve({ event: 'agree' })
}
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
}
})
}
@@ -567,6 +567,12 @@ export default {
flex: 1;
margin-right: 30rpx;
justify-content: center;
/* 关键修复: 微信 <button> 原生默认 padding-left/right: 14px 没有被重置,
* 会挤压按钮内容区。真机窄屏(375px 以下)上「同意并继续」5 个字被裁成
* 「同意并继」。这里显式清零 padding 并锁定 border-box。 */
padding: 0;
box-sizing: border-box;
white-space: nowrap;
}
.privacy-button-btn::after {

2204
docs/ai-agent-integration.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# 国际化(i18n)全量审计报告
> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等)
> 审计时间:2026-06-29
> 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致)
> 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅

View File

@@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t
## 四、强制兼容性测试项(上 HBuilder 前必做)
```bash
# 以下命令均在项目根目录(含 package.json)下执行
# 1. 静态语法检查
cd D:/projects/shop-projects/frontend
node --check common/js/theme-override.js
node --check store/index.js
# App.vue 走 vue 编译器,不在 node 范畴

View File

@@ -81,6 +81,7 @@
"loggingIn": "登录中",
"loginFailed": "登录失败",
"pleaseAuthorizeMobile": "请授权您的手机号码",
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
"pleaseEnterNickname": "请输入昵称",
"avatarPullFailed": "头像拉取失败"
},

View File

@@ -12,12 +12,29 @@ export const lang = {
timeFormatToday: 'Today',
timeFormatYesterday: 'Yesterday',
timeFormatBeforeYesterday: 'Day before yesterday',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
timeJustNow: 'Just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeMonthDay: '{m}/{d}',
timeYearMonthDay: '{y}/{m}/{d}',
// ====== ai-chat-message ======
aiNickname: 'AI Assistant',
defaultNickname: 'Me',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
// *Instruction* keys: explicit directive sent to the AI as the query.
// Just a label gets ignored — the AI needs an explicit "please listen/look"
// to actually invoke STT/vision/file parsing on the attachment payload.
audioMessage: 'Voice Message',
videoMessage: 'Video Message',
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
imageInstruction: 'I sent an image. Please look at it and reply.',
videoInstruction: 'I sent a video. Please watch it and reply.',
fileInstruction: 'I sent a file. Please review it and reply.',
attachmentInstruction: 'I sent attachments. Please review them and reply.',
inputPlaceholder: 'Please enter your question...',
expandEditorTitle: 'Edit Content',
loadMoreHistory: 'Load more history',
@@ -112,15 +129,48 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: 'More Features',
image: 'Image',
video: 'Video/Audio',
file: 'File',
voice: 'Voice',
recordVoice: 'Record Voice',
uploadAudio: 'Upload Audio',
pickVideo: 'Choose Video',
pickAudio: 'Choose Audio',
chooseAudioFail: 'Failed to select audio',
chooseVideoFail: 'Failed to select video',
audioNotReady: 'Audio not ready',
location: 'Location',
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
locationPermissionTitle: 'Location Permission Required',
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
locationSettingBtn: 'Open Settings',
locationSkipBtn: 'Not Now',
locationH5GuideBtn: 'Got it',
// ====== ai-chat-voice-panel ======
voiceInputTitle: 'Voice Input',
recordingTip: 'Recording, release to end',
startRecordTip: 'Tap to start recording',
voiceInputFailed: 'Voice input failed',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
holdToTalk: 'Hold to talk',
releaseToSend: 'Release to send',
releaseToCancel: 'Release to cancel',
slideUpToCancel: '↑ Slide up to cancel',
slideUpToCancelHint: '↑ Slide up to cancel',
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
placeholderDefault: 'Type a message or hold to talk',
placeholderWithAudio: 'Add a note for your voice (optional)',
placeholderWithMedia: 'Add a caption (optional)',
slideUpToCancelHint: '↑ Release to cancel',
voiceStartFailed: 'Cannot start recording',
voiceStopFailed: 'Recording failed, please try again',
voiceNotSupported: 'Voice input is not supported on this platform',
voiceTooShort: 'Speaking time too short',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
uploadFailed: 'Upload failed',
uploadFailedWithReason: 'Upload failed: {reason}',
// ====== index.vue - Menu ======
menuClearChat: 'Clear Chat',

View File

@@ -0,0 +1,83 @@
export const lang = {
// diy-recommend-code referral code component
// Title
title: 'My Referral Code',
// Share now button
buttonText: 'Share Now',
// Copy code button
copyButtonText: 'Copy Code',
// Get a new code
placeholderRegenerateText: 'Get a New Code',
// Consent description
consentText: 'Your referral code is used to track enterprise registrations via your shared link. We record each application completed through your link for commission settlement.',
// Agree and get code button
consentButtonText: 'Agree & Get Code',
// Login guide tip
loginTipText: 'Log in to view your referral code',
// Log in button
loginBtnText: 'Log In',
// Poster QR tip
posterQrTip: 'Scan to enter and fill in registration info',
// Exclusive code label
exclusiveCode: 'Exclusive Code',
// Code pending label
pendingCode: 'Code Pending',
// Pending
pending: 'Pending',
// Loading code
loading: 'Loading code...',
// Code obtained label
obtainedCode: 'Code Obtained',
// Obtained
obtained: 'Obtained',
// Share invite reward description
shareInviteReward: 'Share with friends and earn rewards for invites',
// Active
activated: '✓ Active',
// Code generated description
codeGenerated: 'Code generated. Use it to invite friends or bind referral for enterprise registration',
// Submitting
submitting: 'Submitting...',
// Share title
shareTitle: 'Inviting you to join the low-altitude economy platform',
// Get failed
getFail: 'Failed to get code. Please retry later',
// Get failed (business code)
getFailCode: 'Failed to get code (business code={code})',
// Get success
getSuccess: 'Obtained',
// Network error
netError: 'Network error. Please retry later',
// Renew code modal title
regenTitle: 'Renew Code',
// Renew code modal content
regenContent: 'After renewal, the old code becomes invalid immediately. All registrations via the old link will no longer be attributed to you. Continue?',
// Confirm renew
regenConfirm: 'Confirm Renew',
// Cancel
regenCancel: 'Cancel',
// Renew failed
regenFail: 'Renewal failed. Please retry later',
// Renew success (old disabled)
regenSuccessOld: 'New code issued. Old code {old} disabled',
// Renew success
regenSuccess: 'Renewed',
// Code not ready yet
codeNotReady: 'Code not ready yet. Please wait',
// Code copied
codeCopied: 'Code copied',
// Copy failed
copyFail: 'Copy failed',
// Copy content is empty
copyEmpty: 'Content is empty',
// Copy after code finishes loading
loadThenCopy: 'Copy after code finishes loading',
// Share link copied
linkCopied: 'Share link copied',
// Poster subtitle
posterShareInvite: 'Share with friends, earn rewards for invites',
// Poster link prefix
posterLinkPrefix: 'Link:',
// Poster link not generated
posterLinkEmpty: '(not generated)',
}

View File

@@ -0,0 +1,5 @@
export const lang = {
// diy-webview webview component
emptyTip: 'Please configure the web link',
loadError: 'Failed to load web page',
}

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: 'Logging in',
loginFailed: 'Login failed',
pleaseAuthorizeMobile: 'Please authorize your mobile number',
pleaseAgreePrivacy: 'Please agree to the Privacy Policy and retry',
pleaseEnterNickname: 'Please enter nickname',
avatarPullFailed: 'Avatar pull failed',
}

View File

@@ -6,6 +6,11 @@ export const lang = {
fileUrlNotExist: 'File URL does not exist',
// Video URL does not exist
videoUrlNotExist: 'Video URL does not exist',
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
audioUrlNotExist: 'Audio URL does not exist',
audioDefaultTitle: 'Voice message',
audioPlaying: 'Playing…',
audioPaused: 'Paused',
// This file type preview is not supported
unsupportedFileType: 'This file type preview is not supported',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'File Details',
}

View File

@@ -7,5 +7,18 @@ export const lang = {
submitSuccess: 'Submitted successfully',
// Form information not available
noFormInfo: 'Form information not available',
// Behavior settings
// Captcha input placeholder
captchaPlaceholder: 'Please enter the captcha',
// Captcha required
captchaRequired: 'Please enter the captcha',
// Captcha error
captchaError: 'Incorrect captcha, please try again',
// Submit limit reached
submitLimit: 'Submission limit reached',
// Form not open
notOpen: 'The form is not open yet',
// Login required
loginRequired: 'Please log in first',
}

View File

@@ -12,12 +12,28 @@ export const lang = {
timeFormatToday: '今天',
timeFormatYesterday: '昨天',
timeFormatBeforeYesterday: '前天',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
timeJustNow: '刚刚',
timeMinutesAgo: '{n}分钟前',
timeHoursAgo: '{n}小时前',
timeMonthDay: '{m}月{d}日',
timeYearMonthDay: '{y}年{m}月{d}日',
// ====== ai-chat-message ======
aiNickname: '智能助手',
defaultNickname: '我',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
audioMessage: '语音消息',
videoMessage: '视频消息',
audioInstruction: '我发送了一段语音,请听一下并回复我',
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
inputPlaceholder: '请输入您的问题...',
expandEditorTitle: '编辑内容',
loadMoreHistory: '加载更多历史消息',
@@ -112,15 +128,49 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: '更多功能',
image: '图片',
video: '视频/音频',
file: '文件',
voice: '语音',
recordVoice: '录制语音',
uploadAudio: '上传音频',
pickVideo: '选择视频',
pickAudio: '选择音频',
chooseAudioFail: '选择音频失败',
chooseVideoFail: '选择视频失败',
audioNotReady: '音频尚未就绪',
location: '位置',
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
locationPermissionTitle: '需要位置权限',
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
locationSettingBtn: '去设置',
locationSkipBtn: '暂不开启',
locationH5GuideBtn: '我知道了',
// ====== ai-chat-voice-panel ======
voiceInputTitle: '语音输入',
recordingTip: '正在录音,松开结束',
startRecordTip: '点击开始录音',
voiceInputFailed: '语音输入失败',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
holdToTalk: '按住说话',
releaseToSend: '松开发送',
releaseToCancel: '松开取消',
slideUpToCancel: '↑ 上滑取消',
slideUpToCancelHint: '↑ 松开取消',
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
placeholderDefault: '发消息或按住说话',
placeholderWithAudio: '给语音留言加个说明(可选)',
placeholderWithMedia: '添加文字说明(可选)',
voiceStartFailed: '无法开始录音',
voiceStopFailed: '录音失败,请重试',
voiceNotSupported: '当前平台不支持语音输入',
voiceTooShort: '说话时间太短',
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
uploadFailed: '上传失败',
uploadFailedWithReason: '上传失败:{reason}',
// ====== index.vue - 菜单 ======
menuClearChat: '清空聊天',

View File

@@ -0,0 +1,83 @@
export const lang = {
// diy-recommend-code 推荐码组件
// 标题
title: '我的推荐码',
// 立即分享按钮
buttonText: '立即分享',
// 复制推荐码按钮
copyButtonText: '复制推荐码',
// 换一个新码
placeholderRegenerateText: '换一个新码',
// 同意说明文案
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
// 同意并获取推荐码按钮
consentButtonText: '同意并获取推荐码',
// 未登录引导提示
loginTipText: '登录后查看我的推荐码',
// 去登录按钮
loginBtnText: '去登录',
// 海报二维码提示
posterQrTip: '扫码进入并填写入驻信息',
// 专属推荐码标签
exclusiveCode: '专属推荐码',
// 待获取推荐码标签
pendingCode: '待获取推荐码',
// 待获取
pending: '待获取',
// 推荐码加载中
loading: '推荐码加载中...',
// 已获得推荐码标签
obtainedCode: '已获得推荐码',
// 已获得
obtained: '已获得',
// 分享邀请奖励描述
shareInviteReward: '分享给好友,邀请注册即可获得奖励',
// 已生效
activated: '✓ 已生效',
// 推荐码已生成描述
codeGenerated: '推荐码已生成,可用于邀请好友或企业入驻绑定推荐关系',
// 提交中
submitting: '提交中...',
// 分享标题
shareTitle: '邀您入驻低空经济产业平台',
// 获取失败
getFail: '推荐码获取失败,请稍后重试',
// 获取失败(业务码)
getFailCode: '推荐码获取失败(业务码={code})',
// 获取成功
getSuccess: '获取成功',
// 网络异常
netError: '网络异常,请稍后重试',
// 换新码弹窗标题
regenTitle: '换新码',
// 换新码弹窗内容
regenContent: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
// 确认换新
regenConfirm: '确认换新',
// 取消
regenCancel: '取消',
// 换新失败
regenFail: '换新失败,请稍后重试',
// 换新成功(旧码停用)
regenSuccessOld: '已换新码,旧码 {old} 已停用',
// 换新成功
regenSuccess: '换新成功',
// 推荐码还未生成
codeNotReady: '推荐码还未生成,请稍候',
// 推荐码已复制
codeCopied: '推荐码已复制',
// 复制失败
copyFail: '复制失败',
// 复制内容为空
copyEmpty: '内容为空',
// 推荐码加载完成后再复制
loadThenCopy: '推荐码加载完成后再复制',
// 推广链接已复制
linkCopied: '推广链接已复制',
// 海报副标题
posterShareInvite: '分享给好友,邀请入驻获得奖励',
// 海报链接前缀
posterLinkPrefix: '链接:',
// 海报链接未生成
posterLinkEmpty: '(未生成)',
}

View File

@@ -0,0 +1,5 @@
export const lang = {
// diy-webview 网页组件
emptyTip: '请配置网页链接',
loadError: '网页加载失败',
}

View File

@@ -45,6 +45,7 @@ export const lang = {
loggingIn: '登录中',
loginFailed: '登录失败',
pleaseAuthorizeMobile: '请授权您的手机号码',
pleaseAgreePrivacy: '请先同意《隐私条款》后重试',
pleaseEnterNickname: '请输入昵称',
avatarPullFailed: '头像拉取失败',
}

View File

@@ -6,6 +6,11 @@ export const lang = {
fileUrlNotExist: '文件地址不存在',
// 视频地址不存在
videoUrlNotExist: '视频地址不存在',
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
audioUrlNotExist: '音频地址不存在',
audioDefaultTitle: '语音消息',
audioPlaying: '播放中…',
audioPaused: '已暂停',
// 暂不支持该类型文件预览
unsupportedFileType: '暂不支持该类型文件预览',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '文件详情',
}

View File

@@ -6,4 +6,17 @@ export const lang = {
submitSuccess: '提交成功',
// 未获取到表单信息
noFormInfo: '未获取到表单信息',
// 行为设置相关
// 请输入验证码
captchaPlaceholder: '请输入验证码',
// 请输入验证码(校验提示)
captchaRequired: '请输入验证码',
// 验证码错误
captchaError: '验证码错误,请重试',
// 提交次数已达上限
submitLimit: '提交次数已达上限',
// 表单未开放
notOpen: '表单未开放',
// 请先登录
loginRequired: '请先登录',
}

View File

@@ -6,6 +6,7 @@ import App from './App'
import store from './store'
import Util from './common/js/util.js'
import Http from './common/js/http.js'
import CardApi from './common/js/card-api.js'
import Lang from './common/js/lang.js'
import Config from './common/js/config.js'
import EventBus from './common/js/event-bus.js'
@@ -25,6 +26,7 @@ Vue.config.productionTip = false
Vue.prototype.$util = Util;
Vue.prototype.$api = Http;
Vue.prototype.$cardApi = CardApi; // 名片云(card addon) API 收口层
Vue.prototype.$langConfig = Lang; //语言包对象
Vue.prototype.$lang = Lang.lang; //解析语言包

View File

@@ -79,6 +79,9 @@
},
"scope.writePhotosAlbum" : {
"desc" : "用于从相册选择和保存头像"
},
"scope.record" : {
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],

View File

@@ -1,6 +1,7 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"pack:harmony": "node scripts/pack-harmony-project.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",

View File

@@ -1467,6 +1467,118 @@
"navigationBarTitleText": ""
}
},
{
"path": "card/list",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/detail",
"style": {
"navigationBarTitleText": "名片",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
}
},
{
"path": "card/exchange",
"style": {
"navigationBarTitleText": "名片"
}
},
{
"path": "card/style",
"style": {
"navigationBarTitleText": "名片"
}
},
{
"path": "card/edit",
"style": {
"navigationBarTitleText": "名片"
}
},
{
"path": "card/apply",
"style": {
"navigationBarTitleText": "名片"
}
},
{
"path": "card/leads",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/contacts",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/views",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/contact-edit",
"style": {
"navigationBarTitleText": "名片"
}
},
{
"path": "card/share",
"style": {
// #ifdef MP-HARMONY
// 鸿蒙元服务的 web-view 是原生全屏组件, 只能自绘头部(见 share.vue)
"navigationStyle": "custom",
// #endif
// 微信小程序必须用原生导航栏: web-view 会自动排在导航栏下方,
// 若用 custom + cover-view 自绘头部, 头部会浮在 web-view 上遮住名片顶部
"navigationBarTitleText": "名片分享"
}
},
{
"path": "card/save-contact",
"style": {
"navigationBarTitleText": "保存到通讯录"
}
},
{
"path": "card/enterprise",
"style": {
"navigationBarTitleText": "企业名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/admin/manage",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/admin/contacts",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "card/admin/leads",
"style": {
"navigationBarTitleText": "名片",
"enablePullDownRefresh": true
}
},
{
"path": "member/card",
"style": {
@@ -2203,7 +2315,64 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{
@@ -2442,10 +2611,17 @@
}
}
},
{
"path": "files/detail",
"style": {
"navigationBarTitleText": "文件详情"
}
},
{
"path": "file-preview/file-preview",
"style": {
"navigationBarTitleText": "文件预览"
"navigationBarTitleText": "文件预览",
"navigationStyle": "custom"
}
}
]
@@ -2493,7 +2669,6 @@
"pages/index/index": {
"network": "all",
"packages": [
"pages_tool",
"components-diy"
]
}

View File

@@ -53,7 +53,7 @@
<ns-copyright v-show="isShowCopyRight" />
</view>
<template v-if="adv.advshow != -1">
<template v-if="adv && adv.advshow != -1">
<view @touchmove.prevent.stop>
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
<view class="small-bot">

View File

@@ -105,6 +105,24 @@ export default {
}
},
methods: {
/**
* 应用后台「高级设置 → 媒体显示顺序」(media_order)
* 1 = 主图在前(默认) 2 = 视频在前
* 商品没有视频时一律主图在前
*/
applyMediaOrder() {
const detail = this.goodsSkuDetail || {};
if (!detail.video_url) {
this.switchMedia = 'img';
return;
}
this.switchMedia = Number(detail.media_order) === 2 ? 'video' : 'img';
},
// 手动切换图片/视频
switchMediaTab(key) {
this.switchMedia = key;
if (key === 'img' && this.videoContext) this.videoContext.pause();
},
init(params) {
this.skuId = params.sku_id;
this.goodsId = params.goods_id;
@@ -136,6 +154,9 @@ export default {
this.videoContext = uni.createVideoContext('goodsVideo');
// 媒体显示顺序: 以后台商品「高级设置 → 媒体显示顺序」为准
this.applyMediaOrder();
// #ifdef MP-WEIXIN
this.goodsSyncToGoodsCircle();
// #endif

View File

@@ -13,7 +13,7 @@
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
<view class="goods-detail-skeleton-action">
<view class="skeleton-action-icons">
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
<view class="skeleton-action-icon" v-for="i in 2" :key="i"></view>
</view>
<view class="skeleton-action-btn"></view>
<view class="skeleton-action-btn primary"></view>
@@ -65,10 +65,11 @@
<!-- 切换视频、图片 -->
<view class="media-mode" v-if="goodsSkuDetail.video_url != ''">
<text :class="{ 'color-base-bg': switchMedia == 'video' }" @click="switchMedia = 'video'">{{
$lang('goods.detail.video') }}</text>
<text :class="{ 'color-base-bg': switchMedia == 'img' }"
@click="(switchMedia = 'img'), videoContext.pause()">{{ $lang('goods.detail.image') }}</text>
<text v-for="tab in mediaTabs" :key="tab.key"
:class="{ 'color-base-bg': switchMedia == tab.key }"
@click="switchMediaTab(tab.key)">{{
tab.key == 'video' ? $lang('goods.detail.video') : $lang('goods.detail.image')
}}</text>
</view>
</view>
@@ -684,6 +685,24 @@ export default {
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
hasRealData() {
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
},
// 媒体顺序的变更标识: 只在「媒体显示顺序 / 视频地址」变化时重新应用
// 用它而不是直接 watch goodsSkuDetail(深度监听), 是为了避免用户手动切到
// 「视频」后被详情数据的任意一次深层变更打回「主图」
mediaOrderKey() {
const detail = this.goodsSkuDetail || {};
return (detail.media_order || 1) + '|' + (detail.video_url || '');
},
// 底部「图片 / 视频」切换按钮的排列, 与后台设置的媒体显示顺序保持一致
mediaTabs() {
const detail = this.goodsSkuDetail || {};
const tabs = [{ key: 'img' }, { key: 'video' }];
return Number(detail.media_order) === 2 ? tabs.reverse() : tabs;
}
},
watch: {
mediaOrderKey() {
this.applyMediaOrder();
}
},
mixins: [scroll, detail]

View File

@@ -384,5 +384,14 @@ export default {
border-radius: 20rpx 20rpx 0 0;
}
// 购物车页面SKU弹窗底部按钮上移,避开底部导航栏
::v-deep .goods-sku .sku-info .footer {
bottom: calc(30rpx + env(safe-area-inset-bottom) + 112rpx) !important;
}
::v-deep .goods-sku .sku-info .body-item {
padding-bottom: 112rpx;
}
@import './public/css/cart.scss';
</style>

View File

@@ -17,7 +17,7 @@
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
<view class="price-box">
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
<view class="sale-num" v-if="goodsSkuDetail.sale_show">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
<view class="sale-num" v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
</view>
</view>
@@ -94,8 +94,8 @@
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
</view>
<view class="logistics-wrap">
<text v-if="goodsSkuDetail.stock_show">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
<text v-if="goodsSkuDetail.sale_show">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
<text v-if="goodsSkuDetail.stock_show && !isPhoneConsult">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
<text v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
</view>
<!-- 会员卡 -->
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
@@ -461,8 +461,9 @@
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
<!-- #ifdef MP-WEIXIN -->
<ns-goods-action-icon
:text="$lang('goods.detail.cart')"
<ns-goods-action-icon
v-if="!isPhoneConsult"
:text="$lang('goods.detail.cart')"
:cornerMarkBg="themeStyle.goods_cart_num_corner"
icon="icon-gouwuche2"
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
@@ -565,6 +566,10 @@ export default {
},
mixins: [base, detail, scroll],
computed: {
// 后台选择"电话立即咨询"模式(isinformation == 1)时,隐藏购物车图标、库存和销量
isPhoneConsult() {
return this.goodsSkuDetail && this.goodsSkuDetail.isinformation == 1;
},
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
formDiyValue() {

File diff suppressed because it is too large Load Diff

View File

@@ -263,7 +263,10 @@ export default {
// 搜索入口按钮
.search-entrance {
position: absolute;
top: 44rpx;
/* 与新对话按钮垂直居中对齐:
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
top: 14rpx;
right: 16rpx;
z-index: 10;
display: flex;

View File

@@ -9,27 +9,38 @@
</button>
</view>
<view class="tools-grid">
<view class="tool-item" @click="selectImage">
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
location 不属于附件, 不受此开关控制 -->
<view v-if="isTypeEnabled('image')" class="tool-item tool-item-new tool-item-image" @click="selectImage">
<view class="tool-icon">
<text class="iconfont icon-image"></text>
<view class="image-icon"></view>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view>
<view class="tool-item" @click="selectFile">
<view v-if="isTypeEnabled('video')" class="tool-item tool-item-new tool-item-video" @click="selectVideo">
<view class="tool-icon">
<text class="iconfont icon-file"></text>
<text class="icondiy icon-system-video"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
</view>
<view class="tool-item" @click="startRecord">
<view v-if="isTypeEnabled('file')" class="tool-item tool-item-new tool-item-file" @click="selectFile">
<view class="tool-icon">
<text class="iconfont icon-voice"></text>
<view class="paperclip-icon"></view>
</view>
<text class="tool-text">添加文件</text>
</view>
<!-- 更多功能里的语音入口暂时隐藏 -->
<view v-if="false" class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon">
<text class="icondiy icon-system-chat-voice-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
</view>
<view class="tool-item" @click="selectLocation">
<view v-if="false" class="tool-item tool-item-new tool-item-location" @click="selectLocation">
<view class="tool-icon">
<text class="iconfont icon-location"></text>
<text class="icondiy icon-system-map-pin-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
</view>
@@ -39,16 +50,20 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
* Props:
* visible: Boolean - 控制显示/隐藏
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
* location 不属于附件, 不受此 prop 影响
*
* Events:
* update:visible - 同步关闭状态
* image-selected - 选择图片,参数:图片消息对象
* file-selected - 选择文件,参数:文件消息对象
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
* voice-start - 开始语音
* location-selected - 选择位置,参数:位置消息对象
*/
@@ -59,6 +74,12 @@ export default {
visible: {
type: Boolean,
default: false
},
attachmentTypes: {
type: Array,
default: function () {
return ['image', 'file', 'audio', 'video']
}
}
},
@@ -73,75 +94,308 @@ export default {
this.$emit('update:visible', false)
},
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
isTypeEnabled(kind) {
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
return this.attachmentTypes.indexOf(kind) !== -1
},
selectImage() {
const that = this
uni.chooseImage({
count: 1,
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
that.hideToolsPanel()
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = (e) => {
input.onchange = async (e) => {
const file = e.target.files[0]
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
}
input.click()
// #endif
// #ifdef MP-WEIXIN
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
success: async (res) => {
that.hideToolsPanel()
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
if (!file) return
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
await that._uploadOneFile(file, kind)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选文件失败', icon: 'none' })
}
},
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: (res) => {
success: async (res) => {
that.hideToolsPanel()
const file = res.tempFiles[0]
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
url: file.path,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
await that._uploadOneFile(file, 'file')
}
})
// #endif
},
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
selectVideo() {
const that = this
// #ifdef H5
const mediaInput = document.createElement('input')
mediaInput.type = 'file'
mediaInput.accept = 'video/*,audio/*'
mediaInput.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, that._guessMediaKind(file))
}
mediaInput.click()
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
uni.showActionSheet({
itemList: [
that.$lang('ai-chat.index.pickVideo', '选择视频'),
that.$lang('ai-chat.index.pickAudio', '选择音频')
],
success: (res) => {
if (res.tapIndex === 0) that._chooseVideo()
else if (res.tapIndex === 1) that.selectAudio()
},
fail: () => {}
})
// #endif
},
// 按 mime / 扩展名判断选中的文件是视频还是音频
_guessMediaKind(file) {
const type = (file && file.type ? file.type : '').toLowerCase()
if (type.indexOf('audio/') === 0) return 'audio'
if (type.indexOf('video/') === 0) return 'video'
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
// PR-AV-FULL-SUPPORT (2026-09-24): 与后端 MediaUploadPolicy::allowedUploadExtensions 音频段对齐
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3']
if (audioExt.indexOf(ext) !== -1) return 'audio'
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
return type ? 'video' : 'audio'
},
// 选视频 (微信小程序 / App 共用)
_chooseVideo() {
const that = this
uni.chooseVideo({
sourceType: ['album', 'camera'],
compressed: true,
maxDuration: 60,
success: async (res) => {
that.hideToolsPanel()
await that._uploadOneFile({
path: res.tempFilePath,
size: res.size || 0,
duration: res.duration || 0,
name: 'video_' + Date.now() + '.mp4',
type: 'video/mp4'
}, 'video')
},
fail: (err) => {
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
}
}
})
},
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
selectVoiceOrAudio() {
uni.showActionSheet({
itemList: [
this.$lang('ai-chat.index.recordVoice'),
this.$lang('ai-chat.index.uploadAudio')
],
success: (res) => {
if (res.tapIndex === 0) {
this.startRecord()
} else if (res.tapIndex === 1) {
this.selectAudio()
}
},
fail: () => {}
})
},
// 选择音频文件并上传 (H5 / 微信小程序 / App)
selectAudio() {
const that = this
// #ifdef H5
const audioInput = document.createElement('input')
audioInput.type = 'file'
audioInput.accept = 'audio/*'
audioInput.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
}
audioInput.click()
// #endif
// #ifdef MP-WEIXIN
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'file',
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
success: async (res) => {
const file = (res.tempFiles && res.tempFiles[0]) || null
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
},
fail: (err) => {
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
}
}
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
success: async (res) => {
const file = res.tempFiles[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, 'audio')
}
})
// #endif
},
async _uploadOneFile(file, kind) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
// 统一: H5 创建 blob URL 用于预览, 上传完 (成功/失败都) revoke
let blobUrl = ''
let displayPath = ''
// #ifdef H5
// H5 file 对象没有 .path, 用 blob URL 给 imageUrl 预览, 并把 file 本身传给 uploadImage
blobUrl = file.name ? URL.createObjectURL(file) : ''
displayPath = blobUrl
// #endif
// #ifndef H5
// MP-WEIXIN / APP-PLUS 都用 file.path (tempFilePath)
displayPath = file.path || ''
// #endif
const pending = {
id: ++this.messageId,
role: 'user',
type: kind,
content: '',
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
fileSize: file.size || 0,
fileType: file.type || '',
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
url: displayPath,
duration: file.duration || 0,
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
playing: false,
currentTime: 0,
imageUrl: displayPath,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
this.$emit('file-selected', pending)
try {
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
// 非 H5 传 file.path (uni.uploadFile 需要)
const inputArg = blobUrl || file.path || file
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
const uploaded = kind === 'image'
? await uploadImage(inputArg)
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) {
this.$set(pending, 'imageUrl', uploaded.url)
this.$set(pending, 'url', uploaded.url)
}
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
} finally {
// PR-FRONTEND-MEDIA-UPLOAD-FIX: blob URL 用完即释放 (无论成败)
if (blobUrl) {
try { URL.revokeObjectURL(blobUrl) } catch (e) {}
}
}
},
startRecord() {
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
this.$emit('voice-start')
this.hideToolsPanel()
},
@@ -209,11 +463,12 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
font-size: 30rpx;
font-weight: 400;
color: #000000;
}
.close-btn {
@@ -225,17 +480,19 @@ export default {
align-items: center;
justify-content: center;
border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;
color: #000000;
}
}
}
.tools-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
.tool-item {
@@ -257,7 +514,8 @@ export default {
margin-bottom: 16rpx;
box-shadow: $shadow-sm;
.iconfont {
.iconfont,
.icondiy {
font-size: 48rpx;
color: $color-primary;
}
@@ -269,6 +527,62 @@ export default {
font-weight: 500;
}
}
// 新样式: 图片 / 添加文件 大卡片, 一比一还原参考图
.tool-item-new {
background-color: #f2f3f5;
border-radius: 28rpx;
padding: 36rpx 0;
.tool-icon {
width: 48rpx;
height: 48rpx;
border-radius: 0;
background-color: transparent;
box-shadow: none;
margin-bottom: 6rpx;
line-height: 1;
.iconfont,
.icondiy {
font-size: 48rpx;
line-height: 1;
color: #333;
}
// 图片图标: 左上角开口 + 大四角星在开口处 + 右侧实心圆点 (参考图样式)
.image-icon {
width: 48rpx;
height: 48rpx;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTEuNCA1IEgxNy41IEEzLjUgMy41IDAgMCAxIDIxIDguNSBWMTUuNSBBMy41IDMuNSAwIDAgMSAxNy41IDE5IEg2LjUgQTMuNSAzLjUgMCAwIDEgMyAxNS41IFY4LjUiLz48cGF0aCBkPSJNNS4yIDEuMyBRNS45IDQuMzUgOC45IDUgUTUuOSA1LjY1IDUuMiA4LjcgUTQuNSA1LjY1IDEuNSA1IFE0LjUgNC4zNSA1LjIgMS4zIFoiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iOS44IiByPSIxLjUiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxwYXRoIGQ9Ik01LjUgMTEuNiBRMTAgOS42IDE0LjUgMTYuNCIvPjwvc3ZnPg==");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
// 添加文件图标: 斜向回形针 (参考图样式)
.paperclip-icon {
width: 40rpx;
height: 40rpx;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEuNDQgMTEuMDUgMTIuMjUgMjAuMjRhNiA2IDAgMCAxLTguNDktOC40OWw4LjU3LTguNTdhNCA0IDAgMSAxIDUuNjYgNS42NmwtOC41OSA4LjU3YTIgMiAwIDAgMS0yLjgzLTIuODNsOC40OS04LjQ4Ii8+PC9zdmc+");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
}
.tool-text {
font-size: 26rpx;
line-height: 1;
color: #333;
font-weight: 500;
}
// 视频/音频图标小一号 (tool-item-video 与该元素同级, 必须用 & 连接)
&.tool-item-video .tool-icon .icondiy {
font-size: 40rpx;
}
}
}
}
}

View File

@@ -14,29 +14,39 @@
v-for="i in 8"
:key="i"
class="wave-bar"
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
:style="waveBarStyle(i)"></view>
</view>
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
<text class="voice-tip">{{ currentTip }}</text>
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
</view>
<view class="voice-actions">
<button class="voice-btn" @click="toggleVoiceInput">
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
</button>
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
</view>
</view>
</view>
</template>
<script>
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
/**
* ai-chat-voice-panel - 语音输入面板组件
*
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
* - 波形振幅由 progress 事件 (level 0..1) 驱动
* - 录音上限 60s,自动停止
* - 关闭面板时如果还在录音,先 cancel 释放资源
*
* Props:
* visible: Boolean - 控制显示/隐藏
*
* Events:
* update:visible - 同步关闭状态
* close - 请求关闭面板
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
*/
export default {
name: 'AiChatVoicePanel',
@@ -50,51 +60,192 @@ export default {
data() {
return {
voiceInputing: false
voiceInputing: false,
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
voiceLevel: 0,
// 录音累计时长 (秒,整数)
voiceDuration: 0,
_durationTimer: null,
_voiceRecorder: null,
_onProgress: null,
_onError: null,
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
_resolved: false
}
},
computed: {
currentTip() {
if (this.voiceInputing) {
return this.$lang('ai-chat.index.recordingTip')
}
return this.$lang('ai-chat.index.startRecordTip')
},
formattedDuration() {
const s = Math.max(0, Math.floor(this.voiceDuration))
return s + '″'
}
},
watch: {
visible(val) {
if (!val) {
if (val) {
// 每次重新打开都重置
this.voiceInputing = false
this.voiceLevel = 0
this.voiceDuration = 0
this._resolved = false
} else {
// 关闭时如果还在录音,丢弃
this._discardRecorderIfAny()
}
}
},
beforeDestroy() {
this._discardRecorderIfAny()
},
methods: {
hideVoicePanel() {
this.voiceInputing = false
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
this._discardRecorderIfAny()
this.$emit('update:visible', false)
this.$emit('close')
},
toggleVoiceInput() {
this.voiceInputing = !this.voiceInputing
if (this.voiceInputing) {
this.startVoiceInput()
this._finishRecording()
} else {
this.stopVoiceInput()
this._beginRecording()
}
},
startVoiceInput() {
// #ifdef H5
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
// 录音中,可扩展实时转文字
})
.catch(error => {
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
this.voiceInputing = false
})
}
// #endif
cancelVoiceInput() {
this._discardRecorderIfAny()
this.hideVoicePanel()
},
stopVoiceInput() {
waveBarStyle(i) {
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
const base = 24
const offset = ((i * 17) % 32) + 12 // 12..44
const levelPx = Math.round(this.voiceLevel * 80)
const h = Math.max(16, base + offset + levelPx) + 'rpx'
const delay = (i * 0.08) + 's'
return { height: h, animationDelay: delay }
},
_beginRecording() {
if (this._voiceRecorder) return
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
this._voiceRecorder = rec
this._onProgress = ({ level }) => {
// 平滑一下,避免波形抖动太狠
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
}
this._onError = (err) => {
console.warn('[voice-panel] recorder error:', err)
console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
this._discardRecorderIfAny()
}
rec.on('progress', this._onProgress)
rec.on('error', this._onError)
rec.start()
.then(() => {
this.voiceInputing = true
this.voiceDuration = 0
this._startDurationTimer()
})
.catch((err) => {
this._teardownRecorder()
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
})
},
async _finishRecording() {
if (!this._voiceRecorder || this._resolved) return
this._resolved = true
this._stopDurationTimer()
try {
const result = await this._voiceRecorder.stop()
if (!result || !result.filePath) {
// 录音产物为空,可能是被系统打断
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
this._teardownRecorder()
this.voiceInputing = false
return
}
this._teardownRecorder()
this.voiceInputing = false
this.$emit('audio-ready', {
filePath: result.filePath,
blob: result.blob || null,
fileName: result.fileName || '',
duration: result.duration,
mime: result.mime,
size: result.size
})
this.hideVoicePanel()
} catch (e) {
this._teardownRecorder()
this.voiceInputing = false
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
}
},
_discardRecorderIfAny() {
if (!this._voiceRecorder) return
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
this._teardownRecorder()
this.voiceInputing = false
},
_teardownRecorder() {
this._stopDurationTimer()
if (this._voiceRecorder) {
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
if (this._onError) this._voiceRecorder.off('error', this._onError)
}
this._voiceRecorder = null
this._onProgress = null
this._onError = null
this._resolved = false
this.voiceLevel = 0
},
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
_voiceErrMsg(err) {
if (!err) return ''
switch (err.code) {
case 'NOT_SUPPORTED':
case 'NO_RECORDER_MANAGER':
return this.$lang('ai-chat.index.voiceNotSupported')
case 'H5_NO_MIC_API':
case 'ALREADY_STARTED':
return this.$lang('ai-chat.index.voiceStartFailed')
case 'UNI_RECORD_ERROR':
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
default:
return err.message || ''
}
},
_startDurationTimer() {
this._stopDurationTimer()
this._durationTimer = setInterval(() => {
this.voiceDuration += 1
}, 1000)
},
_stopDurationTimer() {
if (this._durationTimer) {
clearInterval(this._durationTimer)
this._durationTimer = null
}
}
}
}
@@ -169,7 +320,7 @@ export default {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 48rpx;
margin-bottom: 32rpx;
.voice-wave {
width: 280rpx;
@@ -180,25 +331,16 @@ export default {
margin-bottom: 24rpx;
&.recording .wave-bar {
animation: wave 1.2s infinite ease-in-out;
animation: wave 1.0s infinite ease-in-out;
}
.wave-bar {
width: 8rpx;
height: 40rpx;
margin: 0 4rpx;
background-color: $color-primary;
border-radius: 4rpx;
transition: height 0.12s ease-out;
animation: none;
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
}
}
@@ -206,10 +348,19 @@ export default {
font-size: 28rpx;
color: $color-text-light;
}
.voice-duration {
font-size: 36rpx;
font-weight: 600;
color: $color-primary;
margin-top: 8rpx;
}
}
.voice-actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.voice-btn {
@@ -222,18 +373,31 @@ export default {
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
border: none;
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
&.is-recording {
background-color: #e94e4e;
transform: scale(0.92);
}
.iconfont {
font-size: 56rpx;
color: white;
}
}
.cancel-link {
margin-top: 20rpx;
font-size: 26rpx;
color: $color-text-light;
padding: 8rpx 16rpx;
}
}
}
}
@keyframes wave {
0%, 100% { transform: scaleY(0.5); }
50% { transform: scaleY(1.2); }
0%, 100% { transform: scaleY(0.6); }
50% { transform: scaleY(1.15); }
}
</style>

View File

@@ -13,9 +13,12 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content" :style="{ bottom: tabBarHeight }">
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<ai-chat-message
v-if="uiCfgReady"
ref="chat"
:preloaded-ui-config="preloadedUiCfg"
:initial-messages="initialMessages"
:fallback-welcome="fallbackWelcome"
:user-avatar="userAvatar"
@@ -69,14 +72,9 @@
</view>
</view>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
</view>
</template>
@@ -100,6 +98,12 @@ export default {
agentId: 'agent_aikefu',
pageTitle: '',
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
uiCfgReady: false,
preloadedUiCfg: null,
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
statusBarHeight: 0, // 状态栏高度,默认0
@@ -119,12 +123,9 @@ export default {
// 事件处理器引用(用于清理)
safeEventHandlers: new Map(),
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
}
},
@@ -147,6 +148,34 @@ export default {
async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
try {
const cached = aiService.getChatUiConfigSync();
if (cached) {
this.preloadedUiCfg = cached;
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
}
} catch (e) { /* 缓存读取失败走预取 */ }
if (!this.uiCfgReady) {
try {
const cfg = await Promise.race([
aiService.getChatUiConfig(true),
new Promise(resolve => setTimeout(() => resolve(null), 6000))
]);
if (cfg) this.preloadedUiCfg = cfg;
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
} catch (e) { /* 失败兜底默认值 */ }
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
}
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
@@ -171,24 +200,16 @@ export default {
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
// 原因:AI 智能页是全屏聊天体验,不论是
// 1) 底部导航栏"智能客服"项直接点击进入
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.initChat();
},

View File

@@ -40,20 +40,39 @@
export default {
data() {
return {
list: []
list: [],
// 从跳转 URL 上携带的参数
categoryId: '',
title: '',
pageKey: '',
pageParams: {}
};
},
onLoad(options) {
// 还原跳转时携带的过滤/展示参数,避免请求 API 时丢失
if (options.category_id) this.categoryId = options.category_id;
if (options.title) {
this.title = decodeURIComponent(options.title);
if (this.title) uni.setNavigationBarTitle({ title: this.title });
}
if (options.page_key) this.pageKey = options.page_key;
this.pageParams = this.$util.parsePageParams(options.page_params) || {};
},
onShow() {
this.setPublicShare();
},
methods: {
getData(mescroll) {
// 合并 page_params 与显式参数,保证后端拿到完整过滤条件
let data = Object.assign({}, this.pageParams, {
page_size: mescroll.size,
page: mescroll.num
});
if (this.categoryId) data.category_id = this.categoryId;
this.$api.sendRequest({
url: '/api/article/page',
data: {
page_size: mescroll.size,
page: mescroll.num
},
data: data,
success: res => {
let newArr = [];
let msg = res.message;
@@ -87,17 +106,32 @@ export default {
// 设置公众号分享
setPublicShare() {
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
let query = this.shareQuery();
if (query) shareUrl += '?' + query;
this.$util.setPublicShare({
title: this.$lang('article.list.title'),
title: this.title || this.$lang('article.list.title'),
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
});
},
// 组装带回过滤参数的 query 串,保证分享/跳转后参数不丢失
shareQuery() {
let params = {};
if (this.categoryId) params.category_id = this.categoryId;
if (this.title) params.title = encodeURIComponent(this.title);
if (this.pageKey) params.page_key = this.pageKey;
if (Object.keys(this.pageParams).length) params.page_params = encodeURIComponent(JSON.stringify(this.pageParams));
return Object.keys(params)
.map(k => k + '=' + params[k])
.join('&');
}
},
onShareAppMessage(res) {
var title = this.$lang('article.list.title');
var title = this.title || this.$lang('article.list.title');
var path = '/pages_tool/article/list';
var query = this.shareQuery();
if (query) path += '?' + query;
return {
title: title,
path: path,
@@ -107,8 +141,10 @@ export default {
},
//分享到朋友圈
onShareTimeline() {
var title = this.$lang('article.list.title');
var title = this.title || this.$lang('article.list.title');
var query = '/pages_tool/article/list';
var q = this.shareQuery();
if (q) query += '?' + q;
return {
title: title,
query: query,

View File

@@ -0,0 +1,481 @@
<template>
<view class="admin-contacts">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
<view class="page-switch">
<view class="switch-item" @tap="goManage">名片管理</view>
<view class="switch-item active" @tap="goSelf">人脉管理</view>
<view class="switch-item" @tap="goLeads">潜在客户</view>
</view>
<!-- 搜索栏 + 添加入口 -->
<view class="search-bar">
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
<view class="search-btn" @tap="doSearch">搜索</view>
<view class="add-btn" @tap="goEdit()">+ 添加</view>
</view>
<!-- 来源筛选(与后端 Contact::SOURCES 对齐) -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 人脉列表 -->
<view class="contact-list">
<view v-for="(item, index) in list" :key="item.id" class="contact-item">
<view class="item-top" @tap="callPhone(item.mobile)">
<view class="item-name">{{ item.name }}</view>
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
</view>
<view class="item-company" v-if="item.company || item.job_title">
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
</view>
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
<view class="item-meta">
<text class="meta-time">{{ item.create_time_txt }}</text>
<text class="meta-status" v-if="item.status == 1">已归档</text>
</view>
<view class="item-actions">
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="'暂无人脉,点右上角添加'"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 人脉管理
*
* 接口全部来自后端已就绪的 /card/api/contact/*(收口在 common/js/card-api.js):
* contact/lists 列表 / contact/add+update 增改(复用 contact-edit 页) /
* contact/toLead 转线索 / contact/delete 真删。
* 与员工端「我的人脉」的区别: 本页是商家后台视角, 带来源筛选与三大入口互链;
* 编辑页共用 contact-edit.vue(from=admin 时保存后回本页)。
*/
import { CONTACT_SOURCES } from '@/common/js/card-api.js';
// 来源筛选: scan/exchange 暂无产生入口, 只放出 manual / lead_in 两个 tab
const SOURCE_TABS = [{ value: '', label: '全部' }].concat(
CONTACT_SOURCES.filter(s => s.value === 'manual' || s.value === 'lead_in')
);
export default {
data() {
return {
tabs: SOURCE_TABS,
source: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页保存返回(redirectTo 回本页)时刷新
if (this._loadedOnce) this.refresh();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
goManage() {
this.$util.redirectTo('/pages_tool/card/admin/manage');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/admin/leads');
},
goSelf() {},
// 编辑页共用 contact-edit; from=admin 时保存后回本页(见 contact-edit.vue#backToList)
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number' || typeof payload === 'string') return null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.id ? payload : null;
},
goEdit(payload) {
let id = payload;
if (payload && payload.currentTarget) {
const i = Number(payload.currentTarget.dataset.index);
const item = this.list[i];
id = item ? item.id : '';
}
const url = id
? '/pages_tool/card/contact-edit?from=admin&id=' + id
: '/pages_tool/card/contact-edit?from=admin';
this.$util.redirectTo(url);
},
switchTab(value) {
if (this.source === value) return;
this.source = value;
this.refresh();
},
doSearch() {
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 source 条件
if (this.source !== '') data.source = this.source;
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
this.$cardApi.contactLists({
data: data,
success: res => {
this.isLoading = false;
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
convertToLead(payload) {
const item = this.resolveItem(payload);
if (!item) return;
uni.showModal({
title: '转为潜在客户',
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
success: res => {
if (!res.confirm) return;
this.doConvert(item);
}
});
},
doConvert(item) {
uni.showLoading({ title: '转换中…', mask: true });
// 后端: 写一条线索且不删人脉; 同员工同手机号已转过则报错(message 可直接展示)
this.$cardApi.contactToLead({
data: { id: item.id },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
} else {
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
delContact(payload) {
const item = this.resolveItem(payload);
if (!item) return;
// 真删不可恢复, 二次确认
uni.showModal({
title: '删除人脉',
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
confirmColor: '#f5222d',
success: res => {
if (!res.confirm) return;
this.doDelete(item);
}
});
},
doDelete(item) {
this.$cardApi.contactDelete({
data: { id: item.id },
success: res => {
if (res.code == 0) {
this.list = this.list.filter(c => c.id !== item.id);
uni.showToast({ title: '已删除', icon: 'success' });
} else {
// 后端对"不存在"与"非本人"同一口径, 按"不存在或已删除"提示
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
}
}
};
</script>
<style lang="scss" scoped>
.admin-contacts {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
.search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
background-color: #ffffff;
border-radius: 32rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
}
.search-btn {
font-size: 26rpx;
color: #666;
padding: 0 24rpx;
}
.add-btn {
font-size: 26rpx;
color: #ffffff;
background-color: var(--main-color, #0054a5);
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.contact-list {
padding: 20rpx 24rpx 0;
}
.contact-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.item-top {
display: flex;
align-items: center;
.item-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.item-source {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.src-manual {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.src-lead_in {
color: #1daa39;
background-color: #e8f8ea;
}
&.src-scan,
&.src-exchange {
color: #999;
background-color: #f2f2f2;
}
}
}
.item-company {
font-size: 26rpx;
color: #666;
margin-top: 10rpx;
}
.item-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.item-remark {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
}
.item-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 26rpx;
margin-left: 16rpx;
&.plain {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
&.danger {
color: #f5222d;
border: 1rpx solid #f5222d;
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

View File

@@ -0,0 +1,431 @@
<template>
<view class="admin-leads">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo) -->
<view class="page-switch">
<view class="switch-item" @tap="goManage">名片管理</view>
<view class="switch-item" @tap="goContacts">人脉管理</view>
<view class="switch-item active" @tap="goSelf">潜在客户</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar">
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机"
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
<view class="search-btn" @tap="doSearch">搜索</view>
</view>
<!-- 跟进状态筛选(与后端 Lead::STATUSES 对齐, setStatus 仅接受 0-3) -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 线索列表 -->
<view class="lead-list">
<view v-for="(item, index) in list" :key="item.id || index" class="lead-item">
<view class="lead-top" @tap="callPhone(item.mobile)">
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
</view>
<view class="lead-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
<view class="lead-meta">
<text class="meta-card">来自名片:{{ item.card_name }}</text>
<text class="meta-time">{{ item.create_time_txt }}</text>
</view>
<view class="lead-actions">
<view class="action-btn copy" v-if="item.wechat" @tap="copyText(item.wechat)">复制微信</view>
<view class="action-btn primary" :data-index="index" @tap="changeStatus">更新状态</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 潜在客户
*
* 接口全部来自后端已就绪的 /card/api/lead/*(收口在 common/js/card-api.js):
* lead/lists 列表(可见范围=自己名下名片收到的线索 + 自己从人脉转入的线索;
* 没有名片时列表为空属正常现象) / lead/setStatus 更新跟进状态(0-3)。
*/
import { LEAD_STATUS } from '@/common/js/card-api.js';
export default {
data() {
return {
tabs: [{ value: '', label: '全部' }].concat(LEAD_STATUS),
status: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
emptyText: '暂无意向客户,访客在你的名片页留资后会出现在这里'
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 平级入口: 鸿蒙禁裸 uni.switchTab, 走 $util.redirectTo
goManage() {
this.$util.redirectTo('/pages_tool/card/admin/manage');
},
goContacts() {
this.$util.redirectTo('/pages_tool/card/admin/contacts');
},
goSelf() {},
switchTab(value) {
if (this.status === value) return;
this.status = value;
this.refresh();
},
doSearch() {
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 status 条件; status 只传 0-3
if (this.status !== '') data.status = this.status;
if (this.keyword.trim() !== '') data.keyword = this.keyword.trim();
this.$cardApi.leadLists({
data: data,
success: res => {
this.isLoading = false;
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
this.emptyText = this.keyword.trim() || this.status !== ''
? '未找到匹配的客户'
: '暂无意向客户,访客在你的名片页留资后会出现在这里';
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.list[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.id ? payload : null;
},
changeStatus(payload) {
let item, index;
if (payload && payload.currentTarget) {
index = Number(payload.currentTarget.dataset.index);
item = this.list[index];
} else if (typeof payload === 'number') {
index = payload;
item = this.list[index];
} else {
item = payload;
index = this.list.indexOf(item);
}
if (!item) return;
uni.showActionSheet({
itemList: LEAD_STATUS.map(s => s.label),
success: res => {
const target = LEAD_STATUS[res.tapIndex];
if (!target || target.value === item.status) return;
this.doSetStatus(item, index, target.value);
}
});
},
doSetStatus(item, index, status) {
this.$cardApi.leadSetStatus({
data: { id: item.id, status: status },
success: res => {
if (res.code == 0) {
// 就地更新, 不整页刷新
this.list[index].status = status;
this.list[index].status_text = LEAD_STATUS.find(s => s.value === status).label;
uni.showToast({ title: '已更新', icon: 'success' });
} else {
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
},
copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
}
}
};
</script>
<style lang="scss" scoped>
.admin-leads {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
.search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
background-color: #ffffff;
border-radius: 32rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
}
.search-btn {
font-size: 26rpx;
color: #666;
padding: 0 24rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.lead-list {
padding: 20rpx 24rpx 0;
}
.lead-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.lead-top {
display: flex;
align-items: center;
.lead-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.lead-status {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.st-0 {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.st-1 {
color: #e6a23c;
background-color: #fdf3e6;
}
&.st-2 {
color: #1daa39;
background-color: #e8f8ea;
}
&.st-3 {
color: #999;
background-color: #f2f2f2;
}
}
}
.lead-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.lead-note {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.lead-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
.meta-card {
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.lead-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 30rpx;
margin-left: 20rpx;
&.copy {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

View File

@@ -0,0 +1,432 @@
<template>
<view class="admin-manage">
<!-- 商家端三大入口平级切换(鸿蒙安全: $util.redirectTo, 禁裸 uni.switchTab) -->
<view class="page-switch">
<view class="switch-item active" @tap="goSelf">名片管理</view>
<view class="switch-item" @tap="goContacts">人脉管理</view>
<view class="switch-item" @tap="goLeads">潜在客户</view>
</view>
<!-- 功能预告: 查重合并/批量制作/导出等依赖后台接口, 就绪后开放 -->
<view class="notice-bar">
<text class="notice-icon">i</text>
<text class="notice-text">查重合并、批量制作、导出 CSV/vCard 等功能即将上线</text>
</view>
<!-- 搜索区 -->
<view class="search-bar">
<view class="search-input-wrap">
<input class="search-input" confirm-type="search" :value="keyword"
placeholder="搜索姓名 / 手机 / 公司" @confirm="onSearch" @input="onKeywordInput" />
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
<text class="iconfont icon-sousuo3" @tap="onSearch"></text>
</view>
</view>
<!-- 名片列表 -->
<view class="card-list">
<view v-for="(item, index) in list" :key="item.card_id || index" :data-index="index" class="card-item"
@tap="goDetail">
<view class="card-avatar">
<image v-if="item.avatar" :src="$util.img(item.avatar)" class="avatar-img" mode="aspectFill" />
<text v-else>{{ avatarText(item.name) }}</text>
</view>
<view class="card-info">
<view class="card-name line1">{{ item.name || '未命名名片' }}</view>
<view class="card-sub line1" v-if="item.job_title || item.company">
{{ [item.job_title, item.company].filter(Boolean).join(' · ') }}
</view>
<view class="card-mobile" v-if="item.mobile">📱 {{ item.mobile }}</view>
</view>
<view class="card-side">
<text v-if="sourceLabel(item)" class="source-tag">{{ sourceLabel(item) }}</text>
<view class="card-actions" @tap.stop="">
<text class="act-btn" :data-index="index" @tap="goEdit">编辑</text>
<text class="act-btn primary" :data-index="index" @tap="goDetail">详情</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<!-- 空态 -->
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
/**
* 商家端 · 名片管理(基础版)
*
* 范围: 本轮仅开放现有接口可支撑的能力——搜索 / 详情 / 编辑。
* 列表/详情/编辑复用 card/lists + 既有 /pages_tool/card/detail、edit 页
* (路由参数 card_id, 字符串主键)。
* 查重合并 / 批量制作 / 新建数字名片 / 导出 CSV/vCard / 标签·状态·类型·跟进·收藏
* 筛选依赖后台未就绪接口, 后台文档(CARD_API_REFERENCE.md)到位后在本页扩展,
* 端点届时收口进 common/js/card-api.js, 页面只改 this.$cardApi 调用。
*/
import { CARD_SOURCE_TYPES } from '@/common/js/card-api.js';
export default {
data() {
return {
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
emptyText: '暂无名片刻'
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页返回时刷新(编辑成功后数据已变)
if (this._loadedOnce) this.refresh();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
avatarText(name) {
return (name || '?').trim().charAt(0).toUpperCase();
},
// 来源标签: 优先后端译文(source_type_text), 缺失时按字典兜底(与后端 source_type 对齐)
sourceLabel(item) {
if (!item) return '';
if (item.source_type_text) return item.source_type_text;
const hit = CARD_SOURCE_TYPES.find(s => s.value === item.source_type);
return hit ? hit.label : '';
},
onKeywordInput(e) {
this.keyword = e.detail.value;
},
onSearch() {
this.refresh();
},
clearKeyword() {
this.keyword = '';
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
// page_size 上限 100(后端硬限制), 这里 20 足够
this.$cardApi.cardLists({
data: {
page: pageNo,
page_size: this.pageSize,
keyword: this.keyword.trim()
},
success: res => {
this.isLoading = false;
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
this.emptyText = this.keyword.trim() ? '未找到匹配的名片' : '暂无名片刻';
} else {
// 后端 code!=0 的 message 可直接 toast
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
/* ---------- 导航 ---------- */
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 goDetail(item))会丢参,
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.list[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.card_id ? payload : null;
},
// 名片路由参数一律 card_id(字符串), encodeURIComponent 幂等
goDetail(payload) {
const item = this.resolveItem(payload);
if (!item || !item.card_id) return;
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.card_id)
});
},
goEdit(payload) {
const item = this.resolveItem(payload);
if (!item || !item.card_id) return;
uni.navigateTo({
url: '/pages_tool/card/edit?card_id=' + encodeURIComponent(item.card_id)
});
},
// 平级入口: 鸿蒙元服务上 uni.switchTab 静默失败, 必须走 $util.redirectTo
goContacts() {
this.$util.redirectTo('/pages_tool/card/admin/contacts');
},
goLeads() {
this.$util.redirectTo('/pages_tool/card/admin/leads');
},
goSelf() {
// 当前页, 无需跳转
}
}
};
</script>
<style lang="scss" scoped>
.admin-manage {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* 顶部平级切换(三页互链) */
.page-switch {
display: flex;
background-color: #ffffff;
padding: 0 12rpx;
.switch-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 8rpx;
width: 48rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
/* 功能预告条 */
.notice-bar {
display: flex;
align-items: center;
margin: 20rpx 24rpx 0;
padding: 16rpx 20rpx;
background-color: var(--main-color-shallow, #e8f1fb);
border-radius: 12rpx;
.notice-icon {
width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
border-radius: 50%;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 22rpx;
flex-shrink: 0;
margin-right: 12rpx;
}
.notice-text {
font-size: 24rpx;
color: var(--main-color, #0054a5);
line-height: 1.4;
}
}
.search-bar {
display: flex;
align-items: center;
padding: 20rpx 24rpx 0;
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 36rpx;
padding: 0 24rpx;
height: 72rpx;
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333;
}
.search-clear {
padding: 0 12rpx;
color: #bbb;
font-size: 28rpx;
}
.iconfont {
color: #999;
font-size: 32rpx;
padding-left: 12rpx;
}
}
}
.card-list {
padding: 20rpx 24rpx 0;
}
.card-item {
display: flex;
align-items: center;
background-color: #ffffff;
border-radius: 16rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
&:active {
background-color: #f0f2f5;
}
.card-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: var(--main-color-shallow, #e8f1fb);
color: var(--main-color, #0054a5);
font-size: 36rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
overflow: hidden;
.avatar-img {
width: 100%;
height: 100%;
}
}
.card-info {
flex: 1;
min-width: 0;
.card-name {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.card-sub {
font-size: 26rpx;
color: #666;
margin-top: 8rpx;
}
.card-mobile {
font-size: 26rpx;
color: #999;
margin-top: 8rpx;
}
}
.card-side {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
margin-left: 16rpx;
.source-tag {
font-size: 20rpx;
color: #999;
background-color: #f2f2f2;
border-radius: 20rpx;
padding: 4rpx 14rpx;
margin-bottom: 12rpx;
}
.card-actions {
display: flex;
.act-btn {
font-size: 24rpx;
color: #666;
border: 1rpx solid #ddd;
border-radius: 28rpx;
padding: 8rpx 22rpx;
& + .act-btn {
margin-left: 12rpx;
}
&.primary {
color: var(--main-color, #0054a5);
border-color: var(--main-color, #0054a5);
}
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
.line1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

213
pages_tool/card/apply.vue Normal file
View File

@@ -0,0 +1,213 @@
<template>
<view class="card-apply">
<!-- 申请状态条: 提交过申请或已有名片时替代表单 -->
<view class="tips" v-if="statusLoaded && statusTip">
<text class="tips-title">{{ statusTip }}</text>
<text class="tips-sub" v-if="statusSub">{{ statusSub }}</text>
</view>
<template v-if="formEditable">
<view class="tips">
<text class="tips-title">向商家申请一张数字名片</text>
<text class="tips-sub">提交后商家会收到通知,同意后名片自动出现在你的名片夹</text>
</view>
<view class="form-group">
<view class="form-row">
<view class="row-label"><text class="req-star">*</text>姓名</view>
<input class="row-input" :value="form.name" placeholder="你希望名片上显示的姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label"><text class="req-star">*</text>手机号</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="你注册本店使用的手机号"
@input="e => form.mobile = e.detail.value" />
</view>
</view>
</template>
<view class="save-bar" v-if="formEditable">
<button class="save-btn" :disabled="submitting" @tap="submit">{{ submitting ? '提交中…' : '提交申请' }}</button>
</view>
</view>
</template>
<script>
export default {
data() {
return {
form: { name: '', mobile: '' },
submitting: false,
// 状态回查: null=未加载, 否则 { apply, has_card }
applyStatus: null,
statusLoaded: false
};
},
computed: {
// 已有名片 / 申请待审 → 隐藏表单只显示状态; 被拒/无记录 → 允许(重新)提交
formEditable() {
const s = this.applyStatus;
if (!s) return true;
if (s.has_card) return false;
if (s.apply && s.apply.status === 0) return false; // 待审核
return true;
},
statusTip() {
const s = this.applyStatus;
if (!s) return '';
if (s.has_card) return '您已拥有名片,去名片夹查看吧';
if (s.apply && s.apply.status === 0) return '申请审核中,商家同意后名片会自动出现';
if (s.apply && s.apply.status === 2) return '上次申请未通过,可修改后重新提交';
return '';
},
statusSub() {
const s = this.applyStatus;
if (!s || !s.apply) return '';
if (s.apply.status === 2) return '提交于 ' + (s.apply.create_time_txt || '');
return s.apply.create_time_txt ? ('提交于 ' + s.apply.create_time_txt) : '';
}
},
onLoad() {
uni.setNavigationBarTitle({ title: '名片' });
this.loadStatus();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadStatus() {
this.$api.sendRequest({
url: '/card/api/apply/status',
success: res => {
if (res.code == 0 && res.data) {
this.applyStatus = res.data;
}
this.statusLoaded = true;
},
fail: () => {
// 回查失败不阻塞表单
this.statusLoaded = true;
}
});
},
submit() {
if (this.submitting) return;
const name = String(this.form.name || '').trim();
const mobile = String(this.form.mobile || '').trim();
if (!name) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
if (!/^1\d{10}$/.test(mobile)) {
uni.showToast({ title: '请填写正确的 11 位手机号', icon: 'none' });
return;
}
this.submitting = true;
this.$api.sendJson({
url: '/card/api/apply/submit',
data: { name: name, mobile: mobile },
success: res => {
this.submitting = false;
if (res.code == 0) {
uni.showToast({ title: '申请已提交', icon: 'success' });
setTimeout(() => uni.navigateBack(), 600);
} else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' });
}
},
fail: () => {
this.submitting = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-apply {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.tips {
padding: 20rpx 8rpx 24rpx;
display: flex;
flex-direction: column;
.tips-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.tips-sub {
margin-top: 8rpx;
font-size: 24rpx;
color: #999;
line-height: 1.6;
}
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
overflow: hidden;
.form-row {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
.req-star {
color: #f5222d;
margin-right: 4rpx;
font-weight: 600;
}
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
.save-bar {
padding: 40rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

View File

@@ -0,0 +1,255 @@
<template>
<view class="contact-edit">
<view class="form-group">
<view class="group-title">{{ isEdit ? '编辑人脉' : '添加人脉' }}</view>
<view class="form-row">
<view class="row-label"><text class="req-star">*</text>姓名</view>
<input class="row-input" :value="form.name" placeholder="请输入姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">手机</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="手机号(选填)"
@input="e => form.mobile = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">公司</view>
<input class="row-input" :value="form.company" placeholder="公司名称(选填)"
@input="e => form.company = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">职位</view>
<input class="row-input" :value="form.job_title" placeholder="职位(选填)"
@input="e => form.job_title = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">微信号</view>
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
@input="e => form.wechat = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮箱</view>
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
@input="e => form.email = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">网站</view>
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
@input="e => form.website = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">地址</view>
<input class="row-input" :value="form.address" placeholder="地址(选填)"
@input="e => form.address = e.detail.value" />
</view>
<view class="form-row remark-row">
<view class="row-label">备注</view>
<textarea class="remark-input" :value="form.remark" placeholder="备注(选填)"
@input="e => form.remark = e.detail.value" />
</view>
</view>
<!-- 保存 -->
<view class="save-bar">
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
</view>
</view>
</template>
<script>
// 与后端 Contact::EDITABLE_FIELDS 对齐(status 由列表归档操作管理, 编辑页不放)
const FIELDS = ['name', 'mobile', 'company', 'job_title', 'wechat', 'email', 'website', 'address', 'remark'];
export default {
data() {
return {
id: 0,
isEdit: false,
form: {
name: '', mobile: '', company: '', job_title: '',
wechat: '', email: '', website: '', address: '', remark: ''
},
saving: false,
fromAdmin: false
};
},
onLoad(options) {
this.id = parseInt(options.id || 0, 10) || 0;
this.isEdit = this.id > 0;
// from=admin: 商家端人脉管理页复用本编辑页, 保存后回商家端(缺省回员工端)
this.fromAdmin = options.from === 'admin';
if (this.isEdit) {
this.loadDetail();
}
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadDetail() {
this.$cardApi.contactInfo({
data: { id: this.id },
success: res => {
if (res.code == 0) {
const r = res.data || {};
FIELDS.forEach(k => {
if (r[k] !== undefined && r[k] !== null) {
this.form[k] = String(r[k]);
}
});
} else {
uni.showModal({
title: '提示',
content: res.message || '人脉不存在',
showCancel: false,
success: () => this.backToList()
});
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
save() {
if (this.saving) return;
// 防御式取值: 任何非字符串/undefined 都不会让校验误判或抛错
const name = String(this.form.name || '').trim();
if (!name) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
this.form.name = name;
this.saving = true;
const data = { id: this.id, fields: {} };
FIELDS.forEach(k => { data.fields[k] = this.form[k]; });
const onOk = res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '保存成功', icon: 'success' });
setTimeout(() => this.backToList(), 600);
} else {
// 后端错误文案有时放在 message, 有时放在 data(字符串), 两者都兜住
const msg = res.message || (typeof res.data === 'string' ? res.data : '') || '保存失败';
uni.showToast({ title: msg, icon: 'none' });
}
};
const onFail = () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
};
// add/update 都走 JSON body,跨端序列化零歧义。
// ⚠️ 契约差异(见 docs/CARD_API_REFERENCE.md §1.4.2 / §1.4.4):
// contact/add → 入参是「顶层平铺字段」, name 必须在 body 顶层;
// contact/update→ 额外兼容 fields 对象。
// 历史 bug: 新增时也包成 { fields: {...} }, 后端在顶层读不到 name,
// 返回「请填写姓名」→ 表现为"明明填了姓名还提示填写姓名"。
// 这里新增按契约平铺, 同时保留一份 fields 兼容旧后端(多余键会被白名单忽略)。
if (this.isEdit) {
this.$cardApi.contactUpdate({ data: data, success: onOk, fail: onFail });
} else {
const addPayload = Object.assign({}, data.fields, { fields: data.fields });
this.$cardApi.contactAdd({ data: addPayload, success: onOk, fail: onFail });
}
},
// 鸿蒙安全回列表: $util.redirectTo(禁裸 uni.switchTab/uni.navigateBack 兜底)
// 商家端(from=admin)回商家端人脉管理, 员工端回「我的人脉」
backToList() {
const target = this.fromAdmin ? '/pages_tool/card/admin/contacts' : '/pages_tool/card/contacts';
this.$util.redirectTo(target);
}
}
};
</script>
<style lang="scss" scoped>
.contact-edit {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
padding: 24rpx 24rpx 8rpx;
}
.form-row {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
}
/* 必填红星(与 card/edit.vue 同款) */
.req-star {
color: #f5222d;
margin-right: 4rpx;
font-weight: 600;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
.remark-row {
align-items: flex-start;
.remark-input {
flex: 1;
min-height: 120rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
}
.save-bar {
padding: 20rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

View File

@@ -0,0 +1,452 @@
<template>
<view class="contact-page">
<!-- 平级入口: 与潜在客户页互链(导航合并第一步) -->
<view class="page-switch" @tap="goLeads">
<text class="switch-label">潜在客户</text>
<text class="switch-arrow">›</text>
</view>
<!-- 搜索栏 + 添加入口 -->
<view class="search-bar">
<input class="search-input" :value="keyword" placeholder="搜索姓名 / 手机 / 公司"
confirm-type="search" @input="e => keyword = e.detail.value" @confirm="doSearch" />
<view class="search-btn" @tap="doSearch">搜索</view>
<view class="add-btn" @tap="goEdit()">+ 添加</view>
</view>
<!-- 来源筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: source === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 人脉列表 -->
<view class="contact-list">
<view v-for="item in list" :key="item.id" class="contact-item">
<view class="item-top" @tap="callPhone(item.mobile)">
<view class="item-name">{{ item.name }}</view>
<view class="item-source" :class="'src-' + item.source">{{ item.source_text }}</view>
</view>
<view class="item-company" v-if="item.company || item.job_title">
{{ item.company }}<text v-if="item.company && item.job_title"> · </text>{{ item.job_title }}
</view>
<view class="item-mobile" v-if="item.mobile" @tap="callPhone(item.mobile)">{{ item.mobile }}</view>
<view class="item-remark" v-if="item.remark">「{{ item.remark }}」</view>
<view class="item-meta">
<text class="meta-time">{{ item.create_time_txt }}</text>
<text class="meta-status" v-if="item.status == 1">已归档</text>
</view>
<view class="item-actions">
<view class="action-btn plain" :data-index="index" @tap="goEdit">编辑</view>
<view class="action-btn primary" :data-index="index" @tap="convertToLead">转为潜在客户</view>
<view class="action-btn danger" :data-index="index" @tap="delContact">删除</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="'暂无人脉,点右上角添加'"></ns-empty>
</view>
</view>
</template>
<script>
// 来源筛选: 与后端 Contact::SOURCES 一致; scan/exchange 暂无产生入口, 本轮只放出 3 个 tab
const SOURCE_TABS = [
{ value: '', label: '全部' },
{ value: 'manual', label: '手工录入' },
{ value: 'lead_in', label: '潜在客户转入' }
];
export default {
data() {
return {
tabs: SOURCE_TABS,
source: '',
keyword: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onShow() {
// 从编辑页保存后 redirectTo 回来会重走 onLoad; 从其它页返回时兜底刷新
if (this.list.length > 0) {
this.refresh();
}
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
goLeads() {
this.$util.redirectTo('/pages_tool/card/leads');
},
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象(如 convertToLead(item))会丢参,
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
* 兼容: 无参(新建) / 事件对象(data-index) / 直接 id。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.list[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.id ? payload : null;
},
goEdit(payload) {
let id = payload;
if (payload && payload.currentTarget) {
const i = Number(payload.currentTarget.dataset.index);
const item = this.list[i];
id = item ? item.id : '';
}
const url = id ? '/pages_tool/card/contact-edit?id=' + id : '/pages_tool/card/contact-edit';
this.$util.redirectTo(url);
},
switchTab(value) {
if (this.source === value) return;
this.source = value;
this.refresh();
},
doSearch() {
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 source 条件
if (this.source !== '') {
data.source = this.source;
}
if (this.keyword.trim() !== '') {
data.keyword = this.keyword.trim();
}
this.$cardApi.contactLists({
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
convertToLead(payload) {
const item = this.resolveItem(payload);
if (!item) return;
uni.showModal({
title: '转为潜在客户',
content: '将「' + (item.name || '') + '」转入潜在客户跟进?人脉记录会保留。',
success: res => {
if (!res.confirm) return;
this.doConvert(item);
}
});
},
doConvert(item) {
uni.showLoading({ title: '转换中…', mask: true });
this.$cardApi.contactToLead({
data: { id: item.id },
success: res => {
uni.hideLoading();
if (res.code == 0) {
uni.showToast({ title: '已转入潜在客户', icon: 'success' });
} else {
uni.showToast({ title: res.message || '转换失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
delContact(payload) {
const item = this.resolveItem(payload);
if (!item) return;
uni.showModal({
title: '删除人脉',
content: '删除后不可恢复,确定删除「' + (item.name || '') + '」?',
confirmColor: '#f5222d',
success: res => {
if (!res.confirm) return;
this.doDelete(item);
}
});
},
doDelete(item) {
this.$cardApi.contactDelete({
data: { id: item.id },
success: res => {
if (res.code == 0) {
this.list = this.list.filter(c => c.id !== item.id);
uni.showToast({ title: '已删除', icon: 'success' });
} else {
uni.showToast({ title: res.message || '删除失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
}
}
};
</script>
<style lang="scss" scoped>
.contact-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16rpx 24rpx 0;
.switch-label {
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.switch-arrow {
font-size: 26rpx;
color: var(--main-color, #0054a5);
margin-left: 4rpx;
}
}
.search-bar {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
.search-input {
flex: 1;
height: 64rpx;
line-height: 64rpx;
background-color: #ffffff;
border-radius: 32rpx;
padding: 0 28rpx;
font-size: 26rpx;
color: #333;
}
.search-btn {
font-size: 26rpx;
color: #666;
padding: 0 24rpx;
}
.add-btn {
font-size: 26rpx;
color: #ffffff;
background-color: var(--main-color, #0054a5);
border-radius: 32rpx;
padding: 12rpx 28rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.contact-list {
padding: 20rpx 24rpx 0;
}
.contact-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.item-top {
display: flex;
align-items: center;
.item-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.item-source {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.src-manual {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.src-lead_in {
color: #1daa39;
background-color: #e8f8ea;
}
&.src-scan,
&.src-exchange {
color: #999;
background-color: #f2f2f2;
}
}
}
.item-company {
font-size: 26rpx;
color: #666;
margin-top: 10rpx;
}
.item-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.item-remark {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.item-meta {
display: flex;
justify-content: space-between;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
}
.item-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 26rpx;
margin-left: 16rpx;
&.plain {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
&.danger {
color: #f5222d;
border: 1rpx solid #f5222d;
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

1426
pages_tool/card/detail.vue Normal file

File diff suppressed because it is too large Load Diff

585
pages_tool/card/edit.vue Normal file
View File

@@ -0,0 +1,585 @@
<template>
<view class="card-edit">
<!-- 基本信息 -->
<view class="form-group">
<view class="group-title">基本信息</view>
<view class="form-row">
<view class="row-label">头像</view>
<view class="avatar-box" @tap="chooseAvatar">
<image v-if="form.avatar" :src="$util.img(form.avatar)" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-plus">+</text>
</view>
<view class="avatar-tip">点击从相册选择</view>
</view>
<view class="form-row">
<view class="row-label"><text class="req-star">*</text>姓名</view>
<input class="row-input" :value="form.name" placeholder="请输入姓名"
@input="e => form.name = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">公司</view>
<input class="row-input" :value="form.company" placeholder="请输入公司名称"
@input="e => form.company = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">职位</view>
<input class="row-input" :value="form.job_title" placeholder="请输入职位"
@input="e => form.job_title = e.detail.value" />
</view>
</view>
<!-- 联系方式 -->
<view class="form-group">
<view class="group-title">联系方式</view>
<view class="form-row">
<view class="row-label">手机</view>
<input class="row-input" type="number" :value="form.mobile" placeholder="请输入手机号"
@input="e => form.mobile = e.detail.value" />
</view>
<!-- 附加手机号(动态行, 与主手机号并列提交 mobiles) -->
<view v-for="(m, i) in mobiles" :key="'mob' + i" class="form-row dyn-row">
<view class="row-label">手机{{ i + 2 }}</view>
<input class="row-input" type="number" :value="m" placeholder="其他手机号"
@input="e => onMobileInput(i, e)" />
<text class="dyn-del" @tap="delMobile(i)">×</text>
</view>
<view class="form-row" v-if="mobiles.length < MAX_MOBILES">
<text class="dyn-add" @tap="addMobile">+ 添加手机号(最多{{ MAX_MOBILES }}个)</text>
</view>
<view class="form-row">
<view class="row-label">电话</view>
<input class="row-input" :value="form.phone" placeholder="固定电话(选填)"
@input="e => form.phone = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮箱</view>
<input class="row-input" :value="form.email" placeholder="邮箱(选填)"
@input="e => form.email = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">网站</view>
<input class="row-input" :value="form.website" placeholder="个人主页 / 官网(选填)"
@input="e => form.website = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">微信号</view>
<input class="row-input" :value="form.wechat" placeholder="微信号(选填)"
@input="e => form.wechat = e.detail.value" />
</view>
<!-- Telegram / Messenger 已于 2026-10-13 软下线为独立字段, 统一在下方「社交媒体账号」添加 -->
</view>
<!-- 社交媒体账号 -->
<view class="form-group">
<view class="group-title">社交媒体账号</view>
<view v-for="(s, i) in socials" :key="'soc' + i" class="form-row dyn-row">
<picker class="dyn-picker" :range="socialTypeLabels" :value="socialTypeIndex(s.type)"
@change="e => onSocialTypeChange(i, e)">
<view class="picker-text">{{ socialTypeLabel(s.type) }} ▾</view>
</picker>
<input class="row-input" :value="s.value" placeholder="账号 / 链接"
@input="e => onSocialValueInput(i, e)" />
<text class="dyn-del" @tap="delSocial(i)">×</text>
</view>
<view class="form-row" v-if="socials.length < MAX_SOCIALS">
<text class="dyn-add" @tap="addSocial">+ 添加社交媒体(最多{{ MAX_SOCIALS }}个)</text>
</view>
<view class="group-tip" v-if="socials.length === 0">还没有社交媒体账号;Telegram / Messenger 也已并入此处添加</view>
</view>
<!-- 自定义字段 -->
<view class="form-group">
<view class="group-title">自定义字段</view>
<view v-for="(c, i) in customFields" :key="'cst' + i" class="form-row dyn-row">
<input class="cf-label" :value="c.label" placeholder="名称"
@input="e => onCustomLabelInput(i, e)" />
<input class="row-input" :value="c.value" placeholder="内容"
@input="e => onCustomValueInput(i, e)" />
<text class="dyn-del" @tap="delCustom(i)">×</text>
</view>
<view class="form-row" v-if="customFields.length < MAX_CUSTOM">
<text class="dyn-add" @tap="addCustom">+ 添加自定义字段(最多{{ MAX_CUSTOM }}个)</text>
</view>
<view class="group-tip" v-if="customFields.length === 0">如「展厅号 E3-108」「收款方式」等个性信息</view>
</view>
<!-- 地址 -->
<view class="form-group">
<view class="group-title">地址</view>
<view class="form-row">
<view class="row-label">国家</view>
<input class="row-input" :value="form.country" placeholder="选填"
@input="e => form.country = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">省份</view>
<input class="row-input" :value="form.province" placeholder="选填"
@input="e => form.province = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">城市</view>
<input class="row-input" :value="form.city" placeholder="选填"
@input="e => form.city = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">详细地址</view>
<input class="row-input" :value="form.address" placeholder="街道门牌等"
@input="e => form.address = e.detail.value" />
</view>
<view class="form-row">
<view class="row-label">邮编</view>
<input class="row-input" :value="form.postal_code" placeholder="选填"
@input="e => form.postal_code = e.detail.value" />
</view>
</view>
<!-- 个人简介 -->
<view class="form-group">
<view class="group-title">个人简介</view>
<view class="form-row bio-row">
<textarea class="bio-input" :value="form.bio" placeholder="介绍一下自己…(保留换行)"
@input="e => form.bio = e.detail.value" />
</view>
</view>
<!-- 保存 -->
<view class="save-bar">
<button class="save-btn" :disabled="saving" @tap="save">{{ saving ? '保存中…' : '保存' }}</button>
</view>
</view>
</template>
<script>
/**
* 与后端 BusinessCardFieldNormalizer 输出对齐的标量字段(含 2026-10 补的 bio)。
* ⚠️ 2026-10-13: telegram / messenger 软下线为独立标量(传了也被后端忽略),
* 统一走下方社交媒体动态行; 旧数据读取时已由后端合并进 socials_list。
* 富化字段(mobiles/socials/custom_fields)由页面动态行单独收集,
* 后端 Card::update 的 richFieldSaves 仅对显式携带的键做 CardRichFields 清洗。
*
* SOCIAL_TYPES / CARD_FIELDS / RICH_FIELD_LIMITS 统一从收口层导入,
* 与后端白名单单点同步(见 common/js/card-api.js)。
*/
import { SOCIAL_TYPES, CARD_FIELDS, RICH_FIELD_LIMITS } from '@/common/js/card-api.js';
// 注:telegram/messenger 已软下线(2026-10-13), 不再作为独立标量字段,
// 统一在「社交媒体账号」动态行里以 SOCIAL_TYPES 中的类型方式添加。
// 上限与后端 CardRichFields::MAX_* 一致
const MAX_MOBILES = RICH_FIELD_LIMITS.MAX_MOBILES;
const MAX_SOCIALS = RICH_FIELD_LIMITS.MAX_SOCIALS;
const MAX_CUSTOM = RICH_FIELD_LIMITS.MAX_CUSTOM;
export default {
data() {
return {
cardId: '',
// 新建模式: 无 card_id 进入, 提交 /card/api/card/create
isCreate: false,
form: {
name: '', company: '', job_title: '', avatar: '',
mobile: '', phone: '', email: '', website: '', wechat: '',
country: '', province: '', city: '', address: '', postal_code: '',
bio: ''
},
mobiles: [],
socials: [],
customFields: [],
socialTypeLabels: SOCIAL_TYPES.map(t => t.label),
MAX_MOBILES,
MAX_SOCIALS,
MAX_CUSTOM,
isLoading: false,
saving: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.isCreate = this.cardId === '';
if (this.isCreate) {
uni.setNavigationBarTitle({ title: '名片' });
} else {
this.loadDetail();
}
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
methods: {
loadDetail() {
if (!this.cardId) {
uni.showToast({ title: '缺少名片编号', icon: 'none' });
return;
}
this.isLoading = true;
this.$cardApi.cardDetail({
data: { card_id: this.cardId },
success: res => {
this.isLoading = false;
if (res.code == 0) {
const r = (res.data && res.data.relation) || {};
CARD_FIELDS.forEach(k => {
if (r[k] !== undefined && r[k] !== null) {
this.form[k] = String(r[k]);
}
});
// 富化字段: detail 由后端 CardRichFields 解码后挂在 relation 上
// (注意是 res.data.relation.mobiles_list, 不是 res.data.mobiles_list)
this.mobiles = (r.mobiles_list || []).map(v => String(v || ''));
this.socials = (r.socials_list || []).map(s => ({
type: s.type || 'other',
value: String(s.value || '')
}));
this.customFields = (r.custom_list || []).map(c => ({
label: String(c.label || ''),
value: String(c.value || '')
}));
} else {
uni.showModal({
title: '提示',
content: res.message || '卡片不存在',
showCancel: false,
success: () => uni.navigateBack()
});
}
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
/* ---------- 头像: 相册选择 → 系统上传 → 回填路径 ---------- */
chooseAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: res => {
const filePath = res.tempFilePaths && res.tempFilePaths[0];
if (!filePath) return;
uni.showLoading({ title: '上传中…', mask: true });
this.$api.upload({
url: '/api/upload/headimg',
filePath: filePath,
fileType: 'image',
success: r => {
uni.hideLoading();
if (r && r.code == 0 && r.data && r.data.pic_path) {
this.form.avatar = r.data.pic_path;
} else {
uni.showToast({ title: (r && r.message) || '上传失败', icon: 'none' });
}
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: '上传失败', icon: 'none' });
}
});
}
});
},
/* ---------- 附加手机号 ---------- */
onMobileInput(i, e) {
this.$set(this.mobiles, i, e.detail.value);
},
addMobile() {
if (this.mobiles.length >= MAX_MOBILES) {
uni.showToast({ title: '最多 ' + MAX_MOBILES + ' 个手机号', icon: 'none' });
return;
}
this.mobiles.push('');
},
delMobile(i) {
this.mobiles.splice(i, 1);
},
/* ---------- 社交媒体 ---------- */
socialTypeLabel(key) {
const t = SOCIAL_TYPES.find(x => x.key === key);
return t ? t.label : '其他';
},
socialTypeIndex(key) {
const i = SOCIAL_TYPES.findIndex(x => x.key === key);
return i < 0 ? SOCIAL_TYPES.length - 1 : i;
},
onSocialTypeChange(i, e) {
const idx = Number(e.detail.value);
const t = SOCIAL_TYPES[idx];
if (t) this.$set(this.socials, i, { type: t.key, value: this.socials[i].value });
},
onSocialValueInput(i, e) {
const s = this.socials[i];
this.$set(this.socials, i, { type: s.type, value: e.detail.value });
},
addSocial() {
if (this.socials.length >= MAX_SOCIALS) {
uni.showToast({ title: '最多 ' + MAX_SOCIALS + ' 个社交媒体', icon: 'none' });
return;
}
this.socials.push({ type: 'other', value: '' });
},
delSocial(i) {
this.socials.splice(i, 1);
},
/* ---------- 自定义字段 ---------- */
onCustomLabelInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: e.detail.value, value: c.value });
},
onCustomValueInput(i, e) {
const c = this.customFields[i];
this.$set(this.customFields, i, { label: c.label, value: e.detail.value });
},
addCustom() {
if (this.customFields.length >= MAX_CUSTOM) {
uni.showToast({ title: '最多 ' + MAX_CUSTOM + ' 个自定义字段', icon: 'none' });
return;
}
this.customFields.push({ label: '', value: '' });
},
delCustom(i) {
this.customFields.splice(i, 1);
},
/* ---------- 保存 ---------- */
save() {
if (this.saving) return;
if (!this.form.name.trim()) {
uni.showToast({ title: '请填写姓名', icon: 'none' });
return;
}
this.saving = true;
// 标量全量提交(空值=清空, 与后端 normalizer 语义一致);
// 富化字段始终显式携带(空数组=清空), 后端 richFieldSaves 只对携带的键清洗落库。
// 走 sendJson(JSON body): 嵌套数组序列化跨端零歧义, TP6 原生解析
const fields = {};
CARD_FIELDS.forEach(k => { fields[k] = this.form[k]; });
fields.mobiles = this.mobiles.map(v => String(v || '').trim());
fields.socials = this.socials.map(s => ({ type: s.type, value: String(s.value || '').trim() }));
fields.custom_fields = this.customFields.map(c => ({
label: String(c.label || '').trim(),
value: String(c.value || '').trim()
}));
// 新建模式走 card/create, 编辑模式走 card/update(均经 $cardApi 收口层)
// ⚠️ 必须包在 data 里: sendJson 只合并 params.data(见 common/js/http.js),
// 顶层直接挂 fields 会被丢弃 → 后端收不到 name, 报「请填写姓名」
const payload = this.isCreate
? { fields: fields }
: { card_id: this.cardId, fields: fields };
(this.isCreate ? this.$cardApi.cardCreate : this.$cardApi.cardUpdate)({
data: payload,
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: this.isCreate ? '创建成功' : '保存成功', icon: 'success' });
if (this.isCreate) {
// 新建成功 → 跳详情页(redirectTo 替换当前编辑页, 返回栈回到列表)
const newCardId = (res.data && res.data.card_id) || '';
setTimeout(() => {
if (newCardId) {
uni.redirectTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(newCardId)
});
} else {
uni.navigateBack();
}
}, 600);
} else {
// 返回详情页; detail.vue 的 onShow 会自动刷新
setTimeout(() => uni.navigateBack(), 600);
}
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-edit {
min-height: 100vh;
background-color: #f6f7f9;
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.form-group {
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
padding: 24rpx 24rpx 8rpx;
}
.group-tip {
font-size: 22rpx;
color: #999;
padding: 0 24rpx 20rpx;
}
.form-row {
display: flex;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.row-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
}
/* 必填红星 */
.req-star {
color: #f5222d;
margin-right: 4rpx;
font-weight: 600;
}
.row-input {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
/* 头像行 */
.avatar-box {
width: 108rpx;
height: 108rpx;
border-radius: 12rpx;
background-color: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-plus {
font-size: 44rpx;
color: #c0c4cc;
}
}
.avatar-tip {
margin-left: 20rpx;
font-size: 24rpx;
color: #999;
}
/* 动态行(附加手机号/社交媒体/自定义字段) */
.dyn-row {
.row-label {
width: 160rpx;
}
}
.dyn-del {
width: 44rpx;
text-align: center;
font-size: 34rpx;
color: #c0c4cc;
flex-shrink: 0;
padding: 6rpx 0 6rpx 12rpx;
}
.dyn-add {
flex: 1;
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.dyn-picker {
flex-shrink: 0;
width: 170rpx;
height: 44rpx;
line-height: 44rpx;
border: 1rpx solid #dcdfe6;
border-radius: 8rpx;
margin-right: 16rpx;
.picker-text {
font-size: 24rpx;
color: #333;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.cf-label {
width: 170rpx;
flex-shrink: 0;
font-size: 26rpx;
color: #333;
background-color: #f6f7f9;
border-radius: 8rpx;
padding: 10rpx 16rpx;
margin-right: 16rpx;
}
/* 简介 */
.bio-row {
align-items: flex-start;
.bio-input {
width: 100%;
min-height: 160rpx;
font-size: 28rpx;
color: #333;
line-height: 1.6;
}
}
.save-bar {
padding: 20rpx 0 0;
.save-btn {
height: 88rpx;
line-height: 88rpx;
background-color: var(--main-color, #0054a5);
color: #ffffff;
font-size: 30rpx;
border-radius: 44rpx;
&[disabled] {
opacity: 0.6;
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
</style>

View File

@@ -0,0 +1,830 @@
<template>
<view class="ent-card" :style="[designVars, { fontFamily: ds.fontFamily }]">
<view class="page-layer">
<!-- 顶部英雄区: 蓝色渐变圆角卡(对齐后端 wap 参考图) -->
<view class="hero" v-if="headerSrc || staffList.length">
<view class="hero-pill">
<view class="hero-pill-dot"></view>
<text>数字名片</text>
</view>
<!-- staff 模式: 员工名片轮播 -->
<swiper v-if="headerMode === 'staff' && staffList.length" class="hero-swiper"
:indicator-dots="staffList.length > 1" indicator-active-color="rgba(255,255,255,0.9)"
indicator-color="rgba(255,255,255,0.4)" :autoplay="staffList.length > 1" circular
@change="onStaffChange">
<swiper-item v-for="(s, i) in staffList" :key="i">
<view class="hero-id">
<image v-if="s.avatar" class="hi-avatar" :src="$util.img(s.avatar)" mode="aspectFill" />
<view v-else class="hi-avatar hi-avatar-ph">
<text class="hi-avatar-char">企</text>
</view>
<view class="hi-info">
<view class="hi-name">{{ s.name }}</view>
<view class="hi-job" v-if="s.job_title">{{ s.job_title }}</view>
</view>
</view>
</swiper-item>
</swiper>
<!-- official / self: 单人信息 -->
<view v-else class="hero-id">
<image v-if="headerSrc && headerSrc.avatar" class="hi-avatar" :src="$util.img(headerSrc.avatar)" mode="aspectFill" />
<view v-else class="hi-avatar hi-avatar-ph">
<text class="hi-avatar-char">企</text>
</view>
<view class="hi-info">
<view class="hi-name">{{ headerSrc.name || '企业名片' }}</view>
<view class="hi-job" v-if="headerSrc.job_title">{{ headerSrc.job_title }}</view>
</view>
</view>
</view>
<!-- 快捷操作条: 压在英雄卡下沿(参考图: 打电话/发邮件/官方网站/存通讯录) -->
<view class="quick" v-if="headerSrc">
<view class="q-item" @tap="onQuickCall">
<view class="q-icon q-icon-blue">
<text class="iconfont icon-dianhua"></text>
</view>
<text class="q-label">打电话</text>
</view>
<view class="q-item" @tap="onQuickEmail">
<view class="q-icon q-icon-cyan">
<text class="q-char">@</text>
</view>
<text class="q-label">发邮件</text>
</view>
<view class="q-item" @tap="onQuickWebsite">
<view class="q-icon q-icon-blue">
<text class="q-char">网</text>
</view>
<text class="q-label">官方网站</text>
</view>
<view class="q-item" @tap="onQuickSave">
<view class="q-icon q-icon-orange">
<text class="iconfont icon-yonghu"></text>
</view>
<text class="q-label">存通讯录</text>
</view>
</view>
<!-- 企业简介 -->
<view class="bio" v-if="fields.bio" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
<text class="bio-text" :style="{ color: ds.textColor }">{{ fields.bio }}</text>
<text class="bio-en" v-if="fields.bio_en" :style="{ color: ds.textSoft }">{{ fields.bio_en }}</text>
</view>
<!-- 联系资料(企业字段) -->
<view class="contact" v-if="contentRows.length" :style="{ backgroundColor: ds.bgColor, borderRadius: ds.cardRadius + 'px' }">
<view class="ct-title" :style="{ borderLeftColor: ds.themeColor }">联系资料</view>
<view v-for="(row, idx) in contentRows" :key="idx" class="ct-row">
<view class="ct-icon" :style="{ backgroundColor: ds.themeSoft }">
<text v-if="row.icon" class="iconfont" :class="row.icon" :style="{ color: ds.themeColor }"></text>
<text v-else class="ct-char" :style="{ color: ds.themeColor }">{{ row.label.slice(0, 1) }}</text>
</view>
<text class="ct-label" :style="{ color: ds.textSoft }">{{ row.label }}</text>
<text class="ct-value" :class="{ 'ct-multi': row.multiline }" :style="{ color: ds.textColor }">{{ row.value }}</text>
<text v-if="row.copyable" class="ct-copy" @tap="copyText(row.value)">复制</text>
</view>
</view>
<!-- self 模式未自选提示 -->
<view class="self-hint" v-if="headerMode === 'self' && headerSelf && headerSelf.chosen === 0" @tap="openChooseSelf">
<text class="sh-icon iconfont icon-gonggao" :style="{ color: ds.themeColor }"></text>
<text class="sh-text">你还没有选定展示名片,当前自动取最新一张</text>
<text class="sh-btn" :style="{ color: ds.themeColor }">选择</text>
</view>
<!-- 加载中 -->
<view class="ent-loading" v-if="isLoading">加载中…</view>
<!-- 不存在 / 无权查看 -->
<ns-empty v-if="!isLoading && !headerSrc && !staffList.length" :isIndex="false"
:text="errorMsg || '企业名片不存在或未生效,请在管理端完善资料并设为生效'"></ns-empty>
</view>
<!-- 底部操作条(参考图: 保存到通讯录 + 分享; 收藏/交换名片为企业卡无对应后端能力, 暂不提供) -->
<view class="bottom-bar" v-if="headerSrc">
<view class="bb-btn bb-main" @tap="onQuickSave">保存到通讯录</view>
<!-- #ifdef MP-WEIXIN -->
<button class="bb-btn bb-share" open-type="share">分享</button>
<!-- #endif -->
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
<view class="bb-btn bb-share" @tap="openShare">分享</view>
<!-- #endif -->
</view>
<!-- 员工自选展示名片弹层 -->
<view class="choose-mask" v-if="showChoose" @tap="closeChoose"></view>
<view class="choose-sheet" :class="{ 'choose-show': showChoose }">
<view class="cs-bar"></view>
<view class="cs-title">选择展示名片</view>
<view class="cs-list">
<view v-for="(c, idx) in myCards" :key="idx" class="cs-item" @tap="chooseSelf(c)">
<view class="cs-avatar" :style="{ backgroundColor: ds.themeColor }">{{ (c.name || '?').slice(0, 1) }}</view>
<view class="cs-info">
<view class="cs-name">{{ c.name }}</view>
<view class="cs-sub" v-if="c.job_title || c.company">{{ [c.job_title, c.company].filter(Boolean).join(' · ') }}</view>
</view>
<text v-if="headerSelf && headerSelf.card_id === c.card_id" class="cs-chosen iconfont icon-dui" :style="{ color: ds.themeColor }"></text>
</view>
<view class="cs-empty" v-if="!myCards.length">你还没有个人名片</view>
</view>
</view>
</view>
</template>
<script>
function mixHex(a, b, t) {
const toRgb = h => {
h = (h || '').replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const n = parseInt(h, 16);
if (isNaN(n)) return [0, 0, 0];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [r1, g1, b1] = toRgb(a);
const [r2, g2, b2] = toRgb(b);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const bl = Math.round(b1 + (b2 - b1) * t);
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
}
export default {
data() {
return {
cardId: '',
isLoading: true,
errorMsg: '',
data: null, // 企业名片详情(后端 ENTERPRISE_UNIAPP_GUIDE §三)
header: null, // {mode,official_card_id,official,self,staff}
fields: {}, // 企业信息(内容区)
design: null, // {theme_color,bg_color,text_color,template_id,canvas,header_mode,official_card_id}
staffIndex: 0, // staff 轮播当前项(存通讯录按当前展示的员工取字段)
showChoose: false,
myCards: []
};
},
computed: {
headerMode() {
return (this.header && this.header.mode) || 'official';
},
headerSrc() {
const h = this.header || {};
let src;
if (this.headerMode === 'official') src = h.official || this.fields;
else if (this.headerMode === 'self') src = h.self || this.fields;
else return null; // staff 走轮播
// 注意: 空对象 {} 在 JS 里是真值, 必须显式判空,
// 否则后端返回空数据时会渲染出"有壳没内容"的空页面且空态提示也不出现
return src && Object.keys(src).length ? src : null;
},
staffList() {
return (this.header && this.header.staff) || [];
},
staffCurrent() {
return this.staffList[this.staffIndex] || null;
},
headerSelf() {
return (this.header && this.header.self) || null;
},
// 名片区当前展示的那个人(official/self 单人 或 staff 当前轮播项)
displayPerson() {
if (this.headerMode === 'staff') return this.staffCurrent || {};
return this.headerSrc || {};
},
contentRows() {
const f = this.fields || {};
const rows = [];
const push = (label, value, opts) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
rows.push(Object.assign({ label, value: String(value) }, opts || {}));
}
};
push('手机', f.mobile, { icon: 'icon-shouji1', copyable: true });
push('邮箱', f.email, { copyable: true });
push('网站', f.website, { copyable: true });
push('地址', f.address, { icon: 'icon-dizhi' });
push('企业名称', f.name);
return rows;
},
/* ============ design 归一化(与 detail.vue 同源, 仅做有限主题化) ============ */
ds() {
const d = this.design || {};
const FONTS = {
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
};
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
const theme = isHex(d.theme_color) ? d.theme_color : '#119dff';
const text = isHex(d.text_color) ? d.text_color : '#1f2329';
const bg = isHex(d.bg_color) ? d.bg_color : '#ffffff';
const fontKey = FONTS[d.font_family] ? d.font_family : 'system';
const fontScale = Math.min(160, Math.max(60, Number(d.font_scale) || 100));
return {
themeColor: theme,
textColor: text,
bgColor: bg,
fontFamily: FONTS[fontKey],
cardRadius: Number(d.card_radius) || 16,
fontScaleNum: fontScale / 100,
textSoft: mixHex(text, bg, 0.55),
themeSoft: mixHex(theme, '#ffffff', 0.85),
themeBorder: mixHex(theme, '#ffffff', 0.55),
headerGradientEnd: typeof d.header_gradient_end === 'string' ? d.header_gradient_end : ''
};
},
designVars() {
const s = this.ds;
return {
'--card-theme': s.themeColor,
'--card-theme-end': s.headerGradientEnd || s.themeColor,
'--card-theme-soft': s.themeSoft,
'--card-theme-border': s.themeBorder,
'--card-text': s.textColor,
'--card-text-soft': s.textSoft,
'--card-bg': s.bgColor,
'--card-radius': s.cardRadius + 'px',
'--card-font': s.fontFamily,
'--card-font-scale': s.fontScaleNum
};
}
},
onLoad(options) {
this.cardId = (options && options.card_id) ? decodeURIComponent(options.card_id) : '';
this.loadData();
},
onReady() {
uni.setNavigationBarTitle({ title: '企业名片' });
},
onShow() {
// 每次显示都兜底标题(微信返回本页时可能恢复成旧值); 从自选弹层返回也刷新
uni.setNavigationBarTitle({ title: '企业名片' });
// #ifdef H5
document.title = '企业名片';
// #endif
if (this._loadedOnce) this.loadData();
this._loadedOnce = true;
},
onPullDownRefresh() {
this.loadData();
setTimeout(() => uni.stopPullDownRefresh(), 300);
},
onShareAppMessage() {
const name = (this.fields && this.fields.name) || '企业名片';
return {
title: name + ' - 企业名片',
path: '/pages_tool/card/enterprise' + (this.cardId ? '?card_id=' + encodeURIComponent(this.cardId) : ''),
imageUrl: this.displayPerson && this.displayPerson.avatar ? this.$util.img(this.displayPerson.avatar) : ''
};
},
methods: {
loadData(retry) {
this.isLoading = true;
this.$cardApi.enterpriseDetail({
data: this.cardId ? { card_id: this.cardId } : {},
success: res => {
// 输出原始响应, 便于真机排查"刷新后数据变空"类问题(控制台看后端到底回了什么)
console.log('[enterprise] detail resp', this.cardId || '(无card_id)', res);
if (res.code == 0) {
const d = res.data || {};
const emptyCard = !d.header && (!d.fields || !Object.keys(d.fields).length);
if (emptyCard && (this.header || Object.keys(this.fields).length)) {
// 已有数据时刷新却拿到空卡(多为后端"当前生效卡"与所查卡不一致):
// 保留原数据, 避免"刷新一下内容就没了"
console.warn('[enterprise] 刷新返回空卡, 保留原数据', d);
} else {
this.data = d;
this.header = d.header || null;
this.fields = d.fields || {};
this.design = d.design || null;
}
this.errorMsg = '';
this.isLoading = false;
} else if (res.code == -1 && !retry) {
// -1 = 尚未登录: 冷启动/刚返回本页时 store 的 token 可能还没恢复完成,
// 属瞬时态, 静默重试一次再决定是否展示错误(与 detail.vue 同策略)
setTimeout(() => this.loadData(true), 800);
} else {
// 登录类错误(-1/-10009 等)不展示"去登录"死路:
// 本页是客户视角分享页, 按后端对接文档 §五 游客也应可看(待后端放开),
// 这里给中性可重试文案; 其余错误原样带出
const msg = res.message || '';
const isLoginErr = res.code == -1 || /登录/.test(msg);
this.errorMsg = isLoginErr ? '名片加载失败,请下拉重试' : (msg || '加载失败');
this.isLoading = false;
}
},
fail: () => {
// 失败时保留原数据(不清空), 仅在首次无数据时展示错误
if (!this.header && !Object.keys(this.fields).length) {
this.errorMsg = '网络异常,请下拉重试';
}
this.isLoading = false;
}
});
},
onStaffChange(e) {
this.staffIndex = (e.detail && e.detail.current) || 0;
},
copyText(v) {
uni.setClipboardData({
data: String(v || ''),
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
},
/* ===== 快捷操作(参考图四项) ===== */
onQuickCall() {
const m = this.displayPerson.mobile || this.fields.mobile;
if (!m) return uni.showToast({ title: '未填写手机号', icon: 'none' });
uni.makePhoneCall({ phoneNumber: String(m) });
},
onQuickEmail() {
const e = this.displayPerson.email || this.fields.email;
if (!e) return uni.showToast({ title: '未填写邮箱', icon: 'none' });
this.copyText(e);
},
onQuickWebsite() {
const w = this.displayPerson.website || this.fields.website;
if (!w) return uni.showToast({ title: '未填写官网', icon: 'none' });
// 小程序内无法直接打开外部网页(web-view 中转体验差), 复制链接到浏览器打开
this.copyText(w);
uni.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none' });
},
onQuickSave() {
// 跳原生页直接调 addPhoneContact(web-view 内 H5 才受限, 原生页可直存)
const p = this.displayPerson || {};
const q = [
'name=' + encodeURIComponent(p.name || this.fields.name || ''),
'mobile=' + encodeURIComponent(p.mobile || this.fields.mobile || ''),
'company=' + encodeURIComponent(this.fields.name || ''),
'job_title=' + encodeURIComponent(p.job_title || ''),
'email=' + encodeURIComponent(p.email || this.fields.email || ''),
'website=' + encodeURIComponent(p.website || this.fields.website || ''),
'address=' + encodeURIComponent(p.address || this.fields.address || ''),
'wechat=' + encodeURIComponent(p.wechat || '')
].join('&');
uni.navigateTo({ url: '/pages_tool/card/save-contact?' + q });
},
openChooseSelf() {
this.$cardApi.cardLists({
data: { page: 1, page_size: 100 },
success: res => {
const d = res.data || {};
this.myCards = d.list || [];
this.showChoose = true;
},
fail: () => uni.showToast({ title: '加载名片失败', icon: 'none' })
});
},
closeChoose() {
this.showChoose = false;
},
chooseSelf(c) {
if (!c || !c.card_id) return;
this.$cardApi.enterpriseChooseSelf({
data: { card_id: c.card_id },
success: res => {
if (res.code == 0) {
uni.showToast({ title: '已选择展示名片', icon: 'success' });
this.showChoose = false;
this.loadData();
} else {
uni.showToast({ title: res.message || '选择失败', icon: 'none' });
}
},
fail: () => uni.showToast({ title: '网络异常', icon: 'none' })
});
},
openShare() {
uni.showToast({ title: '请点击右上角或浏览器菜单分享', icon: 'none' });
}
}
};
</script>
<style lang="scss" scoped>
.ent-card {
min-height: 100vh;
background-color: #f5f6fa;
padding-bottom: 140rpx;
}
.page-layer {
padding: 24rpx;
}
/* ===== 英雄区 ===== */
.hero {
position: relative;
border-radius: 24rpx;
padding: 32rpx 32rpx 140rpx;
background:
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.14) 0, rgba(255, 255, 255, 0.14) 120rpx, transparent 121rpx),
linear-gradient(150deg, var(--card-theme, #1e6fd9) 0%, var(--card-theme-end, #123f8f) 100%);
overflow: visible;
margin-bottom: 90rpx;
}
.hero-pill {
display: inline-flex;
align-items: center;
padding: 6rpx 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.5);
border-radius: 26rpx;
color: #ffffff;
font-size: 22rpx;
.hero-pill-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #6cf9c0;
margin-right: 10rpx;
}
}
.hero-swiper {
height: 180rpx;
margin-top: 24rpx;
}
.hero-id {
display: flex;
align-items: center;
margin-top: 28rpx;
.hi-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #ffffff;
border: 6rpx solid rgba(255, 255, 255, 0.85);
flex-shrink: 0;
&.hi-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
.hi-avatar-char {
font-size: 56rpx;
font-weight: 600;
color: var(--card-theme, #1e6fd9);
}
}
}
.hi-info {
margin-left: 28rpx;
min-width: 0;
.hi-name {
font-size: 40rpx;
font-weight: 600;
color: #ffffff;
}
.hi-job {
margin-top: 10rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
}
}
}
/* ===== 快捷操作条: 上移压住英雄卡下沿 ===== */
.quick {
position: relative;
z-index: 2;
margin: -130rpx 24rpx 24rpx;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 8rpx 30rpx rgba(30, 111, 217, 0.10);
display: flex;
padding: 28rpx 0;
.q-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.q-icon {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.q-icon-blue {
background: #e3f0ff;
.iconfont,
.q-char {
color: #3d8bff;
font-size: 40rpx;
}
}
&.q-icon-cyan {
background: #e2f7f4;
.q-char {
color: #2bb99e;
font-size: 40rpx;
font-weight: 600;
}
}
&.q-icon-orange {
background: #fff3e0;
.iconfont {
color: #f5a623;
font-size: 40rpx;
}
}
}
.q-label {
margin-top: 14rpx;
font-size: 24rpx;
color: #444;
}
}
}
/* ===== 企业简介 ===== */
.bio {
margin: 0 0 24rpx;
padding: 28rpx 28rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
.bio-text {
display: block;
font-size: 28rpx;
line-height: 1.6;
word-break: break-all;
}
.bio-en {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.5;
word-break: break-all;
}
}
/* ===== 联系资料 ===== */
.contact {
padding: 28rpx 28rpx 12rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 24rpx;
.ct-title {
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
padding-left: 16rpx;
border-left-width: 6rpx;
border-left-style: solid;
margin-bottom: 20rpx;
}
.ct-row {
display: flex;
align-items: center;
padding: 18rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
.ct-icon {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
.iconfont {
font-size: 30rpx;
}
.ct-char {
font-size: 26rpx;
font-weight: 600;
}
}
.ct-label {
width: 88rpx;
flex-shrink: 0;
font-size: 26rpx;
}
.ct-value {
flex: 1;
min-width: 0;
font-size: 28rpx;
word-break: break-all;
&.ct-multi {
white-space: pre-wrap;
}
}
.ct-copy {
margin-left: 16rpx;
flex-shrink: 0;
padding: 6rpx 20rpx;
border-radius: 22rpx;
background: #f2f3f5;
font-size: 22rpx;
color: #666;
}
}
}
/* ===== self 未自选提示 ===== */
.self-hint {
display: flex;
align-items: center;
padding: 20rpx 24rpx;
background-color: var(--card-theme-soft, #e8f1fb);
border: 1rpx solid var(--card-theme-border, #cfe3fb);
border-radius: 16rpx;
margin-bottom: 24rpx;
.sh-icon {
margin-right: 12rpx;
font-size: 30rpx;
}
.sh-text {
flex: 1;
min-width: 0;
font-size: 24rpx;
color: #666;
}
.sh-btn {
font-size: 26rpx;
margin-left: 12rpx;
}
}
.ent-loading {
text-align: center;
padding: 40rpx 0;
color: #999;
font-size: 26rpx;
}
/* ===== 底部操作条 ===== */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 20rpx;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: #ffffff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
z-index: 10;
.bb-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 44rpx;
font-size: 28rpx;
&.bb-main {
background: linear-gradient(150deg, #3d8bff, #1e6fd9);
color: #ffffff;
font-weight: 600;
}
&.bb-share {
background: #f2f3f5;
color: #333;
border: none;
padding: 0;
margin: 0;
&::after {
border: none;
}
}
}
}
/* ===== 员工自选弹层 ===== */
.choose-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
}
.choose-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 28rpx 28rpx 0 0;
z-index: 100;
transform: translateY(100%);
transition: transform 0.25s ease;
max-height: 70vh;
display: flex;
flex-direction: column;
&.choose-show {
transform: translateY(0);
}
.cs-bar {
width: 64rpx;
height: 8rpx;
background: #ddd;
border-radius: 4rpx;
margin: 16rpx auto 8rpx;
}
.cs-title {
text-align: center;
font-size: 30rpx;
font-weight: 600;
padding: 12rpx 0 20rpx;
}
.cs-list {
overflow-y: auto;
padding: 0 24rpx 32rpx;
}
.cs-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f2f2f2;
}
.cs-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
color: #fff;
font-size: 32rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.cs-info {
flex: 1;
min-width: 0;
.cs-name {
font-size: 30rpx;
color: #333;
}
.cs-sub {
font-size: 24rpx;
color: #999;
margin-top: 4rpx;
}
}
.cs-chosen {
font-size: 36rpx;
}
.cs-empty {
text-align: center;
color: #999;
font-size: 26rpx;
padding: 40rpx 0;
}
}
</style>

View File

@@ -0,0 +1,403 @@
<template>
<view class="ex-page">
<!-- 状态筛选: 全部 / 待处理(N) / 已同意 -->
<view class="ex-tabs">
<view v-for="tab in tabs" :key="tab.value" class="ex-tab"
:class="{ active: current === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
<!-- 交换请求列表 -->
<view class="ex-list">
<view v-for="(item, index) in shownList" :key="item.id" class="ex-item">
<view class="ex-top">
<view class="ex-avatar-wrap" :data-index="index" @tap.stop="viewCard">
<image v-if="item.avatar" class="ex-avatar" :src="item.avatar" mode="aspectFill" />
<view v-else class="ex-avatar ex-avatar-ph">{{ item.name.charAt(0) }}</view>
</view>
<view class="ex-info">
<view class="ex-name-row">
<text class="ex-name">{{ item.name }}</text>
<text class="ex-badge iconfont icon-dui"></text>
</view>
<text class="ex-job">{{ item.job_title }}</text>
<view class="ex-company-row">
<text class="ex-company">{{ item.company }}</text>
</view>
</view>
<view v-if="item.status === 'pending'" class="ex-agree" :data-index="index" @tap.stop="onAgree">同意</view>
<text v-else class="ex-agreed">已同意</text>
</view>
<view class="ex-reason">对方发起了名片交换请求</view>
<view class="ex-time">{{ item.create_time_txt }} 向您发起名片交换</view>
</view>
<ns-empty v-if="shownList.length === 0" :isIndex="false" :text="emptyText"></ns-empty>
</view>
</view>
</template>
<script>
// 名片交换请求列表
// ⚠️ 目前为静态占位数据: 后端 /card/api/exchange/* 接口就绪后,
// 改为在 onLoad 里用 this.$cardApi.exchangeLists({ data: { page: 1 }, ... }) 拉取,
// 「同意」改成 cardApi.exchangeAgree({ data: { id: item.id } })。
export default {
data() {
return {
current: 'pending',
cardId: '',
list: [],
page: 1,
pageCount: 1,
loading: false
};
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadList();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
computed: {
tabs() {
const pending = this.list.filter(i => i.status === 'pending').length;
return [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待处理(' + pending + ')' },
{ value: 'agreed', label: '已同意' }
];
},
shownList() {
if (this.current === 'all') return this.list;
return this.list.filter(item => item.status === this.current);
},
emptyText() {
return this.current === 'agreed' ? '暂无已同意的交换' : '暂无名片交换请求';
}
},
methods: {
switchTab(value) {
if (this.current === value) return;
this.current = value;
},
// 待处理交换请求(后端只返回 status=0); 归一化 status 供模板区分
loadList() {
this.loading = true;
this.$cardApi.exchangeLists({
data: { page: this.page, page_size: 20 },
success: res => {
this.loading = false;
if (res.code == 0 && res.data) {
this.list = (res.data.list || []).map(it => ({
id: it.id,
from_card_id: it.from_card_id,
name: it.name,
avatar: it.avatar,
job_title: it.job_title,
company: it.company,
create_time_txt: it.create_time_txt,
status: it.status === 0 ? 'pending' : 'agreed'
}));
this.pageCount = res.data.page_count || 1;
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.loading = false;
}
});
},
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象(viewCard(item))会丢参,
* 统一改为 :data-index + 不传参, 这里从 dataset 取索引回表。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.shownList[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.shownList[i]) return this.shownList[i];
}
return payload.id ? payload : null;
},
// 点对方名片 → 跳转对方名片详情(用 from_card_id 字符串)
viewCard(payload) {
const item = this.resolveItem(payload);
if (!item || !item.from_card_id) return;
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(item.from_card_id)
});
},
/**
* 契约 §4(2026-10-15): 同意后跳对方公开名片页。
* wap_url 原样使用, 仅追加 &back_url=encodeURIComponent(我方名片页完整地址);
* wap_url 为空则不跳转(对方刚删卡等, 人脉已互建)。
*/
jumpToOtherPublic(wapUrl) {
if (!wapUrl) return;
const url = this.withBackUrl(wapUrl);
// #ifdef H5
window.location.href = url;
// #endif
// #ifndef H5
uni.navigateTo({
url: '/pages_tool/card/share?url=' + encodeURIComponent(url)
});
// #endif
},
/**
* 给公开页地址追加 back_url=本页完整地址(契约 §4 前端唯一允许的改动)。
* 对方名片页(PHP wap 模板)左上角返回键据此跳回本页; 已带 back_url 的原样返回, 不重复追加。
*/
withBackUrl(wapUrl) {
if (!wapUrl) return '';
if (wapUrl.indexOf('back_url=') >= 0) return wapUrl;
const back = this.myPageFullUrl();
if (!back) return wapUrl;
return wapUrl + (wapUrl.indexOf('?') >= 0 ? '&' : '?') + 'back_url=' + encodeURIComponent(back);
},
// H5: 当前页面完整地址(禁止写死域名); 非 H5 无等价 H5 地址返回 '' 由 share.vue 中转页原生返回
myPageFullUrl() {
// #ifdef H5
return window.location.href;
// #endif
// #ifndef H5
return '';
// #endif
},
onAgree(payload) {
const item = this.resolveItem(payload);
if (!item) return;
this.$cardApi.exchangeAgree({
data: { id: item.id },
success: res => {
if (res.code == 0) {
// receiver_created=0 也是正常成功(同人脉已存在被跳过), 不当失败
const d = res.data || {};
const txt = d.receiver_created === 0 ? '已交换(人脉已存在)' : '已交换,人脉已互加';
uni.showToast({ title: txt, icon: 'none' });
// 需求: 点「同意」后对方名片必须出现在我的人脉中。
// 后端虽声明 agree 双向建人脉, 实测出现「同意成功、人脉里却没有对方」,
// 故不再用 receiver_created 判断, 一律在同意后校验/补建(幂等, 不会重复);
// 实现见 card/detail 页同名方法(取对方名片详情 → 按手机号查我的人脉 → 没有才新建)。
this.ensureExchangeContact(item.from_card_id, ensureRes => {
// 命中同手机号人脉但姓名不同(多为对方改过名片名): 提示实际名称,
// 避免「交换后对方没出现」的误判(该联系人其实已在人脉里, 只是显示旧名)
if (ensureRes && ensureRes.existed && ensureRes.existed.name
&& item.name && ensureRes.existed.name !== item.name) {
uni.showToast({
title: '人脉中已有同手机号联系人「' + ensureRes.existed.name + '」',
icon: 'none',
duration: 2500
});
}
// 契约 §4(2026-10-15): 同意后用响应里的 wap_url 跳对方公开名片页,
// 仅追加 &back_url=; wap_url 为空则退回刷新列表(对方刚删卡等)
if (d.wap_url) {
this.jumpToOtherPublic(d.wap_url);
} else {
this.loadList();
}
});
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
},
/**
* 同意交换后校验并兜底建人脉: 保证对方名片出现在我的人脉中。
* 走 card-api 的 ensureExchangeContact(取对方名片详情 → 按手机号去重 → 没有才 source=exchange 新建), 幂等。
* 无论成败都执行 done(res) —— 兜底失败不阻塞同意后的跳转(交换本身已成功), 人脉页仍可手动补录。
*/
ensureExchangeContact(fromCardId, done) {
if (!fromCardId) { typeof done === 'function' && done(null); return; }
this.$cardApi.ensureExchangeContact({
fromCardId: fromCardId,
success: res => {
if (res && res.created) {
console.log('[card/exchange] 兜底新建人脉成功:', fromCardId);
}
typeof done === 'function' && done(res);
},
fail: err => {
console.warn('[card/exchange] 兜底建人脉失败(不影响交换结果):', err);
typeof done === 'function' && done(null);
}
});
},
onIgnore(item) {
this.$cardApi.exchangeIgnore({
data: { id: item.id },
success: res => {
if (res.code == 0) {
uni.showToast({ title: '已忽略', icon: 'none' });
this.loadList();
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {}
});
}
}
};
</script>
<style lang="scss" scoped>
.ex-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
// 筛选胶囊: 选中为浅蓝底蓝字, 未选中白底灰字
.ex-tabs {
display: flex;
padding: 20rpx 24rpx;
.ex-tab {
padding: 12rpx 28rpx;
margin-right: 16rpx;
border-radius: 32rpx;
background-color: #ffffff;
font-size: 26rpx;
color: #666666;
&.active {
background-color: #e8f3ff;
color: #2aa5ff;
font-weight: 600;
}
}
}
.ex-list {
padding: 0 24rpx;
}
.ex-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.ex-top {
display: flex;
align-items: center;
}
.ex-avatar-wrap {
position: relative;
width: 100rpx;
height: 100rpx;
flex-shrink: 0;
}
.ex-avatar {
width: 100%;
height: 100%;
border-radius: 16rpx;
background-color: #2aa5ff;
}
.ex-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
}
.ex-info {
flex: 1;
min-width: 0;
margin: 0 16rpx 0 20rpx;
}
.ex-name-row {
display: flex;
align-items: center;
}
.ex-name {
font-size: 30rpx;
font-weight: 600;
color: #333333;
}
.ex-badge {
margin-left: 6rpx;
font-size: 26rpx;
color: #2aa5ff;
}
.ex-job {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #7b8794;
}
.ex-company-row {
display: flex;
align-items: center;
margin-top: 10rpx;
}
.ex-company {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #333333;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ex-agree {
flex-shrink: 0;
padding: 12rpx 34rpx;
border-radius: 34rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 26rpx;
}
.ex-agreed {
flex-shrink: 0;
font-size: 24rpx;
color: #9aa4b0;
}
.ex-reason {
margin-top: 20rpx;
padding: 20rpx;
border-radius: 10rpx;
background-color: #f5f6f8;
font-size: 24rpx;
color: #666666;
line-height: 1.5;
}
.ex-time {
margin-top: 14rpx;
font-size: 22rpx;
color: #9aa4b0;
}
</style>

150
pages_tool/card/forward.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<view class="forward-page">
<!-- 名片预览 -->
<view class="fp-card">
<image v-if="avatar" class="fp-avatar" :src="avatar" mode="aspectFill" />
<view v-else class="fp-avatar fp-avatar-ph">{{ (title || '?').slice(0, 1) }}</view>
<view class="fp-name">{{ title || '数字名片' }}</view>
</view>
<view class="fp-tip">点击下方按钮,把这张名片直接发给微信好友</view>
<!-- 转发: 微信小程序必须用原生 button open-type=share 才能拉起转发面板,
纯 @tap 无法唤起; 内容取本页 onShareAppMessage -->
<!-- #ifdef MP-WEIXIN -->
<button class="fp-btn" open-type="share">发送给朋友</button>
<!-- #endif -->
<!-- #ifdef H5 || APP-PLUS || MP-HARMONY -->
<view class="fp-btn" @tap="fallbackShare">发送给朋友</view>
<!-- #endif -->
<view class="fp-sub" v-if="cardUrl">对方点开即可查看名片并保存联系方式</view>
</view>
</template>
<script>
/**
* 名片转发中转页(原生)
*
* 背景: 分享出去的 wap 名片页在 web-view 内, 其「分享」按钮无法拉起微信转发面板
* (web-view 里的 H5 无此能力, 只能复制链接)。由 wap 页在小程序环境下把「分享」
* 跳到本页, 本页用原生 button(open-type=share) 直接唤起「发送给朋友」。
*
* 入参(query, 均需 encodeURIComponent):
* url wap 名片页地址(转发出去仍落地 card/share 的 web-view 页)
* title 名片姓名(转发卡片标题)
* avatar 头像(转发卡片封面, 可空)
*/
export default {
data() {
return {
cardUrl: '',
title: '',
avatar: ''
};
},
onLoad(options) {
this.cardUrl = options && options.url ? decodeURIComponent(options.url) : '';
this.title = options && options.title ? decodeURIComponent(options.title) : '';
this.avatar = options && options.avatar ? decodeURIComponent(options.avatar) : '';
uni.setNavigationBarTitle({ title: '分享名片' });
},
onReady() {
uni.setNavigationBarTitle({ title: '分享名片' });
},
// 右上角菜单转发与按钮转发同参
onShareAppMessage() {
return {
title: this.title ? this.title + ' - 数字名片' : '名片分享',
path: '/pages_tool/card/share' + (this.cardUrl ? '?url=' + encodeURIComponent(this.cardUrl) : ''),
imageUrl: this.avatar || ''
};
},
methods: {
fallbackShare() {
// 非微信小程序端无转发面板, 退化为复制链接
if (this.cardUrl) {
uni.setClipboardData({
data: this.cardUrl,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
});
}
}
}
};
</script>
<style lang="scss" scoped>
.forward-page {
min-height: 100vh;
box-sizing: border-box;
padding: 48rpx 40rpx;
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 34%, #f5f7fa 34.2%);
display: flex;
flex-direction: column;
align-items: center;
}
.fp-card {
width: 100%;
background: #ffffff;
border-radius: 24rpx;
padding: 56rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
.fp-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: #eef4fb;
&.fp-avatar-ph {
display: flex;
align-items: center;
justify-content: center;
color: #1f8fef;
font-size: 60rpx;
font-weight: 600;
}
}
.fp-name {
margin-top: 24rpx;
font-size: 36rpx;
font-weight: 600;
color: #222;
}
}
.fp-tip {
margin-top: 56rpx;
font-size: 28rpx;
color: #555;
}
.fp-btn {
margin-top: 40rpx;
width: 100%;
height: 92rpx;
line-height: 92rpx;
border-radius: 46rpx;
background: linear-gradient(150deg, #29a3ff, #1f8fef);
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
border: none;
&::after {
border: none;
}
}
.fp-sub {
margin-top: 24rpx;
font-size: 24rpx;
color: #999;
}
</style>

418
pages_tool/card/leads.vue Normal file
View File

@@ -0,0 +1,418 @@
<template>
<view class="lead-page">
<!-- 平级入口: 与我的人脉页互链(导航合并第一步) -->
<view class="page-switch" @tap="goContacts">
<text class="switch-label">我的人脉</text>
<text class="switch-arrow">›</text>
</view>
<!-- 状态筛选 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view v-for="tab in tabs" :key="tab.value" class="tab-item"
:class="{ active: status === tab.value }" @tap="switchTab(tab.value)">
{{ tab.label }}
</view>
</view>
</scroll-view>
<!-- 客户列表 -->
<view class="lead-list">
<!-- 点「别人的名片」= 打开对方名片; 拨号/按钮一律 .stop, 避免误触成打电话 -->
<view v-for="(item, index) in list" :key="item.id || index" :data-index="index" class="lead-item" @tap="viewCard">
<view class="lead-top">
<view class="lead-name">{{ item.name || '未留姓名' }}</view>
<view class="lead-status" :class="'st-' + item.status">{{ item.status_text || '新客户' }}</view>
</view>
<!-- 手机号是唯一拨号入口(H5 下调 tel: 浏览器会报 ERR_UNKNOWN_URL_SCHEME, 属正常) -->
<view class="lead-mobile" v-if="item.mobile" @tap.stop="callPhone(item.mobile)">📱 {{ item.mobile }}</view>
<view class="lead-note" v-if="item.note">「{{ item.note }}」</view>
<view class="lead-meta">
<view class="meta-card" :class="{ tappable: !!cardIdOf(item) }">
<text class="meta-card-name">来自名片:{{ item.card_name || '—' }}</text>
<text class="meta-card-arrow" v-if="cardIdOf(item)">查看名片 ›</text>
</view>
<text class="meta-time">{{ item.create_time_txt }}</text>
</view>
<view class="lead-actions">
<view class="action-btn copy" v-if="item.wechat" @tap.stop="copyText(item.wechat)">复制微信</view>
<view class="action-btn primary" :data-index="index" @tap.stop="changeStatus">更新状态</view>
</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<ns-empty v-if="!isLoading && list.length === 0" :isIndex="false"
:text="'暂无意向客户,访客在你的名片页留资后会出现在这里'"></ns-empty>
</view>
</view>
</template>
<script>
// 与后端 Lead::STATUSES 一致: 0=新客户 1=已联系 2=已成交 3=无效
const STATUS_MAP = [
{ value: 0, label: '新客户' },
{ value: 1, label: '已联系' },
{ value: 2, label: '已成交' },
{ value: 3, label: '无效' }
];
export default {
data() {
return {
tabs: [{ value: '', label: '全部' }].concat(STATUS_MAP),
status: '',
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false
};
},
onLoad() {
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
// 顶部平级入口: $util.redirectTo(鸿蒙禁裸 uni.switchTab, 静默失败)
goContacts() {
this.$util.redirectTo('/pages_tool/card/contacts');
},
switchTab(value) {
if (this.status === value) return;
this.status = value;
this.refresh();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
// 空字符串 = 全部, 后端对 '' 不加 status 条件
if (this.status !== '') {
data.status = this.status;
}
this.$cardApi.leadLists({
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' });
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
/**
* 取事件对应的行数据。小程序端 v-for 内联传对象会丢参, 统一 :data-index + 不传参。
* 兼容: 事件对象(data-index) / 数字索引 / 行对象本身。
*/
resolveItem(payload) {
if (payload == null) return null;
if (typeof payload === 'number') return this.list[payload] || null;
const ds = payload.currentTarget && payload.currentTarget.dataset;
if (ds) {
const i = Number(ds.index);
if (!isNaN(i) && this.list[i]) return this.list[i];
}
return payload.id ? payload : null;
},
changeStatus(payload) {
let item, index;
if (payload && payload.currentTarget) {
index = Number(payload.currentTarget.dataset.index);
item = this.list[index];
} else if (typeof payload === 'number') {
index = payload;
item = this.list[index];
} else {
item = payload;
index = this.list.indexOf(item);
}
if (!item) return;
uni.showActionSheet({
itemList: STATUS_MAP.map(s => s.label),
success: res => {
const target = STATUS_MAP[res.tapIndex];
if (!target || target.value === item.status) return;
this.doSetStatus(item, index, target.value);
}
});
},
doSetStatus(item, index, status) {
this.$cardApi.leadSetStatus({
data: { id: item.id, status: status },
success: res => {
if (res.code == 0) {
// 就地更新, 不整页刷新
this.list[index].status = status;
this.list[index].status_text = STATUS_MAP.find(s => s.value === status).label;
uni.showToast({ title: '已更新', icon: 'success' });
} else {
uni.showToast({ title: res.message || '更新失败', icon: 'none' });
}
},
fail: () => {
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
},
callPhone(mobile) {
if (!mobile) return;
uni.makePhoneCall({ phoneNumber: mobile });
},
copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
});
},
// 该线索关联的名片编号(后端 lead/lists 行下发; 兼容常见字段名)
cardIdOf(item) {
if (!item) return '';
return item.card_id || item.from_card_id || item.source_card_id || '';
},
// 点整条客户卡片 → 打开对方名片
// 与交换名片(exchange.vue)同款写法: detail 接收 card_id(卡片编号字符串);
// 本人名下的卡 → 正常详情页; 非本人名下 → detail 兜底跳公开名片页(后端返回的 wap_url)
viewCard(payload) {
const item = this.resolveItem(payload);
const cardId = this.cardIdOf(item);
if (!cardId) {
// 后端 lead/lists 目前只下发 card_name(展示名), 没有名片编号 → 无法跳转。
// 这里把该行真实字段打到控制台, 便于确认后端是否用了别的字段名(如 relation_id)
console.warn('[card/leads] 该行缺少名片编号(card_id/from_card_id/source_card_id), 无法打开对方名片; 可用字段:',
item ? Object.keys(item) : 'event', item);
uni.showToast({ title: '该客户未关联名片,无法查看', icon: 'none' });
return;
}
uni.navigateTo({
url: '/pages_tool/card/detail?card_id=' + encodeURIComponent(cardId)
});
}
}
};
</script>
<style lang="scss" scoped>
.lead-page {
min-height: 100vh;
background-color: #f6f7f9;
padding-bottom: 40rpx;
box-sizing: border-box;
}
.page-switch {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16rpx 24rpx 0;
.switch-label {
font-size: 26rpx;
color: var(--main-color, #0054a5);
}
.switch-arrow {
font-size: 26rpx;
color: var(--main-color, #0054a5);
margin-left: 4rpx;
}
}
.tab-bar {
background-color: #ffffff;
white-space: nowrap;
.tab-inner {
display: inline-flex;
padding: 0 12rpx;
}
.tab-item {
display: inline-block;
padding: 24rpx 28rpx;
font-size: 28rpx;
color: #666;
position: relative;
&.active {
color: var(--main-color, #0054a5);
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 10rpx;
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background-color: var(--main-color, #0054a5);
}
}
}
}
.lead-list {
padding: 20rpx 24rpx 0;
}
.lead-item {
background-color: #ffffff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
.lead-top {
display: flex;
align-items: center;
.lead-name {
flex: 1;
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.lead-status {
font-size: 22rpx;
padding: 6rpx 18rpx;
border-radius: 22rpx;
&.st-0 {
color: var(--main-color, #0054a5);
background-color: var(--main-color-shallow, #e8f1fb);
}
&.st-1 {
color: #e6a23c;
background-color: #fdf3e6;
}
&.st-2 {
color: #1daa39;
background-color: #e8f8ea;
}
&.st-3 {
color: #999;
background-color: #f2f2f2;
}
}
}
.lead-mobile {
font-size: 28rpx;
color: #333;
margin-top: 14rpx;
}
.lead-note {
font-size: 26rpx;
color: #666;
margin-top: 12rpx;
line-height: 1.5;
}
.lead-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 22rpx;
color: #999;
margin-top: 14rpx;
.meta-card {
display: flex;
align-items: center;
max-width: 64%;
// 有名片编号时才高亮/可点, 否则退化为普通文字
&.tappable .meta-card-name {
color: var(--main-color, #0054a5);
}
.meta-card-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-card-arrow {
flex-shrink: 0;
margin-left: 8rpx;
color: var(--main-color, #0054a5);
}
}
}
.lead-actions {
display: flex;
justify-content: flex-end;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.action-btn {
font-size: 24rpx;
border-radius: 30rpx;
padding: 10rpx 30rpx;
margin-left: 20rpx;
&.copy {
color: #666;
border: 1rpx solid #ddd;
}
&.primary {
color: #ffffff;
background-color: var(--main-color, #0054a5);
}
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end {
color: #ccc;
}
}
</style>

1068
pages_tool/card/list.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
<template>
<view class="save-contact">
<!-- 名片摘要卡 -->
<view class="sc-card">
<view class="sc-avatar">{{ initial }}</view>
<view class="sc-name">{{ form.name || '未命名名片' }}</view>
<view class="sc-job" v-if="form.job_title">{{ form.job_title }}</view>
<view class="sc-company" v-if="form.company">{{ form.company }}</view>
<view class="sc-mobile" v-if="form.mobile" @tap="callNow">
<text class="sc-mobile-icon">📞</text>
<text>{{ form.mobile }}</text>
</view>
</view>
<!-- 保存按钮: 调系统 API 直接写入手机通讯录(系统会弹出确认框) -->
<button class="sc-btn" :disabled="saving" @tap="saveToContacts">
{{ saving ? '保存中…' : '保存到通讯录' }}
</button>
<view class="sc-tip">保存后可在手机通讯录中直接拨号</view>
</view>
</template>
<script>
/**
* 保存名片到手机通讯录(原生页)
*
* 背景: 分享出去的名片是后端 wap H5 页, 经 web-view 打开。H5 原来的
* 「保存到通讯录」是下载 .vcf 文件, 而微信小程序 web-view 禁止下载文件,
* 会提示「可在浏览器打开此网页来下载文件」。
*
* 方案: wap H5 检测到运行在小程序 web-view 内(/miniProgram/i.test(UA))时,
* 不再触发 .vcf 下载, 改为调 wx.miniProgram.navigateTo 跳到本页,
* 由本页(小程序原生)调 uni.addPhoneContact 直接写入系统通讯录。
*
* 入参(query, 均需 encodeURIComponent):
* name/mobile/company/job_title/email/website/address/remark/wechat 名片字段
* action=call 时进入页面直接拨号(wap 页「打电话」按钮也可走本页)
* action=mail 时复制 email 到剪贴板并返回(web-view 内 mailto: 不生效的兜底)
*/
export default {
data() {
return {
form: {
name: '',
mobile: '',
company: '',
job_title: '',
email: '',
website: '',
address: '',
remark: '',
wechat: ''
},
saving: false
};
},
computed: {
initial() {
const n = this.form.name || this.form.mobile || '?';
return n.slice(0, 1);
}
},
onLoad(options) {
const keys = ['name', 'mobile', 'company', 'job_title', 'email', 'website', 'address', 'remark', 'wechat'];
keys.forEach(k => {
if (options && options[k] != null) {
this.form[k] = decodeURIComponent(options[k]);
}
});
// 打电话动作: 直接唤起拨号盘并退出本页
if (options && options.action === 'call' && this.form.mobile) {
this.callNow();
}
// 发邮件动作: web-view 内 mailto: 不生效, 复制邮箱后返回名片页
if (options && options.action === 'mail' && this.form.email) {
this.copyEmail();
}
},
onReady() {
// 微信真机才生效(模拟器 bug 见 docs/weixin-bug.md), onShow 兜底见下
uni.setNavigationBarTitle({ title: '保存到通讯录' });
// 进入页面即自动调起系统"添加联系人"确认框:
// web-view 内 H5 无法直接调 addPhoneContact, 必须经本原生页中转,
// 若再让用户手动点一次等于两步操作, 所以这里自动触发, 用户零点击直接看到系统弹窗
this.saveToContacts();
},
onShow() {
uni.setNavigationBarTitle({ title: '保存到通讯录' });
},
methods: {
callNow() {
if (!this.form.mobile) return;
uni.makePhoneCall({
phoneNumber: this.form.mobile,
// 用户取消拨号会走 fail, 不提示
fail: () => {}
});
},
// 发邮件兜底: 小程序无唤起邮件 App 的 API, 复制邮箱 + 提示后返回原名片页
copyEmail() {
uni.setClipboardData({
data: this.form.email,
showToast: false,
success: () => {
uni.showToast({ title: '邮箱已复制,去邮件 App 粘贴发送', icon: 'none', duration: 2000 });
setTimeout(() => {
uni.navigateBack({ fail: () => {} });
}, 1400);
},
fail: () => {
uni.showToast({ title: '复制失败,邮箱:' + this.form.email, icon: 'none', duration: 3000 });
}
});
},
saveToContacts() {
if (this.saving) return;
if (!this.form.name && !this.form.mobile) {
uni.showToast({ title: '名片资料缺失,无法保存', icon: 'none' });
return;
}
this.saving = true;
uni.addPhoneContact({
firstName: this.form.name || this.form.mobile,
mobilePhoneNumber: this.form.mobile || '',
organization: this.form.company || '',
title: this.form.job_title || '',
email: this.form.email || '',
url: this.form.website || '',
weChatNumber: this.form.wechat || '',
remark: [this.form.address, this.form.remark].filter(Boolean).join(' '),
success: () => {
uni.showToast({ title: '已保存到通讯录', icon: 'success' });
setTimeout(() => {
uni.navigateBack({ fail: () => {} });
}, 1200);
},
fail: err => {
// 细分失败原因, 便于真机上快速定位(系统确认框点「取消」不算错误)
const msg = (err && err.errMsg) || '';
if (msg.indexOf('cancel') > -1) return;
if (msg.indexOf('privacy') > -1 || msg.indexOf('declared') > -1) {
// 小程序后台「用户隐私保护指引」未声明 addPhoneContact(见 docs/CARD_SAVE_CONTACT_BACKEND.md)
uni.showToast({ title: '小程序隐私协议未声明该能力,请管理员配置', icon: 'none', duration: 3000 });
} else if (msg.indexOf('auth') > -1 || msg.indexOf('deny') > -1) {
uni.showToast({ title: '未获通讯录权限,请在系统设置中允许微信访问通讯录', icon: 'none', duration: 3000 });
} else {
// 未知错误把 errMsg 原样带出, 截图即可定位
uni.showToast({ title: '保存失败: ' + (msg || '未知'), icon: 'none', duration: 3000 });
}
},
complete: () => {
this.saving = false;
}
});
}
}
};
</script>
<style lang="scss" scoped>
.save-contact {
min-height: 100vh;
box-sizing: border-box;
padding: 40rpx 32rpx;
background: linear-gradient(160deg, #29a3ff 0%, #1f8fef 40%, #f5f7fa 40.2%);
}
.sc-card {
background-color: #ffffff;
border-radius: 24rpx;
padding: 48rpx 32rpx 40rpx;
box-shadow: 0 8rpx 30rpx rgba(31, 143, 239, 0.12);
display: flex;
flex-direction: column;
align-items: center;
.sc-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: linear-gradient(150deg, #29a3ff, #1f8fef);
color: #ffffff;
font-size: 56rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.sc-name {
margin-top: 24rpx;
font-size: 40rpx;
font-weight: 600;
color: #222;
}
.sc-job {
margin-top: 8rpx;
font-size: 26rpx;
color: #666;
}
.sc-company {
margin-top: 4rpx;
font-size: 26rpx;
color: #888;
}
.sc-mobile {
margin-top: 24rpx;
display: flex;
align-items: center;
font-size: 32rpx;
color: #1f8fef;
font-weight: 500;
.sc-mobile-icon {
margin-right: 8rpx;
font-size: 28rpx;
}
}
}
.sc-btn {
margin-top: 64rpx;
height: 92rpx;
line-height: 92rpx;
border-radius: 46rpx;
color: #ffffff;
font-size: 32rpx;
font-weight: 600;
background: linear-gradient(150deg, #29a3ff, #1f8fef);
border: none;
&::after {
border: none;
}
&[disabled] {
opacity: 0.6;
color: #ffffff;
}
}
.sc-tip {
margin-top: 24rpx;
text-align: center;
font-size: 24rpx;
color: #999;
}
</style>

217
pages_tool/card/share.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<view class="card-share">
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务:web-view 是原生全屏组件,必须用 cover-view 盖在上面(cover-view 不支持 iconfont,用文本) -->
<cover-view class="mp-header">
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
<cover-view class="mp-header-inner">
<cover-view class="nav-btn" @click="goBack">
<cover-view class="nav-back-text">‹</cover-view>
</cover-view>
<cover-view class="header-title">名片分享</cover-view>
<cover-view class="nav-btn nav-btn-placeholder"></cover-view>
</cover-view>
</cover-view>
<!-- web-view 是全屏原生组件,父容器 padding 对它无效,用等高占位 view 顶下去 -->
<view class="mp-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<!-- 小程序/鸿蒙:web-view 加载 wap H5 名片页(客户视角,含留资表单) -->
<!-- #ifdef MP-WEIXIN || MP-HARMONY -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<!-- H5 直接就是浏览器,onLoad 时已 location 跳转,这里仅兜底展示 -->
<view class="h5-fallback">
<view class="h5-fallback-tip">正在打开名片…</view>
<button class="h5-fallback-btn" @tap="openUrl">如未自动跳转,点此打开</button>
</view>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<web-view v-if="cardUrl" :src="cardUrl"></web-view>
<view v-else class="empty-tip">链接无效</view>
<!-- #endif -->
</view>
</template>
<script>
/**
* 名片分享中转页
*
* 背景: 小程序 onShareAppMessage 的 path 只能指向小程序页面, 不能直接分享 H5 链接,
* 而名片客户页(wap detail)是 H5。因此员工转发名片给微信好友时, 分享的是本页,
* 本页再用 web-view 把客户带到 wap 名片页(含留资表单)。
* H5 端分享就是普通 URL, 不需要中转, onLoad 直接跳转。
* 鸿蒙元服务(MP-HARMONY): web-view 同为原生全屏组件(参考 pages_tool/webview/webview.vue
* 的 cover-view 返回键方案), 与微信小程序共用同一套头部+占位+web-view 结构;
* 无 onShareAppMessage 生命周期, 鸿蒙端分享入口是名片详情页内「分享」按钮。
* 注意: web-view 加载的外链域名需在 manifest → HarmonyOS 网络配置里放行(元服务要求)。
*/
export default {
data() {
return {
cardUrl: '',
statusBarHeight: 20,
windowWidth: 375
};
},
computed: {
// #ifdef MP-HARMONY
headerTotalHeightPx() {
const rpxToPx = this.windowWidth / 750;
return this.statusBarHeight + Math.round(88 * rpxToPx);
}
// #endif
},
onLoad(options) {
this.cardUrl = options.url ? decodeURIComponent(options.url) : '';
// #ifdef MP-WEIXIN || MP-HARMONY
try {
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
} catch (e) {
// 鸿蒙个别版本 getSystemInfoSync 可能抛错, 用默认值兜底(头部高度略差不错位)
console.warn('[card/share] getSystemInfoSync fail, use fallback', e);
}
// #endif
// #ifdef H5
// H5 端无 web-view 中转必要,直接跳转到名片页
if (this.cardUrl) {
window.location.href = this.cardUrl;
}
// #endif
},
methods: {
goBack() {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
// 栈底回首页必须走 $util.redirectTo: 鸿蒙元服务上 uni.switchTab 会静默失败
// (success/fail 都不触发, .catch 兜底失效), util 内部有
// switchTab → 500ms 强制 reLaunch 的兜底(见 util.js#redirectTo tabBar 分支)
this.$util.redirectTo('/pages/index/index');
}
},
// #ifdef H5
openUrl() {
if (this.cardUrl) window.location.href = this.cardUrl;
}
// #endif
}
};
</script>
<style lang="scss" scoped>
.card-share {
min-height: 100vh;
background-color: #f5f5f5;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 60vh;
color: #999;
font-size: 28rpx;
}
/* #ifdef MP-HARMONY */
.mp-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.mp-status-bar {
width: 100%;
}
.mp-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
.nav-back-text {
font-size: 48rpx;
color: #333;
line-height: 1;
}
}
.nav-btn-placeholder {
visibility: hidden;
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 16rpx;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.mp-spacer {
width: 100%;
}
/* #endif */
/* #ifdef H5 */
.h5-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80vh;
.h5-fallback-tip {
font-size: 28rpx;
color: #999;
margin-bottom: 24rpx;
}
.h5-fallback-btn {
font-size: 28rpx;
color: #333;
border: 1rpx solid #dcdfe6;
border-radius: 36rpx;
padding: 0 40rpx;
height: 72rpx;
line-height: 72rpx;
background: #fff;
margin: 0;
&::after {
border: none;
}
}
}
/* #endif */
</style>

819
pages_tool/card/style.vue Normal file
View File

@@ -0,0 +1,819 @@
<template>
<view class="card-detail" :style="[previewVars, { fontFamily: previewDs.fontFamily }]" :class="{ 'qs-square': previewDs.quickStyle === 'square' }">
<view class="page-layer">
<view class="ps-disabled" v-if="relation && !enabled">商家未开启员工端样式自选,暂不可选样式</view>
<!-- 实时预览卡: 与详情页同款 design 渲染(展示当前所选预设的实际落库效果) -->
<view class="card-wrap" v-if="relation">
<view class="card-wrap-bg" :style="previewHeaderBg"></view>
<view class="detail-card" :style="{ backgroundColor: previewDs.bgColor, borderRadius: previewDs.cardRadius + 'px' }"
:class="['layout-' + previewDs.layout, previewDs.cover && previewDs.layout !== 'classic' ? 'has-cover' : '']">
<view v-if="previewDs.cover && previewDs.layout !== 'classic'" class="dc-cover"
:style="{ borderTopLeftRadius: previewDs.cardRadius + 'px', borderTopRightRadius: previewDs.cardRadius + 'px' }"
:class="previewDs.layout === 'compact' ? 'dc-cover-compact' : 'dc-cover-hero'">
<image class="dc-cover-img" :src="$util.img(previewDs.cover)" mode="aspectFill"></image>
</view>
<view class="dc-name" :style="{ color: previewDs.textColor }">{{ relation.name || '未命名名片' }}</view>
<view class="dc-job" v-if="relation.job_title" :style="{ color: previewDs.textSoft, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.job_title }}</view>
<view class="dc-company" v-if="relation.company" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ relation.company }}</view>
<view class="dc-rows" v-if="fieldRows.length > 0">
<view v-for="(row, index) in fieldRows" :key="index" class="field-row"
:class="{ 'field-row-multi': row.multiline }">
<text v-if="row.icon" class="field-icon iconfont" :class="row.icon" :style="{ color: previewDs.textSoft }"></text>
<text v-else class="field-tag" :style="{ color: previewDs.textSoft, fontSize: (26 * previewDs.fontScaleNum) + 'rpx' }">{{ row.label }}</text>
<view class="field-value" :class="{ 'field-value-multi': row.multiline }" :style="{ color: previewDs.textColor, fontSize: (28 * previewDs.fontScaleNum) + 'rpx' }">{{ row.value }}</view>
</view>
</view>
</view>
</view>
<view class="detail-loading" v-if="isLoading">加载中…</view>
<ns-empty v-if="!isLoading && !relation" :isIndex="false"
:text="errorMsg || '卡片不存在或已被删除'"></ns-empty>
<!-- 预设网格: 每个单元格 = 该预设的迷你预览(同源后台 CardTemplatePresets) -->
<view class="preset-section" v-if="relation && enabled">
<text class="ps-title">选择样式<text class="ps-count">(共 {{ presets.length }} 个)</text></text>
<view class="ps-canvas-hint" v-if="isCanvasCard">
当前名片为商家自定义画布设计,保存将不被允许
</view>
<view class="preset-grid">
<view class="preset-cell" v-for="(p, idx) in presets" :key="p.key"
:class="{ active: p.key === selectedKey }" :style="cellActiveStyle(p)"
@tap="selectPreset(p.key)">
<text class="preset-tag" v-if="p.source === 'canvas'">画布式</text>
<!-- 缩略图: 彩色底 + 内嵌白卡。
底色改由样式表 .pt-N(N=格序) 给出: 微信小程序会丢弃内联 background,
只用内联会导致 14 个格子全部回落成同一个兜底蓝。 -->
<view class="preset-thumb" :class="'pt-' + (idx % 14)" :style="cellVars(p)">
<view class="pt-header"></view>
<view class="pt-body" :style="cellBody(p)">
<view class="pt-name" :style="cellText(p)"></view>
<view class="pt-line" :style="cellSoft(p)"></view>
<view class="pt-line pt-short" :style="cellSoft(p)"></view>
</view>
<view v-if="p.key === selectedKey" class="pt-check">
<view class="pt-check-mark"></view>
</view>
</view>
<text class="preset-name">{{ p.name }}</text>
<text class="preset-desc">{{ p.desc }}</text>
<text v-if="p.key === currentKey" class="preset-current">当前</text>
</view>
</view>
<view v-if="!isLoading && presets.length === 0" class="ps-empty">
预设列表为空(后端 design/presets 未返回)
</view>
</view>
</view>
<!-- 底部保存 -->
<view class="ps-footer" v-if="relation && enabled">
<view class="ps-save" :class="{ disabled: !selectedKey || saving || isCanvasCard }" @tap="save">
{{ saving ? '保存中…' : (isCanvasCard ? '不支持修改' : '保存') }}
</view>
</view>
</view>
</template>
<script>
/**
* 名片样式(可选样式页)· 后端 v2 对接
* GET /card/api/design/presets → 与商家后台 CardTemplatePresets 同源的预设列表
* POST /card/api/design/save → 按 preset_key 应用预设并保存到本卡
* 关键点(v2):
* - 预设列表由后端下发, 前端不自建; 数量不写死(当前 14 个, 直接渲染 list.length)
* - save 只写入 10 个字段(theme_color/text_color/bg_color/font_family/font_scale/
* card_radius/header_gradient_end/header_gradient_angle/quick_style/template_id),
* 不覆盖 layout / cover; 预览据此合并(本卡 layout/cover + 预设 10 字段)
* - source==='canvas' 的预设仅改配色; 本卡自身为 canvas 设计时 save 会被拒
* - data.enabled 控制入口显隐(detail.vue 已据此隐藏按钮)
*/
function mixHex(a, b, t) {
const toRgb = h => {
h = (h || '').replace('#', '');
if (h.length === 3) h = h.split('').map(c => c + c).join('');
const n = parseInt(h, 16);
if (isNaN(n)) return [0, 0, 0];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const [r1, g1, b1] = toRgb(a);
const [r2, g2, b2] = toRgb(b);
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const bl = Math.round(b1 + (b2 - b1) * t);
return '#' + [r, g, bl].map(x => x.toString(16).padStart(2, '0')).join('');
}
// save 实际写入的字段(与后端 v2 一致); 预览只叠加这些, 其余沿用本卡既有值
const SAVE_WRITTEN_FIELDS = [
'theme_color', 'text_color', 'bg_color', 'font_family', 'font_scale',
'card_radius', 'header_gradient_end', 'header_gradient_angle', 'quick_style', 'template_id'
];
// 归一化 design(字段缺失/非法回落文档默认值); d 可为 null(旧后端)
function normalizeDesign(d) {
const dd = d || {};
const FONTS = {
system: '-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif',
sans: '"Source Han Sans SC","Noto Sans SC","PingFang SC",Helvetica,Arial,sans-serif',
serif: '"Source Han Serif SC","Songti SC",SimSun,Georgia,serif',
rounded: '"Yuanti SC","YouYuan","Quicksand",-apple-system,sans-serif'
};
const isHex = v => /^#[0-9a-fA-F]{3,8}$/.test(v || '');
// 兼容后端可能的多种命名: snake_case / camelCase / 简写
// (后端若下发 camelCase 而这里只读 snake_case, 会全部回落默认蓝 → 14 个格子同色)
const pick = (...keys) => {
for (let i = 0; i < keys.length; i++) {
const v = dd[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return undefined;
};
const themeRaw = pick('theme_color', 'themeColor', 'theme', 'primary_color', 'primaryColor', 'primary', 'color');
const textRaw = pick('text_color', 'textColor', 'text', 'text_primary', 'textPrimary');
const bgRaw = pick('bg_color', 'bgColor', 'background_color', 'backgroundColor', 'background', 'bg');
const radiusRaw = pick('card_radius', 'cardRadius', 'radius');
const layoutRaw = pick('layout', 'card_layout', 'cardLayout');
const fontRaw = pick('font_family', 'fontFamily', 'font');
const scaleRaw = pick('font_scale', 'fontScale');
const headerEndRaw = pick('header_gradient_end', 'headerGradientEnd', 'gradient_end', 'gradientEnd');
const angleRaw = pick('header_gradient_angle', 'headerGradientAngle', 'gradient_angle', 'gradientAngle');
const theme = isHex(themeRaw) ? themeRaw : '#119dff';
const text = isHex(textRaw) ? textRaw : '#1f2329';
const bg = isHex(bgRaw) ? bgRaw : '#ffffff';
const fontKey = FONTS[fontRaw] ? fontRaw : 'system';
const fontScale = Math.min(160, Math.max(60, Number(scaleRaw) || 100));
const cardRadius = Number(radiusRaw) || 16;
const layout = ['hero', 'compact', 'classic'].includes(layoutRaw) ? layoutRaw : 'classic';
const headerEnd = typeof headerEndRaw === 'string' ? headerEndRaw : '';
const headerAngle = Number(angleRaw) || 150;
return {
themeColor: theme,
textColor: text,
bgColor: bg,
layout: layout,
cover: pick('cover', 'cover_url', 'coverUrl') || '',
fontKey: fontKey,
fontFamily: FONTS[fontKey],
fontScale: fontScale,
cardRadius: cardRadius,
headerGradientEnd: headerEnd,
headerGradientAngle: headerAngle,
quickStyle: pick('quick_style', 'quickStyle') || '',
templateId: pick('template_id', 'templateId', 'template') || '',
source: pick('source') || 'none',
textSoft: mixHex(text, bg, 0.55),
themeSoft: mixHex(theme, '#ffffff', 0.85),
fontScaleNum: fontScale / 100
};
}
/**
* 预设配色兜底: 后端 design 未带合法 theme_color 时(字段名不一致 / 未下发),
* 按预设 key/name/desc 关键词匹配一组与后台预设一致的色。
* 仅用于缩略图观感, 不影响保存(save 仍按 preset_key 走后端)。
*/
const PRESET_FALLBACK = [
{ re: /(极简|留白|minimal|white|blank)/, theme: '#1f2329' },
{ re: /(霓虹|深色|黑|墨|neon|dark|night)/, theme: '#111827' },
{ re: /(紫|purple|violet)/, theme: '#7b61ff' },
{ re: /(绿|green)/, theme: '#12b886' },
{ re: /(橙|暖|orange|warm)/, theme: '#f59f3c' },
{ re: /(红|crimson|red)/, theme: '#ef4444' },
{ re: /(粉|pink|rose)/, theme: '#ec4899' },
{ re: /(金|gold|amber)/, theme: '#d4a017' },
{ re: /(青|teal|cyan)/, theme: '#06b6d4' },
{ re: /(灰|gray|grey)/, theme: '#6b7280' },
{ re: /(渐|gradient)/, theme: '#5b6bff' },
{ re: /(大图|门面|封面|cover)/, theme: '#0b5cff' },
{ re: /(经典|商务|classic|business)/, theme: '#0b5cff' },
{ re: /(蓝|blue|flow|origin|原生)/, theme: '#119dff' }
];
function fallbackTheme(p) {
if (!p) return '';
const hay = String(p.key || '') + ' ' + String(p.name || '') + ' '
+ String(p.title || '') + ' ' + String(p.label || '') + ' ' + String(p.desc || '');
const low = hay.toLowerCase();
for (let i = 0; i < PRESET_FALLBACK.length; i++) {
if (PRESET_FALLBACK[i].re.test(low)) return PRESET_FALLBACK[i].theme;
}
return '';
}
function designVarsFor(s) {
return {
'--card-theme': s.themeColor,
'--card-theme-end': s.headerGradientEnd || s.themeColor,
'--card-text': s.textColor,
'--card-text-soft': s.textSoft,
'--card-bg': s.bgColor,
'--card-radius': s.cardRadius + 'px',
'--card-font': s.fontFamily,
'--card-font-scale': s.fontScaleNum
};
}
function headerBgFor(s) {
if (s.headerGradientEnd) {
return 'linear-gradient(' + s.headerGradientAngle + 'deg, ' + s.themeColor + ', ' + s.headerGradientEnd + ')';
}
return 'linear-gradient(150deg, ' + s.themeColor + ' 0%, ' + s.themeColor + ' 62%, rgba(0,0,0,0.28) 160%)';
}
export default {
data() {
return {
cardId: '',
relation: null,
// 名片设计摘要(后端 /card/api/card/detail 顶层 design, 旧后端无此键时为 null)
design: null,
// 后端下发的预设列表(同源后台 CardTemplatePresets, 数量不写死)
presets: [],
// 商家是否开启「员工端样式自选」(presets 响应 enabled)
enabled: true,
// 当前选中的预设 key(初始 = 本卡当前 template_id)
selectedKey: '',
isLoading: true,
errorMsg: '',
saving: false
};
},
computed: {
fieldRows() {
const r = this.relation;
if (!r) return [];
const rows = [];
const push = (label, value, action, icon) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
rows.push({ label, value: String(value), action: action || null, icon: icon || '' });
}
};
push('手机', r.mobile, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('电话', r.phone, { type: 'call', text: '拨打' }, 'icon-dianhua');
push('邮箱', r.email, { type: 'copy', text: '复制' });
push('微信', r.wechat, { type: 'copy', text: '复制' }, 'icon-weixin');
push('网站', r.website);
push('地址', [r.country, r.province, r.city, r.address].filter(Boolean).join(' '), null, 'icon-dizhi');
push('邮编', r.postal_code);
(r.mobiles_list || []).forEach((m, i) => {
push('手机' + (i + 2), m, { type: 'call', text: '拨打' }, 'icon-dianhua');
});
(r.socials_list || []).forEach(s => {
push(s.label || s.type, s.value, { type: 'copy', text: '复制' });
});
(r.custom_list || []).forEach(c => {
push(c.label, c.value);
});
if (r.bio && String(r.bio).trim() !== '') {
rows.push({ label: '简介', value: String(r.bio), action: null, multiline: true });
}
return rows;
},
// 本卡当前 design 的 template_id(用于标记"当前")
currentKey() {
return (this.design && this.design.template_id) || '';
},
// 本卡自身为画布设计时, save 会被后端拒
isCanvasCard() {
return !!(this.design && this.design.source === 'canvas');
},
activePreset() {
return this.presets.find(p => p.key === this.selectedKey) || null;
},
// 实时预览用的 design: 以本卡既有 design 为底, 仅叠加 save 实际写入的 10 个字段
// (layout/cover 不被 save 覆盖, 预览须与落库一致, 避免误导)
previewDesign() {
const base = this.design || {};
const p = this.activePreset ? this.activePreset.design : null;
if (!p) return base;
const merged = Object.assign({}, base);
SAVE_WRITTEN_FIELDS.forEach(k => {
if (p[k] !== undefined) merged[k] = p[k];
});
return merged;
},
previewDs() {
return normalizeDesign(this.previewDesign);
},
previewVars() {
return designVarsFor(this.previewDs);
},
previewHeaderBg() {
// 同 cellVars: 小程序内联 `background` 渐变不生效, 用 backgroundColor 纯色兜住
return { backgroundColor: this.previewDs.themeColor };
}
},
methods: {
/**
* 预设缩略图的配色。
*
* 为什么不用 CSS 变量: 微信小程序的渲染层不支持在内联 style 里定义自定义属性
* (`style="--card-bg:#fff"`), 整条 style 会被丢弃 → var(--card-bg) 全部回落默认值,
* 表现就是「小程序端样式缩略图全是白底黑条灰线, 没有颜色」(H5 正常)。
* 这里改为直接输出具体属性 + 具体色值, 三端一致。
*/
cellDs(p) {
let raw = p && p.design;
// design 可能是 JSON 字符串(部分后端把设计摘要存 JSON 列)
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch (e) { raw = null; }
}
// 合并: 预设项顶层字段(扁平下发) + design 子对象(优先), 两种结构都能读到色值
raw = Object.assign({}, p || {}, (raw && typeof raw === 'object') ? raw : {});
const s = normalizeDesign(raw);
// 缩略图颜色以「预设名称语义」为准(与后台/H5 的预设一致)。
// 后端可能给所有预设下发同一个默认 theme_color(合法但是同一种蓝),
// 若只在"没有合法色值"时才兜底, 就会 14 个格子全蓝 —— 所以这里名称命中即覆盖。
const fb = fallbackTheme(p);
if (fb) {
s.themeColor = fb;
s.headerGradientEnd = ''; // 纯色, 避免后端残留的渐变把兜底色盖掉
s.themeSoft = mixHex(fb, '#ffffff', 0.85);
}
return s;
},
cellVars(p) {
const s = this.cellDs(p);
// 底色不再走内联(小程序端实测内联 background/backgroundColor 均被丢,
// 导致 14 格回落同一个 CSS 兜底蓝), 改由样式表按格序上色(见 .preset-cell:nth-child)。
// 这里只保留圆角这类无颜色语义的属性。
const radius = Math.min(Number(s.cardRadius) || 16, 18);
return { borderRadius: radius + 'px' };
},
// 内嵌白卡: 底色取预设 bg_color, 在彩色底上形成"浮起的名片"层次
cellBody(p) {
const s = this.cellDs(p);
return { backgroundColor: s.bgColor };
},
cellText(p) {
return { backgroundColor: this.cellDs(p).textColor };
},
cellSoft(p) {
return { backgroundColor: this.cellDs(p).textSoft };
},
// 选中描边同样交给样式表(按格序), 避免内联失效
cellActiveStyle() {
return null;
},
onLoad(options) {
this.cardId = decodeURIComponent(options.card_id || '');
this.loadDetail();
this.loadPresets();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
loadDetail() {
if (!this.cardId) {
this.isLoading = false;
this.errorMsg = '缺少名片编号';
return;
}
this.$cardApi.cardDetail({
data: { card_id: this.cardId },
success: res => {
const data = res.data || {};
if (res.code == 0) {
this.relation = data.relation || null;
this.design = data.design || null;
this.initSelected();
} else {
this.errorMsg = res.message || '卡片不存在或已被删除';
this.relation = null;
}
this.isLoading = false;
},
fail: () => {
this.isLoading = false;
this.errorMsg = '网络异常,请稍后重试';
}
});
},
loadPresets() {
this.$cardApi.cardDesignPresets({
success: res => {
const data = res.data || {};
// 商家未开启样式自选时前端隐藏入口(默认 true, 失败开放)
this.enabled = data.enabled !== false;
this.presets = Array.isArray(data.list) ? data.list : [];
this.initSelected();
},
fail: () => {
this.enabled = true;
this.presets = [];
}
});
},
// 两路加载都到位后, 若本卡 template_id 命中某个预设, 默认选中它
initSelected() {
if (this.selectedKey || !this.currentKey || this.presets.length === 0) return;
const hit = this.presets.find(p => p.key === this.currentKey);
if (hit) this.selectedKey = hit.key;
},
selectPreset(key) {
this.selectedKey = key;
},
save() {
if (!this.selectedKey || this.saving) return;
if (this.isCanvasCard) {
uni.showToast({ title: '该名片使用商家自定义画布设计,暂不支持员工端修改', icon: 'none' });
return;
}
this.saving = true;
this.$cardApi.cardDesignSave({
data: { card_id: this.cardId, preset_key: this.selectedKey },
success: res => {
this.saving = false;
if (res.code == 0) {
uni.showToast({ title: '已保存', icon: 'success' });
// 返回详情页, 其 onShow 会重新拉取 design 并刷新生效
setTimeout(() => uni.navigateBack(), 600);
} else if (res.message) {
uni.showToast({ title: res.message, icon: 'none' });
}
},
fail: () => {
this.saving = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
}
});
}
}
};
</script>
<style lang="scss" scoped>
.card-detail {
min-height: 100vh;
background-color: #ffffff;
// 底部预留高度须 ≥ 固定底栏(按钮 88 + 上下 padding + 安全区), 否则滚到底最后一行会被遮住
padding: 0 0 240rpx;
box-sizing: border-box;
font-family: var(--card-font, -apple-system, "PingFang SC", "Helvetica Neue", Arial, sans-serif);
}
.page-layer {
position: relative;
}
.ps-disabled {
margin: 24rpx;
padding: 28rpx;
text-align: center;
font-size: 26rpx;
color: #8a94a6;
background-color: #f5f7fa;
border-radius: 12rpx;
}
// 预览卡(与详情页同款 design 渲染)
.card-wrap {
position: relative;
padding-top: 24rpx;
}
.card-wrap-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 48rpx;
z-index: 0;
background-color: var(--card-theme, #2aa5ff);
border-bottom-left-radius: 240rpx 44rpx;
border-bottom-right-radius: 240rpx 44rpx;
pointer-events: none;
}
.detail-card {
position: relative;
z-index: 1;
margin: 0 24rpx;
background-color: var(--card-bg, #ffffff);
border-radius: var(--card-radius, 20rpx);
padding: 40rpx 36rpx 32rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 30, 80, 0.06);
.dc-cover {
margin: -40rpx -36rpx 24rpx;
border-top-left-radius: var(--card-radius, 20rpx);
border-top-right-radius: var(--card-radius, 20rpx);
overflow: hidden;
.dc-cover-img {
display: block;
width: 100%;
}
}
.dc-cover-hero .dc-cover-img { height: 200rpx; }
.dc-cover-compact .dc-cover-img { height: 90rpx; }
.dc-name {
font-size: 44rpx;
font-weight: 700;
color: var(--card-text, #1a1a1a);
line-height: 1.3;
}
.dc-job {
margin-top: 6rpx;
font-size: calc(28rpx * var(--card-font-scale, 1));
line-height: 1.3;
color: var(--card-text-soft, #4a4a4a);
}
.dc-company {
margin-top: 2rpx;
font-size: calc(26rpx * var(--card-font-scale, 1));
line-height: 1.3;
color: var(--card-text-soft, #9aa4b2);
}
.dc-rows {
margin-top: 56rpx;
}
&.layout-compact {
padding-top: 28rpx;
.dc-rows { margin-top: 28rpx; }
.field-row { padding: 8rpx 0; }
.dc-name { font-size: 40rpx; }
}
.field-row {
display: flex;
align-items: center;
line-height: 1.2;
padding: 12rpx 0;
&.field-row-multi {
align-items: flex-start;
}
.field-icon {
width: 40rpx;
margin-right: 18rpx;
font-size: 32rpx;
line-height: 1;
color: var(--card-text-soft, #8a94a6);
text-align: center;
flex-shrink: 0;
}
.field-tag {
min-width: 40rpx;
margin-right: 18rpx;
font-size: calc(26rpx * var(--card-font-scale, 1));
line-height: 1.2;
color: var(--card-text-soft, #9aa4b2);
flex-shrink: 0;
}
.field-value {
flex: 1;
min-width: 0;
font-size: calc(28rpx * var(--card-font-scale, 1));
line-height: 1.25;
color: var(--card-text, #333333);
word-break: break-all;
}
.field-value-multi {
line-height: 1.4;
white-space: pre-wrap;
}
}
}
/* 预设网格(3 列自适应, 14 个不再过长) */
.preset-section {
margin: 32rpx 24rpx 0;
.ps-title {
display: block;
margin-bottom: 16rpx;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
.ps-count {
font-size: 24rpx;
font-weight: 400;
color: #8a94a6;
}
.ps-canvas-hint {
margin-bottom: 16rpx;
font-size: 24rpx;
color: #f5a623;
}
.preset-grid {
display: flex;
flex-wrap: wrap;
}
.preset-cell {
position: relative;
// 自建堆叠上下文: 把格子内的绝对定位元素(角标/选中钩)限制在本格内,
// 避免其 z-index 逃逸到固定底栏之上
z-index: 0;
width: 32%;
margin: 0 2% 16rpx 0;
background-color: #ffffff;
border: 2rpx solid #e8ebee;
border-radius: 16rpx;
padding: 14rpx;
box-sizing: border-box;
&:nth-child(3n) {
margin-right: 0;
}
&.active {
border-color: var(--card-theme, #2aa5ff);
box-shadow: 0 6rpx 18rpx rgba(42, 165, 255, 0.18);
}
.preset-tag {
position: absolute;
top: 8rpx;
left: 8rpx;
z-index: 2;
padding: 2rpx 10rpx;
font-size: 20rpx;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.45);
border-radius: 8rpx;
}
.preset-thumb {
position: relative;
height: 160rpx;
// 底色由 .pt-N 提供(见下方); 这里不能设兜底色, 否则会盖掉 .pt-N
border-radius: 12rpx;
overflow: hidden;
// 顶部彩色露出高度(底色由 cellVars 的渐变铺满整块)
.pt-header {
height: 24rpx;
}
// 内嵌白卡: 浮在主题色底上, 左右下留边露出彩色(对齐 H5 缩略图层次)
.pt-body {
margin: 0 12rpx 18rpx;
padding: 12rpx 14rpx;
background-color: #ffffff;
border-radius: 8rpx;
box-sizing: border-box;
}
.pt-name {
height: 14rpx;
width: 56%;
border-radius: 7rpx;
background-color: var(--card-text, #1a1a1a);
}
.pt-line {
height: 7rpx;
width: 80%;
margin-top: 12rpx;
border-radius: 4rpx;
background-color: var(--card-text-soft, #9aa4b2);
}
.pt-short {
width: 50%;
}
.pt-check {
position: absolute;
right: 10rpx;
bottom: 10rpx;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
.pt-check-mark {
width: 13rpx;
height: 6rpx;
margin-top: -4rpx;
border-left: 3rpx solid #2aa5ff;
border-bottom: 3rpx solid #2aa5ff;
transform: rotate(-45deg);
}
}
}
.preset-name {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-desc {
display: block;
margin-top: 4rpx;
font-size: 20rpx;
color: #8a94a6;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-current {
position: absolute;
top: 8rpx;
right: 8rpx;
padding: 2rpx 10rpx;
font-size: 20rpx;
color: #ffffff;
background-color: rgba(0, 0, 0, 0.35);
border-radius: 8rpx;
}
}
/* ===== 缩略图底色 =====
* 按后台 14 个预设的固定顺序写死。
* 为什么不用内联/渐变: 微信小程序会丢弃内联 background(渐变/纯色均可能失效),
* 之前 14 个格子全部回落成同一个蓝就是因此。样式表 class 是唯一稳定的方式。
* 后台预设顺序/配色调整时, 同步修改这里即可。
*/
.pt-0 { background-color: #119dff; } /* 原生样式 */
.pt-1 { background-color: #12b886; } /* 商务绿 */
.pt-2 { background-color: #f59f3c; } /* 暖橙 */
.pt-3 { background-color: #7b61ff; } /* 紫罗兰 */
.pt-4 { background-color: #0b5cff; } /* 经典商务 */
.pt-5 { background-color: #1f2329; } /* 极简留白 */
.pt-6 { background-color: #111827; } /* 深色霓虹 */
.pt-7 { background-color: #f59f3c; } /* 暖调名片 */
.pt-8 { background-color: #0b5cff; } /* 大图门面 */
.pt-9 { background-color: #7aa7c7; }
.pt-10 { background-color: #6cc24a; }
.pt-11 { background-color: #111827; }
.pt-12 { background-color: #d4a017; } /* 黑金质感: 金 */
.pt-13 { background-color: #ef4444; } /* 朱砂活力: 红 */
.ps-empty {
margin-top: 20rpx;
font-size: 24rpx;
color: #f5a623;
}
}
.detail-loading {
text-align: center;
color: #999999;
font-size: 26rpx;
padding: 120rpx 0;
}
/* 底部保存条 */
.ps-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
// 必须高于内容层: 否则预设格子里的绝对定位元素(如 .preset-tag z-index:2)
// 会"浮"到固定底栏之上, 只露出角标而卡片本体被遮住
z-index: 100;
padding: 20rpx 24rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
.ps-save {
height: 88rpx;
line-height: 88rpx;
text-align: center;
border-radius: 44rpx;
background-color: #2aa5ff;
color: #ffffff;
font-size: 32rpx;
&.disabled {
background-color: #c8d6e5;
}
}
}
</style>

244
pages_tool/card/views.vue Normal file
View File

@@ -0,0 +1,244 @@
<template>
<view class="views-page">
<!-- 汇总卡(2.11.4): 今日/累计 的 PV 与 UV -->
<view class="summary">
<view class="sum-card">
<view class="sum-num">{{ summary.today_pv || 0 }}</view>
<view class="sum-label">今日浏览 PV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.today_uv || 0 }}</view>
<view class="sum-label">今日访客 UV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.total_pv || 0 }}</view>
<view class="sum-label">累计浏览 PV</view>
</view>
<view class="sum-card">
<view class="sum-num">{{ summary.total_uv || 0 }}</view>
<view class="sum-label">累计访客 UV</view>
</view>
</view>
<!-- 列表: time / name / channel / device / ip -->
<view class="view-list">
<view v-for="(item, idx) in list" :key="item.id || idx" class="view-item">
<view class="view-avatar">{{ avatarText(item) }}</view>
<view class="view-info">
<view class="view-name">{{ item.viewer_name || item.name || '匿名访客' }}</view>
<view class="view-meta">
<text class="view-channel">{{ item.channel || '未知渠道' }}</text>
<text class="view-dot">·</text>
<text class="view-device">{{ item.device || '' }}</text>
<text class="view-dot" v-if="item.ip">·</text>
<text class="view-ip">{{ item.ip || '' }}</text>
</view>
</view>
<view class="view-time">{{ item.view_time || item.time || '' }}</view>
</view>
<view class="load-more" v-if="list.length > 0">
<text v-if="isLoading">加载中…</text>
<text v-else-if="page < pageCount">上拉加载更多</text>
<text v-else class="load-end">— 没有更多了 —</text>
</view>
<view v-if="!isLoading && list.length === 0" class="views-empty">暂无访客记录</view>
</view>
</view>
</template>
<script>
/**
* 访客轨迹 / 谁看了我(2.11.4,建议新页面)
* 入口: 我的名片「访客」总入口(不带 card_id,查名下全部名片)
* 或某张名片「浏览」入口(带 card_id,查该卡访客)
* 接口: POST /card/api/card/views { card_id?, page?, page_size?(<=50, 默认20) }
* 响应: { list, total, page, page_size, summary: { today_pv, today_uv, total_pv, total_uv } }
*/
export default {
data() {
return {
cardId: '', // 带 card_id=查该卡访客; 空=名下全部
list: [],
page: 1,
pageSize: 20,
pageCount: 1,
isLoading: false,
summary: {}
};
},
onLoad(options) {
this.cardId = (options && options.card_id) ? String(options.card_id) : '';
this.refresh();
},
onReady() {
// H5 uni-page-head 挂载早于路由 meta 就绪会回落默认标题 "title", onReady 再兜底一次
uni.setNavigationBarTitle({ title: '名片' });
// #ifdef H5
document.title = '名片';
// #endif
},
onPullDownRefresh() {
this.refresh(() => uni.stopPullDownRefresh());
},
onReachBottom() {
if (this.isLoading) return;
if (this.page >= this.pageCount) return;
this.loadPage(this.page + 1);
},
methods: {
avatarText(item) {
const name = item.viewer_name || item.name || '?';
return name.trim().charAt(0).toUpperCase();
},
refresh(done) {
this.page = 1;
this.loadPage(1, done);
},
loadPage(pageNo, done) {
this.isLoading = true;
const data = { page: pageNo, page_size: this.pageSize };
if (this.cardId) data.card_id = this.cardId;
this.$cardApi.cardViews({
data: data,
success: res => {
if (res.code == 0) {
const d = res.data || {};
const rows = d.list || [];
this.pageCount = d.page_count || 1;
this.page = pageNo;
this.summary = d.summary || {};
this.list = pageNo === 1 ? rows : this.list.concat(rows);
} else {
this.handleApiError(res);
}
this.isLoading = false;
typeof done === 'function' && done();
},
fail: () => {
this.isLoading = false;
uni.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
typeof done === 'function' && done();
}
});
},
handleApiError(res) {
const msg = (res && res.message) || '加载失败';
uni.showToast({ title: msg, icon: 'none' });
}
}
};
</script>
<style lang="scss" scoped>
.views-page {
min-height: 100vh;
background-color: #f6f7f9;
padding: 20rpx 24rpx 40rpx;
box-sizing: border-box;
}
.summary {
display: flex;
justify-content: space-between;
margin-bottom: 20rpx;
.sum-card {
flex: 1;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx 0;
text-align: center;
margin: 0 8rpx;
&:first-child { margin-left: 0; }
&:last-child { margin-right: 0; }
.sum-num {
font-size: 40rpx;
font-weight: 700;
color: var(--main-color, #0054a5);
line-height: 1.2;
}
.sum-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
}
}
}
.view-list {
.view-item {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
.view-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: var(--main-color-shallow, #e8f1fb);
color: var(--main-color, #0054a5);
font-size: 32rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 20rpx;
}
.view-info {
flex: 1;
min-width: 0;
.view-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.view-meta {
margin-top: 8rpx;
font-size: 24rpx;
color: #999;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.view-dot {
margin: 0 10rpx;
}
}
}
.view-time {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 24rpx;
color: #bbb;
}
}
}
.load-more {
text-align: center;
color: #999;
font-size: 24rpx;
padding: 24rpx 0;
.load-end { color: #ccc; }
}
.views-empty {
text-align: center;
color: #999;
font-size: 28rpx;
padding: 120rpx 0;
}
</style>

View File

@@ -1,35 +1,134 @@
<template>
<view class="file-preview-container" :style="themeColor">
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序:web-view 是原生组件,必须用 cover-view 盖在上面;cover-view 不支持 iconfont,改用 cover-image PNG -->
<cover-view class="mp-header">
<cover-view class="mp-header-inner">
<cover-view class="nav-btn back-btn" @click="goBack">
<cover-image src="/static/img/icon-back.png" class="nav-icon"></cover-image>
</cover-view>
<cover-view class="header-title">{{ headerTitle }}</cover-view>
<button class="nav-btn share-btn" open-type="share" hover-class="none">
<cover-image src="/static/img/icon-share.png" class="nav-icon"></cover-image>
</button>
</cover-view>
<!-- 状态栏占位(navigationStyle custom 时,避免被时间/电量遮挡) -->
<cover-view class="mp-status-bar" :style="{ height: statusBarHeight + 'px' }"></cover-view>
</cover-view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- 非小程序:普通 view + iconfont 即可 -->
<view class="header">
<view class="back-btn" @click="goBack">
<view class="nav-btn back-btn" @click="goBack">
<text class="iconfont icon-back"></text>
</view>
<view class="header-title">{{ fileName }}</view>
<view class="placeholder"></view>
<view class="header-title">{{ headerTitle }}</view>
<button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
<text class="iconfont icon-fenxiang"></text>
</button>
</view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<view class="content">
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览分支
aikefu 测试对话附件 type='image' / AI 侧 attachments.image 跳转时传 fileType=image -->
<view v-if="fileType === 'image'" class="image-viewer">
<image v-if="fileUrl" :src="fileUrl" mode="aspectFit" class="image-element" @click="previewImageFullscreen" />
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览分支
AI 侧 attachments.audio 跳转时传 fileType=audio, 用 createInnerAudioContext 播放 -->
<view v-else-if="fileType === 'audio'" class="audio-viewer">
<view v-if="fileUrl" class="audio-player-card">
<view class="audio-icon">
<text class="iconfont icon-voice"></text>
</view>
<view class="audio-info">
<view class="audio-title">{{ fileName || $lang('file-preview.file-preview.audioDefaultTitle') }}</view>
<view class="audio-status">{{ audioPlaying ? $lang('file-preview.file-preview.audioPlaying') : $lang('file-preview.file-preview.audioPaused') }}</view>
<view class="audio-progress">
<text class="audio-time">{{ formatAudioTime(audioCurrentTime) }}</text>
<view class="audio-progress-bar">
<view class="audio-progress-fill" :style="{ width: audioProgressPct + '%' }"></view>
</view>
<text class="audio-time">{{ formatAudioTime(audioDuration) }}</text>
</view>
</view>
<button class="audio-play-btn" @click="toggleAudioPlay">
<text class="iconfont" :class="audioPlaying ? 'icon-pause' : 'icon-play'"></text>
</button>
</view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.audioUrlNotExist') }}</view>
<!-- #ifdef H5 -->
<view class="h5-action-bar">
<view class="h5-action-tip">如无法播放,请使用下方按钮:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
</view>
</view>
<!-- #endif -->
</view>
<!-- PDF 文件预览 -->
<view v-if="fileType === 'pdf'" class="file-viewer">
<!-- #ifdef H5 -->
<!-- H5 端:web-view 渲染为 iframe,若被 X-Frame-Options/CORS 拦截则显示空白;额外提供"新标签页打开"与"下载"按钮兜底 -->
<view class="h5-action-bar">
<view class="h5-action-tip">如页面空白,请使用下方按钮:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
</view>
</view>
<!-- #endif -->
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- Word 文件预览 -->
<view v-else-if="fileType === 'word'" class="file-viewer">
<!-- #ifdef H5 -->
<view class="h5-action-bar">
<view class="h5-action-tip">浏览器可能无法直接内嵌预览 Word,建议下载后查看:</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
</view>
</view>
<!-- #endif -->
<web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view>
<!-- 视频文件预览 -->
<view v-else-if="fileType === 'video'" class="video-viewer">
<video v-if="fileUrl" :src="fileUrl" controls autoplay class="video-player"></video>
<video v-if="fileUrl" id="filePreviewVideo" :src="fileUrl" controls class="video-player"></video>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
</view>
<!-- 其他文件类型 -->
<view v-else class="file-viewer">
<!-- #ifdef H5 -->
<view v-if="fileUrl" class="h5-action-bar">
<view class="h5-action-tip">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
<view class="h5-action-btns">
<button class="h5-action-btn primary" @click="triggerDownload">下载文件</button>
<button class="h5-action-btn" @click="openInNewTab">新标签页打开</button>
</view>
</view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
<!-- #endif -->
</view>
</view>
</view>
@@ -41,48 +140,263 @@ export default {
return {
fileName: '',
fileUrl: '',
fileType: ''
fileType: '',
statusBarHeight: 20,
windowWidth: 375,
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频播放引擎
audioCtx: null,
audioPlaying: false,
audioCurrentTime: 0,
audioDuration: 0
};
},
computed: {
audioProgressPct() {
if (!this.audioDuration || this.audioDuration <= 0) return 0;
return Math.min(100, (this.audioCurrentTime / this.audioDuration) * 100);
}
},
computed: {
// #ifdef MP-WEIXIN
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
headerTotalHeightPx() {
const rpxToPx = this.windowWidth / 750;
return this.statusBarHeight + Math.round(88 * rpxToPx);
},
// #endif
// 页面标题栏显示的短标题(避免超长挤压分享按钮;对用户无意义的tmp名统一显示通用"文件预览")
headerTitle() {
const name = (this.fileName || '').trim();
// 如果是临时文件名(tmp_ 开头 或 带路径hash的长串),统一显示通用"文件预览"
if (/^tmp_/i.test(name) || (name.length > 30 && /[a-f0-9]{20,}/i.test(name))) {
return this.$lang('file-preview.file-preview.title');
}
// 否则显示原始文件名(列表里传入的,如"乐瑞固企业介绍",本身就短)
if (name) {
// JS 层保险截断(超过16字 + …),应对 cover-view CSS 省略号不生效
if (name.length > 16) return name.slice(0, 16) + '…';
return name;
}
return this.$lang('file-preview.file-preview.title');
},
// 分享的真实标题(好友收到的卡片显示真实文件名,不截断)
shareTitle() {
const name = (this.fileName || '').trim();
if (name && !/^tmp_/i.test(name)) return name;
return this.$lang('file-preview.file-preview.title');
},
// 分享路径:只带文件三要素,不挂推荐码/关系
sharePath() {
const query = [];
if (this.fileName) query.push(`fileName=${encodeURIComponent(this.fileName)}`);
if (this.fileUrl) query.push(`fileUrl=${encodeURIComponent(this.fileUrl)}`);
if (this.fileType) query.push(`fileType=${encodeURIComponent(this.fileType)}`);
return `/pages_tool/file-preview/file-preview${query.length ? '?' + query.join('&') : ''}`;
},
themeColor() {
if (this.$shop && this.$shop.themeColor) {
return `--theme-color: ${this.$shop.themeColor};`;
}
return '';
}
},
onLoad(option) {
// 解析传递的参数
if (option.fileName) {
this.fileName = decodeURIComponent(option.fileName);
}
if (option.fileUrl) {
this.fileUrl = decodeURIComponent(option.fileUrl);
}
if (option.fileType) {
this.fileType = decodeURIComponent(option.fileType);
} else {
// 根据文件名后缀推断文件类型
this.inferFileType();
}
// #ifdef MP-WEIXIN
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
// #endif
},
onReady() {
// 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
// #ifndef H5
if (this.fileType === 'video' && this.fileUrl) {
try {
const ctx = uni.createVideoContext('filePreviewVideo', this);
if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
} catch (e) {}
}
// #endif
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 离开页面销毁音频 ctx
onUnload() {
if (this.audioCtx) {
try { this.audioCtx.stop(); this.audioCtx.destroy(); } catch (e) {}
this.audioCtx = null;
}
},
// #ifdef MP-WEIXIN
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
onShareAppMessage() {
return {
title: this.shareTitle,
path: this.sharePath
};
},
// 微信小程序:分享到朋友圈
onShareTimeline() {
return {
title: this.shareTitle,
query: this.sharePath.split('?')[1] || ''
};
},
// #endif
methods: {
/**
* 根据文件名后缀推断文件类型
*/
inferFileType() {
if (this.fileName) {
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 加 image / audio 识别
const classify = (ext) => {
if (['pdf'].includes(ext)) return 'pdf';
if (['doc', 'docx'].includes(ext)) return 'word';
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video';
if (['mp3', 'wav', 'aac', 'm4a', 'ogg', 'flac'].includes(ext)) return 'audio';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
return null;
};
if (this.fileUrl) {
const clean = String(this.fileUrl).split('?')[0];
const ext = clean.split('.').pop().toLowerCase();
const t = classify(ext);
if (t) this.fileType = t;
} else if (this.fileName) {
const ext = this.fileName.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
this.fileType = 'pdf';
} else if (['doc', 'docx'].includes(ext)) {
this.fileType = 'word';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
this.fileType = 'video';
const t = classify(ext);
if (t) this.fileType = t;
}
},
goBack() {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/index/index' }).catch(() => {
uni.navigateBack();
});
}
},
// #ifdef H5
// H5 端:新标签页打开文件(绕过 iframe X-Frame-Options/CORS 限制)
openInNewTab() {
if (!this.fileUrl) return;
try {
window.open(this.fileUrl, '_blank', 'noopener,noreferrer');
} catch (e) {
uni.showToast({ title: '打开失败,请稍后重试', icon: 'none' });
}
},
// H5 端:通过 <a download> 触发浏览器下载(最稳定的跨浏览器方案)
triggerDownload() {
if (!this.fileUrl) return;
try {
const link = document.createElement('a');
link.href = this.fileUrl;
link.target = '_blank';
link.rel = 'noopener noreferrer';
if (this.fileName) {
// 仅提示浏览器用这个名字;受 CORS 影响,跨域文件时 download 属性可能被忽略,
// 此时浏览器会直接按服务器返回的文件名/直接显示,但不会丢失文件本身
link.download = this.fileName;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (e) {
// 兜底:新标签页打开
this.openInNewTab();
}
},
// #endif
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片分支 — 点击全屏预览
previewImageFullscreen() {
if (!this.fileUrl) return;
// #ifdef H5 || MP-WEIXIN
uni.previewImage({ urls: [this.fileUrl], current: this.fileUrl });
// #endif
},
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频分支 — 切换播放/暂停
toggleAudioPlay() {
if (!this.fileUrl) {
uni.showToast({ title: '音频地址为空', icon: 'none' });
return;
}
if (this.audioPlaying) {
this._pauseAudio();
} else {
this._playAudio();
}
},
/**
* 返回上一页
*/
goBack() {
uni.navigateBack();
_ensureAudioCtx() {
if (this.audioCtx) return this.audioCtx;
try {
const ctx = uni.createInnerAudioContext();
ctx.src = this.fileUrl;
ctx.onTimeUpdate(() => {
this.audioCurrentTime = ctx.currentTime || 0;
if (ctx.duration && !isNaN(ctx.duration)) {
this.audioDuration = ctx.duration;
}
});
ctx.onEnded(() => {
this.audioPlaying = false;
this.audioCurrentTime = 0;
});
ctx.onError((e) => {
this.audioPlaying = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
console.error('[file-preview audio] error:', e);
});
this.audioCtx = ctx;
return ctx;
} catch (e) {
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' });
console.error('[file-preview audio] createInnerAudioContext failed:', e);
return null;
}
},
_playAudio() {
const ctx = this._ensureAudioCtx();
if (!ctx) return;
if (this.audioCurrentTime > 0) {
try { ctx.seek(this.audioCurrentTime); } catch (e) { /* ignore */ }
}
this.audioPlaying = true;
try {
ctx.play();
} catch (e) {
this.audioPlaying = false;
uni.showToast({ title: '音频播放失败', icon: 'none' });
}
},
_pauseAudio() {
if (this.audioCtx) {
try { this.audioCtx.pause(); } catch (e) { /* ignore */ }
}
this.audioPlaying = false;
},
// 00:00 格式
formatAudioTime(seconds) {
if (!seconds || isNaN(seconds)) return '00:00';
const total = Math.floor(seconds);
const mm = String(Math.floor(total / 60)).padStart(2, '0');
const ss = String(total % 60).padStart(2, '0');
return mm + ':' + ss;
}
}
};
@@ -92,7 +406,10 @@ export default {
.file-preview-container {
min-height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
/* =================== 非 MP 端 header =================== */
.header {
display: flex;
align-items: center;
@@ -101,22 +418,34 @@ export default {
padding: 0 30rpx;
background-color: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
flex-shrink: 0;
z-index: 10;
.back-btn {
.nav-btn {
flex-shrink: 0;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
line-height: 1;
border: none;
margin: 0;
&::after { border: none; }
}
.back-btn {
.icon-back {
font-size: 32rpx;
font-size: 36rpx;
color: #333;
}
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
@@ -124,16 +453,90 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 20rpx;
}
.placeholder {
width: 60rpx;
.share-btn {
.icon-fenxiang {
font-size: 34rpx;
color: #333;
}
}
}
/* =================== MP 端 header (cover-view 浮层) =================== */
/* #ifdef MP-WEIXIN */
.mp-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
background-color: #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.mp-status-bar {
width: 100%;
}
.mp-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
padding: 0 20rpx;
width: 100%;
box-sizing: border-box;
}
.nav-btn {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
line-height: 1;
border: none;
margin: 0;
&::after { border: none; }
}
.nav-icon {
width: 40rpx;
height: 40rpx;
display: block;
}
.header-title {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 16rpx;
/* cover-view 下 ellipsis 支持有限,JS 层(headerTitle computed)已经做了截断,这里再冗余防守 */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
/* #endif */
/* =================== 内容区 =================== */
.content {
flex: 1;
padding: 20rpx;
/* #ifdef MP-WEIXIN */
/* 给浮层 header(statusBar+88rpx)留位置,避免内容顶上去 */
padding-top: calc(var(--mp-header-h, 120rpx));
/* #endif */
padding-left: 20rpx;
padding-right: 20rpx;
padding-bottom: 20rpx;
box-sizing: border-box;
.file-viewer {
width: 100%;
@@ -152,6 +555,50 @@ export default {
color: #999;
font-size: 28rpx;
}
/* #ifdef H5 */
/* H5 兜底操作栏(PDF/Word 被 X-Frame-Options 拦截时显示)*/
.h5-action-bar {
padding: 24rpx 28rpx 20rpx;
background: linear-gradient(180deg, #fffbe6 0%, #ffffff 100%);
border-bottom: 1rpx solid #f0e9c8;
.h5-action-tip {
font-size: 26rpx;
color: #8a6d1b;
margin-bottom: 18rpx;
line-height: 1.5;
}
.h5-action-btns {
display: flex;
gap: 20rpx;
.h5-action-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
padding: 0;
font-size: 28rpx;
border-radius: 36rpx;
background: #f2f3f5;
color: #333;
border: 1rpx solid #e2e4e8;
margin: 0;
&::after { border: none; }
&.primary {
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
color: #fff;
border-color: transparent;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
}
}
}
}
/* #endif */
}
.video-viewer {
@@ -178,6 +625,128 @@ export default {
background-color: #000;
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览 */
.image-viewer {
width: 100%;
min-height: 80vh;
background-color: #000;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
.image-element {
width: 100%;
max-height: 80vh;
display: block;
}
}
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览 */
.audio-viewer {
width: 100%;
min-height: 80vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.audio-player-card {
width: 90%;
max-width: 560rpx;
padding: 40rpx 32rpx;
background-color: #fff;
border-radius: 16rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
.audio-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
.iconfont {
font-size: 48rpx;
color: #fff;
}
}
.audio-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
.audio-title {
font-size: 30rpx;
font-weight: 600;
color: #2c3e50;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audio-status {
font-size: 22rpx;
color: #8a9fb8;
}
.audio-progress {
display: flex;
align-items: center;
gap: 12rpx;
margin-top: 8rpx;
.audio-time {
font-size: 22rpx;
color: #8a9fb8;
flex-shrink: 0;
min-width: 60rpx;
text-align: center;
}
.audio-progress-bar {
flex: 1;
height: 6rpx;
background-color: #e8edf3;
border-radius: 3rpx;
overflow: hidden;
.audio-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6b7c93 0%, #4a5a72 100%);
transition: width 0.1s linear;
}
}
}
}
.audio-play-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
border: none;
display: flex;
align-items: center;
justify-content: center;
margin-left: 16rpx;
flex-shrink: 0;
padding: 0;
line-height: 1;
&::after { border: none; }
.iconfont {
font-size: 36rpx;
color: #fff;
}
}
}
}
}
}
</style>

589
pages_tool/files/detail.vue Normal file
View File

@@ -0,0 +1,589 @@
<template>
<view class="files-detail" :style="themeColor">
<view class="file-card">
<view class="pdf-icon">
<text class="pdf-icon-p">P</text>
<view class="pdf-icon-fold"></view>
<view class="pdf-icon-lines">
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
</view>
</view>
<view class="info-wrap">
<view class="title">{{ displayTitle }}</view>
<view v-if="!loaded" class="read-wrap">
<text>{{ $lang('files.list.loading') }}</text>
</view>
</view>
</view>
<view v-if="fileUrl" class="action-area">
<button class="action-btn primary" @tap="openFile">{{ $lang('files.list.viewPdf') }}</button>
</view>
<view v-if="failed" class="error-tip">{{ $lang('files.list.fileNotExist') }}</view>
<loading-cover ref="loadingCover"></loading-cover>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
</view>
</template>
<script>
/**
* 文件详情页 —— 承接 DIY 装修里的文件链接: /pages_tool/files/detail?files_id=43&title=xxx
*
* 背景: 后台装修数据(如"图标导航/文本导航"的 FILES_xx 链接)把文件指向本页,
* 此前页面缺失导致点击后打不开。本页负责:
* 1) 按 files_id 取到文件信息(files_url / files_title)
* 2) 进入页面即自动按平台打开文件(与 files/list 点击行为一致)
* 3) 自动打开失败时,可点击"查看PDF文件"按钮重试
*
* 注1: 本页文案只使用语言包中已存在的 key(files.list.*),避免新增 key 未同步到服务端
* (lang 为 remote-only 模式,运行时取 /api/lang/bundle)。
* 注2: Files 控制器没有单文件详情接口,只能从列表接口里按 files_id 取,详见 fetchFile()。
*/
export default {
data() {
return {
filesId: '',
filesTitle: '',
detail: null,
fileUrl: '',
loaded: false,
failed: false,
// 排查用:记录列表接口扫过的文件(files_id:标题)
scanned: [],
titleTimers: []
};
},
computed: {
displayTitle() {
if (this.filesTitle) return this.filesTitle;
if (this.detail && (this.detail.files_title || this.detail.title)) {
return this.detail.files_title || this.detail.title;
}
return this.$lang('files.list.title');
},
themeColor() {
if (this.$shop && this.$shop.themeColor) {
return `--theme-color: ${this.$shop.themeColor};`;
}
return '';
}
},
onLoad(options) {
options = options || {};
// page_params 可能是 JSON 字符串 / 对象 / 数组(装修数据里常见 [])
const params = this.$util.parsePageParams(options.page_params) || {};
const rawId = options.files_id || options.file_id || options.filesId || options.id
|| params.files_id || params.file_id || params.id || '';
this.filesId = rawId === '' || rawId === undefined || rawId === null ? '' : String(rawId);
const rawTitle = options.title || params.title || '';
if (rawTitle) this.filesTitle = this.decodeTitle(rawTitle);
this.scheduleTitleSync();
this.getData();
},
onUnload() {
this.clearTitleTimers();
},
watch: {
// main.js 的全局 mixin 监听 componentRefresh,变化时调用 $langConfig.refresh(),
// 而 refresh() 会用 $lang('title') 重置导航标题。本页标题就是文件名、且远端语言包里
// 没有 files/detail 词条(remote-only 模式),$lang('title') 会退化成字面量 "title"。
// refresh() 对每个组件都会跑一遍、"最后一个生效",所以这里延时补写几次兜底。
componentRefresh() {
if (this.isCurrentPage()) this.scheduleTitleSync();
}
},
onShareAppMessage() {
return {
title: this.displayTitle,
path: `/pages_tool/files/detail?files_id=${this.filesId}`
};
},
onShareTimeline() {
return {
title: this.displayTitle,
query: `files_id=${this.filesId}`
};
},
methods: {
syncNavigationTitle() {
this.$langConfig.title(this.displayTitle);
},
// 导航标题兜底:立即写一次,再延时补几次(等全局 refresh() 都跑完)
scheduleTitleSync() {
this.clearTitleTimers();
this.syncNavigationTitle();
[100, 500, 1500].forEach(delay => {
this.titleTimers.push(setTimeout(() => {
if (this.isCurrentPage()) this.syncNavigationTitle();
}, delay));
});
},
clearTitleTimers() {
this.titleTimers.forEach(id => clearTimeout(id));
this.titleTimers = [];
},
// 本页是否在最上层(避免本页在后台时去改别的页面标题)
isCurrentPage() {
try {
const pages = getCurrentPages();
const current = pages[pages.length - 1];
if (!current) return false;
const route = current.route || (current.$page && current.$page.path) || '';
return route.indexOf('files/detail') !== -1;
} catch (e) {
return false;
}
},
decodeTitle(value) {
const str = String(value || '');
// 多数端 uni 已自动 decode 一次,这里只在明显还是百分号编码时才解码,避免二次解码破坏文案
if (!str || str.indexOf('%') === -1) return str;
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
},
hideLoadingCover() {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
async getData() {
if (!this.filesId) {
this.finishLoad(null);
return;
}
// 优先走后端单文件详情接口: 按 files_id 直接取, 无需翻列表(文件多时省几十个请求)
let detail = await this.fetchGetfile(this.filesId);
// 兜底: 列表逐页扫(可能为空/被过滤) / 企业文件接口
if (!detail) detail = await this.fetchFile(this.filesId) || await this.fetchFromPersonnel();
if (!detail || !this.normalizeUrl(detail.files_url)) {
this.finishLoad(null);
this.$util.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
});
return;
}
this.detail = detail;
if (!this.filesTitle) this.filesTitle = detail.files_title || detail.title || '';
this.syncNavigationTitle();
this.fileUrl = this.normalizeUrl(detail.files_url);
this.finishLoad(detail);
// 进入详情页即自动打开,与点击文件列表项行为一致
this.openFile();
},
finishLoad(detail) {
this.loaded = true;
this.failed = !detail;
this.hideLoadingCover();
},
/**
* 取单个文件信息(优先链路)。
* 后端接口: GET /api/files/getfile?files_id=xxx
* 内部 getFilesInfo([['files_id','=',$files_id]]) → model('files')->getInfo(),
* 返回整条记录(files_title / files_url / files_desc / category_id 等),
* files_url 已 img() 补全域名; files 表带 site_id, bootSiteGuard 自动注入当前站点,多租户不串号。
* 仅 files_id 一个参数即可,后端无需改造。
*/
async fetchGetfile(filesId) {
const res = await this.request('/api/files/getfile', { files_id: filesId });
if (!res || res.code !== 0 || !res.data) return null;
const d = res.data;
return {
files_id: d.files_id || filesId,
files_title: d.files_title || d.title || '',
files_url: d.files_url || '',
files_desc: d.files_desc,
category_id: d.category_id,
category_name: d.category_name
};
},
/**
* 兜底链路(优先链路 fetchGetfile 失败时): 逐页拉列表按 files_id 精确匹配。
* 该 addon 之前 Files 控制器只提供列表接口(实测 /api/files/info、/detail、/getinfo 均 404:
* "method not exists:app\api\controller\Files->info()"),所以用 getpage 翻页扫。
*/
async fetchFile(filesId) {
// 1) 有标题时先拿标题当关键词搜一次(命中就 1 个请求解决,不必深翻页)
if (this.filesTitle) {
const searched = await this.queryList({ page: 1, page_size: 100, keyword: this.filesTitle }, filesId, true);
if (searched && searched.hit) return searched.hit;
}
// 2) 逐页遍历全量列表(后端只返回 10 条/页时最多翻 30 页)
for (let page = 1; page <= 30; page++) {
const queried = await this.queryList({ page: page, page_size: 100 }, filesId);
if (queried && queried.hit) return queried.hit;
if (!queried || queried.empty) return null;
}
return null;
},
/**
* 兜底链路: 从企业文件接口(/api/member/personnel, 电子名片页"企业文件"同源)取文件。
* 该接口返回 res.file_list, 每项形如 { name, url }(可能带 files_id/file_id/id)。
* 先按 files_id 精确匹配, 再按标题精确匹配, 最后按标题互相包含模糊匹配。
*/
async fetchFromPersonnel() {
const res = await this.request('/api/member/personnel', {});
if (!res || res.code !== 0) return null;
const list = res.file_list || (res.data && res.data.file_list) || [];
if (!Array.isArray(list)) return null;
const title = (this.filesTitle || '').toString();
const getId = item => item.files_id || item.file_id || item.id || '';
let fuzzyHit = null;
for (const item of list) {
if (!item) continue;
// 1) files_id 精确匹配
const id = getId(item);
if (id !== '' && String(id) === String(this.filesId)) return this.wrapPersonnelFile(item);
// 2) 标题精确匹配
const name = (item.name || item.files_title || item.title || '').toString();
if (title && name === title) return this.wrapPersonnelFile(item);
// 3) 标题互相包含(记下第一个命中, 优先级最低)
if (title && !fuzzyHit && (name.indexOf(title) !== -1 || title.indexOf(name) !== -1)) {
fuzzyHit = item;
}
}
return fuzzyHit ? this.wrapPersonnelFile(fuzzyHit) : null;
},
wrapPersonnelFile(item) {
return {
files_id: item.files_id || item.file_id || item.id || this.filesId,
files_title: item.name || item.files_title || item.title || '',
files_url: item.url || item.files_url || ''
};
},
/**
* 请求一页文件列表,并按 files_id 精确匹配(入参与 files/list 页保持一致)
* @param {Boolean} allowTitleMatch 该页是标题搜索结果时,允许退一步用标题全等匹配
* @returns {Object|null} 命中返回 { hit };本页无数据返回 { empty: true };请求失败返回 null
*/
async queryList(data, filesId, allowTitleMatch) {
const res = await this.request('/api/files/getpage', Object.assign({
category_id: '',
keyword: ''
}, data));
if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) return null;
const list = res.data.list;
let titleHit = null;
for (const item of list) {
const src = item && item.$orig ? item.$orig : item;
if (!src) continue;
if (String(src.files_id) === String(filesId)) return { hit: src };
// 兜底:装修链接里的 files_id 与接口不一致时,用标题全等再认一次
if (allowTitleMatch && !titleHit && this.filesTitle && src.files_title === this.filesTitle) {
titleHit = src;
}
}
if (titleHit) return { hit: titleHit };
return { empty: list.length === 0 };
},
async request(url, data) {
try {
return await this.$api.sendRequest({
url,
data,
async: false,
noRetry: true,
// 失败静默返回 null,由调用方兜底,不弹全局报错
silentError: true
});
} catch (e) {
return null;
}
},
normalizeUrl(url) {
if (!url) return '';
const str = String(url);
if (/^https?:\/\//i.test(str)) return str;
// $util.img 对含逗号的路径会按"多图字段"截断,文件名可能含逗号,这里规避
if (str.indexOf(',') !== -1) return str;
// 相对路径补全域名(与图片同一套域名规则)
return this.$util.img(str);
},
getFileExt() {
let ext = 'pdf';
if (this.fileUrl) {
const clean = this.fileUrl.split('?')[0];
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
if (matched) ext = matched[1].toLowerCase();
}
return ext;
},
getFileName() {
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
return `${title}.${this.getFileExt()}`;
},
getFileType() {
const ext = this.getFileExt();
return ['pdf'].includes(ext) ? 'pdf'
: ['doc', 'docx'].includes(ext) ? 'word'
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext) ? 'video'
: 'pdf';
},
buildPreviewUrl() {
const fileName = encodeURIComponent(this.getFileName());
const fileUrl = encodeURIComponent(this.fileUrl);
const fileType = encodeURIComponent(this.getFileType());
return `/pages_tool/file-preview/file-preview?fileName=${fileName}&fileUrl=${fileUrl}&fileType=${fileType}`;
},
openFile() {
if (!this.fileUrl) {
this.failed = true;
this.$util.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
});
return;
}
this.failed = false;
const ext = this.getFileExt();
const previewPage = this.buildPreviewUrl();
// ========== H5 端: 用预览页替换当前页(web-view 走 iframe),失败则新标签页打开 ==========
// #ifdef H5
uni.redirectTo({
url: previewPage,
fail: () => {
try {
window.open(this.fileUrl, '_blank');
} catch (e) {
this.failed = true;
}
}
});
return;
// #endif
// ========== 非 H5 端: 下载文件后按平台分支打开 ==========
// #ifndef H5
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
uni.downloadFile({
url: this.fileUrl,
success: res => {
if (res.statusCode !== 200) {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
return;
}
// ----- 微信小程序: saveFile 重命名 + openDocument(标题取短文件名) -----
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager();
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
const openByPath = (filePath, cleanup) => {
uni.openDocument({
filePath,
fileType: ext,
showMenu: true,
fail: () => {
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: () => {
if (cleanup) {
try { fs.removeSavedFile({ filePath }); } catch (e) {}
}
}
});
};
try {
fs.saveFile({
tempFilePath: res.tempFilePath,
filePath: targetPath,
success: r => {
uni.hideLoading();
openByPath(r.savedFilePath, true);
},
fail: () => {
// saveFile 失败(配额等),直接用临时文件兜底
uni.hideLoading();
openByPath(res.tempFilePath, false);
}
});
} catch (e) {
uni.hideLoading();
openByPath(res.tempFilePath, false);
}
// #endif
// ----- App / 其他小程序: 直接 openDocument;失败兜底跳转预览页 -----
// #ifndef MP-WEIXIN
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.navigateTo({
url: previewPage,
fail: () => {
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
});
}
});
// #endif
},
fail: () => {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: res => {
if (res && res.statusCode === 404) {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
// #endif
}
}
};
</script>
<style scoped>
.files-detail {
width: 100%;
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
background-color: #f8f8f8;
}
.files-detail .file-card {
display: flex;
align-items: center;
padding: 30rpx;
background-color: #fff;
border-radius: 16rpx;
}
.files-detail .file-card .pdf-icon {
margin-right: 24rpx;
width: 96rpx;
height: 96rpx;
flex-shrink: 0;
background-color: #e64340;
border-radius: 10rpx;
position: relative;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
}
.files-detail .file-card .pdf-icon .pdf-icon-p {
position: absolute;
top: 2rpx;
left: 10rpx;
color: #ffffff;
font-size: 48rpx;
font-weight: 900;
font-family: Arial, Helvetica, sans-serif;
line-height: 1;
}
.files-detail .file-card .pdf-icon .pdf-icon-fold {
position: absolute;
top: 0;
right: 0;
width: 28rpx;
height: 28rpx;
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
border-bottom-left-radius: 4rpx;
}
.files-detail .file-card .pdf-icon .pdf-icon-lines {
position: absolute;
right: 8rpx;
bottom: 8rpx;
width: 42rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.files-detail .file-card .pdf-icon .pdf-icon-line {
width: 100%;
height: 3rpx;
background-color: #ffffff;
opacity: 0.85;
margin-bottom: 4rpx;
border-radius: 2rpx;
}
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(2) {
width: 80%;
}
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(3) {
width: 60%;
margin-bottom: 0;
}
.files-detail .file-card .info-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.files-detail .file-card .info-wrap .title {
font-weight: 700;
color: #303133;
font-size: 30rpx;
line-height: 1.4;
word-break: break-all;
}
.files-detail .file-card .info-wrap .read-wrap {
display: flex;
color: #999ca7;
align-items: center;
margin-top: 12rpx;
line-height: 1;
}
.files-detail .file-card .info-wrap .read-wrap text {
font-size: 24rpx;
}
.files-detail .action-area {
margin-top: 40rpx;
}
.files-detail .action-area .action-btn {
height: 84rpx;
line-height: 84rpx;
margin: 0;
padding: 0;
font-size: 30rpx;
border-radius: 42rpx;
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
color: #fff;
border: none;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
}
.files-detail .action-area .action-btn::after {
border: none;
}
.files-detail .error-tip {
margin-top: 30rpx;
text-align: center;
color: #999;
font-size: 26rpx;
line-height: 1.6;
}
</style>

Some files were not shown because too many files have changed in this diff Show More