Compare commits

...

157 Commits

Author SHA1 Message Date
f35f48434d chore:​ 隐藏电话悬浮按钮 2026-09-28 11:40:46 +08:00
b57a457734 chore:商品分类模板5 点进商品分类,进入商品列表,红色框的”产品说明书“和”商品详情“俩个功能交换顺序之后,默认显示”商品详情“ 2026-09-28 11:33:20 +08:00
535ae732bc chore:商品分类模板5 点进商品分类,进入商品列表隐藏搜索框下面功能 2026-09-28 11:25:02 +08:00
993b5c5cb0 chore:进入商品列表,默认视图要图片中的竖版视图 2026-09-28 10:54:06 +08:00
f40c4b2a69 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-24 17:05:26 +08:00
2dae3adf66 chore:网页容器H5显示正常 2026-09-24 17:00:14 +08:00
e079afc0a2 feat(media-upload): 增加自愈机制,自动切换 STS 直传以应对后端策略变化 2026-09-24 16:27:33 +08:00
85b3e4db47 chore:AI回答的文字只有网址是蓝色,其他回答是黑色的 2026-09-24 13:59:24 +08:00
0197224c75 chore:H5所有气泡都统一 2026-09-24 11:59:50 +08:00
70f8fb867d feat(chat-ui): 增加聊天 UI 配置的本地持久化和预加载功能,优化首帧渲染体验 2026-09-24 11:10:21 +08:00
86b87eeb9d feat(ai-chat): 聊天 UI 配置 V3 — 名称/头像/时间配置项消费
- CHAT_UI_DEFAULTS 同步后端 4 新键 (show_name/show_avatar/avatar/time_format/time_humanize)
- 标题与 AI 头像按开关显隐, 自定义头像 (素材库 pic_path / URL) 优先于内置
- formatTime 支持 auto(智能分段)/full(完整) 与 humanizeTime 相对时间优先 (刚刚/N分钟前/N小时前/昨天/前天/M月D日/Y年M月D日)
- i18n 中英各+5 键
2026-09-24 10:30:28 +08:00
297b545e55 fix(media-upload): alioss 直传 endpoint 缺 oss- 前缀修复
- sts.region 是简写 (cn-hangzhou), 真实 OSS endpoint 须完整形式 (oss-cn-hangzhou)
- 优先使用后端 sts.oss_url (OssProviderRegistry::buildDirectEndpoint 已拼好), 兜底 oss-${region} 拼接
2026-09-24 10:29:55 +08:00
3e2ae9b7ca chore 2026-09-24 08:51:52 +08:00
2016b87b6e chore:回答问题视口随文字增长一直是贴着最新一句往上走。 2026-09-23 17:12:11 +08:00
a42ad4ebb3 chore:新增AI智能问题是否合并回答/单独回答 2026-09-23 16:25:39 +08:00
158750b933 chore:删除AI回答后下面出现修改图标的英文 2026-09-23 15:26:36 +08:00
3abe65de2a chore:H5的气泡修改 2026-09-23 15:22:19 +08:00
c0e432cc22 chore:微信小程序真机可以自定义复制 2026-09-23 15:04:27 +08:00
f8115f47fd chore:AI修改新对话按钮图标,搜索记录按钮向上移动,删除AI回答后的AI名称 2026-09-23 14:13:54 +08:00
66b56387e6 chore:滑动图片后台上传图片自适应 2026-09-22 15:15:24 +08:00
3079cf4ba5 chore:点赞图标正常 2026-09-22 10:39:01 +08:00
aefb0c1550 Merge branch 'temp221' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-21 08:53:52 +08:00
235adb5ebd chore:优化工具调用的可见性控制,确保与思考过程开关一致;统一欢迎语字段名,移除冗余信息;兼容旧数据格式,提升数据处理的健壮性 2026-09-20 18:25:59 +08:00
744981ed93 chore:添加 chat_ui_config 支持,优化前端 UI 行为开关;实现附件类型动态控制,确保与后端配置同步 2026-09-20 18:01:43 +08:00
ee20c214de chore:优化服务配置缓存逻辑,减少不必要的 API 调用;修复欢迎语加载逻辑,避免重复推送欢迎气泡;调整快捷问题样式以兼容低版本环境 2026-09-20 15:45:12 +08:00
8fd68033ed chore:添加欢迎语配套快捷问题功能,支持用户快速发送常见问题;优化数据格式处理与样式 2026-09-20 15:07:43 +08:00
62b4b44f88 chore :真机可以支持测试 2026-09-20 14:06:28 +08:00
9d599d5f47 chore:后台更改卡片底板内边距,四边样式颜色,前端和后台同步 2026-09-20 10:33:28 +08:00
2b1f8a4fe2 chore:修复清除会话的 URL 格式,确保使用 camelCase,避免 404 错误;优化 agent 切换时的会话 ID 管理 2026-09-19 16:25:16 +08:00
7a81045290 Merge branch 'temp221' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-19 15:26:53 +08:00
67ce0f8b39 chore:更新 AI 服务会话管理逻辑,优化用户状态切换时的 key 迁移与会话隔离,确保 H5 端 elicitResponse 请求正常处理 2026-09-19 15:26:38 +08:00
01df6c9116 chore:后台调节滑动图片组件的左右边距,前端运行出来和后台一致 2026-09-19 15:01:22 +08:00
480a109114 chore:商品分类模板1更改,只有「加载完成 且 该二级分类下商品列表非空」才渲染,否则标题 + 网格整块都不显示。 2026-09-18 17:44:03 +08:00
509fe203f7 chore:商品分类模板5 更改完成 2026-09-18 17:28:51 +08:00
0f9cc1087f chore:商品分类模板5 2026-09-18 16:44:14 +08:00
0f24f47048 chore:滑动图片的上下左间距可以后台自定义调节 2026-09-18 15:26:05 +08:00
a3d2ee562e chore:新增商品分类模板5 2026-09-18 13:53:47 +08:00
1b1704f7c6 chore:滑动图片是水平滑动滚动条根据后台显示,AI智能点赞svg 2026-09-18 09:58:15 +08:00
8b2670bf31 chore:客服信息组件转英文模式显示全部英文 2026-09-17 18:19:30 +08:00
e66c91acfc chore:【搜索框】的样式设置中的“背景颜色”可以控制搜索框的"背景颜色"和搜索框的“上下左右边距”的颜色 2026-09-17 15:09:42 +08:00
29699a66fd chore:AI智能输入框样式改变 2026-09-17 14:21:28 +08:00
3b4051c775 chore:上传图片可以支持3张,正常显示 2026-09-17 09:48:49 +08:00
fffb5dae18 chore:用户的消息,图片在上文字在下 2026-09-17 09:11:59 +08:00
8cfc144ba9 fix(frontend): 端用户附件走 inline base64 路径, 避开 MediaGuard SSRF 闸门
PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16)
PR-WB-MP-INBOUND-MEDIA 配套

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -21,7 +21,7 @@ const localDevConfig = ({
'2881': { // 防爆电器
uniacid: 2881,
domain: 'https://xcx6.aigc-quickapp.com/',
},
},
'2724': { // 生物菌肥
uniacid: 2724,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -45,11 +45,11 @@ const localDevConfig = ({
'11-test': { // 测试平台
uniacid: 11,
domain: 'https://test.aigc-quickapp.com',
},
},
'18-test': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
@@ -59,16 +59,16 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118: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': { // 测试平台
uniacid: 11,
domain: 'http://localhost:8050/',
},
'100': {
},
'100': {// 口译教学
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -76,51 +76,51 @@ const localDevConfig = ({
uniacid: 1,
domain: 'https://5g.aigc-quickapp.com/',
},
'115': {
'115': {// 低压断路器
uniacid: 115,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-11': {
'5g-11': {// 铝箔袋包装
uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-206': {
},
'5g-206': {// 气体探测
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
'47': { // 修改弹窗
},
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
},
'145': { // 表单链接转义问题
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-213': { // 单图组点击链接无法跳转
},
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-4': { // 公告点击链接无法跳转
},
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 97,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-50': { // 燃气安全产品机器人,购物车无反应
uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-237': { // 商品分类,没有立即咨询文字
},
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/',
},
'2847': { //
},
'2847': { // 家居装饰品
uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/',
},
@@ -128,42 +128,100 @@ const localDevConfig = ({
uniacid: 2482,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2485': { // 验证电子表单前后端数据一致性
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
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': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //
uniacid: 2797,
domain: 'https://xcx.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-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -176,8 +234,17 @@ const localDevConfig = ({
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['5g-351']; //洗车机
// })['5g-204']; //热固性塑料
// })['5g-59']; //合成孔径雷达
// })['2709']; //中央厨房
// })['3007']; //钎焊材料
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
})['2797']; // 本地
// })['2811']; // 本地
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['5g-351']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

11
App.vue
View File

@@ -5,6 +5,9 @@
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
import aiService from 'common/js/ai-service.js';
import {
Weixin
@@ -463,6 +466,14 @@
if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
try {
if (aiService && typeof aiService.onUserChange === 'function') {
aiService.onUserChange();
}
} catch (e) { /* ignore */ }
// 查询购物车信息
this.$store.dispatch('getCartNumber');
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,6 +4,7 @@
*/
import IMAGE_CONFIG from './image-config.js';
import Platform from './platform.js';
import PAGE_KEY_MAP from './util.js';
export class CustomerService {
constructor(vueInstance, externalConfig = null) {
@@ -118,6 +119,16 @@ export class CustomerService {
/**
* 跳转到 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() {
try {
@@ -127,7 +138,7 @@ export class CustomerService {
}
// ✅ 修正路径:必须与 pages.json 中注册的路径一致
const aiChatUrl = '/pages_tool/ai-chat/index';
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
// ✅ 使用 navigateTo 保留返回栈(体验更好)
uni.navigateTo({
@@ -551,6 +562,9 @@ export class CustomerService {
/**
* 打开 AI 智能助手
*
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
* 由调用方(hover-nav.vue 等)按需注入。
*/
openAIChat() {
try {
@@ -558,7 +572,7 @@ export class CustomerService {
this.vm.setAiUnreadCount(0);
}
const aiChatUrl = '/pages_tool/ai-chat/index';
const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
uni.navigateTo({
url: aiChatUrl,

View File

@@ -489,36 +489,27 @@ function isBundleComplete(tree) {
return false;
}
// 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用)
// 2) tree 不能是空对象(空对象没有任何可用文案, 显然不可用)
const treeKeys = Object.keys(tree);
if (treeKeys.length === 0) {
return false;
}
// 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖)
if (!tree.common || typeof tree.common !== 'object') {
return false;
// === 粗粒度校验(与上方设计哲学一致) ===
// 不再硬性要求 common / components 节点必须同时存在。
// 远端可能在某些时刻少发某个节点(部署期间、缓存未刷新、租户未配置 common 等),
// 此时仍应让 boot 成功写入 remoteTreeCache, 缺失字段由 $lang() 自然回落为空,
// UI 降级显示(空文案/空按钮文字), 而不是让整个 boot 失败导致整站变英文 / raw key。
// 软校验: components.privacyPopup(节点存在才检查, 避免 tree.components 为 undefined 时报错)
const privacyPopup = tree.components && tree.components.privacyPopup;
if (privacyPopup && typeof privacyPopup === 'object') {
// defaultAppNameMp 存在性仅作诊断, 不阻止 boot
if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
}
}
// 4) tree 必须有 components 节点(组件级文案)
if (!tree.components || typeof tree.components !== 'object') {
return false;
}
// === 以下为软校验(只 warn 不 return false) ===
// 设计: 不再因为"缺某个 key"就让整个 boot 失败
// 缺失的字段让 $lang() 自然返回空, UI 降级显示
// 软校验 1: components.privacyPopup
const privacyPopup = tree.components.privacyPopup;
if (!privacyPopup || typeof privacyPopup !== 'object') {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
} else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
}
// 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断)
// 此处仅保留注释说明, 不在生产代码中输出
return true;
}

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

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

View File

@@ -71,6 +71,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -264,6 +268,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -456,6 +464,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -648,6 +660,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -840,6 +856,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1032,6 +1052,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1224,6 +1248,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称
@@ -1416,6 +1444,10 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
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_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称

View File

@@ -5,6 +5,24 @@
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
@@ -97,8 +115,12 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {})
fail: (err) => {
clearTimeout(timeoutId);
try {
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误
const tip = isClipboardPrivacyError(err)
? lang.lang('uniappUtils.clipboardPrivacyNotDeclared')
: (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'));
uni.showToast({
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
title: tip,
icon: 'none',
duration: 2000
});

View File

@@ -139,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
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 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -1203,6 +1204,23 @@ export default {
* @param {Object} 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));
//if (link == null || Object.keys(link).length == 1) return;
@@ -1626,59 +1644,118 @@ export default {
},
/**
* 获取小程序分享内容数据
*
* 重要:本函数保证永远 resolve(绝不 reject)。
* 原因:外部 8 处调用(category.vue / diy.js / promote_code.vue / contact.vue 等)
* 全部只写了 .then(res => {}) 没有 .catch(),一旦 reject 就会变成 Unhandled Promise Rejection,
* 微信小程序框架会把它包成 Error: MiniProgramError 并以红框形式打印到控制台,污染日志且易被
* 误判为业务崩溃。后台未配置某路径的分享(data:null)、接口返回 code<0、甚至 HTTP 网络失败,
* 全部退化为"从传入 path 构造最小分享对象",保证调用方始终能拿到可消费的结构。
*/
getMpShare(path) {
//如果没有特别指定 则获取当前页面的路由
// 如果没有特别指定 则获取当前页面的路由
if (!path) {
let route = this.getCurrentRoute();
path = route.path;
if (path == '/pages_tool/member/index') {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
resolve({})
});
}
}
return new Promise((resolve, reject) => {
// 从传入 path 生成的兜底分享(后台未配置 / 接口异常时使用)
const buildFallback = (p) => {
const fallbackPath = p || '/pages/index/index';
const fallback = {
title: '小程序分享',
path: fallbackPath,
imageUrl: ''
};
const appMessage = {
title: fallback.title,
path: fallback.path,
imageUrl: fallback.imageUrl,
success: () => {},
fail: () => {}
};
const query = (fallback.path.indexOf('?') > 0) ? fallback.path.split('?')[1] : '';
const timeLine = {
title: fallback.title,
query: fallback.path,
imageUrl: fallback.imageUrl
};
return { appMessage, timeLine, _fallback: true, query };
};
// 通用构建:从后端返回的 {title,path,imageUrl} 拼成标准结构
const buildFromConfig = (shareConfig) => {
const appMessage = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl || '',
success: () => {},
fail: () => {}
};
const query = (shareConfig.path && shareConfig.path.indexOf('?') > 0)
? shareConfig.path.split('?')[1]
: '';
const timeLine = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl || ''
};
return { appMessage, timeLine, query };
};
const fallback = buildFallback(path);
return new Promise((resolve) => {
Http.sendRequest({
url: "/weapp/api/weapp/share",
data: {
path: path
},
success: res => {
if (res.code >= 0) {
let shareConfig = res.data.data;
if (shareConfig) {
//分享给好友
let appMessageData = {
title: shareConfig.title,
path: shareConfig.path,
imageUrl: shareConfig.imageUrl,
success: res => {
},
fail: res => {
try {
if (!res || typeof res !== 'object') {
resolve(fallback);
return;
}
if (res.code >= 0) {
// 响应可能是两种形态:
// A) { code, message, data: { title, path, imageUrl } }
// B) { code, message, permission:{...}, data: null }
// 统一安全解包:外层 payload 可能在 res.data 里,也可能直接在 res 里
const outer = res.data !== undefined ? res.data : res;
let shareConfig = null;
if (outer && typeof outer === 'object') {
// 后端在 outer 内嵌真正的配置对象(A 形态):outer.data = {title, path, ...}
if (outer.data && typeof outer.data === 'object' && (outer.data.title || outer.data.path)) {
shareConfig = outer.data;
} else if (outer.title || outer.path) {
// 极少数后端版本把配置平铺在 outer 上(扁平形态)
shareConfig = outer;
}
}
//分享到朋友圈
let query = '';
if (shareConfig.path.indexOf('?') > 0) {
query = shareConfig.path.split('?')[1];
if (shareConfig) {
resolve(buildFromConfig(shareConfig));
} else {
// 后台未配置该路径(B 形态 / data=null / 空对象)→ 不 reject,直接兜底
// 防止 8 处无 catch 的调用方变成 Unhandled Promise Rejection
resolve(fallback);
}
let timeLineData = {
title: shareConfig.title,
query: shareConfig.path,
imageUrl: shareConfig.imageUrl,
}
resolve({
appMessage: appMessageData,
timeLine: timeLineData,
})
} else {
reject(res.data);
// 业务 code<0:视为"无可用分享配置",兜底 resolve,不抛错
resolve(fallback);
}
} else {
reject(res.data)
} catch (e) {
// 任何解析异常(响应结构异常/字段缺失/后端改版)一律兜底,绝不 reject
console.warn('[UTIL] getMpShare parse error, use fallback:', e && e.message ? e.message : e);
resolve(fallback);
}
}
},
fail: () => {
// 网络失败:同样兜底(之前这里没写 fail 回调,Promise 会 hang 住)
resolve(fallback);
},
complete: () => {}
});
})
},

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();
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template>
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
<view>
<view v-if="pageCount === 1 || need" class="fixed-box"
<view v-if="(pageCount === 1 || need) && hasVisibleFixBtn" class="fixed-box"
:style="[customContainerStyle, {
height: containerHeight,
bottom: containerBottom,
@@ -74,10 +74,11 @@
<text class="icox icox-kefu" v-if="!kefuimg"></text>
</button>
<!-- 电话按钮 -->
<!-- 电话按钮 (已隐藏: 仅 display:none, DOM/事件/逻辑全部保留, 把 hidePhoneBtn 改回 false 即恢复) -->
<view
v-if="fixBtnShow"
class="btn-item common-bg"
:class="{ 'btn-item-hide': hidePhoneBtn }"
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
@click="call"
>
@@ -178,6 +179,9 @@ export default {
langDisplayMap: { ...LANG_DISPLAY_NAMES }, // 后端 langDisplayNames 会覆盖默认值
showLangPopup: false, // 语言选择弹窗显隐
customerService: null,
// 是否隐藏「电话」悬浮按钮: true = 隐藏(只隐藏不移除, DOM/点击事件/拨打逻辑都保留)
// 需要恢复时改回 false 即可, 或按需接后端配置
hidePhoneBtn: true,
// ============================================================
// 布局参数(从 App.vue 的 :root CSS 变量读取,权威源在 App.vue)
// 关键设计:
@@ -295,6 +299,12 @@ export default {
const customerService = createCustomerService(this);
return customerService.hasValidKefuConfig();
},
// 是否还有需要展示的浮动按钮:
// 电话按钮被隐藏、且没有语言切换/客服按钮时, 整个浮动按钮容器都不渲染,
// 避免留下一个透明的空容器挡住下面的页面内容点击
hasVisibleFixBtn() {
return !this.hidePhoneBtn || this.isLanguageSwitchEnabled || this.hasValidKefuConfig;
},
//根据显示的按钮数量动态计算容器高度
// 计算明细(buttonCount 按钮场景):
@@ -309,7 +319,8 @@ export default {
containerHeight() {
if (!this.fixBtnShow) return '320rpx';
let buttonCount = 1;
// 电话按钮隐藏时不再占用容器高度, 避免底部留白
let buttonCount = this.hidePhoneBtn ? 0 : 1;
if (this.isLanguageSwitchEnabled) buttonCount++;
if (this.showWeappSystemKefu) buttonCount++;
buttonCount++;
@@ -743,7 +754,53 @@ export default {
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() {
// 仅智能客服(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({
sendMessageTitle: '来自悬浮按钮的咨询',
sendMessagePath: '/pages/index/index'
@@ -818,6 +875,11 @@ export default {
background-color: var(--hover-nav-bg-color);
}
/* 电话悬浮按钮隐藏态: 只隐藏不移除(DOM、点击事件、拨打逻辑都还在), 去掉该类即恢复 */
.btn-item-hide {
display: none !important;
}
.btn-item text {
font-size: 28rpx;
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -281,18 +281,18 @@ export default {
// #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => {
// 检查是否已同意
if (!PrivacyManager.needShow()) {
// 已同意, 直接放行
resolve({ event: 'agree' })
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') {
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, 直接放行避免 API 卡死
resolve({ event: 'agree' })
}
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
}
})
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -105,4 +105,38 @@ components/mescroll/components/mescroll-empty.json
有些"修模拟器表现"的尝试(@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)。

View File

@@ -81,6 +81,7 @@
"loggingIn": "登录中",
"loginFailed": "登录失败",
"pleaseAuthorizeMobile": "请授权您的手机号码",
"pleaseAgreePrivacy": "请先同意《隐私条款》后重试",
"pleaseEnterNickname": "请输入昵称",
"avatarPullFailed": "头像拉取失败"
},
@@ -2624,7 +2625,7 @@
},
"ai-chat": {
"index": {
"title": "AI智能客服",
"title": "AI智能",
"cancel": "取消",
"save": "保存",
"send": "发送",
@@ -2641,7 +2642,7 @@
"loadMoreHistory": "加载更多历史消息",
"loadingHistory": "加载历史消息中...",
"aiThinking": "AI正在思考",
"receivingReply": "正在接收回复",
"receivingReply": "思考中",
"aiThinkingStatus1": "AI正在思考",
"aiThinkingStatus2": "AI正在分析",
"aiThinkingStatus3": "AI正在检索",
@@ -2651,6 +2652,10 @@
"noContentToCopy": "无可复制的内容",
"copySuccess": "已复制",
"copySelected": "已复制选中内容",
"selectTextToCopy": "选取文字复制",
"selectedText": "已选文字",
"copyAll": "复制全部",
"selectTextTip": "长按文字拖动两端手柄,可自由选择复制范围",
"copyFail": "复制失败",
"copyFailWithReason": "复制失败:{reason}",
"openingFile": "打开文件中...",

View File

@@ -12,6 +12,12 @@ export const lang = {
timeFormatToday: 'Today',
timeFormatYesterday: 'Yesterday',
timeFormatBeforeYesterday: 'Day before yesterday',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
timeJustNow: 'Just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeMonthDay: '{m}/{d}',
timeYearMonthDay: '{y}/{m}/{d}',
// ====== ai-chat-message ======
aiNickname: 'AI Assistant',
@@ -23,7 +29,7 @@ export const lang = {
loadMoreHistory: 'Load more history',
loadingHistory: 'Loading history...',
aiThinking: 'AI is thinking',
receivingReply: 'Receiving reply',
receivingReply: 'Thinking',
aiThinkingStatus1: 'AI is thinking',
aiThinkingStatus2: 'AI is analyzing',
aiThinkingStatus3: 'AI is searching',
@@ -35,6 +41,7 @@ export const lang = {
copySelected: 'Copied selected content',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}',
@@ -112,8 +119,16 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: 'More Features',
image: 'Image',
video: 'Video/Audio',
file: 'File',
voice: 'Voice',
recordVoice: 'Record Voice',
uploadAudio: 'Upload Audio',
pickVideo: 'Choose Video',
pickAudio: 'Choose Audio',
chooseAudioFail: 'Failed to select audio',
chooseVideoFail: 'Failed to select video',
audioNotReady: 'Audio not ready',
location: 'Location',
// ====== ai-chat-voice-panel ======
@@ -135,5 +150,9 @@ export const lang = {
logEventHandleError: 'Event handling error',
logInitNavbarFailed: 'Failed to initialize navigation bar',
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

@@ -126,6 +126,8 @@ export const lang = {
copyTimeoutReason: 'Reason:',
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',
// readable tip when clipboard privacy scope is not declared (errno 112)
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
copyFailed: 'Copy failed',
},

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能客服',
title: 'AI智能',
// ====== 通用 ======
cancel: '取消',
@@ -12,6 +12,12 @@ export const lang = {
timeFormatToday: '今天',
timeFormatYesterday: '昨天',
timeFormatBeforeYesterday: '前天',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
timeJustNow: '刚刚',
timeMinutesAgo: '{n}分钟前',
timeHoursAgo: '{n}小时前',
timeMonthDay: '{m}月{d}日',
timeYearMonthDay: '{y}年{m}月{d}日',
// ====== ai-chat-message ======
aiNickname: '智能助手',
@@ -23,7 +29,7 @@ export const lang = {
loadMoreHistory: '加载更多历史消息',
loadingHistory: '加载历史消息中...',
aiThinking: 'AI正在思考',
receivingReply: '正在接收回复',
receivingReply: '思考中',
aiThinkingStatus1: 'AI正在思考',
aiThinkingStatus2: 'AI正在分析',
aiThinkingStatus3: 'AI正在检索',
@@ -35,6 +41,7 @@ export const lang = {
copySelected: '已复制选中内容',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}',
@@ -112,8 +119,16 @@ export const lang = {
// ====== ai-chat-tools-panel ======
moreFunctions: '更多功能',
image: '图片',
video: '视频/音频',
file: '文件',
voice: '语音',
recordVoice: '录制语音',
uploadAudio: '上传音频',
pickVideo: '选择视频',
pickAudio: '选择音频',
chooseAudioFail: '选择音频失败',
chooseVideoFail: '选择视频失败',
audioNotReady: '音频尚未就绪',
location: '位置',
// ====== ai-chat-voice-panel ======
@@ -135,5 +150,9 @@ export const lang = {
logEventHandleError: '事件处理错误',
logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
}
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}

View File

@@ -126,6 +126,8 @@ export const lang = {
copyTimeoutReason: '原因:',
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
copyFailed: '复制失败',
},

View File

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

View File

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

View File

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

View File

@@ -82,7 +82,7 @@
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
"__usePrivacyCheck__" : true,
"__usePrivacyCheck__" : false,
"optimization" : {
"subPackages" : true
}

View File

@@ -13,7 +13,8 @@
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
"jweixin-module": "^1.6.0",
"playwright-core": "^1.63.0"
},
"uni-app": {
"scripts": {
@@ -89,4 +90,4 @@
}
}
}
}
}

View File

@@ -1086,7 +1086,64 @@
{
"path": "ai-chat/index",
"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"
}
}
},
{
@@ -2146,7 +2203,64 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": ""
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{
@@ -2388,7 +2502,8 @@
{
"path": "file-preview/file-preview",
"style": {
"navigationBarTitleText": "文件预览"
"navigationBarTitleText": "文件预览",
"navigationStyle": "custom"
}
}
]

View File

@@ -90,16 +90,17 @@ export default {
}
},
// 跟随 goodsSkuDetail 变化重新决定默认 Tab
// 解决 refreshGoodsSkuDetail 后续合并 pdf_url 时,Tab 已显示但 detailTab 仍为 'html' 的问题
// 默认展示排在前面的「商品详情」, 只有在没有图文详情、仅有说明书(pdf_url)时才默认选中说明书;
// 同时解决 refreshGoodsSkuDetail 后续合并数据时,Tab 已显示但 detailTab 未跟上的问题
goodsSkuDetail: {
deep: true,
immediate: true,
handler(newVal) {
if (!newVal) return;
if (newVal.pdf_url) {
this.detailTab = 'pdf';
} else if (newVal.goods_content) {
if (this.hasHtmlTab) {
this.detailTab = 'html';
} else if (newVal.pdf_url) {
this.detailTab = 'pdf';
}
}
}

View File

@@ -13,7 +13,7 @@
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
<view class="goods-detail-skeleton-action">
<view class="skeleton-action-icons">
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
<view class="skeleton-action-icon" v-for="i in 2" :key="i"></view>
</view>
<view class="skeleton-action-btn"></view>
<view class="skeleton-action-btn primary"></view>
@@ -56,10 +56,10 @@
</view>
</view>
<!-- 商品视频 -->
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
playsinline webkit-playsinline x5-playsinline></video>
</view>
@@ -386,14 +386,15 @@
<!-- 商品详情 -->
<view v-if="hasDetailContent" class="goods-detail-tab">
<view class="detail-tab">
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
</view>
<!-- 商品详情 在前, 产品说明书(文档详情) 在后 -->
<view v-if="hasHtmlTab" class="tab-item"
:class="{ active: detailTab === 'html' }" @click="switchDetailTab('html')">
<text class="tab-label">{{ $lang('goods.detail.details') }}</text>
</view>
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
</view>
</view>
<view class="detail-content active">
<!-- PDF文档 -->

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template>
<view class="content" :style="themeColor">
<view class="head-wrap">
<view class="head-wrap" :class="{ 'head-wrap-extend': hideSort }">
<!-- 搜索区域 -->
<view class="search-wrap uni-flex uni-row">
<view class="flex-item input-wrap">
@@ -12,8 +12,8 @@
@click="changeListStyle()"></view>
</view>
<!-- 排序 -->
<view class="sort-wrap">
<!-- 排序 (商品分类模板5进入时隐藏, 只隐藏不移除) -->
<view class="sort-wrap" :class="{ 'sort-wrap-hide': hideSort }">
<view class="comprehensive-wrap" :class="{ 'color-base-text': orderType === '' }"
@click="sortTabClick('')">
<text :class="{ 'color-base-text': orderType === '' }">{{ $lang('goods.list.Random') }}</text>
@@ -45,7 +45,7 @@
</view>
</view>
<mescroll-uni top="180" ref="mescroll" @getData="getGoodsList">
<mescroll-uni :top="hideSort ? 120 : 180" ref="mescroll" @getData="getGoodsList">
<block slot="list">
<view v-if="isList" class="goods-list single-column" :class="{ show: isList }">
<view class="goods-item margin-bottom" v-for="(item, index) in goodsList" :key="item.id"

View File

@@ -5,6 +5,13 @@
left: 0;
z-index: 1;
// 商品分类模板5进入时排序栏被隐藏, 让白色背景继续向下延伸(搜索框位置不变),
// 一直覆盖到第一行商品卡片顶部, 避免搜索框下方出现灰色空白条
&.head-wrap-extend {
// 26rpx = mescroll top(100rpx) - 搜索区高度(94rpx) + 列表 padding-top($margin-updown: 20rpx)
padding-bottom: 20rpx;
}
.search-wrap {
flex: 0.5;
padding: 30rpx 30rpx 0;
@@ -60,6 +67,12 @@
.sort-wrap {
display: flex;
padding: 10rpx 20rpx 10rpx 0;
// 商品分类模板5进入商品列表时隐藏排序栏(综合/销量/价格/筛选), 只隐藏不移除
&.sort-wrap-hide {
display: none;
}
> view {
flex: 1;
text-align: center;

View File

@@ -19,6 +19,7 @@ export default {
coupon: 0,
emptyShow: false,
isList: true, //列表样式
hideSort: false, //是否隐藏排序栏(综合/销量/价格/筛选)
//分享所需标题
share_title: '',
//搜索到多少件商品
@@ -82,6 +83,15 @@ export default {
});
this.categoryId = options.category_id || 0;
// 商品分类(模板5)点二级分类进入时带 list_style=grid, 默认展示竖版(双列宫格)视图;
// 其它入口(搜索/优惠券/品牌等)保持原有的单列列表视图
if (options.list_style === 'grid') {
this.isList = false;
}
// 商品分类(模板5)进入时隐藏排序栏(综合/销量/价格/筛选), 只隐藏不移除
if (options.hide_sort == 1) {
this.hideSort = true;
}
this.keyword = options.keyword || '';
this.coupon = options.coupon || 0;
this.goods_id_arr = options.goods_id_arr || 0;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,27 +9,37 @@
</button>
</view>
<view class="tools-grid">
<view class="tool-item" @click="selectImage">
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
location 不属于附件, 不受此开关控制 -->
<view v-if="isTypeEnabled('image')" class="tool-item" @click="selectImage">
<view class="tool-icon">
<text class="iconfont icon-image"></text>
<text class="icondiy icon-system-image-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view>
<view class="tool-item" @click="selectFile">
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
<view class="tool-icon">
<text class="iconfont icon-file"></text>
<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 v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
<view class="tool-icon">
<text class="icondiy icon-system-file-2-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
</view>
<view class="tool-item" @click="startRecord">
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon">
<text class="iconfont icon-voice"></text>
<text class="icondiy icon-system-chat-voice-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
</view>
<view class="tool-item" @click="selectLocation">
<view class="tool-icon">
<text class="iconfont icon-location"></text>
<text class="icondiy icon-system-map-pin-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
</view>
@@ -39,16 +49,20 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
* Props:
* visible: Boolean - 控制显示/隐藏
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
* location 不属于附件, 不受此 prop 影响
*
* Events:
* update:visible - 同步关闭状态
* image-selected - 选择图片,参数:图片消息对象
* file-selected - 选择文件,参数:文件消息对象
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
* voice-start - 开始语音
* location-selected - 选择位置,参数:位置消息对象
*/
@@ -59,6 +73,12 @@ export default {
visible: {
type: Boolean,
default: false
},
attachmentTypes: {
type: Array,
default: function () {
return ['image', 'file', 'audio', 'video']
}
}
},
@@ -73,74 +93,304 @@ export default {
this.$emit('update:visible', false)
},
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
isTypeEnabled(kind) {
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
return this.attachmentTypes.indexOf(kind) !== -1
},
selectImage() {
const that = this
uni.chooseImage({
count: 1,
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
that.hideToolsPanel()
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = (e) => {
input.onchange = async (e) => {
const file = e.target.files[0]
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
}
input.click()
// #endif
// #ifdef MP-WEIXIN
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
success: async (res) => {
that.hideToolsPanel()
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
if (!file) return
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
await that._uploadOneFile(file, kind)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选文件失败', icon: 'none' })
}
},
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: (res) => {
success: async (res) => {
that.hideToolsPanel()
const file = res.tempFiles[0]
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
url: file.path,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
await that._uploadOneFile(file, 'file')
}
})
// #endif
},
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
selectVideo() {
const that = this
// #ifdef H5
const mediaInput = document.createElement('input')
mediaInput.type = 'file'
mediaInput.accept = 'video/*,audio/*'
mediaInput.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, that._guessMediaKind(file))
}
mediaInput.click()
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
uni.showActionSheet({
itemList: [
that.$lang('ai-chat.index.pickVideo', '选择视频'),
that.$lang('ai-chat.index.pickAudio', '选择音频')
],
success: (res) => {
if (res.tapIndex === 0) that._chooseVideo()
else if (res.tapIndex === 1) that.selectAudio()
},
fail: () => {}
})
// #endif
},
// 按 mime / 扩展名判断选中的文件是视频还是音频
_guessMediaKind(file) {
const type = (file && file.type ? file.type : '').toLowerCase()
if (type.indexOf('audio/') === 0) return 'audio'
if (type.indexOf('video/') === 0) return 'video'
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
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() {
this.$emit('voice-start')
this.hideToolsPanel()
@@ -209,6 +459,7 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
@@ -225,7 +476,9 @@ export default {
align-items: center;
justify-content: center;
border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;
@@ -257,7 +510,8 @@ export default {
margin-bottom: 16rpx;
box-shadow: $shadow-sm;
.iconfont {
.iconfont,
.icondiy {
font-size: 48rpx;
color: $color-primary;
}

View File

@@ -13,9 +13,12 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content">
<ai-chat-message
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<ai-chat-message
v-if="uiCfgReady"
ref="chat"
:preloaded-ui-config="preloadedUiCfg"
:initial-messages="initialMessages"
:fallback-welcome="fallbackWelcome"
:user-avatar="userAvatar"
@@ -68,6 +71,10 @@
</view>
</view>
</view>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
</view>
</template>
@@ -87,6 +94,15 @@ export default {
return {
initialMessages: [],
fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
uiCfgReady: false,
preloadedUiCfg: null,
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -105,7 +121,12 @@ export default {
keyboardHeight: 0,
// 事件处理器引用(用于清理)
safeEventHandlers: new Map()
safeEventHandlers: new Map(),
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
}
},
@@ -127,6 +148,49 @@ export default {
async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
try {
const cached = aiService.getChatUiConfigSync();
if (cached) {
this.preloadedUiCfg = cached;
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
}
} catch (e) { /* 缓存读取失败走预取 */ }
if (!this.uiCfgReady) {
try {
const cfg = await Promise.race([
aiService.getChatUiConfig(true),
new Promise(resolve => setTimeout(() => resolve(null), 6000))
]);
if (cfg) this.preloadedUiCfg = cfg;
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
} catch (e) { /* 失败兜底默认值 */ }
this.uiCfgReady = true;
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
}
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
// 当前 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();
// if (!hasAccess) {
@@ -134,7 +198,19 @@ export default {
// 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();
},
@@ -180,6 +256,15 @@ export default {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup();
aiService.disconnectWebSocket();
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
if (this.isFromFloatBtn) {
try {
this.$store.commit('setBottomNavHidden', false);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.isFromFloatBtn = false;
}
},
methods: {
@@ -647,7 +732,9 @@ export default {
/* #endif */
left: 0;
right: 0;
bottom: 0;
/* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
}
/* 底部tabBar占位样式 */

View File

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

View File

@@ -13,12 +13,18 @@
<view class="article-wrap">
<ns-adv keyword="NS_ARTICLE" class-name="adv-wrap"></ns-adv>
<block v-for="(item, index) in list" :key="index">
<view class="item" @tap="toDetail(item.$orig)">
<view class="article-img">
<image class="cover-img" :src="item.g1" mode="widthFix" @error="imgError(index)" show-menu-by-longpress></image>
<view class="item" @tap="toDetail(item)">
<view class="pdf-icon">
<text class="pdf-icon-p">P</text>
<view class="pdf-icon-fold"></view>
<view class="pdf-icon-lines">
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
</view>
</view>
<view class="info-wrap">
<view class="title">{{ item.$orig.files_title }}</view>
<view class="title">{{ item.files_title }}</view>
<view class="read-wrap">
<text>{{ $lang('files.list.viewPdf') }}</text>
</view>
@@ -84,8 +90,10 @@ export default {
if (res.code === 0 && res.data) {
list = res.data.list
if (!this.title && res.data.list.length > 0) {
const first = res.data.list[0]
const firstOrig = (first && first.$orig) ? first.$orig : first
uni.setNavigationBarTitle({
title: res.data.list[0].category_name
title: firstOrig.category_name || ''
})
}
} else {
@@ -98,9 +106,18 @@ export default {
this.list = []
}
this.list = this.list.concat(list.map(item => {
const orig = item
const g1 = this.$util.img("addon/personnel/shop/view/enterprise/fileicon.png")
return { $orig: orig, g1: g1 }
// 兼容两种后端结构:自带 $orig(如 cases 接口)或扁平结构
const src = (item && item.$orig) ? item.$orig : item
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) {
this.$refs.loadingCover.hide()
@@ -115,41 +132,121 @@ export default {
})
},
toDetail(item) {
uni.showLoading({
title: this.$lang('files.list.loading')
})
try {
uni.downloadFile({
url: item.files_url,
success: res => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
},
fail: () => {
},
complete: () => {
uni.hideLoading()
}
})
}
},
fail: () => {
uni.hideLoading()
},
complete: res => {
if (res.statusCode === 404) {
uni.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
})
}
}
})
} catch (e) {
uni.hideLoading()
const title = (item.files_title || '文件').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
// 根据 URL 推断后缀(若无扩展名则默认 pdf)
let ext = 'pdf';
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({
url: item.files_url,
success: res => {
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({
filePath: r.savedFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: () => {
// 预览结束后清理,避免占用持久空间(失败忽略)
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) {}
}
});
},
fail: () => {
// 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 => {
if (res && res.statusCode === 404) {
uni.hideLoading();
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
},
imgError(index) {
if (this.list[index]) {
@@ -224,18 +321,70 @@ export default {
border-radius: 16rpx;
}
.article-wrap .item .article-img {
.article-wrap .item .pdf-icon {
margin-right: 20rpx;
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
background-color: #e64340;
border-radius: 10rpx;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
}
.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%;
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 {
@@ -243,16 +392,21 @@ export default {
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
}
.article-wrap .item .info-wrap .title {
font-weight: 700;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-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 {

View File

@@ -1,183 +1,746 @@
<template>
<view :style="themeColor">
<view class="page" v-if="detail">
<view class="form-banner">
<image :src="$util.img('public/uniapp/form/banner.png')" mode="widthFix"></image>
</view>
<view class="system-form-wrap">
<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>
<ns-empty :text="complete ? $lang('form.form.submitSuccess') : $lang('form.form.noFormInfo')" v-else></ns-empty>
<view :style="themeColor">
<!-- 顶部图条:位于表单卡片之上,宽度 calc(100% - 2*containerInset) 水平居中。
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染。 -->
<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 class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
</view>
</view>
</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>
<ns-login ref="login"></ns-login>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
</view>
</template>
<script>
import scroll from '@/common/js/scroll-view.js';
import scroll from '@/common/js/scroll-view.js';
export default {
mixins: [scroll],
data() {
return {
id: 0,
detail: null,
isRepeat: false,
complete: false,
scroll: true,
urlParams: {}
};
},
onLoad(data) {
// #ifdef MP-ALIPAY
let options = my.getLaunchOptionsSync();
options.query && Object.assign(data, options.query)
// #endif
export default {
mixins: [scroll],
data() {
return {
id: 0,
detail: null,
isRepeat: false,
complete: false,
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) {
// #ifdef MP-ALIPAY
let options = my.getLaunchOptionsSync();
options.query && Object.assign(data, options.query)
// #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) {
var sceneParams = decodeURIComponent(data.scene);
sceneParams = sceneParams.split('&');
if (sceneParams.length) {
sceneParams.forEach(item => {
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
// scene 中也可能携带其他参数
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
}
});
}
}
},
onShow() {
if (!this.storeToken) {
this.redirectTologin('/pages_tool/form/form?id=' + this.id);
return;
// 通用 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];
}
this.getData();
},
watch: {
storeToken: function(nVal, oVal) {
if (nVal) this.getData();
}
},
methods: {
getData() {
this.$api.sendRequest({
url: '/form/api/form/info',
data: {
form_id: this.id
},
success: res => {
if (res.code == 0 && res.data) {
this.detail = res.data;
}
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
fail: res => {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
}
});
},
create() {
if (this.$refs.form.verify()) {
if (this.isRepeat) return;
this.isRepeat = true;
if (data.scene) {
var sceneParams = decodeURIComponent(data.scene);
sceneParams = sceneParams.split('&');
if (sceneParams.length) {
sceneParams.forEach(item => {
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];
}
});
}
}
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
},
success: res => {
if (res.code == 0) {
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') })
setTimeout(() => {
this.complete = true;
this.detail = null;
}, 1500)
} else {
this.isRepeat = false;
this.$util.showToast({ title: res.message })
}
}
});
}
},
}
};
// #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() {
// 打开是否需要登录由后端 behavior 决定(info 返回 FORM_LOGIN_REQUIRED 时弹登录)
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: {
storeToken: function(nVal, oVal) {
if (nVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
}
},
methods: {
getData() {
this.$api.sendRequest({
url: '/form/api/form/info',
data: {
form_id: this.id
},
success: res => { this._handleInfoResponse(res); },
fail: err => { this._handleInfoFailure(err); }
});
},
// ====== 表单 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();
},
// 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();
},
// 打开登录: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 = '';
}
}
});
},
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
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.$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({
url: '/form/api/form/create',
data: {
form_id: this.id,
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 => {
if (res.code == 0) {
this.$util.showToast({ title: this.$lang('form.form.submitSuccess') });
const link = behavior.redirect;
setTimeout(() => {
link ? this.doRedirect(link)
: (this.complete = true, this.detail = null);
}, link ? 800 : 1500);
} else {
this.isRepeat = false;
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>
<style lang="scss">
<style lang="scss" scoped>
page {
background: #fff;
}
.form-banner {
width: 100vw;
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;
}
image {
width: 100%;
line-height: 1;
}
}
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.system-form-wrap {
background: $color-bg;
border-radius: 32rpx;
overflow: hidden;
margin: 0 0 60rpx 0;
padding: 0 26rpx;
transform: translateY(-40rpx);
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
/* ===== 表单卡片:白底圆角、左右内缩居中 =====
⚠️ 此 padding 仅为首屏兜底(computed 尚未就绪时生效),
运行中会被 containerStyle 内联样式覆盖,实际值由 containerInset computed 决定 */
padding: 0 30rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
}
.form-title {
line-height: 100rpx;
padding-top: 20rpx;
}
// 穿透 diy-form 包装层:消除其自带的圆角与背景,
// 让 form 内容区域与 system-form-wrap 卡片视觉融合
.system-form-wrap ::v-deep .diy-from {
background: transparent;
border-radius: 0;
}
.button {
height: 80rpx;
line-height: 80rpx !important;
margin-top: 30rpx !important;
width: 100%;
border-radius: 80rpx;
}
// ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
.form-extra-wrap {
padding: 0 28rpx 20rpx;
}
.captcha-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 20rpx;
margin-top: 24rpx;
::v-deep .form-wrap {
background: #fff;
padding: 30rpx;
border-radius: 32rpx;
}
}
.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 lang="scss" scoped>
::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important;
bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
}
::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important;
bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
bottom: 180rpx !important;
/* #endif */
}
</style>

View File

@@ -1,5 +1,14 @@
<template>
<!-- #ifdef MP-HARMONY -->
<view :style="themeColor" class="harmony-root">
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view :style="themeColor">
<!-- #endif -->
<!-- 顶部图条:位于 navbar 之下、表单卡片之上。宽度 calc(100% - 2*containerInset) 水平居中,
留下与卡片等宽的左右对称留白(默认容器内缩 30rpx ≈ 屏宽 1.3%,即内容宽 97.3%,与参考图一致)。
图片由后台 form_config.bg_image / bgImage 下发;未配置时不渲染,保持原来的纯白布局。 -->
<view v-if="bgImage" class="formdata-bg" :style="bgLayerStyle"></view>
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
@@ -23,19 +32,64 @@
</view>
<!-- #endif -->
<block v-if="detail">
<!-- 鸿蒙元服务:用原生 scroll-view 包裹表单,替代 CSS overflow 滚动(ArkUI 不识别 WebKit overflow 语义)。
根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余,scroll-view 在其内滚动。
提交按钮由 diy-form 内部渲染,处于 scroll-view 内,用户可滚到末尾点击。 -->
<!-- #ifdef MP-HARMONY -->
<view class="page harmony-page">
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
<view class="system-form-wrap" :style="containerStyle">
<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 class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
</view>
</view>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view class="page">
<view class="system-form-wrap">
<view class="system-form-wrap" :style="containerStyle">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 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 class="custom-submit" @click="submitFromPage">{{ submitBtnText }}</view>
</view>
</view>
</view>
<!-- #endif -->
</block>
<block v-else>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
:isIndex="false"></ns-empty>
<ns-empty :text="emptyText" :isIndex="false"></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>
<ns-login ref="login"></ns-login>
@@ -62,6 +116,20 @@ export default {
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
urlParams: {}, // 通用 URL 透传参数收集
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
// 行为设置(后端 __form_config__.behavior 下发,含默认值)
behavior: {},
// 非成功状态的空态文案(表单未开放/需登录/加载失败等)
formError: '',
// 图形验证码(behavior.captcha=1 时展示,由本页渲染而非 diy-form)
captcha: { id: '', img: '', code: '' },
// 是否已加载过验证码(避免 onShow 重复刷新导致 session 错乱)
captchaLoaded: false,
// 登录后回跳地址(onLoad 时由原始 options 重建)
backUrl: '',
// 登录拦截时暂存待提交数据:组件内存(MP 不丢态)+ 本地存储(H5 登录整页回跳后从存储恢复)
pendingSubmit: null,
// onLoad→getData 比 ns-login 挂载早时,用此标志在 mounted 再兜底弹登录(避免 ref 空报错)
pendingLoginRequired: false,
// #ifndef H5
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
@@ -106,16 +174,117 @@ export default {
// diy-form 内部 mergedFormConfig 同时兼容 snake_case 后再交给 ns-newform
// - show_banner / form_banner:交给 diy-form 渲染 banner,样式走 form_config.bannerPaddingX;form_banner 给保底图
// - show_tip / form_tip:交给 diy-form 渲染 tip,样式走 form_config;form_tip 给保底文案
// 注:本 computed 只在 <block v-if="detail"> 内被消费,无需 null 守卫
// 注:小程序端模板引用的 computed 会进入 setData 数据流、每次更新都重新求值,
// 即使消费节点都在 v-if="detail" 内,detail 置空瞬间(提交成功/登录回跳等)也会求值,
// 必须做空值守卫,否则真机抛 Cannot read properties of null (reading 'json_data') 导致渲染中断
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: this.detail.form_config,
show_banner: this.detail.show_banner,
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')
}
},
// ===== 容器左右内缩(默认开启,不依赖 bgImage) =====
// 图条图片:后台 form_config.bg_image / bgImage 下发(兼容 snake_case / camelCase)。
// 未配置时不渲染图条,但表单卡片本身已有内缩效果(见 .system-form-wrap 默认 padding)
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,在 750rpx 屏宽下左右各留 30rpx ≈ 4%,内容宽 ≈ 92%。
// ⚠️ 改这里才生效:本 computed 的返回值通过 containerStyle 内联样式作用于 .system-form-wrap,
// 内联样式优先级高于 SCSS 类选择器,会覆盖下方 SCSS 里的 padding(SCSS 仅作首屏兜底)。
// ⚠️ 后台优先级最高:若 form_config.containerInset / container_inset 下发了数值,会覆盖此默认值。
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));
},
// 图条内联样式:水平居中(width=100%-2*inset + 左右各 inset 边距),高度由配置控制
bgLayerStyle() {
const inset = this.containerInset;
return 'width:calc(100% - ' + (inset * 2) + 'rpx);margin:0 ' + inset + 'rpx;height:' + this.bgImageHeight + 'rpx;background-image:url(' + this.bgImage + ');';
},
// 表单容器内联样式:覆盖 .system-form-wrap 默认 padding,使后台可配。始终返回有效样式(含默认值),
// 不再依赖 bgImage 开关 —— 表单默认就有左右内缩居中(不需后台任何配置)
containerStyle() {
return 'padding-left:' + this.containerInset + 'rpx;padding-right:' + this.containerInset + 'rpx;';
},
// 表单风格(与 diy-form.vue hasBannerMode 对齐):优先读显式 form_style,未设置时回退到原 bgImage 隐式判定
// - card :卡片浮起需要 banner 作为被压对象 → 保留内联 banner(与后端预览一致);仅 bgImage 全屏底图时隐藏避免重复
// - classic:保留内联 banner(与原行为一致),仅当后台显式配 bgImage 才隐藏
// 老表单无 form_style 字段时 → undefined → 走原隐式判定,零迁移风险
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.formdata.submitSuccess')
: (this.formError || this.$lang('form.formdata.noFormInfo'));
}
},
onLoad(options) {
@@ -180,17 +349,67 @@ export default {
const scene = decodeURIComponent(options.scene)
const params = scene.split('&')
params.length && params.forEach(item => {
if (item.indexOf('id') != -1) {
this.id = item.split('-')[1]
}
// scene 中也可能携带其他参数
if (!item) return;
// 兼容标准 key=value(id=13&uniacid=1)与旧式 id-13 两种 scene 格式
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
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('&');
const reservedH5 = ['scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
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') { if (!this.uniacid) this.uniacid = v; }
else if (reservedH5.indexOf(k) !== -1) {
// 导航/显示类参数:仅当 options 没有传时才从 href 补
if (options[k] === undefined && this[k] === undefined) {
if (k === 'from') this.isFromMpWebView = (v === 'mp-webview');
else if (k === 'hide_navbar') this.hideNavbar = this.$util.coerceBool(v);
else if (k === 'show_home') this.showHomeBtn = this.$util.coerceBool(v);
else if (k === 'title') this.formTitle = v;
else if (k === 'enable_set_title') this.enableSetTitle = (v == 1);
}
} else {
if (this.urlParams[k] === undefined) this.urlParams[k] = v;
}
}
}
} catch (e) {}
// #endif
// #ifdef H5
if (this.isFromMpWebView || this.hideNavbar) {
this.$nextTick(() => {
@@ -198,19 +417,64 @@ export default {
if (navbar) navbar.style.display = 'none'
})
}
// #endif
// #endif
// 登录后回跳地址:基于最终状态(this.id / this.uniacid + urlParams + 导航类参数)重建,
// 确保 options/scene/H5 href 三个来源的参数都被带到登录回跳 URL 中(尤其 recommend_code)
const backQuery = [];
if (this.uniacid) backQuery.push('uniacid=' + encodeURIComponent(this.uniacid));
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));
}
}
// 导航类参数(不在 urlParams 中,单独收集)
if (options && options.from) backQuery.push('from=' + encodeURIComponent(options.from));
if (this.hideNavbar) backQuery.push('hide_navbar=1');
if (this.showHomeBtn) backQuery.push('show_home=1');
// #ifdef H5
if (this.formTitle) backQuery.push('title=' + encodeURIComponent(this.formTitle));
if (this.enableSetTitle) backQuery.push('enable_set_title=1');
// #endif
this.backUrl = '/pages_tool/form/formdata' + (backQuery.length ? '?' + backQuery.join('&') : '');
this.getData()
},
watch: {
storeToken(newVal, oldVal) {
newVal && this.getData()
if (newVal) {
this.getData();
// 填写需登录:弹窗登录成功后自动续提交
this.resumeSubmitAfterLogin();
}
}
},
onShow() {
this.updateNavbarTitle();
},
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('[FORMDATA] 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(); });
}
},
methods: {
// #ifndef H5
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
@@ -233,18 +497,136 @@ export default {
form_id: this.id,
uniacid: this.uniacid
},
success: res => {
if (res.code == 0 && res.data) {
this.detail = res.data
this.updateNavbarTitle();
}
this.$refs.loadingCover && this.$refs.loadingCover.hide()
},
fail: () => {
this.$refs.loadingCover && this.$refs.loadingCover.hide()
}
success: res => { this._handleInfoResponse(res); },
fail: err => { this._handleInfoFailure(err); }
})
},
// ====== 表单 info 响应统一处理(剥离自 getData,便于严格判定与复用) ======
// 成功必须同时满足:code==0 + data 是对象 + 内含 json_data 或 form_config
// 不满足 → 宽网判定是否是登录拦截:
// - 显式消息:FORM_LOGIN_REQUIRED / LOGIN_REQUIRED / 请先登录
// - 兜底:数据为空 + 用户未登录 + 有 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.updateNavbarTitle();
this.behavior = (dataObj.form_config && dataObj.form_config.behavior) || {};
if (this.behavior.captcha == 1 && !this.captchaLoaded) this.getCaptcha();
this.resumeSubmitAfterLogin();
// 本地缓存:下次再打开时若被登录拦截,可作为 skeleton 数据使用
try { uni.setStorageSync('form_fields_cache_' + this.id, dataObj.json_data); } catch (e) {}
// 成功分支同样必须关闭 loading 遮罩:
// 之前这里直接 return 跳过了函数底部的 hide(),导致登录后进入表单一直转圈
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 存在)但后端仍返回登录拦截:
// 登录一次后就不再自动弹登录/跳登录页(防止 H5 登录回跳后 token 未被
// 及时认可或双请求竞态时反复弹登录的死循环),仅展示空态文案 + 手动登录按钮
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 || '';
}
} catch (e) {
console.warn('[FORMDATA] _handleInfoResponse exception:', e && e.message ? e.message : e);
this.formError = '';
}
this.$refs.loadingCover && this.$refs.loadingCover.hide();
},
// info 接口网络失败时同样判定:未登录+有 id 就先走登录流程
_handleInfoFailure(err) {
try {
if (this.id && !this.storeToken) {
this.formError = this.$lang('form.form.loginRequired');
this._openLoginWithRetry();
} else {
this.formError = '';
}
} catch (e) {
this.formError = '';
}
this.$refs.loadingCover && this.$refs.loadingCover.hide();
},
// 打开登录:3 次 setTimeout 重试(0ms / 60ms / 320ms)
// 彻底覆盖 onLoad→回调→ns-login 尚未挂载的所有时序问题
_openLoginWithRetry() {
// 防重复触发:3 秒内只允许自动弹一次登录
// (响应回调 / mounted 兜底 / watcher 同时命中时只生效一次,避免 H5 反复跳登录页)
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('[FORMDATA] login.open attempt ' + (attempts + 1) + ' failed:', e);
}
attempts++;
if (attempts < delays.length) {
setTimeout(tryOnce, delays[attempts]);
} else {
this.pendingLoginRequired = true;
}
return false;
};
tryOnce();
},
// 模板空态按钮的手动入口(最后一道防线)
forceOpenLogin() {
this._openLoginWithRetry();
},
updateNavbarTitle() {
if (!this.detail?.form_name) return;
let formName = this.detail.form_name;
@@ -263,6 +645,125 @@ export default {
this.navbarTitle = formName;
// #endif
},
// 图形验证码(点击图片/按钮刷新;提交失败后自动刷新)
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 = ''
}
}
})
},
// 登录拦截时保存待提交数据(组件内存 + 本地存储,H5 登录会整页回跳)
savePendingSubmit(formData) {
const payload = {
formId: this.id,
uniacid: this.uniacid,
urlParams: this.urlParams,
formData: formData,
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 已挂载),避免 H5 整页回跳后好友看到空白表单
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.$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 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
@@ -271,30 +772,69 @@ export default {
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
if (this.isRepeat) return;
this.isRepeat = true;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
},
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();
console.log('[FORMDATA-SUBMIT-CAPTCHA]', { captcha_id: submitCaptchaId, captcha_code: submitCaptchaCode, raw_code: this.captcha.code });
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0,
captcha_id: submitCaptchaId,
captcha_code: submitCaptchaCode
},
success: res => {
this.isRepeat = false;
if (res.code == 0) {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
showCancel: false,
success: () => { }
})
const link = behavior.redirect;
if (link) {
this.$util.showToast({ title: this.$lang('form.formdata.submitSuccess') });
setTimeout(() => this.doRedirect(link), 800);
} else {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
showCancel: false,
success: () => { }
})
}
} else {
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
} else if (res.message == 'FORM_NOT_OPEN') {
this.$util.showToast({ title: this.$lang('form.form.notOpen') })
} else {
this.$util.showToast({
title: res.message
})
}
}
},
fail: () => {
@@ -420,25 +960,162 @@ page {
background: #fff;
}
.system-form-wrap {
// ===== 非鸿蒙端(H5 / 微信小程序 / App):保留原有 WebKit 滚动 / 安全区处理 =====
// 鸿蒙走下方 MP-HARMONY 专属的 scroll-view 方案,不编译这些 WebKit 属性
/*
* ===== 顶部图条 =====
* 位于 navbar 之下、表单卡片之上。宽度由内联 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;
margin: 0 0 60rpx 0;
box-sizing: border-box;
}
// 穿透 diy-form 包装层:消除其自带的 10rpx 圆角与白色背景,
/* #ifdef H5 || MP-WEIXIN || APP-PLUS */
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
/* 页面容器不再垫白底 —— 只有红色框区域(.system-form-wrap 表单卡片)保留白底,
图条上下、左右留白等区域透出页面默认底色 */
}
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
/* ===== 表单卡片:唯一保留白底的区域 =====
92% 宽居中(左右各留 30rpx ≈ 4%) + 16rpx 圆角 + 白底,对应红色框范围(从图条底部到页面底部)。
⚠️ 注意:此 padding 仅为首屏兜底(H5 computed 尚未就绪时生效),
运行中会被 containerStyle 内联样式覆盖,实际值由 containerInset computed 决定 */
padding: 0 30rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
}
/* #endif */
// ===== 鸿蒙元服务:用原生 scroll-view 做滚动容器 =====
// ArkUI 不识别 WebKit 的 overflow / -webkit-overflow-scrolling,必须用 scroll-view 组件(编译为 ArkUI Scroll)。
// 根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余空间,
// scroll-view 在 page 内 flex:1 获得确定高度后内部滚动。提交按钮由 diy-form 渲染在 scroll-view 内,可滚动到。
/* #ifdef MP-HARMONY */
.harmony-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.harmony-page {
flex: 1;
min-height: 0; // 允许内容收缩,内部 scroll-view 才能拿到确定高度并正确滚动
display: flex;
flex-direction: column;
/* 鸿蒙端与 .page 保持一致:去掉白底,只有 .system-form-wrap 是白底卡片 */
}
.form-scroll {
flex: 1;
height: 100%;
}
.system-form-wrap {
// 不再用 overflow:hidden 裁剪;提交按钮在 scroll-view 内,滚动可见
overflow: visible;
margin: 0 0 60rpx 0;
// ===== 表单卡片:默认就是「白底圆角、左右内缩居中」(参考图效果),不依赖后台配置 =====
// 左右各留 30rpx(≈ 4% 屏宽),与 H5/MP/APP 端保持一致;后台可通过 form_config.container_inset 调整
// ⚠️ 注意:此 padding 仅为首屏兜底,运行中会被 containerStyle 内联样式覆盖(同 H5/MP/APP 端)
padding: 0 30rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 16rpx;
padding-bottom: 40rpx; // 鸿蒙不识别 env(safe-area-inset-bottom),固定值兜底
}
/* #endif */
/*
* ===== .form-card 兼容占位(旧模板/旧调用残留) =====
* 旧实现用 .form-card 做条件类加强化,现已将白底/圆角合并到 .system-form-wrap 默认样式中。
* 保留此类名是为了不破坏潜在的 :class="formCardClass" 引用(已从模板移除,但旧页面缓存可能用到);
* 这里只做 noop,不影响任何视觉。
*/
.form-card {}
// 穿透 diy-form 包装层:消除其自带的 30rpx 圆角与白色背景,
// 让 form 内容区域保持与原始 ns-newform 接入时一致的视觉
.system-form-wrap ::v-deep .diy-from {
background: transparent;
border-radius: 0;
}
// ===== 行为设置:验证码行 + 自有提交按钮(diy-form 内置按钮被 showSubmit=false 隐藏时接管) =====
.form-extra-wrap {
padding: 0 28rpx 20rpx;
}
.captcha-wrap {
display: flex;
align-items: center;
background: #fff;
border-radius: 12rpx;
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;
}
::v-deep .mescroll-totop {
/* #ifdef H5 */
right: 27rpx !important;
right: 230rpx !important;
bottom: 120rpx !important;
/* #endif */
/* #ifdef MP */
right: 27rpx !important;
right: 230rpx !important;
bottom: 180rpx !important;
/* #endif */
}

View File

@@ -8,7 +8,7 @@ const paths = [
['pages_promotion/merch/detail','商户首页'],
['pages_promotion/fenxiao/ranking_list','排行榜'],
['pages_promotion/fenxiao/child_fenxiao','分销商'],
['pages_tool/ai-chat/index','AI智能客服'],
['pages_tool/ai-chat/index','AI智能'],
['pages_tool/contact/contact','电子名片'],
['pages_tool/member/info','个人资料'],
['pages_tool/order/activist','退款'],

View File

@@ -121,7 +121,7 @@
],
[
"pages_tool/ai-chat/index",
"AI智能客服"
"AI智能"
],
[
"pages_tool/contact/contact",

View File

@@ -0,0 +1,144 @@
// tests/media-upload-fix.test.js
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 单元测试核心逻辑修复
// - getFileSize 失败抛错而不是 resolve(0)
// - ext 提取: blob URL → 不取错 URL 整段
// - guessMime 包含 HEIC/HEIF/AVIF/BMP/TIFF 等现代格式
// - uploadViaSts 返回的 size 不再是 0 (用已知 size)
//
// 注意: 这是白盒 smoke, 不 mock uni.* API. 测试纯函数层逻辑.
const { test: nodeTest } = require('node:test')
const assert = require('node:assert/strict')
// 加载被测模块: 用 rewire 风格手动注入 mock
// 因为 media-upload.js 直接 import ai-service, 这里只测可独立验证的 helper
// 我们直接 require 然后 inspect 内部状态. 不行就复制粘贴. 这里走第二种.
// === guessMime 等价实现 (复制自 media-upload.js, 测试时和源文件保持同步) ===
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',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
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',
}
function guessMime(ext) {
return EXT_MIME_MAP[ext] || 'application/octet-stream'
}
// === ext 提取 (复制自 uploadViaSts 顶部) ===
function extractExt(filePath) {
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()
}
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
return ext
}
nodeTest('guessMime 现代格式 (HEIC/AVIF/BMP/TIFF)', () => {
assert.equal(guessMime('heic'), 'image/heic')
assert.equal(guessMime('heif'), 'image/heif')
assert.equal(guessMime('avif'), 'image/avif')
assert.equal(guessMime('bmp'), 'image/bmp')
assert.equal(guessMime('tif'), 'image/tiff')
assert.equal(guessMime('tiff'), 'image/tiff')
// 老格式不破坏
assert.equal(guessMime('jpg'), 'image/jpeg')
assert.equal(guessMime('png'), 'image/png')
})
nodeTest('guessMime 未知扩展名降级', () => {
assert.equal(guessMime('xyz'), 'application/octet-stream')
assert.equal(guessMime(''), 'application/octet-stream')
})
nodeTest('guessMime 办公格式', () => {
assert.equal(guessMime('docx'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
assert.equal(guessMime('pptx'), 'application/vnd.openxmlformats-officedocument.presentationml.presentation')
assert.equal(guessMime('zip'), 'application/zip')
})
nodeTest('extractExt 普通路径', () => {
assert.equal(extractExt('/tmp/upload/abc.heic'), 'heic')
assert.equal(extractExt('blob:https://example.com/photo.png'), 'png')
assert.equal(extractExt('http://x.com/y/test.jpg?ver=1'), 'jpg')
assert.equal(extractExt('C:\\Users\\test\\photo.heic'), 'heic')
})
nodeTest('extractExt blob URL 无扩展名时降级', () => {
// 关键 case: blob:https://xxx/uuid 没有扩展名
const ext = extractExt('blob:https://example.com/uuid-12345')
// 不应该返回 'https' 或 'example' 这种 URL 片段
assert.match(ext, /^[a-z0-9]{2,5}$/)
assert.notEqual(ext, 'https')
assert.notEqual(ext, 'example')
// 应该是 'bin' (兜底)
assert.equal(ext, 'bin')
})
nodeTest('extractExt file 对象带 name', () => {
const fileObj = { name: 'report.pdf', size: 1024 }
assert.equal(extractExt(fileObj), 'pdf')
assert.equal(extractExt({ name: 'image.HEIC' }), 'heic') // 大写也接受
})
nodeTest('extractExt 极端情况', () => {
assert.equal(extractExt(null), 'bin')
assert.equal(extractExt(undefined), 'bin')
assert.equal(extractExt(''), 'bin')
assert.equal(extractExt(123), 'bin') // 非字符串
})
// === 测试 useSts 决策逻辑 ===
// 复制自 uploadImage 末尾 (行为必须和源文件保持同步)
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024
function decideSts(detectedSize, fileSizeFinal, totallyUnknown, strategy) {
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
return finalUseSts
}
nodeTest('decideSts: oss_sts 永远走 STS', () => {
assert.equal(decideSts(100, 100, false, 'oss_sts'), true)
assert.equal(decideSts(0, 0, true, 'oss_sts'), true)
})
nodeTest('decideSts: backend 永不 STS', () => {
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'backend'), false)
})
nodeTest('decideSts: auto + 小图走 PHP', () => {
assert.equal(decideSts(500 * 1024, 500 * 1024, false, 'auto'), false)
})
nodeTest('decideSts: auto + 大图走 STS', () => {
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'auto'), true)
})
nodeTest('decideSts: auto + 探测完全失败兜底走 STS (防 OOM 关键 case)', () => {
// 修复前: fileSizeFinal=0 → useSts=false → 走 PHP 中转 → 大图 OOM
// 修复后: totallyUnknown=true → finalUseSts=true → 走 STS 直传
assert.equal(decideSts(0, 0, true, 'auto'), true)
})
console.log('✓ 所有 PR-FRONTEND-MEDIA-UPLOAD-FIX 单测通过')