Compare commits

..

341 Commits

Author SHA1 Message Date
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
8901e302e4 style: 修复配置文件缩进格式不一致问题
统一调整配置文件内所有对象的缩进样式,移除多余的空格,修正代码格式对齐问题,提升代码可读性。
2026-08-17 14:43:54 +08:00
68bcd3ae7c Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797
# Conflicts:
#	.local.config.js
2026-08-17 14:41:25 +08:00
9f89172829 chore: 2026-08-17 08:48:14 +08:00
ddd1ec3056 chore(gitignore): 补充 Trae IDE 临时文件的忽略规则
新增匹配根目录下所有以 .trae_ 开头的临时文件和目录,完善git忽略配置
2026-08-15 17:32:16 +08:00
d305268cb8 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-08-15 17:29:52 +08:00
1efb866e41 style(config): 格式化本地配置文件的缩进与空格
1. 统一所有配置项的缩进风格为4空格
2. 修正多余的尾随和冗余空格
3. 添加新增的5g-1测试配置项
4. 调整注释掉的配置项格式
2026-08-15 17:29:01 +08:00
b8182a0f97 chore: 2026-08-15 14:31:01 +08:00
37463a173c chore:一级分类的不选中的样式后台可以自定义 2026-08-15 10:37:55 +08:00
422ca8cdb5 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797 2026-08-15 09:53:01 +08:00
f92b1fb5b7 chore: 2026-08-15 09:52:30 +08:00
a0043418c8 chore: 2026-08-15 09:46:12 +08:00
927f763d0a chore:后台可以自定义配置一级分类选中后的字体颜色和字体大小 2026-08-15 09:28:15 +08:00
69ad814985 Merge remote-tracking branch 'remotes/origin/dev/1.0' into PR-1-ai-agent-link 2026-08-15 09:27:58 +08:00
92599453c2 chore:竖版视频可以正常显示正常大小比例 2026-08-14 15:10:03 +08:00
5d7facc94d chore:竖版视频可以正常在容器里 2026-08-14 15:03:23 +08:00
f6eb1fad11 Merge branch 'dev/1.0' into PR-1-ai-agent-link 2026-08-14 14:03:49 +08:00
83b253bced chore:表单鸿蒙做更改 2026-08-14 11:21:05 +08:00
19cb332645 refactor(ai-page): 统一页面路由管理并修复AI智能体路径
1.  将硬编码的页面路径替换为PAGE_KEY_MAP常量统一管理
2.  修正AI智能体页面路径为/pages_tool/ai-agent/index
3.  优化客服跳转逻辑,避免重复注入tabBar隐藏标记
4.  为AI聊天页面添加自定义组件占位配置
2026-08-14 09:36:04 +08:00
e5406a27ba chore:苹果ios表单显示正常,商品详情的视频可以支持竖版横板视频 2026-08-14 09:09:07 +08:00
ffa170ff13 feat(ai): PR-7 前端 ai-service 按 agent_id 路由 endpoint
背景:
- 后端 aikefu 和 ai-agent 两个独立 addon (PR-6 v2.0)
- 前端 ai-chat 之前所有 API endpoint 都 hardcode 到 /api/kefu/* + /ws/aikefu
- 5 个 agent (智能客服默认 + 4 个 AI智能体) 实际上无法被前端独立路由

改动:
- 新增 resolveAddonName(agentId) -> 'aikefu' / 'aiagent'
- 新增 resolveApiUrl(apiName) -> '/api/kefu/X' / '/api/aiagent/X'
- 新增 resolveWsPath() -> '/ws/aikefu' / '/ws/aiagent'

- 全量替换 sendMessage / sendHttpStream / sendStreamMessage / generateConversationId /
  getServiceStatus / getServiceConfig / clearConversation / getConversationHistory /
  chatWithEventSource / WebSocket connect URL:
  全部用 resolveApiUrl / resolveWsPath 动态计算

- setAgentId 跨 addon 切换时自动 disconnectWebSocket (路径变了)
- 新增 getAddonName() / getApiPrefix() 测试辅助方法

后端配套 PR-8+9:
- /api/aiagent/api/Chat/chat (HTTP, 含流式 SSE)
- /api/aiagent/api/Chat/{info,health,getHistory,clearConversation,createConversation}
- /ws/aiagent WebSocket 端点 (从 aikefu WebSocket 复制改名)

路由规则:
- agent_aikefu -> aikefu addon (老路径, 默认智能客服)
- 其他 agent (knowledge / quick_reply / sales / hr) -> aiagent addon (新路径)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:30:00 +08:00
3b81ecdf98 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797 2026-08-13 14:11:20 +08:00
c536fac4d2 chore:视频显示布局正常 2026-08-13 11:15:57 +08:00
475449f04e chore:视频正常上下黑边删除 2026-08-13 10:32:35 +08:00
e40b46a888 chore:视频可以播放 2026-08-13 10:20:27 +08:00
8a95b0310d feat(ai-chat): 适配浮动按钮入口隐藏底部tabBar
实现浮动按钮跳转AI聊天页时自动隐藏底部tabBar,通过__dyu标记区分入口来源:
1. 浮动按钮点击时注入__dyu=FloatBtn来源标记
2. AI聊天页根据该标记临时隐藏tabBar并在卸载时恢复
3. 兼容旧store无对应mutation的场景
2026-08-12 18:18:19 +08:00
a81386d336 refactor(components): 统一多端textarea配置与高度计算逻辑
1.  对齐ns-form和ns-newform的textarea配置参数,新增textareaRows和textareaLineHeight配置
2.  移除平台分支判断,所有平台统一使用:rows+行高计算高度的方案
3.  适配鸿蒙ArkUI原生TextArea,通过:rows控制行数,JS动态计算高度
4.  移除textarea硬编码高度,改为通过formConfig动态控制
2026-08-12 15:47:11 +08:00
b69eeba74b fix(ns-form): 适配微信小程序和H5的textarea渲染逻辑
重构了多平台textarea的渲染条件,合并了平台判断逻辑,新增rows属性配置,优化了样式适配和代码可读性
2026-08-12 15:41:06 +08:00
f656be789d feat(ai-agent): 新增AI智能体通用支持框架
1.  新增多语言文案,适配AI智能体通用场景
2.  新增AI智能体页面路由常量与跳转逻辑
3.  改造AI聊天页面支持动态agent_id和页面标题
4.  升级AI服务层,支持多agent_id自动传递
2026-08-12 15:12:00 +08:00
82139e616d chore:后台国际化可以控制查看详情按钮 2026-08-10 14:18:24 +08:00
ed6d9f563d chore:后台设置二级分类,模板1显示“查看详情”按钮 2026-08-10 13:59:44 +08:00
3a8ad7d871 refactor(diy-recommend-code): 动态 style 全改用 $util.objToStyle 输出 px
问题:
- 后端 diyData 数值语义是 px(后端 wap/design.html 都按 px 输出),
  但前端 px2rpx() 之前 *2 转 rpx,导致商家设置 8px 实际渲染 16px
- 鸿蒙元服务对动态 style 中的 rpx 解析有问题

方案:
- px2rpx(val) 改为返回原值(当作 px 用)
- 17 处动态 :style="{...}" 全部改成 :style="$util.objToStyle({...})"
  用 objToStyle 工具方法生成标准 CSS 字符串,跨平台兼容
- 34 处 px2rpx() + 'rpx' 拼接改成 + 'px'
- 8 处 padding/border 中的 'rpx ' / 'rpx solid' 也同步改成 'px'

静态 'XXrpx' 字符串(写在模板里的)保留,uniapp 静态模板
支持 rpx,不影响。

后端保持原状不动(数值 px 语义,与 wap/design.html 一致)。
这样前后端语义统一,商家在 diyData 改数值时:
- H5/小程序: 静态 rpx 自适应屏幕
- 动态部分: 按 px 渲染,商家设计器里的 px 值精确生效
- 鸿蒙元服务: 全部用 px,无解析问题
2026-08-08 15:43:13 +08:00
ec960a29c4 fix(diy-recommend-code): 复制按钮 style 单位 rpx 改 px 适配鸿蒙元服务
之前复制按钮用 rpx 单位:
- border:2rpx solid #xxx
- font-size:24rpx
- height:60rpx / padding:0 32rpx / border-radius:30rpx / gap:8rpx

在 H5/微信小程序没问题,但鸿蒙元服务对动态拼接 style 中的 rpx
解析异常,渲染尺寸可能错乱。

修复: rpx 全改成 px(1rpx = 0.5px,即原数值 / 2):
- border:1px solid #xxx     (原 2rpx)
- font-size:12px            (原 24rpx)
- border-radius:15px        (原 30rpx)
- padding:0 16px            (原 0 32rpx)
- height:30px               (原 60rpx)
- gap:4px                   (原 8rpx)

horizontal 预设:
- border-radius:16px        (原 32rpx)
- padding:0 12px            (原 0 24rpx)
- height:32px               (原 64rpx)
- gap:3px                   (原 6rpx)

这样 4 处复制按钮 + 文字节点所有动态 style 数值都用 px,
鸿蒙元服务全平台兼容。
2026-08-08 15:01:52 +08:00
8312de5d09 refactor(diy-recommend-code): 复制按钮用 $util.objToStyle 生成 style
之前复制按钮用字符串拼接方式:
  :style="'...static...' + method() + '...static...'"

虽然字符串拼接全平台兼容,但写法繁琐,且 camelCase/kebab-case
容易写错(camelCase 用驼峰,字符串里要手写 - 转换)。

改成调用 common/js/util.js 中已有的 objToStyle 方法:
  :style="$util.objToStyle(copyBtnStyleObj(...))"

copyBtnStyleObj(preset, textOnly) 返回对象:
- 边框/背景/border-color: 跟随后端 diyData.codeColor
- 文字色/字号/字重: 跟随后端 codeColor(可选 textOnly=true 时)
- 其余布局属性: 由 preset 参数覆盖(4 个预设各自差异)

$util.objToStyle 自动 camelCase → kebab-case(如 alignItems → align-items)
转换后拼接为标准 CSS 字符串,全平台兼容。

好处:
- 写法更清晰(对象语法)
- 不用手写 kebab-case
- 与项目其他地方统一风格(common/js/util.js 已有此工具)
2026-08-08 14:57:39 +08:00
5833a244de refactor(diy-recommend-code): 复制按钮 style 改用字符串拼接兼容多端
之前 4 个预设(gradient/minimal/card/horizontal)的"复制推荐码"按钮
用 :style="{...}" 对象语法 + value.codeColor || '#xxx' 三元拼接。

对象语法在 H5 / 小程序正常,但在鸿蒙元服务等部分平台对嵌套对象/
三元拼接的支持有限,容易渲染异常。

修改:
- 新增 methods:
  copyBtnBorderStyle()  → 返回 '2rpx solid #xxx'
  copyBtnTextColor()    → 返回 '#xxx'
- 4 处复制按钮改成 :style="'...字符串...' + 方法() + '...字符串...'"
  风格,只把边框/文字色动态部分用方法返回,
  其余布局属性(display/border-radius/padding/gap 等)写成静态字符串。
- 字符串拼接方式在 H5 / 小程序 / APP / 鸿蒙元服务全平台表现一致

copyBtnBorderStyle() 与 copyBtnTextColor() 都从 diyData.codeColor 读取,
商家改 codeColor 时所有预设的复制按钮边框/文字色都跟随变化。
2026-08-08 14:51:57 +08:00
92fe95d131 feat(diy-recommend-code): 复制按钮颜色跟随后端 diyData.codeColor
之前 gradient 和 horizontal 预设的"复制推荐码"按钮边框/文字颜色
是写死的:
- gradient: #DC143C
- horizontal: #FF8F00

minimal 和 card 已经跟随后端 codeColor 字段。统一所有预设都跟随
codeColor,让商家可以在后端 diyData 调整颜色。

修复:
- gradient 复制按钮: 边框/文字改用 '2rpx solid ' + (value.codeColor || '#DC143C')
  文字色改用 value.codeColor || '#DC143C'
- horizontal 复制按钮: 边框/文字改用 '2rpx solid ' + (value.codeColor || '#FF8F00')
  文字色改用 value.codeColor || '#FF8F00'

这样 4 个预设风格统一,商家改 codeColor 时所有预设的复制按钮边框/
文字色都会跟随。
2026-08-08 14:47:52 +08:00
07718235c3 Revert "fix(diy-recommend-code): horizontal 预设"立即分享"颜色对齐后端 wap.html"
This reverts commit 183181a15d.
2026-08-08 14:42:04 +08:00
183181a15d fix(diy-recommend-code): horizontal 预设"立即分享"颜色对齐后端 wap.html
对比后端 PHP wap.html(第 174 行):
  background:linear-gradient(90deg,#FF9800 0%,#FF4444 100%);
  box-shadow:0 2px 6px rgba(255,68,68,0.25);

前端 horizontal 预设的"立即分享"按钮之前用黄橙渐变
  linear-gradient(90deg,#FFC107 0%,#FF8F00 100%)

不一致。改为后端的橙红渐变:
  linear-gradient(90deg,#FF9800 0%,#FF4444 100%);
  box-shadow 也改为 rgba(255,68,68,0.25)

影响两个分支:
- 小程序 button 路径(button path 行 298)
- H5 view 路径(view path 行 303)

其他 3 个预设(gradient/minimal/card)颜色与后端已对齐,无需调整。
2026-08-08 14:38:29 +08:00
a4f2c81528 fix(diy-recommend-code): 海报背景 + 分享链接绘制
问题 1: 海报整张画成蓝色渐变
原因: _drawPosterBg 用 diyData.bgColorStart/End 当整个画布背景,
但这两个字段实际是给"卡片"用的渐变色,不是给整张海报用的。

修复: _drawPosterBg 只用 bgColor(浅色)填充整张画布;
bgColorStart/End 留给卡片本身(推荐码区)的样式。

问题 2: 后端 showLink=true 时没有绘制分享链接

修复: 在标题副标题后,推荐码区前加一行分享链接
(读 v.shareLink || this.shareLink,截断 36 字符)。
showLink=false 时不画,blockY 保持 88;
showLink=true 时画一行,blockY 下移到 104,推荐码区跟着下移
避免重叠。

drawQrcodeAndExport 的 qrY = blockY + blockH + 24 自动跟随,
底部提示也跟随,整张布局保持完整。
2026-08-08 14:05:11 +08:00
30b66ad7b3 feat(diy-recommend-code): 海报颜色布局改为读取 diyData 动态适配
之前 generateSharePoster 颜色和布局都是 hardcode(米黄区、
黄橙渐变、FFC107 等)。后端改 stylePreset 后分享图不会跟随变化。

修改: 抽出 palette 对象,所有视觉参数都从 diyData.value 读:
- bgColor / bgColorStart / bgColorEnd / borderColor
- borderRadius / padding
- titleColor / titleFontSize
- codeColor / codeFontSize
- accentColor / textColor

新增方法:
- _drawPosterBg(ctx, W, H, palette)
  优先用 createLinearGradient 画 bgColorStart→bgColorEnd 渐变,
  旧 canvas 不支持则 fallback 到 bgColor 单色
- _drawPosterCard(ctx, W, H, palette, stylePreset)
  按 stylePreset 区分装饰:minimal/card/horizontal 右上角加 🎁 emoji
  gradient 不加装饰

后续如需更多差异,只在这两个方法里扩展即可,不影响主流程。
2026-08-08 13:58:55 +08:00
077fac40c2 fix(diy-recommend-code): 兼容 setFontWeight 旧 canvas API
用户日志: TypeError: c.setFontWeight is not a function
原因: setFontWeight 是微信小程序新版 type=2d canvas 专有 API,
旧版不支持。运行时 ctx.setFontWeight 不存在,调用报错。

修复: 先用 typeof 检查,存在则调用;不存在则用
ctx.font = 'bold 36px sans-serif' 字符串设置字重(旧 API 支持)。
2026-08-08 12:03:46 +08:00
10d45b1c3b fix(diy-recommend-code): generateSharePoster 等方法移到 methods 块
问题: 用户日志显示 "TypeError: this.generateSharePoster is not a function"。
原因: generateSharePoster / _exportCanvas / _roundRect / _downloadImage
等方法之前被定义在 methods 块外的 export default 对象上(与
onShareAppMessage 这种生命周期钩子同级),Vue 不会自动绑定它们到
组件实例,导致 this.xxx 找不到。

修复:
- 删除 methods 块外的那 5 个方法的旧定义
- 重新加到 methods 块内(放在 goLogin 后,methods 块关闭 } 之前)
- onShareAppMessage / onShareTimeline 是小程序生命周期钩子,
  必须留在 export default 对象上,不能进 methods(保留不动)

验证:
- H5 测试 isH5Platform=true,提前 return 不进入 generateSharePoster,正常
- 小程序下 this.generateSharePoster 现在能找到方法
- loadCode 成功后 _prepareSharePoster 调用正常
2026-08-08 12:02:11 +08:00
60dc054383 fix(diy-recommend-code): canvas 海报生成加详细调试日志 + ctx.draw 包裹
问题: 小程序下 generateSharePoster "开始调" 后无 "resolved",
卡在 canvas 绘制或导出步骤。

修复 + 调试:

1. generateSharePoster 流程加详细日志:
   - 进入
   - 准备 createCanvasContext
   - createCanvasContext 返回值
   - qrcodeRenderUrl 当前值
   - drawQrcodeAndExport 进入
   - ctx.draw(false, cb) 准备
   - ctx.draw callback 触发

2. 关键修复: ctx.draw(false, callback) 包裹导出
   - 旧代码:画完 fill() 后直接调 _exportCanvas(canvas 还是空的)
   - 新代码:画完后 ctx.draw(false, () => _exportCanvas())
     在 callback 里导出真正渲染完的 canvas

3. _exportCanvas 简化:
   - 不再走 createSelectorQuery(可能挂起)
   - 直接用 canvasId 调 canvasToTempFilePath
   - 加 5s 超时兜底,避免永远卡住

4. _prepareSharePoster 加 isH5Platform 短路:
   - H5 下 canvas 模板不渲染,createCanvasContext 会失败
   - 短路后不调用 generateSharePoster,避免无效报错

请在微信开发者工具里再次测试,看哪一行日志卡住
(预期: 进入 → createCanvasContext 返回 → qrcodeRenderUrl →
drawQrcodeAndExport → ctx.draw 准备 → ctx.draw callback → _exportCanvas → 成功)
,把卡住那一行的日志贴出来。
2026-08-08 11:52:57 +08:00
ef8c7568ac chore(diy-recommend-code): 海报预生成流程加调试日志
增加 _prepareSharePoster 流程日志:
- _prepareSharePoster 被调用 + isH5Platform/code
- 开始调 generateSharePoster
- generateSharePoster resolved + posterPath
- 写 storage 完成

方便在微信开发者工具/真机控制台调试为什么没看到
[推荐码分享]海报预生成完成 日志,定位是 _prepareSharePoster
没被调用、generateSharePoster 抛错、还是写 storage 失败。
2026-08-08 11:48:55 +08:00
551d6c103d fix(diy-recommend-code): 修复小程序分享图仍为全屏截图
之前的方案是用户点"立即分享"按钮时,onShareClick 异步调
generateSharePoster() 生成海报后写 storage。但这有问题:

微信小程序 <button open-type="share"> 流程:
1. 用户点 button → 同步触发页面级 onShareAppMessage
2. onShareAppMessage 同步返回 shareData(必须含 imageUrl)
3. 微信用这个 imageUrl 展示分享面板

onShareClick 的异步 generateSharePoster 永远来不及,onShareAppMessage
读取 storage 时海报还没生成,所以微信用全屏截图。

修复:预生成海报。
- 在 loadCode 成功回调里,推荐码拿到后立即调 _prepareSharePoster()
  异步生成海报,生成完写入 _recommendShareData storage
- loadQrcodeIfNeeded 成功回调里也再调一次,确保带二维码海报覆盖
  不带二维码的占位版

_prepareSharePoster():
- #ifdef MP-WEIXIN 包住,仅小程序执行
- 调 generateSharePoster 拿 tempFilePath
- 写入 _recommendSharePoster 缓存 + _recommendShareData 当前值
- 用户点分享时,storage 已经有最新海报,onShareAppMessage
  读取后返回 imageUrl,微信用这个图展示分享面板
2026-08-08 11:46:23 +08:00
4a92d9bec2 feat(diy-recommend-code): 微信小程序分享海报自动生成(仅推荐码组件)
之前微信小程序点击"立即分享"会触发 onShareAppMessage,
默认 imageUrl 用 diyData.shareImageUrl(为空时微信截全页)。
问题是分享图片是整张页面,而不是推荐码组件本身。

新增 generateSharePoster():用 uni.canvas 绘制一张仅含推荐码组件内容
的 375x480 海报:
- 白底 + 顶部标题
- 米黄背景区 + 推荐码大字
- 二维码(如有 qrcodeRenderUrl)
- 底部提示文字

并修改 onShareAppMessage:
- 小程序环境(!isH5Platform):返回 Promise,等待 canvas 生成完成后
  用临时文件路径作 imageUrl
- 失败回退到 diyData.shareImageUrl
- H5 环境:保持原逻辑(不走 canvas)

新增辅助方法:
- _exportCanvas():canvas 转临时文件路径(支持 type=2d 和旧版 API)
- _roundRect():绘制圆角矩形路径(canvas 2d 不直接支持 roundRect 时的兜底)
- _downloadImage():网络图片下载到临时路径(uni.downloadFile Promise 化)

模板:
- 在 root view 内加一个隐藏 canvas (canvas-id="sharePosterCanvas"),
  仅小程序环境渲染,固定在视口外 left:-9999px top:-9999px
2026-08-08 11:39:25 +08:00
ff4340c3a9 style(diy-recommend-code): horizontal 预设按钮行两端对称对齐
horizontal 预设的"复制推荐码"和"立即分享"按钮之前是错落布局
(align-items:flex-end),且两端外边距不一致(padding 不统一)。
改为两端对称对齐:

- 父级 view:
  align-items:center (垂直居中,不再错落)
  padding:0 8rpx (两端外边距对称)
- 复制按钮:
  display:inline-flex (按内容自适应宽度)
  border:2rpx solid #FF8F00 (固定橙色边框,不读 diyData.codeColor)
- 立即分享按钮 (button path):
  position:relative; left:0; right:0; margin:0; padding:0
  (修复小程序 button 默认偏移问题,与其他预设 button path 一致)

效果:
- H5: 复制按钮左边缘距卡片左边 = 分享按钮右边缘距卡片右边 (各 8rpx)
- 小程序: 立即分享按钮不再向左偏移,与复制按钮保持视觉对称
- 两个按钮高度统一,垂直居中对齐
2026-08-08 11:29:04 +08:00
4303a93278 style(diy-recommend-code): card 预设同步采用 100% 宽 + 居中结构
card 预设的"立即分享"按钮之前是 inline-block + padding 自适应宽度,
与其他预设不一致。改为和 gradient / minimal 预设一致的"双层结构":

- button path (小程序/APP):
  外层 button: position:relative; left:0; right:0;
  margin:16rpx 0 0 0; width:100%; display:block; box-sizing:border-box;
  内层 view: display:block; width:100%; height:76rpx;
  background:#FFFFFF; border:2rpx solid #FF6B6B; border-radius:38rpx;
  text-align:center;

- view path (H5):
  外层 view: margin-top:16rpx; width:100%;
  内层 view: display:block; width:100%; height:76rpx;
  同上样式

card 风格保持"白底红边框"设计,不读 diyData.buttonBgColor。

效果:
- 按钮 100% 卡片宽,与 gradient / minimal 一致
- 小程序 button 默认偏移问题(position:relative + left:0 + right:0)同步修复
- 商家在 4 个预设下看到一致的全宽分享按钮布局
2026-08-08 10:35:12 +08:00
12e4493a17 style(diy-recommend-code): minimal 预设同步采用 100% 宽 + 居中结构
minimal 预设的"立即分享"按钮之前是 inline-block + padding 自适应宽度,
与其他预设不一致。改为和 gradient 预设一致的"双层结构":

- button path (小程序/APP):
  外层 button: position:relative; left:0; right:0;
  margin:16rpx 0 0 0; width:100%; display:block; box-sizing:border-box;
  内层 view: display:block; width:100%; height:76rpx;
  background:linear-gradient(90deg,#FF6B6B 0%,#FF4444 100%);
  border-radius:38rpx; box-shadow; text-align:center;

- view path (H5):
  外层 view: margin-top:16rpx; width:100%;
  内层 view: display:block; width:100%; height:76rpx;
  同上渐变样式

效果:
- 按钮 100% 卡片宽,与 gradient 一致
- 小程序 button 默认偏移问题(position:relative + left:0 + right:0)同步修复
- 商家在 4 个预设下看到一致的全宽分享按钮布局
2026-08-08 10:29:31 +08:00
c94e214d05 style(diy-recommend-code): 优化分享按钮布局样式
调整分享按钮的内外层元素排版,统一宽高对齐方式,修复按钮展示偏移问题,提升多端视觉一致性
2026-08-08 10:11:14 +08:00
afede79047 fix(diy-recommend-code): 小程序 button 路径同步采用双层居中结构
之前 H5 路径(v-else-if 走 view)已修复为双层结构
(外层 text-align:center + 内层 inline-block),但小程序/APP
路径仍保留旧的「display:flex + width:100% + height:80rpx +
box-shadow」组合,在 webview 中可能存在同样问题。

为了让小程序下居中也稳定,4 个预设的 <button open-type="share">
也改为双层结构:
- 外层 button: text-align:center;background:transparent;
  border:none;padding:0;line-height:normal;
  保留 open-type="share" 让微信小程序原生分享能力依然有效
- 内层 view: display:inline-block; 自带 padding+渐变+阴影样式

horizontal 预设的 button (行 298) 是水平按钮行右侧窄按钮,
额外加 display:inline-block 让其按内容撑开(不被推到 100% 宽)。

这样:
- H5: view fallback(text-align:center)→ 居中
- 小程序: button + 内联 view(双层结构)→ 居中
- APP: 与小程序走同一份代码 → 居中

注意:本次修改的 HTML 结构对小程序的 onShareAppMessage 钩子
无影响(uniapp 仍按 v-if 条件编译 + open-type="share" 自动桥接)。
2026-08-08 09:36:57 +08:00
ab727eef1d fix(diy-recommend-code): H5 立即分享按钮居中问题修复
之前 H5 平台下 view fallback 的样式:
  display:flex; justify-content:center; align-items:center;
  width:100%; height:80rpx; ...
在某些情况下未生效(可能与 box-shadow / border-radius / box-sizing
互动有关),导致按钮在 H5 中贴左显示。

修复方案:改用经典的双层结构:
- 外层 view: text-align:center; (行内元素自动居中)
- 内层 view: display:inline-block; width 按 padding 自适应
这种结构 uniapp H5 / 小程序 / APP 各端都稳定支持。

修改点:
- gradient 预设(L119): 黄橙渐变胶囊
- minimal 预设(L187): 红橙渐变胶囊
- card 预设(L238): 白底红边胶囊
- horizontal 预设(L295): 不变(靠右浮动按钮,已有 flex-end 对齐)
- gradient 复制按钮父级(L106): 同步改 text-align:center + inline-flex
  (修复 H5 下 fit-content + flex 在某些机型失效导致按钮偏左)

影响:仅修改 H5 平台的 view fallback 样式(实际渲染层),小程序/APP
端 button 路径未改动。
2026-08-08 09:19:23 +08:00
ee050f5df9 chore(.gitignore): 归档本地测试文件到 _test_archive/
将之前调试时遗留的 43 个测试脚本/截图/对比图/草稿文件
移动到 _test_archive/ 子目录,并加 .gitignore 规则使其不入仓。

规则:
- _test_archive/ : 整个归档目录
- _test_*.png / _test_*.html : 根目录兜底
- _compare*.png / _compare*.py
- _auth_*.json
- _commit_msg.txt
- /check_button.py

_test_v6_clean.py 同时修改,所有截图路径改为 OUT_DIR = __file__ 目录,
保证未来运行测试时产物仍归档到 _test_archive/ 而不是污染根目录。
2026-08-07 18:28:12 +08:00
855fbb2fe5 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-08-07 18:24:13 +08:00
b706e67e82 chore(gitignore): add test archive directory to gitignore
新增本地测试脚本与产物归档的git忽略规则,避免将测试临时文件提交到公共仓库
2026-08-07 18:24:08 +08:00
557addb76b Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into Branch_24cb3a09 2026-08-07 18:14:15 +08:00
dacc25b281 chore:微信小程序推荐码登录按钮正常弹出手机号授权 2026-08-07 18:13:35 +08:00
ec4fb3e688 feat(diy-recommend-code): 二维码容器支持尺寸/边框/背景/圆角/内边距可配
新增 6 个 props 字段:
- qrSize: 数字,默认 200,二维码图片宽高(rpx)
- qrBoxBorderColor: 颜色,默认 '',容器边框色(空=按预设默认)
- qrBoxBorderWidth: 数字,默认 0,边框宽度(rpx,0=按预设默认)
- qrBoxBorderRadius: 数字,默认 0,容器圆角(rpx)
- qrBoxBackground: 颜色,默认 '',容器背景色(空=透明)
- qrBoxPadding: 数字,默认 0,容器内边距(rpx)

修改点:4 个预设(gradient/minimal/card/horizontal)的 showQR 块
不再用硬编码 <image width=200rpx>,改为:
  <view style="<动态 width/height/border/borderRadius/bg/padding>">
    <image style="width:100%;height:100%">
  </view>
保证缩放、边框、背景、圆角、内边距都能响应 props 字段。

后端同步:design.html / wap.html 新增 nc.qrSize / nc.qrBoxBorderColor /
  nc.qrBoxBorderWidth / nc.qrBoxBorderRadius / nc.qrBoxBackground /
  nc.qrBoxPadding 字段并应用。

注:单位上,前端 rpx;后端 px。两者各自适配,与现有 fontSize/
  borderRadius/titleFontSize 等字段命名一致。
2026-08-07 18:06:25 +08:00
75e40c752b feat(diy-recommend-code): 复制推荐码按钮支持显示/文案自定义
新增 props 字段:
- showCopyButton: 布尔,默认 true,控制"复制推荐码"按钮显示
- copyButtonText: 字符串,默认 "复制推荐码",自定义按钮文案

修改点:
- props default: 新增 showCopyButton: true、copyButtonText: '复制推荐码';
                  移除未使用的 placeholderCopyText、placeholderShareText、
                  showButton,统一用 showShareButton 名称
- 4 个预设(gradient/minimal/card/horizontal)的"复制推荐码"按钮
                  包裹层加 v-if="value.showCopyButton !== false"
                  按钮文案改为 {{ nc.copyButtonText || '复制推荐码' }}

后端同步: design.html / wap.html 同步加 nc.showCopyButton / nc.copyButtonText 字段。
2026-08-07 17:53:26 +08:00
24df6f0dd5 chore(diy-recommend-code): 同步 titleColor 默认值与后端 design.html 对齐
后端 design.html 的 titleColor fallback 是 '#1A1A1A',4 个预设都使用这个值。
前端 props default 之前是 '#333333',导致 DIY 后台预览(design.html)和
H5 实际渲染(前端组件)标题颜色不一致。

修复:前端 titleColor 改为 '#1A1A1A',与后端对齐。

注意:前端 H5 渲染的样式仍然优先读取 props.value.titleColor(来自后端 diyData),
只有当后端没有传 titleColor 时才落到这个默认值,所以对线上渲染无破坏。
2026-08-07 17:40:44 +08:00
a8ab093eae fix(diy-recommend-code): Minimal 风格立即分享按钮改红橙渐变胶囊
之前后端 diyData.buttonBgColor 默认 #E4393C 覆盖了 Minimal 风格的
linear-gradient(90deg,#FF6B6B 0%,#FF4444 100%) 默认值,导致「立即分享」
按钮显示为红色实心,与设计参考图不一致。

调整内容:
- 立即分享按钮固定走红橙渐变 linear-gradient(90deg,#FF6B6B 0%,#FF4444 100%)
  不再读 diyData.buttonBgColor(与 Card/Gradient/Horizontal 风格保持统一)
- H5 端 view fallback 同步(与已有 H5 风格统一处理)

影响范围:仅 Minimal 极简风格(stylePreset === 'minimal')。
2026-08-07 17:28:15 +08:00
7f4b1d6d13 fix(diy-recommend-code): Horizontal 风格立即分享按钮改黄橙渐变胶囊
之前后端 diyData.buttonBgColor 默认 #E4393C 覆盖了 Horizontal 风格的
`linear-gradient(90deg,#FF9800 0%,#FF4444 100%)` 默认值,导致「立即分享」
按钮显示为红色实心,与设计参考图不一致。

调整内容:
- 立即分享按钮固定走 linear-gradient(90deg,#FFC107 0%,#FF8F00 100%)
  不再读 diyData.buttonBgColor(与 Gradient 风格保持统一)
- 阴影色从 rgba(255,68,68,...) 改为 rgba(255,143,0,...) 与渐变色一致
- H5 端 view fallback 同步(与 Card / Gradient 风格统一处理)

影响范围:仅 Horizontal 横向风格(stylePreset === 'horizontal')。
2026-08-07 17:24:31 +08:00
1420b7435e fix(diy-recommend-code): Gradient 风格立即分享按钮改胶囊形 + 黄橙渐变
之前后端 diyData.buttonBgColor 默认 #E4393C 会覆盖 Gradient 风格的
linear-gradient 默认值,导致「立即分享」按钮显示为红色实心。

调整内容:
- 复制按钮顶部距 12rpx → 16rpx(更均匀)
- 立即分享按钮 marginTop 20rpx → 16rpx(紧凑布局)
- 立即分享按钮圆角 16rpx → 40rpx(胶囊形)
- 立即分享按钮固定走 linear-gradient(90deg,#FFC107 0%,#FF8F00 100%),
  不再读 diyData.buttonBgColor(避免被后端默认色覆盖)
- H5 端 view fallback 同步(与 Card 风格统一处理)

影响范围:仅 Gradient 渐变风格(stylePreset === 'gradient')。
2026-08-07 17:21:12 +08:00
8b331e53d3 fix(diy-recommend-code): Card 风格立即分享按钮固定「白底红边框」
之前 Button 背景色由 diyData.buttonBgColor 决定,后端默认 #E4393C 导致
Card 风格的「立即分享」按钮显示为红色实心,与设计参考图不一致。

调整内容:
- props default 中 buttonBgColor 默认值改为空字符串
- 新增 isH5Platform 状态(条件编译 H5 段在 created 中标记)
- H5 端用 view 替代 button 触发分享,避开浏览器 button 默认样式
- 小程序/APP 端保留 button 以触发 open-type="share"
- Card 预设的「立即分享」按钮固定为白底 + 红边 + 红字设计

影响范围:仅 Card 卡片风格(stylePreset === 'card')。
2026-08-07 17:16:52 +08:00
10fd785b4c refactor(formdata): 适配H5端保留原生导航栏
调整自绘导航栏逻辑,仅在非H5端渲染自定义导航栏,补充H5端相关注释说明,修正样式代码位置
2026-08-07 16:45:14 +08:00
2c0b8ed076 docs(formdata): add notes about show_home btn effective scope
补充说明show_home参数仅在custom导航栏端生效,H5端忽略该参数
2026-08-07 16:25:19 +08:00
ab8625ebaf feat(form): add custom navigation bar for form page across multiple platforms
适配APP、微信小程序、华为快应用和鸿蒙元服务,新增自定义导航栏,支持返回和首页按钮,修正多端导航栏适配逻辑
2026-08-07 16:22:50 +08:00
bbef0ecf55 feat(util): add coerceBool function for parsing URL query booleans
添加专门用于处理URL查询参数的布尔值转换函数,解决直接使用Boolean转换字符串'false'、'0'时不符合预期的问题,兼容多种合法的布尔值参数写法。
2026-08-07 16:09:02 +08:00
671c350d95 feat(diy-recommend-code): 为推荐码组件添加可定制化样式配置
1.  支持自定义标题字体大小、颜色,推荐码字体大小和颜色
2.  支持自定义卡片圆角、内边距
3.  支持自定义分享按钮背景色、文字颜色
4.  新增分享按钮显示控制、占位推荐码和换码文案配置
5.  统一将硬编码样式替换为动态绑定的可配置样式
2026-08-07 15:00:52 +08:00
2d19a653c9 refactor(form): remove unused back home button and related code
移除了表单页面中不再使用的返回主页按钮、对应的国际化文案和重置表单方法,清理冗余代码
2026-08-07 14:36:28 +08:00
5f60504c75 chore: 2026-08-07 11:11:38 +08:00
289e582ba1 refactor(form): 调整代码缩进与格式,优化提交逻辑
统一模板缩进为制表符,移除冗余日志,将提交数据内联至请求参数以精简代码结构
2026-08-07 10:35:26 +08:00
c26280a4d3 refactor(form): 重构提交逻辑以支持跨端统一接管
diy-form 组件将提交事件机制改为函数 prop (onSubmit) 接管,避免 $listeners 在 MP 端不可用的问题。
formdata 页面使用 bind 预绑定 this,防止子组件调用时上下文漂移。
新增防重复提交标记复位逻辑,优化网络失败时的重试体验。
2026-08-07 10:31:57 +08:00
619ca3a236 chore:微信小程序可以分享给好友表单 2026-08-06 16:52:41 +08:00
c35d0e2505 chore:微信小程序分享给好友和微信分享功能一样 2026-08-06 16:31:59 +08:00
a6e9176dbf chore:推荐码4 种样式 2026-08-06 15:03:29 +08:00
f4517be6c3 chore :推荐码样式123 2026-08-06 14:03:24 +08:00
408c40fbe9 chore:修改同意并获取推荐码页面 2026-08-06 09:57:56 +08:00
b46dc248d5 chore:去登录按钮和上面登录注册一样跳转 2026-08-06 09:39:22 +08:00
1fb2b470da chore:同意按钮改颜色 2026-08-06 09:07:57 +08:00
81ff205d2c chore:我的页面登录后显示同意获取推荐码 2026-08-06 08:56:50 +08:00
1b3dd41a4b chore:样式4正常 2026-08-05 17:17:45 +08:00
670d3f99fc Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into Branch_24cb3a09
# Conflicts:
#	.local.config.js
2026-08-05 15:50:36 +08:00
eb2839eeb8 chore:微信小程序分享链接 2026-08-05 15:48:05 +08:00
b0f7c7a15c chore:样式134推荐码居中 2026-08-05 15:02:50 +08:00
f1487e60bb chore: 2026-08-05 14:23:29 +08:00
74c24d084e chore:推荐码样式2立即分享按钮和推荐码对齐 2026-08-05 14:15:27 +08:00
5e8a1a48dd chore:删除棕色小圆点 2026-08-05 14:05:58 +08:00
66deecdcf8 chore:推荐码4可以正常显示 2026-08-05 11:48:34 +08:00
37d72381c7 chore:推荐码2的立即分享按钮正常 2026-08-05 11:09:20 +08:00
be40bb19b3 fix(ns-form): 修复微信小程序textarea条件编译缩进错误
调整了微信小程序和其他平台textarea的条件编译代码缩进,修正了模板语法嵌套错误,确保编译逻辑正确执行
2026-08-05 11:07:43 +08:00
157a7b4e16 fix(ns-form): 修复微信小程序textarea显示和滚动问题
针对微信小程序原生textarea,改用固定高度176rpx替代auto-height,解决无限扩展遮挡组件的问题,同时优化滚动条拇指比例,提升交互体验
2026-08-05 11:01:04 +08:00
9d187bb8a4 chore:分支9 切换的新分支,把4个推荐码样式改了,但不完全可以 2026-08-05 09:57:09 +08:00
d4326bc827 chore(config): add local 192.168.197.1 config and switch default env
1. 新增192.168.197.1:8050的本地开发配置项
2. 调整默认启用的环境配置为local-1
3. 修复package.json缩进格式并新增对应局域网调试的H5打包配置
2026-08-04 16:19:19 +08:00
24cb3a096d chore:正常运行 2026-08-04 14:20:37 +08:00
1610f3f75d chore:可以复制链接自动更新 2026-08-04 11:18:10 +08:00
837bfbfd77 chore:链接可以复制 2026-08-04 10:21:37 +08:00
e24a6b25f2 chore:邀请码可以显示 2026-08-03 17:36:50 +08:00
318ed4028e chore 2026-08-03 16:29:01 +08:00
1cda06573e refactor: 移除旧的邀请码来源追踪逻辑
1. 删除App.vue中全局邀请码持久化逻辑与相关工具函数
2. 简化http请求的方法与请求头处理逻辑
3. 移除diy-form组件内的邀请码暂存、提交与展示逻辑
4. 清理不再使用的工具类邀请码辅助函数
2026-08-03 16:24:18 +08:00
be1e175471 chore: 2026-08-03 15:34:43 +08:00
0598f97efe chore:修改了会员中心页面弹出【网络异常,请稍后重试】,无法获取推荐码。 2026-08-03 14:55:36 +08:00
cd91ff9d49 chore:邀请码初步更改 2026-08-03 14:19:21 +08:00
e01ba29604 fix(鸿蒙兼容): 修复商品详情页骨架屏常驻问题
1. 兼容后端不返回goods_promotion字段的场景,避免直接取值抛错
2. 替换鸿蒙元服务下不可靠的nextTick和延迟关闭逻辑,改为同步关闭骨架屏,解决数据返回后骨架屏常驻的问题
2026-07-31 16:52:16 +08:00
ca60948fcd chore:原来的购物车+收藏按钮隐藏 2026-07-31 16:19:45 +08:00
beaa330651 fix(forms): 修复单选多选组布局样式兼容问题
1. 为ns-newform和ns-form的单选/多选组添加内联样式属性`_optionLayoutStyle`
2. 预计算垂直/流式布局的flex样式,解决跨端渲染兼容问题
3. 调整本地配置文件的启用项注释
2026-07-31 13:55:49 +08:00
a891e4abf9 feat(ns-form,ns-newform): add option layout configuration for radio/checkbox groups
add vertical and wrap layout options for form option groups, support passing option_layout field in form items to control arrangement style, default is wrap auto wrap layout
2026-07-31 11:39:24 +08:00
de6a76e91b feat(form): 统一表单组件样式并新增配置项
1.  新增标签字重、自动宽度上限、输入框边框/背景色等配置项
2.  统一ns-form、ns-newform和diy-form的输入框样式与默认值
3.  优化标签宽度计算逻辑,添加自动宽度上限防止布局溢出
4.  适配多端输入框样式,添加聚焦态交互效果
2026-07-31 11:10:41 +08:00
eed766366d fix(ns-form,ns-newform): 优化文本输入框默认值处理逻辑
针对Text和Textarea组件,当placeholder为空字符串时不再自动设置默认空值,保留用户输入的空状态
2026-07-31 10:19:47 +08:00
ed944ee192 chore(mp-harmony): vue.config.js IIFE 加 UNI_PLATFORM 判断, 只在鸿蒙场景跑
背景:
  之前 vue.config.js 顶层 IIFE 在任何 require 场景都会跑 fixSubpackageModuleNames
  + runSyncCheck, 不区分平台。导致 mp-weixin / h5 编译时也会修改 5 个
  mp-harmony 子包的 .json5 文件, 污染 git status (虽然不是破坏性修改,
  但用户看不到为什么改了这些文件)。

改动:
  vue.config.js 顶层 IIFE 入口加平台判断:
    if (process.env.UNI_PLATFORM !== 'mp-harmony') {
      return;  // 非鸿蒙场景直接跳过
    }
  UNI_PLATFORM === 'mp-harmony' 是 uniapp 编译器在编译鸿蒙时设置的环境变量,
  HBuilderX 选 "发行 → 鸿蒙元服务" 会设此变量, 编译 mp-weixin / h5 时不会设。

  顺便清理了一个 dead code 块:
    if (... && __TEST_GATE_BLOCKED__) return;
  搜索全文件, __TEST_GATE_BLOCKED__ 没有任何地方被设置, 这个判断永远不成立。

端到端验证 (3 个测试场景):
  1. UNI_PLATFORM=mp-weixin → 修子包逻辑跳过, name 保持 ""
  2. UNI_PLATFORM=mp-harmony → 修子包 + 跑 sync check, 5 个 name 恢复正确
  3. UNI_PLATFORM 不设 (默认) → 修子包逻辑跳过, name 保持 ""

影响:
  - HBuilderX "发行 → 鸿蒙元服务" 行为不变 (修复 + sync check 仍自动跑)
  - HBuilderX "发行 → 微信小程序" / "运行 → H5" 行为更干净
    (不会无意义改 mp-harmony 子包文件)
  - 命令行 vue-cli-service build 同理, 避免 mp-weixin / h5 编译后
    git status 出现误改提示
2026-07-31 09:52:34 +08:00
270f5f4eff build(mp-harmony): 仅在鸿蒙元服务场景执行子包模块名修复
避免在微信小程序、H5编译时无意义修改鸿蒙平台的子包文件,防止污染编译git状态
2026-07-31 09:50:46 +08:00
0cb5c1a4df chore(mp-harmony): vue.config.js 顶层 IIFE 集成 runSyncCheck (4 处一致性自动校验)
背景:
  之前 vue.config.js 顶层 IIFE 只调 fixSubpackageModuleNames 修 5 个子包 name,
  但漏改 build-profile.json5 / entry/oh-package.json5 / ascf_build/ 目录时
  没有任何提示, 用户在 HBuilderX 看不到错误, 直到 DevEco ohpm 报错才发现。

改动:
  1. scripts/fix-subpackage-module-names.js 新增 runSyncCheck() 函数:
     - 读取 har-deps-required.json5 (whitelist) 作为单一事实源 (优先 json5 包,
       失败回退手写解析)
     - 扫描 3 个地方的一致性: build-profile.json5 modules / entry/oh-package.json5
       file: 依赖 / ascf_build/ 实际目录
     - 不一致时 console 打印明确错误 + 修复步骤 (1, 2, 3, 4 个文件)
     - 默认不抛错 (silent: false 仅 console 输出, exitOnError: false)
     - 暴露为 module.exports.runSyncCheck

  2. vue.config.js 顶层 IIFE 集成:
     - 修完 name 后调 runSyncCheck()
     - HBuilderX 触发 require vue.config.js 时立即执行
     - 不阻塞 vue-cli-service, 但 console 输出立刻告知用户漏改位置
     - 鸿蒙端的硬性错误 (ohpm "Field IS Empty") 仍由 DevEco 抛出

  端到端验证:
    1. 故意在 whitelist 加 pages_extra (未同步其他 3 处)
       → vue.config.js require 立即输出:
         ✘ build-profile.json5 缺失子包: [pages_extra]
         ✘ entry/oh-package.json5 缺失 file: 依赖: [pages_extra]
         ✘ ascf_build/ 目录缺失子包: [pages_extra]
         修复: 1. entry/har-deps-required.json5 ... 2. mp-harmony/build-profile.json5 ...
               3. entry/oh-package.json5 ... 4. ascf_build/<pkg>/ 实际目录
    2. 故意清空 pages_tool 的 name 字段
       → 修成 "pages_tool" + 跑 sync check, 输出 ✓ 5 个子包配置完全一致

  module.exports 仍只有白名单字段 (productionSourceMap, configureWebpack),
  vue-cli-service 不会报 "Invalid options" 错。

依赖: 项目已有 json5 包, 无需新装
2026-07-31 08:54:30 +08:00
4f7f4f5117 build: add localhost:8050 development build configuration
新增本地开发环境8050端口的构建配置,包含UNI_PLATFORM和UNI_APP_DOMAIN环境变量以及生产模式定义
2026-07-31 08:52:21 +08:00
51274b70ce feat(form): add url params透传和member_id上报功能
1. 为form和formdata页面新增通用URL参数透传逻辑,过滤保留字段后收集参数
2. 处理scene参数中的额外透传参数
3. 提交请求时携带url_params和当前会员ID
4. 修复代码格式问题
2026-07-31 08:48:23 +08:00
9d8324bb44 chore(mp-harmony): 新增子包配置一致性检查脚本 (防新增/删除漏改)
背景:
  mp-harmony 项目的子包 HAR 配置分散在 4 个地方:
    1. entry/har-deps-required.json5 的 requiredHarDeps (单一事实源)
    2. mp-harmony/build-profile.json5 的 modules 数组
    3. entry/oh-package.json5 的 dependencies
    4. ascf_build 实际目录

  新增/删除子包时, 任何一处忘改都会导致鸿蒙构建失败:
    - build-profile.json5 没加 → hvigor 不构建该模块
    - entry/oh-package.json5 没加 file: → ohpm 不 link 该 HAR
    - ascf_build/ 目录不存在 → ohpm "Field IS Empty Error"
    - har-deps-required.json5 没加 → check-har-deps 不会检查

改动:
  新增 scripts/check-subpackage-sync.js:
    - 读取 entry/har-deps-required.json5 作为单一事实源
    - 扫描 build-profile.json5 的 modules / entry/oh-package.json5
      的 file: 依赖 / ascf_build 实际目录 / 每个子包的关键文件齐备性
    - 任何一处不一致立即报错, 指出哪一处需要修改
    - 支持 --strict 模式 (CI 用, exit 1 阻断 build)
    - 已验证反向场景: 故意添加 pages_extra 到 build-profile.json5
      后跑 --strict, 立即报告 "build-profile.json5 多了 [pages_extra]"

使用方式:
  本地日常:  node scripts/check-subpackage-sync.js
  CI 严格:   node scripts/check-subpackage-sync.js --strict
  新增子包 SOP: 跑一次确认没漏, 不一致会列出缺失位置

依赖: 项目已有 json5 包 (devDependencies transitive), 无需新装
2026-07-30 15:27:28 +08:00
d1c58f2ec8 chore(mp-harmony): 移除 ascf_build 下的构建产物 (rawfile + Index.ets)
背景:
  上一个 commit 把 ascf_build 整个目录的元数据纳入 git, 但误把
  每次构建都会变化的产物也入仓了:
    - rawfile/index.js: 每次 DevEco Build 重新生成 (含 webpack chunk id, hash 等)
    - rawfile/<root>/diy-*.pack.js: 同上
    - rawfile/<root>/placeholder/index.html + index.pack.js: 同上
    - Index.ets: 5 个包都是 0 字节空壳, 没有任何内容

  这些文件应该被 .gitignore 排除, 避免每次 DevEco Build 后 git
  status 都会报告 "modified", 污染 diff。

改动:
  1. .gitignore 改造
     - rawfile/** 整体 ignore (鸿蒙端每次 Build 重新生成)
     - Index.ets ignore (空壳, 0 字节)
     - 移除之前的 ! 兜底规则 (rawfile/**/placeholder/index.*,
       rawfile/index.js, base/element/*.json 等), 因为这些都
       已经在 rawfile/ 下被整体 ignore
  2. 从 git 历史删除 12 个误入的产物文件 (5 个 Index.ets +
     5 个 rawfile/index.js + 1 个 placeholder/index.html +
     1 个 placeholder/index.pack.js), 保留在工作树

仍然入 git 的 ascf_build 关键文件 (每次 commit 都更新):
  - oh-package.json5
  - build-profile.json5
  - hvigorfile.ts
  - obfuscation-rules.txt
  - src/main/module.json5
  - src/main/resources/base/element/string.json
2026-07-30 14:36:14 +08:00
6f0d6a9f59 fix(mp-harmony): 修正签名证书文件路径
修复了build-profile.json5中证书文件路径配置错误,将原本的./cert-profile/qq/.p12调整为正确的./cert-profile/qq/qq.p12
2026-07-30 14:32:34 +08:00
8ebd7918fb chore(mp-harmony): 修正子包 HAR name 字段 + 纳入元数据到 git
背景:
  mp-harmony 项目的 5 个 ascf shared HAR 子包 (components_diy / pages_goods /
  pages_order / pages_promotion / pages_tool) 在 ascf_build 目录下, 关键
  元数据 (oh-package.json5 / module.json5 / build-profile.json5 /
  Index.ets / hvigorfile.ts / obfuscation-rules.txt) 之前被 .gitignore
  整体忽略, 从未被 git 跟踪, 导致 "module.name 被清空" 等回归无法被
  git diff / blame 追踪。

改动:
  1. .gitignore 改造
     - 不再整体 ignore ascf_build
     - 精确 ignore 已知产物路径 (rawfile/**/*.pack.js / rawfile/**/*.html
       / rawfile/**/*.json / build/ / oh_modules/ / .preview/ / .cxx/
       / .test/ / node_modules/)
     - ! 取反保留 HAR 必需文件 (placeholder/index.*, rawfile/index.js,
       base/element/*.json)
     - 补充 entry / hvigor / 鸿蒙签名材料 / IDE 临时文件 ignore 规则
  2. 首次将 5 个 HAR 包的元数据纳入 git (40 个文件)
  3. 新增 scripts/fix-subpackage-module-names.js: 任何入口 require
     vue.config.js 时自动修复 oh-package.json5.name + module.json5.module.name
     双重覆盖, 防止 DevEco ohpm 报 "00604007 Field IS Empty Error" 或
     hvigor 报 "Schema validate failed"
  4. vue.config.js 顶层 IIFE 触发修复 (任何 require 立即生效, 不依赖
     webpack 钩子)
  5. entry/scripts/check-har-deps.js 扩展: 校验 ascf_build 子包
     module.json5.module.name 字段 (兜底校验)
  6. entry/har-deps-required.json5 扩展: 新增 subpackageModuleJson5
     路径映射 + ohModulesRoots 校验资源齐备性
  7. entry/README-HAR-DEPS.md 更新: 故障速查表新增 module.name 相关
     报错条目

预期效果:
  - HBuilderX 触发 vue-cli-service build → vue.config.js 自动修复子包
    name → DevEco ohpm install / hvigor schema 校验通过
  - check-har-deps.js 兜底, 任何回归立即被检测并报错
  - 团队成员 git pull 后能立即看到 ascf_build 元数据, 可用 git diff
    跟踪 module.name 等关键字段变更
2026-07-30 14:26:55 +08:00
3dd059a6f6 chore: 更新站点域名和uniacid配置
将基础域名从https://xcx2.aigc-quickapp.com/修改为https://5g.aigc-quickapp.com,同时将uniacid从2482调整为248以匹配新后台配置
2026-07-30 14:05:48 +08:00
414a0e4438 chore(mp-harmony: 切换到QQ签名配置
更新应用包名和签名证书为QQ渠道的签名文件与配置
2026-07-30 11:52:09 +08:00
df9d45fc1d refactor(mp-harmony): add HAR dependency checks and debug logs
1. add debug logs for diy component lifecycle and data flow
2. add required HAR dependency declarations for mp-harmony entry module
3. add har-deps config, validation script and documentation
2026-07-30 11:43:25 +08:00
3bbf8c9531 feat(http): 添加HTTP请求全链路日志打印
新增请求ID、耗时统计,打印请求参数、响应结果与失败信息,同时添加重试相关日志,完善各个接口的调用日志输出
2026-07-30 10:40:07 +08:00
f6cc866aa7 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-07-30 10:35:04 +08:00
d5d3c2b7be chore: add cs and qq region harmony cert profile files
add all required certificate, key and profile files for huawei harmonyos cs and qq region packaging
2026-07-30 10:27:38 +08:00
c5134c1eb2 chore(mp-harmony): 更新鸿蒙项目配置与依赖
1.  新增忽略node_modules目录到gitignore
2.  升级modelVersion到6.0.0并更新相关配置
3.  更新ascf-toolkit插件与ascfapi依赖版本
4.  新增业务模块构建配置
5.  优化hvigor配置与插件参数
2026-07-30 09:01:25 +08:00
7c51841dba chore: 2026-07-25 17:53:39 +08:00
a70a29eda1 chore:自定义标题显示 2026-07-25 17:10:32 +08:00
4bd36db70f chore:微信小程序自定义标题已经可以开始展示了 2026-07-25 15:57:37 +08:00
eab5a97ba5 feat(vr page): add custom nav bar and support page title param
1. 为APP-PLUS端的VR页面配置自定义导航栏样式
2. 从路由参数中获取并设置页面标题
2026-07-25 15:20:51 +08:00
3d4941865e fix(vr page): 修复导航栏标题无法正确更新的问题
优化vr页面的导航栏标题更新逻辑,通过监听title变化和监听webview的titleUpdate事件,以及在页面显示时更新标题,确保标题能正确同步显示。
2026-07-25 14:57:22 +08:00
9da07f1566 merge: feat/google-detail-skeleton → dev/1.0 2026-07-25 09:24:41 +08:00
8cef7b775b Revert "build: add localhost:8050-h5 build configuration"
This reverts commit 6cf8261298.
2026-07-25 09:24:16 +08:00
791d0bb623 fix(goods-detail): 骨架屏期间不再渲染真实底部 action
- 把 <slot name="action"> 移入 v-else 分支, 与 .goods-detail 同一生命周期
- 修复: 加载初期 goodsSkuDetail.goods_state=0 (默认值), 父组件 detail.vue
  模板里 <ns-goods-action> 走 v-else 显示"该商品已下架"
- 数据到达 goods_state=1 后才切到正常按钮, 导致首屏出现"已下架→正常"闪烁
- 修复后: 骨架屏状态下完全不渲染 ns-goods-action, 仅显示 .goods-detail-skeleton-action 占位

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 09:06:39 +08:00
dbc6002248 fix(vr page): optimize navigation bar title setting logic
add optional chaining to safely access res.data.title,
only set navigation bar title when title exists, fallback to default removed
2026-07-25 08:50:43 +08:00
6cf8261298 build: add localhost:8050-h5 build configuration
新增本地开发的h5端打包配置,指定端口8050并设置对应平台环境变量
2026-07-24 18:14:18 +08:00
9eb42c4a0c feat(goods-detail): 商品详情页接入骨架屏
- 新增 7 段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
  配合底部 action 占位(fixed bottom,2 个 icon + 2 个 btn),
  避免首屏出现"空白 → 真实内容"的跳变
- detail.vue 透传 loading 状态;骨架屏在加载态、未拿到 goods_id 时
  持续显示(hasRealData computed),网络断开时不切到空详情
- loading-cover 改为 initShow=false;骨架屏接管首屏视觉
- 骨架屏最短展示 300ms,保证数据快时用户也能感知到占位

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 18:10:31 +08:00
ed8a316bc0 chore: 2026-07-24 17:20:34 +08:00
daab99fd88 chore: 2026-07-24 17:18:09 +08:00
dac27ca43b Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-07-24 16:40:27 +08:00
f5f893b445 refactor(splash&diy): 重构启动页逻辑,优化白屏问题
1. 统一全平台启动页为strict模式,仅在12s兜底超时才强制关闭
2. 新增网络自适应超时策略,根据网络环境调整兜底时长
3. 补充diy加载失败时的启动页关闭逻辑,避免严格模式下无限等待
4. 优化代码格式与注释,修复部分代码细节问题
2026-07-24 16:11:41 +08:00
45c0c2afb1 refactor(鸿蒙元服务): 适配鸿蒙平台隐私托管与启动屏时序优化
1. 适配鸿蒙元服务平台隐私托管,由系统自动弹出隐私弹窗,应用层不再弹出自建弹窗
2. 新增启动屏strict模式,鸿蒙场景下严格等待首页DIY渲染完成再关闭启动屏,避免白屏
3. 新增splash-state单元测试覆盖新旧模式与鸿蒙场景
4. 清理隐私相关冗余代码与注释,统一平台逻辑分支
2026-07-24 15:10:49 +08:00
f4a770e246 refactor(mp-harmony): 完善隐私协议配置同步流程与鸿蒙双轨适配
1.  新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物
2.  补充鸿蒙元服务隐私托管双轨制适配逻辑
3.  新增完整的隐私弹窗组件单元测试覆盖
4.  修复构建产物隐私配置被占位符覆盖的问题
2026-07-24 14:43:02 +08:00
352c359326 chore(mp-harmony): 初始化鸿蒙元服务项目并完善配置
1.  添加应用及启动图标资源
2.  配置页面路由、卡片信息与资源文件
3.  更新gitignore排除rawfile目录
4.  修改构建签名的keyAlias为cs
2026-07-24 11:35:14 +08:00
1cc9fec69c build(mp-harmony): 更新签名密钥和证书密码
替换原有调试环境的签名密码为新的配置值,确保应用签名正常
2026-07-24 11:25:05 +08:00
2c89235f15 fix(mp-harmony): 更新应用包名
修正AppScope下app.json5中的bundleName配置项
2026-07-24 11:07:28 +08:00
1d8d18922b build(mp-harmony): 切换签名证书为cs相关配置并清理冗余资源
更新了build-profile.json5中的签名配置路径,替换为csDebug.p7b和cs.cer;删除了旧的kk.csr证书请求文件以及冗余的证书物料文件,同时移除了不再需要的组件依赖配置
2026-07-24 10:57:09 +08:00
18dad6c112 chore:VR正常 2026-07-24 08:49:31 +08:00
3debab7291 chore:VR有返回箭头了 2026-07-23 18:27:34 +08:00
a2449ddd91 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-07-23 17:26:20 +08:00
ff0d26908c fix(member): 修复账号注销/退出登录后无法正常跳转到微信原生授权登录的问题
修复背景:
  账号注销成功后, 用户被重定向到 native 登录页 (手机号+验证码表单风格, 像 H5),
  而不是微信小程序原生授权登录 (ns-login 弹窗 + getPhoneNumber 授权).
  此外, 微信小程序下短时间内连发 reLaunch 会触发 native 错误:
    appLaunch with non-empty page stack
  以及历史遗留的 navigateTo push 登录页 + 旧页面在栈中导致的死循环问题.

主要修复:

1. util.js redirectTo reLaunch 分支 (4 平台兼容):
   - H5 平台: window.location.replace (真正清空 history, 避免 history.back + router.replace 假清栈)
   - 小程序平台 (mp-weixin/mp-harmony/...): 加 300ms 全局跳转锁 + setTimeout(0) 包裹
     解决 appLaunch with non-empty page stack 错误
   - APP/其他: 保留原生 uni.reLaunch

2. util.js redirectTo 入口智能默认 (跨平台兜底):
   - 目标 == LOGIN_PAGE_URL && mode 是默认 navigateTo && 没传 back 参数
     → 自动改成 reLaunch (避免注销后死循环)
   - 显式传 mode 或带 back 参数的场景保持原行为 (不影响登录后回原页面)

3. member-public.js mixin 增强:
   - getLoginEntryUrl(): 跨平台登录入口智能分流
     微信小程序/鸿蒙/其他小程序 → INDEX_PAGE_URL (首页, 走 ns-login 原生授权弹窗)
     H5/快应用/APP → LOGIN_PAGE_URL (原生登录页)
   - redirectToLoginOnce(): 统一封装 clearLoginState + 跳登录入口, 带 800ms 组件级防抖锁
   - logout / getCancelStatus 兜底 / submitCancelApply 三个地方都改用 redirectToLoginOnce()

4. 5 个注销/退出流程页面改造:
   - cancelsuccess.vue / cancelstatus.vue / cancellation.vue / cancelrefuse.vue
     / assets.vue: 跳登录入口全部用 redirectToLoginOnce (不再直接 redirectTo(LOGIN_PAGE_URL))
   - cancellation.vue / cancelrefuse.vue: 加 memberPublic mixin (原本没有)

5. 取消了在 pages/index/public/js/index.js 中 onShow 自动弹登录弹窗的尝试
   (因触发 Vue 框架的 dataset of null pre-existing 错误, 改由其他途径处理)

测试覆盖 (300+ 测试用例全部通过):
  - tests/member-cancel-flow.test.js (28 个测试, 9 个用户故事场景)
  - tests/redirectTo-relaunch-h5.test.js (11 个测试, 9 个跨平台场景)
  - tests/redirectTo-real.test.js (12 个)
  - tests/util.test.js (30 个)
  - 其他 8 个测试文件全部通过
2026-07-23 17:26:00 +08:00
c4675deb1e Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide6 2026-07-23 16:51:55 +08:00
c99d808051 chore:二级标题按钮白色背景和一级标题对齐 2026-07-23 16:51:00 +08:00
acbac773db fix(member): 修复退出登录后可返回旧页面的问题
1.  退出登录和注销流程兜底分支都改用reLaunch跳转登录页,清空页面栈防止返回旧会员页
2.  新增完整的单元测试覆盖logout和getCancelStatus的异常分支、调用顺序和跳转模式
2026-07-23 15:17:08 +08:00
b9cb6bf75b fix(member-cancel): 修复注销成功后仍能回退查看旧账号信息的问题
1.  注销申请成功后立即调用clearLoginState清除登录态,避免残留账号信息
2.  所有跳转均改用reLaunch模式清空页面栈,防止用户通过回退键回到历史页面
3.  新增完整的注销流程行为测试用例覆盖所有场景
2026-07-23 15:14:01 +08:00
14e0fb9969 merge: 将 fix/2026-07-20-errkey 合并到 dev/1.0
# Conflicts:
#	.local.config.js
#	pages_tool/ai-chat/ai-chat-message.vue
2026-07-23 14:11:27 +08:00
3d2a982718 chore(config): add test-12 environment config
新增户外鞋优惠券测试环境的配置项,同时注释掉原有的18-test测试配置
2026-07-23 11:49:34 +08:00
eefcc9fe57 feat: 新增鸿蒙元服务平台适配
适配鸿蒙小程序平台,包括导航栏胶囊适配、支付通道支持、登录授权逻辑,以及分享功能兼容
2026-07-23 11:34:03 +08:00
f51b601ecb feat(detail页): 新增鸿蒙小程序端购物车和关注按钮
补充鸿蒙小程序平台的专属操作按钮,包括购物车入口和收藏关注按钮,对齐其他平台的功能展示
2026-07-23 11:04:54 +08:00
70a01bfd2b fix: 修复多页面国际化文案调用路径错误
将多处误用的 common.前缀国际化调用改为顶层路径,同时新增语言调用错误的回归测试用例,防止后续再次出现类似的命名空间误用问题
2026-07-23 10:37:22 +08:00
94142268a0 refactor(i18n): 完成全量硬编码中文文案替换为多语言方案
1.  替换所有页面/组件中的硬编码中文提示文案为$lang调用,包括支付、帮助中心、公告等页面
2.  重构diy.js加载失败文案处理逻辑,支持参数化替换
3.  新增多语言混入逻辑,实现导航标题随语种动态切换
4.  重构支付组件初始化逻辑,优化平台适配代码
5.  添加多语言单元测试和硬编码扫描脚本
6.  更新语言包,新增缺失的翻译词条
2026-07-23 09:46:16 +08:00
4b0aa0ac40 i18n: 补充多语言文案并优化英文tabbar命名
1. 为支付模块新增未获取到支付信息的多语言文案
2. 为通用模块新增网络错误、视频号播放限制、提交/加载相关文案
3. 优化英文环境底部tabbar的命名为更自然的首字母大写形式
2026-07-23 09:07:32 +08:00
6ee4de94a2 fix: 修复多商户优惠券选择异常和空指针问题
1. 新增coupon_list数组类型校验避免渲染异常
2. 重构优惠券选择逻辑,使用merch_id隔离选中状态避免多商户污染
3. 增加orderCreateData.coupon的类型兜底处理,防止非对象/数组类型报错
4. 修复优惠券弹窗选中状态同步逻辑,保留用户已选优惠券
2026-07-23 08:45:01 +08:00
658396c9a3 chore:二级标题按钮和一级标题第一个对齐 2026-07-22 17:49:08 +08:00
0a6abd87ae build(mp-harmony): 添加components_diy和页面模块的构建配置
同时新增清理华为IDE内置node路径的脚本,以及空的权限检查文件
2026-07-22 16:48:46 +08:00
25fc8311f8 chore:H5和微信小程序的返回第一个一级标题时右侧高度正确更新。 2026-07-22 10:38:51 +08:00
d4a249d744 chore:99的微信小程序左侧一级标题的高度会根据字数自动调节,右侧二级标题的白色背景高度会随着左侧选中项的高度自动改变,保持两侧高度一致。 2026-07-22 10:28:57 +08:00
f7efbfd4b7 chore:现在左侧一级题的高度会根据字数自动调节,右侧二级标题的白色背景高度会随着左侧选中项的高度自动改变,保持两侧高度一致。 2026-07-21 18:28:29 +08:00
e9b96df758 chore:一级分类高度和二级分类高度一致 2026-07-21 17:36:35 +08:00
75f70138ed chore:解决key的问题 2026-07-21 15:40:27 +08:00
c9aa4c75ae chore:template==99左侧一级分类选中时的指示器是一个红色的垂直竖条 2026-07-21 12:00:08 +08:00
a1db706072 chore:一级标题选中时加粗字体 2026-07-21 11:55:48 +08:00
276120162a chore:二级分类按钮是一致的颜色 2026-07-21 11:45:12 +08:00
f523122889 chore:99的视图按钮单列商品正常显示立即咨询 2026-07-21 11:40:55 +08:00
cea0e36d5d chore:视图改变 2026-07-21 10:42:21 +08:00
0169f25ca5 chore:H5的二级分类按钮下框对齐 2026-07-21 10:31:46 +08:00
3939d15f2a style(diy-category-item): 调整不同端的分类按钮容器内边距
针对H5和非H5平台分别适配二级分类按钮的内边距样式,保证页面布局在各端显示一致
2026-07-21 10:30:41 +08:00
fd4edc177d chore(config): update local dev config and env entries
1. add local-18 artificial crystal dev environment config
2. update domain for 2482 low-altitude economy env
3. adjust default active env to 2821 and add local-18 comment option
2026-07-21 09:54:13 +08:00
f96fee60d3 chore: 更新本地配置并补全页面导航栏标题配置
1.  调整.local.config.js中的测试平台配置项,替换原有54号配置为18号并新增48号配置,同时修改默认启用的低空经济项目id为2482
2.  在pages.json的所有页面配置中统一添加navigationBarTitleText空配置项,修正部分页面的APP端导航样式适配
2026-07-21 09:52:51 +08:00
d57b69d7cb Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-07-21 09:43:31 +08:00
d875642d99 feat(diy-category): 新增模板1和模板4分类页面样式与功能
1. 新增模板4:顶部图片搜索框、左侧一级分类列表、底部购物车栏
2. 新增模板1:仿京东淘宝分类布局,支持三级分类分组展示商品
3. 优化模板1样式:调整分类栏、商品列表、标签样式
4. 修复模板切换时的搜索框重复渲染问题
5. 新增模板1、3、4的专属UI样式和交互逻辑
2026-07-21 09:41:25 +08:00
0bcb5e7f4a fix(common/js/diy.js): 修正错误处理中使用的错误key函数
将diyErrorKey替换为httpErrorKey来适配HTTP状态码错误处理逻辑
2026-07-21 09:33:48 +08:00
65013a35f0 chore: 2026-07-21 09:20:48 +08:00
31daebc83b Merge remote-tracking branch 'origin/dev/1.0' into yuanlaide6 2026-07-21 08:50:19 +08:00
b8894ea2dc chore: 2026-07-20 18:00:04 +08:00
ff130bbf15 chore:改了二级分类的下边框 2026-07-20 17:29:15 +08:00
3a88941828 build(vue.config): 修复生产构建门禁的node执行路径问题
将执行test-gate的命令从硬编码的"node"改为使用process.execPath,避免依赖PATH环境变量查找node二进制,解决HBuilderX等IDE下PATH无node条目导致的构建失败问题,同时打印当前使用的node版本
2026-07-20 15:38:15 +08:00
b8c813e43b build(vue.config): add production build test gate
新增生产构建门禁校验,在正式打包前执行测试脚本,覆盖所有会加载vue配置的构建入口,包括HBuilderX、vue-cli和uni-cli,拦截未通过测试的生产构建
2026-07-20 11:07:24 +08:00
73776841d8 fix: 修复鸿蒙元服务商品详情页空白及接口兼容问题
1. 移除view组件无效的scroll-y属性,避免鸿蒙JSVM静默不渲染导致页面空白
2. 删除冗余的商品分类页面路由配置
3. 为ref调用和接口请求添加try-catch与空值防御,避免极端场景下崩溃
4. 补充鸿蒙元服务接口调用的兼容性处理,确保请求正常发起
5. 调整loading隐藏时机和异常捕获逻辑,优化页面容错能力
2026-07-20 08:44:15 +08:00
cd2705e5b9 fix(test): 修复沙箱环境下tabbar测试的依赖和调试flag问题
1. 移除沙箱不支持的ESM import语句
2. 内联注入依赖的_toParamValueString函数
3. 将globalThis.__TABBAR_DEBUG__替换为global以穿透沙箱
4. 修复全局调试flag无法在沙箱内生效的问题
2026-07-18 18:31:00 +08:00
02b8966a91 fix(mp-harmony): 修正鸿蒙元服务的url编码逻辑,对齐微信小程序行为
更新了鸿蒙元服务的_safeEncodeForPlatform实现和相关测试:
1. 修正此前错误的"鸿蒙整体encode url"假设,实际鸿蒙行为和微信一致,按&=分隔解析query
2. JS侧不再完全不编码,而是仅对&=?#和空格做encode,保留中文/字母数字等
3. 更新所有相关测试用例和文档注释,同步修复微信平台的编码逻辑说明
2026-07-18 18:23:39 +08:00
2adb574dd5 refactor(util-navigate): 合并微信和鸿蒙平台的编码逻辑
将原有的mp-weixin和mp-harmony平台的编码判断合并,简化分支代码逻辑
2026-07-18 18:19:38 +08:00
5b7351dcd2 fix: 兼容鸿蒙元服务端小程序适配优化
1.  修复cart页面unload时调用不存在方法的报错
2.  适配鸿蒙themeColor绑定和$nextTick兼容问题
3.  给多处逻辑添加try/catch防止页面空白
4.  补全v-for循环的key属性
2026-07-18 17:54:05 +08:00
63e2cb2ff7 fix(diy-category): 修复鸿蒙平台下样式条件编译错误
修正了骨架树项的样式宏定义拼写错误,确保鸿蒙平台能正确应用对应样式
2026-07-18 17:21:42 +08:00
d65f27b256 style(components): 优化多端兼容性适配细节
1. 补充loading-cover组件的兼容场景说明
2. 为鸿蒙元服务适配flex gap替代方案
2026-07-18 17:19:58 +08:00
ce7cbc75df feat(diy-category): add skeleton loading state for category page
优化分类页面加载体验,请求分类数据时显示骨架屏占位,避免页面先展示空态再切换内容造成的闪烁问题,同时调整了空状态的渲染逻辑,请求失败时也能正确显示空态占位
2026-07-18 17:14:02 +08:00
a6091f6b68 refactor(commodity-detail): optimize performance and fix UI flicker issues
1. add empty form tip for product message section
2. lazy load goods form to unblock initial page render
3. optimize category and tabbar rendering to fix flicker on WeChat mini-program
4. delay non-critical data processing to improve first-screen rendering speed
5. fix popup blank issue when opening product message form for the first time
2026-07-18 16:57:10 +08:00
9d6c3218fb refactor(http, diy): 抽象通用网络错误处理逻辑
1. 抽离通用http错误分类器到单独模块,实现错误处理复用
2. 统一多语言文案,将diy专属错误合并到通用http命名空间
3. 简化diy页面的错误处理代码,降低维护成本
2026-07-18 16:18:35 +08:00
71ea945743 style: 修复pages.json缩进与格式问题
调整pages.json中所有页面配置的缩进对齐,修正多处格式错误,统一代码风格,包括分包页面、全局配置和easycom规则的排版问题,修复了文件末尾换行缺失问题。
2026-07-18 16:04:40 +08:00
c8d92d8100 feat(diy): add in-page error prompt for diy data loading failure
1. add multi-language error copy for api request and network exceptions
2. add fixed top error banner component for index page
3. rewrite diy data request error handling: replace toast with reliable in-page prompt with retry button
4. add detailed error reason and request url display in error prompt
2026-07-18 15:58:40 +08:00
5831189e5e fix(diy-form): 修复鸿蒙元服务下value未定义导致的报错
针对鸿蒙元服务运行时首次渲染时value可能为undefined的情况,为所有模板表达式和计算属性添加空值守护,避免触发Cannot read properties of undefined错误
2026-07-18 14:51:42 +08:00
109cba6b6c style(vue.config): 给控制台日志添加前缀标识
统一修改vue.config.js里的console日志,添加[vue.config]前缀便于识别
2026-07-18 14:47:54 +08:00
f01a1d7efc build(vue.config): replace config.log with console.log
将vue配置文件中的自定义日志输出替换为标准console.log,移除了依赖的自定义日志工具调用
2026-07-18 14:42:09 +08:00
ae5b878822 build(vue.config): adjust terser console drop logic and add debug log
重构了鸿蒙平台保留console的判断逻辑,新增配置日志方便调试,统一了控制台语句移除的配置规则
2026-07-18 14:41:42 +08:00
7d4ecb9731 i18n: add missing diy page error prompt copy
补充中英文双语的自定义页面错误提示文案,包括弹窗标题和重试按钮文字
2026-07-18 14:26:11 +08:00
b5e9940934 fix(pages.json): 为首页页面配置新增components-diy分包
补充pages/index/index页面的依赖分包,将components-diy加入到依赖列表中
2026-07-18 12:01:16 +08:00
018616dd0e fix(common/js/event-safety): 修复dataset访问可能的空指针问题
使用可选链操作符避免target为null/undefined时访问dataset报错
2026-07-18 11:58:09 +08:00
74e53a9ca6 style(pages.json): 修复pages.json的缩进格式问题
修正pages.json中商品模块分包下category页面配置的缩进不一致问题,提升代码可读性
2026-07-18 11:52:36 +08:00
fdc0b54fbd fix(common/js/goods_detail_base): add format parameter to goodsform api request
为商品表单接口请求添加format: 'object'参数,指定返回数据格式为对象类型
2026-07-18 11:27:21 +08:00
8e31b9e327 chore:模板4的二级分类按钮正常 2026-07-18 11:26:27 +08:00
460dbcb447 chore:二级分类按钮显示正常 2026-07-18 11:15:58 +08:00
81ecd45de4 chore:二级分类按钮显示 2026-07-18 11:13:34 +08:00
059630ba2c refactor: 重构商品留言表单逻辑,替换为diy-form组件
1.  移除页面data中维护的留言表单数据,改为通过computed实时从商品详情数据构建diy-form所需格式
2.  替换ns-newform为diy-form组件,使用raw模式并接管表单提交逻辑
3.  完善平台别名映射,新增更多小程序平台兼容配置
4.  为diy-form新增占位符样式属性绑定
5.  重构商品表单接口处理逻辑,统一所有元数据字段的初始化和兼容处理
2026-07-18 10:55:39 +08:00
1ac0fb2484 refactor(goods-detail): 升级商品留言表单组件与接口适配
1.  将原有的同步调用改为异步处理handleGoodsSkuData和getGoodsForm方法
2.  重构getGoodsForm接口请求逻辑,兼容新旧版接口返回格式
3.  新增goods_form_config字段支持新表单组件样式配置
4.  替换旧版ns-form为ns-newform组件,完善空数据兜底处理
5.  优化商品留言初始化的可选链操作避免空指针报错
2026-07-18 10:34:37 +08:00
d8816bbe42 chore:还原到模板3二级分类按钮正常 2026-07-18 10:31:35 +08:00
b410597d9c chore 2026-07-18 10:15:27 +08:00
1143ef7b72 fix(util-navigate): 完善鸿蒙元服务编码逻辑注释与说明
更新了util-navigate和对应测试文件的注释,补充鸿蒙原生编码的细节,说明双重编码的问题以及&符号的处理逻辑,修正了注释里的重复表述
2026-07-18 09:24:10 +08:00
987e75c65a Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide3 2026-07-18 09:07:28 +08:00
ac8c461546 chore:二级分类按钮点击一会能切一会不能切 2026-07-18 08:58:52 +08:00
f3032a6004 fix(util-navigate): 修复多平台搜索关键词编码乱码问题
重构跨平台参数编码逻辑:
1. 微信小程序/鸿蒙元服务:仅处理编码边界符和decode roundtrip,不主动编码,交由平台原生处理
2. H5/华为快应用:保留主动编码逻辑,适配浏览器query解析规则
3. 新增专项测试用例覆盖搜索关键词跨端流转场景,修复商品分类页跳转列表页关键词显示乱码的bug
2026-07-18 08:34:43 +08:00
a4d8023e1f chore:二级分类按钮正常 2026-07-17 18:17:45 +08:00
bb0adb0542 style(ns-form, ns-newform): 新增占位符字体大小配置项
1. 为两个表单组件新增placeholderFontSize配置项与对应CSS变量
2. 将输入框占位符字体大小从复用label字号改为独立配置
3. 适配小程序/鸿蒙原生占位符样式规则
2026-07-17 16:14:25 +08:00
a4c218fecc chore: 2026-07-17 16:06:07 +08:00
0d820b04f7 chore:template == 4页面后台选择二级分类正常 2026-07-17 15:53:09 +08:00
70c9c49b94 refactor(form): 抽离平台检测逻辑到共用mixin,统一多端表单配置处理
1. 新增form-platform-mixin.js集中管理平台判断、别名兼容和配置键获取逻辑
2. 移除ns-form和ns-newform组件内重复的平台相关代码
3. 统一替换rpx单位处理逻辑为$util.rpx,简化样式计算代码
4. 移除冗余的_resolveFontSize工具方法,复用现有工具能力
2026-07-17 15:44:40 +08:00
6d1bdb5d8b chore:删除右侧上面的和一级标题一样的字 2026-07-17 15:07:19 +08:00
73b7b2a8df chore:template == 3的模板正常 2026-07-17 14:54:44 +08:00
b2063418fb chore:template == 1的模板正常 2026-07-17 14:01:40 +08:00
21c342e389 chore: 2026-07-17 11:56:44 +08:00
cbf91fa905 feat(util,navigate): add uniacid stale check for wap url
新增租户uniacid一致性校验逻辑,修复装修外链携带过期租户ID导致的跳转错误问题:
1. 新增pickCurrentUniacid和hasStaleUniacidInUrl工具函数
2. 在链接处理逻辑中校验wap_url内的uniacid与当前系统租户ID是否一致
3. 脏数据场景下强制走外链模式而非内部跳转
4. 补充完整的单元测试覆盖
2026-07-17 11:37:53 +08:00
61495b5e90 refactor(utils&form): 优化设备信息获取与表单组件样式配置
1.  为设备信息相关方法添加可选链操作符,避免空值报错
2.  重构getDeviceInfo和getWindowInfo的异常处理逻辑,简化代码
3.  重构ns-form表单组件,新增全局样式配置支持,兼容多端样式自定义
4.  优化表单布局样式、必填项和选中态颜色,修复鸿蒙元服务TextArea适配问题
5.  新增标签宽度自动计算功能,优化表单排版对齐效果
2026-07-17 10:43:49 +08:00
185d4914a6 chore:页面显示正常1模板 2026-07-17 10:21:40 +08:00
e3bbb7c77a chore:商品分类页面符合template==1 2026-07-17 09:58:25 +08:00
4044070867 fix(splash): 移除 onDiyGroupLoaded 多余 500ms 延迟避免 page-ready 兜底超时
diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle, 叠加的 500ms 会吃掉
splash 2s page-ready 兜底预算, 导致弱网/冷启动下走 page-ready-timeout 兜底路径
(splash 在页面就绪前被强制关闭)。改为收到 loaded 事件后立即 notifyPageReady。
2026-07-16 17:20:46 +08:00
41b0d15570 chore: 切换默认启用的项目配置为低空经济
调整注释掉的配置项,将48切换为2482作为默认启用的项目配置
2026-07-16 17:09:30 +08:00
aafb75bad3 style: 调整多个页面的回到顶部按钮位置适配多端
为H5和小程序平台分别配置mescroll-totop的right和bottom样式,修复不同终端下按钮位置适配问题
2026-07-16 17:07:30 +08:00
6e19bc7aff chore: 注释新增低空经济项目配置
暂时注释掉2482号项目的配置代码,避免其被错误加载启用
2026-07-16 16:53:41 +08:00
94e852032b Merge remote-tracking branch 'remotes/origin/dev/1.0' into dev/1.0 2026-07-16 16:46:06 +08:00
5ccbc5d4f4 chore(config): add 18-test development environment configuration
Also add commented reference for the new 18-test environment
2026-07-16 16:45:59 +08:00
cf6d54e2bf fix(util): 修复内部页面绝对URL被误判为外链的问题
新增extractInternalAppPath和resolveNavMode工具函数,在diyRedirectTo中增加内部页面绝对URL还原逻辑:
1. 支持通过open_mode配置三种导航模式:auto/online/internal
2. 严格校验域名避免误伤外部站点,支持强制内部跳转场景
3. 修复H5整页刷新丢失状态、MP被塞进web-view的问题
2026-07-16 16:32:10 +08:00
204 changed files with 46096 additions and 9945 deletions

78
.gitignore vendored
View File

@@ -7,8 +7,80 @@
.codebuddy .codebuddy
.vscode .vscode
.trae .trae
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
# 清理历史 (仅限团队协商后操作)
.local.config.js
# 鸿蒙元服务构建产物 # 鸿蒙元服务构建产物 (被 .gitignore 的只是产物, HAR 元数据需要 git 管理)
# 重要: ascf_build 根目录的元数据 (oh-package.json5 / Index.ets / build-profile.json5 / ...)
# 必须被 git 管理, 否则无法追踪 "module.name 被清空" 等回归。
# 这些元数据通过下方的 ! 取反规则显式纳入 git。
stub-app-projects/mp-harmony/ascf/ascf_src stub-app-projects/mp-harmony/ascf/ascf_src
stub-app-projects/mp-harmony/ascf/ascf_build stub-app-projects/mp-harmony/entry/src/main/resources/rawfile
stub-app-projects/mp-harmony/entry/src/main/resources
# ascf_build/<pkg>/ 是 HAR 包定义
# 区分两类内容:
# 1. 必须入 git (元数据 / 构建配置 / HAR 入口):
# - oh-package.json5, build-profile.json5, hvigorfile.ts
# - obfuscation-rules.txt, src/main/module.json5
# - src/main/resources/base/element/string.json (HAR i18n)
# 2. 每次构建覆盖 (不应入 git):
# - src/main/resources/rawfile/** : 鸿蒙端从 ascf_src 同步的编译产物
# - Index.ets : 空壳文件, 无内容
# - build/, oh_modules/, .preview/, .cxx/, .test/, node_modules/
# rawfile/ 整体 ignore (鸿蒙端每次 Build 重新生成)
stub-app-projects/mp-harmony/ascf/ascf_build/**/rawfile/**
# Index.ets 是空壳 (每个包都是 0 字节), 不入 git
stub-app-projects/mp-harmony/ascf/ascf_build/**/Index.ets
# 其他产物目录
stub-app-projects/mp-harmony/ascf/ascf_build/**/build
stub-app-projects/mp-harmony/ascf/ascf_build/**/oh_modules
stub-app-projects/mp-harmony/ascf/ascf_build/**/.preview
stub-app-projects/mp-harmony/ascf/ascf_build/**/.cxx
stub-app-projects/mp-harmony/ascf/ascf_build/**/.test
stub-app-projects/mp-harmony/ascf/ascf_build/**/node_modules
# entry 下的产物
stub-app-projects/mp-harmony/entry/build
stub-app-projects/mp-harmony/entry/oh_modules
stub-app-projects/mp-harmony/entry/.preview
stub-app-projects/mp-harmony/entry/.cxx
stub-app-projects/mp-harmony/entry/.test
stub-app-projects/mp-harmony/entry/node_modules
# 鸿蒙工程顶层产物 (.hvigor / hvigor / .idea 已在根 .gitignore 范围或更精准的鸿蒙子 .gitignore)
stub-app-projects/mp-harmony/oh_modules
stub-app-projects/mp-harmony/.hvigor
stub-app-projects/mp-harmony/.idea
# 鸿蒙 hvigor 自身依赖
stub-app-projects/mp-harmony/hvigor
# Trae/IDE 临时文件
.hvigor/outputs
.trae_permission_check_*
# 本地测试脚本与产物归档(不入 git, 留本地复用)
# 内容: Playwright 截图 / 测试脚本 / 对比图 / 临时提交草稿
# 复用方式: cd _test_archive && python xxx.py
_test_archive/
# 兜底:防止任何临时测试文件被遗漏在根目录
# 名称以 _test_/_compare_/_auth_ 开头, 或 check_button.py 等
_test_*.png
_test_*.html
_compare*.png
_compare*.py
_auth_*.json
_commit_msg.txt
/check_button.py
# 鸿蒙签名材料 (敏感, 绝不能提交到公共 git)
# 仓库有 origin (github.com/Zajd/jsysq-uniapp) 是公开仓库, 签名材料必须 gitignore
stub-app-projects/mp-harmony/**/cert-profile
stub-app-projects/mp-harmony/**/.p12
stub-app-projects/mp-harmony/**/.p7b
stub-app-projects/mp-harmony/**/.cer
stub-app-projects/mp-harmony/**/.csr
stub-app-projects/mp-harmony/**/material

View File

@@ -46,6 +46,10 @@ const localDevConfig = ({
uniacid: 11, uniacid: 11,
domain: 'https://test.aigc-quickapp.com', domain: 'https://test.aigc-quickapp.com',
}, },
'18-test': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台 'local-2': { // 测试平台
uniacid: 2, uniacid: 2,
domain: 'http://localhost:8050/', domain: 'http://localhost:8050/',
@@ -55,11 +59,16 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/', domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/', // domain: 'http://localhost:8050/',
}, },
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'local-11': { // 测试平台 'local-11': { // 测试平台
uniacid: 11, uniacid: 11,
domain: 'http://localhost:8050/', domain: 'http://localhost:8050/',
}, },
'100': { '100': {// 口译教学
uniacid: 100, uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
@@ -67,31 +76,35 @@ const localDevConfig = ({
uniacid: 1, uniacid: 1,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'115': { '115': {// 低压断路器
uniacid: 115, uniacid: 115,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-11': { '5g-11': {// 铝箔袋包装
uniacid: 11, uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-206': { '5g-206': {// 气体探测
uniacid: 206, uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'47': { // 修改弹窗 '5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
uniacid: 47, uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'145': { // 表单链接转义问题 '145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
uniacid: 145, uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-213': { // 单图组点击链接无法跳转 '5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
uniacid: 213, uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-4': { // 公告点击链接无法跳转 '5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
uniacid: 4, uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
@@ -103,11 +116,11 @@ const localDevConfig = ({
uniacid: 50, uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'5g-237': { // 商品分类,没有立即咨询文字 '5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
uniacid: 237, uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/', domain: 'https://5g.aigc-quickapp.com/',
}, },
'2847': { // '2847': { // 家居装饰品
uniacid: 2847, uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/', domain: 'https://xcx.aigc-quickapp.com/',
}, },
@@ -115,7 +128,7 @@ const localDevConfig = ({
uniacid: 2482, uniacid: 2482,
domain: 'https://xcx6.aigc-quickapp.com/', domain: 'https://xcx6.aigc-quickapp.com/',
}, },
'2485': { // 验证电子表单前后端数据一致性 '2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
uniacid: 2485, uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/', domain: 'https://xcx6.aigc-quickapp.com/',
}, },
@@ -135,17 +148,104 @@ const localDevConfig = ({
uniacid: 48, uniacid: 48,
domain: 'https://test.aigc-quickapp.com', domain: 'https://test.aigc-quickapp.com',
}, },
'test-12': { // 户外鞋,测试优惠券
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //气雾栽培机,视频组件安卓ios播放正常
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-95': { // 加热器 ,商品分类,没有立即咨询文字
uniacid: 95,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-160': { // 过瘤胃瘤胃保护,商品详情视频
uniacid: 160,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-268': { // 合成孔径雷达,一级分类背景字体颜色
uniacid: 268,
domain: 'https://5g.aigc-quickapp.com/',
},
'225-test': { // 测试平台
uniacid: 225,
domain: 'https://test.aigc-quickapp.com',
},
'192.168.8.118-321': { // 测试平台
uniacid: 321,
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/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字 // })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色 // })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应 // })['5g-50']; // 燃气安全产品机器人,购物车无反应
// })['2821']; // 离型纸硅油纸 // })['2821']; // 离型纸硅油纸
})['48']; // 离型纸硅油纸 // })['2482']; // 低空经济
// })['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性 // })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码 // })['5g-11']; // 数码
// })['5g-213']; // 选择要使用的环境配置 // })['5g-213']; // 选择要使用的环境配置
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置 // })['2811']; // 选择要使用的环境配置
// })['5g-351']; //洗车机
// })['5g-204']; //热固性塑料
// })['5g-59']; //合成孔径雷达
// })['2709']; //中央厨房
// })['3007']; //钎焊材料
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置 // })['local-11']; // 选择要使用的环境配置
// })['local-1']; // 本地 // })['2811']; // 本地
//})['5g-317']; // 电磁兼容
})['5g-351']; //
// })['local-2']; // 本地 // })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test // })['1-test']; // test
// })['test-12']; // test
export default localDevConfig; export default localDevConfig;

33
App.vue
View File

@@ -271,27 +271,56 @@
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发 // 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件 // 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
// 只弹一次: 同意后写 storage,后续不再弹 // 只弹一次: 同意后写 storage,后续不再弹
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回 // 异步查询平台隐私状态(微信/快应用), H5 同步返回
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹 // H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底 // urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取 // readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels)) // (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底 // 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
//
// 鸿蒙元服务(双轨制):
// 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
// 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
// #ifdef MP-HARMONY
// 鸿蒙: 走平台隐私托管, markSkipped 写 storage
try {
await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
PrivacyManager.markSkipped()
} catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
// #endif
// #ifndef MP-HARMONY
try { try {
await PrivacyManager.show({ await PrivacyManager.show({
urlFallback: _buildPrivacyUrlFallback(options), urlFallback: _buildPrivacyUrlFallback(options),
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]), readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
}) })
} catch (e) { console.warn('[privacy] show fail', e); } } catch (e) { console.warn('[privacy] show fail', e); }
// #endif
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下) // splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉 // mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间 // 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端) // 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
//
// 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
// splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
// _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
// 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
// 避免 splash 关闭后到 diy 内容出现之间出现白屏。
//
// 鸿蒙端 (mode='strict'):
// 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
// 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
// (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
// strict 模式只受 DEFAULT_TIMEOUT=12s 总超时兜底, 实际由 notifyPageReady() 触发
// #ifndef H5 // #ifndef H5
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly(); const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
if (_needSplash) { if (_needSplash) {
splashState.hide({ reason: 'boot-complete', mode: 'fast' }); // 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
// 避免 splash 提前关导致白屏
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
} }
// #endif // #endif

View File

@@ -118,6 +118,8 @@
.goods-video video { .goods-video video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain;
object-position: center;
} }
.goods-video .uni-video-cover { .goods-video .uni-video-cover {
@@ -1223,6 +1225,7 @@
} }
.popup-layer { .popup-layer {
position: relative;
background: #fff; background: #fff;
.head-wrap { .head-wrap {
font-size: $font-size-toolbar; font-size: $font-size-toolbar;

View File

@@ -4,7 +4,73 @@ import store from '@/store/index.js'
import lang from './lang.js' import lang from './lang.js'
let currentConversationId = null; let currentConversationId = null;
export const CONVERSATION_KEY = 'ai_conversation_id';
// ====== AI Agent Generalization (customer service is the first agent) ======
// setAgentId updates currentAgentId; default is agent_aikefu.
// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically.
//
// PR-7: 按 agent_id 路由 endpoint
// - 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;
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
}
// 兼容旧代码: 单 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 路由映射
*
* 规则:
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
*
* 后端两个 addon 完全独立:
* - aikefu: lucky_aikefu_config / conversation / message
* - aiagent: lucky_ai_agent_config / conversation / message
*/
function resolveAddonName(agentId) {
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
return 'aiagent';
}
/**
* PR-7: agent_id -> API endpoint URL
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
*/
function resolveApiUrl(apiName) {
var addon = resolveAddonName(currentAgentId);
if (addon === 'aikefu') {
return '/api/kefu/' + apiName;
}
return '/api/aiagent/' + apiName;
}
/**
* PR-7: WebSocket URL 路径(/ws/{addonName})
*/
function resolveWsPath() {
return '/ws/' + resolveAddonName(currentAgentId);
}
/** /**
* 获取域名配置参数(避免重复代码) * 获取域名配置参数(避免重复代码)
@@ -17,9 +83,9 @@ function getDomainParams() {
}; };
} }
// 初始化时从本地读取会话ID // 初始化时从本地读取会话ID(按当前 agent 隔离)
try { try {
const saved = uni.getStorageSync(CONVERSATION_KEY); const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) { if (saved) {
currentConversationId = saved; currentConversationId = saved;
} }
@@ -50,6 +116,42 @@ function getUserId() {
return guestId; 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。
*/
function getAgentId() {
return currentAgentId || DEFAULT_AGENT_ID;
}
/**
* 设置当前 AI Agent。
*/
function setAgentId(agentId) {
if (agentId && typeof agentId === 'string') currentAgentId = agentId;
}
// #ifdef MP // #ifdef MP
// ================================ // ================================
// WebSocket 长连接管理(微信小程序) // WebSocket 长连接管理(微信小程序)
@@ -91,7 +193,9 @@ function ensureWebSocketConnected() {
return; return;
} }
var wssUrl = 'wss://' + domain + '/ws/aikefu'; // PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
wsConnected = false; wsConnected = false;
wsAuthenticated = false; wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} }); wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -106,6 +210,7 @@ function ensureWebSocketConnected() {
wsSocketTask.send({ wsSocketTask.send({
data: JSON.stringify({ data: JSON.stringify({
action: 'auth', action: 'auth',
agent_id: getAgentId(),
uniacid: _uniacid, uniacid: _uniacid,
token: _token, token: _token,
user_id: _userId user_id: _userId
@@ -133,25 +238,56 @@ function ensureWebSocketConnected() {
// 以下为聊天消息响应,只在有活跃消息时处理 // 以下为聊天消息响应,只在有活跃消息时处理
if (!wsMessageActive) return; if (!wsMessageActive) return;
if (data.event === 'message') { // 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
var onEvt = eventHandlers.onEvent;
switch (data.event) {
case 'message': {
var text = data.answer || data.content || data.text || ''; var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式 // 兼容嵌套 data.data.content 格式
if (!text && data.data) { if (!text && data.data) {
text = data.data.content || data.data.answer || ''; 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; wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text); if (wsMessageOnChunk) wsMessageOnChunk(text);
} else if (data.event === 'message_end' || data.event === 'done') { dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
break;
}
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(服务端完成) // 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(独立函数,不使用 this) // 保存会话ID(按当前 agent 隔离)
if (conversationId) { if (conversationId) {
if (conversationId !== currentConversationId) { if (conversationId !== currentConversationId) {
currentConversationId = conversationId; currentConversationId = conversationId;
try { try {
uni.setStorageSync(CONVERSATION_KEY, conversationId); uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) { } catch (e) {
console.error('保存会话ID失败:', e); console.error('保存会话ID失败:', e);
@@ -161,14 +297,41 @@ function ensureWebSocketConnected() {
var content = wsMessageContent; var content = wsMessageContent;
var convId = conversationId; 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; wsMessageActive = false;
wsMessageContent = ''; wsMessageContent = '';
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId }); dispatchEvent(onEvt, 'message_end', endPayload);
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId }); if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
if (wsMessageResolve) wsMessageResolve(endPayload);
break;
}
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 { } else {
// 兜底日志:打印所有未识别的消息类型
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
} }
}
} catch (e) { } catch (e) {
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data); console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
} }
@@ -233,21 +396,38 @@ export default {
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
}, },
/**
* PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent)
*/
getAddonName() {
return resolveAddonName(currentAgentId);
},
/**
* PR-7: 获取当前 agent 对应的 API endpoint 前缀
*/
getApiPrefix() {
return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent';
},
/** /**
* 发送普通消息(blocking 模式) * 发送普通消息(blocking 模式)
* PR-7: URL 按 agent_id 路由
*/ */
async sendMessage(message, options = {}) { async sendMessage(message, options = {}) {
try { try {
const params = { const params = {
url: '/api/kefu/chat', url: resolveApiUrl('chat'),
data: { data: {
agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
stream: false, stream: false,
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'blocking', // 强制 blocking response_mode: 'blocking',
user: getUserId(), user: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
attachments: (options && options.attachments) || [],
...getDomainParams() ...getDomainParams()
}, },
header: { header: {
@@ -268,7 +448,6 @@ export default {
} catch (error) { } catch (error) {
console.error('[AI Service] AiChat API请求失败,详细错误:', error); console.error('[AI Service] AiChat API请求失败,详细错误:', error);
console.error('[AI Service] 错误堆栈:', error.stack); console.error('[AI Service] 错误堆栈:', error.stack);
// 打印更多信息帮助调试
console.error('[AI Service] store 状态:', { console.error('[AI Service] store 状态:', {
memberInfo: store.state.memberInfo, memberInfo: store.state.memberInfo,
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
@@ -282,46 +461,96 @@ export default {
* 流式消息入口(自动适配平台) * 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket * 微信小程序:复用长连接 WebSocket
*/ */
async sendStreamMessage(message, onChunk, onComplete) { async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
// #ifdef MP // #ifdef MP
// 先确保连接已就绪
try { try {
await ensureWebSocketConnected(); await ensureWebSocketConnected();
} catch (e) { } catch (e) {
if (onComplete) onComplete({ error: e.message }); // WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
throw e; // 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
return this._fallbackToHttp(message, onEvent, onComplete, opts);
} }
// 设置当前消息回调
wsMessageContent = ''; wsMessageContent = '';
wsMessageActive = true; wsMessageActive = true;
wsMessageOnChunk = onChunk || null; eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
wsMessageOnComplete = onComplete || null; // onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
wsMessageResolve = resolve; wsMessageResolve = resolve;
wsMessageReject = reject; wsMessageReject = reject;
try {
wsSocketTask.send({ wsSocketTask.send({
data: JSON.stringify({ data: JSON.stringify({
action: 'chat', action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
query: message, query: message,
user_id: getUserId(), user_id: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
attachments: opts.attachments || [],
...getDomainParams() ...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 // #endif
}, },
/**
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
*/
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
try {
const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] });
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 使用) * HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/ */
async sendHttpStream(message, onChunk, onComplete) { async sendHttpStream(message, onEvent, onComplete, opts = {}) {
// #ifdef H5 // #ifdef H5
try { try {
const url = Config.baseUrl + `/api/kefu/chat`; var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -334,8 +563,10 @@ export default {
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'streaming', response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
attachments: (opts && opts.attachments) || [],
...getDomainParams() ...getDomainParams()
}) })
}); });
@@ -349,6 +580,7 @@ export default {
let buffer = ''; let buffer = '';
let content = ''; let content = '';
let conversationId = ''; let conversationId = '';
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -356,33 +588,76 @@ export default {
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// 按行分割,保留不完整的最后一行
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 未完成的行留到下次 buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.startsWith('data:')) { if (!trimmed.startsWith('data:')) continue;
try {
const jsonStr = trimmed.slice(5).trim(); const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') { if (!jsonStr || jsonStr === '[DONE]') continue;
try {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.event === 'message') { switch (data.event) {
var text = data.answer || data.text || ''; case 'message': {
// 兼容嵌套 data.data 格式 var text = data.answer || data.text || data.content || '';
if (!text && data.data) { if (!text && data.data) text = data.data.content || data.data.answer || '';
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; content += text;
if (onChunk) onChunk(text); fire('message', { content: text, message_id: data.message_id });
break;
} }
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中 case 'thought':
var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; case 'thinking':
if (cid) { case 'reasoning': {
conversationId = cid; // 兼容字段: 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;
} }
if (data.event === 'message_end' || data.event === 'done') { 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); 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) { } catch (e) {
@@ -390,31 +665,6 @@ export default {
} }
} }
} }
}
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
if (jsonStr) {
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;
}
}
} catch (e) {
console.warn('最后 buffer 解析失败:', e);
}
}
console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length); console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length);
if (onComplete) { if (onComplete) {
@@ -437,7 +687,9 @@ export default {
success: true, success: true,
content: response.data?.answer || response.data?.content || response.data?.reply || response.data, content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
conversationId: response.data?.conversation_id, 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 { } else {
throw new Error(response.message || lang.lang('aiService.serviceResponseError')); throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
@@ -458,7 +710,7 @@ export default {
if (id && id !== currentConversationId) { if (id && id !== currentConversationId) {
currentConversationId = id; currentConversationId = id;
try { try {
uni.setStorageSync(CONVERSATION_KEY, id); uni.setStorageSync(getConversationKey(currentAgentId), id);
} catch (e) { } catch (e) {
console.error('保存会话ID失败:', e); console.error('保存会话ID失败:', e);
} }
@@ -471,7 +723,7 @@ export default {
clearConversationId() { clearConversationId() {
currentConversationId = null; currentConversationId = null;
try { try {
uni.removeStorageSync(CONVERSATION_KEY); uni.removeStorageSync(getConversationKey(currentAgentId));
} catch (e) { } catch (e) {
console.error('清除会话ID失败:', e); console.error('清除会话ID失败:', e);
} }
@@ -479,11 +731,13 @@ export default {
/** /**
* 生成新会话(如需) * 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/ */
async generateConversationId() { async generateConversationId() {
const params = { const params = {
url: '/api/kefu/createConversation', url: resolveApiUrl('createConversation'),
data: { data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
token: store.state.token || 'test_token', token: store.state.token || 'test_token',
user_id: getUserId(), user_id: getUserId(),
@@ -503,11 +757,12 @@ export default {
/** /**
* 获取服务状态 * 获取服务状态
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceStatus() { async getServiceStatus() {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/health', url: resolveApiUrl('health'),
async: false, async: false,
data: {} data: {}
}); });
@@ -527,12 +782,14 @@ export default {
/** /**
* 获取服务配置信息 * 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceConfig() { async getServiceConfig() {
try { try {
const params = { const params = {
url: '/api/kefu/info', url: resolveApiUrl('info'),
data: { data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
token: store.state.token || 'test_token', token: store.state.token || 'test_token',
user_id: getUserId(), user_id: getUserId(),
@@ -548,6 +805,15 @@ export default {
async: false, async: false,
}); });
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response)); 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));
}
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('[AI Service] getServiceConfig error:', error); console.error('[AI Service] getServiceConfig error:', error);
@@ -557,12 +823,14 @@ export default {
/** /**
* 清除会话历史(调用后端接口) * 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/ */
async clearConversation(conversationId) { async clearConversation(conversationId) {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/clear-conversation', url: resolveApiUrl('clear-conversation'),
data: { data: {
agent_id: getAgentId(),
conversation_id: conversationId, conversation_id: conversationId,
...getDomainParams() ...getDomainParams()
}, },
@@ -575,8 +843,44 @@ 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
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
return false;
// #endif
},
/** /**
* 获取会话历史 * 获取会话历史
* PR-7: URL 按 agent_id 路由
*/ */
async getConversationHistory(params = {}) { async getConversationHistory(params = {}) {
try { try {
@@ -585,6 +889,7 @@ export default {
} }
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit); console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
const requestData = { const requestData = {
agent_id: params.agent_id || getAgentId(),
uniacid: params.uniacid || this.getUniacid(), uniacid: params.uniacid || this.getUniacid(),
conversation_id: params.conversation_id, conversation_id: params.conversation_id,
user_id: params.user_id || getUserId(), user_id: params.user_id || getUserId(),
@@ -595,7 +900,7 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/getHistory', url: resolveApiUrl('getHistory'),
data: requestData, data: requestData,
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -625,6 +930,7 @@ export default {
/** /**
* [H5 Only] EventSource 流式聊天(备用) * [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/ */
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) { chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
@@ -632,6 +938,7 @@ export default {
window.currentEventSource.close(); window.currentEventSource.close();
} }
const paramsObj = { const paramsObj = {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
user_id: getUserId(), user_id: getUserId(),
member_id: getUserId(), member_id: getUserId(),
@@ -642,7 +949,8 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const params = new URLSearchParams(paramsObj); const params = new URLSearchParams(paramsObj);
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`; var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
try { try {
const eventSource = new EventSource(url); const eventSource = new EventSource(url);
window.currentEventSource = eventSource; window.currentEventSource = eventSource;
@@ -687,7 +995,6 @@ export default {
*/ */
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) { async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => { return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id }); onComplete?.({ message: res.content, conversation_id: res.conversation_id });
}); });
@@ -711,4 +1018,67 @@ export default {
// H5 无 WebSocket 长连接,无需操作 // H5 无 WebSocket 长连接,无需操作
}, },
// #endif // #endif
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
* @param {string} agentId
*/
setAgentId(agentId) {
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
// 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离)
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) currentConversationId = saved;
} 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();
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
}; };

View File

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

View File

@@ -4,6 +4,7 @@
*/ */
import IMAGE_CONFIG from './image-config.js'; import IMAGE_CONFIG from './image-config.js';
import Platform from './platform.js'; import Platform from './platform.js';
import PAGE_KEY_MAP from './util.js';
export class CustomerService { export class CustomerService {
constructor(vueInstance, externalConfig = null) { constructor(vueInstance, externalConfig = null) {
@@ -118,6 +119,16 @@ export class CustomerService {
/** /**
* 跳转到 AI智能客服页面(AiChat) * 跳转到 AI智能客服页面(AiChat)
*
* 注意:不在此处注入 __dyu=FloatBtn。
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
* - hover-nav(悬浮客服按钮)
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
*/ */
openAiChatService() { openAiChatService() {
try { try {
@@ -127,7 +138,7 @@ export class CustomerService {
} }
// ✅ 修正路径:必须与 pages.json 中注册的路径一致 // ✅ 修正路径:必须与 pages.json 中注册的路径一致
const aiChatUrl = '/pages_tool/ai-chat/index'; const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
// ✅ 使用 navigateTo 保留返回栈(体验更好) // ✅ 使用 navigateTo 保留返回栈(体验更好)
uni.navigateTo({ uni.navigateTo({
@@ -551,6 +562,9 @@ export class CustomerService {
/** /**
* 打开 AI 智能助手 * 打开 AI 智能助手
*
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
* 由调用方(hover-nav.vue 等)按需注入。
*/ */
openAIChat() { openAIChat() {
try { try {
@@ -558,7 +572,7 @@ export class CustomerService {
this.vm.setAiUnreadCount(0); this.vm.setAiUnreadCount(0);
} }
const aiChatUrl = '/pages_tool/ai-chat/index'; const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
uni.navigateTo({ uni.navigateTo({
url: aiChatUrl, url: aiChatUrl,

View File

@@ -1,6 +1,7 @@
import WxMap from 'common/js/map-wx-jssdk.js'; import WxMap from 'common/js/map-wx-jssdk.js';
import Config from '@/common/js/config.js'; import Config from '@/common/js/config.js';
import util from '@/common/js/util.js'; import util from '@/common/js/util.js';
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
let systemInfo = util.getDeviceInfo(); let systemInfo = util.getDeviceInfo();
@@ -52,6 +53,11 @@ export default {
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新 lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求 diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
diyLoadError: false,
diyLoadErrorMsg: '',
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告 //启动广告
adv: {}, adv: {},
@@ -226,6 +232,7 @@ export default {
} }
} }
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
await this.getDiyInfo(); await this.getDiyInfo();
//获取启动广告 //获取启动广告
await this.getDiyAdv(); await this.getDiyAdv();
@@ -310,23 +317,73 @@ export default {
} }
}, },
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
_showDiyLoadError(err, url, errorType) {
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
this.diyLoadErrorUrl = url || '';
this.diyLoadError = true;
// 通知 splash: diy 加载失败, 启动屏应该让位给错误横幅
// 用 uni.$emit 而非直接 import splash-state, 避免循环依赖
// reason 拼成 'diy-{errorType}-error' 方便业务方区分 (splash:diy-error 事件)
const reason = errorType ? `diy-${errorType}-error` : 'diy-load-error'
try { uni.$emit && uni.$emit('diy:load-error', { reason, errorType, err, url }); } catch (e) { }
},
// 根据错误信息组织更详细的提示文案
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
_buildDiyErrorMsg(err) {
const info = classifyHttpError(err);
// 服务端明确返回的业务消息(字符串):直接透传
if (info.type === 'business') return info.raw;
const key = httpErrorKey(info.type);
let reason = this.$lang(key);
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
if (info.statusCode) reason += '(' + info.statusCode + ')';
// empty / unknown 不走模板,直接返回通用网络异常
if (info.type === 'empty' || info.type === 'unknown') return reason;
const tpl = this.$lang('diy.loadFailTpl', { reason });
return tpl;
},
// 错误横幅「重试」入口
retryDiyInfo() {
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
this.getDiyInfo(true);
},
async getDiyInfo(force = false) { async getDiyInfo(force = false) {
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过 // 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
const now = Date.now(); const now = Date.now();
const currentLang = uni.getStorageSync("lang") || ''; const currentLang = uni.getStorageSync("lang") || '';
console.log('[DIY][getDiyInfo] start', 'force=', force, 'id=', this.id, 'name=', this.name, 'lang=', currentLang, 'loading=', this.diyInfoLoading, 'lastAt=', this.lastDiyInfoTime, 'lastLang=', this.lastDiyInfoLang);
if (!force) { if (!force) {
if (this.diyInfoLoading) return; if (this.diyInfoLoading) {
console.log('[DIY][getDiyInfo] skip: already loading');
return;
}
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
&& this.lastDiyInfoLang === currentLang) { && this.lastDiyInfoLang === currentLang) {
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
return; return;
} }
} }
this.diyInfoLoading = true; this.diyInfoLoading = true;
let res; let res;
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
const diyInfoUrl = '/api/diyview/info';
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
try { try {
res = await this.$api.sendRequest({ res = await this.$api.sendRequest({
url: '/api/diyview/info', url: diyInfoUrl,
data: { data: {
id: this.id, id: this.id,
name: this.name, name: this.name,
@@ -335,19 +392,30 @@ export default {
async: false async: false
}); });
} catch (e) { } catch (e) {
console.error('getDiyInfo request failed', e); console.error('[DIY][getDiyInfo] request failed', e);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.$util.showToast({
title: this.$lang('diy.networkError')
});
this.diyInfoLoading = false; this.diyInfoLoading = false;
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
// errorType='network': 网络异常 (uni.request catch)
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
return; return;
} }
this.diyInfoLoading = false; this.diyInfoLoading = false;
console.log('[DIY][getDiyInfo] got response', 'code=', res && res.code, 'hasData=', !!(res && res.data), 'valueType=', res && res.data && typeof res.data.value, 'valueLen=', res && res.data && (typeof res.data.value === 'string' ? res.data.value.length : (res.data.value ? res.data.value.length : null)));
if (!res || res.code != 0 || !res.data) { if (!res || res.code != 0 || !res.data) {
console.warn('[DIY][getDiyInfo] invalid response', JSON.stringify(res));
if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
if (!res) {
// errorType='no-response': 完全没有 res 对象
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
if (res && res.code == -3) { if (res && res.code == -3) {
this.$util.showToast({ this.$util.showToast({
title: res.message title: res.message
@@ -357,9 +425,10 @@ export default {
return; return;
} }
this.$util.showToast({ // 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
title: this.$lang('diy.noDiyData') // 同样视为无法获取到 diy 数据,页面内显示错误横幅
}); // errorType='business': 业务失败 (code!=0 / 消息提示)
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局 // 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] }; this.diyData = { ...(this.diyData || {}), value: [] };
return; return;
@@ -367,10 +436,20 @@ export default {
this.lastDiyInfoTime = now; this.lastDiyInfoTime = now;
this.lastDiyInfoLang = currentLang; this.lastDiyInfoLang = currentLang;
// 成功加载后清除错误提示
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
let diyDataValue = res.data; let diyDataValue = res.data;
if (diyDataValue.value) { if (diyDataValue.value) {
try {
this.diyData = JSON.parse(diyDataValue.value); this.diyData = JSON.parse(diyDataValue.value);
} catch (parseErr) {
console.error('[DIY][getDiyInfo] JSON.parse(value) failed', parseErr, 'value preview=', String(diyDataValue.value).slice(0, 200));
throw parseErr;
}
console.log('[DIY][getDiyInfo] parsed diyData', 'globalKeys=', this.diyData && this.diyData.global ? Object.keys(this.diyData.global) : null, 'valueCount=', this.diyData && this.diyData.value && this.diyData.value.length, 'topIndexValue before=', this.topIndexValue);
// 导航栏标题要根据语言环境切换 // 导航栏标题要根据语言环境切换
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title // 优先使用 title_map 多语言 map,没有则回退到 title / en_title
@@ -458,6 +537,9 @@ export default {
this.openBottomNav = this.diyData.global.openBottomNav; this.openBottomNav = this.diyData.global.openBottomNav;
} }
this.isShowCopyRight = true; this.isShowCopyRight = true;
console.log('[DIY][getDiyInfo] done', 'topIndexValue after=', this.topIndexValue && this.topIndexValue.componentName, 'valueCount=', this.diyData.value.length, 'openBottomNav=', this.openBottomNav, 'isShowCopyRight=', this.isShowCopyRight);
} else {
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
} }
}, },
closePopupWindow() { closePopupWindow() {

View File

@@ -44,7 +44,7 @@ export class EventSafety {
// 安全的方法 // 安全的方法
matches: (selector) => EventSafety.safeMatches(target, selector), matches: (selector) => EventSafety.safeMatches(target, selector),
// 数据集 // 数据集
dataset: target.dataset || {} dataset: target?.dataset || {}
} }
return safeTarget return safeTarget

View File

@@ -0,0 +1,112 @@
/**
* form-platform-mixin.js
*
* 表单组件共用的平台检测 mixin
*
* 集中维护 3 个 computed:
* - platform 当前运行平台 key(与后端 formConfig.terminal 键名对齐)
* - platformAliases 后端键名 → 内部 platform 名 兼容层
* - platformConfigKeys 当前平台的有效配置键集合(用于从 formConfig.terminal 查找)
*
* 消费方:
* - components/ns-form/ns-form.vue
* - components/ns-newform/ns-newform.vue
* - 任何需要按端读取 formConfig.terminal.{h5|weapp|harmony|app} 的表单组件
*
* 设计原则:
* 1. 单一真值:避免 ns-form / ns-newform 各自维护导致漂移
* 2. 纯 computed mixin:不依赖 props/data/methods,便于跨组件复用
* 3. 不依赖 util.js 等其它模块,避免循环依赖
* 4. 通过 mixins: [...] 引入,组件侧不再写平台判断代码
*
* 用法:
* import formPlatformMixin from '@/common/js/form-platform-mixin.js';
* export default {
* mixins: [formPlatformMixin],
* computed: {
* config() {
* const cur = this.platform;
* const keys = this.platformConfigKeys;
* // ...
* }
* }
* };
*/
export default {
computed: {
/**
* 当前运行平台标识(与后端 formConfig.terminal 键名对齐)
* 后端 terminal 键:h5 / weapp / harmony / app / mp-alipay / mp-baidu / mp-toutiao
*/
platform() {
// #ifdef H5
return 'h5';
// #endif
// #ifdef MP-WEIXIN
return 'weapp';
// #endif
// #ifdef MP-ALIPAY
return 'mp-alipay';
// #endif
// #ifdef MP-BAIDU
return 'mp-baidu';
// #endif
// #ifdef MP-TOUTIAO
return 'mp-toutiao';
// #endif
// #ifdef APP-PLUS
return 'app';
// #endif
// #ifdef MP-HARMONY
return 'harmony';
// #endif
// #ifdef QUICKAPP-WEBVIEW
return 'quickapp';
// #endif
return 'h5';
},
/**
* 后端键名 → 内部 platform 名 兼容层
* canonical 与 platform() 返回值一致;别名为兼容 DB 旧 app_type(upgrade-to-ai-agent.sql)
* 及 uniapp / 设计器的历史命名。键名严格区分大小写。
*/
platformAliases() {
return {
'h5': 'h5',
'weapp': 'weapp',
'mp-weixin': 'weapp',
'wechat': 'weapp',
'harmony': 'harmony',
'app': 'app',
'app-plus': 'app',
'mp-alipay': 'mp-alipay',
'aliapp': 'mp-alipay',
'alipay': 'mp-alipay',
'mp-baidu': 'mp-baidu',
'baiduapp': 'mp-baidu',
'baidu': 'mp-baidu',
'mp-toutiao': 'mp-toutiao',
'toutiao': 'mp-toutiao'
};
},
/**
* 当前平台的有效配置键集合
* 用于 config() 中查找 formConfig.terminal 下的平台差异化配置:
* - 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} }
* - 旧式扁平:formConfig[platformKey] = { ... }
* 返回所有映射到当前 platform 的后端键名,方便两种结构都能命中。
*/
platformConfigKeys() {
const cur = this.platform;
const aliases = this.platformAliases;
const keys = [];
Object.keys(aliases).forEach(k => {
if (aliases[k] === cur) keys.push(k);
});
// 加入当前平台名(兼容 formConfig[platform] 这种扁平写法)
if (keys.indexOf(cur) === -1) keys.push(cur);
return keys;
}
}
};

View File

@@ -79,10 +79,9 @@ export default {
}, },
methods: { methods: {
// 处理商品详情数据 // 处理商品详情数据
handleGoodsSkuData() { async handleGoodsSkuData() {
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name; // 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
//设置标题 try {
// this.$langConfig.title(this.navbarData.title);
if (this.goodsSkuDetail.config) { if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch; this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
} }
@@ -92,7 +91,9 @@ export default {
this.modifyGoodsInfo(); this.modifyGoodsInfo();
// 初始化商品详情视图数据 // 初始化商品详情视图数据
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({ try {
if (this.$refs && this.$refs.goodsDetailView) {
this.$refs.goodsDetailView.init({
sku_id: this.skuId, sku_id: this.skuId,
goods_id: this.goodsSkuDetail.goods_id, goods_id: this.goodsSkuDetail.goods_id,
preview: this.preview, preview: this.preview,
@@ -108,6 +109,10 @@ export default {
evaluateList: this.goodsSkuDetail.evaluate_list, evaluateList: this.goodsSkuDetail.evaluate_list,
evaluateCount: this.goodsSkuDetail.evaluate_count evaluateCount: this.goodsSkuDetail.evaluate_count
}); });
}
} catch (e) {
console.error('goodsDetailView.init failed:', e);
}
//媒体 //媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video"; if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
@@ -180,13 +185,21 @@ export default {
size: 'big' size: 'big'
}) })
}; };
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion); try {
if (this.$refs && this.$refs.goodsPromotion) {
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
}
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
if (this.goodsRoute != '/pages_goods/detail') this.setPublicShare(); // 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
// this.getBarrageData(); try {
if (this.addonIsExist.form) { this._goodsFormPromise = this.getGoodsForm();
this.getGoodsForm(); } catch (e) {
console.error('getGoodsForm kicked off failed:', e);
}
} catch (e) {
console.error('handleGoodsSkuData failed:', e);
} }
}, },
/** /**
@@ -321,17 +334,76 @@ export default {
}, },
/** /**
* 获取商品表单 * 获取商品表单
* 后端 goodsform 接口与 info() 对齐,返回 {json_data, form_config(camelCase),
* form_name, form_tip, form_banner, show_banner, show_tip}。
* 全部字段都落到 goodsSkuDetail 上:表单字段 / 样式 / banner / tip 元数据,
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
*/ */
getGoodsForm() { async getGoodsForm() {
this.$api.sendRequest({ // 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
const emptyFn = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
};
emptyFn();
return;
}
let res;
try {
res = await this.$api.sendRequest({
url: "/form/api/form/goodsform", url: "/form/api/form/goodsform",
data: { data: {
goods_id: this.goodsSkuDetail.goods_id goods_id: this.goodsSkuDetail.goods_id,
format: 'object'
}, },
success: res => { async: false
if (res.code == 0 && res.data) this.$set(this.goodsSkuDetail, 'goods_form', res.data);
}
}); });
} catch (e) {
console.warn('getGoodsForm request failed', e);
res = null;
}
const empty = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
};
if (!res || res.code != 0 || !res.data) {
empty();
return;
}
if (Array.isArray(res.data)) {
// 旧版扁平数组兼容
empty();
this.$set(this.goodsSkuDetail, 'goods_form', res.data);
return;
}
this.$set(this.goodsSkuDetail, 'goods_form', res.data.json_data || []);
this.$set(this.goodsSkuDetail, 'goods_form_config', res.data.form_config || {});
this.$set(this.goodsSkuDetail, 'goods_form_name', res.data.form_name || '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', res.data.form_tip || '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', res.data.form_banner || '');
// show_banner/show_tip 后端存 0/1,归一化 boolean
this.$set(
this.goodsSkuDetail,
'goods_form_show_banner',
res.data.show_banner == 1 ? true : (res.data.show_banner === 0 ? false : true)
);
this.$set(
this.goodsSkuDetail,
'goods_form_show_tip',
res.data.show_tip == 1 ? true : (res.data.show_tip === 0 ? false : true)
);
} }
}, },
/** /**

101
common/js/http-error.js Normal file
View File

@@ -0,0 +1,101 @@
/**
* 通用网络/接口错误分类器
*
* 适用场景:
* - uniapp 各端统一错误分类(diy.js / 商品 / 订单 / 自定义页面等)
* - 任意传入 {statusCode, errMsg, message} 形式错误对象的位置
*
* 设计要点:
* 1) 与 Vue / $lang 解耦:不调用任何 i18n 函数,只返回分类结果 + 原始错误字段
* 调用方拿到 type 后,自行决定使用 $lang('http.timeout') / $lang('http.serverError')
* 或其它命名空间的文案,完全保持各模块原有的 key 命名习惯
* 2) 通用网络/接口错误统一映射到 common.http.* 命名空间:
* - http.networkError: 通用网络异常(兜底)
* - http.timeout: 请求超时
* - http.serverError: 服务器错误(5xx)
* - http.notFound: 接口不存在(404)
* - http.auth: 无访问权限(401/403)
* - http.unknown: 其他非 2xx
* 3) 兼容多种错误对象形态:
* - uniapp request: { statusCode, errMsg, data }
* - axios: { response: { status }, message }
* - fetch / 原生: { message, name }
*
* @param {Object|string} err 错误对象或后端业务消息字符串
* @returns {{ type: string, statusCode?: number, errMsg?: string, raw?: any }}
* - type 取值:
* 'business' - 后端返回的业务消息字符串(已透传到 raw)
* 'empty' - 错误对象为空
* 'notFound' - HTTP 404
* 'server' - HTTP 5xx
* 'auth' - HTTP 401 / 403
* 'request' - 其它非 2xx 状态码
* 'timeout' - errMsg 含 timeout
* 'network' - errMsg 含 connect / network / fail
* 'unknown' - 无法分类
* - statusCode: 若有则原样返回
* - errMsg: 归一后的原始消息
* - raw: 原始输入,方便调用方进一步处理
*/
export function classifyHttpError(err) {
// 1) 无错误对象
if (!err) {
return { type: 'empty', raw: err };
}
// 2) 后端直接返回的业务消息(字符串)
if (typeof err === 'string') {
return { type: 'business', raw: err };
}
// 3) 字段归一(兼容 uni / axios / 原生 Error)
const statusCode = err.statusCode
?? err.response?.status
?? err.status;
const errMsg = err.errMsg || err.message || '';
if (statusCode) {
if (statusCode === 404) return { type: 'notFound', statusCode, errMsg, raw: err };
if (statusCode >= 500) return { type: 'server', statusCode, errMsg, raw: err };
if (statusCode === 401 || statusCode === 403) return { type: 'auth', statusCode, errMsg, raw: err };
return { type: 'request', statusCode, errMsg, raw: err };
}
if (/timeout/i.test(errMsg)) {
return { type: 'timeout', errMsg, raw: err };
}
if (/(connect|network|fail)/i.test(errMsg)) {
return { type: 'network', errMsg, raw: err };
}
return { type: 'unknown', errMsg, raw: err };
}
/**
* 根据 classifyHttpError 的结果,映射到 common.http.* 命名空间的 i18n key
*
* 通用网络/接口错误统一走 http.*,调用方通过 $lang(key) 解析文案。
* 原 diy.errTimeout 这类 key 已合并到 http.*,保持项目国际化更专业。
*
* @param {string} type classifyHttpError 返回的 type
* @returns {string|null} 对应的 http.* key;type='business' 时返回 null(透传 raw)
*/
export function httpErrorKey(type) {
switch (type) {
case 'empty': return 'http.networkError';
case 'business': return null; // 透传,不用 key
case 'notFound': return 'http.notFound';
case 'server': return 'http.serverError';
case 'auth': return 'http.auth';
case 'request': return 'http.unknown';
case 'timeout': return 'http.timeout';
case 'network': return 'http.networkError';
case 'unknown':
default: return 'http.networkError';
}
}
export default {
classifyHttpError,
httpErrorKey,
};

View File

@@ -44,6 +44,15 @@ function _sleep(ms) {
} }
function _makeRequest(url, method, data, header, dataType, responseType, timeout) { function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
const reqId = 'req_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
const reqStart = Date.now();
console.log('[HTTP][REQUEST]', reqId, method, url, JSON.stringify({
data: data,
header: header,
dataType: dataType,
responseType: responseType,
timeout: timeout
}));
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
uni.request({ uni.request({
url: url, url: url,
@@ -54,6 +63,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
responseType: responseType, responseType: responseType,
timeout: timeout, timeout: timeout,
success: (res) => { success: (res) => {
console.log('[HTTP][RESPONSE]', reqId, method, url, 'statusCode=', res.statusCode, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res.data));
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) { if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode }); reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
return; return;
@@ -73,6 +83,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
resolve(res.data); resolve(res.data);
}, },
fail: (res) => { fail: (res) => {
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
reject(res); reject(res);
} }
}); });
@@ -87,15 +98,20 @@ async function _requestWithRetry(params, url, method, data, header, dataType, re
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
if (attempt > 0) {
console.log('[HTTP][RETRY]', method, url, 'attempt=', attempt, 'maxRetries=', maxRetries);
}
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout); const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
return result; return result;
} catch (err) { } catch (err) {
lastError = err; lastError = err;
lastStatusCode = err && err.statusCode; lastStatusCode = err && err.statusCode;
console.warn('[HTTP][RETRY-FAIL]', method, url, 'attempt=', attempt, 'errMsg=', err && err.errMsg, 'statusCode=', lastStatusCode);
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) { if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
break; break;
} }
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt); const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
console.log('[HTTP][RETRY-WAIT]', method, url, 'delay=', delay + 'ms');
await _sleep(delay); await _sleep(delay);
} }
} }
@@ -207,6 +223,7 @@ export default {
} }
}, },
uploadBase64(params) { uploadBase64(params) {
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
uni.request({ uni.request({
url: Config.baseUrl + '/api/upload/headimgBase64', url: Config.baseUrl + '/api/upload/headimgBase64',
method: 'POST', method: 'POST',
@@ -221,14 +238,17 @@ export default {
dataType: 'json', dataType: 'json',
responseType: 'text', responseType: 'text',
success: res => { success: res => {
console.log('[HTTP][RESPONSE] uploadBase64', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data); typeof params.success == 'function' && params.success(res.data);
}, },
fail: () => { fail: () => {
console.error('[HTTP][FAIL] uploadBase64', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res); typeof params.fail == 'function' && params.fail(res);
} }
}); });
}, },
pullImage(params) { pullImage(params) {
console.log('[HTTP][REQUEST] pullImage', Config.baseUrl + '/api/upload/headimgPull', 'path=', params.path);
uni.request({ uni.request({
url: Config.baseUrl + '/api/upload/headimgPull', url: Config.baseUrl + '/api/upload/headimgPull',
method: 'POST', method: 'POST',
@@ -243,14 +263,17 @@ export default {
dataType: 'json', dataType: 'json',
responseType: 'text', responseType: 'text',
success: res => { success: res => {
console.log('[HTTP][RESPONSE] pullImage', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data); typeof params.success == 'function' && params.success(res.data);
}, },
fail: () => { fail: () => {
console.error('[HTTP][FAIL] pullImage', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res); typeof params.fail == 'function' && params.fail(res);
} }
}); });
}, },
upload(params) { upload(params) {
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
uni.uploadFile({ uni.uploadFile({
url: Config.baseUrl + params.url, url: Config.baseUrl + params.url,
filePath: params.filePath, filePath: params.filePath,
@@ -264,9 +287,11 @@ export default {
'content-type': 'application/x-www-form-urlencoded;application/json' 'content-type': 'application/x-www-form-urlencoded;application/json'
}, },
success: (res) => { success: (res) => {
console.log('[HTTP][RESPONSE] upload', Config.baseUrl + params.url, 'statusCode=', res.statusCode, res.data);
typeof params.success == 'function' && params.success(JSON.parse(res.data)); typeof params.success == 'function' && params.success(JSON.parse(res.data));
}, },
fail: (res) => { fail: (res) => {
console.error('[HTTP][FAIL] upload', Config.baseUrl + params.url, JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res); typeof params.fail == 'function' && params.fail(res);
} }
}); });

View File

@@ -1247,7 +1247,7 @@ export default {
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`; const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
const cachedFieldResult = _fieldResultCache[fieldCacheKey]; const cachedFieldResult = _fieldResultCache[fieldCacheKey];
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== ''; const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
if (cachedFieldResult !== undefined) { if (cachedFieldResult !== undefined) {
let value = cachedFieldResult; let value = cachedFieldResult;
// 没找到翻译 // 没找到翻译
@@ -1256,10 +1256,6 @@ export default {
if (hasDefault) { if (hasDefault) {
return arguments[1]; return arguments[1];
} }
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
if (!__DEV__) {
return '';
}
// 开发环境 → 返回 raw key(便于发现问题) // 开发环境 → 返回 raw key(便于发现问题)
return value; return value;
} }
@@ -1350,9 +1346,6 @@ export default {
// 有默认值 → 使用默认值 // 有默认值 → 使用默认值
if (hasDefault) { if (hasDefault) {
value = arguments[1]; value = arguments[1];
} else if (!__DEV__) {
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
value = '';
} }
// 开发环境无默认值 → 保留 raw key(便于发现问题) // 开发环境无默认值 → 保留 raw key(便于发现问题)
} else if (arguments.length > 1) { } else if (arguments.length > 1) {
@@ -1367,7 +1360,7 @@ export default {
if (value == undefined) value = ''; if (value == undefined) value = '';
// 多语言调试 // 多语言调试
if (__DEV__ && field == value) { if (field == value) {
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`); console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
} }

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

@@ -0,0 +1,572 @@
// 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',
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',
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)
} else {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
}
}
/**
* 上传 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 凭据获取失败" 完全不可用
if (strategy === 'backend') {
return await doPhpUpload()
}
try {
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
} catch (e) {
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
return await doPhpUpload(e)
}
}
/**
* 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 {
reject(new Error(data.msg || data.message || '上传失败'))
}
} 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,
}
}
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
}
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
// 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 uni.request({
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 模板拼
const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
: (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

@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
} }
/** /**
* 鸿蒙元服务: 查询平台级隐私设置 * 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage * 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>} * @returns {Promise<{needAuthorization: boolean}>}
*/ */
function getHarmonyPrivacySetting() { function getHarmonyPrivacySetting() {
return new Promise((resolve) => { return new Promise((resolve) => {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
resolve({ needAuthorization: !getStorageAgreed() }); resolve({ needAuthorization: !getStorageAgreed() });
// #endif
// #ifndef MP-HARMONY
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
}); });
} }
@@ -188,7 +181,7 @@ async function checkPrivacy() {
} }
// #endif // #endif
// 鸿蒙元服务: 隐私由系统管理,仅查 storage // 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// H5 / 其他平台: 仅查 storage // H5 / 其他平台: 仅查 storage
return checkPrivacySync(); return checkPrivacySync();
} }
@@ -730,8 +723,9 @@ const PrivacyManager = {
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy() // 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
// 节省 1 个 microtask (~0-4ms, dev 模式更明显) // 节省 1 个 microtask (~0-4ms, dev 模式更明显)
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步) // 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可 // H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy(); const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
_needShow = result.needAuthorization; _needShow = result.needAuthorization;
@@ -741,7 +735,18 @@ const PrivacyManager = {
return; return;
} }
// 设置待弹窗标志 // #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
// 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
// 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
_needShow = false;
_pendingShow = false;
_urlRuleDecided = true;
console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
return;
// #endif
// 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
// 已 mounted 的页面组件通过事件接收 // 已 mounted 的页面组件通过事件接收
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿 // 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
_pendingShow = true; _pendingShow = true;
@@ -757,18 +762,28 @@ const PrivacyManager = {
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件 * 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
* 此方法返回 true 并清除标志,页面组件据此自动弹窗 * 此方法返回 true 并清除标志,页面组件据此自动弹窗
* *
* 鸿蒙元服务兼容: * 历史兼容:
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过; * Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。 * 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。 * 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
*
* 鸿蒙元服务(双轨制):
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
* @returns {boolean} true=需要弹窗,false=不需要 * @returns {boolean} true=需要弹窗,false=不需要
*/ */
consumePendingShow() { consumePendingShow() {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
return false;
// #endif
if (_pendingShow && _needShow) { if (_pendingShow && _needShow) {
_pendingShow = false; _pendingShow = false;
return true; return true;
} }
// 鸿蒙兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹 // 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发 // 同一次进程内只补一次,避免隐私弹窗被反复触发
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景) // 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) { if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {

View File

@@ -33,7 +33,16 @@ function useNativeLoading() {
// #endif // #endif
} }
const DEFAULT_TIMEOUT = 8000; // 总兜底超时 // 总兜底超时档位 (show 时可覆盖):
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
// 业务方覆盖: splashState.show({ timeout: 17000 })
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
// 严防因为 timeout 太短导致用户看到白屏.
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间 const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间 const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
@@ -71,6 +80,111 @@ class SplashState {
// 避免多通道并发时, splash 组件 re-render 风暴 // 避免多通道并发时, splash 组件 re-render 风暴
this._pendingProgress = null; // 待 emit 的最新进度 this._pendingProgress = null; // 待 emit 的最新进度
this._flushTimer = null; // 节流定时器 this._flushTimer = null; // 节流定时器
// 网络环境 (用于 show() 自动选档)
// _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown'
// _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见)
// _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新
this._networkType = 'unknown';
this._networkReady = false;
// 启动网络检测 (异步, 不阻塞 splash 显示)
this._initNetworkDetect();
// 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发)
// 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash)
// 解耦: splash-state 不直接 import diy.js
// 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用
try {
if (typeof uni !== 'undefined' && uni.$on) {
uni.$on('diy:load-error', (payload) => {
try { this.notifyDiyLoadError(payload || {}) } catch (e) {}
})
}
} catch (e) {}
}
/**
* 初始化网络检测
* - 调 uni.getNetworkType 异步获取当前网络类型
* - 监听 uni.onNetworkStatusChange 实时更新
* - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型
* 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash)
*/
_initNetworkDetect() {
try {
// #ifdef H5
// H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线
// 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5
// Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType)
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
// 初始检测: 用 _updateNetworkType (内部会触发事件)
this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' })
// 监听 online/offline 事件
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('online', () => {
this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true })
})
window.addEventListener('offline', () => {
this._updateNetworkType('none', { source: 'h5-offline', isConnected: false })
})
}
return
}
// navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType
// #endif
// #ifndef H5
if (typeof uni === 'undefined' || !uni.getNetworkType) {
// 不可用: 用 _updateNetworkType 设 unknown (会触发事件)
this._updateNetworkType('unknown', { source: 'no-uni-api' })
return
}
// 异步获取当前网络类型
uni.getNetworkType({
success: (res) => {
this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' })
},
fail: () => {
this._updateNetworkType('unknown', { source: 'uni-initial-fail' })
},
})
// 监听网络变化
if (uni.onNetworkStatusChange) {
uni.onNetworkStatusChange((res) => {
const newType = (res && res.networkType)
|| (res && res.isConnected === false ? 'none' : 'unknown')
this._updateNetworkType(newType, {
source: 'uni-change',
isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined,
})
})
}
// #endif
} catch (e) {
// 任意失败都用 unknown, 不影响 splash
this._updateNetworkType('unknown', { source: 'init-error' })
}
}
/**
* 根据当前网络环境选档
* - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发)
* - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲)
* - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲)
* - 未知 (unknown): 17000 (17s 中档, 保守)
* - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守)
*/
_pickTimeoutByNetwork() {
if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认
switch (this._networkType) {
case 'none': return 4000 // 离线: 4s 兜底
case '2g':
case '3g': return 22000 // 弱网: 22s 慢网档
case '4g':
case '5g':
case 'wifi': return 12000 // 正常: 12s 默认档
case 'unknown':
default: return 17000 // 未知: 17s 中档 (保守)
}
} }
show(opts = {}) { show(opts = {}) {
@@ -111,7 +225,34 @@ class SplashState {
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {} try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
} }
const timeout = opts.timeout || DEFAULT_TIMEOUT; // 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
const MIN_RECOMMENDED_TIMEOUT = 5000
let timeout
if (opts.timeout) {
timeout = opts.timeout
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
console.warn(
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
)
}
} else {
// 自动选档: 根据当前网络环境选 timeout
timeout = this._pickTimeoutByNetwork()
}
this._armTimeout(timeout);
}
/**
* 设置/重设 splash 总超时兜底
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
*/
_armTimeout(timeout) {
if (this._timeoutId) { if (this._timeoutId) {
clearTimeout(this._timeoutId); clearTimeout(this._timeoutId);
this._timeoutId = null; this._timeoutId = null;
@@ -125,6 +266,55 @@ class SplashState {
}, timeout); }, timeout);
} }
/**
* 网络变化时更新 _networkType, 并按需重启 splash 兜底
* @param {string} newType 新的网络类型
* @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... }
* @returns {boolean} 是否真的更新了 (新旧不同时为 true)
*/
_updateNetworkType(newType, meta) {
newType = newType || 'unknown'
const oldType = this._networkType
const changed = oldType !== newType
// 关键: 即使 type 没变, _networkReady 也要设 true
// 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来
// 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用)
// 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作)
this._networkReady = true
if (!changed) return false
this._networkType = newType
// 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档)
// 业务场景:
// - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关
// - 2g → wifi: 兜底缩短, 避免过晚强关
// - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发
// - none → any: 兜底恢复正常档, 给足时间
const shouldReboot = this._visible && this._pendingHide === null
// 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底
// _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可
// 此时网络变化不影响兜底 (业务方已接管决策)
if (shouldReboot) {
const newTimeout = this._pickTimeoutByNetwork()
this._armTimeout(newTimeout)
}
// 发 splash:network-change 事件 (业务方监听)
// payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout }
// retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口)
// rebootSplashTimeout: 是否重启了 splash 兜底 (true/false)
const eventPayload = {
from: oldType,
to: newType,
isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none',
retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试
rebootSplashTimeout: shouldReboot,
source: (meta && meta.source) || 'unknown',
}
this._emit('splash:network-change', eventPayload)
return true
}
/** /**
* 隐藏 splash * 隐藏 splash
* @param {Object} opts * @param {Object} opts
@@ -136,15 +326,22 @@ class SplashState {
* @param {string} [opts.mode] - 离开动画模式 * @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画 * - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感 * - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
* splash 必须真等到首页 diy 内容渲染好才能关
*/ */
hide(opts = {}) { hide(opts = {}) {
const reason = opts.reason || 'manual'; const reason = opts.reason || 'manual';
const useStrict = opts.mode === 'strict' || opts._strict === true;
// boot-complete 默认走软隐藏, 避免时序问题 // boot-complete 默认走软隐藏, 避免时序问题
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete'); const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default'; // strict 模式复用 fast 动画
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
if (useSoft) { if (useSoft) {
this._softHide({ reason, mode }); // 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
this._softHide(opts);
} else { } else {
this._doHide({ reason, mode }); this._doHide({ reason, mode });
} }
@@ -154,10 +351,17 @@ class SplashState {
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪 * 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide * 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide * 如果页面 2s 内没就绪, 兜底自动 hide
*
* strict 模式 (鸿蒙已接入平台托管):
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 保证 splash 一定等到首页 diy 内容真的渲染好
*/ */
_softHide(opts) { _softHide(opts) {
if (!this._visible) return; // 已经隐藏了 if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts; this._pendingHide = opts;
// strict 模式识别: mode='strict' 或 _strict=true
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
// 如果组件已经 mount 且页面已就绪, 立即隐藏 // 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) { if (this._hasMounted && this._pageReady) {
@@ -165,6 +369,12 @@ class SplashState {
return; return;
} }
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
if (useStrict) {
return;
}
// 兜底: 1s 内没 mount, 强制隐藏 // 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId); if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => { this._mountGraceId = setTimeout(() => {
@@ -177,15 +387,27 @@ class SplashState {
/** /**
* 真正触发软隐藏 * 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide * 如果页面还未就绪, 等待页面就绪后再 hide
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
*/ */
_flushPendingHide() { _flushPendingHide() {
if (this._mountGraceId) { if (this._mountGraceId) {
clearTimeout(this._mountGraceId); clearTimeout(this._mountGraceId);
this._mountGraceId = null; this._mountGraceId = null;
} }
// 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop
// 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费
// 不应再调 _doHide (避免重复发 splash:hide 或时序错乱)
if (!this._pendingHide) return;
const _opts = this._pendingHide
const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理) // 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) { if (!this._pageReady) {
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
if (useStrict) {
return;
}
// 兜底: 2s 内没就绪, 强制隐藏 // 兜底: 2s 内没就绪, 强制隐藏
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId); if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = setTimeout(() => { this._pageReadyGraceId = setTimeout(() => {
@@ -204,6 +426,11 @@ class SplashState {
/** /**
* 实际隐藏 (硬路径) * 实际隐藏 (硬路径)
* @param {Object} opts
* @param {string} [opts.reason='manual'] 隐藏原因
* @param {string} [opts.mode='default'] 动画模式
* @param {boolean} [opts._silent=false] true=不发 splash:hide 事件
* 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide
*/ */
_doHide(opts) { _doHide(opts) {
if (!this._visible) return; // 防重入 if (!this._visible) return; // 防重入
@@ -225,6 +452,8 @@ class SplashState {
if (useNativeLoading()) { if (useNativeLoading()) {
try { uni.hideLoading(); } catch (e) {} try { uni.hideLoading(); } catch (e) {}
} }
// _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide
if (opts._silent) return;
this._emit('splash:hide', { this._emit('splash:hide', {
reason: opts.reason || 'manual', reason: opts.reason || 'manual',
mode: opts.mode || 'default', mode: opts.mode || 'default',
@@ -264,6 +493,60 @@ class SplashState {
} }
} }
/**
* diy 加载失败时通知 (网络异常/业务失败/无响应)
* - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上)
* - 触发独立事件 'splash:diy-error' (不是 'splash:hide')
* 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型
* 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError)
*
* @param {Object} [opts]
* @param {string} [opts.reason='diy-load-error'] 隐藏原因
* 用于 'splash:diy-error' 事件的 payload, 业务方可区分:
* - 'diy-network-error': 网络异常 (uni.request catch)
* - 'diy-no-response': 无响应 (res 为空)
* - 'diy-business-error': 业务失败 (code!=0)
* @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response'
* @param {Object|string} [opts.err] 原始错误对象
* @param {string} [opts.url] 出错的请求地址
*/
notifyDiyLoadError(opts = {}) {
// 已 _pageReady=true (notifyPageReady 之后): noop
// 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱)
// 不应再触发 splash:diy-error (已经发过 splash:hide)
if (this._pageReady) return;
// 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪")
// 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉
this._pageReady = true
// 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError)
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId)
this._pageReadyGraceId = null
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId)
this._mountGraceId = null
}
// 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误)
this._emit('splash:diy-error', {
reason: opts.reason || 'diy-load-error',
errorType: opts.errorType,
err: opts.err,
url: opts.url,
})
// 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide
// 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件
// notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide
// (业务方根据事件类型区分, 不能混淆)
if (this._pendingHide) {
// 把 _pendingHide 取出, 直接 _doHide 关闭
// _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error)
const _pendingOpts = this._pendingHide
this._pendingHide = null
setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50)
}
}
/** /**
* 组件 unmount 时通知 (用于 _hasMounted 状态管理) * 组件 unmount 时通知 (用于 _hasMounted 状态管理)
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减 * 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减

View File

@@ -71,6 +71,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -264,6 +268,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -456,6 +464,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -648,6 +660,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -840,6 +856,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1032,6 +1052,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1224,6 +1248,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1416,6 +1444,10 @@ export default {
...{ ...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号 diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号 diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐") diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx) diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称 diy_category_item_name_size: '24rpx',// 分类项名称

View File

@@ -5,6 +5,24 @@
import lang from './lang.js'; import lang from './lang.js';
/**
* 判断是否为「微信剪贴板隐私声明缺失」导致的复制失败
*
* 微信自 2023-09-15 起把剪贴板接口纳入《小程序用户隐私保护指引》,
* 若小程序后台未声明「剪贴板」(或声明未审核通过),调用会返回:
* { errno: 112, errMsg: 'setClipboardData:fail api scope is not declared in the privacy agreement' }
* 这是平台侧配置问题,不改代码也能复现,因此不能把原始英文 errMsg 直接展示给用户。
*
* @param {*} err uni.setClipboardData 的 fail 回调参数
* @returns {Boolean} true=剪贴板隐私声明缺失
*/
export const isClipboardPrivacyError = (err) => {
if (!err) return false;
if (Number(err.errno) === 112) return true;
const msg = `${err.errMsg || err.message || ''}`;
return /privacy agreement/i.test(msg) || /api scope is not declared/i.test(msg);
};
/** /**
* 显示错误信息 * 显示错误信息
* @param {Exception} err * @param {Exception} err
@@ -97,8 +115,12 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {})
fail: (err) => { fail: (err) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
try { try {
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误
const tip = isClipboardPrivacyError(err)
? lang.lang('uniappUtils.clipboardPrivacyNotDeclared')
: (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'));
uni.showToast({ uni.showToast({
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'), title: tip,
icon: 'none', icon: 'none',
duration: 2000 duration: 2000
}); });

View File

@@ -368,6 +368,186 @@ export function normalizeUrl(urlString) {
return schemeAndHost + normalizedPath + normalizedQuery + hash; return schemeAndHost + normalizedPath + normalizedQuery + hash;
} }
// =============================================================
// extractInternalAppPath
// =============================================================
/**
* 从一个 URL 中提取"本 App 内部页面路径"。
*
* 背景 / 修复的 bug:
* 后台装修数据里,内部页面常被存成【绝对 URL】,例如
* https://host/hwappx/common/pages_tool/form/formdata?id=130
* diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是:
* - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失
* - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view
* 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径,
* 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。
*
* 判定规则(双闸门,避免误伤真正的外部站点):
* 闸门 1 —— host 校验(相对路径直接放行):
* 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感);
* 否则返回 null(视为真外链, 维持原逻辑)。
* ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链,
* 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。
* 闸门 2 —— 路径校验:
* pathname 中出现以 App 页面根开头的段(pages / pages_xxx),
* 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回;
* 未命中返回 null。
*
* @example
* const H = ['xcx6.aigc-quickapp.com']
* extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H)
* // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原)
* extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H)
* // null (子域不同 → 维持外链)
* extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H)
* // null (外域名 → 维持外链,不误伤)
* extractInternalAppPath('/pages_goods/detail?id=9', H)
* // '/pages_goods/detail?id=9' (相对路径 → 内部)
*
* @param {string} url 绝对或相对 URL
* @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取);
* 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。
* @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null
*/
export function extractInternalAppPath(url, allowedHosts) {
if (!url || typeof url !== 'string') return null;
let rest = url;
// 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部)
const hostMatch = rest.match(/^https?:\/\/([^/]*)/i);
if (hostMatch) {
if (allowedHosts !== true) {
const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, '');
const allow = _normalizeHostList(allowedHosts);
if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改
}
rest = rest.substring(hostMatch[0].length);
}
// 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx)
const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i);
if (!pageMatch) return null;
const internal = rest.substring(pageMatch.index);
return internal || null;
}
/**
* 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。
* 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。
* @param {string|string[]} hosts
* @returns {string[]}
*/
function _normalizeHostList(hosts) {
if (!hosts) return [];
const arr = Array.isArray(hosts) ? hosts : [hosts];
const out = [];
for (const h of arr) {
if (!h || typeof h !== 'string') continue;
let s = h.trim().toLowerCase();
const m = s.match(/^https?:\/\/([^/]*)/i);
if (m) s = m[1] || '';
else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path
s = s.replace(/:\d+$/, ''); // 去端口
if (s) out.push(s);
}
return out;
}
/**
* 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。
*
* 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode):
* - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致)
* - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view)
* - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上
*
* @param {Object} link
* @returns {'internal'|'online'|'auto'}
*/
export function resolveNavMode(link) {
if (!link || typeof link !== 'object') return 'auto';
const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
const raw = link.open_mode != null ? link.open_mode
: (link.openMode != null ? link.openMode
: (pp.open_mode != null ? pp.open_mode : pp.openMode));
const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : '';
if (m === 'internal' || m === 'spa') return 'internal';
if (m === 'online' || m === 'web') return 'online';
return 'auto';
}
// =============================================================
// hasStaleUniacidInUrl
// =============================================================
// pickCurrentUniacid
// =============================================================
/**
* 从两个候选 uniacid 源中选出"系统当前 uniacid"。
*
* 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致):
* pickCurrentUniacid(
* uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入
* Config.uniacid, // 编译期兜底
* )
*
* 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。
* 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。
*
* ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置
* uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现
* "守卫用 A 真值, http 请求用 B 真值"的不一致。
*
* @param {*} primaryValue 第一候选(运行时持久化值)
* @param {*} secondaryValue 第二候选(编译期兜底)
* @returns {number} 选出的 uniacid; 都无效时返回 0
*/
export function pickCurrentUniacid(primaryValue, secondaryValue) {
const a = parseInt(primaryValue, 10);
if (a) return a; // 非 0 真值
const b = parseInt(secondaryValue, 10);
if (b) return b;
return 0;
}
// =============================================================
// hasStaleUniacidInUrl
// =============================================================
/**
* 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。
*
* 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。
* - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致,
* 说明这份装修数据可能是历史遗留或别的租户的(脏数据),
* 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
* - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
*
* 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经
* 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。
*
* 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。
*
* @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值)
* @param {string|number|null} currentUniacid 系统当前的 uniacid
* (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做)
* @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上)
*/
export function hasStaleUniacidInUrl(wap_url, currentUniacid) {
if (!wap_url || typeof wap_url !== 'string') return false;
if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false;
const m = wap_url.match(/[?&]uniacid=([^&#]*)/i);
if (!m) return false;
let urlUniacid;
try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; }
const cur = String(currentUniacid);
const a = parseInt(urlUniacid, 10);
const b = parseInt(cur, 10);
// 两侧都能 parse 成数字时按数字比; 否则按字符串比
if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b;
return urlUniacid !== cur;
}
// ============================================================= // =============================================================
// appendQueryParams // appendQueryParams
// ============================================================= // =============================================================
@@ -484,29 +664,30 @@ export function shouldEncodeUrl() {
return true; return true;
} }
/** /**
* 跨平台 safeEncode(decode roundtrip + encode 一次) * 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
* *
* 4 平台编码行为: * 解决问题:
* | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? | * - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
* |-----------|---------------|------------------|------------------| * 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
* | h5 | 浏览器 | 否 | 必须 | * 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
* | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) | * - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
* | mp-harmony| 鸿蒙原生 | 否 | 必须 | * 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
* | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 | * - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
* *
* 关键 bug 修复(2026-07-14): * @param {string} s 已 decode roundtrip 后的原文
* 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时,
* 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符,
* 导致后续 key(id/uniacid/page_params) 全部丢失。
*
* @param {*} v
* @returns {string} * @returns {string}
*/ */
function _encodeUrlDelimiters(s) {
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
}
export function _safeEncodeForPlatform(v) { export function _safeEncodeForPlatform(v) {
const s = _toParamValueString(v); const s = _toParamValueString(v);
// decode roundtrip: 兼容上游已 encode 过的入参 // decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
let once = s; let once = s;
try { try {
if (/%[0-9a-fA-F]{2}/.test(s)) { if (/%[0-9a-fA-F]{2}/.test(s)) {
@@ -516,7 +697,16 @@ export function _safeEncodeForPlatform(v) {
once = s; once = s;
} }
// 4 平台都主动 encode 一次 const platform = getPlatform();
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
if (['mp-weixin','mp-harmony'].includes(platform)) {
return _encodeUrlDelimiters(once);
}
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
return encodeURIComponent(once); return encodeURIComponent(once);
} }
@@ -616,6 +806,7 @@ export function createNavigateApi(opts = {}) {
cleanParamUrls, cleanParamUrls,
normalizeUrl, normalizeUrl,
appendQueryParams, appendQueryParams,
extractInternalAppPath,
parsePageParams, parsePageParams,
resolveLinkUrl, resolveLinkUrl,
decodeEntity, decodeEntity,

View File

@@ -17,6 +17,10 @@ import {
cleanParamUrls, cleanParamUrls,
normalizeUrl as _normalizeUrlBase, normalizeUrl as _normalizeUrlBase,
appendQueryParams as _appendQueryParamsBase, appendQueryParams as _appendQueryParamsBase,
extractInternalAppPath as _extractInternalAppPath,
resolveNavMode as _resolveNavMode,
hasStaleUniacidInUrl as _hasStaleUniacidInUrl,
pickCurrentUniacid as _pickCurrentUniacid,
_shouldEncodeUrl, _shouldEncodeUrl,
_safeEncodeForPlatform, _safeEncodeForPlatform,
_navigateTo, _navigateTo,
@@ -42,6 +46,36 @@ import {
export { decodeEntity, cleanParamUrls, _toParamValueString, adaptSubpackageUrl }; export { decodeEntity, cleanParamUrls, _toParamValueString, adaptSubpackageUrl };
/**
* 获取系统当前的 uniacid(运行时单点真值)。
*
* 必须【与 common/js/http.js 发送请求时使用的 uniacid 完全一致】(行 168):
* data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid
* 否则 uniacid 守卫(脏数据判断)和实际网络请求会基于不同的真值,
* 产生不一致行为。
*
* 真值源:
* 1) uni.getStorageSync('uniacid') — 持久化, 由登录 / 初始化写入
* 2) Config.uniacid — 编译期兜底(从 .local.config.js / vue.config.js 注入)
*
* 0 视为"无 uniacid": hasStaleUniacidInUrl 收到 0 时直接返回 false (不强行判断)。
*
* @returns {number} 当前 uniacid; 无法获取时返回 0
*/
function _getCurrentUniacid() {
let storageVal;
try {
if (typeof uni !== 'undefined' && uni && typeof uni.getStorageSync === 'function') {
storageVal = uni.getStorageSync('uniacid');
}
} catch (e) {}
let configVal;
try {
if (Config && Config.uniacid) configVal = Config.uniacid;
} catch (e) {}
return _pickCurrentUniacid(storageVal, configVal);
}
/** /**
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。 * 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
* 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。 * 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。
@@ -68,6 +102,36 @@ export function parsePageParams(raw, defaultValue = {}) {
} }
/**
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
*
* 设计动机:URL query 中布尔值的合法写法不止一种——
* ?show_home=1 -> '1' -> true
* ?show_home=true -> 'true' -> true
* ?show_home=true -> true -> true
* ?show_home=0 -> '0' -> false
* ?show_home=false -> 'false' -> false
* ?show_home= -> '' -> false
* 未传 -> undefined -> false
*
* 与裸 `Boolean(x)` 的差异:
* Boolean('false') === true (任何非空字符串都是 true)
* Boolean('0') === true (同上)
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
*
* @param {*} val 任意值
* @returns {boolean}
*/
export function coerceBool(val) {
if (val === true || val === 1) return true;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
return s === '1' || s === 'true';
}
return false;
}
// 关于页面路径,使用常量导出 // 关于页面路径,使用常量导出
export const INDEX_PAGE_URL = '/pages/index/index'; export const INDEX_PAGE_URL = '/pages/index/index';
export const CATEGORY_PAGE_URL = '/pages_goods/category'; export const CATEGORY_PAGE_URL = '/pages_goods/category';
@@ -75,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index'; export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login'; export const LOGIN_PAGE_URL = '/pages_tool/login/login';
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index'; export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径) // page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表 // 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -423,6 +488,9 @@ export default {
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */ /** 把 page_params 还原成对象(JSON.parse 的安全封装) */
parsePageParams, parsePageParams,
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
coerceBool,
/** HTML 实体解码 */ /** HTML 实体解码 */
decodeEntity, decodeEntity,
@@ -477,6 +545,27 @@ export default {
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。 // 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。 // redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
// //
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
// 修复场景:
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
// onShow 死循环跳登录页.
// 排除场景 (重要, 避免影响):
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
if (cleanPath === LOGIN_PAGE_URL) {
mode = 'reLaunch';
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
}
}
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判 // 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param)); console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
@@ -505,12 +594,17 @@ export default {
} }
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform) // 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 小程序/App: uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码, // - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// 若在 JS 侧再 encodeURIComponent 一次,会被平台再编码一层,目标页 onLoad(options) // JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 只 decode 一次后仍是编码形态(如 %25E6%25B2%2599%25E5%258F%2591 -> %E6%B2%99%E5%8F%91) // 是已编码字符串(典型表现:商品分类 → 商品列表,
// => 这里必须保持原始值,完全交给平台去编码 // 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// - H5: 浏览器不会自动编码 url,若参数含 & ? = 等特殊字符会被 queryString 错位解析 // => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// => 这里仍需手动 encodeURIComponent,decode->encode roundtrip 兼容调用方已编码的入参 // 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
const queryStr = Object.keys(merged) const queryStr = Object.keys(merged)
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k])) .map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&'); .join('&');
@@ -576,6 +670,8 @@ export default {
const diyTabBar = store.state.tabBarList; const diyTabBar = store.state.tabBarList;
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) { if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
const cleanUrl = url.split('?')[0]; const cleanUrl = url.split('?')[0];
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
if (!isExcludedPage) {
const isDiyTabRoot = diyTabBar.list.some(item => { const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false; if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0]; const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
@@ -602,6 +698,7 @@ export default {
return; return;
} }
} }
}
console.log(`====>mode=${mode}, url=${url}`); console.log(`====>mode=${mode}, url=${url}`);
@@ -630,6 +727,41 @@ export default {
break; break;
case 'reLaunch': case 'reLaunch':
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1 // 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
// H5 平台修复 (2026-07-23):
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
// 注销/退出登录后用户能看到旧会员信息。
// location.replace 不产生新历史条目, 直接替换当前条目,
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
// #ifdef H5
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
&& typeof window.location.replace === 'function') {
window.location.replace(url);
return;
}
// #endif
// 小程序平台修复 (2026-07-23):
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
// 解决方案:
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
// #ifdef MP
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
return;
}
globalThis.__reLaunchLock = true;
setTimeout(() => {
uni.reLaunch({ url });
// 300ms 后释放锁
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
}, 0);
return;
// #endif
// APP / 其他平台: 原生 reLaunch
uni.reLaunch({ uni.reLaunch({
url url
}); });
@@ -986,14 +1118,14 @@ export default {
*/ */
isIPhoneX() { isIPhoneX() {
let res = this.getDeviceInfo(); let res = this.getDeviceInfo();
if (res.model.search('iPhone X') != -1) { if (res?.model?.search('iPhone X') != -1) {
return true; return true;
} }
return false; return false;
}, },
//判断安卓还是iOS //判断安卓还是iOS
isAndroid() { isAndroid() {
let platform = this.getDeviceInfo().platform; let platform = this.getDeviceInfo()?.platform;
if (platform == 'ios') { if (platform == 'ios') {
return false; return false;
} else if (platform == 'android') { } else if (platform == 'android') {
@@ -1004,21 +1136,21 @@ export default {
* 获取设备信息(包含降级处理) * 获取设备信息(包含降级处理)
*/ */
getDeviceInfo() { getDeviceInfo() {
try { let info = {};
return uni.getDeviceInfo(); try { info = uni.getDeviceInfo(); } catch (e) {
} catch (e) { try { info = uni.getSystemInfoSync(); } catch (e) {}
return uni.getSystemInfoSync();
} }
return info;
}, },
/** /**
* 获取窗口信息(包含降级处理) * 获取窗口信息(包含降级处理)
*/ */
getWindowInfo() { getWindowInfo() {
try { let info;
return uni.getWindowInfo(); try {info = uni.getWindowInfo();} catch (e) {
} catch (e) { try {info = uni.getSystemInfoSync();} catch (e) {}
return uni.getSystemInfoSync();
} }
return info;
}, },
/** /**
* 深度拷贝对象 * 深度拷贝对象
@@ -1072,6 +1204,23 @@ export default {
* @param {Object} link * @param {Object} link
*/ */
diyRedirectTo(link) { diyRedirectTo(link) {
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
const title = link.title || 'AI 智能体';
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
return this.redirectTo(AI_AGENT_PAGE_URL, {
agent_id: agentId,
title: encodeURIComponent(title),
enable_set_title: link.enable_set_title ? 1 : 0,
...(dyuEntry ? { __dyu: dyuEntry } : {}),
});
}
console.log(`-----> diyRedirectTo`, JSON.stringify(link)); console.log(`-----> diyRedirectTo`, JSON.stringify(link));
//if (link == null || Object.keys(link).length == 1) return; //if (link == null || Object.keys(link).length == 1) return;
@@ -1086,12 +1235,61 @@ export default {
link.wap_url = decodeEntity(link.wap_url); link.wap_url = decodeEntity(link.wap_url);
} }
// 修复: wap_url 自带的 uniacid query 必须与系统当前 uniacid 一致
// 原因: uniacid 是本平台租户 ID,会随租户迁移 / 平台调整而变化。
// 装修数据里的 wap_url?uniacid=2482 一旦与系统当前 uniacid(比如 2483)不一致,
// 说明这份数据可能是历史遗留或别的租户的(脏数据),
// 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
// 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
// ★ 必须在 resolveLinkUrl 之前判断 —— 否则 URL 的 query 被抽走就分不清了。
// ★ open_mode=internal 时不覆盖 —— 用户显式强制内部, 表示"我知道我在做什么"。
if (_hasStaleUniacidInUrl(link.wap_url, _getCurrentUniacid())
&& _resolveNavMode(link) !== 'internal') {
link.open_mode = 'online';
}
// 优先用 page_key 解析实际URL(新机制,向后兼容) // 优先用 page_key 解析实际URL(新机制,向后兼容)
const resolvedUrl = resolveLinkUrl(link); const resolvedUrl = resolveLinkUrl(link);
if (link.page_key && resolvedUrl) { if (link.page_key && resolvedUrl) {
link.wap_url = resolvedUrl; link.wap_url = resolvedUrl;
} }
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
// - MP 走 web-view → 本应原生跳转的内部页被塞进 web-view
// 这里把"指向本 App 内部页面"的绝对 URL 还原为相对路径,使其改走下方内部页面分支(SPA 跳转)。
//
// 由 open_mode 统一控制(顶层字段 或 page_params):
// - 'auto'(默认): 双闸门 —— host 与本 App 域名【完全一致】且路径带 /pages 段才还原。
// 不同子域/不同域名一律当外链,彻底避免误伤 Shopify、Facebook 等外站。
// - 'internal': 强制内部 —— 跳过 host 校验, 只要路径带 /pages 段就还原为 SPA 跳转。
// 用途: 数据里链接域名与当前部署域名不同(如 xcx3 vs xcx6)但确认是同一 App 内部页。
// - 'online': 强制线上 —— 不还原, 保持整页刷新 / web-view。
// 用途: 某些自域名页面是非 SPA / 遗留页,必须整页或 web-view 打开。
if (link.wap_url && /^https?:\/\//i.test(link.wap_url)) {
const navMode = _resolveNavMode(link); // 'internal' | 'online' | 'auto'
if (navMode !== 'online') {
let internalPath;
if (navMode === 'internal') {
// 强制内部: 传 true 跳过 host 校验
internalPath = _extractInternalAppPath(link.wap_url, true);
} else {
// auto: host 完全一致才还原
const appHosts = [];
// #ifdef H5
try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) { }
// #endif
try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) { }
try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) { }
internalPath = _extractInternalAppPath(link.wap_url, appHosts);
}
if (internalPath) {
link.wap_url = internalPath;
}
}
}
// 外部链接 // 外部链接
if (link.wap_url && link.wap_url.indexOf('http') != -1) { if (link.wap_url && link.wap_url.indexOf('http') != -1) {
// 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params) // 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params)
@@ -1274,7 +1472,7 @@ export default {
*/ */
uniappIsIPhone11() { uniappIsIPhone11() {
let isIphone11 = false; let isIphone11 = false;
let systemInfo = this.getDeviceInfo(); let systemInfo = this?.getDeviceInfo();
// #ifdef MP // #ifdef MP
if (systemInfo.model.search('iPhone 11') != -1) { if (systemInfo.model.search('iPhone 11') != -1) {
isIphone11 = true; isIphone11 = true;
@@ -1285,8 +1483,7 @@ export default {
// #ifdef H5 // #ifdef H5
//判断该浏览器是否为safaria浏览器 //判断该浏览器是否为safaria浏览器
isSafari() { isSafari() {
let res = this.getDeviceInfo(); var ua = navigator?.userAgent?.toLowerCase();
var ua = navigator.userAgent.toLowerCase();
if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 && if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 &&
ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 && ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 &&
ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) { ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) {
@@ -1447,6 +1644,13 @@ 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) { getMpShare(path) {
// 如果没有特别指定 则获取当前页面的路由 // 如果没有特别指定 则获取当前页面的路由
@@ -1454,52 +1658,104 @@ export default {
let route = this.getCurrentRoute(); let route = this.getCurrentRoute();
path = route.path; path = route.path;
if (path == '/pages_tool/member/index') { if (path == '/pages_tool/member/index') {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
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({ Http.sendRequest({
url: "/weapp/api/weapp/share", url: "/weapp/api/weapp/share",
data: { data: {
path: path path: path
}, },
success: res => { success: res => {
try {
if (!res || typeof res !== 'object') {
resolve(fallback);
return;
}
if (res.code >= 0) { if (res.code >= 0) {
let shareConfig = res.data.data; // 响应可能是两种形态:
// 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;
}
}
if (shareConfig) { if (shareConfig) {
//分享给好友 resolve(buildFromConfig(shareConfig));
let appMessageData = { } else {
title: shareConfig.title, // 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
path: shareConfig.path, // 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
imageUrl: shareConfig.imageUrl, resolve(fallback);
success: res => { }
} else {
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
resolve(fallback);
}
} catch (e) {
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
resolve(fallback);
}
}, },
fail: res => { fail: () => {
} // 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
} resolve(fallback);
//分享到朋友圈 },
let query = ''; complete: () => {}
if (shareConfig.path.indexOf('?') > 0) {
query = shareConfig.path.split('?')[1];
}
let timeLineData = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl,
}
resolve({
appMessage: appMessageData,
timeLine: timeLineData,
})
} else {
reject(res.data);
}
} else {
reject(res.data)
}
}
}); });
}) })
}, },
@@ -1845,4 +2101,67 @@ export default {
return newObj; return newObj;
}, },
/**
* rpx 单位按端适配转换(uni-app 标准约定)
*
* 后端 formConfig / diyview 等接口返回的数值字段(label_font_size / input_height /
* row_min_height 等)一律以 rpx 传递。前端处理:
* - 微信小程序 / App / 鸿蒙元服务:原生支持 rpx,直接返回 `${val}rpx`
* - H5(WebView):浏览器不认 rpx,通过 uni.upx2px 转 px 返回 `${uni.upx2px(val)}px`
*
* 主要用于 JS 动态生成的 CSS 值(computed 里的 inline style、CSS 变量赋值)。
* Vue 的 :style binding 对普通 CSS 属性会自动转 rpx→px,但对 CSS 自定义属性
* (以 -- 开头的变量)不会做单位转换,因此必须经此方法处理。
*
* @param {number|string} val 后端返回的 rpx 数值
* 可以是数字(如 60)、字符串数字('60')、带 px 后缀的字符串('30px',原样返回)
* 或带 rpx 后缀的字符串('60rpx',先剥后缀再处理)
* @returns {string} 带单位的 CSS 值(H5 返回 'Npx',其它端返回 'Nrpx');
* val 为空/undefined 时返回 ''
*/
rpx(val) {
if (val === undefined || val === null || val === '') return '';
// 已带 px 后缀的,原样返回
if (typeof val === 'string' && /px$/i.test(val.trim())) return val.trim();
// 已带 rpx 后缀的,剥掉(避免 '60rpx' + 'rpx' → '60rpxrpx' 的双重后缀 bug)
if (typeof val === 'string' && /rpx$/i.test(val.trim())) {
val = parseFloat(val);
}
// #ifdef H5
if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') {
return uni.upx2px(Number(val) || 0) + 'px';
}
return (Number(val) || 0) / 2 + 'px';
// #endif
// #ifndef H5
return val + 'rpx';
// #endif
},
/**
* 计算最长 label 宽度(rpx 单位)
*
* 与后端 backend/src/app/component/view/form/js/design.js calculateMaxLabelWidth
* 算法对齐:取所有 title 中最长字符数(必填 +1),按 fontSize(rpx)近似换算宽度。
* 后端在 H5 中按 1rpx = 0.5px 计算宽度,再加 12 边距。
*
* @param {Array} formData 表单数据数组(item.value.title 存在即参与计算)
* @param {number|string} labelFontSize 标签字号(rpx)
* @returns {number} 最长 label 宽度,单位 rpx
*/
calculateMaxLabelWidth(formData, labelFontSize) {
if (!formData || formData.length === 0) return 0;
const fontSize = parseInt(labelFontSize) || 28;
let maxChars = 0;
formData.forEach(item => {
if (!item.value || !item.value.title) return;
let len = (item.value.title || '').length;
if (item.value.required) len += 1;
if (len > maxChars) maxChars = len;
});
// chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
return Math.round(widthPx * 2);
},
} }

2090
common/js/util.js.bak Normal file

File diff suppressed because it is too large Load Diff

92
common/js/video-helper.js Normal file
View File

@@ -0,0 +1,92 @@
// iOS 微信小程序 <video> 兼容工具
// 问题背景:
// 1) 弹窗内的 <video> 用 autoplay 不可靠——popup 初始隐藏,autoplay 在隐藏态不触发,
// 等 popup 显示后也不会重新 autoplay,导致"安卓有时能播、iOS 不能";
// 2) iOS 对同一个 <video> 实例在 src 重绑 / 弹窗重开 / 播放结束后,不会自动重新加载,
// 需要靠 :key 重建实例或 seek(0) 才能重播;
// 3) iOS 微信不支持 webm / vp9 / hevc / ogv 等封装或编码,会直接黑屏;
// 4) H5 端(含微信内置浏览器)$nextTick 调 play() 太快,弹窗动画未完时浏览器内部
// 会自动 pause(),导致 "The play() request was interrupted by a call to pause()" 错误。
// 这里统一提供"格式检测"与"在视频可见后触发播放"的能力,供各视频组件复用。
//
// 重要:微信小程序的 <video>.play() 不要求用户手势(与 H5 不同),只要在视频元素
// 已经渲染、popup 已经显示后调用即可。因此请务必在 uni-popup 的 @change(show=true)
// 回调里调用本方法,而不是在 open() 里紧跟着 $nextTick 调用(那时元素尚未可见)。
// iOS 微信 <video> 已知不支持的封装/编码(仅按扩展名做保守判断,用于给出友好提示)
const UNSUPPORTED_EXT_ON_IOS = ['webm', 'ogv', 'ogg', 'mov', 'm4v', 'hevc', 'av1', '3gp'];
// 取 URL 中的小写扩展名(忽略查询参数与 hash)
export function getVideoExt(url) {
if (!url || typeof url !== 'string') return '';
const clean = url.split('?')[0].split('#')[0];
const idx = clean.lastIndexOf('.');
if (idx < 0) return '';
return clean.slice(idx + 1).toLowerCase();
}
// 该地址在 iOS 上是否很可能无法播放(基于扩展名的保守判断)
export function isLikelyUnsupportedOnIOS(url) {
const ext = getVideoExt(url);
if (!ext) return false;
return UNSUPPORTED_EXT_ON_IOS.indexOf(ext) !== -1;
}
// 在"视频已经渲染、popup 已经显示"的前提下触发播放。
// 必须在 uni-popup 的 @change(show=true) 回调里调用,此时 <video> 元素已可见,
// createVideoContext 才能拿到有效实例。
// videoId: <video> 的 id;instance: 当前组件/页面实例(组件内必传)。
export function playVideoInContext(videoId, instance) {
if (!videoId || !instance) return;
// H5 端需要更长延迟:等 uni-popup 动画播完(~350ms),否则浏览器内部会自动
// pause() 导致 "play() interrupted by pause()" 错误;小程序端 $nextTick 即可。
// #ifdef H5
const delay = 400;
// #endif
// #ifndef H5
const delay = 0;
// #endif
const doPlay = () => {
let ctx = null;
try {
ctx = uni.createVideoContext(videoId, instance);
} catch (e) {
console.error('[video-helper] createVideoContext failed:', e);
return;
}
if (!ctx) return;
try {
ctx.play();
} catch (e) {
console.error('[video-helper] play() failed, will retry:', e);
// H5 端首次 play() 常被浏览器自动 pause() 打断,延迟重试一次
// #ifdef H5
setTimeout(() => {
try { ctx.play(); } catch (retryErr) {
console.error('[video-helper] retry play() also failed:', retryErr);
}
}, 300);
// #endif
}
};
if (delay > 0) {
// H5:先等 nextTick(DOM 更新),再等 delay(弹窗动画完成)
if (typeof instance.$nextTick === 'function') {
instance.$nextTick(() => setTimeout(doPlay, delay));
} else {
setTimeout(doPlay, delay);
}
} else {
// 小程序端:nextTick 即可
if (typeof instance.$nextTick === 'function') {
instance.$nextTick(doPlay);
} else {
doPlay();
}
}
}

View File

@@ -1,6 +1,11 @@
<template> <template>
<view data-component-name="diy-bottom-nav" v-if="shouldShowTabBar"> <!-- 修复底部 TabBar 频闪:
<view class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }"> 微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
<view class="tabbar-border"></view> <view class="tabbar-border"></view>
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)"> <view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
<view class="bd"> <view class="bd">
@@ -38,7 +43,7 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 解决fixed定位后底部导航栏塌陷问题 --> <!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
<view class="tab-bar-placeholder"></view> <view class="tab-bar-placeholder"></view>
</view> </view>
</template> </template>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -265,7 +265,7 @@ export default {
// #ifndef MP-WEIXIN // #ifndef MP-WEIXIN
if (item?.channelType === 'wechat') { if (item?.channelType === 'wechat') {
uni.showToast({ uni.showToast({
title: '视频号仅支持在微信小程序环境中播放', title: this.$lang('wechatChannel.wechatMpOnly'),
icon: 'none', icon: 'none',
duration: 2000 duration: 2000
}); });

View File

@@ -1,34 +1,23 @@
<template> <template>
<view :style="componentStyle" data-component-name="diy-digit"> <view :style="componentStyle" data-component-name="diy-digit">
<scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]" <scroll-view :class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
:scroll-x="value.showStyle == 'singleSlide'"> :scroll-x="value.showStyle == 'singleSlide'">
<view class="uni-scroll-view-content"> <view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" <view v-for="(item, index) in value.list" :key="index"
:class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']" :class="['graphic-nav-item', value.mode, value.mode === 'text' ? 'newright' : '']"
:style="{ width: (100 / value.rowCount + '%') + ';' }"> :style="itemStyle">
<view style="display:flex;"> <view :style="innerWrapStyle">
<view :style="{ <view :style="innerRowStyle">
'line-height': '1.2;',
'font-size': (value.font.titlesize * 2 + 'rpx') + ';',
'font-weight': '600;',
'color': value.font.titlecolor + ';'
}">
<uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title" <uv-count-to :ref="`countTo-${index}`" :autoplay="true" :startVal="30" :endVal="item.title"
:decimals="getvalue(item.title)" decimal="."></uv-count-to> :decimals="getvalue(item.title)" decimal="."
<text :style="{ :color="value.font.titlecolor"
'margin-left': '4rpx;', :fontSize="titleFontSize"
'font-size': (value.font.unitsize * 2 + 'rpx') + ';', :bold="isBold(value.font.weight)"></uv-count-to>
'font-weight': value.font.weight + ';', <text :style="unitStyle">{{ item.unit }}</text>
'color': value.font.unitcolor + ';'
}">{{ item.unit }}</text>
</view> </view>
</view> </view>
<view class="graphic-text"> <view class="graphic-text">
<text :style="{ <text :style="descStyle">{{ item.desc }}</text>
'font-size': (value.font.descsize * 2 + 'rpx') + ';',
'font-weight': value.font.weight + ';',
'color': value.font.desccolor + ';'
}">{{ item.desc }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -54,10 +43,20 @@ export default {
pageWidth: '', pageWidth: '',
indicatorDots: false, indicatorDots: false,
swiperCurrent: 0 swiperCurrent: 0
// #ifndef MP
,windowWidth: 375
// #endif
} }
}, },
created() { 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: { watch: {
componentRefresh(newValue) { componentRefresh(newValue) {
@@ -65,6 +64,42 @@ export default {
} }
}, },
computed: { 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() { componentStyle() {
let style = ''; let style = '';
style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;'; style += 'background-image:url(' + this.$util.img(this.value.imageUrl) + ');background-size:100% 100%;';
@@ -83,11 +118,25 @@ export default {
} }
}, },
methods: { 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) { getvalue(value) {
return value % 1 !== 0 ? 2 : 0; return value % 1 !== 0 ? 2 : 0;
}, },
// 是否加粗
isBold(weight) {
return weight === 'bold' || (weight && Number(weight) >= 600);
},
// 页面跳转 // 页面跳转
redirectTo(item) { redirectTo(item) {
if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) { if (!item.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -257,4 +306,5 @@ export default {
} }
} }
} }
</style> </style>

View File

@@ -3,7 +3,7 @@
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }" :class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
:style="style"> :style="style">
<block v-for="(item, index) in value.list" :key="index"> <block v-for="(item, index) in value.list" :key="index">
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)" <view class="button-box" @tap.stop="onFloatBtnClick(item)"
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }"> :style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit" <image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
show-menu-by-longpress /> show-menu-by-longpress />
@@ -44,7 +44,44 @@ export default {
componentRefresh: function (nval) { } componentRefresh: function (nval) { }
}, },
components: {}, components: {},
methods: {}, methods: {
/**
* 浮动按钮点击处理:
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
*
* 为什么用 __dyu 这种 3 字符命名:
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
* - URL 中占用空间极小,不污染业务 query 字符串
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
*
* 实现要点:
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
* 目标页 onLoad(options) 可直接读取
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
*/
onFloatBtnClick(item) {
if (!item || !item.link) return;
const link = { ...item.link };
link.page_params = Object.assign({}, link.page_params || {}, {
__dyu: 'FloatBtn'
});
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
if (link.wap_url.indexOf('__dyu=') === -1) {
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
}
}
this.$util.diyRedirectTo(link);
},
},
computed: { computed: {
style() { style() {
let style = {}, let style = {},

View File

@@ -1,32 +1,84 @@
<template> <template>
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle"> <view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
<!-- Banner --> <!-- Banner -->
<view v-if="value.show_banner && value.form_banner" class="form-banner" :style="bannerStyle"> <!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
<image :src="$util.img(value.form_banner)" mode="widthFix" class="banner-img" /> 下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
否则会触发 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 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>
<view v-if="value.show_tip && value.form_tip" class="form-tip" :style="tipStyle">{{ value.form_tip }}</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 <ns-newform
:data="formComponents" :data="formComponents"
:customAttr="value" :customAttr="value"
:mode="mergedFormConfig.formLayout || 'horizontal'" :mode="mergedFormConfig.formLayout || 'vertical'"
:formConfig="mergedFormConfig" :formConfig="mergedFormConfig"
ref="nsNewform" ref="nsNewform"
></ns-newform> ></ns-newform>
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。 <!-- 提交按钮(inside,默认):在内容卡内 -->
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 --> <view v-if="showSubmit && submitPosition !== 'outside'" class="form-submit-wrap" :style="submitWrapStyle">
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle"> <view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view> </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> </template>
<script> <script>
// 自定义表单 // 自定义表单
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
import DiyMinx from './minx.js' import DiyMinx from './minx.js'
import NsNewform from '@/components/ns-newform/ns-newform.vue' 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 { export default {
name: 'diy-from', name: 'diy-from',
components: { NsNewform }, components: { NsNewform },
@@ -47,6 +99,20 @@ export default {
showSubmit: { showSubmit: {
type: Boolean, type: Boolean,
default: true 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: {
type: Function,
default: null
} }
}, },
mixins: [DiyMinx], mixins: [DiyMinx],
@@ -70,13 +136,15 @@ export default {
}, },
computed: { computed: {
style() { style() {
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
var css = ''; var css = '';
css += 'background-color:' + this.value.contentBgColor + ';'; var v = this.value || {};
if (this.value.elementAngle === 'round') { css += 'background-color:' + (v.contentBgColor || '') + ';';
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;'; if (v.elementAngle === 'round') {
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;'; css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;'; css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;'; css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
} }
return css; return css;
}, },
@@ -90,24 +158,292 @@ export default {
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx` 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() { tipStyle() {
const cfg = this.mergedFormConfig || {}; 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 = { 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`, 'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || '#000000', 'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
'background-color': cfg.tipBgColor || 'transparent' 'background-color': cfg.tipBgColor || 'transparent',
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
}; };
// 仅在配置了 tipBorderColor 时添加底线,避免 transparent 占位仍增加 2rpx 高度 // 边框:配置了宽度(>0)输出;未配宽度但配了颜色 → 旧数据兼容,2rpx 底边(仅经典态)
if (cfg.tipBorderColor) { 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}`; 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); return this.$util.objToStyle(style);
}, },
// 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color) // 提交按钮外层:与后端 .form-preview-submit 对齐(margin-top + padding: row-padding-y form-padding-x;background: form-bg-color)
// card + inside(默认)时按钮已在内容卡内:padding 收窄为卡内边距(≈后端预览 14px 12px 20px),不设独立背景(继承卡白底)
submitWrapStyle() { submitWrapStyle() {
const cfg = this.mergedFormConfig || {}; 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({ return this.$util.objToStyle({
marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`, marginTop: `${this._num(cfg.submitMarginTop, 0)}rpx`,
padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`, padding: `${this._num(cfg.rowPaddingY, 4)}rpx ${this._num(cfg.formPaddingX, 14)}rpx`,
@@ -131,7 +467,7 @@ export default {
width: widthPct + '%', width: widthPct + '%',
padding: `${submitPaddingY}rpx 0`, padding: `${submitPaddingY}rpx 0`,
color: cfg.submitColor || '#ffffff', color: cfg.submitColor || '#ffffff',
'background-color': cfg.submitBgColor || '#ff4544', 'background-color': cfg.submitBgColor || '#3b82f6',
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`, 'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx` 'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
}); });
@@ -141,6 +477,10 @@ export default {
submitText() { submitText() {
return this.mergedFormConfig.submitText || '提交'; return this.mergedFormConfig.submitText || '提交';
}, },
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
hasCustomSubmit() {
return typeof this.onSubmit === 'function';
},
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。 // 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。 // 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
formCfg() { formCfg() {
@@ -162,12 +502,33 @@ export default {
if (v1 !== undefined && v1 !== null && v1 !== '') return v1; if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
return fc[snake]; 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 字段名一致) // 通用全局配置(与后端 design.js getDefaultFormData().formConfig 字段名一致)
const base = { const base = {
formPaddingX: pick('formPaddingX', 'form_padding_x'), formPaddingX: pick('formPaddingX', 'form_padding_x'),
// Banner 高度(rpx)。后台可配,默认 180rpx ≈ 页面 1/8 高度(适配主流手机屏高)
// 配合 mode="aspectFill" 居中裁剪,保证横幅只占页面八分之一左右
bannerHeight: pick('bannerHeight', 'banner_height'),
bannerPaddingX: pick('bannerPaddingX', 'banner_padding_x'), 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'), 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'), rowMinHeight: pick('rowMinHeight', 'row_min_height'),
labelGap: pick('labelGap', 'label_gap'), labelGap: pick('labelGap', 'label_gap'),
formBgColor: pick('formBgColor', 'form_bg_color'), formBgColor: pick('formBgColor', 'form_bg_color'),
@@ -180,8 +541,24 @@ export default {
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'), labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'), labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'), labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
labelAutoMaxWidth: pick('labelAutoMaxWidth', 'label_auto_max_width'),
requiredColor: pick('requiredColor', 'required_color'), requiredColor: pick('requiredColor', 'required_color'),
// 必填星号位置:before=标签前(默认)/ after=标签后(终端无关,存 base 层)
requiredPosition: pick('requiredPosition', 'required_position'),
checkedColor: pick('checkedColor', 'checked_color'), checkedColor: pick('checkedColor', 'checked_color'),
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
textareaRows: pick('textareaRows', 'textarea_rows'),
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
submitText: pick('submitText', 'submit_text'), submitText: pick('submitText', 'submit_text'),
submitColor: pick('submitColor', 'submit_color'), submitColor: pick('submitColor', 'submit_color'),
submitBgColor: pick('submitBgColor', 'submit_bg_color'), submitBgColor: pick('submitBgColor', 'submit_bg_color'),
@@ -189,11 +566,37 @@ export default {
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'), submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
submitSize: pick('submitSize', 'submit_size'), submitSize: pick('submitSize', 'submit_size'),
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'), submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
// 提示语 // 提示语(与后端 form.js syncFormConfigToCSS 的 --tip-* 变量一一对应)
tipFontSize: pick('tipFontSize', 'tip_font_size'), tipFontSize: pick('tipFontSize', 'tip_font_size'),
tipColor: pick('tipColor', 'tip_color'), tipColor: pick('tipColor', 'tip_color'),
tipBgColor: pick('tipBgColor', 'tip_bg_color'), tipBgColor: pick('tipBgColor', 'tip_bg_color'),
tipBorderColor: pick('tipBorderColor', 'tip_border_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) // 移除 undefined / 空字符串(保留 0/false)
Object.keys(base).forEach(function(k) { Object.keys(base).forEach(function(k) {
@@ -201,9 +604,33 @@ export default {
delete 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 子对象内) // 平台差异化配置(terminal 块在 formConfig 子对象内)
// 优先级:v.formConfig.terminal > v.form_config_terminal > v.terminal > v.form_config_platform // 优先级: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; const termBlock = (v.formConfig && v.formConfig.terminal) || v.form_config_terminal || v.terminal || v.form_config_platform;
if (termBlock && typeof termBlock === 'object') { if (termBlock && typeof termBlock === 'object') {
Object.keys(termBlock).forEach(function(platform) { Object.keys(termBlock).forEach(function(platform) {
@@ -269,24 +696,26 @@ export default {
return true; return true;
}, },
// 提交表单 // 统一提交入口:内置按钮 / 父级 ref 调用都走这里
submitForm() { // 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
var self = this; handleSubmit() {
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示) // 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) { if (!this.verify()) {
return; return;
} }
const formData = this.getFormData();
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid) // 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
if (this.$listeners && this.$listeners.submit) { if (this.hasCustomSubmit) {
this.$emit('submit', this.getFormData()); return this.onSubmit(formData);
return;
} }
// 兜底:内置 sendRequest 流程
this.defaultSubmit(formData);
},
// 获得保单数据 // 公共兜底实现:默认的 /form/api/form/create 流程;
const submitFormData = this.getFormData(); // 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
defaultSubmit(submitFormData) {
// 提交到后端 var self = this;
this.$api.sendRequest({ this.$api.sendRequest({
url: '/form/api/form/create', url: '/form/api/form/create',
data: { data: {
@@ -298,7 +727,7 @@ export default {
success: function(res) { success: function(res) {
if (res.code === 0) { if (res.code === 0) {
uni.showToast({ uni.showToast({
title: '提交成功', title: self.$lang('submitSuccess'),
icon: 'success' icon: 'success'
}); });
// 重置表单 // 重置表单
@@ -307,7 +736,7 @@ export default {
} }
} else { } else {
uni.showToast({ uni.showToast({
title: res.message || '提交失败', title: res.message || self.$lang('submitFail'),
icon: 'none' icon: 'none'
}); });
} }
@@ -330,12 +759,139 @@ export default {
// 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖) // 默认左右无边距(与后端 --banner-padding-x 对齐;由内联 bannerStyle 覆盖)
margin: 0; 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 { .banner-img {
width: 100%; width: 100%;
display: block; 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 { .form-tip {
// 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入 // 提示语:上下内边距 / 左右内边距 / 字号 / 颜色 / 背景 全部由 tipStyle 内联写入
padding: 0; padding: 0;

View File

@@ -159,7 +159,10 @@
<!-- 商品推荐 --> <!-- 商品推荐 -->
<diy-goods-recommend :value="item"></diy-goods-recommend> <diy-goods-recommend :value="item"></diy-goods-recommend>
</template> </template>
<template v-if="item.componentName == 'DiyRecommendCode'">
<!-- 自定义推荐码组件 -->
<diy-recommend-code :value="item"></diy-recommend-code>
</template>
<template v-if="item.componentName == 'GoodsBrand'"> <template v-if="item.componentName == 'GoodsBrand'">
<!-- 商品品牌 --> <!-- 商品品牌 -->
<diy-goods-brand :value="item"></diy-goods-brand> <diy-goods-brand :value="item"></diy-goods-brand>
@@ -306,9 +309,11 @@ export default {
} }
}, },
created() { created() {
console.log('[DIY-GROUP][created] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
this.processData(); this.processData();
}, },
mounted() { mounted() {
console.log('[DIY-GROUP][mounted] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
if (this.diyData && this.diyData.value) { if (this.diyData && this.diyData.value) {
this._notifyLoaded(); this._notifyLoaded();
} }
@@ -349,8 +354,10 @@ export default {
}, },
methods: { methods: {
_notifyLoaded() { _notifyLoaded() {
console.log('[DIY-GROUP][_notifyLoaded] enter, will emit loaded in 300ms');
if (this._loadedTimer) clearTimeout(this._loadedTimer); if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => { this._loadedTimer = setTimeout(() => {
console.log('[DIY-GROUP][_notifyLoaded] emit loaded');
this.$emit('loaded'); this.$emit('loaded');
}, 300); }, 300);
}, },
@@ -370,7 +377,13 @@ export default {
: index + 1; : index + 1;
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') { 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) { if (newItem.margin) {
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`; newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;

View File

@@ -1,10 +1,16 @@
<template> <template>
<view :style="componentStyle" data-component-name="diy-image-nav"> <view :style="componentStyle" data-component-name="diy-image-nav">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]" <!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
:scroll-x="value.showStyle == 'singleSlide'"> slide=滑动时显示(默认) / always=始终显示 / never=永不显示
小程序端: show-scrollbar 仅在 enhanced=true 时生效, 所以两者同步绑定 -->
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
:style="imageSpacingStyle"
:scroll-x="value.showStyle == 'singleSlide'"
:show-scrollbar="scrollBarMode != 'never'"
:enhanced="scrollBarMode != 'never'">
<view class="uni-scroll-view-content"> <view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]" <view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
style="margin-right: 28rpx;"> :style="imageItemStyle">
<!-- 图片部分 --> <!-- 图片部分 -->
<view v-if="value.mode != 'text'" class="image-img" :style="{ <view v-if="value.mode != 'text'" class="image-img" :style="{
'font-size': (value.imageSize * 2 + 'rpx') + ';', 'font-size': (value.imageSize * 2 + 'rpx') + ';',
@@ -60,7 +66,10 @@ export default {
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
slideStartX: 0, slideStartX: 0,
slideStartScrollLeft: 0, slideStartScrollLeft: 0,
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转 _suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
slideScrolling: false
} }
}, },
created() { created() {
@@ -75,12 +84,14 @@ export default {
var self = this; var self = this;
this.$nextTick(function() { this.$nextTick(function() {
self.bindSlideMouseEvents(); self.bindSlideMouseEvents();
self.bindSlideScrollbar();
}); });
// #endif // #endif
}, },
beforeDestroy() { beforeDestroy() {
// #ifdef H5 // #ifdef H5
this.unbindSlideMouseEvents(); this.unbindSlideMouseEvents();
this.unbindSlideScrollbar();
// #endif // #endif
}, },
watch: { watch: {
@@ -89,9 +100,47 @@ export default {
} }
}, },
computed: { 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() { componentStyle() {
let style = ''; let style = '';
style += 'background-color:' + this.value.componentBgColor + ';'; if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
if (this.value.componentAngle == 'round') { if (this.value.componentAngle == 'round') {
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;'; style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
@@ -123,9 +172,219 @@ export default {
isIndicatorDots() { isIndicatorDots() {
return this.value.carousel.type != 'hide' && return this.value.carousel.type != 'hide' &&
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount)); 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;
},
// ========== 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: { methods: {
// ========== 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;
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
},
// 页面跳转 // 页面跳转
redirectTo(link) { redirectTo(link) {
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) { if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -327,17 +586,61 @@ export default {
// #ifdef H5 // #ifdef H5
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge legacy
&::-webkit-scrollbar {
display: none;
}
// 禁止鼠标选中文字(拖拽时选中会显得很别扭) // 禁止鼠标选中文字(拖拽时选中会显得很别扭)
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权 // 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
-webkit-overflow-scrolling: touch; -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 // #endif
} }

View File

@@ -6,13 +6,18 @@
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image> <image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
</view> </view>
<view class="fui-list-inner"> <view class="fui-list-inner">
<view class="title" style="height: 75rpx;"> <view class="title">
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text> <text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }} <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> </view>
</view> <!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;"> <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" <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> :style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
</view> </view>
@@ -35,15 +40,31 @@ export default {
data() { data() {
return { return {
loading: true, loading: true,
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
mpTitleSize: 0,
mpDescSize: 0
}; };
}, },
created() { created() {
// this.getDataList(); // this.getDataList();
}, },
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
mounted() {
this.$nextTick(this.fitOneLineText)
},
watch: { watch: {
// 组件刷新监听 // 组件刷新监听
componentRefresh: function (nval) { componentRefresh: function (nval) {
// this.getDataList(); // this.getDataList();
this.$nextTick(this.fitOneLineText)
},
value: {
deep: true,
handler() {
this.$nextTick(this.fitOneLineText)
}
} }
}, },
computed: { computed: {
@@ -66,9 +87,130 @@ export default {
current: img, current: img,
urls: [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> </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 --> <!-- #ifdef MP -->
<block <block
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile"> 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> </block>
<view class="nickname" v-else> <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" <view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level"> v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
<!-- <text class="icondiy icon-system-huangguan"></text> --> <!-- <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> <view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
</block> </block>
<view class="nickname" v-else> <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> }}</text>
<view class="member-level" <view class="member-level"
v-if="(value.style == 1 || value.style == 2) && memberInfo.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> <image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
</view> </view>
<view class="info"> <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 class="desc">{{ $lang('member.index.loginTips') }}</view>
</view> </view>
@@ -367,6 +367,26 @@ export default {
isDisabled() { isDisabled() {
if (this.nickName.length > 0) return false; if (this.nickName.length > 0) return false;
return true; 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: { methods: {

View File

@@ -10,8 +10,8 @@
@error="imgError(index)" show-menu-by-longpress /> @error="imgError(index)" show-menu-by-longpress />
</view> </view>
<view class="info-wrap"> <view class="info-wrap">
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text> <text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
<text class="desc">{{ item.desc }}</text> <text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
<view class="read-wrap"></view> <view class="read-wrap"></view>
</view> </view>
</view> </view>
@@ -179,6 +179,25 @@ export default {
this.toDetail(item); 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 { .info-wrap {
flex: 1;
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
// flex: 1;
// display: flex; // display: flex;
// flex-direction: column; // flex-direction: column;
// justify-content: space-between; // justify-content: space-between;
.desc { .desc {
color: #888 color: #888;
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.title { .title {
@@ -311,8 +336,9 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
white-space: nowrap;
font-size: 30rpx; font-size: 30rpx;
line-height: 1.5; line-height: 1.5;
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -55,6 +55,7 @@
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;"> <uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
<view class="video-container" :style="popupVideoStyle"> <view class="video-container" :style="popupVideoStyle">
<video <video
:key="video_url"
ref="popupVideoRef" ref="popupVideoRef"
class="adaptive-video" class="adaptive-video"
:autoPauseIfNavigate="true" :autoPauseIfNavigate="true"
@@ -65,6 +66,9 @@
:src="video_url" :src="video_url"
:controls="true" :controls="true"
object-fit="contain" object-fit="contain"
playsinline
webkit-playsinline
x5-playsinline
@loadedmetadata="onPopupVideoLoaded" @loadedmetadata="onPopupVideoLoaded"
@error="onPopupVideoError" @error="onPopupVideoError"
></video> ></video>
@@ -144,41 +148,21 @@ export default {
// 弹窗视频容器样式 // 弹窗视频容器样式
popupVideoStyle() { popupVideoStyle() {
let style = 'position:fixed;width:100%;left:0;'; // 容器横向铺满,黑色背景填满左右边界
const defaultRatio = 9 / 16; // 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
let ratio = defaultRatio;
if (this.popupVideoWidth && this.popupVideoHeight) {
ratio = this.popupVideoHeight / this.popupVideoWidth;
}
const isPortrait = ratio > 1;
if (isPortrait) {
style += 'top:50%;transform:translateY(-50%);';
} else {
style += 'top:20%;';
}
// #ifdef H5
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-WEIXIN
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-HARMONY
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
// #endif
return style; return style;
} }
}, },
methods: { methods: {
// 显示视频播放弹窗 // 显示视频播放弹窗
showVideo(item) { showVideo(item) {
this.video_url = item.videoUrl; let url = item.videoUrl || '';
// 处理视频URL:完整链接直接使用,否则通过 $util.img() 转换
if (url && url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
url = this.$util.img(url);
}
this.video_url = url;
this.popupVideoWidth = 0; this.popupVideoWidth = 0;
this.popupVideoHeight = 0; this.popupVideoHeight = 0;
this.$refs.videoPopup.open(); this.$refs.videoPopup.open();
@@ -625,22 +609,6 @@ export default {
left: 0; left: 0;
} }
/* #ifdef MP-WEIXIN */
.adaptive-video {
position: absolute;
top: 0;
left: 0;
}
/* #endif */
/* #ifdef MP-HARMONY */
.adaptive-video {
position: static;
display: block;
box-sizing: border-box;
}
/* #endif */
.video-nav { .video-nav {
padding: 16rpx; padding: 16rpx;
box-sizing: border-box; box-sizing: border-box;

View File

@@ -11,6 +11,9 @@
:style="videoWarpCss" :style="videoWarpCss"
controls controls
preload="metadata" preload="metadata"
playsinline
webkit-playsinline
x5-playsinline
@loadedmetadata="onVideoLoaded" @loadedmetadata="onVideoLoaded"
@click="handlerClick(videoUrl)" @click="handlerClick(videoUrl)"
></video> ></video>
@@ -106,11 +109,11 @@ export default {
} }
// #ifdef H5 // #ifdef H5
style += 'padding-top: ' + (ratio * 100) + '%;'; style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
// #endif // #endif
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
style += 'padding-top: ' + (ratio * 100) + '%;'; style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
// #endif // #endif
// #ifdef MP-HARMONY // #ifdef MP-HARMONY

View File

@@ -1,5 +1,56 @@
export default { export default {
methods: { 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}) { async __$emitEvent(payload = {eventName: '__unnamedEvent', data: {}, promiseCallback: null}) {
await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback) await this.$eventBus.emit(payload.eventName, payload.data, payload.promiseCallback)

View File

@@ -743,7 +743,53 @@ export default {
this.$langConfig.change(targetLang, url); this.$langConfig.change(targetLang, url);
}, },
/**
* 客服按钮点击的统一入口。
*
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
*
* 为什么不在 customer-service.js#openAiChatService 统一注入:
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
* 其他入口保持原样。
*
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
*/
handleUnifiedKefuClick() { handleUnifiedKefuClick() {
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
// 与底部 tabBar 无关,继续走原统一入口。
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
// 清除未读数(与 openAiChatService 行为保持一致)
if (typeof this.setAiUnreadCount === 'function') {
this.setAiUnreadCount(0);
} else if (this.$store && typeof this.$store.commit === 'function') {
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
}
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
uni.navigateTo({
url: aiChatUrl,
fail: (err) => {
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
// H5 兜底
// #ifdef H5
window.location.href = aiChatUrl;
// #endif
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
}
});
return;
}
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
this.customerService.handleUnifiedKefuClick({ this.customerService.handleUnifiedKefuClick({
sendMessageTitle: '来自悬浮按钮的咨询', sendMessageTitle: '来自悬浮按钮的咨询',
sendMessagePath: '/pages/index/index' sendMessagePath: '/pages/index/index'

View File

@@ -60,6 +60,14 @@ export default {
left: 0; left: 0;
z-index: 997; z-index: 997;
background: #f8f8f8; background: #f8f8f8;
// 修复: 强制合成层, 让 spin 动画只在自身合成层跑, 不触发兄弟节点 (尤其是底部 TabBar) 重绘
// 兼容环境: 微信小程序 / 鸿蒙元服务 / H5 都受益
// - 微信小程序: spin 动画每帧导致兄弟 fixed 元素 TabBar 重绘
// - 鸿蒙元服务 JSVM: 同上, JSVM 重绘更明显
// - H5: will-change 提示浏览器持续合成
transform: translateZ(0);
-webkit-transform: translateZ(0);
will-change: transform;
} }
.loading-anim { .loading-anim {
@@ -99,6 +107,7 @@ export default {
border-bottom-color: #C5C5C5 !important; border-bottom-color: #C5C5C5 !important;
// border-bottom-color: rgba($color: #000000, $alpha: 0) !important; // border-bottom-color: rgba($color: #000000, $alpha: 0) !important;
animation: spin 0.6s linear normal infinite; animation: spin 0.6s linear normal infinite;
transform-origin: center center;
} }
.loading-anim .in { .loading-anim .in {
@@ -109,6 +118,7 @@ export default {
border-top-color: transparent !important; border-top-color: transparent !important;
border-bottom-color: transparent !important; border-bottom-color: transparent !important;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
transform-origin: center center;
} }
.loading-anim .mid { .loading-anim .mid {
@@ -117,7 +127,7 @@ export default {
width: 20%; width: 20%;
height: 20%; height: 20%;
border-left-color: transparent; border-left-color: transparent;
border-right-color: transparent;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
transform-origin: center center;
} }
</style> </style>

View File

@@ -1,58 +1,74 @@
<template> <template>
<view class="form-wrap form-component"> <view class="form-wrap form-component" :style="formWrapStyle">
<block v-for="(item, index) in formData"> <block v-for="(item, index) in formData" :key="index">
<view class="order-wrap" v-if="item.controller == 'Text'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Text'">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box"> <view class="box">
<input type="text" :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val" /> <input
type="text"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
/>
</view> </view>
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'Textarea'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Textarea'">
<view class="order-cell flex-box textarea"> <view class="order-cell flex-box textarea" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box"> <view class="box">
<textarea :placeholder="item.value.placeholder" placeholder-class="placeholder color-tip" v-model="item.val"></textarea> <!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
<textarea
:rows="item.value.rows || textareaRowsValue"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="textareaStyle"
></textarea>
</view> </view>
</view> </view>
</view> </view>
<picker mode="selector" :range="item.value.options" v-if="item.controller == 'Select'" @change="pickerChange($event, index)"> <picker mode="selector" :range="item.value.options" v-if="item.controller == 'Select'" @change="pickerChange($event, index)">
<view class="order-wrap"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text> <text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box"> <view :class="['box', 'box-flex']">
<text v-if="item.val != ''">{{ item.val }}</text> <text v-if="item.val != ''">{{ item.val }}</text>
<text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text> <text v-else class="color-tip">{{ $lang('components.nsForm.selectPlaceholder') }}</text>
</view>
<text class="iconfont icon-right"></text> <text class="iconfont icon-right"></text>
</view> </view>
</view> </view>
</view>
</picker> </picker>
<view class="order-wrap" v-if="item.controller == 'Checkbox'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Checkbox'">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box check-group-box"> <view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)"> <checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k"> <label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked" /> <checkbox :value="v.value" :checked="v.checked" />
<view class="checkbox"> <view class="checkbox">
<text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }"></text> <text class="iconfont" :class="{ 'icon-fuxuankuang2': !v.checked, 'icon-fuxuankuang1 color-base-text': v.checked }" :style="{ color: v.checked ? checkedColor : '' }"></text>
{{ v.value }} {{ v.value }}
</view> </view>
</label> </label>
@@ -61,18 +77,18 @@
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'Radio'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Radio'">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box radio-group-box"> <view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)"> <radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k"> <label v-for="(v, k) in item.option_lists" :key="k">
<radio :value="v.value" :checked="item.val == v.value" /> <radio :value="v.value" :checked="item.val == v.value" />
<view class="radio-box"> <view class="radio-box">
<text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }"></text> <text class="iconfont" :class="{ 'icon-yuan_checkbox': item.val != v.value, 'icon-yuan_checked color-base-text': item.val == v.value }" :style="{ color: item.val == v.value ? checkedColor : '' }"></text>
{{ v.value }} {{ v.value }}
</view> </view>
</label> </label>
@@ -81,11 +97,11 @@
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'Img'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Img'">
<view class="order-cell flex-box"> <view class="order-cell flex-box" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box img-boxs"> <view class="box img-boxs">
<view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)"> <view class="img-box" v-for="(v, k) in item.img_lists" :key="k" @click="uploadImg(index)">
@@ -97,26 +113,26 @@
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'Date'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Date'">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box box-flex"> <view :class="['box', 'box-flex']">
<picker mode="date" :value="item.val" @change="bindDateChange($event, index)"> <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> <view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker> </picker>
</view>
<text class="iconfont icon-right"></text> <text class="iconfont icon-right"></text>
</view> </view>
</view> </view>
</view>
<view class="order-wrap" v-if="item.controller == 'Datelimit'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Datelimit'">
<view class="order-cell flex-box"> <view class="order-cell flex-box" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box date-boxs"> <view class="box date-boxs">
<view class="date-box"> <view class="date-box">
@@ -138,26 +154,26 @@
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'Time'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Time'">
<view class="order-cell"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box box-flex"> <view :class="['box', 'box-flex']">
<picker mode="time" :value="item.val" @change="bindTimeChange($event, index)"> <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> <view class="uni-input" :class="{ 'color-tip': !item.val }">{{ item.val ? item.val : item.value.placeholder }}</view>
</picker> </picker>
</view>
<text class="iconfont icon-right"></text> <text class="iconfont icon-right"></text>
</view> </view>
</view> </view>
</view>
<view class="order-wrap" v-if="item.controller == 'Timelimit'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'Timelimit'">
<view class="order-cell flex-box"> <view class="order-cell flex-box" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box date-boxs"> <view class="box date-boxs">
<view class="date-box"> <view class="date-box">
@@ -179,60 +195,332 @@
</view> </view>
</view> </view>
<view class="order-wrap" v-if="item.controller == 'City'"> <view class="order-wrap" :class="rowWrapClass" :style="rowWrapStyle" v-if="item.controller == 'City'">
<view class="order-cell box-flex"> <view class="order-cell" :style="rowCellStyle">
<view class="name"> <view class="name" :style="nameWidthStyle">
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
<text class="tit">{{ item.value.title }}</text> <text class="tit">{{ item.value.title }}</text>
<text class="required">{{ item.value.required ? '*' : '' }}</text>
</view> </view>
<view class="box"> <view :class="['box', 'box-flex']">
<pick-regions :default-regions="item.default_regions" :select-arr="item.select_arr" @getRegions="handleGetRegions($event, index)"> <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 }"> <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')) }} {{ item.val ? item.val : (item.select_arr == '2' ? $lang('components.nsForm.selectProvinceCity') : $lang('components.nsForm.selectProvinceCityDistrict')) }}
</view> </view>
</pick-regions> </pick-regions>
</view>
<text class="iconfont icon-right"></text> <text class="iconfont icon-right"></text>
</view> </view>
</view> </view>
</view>
</block> </block>
</view> </view>
</template> </template>
<script> <script>
import formPlatformMixin from '@/common/js/form-platform-mixin.js';
export default { export default {
name: 'ns-form', name: 'ns-form',
mixins: [formPlatformMixin],
props: { props: {
data: { data: {
type: Array, type: Array,
default: {} default: () => []
}, },
customAttr: { customAttr: {
type: Object, type: Object,
default: function() { default: function() {
return {}; return {};
} }
},
/**
* 表单样式配置(由后端 designer 通过 API 传入)。
* 字段名同时支持 camelCase(如 formPaddingX / inputHeight / labelColor)
* 与 snake_case(如 form_padding_x / input_height / label_color),camelCase 优先。
* 不传则使用组件内置默认(与重构前硬编码值一致,无视觉回归)。
*
* 支持的键:
* formPaddingX, formBgColor, rowPaddingY, rowMinHeight, labelGap,
* inputHeight, inputRadius, labelWidthMode ('auto'|'fixed'), labelFixedWidth,
* labelFontSize, labelColor, requiredColor, checkedColor,
* placeholderColor, showRowDivider, rowDividerColor
*
* 平台差异化:formConfig.terminal.{h5|weapp|harmony|app|quickapp} 只放差异字段
*/
formConfig: {
type: Object,
default: () => ({})
} }
}, },
data() { data() {
return { return {
formData: this.data formData: [],
maxLabelWidth: 0
}; };
}, },
computed: {
// 合并配置:内置默认 < 全局 formConfig < 当前平台 formConfig.terminal[platform]
// 同时兼容 snake_case 与 camelCase 字段名
config() {
// 1) 内置默认值(最低优先级,与重构前的硬编码值一一对应,确保未传 formConfig 时零回归)
const defaults = {
formPaddingX: 28,
formBgColor: '#ffffff',
rowPaddingY: 24,
rowMinHeight: 60,
labelGap: 20,
inputHeight: 84,
inputRadius: 16,
labelWidthMode: 'auto', // 与 ns-newform 对齐:标签在上、输入框在下,整行宽度
labelFixedWidth: 180,
labelFontSize: 32,
labelColor: '#1f2937',
labelFontWeight: 700,
labelAutoMaxWidth: 200,
requiredColor: '#ef4444',
checkedColor: '#3b82f6',
placeholderColor: '#9ca3af',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-newform 对齐)
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
textareaRows: 4,
textareaLineHeight: 44,
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputFocusBgColor: '#ffffff',
showRowDivider: true,
rowDividerColor: '#f0f0f0'
};
const fc = this.formConfig || {};
// 同时读 camelCase 与 snake_case,camelCase 优先
const pick = (camel, snake) => {
const v1 = fc[camel];
if (v1 !== undefined && v1 !== null && v1 !== '') return v1;
return fc[snake];
};
const base = {
formPaddingX: pick('formPaddingX', 'form_padding_x'),
formBgColor: pick('formBgColor', 'form_bg_color'),
rowPaddingY: pick('rowPaddingY', 'row_padding_y'),
rowMinHeight: pick('rowMinHeight', 'row_min_height'),
labelGap: pick('labelGap', 'label_gap'),
inputHeight: pick('inputHeight', 'input_height'),
inputRadius: pick('inputRadius', 'input_radius'),
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
textareaRows: pick('textareaRows', 'textarea_rows'),
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
showRowDivider: pick('showRowDivider', 'show_row_divider'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
};
// 移除 undefined / 空字符串(保留 0 / false)
Object.keys(base).forEach(k => {
if (base[k] === undefined || base[k] === null || base[k] === '') delete base[k];
});
// showRowDivider 在前后端可能传数字 0/1,统一转 boolean
if ('showRowDivider' in base) {
base.showRowDivider = base.showRowDivider === true || base.showRowDivider === 1 || base.showRowDivider === '1';
}
// 2) 当前平台差异化配置(最高优先级),从 formConfig.terminal 取出当前平台 + 所有别名键,合并
const termBlock = fc.terminal || {};
const platformKeys = this.platformConfigKeys || [];
const platformCfg = Object.assign(
{},
...platformKeys.map(k => termBlock[k] || fc[k] || {})
);
// 平台块可能也是 snake_case,统一转 camelCase
const platformCfgCamel = {};
if (this.$util && typeof this.$util.snakeToCamel === 'function') {
Object.keys(platformCfg).forEach(k => {
platformCfgCamel[this.$util.snakeToCamel(k)] = platformCfg[k];
});
} else {
Object.assign(platformCfgCamel, platformCfg);
}
// 3) 三层合并:defaults < base < platformCfgCamel
return Object.assign({}, defaults, base, platformCfgCamel);
},
// 容器样式:内边距 + 背景 + 16 个 CSS 自定义属性
formWrapStyle() {
const cfg = this.config;
const num = (v, fb) => (typeof v === 'number' ? v : parseInt(v) || fb);
// 后端 formConfig 一律以 rpx 传递;
// _rpx() 按端分支:H5 输出 'Npx'(uni.upx2px),其它端输出 'Nrpx'
return this.$util.objToStyle({
padding: `0 ${this.$util.rpx(cfg.formPaddingX)}`,
background: cfg.formBgColor,
'--form-padding-x': this.$util.rpx(cfg.formPaddingX),
'--form-bg-color': cfg.formBgColor,
'--row-padding-y': this.$util.rpx(cfg.rowPaddingY),
'--row-min-height': this.$util.rpx(cfg.rowMinHeight),
'--label-gap': this.$util.rpx(cfg.labelGap),
'--input-height': this.$util.rpx(cfg.inputHeight),
'--input-radius': this.$util.rpx(cfg.inputRadius),
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
'--label-color': cfg.labelColor,
'--label-font-weight': cfg.labelFontWeight,
'--required-color': cfg.requiredColor,
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
'--textarea-rows': num(cfg.textareaRows, 4),
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
// 输入框视觉增强(与 ns-newform 对齐)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--row-divider-color': cfg.rowDividerColor,
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
});
},
rowWrapStyle() {
return this.$util.objToStyle({
padding: `${this.$util.rpx(this.config.rowPaddingY)} 0`
});
},
rowWrapClass() {
return this.config.showRowDivider === false ? 'order-wrap no-divider' : 'order-wrap';
},
rowCellStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.rowMinHeight)
});
},
// 标签宽度:垂直布局下标签整行显示,宽度交由 CSS(.name { width:100% })控制;
// 字号 / 颜色 / 字重已由 .form-wrap 注入的 --label-* 变量统一驱动,无需 inline 注入。
nameWidthStyle() {
return {};
},
// 输入框样式(input / textarea 共用)
inputStyle() {
return this.$util.objToStyle({
'min-height': this.$util.rpx(this.config.inputHeight),
'border-radius': this.$util.rpx(this.config.inputRadius),
'font-size': this.$util.rpx(this.config.labelFontSize),
'color': this.config.labelColor,
'line-height': this.$util.rpx(this.config.inputHeight)
});
},
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
textareaRowsValue() {
const n = parseInt(this.config.textareaRows, 10);
return Number.isFinite(n) && n > 0 ? n : 4;
},
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出固定可视高度)
textareaLineHeightValue() {
const n = parseInt(this.config.textareaLineHeight, 10);
return Number.isFinite(n) && n > 0 ? n : 44;
},
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
// 设计要点:
// 1) 不复用 inputStyle 的 min-height / line-height:那两个值取自 inputHeight(默认 60rpx),
// 对 textarea 太小,会让原生 textarea 单行过高、可视区域过小;
// 2) 不使用 auto-height:原生 textarea 上 auto-height 会让组件随内容无限扩展,
// 遮挡后续组件,导致"看不到下面的其他组件";
// 3) 高度 = rows × lineHeight(两个值都由 formConfig 动态下发,不再写死 176rpx):
// - H5 浏览器 / 微信原生层:原生 <textarea> 同时识别 :rows 和 CSS line-height,
// 高度由浏览器自然算出,与 :rows 完全一致;
// - 鸿蒙 ArkUI:原生 TextArea 只识别 :rows,不识别 CSS height,所以我们在 JS 里
// 显式算 height = rows × lineHeight 反向写出,保证三端可视高度像素级一致。
textareaStyle() {
const lh = this.$util.rpx(this.textareaLineHeightValue);
const rows = this.textareaRowsValue;
// 加上 padding(12rpx 上下) 和 border(1rpx 上下) 的占用,避免与 :rows 算出的高度不一致时被截断
const paddings = this.$util.rpx(24) + this.$util.rpx(2);
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
return this.$util.objToStyle({
'height': h,
'min-height': h,
'line-height': lh,
'border-radius': this.$util.rpx(this.config.inputRadius),
'font-size': this.$util.rpx(this.config.labelFontSize),
'color': this.config.labelColor
});
},
// 必填星号颜色
requiredColor() {
return this.config.requiredColor;
},
// 复选 / 单选 选中色
checkedColor() {
return this.config.checkedColor;
}
},
created() { created() {
this.setFormData(); this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
}, },
watch: { watch: {
data: function() { data: {
this.setFormData(); deep: true,
handler() {
this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
}
},
formConfig: {
deep: true,
handler() {
this.$nextTick(() => this.calculateMaxLabelWidth());
}
} }
}, },
methods: { methods: {
// 预计算每项的 option_layout inline style(与 ns-newform 对齐)
// 跨端兼容:小程序 / 鸿蒙元服务 WXML 不支持模板里的方法调用,必须预计算到 item 上
recomputeItemStyles() {
if (!this.formData || this.formData.length === 0) return;
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
initFormData() {
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
// 预计算每项 inline style(与 ns-newform 对齐,跨端兼容)
this.recomputeItemStyles();
},
setFormData() { setFormData() {
this.formData = this.data; this.initFormData();
this.$nextTick(() => this.calculateMaxLabelWidth());
},
processFormData() {
this.formData.forEach(item => { this.formData.forEach(item => {
if (!item.val) item.val = item.value.default ? item.value.default : ''; // Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) item.val = item.value.default ? item.value.default : '';
if (item.value.options) { if (item.value.options) {
item.option_lists = []; item.option_lists = [];
@@ -356,6 +644,14 @@
} }
}); });
}, },
// 计算最长 label 宽度(封装自 util.calculateMaxLabelWidth)
// 用于 labelWidthMode === 'auto' 时把 .name 的 width 撑开,避免长短 label 错位
calculateMaxLabelWidth() {
this.maxLabelWidth = this.$util.calculateMaxLabelWidth(
this.formData,
this.config.labelFontSize
);
},
verify() { verify() {
let verify = true; let verify = true;
@@ -643,9 +939,45 @@
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
/* CSS 自定义属性:默认值,与组件内 config.defaults 一一对应,
formConfig 未传时作为兜底;运行时会被 formWrapStyle 注入的 inline style 覆盖。
后端 formConfig 一律以 rpx 传递,前端按 rpx 写入;
仅 H5 端需要在 JS computed(formWrapStyle 等)里通过 _rpx() 转 px。 */
.form-wrap {
--form-padding-x: 28rpx;
--form-bg-color: #ffffff;
--row-padding-y: 24rpx;
--row-min-height: 60rpx;
--label-gap: 20rpx;
--input-height: 84rpx;
--input-radius: 16rpx;
--label-fixed-width: 180rpx;
--label-font-size: 32rpx;
--label-color: #1f2937;
--label-font-weight: 700;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #3b82f6;
--placeholder-color: #9ca3af;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 48rpx;
// 输入框视觉增强(与 ns-newform 对齐)
--input-border-color: #e5e7eb;
--input-bg-color: #ffffff;
--input-focus-border-color: #3b82f6;
--input-focus-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--show-row-divider: 0;
}
.order-wrap { .order-wrap {
padding: 20rpx 0; padding: var(--row-padding-y, 20rpx) 0;
&.no-divider {
border-bottom: 0;
}
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@@ -655,12 +987,14 @@
.order-cell { .order-cell {
display: flex; display: flex;
align-items: center; flex-direction: column; // 与 ns-newform vertical 对齐:标签在上、输入框在下
align-items: flex-start;
background: #fff; background: #fff;
position: relative; position: relative;
min-height: var(--row-min-height, 60rpx);
&.textarea { &.textarea {
align-items: unset; align-items: flex-start;
} }
&.clear-flex { &.clear-flex {
@@ -674,7 +1008,8 @@
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
border-bottom: solid 1px #eee; // calc + 0/1 控制分割线显隐(formConfig.showRowDivider = false 时整条隐藏)
border-bottom: calc(var(--show-row-divider, 1) * 2rpx) solid var(--row-divider-color, #f0f0f0);
} }
&.align-top { &.align-top {
@@ -682,61 +1017,165 @@
} }
text { text {
font-size: $font-size-base; font-size: var(--label-font-size, $font-size-base);
} }
.name { .name {
width: 80vw; width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
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 { .tit {
text-align: left; text-align: left;
font-size: $font-size-base; font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
font-weight: var(--label-font-weight, 700);
text { text {
font-size: $font-size-base; font-size: var(--label-font-size, $font-size-base);
} }
} }
.required { .required {
color: red; color: var(--required-color, #ef4444);
font-size: 28rpx; font-size: var(--label-font-size, 28rpx);
margin-left: 4rpx; margin-right: 6rpx;
width: 14rpx; font-weight: bold;
text-align: left; text-align: left;
display: inline-block; display: inline-block;
vertical-align: middle;
} }
.box { .box {
flex: 1; flex: 1;
padding: 0 10rpx; width: 100%;
padding: 0;
line-height: inherit; line-height: inherit;
text-align: left; text-align: left;
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
::v-deep input,
input { input {
font-size: $font-size-base; font-size: var(--label-font-size, $font-size-base);
text-align: left; text-align: left;
} color: var(--label-color, #1f2937);
// 显式 height / line-height / min-height 同步覆盖 WebView 默认与鸿蒙原生 TextInput 的固有高度,
textarea { // 避免 placeholder / 文字下半部分被裁切
font-size: $font-size-base; 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;
width: 100%; width: 100%;
height: 88rpx; padding: 0 24rpx;
line-height: 44rpx; // 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
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, #3b82f6);
}
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
&::placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
}
}
// placeholder-class 钩子:在鸿蒙原生 TextInput / WebView 中都生效,
// 限定在 .form-wrap 内避免污染全局 .placeholder / .color-tip
.placeholder {
color: var(--placeholder-color, #9ca3af);
font-size: var(--placeholder-font-size, 28rpx);
line-height: var(--input-height, 84rpx);
}
::v-deep textarea,
textarea {
font-size: var(--label-font-size, $font-size-base);
width: 100%;
color: var(--label-color, #1f2937);
// 行高由 formConfig.textareaLineHeight(兜底 48rpx)控制;
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
// 真正做到了"rows 改 → 高度自动改"。
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; text-align: left;
border-radius: var(--input-radius, 16rpx);
box-sizing: border-box;
padding: 12rpx 24rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
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, #3b82f6);
}
} }
checkbox-group { .uni-input {
display: flex; font-size: var(--label-font-size, $font-size-base);
flex-wrap: wrap; color: var(--label-color, #1f2937);
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, #ffffff);
border-radius: var(--input-radius, 16rpx);
transition: all 0.3s ease;
} }
.select-address {
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
}
.color-tip {
color: var(--placeholder-color, #9ca3af);
}
// 让原生 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 { radio-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%;
} }
label { label {
@@ -746,6 +1185,39 @@
margin-right: 30rpx; 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 { &.img-boxs {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -822,8 +1294,9 @@
line-height: 1; line-height: 1;
.iconfont { .iconfont {
font-size: 32rpx; font-size: 40rpx;
margin-right: 10rpx; margin-right: 10rpx;
color: #d1d5db;
} }
} }
} }
@@ -839,15 +1312,16 @@
line-height: 1; line-height: 1;
.iconfont { .iconfont {
font-size: 32rpx; font-size: 40rpx;
margin-right: 10rpx; margin-right: 10rpx;
color: #d1d5db;
} }
} }
} }
.iconfont { .iconfont {
color: $color-tip; color: $color-tip;
font-size: $font-size-base; font-size: var(--label-font-size, $font-size-base);
} }
.box-flex picker { .box-flex picker {

View File

@@ -10,14 +10,14 @@
return { return {
list: [], list: [],
config: { config: {
title: '猜你喜欢', title: '',
sources: 'sort', sources: 'sort',
supportPage: [], supportPage: [],
goodsIds: [], goodsIds: [],
fontWeight: false, fontWeight: false,
padding: 0, padding: 0,
cartEvent: 'detail', cartEvent: 'detail',
text: '购买', text: '',
textColor: '#FFFFFF', textColor: '#FFFFFF',
theme: 'default', theme: 'default',
aroundRadius: 25, aroundRadius: 25,
@@ -67,6 +67,9 @@
} }
}, },
mounted() { mounted() {
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
if (!this.config.text) this.config.text = this.$lang('buyNow');
if (this.auto) { if (this.auto) {
this.getLikeList(); this.getLikeList();
} }

View File

@@ -56,10 +56,18 @@
<!-- 手机号授权按钮 --> <!-- 手机号授权按钮 -->
<block v-if="isAgree"> <block v-if="isAgree">
<!-- #ifdef MP-WEIXIN -->
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber" <button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber"> @getphonenumber="getPhoneNumber">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text> <text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button> </button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务无 getPhoneNumber 微信 API,改为弹出手机号+验证码表单 -->
<button class="auth-login save-btn border-0" style="border: none;" @tap="showHarmonyMobileForm = true">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button>
<!-- #endif -->
</block> </block>
<block v-else> <block v-else>
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno"> <button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
@@ -67,6 +75,27 @@
</button> </button>
</block> </block>
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙专属:手机号+验证码表单 -->
<view v-if="showHarmonyMobileForm" class="harmony-mobile-form" @touchmove.prevent.stop>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.mobilePlaceholder')" v-model="formData.mobile" maxlength="11" />
</view>
<view class="item-wrap" v-if="isOpenCaptcha == 1">
<text class="label">{{ $lang('components.ns-login.captchaLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.captchaPlaceholder')" v-model="formData.vercode" maxlength="4" />
<image :src="captcha.img" class="captcha" @tap="getCaptcha" show-menu-by-longpress></image>
</view>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.dynacodeLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.dynacodePlaceholder')" v-model="formData.dynacode" maxlength="4" />
<view class="send color-base-text" @tap="sendMobileCode">{{ dynacodeData.codeText }}</view>
</view>
<button type="default" class="save-btn" @tap="harmonySaveMobile" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</view>
<!-- #endif -->
<view @tap="closeLogin" <view @tap="closeLogin"
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;"> style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
{{ $lang('components.ns-login.notNow') }} {{ $lang('components.ns-login.notNow') }}
@@ -109,11 +138,20 @@
<view class="item-wrap" v-if="forceBindingMobileControl"> <view class="item-wrap" v-if="forceBindingMobileControl">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text> <text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<!-- #ifdef MP-WEIXIN -->
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0" <button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
@getphonenumber="getPhoneNumber"> @getphonenumber="getPhoneNumber">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text> <text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text> <text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
</button> </button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙无 getPhoneNumber,展示手动输入的手机号,没有时显示"获取手机号"提示用户去上面表单录入 -->
<view class="auth-login border-0" :plain="true">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else @tap="showHarmonyMobileForm = true">{{ $lang('components.ns-login.getMobile') }}</text>
</view>
<!-- #endif -->
</view> </view>
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button> <button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</template> </template>
@@ -174,7 +212,11 @@ export default {
iv: '', iv: '',
encryptedData: '' encryptedData: ''
}, },
site_name: '' site_name: '',
// #ifdef MP-HARMONY
// 鸿蒙专属:控制手机号+验证码表单显示
showHarmonyMobileForm: false,
// #endif
}; };
}, },
options: { options: {
@@ -203,7 +245,7 @@ export default {
if (this.forceBindingMobileControl) { if (this.forceBindingMobileControl) {
if (this.formData.mobile.length == 0) return true; if (this.formData.mobile.length == 0) return true;
// #ifdef H5 // #ifdef H5 || MP-HARMONY
// 验证码 // 验证码
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true; if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
// 动态码 // 动态码
@@ -283,14 +325,69 @@ export default {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
// "privacy permission is not authorized or buttonId is invalid"。
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
this.ensureWxPrivacyAuthorized(() => {
this.getCode(authData => { this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin'); this.authLogin(authData, 'authOnlyLogin');
}, (err) => { }, (err) => {
callback && callback(err); callback && callback(err);
}); });
});
// #endif // #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') { toLoginH5Page(mode = 'navigateTo') {
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, { if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
@@ -343,7 +440,7 @@ export default {
openCompleteInfoPop() { openCompleteInfoPop() {
this.getRegisterConfig(); this.getRegisterConfig();
// #ifdef H5 // #ifdef H5 || MP-HARMONY
if (!this.storeToken) this.getCaptchaConfig(); if (!this.storeToken) this.getCaptchaConfig();
// #endif // #endif
@@ -572,8 +669,24 @@ export default {
} }
}); });
} else { } 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({ this.$util.showToast({
title: e.detail.errMsg 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: errMsg
}) })
} }
}, },
@@ -676,6 +789,26 @@ export default {
this.forceBindMobile(); this.forceBindMobile();
}, },
// #ifdef MP-HARMONY
// 鸿蒙元服务保存手机号:复用 H5 的强制绑定手机号流程
harmonySaveMobile() {
if (this.forceBindingMobileControl) {
let data = {
mobile: this.formData.mobile,
key: this.formData.key,
code: this.formData.dynacode
};
if (this.captcha.id != '') {
data.captcha_id = this.captcha.id;
data.captcha_code = this.formData.vercode;
}
if (!this.verify(data)) return;
}
// 鸿蒙环境下通常也开启了 forceBindingMobileControl(走 forceBindMobile 接口)
this.forceBindMobile();
},
// #endif
// 微信小程序保存数据 // 微信小程序保存数据
saveMp() { saveMp() {
if (this.formData.mobile === '') { if (this.formData.mobile === '') {

View File

@@ -53,7 +53,7 @@
let systemInfo = uni.getSystemInfoSync(); let systemInfo = uni.getSystemInfoSync();
let menuButtonInfo = {}; let menuButtonInfo = {};
// 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容) // 如果是小程序,获取右上角胶囊的尺寸信息,避免导航栏右侧内容与胶囊重叠(支付宝小程序非本API,尚未兼容)
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ // #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
menuButtonInfo = uni.getMenuButtonBoundingClientRect(); menuButtonInfo = uni.getMenuButtonBoundingClientRect();
// #endif // #endif

File diff suppressed because it is too large Load Diff

View File

@@ -70,37 +70,11 @@ export default {
return { return {
isIphoneX: false, isIphoneX: false,
payIndex: 0, payIndex: 0,
// #ifdef H5 payTypeList: [],
payTypeList: [{
name: this.$lang('components.nsPayment.alipay'),
icon: 'icon-zhifubaozhifu-',
type: 'alipay'
},
{
name: this.$lang('components.nsPayment.wechatpay'),
icon: 'icon-weixin1',
type: 'wechatpay'
}
],
timer: null, timer: null,
// #endif
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
payTypeList: [{
name: this.$lang('components.nsPayment.wechatpay'),
provider: 'wxpay',
icon: 'icon-weixin1',
type: 'wechatpay'
}],
isMatched: 0, // 支付是否需要校验 isMatched: 0, // 支付是否需要校验
// #endif // #endif
// #ifdef MP-ALIPAY
payTypeList: [{
name: this.$lang('components.nsPayment.alipay'),
icon: 'icon-zhifubaozhifu-',
type: 'alipay',
provider: 'alipay'
}],
// #endif
payInfo: {}, payInfo: {},
balanceConfig: 0, balanceConfig: 0,
// 预售页面判断 // 预售页面判断
@@ -108,6 +82,30 @@ export default {
}; };
}, },
created(e) { created(e) {
// #ifdef H5
this.payTypeList = [
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay' },
{ name: this.$lang('components.nsPayment.wechatpay'), icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
// #ifdef MP-WEIXIN
this.payTypeList = [
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
// #ifdef MP-ALIPAY
this.payTypeList = [
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay', provider: 'alipay' },
];
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务的支付通道: 与微信小程序相同的 provider='wxpay',
// 但走鸿蒙 JSSDK 的 requestPayment(走鸿蒙支付服务而非 wx.requestPayment)
this.payTypeList = [
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
];
// #endif
let url; let url;
// #ifdef H5 // #ifdef H5
url = window.location.href url = window.location.href

View File

@@ -9,9 +9,13 @@
<view class="video-popup-body"> <view class="video-popup-body">
<video <video
v-if="videoUrl" v-if="videoUrl"
id="popupVideoPlayer"
:src="videoUrl" :src="videoUrl"
controls controls
autoplay autoplay
playsinline
webkit-playsinline
x5-playsinline
class="video-player" class="video-player"
></video> ></video>
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view> <view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
@@ -22,6 +26,8 @@
</template> </template>
<script> <script>
import { playVideoInContext } from '@/common/js/video-helper.js';
export default { export default {
name: 'ns-video-player-popup', name: 'ns-video-player-popup',
props: { props: {
@@ -76,6 +82,10 @@ export default {
* @param {Object} e - 弹窗状态变化事件 * @param {Object} e - 弹窗状态变化事件
*/ */
onVideoPopupChange(e) { onVideoPopupChange(e) {
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
if (e && e.show) {
playVideoInContext('popupVideoPlayer', this);
}
this.$emit('popup-change', e); this.$emit('popup-change', e);
} }
} }

View File

@@ -95,6 +95,16 @@
i18nKey: 'components.payment.alipay' i18nKey: 'components.payment.alipay'
}], }],
// #endif // #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务的支付通道: provider='wxpay' 由鸿蒙 JSSDK 接管
payTypeList: [{
name: '微信支付',
provider: 'wxpay',
icon: 'icon-weixin1',
type: 'wechatpay',
i18nKey: 'components.payment.wechatpay'
}],
// #endif
payInfo: null, payInfo: null,
balanceConfig: 0, balanceConfig: 0,
// 预售页面判断 // 预售页面判断

View File

@@ -1,4 +1,13 @@
<template> <template>
<!-- #ifdef MP-HARMONY -->
<!--
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
-->
<!-- #endif-->
<!-- #ifndef MP-HARMONY -->
<view> <view>
<view class="privacy-mask" v-if="showPop"> <view class="privacy-mask" v-if="showPop">
<view class="privacy-wrap"> <view class="privacy-wrap">
@@ -6,7 +15,7 @@
<view class="privacy-desc"> <view class="privacy-desc">
{{ i18n.descPrefix }} {{ i18n.descPrefix }}
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text> <text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY --> <!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text> <text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
<!-- #endif --> <!-- #endif -->
{{ i18n.descSuffix }} {{ i18n.descSuffix }}
@@ -17,13 +26,15 @@
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization" <button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button> @agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
<!-- #endif --> <!-- #endif -->
<!-- #ifdef H5 || MP-HARMONY --> <!-- #ifdef H5 -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button> <button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
}}</button>
<!-- #endif --> <!-- #endif -->
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- #endif -->
</template> </template>
<script> <script>
@@ -48,6 +59,12 @@ export default {
// 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置) // 平台提供的隐私协议名称(由 wx.getPrivacySetting 等异步设置)
// 弹窗前才参与 i18n.privacyContractName 的填充 // 弹窗前才参与 i18n.privacyContractName 的填充
platformContractName: '', platformContractName: '',
// 平台协议名称就绪 Promise:
// created() 中发起异步查询, success/fail 时 resolve
// popUp() 中 await 此 Promise 后再调 prepareI18n(), 保证 $lang() 与
// platformContractName 都已经准备好, 不会出现"空串被永久缓存"的问题
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"是同源契约)
platformContractNameReady: null,
// 弹窗文案: 模板只读 i18n.xxx // 弹窗文案: 模板只读 i18n.xxx
// 初值为空串, 弹窗前在 popUp() 中**同步**填充 // 初值为空串, 弹窗前在 popUp() 中**同步**填充
// 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成, // 此时 App.vue 已 await boot() + await PrivacyManager.show() 完成,
@@ -70,10 +87,22 @@ export default {
// 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染 // 所以 listener 可在 created 注册, 但 callback 仍要 nextTick 渲染
created() { created() {
try { try {
// 创建平台协议名称就绪 Promise
// 后续 wx/has/uni.* 三端的异步 API 都会挂到这个 promise 上 resolve,
// popUp() 中 await 后再 prepareI18n(), 保证 platformContractName 已就绪
// (与 i18n-lazy mixin 的"boot 完成才能 prepareI18n"契约同源)
this.platformContractNameReady = new Promise((resolve) => {
this._resolvePlatformContractName = resolve
})
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
wx.getPrivacySetting({ wx.getPrivacySetting({
success: (res) => { success: (res) => {
this.platformContractName = res.privacyContractName || '' this.platformContractName = res.privacyContractName || ''
this._resolvePlatformContractName && this._resolvePlatformContractName()
},
fail: () => {
this._resolvePlatformContractName && this._resolvePlatformContractName()
} }
}) })
// #endif // #endif
@@ -85,13 +114,28 @@ export default {
if (res.privacyContractName) { if (res.privacyContractName) {
this.platformContractName = res.privacyContractName this.platformContractName = res.privacyContractName
} }
this._resolvePlatformContractName && this._resolvePlatformContractName()
},
fail: () => {
this._resolvePlatformContractName && this._resolvePlatformContractName()
}, },
}) })
} else {
// 无 API 时立即 resolve (避免 await 永久阻塞)
this._resolvePlatformContractName && this._resolvePlatformContractName()
} }
// #endif // #endif
// #ifdef MP-HARMONY // #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName // 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
// 立即 resolve, 让 popUp 的 await 不永久阻塞
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #endif
// #ifdef H5
// H5 无平台隐私 API, 立即 resolve
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #endif // #endif
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口) // 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
@@ -179,14 +223,14 @@ export default {
* 统一弹窗 + 关闭其他页面的弹窗 * 统一弹窗 + 关闭其他页面的弹窗
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭 * 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
*/ */
showPrivacyPopup() { async showPrivacyPopup() {
// 先通知其他实例关闭 // 先通知其他实例关闭
try { uni.$emit('privacy:forceClose'); } catch (e) { } try { uni.$emit('privacy:forceClose'); } catch (e) { }
// 直接弹窗: boot 阶段已经保证 bundle 完整 // 直接弹窗: boot 阶段已经保证 bundle 完整
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key) // 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
// 不完整时 force 重试, 仍不完整则报错不静默回退 // 不完整时 force 重试, 仍不完整则报错不静默回退
// 所以这里无需再检测/自愈, 信任 boot 已就绪 // 所以这里无需再检测/自愈, 信任 boot 已就绪
this.popUp() await this.popUp()
}, },
/** /**
@@ -237,18 +281,18 @@ export default {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) { if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => { wx.onNeedPrivacyAuthorization((resolve) => {
// 检查是否已同意 // 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
if (!PrivacyManager.needShow()) { // (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 已同意, 直接放行 // 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
resolve({ event: 'agree' }) // 直接报 "privacy permission is not authorized or buttonId is invalid"。
} else { // 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') { if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve) this.privacyAuthorization(resolve)
} else { } else {
// 兜底: 组件未就绪, 直接放行避免 API 卡死 // 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
resolve({ event: 'agree' }) // (disagree 无需 buttonId, 不会产生非法 resolve)
} resolve({ event: 'disagree' })
} }
}) })
} }
@@ -265,7 +309,8 @@ export default {
// #endif // #endif
// #ifdef MP-HARMONY // #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听 // 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
// #endif // #endif
}, },
@@ -307,8 +352,8 @@ export default {
// #endif // #endif
// #ifdef MP-HARMONY // #ifdef MP-HARMONY
// 鸿蒙元服务: 使用应用内协议页面 // 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
this.$util.redirectTo(privacyContractPage); // (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
// #endif // #endif
// #ifdef H5 // #ifdef H5
@@ -329,7 +374,7 @@ export default {
// #endif // #endif
// #ifdef MP-HARMONY // #ifdef MP-HARMONY
this.$util.redirectTo(privacyServicePage); // 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
// #endif // #endif
// #ifdef H5 // #ifdef H5
@@ -392,7 +437,7 @@ export default {
let appName, privacyContractName, privacyServiceName; let appName, privacyContractName, privacyServiceName;
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数 // 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
// #ifdef H5 || MP-HARMONY // #ifdef H5
appName = this.$lang('components.privacyPopup.defaultAppNameH5'); appName = this.$lang('components.privacyPopup.defaultAppNameH5');
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5'); privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5'); privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
@@ -416,10 +461,23 @@ export default {
return result; return result;
}, },
popUp() { async popUp() {
// 弹窗前**一次性**计算所有 i18n 文案 // 弹窗前**一次性**计算所有 i18n 文案
// boot 阶段已用 isBundleComplete() 保证 bundle 完整 // 必须先等两类数据准备好:
// $lang() 返回真实翻译(效果与 H5 一致) // 1) Lang.boot() 完成 — 由 i18n-lazy mixin 隐含保证
// (调用方 App.vue 在 await PrivacyManager.show() 后才触发弹窗,
// PrivacyManager.show() await 了 boot 守卫)
// 2) platformContractName 就绪 — 微信/快应用/鸿蒙三端的 API 都是异步的,
// success 回调时机不确定, 直接 prepareI18n() 会读到空字符串, 导致
// "空串被永久缓存" (与 i18n-lazy mixin 警告的问题同源)
// 因此这里 await platformContractNameReady, 然后再 prepareI18n。
if (this.platformContractNameReady) {
try {
await this.platformContractNameReady
} catch (e) {
// 防御性: 即便 ready 出错也继续 (走 fallback 默认文案)
}
}
this.prepareI18n(); this.prepareI18n();
this.showPop = true; this.showPop = true;
// 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估) // 通知 PrivacyManager 弹窗实际显示状态 (用于 rules-updated 双阶段重评估)
@@ -438,6 +496,11 @@ export default {
uni.$off('privacy:show', this.handlePrivacyShow) uni.$off('privacy:show', this.handlePrivacyShow)
uni.$off('privacy:forceClose', this.handleForceClose) uni.$off('privacy:forceClose', this.handleForceClose)
uni.$off('privacy:re-evaluate', this.handleReEvaluate) uni.$off('privacy:re-evaluate', this.handleReEvaluate)
// 销毁时强制 resolve, 避免 popUp() 中的 await 永久挂起 (例如用户提前离开页面)
if (typeof this._resolvePlatformContractName === 'function') {
this._resolvePlatformContractName()
this._resolvePlatformContractName = null
}
}, },
} }
</script> </script>
@@ -491,7 +554,7 @@ export default {
.privacy-button-btn { .privacy-button-btn {
color: #FFF; color: #FFF;
font-size: 30rpx; font-size: 30rpx;
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */ /* #ifdef QUICKAPP-WEBVIEW || H5 */
font-size: 28rpx; font-size: 28rpx;
/* #endif */ /* #endif */
font-weight: 500; font-weight: 500;

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

File diff suppressed because it is too large Load Diff

View File

@@ -106,3 +106,37 @@ components/mescroll/components/mescroll-empty.json
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。 有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。 **结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
#### 4. `setClipboardData:fail api scope is not declared in the privacy agreement`
**症状**:AI 智能(`pages_tool/ai-chat`)复制消息时 Toast 提示
`复制失败:setClipboardData:fail api scope is not declared in the privacy agreement`,
H5 正常,**只有微信小程序失败**。
**根因**:微信自 2023-09-15 起把剪贴板接口(`setClipboardData` / `getClipboardData`)纳入
《小程序用户隐私保护指引》。小程序后台未声明「剪贴板」,或声明还在审核中时,调用直接失败,
返回 `errno: 112`。这是**平台侧配置问题,不是代码问题**:
`app.json` 的 `permission` / `requiredPrivateInfos` 都不涉及剪贴板,改代码无法绕过。
**正确处理**:
- 微信公众平台 → 设置 → 基本设置 → 服务内容声明 → 用户隐私保护指引 → 更新 →
在「开发者收集你的……信息」中勾选「剪贴板」并填写用途 → 提交审核;
**审核通过后才生效**,通过后重新编译/发版即恢复。
- 代码侧只做兜底:`common/js/uniapp.utils.js` 的 `isClipboardPrivacyError()` 识别
`errno === 112` / errMsg 含 `privacy agreement`,用 i18n 文案替换微信原始英文错误
(AI 聊天页 `copyMessage` 同样处理),不要把平台英文报错抛给用户。
- 开发期想临时绕过校验:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false`
(**仅限本地调试**,上线前必须还原,并按上面的后台配置完成声明)。
**注意**:根目录的 `privacy.json`(内容为 `Scope.writeClipboard`)不是微信的隐私声明机制,
未被任何代码或构建流程引用,不要把它当成剪贴板隐私的解决方案。
**代码侧备选(无法改后台、只想先跑起来时)**:
- 写剪贴板在微信里只有 `wx.setClipboardData` 这一个 JS API,平台拦截 errno 112 时**任何代码都绕不过**,
所以「复制」按钮在没声明剪贴板权限前必然失败。
- AI 回复的**展示**走 `<rich-text>` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由
`parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 `<rich-text :nodes>`。
- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`),
因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false`
(仅本地调试,上线前必须还原)。
- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ export const lang = {
loadMoreHistory: 'Load more history', loadMoreHistory: 'Load more history',
loadingHistory: 'Loading history...', loadingHistory: 'Loading history...',
aiThinking: 'AI is thinking', aiThinking: 'AI is thinking',
receivingReply: 'Receiving reply', receivingReply: 'Thinking',
aiThinkingStatus1: 'AI is thinking', aiThinkingStatus1: 'AI is thinking',
aiThinkingStatus2: 'AI is analyzing', aiThinkingStatus2: 'AI is analyzing',
aiThinkingStatus3: 'AI is searching', aiThinkingStatus3: 'AI is searching',
@@ -35,6 +35,7 @@ export const lang = {
copySelected: 'Copied selected content', copySelected: 'Copied selected content',
copyFail: 'Copy failed', copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}', copyFailWithReason: 'Copy failed: {reason}',
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
openingFile: 'Opening file...', openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}', openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}', openingFileTitle: 'Opening: {title}',
@@ -112,8 +113,16 @@ export const lang = {
// ====== ai-chat-tools-panel ====== // ====== ai-chat-tools-panel ======
moreFunctions: 'More Features', moreFunctions: 'More Features',
image: 'Image', image: 'Image',
video: 'Video/Audio',
file: 'File', file: 'File',
voice: 'Voice', 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', location: 'Location',
// ====== ai-chat-voice-panel ====== // ====== ai-chat-voice-panel ======
@@ -135,5 +144,9 @@ export const lang = {
logEventHandleError: 'Event handling error', logEventHandleError: 'Event handling error',
logInitNavbarFailed: 'Failed to initialize navigation bar', logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized', logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...' logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...',
}
// ====== AI Agent general copy (customer service is the first agent) ======
aiAgentDefaultTitle: 'AI Agent',
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
aiAgentNotConfigured: 'This AI Agent is not enabled'}

View File

@@ -2,10 +2,10 @@ export const lang = {
// 底部选项卡 // 底部选项卡
tabBar: { tabBar: {
home: 'index', home: 'Home',
category: 'category', category: 'Category',
cart: 'cart', cart: 'Cart',
member: 'member', member: 'Me',
}, },
//公共 //公共
@@ -54,11 +54,15 @@ export const lang = {
serviceUnavailable: 'Navigation service temporarily unavailable', serviceUnavailable: 'Navigation service temporarily unavailable',
}, },
// diy custom page // diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
diy: { diy: {
networkErrorTitle: 'Notice',
networkError: 'Network error, please try again later', networkError: 'Network error, please try again later',
retry: 'Retry',
noDiyData: 'Custom page data not configured', noDiyData: 'Custom page data not configured',
locationNotAvailable: 'Location not available', locationNotAvailable: 'Location not available',
// detailed data-load failure template (diy-specific, {reason} is filled by generic http.*)
loadFailTpl: 'Failed to load page data: {reason}, please try again later',
}, },
//customer service //customer service
@@ -108,6 +112,7 @@ export const lang = {
notSupported: 'Current environment does not support WeChat Channel', notSupported: 'Current environment does not support WeChat Channel',
missingFields: 'Missing required field: {field}', missingFields: 'Missing required field: {field}',
playFailed: 'WeChat video playback failed!', playFailed: 'WeChat video playback failed!',
wechatMpOnly: 'WeChat Channel only plays in WeChat Mini Program',
}, },
// uniapp utility functions // uniapp utility functions
@@ -121,6 +126,8 @@ export const lang = {
copyTimeoutReason: 'Reason:', copyTimeoutReason: 'Reason:',
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;', wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency', wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
// readable tip when clipboard privacy scope is not declared (errno 112)
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
copyFailed: 'Copy failed', copyFailed: 'Copy failed',
}, },
@@ -134,12 +141,18 @@ export const lang = {
requestFailed: '{url} request failed. {err}', requestFailed: '{url} request failed. {err}',
// network timeout // network timeout
timeout: 'Network request timeout, please try again later', timeout: 'Network request timeout, please try again later',
// network connection failed // network connection failed / generic network error (unified copy, reused by diy and other generic scenarios)
networkError: 'Network connection failed, please check your network', networkError: 'Network error, please try again later',
// server error // server error
serverError: 'Service temporarily unavailable, please try again later', serverError: 'Server error, please try again later',
// API not found (HTTP 404)
notFound: 'API not found',
// no access permission (HTTP 401 / 403)
auth: 'No access permission',
// request aborted // request aborted
aborted: 'Request cancelled', aborted: 'Request cancelled',
// unknown error (errorType fallback, e.g. non-5xx/404/401/403 HTTP status)
unknown: 'Request failed, please try again later',
// unknown error // unknown error
unknownError: 'Request failed, please try again later', unknownError: 'Request failed, please try again later',
}, },
@@ -247,7 +260,14 @@ export const lang = {
addToCartFailMin: 'Minimum purchase quantity not met', addToCartFailMin: 'Minimum purchase quantity not met',
addToCartFailStock: 'Insufficient Stock', addToCartFailStock: 'Insufficient Stock',
// 咨询 // generic submit/version/loading text (key lives in root because common.js's lang object IS the common namespace)
submitSuccess: 'Submit Success',
submitFail: 'Submit Failed',
versionInfo: 'Version Info',
commitId: 'Commit ID',
buildTime: 'Build Time',
loading: 'Loading...',
consult: 'Consult Now', consult: 'Consult Now',
consultSuccess: 'Consultation Successful', consultSuccess: 'Consultation Successful',
consultFail: 'Consultation Failed', consultFail: 'Consultation Failed',

View File

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

View File

@@ -6,6 +6,11 @@ export const lang = {
fileUrlNotExist: 'File URL does not exist', fileUrlNotExist: 'File URL does not exist',
// Video URL does not exist // Video URL does not exist
videoUrlNotExist: '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 // This file type preview is not supported
unsupportedFileType: 'This file type preview is not supported', unsupportedFileType: 'This file type preview is not supported',
} }

View File

@@ -7,5 +7,18 @@ export const lang = {
submitSuccess: 'Submitted successfully', submitSuccess: 'Submitted successfully',
// Form information not available // Form information not available
noFormInfo: '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

@@ -115,6 +115,7 @@ export const lang = {
// Product Message // Product Message
productMessage: 'Product Message', productMessage: 'Product Message',
formEmptyTip: 'No message fields to fill',
// AI Customer Service // AI Customer Service
aiCustomerService: 'AI Assistant', aiCustomerService: 'AI Assistant',

View File

@@ -3,5 +3,6 @@ export const lang = {
title: 'Payment Method', title: 'Payment Method',
paymentAmount: 'Payment Amount', paymentAmount: 'Payment Amount',
confirmPayment: 'Confirm Payment', confirmPayment: 'Confirm Payment',
seeOrder: 'View Order' seeOrder: 'View Order',
payInfoNotFound: 'Payment info not found!'
} }

View File

@@ -1,6 +1,6 @@
export const lang = { export const lang = {
//title为每个页面的标题 //title为每个页面的标题
title: 'AI智能客服', title: 'AI智能',
// ====== 通用 ====== // ====== 通用 ======
cancel: '取消', cancel: '取消',
@@ -23,7 +23,7 @@ export const lang = {
loadMoreHistory: '加载更多历史消息', loadMoreHistory: '加载更多历史消息',
loadingHistory: '加载历史消息中...', loadingHistory: '加载历史消息中...',
aiThinking: 'AI正在思考', aiThinking: 'AI正在思考',
receivingReply: '正在接收回复', receivingReply: '思考中',
aiThinkingStatus1: 'AI正在思考', aiThinkingStatus1: 'AI正在思考',
aiThinkingStatus2: 'AI正在分析', aiThinkingStatus2: 'AI正在分析',
aiThinkingStatus3: 'AI正在检索', aiThinkingStatus3: 'AI正在检索',
@@ -35,6 +35,7 @@ export const lang = {
copySelected: '已复制选中内容', copySelected: '已复制选中内容',
copyFail: '复制失败', copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}', copyFailWithReason: '复制失败:{reason}',
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
openingFile: '打开文件中...', openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}', openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}', openingFileTitle: '正在打开:{title}',
@@ -112,8 +113,16 @@ export const lang = {
// ====== ai-chat-tools-panel ====== // ====== ai-chat-tools-panel ======
moreFunctions: '更多功能', moreFunctions: '更多功能',
image: '图片', image: '图片',
video: '视频/音频',
file: '文件', file: '文件',
voice: '语音', voice: '语音',
recordVoice: '录制语音',
uploadAudio: '上传音频',
pickVideo: '选择视频',
pickAudio: '选择音频',
chooseAudioFail: '选择音频失败',
chooseVideoFail: '选择视频失败',
audioNotReady: '音频尚未就绪',
location: '位置', location: '位置',
// ====== ai-chat-voice-panel ====== // ====== ai-chat-voice-panel ======
@@ -135,5 +144,9 @@ export const lang = {
logEventHandleError: '事件处理错误', logEventHandleError: '事件处理错误',
logInitNavbarFailed: '初始化导航栏失败', logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化', logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...' logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
}
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}

View File

@@ -61,11 +61,15 @@ export const lang = {
weChatWorkKefu: '企业微信客服', weChatWorkKefu: '企业微信客服',
}, },
// diy 自定义页面 // diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
diy: { diy: {
networkErrorTitle: '提示',
networkError: '网络异常,请稍后重试', networkError: '网络异常,请稍后重试',
retry: '重试',
noDiyData: '未配置自定义页面数据', noDiyData: '未配置自定义页面数据',
locationNotAvailable: '未获取到定位', locationNotAvailable: '未获取到定位',
// 数据加载失败详细文案(diy 专属模板,内部 {reason} 由通用 http.* 填充)
loadFailTpl: '页面数据加载失败:{reason},请稍后重试',
}, },
// 客服服务 // 客服服务
@@ -108,6 +112,7 @@ export const lang = {
notSupported: '当前环境不支持微信视频号', notSupported: '当前环境不支持微信视频号',
missingFields: '缺少必要字段: {field}', missingFields: '缺少必要字段: {field}',
playFailed: '微信视频播放失败!', playFailed: '微信视频播放失败!',
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
}, },
// uniapp 工具函数 // uniapp 工具函数
@@ -121,6 +126,8 @@ export const lang = {
copyTimeoutReason: '原因:', copyTimeoutReason: '原因:',
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;', wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制', wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
copyFailed: '复制失败', copyFailed: '复制失败',
}, },
@@ -134,12 +141,18 @@ export const lang = {
requestFailed: '{url}请求失败. {err}', requestFailed: '{url}请求失败. {err}',
// 网络超时 // 网络超时
timeout: '网络请求超时,请稍后重试', timeout: '网络请求超时,请稍后重试',
// 网络连接失败 // 网络连接失败 / 通用网络异常(统一文案,供 diy 等通用场景复用)
networkError: '网络连接失败,请检查网络设置', networkError: '网络异常,请稍后重试',
// 服务器错误 // 服务器错误
serverError: '服务暂时不可用,请稍后重试', serverError: '服务器异常,请稍后重试',
// 接口不存在(HTTP 404)
notFound: '接口不存在',
// 无访问权限(HTTP 401 / 403)
auth: '无访问权限',
// 请求被中断 // 请求被中断
aborted: '请求已取消', aborted: '请求已取消',
// 未知错误(errorType 分类兜底,如非 5xx/404/401/403 的 HTTP 状态码)
unknown: '请求失败,请稍后重试',
// 未知错误 // 未知错误
unknownError: '请求失败,请稍后重试', unknownError: '请求失败,请稍后重试',
}, },
@@ -245,6 +258,14 @@ export const lang = {
addToCartFailMin: '已达到最小购买数量', addToCartFailMin: '已达到最小购买数量',
addToCartFailStock: '库存不足', addToCartFailStock: '库存不足',
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
submitSuccess: '提交成功',
submitFail: '提交失败',
versionInfo: '版本信息',
commitId: '提交 ID',
buildTime: '构建时间',
loading: '加载中...',
consult: '立即咨询', consult: '立即咨询',
consultSuccess: '咨询成功', consultSuccess: '咨询成功',
consultFail: '咨询失败', consultFail: '咨询失败',

View File

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

View File

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

View File

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

View File

@@ -108,6 +108,7 @@ export const lang = {
// 商品留言 // 商品留言
productMessage: '商品留言', productMessage: '商品留言',
formEmptyTip: '暂无需填写的留言字段',
// 智能客服 // 智能客服
aiCustomerService: '智能客服', aiCustomerService: '智能客服',

View File

@@ -3,5 +3,6 @@ export const lang = {
title: '支付方式', title: '支付方式',
paymentAmount: '支付金额', paymentAmount: '支付金额',
confirmPayment: '确认支付', confirmPayment: '确认支付',
seeOrder: '查看订单' seeOrder: '查看订单',
payInfoNotFound: '未获取到支付信息!'
} }

View File

@@ -80,10 +80,18 @@ Vue.mixin(MpShareDefault);
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'), // lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
// golbalConfig.js 已把 componentRefresh 注册为全局 computed, // golbalConfig.js 已把 componentRefresh 注册为全局 computed,
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值 // 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
// 同时调用 lang.refresh() 让当前页面的 navigationBarTitleText 跟随语种变化
// (pages.json 里的硬编码中文标题需要被 $lang('title') 覆盖)
Vue.mixin({ Vue.mixin({
watch: { watch: {
componentRefresh() { componentRefresh() {
this.$forceUpdate(); this.$forceUpdate();
try {
if (this.$langConfig && typeof this.$langConfig.refresh === 'function') {
// emit=false: lang.js 已 emit 过,这里避免重复 store.commit
this.$langConfig.refresh(false);
}
} catch (e) { /* 容错:refresh 失败不影响主流程 */ }
} }
} }
}); });

View File

@@ -82,7 +82,7 @@
} }
}, },
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ], "requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
"__usePrivacyCheck__" : true, "__usePrivacyCheck__" : false,
"optimization" : { "optimization" : {
"subPackages" : true "subPackages" : true
} }
@@ -136,7 +136,23 @@
"sassImplementationName" : "dart-sass", "sassImplementationName" : "dart-sass",
"mp-harmony" : { "mp-harmony" : {
"distribute" : { "distribute" : {
"bundleName" : "com.atomicservice.6917577984608507983" "bundleName" : "com.atomicservice.6917607804641099460"
},
// 隐私托管: 元服务必须使用 AGC 后台的标准化隐私声明托管服务,
// 弹窗由鸿蒙系统自动弹出, 应用层不能自建.
// 下方的 privacy 块仅作为应用内协议页兜底 (例如用户在系统设置里手动查看协议),
// 不参与首次启动弹窗逻辑.
"privacy" : {
"privacyContractUrl" : "YOUR_DOMAIN/api/agreementview/privacy?uniacid=YOUR_UNIACID",
"serviceContractUrl" : "YOUR_DOMAIN/api/agreementview/service?uniacid=YOUR_UNIACID",
"privacyContractName" : {
"zh" : "隐私政策",
"en" : "Privacy Policy"
},
"serviceContractName" : {
"zh" : "用户服务协议",
"en" : "User Service Agreement"
}
} }
} }
} }

View File

@@ -66,6 +66,26 @@
"define": { "define": {
"PRODUCTION": true "PRODUCTION": true
} }
},
"http://localhost:8050/": {
"title": "H5-localhost:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://localhost:8050/"
},
"define": {
"PRODUCTION": true
}
},
"http://192.168.8.118:8050/": {
"title": "H5-192.168.8.118:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://192.168.8.118:8050/"
},
"define": {
"PRODUCTION": true
}
} }
} }
} }

View File

@@ -51,6 +51,7 @@
"diy-pintuan": "view", "diy-pintuan": "view",
"diy-presale": "view", "diy-presale": "view",
"diy-quick-nav": "view", "diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view", "diy-rich-text": "view",
"diy-rubik-cube": "view", "diy-rubik-cube": "view",
"diy-search": "view", "diy-search": "view",
@@ -960,10 +961,10 @@
{ {
"path": "fenxiao/withdrawal_detail", "path": "fenxiao/withdrawal_detail",
"style": { "style": {
"navigationBarTitleText": ""
// #ifdef APP-PLUS // #ifdef APP-PLUS
,"navigationStyle": "custom" "navigationStyle": "custom",
// #endif // #endif
"navigationBarTitleText": ""
} }
}, },
{ {
@@ -1085,7 +1086,64 @@
{ {
"path": "ai-chat/index", "path": "ai-chat/index",
"style": { "style": {
"navigationBarTitleText": "AI智能客服" "navigationBarTitleText": "AI智能",
"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"
}
} }
}, },
{ {
@@ -1097,7 +1155,10 @@
{ {
"path": "vr/index", "path": "vr/index",
"style": { "style": {
"navigationBarTitleText": "" // #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "工厂展示"
} }
}, },
{ {
@@ -1291,6 +1352,7 @@
"diy-pintuan": "view", "diy-pintuan": "view",
"diy-presale": "view", "diy-presale": "view",
"diy-quick-nav": "view", "diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view", "diy-rich-text": "view",
"diy-rubik-cube": "view", "diy-rubik-cube": "view",
"diy-search": "view", "diy-search": "view",
@@ -2141,13 +2203,72 @@
// #ifdef APP-PLUS // #ifdef APP-PLUS
"navigationStyle": "custom", "navigationStyle": "custom",
// #endif // #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"
}
} }
}, },
{ {
"path": "form/formdata", "path": "form/formdata",
"style": { "style": {
// #ifdef APP-PLUS // APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
// #ifdef APP-PLUS || MP
"navigationStyle": "custom", "navigationStyle": "custom",
// #endif // #endif
"navigationBarTitleText": "", "navigationBarTitleText": "",
@@ -2381,7 +2502,8 @@
{ {
"path": "file-preview/file-preview", "path": "file-preview/file-preview",
"style": { "style": {
"navigationBarTitleText": "文件预览" "navigationBarTitleText": "文件预览",
"navigationStyle": "custom"
} }
} }
] ]
@@ -2420,10 +2542,8 @@
"x-skeleton": "@/uni_modules/x-skeleton/components/x-skeleton/x-skeleton.vue", "x-skeleton": "@/uni_modules/x-skeleton/components/x-skeleton/x-skeleton.vue",
"mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue", "mp-html": "@/uni_modules/mp-html/components/mp-html/mp-html.vue",
"uni-*(\\W.*)": "@/uni_modules/uni$1/components/uni$1/uni$1.vue", "uni-*(\\W.*)": "@/uni_modules/uni$1/components/uni$1/uni$1.vue",
"mescroll-uni": "@/components/mescroll/my-list-mescroll.vue", "mescroll-uni": "@/components/mescroll/my-list-mescroll.vue",
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue", "ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
"diy-*(\\W.*)": "@/components-diy/diy$1.vue" "diy-*(\\W.*)": "@/components-diy/diy$1.vue"
} }
}, },
@@ -2431,7 +2551,8 @@
"pages/index/index": { "pages/index/index": {
"network": "all", "network": "all",
"packages": [ "packages": [
"pages_tool" "pages_tool",
"components-diy"
] ]
} }
} }

View File

@@ -2,6 +2,15 @@
<view :style="themeColor"> <view :style="themeColor">
<view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }" <view :style="{ backgroundColor: bgColor, minHeight: openBottomNav ? 'calc(100vh - 55px)' : '' }"
class="page-img"> class="page-img">
<!-- diy 数据网络加载失败提示(页面内横幅,各端可靠,悬浮在最上层) -->
<view v-if="diyLoadError" class="diy-load-error">
<view class="diy-load-error__body">
<text class="diy-load-error__msg">{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}</text>
<text v-if="diyLoadErrorUrl" class="diy-load-error__url" selectable user-select>{{ diyLoadErrorUrl }}</text>
</view>
<view class="diy-load-error__btn" @tap="retryDiyInfo">{{ $lang('diy.retry') || '重试' }}</view>
</view>
<view class="site-info-box" <view class="site-info-box"
v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode"> v-if="$util.isWeiXin() && followOfficialAccount && followOfficialAccount.isShow && wechatQrcode">
<view class="site-info"> <view class="site-info">
@@ -179,6 +188,52 @@ export default {
z-index: 122; z-index: 122;
} }
.diy-load-error {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100000;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
padding-top: calc(24rpx + var(--status-bar-height, 0px));
background-color: #fff5f5;
border-bottom: 1rpx solid #ffccc7;
}
.diy-load-error__body {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.diy-load-error__msg {
font-size: 26rpx;
color: #cf1322;
line-height: 1.5;
}
.diy-load-error__url {
margin-top: 6rpx;
font-size: 22rpx;
color: #999;
line-height: 1.4;
word-break: break-all;
}
.diy-load-error__btn {
margin-left: 20rpx;
padding: 10rpx 28rpx;
font-size: 26rpx;
color: #fff;
background-color: #cf1322;
border-radius: 32rpx;
white-space: nowrap;
}
.small-bot swiper { .small-bot swiper {
width: 560rpx; width: 560rpx;
height: 784rpx; height: 784rpx;

View File

@@ -128,12 +128,12 @@ export default {
}, },
// diy-group 渲染完毕回调, 用于触发版本水印等延后显示元素 // diy-group 渲染完毕回调, 用于触发版本水印等延后显示元素
// 同时通知 splash-state 页面就绪, 启动屏可以关闭 // 同时通知 splash-state 页面就绪, 启动屏可以关闭
// 延迟 500ms 是为了确保页面内容有足够时间渲染 // 注意: diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle,
// 这里不再叠加 500ms, 避免吃掉 splash 的 2s page-ready 兜底预算
// (叠加延迟会导致弱网/冷启动下 splash 走 page-ready-timeout 兜底路径)
onDiyGroupLoaded() { onDiyGroupLoaded() {
this.diyGroupReady = true; this.diyGroupReady = true;
setTimeout(() => {
try { splashState.notifyPageReady(); } catch (e) {} try { splashState.notifyPageReady(); } catch (e) {}
}, 500);
}, },
/** /**
* 版本连击 3 次以上显示构建版本信息(供测试人员使用) * 版本连击 3 次以上显示构建版本信息(供测试人员使用)
@@ -160,8 +160,8 @@ export default {
} catch (e) {} } catch (e) {}
if (v && v.commitId) { if (v && v.commitId) {
uni.showModal({ uni.showModal({
title: '版本信息', title: this.$lang('versionInfo'),
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`, content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false showCancel: false
}); });
} else { } else {

View File

@@ -1,6 +1,30 @@
<template> <template>
<view> <view>
<view scroll-y="true" class="goods-detail" :class="isIphoneX ? 'active' : ''"> <!-- 骨架屏:商品详情加载时按结构分段占位(banner / 价格 / 标题 / 规格 / 商家 / 详情 / 推荐)
关闭条件: loading=false 且已拿到真实商品数据(goods_id>0); 任一不满足都保留骨架屏 -->
<view v-if="loading || !hasRealData" class="goods-detail-skeleton">
<x-skeleton data-component-name="goods-detail-view-banner" :loading="true" :configs="skeletonBanner"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-price" :loading="true" :configs="skeletonPrice"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-title" :loading="true" :configs="skeletonTitle"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-sku" :loading="true" :configs="skeletonSku"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-merch" :loading="true" :configs="skeletonMerch"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-detail" :loading="true" :configs="skeletonDetail"></x-skeleton>
<x-skeleton data-component-name="goods-detail-view-recommend" :loading="true" :configs="skeletonRecommend"></x-skeleton>
<!-- 底部 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>
<view class="skeleton-action-btn"></view>
<view class="skeleton-action-btn primary"></view>
</view>
</view>
<!-- 修复鸿蒙元服务: 移除无效的 scroll-y="true" 属性 (scroll-y 是 <scroll-view> 的指令属性)
鸿蒙 JSVM 早期版本遇到无效 attribute 会让 view 子树静默不渲染, 导致整个详情页空白 -->
<view v-else class="goods-detail-wrap">
<!-- 操作区域 slot: 与 .goods-detail 同一 v-else 分支, 避免骨架屏状态下被父组件渲染真实按钮(基于默认 goods_state=0 显示"该商品已下架") -->
<slot name="action"></slot>
<view class="goods-detail" :class="isIphoneX ? 'active' : ''">
<view class="goods-container"> <view class="goods-container">
<!-- AI客服悬浮按钮 --> <!-- AI客服悬浮按钮 -->
<view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn"> <view class="ai-chat-float-btn" @tap.stop="$emit('aiChatClick')" v-if="showAIChatBtn">
@@ -32,10 +56,11 @@
</view> </view>
</view> </view>
<!-- 商品视频 --> <!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }"> <view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)" <video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video> :poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
playsinline webkit-playsinline x5-playsinline></video>
</view> </view>
<!-- 切换视频、图片 --> <!-- 切换视频、图片 -->
@@ -459,7 +484,7 @@
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text> <text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
</button> </button>
</view> </view>
<!-- #ifdef H5 --> <!-- #ifdef H5 || MP-HARMONY -->
<view class="share-box" @click="copyUrl"> <view class="share-box" @click="copyUrl">
<button class="share-btn" :plain="true"> <button class="share-btn" :plain="true">
<view class="iconfont icon-fuzhilianjie"></view> <view class="iconfont icon-fuzhilianjie"></view>
@@ -476,9 +501,8 @@
<slot name="fixedbtn"></slot> <slot name="fixedbtn"></slot>
</view> </view>
</view> </view>
</view>
<!-- 操作区域 -->
<slot name="action"></slot>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top> <to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login> <ns-login ref="login"></ns-login>
</view> </view>
@@ -501,11 +525,142 @@ export default {
default: () => { default: () => {
return {}; return {};
} }
},
// 是否显示骨架屏(true:展示骨架;false:展示真实内容)
loading: {
type: Boolean,
default: true
} }
}, },
components: { components: {
pengpaiFadeinOut pengpaiFadeinOut
}, },
data() {
// 商品详情骨架屏:模拟「轮播图 + 价格行 + 标题简介 + 规格行 + 商家 + 详情 + 推荐」
return {
// 1. 轮播图:方形 banner 占位
skeletonBanner: {
padding: '0',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '24rpx',
headShow: true,
headWidth: '100%',
headHeight: '750rpx',
headBorderRadius: '0',
textShow: false
},
// 2. 价格行:一行价 + 一行原价
skeletonPrice: {
padding: '24rpx 24rpx 16rpx',
gridRows: 2,
gridColumns: 1,
gridRowsGap: '16rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 1,
textRowsGap: '0',
textWidth: ['40%', '30%'],
textHeight: ['48rpx', '24rpx'],
textBorderRadius: '6rpx'
},
// 3. 标题/简介:标题两行 + 简介 + 标签
skeletonTitle: {
padding: '8rpx 24rpx 24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '20rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 4,
textRowsGap: '16rpx',
textWidth: ['90%', '60%', '100%', '40%'],
textHeight: ['40rpx', '40rpx', '24rpx', '24rpx'],
textBorderRadius: '6rpx'
},
// 4. 规格行:单行高度的小条
skeletonSku: {
padding: '20rpx 24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'row',
itemAlign: 'center',
headShow: true,
headWidth: '120rpx',
headHeight: '32rpx',
headBorderRadius: '6rpx',
textShow: true,
textRows: 1,
textRowsGap: '0',
textWidth: '60%',
textHeight: '28rpx',
textBorderRadius: '6rpx',
itemGap: '20rpx'
},
// 5. 商家:头像 + 名称
skeletonMerch: {
padding: '24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'row',
itemAlign: 'center',
headShow: true,
headWidth: '80rpx',
headHeight: '80rpx',
headBorderRadius: '50%',
textShow: true,
textRows: 2,
textRowsGap: '12rpx',
textWidth: ['50%', '80%'],
textHeight: ['28rpx', '20rpx'],
textBorderRadius: '6rpx',
itemGap: '24rpx'
},
// 6. 商品详情:多行段落
skeletonDetail: {
padding: '24rpx',
gridRows: 1,
gridColumns: 1,
gridRowsGap: '0',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: false,
textShow: true,
textRows: 6,
textRowsGap: '16rpx',
textWidth: ['100%', '100%', '90%', '100%', '80%', '60%'],
textHeight: '24rpx',
textBorderRadius: '6rpx'
},
// 7. 推荐:两列卡片缩略图
skeletonRecommend: {
padding: '24rpx',
gridRows: 1,
gridColumns: 2,
gridRowsGap: '24rpx',
gridColumnsGap: '24rpx',
itemDirection: 'column',
itemAlign: 'flex-start',
headShow: true,
headWidth: '100%',
headHeight: '320rpx',
headBorderRadius: '12rpx',
textShow: true,
textRows: 2,
textRowsGap: '12rpx',
textWidth: ['100%', '70%'],
textHeight: ['24rpx', '24rpx'],
textBorderRadius: '6rpx',
itemGap: '12rpx'
}
};
},
computed: { computed: {
aiAvatarUrl() { aiAvatarUrl() {
return this.$util.img(IMAGE_CONFIG.aiAvatar); return this.$util.img(IMAGE_CONFIG.aiAvatar);
@@ -524,6 +679,11 @@ export default {
hasDetailContent() { hasDetailContent() {
const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url; const hasPdf = this.goodsSkuDetail && this.goodsSkuDetail.pdf_url;
return hasPdf || this.hasHtmlTab; return hasPdf || this.hasHtmlTab;
},
// 是否有真实商品数据: goods_id > 0 才算拿到商品详情
// 用于「未拿到任何数据时不显示真实内容」, 避免加载失败/异常时切到空详情页
hasRealData() {
return this.goodsSkuDetail && Number(this.goodsSkuDetail.goods_id) > 0;
} }
}, },
mixins: [scroll, detail] mixins: [scroll, detail]
@@ -532,6 +692,79 @@ export default {
<style lang="scss"> <style lang="scss">
@import '@/common/css/goods_detail.scss'; @import '@/common/css/goods_detail.scss';
/* 骨架屏外层:与商品详情整体上对齐,左右内边距由各 skeleton 自己控制 */
.goods-detail-skeleton {
width: 100%;
background-color: #fff;
}
/* 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 高度接近真实按钮 */
.goods-detail-skeleton-action {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 100rpx;
display: flex;
align-items: center;
background-color: #fff;
z-index: 9;
padding: 0 16rpx;
box-sizing: border-box;
border-top: 1rpx solid #f2f2f2;
padding-bottom: env(safe-area-inset-bottom);
}
.goods-detail-skeleton-action .skeleton-action-icons {
display: flex;
align-items: center;
margin-right: 16rpx;
}
.goods-detail-skeleton-action .skeleton-action-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #EAEDF5;
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
background-size: 400% 100%;
animation: skActAnim 1.8s ease infinite;
margin-right: 16rpx;
}
.goods-detail-skeleton-action .skeleton-action-icon:last-child {
margin-right: 0;
}
.goods-detail-skeleton-action .skeleton-action-btn {
flex: 1;
height: 70rpx;
border-radius: 10rpx;
margin-right: 16rpx;
background-color: #EAEDF5;
background: linear-gradient(90deg, #EAEDF5 25%, #F9FAFF 37%, #EAEDF5 50%);
background-size: 400% 100%;
animation: skActAnim 1.8s ease infinite;
}
.goods-detail-skeleton-action .skeleton-action-btn:last-child {
margin-right: 0;
}
.goods-detail-skeleton-action .skeleton-action-btn.primary {
background: linear-gradient(90deg, #d8d8d8 25%, #ececec 37%, #d8d8d8 50%);
background-size: 400% 100%;
}
@keyframes skActAnim {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
</style> </style>
<style scoped> <style scoped>
.ai-chat-float-btn { .ai-chat-float-btn {

View File

@@ -384,5 +384,14 @@ export default {
border-radius: 20rpx 20rpx 0 0; 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'; @import './public/css/cart.scss';
</style> </style>

View File

@@ -1,6 +1,7 @@
<template> <template>
<view :style="themeColor"> <view :style="themeColor">
<view v-show="diyData && diyData.value && diyData.value.length"> <!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
<view v-if="diyData && diyData.value && diyData.value.length">
<block v-for="(item, index) in diyData.value" :key="index"> <block v-for="(item, index) in diyData.value" :key="index">
<view v-if="item.componentName == 'GoodsCategory'"> <view v-if="item.componentName == 'GoodsCategory'">
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category> <diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
@@ -15,8 +16,8 @@
<!-- 隐私弹窗 --> <!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup> <privacy-popup ref="privacyPopup"></privacy-popup>
<!-- 底部tabBar --> <!-- 底部tabBar: loading 期间隐藏, 等 loading-cover 关闭后下一 tick 再挂载, 避免 TabBar 与 loading-cover 同时 GPU 合成导致频闪 -->
<view id="tab-bar"> <view id="tab-bar" v-if="!loadingVisible">
<diy-bottom-nav></diy-bottom-nav> <diy-bottom-nav></diy-bottom-nav>
</view> </view>
</view> </view>
@@ -27,7 +28,13 @@ export default {
data() { data() {
return { return {
diyData: { value: [] }, diyData: { value: [] },
mpShareData: null //小程序分享数据 mpShareData: null, //小程序分享数据
// 修复底部 TabBar 频闪: 用本地 loadingVisible 状态镜像 <loading-cover> 的可见性,
// loading-cover 的 spin 动画与 diy-bottom-nav 都是 fixed/z-index 高 + GPU 合成,
// 在 MP-Weixin 下会互相触发重绘, 让用户看到 TabBar 抖动。
// 策略: loading-cover 显示期间, 也把 tabBar 先收起来, loading-cover 隐藏后
// 下一次 nextTick 再放出 tabBar, 把整段 loading 过程的合成层冲突降到最低。
loadingVisible: true
}; };
}, },
onLoad() { onLoad() {
@@ -40,8 +47,13 @@ export default {
this.$util.hideHomeButton(); this.$util.hideHomeButton();
try { try {
// 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御 // 鸿蒙元服务 JSVM 下 $refs.category 可能为 undefined, 需防御
if (this.$refs.category && this.$refs.category[0]) { // 节流: 避免 onShow 重复触发 pageShow() 中的 getHeightArea(-1) 高耗时选择器查询, 引起频闪和性能损耗
const now = Date.now();
if (this._lastPageShowTime && now - this._lastPageShowTime < 500) {
// 短时间内重复触发, 跳过本次 pageShow
} else if (this.$refs.category && this.$refs.category[0]) {
this.$refs.category[0].pageShow(); this.$refs.category[0].pageShow();
this._lastPageShowTime = now;
} }
} catch (e) { } catch (e) {
console.warn('onShow pageShow failed', e); console.warn('onShow pageShow failed', e);
@@ -109,6 +121,13 @@ export default {
this._diyInfoLoading = false; this._diyInfoLoading = false;
if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
uni.stopPullDownRefresh(); uni.stopPullDownRefresh();
// loading-cover 收起后, 等下一 tick 让出 frame, 再挂载 diy-bottom-nav
// (避免两个 fixed 高 z-index GPU 合成层在同帧 mount 触发 MP-Weixin 重绘频闪)
this.$nextTick(() => {
this.$nextTick(() => {
this.loadingVisible = false;
});
});
} }
}); });
}, },

View File

@@ -1,12 +1,14 @@
<template> <template>
<view :style="themeColor"> <!-- 鸿蒙兼容: themeColor 是 CSS 变量字符串, 鸿蒙 JSVM 早期版本对 :style="字符串" 处理可能不稳.
兜底: 只有非空字符串才绑定 :style. -->
<view :style="themeColor || undefined">
<!-- #ifndef H5 --> <!-- #ifndef H5 -->
<!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0"> <!-- <view class="page-header" v-if="goodsSkuDetail && goodsSkuDetail.config && goodsSkuDetail.config.nav_bar_switch == 0">
<ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar> <ns-navbar :data="navbarData" :isBack="!isTabRootPage"></ns-navbar>
</view> --> </view> -->
<!-- #endif --> <!-- #endif -->
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)"> <goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<!-- 价格区域 --> <!-- 价格区域 -->
<template v-slot:price> <template v-slot:price>
<!-- 限时折扣 --> <!-- 限时折扣 -->
@@ -15,7 +17,7 @@
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view> <view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
<view class="price-box"> <view class="price-box">
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view> <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>
</view> </view>
@@ -72,7 +74,9 @@
</view> </view>
<view class="follow-and-share"> <view class="follow-and-share">
<text class="follow iconfont icon-share" @click="openSharePopup()"></text> <text class="follow iconfont icon-share" @click="openSharePopup()"></text>
<!-- #ifndef MP-HARMONY -->
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text> <text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
<!-- #endif -->
</view> </view>
</block> </block>
<block v-else> <block v-else>
@@ -90,8 +94,8 @@
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text> <text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
</view> </view>
<view class="logistics-wrap"> <view class="logistics-wrap">
<text v-if="goodsSkuDetail.stock_show">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +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">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text> <text v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
</view> </view>
<!-- 会员卡 --> <!-- 会员卡 -->
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard"> <!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
@@ -411,7 +415,17 @@
</view> --> </view> -->
<!-- v-if="goodsSkuDetail.goodsForm" --> <!-- v-if="goodsSkuDetail.goodsForm" -->
<view style="padding: 0 30rpx;"> <view style="padding: 0 30rpx;">
<ns-form :data="informationform" ref="form"></ns-form> <!-- 表单暂无字段时的友好提示, 避免"打开弹窗却什么都不显示"困惑用户 -->
<view v-if="!formDiyValue || !formDiyValue.jsonData || !formDiyValue.jsonData.length" class="form-empty" style="padding: 60rpx 0; text-align: center; color: #999;">
{{ $lang('goods.detail.formEmptyTip') }}
</view>
<diy-form
v-else
:value="formDiyValue"
dataMode="raw"
:show-submit="false"
ref="form"
></diy-form>
</view> </view>
</scroll-view> </scroll-view>
<view class="button-box"><button type="primary" @click="saveubfirnation()">{{ $lang('goods.detail.confirm') }}</button></view> <view class="button-box"><button type="primary" @click="saveubfirnation()">{{ $lang('goods.detail.confirm') }}</button></view>
@@ -448,6 +462,7 @@
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<ns-goods-action-icon <ns-goods-action-icon
v-if="!isPhoneConsult"
:text="$lang('goods.detail.cart')" :text="$lang('goods.detail.cart')"
:cornerMarkBg="themeStyle.goods_cart_num_corner" :cornerMarkBg="themeStyle.goods_cart_num_corner"
icon="icon-gouwuche2" icon="icon-gouwuche2"
@@ -456,6 +471,9 @@
/> />
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" /> <ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
<!-- #endif --> <!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙平台: 隐藏购物车、客服、收藏按钮 -->
<!-- #endif -->
<!-- #ifdef H5 --> <!-- #ifdef H5 -->
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" /> <ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
<!-- #endif --> <!-- #endif -->
@@ -511,7 +529,9 @@
</goods-detail-view> </goods-detail-view>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top> <to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login> <ns-login ref="login"></ns-login>
<loading-cover ref="loadingCover"></loading-cover> <!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
<!-- 隐私弹窗 --> <!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup> <privacy-popup ref="privacyPopup"></privacy-popup>
@@ -545,6 +565,25 @@ export default {
nsGoodsPromotion nsGoodsPromotion
}, },
mixins: [base, detail, scroll], 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() {
const sku = this.goodsSkuDetail || {};
return {
jsonData: sku.goods_form || [],
formConfig: sku.goods_form_config || {},
show_banner: sku.goods_form_show_banner === true,
form_banner: sku.goods_form_banner || '',
show_tip: sku.goods_form_show_tip === true,
form_tip: sku.goods_form_tip || ''
};
}
},
methods: { methods: {
/** /**
* 发送商品信息到智能客服 * 发送商品信息到智能客服
@@ -569,7 +608,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo); uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({ uni.navigateTo({
url: '/pages_tool/ai-chat/index' url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
}); });
}, },

View File

@@ -40,7 +40,9 @@ export default {
this.isAction = false; this.isAction = false;
}, },
onUnload() { onUnload() {
if (!this.storeToken && this.$refs.login) this.$refs.login.cancelCompleteInfo(); if (!this.storeToken && this.$refs.login && typeof this.$refs.login.cancelCompleteInfo === 'function') {
this.$refs.login.cancelCompleteInfo();
}
}, },
onReady() { onReady() {
if (!this.storeToken) { if (!this.storeToken) {

View File

@@ -4,6 +4,9 @@ export default {
goodsRoute: '/pages_goods/detail', goodsRoute: '/pages_goods/detail',
couponList: [], //优惠券列表 couponList: [], //优惠券列表
couponBtnSwitch: false, //获取优惠券防止重复提交 couponBtnSwitch: false, //获取优惠券防止重复提交
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
loading: true,
posterApi: '/api/goods/poster', posterApi: '/api/goods/poster',
@@ -24,9 +27,10 @@ export default {
membercard: null, // 会员卡信息 membercard: null, // 会员卡信息
hackReset: true, hackReset: true,
cardOff: false, cardOff: false
informationform:[]//留言表单 // 留言表单数据不再在 data() 维护:detail.vue 的 computed formDiyValue
// 直接从 goodsSkuDetail.goods_form / goods_form_config / goods_form_* 实时读取并打包成
// diy-form raw 模式期望的 value 结构。
} }
}, },
computed: { computed: {
@@ -52,11 +56,19 @@ export default {
} }
return discount.toFixed(2); return discount.toFixed(2);
}, },
//表单高度 // 弹窗高度: 受限于视口, 内部表单 <scroll-view> 自带滚动, 不再按字段数无限加高
FormHeight() { FormHeight() {
let height = 48; // 上限 65vh: 留足顶部状态栏/导航栏 + 底部 home indicator, 也不会显得"一打开就半屏遮脸"
if (this.goodsForm && this.goodsForm.length) { // 字段多时通过内部 <scroll-view scroll-y> 自然滚动
height += this.goodsForm.length * 5; const formList = (this.goodsSkuDetail && this.goodsSkuDetail.goods_form)
|| this.goodsForm;
let height = 45;
if (formList && formList.length) {
// 少量字段: 每个约 5vh; 字段多时递增放缓并封顶, 避免接近屏顶
// 5 个字段 → 45 + min(10, 25) = 55vh
// 10 个字段 → 45 + min(10, 50) = 55vh (已封顶)
const dynamic = Math.min(10, formList.length * 5);
height = Math.min(55, height + dynamic);
} }
height += 'vh'; height += 'vh';
return height; return height;
@@ -89,7 +101,18 @@ export default {
}, },
async onShow() { async onShow() {
// 同步获取商品详情 // 同步获取商品详情
// 鸿蒙元服务兼容: 包 try/catch, 防止内部任何抛错导致整个 page 显示空白
try {
await this.getGoodsSkuDetail(); await this.getGoodsSkuDetail();
} catch (e) {
console.error('getGoodsSkuDetail failed:', e);
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
// 避免错误地把页面切到"空详情"状态
uni.showToast({
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
icon: 'none'
});
}
}, },
onHide() { onHide() {
this.couponBtnSwitch = false; this.couponBtnSwitch = false;
@@ -111,7 +134,13 @@ export default {
}, },
// 获取商品详情 // 获取商品详情
async getGoodsSkuDetail() { async getGoodsSkuDetail() {
let res = await this.$api.sendRequest({ let res;
try {
// 修复鸿蒙元服务: 必须传 async: false !
// 当 App.vue 的 boot (lang/theme/remote-config) 未完成时,
// sendRequest 在 async !== false 模式下会直接 return undefined (不返回 promise),
// 导致 res.code undefined / res.data 抛 TypeError, 整个 onShow 失败.
res = await this.$api.sendRequest({
url: '/api/goodssku/detail', url: '/api/goodssku/detail',
async: false, async: false,
data: { data: {
@@ -119,6 +148,14 @@ export default {
goods_id: this.goodsId, goods_id: this.goodsId,
} }
}); });
// 防御: 即便 async:false 仍可能在极端情况下返回 undefined
if (!res || typeof res !== 'object') {
throw new Error('sendRequest returned non-object response (likely boot not complete)');
}
} catch (e) {
console.error('getGoodsSkuDetail sendRequest failed:', e);
throw e;
}
let data = res.data; let data = res.data;
if (data.goods_sku_detail != null) { if (data.goods_sku_detail != null) {
if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) { if (data.goods_sku_detail.promotion_type == 'presale' && data.goods_sku_detail.presale_id) {
@@ -130,6 +167,9 @@ export default {
} }
if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name if(uni.getStorageSync('lang') == 'en-us') data.goods_sku_detail.goods_name = data.goods_sku_detail.en_goods_name
// 关键改动: 先一次性赋值, 释放事件循环让 MP-Weixin 把首屏 (含媒体/价格/标题) 渲出来,
// 再 $nextTick 后才跑同步字段变换 (sku_images split / JSON.parse / 属性去重 等),
// 避免连续多次 Vue 响应式写触发同一帧内多次 vdom 重算, 显著改善"打开详情很慢"的体感。
this.goodsSkuDetail = data.goods_sku_detail; this.goodsSkuDetail = data.goods_sku_detail;
if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id; if (!this.skuId) this.skuId = this.goodsSkuDetail.sku_id;
if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id; if (!this.goodsId) this.goodsId = this.goodsSkuDetail.goods_id;
@@ -141,7 +181,10 @@ export default {
this.chatRoomParams = { this.chatRoomParams = {
sku_id: this.goodsSkuDetail.sku_id sku_id: this.goodsSkuDetail.sku_id
}; };
let typeId = this.goodsSkuDetail.goods_promotion[0]; // 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
if (typeId) { if (typeId) {
// 限时折扣 // 限时折扣
if (typeId.discount_id) { if (typeId.discount_id) {
@@ -155,8 +198,28 @@ export default {
goods_id: this.goodsId goods_id: this.goodsId
}; };
// 处理商品数据 // 让首屏主区域先绘制
this.handleGoodsSkuData(); // 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
// #ifdef MP-HARMONY
await new Promise(resolve => setTimeout(resolve, 16));
// 修复鸿蒙元服务: 不依赖 setTimeout 关闭骨架屏.
// 鸿蒙 JSVM 在 onShow 早期窗口的 setTimeout 回调有时被推迟/丢弃, 表现为"数据已回来但永远显示骨架屏".
// 数据已赋值 + 首屏让步, 同步直接关闭骨架屏, 由 hasRealData 兜底避免空详情.
this.loading = false;
// #endif
// #ifndef MP-HARMONY
await this.$nextTick();
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
// 用户会看到骨架屏 → 真实详情 的清晰过渡
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
setTimeout(() => {
this.loading = false;
}, 300);
// #endif
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
await this.handleGoodsSkuData();
// 限时折扣 // 限时折扣
if (this.goodsSkuDetail.promotion_type == 1 && this.addonIsExist.discount) { if (this.goodsSkuDetail.promotion_type == 1 && this.addonIsExist.discount) {
@@ -192,7 +255,7 @@ export default {
} }
// 优惠券 // 优惠券
if (this.goodsSkuDetail.coupon_list) { if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
this.couponList = this.goodsSkuDetail.coupon_list; this.couponList = this.goodsSkuDetail.coupon_list;
this.couponList.forEach(v => { this.couponList.forEach(v => {
if (v.count == v.lead_count) v.useState = 2; if (v.count == v.lead_count) v.useState = 2;
@@ -207,8 +270,6 @@ export default {
// this.handleBundlingData(this.goodsSkuDetail.bundling_list); // this.handleBundlingData(this.goodsSkuDetail.bundling_list);
// } // }
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
} else { } else {
this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo'); this.$util.redirectTo('/pages_tool/goods/not_exist', {}, 'redirectTo');
} }
@@ -220,9 +281,35 @@ export default {
}, },
//商品留言打开 //商品留言打开
showinformation(){ async showinformation(){
this.informationform = this.goodsSkuDetail.goods_form // 修复: 第一次打开时, uni-popup 内部 <scroll-view><diy-form> 还没完成首次渲染,
// 直接 open() 会让用户看到空白弹窗。这里:
// 1) 若商品表单后台请求尚未完成 (或根本没触发), 先发起并 await 该请求
// 2) 用 $nextTick 等组件挂载完成后再打开
try {
// 若页面已进入但 _goodsFormPromise 还没建 (如 sku 切换/数据未到等场景),
// 这里补一个并发请求; 若已存在, 复用旧 promise 不重复请求。
if (!this._goodsFormPromise || typeof this._goodsFormPromise.then !== 'function') {
const loader = (this.getGoodsForm && typeof this.getGoodsForm === 'function')
? this.getGoodsForm()
: Promise.resolve();
this._goodsFormPromise = loader;
await loader;
} else {
await this._goodsFormPromise;
}
} catch (e) {
console.warn('await goods form promise failed', e);
}
// 第一次强制刷新 popup 内部组件布局 (nextTick), 确保表单完整渲染
// 兼容鸿蒙元服务: $nextTick 不可靠时降级到 setTimeout
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
// 微信小程序下 scroll-view 在首次以 transform 隐藏后再显示时, 内部子节点布局可能未刷新
// 双 nextTick 兜底, 保证表单完整可见
try { await this.$nextTick(); } catch (e) { /* 兜底 */ }
if (this.$refs.informationPopup && typeof this.$refs.informationPopup.open === 'function') {
this.$refs.informationPopup.open(); this.$refs.informationPopup.open();
}
}, },
//商品留言关闭 //商品留言关闭
closeinformationPopup(){ closeinformationPopup(){
@@ -230,18 +317,18 @@ export default {
}, },
//保存商品留言 //保存商品留言
saveubfirnation(){ saveubfirnation(){
//缺少 // ref 指向 <diy-form>:diy-form 只暴露 getFormData() 方法,无 formData 属性
if (!this.$refs.form.verify()) return; if (!this.$refs.form.verify()) return;
// uni.setStorageSync('goodFormData', { // uni.setStorageSync('goodFormData', {
// goods_id: this.goodsSkuDetail.goods_id, // goods_id: this.goodsSkuDetail.goods_id,
// form_data: this.$refs.form.formData // form_data: this.$refs.form.getFormData()
// }); // });
this.$api.sendRequest({ this.$api.sendRequest({
url: '/api/goods/information', url: '/api/goods/information',
data: { data: {
goods_id: this.goodsSkuDetail.goods_id, goods_id: this.goodsSkuDetail.goods_id,
form_data: JSON.stringify(this.$refs.form.formData) form_data: JSON.stringify(this.$refs.form.getFormData())
}, },
success: res => { success: res => {
var data = res.data; var data = res.data;

View File

@@ -37,7 +37,7 @@ export default {
fontWeight: false, fontWeight: false,
padding: 0, padding: 0,
cartEvent: "detail", cartEvent: "detail",
text: "购买", text: "",
textColor: "#FFFFFF", textColor: "#FFFFFF",
theme: "default", theme: "default",
aroundRadius: 25, aroundRadius: 25,
@@ -67,7 +67,7 @@ export default {
}, },
onLoad(options) { onLoad(options) {
this.config.text = this.$lang('goods.list.buyText'); this.config.text = this.$lang('goods.list.buyText') || this.$lang('buyNow');
this.$api.sendRequest({ this.$api.sendRequest({
url: '/api/lucky/status', url: '/api/lucky/status',

View File

@@ -234,7 +234,7 @@
<view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1"> <view class="order-cell coupon" v-if="modules.indexOf('coupon') != -1">
<text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text> <text class="tit">{{ $lang('order.payment.orderMoney.coupon') }}</text>
<view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)"> <view class="box text-overflow"@click="openSiteCoupon(calculateGoodsData.merch_id)">
<template v-if="orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id"> <template v-if="(orderCreateData.coupon && orderCreateData.coupon[calculateGoodsData.merch_id] && orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id)">
<text class="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text> <text class="money price-font" style="max-width: 100%;">{{ $lang('order.payment.orderMoney.couponSelected', { count: 1 }) }}</text>
<!-- <text class="unit price-font">优惠</text> <!-- <text class="unit price-font">优惠</text>
<text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</text> --> <text class="money price-font">{{ (calculateData && calculateData.coupon_money ? calculateData.coupon_money : 0) | moneyFormat }}</text> -->
@@ -550,7 +550,7 @@
<view v-if="couponItem.type == 'discount' && couponItem.discount_limit > 0" class="limit">{{ $lang('order.payment.couponPopup.maxDiscount', { amount: couponItem.discount_limit }) }}</view> <view v-if="couponItem.type == 'discount' && couponItem.discount_limit > 0" class="limit">{{ $lang('order.payment.couponPopup.maxDiscount', { amount: couponItem.discount_limit }) }}</view>
<view class="time font-size-goods-tag">{{ $lang('order.payment.couponPopup.validPeriod') }}{{ couponItem.end_time ? $util.timeStampTurnTime(couponItem.end_time) : $lang('order.payment.couponPopup.permanentValid') }}</view> <view class="time font-size-goods-tag">{{ $lang('order.payment.couponPopup.validPeriod') }}{{ couponItem.end_time ? $util.timeStampTurnTime(couponItem.end_time) : $lang('order.payment.couponPopup.permanentValid') }}</view>
</view> </view>
<view class="iconfont" :class="selectCouponId == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view> <view class="iconfont" :class="selectCouponMap[merchCoupon.merch_id] == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -55,7 +55,8 @@ export default {
merch_id: 0, merch_id: 0,
data: [] data: []
}, },
selectCouponId:0 // 按 merch_id 隔离,避免多商户之间互相污染
selectCouponMap: {}
} }
}, },
inject: ['promotion'], inject: ['promotion'],
@@ -85,7 +86,7 @@ export default {
goodsData() { goodsData() {
if (this.paymentData) { if (this.paymentData) {
this.paymentData.goods_list.forEach(item => { this.paymentData.goods_list.forEach(item => {
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format); if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
}) })
return this.paymentData; return this.paymentData;
} }
@@ -93,7 +94,7 @@ export default {
calculateGoodsData() { calculateGoodsData() {
if (this.calculateData) { if (this.calculateData) {
this.calculateData.goods_list.forEach(item => { this.calculateData.goods_list.forEach(item => {
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format); if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
}) })
return this.calculateData; return this.calculateData;
} }
@@ -189,7 +190,7 @@ export default {
//多商户商品分类 //多商户商品分类
goodsSpecFormat(goods_list){ goodsSpecFormat(goods_list){
goods_list.forEach(item => { goods_list.forEach(item => {
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format); if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
}) })
return goods_list return goods_list
}, },
@@ -249,7 +250,11 @@ export default {
this.modules = data.modules; this.modules = data.modules;
//记录多商户优惠券 //记录多商户优惠券
this.orderCreateData.coupon = data.coupon // 兜底为对象,避免后续被当作数组处理
if (!data.coupon || typeof data.coupon !== 'object' || Array.isArray(data.coupon)) {
data.coupon = {};
}
this.orderCreateData.coupon = data.coupon;
//查询优惠券 //查询优惠券
this.getCouponList(); this.getCouponList();
@@ -365,7 +370,17 @@ export default {
} }
}); });
//多商户 //多商户
this.orderCreateData.coupon = res.data.coupon // 保留用户已选的 coupon_id,后端回传的 res.data.coupon 默认都是 0 会覆盖掉
const prevCoupon = (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) ? this.orderCreateData.coupon : {};
this.orderCreateData.coupon = res.data.coupon || {};
if (this.orderCreateData.coupon && typeof this.orderCreateData.coupon === 'object' && !Array.isArray(this.orderCreateData.coupon)) {
Object.keys(prevCoupon).forEach(merch_id => {
const chosen = prevCoupon[merch_id] && prevCoupon[merch_id].coupon_id;
if (chosen && (!this.orderCreateData.coupon[merch_id] || !this.orderCreateData.coupon[merch_id].coupon_id)) {
this.orderCreateData.coupon[merch_id] = { coupon_id: chosen };
}
});
}
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0; // if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id; // else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
@@ -740,10 +755,14 @@ export default {
this.tempData = this.$util.deepClone(this.orderCreateData); this.tempData = this.$util.deepClone(this.orderCreateData);
this.merchCoupon.merch_id = merch_id; this.merchCoupon.merch_id = merch_id;
this.selectCouponId = this.orderCreateData.coupon[merch_id].coupon_id; // 从当前订单已选的 coupon_id 取值,没有则取弹窗里缓存的,再没有为 0
const exist = this.orderCreateData.coupon && this.orderCreateData.coupon[merch_id];
const cached = this.selectCouponMap[merch_id] || 0;
this.selectCouponMap[merch_id] = exist ? (exist.coupon_id || 0) : cached;
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money; // this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
this.merchCoupon.data = this.coupon_list[merch_id]; // 兜底: 列表为空时给一个空数组,弹窗不会渲染空指针
this.merchCoupon.data = (this.coupon_list && this.coupon_list[merch_id]) || [];
this.$refs.couponPopup.open(); this.$refs.couponPopup.open();
this.$forceUpdate(); this.$forceUpdate();
}, },
@@ -755,17 +774,21 @@ export default {
// }else{ // }else{
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id // this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
// }; // };
if (this.selectCouponId != data.coupon_id) { const cur = this.selectCouponMap[merch_id] || 0;
this.selectCouponId = data.coupon_id; this.selectCouponMap[merch_id] = (cur != data.coupon_id) ? data.coupon_id : 0;
} else {
this.selectCouponId = 0;
}
this.$forceUpdate(); this.$forceUpdate();
// this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id // this.orderCreateData.coupon[merch_id].coupon_id = data.coupon_id
}, },
//确定使用优惠券 //确定使用优惠券
useCpopon() { useCpopon() {
this.orderCreateData.coupon[this.merchCoupon.merch_id].coupon_id = this.selectCouponId; const merch_id = this.merchCoupon.merch_id;
// 确保 orderCreateData.coupon 是对象结构,后端按 merch_id 取值
if (!this.orderCreateData.coupon || typeof this.orderCreateData.coupon !== 'object' || Array.isArray(this.orderCreateData.coupon)) {
this.orderCreateData.coupon = {};
}
this.orderCreateData.coupon[merch_id] = {
coupon_id: this.selectCouponMap[merch_id] || 0
};
this.$refs.couponPopup.close(); this.$refs.couponPopup.close();
this.calculate(); this.calculate();
}, },

View File

@@ -39,7 +39,7 @@ export default {
fontWeight: false, fontWeight: false,
padding: 0, padding: 0,
cartEvent: "detail", cartEvent: "detail",
text: this.$lang('merch.list.buyText'), text: '',
textColor: "#FFFFFF", textColor: "#FFFFFF",
theme: "default", theme: "default",
aroundRadius: 25, aroundRadius: 25,
@@ -66,6 +66,7 @@ export default {
}, },
onLoad(options) { onLoad(options) {
this.config.text = this.$lang('merch.list.buyText');
this.categoryId = options.category_id || 0; this.categoryId = options.category_id || 0;
this.keyword = options.keyword || ''; this.keyword = options.keyword || '';
this.coupon = options.coupon || 0; this.coupon = options.coupon || 0;

View File

@@ -180,7 +180,13 @@ export default {
} }
::v-deep .mescroll-totop { ::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important; right: 27rpx !important;
bottom: 120rpx !important; bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -11,25 +11,32 @@
<view class="tools-grid"> <view class="tools-grid">
<view class="tool-item" @click="selectImage"> <view class="tool-item" @click="selectImage">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-image"></text> <text class="icondiy icon-system-image-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view> </view>
<view class="tool-item" @click="selectVideo">
<view class="tool-icon">
<text class="icondiy icon-system-video"></text>
</view>
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
</view>
<view class="tool-item" @click="selectFile"> <view class="tool-item" @click="selectFile">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-file"></text> <text class="icondiy icon-system-file-2-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
</view> </view>
<view class="tool-item" @click="startRecord"> <view class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-voice"></text> <text class="icondiy icon-system-chat-voice-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
</view> </view>
<view class="tool-item" @click="selectLocation"> <view class="tool-item" @click="selectLocation">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-location"></text> <text class="icondiy icon-system-map-pin-line"></text>
</view> </view>
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text> <text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
</view> </view>
@@ -39,6 +46,7 @@
</template> </template>
<script> <script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/** /**
* ai-chat-tools-panel - 更多工具面板组件 * ai-chat-tools-panel - 更多工具面板组件
* *
@@ -48,7 +56,7 @@
* Events: * Events:
* update:visible - 同步关闭状态 * update:visible - 同步关闭状态
* image-selected - 选择图片,参数:图片消息对象 * image-selected - 选择图片,参数:图片消息对象
* file-selected - 选择文件,参数:文件消息对象 * file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
* voice-start - 开始语音 * voice-start - 开始语音
* location-selected - 选择位置,参数:位置消息对象 * location-selected - 选择位置,参数:位置消息对象
*/ */
@@ -74,73 +82,296 @@ export default {
}, },
selectImage() { selectImage() {
const that = this
uni.chooseImage({ uni.chooseImage({
count: 1, count: 9,
sizeType: ['compressed'], sizeType: ['compressed'],
sourceType: ['album', 'camera'], sourceType: ['album', 'camera'],
success: (res) => { success: (res) => {
const imageMessage = { that.hideToolsPanel()
id: ++this.messageId, // 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
role: 'user', // 用户可继续输入文字, 点发送时图文一起发出
type: 'image', that.$emit('image-picked', res.tempFilePaths || [])
content: '', },
imageUrl: res.tempFilePaths[0], fail: (err) => {
timestamp: Date.now() if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
} }
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
} }
}) })
}, },
selectFile() { selectFile() {
const that = this
// #ifdef H5 // #ifdef H5
const input = document.createElement('input') const input = document.createElement('input')
input.type = 'file' input.type = 'file'
input.accept = '*/*' input.accept = '*/*'
input.onchange = (e) => { input.onchange = async (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (!file) return
const fileMessage = { that.hideToolsPanel()
id: ++this.messageId, await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
} }
input.click() input.click()
// #endif // #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 // #ifdef APP-PLUS
uni.chooseFile({ uni.chooseFile({
count: 1, count: 1,
success: (res) => { success: async (res) => {
that.hideToolsPanel()
const file = res.tempFiles[0] const file = res.tempFiles[0]
const fileMessage = { await that._uploadOneFile(file, 'file')
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)
} }
}) })
// #endif // #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()
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid']
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', 'amr', 'wma', 'aiff'],
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', 'amr'],
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() { startRecord() {
this.$emit('voice-start') this.$emit('voice-start')
this.hideToolsPanel() this.hideToolsPanel()
@@ -209,6 +440,7 @@ export default {
margin-bottom: 32rpx; margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8; border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx; padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text { text {
font-size: 34rpx; font-size: 34rpx;
@@ -225,7 +457,9 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: none; border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont { .iconfont {
font-size: 28rpx; font-size: 28rpx;
color: $color-text-light; color: $color-text-light;
@@ -257,7 +491,8 @@ export default {
margin-bottom: 16rpx; margin-bottom: 16rpx;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
.iconfont { .iconfont,
.icondiy {
font-size: 48rpx; font-size: 48rpx;
color: $color-primary; color: $color-primary;
} }

View File

@@ -13,7 +13,8 @@
<!-- #endif --> <!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 --> <!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content"> <!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<ai-chat-message <ai-chat-message
ref="chat" ref="chat"
:initial-messages="initialMessages" :initial-messages="initialMessages"
@@ -68,6 +69,10 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
</view> </view>
</template> </template>
@@ -87,6 +92,9 @@ export default {
return { return {
initialMessages: [], initialMessages: [],
fallbackWelcome: '', fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 --- // --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -105,7 +113,12 @@ export default {
keyboardHeight: 0, keyboardHeight: 0,
// 事件处理器引用(用于清理) // 事件处理器引用(用于清理)
safeEventHandlers: new Map() safeEventHandlers: new Map(),
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
} }
}, },
@@ -127,6 +140,21 @@ export default {
async onLoad(options) { async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome'); this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
// 当前 onShow/initChat 不区分 agent 行为,后续按业务扩展按 agentId 路由即可。
try {
this.agentId = (options && options.agent_id) || 'agent_aikefu';
const rawTitle = (options && options.title) || this.$lang('ai-chat.index.title');
this.pageTitle = decodeURIComponent(rawTitle);
// 同步 agentId 给 ai-service,后续 sendMessage / WS auth 自动带新 agent_id
try { aiService.setAgentId(this.agentId); } catch (e) { /* 兼容旧 ai-service 版本 */ }
} catch (e) {
this.agentId = 'agent_aikefu';
this.pageTitle = this.$lang('ai-chat.index.title');
}
// ✅ 修改:取消权限校验,允许所有客服类型访问此页面 // ✅ 修改:取消权限校验,允许所有客服类型访问此页面
// const hasAccess = this.checkAccessPermission(); // const hasAccess = this.checkAccessPermission();
// if (!hasAccess) { // if (!hasAccess) {
@@ -134,7 +162,19 @@ export default {
// return; // return;
// } // }
this.$langConfig.title(this.$lang('ai-chat.index.title')); this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 进入 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(); this.initChat();
}, },
@@ -180,6 +220,15 @@ export default {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered')); console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup(); this.cleanup();
aiService.disconnectWebSocket(); aiService.disconnectWebSocket();
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
if (this.isFromFloatBtn) {
try {
this.$store.commit('setBottomNavHidden', false);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.isFromFloatBtn = false;
}
}, },
methods: { methods: {
@@ -647,7 +696,9 @@ export default {
/* #endif */ /* #endif */
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; /* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
} }
/* 底部tabBar占位样式 */ /* 底部tabBar占位样式 */

View File

@@ -160,7 +160,13 @@
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .mescroll-totop { ::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important; right: 27rpx !important;
bottom: 120rpx !important; bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
} }
</style> </style>

View File

@@ -1,35 +1,134 @@
<template> <template>
<view class="file-preview-container" :style="themeColor"> <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="header">
<view class="back-btn" @click="goBack"> <view class="nav-btn back-btn" @click="goBack">
<text class="iconfont icon-back"></text> <text class="iconfont icon-back"></text>
</view> </view>
<view class="header-title">{{ fileName }}</view> <view class="header-title">{{ headerTitle }}</view>
<view class="placeholder"></view> <button class="nav-btn share-btn" open-type="share" hover-class="none" @click.stop>
<text class="iconfont icon-fenxiang"></text>
</button>
</view> </view>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<!-- 微信小程序 web-view 是全屏原生组件,父容器 padding/margin 对它无效,必须在它前面放一个等高的占位 view 把它顶下去 -->
<view class="mp-content-spacer" :style="{ height: headerTotalHeightPx + 'px' }"></view>
<!-- #endif -->
<view class="content"> <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 文件预览 --> <!-- PDF 文件预览 -->
<view v-if="fileType === 'pdf'" class="file-viewer"> <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> <web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view> <view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view> </view>
<!-- Word 文件预览 --> <!-- Word 文件预览 -->
<view v-else-if="fileType === 'word'" class="file-viewer"> <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> <web-view v-if="fileUrl" :src="fileUrl"></web-view>
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view> <view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
</view> </view>
<!-- 视频文件预览 --> <!-- 视频文件预览 -->
<view v-else-if="fileType === 'video'" class="video-viewer"> <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 v-else class="error-message">{{ $lang('file-preview.file-preview.videoUrlNotExist') }}</view>
</view> </view>
<!-- 其他文件类型 --> <!-- 其他文件类型 -->
<view v-else class="file-viewer"> <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> <view class="error-message">{{ $lang('file-preview.file-preview.unsupportedFileType') }}</view>
<!-- #endif -->
</view> </view>
</view> </view>
</view> </view>
@@ -41,48 +140,263 @@ export default {
return { return {
fileName: '', fileName: '',
fileUrl: '', 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) { onLoad(option) {
// 解析传递的参数 // 解析传递的参数
if (option.fileName) { if (option.fileName) {
this.fileName = decodeURIComponent(option.fileName); this.fileName = decodeURIComponent(option.fileName);
} }
if (option.fileUrl) { if (option.fileUrl) {
this.fileUrl = decodeURIComponent(option.fileUrl); this.fileUrl = decodeURIComponent(option.fileUrl);
} }
if (option.fileType) { if (option.fileType) {
this.fileType = decodeURIComponent(option.fileType); this.fileType = decodeURIComponent(option.fileType);
} else { } else {
// 根据文件名后缀推断文件类型
this.inferFileType(); this.inferFileType();
} }
// #ifdef MP-WEIXIN
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 20;
this.windowWidth = sys.windowWidth || 375;
// #endif
}, },
methods: { onReady() {
/** // 视频加载后手动触发一次播放占位(去掉 autoplay 防自动播放被系统拦截)
* 根据文件名后缀推断文件类型 // #ifndef H5
*/ if (this.fileType === 'video' && this.fileUrl) {
inferFileType() { try {
if (this.fileName) { const ctx = uni.createVideoContext('filePreviewVideo', this);
const ext = this.fileName.split('.').pop().toLowerCase(); if (ctx && typeof ctx.play === 'function') { /* 留给用户点击,不自动播 */ }
if (['pdf'].includes(ext)) { } catch (e) {}
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';
} }
// #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() {
// 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();
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();
} }
}, },
/** _ensureAudioCtx() {
* 返回上一页 if (this.audioCtx) return this.audioCtx;
*/ try {
goBack() { const ctx = uni.createInnerAudioContext();
uni.navigateBack(); 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 { .file-preview-container {
min-height: 100vh; min-height: 100vh;
background-color: #f5f5f5; background-color: #f5f5f5;
display: flex;
flex-direction: column;
/* =================== 非 MP 端 header =================== */
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -101,22 +418,34 @@ export default {
padding: 0 30rpx; padding: 0 30rpx;
background-color: #fff; background-color: #fff;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); 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; width: 60rpx;
height: 60rpx; height: 60rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0;
background: transparent;
line-height: 1;
border: none;
margin: 0;
&::after { border: none; }
}
.back-btn {
.icon-back { .icon-back {
font-size: 32rpx; font-size: 36rpx;
color: #333; color: #333;
} }
} }
.header-title { .header-title {
flex: 1; flex: 1;
min-width: 0;
font-size: 32rpx; font-size: 32rpx;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
@@ -124,16 +453,90 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin: 0 20rpx;
} }
.placeholder { .share-btn {
width: 60rpx; .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 { .content {
flex: 1; 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 { .file-viewer {
width: 100%; width: 100%;
@@ -152,6 +555,50 @@ export default {
color: #999; color: #999;
font-size: 28rpx; 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 { .video-viewer {
@@ -178,6 +625,128 @@ export default {
background-color: #000; 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> </style>

View File

@@ -13,12 +13,18 @@
<view class="article-wrap"> <view class="article-wrap">
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv> <ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
<block v-for="(item, index) in list" :key="index"> <block v-for="(item, index) in list" :key="index">
<view class="item" @tap="toDetail(item.$orig)"> <view class="item" @tap="toDetail(item)">
<view class="article-img"> <view class="pdf-icon">
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image> <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>
<view class="info-wrap"> <view class="info-wrap">
<view class="title">{{ item.$orig.files_title }}</view> <view class="title">{{ item.files_title }}</view>
<view class="read-wrap"> <view class="read-wrap">
<text>{{ $lang('files.list.viewPdf') }}</text> <text>{{ $lang('files.list.viewPdf') }}</text>
</view> </view>
@@ -84,8 +90,10 @@ export default {
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
list = res.data.list list = res.data.list
if (!this.title && res.data.list.length > 0) { if (!this.title && res.data.list.length > 0) {
const first = res.data.list[0]
const firstOrig = (first && first.$orig) ? first.$orig : first
uni.setNavigationBarTitle({ uni.setNavigationBarTitle({
title: res.data.list[0].category_name title: firstOrig.category_name || ''
}) })
} }
} else { } else {
@@ -98,9 +106,18 @@ export default {
this.list = [] this.list = []
} }
this.list = this.list.concat(list.map(item => { this.list = this.list.concat(list.map(item => {
const orig = item // 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png") const src = (item && item.$orig) ? item.$orig : item
return { $orig: orig, g1: g1 } return {
files_id: src.files_id,
files_title: src.files_title,
files_url: src.files_url,
files_desc: src.files_desc,
category_id: src.category_id,
category_name: src.category_name,
// 保留完整原对象引用,避免后续扩展字段丢失
$orig: src
}
})) }))
if (this.$refs.loadingCover) { if (this.$refs.loadingCover) {
this.$refs.loadingCover.hide() this.$refs.loadingCover.hide()
@@ -115,41 +132,121 @@ export default {
}) })
}, },
toDetail(item) { toDetail(item) {
uni.showLoading({ const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
title: this.$lang('files.list.loading') // 根据 URL 推断后缀(若无扩展名则默认 pdf)
}) let ext = 'pdf';
try { if (item.files_url) {
const clean = item.files_url.split('?')[0];
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
if (matched) ext = matched[1].toLowerCase();
}
const fileName = `${title}.${ext}`;
const fileType =
['pdf'].includes(ext) ? 'pdf'
: ['doc', 'docx'].includes(ext) ? 'word'
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext) ? 'video'
: 'pdf';
const previewPage = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(fileName)}&fileUrl=${encodeURIComponent(item.files_url)}&fileType=${encodeURIComponent(fileType)}`;
// ========== H5 端:跳转预览页(web-view 走 iframe);失败则新标签页打开原链接 ==========
// #ifdef H5
uni.navigateTo({
url: previewPage,
fail: () => {
try { window.open(item.files_url, '_blank'); } catch (e) {
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
return;
// #endif
// ========== 非 H5 端:下载文件后按平台分支打开 ==========
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
uni.downloadFile({ uni.downloadFile({
url: item.files_url, url: item.files_url,
success: res => { success: res => {
if (res.statusCode === 200) { if (res.statusCode !== 200) {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
return;
}
// ----- 微信小程序:saveFile 重命名 + openDocument(短文件名标题)-----
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager();
// 重命名临时文件到用户目录,用短文件名 —— openDocument 的标题会取 filePath 的文件名部分,从而显示短名"乐瑞固企业介绍"而非 tmp_xxx
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
try {
// saveFile:复制临时文件到持久目录并改名(临时文件在某些基础库上禁止直接 rename)
fs.saveFile({
tempFilePath: res.tempFilePath,
filePath: targetPath,
success: r => {
uni.hideLoading();
uni.openDocument({ uni.openDocument({
filePath: res.tempFilePath, filePath: r.savedFilePath,
success: () => { fileType: ext,
}, showMenu: true,
fail: () => { fail: () => {
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}, },
complete: () => { complete: () => {
uni.hideLoading() // 预览结束后清理,避免占用持久空间(失败忽略)
} try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
})
} }
});
}, },
fail: () => { fail: () => {
uni.hideLoading() // saveFile 失败(配额等),直接用临时文件 openDocument 兜底
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
});
} catch (e) {
// 极端异常兜底
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
// #endif
// ----- App / 其他小程序:直接 openDocument 打开临时文件;失败兜底跳转预览页 -----
// #ifndef MP-WEIXIN
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.navigateTo({
url: previewPage,
fail: () => uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' })
});
}
});
// #endif
},
fail: () => {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}, },
complete: res => { complete: res => {
if (res.statusCode === 404) { if (res && res.statusCode === 404) {
uni.showToast({ uni.hideLoading();
title: this.$lang('files.list.fileNotExist'), uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
icon: 'none'
})
} }
} }
}) });
} catch (e) {
uni.hideLoading()
}
}, },
imgError(index) { imgError(index) {
if (this.list[index]) { if (this.list[index]) {
@@ -224,18 +321,70 @@ export default {
border-radius: 16rpx; border-radius: 16rpx;
} }
.article-wrap .item .article-img { .article-wrap .item .pdf-icon {
margin-right: 20rpx; margin-right: 20rpx;
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;
flex-shrink: 0;
background-color: #e64340;
border-radius: 10rpx;
position: relative;
overflow: hidden; overflow: hidden;
display: flex; box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
align-items: center;
justify-content: center;
} }
.article-wrap .item .article-img image { .article-wrap .item .pdf-icon .pdf-icon-p {
position: absolute;
top: 2rpx;
left: 8rpx;
color: #ffffff;
font-size: 40rpx;
font-weight: 900;
font-family: Arial, Helvetica, sans-serif;
line-height: 1;
letter-spacing: 0;
}
.article-wrap .item .pdf-icon .pdf-icon-fold {
position: absolute;
top: 0;
right: 0;
width: 24rpx;
height: 24rpx;
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
border-bottom-left-radius: 4rpx;
}
.article-wrap .item .pdf-icon .pdf-icon-lines {
position: absolute;
right: 6rpx;
bottom: 6rpx;
width: 36rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.article-wrap .item .pdf-icon .pdf-icon-line {
width: 100%; width: 100%;
height: 3rpx;
background-color: #ffffff;
opacity: 0.85;
margin-bottom: 4rpx;
border-radius: 2rpx;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(1) {
width: 100%;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(2) {
width: 80%;
}
.article-wrap .item .pdf-icon .pdf-icon-line:nth-child(3) {
width: 60%;
margin-bottom: 0;
} }
.article-wrap .item .info-wrap { .article-wrap .item .info-wrap {
@@ -243,16 +392,21 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
min-width: 0;
} }
.article-wrap .item .info-wrap .title { .article-wrap .item .info-wrap .title {
font-weight: 700; font-weight: 700;
color: #303133;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
font-size: 24rpx; font-size: 28rpx;
line-height: 1.4;
white-space: normal;
word-break: break-all;
} }
.article-wrap .item .info-wrap .read-wrap { .article-wrap .item .info-wrap .read-wrap {

View File

@@ -1,16 +1,40 @@
<template> <template>
<view :style="themeColor"> <view :style="themeColor">
<view class="page" v-if="detail"> <!-- 顶部图条:位于表单卡片之上,宽度 calc(100% - 2*containerInset) 水平居中。
<view class="form-banner"> 图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染。 -->
<image :src="$util.img('public/uniapp/form/banner.png')" mode="widthFix"></image> <view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
<block v-if="detail">
<view class="page">
<view class="system-form-wrap" :style="containerStyle">
<!-- 复用 diy-form(与 formdata.vue 同构):banner / tip / 表单字段 / 提交按钮全部由其内部渲染。
两种表单风格(classic 平铺 / card 卡片浮起)及全部 formConfig 样式属性(tip 四边内边距/边框/圆角/阴影、
card_overlap/overlap_top/radius/side_pad/bg_color/shadow/border、信息卡片等)由此链路统一支持。
:onSubmit 接管提交逻辑;create 在 created 阶段 bind this,防止 this 漂移到子组件实例。 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :showSubmit="!showCaptcha" :onSubmit="handleSubmit" :container-inset="containerInset" />
<!-- 行为设置:提交验证码开启时,隐藏 diy-form 内置按钮,由本页渲染验证码+提交按钮 -->
<view v-if="showCaptcha" class="form-extra-wrap">
<view class="captcha-wrap">
<input class="captcha-input" type="text" v-model="captcha.code" :placeholder="$lang('form.form.captchaPlaceholder')" />
<!-- #ifdef H5 -->
<img v-if="captcha.img" class="captcha-img" :src="captcha.img" @click="getCaptcha()">
<!-- #endif -->
<!-- #ifndef H5 -->
<image v-if="captcha.img" class="captcha-img" :src="captcha.img" mode="aspectFill" @click="getCaptcha()"></image>
<!-- #endif -->
<view class="captcha-btn" @click="getCaptcha()">{{ captcha.img ? '换一张' : '获取验证码' }}</view>
</view> </view>
<view class="system-form-wrap"> <view class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
<view class="form-title">{{ $lang('form.form.title') }}</view>
<ns-form :data="detail.json_data" :formConfig="detail.form_config" ref="form"></ns-form>
<button type="primary" size="mini" class="button mini" @click="create()">{{ $lang('common.submit') }}</button>
</view> </view>
</view> </view>
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty> </view>
</block>
<!-- 空态:除 ns-empty 文案外,追加一个可手动点的登录按钮——解决一切自动弹窗时序问题后的最后兜底 -->
<block v-else>
<ns-empty :text="emptyText"></ns-empty>
<view v-if="!complete" style="padding: 20rpx 40rpx 40rpx; display: flex; justify-content: center;">
<button type="primary" size="mini" @click="forceOpenLogin">点击登录查看表单</button>
</view>
</block>
<loading-cover ref="loadingCover"></loading-cover> <loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login> <ns-login ref="login"></ns-login>
@@ -29,9 +53,139 @@
detail: null, detail: null,
isRepeat: false, isRepeat: false,
complete: false, complete: false,
scroll:true scroll: true,
urlParams: {},
// 登录后回跳地址(供 ns-login / H5 重定向使用)
backUrl: '',
// create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
handleSubmit: null,
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
behavior: {},
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
formError: '',
// 图形验证码(behavior.captcha=1 时展示)
captcha: { id: '', img: '', code: '' },
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
captchaLoaded: false,
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
pendingSubmit: null,
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
pendingLoginRequired: false
}; };
}, },
created() {
// 预绑定 this:create 重度依赖本组件 this(id/memberInfo/$api/$lang/$util 等)。
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
this.handleSubmit = this.create.bind(this);
},
computed: {
// 构造 diy-form raw 模式期望的 value 形态(与 formdata.vue 对齐):
// - jsonData:透传给 ns-newform([{ controller, value, val }],含 InfoCard 展示组件)
// - formConfig:后端返回 camelCase(convertFormConfigToCamel),diy-form 内部双键兼容
// - show_banner / form_banner:交给 diy-form 渲染 banner(card 风格浮起 / classic 平铺)
// - show_tip / form_tip:交给 diy-form 渲染提示语(四边内边距/边框/圆角/阴影/对齐等新样式生效)
// 注意:小程序端模板引用的 computed 会进入 setData 数据流,detail 置空瞬间也要求值,必须做空值守卫
diyValue() {
// 前端视觉默认:仅作兜底(后端 form_config 未配置的字段才生效)。
// 合并顺序:detail.form_config 在后,后台显式配置(submit_bg_color / input_radius / form_layout 等)
// 必须覆盖前端默认,否则商家端样式配置不生效。
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'
};
if (!this.detail) {
return {
jsonData: null,
formConfig: Object.assign({}, styleDefaults),
show_banner: 0,
form_banner: '',
show_tip: 0,
form_tip: ''
};
}
return {
jsonData: this.detail.json_data,
formConfig: Object.assign({}, styleDefaults, this.detail.form_config || {}),
// 全屏底图模式下隐藏卡片内 banner,避免与底图重复(见 hideInnerBanner)
show_banner: this.hideInnerBanner ? 0 : this.detail.show_banner,
form_banner: this.detail.form_banner || 'public/uniapp/form/banner.png',
show_tip: this.detail.show_tip,
form_tip: this.detail.form_tip || this.$lang('form.formdata.formTipPlaceholder')
};
},
// 顶部图条:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)
bgImage() {
if (!this.detail) return '';
const fc = this.detail.form_config || {};
const raw = fc.bgImage || fc.bg_image || fc.backgroundImage || fc.background_image || '';
return raw ? this.$util.img(raw) : '';
},
// 容器左右内缩(rpx)。后台 form_config.container_inset / containerInset 可调;默认 30rpx
containerInset() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.containerInset !== undefined ? fc.containerInset : fc.container_inset;
const n = parseInt(raw);
return isNaN(n) ? 30 : Math.min(150, Math.max(0, n));
},
// 顶部图条高度(rpx)。后台 form_config.bg_image_height 可调;clamp 80-600
bgImageHeight() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.bgImageHeight !== undefined ? fc.bgImageHeight : fc.bg_image_height;
const n = parseInt(raw);
return isNaN(n) ? 200 : Math.min(600, Math.max(80, n));
},
// 图条与表单卡片之间的间距(rpx)。后台 form_config.card_gap 可调;clamp 0-80
cardGap() {
const fc = (this.detail && this.detail.form_config) || {};
const raw = fc.cardGap !== undefined ? fc.cardGap : fc.card_gap;
const n = parseInt(raw);
return isNaN(n) ? 24 : Math.min(80, Math.max(0, n));
},
// 图条内联样式:水平居中 + 高度 + 与卡片的间距
bgLayerStyle() {
const inset = this.containerInset;
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx ' + this.cardGap + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
},
// 表单容器内联样式:左右内缩可配,覆盖 .system-form-wrap 默认 padding
containerStyle() {
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
},
// 表单风格(与 diy-form.vue hasBannerMode 对齐)
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
hideInnerBanner() {
return !!this.bgImage;
},
// 行为设置:提交验证码开启时 diy-form 隐藏内置提交按钮,本页渲染验证码 + 自有提交按钮
showCaptcha() {
return this.behavior.captcha == 1;
},
// 自有提交按钮文案:跟随表单配置的提交按钮文字(form_config 为 camelCase)
submitBtnText() {
return (this.detail && this.detail.form_config && this.detail.form_config.submitText) || this.$lang('common.submit');
},
emptyText() {
return this.complete ? this.$lang('form.form.submitSuccess') : (this.formError || this.$lang('form.form.noFormInfo'));
}
},
onLoad(data) { onLoad(data) {
// #ifdef MP-ALIPAY // #ifdef MP-ALIPAY
let options = my.getLaunchOptionsSync(); let options = my.getLaunchOptionsSync();
@@ -39,27 +193,115 @@
// #endif // #endif
this.id = data.id || 0; this.id = data.id || 0;
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene'];
this.urlParams = {};
for (let key in data) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = data[key];
}
if (data.scene) { if (data.scene) {
var sceneParams = decodeURIComponent(data.scene); var sceneParams = decodeURIComponent(data.scene);
sceneParams = sceneParams.split('&'); sceneParams = sceneParams.split('&');
if (sceneParams.length) { if (sceneParams.length) {
sceneParams.forEach(item => { sceneParams.forEach(item => {
if (item.indexOf('id') != -1) this.id = item.split('-')[1]; if (!item) return;
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
let pair = item.split('=');
if (pair.length === 2) {
const key = pair[0];
const val = pair[1];
if (key === 'id') this.id = val;
else if (key === 'uniacid') this.uniacid = val;
else this.urlParams[key] = val;
} else if (item.indexOf('id') === 0 && item.indexOf('-') !== -1) {
// 旧格式兜底:id-13
this.id = item.split('-')[1];
}
}); });
} }
} }
// #ifdef H5
// H5:从 window.location.href 解析参数,兼容 history 路由 (?key=val) 与 hash 路由 (#/path?key=val)
// 保证 recommend_code / uniacid 等参数不丢失
try {
const href = window.location.href;
let queryStr = '';
const hashIdx = href.indexOf('#');
const qIdx = href.indexOf('?');
if (qIdx !== -1 && (hashIdx === -1 || qIdx < hashIdx)) {
// history 模式:? 在 # 之前(或无 #)
queryStr = href.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : href.length);
} else if (hashIdx !== -1) {
// hash 模式:# 之后可能还有 ?
const afterHash = href.substring(hashIdx + 1);
const hq = afterHash.indexOf('?');
if (hq !== -1) queryStr = afterHash.substring(hq + 1);
}
if (queryStr) {
const pairs = queryStr.split('&');
for (const p of pairs) {
if (!p) continue;
const eq = p.indexOf('=');
let k, v;
if (eq === -1) { k = decodeURIComponent(p); v = ''; }
else { k = decodeURIComponent(p.substring(0, eq)); v = decodeURIComponent(p.substring(eq + 1)); }
if (k === 'id') { if (!this.id) this.id = v; }
else if (k === 'uniacid') { /* reserved, 透传到后端时不进 urlParams */ }
else if (k === 'scene') { continue; }
else { if (this.urlParams[k] === undefined) this.urlParams[k] = v; }
}
}
} catch (e) {}
// #endif
// 构建登录回跳地址:包含 id + 所有 urlParams(尤其 recommend_code),
// 供 ns-login 弹窗登录成功或 H5 整页重定向登录后回到本页
const backQuery = [];
if (this.id) backQuery.push('id=' + encodeURIComponent(this.id));
for (const k in this.urlParams) {
const v = this.urlParams[k];
if (v !== undefined && v !== null && v !== '') {
backQuery.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
}
this.backUrl = '/pages_tool/form/form' + (backQuery.length ? '?' + backQuery.join('&') : '');
}, },
onShow() { onShow() {
if (!this.storeToken) { // 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
return;
}
this.getData(); this.getData();
}, },
mounted() {
// 1) 兜底 A:onLoad→getData 回调比 ns-login 挂载还早时,pendingLoginRequired=true
// 挂载完成再最后尝试一次自动弹窗
if (this.pendingLoginRequired) {
const tryOpen = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(this.backUrl);
}
} catch (e) { console.warn('[FORM] mounted login.open failed:', e); }
};
this.$nextTick(tryOpen);
this.pendingLoginRequired = false;
}
// 2) 兜底 B:登录态在组件创建之前就已写入(token 提前 race),但 info 因时序没拿到数据
// 此时用户实际上已经登录,再拉一次 info 就能拿到真实表单数据
if (this.storeToken && !this.detail) {
this.$nextTick(() => { this.getData(); });
}
},
watch: { watch: {
storeToken: function(nVal, oVal) { storeToken: function(nVal, oVal) {
if (nVal) this.getData(); if (nVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
} }
}, },
methods: { methods: {
@@ -69,91 +311,436 @@
data: { data: {
form_id: this.id form_id: this.id
}, },
success: res => { success: res => { this._handleInfoResponse(res); },
if (res.code == 0 && res.data) { fail: err => { this._handleInfoFailure(err); }
this.detail = res.data; });
},
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
// 成功分支必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
// 只要不满足就是"没拿到有效表单",然后用最宽的条件判定是否是登录拦截:
// - 消息包含 FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录 / 登录相关中文
// - 或者 data 无效(null/undefined/''/{}/非对象)+ 用户未登录 + 有 form_id
// 命中任一 → 一律弹登录 + 友好文案"请先登录",不再误显示"未获取到表单信息"
_handleInfoResponse(res) {
try {
const payload = (res && typeof res === 'object') ? res : {};
const dataObj = payload.data;
// —— 1) 严格判定:是否拿到了真正的表单结构? ——
const hasValidFormData =
payload.code == 0 &&
dataObj !== null && dataObj !== undefined && dataObj !== '' &&
typeof dataObj === 'object' &&
(dataObj.json_data !== undefined || dataObj.form_config !== undefined);
if (hasValidFormData) {
this.detail = dataObj;
this.formError = '';
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
this.resumeSubmitAfterLogin();
// 本地缓存一份字段 JSON:下次再打开时如果仍被登录拦截,可作为 skeleton 兜底
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
return;
}
// —— 2) 没拿到有效结构 → 宽网判定:是否属于"需要登录"场景? ——
const msg = (payload.message || '').toString();
const isFormNotOpen = (msg === 'FORM_NOT_OPEN');
const isLoginMsg =
msg === 'FORM_LOGIN_REQUIRED' ||
msg === 'LOGIN_REQUIRED' ||
msg.indexOf('请先登录') !== -1 ||
(msg.indexOf('登录') !== -1 && msg.indexOf('请') !== -1) ||
(msg.toLowerCase().indexOf('login') !== -1 && msg.toLowerCase().indexOf('required') !== -1);
const dataIsEmpty =
!dataObj ||
dataObj === '' ||
dataObj === null ||
dataObj === undefined ||
(typeof dataObj === 'object' && !dataObj.json_data && !dataObj.form_config &&
((Array.isArray(dataObj) && dataObj.length === 0) ||
(!Array.isArray(dataObj) && Object.keys(dataObj).length === 0)));
// 最宽判定:只要不是"表单未开放",且(显式登录消息 OR 空数据+未登录+有 id)都按登录拦截处理
const shouldTriggerLogin =
!isFormNotOpen &&
(isLoginMsg || (dataIsEmpty && !this.storeToken && this.id));
if (shouldTriggerLogin && this.storeToken) {
// 已登录(storeToken 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页,仅展示空态文案 + 手动登录按钮
this.formError = this.$lang('form.form.loginRequired');
} else if (shouldTriggerLogin) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else if (isFormNotOpen) {
this.formError = this.$lang('form.form.notOpen');
} else {
this.formError = msg || this.$lang('form.form.noFormInfo');
}
} catch (e) {
console.warn('[FORM] _handleInfoResponse exception:', e && e.message ? e.message : e);
this.formError = this.$lang('form.form.noFormInfo');
} }
if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
}, },
fail: res => { // info 接口网络失败时也做同样判定:未登录+有 id 就尝试启动登录流程
_handleInfoFailure(err) {
try {
if (this.id && !this.storeToken) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else {
this.formError = this.$lang('form.form.noFormInfo');
}
} catch (e) {
this.formError = this.$lang('form.form.noFormInfo');
}
if (this.$refs.loadingCover) this.$refs.loadingCover.hide(); if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
const now = Date.now();
if (this._lastAutoLoginAt && (now - this._lastAutoLoginAt) < 3000) return;
this._lastAutoLoginAt = now;
const url = this.backUrl;
let attempts = 0;
const delays = [0, 60, 320];
const tryOnce = () => {
try {
if (this.$refs.login && typeof this.$refs.login.open === 'function') {
this.$refs.login.open(url);
this.pendingLoginRequired = false;
return true;
}
} catch (e) {
console.warn('[FORM] login.open attempt ' + (attempts + 1) + ' failed:', e);
}
attempts++;
if (attempts < delays.length) {
setTimeout(tryOnce, delays[attempts]);
} else {
// 三次全失败:交给 mounted 钩子最后兜底
this.pendingLoginRequired = true;
}
return false;
};
tryOnce();
},
// 模板空态按钮的手动入口:用户主动点击也能打开登录弹窗(最后一道防线)
forceOpenLogin() {
this._openLoginWithRetry();
},
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
getCaptcha() {
this.captchaLoaded = true;
this.$api.sendRequest({
url: '/api/captcha/captcha',
data: {
captcha_id: this.captcha.id
},
success: res => {
if (res.code == 0 && res.data) {
this.captcha.id = res.data.id;
this.captcha.img = res.data.img ? res.data.img.replace(/\r\n/g, '') : '';
this.captcha.code = '';
}
} }
}); });
}, },
create() { // 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
if (this.$refs.form.verify()) { savePendingSubmit(formData) {
const data = formData || (this.$refs.form && this.$refs.form.getFormData && this.$refs.form.getFormData()) || null;
const payload = {
formId: this.id,
urlParams: this.urlParams,
formData: data,
captchaId: this.captcha.id,
captchaCode: this.captcha.code,
ts: Date.now()
};
this.pendingSubmit = payload;
try { uni.setStorageSync('form_pending_submit', payload); } catch (e) {}
// H5 整页回跳标记:仅当确由登录拦截触发时才允许自动续提交,避免陈旧数据被误提交
try { uni.setStorageSync('form_pending_from_login', 1); } catch (e) {}
},
// 仅在已登录时取回并消费待提交数据(未登录 / 非登录回跳 / 过期不消费,避免误提交);取后清除
takePendingSubmit() {
let payload = this.pendingSubmit;
const fromStorage = !payload;
if (fromStorage) {
try { payload = uni.getStorageSync('form_pending_submit'); } catch (e) {}
}
if (!payload || !payload.formData) return null;
if (!this.storeToken) return null;
// H5 存储路径:必须是因登录拦截触发的回跳,否则视为陈旧数据,清理后不提交
if (fromStorage) {
let fromLogin = false;
try { fromLogin = uni.getStorageSync('form_pending_from_login'); } catch (e) {}
if (!fromLogin) {
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
return null;
}
}
// 仅消费 10 分钟内的待提交,避免极旧数据被自动提交
if (payload.ts && Date.now() - payload.ts > 10 * 60 * 1000) {
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
return null;
}
this.pendingSubmit = null;
try { uni.removeStorageSync('form_pending_submit'); } catch (e) {}
try { uni.removeStorageSync('form_pending_from_login'); } catch (e) {}
return payload;
},
// 登录成功后:回填已填数据并自动续提交
resumeSubmitAfterLogin() {
const pending = this.takePendingSubmit();
if (!pending) return;
// 回填表单字段(nextTick 确保 diy-form 内部 ns-newform 已挂载)
this.$nextTick(() => this.restoreFormData(pending.formData));
// 验证码:MP 登录不丢态可直接提交;H5 回跳后验证码失效,需重新获取并让用户重填
if (this.behavior.captcha == 1 && !this.captcha.code) {
this.getCaptcha();
this.$util.showToast({ title: '请重新输入验证码后提交' });
return;
}
this.create(pending.formData);
},
// 将保存的 formData 回填到 diy-form 内部的 ns-newform
restoreFormData(formData) {
try {
const ns = this.$refs.form && this.$refs.form.$refs && this.$refs.form.$refs.nsNewform;
if (ns && Array.isArray(formData)) {
ns.formData = JSON.parse(JSON.stringify(formData));
if (typeof ns.processFormData === 'function') ns.processFormData();
}
} catch (e) {}
},
// 验证码模式下本页自有提交按钮:先走 diy-form 校验与取数,再进 create
submitFromPage() {
if (this.isRepeat) return; if (this.isRepeat) return;
this.isRepeat = true; if (!this.$refs.form || !this.$refs.form.verify()) return;
const formData = this.$refs.form.getFormData();
this.create(formData);
},
// 填完跳转:消费统一链接选择器的完整对象(wap_url 外链/页面路径、外部小程序、拨打电话)
doRedirect(link) {
const fallback = () => { this.complete = true; this.detail = null; };
if (!link) return fallback();
if (link.appid) {
// 跳转其他微信小程序(仅小程序端可用;H5 场景直接回退完成态)
// #ifdef MP-WEIXIN
wx.navigateToMiniProgram({ appId: link.appid, path: link.page || '', fail: fallback });
return;
// #endif
fallback();
} else if (link.mobile) {
uni.makePhoneCall({ phoneNumber: String(link.mobile), fail: fallback });
fallback();
} else if (link.wap_url) {
if (/^https?:\/\//i.test(link.wap_url)) {
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(link.wap_url), fail: fallback });
} else {
uni.navigateTo({ url: link.wap_url, fail: fallback });
}
} else {
fallback();
}
},
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法;验证码模式下由 submitFromPage 调用。
// 本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this。
create(formData) {
if (this.isRepeat) return;
const behavior = this.behavior || {};
// 填写需登录 / 限填(后端强制,前端提前拦截优化体验)
if ((behavior.require_login_submit == 1 || behavior.submit_limit > 0) && !this.storeToken) {
// 保存已填写数据,登录成功(MP 弹窗 / H5 回跳)后自动续提交,避免数据丢失
this.savePendingSubmit(formData);
this.$refs.login && this.$refs.login.open(this.backUrl);
return;
}
// 验证码必填
if (behavior.captcha == 1 && !this.captcha.code) {
this.$util.showToast({ title: this.$lang('form.form.captchaRequired') });
return;
}
this.isRepeat = true;
const submitCaptchaId = String(this.captcha.id || '');
const submitCaptchaCode = String(this.captcha.code || '').toUpperCase();
this.$api.sendRequest({ this.$api.sendRequest({
url: '/form/api/form/create', url: '/form/api/form/create',
data: { data: {
form_id: this.id, form_id: this.id,
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData) form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0,
captcha_id: submitCaptchaId,
captcha_code: submitCaptchaCode
}, },
success: res => { success: res => {
if (res.code == 0) { if (res.code == 0) {
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') }) this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
const link = behavior.redirect;
setTimeout(() => { setTimeout(() => {
this.complete = true; link ? this.doRedirect(link)
this.detail = null; : (this.complete = true, this.detail = null);
}, 1500) }, link ? 800 : 1500);
} else { } else {
this.isRepeat = false; this.isRepeat = false;
this.$util.showToast({ title: res.message }) if (res.message == 'FORM_LOGIN_REQUIRED') {
// 提交时后端仍要求登录(如登录态过期):暂存数据,重登后自动续提交
this.savePendingSubmit(formData);
this.$refs.login && this.$refs.login.open(this.backUrl);
} else if (res.message == 'CAPTCHA_ERROR' || res.message == 'CAPTCHA_FAILURE') {
this.$util.showToast({ title: this.$lang('form.form.captchaError') });
if (behavior.captcha == 1) this.getCaptcha();
} else if (res.message == 'FORM_SUBMIT_LIMIT') {
this.$util.showToast({ title: this.$lang('form.form.submitLimit') });
this.complete = true;
this.detail = null;
} else if (res.message == 'FORM_NOT_OPEN') {
this.$util.showToast({ title: this.$lang('form.form.notOpen') });
} else {
this.$util.showToast({ title: res.message });
} }
} }
},
fail: () => {
// 网络失败时复位防重复标记,允许用户重试
this.isRepeat = false;
}
}); });
} }
},
} }
}; };
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
page {
.form-banner { background: #fff;
width: 100vw;
line-height: 1;
image {
width: 100%;
line-height: 1;
} }
/*
* ===== 顶部图条 =====
* 位于表单卡片之上。宽度由内联 bgLayerStyle 注入(calc(100% - 2*inset)),
* 与下方表单卡片等宽、左右各留 inset 对称留白。圆角 + overflow 防止图片溢出。
* 未配置 form_config.bg_image 时该节点不渲染(v-if="bgImage")。
*/
.formdata-bg {
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 16rpx;
overflow: hidden;
box-sizing: border-box;
}
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
} }
.system-form-wrap { .system-form-wrap {
background: $color-bg; overflow: visible;
border-radius: 32rpx;
overflow: hidden;
margin: 0 0 60rpx 0; margin: 0 0 60rpx 0;
padding: 0 26rpx; /* ===== 表单卡片:白底圆角、左右内缩居中 =====
transform: translateY(-40rpx); ⚠️ 此 padding 仅为首屏兜底(computed 尚未就绪时生效),
运行中会被 containerStyle 内联样式覆盖,实际值由 containerInset computed 决定 */
.form-title { padding: 0 30rpx;
line-height: 100rpx; box-sizing: border-box;
padding-top: 20rpx; background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
} }
.button { // 穿透 diy-form 包装层:消除其自带的圆角与背景,
height: 80rpx; // 让 form 内容区域与 system-form-wrap 卡片视觉融合
line-height: 80rpx !important; .system-form-wrap ::v-deep .diy-from {
margin-top: 30rpx !important; background: transparent;
width: 100%; border-radius: 0;
border-radius: 80rpx;
} }
::v-deep .form-wrap { // ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
.form-extra-wrap {
padding: 0 28rpx 20rpx;
}
.captcha-wrap {
display: flex;
align-items: center;
background: #fff; background: #fff;
padding: 30rpx; border-radius: 12rpx;
border-radius: 32rpx; padding: 16rpx 20rpx;
margin-top: 24rpx;
.captcha-input {
flex: 1;
height: 84rpx;
line-height: 84rpx;
font-size: 28rpx;
border: 1rpx solid #e5e7eb;
border-radius: 16rpx;
padding: 0 24rpx;
box-sizing: border-box;
background: #ffffff;
} }
.captcha-img {
width: 200rpx;
height: 72rpx;
margin-left: 20rpx;
border-radius: 12rpx;
border: 2rpx solid #e5e7eb;
}
.captcha-btn {
margin-left: 20rpx;
height: 72rpx;
line-height: 72rpx;
padding: 0 24rpx;
font-size: 26rpx;
color: #ff4544;
background: #fff;
border: 2rpx solid #ff4544;
border-radius: 12rpx;
white-space: nowrap;
}
}
// 提交按钮:视觉对齐 diy-form 内置按钮(蓝底白字圆角,medium 内边距)
.custom-submit {
margin-top: 30rpx;
padding: 24rpx 0;
text-align: center;
color: #ffffff;
background-color: #3b82f6;
border-radius: 12rpx;
font-size: 30rpx;
} }
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
::v-deep .mescroll-totop { ::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important; right: 27rpx !important;
bottom: 120rpx !important; bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -79,7 +79,7 @@ export default {
setPublicShare() { setPublicShare() {
let shareUrl = this.$config.h5Domain + '/pages_tool/help/list'; let shareUrl = this.$config.h5Domain + '/pages_tool/help/list';
this.$util.setPublicShare({ this.$util.setPublicShare({
title: '帮助中心', title: this.$lang('help.list.title'),
desc: '', desc: '',
link: shareUrl, link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : '' imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''

View File

@@ -100,7 +100,13 @@ export default {
} }
::v-deep .mescroll-totop { ::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important; right: 27rpx !important;
bottom: 120rpx !important; bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
} }
</style> </style>

View File

@@ -95,7 +95,10 @@ export default {
// 判断登录 // 判断登录
if (!this.storeToken) { if (!this.storeToken) {
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
} else { } else {
this.getAccountInfo(); this.getAccountInfo();
} }
@@ -112,8 +115,8 @@ export default {
this.fenxiao_info = res.data.fenxiao_info; this.fenxiao_info = res.data.fenxiao_info;
} }
} else if (res.code == -1) { } else if (res.code == -1) {
this.clearLoginState(); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); this.redirectToLoginOnce();
} }
} }
}); });

View File

@@ -20,7 +20,10 @@
<script> <script>
import htmlParser from '@/common/js/html-parser'; import htmlParser from '@/common/js/html-parser';
import memberPublic from './public/js/member-public.js';
export default { export default {
mixins: [memberPublic],
data() { data() {
return { return {
agreement: {}, agreement: {},
@@ -31,7 +34,10 @@ export default {
if (option.back) this.back = option.back; if (option.back) this.back = option.back;
// 判断登录 // 判断登录
if (!this.storeToken) { if (!this.storeToken) {
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
} else { } else {
this.getCancelAgreement(); this.getCancelAgreement();
} }
@@ -45,9 +51,8 @@ export default {
this.agreement = res.data; this.agreement = res.data;
if (this.agreement.content) this.agreement.content = htmlParser(this.agreement.content); if (this.agreement.content) this.agreement.content = htmlParser(this.agreement.content);
} else if (res.code == -1) { } else if (res.code == -1) {
this.$store.commit('setToken', ''); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.$store.commit('setMemberInfo', ''); this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} }
} }
}); });

View File

@@ -13,7 +13,10 @@
</template> </template>
<script> <script>
import memberPublic from './public/js/member-public.js';
export default { export default {
mixins: [memberPublic],
components: {}, components: {},
data() { data() {
return { return {
@@ -26,7 +29,8 @@ export default {
// 判断登录 // 判断登录
if (!this.storeToken) { if (!this.storeToken) {
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.redirectToLoginOnce();
} else { } else {
this.getStatus(); this.getStatus();
} }
@@ -39,9 +43,8 @@ export default {
if (res.code >= 0) { if (res.code >= 0) {
this.reason = res.data.reason; this.reason = res.data.reason;
} else if (res.code == -1) { } else if (res.code == -1) {
this.$store.commit('setToken', ''); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.$store.commit('setMemberInfo', ''); this.redirectToLoginOnce();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} }
} }
}); });

View File

@@ -49,7 +49,10 @@
onLoad(option) { onLoad(option) {
// 判断登录 // 判断登录
if (!this.storeToken) { if (!this.storeToken) {
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流:
// 微信小程序 / 鸿蒙下跳首页 INDEX_PAGE_URL, 走 ns-login 原生授权弹窗;
// H5 / APP 下跳原生登录页 LOGIN_PAGE_URL)
this.redirectToLoginOnce();
} else { } else {
this.getStatus(); this.getStatus();
} }
@@ -70,8 +73,8 @@
} }
if (res.code == -1) { if (res.code == -1) {
this.clearLoginState(); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁 + 跨平台分流)
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); this.redirectToLoginOnce();
} }
} }
}); });

View File

@@ -19,7 +19,10 @@
components: {}, components: {},
data() { data() {
return { return {
state: '' state: '',
// 防抖锁: 防止 onLoad 的 getStatus 异步回调 和 用户点击 success 按钮
// 短时间内连发 reLaunch, 触发微信小程序 "appLaunch with non-empty page stack" native 错误
_navigated: false
}; };
}, },
onLoad(option) { onLoad(option) {
@@ -28,7 +31,8 @@
// 判断登录 // 判断登录
if (!this.storeToken) { if (!this.storeToken) {
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
this.redirectToLoginOnce();
} else { } else {
this.getStatus(); this.getStatus();
} }
@@ -40,16 +44,17 @@
success: res => { success: res => {
if (res.code >= 0) { if (res.code >= 0) {
if (res.data && res.data.status == 1) { if (res.data && res.data.status == 1) {
this.clearLoginState(); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); // 跟 success 按钮共享防抖, 避免同时触发
this.redirectToLoginOnce();
} }
} }
} }
}); });
}, },
success() { success() {
this.clearLoginState(); // 跳登录入口 (mixin 提供, 内部带 800ms 防抖锁)
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL); this.redirectToLoginOnce();
} }
} }
}; };

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