Compare commits

...

1217 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  安全约束: 不涉及 nginx 层防盗链 / 限速
2026-09-16 11:42:18 +08:00
3f29116345 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-16 11:15:23 +08:00
3f99a1b9e4 chore:AI回答显示思考... 2026-09-16 11:14:53 +08:00
7a5de9a44f chore:微信小程序也已显示思考过程 2026-09-16 08:44:19 +08:00
b4a8415a38 chore:AI智能回答没有气泡 2026-09-15 16:52:43 +08:00
e2573cbaef chore:商品分类模板2的二级分类下拉按钮删除白色背景 2026-09-15 16:25:38 +08:00
ca772295ba chore: 商品分类模板1/2/3/4/99显示一级分类下全部二级分类商品 2026-09-15 11:11:47 +08:00
0925d28eb0 chore:后台商品分类模板可以显示全部商品 2026-09-15 10:51:50 +08:00
782a26f446 chore:AI智能显示思考过程 2026-09-14 17:49:30 +08:00
278645230a chore:回答行距变小,加上气泡,隐藏气泡下面的"..." 2026-09-14 08:57:45 +08:00
21ee661cef chore:AI智能回答特殊文字有特殊显示 2026-09-12 14:43:57 +08:00
3fffab81c8 chore:可以复制回答 2026-09-12 11:03:34 +08:00
6993582592 chore:AI智能客服改成AI智能 2026-09-12 09:25:30 +08:00
429487b1d1 chore:微信小程序真机智能客服正常 2026-09-12 09:20:51 +08:00
d15724dd2f chore:用户气泡样式 2026-09-11 17:34:43 +08:00
4cb34bf28e chore:用户可以自定义选择复制的文字 2026-09-11 17:14:42 +08:00
2daaa258a0 chore:AI智能可以复制语句 2026-09-11 14:43:23 +08:00
3892fb517f chore:隐藏智能体的任务栏 2026-09-11 10:10:52 +08:00
659308e116 chore:任务栏的图标小程序正常 2026-09-11 09:29:56 +08:00
bc127a45e8 chore:输入框样式 2026-09-11 09:15:12 +08:00
596e7cdb03 chore:输入框的更多功能加上关闭按钮 2026-09-10 17:20:10 +08:00
f09fffb7ad chore:点进智能AI页面,输入框移动到底部 2026-09-10 16:43:51 +08:00
a01587fc68 chore:修改智能AI页面 2026-09-10 16:36:25 +08:00
04cd90078b chore:后台开启商品分类模板一的三级分类按钮,如果切换一级标题时三级分类标题没有商品,就不要显示没有商品的三级标题, 2026-09-10 11:50:38 +08:00
45015cc781 chore:后台开启商品分类模板一的三级分类按钮,运行出来后图片中红色框中分类标题面的白色背景删除 2026-09-10 11:39:39 +08:00
fce62e988f chore: 2026-09-10 10:03:12 +08:00
c5f91be93b chore: 2026-09-09 17:56:54 +08:00
d0960da1d8 chore:商品分类模板1的以及分类和右侧商品上面名称栏对齐 2026-09-09 09:53:58 +08:00
8443cadba9 chore:商品分类模板2,4加购按钮显示正常 2026-09-07 16:52:27 +08:00
9739eea32a chore: 2026-09-07 15:55:24 +08:00
90c77096aa chore:商品分类模板1,2,3,4,99全部正常 2026-09-07 14:56:32 +08:00
1deb805d29 chore:商品分类样式1,2可以切换视图 2026-09-07 12:00:44 +08:00
b079442cfa chore:商品分类的1,2,3,4,99视图可以切换 2026-09-07 11:09:28 +08:00
88a0153f90 chore:商品分类的划线价在价格下面 2026-09-07 10:24:40 +08:00
50cd3081e6 chore:加购商品时购物车出现在页面下面 2026-09-07 09:56:03 +08:00
f37633525f chore:商品分类样式2 点击加购商品时去结算会出现 2026-09-05 18:23:29 +08:00
1893f6d884 chore:商品分类样式2 2026-09-05 17:37:44 +08:00
661b33b6b6 chore:商品分类样式2信这个购物车功能 2026-09-05 17:19:11 +08:00
1d8bcf7d73 chore:微信小程序表单图片和后台一致 2026-09-05 11:54:29 +08:00
aaa4faeb57 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-05 10:55:36 +08:00
2288746182 chore:微信小程序和后台表单样式一致 2026-09-05 10:54:22 +08:00
56d6d17b6a refactor(diy-form.vue): 优化横幅裁剪逻辑,调整背景位置处理以适应uni-app H5编译器 2026-09-04 18:16:13 +08:00
53e885164d refactor(diy-form.vue): 优化横幅图片处理,支持裁剪比例和手动裁栏框配置 2026-09-04 17:38:09 +08:00
e82355cf9b chore: 2026-09-04 15:39:44 +08:00
16f2053927 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 14:42:29 +08:00
0fbff1004c refactor(ns-newform.vue): 增加小程序环境修复,优化CSS变量定义和使用 2026-09-04 14:41:04 +08:00
75690ba6cc Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 13:49:14 +08:00
0506e765a9 chore: 2026-09-04 13:48:44 +08:00
acab1971ab refactor(ns-newform.vue): 优化字段样式,支持字段级控件尺寸和样式动态设置 2026-09-04 13:48:00 +08:00
f8797b9529 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221 2026-09-04 09:24:54 +08:00
11cc4f6319 refactor(ns-newform.vue): 优化字段样式,支持组件独立的间距和内边距设置 2026-09-04 09:13:25 +08:00
fc5c7e682c refactor(ns-newform.vue): 优化必填星号样式,动态计算颜色和间距 2026-09-03 17:59:22 +08:00
b2211edeab refactor(formdata.vue): update default styles and config merging logic
- Adjusted the merging order of form configuration to prioritize frontend defaults when backend configurations are absent.
- Clarified comments regarding the purpose of new visual styles and their application.
- Modified the hideInnerBanner logic to ensure consistent behavior with the updated form styles.
2026-09-03 17:50:11 +08:00
cd1ba1fbe2 chore:后台页面管理编辑-平台入驻核心权益-新增Tab页,前端运行出来可以正常滑动,Tab页正常显示 2026-09-03 16:27:30 +08:00
d2ad7b4108 chore:后台选择电话留言模式下,购物车库存销量隐藏 2026-09-03 11:22:21 +08:00
b4c47fcc26 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	components-diy/diy-form.vue
2026-09-02 11:58:46 +08:00
b31c133d8e chore:表单修改边距 2026-09-02 11:44:19 +08:00
2787e73126 chore:优化表单风格逻辑,支持显式和隐式判定,确保兼容性 2026-09-02 11:14:52 +08:00
aafd888596 chore:表单横幅缩小 2026-09-02 10:51:46 +08:00
ff3c2a0d9b chore:97数字跳动的显示正常 2026-09-01 18:26:36 +08:00
e7ad9a3cb5 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-09-01 16:29:29 +08:00
fc2b77135b chore:更新本地配置注释以提高可读性 2026-09-01 16:29:18 +08:00
33f54eb63c chore:后台数字跳动可以自定义文字大小颜色 2026-09-01 15:56:30 +08:00
a026adaec7 chore:表单样式修改 2026-09-01 09:24:09 +08:00
278d8c94f9 chore:表单样式修改表单浮于图片上方 2026-09-01 08:54:26 +08:00
f66b70c087 chore:推荐码分享按钮上移 2026-08-31 15:40:14 +08:00
c1407a84bb chore:H5登录正常 2026-08-29 18:22:10 +08:00
6ffbae0f33 chore:表单弹窗正常 2026-08-29 17:44:26 +08:00
f6038575cc chore:表单弹窗正常 2026-08-29 16:04:20 +08:00
5cdafb6f7f chore:推荐码立即分享跳转页面正常 2026-08-28 17:49:56 +08:00
7ebbdaf996 chore:微信小程序的表单多选可以垂直对齐 2026-08-28 14:13:32 +08:00
ecb0789780 chore:表单样式更新 2026-08-28 11:58:23 +08:00
e693e4273c chore:表单样式更新 2026-08-28 10:22:24 +08:00
e3171ef7ad chore:H5正常 2026-08-26 18:13:00 +08:00
3ca1ca8cd7 chore:推荐码样式新增 2026-08-26 11:44:39 +08:00
fcbbb05afe chore:表单正常 2026-08-25 18:06:35 +08:00
dd94e53d81 chore:表单提交正常 2026-08-25 17:39:03 +08:00
b8ff67aaa3 chore:提交次数 2026-08-25 11:18:43 +08:00
40a6f739d0 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
2026-08-24 09:56:36 +08:00
58a5a7d535 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp59 2026-08-24 09:31:08 +08:00
0e8db6f821 chore:PDF名字可以自定义 2026-08-22 16:04:33 +08:00
e6a45f6b7d chore:PDF有显示了 2026-08-22 15:55:00 +08:00
1b7cc4b679 chore:打开PDF右边新加一个分享按钮 2026-08-22 13:59:06 +08:00
876f639bb5 chore:红色按钮不乱飘了 2026-08-20 18:26:59 +08:00
02c9fa602e chore:购物车按钮正常 2026-08-20 17:51:48 +08:00
b5b27039ee feat(form): add captcha and form behavior support
实现表单行为相关功能,新增验证码输入模块,支持表单登录校验、提交次数限制、表单开放状态判断,补充多语言文案,优化空状态展示逻辑
2026-08-20 15:55:41 +08:00
8901e302e4 style: 修复配置文件缩进格式不一致问题
统一调整配置文件内所有对象的缩进样式,移除多余的空格,修正代码格式对齐问题,提升代码可读性。
2026-08-17 14:43:54 +08:00
68bcd3ae7c Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797
# Conflicts:
#	.local.config.js
2026-08-17 14:41:25 +08:00
9f89172829 chore: 2026-08-17 08:48:14 +08:00
ddd1ec3056 chore(gitignore): 补充 Trae IDE 临时文件的忽略规则
新增匹配根目录下所有以 .trae_ 开头的临时文件和目录,完善git忽略配置
2026-08-15 17:32:16 +08:00
d305268cb8 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-08-15 17:29:52 +08:00
1efb866e41 style(config): 格式化本地配置文件的缩进与空格
1. 统一所有配置项的缩进风格为4空格
2. 修正多余的尾随和冗余空格
3. 添加新增的5g-1测试配置项
4. 调整注释掉的配置项格式
2026-08-15 17:29:01 +08:00
b8182a0f97 chore: 2026-08-15 14:31:01 +08:00
37463a173c chore:一级分类的不选中的样式后台可以自定义 2026-08-15 10:37:55 +08:00
422ca8cdb5 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797 2026-08-15 09:53:01 +08:00
f92b1fb5b7 chore: 2026-08-15 09:52:30 +08:00
a0043418c8 chore: 2026-08-15 09:46:12 +08:00
927f763d0a chore:后台可以自定义配置一级分类选中后的字体颜色和字体大小 2026-08-15 09:28:15 +08:00
69ad814985 Merge remote-tracking branch 'remotes/origin/dev/1.0' into PR-1-ai-agent-link 2026-08-15 09:27:58 +08:00
92599453c2 chore:竖版视频可以正常显示正常大小比例 2026-08-14 15:10:03 +08:00
5d7facc94d chore:竖版视频可以正常在容器里 2026-08-14 15:03:23 +08:00
f6eb1fad11 Merge branch 'dev/1.0' into PR-1-ai-agent-link 2026-08-14 14:03:49 +08:00
83b253bced chore:表单鸿蒙做更改 2026-08-14 11:21:05 +08:00
19cb332645 refactor(ai-page): 统一页面路由管理并修复AI智能体路径
1.  将硬编码的页面路径替换为PAGE_KEY_MAP常量统一管理
2.  修正AI智能体页面路径为/pages_tool/ai-agent/index
3.  优化客服跳转逻辑,避免重复注入tabBar隐藏标记
4.  为AI聊天页面添加自定义组件占位配置
2026-08-14 09:36:04 +08:00
e5406a27ba chore:苹果ios表单显示正常,商品详情的视频可以支持竖版横板视频 2026-08-14 09:09:07 +08:00
ffa170ff13 feat(ai): PR-7 前端 ai-service 按 agent_id 路由 endpoint
背景:
- 后端 aikefu 和 ai-agent 两个独立 addon (PR-6 v2.0)
- 前端 ai-chat 之前所有 API endpoint 都 hardcode 到 /api/kefu/* + /ws/aikefu
- 5 个 agent (智能客服默认 + 4 个 AI智能体) 实际上无法被前端独立路由

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

修复 + 调试:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

主要修复:

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

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

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

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

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

测试覆盖 (300+ 测试用例全部通过):
  - tests/member-cancel-flow.test.js (28 个测试, 9 个用户故事场景)
  - tests/redirectTo-relaunch-h5.test.js (11 个测试, 9 个跨平台场景)
  - tests/redirectTo-real.test.js (12 个)
  - tests/util.test.js (30 个)
  - 其他 8 个测试文件全部通过
2026-07-23 17:26:00 +08:00
c4675deb1e Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide6 2026-07-23 16:51:55 +08:00
c99d808051 chore:二级标题按钮白色背景和一级标题对齐 2026-07-23 16:51:00 +08:00
acbac773db fix(member): 修复退出登录后可返回旧页面的问题
1.  退出登录和注销流程兜底分支都改用reLaunch跳转登录页,清空页面栈防止返回旧会员页
2.  新增完整的单元测试覆盖logout和getCancelStatus的异常分支、调用顺序和跳转模式
2026-07-23 15:17:08 +08:00
b9cb6bf75b fix(member-cancel): 修复注销成功后仍能回退查看旧账号信息的问题
1.  注销申请成功后立即调用clearLoginState清除登录态,避免残留账号信息
2.  所有跳转均改用reLaunch模式清空页面栈,防止用户通过回退键回到历史页面
3.  新增完整的注销流程行为测试用例覆盖所有场景
2026-07-23 15:14:01 +08:00
14e0fb9969 merge: 将 fix/2026-07-20-errkey 合并到 dev/1.0
# Conflicts:
#	.local.config.js
#	pages_tool/ai-chat/ai-chat-message.vue
2026-07-23 14:11:27 +08:00
3d2a982718 chore(config): add test-12 environment config
新增户外鞋优惠券测试环境的配置项,同时注释掉原有的18-test测试配置
2026-07-23 11:49:34 +08:00
eefcc9fe57 feat: 新增鸿蒙元服务平台适配
适配鸿蒙小程序平台,包括导航栏胶囊适配、支付通道支持、登录授权逻辑,以及分享功能兼容
2026-07-23 11:34:03 +08:00
f51b601ecb feat(detail页): 新增鸿蒙小程序端购物车和关注按钮
补充鸿蒙小程序平台的专属操作按钮,包括购物车入口和收藏关注按钮,对齐其他平台的功能展示
2026-07-23 11:04:54 +08:00
70a01bfd2b fix: 修复多页面国际化文案调用路径错误
将多处误用的 common.前缀国际化调用改为顶层路径,同时新增语言调用错误的回归测试用例,防止后续再次出现类似的命名空间误用问题
2026-07-23 10:37:22 +08:00
94142268a0 refactor(i18n): 完成全量硬编码中文文案替换为多语言方案
1.  替换所有页面/组件中的硬编码中文提示文案为$lang调用,包括支付、帮助中心、公告等页面
2.  重构diy.js加载失败文案处理逻辑,支持参数化替换
3.  新增多语言混入逻辑,实现导航标题随语种动态切换
4.  重构支付组件初始化逻辑,优化平台适配代码
5.  添加多语言单元测试和硬编码扫描脚本
6.  更新语言包,新增缺失的翻译词条
2026-07-23 09:46:16 +08:00
4b0aa0ac40 i18n: 补充多语言文案并优化英文tabbar命名
1. 为支付模块新增未获取到支付信息的多语言文案
2. 为通用模块新增网络错误、视频号播放限制、提交/加载相关文案
3. 优化英文环境底部tabbar的命名为更自然的首字母大写形式
2026-07-23 09:07:32 +08:00
6ee4de94a2 fix: 修复多商户优惠券选择异常和空指针问题
1. 新增coupon_list数组类型校验避免渲染异常
2. 重构优惠券选择逻辑,使用merch_id隔离选中状态避免多商户污染
3. 增加orderCreateData.coupon的类型兜底处理,防止非对象/数组类型报错
4. 修复优惠券弹窗选中状态同步逻辑,保留用户已选优惠券
2026-07-23 08:45:01 +08:00
658396c9a3 chore:二级标题按钮和一级标题第一个对齐 2026-07-22 17:49:08 +08:00
0a6abd87ae build(mp-harmony): 添加components_diy和页面模块的构建配置
同时新增清理华为IDE内置node路径的脚本,以及空的权限检查文件
2026-07-22 16:48:46 +08:00
25fc8311f8 chore:H5和微信小程序的返回第一个一级标题时右侧高度正确更新。 2026-07-22 10:38:51 +08:00
d4a249d744 chore:99的微信小程序左侧一级标题的高度会根据字数自动调节,右侧二级标题的白色背景高度会随着左侧选中项的高度自动改变,保持两侧高度一致。 2026-07-22 10:28:57 +08:00
f7efbfd4b7 chore:现在左侧一级题的高度会根据字数自动调节,右侧二级标题的白色背景高度会随着左侧选中项的高度自动改变,保持两侧高度一致。 2026-07-21 18:28:29 +08:00
e9b96df758 chore:一级分类高度和二级分类高度一致 2026-07-21 17:36:35 +08:00
75f70138ed chore:解决key的问题 2026-07-21 15:40:27 +08:00
c9aa4c75ae chore:template==99左侧一级分类选中时的指示器是一个红色的垂直竖条 2026-07-21 12:00:08 +08:00
a1db706072 chore:一级标题选中时加粗字体 2026-07-21 11:55:48 +08:00
276120162a chore:二级分类按钮是一致的颜色 2026-07-21 11:45:12 +08:00
f523122889 chore:99的视图按钮单列商品正常显示立即咨询 2026-07-21 11:40:55 +08:00
cea0e36d5d chore:视图改变 2026-07-21 10:42:21 +08:00
0169f25ca5 chore:H5的二级分类按钮下框对齐 2026-07-21 10:31:46 +08:00
3939d15f2a style(diy-category-item): 调整不同端的分类按钮容器内边距
针对H5和非H5平台分别适配二级分类按钮的内边距样式,保证页面布局在各端显示一致
2026-07-21 10:30:41 +08:00
fd4edc177d chore(config): update local dev config and env entries
1. add local-18 artificial crystal dev environment config
2. update domain for 2482 low-altitude economy env
3. adjust default active env to 2821 and add local-18 comment option
2026-07-21 09:54:13 +08:00
f96fee60d3 chore: 更新本地配置并补全页面导航栏标题配置
1.  调整.local.config.js中的测试平台配置项,替换原有54号配置为18号并新增48号配置,同时修改默认启用的低空经济项目id为2482
2.  在pages.json的所有页面配置中统一添加navigationBarTitleText空配置项,修正部分页面的APP端导航样式适配
2026-07-21 09:52:51 +08:00
d57b69d7cb Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-07-21 09:43:31 +08:00
d875642d99 feat(diy-category): 新增模板1和模板4分类页面样式与功能
1. 新增模板4:顶部图片搜索框、左侧一级分类列表、底部购物车栏
2. 新增模板1:仿京东淘宝分类布局,支持三级分类分组展示商品
3. 优化模板1样式:调整分类栏、商品列表、标签样式
4. 修复模板切换时的搜索框重复渲染问题
5. 新增模板1、3、4的专属UI样式和交互逻辑
2026-07-21 09:41:25 +08:00
0bcb5e7f4a fix(common/js/diy.js): 修正错误处理中使用的错误key函数
将diyErrorKey替换为httpErrorKey来适配HTTP状态码错误处理逻辑
2026-07-21 09:33:48 +08:00
65013a35f0 chore: 2026-07-21 09:20:48 +08:00
31daebc83b Merge remote-tracking branch 'origin/dev/1.0' into yuanlaide6 2026-07-21 08:50:19 +08:00
b8894ea2dc chore: 2026-07-20 18:00:04 +08:00
ff130bbf15 chore:改了二级分类的下边框 2026-07-20 17:29:15 +08:00
3a88941828 build(vue.config): 修复生产构建门禁的node执行路径问题
将执行test-gate的命令从硬编码的"node"改为使用process.execPath,避免依赖PATH环境变量查找node二进制,解决HBuilderX等IDE下PATH无node条目导致的构建失败问题,同时打印当前使用的node版本
2026-07-20 15:38:15 +08:00
b8c813e43b build(vue.config): add production build test gate
新增生产构建门禁校验,在正式打包前执行测试脚本,覆盖所有会加载vue配置的构建入口,包括HBuilderX、vue-cli和uni-cli,拦截未通过测试的生产构建
2026-07-20 11:07:24 +08:00
73776841d8 fix: 修复鸿蒙元服务商品详情页空白及接口兼容问题
1. 移除view组件无效的scroll-y属性,避免鸿蒙JSVM静默不渲染导致页面空白
2. 删除冗余的商品分类页面路由配置
3. 为ref调用和接口请求添加try-catch与空值防御,避免极端场景下崩溃
4. 补充鸿蒙元服务接口调用的兼容性处理,确保请求正常发起
5. 调整loading隐藏时机和异常捕获逻辑,优化页面容错能力
2026-07-20 08:44:15 +08:00
cd2705e5b9 fix(test): 修复沙箱环境下tabbar测试的依赖和调试flag问题
1. 移除沙箱不支持的ESM import语句
2. 内联注入依赖的_toParamValueString函数
3. 将globalThis.__TABBAR_DEBUG__替换为global以穿透沙箱
4. 修复全局调试flag无法在沙箱内生效的问题
2026-07-18 18:31:00 +08:00
02b8966a91 fix(mp-harmony): 修正鸿蒙元服务的url编码逻辑,对齐微信小程序行为
更新了鸿蒙元服务的_safeEncodeForPlatform实现和相关测试:
1. 修正此前错误的"鸿蒙整体encode url"假设,实际鸿蒙行为和微信一致,按&=分隔解析query
2. JS侧不再完全不编码,而是仅对&=?#和空格做encode,保留中文/字母数字等
3. 更新所有相关测试用例和文档注释,同步修复微信平台的编码逻辑说明
2026-07-18 18:23:39 +08:00
2adb574dd5 refactor(util-navigate): 合并微信和鸿蒙平台的编码逻辑
将原有的mp-weixin和mp-harmony平台的编码判断合并,简化分支代码逻辑
2026-07-18 18:19:38 +08:00
5b7351dcd2 fix: 兼容鸿蒙元服务端小程序适配优化
1.  修复cart页面unload时调用不存在方法的报错
2.  适配鸿蒙themeColor绑定和$nextTick兼容问题
3.  给多处逻辑添加try/catch防止页面空白
4.  补全v-for循环的key属性
2026-07-18 17:54:05 +08:00
63e2cb2ff7 fix(diy-category): 修复鸿蒙平台下样式条件编译错误
修正了骨架树项的样式宏定义拼写错误,确保鸿蒙平台能正确应用对应样式
2026-07-18 17:21:42 +08:00
d65f27b256 style(components): 优化多端兼容性适配细节
1. 补充loading-cover组件的兼容场景说明
2. 为鸿蒙元服务适配flex gap替代方案
2026-07-18 17:19:58 +08:00
ce7cbc75df feat(diy-category): add skeleton loading state for category page
优化分类页面加载体验,请求分类数据时显示骨架屏占位,避免页面先展示空态再切换内容造成的闪烁问题,同时调整了空状态的渲染逻辑,请求失败时也能正确显示空态占位
2026-07-18 17:14:02 +08:00
a6091f6b68 refactor(commodity-detail): optimize performance and fix UI flicker issues
1. add empty form tip for product message section
2. lazy load goods form to unblock initial page render
3. optimize category and tabbar rendering to fix flicker on WeChat mini-program
4. delay non-critical data processing to improve first-screen rendering speed
5. fix popup blank issue when opening product message form for the first time
2026-07-18 16:57:10 +08:00
9d6c3218fb refactor(http, diy): 抽象通用网络错误处理逻辑
1. 抽离通用http错误分类器到单独模块,实现错误处理复用
2. 统一多语言文案,将diy专属错误合并到通用http命名空间
3. 简化diy页面的错误处理代码,降低维护成本
2026-07-18 16:18:35 +08:00
71ea945743 style: 修复pages.json缩进与格式问题
调整pages.json中所有页面配置的缩进对齐,修正多处格式错误,统一代码风格,包括分包页面、全局配置和easycom规则的排版问题,修复了文件末尾换行缺失问题。
2026-07-18 16:04:40 +08:00
c8d92d8100 feat(diy): add in-page error prompt for diy data loading failure
1. add multi-language error copy for api request and network exceptions
2. add fixed top error banner component for index page
3. rewrite diy data request error handling: replace toast with reliable in-page prompt with retry button
4. add detailed error reason and request url display in error prompt
2026-07-18 15:58:40 +08:00
5831189e5e fix(diy-form): 修复鸿蒙元服务下value未定义导致的报错
针对鸿蒙元服务运行时首次渲染时value可能为undefined的情况,为所有模板表达式和计算属性添加空值守护,避免触发Cannot read properties of undefined错误
2026-07-18 14:51:42 +08:00
109cba6b6c style(vue.config): 给控制台日志添加前缀标识
统一修改vue.config.js里的console日志,添加[vue.config]前缀便于识别
2026-07-18 14:47:54 +08:00
f01a1d7efc build(vue.config): replace config.log with console.log
将vue配置文件中的自定义日志输出替换为标准console.log,移除了依赖的自定义日志工具调用
2026-07-18 14:42:09 +08:00
ae5b878822 build(vue.config): adjust terser console drop logic and add debug log
重构了鸿蒙平台保留console的判断逻辑,新增配置日志方便调试,统一了控制台语句移除的配置规则
2026-07-18 14:41:42 +08:00
7d4ecb9731 i18n: add missing diy page error prompt copy
补充中英文双语的自定义页面错误提示文案,包括弹窗标题和重试按钮文字
2026-07-18 14:26:11 +08:00
b5e9940934 fix(pages.json): 为首页页面配置新增components-diy分包
补充pages/index/index页面的依赖分包,将components-diy加入到依赖列表中
2026-07-18 12:01:16 +08:00
018616dd0e fix(common/js/event-safety): 修复dataset访问可能的空指针问题
使用可选链操作符避免target为null/undefined时访问dataset报错
2026-07-18 11:58:09 +08:00
74e53a9ca6 style(pages.json): 修复pages.json的缩进格式问题
修正pages.json中商品模块分包下category页面配置的缩进不一致问题,提升代码可读性
2026-07-18 11:52:36 +08:00
fdc0b54fbd fix(common/js/goods_detail_base): add format parameter to goodsform api request
为商品表单接口请求添加format: 'object'参数,指定返回数据格式为对象类型
2026-07-18 11:27:21 +08:00
8e31b9e327 chore:模板4的二级分类按钮正常 2026-07-18 11:26:27 +08:00
460dbcb447 chore:二级分类按钮显示正常 2026-07-18 11:15:58 +08:00
81ecd45de4 chore:二级分类按钮显示 2026-07-18 11:13:34 +08:00
059630ba2c refactor: 重构商品留言表单逻辑,替换为diy-form组件
1.  移除页面data中维护的留言表单数据,改为通过computed实时从商品详情数据构建diy-form所需格式
2.  替换ns-newform为diy-form组件,使用raw模式并接管表单提交逻辑
3.  完善平台别名映射,新增更多小程序平台兼容配置
4.  为diy-form新增占位符样式属性绑定
5.  重构商品表单接口处理逻辑,统一所有元数据字段的初始化和兼容处理
2026-07-18 10:55:39 +08:00
1ac0fb2484 refactor(goods-detail): 升级商品留言表单组件与接口适配
1.  将原有的同步调用改为异步处理handleGoodsSkuData和getGoodsForm方法
2.  重构getGoodsForm接口请求逻辑,兼容新旧版接口返回格式
3.  新增goods_form_config字段支持新表单组件样式配置
4.  替换旧版ns-form为ns-newform组件,完善空数据兜底处理
5.  优化商品留言初始化的可选链操作避免空指针报错
2026-07-18 10:34:37 +08:00
d8816bbe42 chore:还原到模板3二级分类按钮正常 2026-07-18 10:31:35 +08:00
b410597d9c chore 2026-07-18 10:15:27 +08:00
1143ef7b72 fix(util-navigate): 完善鸿蒙元服务编码逻辑注释与说明
更新了util-navigate和对应测试文件的注释,补充鸿蒙原生编码的细节,说明双重编码的问题以及&符号的处理逻辑,修正了注释里的重复表述
2026-07-18 09:24:10 +08:00
987e75c65a Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide3 2026-07-18 09:07:28 +08:00
ac8c461546 chore:二级分类按钮点击一会能切一会不能切 2026-07-18 08:58:52 +08:00
f3032a6004 fix(util-navigate): 修复多平台搜索关键词编码乱码问题
重构跨平台参数编码逻辑:
1. 微信小程序/鸿蒙元服务:仅处理编码边界符和decode roundtrip,不主动编码,交由平台原生处理
2. H5/华为快应用:保留主动编码逻辑,适配浏览器query解析规则
3. 新增专项测试用例覆盖搜索关键词跨端流转场景,修复商品分类页跳转列表页关键词显示乱码的bug
2026-07-18 08:34:43 +08:00
a4d8023e1f chore:二级分类按钮正常 2026-07-17 18:17:45 +08:00
bb0adb0542 style(ns-form, ns-newform): 新增占位符字体大小配置项
1. 为两个表单组件新增placeholderFontSize配置项与对应CSS变量
2. 将输入框占位符字体大小从复用label字号改为独立配置
3. 适配小程序/鸿蒙原生占位符样式规则
2026-07-17 16:14:25 +08:00
a4c218fecc chore: 2026-07-17 16:06:07 +08:00
0d820b04f7 chore:template == 4页面后台选择二级分类正常 2026-07-17 15:53:09 +08:00
70c9c49b94 refactor(form): 抽离平台检测逻辑到共用mixin,统一多端表单配置处理
1. 新增form-platform-mixin.js集中管理平台判断、别名兼容和配置键获取逻辑
2. 移除ns-form和ns-newform组件内重复的平台相关代码
3. 统一替换rpx单位处理逻辑为$util.rpx,简化样式计算代码
4. 移除冗余的_resolveFontSize工具方法,复用现有工具能力
2026-07-17 15:44:40 +08:00
6d1bdb5d8b chore:删除右侧上面的和一级标题一样的字 2026-07-17 15:07:19 +08:00
73b7b2a8df chore:template == 3的模板正常 2026-07-17 14:54:44 +08:00
b2063418fb chore:template == 1的模板正常 2026-07-17 14:01:40 +08:00
21c342e389 chore: 2026-07-17 11:56:44 +08:00
cbf91fa905 feat(util,navigate): add uniacid stale check for wap url
新增租户uniacid一致性校验逻辑,修复装修外链携带过期租户ID导致的跳转错误问题:
1. 新增pickCurrentUniacid和hasStaleUniacidInUrl工具函数
2. 在链接处理逻辑中校验wap_url内的uniacid与当前系统租户ID是否一致
3. 脏数据场景下强制走外链模式而非内部跳转
4. 补充完整的单元测试覆盖
2026-07-17 11:37:53 +08:00
61495b5e90 refactor(utils&form): 优化设备信息获取与表单组件样式配置
1.  为设备信息相关方法添加可选链操作符,避免空值报错
2.  重构getDeviceInfo和getWindowInfo的异常处理逻辑,简化代码
3.  重构ns-form表单组件,新增全局样式配置支持,兼容多端样式自定义
4.  优化表单布局样式、必填项和选中态颜色,修复鸿蒙元服务TextArea适配问题
5.  新增标签宽度自动计算功能,优化表单排版对齐效果
2026-07-17 10:43:49 +08:00
185d4914a6 chore:页面显示正常1模板 2026-07-17 10:21:40 +08:00
e3bbb7c77a chore:商品分类页面符合template==1 2026-07-17 09:58:25 +08:00
4044070867 fix(splash): 移除 onDiyGroupLoaded 多余 500ms 延迟避免 page-ready 兜底超时
diy-group 内部 _notifyLoaded 已有 300ms 渲染 settle, 叠加的 500ms 会吃掉
splash 2s page-ready 兜底预算, 导致弱网/冷启动下走 page-ready-timeout 兜底路径
(splash 在页面就绪前被强制关闭)。改为收到 loaded 事件后立即 notifyPageReady。
2026-07-16 17:20:46 +08:00
41b0d15570 chore: 切换默认启用的项目配置为低空经济
调整注释掉的配置项,将48切换为2482作为默认启用的项目配置
2026-07-16 17:09:30 +08:00
aafb75bad3 style: 调整多个页面的回到顶部按钮位置适配多端
为H5和小程序平台分别配置mescroll-totop的right和bottom样式,修复不同终端下按钮位置适配问题
2026-07-16 17:07:30 +08:00
6e19bc7aff chore: 注释新增低空经济项目配置
暂时注释掉2482号项目的配置代码,避免其被错误加载启用
2026-07-16 16:53:41 +08:00
94e852032b Merge remote-tracking branch 'remotes/origin/dev/1.0' into dev/1.0 2026-07-16 16:46:06 +08:00
5ccbc5d4f4 chore(config): add 18-test development environment configuration
Also add commented reference for the new 18-test environment
2026-07-16 16:45:59 +08:00
cf6d54e2bf fix(util): 修复内部页面绝对URL被误判为外链的问题
新增extractInternalAppPath和resolveNavMode工具函数,在diyRedirectTo中增加内部页面绝对URL还原逻辑:
1. 支持通过open_mode配置三种导航模式:auto/online/internal
2. 严格校验域名避免误伤外部站点,支持强制内部跳转场景
3. 修复H5整页刷新丢失状态、MP被塞进web-view的问题
2026-07-16 16:32:10 +08:00
19bcf64844 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into yuanlaide2 2026-07-16 16:20:53 +08:00
a27ae6cba0 Merge remote-tracking branch 'origin/dev/1.0' into yuanlaide2
# Conflicts:
#	.local.config.js
2026-07-16 16:17:26 +08:00
660ab27377 chore :按钮 = 全局所有有商品的二级分类(去重,一直显示),选中状态切换一级分类时保持
商品 = 切换一级分类时,用选中按钮的名称在当前一级分类下找同名 category,重新加载商品
2026-07-16 16:11:01 +08:00
d78a23602e chore:删除临时测试网址 2026-07-16 15:31:07 +08:00
7a6495fa33 chore:按钮 = 全局所有有商品的二级分类(去重,一直显示),选中状态在切换一级分类时保持
商品 = 切换一级分类时,用选中按钮的名称在当前一级分类下找同名 category,重新加载该 category 的商品
2026-07-16 14:31:14 +08:00
61e7a0d859 style: 调整所有页面的回到顶部按钮位置
统一修改多个页面的mescroll回到顶部按钮的right和bottom偏移值,优化移动端页面的按钮显示位置,避免遮挡页面内容
2026-07-16 14:23:11 +08:00
4116bfcbb0 feat(article detail): add back-to-top button and scroll mixin
add the to-top component and integrate scroll mixin to support scroll-to-top functionality
2026-07-16 14:10:52 +08:00
29e96a9357 docs(tabbar test): 补全测试文档与场景注释
重构并完善了TabBar高亮测试文件的注释文档:
1.  新增完整的测试说明文档,包含测试原则、核心逻辑、术语表、调试方法和场景索引
2.  为每个测试场景补充了对应的设计决策和业务规则注释
3.  新增边界测试踩坑记录和单元测试说明
4.  新增测试模板和调试指南,方便后续新增测试用例
2026-07-16 13:53:08 +08:00
88024cbd11 test(tabbar): add comprehensive test cases for tabbar highlight
新增多语言切换、登出状态、性能、极端URL、动态tab配置相关的高亮测试场景,覆盖业务边界与性能校验
2026-07-16 11:52:13 +08:00
55a9ed5946 refactor: 重构底部导航高亮逻辑,新增跨平台兼容与工具脚本
1. 抽离底部导航归属逻辑为纯函数resolveEntryTab,优化分包页面高亮规则,对齐iOS HIG规范
2. 新增页面Tab通配符映射,简化PAGE_TAB_MAP维护
3. 修复鸿蒙平台webview返回按钮适配问题
4. 新增pages.json校验与导航标题自动注入脚本
5. 优化启动页动画性能,提升合成渲染效率
6. 修复diy-picture组件v-for缺少key的警告
7. 清理鸿蒙平台多余的分包构建配置
8. 更新统计数据与store注释说明
2026-07-16 11:47:29 +08:00
165edc38ee fix(mp-weixin): 修复微信小程序url内&破坏query分隔的问题
1. 重写safeEncodeMP函数,修复微信小程序不二次编码导致的参数丢失bug:
   - 先解码已有的%xx编码避免双重编码
   - 主动执行一次encodeURIComponent,将&/?=空格等字符转为%xx格式
2. 新增跨平台通用跳转测试和工具单元测试,覆盖H5/微信/鸿蒙/快应用4个平台
3. 重构跳转逻辑,统一各平台的URL编码和跳转行为
4. 新增3个针对性测试用例,验证&符号防护、反引号编码、双重编码防御
2026-07-15 18:25:50 +08:00
b20ab5bf5d feat(H5/quickapp): add quick app pull refresh global mixin and utils
1. add quickapp-pull-refresh.js mixin to handle pull down refresh in quick app H5 environment
2. add notifyNativeHost and requestQuickAppReload util methods for cross-environment communication
3. conditionally register the mixin only for H5 platform
2026-07-15 17:55:27 +08:00
05d6ea1e96 feat(page): add pull-to-refresh and back-to-top component
1. enable pull-down refresh for the target page in pages.json
2. add scroll mixin and back-to-top component for form and formdata pages, show back-to-top button when scrolling down
2026-07-15 17:35:42 +08:00
4c58db0710 feat: add back-to-top component to multiple pages and import scroll mixin
add to-top component to card.vue, card_buy.vue, diy.vue and member index.vue, import scroll mixin for diy and member index pages to support scroll to top functionality
2026-07-15 17:13:45 +08:00
d7bc1165f7 refactor(quickapp): 优化页面稳定性与刷新逻辑
1.  为所有核心API调用增加try-catch兜底,避免异常导致页面崩溃
2.  添加刷新冷却机制防止重复触发重载
3.  移除onShow自动刷新逻辑,改为轻量日志处理
4.  完善CONFIG读取失败降级逻辑与日志输出
2026-07-15 17:07:29 +08:00
a32cedad9e feat(mp-quickapp): 配置统一域名与uniacid,优化缓存与页面刷新
1.  新增集中配置项管理BASE_URL和UNIACID
2.  动态生成带时间戳的加载URL与UA避免缓存
3.  后台切回前台时自动刷新web页面
4.  调整打包版本号与构建产物配置
2026-07-15 16:52:51 +08:00
7f2e5d3934 chore: template 99正常运行 2026-07-15 16:50:26 +08:00
616fac0312 refactor(common,webview): remove redundant console logs and simplify api call
清理了两处页面和工具类中的调试日志,同时简化了uni.setNavigationBarTitle的调用代码,移除了不必要的success/fail回调,只保留核心调用逻辑
2026-07-15 16:24:35 +08:00
2c1703acc5 fix(index page): add base url to version info modal
extend the version info popup to display the backend service base address
2026-07-15 15:58:40 +08:00
82f1659649 refactor(components-diy): simplify useScroll logic and add comment
将useScroll方法的判断逻辑简化为单行表达式,并补充注释说明该方法的作用
2026-07-15 15:39:08 +08:00
1c88bcf99b style(common/css): 删除冗余的H5端全局样式代码
移除了原先针对H5端的根容器撑满和自定义TabBar固定的冗余注释与样式代码,这些样式逻辑目前可能已被框架内置处理或不再需要,清理样式文件减少冗余代码。
2026-07-15 15:17:23 +08:00
793b988376 fix(splash-screen): 修复启动屏拦截首页触摸事件的问题
针对H5端和普通启动场景,分别处理触摸事件拦截问题:
1. 给启动屏组件添加pointer-events:none,让首页在渐隐期也能正常交互
2. 修改uni.showLoading的mask参数为false,避免原生蒙层遮挡触摸
2026-07-15 15:12:59 +08:00
313c283d9b fix: 适配华为快应用WebView场景并修复tabbar定位问题
1. 新增华为快应用WebView环境检测工具函数
2. 文章组件在快应用WebView中强制关闭滚动模式
3. 修复全局tabbar在H5/快应用中的定位漂移问题,加固fixed定位样式
4. 补全页面配置中的导航栏标题字段
2026-07-15 15:00:42 +08:00
b10bf472e3 feat(webview): add webview title auto update and debug logs
1. add @titleUpdate event listener for web-view component
2. decode uri component for title and enableSetTitle params
3. refactor updateNavbarTitle to accept dynamic new title
4. add debug console logs for tracking title update flow
5. add success/fail callbacks for navigation bar title setting
2026-07-15 11:55:52 +08:00
1814fffc12 refactor(util-navigate): simplify platform detection logic
1. remove redundant try-catch wrapper for compile-time macros
2. add quickapp runtime detection for H5 in quickapp webview
3. add optional chaining for uni.getSystemInfoSync call
4. add comment for default return case
2026-07-15 11:18:55 +08:00
13da4f1c9f fix(util): 修正系统导航栏列表工具函数的导入别名
调整了systemTabBarList工具函数的导入别名写法,同时将adaptSubpackageUrl加入到导出列表中保持API一致性
2026-07-15 11:08:35 +08:00
c8fada4b9d refactor(util): 拆分跳转工具集到独立文件
将原util.js中的跳转相关工具函数拆分为util-navigate.js和util-tabbar.js两个独立模块,重构原有代码以:
1.  避免循环依赖问题
2.  提升代码可维护性和复用性
3.  保留原有API兼容性,无需修改业务调用代码
4.  实现纯函数化设计,方便测试和tree-shaking
2026-07-15 11:00:49 +08:00
6c3f90bf96 feat(webview): add dynamic navigation bar title setting function
新增webview页面根据传入参数动态设置导航栏标题的功能,支持通过enable_set_title参数控制是否强制替换默认标题,通过title参数传递自定义标题内容。
2026-07-15 10:38:31 +08:00
229c6aa969 feat(util): add URL sanitization utility and improve redirect handling
1. add cleanParamUrls recursive URL sanitization util
2. add URL cleanup to diyRedirectTo as final defense
3. refactor redirectTo to avoid duplicate cleanup
4. add comprehensive unit tests for all new functionality
2026-07-15 09:24:10 +08:00
00bf4bcfd6 feat(mp-share): add disabled share page list and hide share menu for specific pages
新增了禁用分享的页面配置列表,针对指定页面主动隐藏转发按钮,同时优化了分享逻辑的兜底和性能。
具体包括:
1. 增加_DISABLED_PAGES集合管理禁分享页面路由
2. 添加页面禁用检查和隐藏分享菜单工具方法
3. 在created和onLoad阶段自动隐藏对应页面的转发按钮
4. 优化onShow和分享钩子的禁用逻辑,避免无效请求和调用
2026-07-15 09:05:50 +08:00
cb5878ad29 test: add unit test for mp-share-default module
complete all test cases for the disabled pages share logic, including blacklist check, route judgment, share api behavior and network call control
2026-07-15 09:02:54 +08:00
d213c5a488 build(scripts): add test gate and npm test scripts
1. 新增测试门禁脚本test-gate.js,统一管理tests目录下的测试用例执行
2. 在package.json中添加test、test:util、prebuild、prestart脚本,将测试环节前置到构建和启动流程
3. 重构normalizeUrl函数,新增更严格的URL规范化逻辑并补充完整注释
4. 优化代码格式,修正多处对象字面量的空格排版
5. 新增util.js的单元测试文件,覆盖核心工具函数的测试场景
2026-07-15 08:57:52 +08:00
3ac8162d9c fix(util): 修复子包URL连续斜杠过多的问题
新增逻辑将路径中3个及以上连续斜杠收敛为2个,避免破坏scheme部分,修正不规范的URL路径格式
2026-07-14 17:58:02 +08:00
873e1efe73 fix(util&scss): 修复快应用内嵌H5滚动问题并优化URL参数处理
1. 为body添加滚动样式适配快应用webview视口限制
2. 调整URL参数合并逻辑,确保page_params始终置于URL末尾
2026-07-14 17:22:57 +08:00
c1289cd6b1 fix(util): 优化跳转参数的空值处理逻辑
将原有的可选链空合并赋值改为条件式展开参数,避免传入无效的空值参数
2026-07-14 17:08:12 +08:00
b7cfa61a34 fix(util): 调整参数合并顺序并修复透传参数问题
1. 调整appendQueryParams的参数合并顺序,将page_params放在最后
2. 修复redirectTo调用时的参数透传问题,正确保留title和enable_set_title参数
3. 添加redirectTo跳转时的调试日志
2026-07-14 17:04:18 +08:00
52f952a6df style(common/css): 修复快应用内嵌H5根容器不撑满问题
针对快应用内嵌H5场景下webview viewport高度不可控导致根容器无法铺满整屏的问题,添加H5端显式撑满容器的样式兜底,同时保留微信小程序/鸿蒙元服务的原有编译逻辑
2026-07-14 16:30:31 +08:00
fa082f59c1 fix: 更新快应用项目配置与接口地址
1.  修改userAgent的uniacid值从1116为2482
2.  将导航栏调用方法从hjiTitleBar改为setTitleBar
3.  更新默认加载URL为新的aigc-quickapp地址
4.  修正编译产物中的路径与版本号信息
2026-07-14 16:27:31 +08:00
d8785d9d41 fix(diy.vue): 修复底部导航开启时页面最小高度异常问题
当开启底部导航时,原本没有设置默认最小高度,现在补充完整100vh的默认值,避免页面内容过少时布局异常
2026-07-14 16:09:18 +08:00
0312d00642 chore: add 2821 config for silicone oil paper project
add new project config entry 2821 and comment out its usage for now
2026-07-14 10:37:00 +08:00
39d4264389 chore:增加新功能只在template == 99里生效 2026-07-14 09:59:44 +08:00
ef407d6008 docs(TODO.md): 标记鸿蒙元服务相关的6个BUG为已修复
将之前记录的鸿蒙元服务环境下的组件和页面问题全部标记为已完成修复
2026-07-14 09:38:49 +08:00
aac44de359 style(article detail page): adjust page height to min-h-screen
将文章详情页的固定高度改为视口最小高度,适配不同屏幕内容高度场景
2026-07-14 09:38:35 +08:00
b038175f07 Merge branch 'gaibao-2821' into dev/1.0 2026-07-14 08:47:48 +08:00
2708ac55d1 fix(util): 修复小程序跳转时未拼接参数的问题
修复uni.navigateToMiniProgram调用时未将page_params参数拼接进跳转path的问题,正确处理了参数过滤和url拼接逻辑
2026-07-13 18:22:22 +08:00
3d822ed641 fix(util): 补全wap_url公共参数并透传page_params
将page_params合并到公共参数中,一并追加到跳转链接里
2026-07-13 18:20:51 +08:00
f18577a46c chore:切换左侧一级分类时,右侧会自动选中第一个二级分类按钮(如"离型纸")
如果用户之前点击过某个二级分类(如"离型膜"),切换一级分类后会自动选中同名的二级分类
2026-07-13 18:05:36 +08:00
9915fb9bb1 chore:切换左侧一级分类时,右侧默认选中第一个按钮 2026-07-13 18:00:48 +08:00
bb34e32f87 chore:删除按钮上方的“什么行业” 2026-07-13 17:26:58 +08:00
45e997adae chore:实现左侧一级分类点击哪个行业,右面就只显示这个行业,不显示其他的行业 2026-07-13 17:21:14 +08:00
2e4f11d108 chore:增加分类按钮,把商品列表的商品放在按钮下面 2026-07-13 17:14:15 +08:00
e88dc2b205 docs(mp-share-default): 修正注释里的预处理宏描述
将原来的具体代码注释调整为更通用的"MP-WEIXIN 预处理宏"表述,让注释更清晰易懂
2026-07-13 16:30:10 +08:00
83fc02a279 refactor(diy-goods-list): 统一商品图片mode适配鸿蒙元服务
修复鸿蒙元服务环境下商品图片显示裁剪/不全的问题,根据模板和平台动态适配图片mode模式
2026-07-13 16:22:24 +08:00
edc077eef7 docs: 更新TODO清单,补充鸿蒙元服务等环境的bug
新增多个鸿蒙元服务环境的组件显示异常bug,以及微信小程序的控制台报错bug,更新已修复的bug项格式
2026-07-13 16:08:53 +08:00
30cda71749 refactor(http,lang): 重构请求逻辑并新增多语言错误提示
1.  新增网络相关错误的多语言翻译配置
2.  重写http请求层,添加自动重试机制与错误分类提示
3.  优化鸿蒙平台下的组件渲染兼容性与数据更新逻辑
4.  调整部分页面的模板结构与请求配置
2026-07-13 15:45:15 +08:00
cb9234bdac refactor(diy-video-list): 优化弹窗视频适配逻辑
1. 重构视频比例计算逻辑,改为通过实际宽高计算适配
2. 新增多端适配:H5、微信小程序、鸿蒙元服务
3. 添加视频加载错误捕获与日志输出
4. 调整视频容器样式,统一背景与溢出处理
5. 移除旧的比例状态变量,改用宽高状态存储
2026-07-13 14:41:29 +08:00
10bc06f212 refactor(components/diy-video): 重构视频比例计算逻辑
替换原有的固定比例方案,改为通过视频实际宽高动态计算适配比例,移除冗余的harmonyVideoHeight计算方法,统一多端样式处理逻辑,优化鸿蒙端视频高度计算方式
2026-07-13 14:24:09 +08:00
c739d691a2 fix(diy-video): 适配鸿蒙元服务端视频组件展示
1. 新增鸿蒙平台专属video组件渲染分支
2. 新增窗口宽度初始化逻辑用于计算鸿蒙视频高度
3. 针对不同平台调整视频容器样式计算逻辑
4. 补充视频加载元数据和错误回调
5. 修复微信小程序视频比例适配逻辑
2026-07-13 14:11:14 +08:00
0ca64b7dcf chore: 2026-07-13 13:52:30 +08:00
88921e61d2 fix(splash-screen): 修复H5环境下启动屏关闭后页面空白问题
问题根源分析:
1. diy-group 的 mounted() 在组件挂载时就触发了,但此时 diyData 还没有数据
2.
otifyMounted() 在组件 mount 时立即关闭了 H5 的 uni.showLoading(),但页面内容还没就绪
3. 导致启动屏关闭后,首页内容还是空白的

修复方案:
1. 在 diy-group.vue 中,将 loaded 事件的触发时机改为 diyData watch 中,确保数据加载完成后再通知
2. 在 splash-state.js 中,
otifyMounted() 不再立即关闭 H5 原生 loading,等待页面就绪后由 _doHide() 统一关闭
3. 在 index.js 中,onDiyGroupLoaded() 延迟 500ms 再通知页面就绪,确保内容渲染完成
2026-07-13 10:54:17 +08:00
6b8b4acc96 fix(splash-state): 修复页面就绪后无法关闭启动屏的问题
问题根源: _flushPendingHide() 中过早清除了 _pendingHide,导致页面就绪后 notifyPageReady() 检测不到 pending hide 请求,启动屏无法关闭。

修复方案: 当页面未就绪时,保留 _pendingHide 直到页面真正就绪后再清除。
2026-07-13 10:43:47 +08:00
edea8c32de fix(splash-screen): 启动屏等待首页内容就绪后再消失
问题描述: 首次启动时,启动屏已关闭但首页内容还是空白的,用户体验差。

解决方案:
1. 在 splash-state.js 中新增页面就绪等待机制 (_pageReady 标志、notifyPageReady 方法)
2. 修改软隐藏逻辑,必须同时满足组件已 mount 和页面已就绪才关闭启动屏
3. 在首页 onDiyGroupLoaded 回调中通知页面就绪

兜底保护:
- 组件 1s 未 mount → 强制关闭
- 页面 2s 未就绪 → 强制关闭
- 总超时 8s → 强制关闭
2026-07-13 10:36:04 +08:00
3f09dca054 refactor(splash-screen): 移除废弃的启动屏配置系统
简化启动屏组件代码,删除不再使用的splashConfig全局配置、相关工具方法和冗余的布局逻辑,统一进度条展示样式。
2026-07-13 10:22:45 +08:00
aa15518980 feat(splash-screen): add configurable splash screen display options
add splashConfig in config-external.js to control progress/label/percent display,
add runtime config api in splash-state.js,
refactor splash-screen.vue to support inline layout and prop override
2026-07-13 09:59:18 +08:00
8597504b57 chore: 添加.trae到gitignore忽略列表
将.trae目录加入gitignore,避免将本地临时目录提交到版本库
2026-07-13 09:15:26 +08:00
a913961be5 feat: 实现隐私弹窗全链路功能,支持本地配置与远程patch融合
- 新增隐私管理器核心逻辑,支持URL路由规则匹配、远程patch签名校验与融合
- 实现启动期资源等待机制,确保弹窗在i18n、主题配置就绪后触发
- 新增启动进度条组件,展示启动阶段进度与状态
- 重构隐私弹窗组件生命周期,修复事件丢失问题并支持规则动态重评估
- 适配多端平台隐私检查逻辑,支持微信小程序、鸿蒙元服务与H5
- 支持uniacid租户隔离的持久化规则存储
2026-07-11 18:28:36 +08:00
a2066285cd feat(mp-weixin): 新增全局兜底分享mixin与路径复制能力
为微信小程序端添加全局分享兜底支持,为所有未显式声明分享钩子的页面提供默认转发和朋友圈分享能力,同时新增小程序路径复制工具方法。
2026-07-11 15:19:26 +08:00
397589c4e6 fix(uni-config): 调整页面配置和权限声明
1. 调整recharge/list页面的disableScroll配置位置,全平台生效
2. 修正剪贴板权限名称和描述,移除冗余的剪贴板权限声明
2026-07-11 14:43:27 +08:00
c3cb420bf5 style(pages.json): 格式化页面配置缩进与注释格式
统一调整会员充值相关页面配置的缩进层级,修正冗余的scrollEnabled注释逻辑,保持跨端配置格式一致
2026-07-11 14:10:32 +08:00
bec47d1860 fix: 调整小程序权限配置与页面滚动配置
1. 更新剪贴板权限名称为userClipboardWrite并新增clipboard到隐私信息列表
2. 兼容微信小程序移除scrollEnabled,改用disableScroll保持滚动行为一致
2026-07-11 13:51:46 +08:00
117cbccbd7 chore: 更新配置项与补充项目待办清单
- 修改站点uniacid为2482
- 补充微信小程序相关BUG和优化项到TODO文档
2026-07-11 10:33:49 +08:00
7a76824fcf fix(diy-article, diy-image-nav): 修复H5端拖拽后误触发跳转的问题
1.  重构两个组件的H5端鼠标事件绑定逻辑,改用document级别capture监听解决uni-app自定义元素事件拦截问题
2.  添加拖拽阈值检测,避免点击误触发滚动
3.  新增click拦截逻辑,拖拽结束后自动屏蔽后续一次click事件防止误跳转
4.  优化滚动容器获取方式,兼容uni-app编译后的嵌套DOM结构
2026-07-11 10:03:23 +08:00
75bc8876b5 feat(components-diy): 为两个组件添加H5端鼠标拖拽滚动支持
1.  给diy-image-nav组件新增singleSlide模式下H5端鼠标拖拽和滚轮横向滚动功能,修复uni-app scroll-view在H5端不支持鼠标拖拽的问题,同时添加对应样式优化
2.  给diy-article组件的跑马灯列表新增H5端鼠标拖拽和滚轮滚动支持,复用原有触摸事件逻辑适配鼠标操作,优化拖拽交互体验
3.  统一采用mounted绑定H5专属事件的方式,避免小程序/鸿蒙端出现未知事件编译警告
2026-07-11 08:26:14 +08:00
203c47e437 fix(diy-listmenu): add click event to menu view button
为列表菜单的查看按钮添加点击跳转事件,修复无法跳转对应页面的问题
2026-07-10 18:20:32 +08:00
6eaebc7d75 chore(diy-image-nav): add component identity data attribute
给自定义图片导航组件添加data-component-name标识属性,用于识别组件身份
2026-07-10 18:18:21 +08:00
575145eae9 refactor(pages.json&scripts): 优化自定义组件占位符注入逻辑
1.  为商品详情页添加所有diy-*组件的占位符配置
2.  重构占位符注入脚本,实现组件使用的递归检测
3.  移除原有的简单字符串匹配,改用导入链追踪准确判断组件使用情况
2026-07-10 17:45:38 +08:00
81e85c804a chore(mp-harmony): 更新gitignore和构建配置
1. 修正gitignore中的鸿蒙元服务构建产物路径
2. 新增components_diy、pages相关模块的构建配置
2026-07-10 17:34:50 +08:00
6178f699ed feat(scripts): add harmony component placeholder injection logic
新增鸿蒙组件级componentPlaceholder自动注入脚本,解决diy组件编译为asyncComponent导致的运行时报错,自动遍历处理非页面组件的json配置文件,同时添加异常捕获避免构建中断
2026-07-10 17:28:52 +08:00
2276291a4b build(mp-harmony): 添加目标SDK版本配置
补充鸿蒙项目的targetSdkVersion配置为5.0.0(12)
2026-07-10 16:52:42 +08:00
acbb89997b chore(config): update local dev config and add multiple project configs
1. 修改local-1的domain为局域网地址并注释原本地地址
2. 新增5g-213、5g-4、5g-97、5g-50、5g-237、2485多个项目配置
3. 调整默认激活的环境配置为2482,并注释其他测试配置
2026-07-10 16:44:13 +08:00
e04f5aabc0 build(mp-harmony): 移除多余的targetSdkVersion配置
删除build-profile.json5中冗余的targetSdkVersion字段,清理构建配置文件
2026-07-10 16:43:02 +08:00
013eef46fb refactor(common,components): 优化底部导航栏显隐逻辑
1. 新增isCurrentPageTabBarRoot工具方法封装页面判断逻辑
2. 调整弹窗打开时的导航栏隐藏时机,仅在非TabBar根页面执行
3. 移除弹窗open回调中的导航栏显示逻辑,改为在关闭弹窗时显式处理
4. 补充注释说明回调不可靠的问题和根页面不隐藏导航的原因
2026-07-10 15:46:48 +08:00
d9c6e41ebe fix(privacy): 新增表单页跳过隐私弹窗的匹配路径
补充了/pages_tool/form/form路径到免隐私弹窗的H5匹配列表中,避免外链/扫码访问表单详情时弹窗挡住页面
2026-07-10 14:16:49 +08:00
e3e63899d8 feat(privacy): add markSkipped method and H5 skip logic for privacy popup
add markSkipped method to synchronously skip privacy popup, and call it in App.vue created hook for H5 environment when current URL matches skip condition
2026-07-10 14:11:51 +08:00
9d2b76687c feat(privacy): add url-based skip rule for H5 privacy popup
add ability to skip privacy popup when visiting specific H5 URLs directly,
update PrivacyManager.show to accept skip option,
add skip logic for specific form page to avoid blocking UI
2026-07-10 14:01:41 +08:00
679c53c501 refactor(util): 统一 URL 参数序列化与解析逻辑
1. 新增_toParamValueString处理复杂参数序列化
2. 新增parsePageParams作为page_params统一解析工具
3. 重构多处参数处理逻辑,避免重复代码和对象转字符串异常
4. 统一处理对象/数组参数的JSON序列化,避免出现[object Object]
2026-07-10 13:56:37 +08:00
a90245b201 fix: 适配多平台url编码规则,修复双重编码问题
针对H5和小程序/App平台分别处理url参数编码:
1. H5平台需要手动encodeURIComponent避免参数解析错误
2. 小程序/App平台由uni内部自动编码,避免双重编码导致参数解码异常
同时重构了工具类中的编码逻辑,统一多场景下的平台适配规则
2026-07-10 11:29:16 +08:00
897a8157e7 fix(util): 修复url参数编码逻辑
移除了冗余的try-catch编码逻辑,直接返回原始字符串

直接返回原始字符串,简化了url参数编码的处理流程,避免了不必要的编解码操作
2026-07-10 10:47:48 +08:00
e8db412595 refactor(index): 调整版本水印相关逻辑与样式
1. 调整版本连击触发条件为3次以上而非≥3次
2. 优化版本水印的DOM结构和触发绑定方式
3. 简化背景内联样式并调整水印内边距
4. 修改版本水印的颜色和阴影样式
2026-07-10 10:12:04 +08:00
08e4d866f5 feat: add version watermark and optimize title tap logic
1. 为diy-group组件添加loaded事件通知渲染完成
2. 调整构建时间为北京时间并格式化展示
3. 新增版本水印组件,在diy渲染完成后显示
4. 将标题连击触发版本信息的次数从5次改为3次
5. 移除旧的隐形触发区域,将触发逻辑绑定到水印
2026-07-10 09:47:17 +08:00
d4794a7be4 fix(lang): 修复路径含连字符时无法匹配bundle的问题
新增尝试原始段名的匹配逻辑,同时支持camelCase、snake_case和原始段名三种命名方式,覆盖更多bundle命名场景
2026-07-09 16:26:02 +08:00
d0a3976cda fix(goods search): 修复搜索框点击区域问题
将搜索触发事件从整个搜索盒移动到搜索图标上,避免点击输入框时误触发搜索
2026-07-09 15:51:32 +08:00
94be20d499 refactor: 优化项目配置与代码冗余清理
1. 新增components-diy占位组件与注入脚本,按需注入分包占位配置
2. 开启postcss编译优化
3. 移除多处重复的图标字体全局样式导入
2026-07-09 15:13:06 +08:00
a7dd0b302e refactor(mp-harmony): 简化店铺卡片逻辑,统一使用应用名作为卡片名称
1. 移除ShopInfo接口和默认数据中的shopName字段,不再返回店铺名称
2. 重构WidgetCard页面布局,移除营业状态显示和底部快捷按钮
3. 重构EntryFormAbility,仅保留2x2店铺卡片,使用应用资源名作为卡片名称
4. 注释暂时保留的店铺名称解析逻辑,后续需要时可重新启用
2026-07-08 15:50:46 +08:00
fd4fd61399 feat: 新增多尺寸卡片组件与配套服务
新增2*4商品横滑卡片、4*4活动促销卡片,重构基础店铺卡片UI;添加商品、活动数据拉取缓存服务,修复TextDecoder兼容问题,优化API拼接逻辑,支持多卡片尺寸适配
2026-07-08 11:48:31 +08:00
7808c63a79 feat(harmony): 实现鸿蒙卡片的店铺信息展示功能
1. 新增harmony.patch.js脚本,将根目录site.js同步到鸿蒙项目rawfile目录,供FormAbility读取配置
2. 新增ApiConfig.ets,实现从rawfile读取站点配置并兜底降级
3. 新增ShopInfoService.ets,封装店铺信息缓存、API请求和数据解析逻辑
4. 重构EntryFormAbility.ets,添加卡片异步更新和数据渲染逻辑
5. 重构WidgetCard.ets,实现带店铺logo、名称和营业状态的卡片UI
2026-07-07 18:16:18 +08:00
82fd3839a9 feat: 新增remote-only主题模式,优化首屏包体积
1. 新增theme-override.js实现主题远端拉取逻辑
2. 新增empty-style-stub.js作为主题包占位符,减少主包~75KB
3. 在vue.config.js中添加构建期主题stub替换逻辑
4. 重构config-external.js的主题配置,新增remote-only模式支持
5. 重构store的applyThemeWithRemote方法,适配远端主题拉取
6. 在bootstrap中新增主题初始化和异步拉取逻辑
2026-07-07 14:56:13 +08:00
b60ea984e5 fix: 修复分类商品项布局问题并添加窗口resize监听
1. 调整diy-category-item的price-wrap和name-wrap样式,修复布局偏移
2. 移除冗余的主题变量定义,修正flex布局参数为正确的1
3. 添加窗口尺寸变化监听,实现主题色动态重算
4. 更新语言审计报告的代码调用统计数值
2026-07-07 11:15:07 +08:00
3713cd2cf6 refactor(DIY分类页): 优化商品卡样式与H5主题适配
1.  修复H5端rpx单位CSS变量无法解析问题,添加rpx转px转换工具函数并更新缓存key
2.  给diy-category-item组件添加scoped样式,重构商品卡样式为CSS变量可配置形式
3.  补充多套预设的分类页商品卡样式token到style_color配置中
2026-07-07 10:56:59 +08:00
83a5f358e2 style(diy-category-item): 优化咨询按钮样式token支持度
扩展咨询按钮的可配置css属性,添加内边距、圆角、字重等独立token,便于后台全局调整按钮样式
2026-07-07 09:16:31 +08:00
bd1a9a67e0 style: 重构分类组件样式,支持主题化配置字号和按钮样式
1. 为分类树和分类项的文字样式替换为CSS变量,支持远端主题配置
2. 重命名AI咨询按钮的类名和样式变量,统一命名规范
3. 为每个主题预设添加分类页专属的样式token,包含字号、按钮背景和文字颜色
2026-07-07 09:12:06 +08:00
224b409455 style: 调整模板缩进对齐并修复语法错误
统一修复了两个页面文件中的vue模板缩进格式,修正了多余的闭合标签问题,让代码排版更整洁易读
2026-07-06 17:51:47 +08:00
1c5cc62368 fix(util): 修复跳转链接参数合并与重复拼接问题
1. 重构resolveLinkUrl方法,合并wap_url自带query与page_params,且page_params优先级更高
2. 提取wap_url中的path部分,避免后续跳转重复附加query参数
3. 修复diyRedirectTo中重复解码wap_url和错误传递冗余参数的问题
2026-07-06 17:40:03 +08:00
aa7d967422 fix: 修复鸿蒙元服务TabBar跳转失效问题
1.  修复多处页面onTabItemTap事件,使用uni.reLaunch兜底鸿蒙下switchTab静默失败的问题
2.  全局混入onTabItemTap兜底未手动处理的页面
3.  优化路由跳转逻辑,针对鸿蒙环境增加switchTab失败兜底和超时强制跳转
4.  优化可选链调用和空值判断,提升代码健壮性
2026-07-06 16:48:38 +08:00
9815f09c1f style(diy-category): 给价格文字添加加粗和主题色样式
调整了money类的样式,统一价格文本的字体粗细和颜色变量使用
2026-07-06 15:35:01 +08:00
fc61a02211 style(index page): 优化版本触发按钮的样式与位置
调整按钮位置避开tab栏并避免和to-top组件冲突,增大点击面积提升触摸命中率,提升层级确保不被遮挡,恢复指针事件支持
2026-07-06 13:46:20 +08:00
aebe8b557f fix(diy-notice): 优化跳转链接的判断逻辑
将原判断链接长度的方式改为检查实际存在的跳转字段,更准确识别有效跳转链接
2026-07-06 11:58:30 +08:00
dd90d60a14 fix(diy-notice): 修复并优化公告组件交互与显示逻辑
1. 为横向滚动公告项添加点击跳转事件
2. 为上下滚动的swiper-item补充点击事件
3. 增加item.link存在性校验避免空指针
4. 点击无链接公告时优先显示content内容
2026-07-06 11:56:20 +08:00
960f4ebaaa feat(vr page): 优化VR页面导航体验,添加返回主页按钮并修复返回逻辑
1. 移除旧的自定义返回图标,改用cover-view兼容web-view层级问题
2. 新增主页导航按钮,支持一键返回首页
3. 完善返回逻辑,处理页面栈仅当前页的异常返回场景
4. 调整导航按钮样式,添加半透明背景和圆形布局
2026-07-06 11:55:18 +08:00
9dabcc2135 fix(privacy): 兼容鸿蒙元服务场景补全隐私弹窗逻辑
新增鸿蒙兜底逻辑,当PrivacyManager.show()未执行且用户未同意隐私权限时,触发补偿弹窗,避免用户无法看到隐私授权弹窗
2026-07-06 10:42:41 +08:00
337a9618ef style(diy-category): 调整搜索框和切换按钮的代码排版
在相关代码块前后添加空行优化代码可读性,不影响原有功能逻辑
2026-07-06 10:33:41 +08:00
f48b8f0e41 fix(member page): 调整取消和退出按钮的显示判断逻辑
将原有的严格等于1的判断改为允许值为1或undefined,适配更多配置场景
2026-07-04 18:23:25 +08:00
bb18dedc35 fix(contact page): add HarmonyOS mini-program support for contact section
extend the H5 conditional compile to also cover MP-HARMONY platform
2026-07-04 18:11:03 +08:00
55c4d507db feat(diy-category): 新增鸿蒙小程序端搜索框适配
扩展H5平台条件编译到H5和MP-HARMONY,让鸿蒙小程序也显示搜索框
2026-07-04 18:03:38 +08:00
014f111cd1 style(login.vue): remove redundant harmony platform conditional comments
clean up the mp-harmony specific code blocks that are no longer needed
2026-07-04 18:00:18 +08:00
181717a7ab chore(vue.config.js): 优化构建配置,新增鸿蒙元服务构建清理逻辑
1. 统一修复代码缩进格式,对齐代码风格
2. 新增构建平台控制台输出日志
3. 新增鸿蒙元服务构建前清理输出目录逻辑
4. 完善环境变量缺失的错误提示
5. 优化注释内容,补充说明用途
2026-07-04 17:28:25 +08:00
112c9a2aa5 fix(scripts): 修复路径不存在提示和打开目录延迟问题
1. 为路径不存在的情况添加专属警告日志
2. 给打开文件目录操作增加2秒延迟,适配部分系统文件操作节奏
2026-07-04 15:08:04 +08:00
6617a0b36c fix(harmony): 修鸿蒙元服务 JSVM 兼容性
基于 .logs/harmony-debug.log 排查,修复 3 类 runtime 错误:

1. isWeChatMiniProgram / cashier autoPay / ai-chat 复制文本
   - 鸿蒙 JSVM 没有 navigator 全局, navigator.userAgent 直接 ReferenceError
   - 加 typeof navigator === 'undefined' 守卫,Harmony 上返回 false,
     业务落到 isWeiXin()(已被 #ifdef H5 框住,Harmony 上也 false)→ 普通登录

2. diy-category getHeightArea
   - onShow 在 mounted 前触发,query 还是 undefined
   - query 重新尝试创建;createSelectorQuery 不存在则直接 return
   - 防止首次进入崩溃

3. goods-detail-view 模板 indexOf
   - goodsSkuDetail.support_trade_type 字段偶尔缺失
   - 改 (support_trade_type || []).indexOf(...) 兜底
2026-07-04 11:26:47 +08:00
c9c062edcb chore: 清理并整理项目配置与工作流文件
- 更新.gitignore,移除.hbuilderx、.claude和openspec相关配置
- 新增.hbuilderx/launch.json用于uni-app调试配置
- 新增完整的OpenSpec工作流命令与技能文件,实现标准化变更管理流程
2026-07-04 10:42:39 +08:00
f37baad045 feat(i18n): add full i18n audit toolchain and pre-commit hook
- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容
- 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交
- 编写完整的 i18n 审计脚本、文档与示例报告
- 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
2026-07-04 09:57:43 +08:00
ea504d8434 refactor(lang): 优化远程页面树的命名匹配逻辑
修复多语言路径同时支持camelCase和snake_case的匹配问题,为每个层级同时维护两种命名格式的镜像键,增强路径兼容性
2026-07-04 09:32:56 +08:00
eb46748ae0 style: 优化日志输出,补充目标目录信息,移除多余调试日志
- 为目录清空相关日志添加目标目录路径
- 移除冗余的plugins.length调试日志
2026-07-04 09:30:57 +08:00
2d7d7f731c fix(remote lang tree): 修复命名空间被基本类型值覆盖的bug
修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复:
1. 先将页面语言包按路径段转为命名空间结构再合并
2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
2026-07-04 09:25:01 +08:00
783c10fc62 refactor: 统一项目内国际化key的命名空间格式
本次提交对全项目的国际化调用进行了规范化重构:
1. 为中文/英文语言包新增了avatar和gender的别名映射,兼容旧有headImg/sex写法
2. 修正了lang.js中的注释路径错误
3. 将所有页面和组件内的$lang调用从短路径改为带业务模块前缀的完整命名空间路径,例如将$lang('title')改为$lang('article.list.title')
4. 优化了i18n-lazy.js的注释说明,补充了正确的key命名格式示例
5. 统一了分销、订单、会员等模块的国际化key层级,确保所有语言调用路径一致
2026-07-04 09:24:35 +08:00
92e8823566 fix: 修复多页面语言包key路径错误问题
统一修正所有页面中调用语言包时的路径命名,将短横线/下划线格式调整为正确的嵌套路径格式,确保语言文本可以正常加载
2026-07-03 17:02:43 +08:00
a27e3518da refactor(i18n): 扁平化所有国际化语言文件的翻译结构
移除多层嵌套的翻译对象,将所有翻译项直接挂载到顶层lang对象上,统一代码结构
2026-07-03 16:36:28 +08:00
e4c9b7fd59 fix: 统一处理表单提交数据的序列化逻辑
1.  修复多处表单提交时重复判断序列化的逻辑,统一处理当formData已经是字符串时不再重复序列化
2.  简化nameStyle样式计算的代码写法,移除不必要的objToStyle调用
2026-07-03 15:52:54 +08:00
94f3631421 refactor(diy-form): 简化表单提交数据处理逻辑
移除了多余的submitData对象转换步骤,直接使用getFormData的返回值作为form_data提交内容,同时删除了过时的注释说明
2026-07-03 15:06:33 +08:00
80d390886a fix(yuyue-date): 修复国际化语言key的命名不一致问题
将所有组件内的语言key从components.yuyueDate改为components.yuyue-date,统一命名格式,确保多语言配置可以正确加载对应文案。
2026-07-03 14:06:00 +08:00
b93badedd4 refactor(i18n): 扁平化国际化语言配置结构
移除多层嵌套的语言对象层级,将翻译项直接挂载到顶层lang对象下,简化语言文件结构,提升维护性
2026-07-03 11:50:21 +08:00
d1e6cc3122 refactor(components&cart): 调整语言包引用路径
统一修正空状态组件和购物车页面的语言包调用路径,将零散的语言项归类到goods.cart命名空间下,移除默认兜底文案
2026-07-03 11:26:26 +08:00
2a2161c9d9 refactor: 整理多页面语言包调用路径,统一翻译键层级
1. 调整品牌专区、商品评价、搜索、优惠券等页面的语言翻译引用,将零散翻译项归类到对应模块下
2. 优化auth.js中的语言包路径引用,修正模块命名空间
3. 清理zh-cn/en-us品牌语言包的冗余嵌套结构
2026-07-03 11:23:05 +08:00
6470912498 fix(article page): adjust language resource path for article pages
统一文章列表和详情页的多语言key路径,将原有通用语言项改为文章模块专属的语言配置
2026-07-03 11:10:38 +08:00
f628d7a0ef refactor(ai-chat): 统一调整语言文件引用路径
将所有硬编码的语言调用路径统一改为ai-chat.index下的命名空间,便于语言包管理和维护
2026-07-03 11:07:34 +08:00
b8387de6c6 fix(agreement): fix translation path for empty state texts
调整了协议页面空状态的多语言引用路径,移除了嵌套的agreement对象层级,让调用路径匹配实际的多语言文件结构
2026-07-03 11:01:46 +08:00
2705038c36 refactor(contact page): 统一使用带命名空间的语言包键值
将页面内所有语言引用都改为contact.contact下的完整命名空间键名,统一语言包调用方式
2026-07-03 10:48:53 +08:00
f05eebad43 fix: 修复多组件内语言包文件名路径不一致问题
将所有组件内引用的语言包路径从驼峰式改为短横线命名格式,保持代码中引用与实际语言包文件命名统一
2026-07-03 10:45:34 +08:00
1e0a06850a refactor(i18n): 统一登录页国际化文案,移除重复的login键值
将全局common.js里重复的login: '登录/注册'移除,调整到对应语言的登录页配置中,统一多语言文案展示
2026-07-03 09:55:35 +08:00
a88ecb9529 style(hover-nav): remove redundant box-shadow and comment
移除了hover-nav组件中多余的盒阴影样式和注释代码,优化样式代码整洁度
2026-07-02 18:24:48 +08:00
34f8ae2322 fix(hover-nav): 修复主题色失效和按钮尺寸问题
1.  将按钮尺寸从80rpx调整为72rpx,修正布局计算和样式注释
2.  修复Teleport后主题色无法正确同步的问题:
    - 移除watch的immediate选项,改为在mounted后手动触发主题色注入
    - 新增_applyThemeColor方法,支持安全地更新内联样式并清理旧值
    - 监听themeColor变化,确保主题切换时样式实时更新
2026-07-02 18:21:16 +08:00
cf6b683ffc refactor(i18n): 重构国际化拉取失败的提示逻辑
将i18n失败提示从lang:fallback事件监听迁移到boot返回值判断中,移除冗余的事件监听代码,修复事件监听时机和去重的问题,统一提示逻辑和防刷机制
2026-07-02 17:07:48 +08:00
0cdb956065 fix(http): 为请求失败提示添加错误信息参数
将原始的请求失败和域名不在白名单的提示中,新增传入err参数到多语言翻译中,展示更详细的错误信息
2026-07-02 16:56:28 +08:00
d3e8b62581 docs(i18n): update request failed error prompt to include error info
add {err} placeholder to both en-us and zh-cn language packs for request failed error message, also fix indentation consistency in zh-cn common.js
2026-07-02 16:56:13 +08:00
e1ce2ae268 feat(i18n): add fallback toast and cached bundle fallback logic
1. add global i18n fallback toast on lang:fallback event, only show once per process
2. add getCachedBundleAny to retrieve expired cache for boot fallback
3. add cache fallback logic when remote i18n bundle load fails
2026-07-02 16:33:22 +08:00
c86e688e47 refactor(privacy&lang): optimize privacy check and language bundle loading
1. add cache for privacy check result to reduce repeated calls
2. refactor lang bundle boot process with integrity checks
3. add timeout recovery for late fetch responses
4. improve error handling and fallback logic
5. add comments for better maintainability
2026-07-02 16:03:04 +08:00
2014d27834 chore(config): adjust boot and request timeout values
2026-07-02: 延长启动超时和请求超时时间,适配慢启动和远端冷启动场景,配合迟到数据补救机制确保获取到最终数据
2026-07-02 15:21:47 +08:00
15a61fdc39 style(hover-nav): 优化导航按钮视觉效果与注释
1.  调整border-radius注释位置,补充为clip-path兜底说明
2.  简化旧版aspect-ratio兼容注释
3.  为通用背景添加同色边框消除抗锯齿毛刺
4.  为文字和图标添加凹陷雕刻式text-shadow增强立体感
2026-07-02 15:21:31 +08:00
c430c8971c fix(hover-nav): 修复H5端语言切换时导航跟随页面位移的问题
1.  将H5端的hover-nav组件通过Teleport方式挂载到document.body,避开页面transform的影响
2.  同步store中的主题色变量到组件根元素样式,修复样式继承失效问题
3.  在组件销毁前将DOM元素移回原父节点避免内存泄漏
4.  简化当前语言显示逻辑,统一展示当前选中语言的短码
2026-07-02 14:02:52 +08:00
de52406be8 refactor(common): 统一浮动按钮与侧边导航的尺寸变量与布局
1.  新增全局CSS变量管理浮动按钮和侧边导航的尺寸参数,实现多组件样式同步
2.  重构to-top.vue和hover-nav.vue,改用CSS变量驱动布局,修复多平台适配问题
3.  新增多语言切换弹窗与智能切换逻辑,优化多语言展示与配置能力
4.  修复原容器高度计算的硬编码适配问题,改用rpx单位实现全平台自适应
2026-07-02 11:45:33 +08:00
e749209128 feat(lang): add i18n config for hover-nav component
新增中英文国际化配置,用于hover-nav组件的语言选择弹窗相关文案
2026-07-02 09:42:43 +08:00
fbf2dfdcee docs: 删除过时的CLAUDE.md文档
该文件是给AI助手的项目守则,当前仓库已不再需要该约束文档,移除该文件以清理仓库冗余内容
2026-07-01 18:00:28 +08:00
563ab9274f build: add remote-only mode lang package stub and replacement logic
add empty-lang-stub.js to stub lang package files in remote-only mode,
add webpack plugin logic to replace lang files with stub to reduce bundle size
2026-07-01 16:50:01 +08:00
5cfee417be refactor(splash-screen): 重构启动屏UI与动画逻辑
重写启动屏的模板结构与样式,替换原有简单logo加载动画为带光晕、轨道粒子的全新动效,优化过渡动画时长与曲线,简化冗余的生命周期注释代码
2026-07-01 15:43:41 +08:00
5ae4bbcbd3 feat: 新增统一启动屏组件,优化跨平台加载体验
1. 新增splash-screen全局启动屏组件,适配非APP平台的加载过渡
2. 新增splash-state状态管理模块,统一处理启动屏的显示隐藏、软隐藏时序、兜底超时逻辑
3. 重构App.vue和main.js的加载逻辑,替换原有uni.showLoading方案
4. 在首页和空页面挂载启动屏组件,适配不同页面场景
5. 兼容H5平台降级处理,解决main.js挂载前无法渲染组件的问题
2026-07-01 15:36:55 +08:00
c0220492a2 refactor(privacy-popup): 抽取通用延迟i18n混入并重构隐私弹窗
1. 新建通用的i18n延迟初始化混入,解决小程序/鸿蒙环境下i18n空串缓存问题
2. 将隐私弹窗原有的prepareI18n逻辑迁移到混入中,统一实现i18nPrepareFn
3. 简化弹窗组件内的i18n处理代码,优化可维护性
2026-07-01 14:04:42 +08:00
24ae55867a refactor(privacy-popup): 重构隐私弹窗的国际化文案处理逻辑
修复了小程序启动早期Lang未完成初始化时$lang返回空串的问题,将文案计算移到弹窗前一次性执行,统一多平台的文案获取逻辑
2026-07-01 13:46:49 +08:00
b2eb04ab98 refactor(privacy-popup): 合并多端隐私协议配置逻辑
简化隐私弹窗组件的多端环境配置代码,将H5和鸿蒙小程序的初始化配置合并,减少重复代码
2026-07-01 12:00:39 +08:00
7045a05a73 feat: 添加全局隐私协议管理器并适配鸿蒙元服务
1. 新增common/js/privacy.js全局隐私协议管理器,统一处理多平台隐私弹窗逻辑
2. 替换APP-HARMONY为MP-HARMONY平台判断标识,适配鸿蒙元服务
3. 在mp-html组件中增加MP-HARMONY平台兼容
4. 在App.vue中集成隐私弹窗初始化逻辑
5. 重构privacy-popup.vue,接入全局隐私管理器,优化弹窗互斥逻辑
6. 全局挂载$privacy实例到Vue原型链
2026-07-01 09:39:04 +08:00
4adaa945a1 refactor(i18n): 扁平化多语言文件结构,移除嵌套的components对象
统一调整所有组件级多语言配置,将原有的components.[componentName]嵌套结构改为直接顶层键值对,简化语言包的调用层级
2026-06-30 16:53:53 +08:00
eef59211d3 refactor(i18n): 完善remote-only模式下的首屏渲染和请求门控
1. 补充getCachedBundle的注释说明TTL过期缓存返回null
2. 修复globalConfig中onShow的重复判断逻辑,增加boot完成检查
3. 给http请求增加remote-only模式下的boot门控,等待国际化就绪后再发送业务请求
4. 重构main.js的平台差异化处理逻辑,统一各端初始化流程
5. 优化App.vue的boot流程,为小程序/鸿蒙增加加载遮罩
6. 新增bootCompleted状态标志和并发去重机制,完善lang.js的remote-only适配逻辑
7. 补充lang()方法在boot未完成时返回空串的逻辑,避免首屏raw key
2026-06-30 15:29:45 +08:00
07c38c1b1b fix: 适配多平台语言包加载逻辑,优化首屏渲染
调整语言包加载时机,区分H5和小程序/鸿蒙平台:
H5 remote-only模式下先await Lang.boot()再挂载Vue避免首屏raw key
小程序/鸿蒙平台同步注册App,Lang.boot()放到App.onLaunch异步执行不阻塞挂载
移除了无用的加载语言包错误日志注释
2026-06-30 14:30:22 +08:00
de4314ec9b refactor(vue.config): remove remote-only mode related code
remove the remote-only mode detection and webpack plugin configuration that replaces lang directory imports, clean up redundant code logic
2026-06-30 14:17:53 +08:00
50a1c1c695 feat(i18n): 实现remote-only打包模式,优化首屏加载和产物体积
1. 新增remote-only模式下的空语言包stub
2. 新增缓存有效期配置并对齐后端Cache::set 600s
3. 重构语言包加载逻辑,适配remote-only模式
4. 添加webpack替换插件,排除本地lang目录减少产物体积
5. 优化Vue挂载时机,等待语言包就绪后再渲染首屏
6. 改进缓存策略,新增内存缓存TTL校验和语种缓存淘汰
7. 修复语言包缓存key生成逻辑,支持uniacid和appType隔离
2026-06-30 14:04:52 +08:00
55a5a68517 refactor: 统一多语言key的组件命名格式为短横线分隔
根据i18n规范,将所有组件相关的多语言key从驼峰式改为短横线分隔格式,同步更新文档中的示例说明。
2026-06-30 10:28:36 +08:00
e6d47a3d78 refactor: 优化多语言结构并新增远端语言包加载能力
1. 扁平化多语言文件层级,移除嵌套的命名空间
2. 更新组件内多语言调用路径,适配新的语言包结构
3. 重构HTTP请求错误处理逻辑
4. 新增remote-only模式的远端语言包加载器
5. 新增多语言数据源remote-only配置项
2026-06-30 10:03:25 +08:00
32db276e68 docs: add remote-only i18n solution docs and reference
新增远端完全替代本地语言包的国际化方案文档,并在原指南中添加对应引用,补充新文档到文档列表中。
2026-06-29 17:47:24 +08:00
04a1d3c287 docs: add i18n guidelines documentation
add the complete internationalization specification document for the shop-projects/frontend uni-app Vue2 project, covering architecture, specification, usage, development norms and other content
2026-06-29 15:43:14 +08:00
d124d58552 docs: add i18n full audit report and p2 solution plan docs
新增两份国际化审计相关文档:
1. 全量国际化审计报告,覆盖项目内i18n调用问题并记录修复过程
2. P2动态key国际化处理方案文档,盘点8处动态key现状并提供落地解决方案
2026-06-29 15:39:33 +08:00
c7810f16f7 feat: 新增多语言文案并修复动态国际化调用问题
1. 新增多语言文件覆盖组件、页面、公共模块的翻译文案
2. 修复多处动态拼接lang key的调用,增加兜底逻辑避免空值或原始key暴露
3. 补全DIY组件、业务页面的国际化适配
4. 新增P2动态key国际化处理方案文档
2026-06-29 15:29:24 +08:00
c3956f7bcd refactor: 统一调整多页面的语言包key命名风格
1. 将会员购买卡片页的提示语语言包key从customerService.tip替换为统一命名
2. 修正diy文章组件、取消状态页、积分商品列表页的语言包key大小写和路径问题
3. 统一积分商品列表页的多语言调用路径,简化冗余的key前缀
2026-06-29 13:57:30 +08:00
3ef3d05624 docs: 更新TODO.md,标记三个任务项完成
将原有的两个未完成任务标记为已完成,新增TabBar页面状态梳理的完成标记
2026-06-29 11:27:05 +08:00
509b4d2238 refactor(diy-bottom-nav): 优化 TabBar 高亮逻辑,新增来源参数记忆和页面映射规则
1. 新增 redirectLink 处理,添加 from_tab 来源参数实现 Tab 高亮记忆
2. 新增 TAB_BAR_KEYS、NO_TAB_PAGES、PAGE_TAB_MAP 统一管理 Tab 归属配置
3. 重构 checkTabBarActive 函数,增加页面精确映射和来源参数判断逻辑
4. 兼容小程序和 H5 的页面 query 参数获取
2026-06-29 11:25:10 +08:00
053a279102 refactor: 调整loading-cover和隐私弹窗的挂载位置
1.  将公告列表页的loading-cover移到mescroll-uni外部统一挂载
2.  将文件列表页的loading-cover移到mescroll-uni外部统一挂载
3.  为鸿蒙平台的slot强制重绘逻辑添加条件编译包裹
2026-06-29 10:41:14 +08:00
3cedbd7b6b fix(article list): 修复空数据处理和加载时机调整
1. 给列表数据新增空值兜底避免报错
2. 调整loading隐藏和分页结束的执行顺序
2026-06-29 10:32:00 +08:00
7e4400267e style(article/list): 优化文章列表项样式与交互
1.  修复无图文章项布局对齐问题,添加no-image类处理无图场景
2.  将点击事件从click改为tap适配小程序
3.  调整图片模式为aspectFill并补全高度样式
4.  优化标签行高与日期块对齐方式
5.  仅在有封面图时渲染图片区域
2026-06-29 09:53:23 +08:00
1150dad0b5 refactor(article): 替换CSS动画跑马灯为JS驱动实现
重构文章组件的跑马灯滚动逻辑,移除CSS keyframes动画实现,改用JS控制transform实现滚动,新增支持用户手动拖拽交互,统一连续/步进两种滚动模式的触摸控制逻辑,优化跨端表现和交互体验。
2026-06-29 09:38:10 +08:00
16cfe039ba fix(ns-login): add error callback handling for getCode
补充微信环境外获取验证码失败时的错误回调,保证错误能被正确抛出处理
2026-06-29 08:46:23 +08:00
c629562190 refactor(login-redirect): 统一登录跳转逻辑,提取公共方法
将多处手动调用登录弹窗的逻辑替换为公共的redirectTologin方法,简化代码并统一跳转处理流程
2026-06-29 08:40:27 +08:00
2e535b9d93 refactor(member pages): unify onShow login redirect logic
1. extract common onShow login check to dedicated method redirectTologinOnShowLifecycle
2. add fallback callback for login failure to navigate back on wechat mini program
3. update all page login redirect calls to use the new unified method
4. add debug logs for redirect and auth flow tracing
2026-06-27 18:25:53 +08:00
0b760e41a3 refactor: 统一移除局部金额格式化过滤器并全局复用
1. 移除多个页面/组件内重复的moneyFormat局部过滤器
2. 将moneyFormat方法提取到全局配置中复用
3. 调整登录弹窗跳转模式适配H5场景
4. 新增open方法调试日志方便排查
2026-06-27 17:14:03 +08:00
f794a2ae27 refactor: 统一替换登录跳转逻辑为通用方法
将项目内多处手动调用$refs.login.open的登录跳转逻辑,统一替换为新增的redirectTologin通用方法,简化代码并提升维护性,同时统一处理了延迟和nextTick调用逻辑。
2026-06-27 16:52:25 +08:00
aee630b546 fix: 统一登录跳转逻辑,添加redirectTo参数避免页面栈溢出
1. 为所有登录跳转调用添加redirectTo跳转类型参数
2. 调整部分页面的onShow生命周期执行顺序和定时器包装
3. 补充缺失的ns-login组件引用到两个diy组件中
4. 修复部分页面登录后返回出现循环跳转的问题
2026-06-27 15:08:01 +08:00
95194585d2 refactor: 统一替换小程序事件绑定为@tap并优化跳转逻辑
1. 全局替换@click为@tap以适配uni-app事件规范
2. 优化toLogin方法的路由获取逻辑,增加空值判断
3. 重构redirectTo跳转逻辑,统一tab页跳转使用reLaunch确保页面栈正确
2026-06-27 13:46:29 +08:00
55c8e58f32 docs: add project TODO list
create docs/TODO.md and record two current pending tasks
2026-06-27 10:39:10 +08:00
1bf8a4a9b2 style: 统一修复多页面重复的事件绑定冗余代码
1. 移除多处重复的@click和@tap事件,只保留必要的@tap
2. 修复scoped样式穿透语法,补全样式文件末尾换行
3. 统一调整隐私弹窗、搜索页等页面的样式写法
2026-06-27 09:48:28 +08:00
ea40123c76 style(contact page): adjust contact info text font size
统一调整联系方式模块的文字字号,优化页面视觉展示效果
2026-06-27 09:11:41 +08:00
bf4fe97aa3 feat: 新增多语言配置并优化语言加载逻辑
1. 新增多个页面的中英文多语言词条,包括会员卡协议、积分商品、收银台等
2. 新增退款商品选择、微信支付、电子名片等页面的语言包
3. 优化lang.js语言加载逻辑,支持蛇形命名转换和多路径匹配,完善$lang调用兼容处理
2026-06-27 09:07:51 +08:00
84f5cd4568 refactor(common/lang): add shared component lang pack support
add shared language pack system for cross-page components, normalize component and page lang packs automatically, inject shared packs during language merge
2026-06-26 18:26:21 +08:00
4ae21c5d7a refactor: 完成多语言国际化全量替换与补充
1. 新增大量中英文语言包文件,覆盖自定义组件、页面、工具类的文案
2. 替换硬编码文本为多语言调用,统一全局文案管理
3. 优化语言包结构,拆分公共模块与业务模块
4. 修复部分语言加载与国际化适配问题
2026-06-26 17:32:35 +08:00
f7fb9f34f9 feat: add and optimize multilingual text resources
- add new language packs for multiple pages and components including category, cart, order, member center, distribution, etc.
- add Chinese and English translations for new features and UI text
- fix navigation service error message to use multilingual text
- update component templates to use dynamic language translation instead of hardcoded text
- adjust code indentation and formatting for consistency
2026-06-26 13:49:26 +08:00
66a579d48c feat: add and optimize multiple multilingual language packs
1. 新增拼团、砍价、分销、支付、地址、账户等多业务场景的中英文本地化文案
2. 替换项目中硬编码的文本为多语言调用,统一前端页面文案展示
3. 新增多个业务组件的国际化语言配置文件
2026-06-26 10:17:33 +08:00
066ca61d97 feat: 新增多语言文案与组件国际化适配
本次提交完成了大量多语言相关的新增与优化工作:
1. 新增充值、案例、会员资产、订单等页面的中文/英文翻译文案
2. 新增企业微信客服、时间选择、支付弹窗等组件的多语言包
3. 优化工具函数与自定义页面的多语言适配,替换硬编码提示文案
4. 新增会员等级规则、积分商品、退款管理等页面的国际化支持
5. 修复企业微信JS-SDK的错误提示文案硬编码问题
2026-06-26 09:09:46 +08:00
2d8846c64c feat: 新增多语言文案、优化导航及工具函数,修复部分业务逻辑
1. 新增多语言资源文件,覆盖文章、预约日期、会员信息、优惠券、表单、购物车、积分商城、分销、AI聊天等多个模块
2. 优化底部导航组件,使用多语言配置替代硬编码文案
3. 重构工具函数,新增从后端数据提取多语言文案的工具方法
4. 修复邀请好友页面的保存提示文案,替换为多语言版本
5. 修复商品SKU组件的提示文案,使用多语言配置
6. 重构AI聊天页面的帮助文档和回复模板,使用多语言配置
7. 优化空状态组件,支持多语言默认文案
8. 修复跳转逻辑,补全参数传递
9. 新增本地开发环境配置
2026-06-25 18:15:31 +08:00
ca4e7fd170 fix(util): 给redirectTo调用传递完整link参数
修复跳转逻辑,将完整的link对象作为参数传入跳转方法,保留原有跳转参数的同时传递额外上下文信息
2026-06-25 16:39:57 +08:00
5d3663786c feat: add multi-language page configs and fix i18n issues
1. 新增多套多语言页面翻译文件,覆盖文件列表、案例展示、商户相关、印章相关、分销、小程序发货等页面
2. 修复语言包拼写错误:loginTpis → loginTips
3. 重构语言包合并逻辑,使用深度合并替代浅合并避免子key丢失
4. 优化导航栏标题设置,统一使用$langConfig.title替代硬编码标题
5. 修复底部导航栏语言切换不响应的问题,改为响应式获取当前语言
6. 完善英文通用语言包,补充订单、购物车、会员相关翻译
7. 修复商品详情页默认标题为空的问题,设置为"商品详情"
2026-06-25 14:52:39 +08:00
cc7125361f chore: 移除i18n守卫插件与废弃的国际化文档
1.  删除vue.config.js中的I18nGuardPlugin构建期审计插件
2.  删除docs目录下全部废弃的国际化规则文档
2026-06-25 13:46:58 +08:00
8e218bb663 feat: 新增多语言国际化页面配置,修复部分页面硬编码文案
1. 新增了zh-cn和en-us两套语言下的大量页面国际化配置文件,覆盖商品、订单、会员、营销活动等多个业务模块
2. 修复了分销页面、VR页面、支付页面、商品不存在页面的硬编码提示文案,替换为国际化调用
3. 删除了原有的i18n检查脚本文件
2026-06-25 11:00:50 +08:00
5f41febd70 chore(i18n): 清理死代码 + 遗留 lang 文件 + 旧工具
## 删除的死代码脚本
- scripts/i18n_class_c.py (脚本自承'新架构下永远输出 0',纯防御性)
- scripts/full_migrate_i18n.py (其产出违反 CLAUDE.md 1.2 中文 key 铁律,已标 DEPRECATED)
- scripts/__pycache__/ (Python 缓存)

## 删除的旧 per-page lang 文件
- lang/zh-cn/<page>/<file>.js (140 个)
- lang/en-us/<page>/<file>.js (139 个)
- 共 279 个文件,运行时不再加载,0 个 key 被 .vue 调用(经 diagnose_keys.py 验证)

## 删除的备份和迁移表
- lang/_backup/ (git 已跟踪原文件)
- lang/_migration_map.json / lang/_migration-map.json (运行时不用)

## 删除的中间产物 JSON
- docs/i18n-rename-*.json (smart_rename 中间产物,已用完)
- docs/i18n-ambiguous.json / i18n-audit.json / i18n-call-rewrite.log /
  i18n-clean-dead.log / i18n-file-texts.json / i18n-page-titles.json /
  i18n-vue-extract.json (前一会话残留,无引用方)
- docs/i18n-{naming,shadow,strict}-audit.json (脚本会自动重新生成)

## 删除的 modular lang 文件
- lang/zh-cn/*.js / lang/en-us/*.js (20 个模块文件,内容是上一会话产生的死代码)
- lang/zh-cn/index.js / lang/en-us/index.js (聚合入口)
- lang/DEPRECATED.md (已不再相关)

## 删除的不成熟脚本
- scripts/check_perpage.py (诊断用,已完成)
- scripts/diagnose_keys.py (诊断用,已完成)
- scripts/smart_rename_i18n.py (v1-v3 都 bug,未修完)

## 现状
- lang/ 现在只有 zh-cn.js + en-us.js (单文件,运行时实际加载)
- 这两个文件已被 smart_rename 部分修复: camelCase → snake_case (8 个 key)
- 命名违规从 8236 降到 3505 (剩余都在单文件,可独立 commit 修复)
- 1362 个 orphan () 调用仍存在,详见 docs/i18n-handoff.md
2026-06-25 10:48:15 +08:00
a74263016b chore: 更新gitignore,添加鸿蒙元服务和其他忽略项
补充忽略鸿蒙元服务构建产物目录、openspec文件以及完善换行格式
2026-06-25 10:39:48 +08:00
1f1a642f99 fix(i18n): 修复审计脚本扫不到 modular 文件的盲区,接入命名规范检查到构建卡点
- i18n_audit.py: 同步扫描 lang/<locale>/<module>.js 多文件 + lang/<locale>.js 单文件
- i18n_check_naming.py (新增): 检测中文 key / 扁平 key / 行号式 locational key / 跨业务域同名 / 非 snake_case
- i18n_check.py: 加入 naming 子命令,默认全套审计包含 naming
- vue.config.js: I18nGuardPlugin 增加 naming 阻断
- full_migrate_i18n.py: 标 DEPRECATED,文档说明其输出违反 CLAUDE.md 1.2 铁律
- diagnose_keys.py (新增): 定位 orphan 调用 key

背景:之前审计'假阳性通过',实际 6018 处违规;现在审计能看到真实状态。

Fix: docs/i18n-rules.md 1.6 条铁律需补强 (modular 审计入口已加)

统计 (i18n_check_naming.py 当前输出):
  A. 中文 key: 2146
  B. 扁平 key: 188
  C. 行号式 locational key: 1612
  D. 跨业务域同名 tail: 1366
  E. 非 snake_case key 段: 2924
  TOTAL: 8236 处
2026-06-25 10:26:55 +08:00
2e85cfd78c fix(App.vue): 解决鸿蒙元服务版本,TabBar 就能正常显示了;修复多平台下API调用可能不存在的报错
1. 给options.query访问增加存在性校验,避免空指针报错
2. 给uni.getUpdateManager、uni.onNetworkStatusChange调用增加兼容性判断,防止在不支持的平台抛出错误
2026-06-23 16:57:28 +08:00
638406567d refactor(member page): extract global config getter method
extract repeated diyData.global access into a dedicated globalCfg computed property, simplify button visibility check logic
2026-06-23 11:01:55 +08:00
b0dad04b59 fix(member page): 调整取消和退出按钮的显示逻辑
将判断条件从"不等于0"改为"等于1",匹配配置项的真实生效逻辑
2026-06-23 11:00:13 +08:00
f258987e0e refactor: 替换多页面国际化文本为硬编码中文内容
移除项目内大部分$lang()国际化调用,替换为直接中文文本,统一前端展示文案
2026-06-23 08:55:16 +08:00
750ed2dac7 refactor: 替换硬编码文本为多语言配置
替换了项目中所有硬编码的提示文案,将其统一迁移到多语言语言包中,包括授权、自定义页面、HTTP请求、分享、客服等模块的提示文本,提升项目的国际化可维护性
2026-06-23 08:36:55 +08:00
6c7f7d4837 fix(vr page): restore and adjust bottom tabBar display
将注释掉的自定义底部导航组件恢复并正确嵌入到VR页面布局中,确保页面底部导航正常显示
2026-06-22 18:29:05 +08:00
0858618e77 refactor(merch detail): extract search placeholder to lang config
extract the hardcoded search input placeholder into internationalization language config, unify the text with the existing common lang entry
2026-06-22 18:14:30 +08:00
1143175990 refactor(merch): 替换硬编码文本为国际化语言包
1.  将商品详情页的"立即咨询"替换为$lang('merch.consultNow')
2.  新增商品相关国际化文案并替换原有硬编码字符串
3.  统一使用多语言函数处理页面文本内容
2026-06-22 18:13:11 +08:00
e9483e26e5 refactor(i18n): 替换硬编码文本为多语言配置
1.  删除冗余的语言文件 contenr.js
2.  将页面内硬编码字符串替换为$this.lang()调用
3.  整理common.js内的多语言词条,归类移动旧有配置到正确分组
2026-06-22 18:08:03 +08:00
b30ba914b9 i18n: replace hardcoded strings with language translations
批量将项目中多处硬编码的文本替换为多语言翻译条目,统一项目文案管理,提升多语言适配效率,包括导航提示、操作反馈、空状态提示、表单占位符等各类文本内容。
2026-06-22 17:59:25 +08:00
4715f06b2f i18n: 替换所有硬编码文本为多语言配置
将项目中各处的硬编码字符串替换为国际化语言包调用,统一管理页面和组件的文案内容,方便后续多语言适配与维护
2026-06-22 17:53:38 +08:00
47098bddda refactor: 替换硬编码文本为多语言配置
1. 替换分销等级页标题拼接的硬编码语言文本
2. 替换AI聊天页面的硬编码提示文本为多语言key
3. 新增完整的多语言翻译配置项
2026-06-22 17:48:53 +08:00
cb81aad784 refactor(fenxiao,member): replace hardcoded strings with i18n translations
standardize all hardcoded text across分销 and member pages to use language packs, fix template syntax errors in level.vue, and unify text display logic
2026-06-22 16:20:59 +08:00
d8705398bb refactor(zh-cn lang): add multi-language terms and replace hardcoded strings
add new common and fenxiao section language entries, replace hardcoded toast text in goods_list.js with multi-language support
2026-06-22 16:15:58 +08:00
23426b168f refactor(fenxiao): 替换分销页面硬编码文本为多语言配置
1.  将账单页面的账单编号替换为多语言键fenxiao.orderNo
2.  将下级分销页面的硬编码文本替换为对应多语言配置
3.  新增分销相关多语言词条到中文语言包
2026-06-22 16:07:28 +08:00
26eac57dc2 docs(zh-cn): add chinese localization translations for分销相关文案
补充简体中文语言包中分销、订单、提现等模块的多语言文案
2026-06-22 15:39:47 +08:00
47f21f391c refactor(fenxiao,point): 替换页面硬编码文本为多语言配置
将分销和积分商城页面中的硬编码字符串全部替换为语言包调用,统一项目多语言适配,提升页面文本可维护性和国际化支持
2026-06-22 15:25:19 +08:00
a2a637c320 chore(lang/zh-cn): add chinese translation texts for distributor module
add over 40 new i18n entries for分销相关功能界面, including commission rates, upgrade conditions, share tools and empty prompts
2026-06-22 15:24:02 +08:00
5ca4509d5b refactor(zh-cn lang): 调整国际化文案结构与引号风格
1. 将原cart模块的文案移动到member下的对应位置
2. 统一所有中文文案使用双引号
3. 拆分过长的字符串提升可读性
2026-06-22 15:17:42 +08:00
a7541d0598 refactor: 将页面硬编码文本替换为国际化语言包
统一替换积分商城、分销页面的硬编码字符串为多语言key,包括订单状态、按钮文案、提示信息等,提升项目多语言适配能力
2026-06-22 15:09:19 +08:00
d6633b698a refactor(order): 替换页面硬编码文本为国际化语言包
将订单相关页面的静态文字全部替换为$lang国际化调用,统一多语言管理,提升项目可维护性和国际化适配能力,涉及订单列表、订单支付、通用订单组件三个页面文件。
2026-06-22 15:07:04 +08:00
4d89454a17 chore(lang/zh-cn): add multiple Chinese translation entries
补充新增的页面提示、订单相关、分销模块以及表单字段的中文翻译文案
2026-06-22 15:05:21 +08:00
4b5dda9c4c i18n(zh-cn): add missing translation entries for new arrival time and community
补充简体中文国际化文案,新增上新时间和社群的翻译项
2026-06-22 14:38:58 +08:00
9f07967982 docs(zh-cn lang): add more Chinese localization strings
补充了订单相关、积分商城的多语言文案,包括元、折、确认订单、积分兑换等场景的本地化翻译,完善中文语言包内容
2026-06-22 14:30:03 +08:00
2337e3cacf refactor: 将项目内硬编码文本替换为多语言调用
统一替换所有页面和组件中的硬编码字符串为 $lang 多语言方法调用,提升项目国际化适配能力
2026-06-22 14:04:13 +08:00
46508c7d55 i18n: 替换硬编码文本为多语言配置
替换了多个组件中的硬编码文字,新增完善多语言翻译词条,包括通用、聊天、预约日期、门店等模块的翻译内容
2026-06-22 13:58:26 +08:00
c59abc9545 refactor(zh-cn lang): 完善中文语言包内容
大幅扩充中文语言包,新增商品SKU、订单、支付、会员中心、优惠券等多模块翻译词条,统一使用双引号格式化字符串
2026-06-22 11:45:12 +08:00
fce936feee refactor(member/card_buy): 替换硬编码文本为多语言配置
将页面内所有硬编码的中文文案替换为$lang多语言调用,统一国际化处理,包括按钮文字、标题、权益描述和空状态提示等内容,同时修复了image标签自闭合格式问题
2026-06-22 11:36:12 +08:00
cc39e06161 refactor: replace hardcoded text with language package entries
Replace all inline hardcoded Chinese strings in multiple Vue pages with corresponding language package keys, to unify text management and support multi-language switching.
2026-06-22 11:23:53 +08:00
014046bc9e refactor: 替换页面中硬编码文本为多语言配置
将项目中多处页面的硬编码字符串替换为国际化语言包调用,统一文本管理,方便后续多语言扩展和维护
2026-06-22 10:47:27 +08:00
45830ebda4 refactor: 替换项目中硬编码文本为多语言配置
将多处页面和组件内的硬编码文字替换为国际化语言包调用,包括空状态提示、按钮文案、表单占位符、弹窗标题等内容,统一项目多语言管理
2026-06-22 10:38:33 +08:00
062fb3be90 refactor(ns-form): 替换硬编码提示文本为国际化配置
将表单内所有硬编码的提示文案替换为多语言配置项,统一维护文案内容,方便后续国际化扩展
2026-06-22 10:04:18 +08:00
02644e1959 refactor(ns-contact): 替换硬编码文本为多语言配置
将页面中硬编码的客服相关文本替换为国际化语言包调用,实现多语言支持
2026-06-22 10:03:11 +08:00
315bdc71ff i18n(ns-chat): 替换聊天模块硬编码文本为国际化文案
将ns-chat相关组件中的硬编码中文文本替换为多语言国际化字段,统一页面文案展示方式,方便后续多语言适配维护
2026-06-22 09:59:47 +08:00
f3d9f397d7 refactor(member): 优化多语言调用和可选链使用
1.  移除语言包默认参数,统一使用翻译文本作为默认值
2.  为嵌套属性访问添加可选链操作符,避免空指针报错
3.  新增会员相关的多语言词条
2026-06-18 17:35:52 +08:00
b799e67e54 chore: 调整manifest.json中的超时时间为20000毫秒
将原有3000毫秒的超时设置修改为20000毫秒,延长超时等待时长
2026-06-18 17:11:11 +08:00
06e3a4d796 refactor(globalConfig&member): 重构多端按钮显示逻辑
1. 新增全局配置方法getAppType用于获取当前平台类型,对齐服务端$app_type
2. 将会员页按钮显示判断改为基于当前平台的配置校验
3. 简化模板中的条件判断代码
2026-06-18 17:09:34 +08:00
17864427c1 fix: 统一登录态失效后的跳转逻辑并补充多语言默认值
1.  统一所有接口登录态异常(-1)时的处理,跳转至登录页而非原页面或首页
2.  补充$lang多语言调用的默认参数,防止翻译缺失
3.  修正公共js中的跳转路径和异常处理逻辑
2026-06-17 10:10:17 +08:00
851b5fe722 refactor(member page): 优化页面样式计算与按钮显示逻辑
1. 抽离页面样式为computed属性pageStyle提升代码可读性
2. 新增自定义配置控制注销和退出登录按钮的显示
2026-06-17 09:50:17 +08:00
c2b38476ea refactor(lang): 优化多语言翻译函数,支持传入默认值
修改lang.js的翻译方法,支持传入默认值参数,当找不到对应翻译时使用传入的默认值;同时更新member-public.js中的调用方式,简化多语言调用代码
2026-06-16 18:03:52 +08:00
e9ee61f509 refactor(member): 提取公共登录态清理和注销逻辑到mixin
重构会员相关页面的重复代码,将清除登录态、退出登录、账号注销相关逻辑抽离到公共mixin中,统一复用代码并减少重复维护成本
2026-06-16 17:34:56 +08:00
c01a026149 perf(store, globalConfig): 优化多语言刷新和主题颜色缓存
1.  修改全局配置的多语言刷新逻辑,新增参数控制全量刷新时机
2.  为主题颜色生成添加缓存,避免重复遍历主题对象计算CSS变量
3.  从本地存储初始化主题配置,持久化主题状态
2026-06-16 17:34:09 +08:00
2e85e11e92 refactor(i18n): 优化多语言缓存逻辑,提升翻译性能
1. 新增多语言缓存体系,包括合并包缓存、字段解析结果缓存和页面路由缓存
2. 新增缓存失效方法,在语言更新、切换租户时统一清理缓存
3. 重构$lang方法,复用页面路由缓存避免重复计算
4. 增加字段结果缓存,命中直接返回避免重复解析
5. 复用合并后的语言包,减少深拷贝和合并操作次数
6. 清理冗余递归深度注释
2026-06-16 17:04:03 +08:00
efd7cdb8d7 fix(lang): 修复页面未初始化时获取语言失败的问题
1.  兼容_page不存在的场景,优先使用common语言包
2.  优化路由路径获取逻辑,支持$page.path作为备选
3.  避免深拷贝污染语言包缓存,仅在langPath有效时加载页面包
2026-06-16 16:53:31 +08:00
e51c03235f refactor(nav): 动态控制导航栏返回按钮显示逻辑
1. 新增isTabBarRootPage工具方法判断是否为tab根页面
2. 在全局混入中添加isTabRootPage计算属性
3. 替换多处硬编码的isBack属性为动态绑定
4. 优化shouldShowTabBar计算属性的代码逻辑
2026-06-16 15:34:10 +08:00
92700f9a21 refactor(utils&tabbar): 重构tabbar高亮逻辑与分包路径适配
1. 修复store中tabBarList初始值为null替代空字符串
2. 重构adaptSubpackageUrl,优化正则匹配与移除冗余映射
3. 新增路径归一化、分包名提取工具函数
4. 重写checkTabBarActive,新增精确匹配、packageEntryTab优先级匹配逻辑
5. 重构diy-bottom-nav组件的路由更新与高亮校验逻辑,统一路径处理流程
2026-06-16 14:56:17 +08:00
c0e64437f0 refactor(promotion pages): 统一分销和商品分类页的底部适配逻辑
重构了全局配置里的底部边距计算方式,替换原有tabBarPaddingStyleValue为tabBarPagePaddingStyle,同时调整了tabBarHeight的取值逻辑,适配是否显示tabBar的场景,移除冗余的计算属性
2026-06-16 14:29:09 +08:00
6fb0e264b8 refactor(i18n): 重构多语言模块,修复递归和缓存问题
1.  添加递归保护机制防止$lang调用栈溢出
2.  新增storage缓存层减少重复调用uni.getStorageSync
3.  修复语言包合并污染缓存问题,改用深拷贝合并
4.  增加循环引用检测避免解析异常
5.  注释掉冗余的console.error日志
6.  新增重入保护防止refresh死循环
7.  优化切语言/切租户时的缓存清理逻辑
2026-06-16 11:53:45 +08:00
64d3ac8f8a refactor: 统一重构底部导航栏间距逻辑与显示规则
1.  新增全局计算属性统一处理底部导航栏显示判断与间距样式
2.  替换多个页面的手写底部间距逻辑为通用样式
3.  新增路由匹配工具函数,支持灵活配置导航栏显示范围
4.  优化底部导航组件的显示条件判断
2026-06-16 11:25:54 +08:00
6c6822ca16 feat: 为多个页面添加底部tabBar适配布局
为文章列表、公告列表、品牌页、优惠券页、案例页、文件列表、商品搜索、积分商品列表、分销相关页面以及订单列表页添加了动态底部内边距样式,根据tabBar是否存在适配页面底部空间,同时统一添加自定义底部导航组件
2026-06-16 09:14:39 +08:00
b86e8e7fa6 refactor(merchcategory): add bottom tabbar and optimize page layout
1. add diy bottom navigation component to the merch category page
2. replace hardcoded height style with dynamic computed style based on tabbar status
3. add unified page path mapping and url resolution utilities
4. update page jump logic to use new page_key based routing
2026-06-16 08:40:59 +08:00
7b83636c99 feat(diy-article): add step-by-step marquee scroll mode
新增逐条步进式跑马灯滚动模式,适配后端配置的stepPause参数,实现单条内容间暂停的滚动效果,优化了触摸控制逻辑与定时器清理逻辑,修复了Vue2代理问题。
2026-06-15 16:12:19 +08:00
d01b0d46e4 refactor(diy-article): 替换scroll-view滚动为CSS跑马灯实现
移除原scroll-view自动滚动逻辑,改用CSS animation实现无缝跑马灯效果,解决跨端滚动跳变问题,移除JS定时器驱动逻辑,提升动画流畅度和跨端一致性,优化触摸暂停恢复逻辑。
2026-06-15 15:46:05 +08:00
db1d47f456 feat(diy-article): add number humanization and fix layout alignment
1. 新增_humanizeNum方法实现阅读/点赞数的千/万单位人性化展示
2. 调整图标间距与日期布局为自然的左右分栏样式
3. 修复style-1无图item与有图item的元信息行垂直对齐问题
2026-06-15 14:53:51 +08:00
de4b510c00 refactor(article-component): 替换swiper为scroll-view实现自动滚动,优化紧凑布局
1.  将原swiper自动滚动方案替换为scroll-view实现,支持item自然高度,不再强制等高,解决短item下方留白问题
2.  新增连贯滚动和步进滚动两种自动滚动模式,适配不同场景需求
3.  修复H5端rpx单位不兼容问题,统一内联样式单位转换逻辑
4.  优化滚动容器高度计算,根据配置显示数量动态计算窗口高度
5.  新增触摸暂停恢复逻辑,提升用户交互体验
2026-06-15 14:35:17 +08:00
089da9181b feat(diy-article): add dynamic swiper height and date display control
重构文章组件的swiper高度计算逻辑,改为基于实际item高度动态计算;新增日期显示的配置控制,完善样式统一和布局适配,优化空列表兜底逻辑与间距控制。
2026-06-15 11:54:21 +08:00
c1d4a68e26 refactor(diy-article): replace iconfont with uni-icons component
1. 替换阅读数、点赞数和查看更多按钮的自定义图标为uni-icons组件,统一图标使用方式
2. 新增metaColor计算属性同步阅读区文字色,确保图标颜色与文本保持一致
3. 修复全局样式干扰导致的阅读数/点赞数字号和颜色不一致问题
4. 调整查看更多默认配置与后端对齐,修正control默认值为true
2026-06-15 10:52:58 +08:00
f7f1b1191e fix(diy-article): 修复无图文章样式异常,新增字号样式配置
1. 为无图文章项添加no-image类适配样式,统一与有图项的视觉高度
2. 新增标签、摘要、阅读区的字号CSS变量支持,替换硬编码字号
3. 调整列表项间距默认值和swiper行间距,优化布局紧凑度
4. 补充默认字号配置项,兼容老数据缺失字段的情况
2026-06-15 10:03:26 +08:00
f9d144159a refactor(article-component): 新增多维度CSS变量支持,对齐后端默认配置
新增标签胶囊、摘要文字、阅读区、查看更多、标题字号等多组CSS变量,将组件样式控制权交给外部配置,同时将默认值对齐后端design.js的nestedDefaults。重构列表项间距、分割线颜色等硬编码样式为变量驱动,统一组件样式管理逻辑。
2026-06-15 08:28:07 +08:00
3952517ac9 refactor(article): 重构diy文章组件,支持非滚动模式与样式增强
1. 新增非滚动列表模式,适配scrollMode配置,兼容旧版swiperConfig参数
2. 重构样式系统,使用CSS变量统一管理主题与圆角,对齐ns-newform实现逻辑
3. 新增文章标签、阅读数、点赞数显示配置
4. 新增查看更多按钮,支持按分类/标签跳转列表页
5. 优化空值处理与图片加载失败逻辑
6. 修复computed class编译为逗号分隔字符串的样式错误
7. 重构响应式样式计算,支持自动适配不同布局的高度
2026-06-13 18:01:09 +08:00
01e7c3e981 refactor(diy-article): 优化文章组件,完善空状态和鲁棒性
1. 新增空数据占位提示
2. 给props添加默认值避免空指针
3. 完善样式和判断逻辑的默认值处理
4. 增加请求失败兜底逻辑
5. 优化响应式更新图片错误的处理
6. 抽离class计算逻辑提升代码可读性
2026-06-13 14:26:52 +08:00
ebe2963466 fix(ns-newform): 优化地址选择占位符显示逻辑
根据地址类型动态切换占位符文本,默认区分省市和省市区场景,移除冗余的flex布局样式
2026-06-12 16:58:41 +08:00
5847612a58 fix(ns-newform): 修复选择器箭头对齐与布局问题
1. 调整选择器容器布局,将箭头移入flex容器内,统一picker类布局结构
2. 新增box-flex样式类,实现picker与箭头的自适应右对齐布局
3. 修复城市选择器的flex布局类缺失问题
4. 优化原生picker和自定义组件的宽度适配,确保文本对齐生效
2026-06-12 16:23:23 +08:00
4a5bac7aa5 fix(diy-form): 更新提交按钮内边距映射表
对齐后端design.js的submitSizeMap,新增mini和huge尺寸的内边距配置
2026-06-12 16:07:58 +08:00
e3c69e7e32 style&feat: 优化表单提示语与提交按钮样式
1.  调整默认提示语字号从24rpx改为32rpx,颜色改为纯黑,背景设为透明
2.  新增提交按钮顶部外边距配置项,支持自定义表单与按钮间距
3.  同步更新diy-form组件的提示语样式与提交按钮外层布局
2026-06-12 14:46:12 +08:00
12a1422470 refactor(diy-form&formdata): 重构表单提交与渲染逻辑
1. 为diy-form新增submit事件,支持父组件接管提交逻辑
2. 移除formdata.vue的内置提交按钮与自定义banner/tip,交由diy-form统一处理
3. 调整formdata的diyValue参数,让diy-form自主渲染banner和提示文案
2026-06-12 14:17:57 +08:00
48235c18a5 refactor(diy-form): 兼容前后端命名格式,优化表单配置处理
1.  重构mergedFormConfig逻辑,同时支持camelCase和snake_case格式的配置项,camelCase优先读取
2.  调整ns-newform的mode参数读取mergedFormConfig的formLayout
3.  优化提示语样式,新增tipBorderColor支持显示底部边框
4.  修复submitText读取逻辑,改为从mergedFormConfig获取
5.  移除多余的console日志,更新注释说明
2026-06-12 12:00:10 +08:00
fe9cbcd21d refactor(form, diy-form): 重构表单组件复用逻辑,新增raw模式适配现有表单数据
1. 新增diy-form组件的raw数据源模式,支持直接透传ns-newform格式的表单数据
2. 新增showSubmit配置项,支持隐藏内置提交按钮由父页面自行实现提交逻辑
3. 暴露verify和getFormData方法供父组件调用
4. 替换pages_tool/form/formdata.vue的旧表单组件为diy-form,适配新的调用方式
5. 新增样式穿透修复diy-form默认样式与原有页面风格不一致的问题
2026-06-12 11:09:08 +08:00
87fbda698c refactor(diy-form): use objToStyle to format style objects
统一使用 $util.objToStyle 方法处理样式对象,简化样式代码编写,保持代码风格一致
2026-06-12 08:27:03 +08:00
39ab70510c style(uv-count-to): 优化文本样式处理逻辑
将手动拼接样式改为使用$util.objToStyle统一处理,提升代码可读性和规范性
2026-06-11 18:03:54 +08:00
ea01bbd260 refactor(util): 优化objToStyle方法实现并补充细节
新增多项特性:自动转换驼峰式CSS属性为短横线命名、过滤无效样式值、补充分号、处理非对象入参,同时完善注释说明
2026-06-11 18:03:41 +08:00
b3dd1af674 refactor(ns-newform): 统一使用工具函数转换样式对象
1. 将所有样式计算方法改为通过$util.objToStyle生成样式字符串
2. 优化行分割线的样式定义方式,拆分border-bottom属性
3. 移除最后一个子元素的分割线样式逻辑,改为通过CSS变量控制
2026-06-11 17:47:06 +08:00
bbe3d75267 style(ns-newform): 调整组件样式作用域并补充H5适配
将原全局兜底样式改为scoped作用域,同时为.placeholder类添加H5端高度适配样式,移除冗余的全局样式注释说明
2026-06-11 16:52:51 +08:00
f11146013e refactor(ns-newform): 优化表单样式计算与渲染逻辑
1. 移除模板内动态调用的样式方法,改用预计算的响应式样式属性
2. 重构行分割线实现,改用border-bottom替代伪元素提升兼容性
3. 新增兜底样式解决小程序占位符样式失效问题
4. 统一表单样式的计算时机与响应式更新逻辑
2026-06-11 16:29:14 +08:00
7c7e67df4a style(components-diy): 修正拼团头像边框的变量名和注释
更新了拼团头像边框的css变量名,将原pintuan-label-color改为pintuan-avatar-border,并补充注释说明改名原因,同时调整了对应注释描述。
2026-06-11 16:23:33 +08:00
f5badc550c style(style_color): add unified marketing theme color tokens
为所有主题预设新增统一的营销类颜色令牌,包含拼团、砍价、秒杀、会员、积分、分销等多场景的渐变与装饰色值
2026-06-11 16:23:04 +08:00
25636f8133 style(ns-newform, diy-form): 对齐后端样式规范优化表单组件
1. 调整ns-newform的图片上传框样式,匹配后端design.css的.img-box样式参数
2. 简化diy-form的样式变量绑定,移除冗余的cssVars计算属性
3. 重构diy-form提交按钮样式,支持宽度百分比和尺寸预设,对齐后端设计规范
4. 优化表单布局类名逻辑,简化样式绑定代码
2026-06-11 15:55:11 +08:00
08caa49b43 style: 将项目内硬编码色彩替换为CSS变量,兼容多平台
本次提交将项目内47处硬编码色彩值替换为CSS自定义属性,同时添加了兜底纯色样式以兼容旧版小程序 runtime。覆盖了微信小程序、鸿蒙元服务和H5三端,所有渐变、纯色、边框色都增加了主题化支持,并新增了多平台主题兼容测试文档。
2026-06-11 15:32:00 +08:00
555443dc44 feat: add remote theme override support
实现了全量的主题远端覆写功能,包含:
1. 新增主题合并工具类,支持多桶安全合并与原型链过滤
2. 新增主题远端覆写运行时,支持多租户、多端、三态缓存
3. 改造Vuex主题加载逻辑,实现本地预设+远端覆写的合并应用
4. 改造App.vue首屏主题加载流程,支持并行拉取与缓存预热
5. 新增主题更新、租户切换的事件监听与自动刷新逻辑
2026-06-11 14:55:32 +08:00
fae1d77ba5 refactor(diy-form&ns-newform): 重构表单组件样式与配置体系
1.  统一表单样式配置为中心化CSS变量方案,替换原有硬编码样式
2.  新增表单配置合并逻辑,兼容新旧数据格式与平台差异化配置
3.  拆分提交按钮与提示语样式,实现完全可配置化
4.  优化ns-newform组件的样式复用与响应式布局逻辑
5.  修复多端适配与占位符默认值问题
2026-06-11 09:45:09 +08:00
2f5f23d21a refactor(i18n): 完善多语言适配与缓存隔离逻辑
1. 扩展支持多语种并对齐后端白名单配置
2. 实现租户/应用/语言三维缓存隔离
3. 加固语言包合并的原型链安全防护
4. 优化语言列表加载逻辑与调试日志
5. 调整uniacid切换与语言更新的事件处理
2026-06-10 14:26:48 +08:00
071db2d70c feat(i18n): add remote translation override and hot update support
1. add remote translation override feature with multi-tenant isolation
2. implement hot reload for i18n texts via componentRefresh mechanism
3. add boot timeout fallback and local cache support
4. add events for translation update and fallback state
2026-06-10 14:12:49 +08:00
fd6527aa38 refactor: 迁移ns-newform组件到标准components目录并更新引用
1. 将ns-newform组件从pages_tool/_components移动到项目标准components目录
2. 更新所有文件中对该组件的导入路径和文档引用
3. 优化diy-form组件的表单数据处理逻辑,兼容更多数据格式
2026-06-09 09:50:27 +08:00
605ca69238 style: 统一优化表单组件的样式细节
调整了表单容器、输入框、文本域等组件的尺寸、内边距和圆角参数,统一整体视觉风格,优化交互体验
2026-06-09 09:20:21 +08:00
8c32f78503 style: 优化表单组件的样式与布局细节
调整了标签字体大小、最大宽度限制,重构了表单项的内边距、边框样式和间距,统一了输入框、文本域等组件的尺寸与视觉表现,优化了整体排版一致性
2026-06-09 09:18:01 +08:00
14ba88b645 refactor(diy-form): 重构自定义表单组件,替换为通用表单组件
1.  替换硬编码表单项为可配置的ns-newform组件
2.  支持横向/纵向两种布局模式
3.  添加表单Banner、提示语配置能力
4.  统一表单提交逻辑,新增表单校验和重置功能
5.  优化ns-newform组件样式与交互细节
2026-06-08 16:47:02 +08:00
4f44b4f574 fix(harmony): 双列模式,点击图片等不能进入商品详情页面 2026-06-05 17:39:50 +08:00
dcc261b0d0 fix(harmony): 双列模式首次切换时瀑布流错位导致图片重影
鸿蒙下双列 items 一直在 display:none 状态下渲染,首次切换时
boundingClientRect 测得尺寸为 0,waterfallflow 算出来的 left/top
让 items 全部叠在一起。

修复:通过 :key 加 isList 前缀,切换模式时强制重建 DOM,新 DOM
在 display:flex 可见状态下生成,测量正确。

平台判断放在 mixin 的 goodsItemKey 方法里用 JS 条件编译,模板
只调方法,避免在 vue 模板里写 #ifdef/#else(uni-app 不支持)。
2026-06-05 16:24:59 +08:00
d6c994e8f0 chore(harmony): 清理 mescroll 鸿蒙适配的调试日志
移除 components/mescroll/* 与 pages_goods/public/js/list.js 中所有
[mescroll-uni][HARMONY] / [my-list-mescroll][HARMONY] 调试日志。

保留功能性修复:watcher 触发首屏 resetUpScroll、isInit 提前为 true、
slot :key 强制重建、首次加载 300ms 延迟、setClientHeight data.height>0
校验与 10 次重试。
2026-06-05 15:25:46 +08:00
e604a41e14 fix(harmony): 首次加载延迟300ms确保scroll-view渲染就绪后再请求数据
根因分析(最终结论):
- H5/微信:API请求需200~500ms,此时scroll-view已完成首次渲染,数据正常显示
- 鸿蒙元服务:API响应极快(<50ms),抢在scroll-view首次渲染完成前返回数据
- mescroll在slot内容未渲染时测得高度为0 → 列表不可见
- 点击排序之所以能显示,是因为第二次load时DOM已完全稳定

修复方案:
- 将getGoodsList拆分为getGoodsList(入口) + _doGetGoodsList(实际逻辑)
- MP-HARMONY环境下首次加载延迟300ms,确保scroll-view DOM就绪
- 后续翻页/刷新/排序操作不延迟,保持原有体验
- 配合scrollTo(0,0)作为双重保险强制重新测量内容高度
2026-06-05 11:57:01 +08:00
51ae3b1308 fix(harmony): 恢复 mescroll 上拉加载区域,修复列表不显示的根本原因
根因分析(重要):
- 之前为解决'一直处于加载状态'问题,条件编译隐藏了上拉加载区域
- 但这导致了连锁反应:upLoadType 变化时无任何 DOM 更新 → slot 内容不被刷新
- 点击排序之所以能显示,正是因为排序触发 resetUpScroll → showUpScroll
  → upLoadType 从1变0 → 引起内部 DOM 更新 → 连带刷新 slot

修复:
- 恢复 mescroll-uni 上拉加载区域的渲染(去掉 #ifndef MP-HARMONY)
- upLoadType 的正常变化(1→0/2)会驱动 DOM 更新,从而连带渲染 slot 列表内容
- loading-cover 保持条件编译隐藏(它是独立组件,不影响 slot 渲染)
2026-06-05 11:53:26 +08:00
2ec9aa990f fix(harmony): 数据加载后调用 scrollTo 强制 mescroll 重新计算内容高度
- 根因:mescroll 初始化时 setClientHeight 在 slot 内容渲染前执行,测得高度为 0
- 数据加载后 mescroll 不知道内容高度已变化,scroll-view 仍保持 0 高度
- 点击排序后触发 refresh() → setClientHeight() 重新测量,内容才显示
- 修复:数据加载后调用 scrollTo(0, 0) 强制 mescroll 重新测量内容高度
- mescroll-uni.vue 同步暴露 scrollTo 方法供外部调用
2026-06-05 11:47:22 +08:00
a23d57547b fix(mescroll): 移除鸿蒙端强制刷新slot的临时方案 2026-06-05 11:41:07 +08:00
dc2018ee17 fix(harmony): 数据加载后通过改变 slot key 强制重建列表 DOM
- 根因:鸿蒙 ArkUI 对 slot 内 Vue 响应式更新完全失效
- 之前方案(:class/:style/v-if/)都只能影响 slot 内部,ArkUI 不响应
- 新方案:slot 外包一层 view 绑定动态 key,数据加载后 300ms 自增 key
- key 变化强制 Vue 重建整个 slot 子树,完全绕过 ArkUI 响应式限制
- 还原之前无效的 mounted 延迟方案
2026-06-05 11:36:28 +08:00
cbbb1e0327 fix(harmony): 鸿蒙环境下延迟 mescroll 初始数据加载,确保 DOM 渲染就绪
- 根因:鸿蒙 ArkUI DOM 初始化管线比 Vue 响应式系统慢
- mescroll mounted 时立即触发 resetUpScroll,数据返回时 ArkUI 尚未完全就绪
- 导致 goodsList 赋值后视图不更新,需点击排序等交互后才刷新
- 修复:MP-HARMONY 环境下延迟 400ms 再调用 resetUpScroll
- 不影响其他平台
2026-06-05 11:30:36 +08:00
1ab8a52c38 fix(harmony): 商品列表改用 :style 控制显示隐藏,修复鸿蒙下不显示问题
- 根因:.goods-list 默认 display:none,依赖 .show class 显示
- 鸿蒙 ArkUI 对 :class 动态绑定支持不完善,isList=true 时 .show 未正确添加
- 改用 :style 内联样式控制 display,优先级高于 class 且更可靠
- 同时把 v-for key 从 index 改为 item.id 提升 diff 准确性
- 移除之前无效的 renderKey 强制刷新逻辑
2026-06-05 11:19:05 +08:00
6027f0d6de docs: 更新文档中的URL为HTTPS 2026-06-05 11:10:22 +08:00
048305eb23 fix(harmony): 鸿蒙环境下商品列表初始加载后强制触发视图刷新
- 鸿蒙 ArkUI 对 v-for 数组响应式更新支持不完善
- goodsList 赋值后视图不自动渲染,必须点击排序等操作后才显示
- 在 getGoodsList 数据赋值后,通过快速切换 isList 状态强制触发视图重计算
- 仅对 MP-HARMONY 环境生效,不影响其他平台
2026-06-05 11:03:18 +08:00
8f1eb1f5fe fix: 将 http URL 替换为 https 2026-06-05 10:58:00 +08:00
70cb01cfc0 fix: 修复i18n调用路径及资源链接协议
- 修复商品列表页 $lang 调用路径错误,移除多余的 'common' 前缀
- 将 mescroll 组件中的图片资源链接从 http 升级为 https
2026-06-05 10:41:23 +08:00
719d32b77b fix(harmony): 鸿蒙环境下隐藏 loading-cover 和 mescroll 上拉加载动画
- 鸿蒙 ArkUI 渲染引擎对 Vue 响应式条件渲染(v-if/v-show)支持不完善
- 导致 loading-cover 调用 hide() 后仍显示
- 导致 mescroll-uni 加载完成后 upLoadType 已变为0但视图不更新
- 通过条件编译 #ifdef MP-HARMONY 在鸿蒙环境下跳过这些组件的渲染
- 避免用户界面一直处于加载状态
2026-06-05 10:40:42 +08:00
fe6c000fc7 fix(login): 鸿蒙元服务环境隐藏协议勾选框
- 鸿蒙 ArkUI 渲染引擎对动态 class 绑定(含 :before 伪元素)支持不完善
- 协议勾选状态切换后视图不刷新
- 暂时通过条件编译 #ifndef MP-HARMONY 在非鸿蒙环境显示勾选框
- 鸿蒙环境下跳过协议勾选校验
2026-06-05 10:28:32 +08:00
d4c1e1d6a5 feat(i18n): 添加中文购买与咨询相关翻译 2026-06-05 09:20:06 +08:00
e9c679e1aa fix: 修复跳转及国际化文案 2026-06-05 09:19:57 +08:00
65b49fe4d5 fix(login): 修复多端适配及事件重复绑定问题
1. 修正微信小程序平台的条件编译标识
2. 为uniapp页面元素补充@tap事件,兼容多端点击事件规范
2026-06-04 17:28:24 +08:00
5a0c903346 refactor: 统一将微信小程序条件编译替换为全小程序平台
将项目中所有`MP-WEIXIN`条件编译标识替换为通用的`MP`,适配所有字节小程序平台,减少重复的平台特化代码。
2026-06-04 16:41:13 +08:00
6690788a17 chore: 更新域名并添加鸿蒙构建产物拷贝逻辑
1. 修改站点基础请求域名从xcx6到xcx2
2. 新增mp-harmony构建完成后自动拷贝产物到ascf_src目录的逻辑
3. 限制微信小程序专属分包加载插件仅在微信平台生效
4. 针对鸿蒙平台调整压缩配置,保留console用于调试
2026-06-04 15:43:04 +08:00
9b5275b6f6 style: 调整微信小程序平台适配条件并优化魔方组件逻辑
1.  将MP-WEIXIN平台适配改为通用MP平台适配
2.  重构diy-rubik-cube组件的数据处理逻辑:
    - 新增processedList存储处理后的列表数据
    - 提取初始化列表逻辑为initList方法
    - 新增对value属性的深度监听,动态更新列表
    - 将原list计算属性改为直接返回processedList
2026-06-04 15:02:52 +08:00
1abc53734b build: add pnpm lockfile and project dependencies
初始化项目依赖配置,添加jweixin-module业务依赖,以及dart-sass、terser-webpack-plugin、zion-uniapp-mp-load-package开发依赖,生成完整的pnpm锁定文件。
2026-06-04 14:46:42 +08:00
829444231c refactor(components-diy/diy-group): 重构组件数据处理逻辑
1. 替换原有的深拷贝方式为显式使用$util.deepClone
2. 拆分数据初始化和监听逻辑,新增processedData存储处理后的数据
3. 重构topNavColor计算属性,简化逻辑代码
4. 抽离组件数据处理为独立的processData方法
5. 优化diyDataArray计算属性的过滤和处理流程
2026-06-04 14:33:34 +08:00
ffd046fc0a fix(diy-group): add error catch for deep clone diyData
避免当diyData格式异常时导致页面崩溃,捕获序列化报错并打印日志
2026-06-04 13:48:17 +08:00
0f9647e7c5 refactor(diy-group): simplify topNavColor and setPagestyle logic
remove redundant null checks for diyData and diyGlobalData, streamline the conditional branches for better readability
2026-06-04 13:47:33 +08:00
23b5e91d11 stub-app-projects\mp-harmony\entry\src\main\resources 这个目录也要从git 仓库中删除 2026-06-04 10:35:42 +08:00
b8112e20d6 feat(mp-harmony): add PRIVACY_WINDOW permission for entry ability
新增隐私窗口权限配置,适配EntryAbility使用场景并设置为始终可用
2026-06-04 10:03:14 +08:00
31b22e7225 fix(diy-group): 修复顶部导航颜色和页面样式计算的空值问题
为topNavColor计算属性增加diyData和global字段的空值校验,防止访问undefined属性报错;同时在setPagestyle方法开头增加diyGlobalData的空值判断,避免遍历失败
2026-06-04 10:02:54 +08:00
e86c5d874e 清理不需要git管理的文件 2026-06-04 09:35:10 +08:00
49479ea24e fix(config): 适配鸿蒙小程序的版本注入逻辑
扩展了site.js引入的平台范围,新增mp-harmony鸿蒙小程序的生产构建支持
2026-06-04 09:19:02 +08:00
0e409247cd chore: 清理pages_tool相关冗余文件并调整配置
1.  删除整个pages_tool编译构建目录下的所有冗余文件和配置
2.  更新站点uniacid为2482,移除diy-form对ns-newform的依赖
3.  调整多个组件json配置的字段顺序,移除页面中对ns-newform.pack.js的引用
2026-06-04 09:02:17 +08:00
0b06ad56dc feat(mp-harmony): 完善鸿蒙小程序项目适配与新增功能
1. 新增MP-HARMONY平台编译支持
2. 新增大量自定义组件、页面与工具页面
3. 调整站点配置与应用名称
4. 清理冗余静态资源与旧组件配置
2026-06-04 08:37:57 +08:00
a27925c24f 备份stub-app-projects 作为分发物 2026-06-03 11:47:59 +08:00
1b2a8f83c2 refactor: 优化webview页面和util.js中链接处理逻辑
将链接参数拼接逻辑移至util.js中处理,统一各端链接处理方式
2026-05-30 17:12:36 +08:00
81570dda30 fix(merch/detail): 滚动时立即隐藏滚动提示 2026-05-30 17:01:07 +08:00
db941673fc fix(merch/detail): 将滚动提示自动隐藏时间从3秒改为1.5秒 2026-05-30 17:00:09 +08:00
f58a6723de fix(merch/detail): 修复商户描述展开后滚动收起位置异常的问题
通过 key 强制重新渲染描述区域,重置滚动位置。合并重复的 toggleDesc 方法。

Sources:
- 61243b8 feat(merch/detail): add scroll hint for long product description
2026-05-30 16:56:16 +08:00
61243b8dd4 feat(merch/detail): add scroll hint for long product description
1. 新增商品详情长描述滚动提示组件,展开长描述时显示3秒
2. 调整描述文本容器底部内边距避免提示遮挡内容
3. 替换原有自动滚动动画为上下弹跳提示动画
4. 优化展开描述的逻辑,展开长文本时触发提示显示
2026-05-30 15:37:11 +08:00
b08d7a389b feat(merch/detail): 优化商品描述展示逻辑
1. 替换原有长按弹窗展示为内嵌展开收起功能
2. 长描述支持自动滚动动画
3. 新增展开收起提示按钮
4. 调整相关布局样式与层级
2026-05-30 15:15:54 +08:00
a1fc8f5955 style(common-payment): 移除微信小程序平台条件编译注释
移除了冗余的微信小程序平台专属代码包裹注释,清理页面布局代码结构
2026-05-30 14:20:23 +08:00
d3c34c3698 feat(store&bottom-nav): add package entry tab tracking logic
1. add packageEntryTab state and mutation in vuex store
2. extract and track current/previous page's matching tab entry
3. update package entry tab state via store commit instead of local variable
4. optimize tab url parsing by stripping query parameters
2026-05-30 14:12:01 +08:00
6410257a25 fix(diy-bottom-nav): 优化底部导航栏高亮逻辑,修复分包场景下的激活判断问题
1. 新增decodeEntity工具函数处理URL实体编码
2. 新增packageEntryTab状态追踪分包入口Tab
3. 重构checkTabBarActive方法,支持动态tabBar配置和分包场景判断
4. 优化路由更新和跳转逻辑,适配多分包页面的导航高亮
2026-05-30 11:59:26 +08:00
f836254381 docs(i18n): add translations for get dynacode text
add Chinese and English translation entries for dynamic code acquisition text
2026-05-30 10:06:43 +08:00
21252dc2ad style(merch/detail): 修复非H5端头部排版偏移问题
仅在非H5平台保留top:0样式,避免H5端出现布局异常
2026-05-29 15:46:26 +08:00
cc2ca14133 chore: 修改manifest.json中的应用名称为加载中...
将小程序初始显示名称临时改为加载中占位文本
2026-05-29 15:31:20 +08:00
dc4b3df715 fix(merch/detail): 防止重复初始化头部高度
1. 新增headerHeightInited状态避免重复调用initHeaderHeight
2. 在initHeaderHeight中增加商品名称校验防止无效执行
3. 仅在页面首次获取商品数据后初始化头部高度
2026-05-29 14:58:08 +08:00
6745f2093e refactor(merch/detail): 优化商品详情页头部高度初始化逻辑
1. 移除冗余的headerHeightInited状态和监听逻辑
2. 提取获取商品详情的逻辑为单独getMerch方法
3. 移除initHeaderHeight的延迟定时器并修复top样式值
2026-05-29 14:47:58 +08:00
3781e54a32 fix(merch detail): fix header style and optimize list toggle
1. 修正商品详情页头部容器的样式选择器和优先级问题
2. 调整商品数据监听,改为监听商品描述而非整个merch对象
3. 给列表切换逻辑添加防抖处理,避免频繁切换触发过多渲染
4. 新增列表切换的防抖工具函数
2026-05-29 14:42:38 +08:00
1edda80d21 fix(merch/detail): 修复头部高度重复初始化的问题
添加headerHeightInited标记,避免merch数据更新时重复调用初始化方法
2026-05-29 14:35:52 +08:00
a443121426 style(merch detail page): adjust header wrap top position to 0
fix the layout offset of the merchandise detail page header area
2026-05-29 14:34:16 +08:00
5e5e5cb1d5 refactor(merch/detail): 清理冗余注释并调整商品详情布局结构
1. 移除搜索区域和筛选区域的冗余注释代码
2. 调整商品信息区域的DOM结构,新增merch-info-wrap容器统一布局
3. 修复筛选区域的点击事件绑定,将点击逻辑移到外层容器
2026-05-29 14:32:06 +08:00
2bd94a1e5a style(merch/detail): extract inline styles to class selectors
extract inline css styles from template and style block into reusable scoped class selectors, improve code readability and maintainability
2026-05-29 14:28:47 +08:00
58883b02f3 feat(merch/detail): add long press to show full merchant description
add longpress event handler to display full merch desc in a modal when triggered
2026-05-29 14:26:12 +08:00
92549b979e fix(promotion merch detail): 修复商品详情页下拉错位问题并添加商品描述省略
1.  添加商品描述文本的多行省略显示样式
2.  动态计算顶部header高度替代固定值,解决页面滚动错位问题
3.  监听merch数据变化重新计算头部高度
2026-05-29 14:23:38 +08:00
a929cfedb4 fix(js/list): add response code check for merch info request
优化商家信息接口请求的响应处理,增加返回码和数据有效性校验,避免异常数据赋值
2026-05-29 14:13:04 +08:00
abd6c122d5 style(merch/detail): 添加::v-deep修饰器修复样式穿透问题
修复uni-app中样式无法作用到子组件标签的问题,通过::v-deep正确穿透组件作用域
2026-05-29 14:05:04 +08:00
f7b1552239 fix: 修复网页标题设置逻辑,统一参数传递格式
1.  将enable_set_title的布尔判断改为数字1/0的格式,统一前后端参数传递规则
2.  重构导航栏标题更新逻辑,抽离为公共方法并监听title和enableSetTitle变化
3.  新增onShow钩子触发标题更新,修复页面重新显示时标题不刷新的问题
4.  补充控制台日志用于调试跳转链接参数
2026-05-29 10:55:51 +08:00
b5077b6a33 refactor(formdata): 重构导航栏标题设置逻辑
将导航栏标题设置提取为独立方法updateNavbarTitle,统一处理标题逻辑,同时将data中的title变量重命名为formTitle避免命名歧义
2026-05-29 10:28:17 +08:00
9f40ebc09e feat: add dynamic navigation bar title support for webview and form pages
1. add title and enableSetTitle data fields for webview and form pages
2. implement auto set navigation bar title when enabled
3. add URL normalize and query params append utilities
4. pass title and enable_set_title params across pages and external links
2026-05-29 09:56:05 +08:00
dfbcd0c9a5 fix(formdata): 适配多端页面标题设置逻辑
调整导航栏标题设置代码,兼容WebView内核场景,同时保留H5和小程序的标题设置能力
2026-05-29 08:56:20 +08:00
800cdbc805 fix(formdata): 修复H5端导航栏隐藏选择器失效的问题
将原先通过类名选择uni-page-head改为直接选择uni-page-head组件,适配框架组件渲染变化
2026-05-28 16:56:06 +08:00
8d350b24e5 fix(form): 优化webview场景下H5导航栏的隐藏逻辑
通过条件编译只在H5环境下隐藏导航栏,避免小程序webview
环境下出现导航栏空白区域
2026-05-28 16:51:50 +08:00
a6189a5f9e fix(webview&form): 适配小程序webview跳转场景
1. 给h5和小程序端webview页面分别处理src参数,小程序端添加from=mp-webview参数
2. 新增isFromMpWebView标识,从跳转参数判断是否来自小程序webview
3. 非小程序webview场景下才设置页面导航栏标题
2026-05-28 16:07:38 +08:00
00870e4554 fix(vue.config&manifest): 修复路由base路径补全和校验逻辑
1.  调整manifest.json中router.base默认路径补全末尾斜杠
2.  在vue.config.js中增加base路径校验,自动补全缺失的末尾斜杠并打印警告
2026-05-28 14:36:23 +08:00
233093bebb refactor(form): 优化表单横幅图片的加载逻辑
将硬编码的横幅图片路径改为动态读取配置项,无配置时使用默认图片
2026-05-28 13:51:47 +08:00
d082baa06f style(style): adjust form tip and item top padding
统一调整表单提示和表单项的顶部内边距,优化页面排版间距
2026-05-28 13:46:51 +08:00
5ac7f3cf95 style: 调整表单相关样式并优化表单页面
1. 调整两个表单组件中名称字段的宽度为80vw
2. 调整必填标记的左侧外边距为8rpx
3. 优化formdata页面:添加横幅和表单提示功能,改用scss样式
4. 移除原有表单标题,改用动态提示文本
5. 补充部分调试日志并格式化代码格式
2026-05-28 11:57:57 +08:00
7526143c7d fix: 适配鸿蒙系统platform检测,解决鸿蒙版微信地图打不开的问题 2026-05-27 18:28:32 +08:00
4377ab919f build(webpack): adjust terser compression config
increase passes to 10 for better minification and add unsafe_math optimization
2026-05-27 18:19:40 +08:00
7c703ace4f docs: 更新README文档,补充鸿蒙小程序发布流程
1.  给原有章节标题增加了多余的井号修正为正确层级
2.  新增鸿蒙小程序发布完整流程章节
3.  更新版本特性和构建脚本说明,新增极限压缩优化、harmony.patch.js等内容
2026-05-27 18:15:55 +08:00
6837a5d934 fix: 修复 platform.js 与 http.js/util.js 之间 app_type/app_type_name 变量命名不匹配导致的 undefined 问题
- http.js: 修正 getPlatformInfo() 解构赋值为 appType/appTypeName
- http.js: 修复 headimgBase64、headimgPull、upload 方法中的变量引用
- util.js: 修正 upload 方法中的解构赋值和变量引用

此问题导致所有 API 请求中 app_type 和 app_type_name 参数均为 undefined
2026-05-27 17:18:22 +08:00
474b3a1091 refactor(http): rename platform info variables to camelCase
统一将平台信息相关变量从下划线命名改为驼峰命名,保持代码命名风格一致,同时修正请求参数中对应字段的赋值。
2026-05-27 17:15:21 +08:00
e4c905d278 feat(platform): add harmony mini program platform support
新增鸿蒙元服务小程序平台的识别与支持,补充平台映射表与中文名称配置,将harmony纳入小程序检测列表
2026-05-27 16:21:10 +08:00
fb133ced3c fix: 修复ZIP打包文件名前缀参数支持 2026-05-27 16:16:45 +08:00
da17911a9c feat: 添加鸿蒙元服务(mp-harmony)发行支持 2026-05-27 16:14:48 +08:00
988b36ebb4 build: 新增鸿蒙应用分发配置项
补充mp-harmony分发配置,设置鸿蒙应用的包名为com.atomicservice.6917577984608507983
2026-05-27 15:49:48 +08:00
fe2c53b43e fix: 修复swiper组件display-multiple-items大于swiper-item数量的渲染错误 2026-05-27 15:01:22 +08:00
9ed2acb8ae fix(ai-chat): 新增发送消息后自动滚动到底部
在加载聊天历史并插入加载提示消息后,立即调用滚动到底部方法,确保视图自动定位到最新消息位置
2026-05-26 15:23:31 +08:00
f36f9ce992 fix: AI智能客服首次使用显示fallbackWelcome的问题
1. 将 config.welcome 改为 config.welcome_info 匹配后端返回字段
2. 修复语言包路径错误:将 () 从 data() 移到 onLoad() 中
   - 原因:data() 执行时 getCurrentPages() 返回的是上一个页面
3. 添加调试日志用于排查 API 返回值
2026-05-26 15:02:35 +08:00
bca90e959e chore: 添加.logs目录到gitignore忽略列表
将日志目录加入忽略清单,避免提交日志文件
2026-05-26 14:47:14 +08:00
1615f9aafe chore(manifest): 调整router base路径
移除router base路径末尾多余的斜杠
2026-05-26 14:18:16 +08:00
1f6e260711 chore(config): add explosion-proof electrical config and switch default env to 2881
新增防爆电器的站点配置项,并将默认使用的环境配置切换为2881
2026-05-26 14:18:06 +08:00
dba637af38 chore(config): 整理并修正本地开发配置文件
1. 修复配置项缩进格式,统一代码风格
2. 新增145号项目配置
3. 调整默认启用的环境配置为145
4. 移除重复的本地测试配置项
2026-05-25 15:10:16 +08:00
68ed022c95 refactor(util): 替换isWeiXin实现为Platform模块方法
将原isWeiXin函数的实现替换为调用Platform.isWeiXin(),并标记为废弃
2026-05-25 14:43:34 +08:00
22a89013d0 refactor(platform): extract platform detection into separate module
移除各文件中重复的平台检测代码,统一封装到platform.js模块,添加缓存优化,支持多端平台检测、app_type和显示名称获取等能力,简化后续平台相关代码维护
2026-05-25 14:19:52 +08:00
107b5dd71b fix(webview): 修复url实体编码未解码的问题
1. 新增HTML实体解码工具函数decodeEntity
2. 在webview页面和跳转逻辑中调用该函数解码url参数
3. 统一格式化代码缩进风格
2026-05-25 14:06:17 +08:00
63789124f4 build(vue.config): 打包后自动打开目录增加错误捕获
新增try-catch捕获打开输出目录失败的异常,避免打包流程因目录打开失败中断,同时添加失败提示日志
2026-05-25 11:56:50 +08:00
a2dd7eb6f1 fix(handler.js): 完善HTML实体解码功能
修复文本节点仅替换&amp;的问题,新增完整HTML4实体解码支持,正确解析各类字符实体和数字实体
2026-05-25 11:42:14 +08:00
d0e74a0925 fix(mp-html): 完善html实体字符解码能力
新增html4标准实体映射表和通用解码函数,替换原有的仅替换&amp;的简单处理,支持更多实体字符和数字编码格式的解码
2026-05-25 11:38:28 +08:00
ccbce61755 fix(parser): 给decodeEntity添加amp默认参数为true
补充默认参数值,避免调用时未传参导致的逻辑异常
2026-05-25 11:31:30 +08:00
b827aba722 feat(privacy): add privacy check skip logic
新增隐私弹窗跳过校验的存储逻辑,通过临时缓存标记跳过隐私授权检查步骤
2026-05-25 11:11:33 +08:00
836eeb080e H5发行打包完成后自动打开输出目录 2026-05-20 11:32:06 +08:00
31c6914880 fix(ai-chat): 优化搜索定位 - 移除 doSearch 中多余的 $nextTick 包装,scroll-to 事件同步触发实现即时滚动定位 2026-05-19 15:59:58 +08:00
1ee402b581 fix(ai-chat-message): 修复搜索滚动定位和高亮动画的时序问题
1. 调整注释说明,补充scroll-into-view的微信小程序兼容场景
2. 移除冗余的定时器清除代码,新增延迟350ms触发搜索高亮动画,确保滚动先完成
3. 将原单次定时器改为嵌套定时器结构,正确管理动画状态和定时器资源
2026-05-19 15:53:17 +08:00
b1491ad69e 优化搜索定位反馈:修复Flash动画时序、添加脉冲动画、增加滚动留白、内联无结果提示 2026-05-19 14:55:40 +08:00
a954f9cc49 build(project): 关闭上传时携带sourcemap文件
修改project.config.json配置,将uploadWithSourceMap设为false,减少上传包体积并优化发布流程
2026-05-19 14:01:18 +08:00
3a310f0b98 refactor(js): rename and adjust login auth check function
调整了微信小程序端的登录授权判断逻辑,重命名函数并优化代码结构,补充其他平台的TODO注释
2026-05-19 11:56:21 +08:00
70fbdce641 feat(ai-chat): 优化历史消息时间显示与搜索定位
- 时间显示人性化:昨天/前天/跨年日期
- 搜索上下导航修复 scroll-into-view 触发逻辑
2026-05-19 09:28:29 +08:00
861c43a428 chore: 忽略 .codebuddy 目录 2026-05-18 15:59:40 +08:00
556ce9d132 refactor(ai-chat): 重构AI聊天国际化文件结构
- 将 ai-chat 语言文件从 ai/ 迁移至 ai-chat/ 目录
- 重构消息组件及多个子组件
2026-05-18 15:36:13 +08:00
5c56cfaaa7 chore: 添加 .codebuddy 到 .gitignore 2026-05-18 15:33:55 +08:00
6d704f7f5a refactor(ai-chat): 重构AI聊天消息组件 2026-05-18 11:18:56 +08:00
7580d8b763 feat(ai-chat): 添加聊天记录搜索与远程欢迎语功能 2026-05-16 17:34:24 +08:00
76afe5f433 feat(ai-chat): 支持长按复制消息内容 2026-05-16 11:08:46 +08:00
0bc1b5e90c feat(scripts): 在zip文件名中添加版本号前缀 2026-05-15 16:08:29 +08:00
9fc34ef467 feat(ai-service): 替换 anonymous 为随机访客唯一标识 getUserId()
- 新增 getUserId() 函数,已登录返回 memberId,未登录生成并持久化 guest_* 唯一ID
- 替换全部 10 处 'anonymous' 硬编码为 getUserId()
2026-05-15 09:22:48 +08:00
7a713e07e1 fix(h5): 修复H5 SSE流完成信号兼容和页面onShow时序
- sendHttpStream 兼容 data.event === 'done' 和嵌套 data.data 格式
- index.vue onShow 用  确保 ref 就绪后加载历史
- 组件 mounted 兜底调用 loadHistoryIfNeeded
- 历史消息映射添加 type:'text',兼容 msg.content 字段
- 去掉所有有帮助/没帮助按钮
2026-05-14 18:23:32 +08:00
bd778c0283 fix(ai-chat): 修复历史记录加载、WebSocket长连接、服务端消息格式兼容
- 修复 WebSocket message_end 未收到问题:兼容 data.event === 'done' + data.data.conversation_id 嵌套格式
- 修复子组件 onShow/onUnload 不触发:移到页面 index.vue 的页面生命周期中
- 修复 loadHistoryIfNeeded 被重复 methods 块覆盖:合并到同一 methods 块
- 修复历史消息映射字段:msg.query → msg.content,添加 type:'text'
- 去掉所有有帮助/没帮助按钮
- CONVERSATION_KEY 改为统一导出常量
- WebSocket 长连接重构 + #ifdef 环境隔离
2026-05-14 17:50:48 +08:00
fed3962965 fix(ai-service): WebSocket长连接重构 + 环境隔离 + this上下文修复
- 改为长连接模式:首次建立WebSocket后保持不断开,后续复用
- 添加 #ifdef MP-WEIXIN / #ifdef H5 环境隔离
- 修复 ensureWebSocketConnected 中 .bind(this) 导致 this 为 undefined 的问题
- 组件 onUnload 中调用 disconnectWebSocket() 断开连接
2026-05-14 17:04:28 +08:00
6de74a3baf fix(ai-service): 修复WebSocket域名正则丢弃端口、timeout未定义问题
- 正则 /^(?:https?:\/\/|wss?:\/\/)([^\/\s:?#]+(?::\d+)?)/ 正确捕获host:port
- 添加 let timeout = null 声明,修复 ReferenceError
2026-05-14 15:21:45 +08:00
a4f2823e75 feat(scripts): 细化zip文件名时间戳至秒 2026-05-14 08:50:48 +08:00
cd82ba2f27 fix: 统一 calc() 精度避免小米微信小程序子像素舍入差异
- diy-video-list.vue, diy-goods-list.vue: 删除重复的 3n+3 规则
  (33.33% 精度低于 33.3333333%,且与 3n 规则匹配同一组元素)
- diy-fenxiao-goods-list.vue: 两列布局统一 calc(50% - 10rpx),
  消除第2项 1rpx 的减数差异
2026-05-14 08:42:12 +08:00
698d419525 fix(diy-rubik-cube): 小米微信小程序魔方组件布局修复
- 取消注释 .template-left/.template-right 的 width: 50%,修复小米
  XWeb 引擎 flex-wrap 子像素舍入差异导致的布局错乱
- 重构 calcRowOneLeftOfTwoRight:将 'px' 追加移出主循环,统一在
  第二层循环处理,避免中途字符串转换
- 清理 calcRowOneLeftOfOneTopOfTwoBottom 中的无效空值表达式
- 优化 row1-lt-of2-rt 模板右侧最后一项条件判断 marginBottom
2026-05-13 18:23:56 +08:00
a252d42b62 fix(ai-chat): 消除键盘弹出时输入框闪烁
- 键盘垫高层不再使用 v-if 突然插入,改为始终存在(高度默认为0)
- 添加 CSS transition: height 0.25s ease 平滑过渡动画
2026-05-13 16:38:33 +08:00
69466f410c fix(ai-chat): 修复聊天页面键盘弹出时导航栏上移、输入框不可见、间距问题
- 自定义导航栏 position:absolute 固定在顶部,不随键盘上移
- textarea 设置 adjust-position=false,阻止系统上推页面
- 输入框后方加 spacer 垫高层,键盘弹出时将输入框推至键盘上方
- chat-content 根据平台设置不同 top 值(MP:0 / H5:44px)
- 去掉输入框多余的安全区域内边距
- scroll-view 底部 padding 随键盘高度动态调整
2026-05-13 16:01:15 +08:00
39245dfcac fix(ai-chat): 修复输入框定位及滚动问题并更新客服名称 2026-05-13 14:08:58 +08:00
80b18cee0f feat(ai-chat): 优化加载状态动画与流式消息过渡 2026-05-13 09:19:38 +08:00
b2688dc8a1 fix(ai-chat): 修复历史消息加载逻辑及超时问题 2026-05-13 09:00:20 +08:00
d5ca77e2d8 fix(ai-chat): 跳过健康检查避免服务误判 2026-05-13 08:40:01 +08:00
e789ab2ca3 fix(H5): 从 package.json 读取 UNI_APP_DOMAIN 兜底,确保 H5 发行域名正常注入 2026-05-12 11:39:43 +08:00
5e83771193 chore: 保留H5打包后的源目录 2026-05-12 09:59:43 +08:00
0d18041037 refactor: 使用 Intl.DateTimeFormat 生成时间戳 2026-05-12 08:48:55 +08:00
875cf0b428 feat(ai-chat): 增强Markdown解析及调整聊天气泡布局 2026-05-12 08:30:26 +08:00
e16402c9c1 fix(ai): 修复WebSocket重复回调及意外关闭问题 2026-05-11 17:14:26 +08:00
dee2887026 docs: 更新 readme 反映 H5 自动 ZIP 打包和统一配置方式 2026-05-11 11:56:37 +08:00
6033cc2fa0 feat(vue.config): H5 生产构建后自动打包 ZIP 2026-05-11 10:59:36 +08:00
c1fbabe07b refactor(vue.config): 简化UNI_OUTPUT_DIR逻辑,支持通用H5脚本+读取manifest base 2026-05-11 10:54:36 +08:00
221ef7bb6d refactor: 域名移至package.json脚本env中,config.js零改动 2026-05-11 10:29:33 +08:00
55c1e80ed7 refactor: 用 UNI_SCRIPT + DOMAIN_MAP 替代 #ifdef 环境常量 2026-05-11 10:27:33 +08:00
79202c4542 docs: 更新 package.json 和 readme.md 反映最新构建流程 2026-05-11 10:18:47 +08:00
3bff91e421 refactor: 统一版本信息注入方式
- feat(vue.config): 新增 VersionInjectPlugin,在 webpack emit 钩子中内联注入 __app_version 到 vendor.js / index.html
- refactor(scripts): mp-weixin.patch.js 精简为仅拷贝 site.js + ZIP 打包
- feat(index): 首页连击5次显示构建版本信息
- fix(util): 修复 openWxWorkServiceChat fail 回调语法错误
2026-05-11 09:36:57 +08:00
979384c2d6 feat(index): 新增固定版本触发区,标题不可见时也可连击5次查看版本 2026-05-09 17:27:53 +08:00
504e5322fb fix(scripts): vendor.js注入改为先清理旧行再注入,确保幂等 2026-05-09 17:21:02 +08:00
25fc0a59a8 fix(scripts): version.json改为生成version.js(CommonJS),兼容微信小程序require限制 2026-05-09 17:19:17 +08:00
34e27d0621 feat(index): 首页标题连击5次显示构建版本信息\nfix(scripts): explorer使用spawn避免GUI进程阻塞\nfix(index): version.json运行时读取,不依赖构建时require 2026-05-09 17:14:51 +08:00
913fc90176 feat(scripts): 构建时自动生成 version.json 并注入到 vendor.js 2026-05-09 17:04:12 +08:00
c1bac6d9e8 feat(scripts): ZIP文件名使用Git提交ID替换时间戳 2026-05-09 17:00:38 +08:00
31382a2388 refactor(util): 增加空回调函数 2026-05-09 11:16:30 +08:00
d009077183 update 2026-05-09 09:24:25 +08:00
133eb29a71 Merge branch 'dev/1.0' 2026-05-09 09:22:10 +08:00
a2616c9757 chore: 修改路由base路径为/hwappx/common/ 2026-05-09 09:17:38 +08:00
fb47da8095 refactor(privacy-popup): 优化隐私弹窗逻辑结构 2026-05-09 08:58:48 +08:00
37958cbe39 feat(privacy): 移除隐私弹窗配置检查逻辑 2026-05-09 08:47:24 +08:00
9f53f147ee chore: 移除所有调试日志 2026-05-08 16:09:19 +08:00
2342a14f2e fix(mp-weixin): 将 import 语句改为 require 2026-05-07 17:36:43 +08:00
36b0c22e47 fix: 修复二维码长按识别属性 2026-05-07 16:38:53 +08:00
4a52b9c229 fix(util): 去除URL中的双斜杠 2026-05-07 16:25:37 +08:00
9ec90a906f perf(diy-tab): optimize tab rendering with v-if 2026-05-07 16:14:28 +08:00
e125a5f39c chore: 添加修复长按菜单语法错误的脚本 2026-05-07 15:43:57 +08:00
8c0ec46dd8 style: 统一图片组件属性格式 2026-05-07 15:43:33 +08:00
b65ec35d3c chore: 更新 appid 2026-05-07 15:13:06 +08:00
d113d6125b feat(components): 在图片组件中添加长按显示菜单功能 2026-05-07 15:12:59 +08:00
2f24661cd9 fix(ai-service): 统一用户标识默认值为anonymous 2026-05-06 11:26:10 +08:00
50908a274b refactor(ai-service): 使用getUniacid方法统一获取uniacid 2026-05-06 11:13:08 +08:00
9e68562de5 chore(ai): 增加调试日志输出 2026-05-06 10:53:52 +08:00
8e5561c2a7 refactor(ai-service): 提取域名配置参数以减少重复代码 2026-04-30 16:39:55 +08:00
8d5c543a4f chore:扩大按钮【】 2026-04-28 17:51:54 +08:00
cf411407dd chore:名片可以分享了 2026-04-28 17:35:40 +08:00
86e4be302b chore:商品分类可以分享了 2026-04-28 11:31:32 +08:00
2fd06796e1 chore:加了输入框扩大按钮 2026-04-28 09:59:52 +08:00
60c51e551a chore:修改表情位置 2026-04-28 09:12:15 +08:00
d0d3ac47c6 chore:修改表情位置 2026-04-28 08:59:34 +08:00
af677f6565 chore:资源详情改为商品详情 2026-04-25 16:53:40 +08:00
f889efeb16 chore: 2026-04-25 16:45:41 +08:00
d026bb6180 chore:增加一个后台控制首页弹出的隐私权限 2026-04-25 16:15:17 +08:00
82b856f5d9 chore:表情的布局改为横板 2026-04-25 14:36:31 +08:00
7f33b6948a chore:min-height: 300px; 替换成 min-height: 0px; 2026-04-25 14:26:48 +08:00
3489b89a0b chore:智能客服加了表情 2026-04-25 14:15:03 +08:00
dc4f2769f9 chore:资源详情里的立即咨询可以跳转到智能客服 2026-04-25 11:58:03 +08:00
5cf87e4d56 chore:资源详情的背景设改为绿色 2026-04-25 11:27:32 +08:00
c9ca63cff5 chore:联系我们改为立即质询 2026-04-25 11:09:47 +08:00
a6072f4895 chore: 2026-04-25 10:22:27 +08:00
38947ddc62 chore:立即咨询点击会弹出对话框 2026-04-25 10:09:38 +08:00
cfe83ada38 chore:点击商品分类中的立即咨询不要跳转到智能客服,跳转到资源详情页面 2026-04-25 09:59:45 +08:00
1e9e0ffea8 chore:资源详情智能客服按钮同步智能客服 2026-04-25 09:50:45 +08:00
28acdfb621 chore:红色背景收一些 2026-04-25 09:29:23 +08:00
ad9f1c495b chore:联系我们改为立即质询 2026-04-25 09:22:33 +08:00
7c2a7468e7 chore:调整红色背景 2026-04-25 09:03:13 +08:00
b2051dcf92 chore:立即质询背景改红色 2026-04-25 08:48:06 +08:00
e3c86de84a chore:删除红色的立即质询 2026-04-25 08:42:03 +08:00
6e0eb5d4f3 chore:询底价改为立即咨询 2026-04-25 08:38:15 +08:00
9d186ff094 chore:发送字体按钮调整变大 2026-04-24 16:49:01 +08:00
8611ec4dc3 chore:发送按钮改为微信的发送按钮 2026-04-24 16:38:29 +08:00
89bfd94a07 chore:上传image-config.js 2026-04-24 15:46:42 +08:00
5cf8b7b467 chore:发送按钮调整变大 2026-04-23 15:24:32 +08:00
2cc0162435 chore:输入框不被遮挡了 2026-04-22 11:59:58 +08:00
d8e75a0c9e chore: 2026-04-21 14:15:46 +08:00
8af3c0ccb9 chore:微信小程序真机版本可以运行,微信小程序客服图标显示正常 2026-04-20 17:50:37 +08:00
d8b78168a8 chore:改了头像的地址 2026-04-20 15:44:58 +08:00
7675e8e04d chore: 2026-04-20 09:52:27 +08:00
88d05048a6 chore:长按图片可以识别图中二维码 2026-04-20 09:36:08 +08:00
28a92c0635 chore:ai-service.js 的代码url: 'wss://dev.aigc-quickapp.com/ws/aikefu',被替换 2026-04-18 11:36:18 +08:00
b5a47b8b4e chore:聊天页面的我和时间换位置 2026-04-17 16:03:30 +08:00
4f99e8b5f2 chore:H5的发送按钮变成正圆形 2026-04-17 15:18:31 +08:00
87fc49146c chore:时间居中 2026-04-17 14:19:43 +08:00
826fcfb024 chore:时间和智能助手在同一水平线上 2026-04-17 11:49:33 +08:00
7174d87b79 chore:用户的气泡中商品名称,商品ID,价格都左对齐 2026-04-17 10:13:07 +08:00
4bc747cd9c chore:增加了用户默认头像,自定义头像 2026-04-17 09:48:46 +08:00
e046868665 chore:用户点击自己头像可以自定义头像 2026-04-17 09:21:10 +08:00
19c3ec4f9f chore:小程序输入框向下移动 2026-04-17 08:44:01 +08:00
ffbc15929d chore:智能客服的历史时间回复正常 2026-04-16 14:03:37 +08:00
524280d816 chore:智能助手加了时间 2026-04-16 11:00:50 +08:00
6e736e0733 chore:资源详情页面的智能客服头像变为圆形 2026-04-16 10:02:50 +08:00
4891566793 chore:智能客服改了头像 2026-04-16 09:34:56 +08:00
b02f5fbc21 chore:智能客服换了头像 2026-04-15 17:46:06 +08:00
4f5a300afa chore:H5的智能客服聊天页面上面导航栏自定义的删除 2026-04-15 17:18:22 +08:00
aae6e76797 chore:智能客服聊天不乱窜了 2026-04-15 10:23:43 +08:00
3162d6a9f2 chore:H5的智能客服聊天正常 2026-04-15 09:08:10 +08:00
8010ac45a6 chore:智能客服图标换了 2026-04-14 17:29:17 +08:00
baebba4cef chore:导航栏删除在线客服 2026-04-14 16:30:42 +08:00
1be7155fe8 chore:智能客服的头像换了 2026-04-14 09:42:09 +08:00
611c0b930b chore:删除喜欢不喜欢有帮助没帮助 2026-04-13 17:29:13 +08:00
ec1903519d chore:用户提问和客服回答可以看见了 2026-04-13 09:30:08 +08:00
b1953a020d chore:喜欢不喜欢和👍水平 2026-04-10 15:10:41 +08:00
3ce26322e0 chore: 2026-04-09 16:04:11 +08:00
4e480d35df Merge branch 'Branch_release/v1.4.1' 2026-04-09 15:54:51 +08:00
bc6d243362 chore: 2026-04-09 15:30:32 +08:00
a285706112 chore:用户隐私协议点击不同意后不会在弹出了 2026-04-09 14:56:49 +08:00
9c04c031ab chore:删除原来的多余的调动弹窗的代码 2026-04-09 10:40:43 +08:00
0ce929f8a1 chore:点击同意后,用户隐私不在弹出 2026-04-09 10:04:29 +08:00
61ab9f11bd chore:H5只弹一次,点击同意后就不弹了 2026-04-09 09:35:08 +08:00
2747cb49ae chore:弹出用户隐私 2026-04-08 18:20:03 +08:00
9c8ebf8d74 Merge branch 'main' of https://git.aigc-quickapp.com/Uniapp/lucky_shop 2026-04-08 15:02:08 +08:00
e19fef785f chore:改了commom 2026-04-08 14:39:23 +08:00
92ae114c5c chore: 2026-04-08 11:47:07 +08:00
b25c366a5b chore:增加了弹窗用户隐私 2026-04-08 11:37:29 +08:00
588ec60948 chore:发送按钮向上调动 2026-04-08 10:10:47 +08:00
962ea124fa chore:退出聊天界面后,在进入聊天页面后没有多余的气泡 2026-04-08 09:14:57 +08:00
0ca0fe3b0d chore:修改了客服样式 2026-04-07 15:21:29 +08:00
cef2b6e02e chore:客服回答不重复 2026-04-07 15:10:26 +08:00
e018f9a8b9 chore:修改H5商品价格样式 2026-03-31 09:38:16 +08:00
def8a6cf6f chore:横竖屏 2026-03-30 14:49:24 +08:00
857f295923 chore:H5和小程序都可以自己分辨横竖屏 2026-03-28 17:38:26 +08:00
50ee885b74 chore:微信小程序实现竖屏是竖屏,横屏是横屏 2026-03-25 14:44:25 +08:00
410f248510 chore:H5视频改比例 2026-03-25 09:39:48 +08:00
ba5fcfaab1 chore:微信小程序的视频改为竖屏播放 2026-03-24 16:43:00 +08:00
5a46bb8d69 chore:智能客服回答不重复 2026-03-19 14:26:32 +08:00
e33ad16d10 chore:商品详情页面右上角有跳转到智能客服 2026-03-17 09:53:24 +08:00
d8ba7ace0f chore:跳转智能客服的更多详情按钮放到商品页面里面 2026-03-16 17:21:33 +08:00
875072bb68 chore:商品可以分享给智能客服进行解答 2026-03-14 10:53:55 +08:00
0912b9b031 chore:智能客服加了商品的图标 2026-03-13 16:41:00 +08:00
7ffe4acc0f chore:隐藏了更多功能,录音图标 2026-03-13 13:57:24 +08:00
4bcf0cbd2d chore:可以正常清空聊天记录 2026-03-12 18:29:11 +08:00
fdf85dfec7 chore:更换头像正常 2026-03-12 18:25:12 +08:00
1d62b37060 chore:头像功能从相册选择功能正常了 2026-03-12 15:09:27 +08:00
4ad93ceace refactor: 将视频标题样式绑定从对象改为数组 2026-02-07 18:11:42 +08:00
294856d9ef Merge branch 'test/diy-tabs' into dev/1.0 2026-02-07 17:53:30 +08:00
ba2c7d6d64 Revert "chore:点击中英文按钮不跳到‘我的’了"
This reverts commit 19a53ef414.
2026-02-07 17:51:10 +08:00
ec9e76fda7 chore:改了中英文模式 2026-02-07 16:53:40 +08:00
5dba8071e4 refactor(diy-tab): 统一样式绑定语法为数组形式
将模板中的样式绑定从对象展开语法改为数组形式,保持代码风格一致
2026-02-07 16:16:43 +08:00
19a53ef414 chore:点击中英文按钮不跳到‘我的’了 2026-02-07 16:10:55 +08:00
e943b11c8f fix(组件): 修复tab组件中子组件moduleIndex冲突问题
为tab组件中的子组件添加tabIndex前缀,避免不同tab页中子组件的moduleIndex冲突
2026-02-07 15:57:49 +08:00
e9db54980e refactor(diy-tab): 使用模板字符串和可选链优化key生成逻辑 2026-02-07 14:59:26 +08:00
3ddcc665b8 refactor(diy-tab): 统一使用生成函数获取tab和panel的key
使用getTabKey和getPanelKey方法统一生成key,提高代码可维护性
2026-02-07 14:11:51 +08:00
a981c02baf chore:智能客服页面右上角改成齿轮图标,更多功能图标改为(...) 2026-02-07 13:59:43 +08:00
1c3f759b35 feat: 为DIY标签组件添加可配置的动画效果 2026-02-07 11:41:59 +08:00
bb58866192 feat: 为标签组件添加悬停动画和对齐选项 2026-02-07 11:34:45 +08:00
069b3c2c07 feat: 为DIY标签页组件添加滚动容器和样式 2026-02-06 17:55:44 +08:00
c6fd13956a chore:H5和微信小程序的第三方客服悬浮按钮应该会显示 👤 2026-02-06 17:49:48 +08:00
a489e24988 chore:改了企业微信客服,智能客服的悬浮按钮 2026-02-06 17:18:58 +08:00
6897fc631b feat: 重构DIY标签页组件,支持多种布局和样式配置 2026-02-06 16:47:32 +08:00
6d23a64aef chore:智能客服的页面全部显示出来 2026-02-06 16:30:33 +08:00
f34533b0c7 chore:进入智能客服的页面加了智能客服的头像 2026-02-06 16:08:50 +08:00
72c74f44f0 chore:删除弹出的框的一个叉 2026-02-06 16:02:11 +08:00
7f3d9c0f10 chore:将更改头像和昵称的按钮放在...的设置中 2026-02-06 15:56:00 +08:00
37f95e7267 chore:改了客服的图标 2026-02-05 18:30:16 +08:00
d3c630e860 chore:后台点击什么,页面显示什么 2026-02-05 16:58:54 +08:00
c5b57cedeb chore:后台点击不启用,H5和微信小程序不显示 2026-02-05 16:34:33 +08:00
bf24746a7c chore;更改了客服和中英文按钮的顺序 2026-02-05 10:14:27 +08:00
5ff2925ba1 chore: 2026-02-05 09:56:37 +08:00
3dc81e7f53 chore:优化了hover 2026-02-05 09:50:36 +08:00
0417c5c5ed chore:优化了hover 2026-02-05 09:27:43 +08:00
b23c21d05b Merge pull request 'fix/lang' (#4) from fix/lang into main
Reviewed-on: #4
2026-02-04 09:12:58 +00:00
65a24f25bb fix(lang): 提取getCurrentLocale为独立函数并优化代码格式
将getCurrentLocale方法提取为独立函数以避免重复代码
移除多余的空格并保持代码风格一致
2026-02-04 17:10:12 +08:00
db26a85c8b chore:商品,名片,我的都能正常跳转了 2026-02-04 17:09:57 +08:00
54c39259a7 chore:代码合并,整理功能 2026-02-02 18:04:59 +08:00
15361211b1 Merge pull request 'tjh/merge' (#3) from tjh/merge into dev/1.0
Reviewed-on: #3
2026-02-02 09:52:19 +00:00
e301ddc6ec chore:微信小程序标签栏点击可以跳转 2026-01-31 17:04:50 +08:00
a560ad4a5d chore:后台点击什么页面就显示什么 2026-01-31 10:24:12 +08:00
7f7a18803f chore:智能客服正常运行 2026-01-31 10:06:59 +08:00
42563d7184 chore:改了pages-tool/ai-chat/ai-chat-message.vue 2026-01-31 09:47:41 +08:00
14a903f42a chore:改了components/chat-message/chat-message.vue 2026-01-31 09:44:05 +08:00
c9f8614927 chore:改了common/js/lang.js 2026-01-31 09:23:36 +08:00
fe9303bf4c chore:改了store/index.js 2026-01-31 09:20:31 +08:00
e4dfd0ae11 chore:改了project.config.json 2026-01-31 09:17:57 +08:00
7037d0b083 chore:改了pages-tool/ai-chat/index.vue 2026-01-31 09:14:00 +08:00
e8a79bd245 chore:改了golbalconfig.js 2026-01-31 09:11:00 +08:00
047cf765da chore:改了pages.json 2026-01-31 09:07:48 +08:00
3a30b0817d chore:替换了pages/index/index.vue 2026-01-31 09:03:58 +08:00
a464a8731f chore:替换了customer-service.js 2026-01-31 08:59:30 +08:00
7599a80943 chore:替换了ai-service.js 2026-01-31 08:55:57 +08:00
992a01cc76 chore:替了chat-message.vue 2026-01-31 08:52:55 +08:00
dd09c66215 chore:替了hover 2026-01-31 08:48:18 +08:00
e2b89c348f refactor(diy-tab): 优化标签组件结构和默认配置
- 使用 tab.id 作为 v-for 的 key 提高渲染性能
- 移除默认标签数据,改为空数组
- 添加 activeTabIndex 配置项
- 调整代码格式和注释
2026-01-30 17:41:23 +08:00
475edc93a6 refactor(diy-tab): 将默认配置拆分为多个独立对象以提高可维护性
将原本单一的defaults对象拆分为多个独立的配置对象(tabsConfig、baseConfig等),并通过展开运算符合并。这样修改使代码结构更清晰,便于后续维护和扩展。
2026-01-27 14:48:40 +08:00
cb86cba389 feat(components): 新增可自定义的diy-tab组件,支持多种样式和布局
添加一个高度可配置的tab组件,支持顶部、底部、左侧、右侧四种布局方式
提供默认、下划线和卡片三种tab样式,支持自定义颜色、间距、指示器等样式
组件包含导航栏和内容区域,支持动态切换和动画效果
- 添加自定义样式配置功能,允许通过 customStyles 完全覆盖组件样式
- 重构代码结构,使用计算属性合并默认值和传入值
- 优化样式处理逻辑,增加 mixin 复用
- 完善注释和文档说明
- 改进响应式动画效果
- 新增 getTabTitle 方法,支持根据当前语言环境显示对应的标签标题。该方法处理对象形式的标题(按语言键值匹配)和字符串形式的标题(支持国际化键翻译),提升组件的多语言适配能力。
2026-01-27 14:36:42 +08:00
0dc4dec616 refactor(diy-components): 统一使用@tap.stop替换@click事件处理
将组件中的@click事件统一替换为@tap.stop,避免在小程序环境中可能出现的点击事件冒泡问题,提升交互体验的一致性。同时移除冗余的@tap事件绑定,保持代码简洁。

主要修改包括:
- 表单提交按钮
- 分享功能
- 热区点击
- 富文本点击
- 视频播放
- 直播入口
- 商品品牌
- 客服功能
- 快捷导航
- 公告弹窗
- 文章列表
- 底部导航
- 商品列表
- 浮动按钮
- 优惠券
- 搜索功能
- 店铺相关
- 文本组件
- 分类页面
- 魔方组件
- 秒杀功能
- 拼团功能
- 预售功能
- 砍价功能
- 分销商品
- 图片广告
- 支付二维码
- 图片导航
- 音频控制
- 地图导航
- 笔记功能
- 商户列表
- 图片组件
- 会员订单
- 图文导航
- 首页分类
- 商品推荐
- 团购功能
- 限时折扣
- 商品分类
- 商品列表
- 优惠券
- 搜索功能
- 店铺相关
- 文本组件
- 分类页面
- 魔方组件
- 秒杀功能
- 拼团功能
- 预售功能
- 砍价功能
- 分销商品
- 图片广告
- 支付二维码
- 图片导航
- 音频控制
- 地图导航
- 笔记功能
- 商户列表
- 图片组件
- 会员订单
- 图文导航
- 首页分类
- 商品推荐
- 团购功能
- 限时折扣
- 商品分类
2026-01-26 15:05:36 +08:00
2a5214df11 feat(图片预览): 将图片预览功能提取到mixin并优化点击处理
重构图片预览功能,将其从diy-picture组件移至mixin实现,便于复用
优化diy-rubik-cube组件的点击处理逻辑,支持无链接时预览图片
统一处理图片预览和页面跳转的条件判断
2026-01-26 14:07:45 +08:00
2a0935b581 Merge pull request 'docs: 更新readme文档结构和内容' (#2) from dev/1.0 into main
Reviewed-on: #2
2026-01-24 09:41:36 +00:00
09e410df00 docs: 更新readme文档结构和内容
重构readme文档结构,使用更清晰的markdown格式和emoji图标
添加详细的开发调试说明和发布流程
补充构建脚本说明和版本历史记录
2026-01-24 17:40:40 +08:00
b31197a8b4 Merge pull request 'dev/1.0' (#1) from dev/1.0 into main
Reviewed-on: #1
2026-01-24 09:24:05 +00:00
Zhukj
75dfe80bde Merge remote-tracking branch 'remotes/origin/dev/1.0' into zhu-kaijiie/dev-1.0 2026-01-24 16:59:15 +08:00
Zhukj
2a1f33323c chore:修改readme.md小程序和快应用发布说明文档 2026-01-24 16:51:31 +08:00
Zhukj
b441c46993 chore:更新小程序发布说明文档 2026-01-24 11:19:53 +08:00
38ade75046 fix(diy-map): 替换cover-view为div解决z-index失效问题
移除ns-login组件中不必要的z-index设置
2026-01-24 09:20:01 +08:00
1e51abd7cd docs: 更新本地配置示例文件添加IVD数商模式配置
添加2812环境的IVD数商模式配置,并增加本地开发测试平台选项
2026-01-23 17:09:42 +08:00
0939449aa7 fix(ns-login): 修复complete-info-wrap弹窗层级问题 2026-01-23 17:09:05 +08:00
29b5cfda6f fix: 在页面加载时统一隐藏首页按钮
在多个页面的onLoad生命周期中添加this.$util.hideHomeButton()调用,确保页面加载时即隐藏首页按钮,避免显示不一致问题。同时调整category.vue中onShow的逻辑顺序。
2026-01-23 11:54:31 +08:00
ceca4e5956 fix: 在多个页面添加隐藏首页按钮功能
为统一处理首页按钮显示逻辑,在会员页、联系页和商品分类页的onShow生命周期中添加了hideHomeButton调用
2026-01-23 11:36:15 +08:00
aa9d2e64d2 feat(util): 添加隐藏微信小程序首页按钮功能
添加 hideHomeButton 方法用于在微信小程序环境中隐藏首页按钮,提升用户体验
2026-01-23 11:18:55 +08:00
e8ccb87266 feat(脚本): 增强微信小程序补丁脚本功能
- 添加新的 npm scripts 支持不同模式运行补丁脚本
- 支持命令行参数 --no-zip 和 --mode 控制 ZIP 文件生成和运行模式
- 自动复制项目配置文件到目标目录
- 更新使用说明文档
2026-01-23 10:12:30 +08:00
153c84266a chore: 更新项目配置文件和微信小程序名称
- 将项目名称从'lucky_shop'改为'mp-weixin'
- 在project.config.json中添加include数组和多个新配置项
- 启用enhance功能并添加编译相关设置
2026-01-23 09:25:58 +08:00
7ae7a1d3bd chore(uniapp): 增加拷贝文本超时检测提示功能 2026-01-17 15:30:02 +08:00
1ebb94e9e2 chore: 调整隐私协议及注册服务展示样式 2026-01-17 13:52:19 +08:00
9b53540f91 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-01-16 18:11:00 +08:00
2a36fa2b60 chore(build): 调整默认小程序AppID及site.js文件 2026-01-16 18:07:59 +08:00
2223636184 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-01-16 18:00:31 +08:00
6144dc72b8 chore:非 h5 平台 :key 不支持表达式的问题 2026-01-16 18:00:03 +08:00
47e1c2372d chore(sass): 编译器从node-sass转移成dart-sass 2026-01-16 17:52:33 +08:00
5b9bef9214 chore(build): 调整个别组件的位置,减少对主包尺寸的影响 2026-01-16 15:34:04 +08:00
08880a15df fix(电子名片): 再使用视频号组件的时候,严格转换数据类型 2026-01-16 10:47:52 +08:00
e40e6e73b2 chore(build): 调整ai-chat-message组件的位置,减少对主包尺寸的影响 2026-01-16 10:34:00 +08:00
dd4176998b Merge branch 'feat/personnel_channel' into dev/1.0 2026-01-16 10:16:40 +08:00
2896817435 chore: 删除不需要的页面及demo组件 2026-01-16 08:49:46 +08:00
dc956761a0 Merge branch 'feat/personnel_channel' into dev/1.0 2026-01-15 18:14:50 +08:00
36fd0621fd fix(视频号组件): padding 影响布局 2026-01-15 18:05:30 +08:00
1e6cd55f0a chore:中英文切换按钮能切换英文 2026-01-15 15:10:46 +08:00
f7fcf7fb27 chore:点击英文按钮,页面不是空白了 2026-01-15 15:01:39 +08:00
28359f2f16 fix(视频号组件): 整体控制是否显示观看次数没有生效 2026-01-15 14:58:36 +08:00
b3cc86d088 chore: .local.config.js.example 2026-01-15 14:37:39 +08:00
c6a8bc04f2 chore:解决了siteInfo,bgUrl错误 2026-01-15 14:08:32 +08:00
0ed0789528 chore(视频号组件): 支持轮播模式 2026-01-15 11:40:34 +08:00
16d1a3a945 feat(电子名片): 支持通过后台控制显示样式及封面尺寸 2026-01-15 09:44:54 +08:00
82df9ddbac chore:加了中英文切换按钮 2026-01-14 18:24:42 +08:00
392a8e2952 revert(视频号组件): 精简代码,调整样式以和后台设计统一 2026-01-14 16:37:44 +08:00
db7a8d7202 chore(视频号组件): 与后端设计器同步样式布局 2026-01-14 13:46:47 +08:00
bb87f4b1d2 fix(视频号组件): 用户取消的监听错误,不弹出错误提示 2026-01-14 09:41:41 +08:00
fe039ed126 revert(视频号组件): 精简代码,去除不用的组件 2026-01-14 09:31:22 +08:00
03c1a8d71a fix(视频号组件): 修复视频标题全是数字字符串不会自动换行的问题 2026-01-14 08:56:25 +08:00
e8fb35b310 chore:智能体客服正常运行 2026-01-13 18:26:11 +08:00
286fcc2142 chore:合并了之前智能客服的代码 2026-01-13 16:42:06 +08:00
2280ff68fa Merge branch 'feat-ai-agent-kefu' into xindeznkf
chore:合并了提交
2026-01-13 15:57:35 +08:00
9e0cab7ec4 chore(视频号组件): 针对微信视频号资源,点击播放的时候吧,弹出不能播放的原因 2026-01-13 14:51:09 +08:00
a31c004508 chore(视频号组件): 去除不需要的属性 2026-01-13 14:47:46 +08:00
ee8c24cdf8 chore(util): 格式化util.js 2026-01-13 14:39:22 +08:00
b043601a77 chore(视频号组件): 不仅支持16:9的封面,还支持3:4的 2026-01-13 14:26:10 +08:00
e20968aee2 chore(电子名片): 支持点击视频号视频,非嵌入模式可以播放了 2026-01-13 11:27:59 +08:00
8ceeca33b4 chore(manifest.json): "lazyCodeLoading": "requiredComponents", 2026-01-13 08:33:24 +08:00
02e9cfa313 chore(电子名片): 支持点击视频号视频,观看次数+1 2026-01-12 17:46:15 +08:00
3f894bcb20 chore(电子名片): 针对上传的封面及头像,转换图片路径 2026-01-12 16:25:02 +08:00
8d5d6e3da8 chore(视频号组件): 是否支持嵌入式播放,由使用者决定 2026-01-12 16:24:14 +08:00
ce1ea6eadf chore(电子名片): 更新企业文件及企业视频相关代码 2026-01-12 16:11:53 +08:00
191c803f66 chore(组件): 更新ns-video-player-popup 组件 2026-01-12 16:05:25 +08:00
4e1448d2c4 chore(js): 新增common/js/share.js,专注分享 2026-01-12 15:35:23 +08:00
ce26a9be5f feat(页面): 新增文件预览页面 2026-01-12 15:28:04 +08:00
8b01d8f8ec chore(视频号组件): 非微信小程序环境,发送消息,让父组件决定来怎么操作 2026-01-12 15:03:28 +08:00
6ab2834fdc feat(组件): 新增ns-video-player-popup 组件 2026-01-12 15:02:28 +08:00
85816e416c chore(视频号组件): 支持rowCount=1 的布局 2026-01-12 13:50:26 +08:00
bf8e177c30 chore(视频号组件): 支持2-3-4列布局 2026-01-12 13:47:13 +08:00
57111a00c2 fix(视频号组件): 让原生channel-video 组件样式与普通模式组件模式保持一致 2026-01-12 11:56:18 +08:00
aa7c7cbe60 chore(电子名片): 添加企业文件及企业视频部分模板及样式代码 2026-01-12 11:33:10 +08:00
d012c7fff7 chore(视频号组件): 优化视频号组件展示及播放 2026-01-12 11:32:31 +08:00
128d75b921 chore(视频号组件): 增加对auto-play 的控制 2026-01-12 08:57:07 +08:00
7e0d61c7e5 chore: 根据后端API设置相关模块的显示 2026-01-10 18:05:54 +08:00
26af083839 chore(电子名片): 支持展示视频号 2026-01-10 17:38:55 +08:00
68bc853dff chore(视频号组件): 更新文档及注释内容 2026-01-10 17:38:13 +08:00
13166132c7 chore(视频号组件): 更新视频号样式,控制标题行数,播放按钮等样式 2026-01-10 17:03:41 +08:00
f87c7d963e chore(视频号组件): 更新视频号样式 2026-01-10 16:12:36 +08:00
6f6899deaa chore(视频号组件): 重构视频号组件,不单单是微信视频号 2026-01-10 15:14:44 +08:00
7f54666629 chore(util): 增加蛇形命名转驼峰命名(递归处理对象中的所有属性名) 2026-01-10 13:44:50 +08:00
5747b256c6 chore:改了webd的基础路径为common 2026-01-09 17:54:18 +08:00
09b285016c chore:修改了快应用发行文档 2026-01-09 17:34:06 +08:00
72c12a79fd chore:修改了快应用的发行文档 2026-01-09 17:18:43 +08:00
84f4ad83c2 chore:增加了快应用打包步骤 2026-01-09 15:45:19 +08:00
1512c60109 chore:修改快应用发布 2026-01-09 11:52:29 +08:00
5b41dee46c Merge remote-tracking branch 'remotes/origin/dev/1.0' into dev/1.0 2026-01-09 11:34:19 +08:00
9ed337b59a chore(build): 增加对老服务器xcx20.5g-quickapp.com 网站的支持 2026-01-09 11:17:10 +08:00
8fedeafe63 chore:修改了小程序打包步骤 2026-01-09 10:41:45 +08:00
31336f9a80 chore:增加了快应用的打包过程 2026-01-09 10:33:50 +08:00
2f7c9bf0af Merge remote-tracking branch 'remotes/origin/dev/1.0' into dev/1.0 2026-01-08 16:50:24 +08:00
f8257361b5 chore:新加了快应用发行的文档 2026-01-08 16:08:00 +08:00
ec05534ff1 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-01-08 15:36:57 +08:00
4f9c353e9a chore(用户中心): 账号退出或者注销,清理用户隐私协议授权设置 2026-01-08 14:58:47 +08:00
52b5f5b006 chore:重新合并了dev 2026-01-08 14:58:10 +08:00
5a8663eb48 fix: 解决在快应用环境中不显示用户隐私提示窗体的问题 2026-01-08 14:52:01 +08:00
8108802204 chore:手动和dev合并 2026-01-08 11:30:35 +08:00
6fe3bceb4b chore:手动合并和dev的代码 2026-01-08 11:18:58 +08:00
ee4fbb277c test(隐私): 测试 2026-01-08 09:39:15 +08:00
6718010ceb chore: common/js/util.js 增加判断是快应用的函数 2026-01-08 09:04:36 +08:00
faf1f6d3d8 chore: 增加判断是快应用环境的API函数 2026-01-07 18:28:51 +08:00
2d98a32efc fix(微信视频号组件): 宏导致的H5环境编译出错 2026-01-07 17:43:23 +08:00
7fde30063b chore(微信视频号): 优化channel-video组件的显示,更新技术文档 2026-01-07 17:27:42 +08:00
10465b7cf2 chore(微信视频号): 添加MP-WEIXIN 宏限制channel-video 2026-01-07 17:17:24 +08:00
c7e9b497ae chore(微信视频号): 优化微信视频号的配置及显示 2026-01-07 17:12:38 +08:00
de63cd4c6a feat(微信视频号): 增加微信视频号和微信视频号列表组件 2026-01-07 16:42:28 +08:00
2f230d3b2c chore: 使用常量来处理登录的路由页面路径 2026-01-07 15:51:24 +08:00
6f01b3c1c6 chore: 使用常量来处理首页的路由页面路径 2026-01-07 15:40:13 +08:00
b10fcf02d4 chore: 使用常量来处理会员中心的路由页面路径判断 2026-01-07 15:09:49 +08:00
6956841c8b chore(会员中心): 购物车、足迹、关注,优化副标题 2026-01-07 14:24:02 +08:00
90820ade30 fix(路由): 修复订单列表页面,验证登录页面,而没办法后退 2026-01-07 14:02:03 +08:00
8df59abd03 fix(关注/足迹): 优化交互逻辑 2026-01-07 11:35:28 +08:00
d1b9e528f8 fix(页面前缀): 分包造成的路径对应更新缺失 2026-01-06 18:14:35 +08:00
618babf5bf fix(多语言): 将售后待付款组件的翻译放到zh-cn/common文件中 2026-01-06 17:57:48 +08:00
9a63b5ff4e chore(主题样式): 更新主题列表,补全'pink', 'gold', 'purple', 'yellow', 'black' 2026-01-06 17:03:30 +08:00
d7b07c974d chore(加载主题样式): 只使用异步方式,保留一个函数loadTheme 2026-01-06 17:01:03 +08:00
6358102cae chore(浮动导航按钮样式): 统一使用灰底黑字 2026-01-06 16:46:57 +08:00
6023b5b65e chore(颜色): 明确颜色设置规则 2026-01-06 16:15:04 +08:00
b96b5f0bc3 chore: 更新本地调试配置示例 2026-01-06 15:35:49 +08:00
35aadc7014 fix(电子名片): 修复及调整复制按钮及一键拨打按钮的宽度及样式 2026-01-06 15:05:07 +08:00
4bdbfff7d4 chore(微信小程序权限): 默认申请剪贴板功能 2026-01-06 14:53:56 +08:00
5d34f00b3e chore(build): 只有生产模式下才开启vue.config.js 2026-01-06 14:33:29 +08:00
548aeed7b7 chore(电子名片): 有电子邮箱或者有电话的时候,才显示对应的内容 2026-01-06 14:32:50 +08:00
ce13661826 chore: wx.getSystemInfoSync is deprecated
wx.getSystemInfoSync is deprecated.Please use wx.getSystemSetting/wx.getAppAuthorizeSetting/wx.getDeviceInfo/wx.getWindowInfo/wx.getAppBaseInfo instead.
2026-01-06 14:02:34 +08:00
d9f0d1987e feat(ns-login): 增加隐私条款及用户服务协议 2026-01-06 11:34:19 +08:00
afd002fad3 chore(组件): 解决uni-popup-*组件注册问题及开发环境配置 2026-01-06 10:18:01 +08:00
531dc347f7 chore(组件): 更新依赖 2026-01-05 18:31:42 +08:00
405f37a3f6 chore(组件): privacyPopup 组件全部使用注册机制 2026-01-05 18:11:14 +08:00
73f9cb8820 chore(组件): 全部使用easycom来处理组件问题 2026-01-05 17:24:49 +08:00
b1ebb38c06 chore: .local.config.js.example 2026-01-05 15:58:31 +08:00
60a0e5133e revert: 所有代码与custom/common分支同 2026-01-05 15:56:49 +08:00
8ef6975ee1 chore(组件): 由于easycom会自动注册components和uni_modules 目录下的组件,所以去除引用部分 2026-01-05 15:27:34 +08:00
be51b30fb4 chore: 暂时保存 2026-01-05 11:22:38 +08:00
b24f77be1a chore(组件): components-diy 必须使用easyncom系统,不然在微信上无法渲染 2026-01-05 09:29:19 +08:00
311efe1ecd chore(分包): 拆分pages_tooll子包 2026-01-04 16:02:31 +08:00
db8fb25da8 chore(分包): 拆分pages_promotion子包 2026-01-04 15:27:31 +08:00
9415f397d2 chore(组件): 组件尽量使用异步导入模式 2026-01-04 14:40:00 +08:00
4da852944e chore(代码合并): 代码从Custom/common分支合并 2026-01-04 09:40:09 +08:00
26f8537d24 fix(会员中心): 样式未定义触发异常 2025-12-30 09:22:51 +08:00
44dd7f30cf chore(分包): tarBar达到分包前效果 2025-12-30 09:13:29 +08:00
366c4e4cd0 fix(底部TabBar): 点击后,按钮激活状态下,不变颜色 2025-12-29 17:47:32 +08:00
d5b46b4086 chore(TabBar): 默认systemTabBarList,只保留4个选项 2025-12-29 15:55:51 +08:00
30403871e7 chore(test): 测试代码 2025-12-29 15:54:00 +08:00
6b5a268955 chore(多语言): 优化登录页面的中英文展示 2025-12-29 11:23:32 +08:00
9f25cb5336 chore(多语言): 支持登录页面多语言 2025-12-29 11:11:52 +08:00
74c7eaf497 chore(多语言): 部分多语言采用 isEnEnv 变量了判断,后面移植到语言包中 2025-12-29 10:56:04 +08:00
f9973d9546 fix(ns-login): privacy-popup 没有正确引入 2025-12-29 10:53:29 +08:00
1886616bb4 chore(多语言): 补充英文翻译 2025-12-29 10:51:38 +08:00
332350cfab chore(多语言): 部分语言暂时使用 isEnEnv 计算属性来判断 2025-12-26 18:11:15 +08:00
31623e6667 chore(多语言): 将isEnEnv计算属性挂载到全局Vue组件上,针对商品名称优化英文显示 2025-12-26 17:54:20 +08:00
d36e7f6bba fix(组件): 修复组件错误 2025-12-26 16:50:02 +08:00
40f5f63096 chore(组件): 引入DiyMinx的js及修复组件错误 2025-12-26 16:35:15 +08:00
d1778c1fd7 chore(组件): 将diy组件独立出来 2025-12-26 16:14:06 +08:00
8e2e817a4d chore(组件): 组件移动方案实施可行性最高的方案 2025-12-26 15:23:28 +08:00
6744bd2806 chore(组件): MescrollUni 组件是引用的@/components/mescroll/my-list-mescroll.vue 2025-12-26 14:39:12 +08:00
86831b8551 chore(组件): 全局组件改为按需引用 2025-12-26 14:29:38 +08:00
e949b14090 chore(分包): 目录结构调整 2025-12-26 11:03:16 +08:00
b01de93eee fix(ns-login): 修复H5页面在微信App中点击登录无反应的问题 2025-12-25 17:45:52 +08:00
2c3786546c chore: 使用宏来控制自定义H5网站部署发布 2025-12-25 17:00:10 +08:00
0e60dd7d0f fix(hover-nav): 修复按钮非正圆形状的问题 2025-12-25 10:01:23 +08:00
01b620fcb3 chore(config): 支持从本地文件中获取.loacl.config.js 获取 2025-12-25 09:56:29 +08:00
7d00f9d0f8 fix(member): 修复pages_tool/member/invite_friends.vue组件样式错误 2025-12-25 09:55:06 +08:00
be0f4be0ed chore: 调整在H5端上的浮动按钮位置,上移80px 2025-12-24 16:53:27 +08:00
e5d5c961fb chore(ui): 微调 2025-12-24 16:04:10 +08:00
25337be57f chore(浮动按钮): 样式定制 2025-12-24 15:22:18 +08:00
43e66b1798 fix(ns-goods-action-icon): 客服联系按钮英文状态下,点击无效 2025-12-24 14:49:13 +08:00
d8d6c5631f feat(lang): 实现浮动按钮切换语言功能 2025-12-24 14:45:17 +08:00
d2da799dfc fix(diy-article): display-multiple-items 不能大于 swiper-item 数量 2025-12-23 17:59:23 +08:00
d54a7e9f13 fix(code): 修正代码错误及不严谨的地方 2025-12-23 17:56:20 +08:00
5588165f2c fix(code): 修正代码错误及不严谨的地方 2025-12-23 16:59:14 +08:00
68ecdd6e3c feat: 增加文件查询及展示的模块 2025-12-23 15:33:22 +08:00
306a6af89c feat: 增加介质材料模块及案例展示模块 2025-12-23 15:09:34 +08:00
bccb572552 feat(weapp): 增加小程序发货页面 2025-12-23 14:05:10 +08:00
92db148237 chore(diy-article): 支持diy-article 组件关于滚动控制的参数化设置 2025-12-23 11:45:03 +08:00
96049bbcae feat(diy-article): 支持diy-article 组件内的内容自动垂直滚动 2025-12-23 11:22:29 +08:00
1d7629168c feat: 将底部导航、分类项、分类、图标作为组件添加Diy面板中 2025-12-23 10:19:07 +08:00
b91d76f2d1 feat: 新增视频列表diy-video-lis 组件 2025-12-23 10:08:31 +08:00
3758dbcb03 feat: 新增数字展示diy-digit组件 2025-12-23 10:04:57 +08:00
e49b1ae8f7 feat: 新增图片导航diy-image-nav 组件 2025-12-23 10:02:53 +08:00
8e2f8ef4e8 chore(contact): 增加滚动到顶端的按钮 2025-12-23 09:36:49 +08:00
66a58fd8e8 chore: 调整首页的回到顶端的按钮的样式 2025-12-23 09:35:41 +08:00
f835fafeec chore(h5): 去掉不需要的注释内容 2025-12-22 17:53:32 +08:00
82581efb10 chore: common/js/config.js 更新配置,方便调整 2025-12-22 17:52:23 +08:00
dd7191d3ef chore: 增加快应用/纯H5的如何获取uniacid的处理说明 2025-12-22 17:18:45 +08:00
521c460b2e chore: App.vue 增加快应用/纯H5的如何获取uniacid的处理说明 2025-12-22 17:18:08 +08:00
ed8d3ebd18 chore(h5): 如果是H5环境,首页不强制必须登录 2025-12-22 17:16:42 +08:00
171779455f fix: 商品列表图标按钮切换,页面会跳转 2025-12-22 14:50:00 +08:00
0340c3b67c feat: 支持商品列表样式切换 2025-12-22 14:42:11 +08:00
8cc804bc6b chore(event-bus.js): 去除多余的注释内容 2025-12-20 17:31:51 +08:00
dd2006dd8c fix: 解决首页需要登录才能看到元素 2025-12-20 17:30:01 +08:00
8a934348a6 fix(event-bus.js): defaultAsyncHandler 的返回值,被截 2025-12-20 17:02:24 +08:00
973c8dddd0 fix(index): 修复首页在已登录状态下,不能点击的问题 2025-12-20 16:52:29 +08:00
3556691b56 chore(store): 更新store定义 2025-12-20 15:57:36 +08:00
3c795256e8 chore(js): 新增navigation.js 2025-12-20 15:57:11 +08:00
72b2087204 chore(components): 只增加组件 2025-12-20 15:51:44 +08:00
53a71d7afd chore(js): 新增ai、客服、安全事件、wxwork-jssdk.js 2025-12-20 15:50:04 +08:00
3cbe0263ea chore: 修改遗留代码单词拼写错误 2025-12-20 15:41:32 +08:00
f3bb6a1782 chore:智能体客服和原来的客服都在 2025-12-17 15:43:37 +08:00
2a0489d4b2 chore:能正常运行 2025-12-17 11:17:50 +08:00
1032 changed files with 174261 additions and 53654 deletions

View File

@@ -0,0 +1,155 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,160 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,172 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,107 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,143 @@
---
name: "OPSX: Sync"
description: Sync delta specs from a change to main specs
category: Workflow
tags: [workflow, specs, experimental]
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result

View File

@@ -0,0 +1,159 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
**Workspace guard:** If status JSON reports `actionContext.mode: "workspace-planning"` and `allowedEditRoots` is empty, explain that full workspace apply is not supported in this slice. Treat linked repos and folders as read-only context, ask the user to select an affected area through an explicit implementation workflow, and STOP before editing files.
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,117 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other)
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace archive is not supported in this slice and STOP. Do not move workspace changes into repo-local archives or edit linked repos.
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
```bash
mkdir -p "<planningHome.changesDir>/archive"
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move `changeRoot` to the archive directory
```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,287 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context**
- Run `openspec status --change "<name>" --json`.
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
▼
┌───────────┐
│ Session │
└─────┬─────┘
│
▼
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,111 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

View File

@@ -0,0 +1,147 @@
---
name: openspec-sync-specs
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.4.1"
---
Sync delta specs from a change to main specs.
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show changes that have delta specs (under `specs/` directory).
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Resolve change context**
Run:
```bash
openspec status --change "<name>" --json
```
If status reports `actionContext.mode: "workspace-planning"`, explain that workspace spec sync is not supported in this slice and STOP. Do not fall back to repo-local paths or edit linked repos.
3. **Find delta specs**
Use `artifactPaths.specs.existingOutputPaths` from the status JSON as the list of delta spec files.
Each delta spec file contains sections like:
- `## ADDED Requirements` - New requirements to add
- `## MODIFIED Requirements` - Changes to existing requirements
- `## REMOVED Requirements` - Requirements to remove
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
If no delta specs found, inform user and stop.
4. **For each delta spec, apply changes to main specs**
For each repo-local capability delta spec path returned by the CLI:
a. **Read the delta spec** to understand the intended changes
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
c. **Apply changes intelligently**:
**ADDED Requirements:**
- If requirement doesn't exist in main spec → add it
- If requirement already exists → update it to match (treat as implicit MODIFIED)
**MODIFIED Requirements:**
- Find the requirement in main spec
- Apply the changes - this can be:
- Adding new scenarios (don't need to copy existing ones)
- Modifying existing scenarios
- Changing the requirement description
- Preserve scenarios/content not mentioned in the delta
**REMOVED Requirements:**
- Remove the entire requirement block from main spec
**RENAMED Requirements:**
- Find the FROM requirement, rename to TO
d. **Create new main spec** if capability doesn't exist yet:
- Create `openspec/specs/<capability>/spec.md`
- Add Purpose section (can be brief, mark as TBD)
- Add Requirements section with the ADDED requirements
5. **Show summary**
After applying all changes, summarize:
- Which capabilities were updated
- What changes were made (requirements added/modified/removed/renamed)
**Delta Spec Format Reference**
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL do something new.
#### Scenario: Basic case
- **WHEN** user does X
- **THEN** system does Y
## MODIFIED Requirements
### Requirement: Existing Feature
#### Scenario: New scenario to add
- **WHEN** user does A
- **THEN** system does B
## REMOVED Requirements
### Requirement: Deprecated Feature
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
**Key Principle: Intelligent Merging**
Unlike programmatic merging, you can apply **partial updates**:
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
- The delta represents *intent*, not a wholesale replacement
- Use your judgment to merge changes sensibly
**Output On Success**
```
## Specs Synced: <change-name>
Updated main specs:
**<capability-1>**:
- Added requirement: "New Feature"
- Modified requirement: "Existing Feature" (added 1 scenario)
**<capability-2>**:
- Created new spec file
- Added requirement: "Another Feature"
Main specs are now updated. The change remains active - archive when implementation is complete.
```
**Guardrails**
- Read both delta and main specs before making changes
- Preserve existing content not mentioned in delta
- If something is unclear, ask for clarification
- Show what you're changing as you go
- The operation should be idempotent - running twice should give same result

33
.github/workflows/i18n-audit.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# GitHub Actions 示例:i18n audit on PR
name: i18n-audit
on:
pull_request:
paths:
- 'lang/**'
- 'lang-bundle.json'
- '**/*.vue'
- '**/*.js'
- 'scripts/i18n_audit.py'
- 'scripts/audit_lang_bundle_v2.py'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run i18n audit
run: |
python scripts/i18n_audit.py --strict
- name: Upload audit report
if: always()
uses: actions/upload-artifact@v4
with:
name: i18n-audit-report
path: scripts/lang_audit_report_v2.json

85
.gitignore vendored
View File

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

13
.hbuilderx/launch.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version" : "1.0",
"configurations" : [
{
"openVueDevtools" : true,
"type" : "uni-app:h5"
},
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

43
.husky/pre-commit Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env sh
#
# 自动 hook:i18n 完整性。
# 在 commit 前,如果改动触及 lang/、使用 $lang() 的 .vue/.js,或 lang-bundle.json,
# 就跑一次审计,真问题会拦下 commit。
#
# 启用方法:
# 1) 项目根已有 .husky/pre-commit(本脚本)
# 2) 把这个 commit-msg / pre-commit 加入 husky 钩子(本仓库用 simple-git-hooks
# 或 husky 都行,本脚本不依赖特定框架)
#
# 也可以在 CI 跑:
# python scripts/i18n_audit.py --strict
set -e
# 检测本次 diff 是否触动相关文件
changed=$(git diff --cached --name-only HEAD)
relevant=$(echo "$changed" | grep -E '(lang/|lang-bundle\.json|\.vue$|\.js$)' | grep -v 'scripts/i18n_audit\|scripts/audit\|scripts/I18N_AUDIT' || true)
if [ -z "$relevant" ]; then
echo "[pre-commit] No lang-touching files; skip i18n audit."
exit 0
fi
echo "[pre-commit] Running i18n audit on changed files:"
echo "$relevant" | sed 's/^/ /'
cd "$(git rev-parse --show-toplevel)"
if command -v python >/dev/null 2>&1; then
if python scripts/i18n_audit.py --strict; then
echo "[pre-commit] i18n OK"
exit 0
else
echo "[pre-commit] i18n FAILED — 修复方法见 scripts/I18N_AUDIT.md"
exit 1
fi
else
echo "[pre-commit] python not available; skipping"
exit 0
fi

265
.local.config.js Normal file
View File

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

55
.local.config.js.example Normal file
View File

@@ -0,0 +1,55 @@
// 本地调试配置示例文件
// 复制此文件并重命名为 local.config.js 以使用自定义本地配置
const localDevConfig = ({
'460': { // 制氧设备平台
uniacid: 460,
domain: 'https://xcx30.5g-quickapp.com/',
},
'576-xcx30.5g': { // 活性石灰装备
uniacid: 576,
domain: 'https://xcx30.5g-quickapp.com/',
},
'2285': { // 数码喷墨墨水
uniacid: 2285,
domain: 'https://xcx.aigc-quickapp.com/',
},
'2811': { // POCT检测分析平台
uniacid: 2811,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2812': { // IVD数商模式
uniacid: 2812,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2724': { // 生物菌肥
uniacid: 2724,
domain: 'https://xcx.aigc-quickapp.com/',
},
'2505': { // 煤矿钻机
uniacid: 2505,
domain: 'https://xcx.aigc-quickapp.com/',
},
'2777': { // 养老服务
uniacid: 2777,
domain: 'https://xcx.aigc-quickapp.com/',
},
'1': { // 开发平台
uniacid: 1,
domain: 'https://dev.aigc-quickapp.com',
},
'1-test': { // 测试平台
uniacid: 1,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
},
'local-2-dev': { // 本地开发测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
},
})['2812']; // 选择要使用的环境配置
export default localDevConfig;

459
App.vue
View File

@@ -1,69 +1,419 @@
<script>
import auth from 'common/js/auth.js';
import colorList from 'common/js/style_color.js'
import configExternal, { themeConfig, privacyConfig } from 'common/js/config-external.js'
import Lang from 'common/js/lang.js';
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
import aiService from 'common/js/ai-service.js';
import { markNeedStartupAd } from 'common/js/startup-ad.js';
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
let _appHasShown = false;
import {
Weixin
} from 'common/js/wx-jssdk.js';
// ============================================================
// 隐私弹窗 URL/路由规则 (H5 + 微信小程序 + 鸿蒙元服务)
//
// 配置来源 (沿用 config-external.js 同构模式):
// 1. 本地兜底: privacyConfig.urlRulesBase (与 langConfig/themeConfig 同构)
// 2. 远程 patch: /api/config/init data.privacy_url_rules (由 store/init 融合)
// 融合策略 (同上一轮):
// - mode: 远程有值则覆盖, 否则用本地
// - patterns: 按 patch.merge 字段决定 (replace 默认 / union / intersect)
// 匹配: 子串匹配
// H5: pathname + search + hash
// 小程序/鸿蒙: 当前页面 route + query
// 命中白名单后, 仍会走 storage / 平台级隐私检查, 已同意则不再弹
// ============================================================
const PRIVACY_URL_RULES_BASE = privacyConfig.urlRulesBase
// 启动期配置注入: 把 privacyConfig 的 bootTimeout / waitChannels / signSecret 灌进 PrivacyManager
// 这样 PrivacyManager.show() 即便被业务侧独立调用, 也能感知启动期资源就绪状态
// 签名密钥注入: 远程 patch 必须通过 verifyRemotePatch() 才允许 applyWithMerge
PrivacyManager.setExternalBootTimeout(configExternal.getPrivacyBootTimeout())
PrivacyManager.setWaitChannels(privacyConfig.waitChannels || {})
PrivacyManager.setSignSecret(privacyConfig.signSecret || '')
// 登记本地 base 规则, 供后续远程 patch 融合时读取
// 必须在 setStorageKey / loadPersistedRules 之前完成, 因为 store/init action
// 会通过 PrivacyManager.__getBaseRules() 读取这个 base
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
// uniacid 隔离的持久化 key
// 关键: 持久化必须基于 uniacid 隔离, 否则多租户切换会读到对方租户的规则
// 无 uniacid 时降级到 _anon (使用 localStorage 之外的隔离, 避免与任何具体租户冲突)
const _uniacid = uni.getStorageSync('uniacid')
const _persistKey = _uniacid
? ('privacy_url_rules_' + _uniacid)
: 'privacy_url_rules__anon'
PrivacyManager.setStorageKey(_persistKey)
// 1) 先尝试恢复上次的"已融合规则" (跨冷启动)
// 无持久化值 → 退化为 base
const _persisted = PrivacyManager.loadPersistedRules()
if (_persisted && _persisted.mode && Array.isArray(_persisted.patterns)) {
PrivacyManager.setUrlRules(_persisted)
} else {
// 2) 用本地 base 兜底
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
}
// 业务侧可监听: 远程下发完成后, 会触发 'privacy:rules-updated'
uni.$on && uni.$on('privacy:rules-updated', (info) => {
// 只在远程下发时打印, 避免被本地 base 加载/持久化恢复时的 emit 噪音淹没
if (info && info.source === 'remote' && console && console.log) {
console.log('[privacy] rules updated by remote, mode=' + info.mode + ', patterns=' + info.patterns.length)
}
})
/**
* 取 onLaunch options 作为首次启动的兜底路径
* H5: options.path/query 不一定存在, 此时用 window.location
* 小程序/鸿蒙: options.path 是启动路径, options.query 是启动参数
* @param {Object} launchOptions onLaunch 的 options
* @returns {Object} urlFallback
*/
function _buildPrivacyUrlFallback(launchOptions) {
const fallback = { pathname: '', search: '', hash: '', route: '', query: null }
// #ifdef H5
if (typeof window !== 'undefined' && window.location) {
fallback.pathname = window.location.pathname || ''
fallback.search = window.location.search || ''
fallback.hash = window.location.hash || ''
}
// #endif
// #ifndef H5
if (launchOptions && launchOptions.path) {
fallback.route = launchOptions.path
fallback.query = launchOptions.query || null
}
// #endif
return fallback
}
// ============================================================
// 主题/语言启动
// ============================================================
export default {
mixins: [auth],
onLaunch: function(options) {
// console.log(options.query.uniacid)
if(options.query.uniacid){
uni.setStorageSync('uniacid', options.query.uniacid);
console.log(uni.getStorageSync('uniacid'))
data() {
return {
// remote-only 模式下,业务可通过 await getApp().$langReady 等待语言包就绪
$langReady: Promise.resolve({ ok: true, source: 'local' })
};
},
// 同步钩子:在 page mounted 之前同步设置隐私弹窗的 skip 状态
// 必须在 onLaunch (async) 之前跑,否则 onLaunch 第一个 await 让出事件循环时
// privacy-popup.vue 已经挂载,会走 consumePendingShow 兜底分支把弹窗弹出来
created() {
if (!PrivacyManager.shouldShowByUrlRule()) {
PrivacyManager.markSkipped()
}
},
onLaunch: async function(options) {
// ============================================================
// 主题提前加载(关键):让首屏渲染前 CSS 变量已挂上,避免闪一下
// ============================================================
// ---- 0) 同步预热: 立即读 storage 拿 preset,触发 require(不 await)
// 目的: 让 loadTheme 的 require 提前进入 microtask,后续 await 几乎瞬时
const cached = uni.getStorageSync('themeStyle');
let presetName = themeConfig.defaultTheme;
if (cached) {
presetName = (typeof cached === 'object') ? cached.name : cached;
}
// 触发 preset require(不 await,缓存 promise 到 configExternal.loadedConfigs)
configExternal.loadTheme(presetName);
// ---- 1) uniacid 立即写入(后续所有 HTTP 都要用到)
const uniacidChanged = options.query && options.query.uniacid && String(uni.getStorageSync('uniacid') || '') !== String(options.query.uniacid);
if (options.query && options.query.uniacid) {
uni.setStorageSync('uniacid', options.query.uniacid);
}
// 关键: 即便 uniacid 未变化 (首次启动 options.query 没传 uniacid),
// 也要尝试从 storage 拿真实 uniacid 并设置 PrivacyManager 的 persist key
// 否则 App.vue 顶层用 _anon 占位, 后续 persistRules 写到 _anon key 下,
// 下次冷启动 (有 uniacid) 切到 uniacid key 时读不到上次结果
try {
const _nowUniacid = uni.getStorageSync('uniacid')
const _newPersistKey = _nowUniacid
? ('privacy_url_rules_' + _nowUniacid)
: 'privacy_url_rules__anon'
if (_newPersistKey !== _persistKey) {
PrivacyManager.setStorageKey(_newPersistKey)
const _newPersisted = PrivacyManager.loadPersistedRules()
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
PrivacyManager.setUrlRules(_newPersisted)
} else {
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
}
}
} catch (e) {}
if (uniacidChanged) {
try { Lang.onUniacidChange(); } catch (e) {}
try { ThemeOverride.onUniacidChange(); } catch (e) {}
// 切租户: 切换 PrivacyManager 持久化 key 并重新应用本地 base
// (远程 patch 由后续 /api/config/init 重新下发并融合)
try {
const newUniacid = String(options.query.uniacid)
const newPersistKey = newUniacid
? ('privacy_url_rules_' + newUniacid)
: 'privacy_url_rules__anon'
PrivacyManager.setStorageKey(newPersistKey)
PrivacyManager.setBaseRules(PRIVACY_URL_RULES_BASE)
// 切租户: 重新加载该租户上次持久化的规则
// 无持久化值则退化为 base
const _newPersisted = PrivacyManager.loadPersistedRules()
if (_newPersisted && _newPersisted.mode && Array.isArray(_newPersisted.patterns)) {
PrivacyManager.setUrlRules(_newPersisted)
} else {
PrivacyManager.setUrlRules(PRIVACY_URL_RULES_BASE)
}
} catch (e) {}
try { uni.$emit && uni.$emit('uniacid:changed'); } catch (e) {}
}
// remote-only 模式:
// H5: main.js 已 await boot 完成, 这里秒回 (splash 也由 main.js 全权负责)
// 小程序/鸿蒙: main.js 同步 $mount() 之前已经调用 splashState.show(),
// 这里不需要重复调用
const langBoot = Lang.boot();
this.$langReady = langBoot;
// ---- 2) 启动期关键资源就绪 (并行 + 显式 await)
// 隐私弹窗的诉求: 必须在 i18n + theme + remote-config 三态都就绪后才弹
// 这里把三个通道收敛为一个 await 链, 不再用 fire-and-forget
// 任何通道自身有超时保护 (Lang.bootTimeout=langConfig.remote.bootTimeout=5000,
// ThemeOverride.bootTimeout=themeConfig.overrideRemote.bootTimeout=5000)
// 总超时熔断走 configExternal.getPrivacyBootTimeout() (默认取两者最大值+1000ms)
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大 privacyConfig.bootTimeout
// 业务侧想"快速失败" → 调小或关闭 privacyConfig.waitChannels
const _i18nDone = langBoot.then((res) => {
// 进度上报: i18n 完成 → 30%
try { splashState.reportProgress('lang', { source: 'channel' }); } catch (e) {}
// i18n fallback toast (原逻辑保留)
if (res && res.status === 'fail' && res.reason && res.reason !== 'unknown') {
if (globalThis.__i18nFallbackToastShown) return res;
globalThis.__i18nFallbackToastShown = true;
setTimeout(() => {
try {
uni.showToast({
title: `Failed to load i18n. ${res.reason} Please check your network and try again.`,
icon: 'none',
duration: 10000,
});
} catch (e) {}
}, 3000);
}
return res;
}, (e) => {
console.warn('[i18n] boot fail', e);
try { splashState.reportProgress('lang', { source: 'error' }); } catch (e) {}
return { ok: false, source: 'remote-failed', status: 'fail', reason: 'exception', lang: '' };
})
const _themeFirstApply = this.$store.dispatch('applyThemeWithRemote', presetName)
.then(() => {
try { splashState.reportProgress('theme', { source: 'channel' }); } catch (e) {}
})
.catch(e => {
console.warn('[theme] pre-apply fail', e);
try { splashState.reportProgress('theme', { source: 'error' }); } catch (e) {}
return null
})
const _themeOverride = ThemeOverride.boot()
.then(() => {
try { splashState.reportProgress('override', { source: 'channel' }); } catch (e) {}
})
.catch(e => {
console.warn('[theme] boot fail', e);
try { splashState.reportProgress('override', { source: 'error' }); } catch (e) {}
return null
})
// /api/config/init 走 fire-and-forget:
// - race 不再等它, popup 可以更早弹出
// - 远程 patch (privacy_url_rules 等) 后续异步到达时通过 applyWithMerge 触发双阶段弹窗
// - 失败也不阻塞 boot 链路
//
// 进度上报单独走 (展示效果, 不依赖 _remoteConfig resolve)
this.$store.dispatch('init').then(() => {
try { splashState.reportProgress('config', { source: 'channel' }); } catch (e) {}
}).catch(e => {
console.warn('[config] init fail', e);
try { splashState.reportProgress('config', { source: 'error' }); } catch (e) {}
})
const _remoteConfig = Promise.resolve(null) // 占位 promise, race 不再等 init
// 注入启动期等待通道 → ConfigExternal 持有
// PrivacyManager.show() 时通过 getPrivacyReadyGuard(waitChannels) 自动拼成 readyGuard
configExternal.setReadyWaiters({
lang: _i18nDone,
theme: _themeFirstApply,
themeOverride: _themeOverride,
remoteConfig: _remoteConfig,
})
const _privacyBootTimeout = configExternal.getPrivacyBootTimeout()
const _bootRaceResult = await Promise.race([
Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
new Promise(resolve => setTimeout(() => resolve('timeout'), _privacyBootTimeout))
])
if (_bootRaceResult === 'timeout') {
// race 超时上报 (source: 'timeout', 不允许 percent 后退, 已有最大值为准)
try { splashState.reportProgress('config', { percent: 80, source: 'timeout' }); } catch (e) {}
}
// privacy 阶段开始上报
try { splashState.reportProgress('privacy', { source: 'channel' }); } catch (e) {}
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
// 只弹一次: 同意后写 storage,后续不再弹
// 异步查询平台隐私状态(微信/快应用), H5 同步返回
// H5 直访特定 URL(子串匹配)时跳过本次弹窗,不写 storage,下次正常访问其他页面还会弹
// urlFallback: 首次启动 onLaunch 时 getCurrentPages 还没页面, 用 onLaunch options 兜底
// readyGuard: 双保险 — 若调用方没传, PrivacyManager 内部从 _externalReadyGuard 取
// (= ConfigExternal.getPrivacyReadyGuard(privacyConfig.waitChannels))
// 二次 await 仍保留: race 后还有可能某个通道没真完成, 二次 await 兜底
//
// 鸿蒙元服务(双轨制):
// 已接入平台隐私托管, 鸿蒙系统自动弹出系统级隐私弹窗.
// 应用层不调 PrivacyManager.show() (会和系统弹窗叠加被驳回).
// #ifdef MP-HARMONY
// 鸿蒙: 走平台隐私托管, markSkipped 写 storage
try {
await Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig])
PrivacyManager.markSkipped()
} catch (e) { console.warn('[privacy] MP-HARMONY markSkipped fail', e); }
// #endif
// #ifndef MP-HARMONY
try {
await PrivacyManager.show({
urlFallback: _buildPrivacyUrlFallback(options),
readyGuard: Promise.all([_i18nDone, _themeFirstApply, _themeOverride, _remoteConfig]),
})
} catch (e) { console.warn('[privacy] show fail', e); }
// #endif
// splash 必须在隐私弹窗决策后再 hide (否则 splash 关掉瞬间弹窗还没渲染, 视觉闪一下)
// mode='fast': 隐私弹窗即将出现, splash 用 80ms 短动画, 让 modal 更快接管视觉
// 避免 splash 和弹窗同时显示造成的"双遮罩"感, 减少约 400ms 启动时间
// 只有 remote-only 模式才用了 splash (其他场景 src 模式不需要等远端)
//
// 关键 (鸿蒙 + 其他平台): 这里只发"软隐藏"信号, splash 实际关闭要等
// splashState._softHide() 看到 _hasMounted && _pageReady 才真关。
// _pageReady 由首页 diy-group 渲染完时调 notifyPageReady() 触发。
// 这样保证 splash **一直显示**, 直到首页 diy 内容真的渲染好,
// 避免 splash 关闭后到 diy 内容出现之间出现白屏。
//
// 鸿蒙端 (mode='strict'):
// 鸿蒙系统弹窗是用户视觉锚点, 应用层 splash 必须**真等到**diy 渲染好
// 才能关, 不走 MOUNT_GRACE_MS/PAGE_READY_GRACE_MS 软隐藏兜底
// (软隐藏兜底 1+2=3s 经常不够用, 会导致 splash 提前 hide → 白屏)
// strict 模式只受 DEFAULT_TIMEOUT=12s 总超时兜底, 实际由 notifyPageReady() 触发
// #ifndef H5
const _needSplash = typeof Lang !== 'undefined' && Lang.isRemoteOnly && Lang.isRemoteOnly();
if (_needSplash) {
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
// 避免 splash 提前关导致白屏
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
}
// #endif
// ---- 3) 远端就绪后,主题再 apply 一次(preset + remote 合并 )
// 这一次 setThemeStyle + themeColorSet 是同步路径(preset 已在 cache,remote 也在 cache)
// 如果跟 step 2 的 apply 值相同,Vuex 会重复 commit 但页面无视觉变化
await this.$store.dispatch('applyThemeWithRemote', presetName);
// ============================================================
// 后续: 监听器 / 平台条件 / 其它 storage commit(全部同步/轻量)
// ============================================================
// 监听远端翻译更新事件(管理后台改了词条后由后端推送 / 主动 reload)
// 注意:lang.js 内 emitUpdated 已经 commit 了 setComponentRefresh,这里
// 只做副作用(navigation title 等),不重复 forceUpdate
uni.$on && uni.$on('lang:updated', (info) => {
if (info && info.lang) {
Lang.refresh(true); // true = 显式推送 emitUpdated(虽然 loadOverrides 内部已发,这里保险起见)
}
});
// 监听远端主题覆写更新(管理后台改了 token 后,本地 Vuex 重新合并并应用)
// 关键: 走 applyThemeWithRemote action,保留原 setThemeStyle/themeColorSet 链路不动
uni.$on && uni.$on('theme:updated', () => {
// 重新从 storage 读 preset(可能用户在管理页切了预设)
const cur = uni.getStorageSync('themeStyle');
const curName = (cur && typeof cur === 'object') ? cur.name : (cur || 'default');
this.$store.dispatch('applyThemeWithRemote', curName);
});
// 监听 uniacid 切换: 通知主题运行时清旧租户缓存,下次 refresh 拿到新值
uni.$on && uni.$on('uniacid:changed', () => {
try { ThemeOverride.onUniacidChange(); } catch (e) {}
});
// i18n 远端拉取失败的 toast 提示已迁移到上方 boot() 完成的 then() 中,
// 直接根据 boot 返回值判断,不再依赖 lang:fallback 事件
uni.hideTabBar();
// #ifdef MP
const updateManager = uni.getUpdateManager();
updateManager.onCheckForUpdate(function(res) {
// 请求完新版本信息的回调
});
updateManager.onUpdateReady(function(res) {
uni.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
success(res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate();
}
}
if (uni.getUpdateManager) {
const updateManager = uni.getUpdateManager();
updateManager.onCheckForUpdate(function(res) {
// 请求完新版本信息的回调
});
});
updateManager.onUpdateFailed(function(res) {
// 新的版本下载失败
});
updateManager.onUpdateReady(function(res) {
uni.showModal({
title: Lang.lang('common.updateTip'),
content: Lang.lang('common.updateReady'),
success(res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate();
}
}
});
});
updateManager.onUpdateFailed(function(res) {
// 新的版本下载失败
});
}
// #endif
// #ifdef H5
if (uni.getSystemInfoSync().platform == 'ios') {
if (this.$util.getDeviceInfo().platform == 'ios') {
uni.setStorageSync('initUrl', location.href);
}
// #endif
uni.onNetworkStatusChange(function(res) {
if (!res.isConnected) {
uni.showModal({
title: '网络失去链接',
content: '请检查网络链接',
showCancel: false
});
}
});
this.$store.dispatch('init');
// 存储到store中
// 主题风格
if (uni.getStorageSync('themeStyle')) {
this.$store.commit('setThemeStyle', colorList[uni.getStorageSync('themeStyle')]);
if (uni.onNetworkStatusChange) {
uni.onNetworkStatusChange(function(res) {
if (!res.isConnected) {
uni.showModal({
title: Lang.lang('common.networkLost'),
content: Lang.lang('common.networkCheck'),
showCancel: false
});
}
});
}
// /api/config/init 已在 step 2.5 await 过 (含 800ms 超时), 此处不再重复触发
// 原 fire-and-forget 注释: 不阻塞后续同步 commit
// 现在 init 已被升级为 "首次配置等待通道", 这里留空以避免重复 dispatch
// 插件是否存在
if (uni.getStorageSync('addonIsExist')) {
this.$store.commit('setAddonIsExist', uni.getStorageSync('addonIsExist'));
@@ -122,6 +472,14 @@
if (uni.getStorageSync('memberInfo')) {
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
try {
if (aiService && typeof aiService.onUserChange === 'function') {
aiService.onUserChange();
}
} catch (e) { /* ignore */ }
// 查询购物车信息
this.$store.dispatch('getCartNumber');
}
@@ -150,6 +508,16 @@
},
onShow: function(options) {
// #ifdef MP
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
if (_appHasShown) {
try { Lang.onAppReentry(); } catch (e) {}
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
try { markNeedStartupAd(); } catch (e) {}
}
_appHasShown = true;
// 自动授权登录
this.getAuthInfo();
if (this.$store.state.token) {
@@ -264,7 +632,6 @@
imgUrl: this.$util.img(share_data.imgUrl)
},
res => {
console.log(res);
}
);
}
@@ -301,7 +668,7 @@
this.shareConfig();
}
},
// 代表在wacth里声明了firstName这个方法之后立即先去执行handler方法
// 代表在watch里声明了firstName这个方法之后立即先去执行handler方法
immediate: true
}
}
@@ -312,7 +679,7 @@
@import './common/css/iconfont.css';
@import './common/css/icondiy.css'; // 自定义图标库
@import './common/css/icon/extend.css'; // 扩展图标库
page{
page {
background: #f4f6fa;
}
</style>

View File

@@ -30,7 +30,7 @@ image {
/* #ifdef H5 */
height: calc(100vh - 88rpx);
/* #endif */
/* #ifdef MP-WEIXIN */
/* #ifdef MP */
height: 100vh;
/* #endif */
}
@@ -123,7 +123,7 @@ image {
}
.choose-store {
/deep/ .uni-popup__wrapper{
::v-deep .uni-popup__wrapper{
background: none!important;
}
}

View File

@@ -118,6 +118,8 @@
.goods-video video {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.goods-video .uni-video-cover {
@@ -870,13 +872,13 @@
// 海报
// .uni-popup__wrapper-box
.poster-layer {
/deep/ .uni-popup__wrapper.center {
::v-deep .uni-popup__wrapper.center {
width: 100vw!important;
height: 100vh!important;
background: none!important;
}
/deep/ .uni-popup__wrapper.uni-custom.center .uni-popup__wrapper-box {
::v-deep .uni-popup__wrapper.uni-custom.center .uni-popup__wrapper-box {
max-width: 100vw!important;
max-height: 100vh!important;
background: none!important;
@@ -1223,6 +1225,7 @@
}
.popup-layer {
position: relative;
background: #fff;
.head-wrap {
font-size: $font-size-toolbar;

View File

@@ -5705,9 +5705,9 @@
@font-face {
font-family: "iconj"; /* Project id 1752049 */
src: url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff2?t=1727498649486') format('woff2'),
url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff?t=1727498649486') format('woff'),
url('//at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.ttf?t=1727498649486') format('truetype');
src: url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff2?t=1727498649486') format('woff2'),
url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.woff?t=1727498649486') format('woff'),
url('https://at.alicdn.com/t/c/font_1752049_n0jw9cvfunm.ttf?t=1727498649486') format('truetype');
}
.iconj {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
// 修复图片垂直对齐问题,解决两张图片上下有空白缝隙问题
::v-deep ._img {
vertical-align: top;
}

View File

@@ -25,7 +25,7 @@ view {
background-size: 100% 260rpx;
background-repeat: no-repeat;
// #ifdef MP-WEIXIN
// #ifdef MP
background-size: 100% 380rpx;
// #endif
@@ -52,7 +52,7 @@ view {
background: linear-gradient(180deg, $base-color 10%, $color-bg 100%);
background-size: 100% 260rpx;
// #ifdef MP-WEIXIN
// #ifdef MP
background-size: 100% 380rpx;
// #endif
@@ -377,7 +377,7 @@ view {
}
}
/deep/ .goods-form {
::v-deep .goods-form {
display: flex;
align-items: center;
position: relative;
@@ -462,7 +462,10 @@ view {
background: #f6f6cb;
text-align: left;
padding: 10rpx 20rpx;
color: #FF0000;
// P0: 角标红 → var(--goods-cart-num-corner)
// 注: black preset 值是 #FF0000, default 是 #FC5227 — var() 兼顾
// 兼容: 微信小程序 / 鸿蒙元服务 — color 接受 var() 无问题
color: var(--goods-cart-num-corner, #FF0000);
}
.order-submit {
position: fixed;
@@ -1286,9 +1289,13 @@ view {
margin-right: 10rpx;
line-height: 1;
font-size: 36rpx;
background-image:linear-gradient(156deg, #814635 0%, #3A221B 100%);
-webkit-background-clip:text;
-webkit-text-fill-color:transparent;
// P0: 会员图标渐变 → var(--member-icon-grad-start/end)
// 注: 暂用 placeholder 命名,后续如需主题化可加到 style_color.js
// 兼容: 微信小程序 / 鸿蒙元服务 — linear-gradient 嵌套 var() 现代 runtime 支持
background: #814635; // 兜底
background-image: linear-gradient(156deg, var(--member-icon-grad-start, #814635) 0%, var(--member-icon-grad-end, #3A221B) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.info{
@@ -1354,7 +1361,7 @@ view {
border-bottom: 2rpx solid #F4F4F6;
}
/deep/ .form-wrap {
::v-deep .form-wrap {
margin: 0 24rpx;
.icon-right {

1355
common/js/ai-service.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,11 @@ export default {
/**
* 获取用户登录凭证code
*/
getCode(callback) {
getCode(callback, fallbackFunc) {
console.log(`-----> auth.js getCode`)
// 微信小程序
// #ifdef MP-WEIXIN
console.log(`微信小程序登录`)
uni.login({
provider: 'weixin',
timeout: 3000,
@@ -29,12 +31,15 @@ export default {
.unionid;
typeof callback == 'function' && callback(this.authInfo);
} else {
fallbackFunc && fallbackFunc(res);
this.$util.showToast({
title: res.message ? res.message : '小程序配置错误'
});
title: res.message ? res.message : this.$lang('auth.miniProgramConfigError')
});
}
}
})
} else {
fallbackFunc && fallbackFunc(res);
}
},
fail: (res) => {
@@ -46,6 +51,7 @@ export default {
});
}
// #endif
fallbackFunc && fallbackFunc(res);
}
})
// #endif
@@ -66,9 +72,10 @@ export default {
.user_id;
typeof callback == 'function' && callback(this.authInfo);
} else {
fallbackFunc && fallbackFunc(res);
this.$util.showToast({
title: res.message ? res.message : '小程序配置错误'
});
title: res.message ? res.message : this.$lang('auth.miniProgramConfigError')
});
}
}
})
@@ -94,6 +101,9 @@ export default {
if (res.code >= 0) {
location.href = res.data;
}
},
fail: err => {
fallbackFunc && fallbackFunc(err);
}
});
}

View File

@@ -0,0 +1,331 @@
// 外置配置管理
// 用于管理需要外置的配置项,如语言包、主题配置等
// 语言包配置
export const langConfig = {
// 语言列表 — 与后端 Lang.php::overrides() 白名单对齐
// 增删需要后端同步改 LangOverrideModel::normalizeAppType 旁的 $allow_lang
langList: ['zh-cn', 'en-us'],
// 默认语言
defaultLang: 'zh-cn',
// 语言包加载方式:'lazy'(按需加载)或 'preload'(预加载)
loadMode: 'lazy',
// 数据源开关
// 数据源开关
// local : 纯本地包,忽略 remote
// remote : 本地兜底 + 后端 KV 覆写(首屏强依赖,见 bootTimeout)
// hybrid : 同 remote,后端不可用时用本地
// remote-only : 完全依赖后端 /api/lang/bundle,本地 lang/ 目录可删除
source: 'remote-only',
// 远端覆写相关
remote: {
overridesUrl: '/api/lang/overrides',
// bundle 接口(remote-only 模式下使用)
bundleUrl: '/api/lang/bundle',
// diff 接口(预留)
diffUrl: '/api/lang/diff',
// 首屏强依赖远端翻译(多租户文案强隔离),阻塞启动
// 但必须有上限避免白屏
// 2026-07-02: 从 2000 改为 5000 — 慢启动场景下,给首屏渲染更多时间
bootTimeout: 5000,
// 单次请求超时(ms)
// 2026-07-02: 从 4000 改为 60000 — 远端首次冷启动可能慢(CDN 边缘冷缓存/跨网段回源/MySQL JOIN)
// 配合 loadBundleTree 的"迟到数据补救"机制,即便超时也能拿到远端最终返回的数据
requestTimeout: 60000,
// 缓存有效期(秒),与后端 Cache::set 600s 对齐;过期后后台刷新
cacheTtl: 600,
// 本地缓存 key 前缀: <prefix><uniacid>_<appType>_<lang>
// 三维隔离,与 lang.js::getOverrideStorageKey / getOverrideMemKey 保持一致
storagePrefix: 'LANG_OVR_',
// 切租户/uniacid 时强制重拉
refreshOnUniacidChange: true,
// 签名校验密钥,留空不校验(需与后端 LANG_SIGN_SECRET 一致)
signSecret: '',
}
};
// 主题配置
export const themeConfig = {
// 主题列表 — 与 style_color.js 顶层的 preset 名严格一致(8 个)
// 关键: 这里列出的名字必须能在 style_color.js (或后端 style_color.php 镜像) 中找到对应 preset
// 历史上曾包含 'red' 但 style_color.js 从未定义 'red' preset, 切到 'red' 主题时
// loadTheme() 会返回 undefined → 走 stub default 兜底样式, 远端也无法补齐
// 2026-07-07 移除 'red' 以保持一致性
themeList: ['default', 'green', 'blue', 'pink', 'gold', 'purple', 'yellow', 'black'],
// 默认主题
defaultTheme: 'default',
// 远端 token 覆写相关(与 langConfig 同构)
// source : 'local' | 'remote' | 'hybrid' | 'remote-only'
// remote.* : 首屏拉取 + 缓存 + 三态机 + 多租户多端隔离
overrideRemote: {
overridesUrl: '/api/theme/overrides',
// 全量 preset 拉取(remote-only 模式下使用)
// 返回 8 个 preset 完整 token,替代本地 style_color.js
presetsUrl: '/api/theme/presets',
bootTimeout: 5000,
requestTimeout: 60000,
// 远端全量 preset 缓存 key 前缀
storagePresetsPrefix: 'THEME_PRESETS_',
storagePrefix: 'THEME_OVR_',
refreshOnUniacidChange: true,
// source: 'remote-only' 时,webpack 会用 NormalModuleReplacementPlugin
// 把 style_color.js 替换为 empty-style-stub.js(只含 emergency fallback)
// 节省主包 ~75KB(完整 8 preset × ~50 token = 80KB → stub 7KB)
//
// 与 lang/ 目录的 remote-only 模式完全同构:
// - 构建期: webpack 替换为 stub
// - 运行时: ThemeOverride.loadAllPresets() 异步拉 /api/theme/presets
// - 首屏: 走 stub 的 default preset,不白屏
// - 切换: 切到非 default preset 时,等远端拉完再应用(由 applyThemeWithRemote
// 订阅 theme:presets-updated 事件驱动)
source: 'remote-only',
}
};
// API配置
export const apiConfig = {
// API请求超时时间
timeout: 30000,
// 是否开启重试
retry: true,
// 重试次数
retryCount: 3,
// 重试间隔时间(毫秒)
retryDelay: 1000
};
// 隐私弹窗配置 (与 langConfig/themeConfig 同构)
// 本地兜底 + 远程 patch (来自 /api/config/init data.privacy_url_rules) 融合
// source: 'local' - 仅本地兜底, 忽略远程 patch
// 'remote' - 本地兜底 + 远程 patch 覆盖 (默认)
// 'hybrid' - 同 remote, 远程失败时退回本地
// 'remote-only' - 完全依赖远程 patch, 本地兜底为空
export const privacyConfig = {
// 本地兜底规则 (mode: 'all'|'only'|'skip', patterns: string[])
// 远程 patch 由 store/index.js 的 init action 通过 PrivacyManager.applyWithMerge() 融合
// patterns 写法:
// - 子串匹配 + 结尾匹配 (双通道), 业务侧可以写完整路径或相对路径
// - subPackage 路由 (如 /pages_tool/member/index) 用短路径 'member/index' 即可命中
urlRulesBase: {
// 场景 2: 只有首页、商品详情、个人中心才弹, 其他页面都不弹
mode: 'only',
patterns: [
// 首页 (含 subPackage 重定向后也是 pages/index/index)
'pages/index/index',
// 商品详情 (subPackage 内可能为 pages_goods/goods/detail/detail, 这里用末尾匹配)
'goods/detail/detail',
// 个人中心 (subPackage 内通常为 pages_tool/member/index)
'member/index',
],
},
// dataSource: 与 langConfig.source 同构
// 'local' 纯本地, 忽略 /api/config/init 的 privacy_url_rules
// 'remote' 本地 + 远程 patch (远程失败时仍用本地)
// 'hybrid' 同 remote, 语义别名
// 'remote-only' 必须有远程 patch, 本地空 patterns → 一律不弹
source: 'remote',
// 启动期等待通道 (弹窗必须等这些 promise 才弹)
// waitLang: Lang.boot() - i18n 数据就绪
// waitTheme: applyThemeWithRemote() - 本地 preset 应用
// waitThemeOverride: ThemeOverride.boot() - brand/light/dark 三桶 token
// waitRemoteConfig: /api/config/init (含 privacy_url_rules 远程 patch)
waitChannels: {
lang: true,
theme: true,
themeOverride: true,
remoteConfig: true,
},
// 总超时熔断 (ms): 所有等待通道超过这个时间强制放行, 不阻塞弹窗
// 取 langConfig.remote.bootTimeout (5000) 和 themeConfig.overrideRemote.bootTimeout (5000) 的最大值
// + 1000ms 余量 → 6000ms. 与原硬编码值一致, 但可通过修改此字段调整.
// 业务侧想"宁愿晚一点也要全资源就绪" → 调大; 想"快速失败" → 调小.
bootTimeout: 6000,
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
// 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
// secret='' → 跳过校验, 接受任意 patch (向后兼容老后端)
// secret='xxx' → 校验 patch.sign, 不匹配则拒绝
// 留空默认不校验. 前端实现是 btoa 弱校验, 仅防简单篡改, 强校验建议用 crypto-js.
signSecret: '',
// 单次 /api/config/init 请求超时 (ms)
// 复用 apiConfig.timeout, 不单独配置
// remote: {
// // 远程 patch 字段名 (后端 /api/config/init 返回 data.privacy_url_rules)
// fieldPath: 'privacy_url_rules',
// // 切租户/uniacid 时强制重拉 (远端 patch 重新下发)
// refreshOnUniacidChange: true,
// // 本地缓存 key 前缀: <prefix><uniacid>
// storagePrefix: 'privacy_url_rules_',
// },
};
// 配置外置管理类
class ConfigExternal {
constructor() {
this.loadedConfigs = {};
this.loadPromises = {};
// 启动期各通道的 await promise (由 App.vue onLaunch 注入)
// 隐私弹窗 show() 时通过 PrivacyManager.getReadyWaiters() 拿到 → 串成 readyGuard
this.readyWaiters = {
lang: null,
theme: null,
themeOverride: null,
remoteConfig: null,
};
}
/**
* 设置启动期各通道的 await promise
* App.vue onLaunch 在 step 2 注入, PrivacyManager.show() 时读取
* @param {Object} waiters { lang?, theme?, themeOverride?, remoteConfig? }
*/
setReadyWaiters(waiters) {
if (!waiters || typeof waiters !== 'object') return
for (const k of Object.keys(this.readyWaiters)) {
if (waiters[k] && typeof waiters[k].then === 'function') {
this.readyWaiters[k] = waiters[k]
}
}
}
/**
* 获取隐私弹窗 readyGuard (合并所有启用的等待通道)
* 任意通道 disabled → 不等
* 所有通道都 null → 返回 Promise.resolve() (不退化)
* @param {Object} channels { lang?, theme?, themeOverride?, remoteConfig? }
* @returns {Promise}
*/
getPrivacyReadyGuard(channels) {
const cfg = (typeof channels === 'object' && channels) || (privacyConfig.waitChannels || {})
const promises = []
for (const k of ['lang', 'theme', 'themeOverride', 'remoteConfig']) {
if (cfg[k] && this.readyWaiters[k]) {
promises.push(this.readyWaiters[k])
}
}
if (!promises.length) return Promise.resolve()
return Promise.all(promises).catch(() => {})
}
/**
* 隐私弹窗总超时熔断 (ms)
* 默认取 langConfig.remote.bootTimeout 与 themeConfig.overrideRemote.bootTimeout 的最大值 + 1000ms 余量
* 业务侧可通过 privacyConfig.bootTimeout 覆盖
*/
getPrivacyBootTimeout() {
if (privacyConfig && typeof privacyConfig.bootTimeout === 'number' && privacyConfig.bootTimeout > 0) {
return privacyConfig.bootTimeout
}
const langT = (langConfig && langConfig.remote && langConfig.remote.bootTimeout) || 5000
const themeT = (themeConfig && themeConfig.overrideRemote && themeConfig.overrideRemote.bootTimeout) || 5000
return Math.max(langT, themeT) + 1000
}
/**
* 加载语言包
* @param {string} lang - 语言类型
* @returns {Promise} - 加载结果
*/
async loadLang(lang = langConfig.defaultLang) {
if (this.loadedConfigs[`lang_${lang}`]) {
return this.loadedConfigs[`lang_${lang}`];
}
if (this.loadPromises[`lang_${lang}`]) {
return this.loadPromises[`lang_${lang}`];
}
this.loadPromises[`lang_${lang}`] = new Promise((resolve, reject) => {
try {
// 动态加载语言包
const langData = require(`@/lang/${lang}/common.js`).lang;
this.loadedConfigs[`lang_${lang}`] = langData;
resolve(langData);
} catch (error) {
// console.error(`加载语言包 ${lang} 失败:`, error);
reject(error);
}
});
return this.loadPromises[`lang_${lang}`];
}
/**
* 加载页面语言包
* @param {string} lang - 语言类型
* @param {string} pagePath - 页面路径
* @returns {Promise} - 加载结果
*/
async loadPageLang(lang = langConfig.defaultLang, pagePath) {
const key = `page_${lang}_${pagePath}`;
if (this.loadedConfigs[key]) {
return this.loadedConfigs[key];
}
if (this.loadPromises[key]) {
return this.loadPromises[key];
}
this.loadPromises[key] = new Promise((resolve, reject) => {
try {
// 动态加载页面语言包
const pageLangData = require(`@/lang/${lang}/${pagePath}.js`).lang;
this.loadedConfigs[key] = pageLangData;
resolve(pageLangData);
} catch (error) {
console.error(`加载页面语言包 ${lang}/${pagePath} 失败:`, error);
resolve({});
}
});
return this.loadPromises[key];
}
/**
* 加载主题配置(异步方式)
* 关键: 在 remote-only 模式下, style_color.js 已被 webpack 替换为 empty-style-stub.js,
* 只含 1 个 default preset. 其它 7 个 preset 由 ThemeOverride.loadAllPresets()
* 异步拉取并合并到本地缓存. 这里的 loadTheme 优先从缓存取,缓存未命中时回退到 stub.
*
* @param {string} theme - 主题名称
* @returns {Promise} - 加载结果
*/
async loadTheme(theme = themeConfig.defaultTheme) {
// 1) 先查已加载的内存缓存(包括远端拉取后写入的)
if (this.loadedConfigs[`theme_${theme}`]) {
return this.loadedConfigs[`theme_${theme}`];
}
// 2) remote-only 模式下,从 ThemeOverride 内存缓存(由 loadAllPresets 写入)取
try {
const ThemeOverride = require('./theme-override.js').default;
const remotePresets = ThemeOverride.getAllPresets();
if (remotePresets && remotePresets[theme]) {
this.loadedConfigs[`theme_${theme}`] = remotePresets[theme];
return remotePresets[theme];
}
} catch (e) {
// ignore — ThemeOverride 不可用时继续走 require stub
}
// 3) 兜底: require style_color.js (local 模式全量, remote-only 模式 stub)
if (this.loadPromises[`theme_${theme}`]) {
return this.loadPromises[`theme_${theme}`];
}
this.loadPromises[`theme_${theme}`] = new Promise((resolve, reject) => {
try {
// 动态加载主题配置 (webpack 替换为 stub 时,只返回 1 个 default preset)
const themeData = require(`@/common/js/style_color.js`)['default'][theme];
this.loadedConfigs[`theme_${theme}`] = themeData;
resolve(themeData);
} catch (error) {
console.error(`加载主题 ${theme} 失败:`, error);
reject(error);
}
});
return this.loadPromises[`theme_${theme}`];
}
}
export default new ConfigExternal();

View File

@@ -1,29 +1,94 @@
// 发行版本,配置说明
let releaseCfg = undefined;
// #ifdef APP-PLUS || MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-QQ || MP-TOUTIAO || MP-HARMONY
try {
if (site) {
if (site) { // 从site.js文件中获取配置, 针对微信小程序类
releaseCfg = {
baseUrl: site.baseUrl,
imgDomain: site.baseUrl,
h5Domain: site.baseUrl,
uniacid: site.uniacid,
}
} else {
// 惰性加载 Lang 避免循环依赖(lang.js 引用了 config.js)
try {
const Lang = require('@/common/js/lang.js').default;
console.warn(Lang.lang('common.config.siteJsNotConfigured'));
} catch (e) {
console.warn('小程序发行环境下,必须配置site.js文件,不然将使用默认配置,影响小程序定制分发!');
}
}
} catch (e) {}
// #endif
// 调试版本,配置说明
const devCfg = {
// 线上版本部署域名, 注意: 必须以 '/' 结尾,适用于小程序/H5/快应用等场景
// 默认域名, 自定义发行时可以修改
let defaultDomain = '';
// #ifdef H5
// 域名由 vue.config.js 中 DefinePlugin 注入的 process.env.UNI_APP_DOMAIN 传入
try {
if (process.env.UNI_APP_DOMAIN) {
defaultDomain = process.env.UNI_APP_DOMAIN;
}
} catch (e) {}
// #endif
// 确保域名以 '/' 结尾
if (defaultDomain && !defaultDomain.endsWith('/')) {
defaultDomain += '/';
}
// 本地调试配置示例文件
// 复制.local.config.js.example 并重命名为 .local.config.js 以使用自定义本地配置
let localDevConfig = { uniacid: 0, domain: defaultDomain };
// #ifndef PRODUCTION
// 尝试动态加载本地配置文件
if (process?.env?.NODE_ENV === 'development') {
try {
// 尝试从项目根目录加载本地配置文件
// 使用相对路径的方式,确保在不同平台下都能正确解析
let customConfig = require('@/.local.config.js').default;
if (customConfig) {
localDevConfig = customConfig;
}
} catch (e) {
// 如果本地配置文件不存在或加载失败,保持使用默认配置
// 只在开发模式下输出提示信息
// #ifdef WEB
// #endif
}
}
// #endif
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
export default {
/**
* 1.开发调试模式
* 去掉注释 ...defaultCfg;
* 注释掉 ...releaseCfg,
* 2.发行/发布模式,例如通过`HBuilder>发行>小程序微信`的时候,原理是:
* 然后将 `import site from "../site.js";`追加到 `unpackage\dist\build\mp-weixin\common\vendor.js` 文件内容开头部分
* 然后将 site.js 文件放到 `unpackage\dist\build\mp-weixin\` 目录下面
*/
// 商户ID
uniacid: 460, //825
uniacid: uniacid, //825
//api请求地址
baseUrl: 'https://xcx30.5g-quickapp.com/',
baseUrl: domain,
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
aikefuConfigUuid: aikefuConfigUuid,
// 图片域名
imgDomain: 'https://xcx30.5g-quickapp.com/',
imgDomain: domain,
// H5端域名
h5Domain: 'https://xcx30.5g-quickapp.com/',
h5Domain: domain,
// // api请求地址
// baseUrl: 'https://tsaas.liveplatform.cn/',
@@ -42,31 +107,20 @@ const devCfg = {
// // H5端域名
// h5Domain: 'http://saas.cn/',
};
var config = {
/**
* 1.开发调试模式
* 去掉注释 ...devCfg;
* 注释掉 ...releaseCfg,
* 2.发行/发布模式,例如通过`HBuilder>发行>小程序微信`的时候,原理是:
* 然后将 `import site from "../site.js";`追加到 `unpackage\dist\build\mp-weixin\common\vendor.js` 文件内容开头部分
* 然后将 site.js 文件放到 `unpackage\dist\build\mp-weixin\` 目录下面
*/
...(releaseCfg ?? devCfg),
// 发行版本配置
...(releaseCfg || {}),
// 腾讯地图key
mpKey: 'TUHBZ-CNWKU-UHAVP-GZQ26-HNZFO-3YBF4',
//客服地址
// 客服地址
webSocket: '{{$webSocket}}',
//本地端主动给服务器ping的时间, 0 则不开启 , 单位秒
// 本地端主动给服务器ping的时间, 0 则不开启 , 单位秒
pingInterval: 1500,
// 版本号
version: '1.0'
};
export default config;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
import WxMap from 'common/js/map-wx-jssdk.js';
import Config from '@/common/js/config.js';
import util from '@/common/js/util.js';
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
let systemInfo = util.getDeviceInfo();
let systemInfo = uni.getSystemInfoSync();
export default {
data() {
return {
@@ -43,10 +47,21 @@ export default {
diyRoute: '', // 页面路由
openBottomNav: false,
isShowCopyRight: false,
// getDiyInfo 节流
diyInfoLoading: false, // 是否正在请求
lastDiyInfoTime: 0, // 上次成功加载的时间戳
lastDiyInfoLang: '', // 上次加载时的语言,语言切换时强制刷新
diyInfoTtl: 3 * 60 * 1000, // 3分钟内不重复请求
// diy 数据网络加载失败时的页面内错误提示(比 showModal 在各端更可靠)
diyLoadError: false,
diyLoadErrorMsg: '',
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告
adv:{},
adv: {},
};
},
onLoad(option) {
@@ -109,10 +124,10 @@ export default {
// 关闭定位
this.enterDefaultStore();
}
},
onShow() {
this.init();
},
onHide() {
@@ -197,13 +212,12 @@ export default {
}
},
methods: {
play(){
console.log(123)
play() {
},
async init() {
// 定位信息过期后,重新获取定位
if(this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
if (this.mapConfig.wap_is_open == 1 && this.locationStorage && this.locationStorage.is_expired) {
this.$util.getLocation({
fail: (res) => {
// 拒绝定位,进入默认总店
@@ -219,6 +233,7 @@ export default {
}
}
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
await this.getDiyInfo();
//获取启动广告
await this.getDiyAdv();
@@ -276,83 +291,249 @@ export default {
}, 50);
// #endif
},
async getDiyAdv(){
async getDiyAdv() {
//启动广告
let res = await this.$api.sendRequest({
url: '/api/diyview/getstartadv',
data: {},
async: false
});
this.adv = res.value
// 弹框形式,首次弹出 1,每次弹出 0
if(this.adv.advshow == 1){
setTimeout(() => {
if (res.value.advtype == 1) {
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
let advData = (res && (res.data != null ? res.data : res.value)) || {};
this.adv = advData;
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
if (this.adv.advshow == 1) {
setTimeout(async () => {
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
await this._waitPrivacyResolved();
if (!this.$refs.uniPopupWindow) return;
// 先判定本次是否需要弹出, 避免无谓的图片预加载
let shouldOpen = false;
let isFirstType = false;
if (this.adv.advtype == 1) {
// 首次弹出: 从未展示过才弹
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
if (popwindow_count == '' || popwindow_count == 0) {
shouldOpen = true;
isFirstType = true;
}
} else if (res.value.advtype == 0) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
} else {
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
if (consumeNeedStartupAd() || isColdLaunchPending()) {
shouldOpen = true;
}
}
if (!shouldOpen) return;
// 关键: 广告图片是网络图, 而 uni-popup 用 v-if 在 open() 时才渲染 slot,
// 图片要等弹窗打开后才开始请求/解码, 真机网络慢时就会"一截一截"逐步显示。
// 先把所有广告图片预加载进本地缓存, 全部就绪后再 open, 使弹窗一次性完整出现。
await this._preloadAdvImages(this.adv.list);
if (!this.$refs.uniPopupWindow) return;
this.$refs.uniPopupWindow.open();
if (isFirstType) {
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
}, 500);
}
},
async getDiyInfo() {
let res = await this.$api.sendRequest({
url: '/api/diyview/info',
data: {
id: this.id,
name: this.name,
en_type:uni.getStorageSync("lang"),//获取语言底部
},
async: false
/**
* 等待隐私协议流程结束后再弹启动广告
* - 鸿蒙元服务: 隐私由系统托管, 应用层不弹自建弹窗, 直接放行
* - 已同意过(storage privacyAgreed): 无需等待, 直接放行
* - 未同意: 监听 PrivacyManager 广播的 privacy:agreed / privacy:disagreed,
* 用户点击隐私协议(同意/拒绝)关闭弹窗后放行
* - 兜底超时: 隐私协议因规则未弹等情况永不 emit 时也要弹广告, 避免卡死
* @param {number} [maxWait=15000]
* @returns {Promise<void>}
*/
_waitPrivacyResolved(maxWait = 15000) {
// 鸿蒙元服务: 隐私协议由系统托管, 应用层不弹自建隐私弹窗, 无需等待
// #ifdef MP-HARMONY
return Promise.resolve();
// #endif
// 已经同意过 → 隐私弹窗不会再出现, 直接放行
if (uni.getStorageSync('privacyAgreed')) return Promise.resolve();
return new Promise((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
uni.$off('privacy:agreed', finish);
uni.$off('privacy:disagreed', finish);
clearTimeout(timer);
resolve();
};
uni.$on('privacy:agreed', finish);
uni.$on('privacy:disagreed', finish);
// 兜底: 隐私协议因规则未弹 / 一直未处理时, 超时后也要弹广告
const timer = setTimeout(finish, maxWait);
});
if (res.code != 0 || !res.data) {
},
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
_showDiyLoadError(err, url, errorType) {
this.diyLoadErrorMsg = this._buildDiyErrorMsg(err);
this.diyLoadErrorUrl = url || '';
this.diyLoadError = true;
// 通知 splash: diy 加载失败, 启动屏应该让位给错误横幅
// 用 uni.$emit 而非直接 import splash-state, 避免循环依赖
// reason 拼成 'diy-{errorType}-error' 方便业务方区分 (splash:diy-error 事件)
const reason = errorType ? `diy-${errorType}-error` : 'diy-load-error'
try { uni.$emit && uni.$emit('diy:load-error', { reason, errorType, err, url }); } catch (e) { }
},
// 根据错误信息组织更详细的提示文案
// 说明:remote-only 模式下 $lang 可能返回空串,故统一用 `|| 中文兜底` 保证非空
_buildDiyErrorMsg(err) {
const info = classifyHttpError(err);
// 服务端明确返回的业务消息(字符串):直接透传
if (info.type === 'business') return info.raw;
const key = httpErrorKey(info.type);
let reason = this.$lang(key);
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
if (info.statusCode) reason += '(' + info.statusCode + ')';
// empty / unknown 不走模板,直接返回通用网络异常
if (info.type === 'empty' || info.type === 'unknown') return reason;
const tpl = this.$lang('diy.loadFailTpl', { reason });
return tpl;
},
// 错误横幅「重试」入口
retryDiyInfo() {
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
this.getDiyInfo(true);
},
async getDiyInfo(force = false) {
// 节流:正在请求 / N 秒内已加载过 / 语言未变 -> 跳过
const now = Date.now();
const currentLang = uni.getStorageSync("lang") || '';
console.log('[DIY][getDiyInfo] start', 'force=', force, 'id=', this.id, 'name=', this.name, 'lang=', currentLang, 'loading=', this.diyInfoLoading, 'lastAt=', this.lastDiyInfoTime, 'lastLang=', this.lastDiyInfoLang);
if (!force) {
if (this.diyInfoLoading) {
console.log('[DIY][getDiyInfo] skip: already loading');
return;
}
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
&& this.lastDiyInfoLang === currentLang) {
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
return;
}
}
this.diyInfoLoading = true;
let res;
// 出错时用于展示的完整请求地址(Request URL),与 http.js 拼接/归一化方式保持一致
const diyInfoUrl = '/api/diyview/info';
const diyInfoFullUrl = ((Config.baseUrl || '') + diyInfoUrl).replace(/(?<!:)\/+/g, '/');
try {
res = await this.$api.sendRequest({
url: diyInfoUrl,
data: {
id: this.id,
name: this.name,
en_type: currentLang,
},
async: false
});
} catch (e) {
console.error('[DIY][getDiyInfo] request failed', e);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.diyInfoLoading = false;
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
// errorType='network': 网络异常 (uni.request catch)
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
return;
}
this.diyInfoLoading = false;
console.log('[DIY][getDiyInfo] got response', 'code=', res && res.code, 'hasData=', !!(res && res.data), 'valueType=', res && res.data && typeof res.data.value, 'valueLen=', res && res.data && (typeof res.data.value === 'string' ? res.data.value.length : (res.data.value ? res.data.value.length : null)));
if (!res || res.code != 0 || !res.data) {
console.warn('[DIY][getDiyInfo] invalid response', JSON.stringify(res));
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
if (res.code == -3) {
this.$util.showToast({
title: res.message
});
this.diyData = {};
// 完全没有拿到网络响应,视为无法从网络获取数据,页面内显示错误横幅
if (!res) {
// errorType='no-response': 完全没有 res 对象
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
this.$util.showToast({
title: '未配置自定义页面数据'
});
this.diyData = {};
if (res && res.code == -3) {
this.$util.showToast({
title: res.message
});
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
// errorType='business': 业务失败 (code!=0 / 消息提示)
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
}
this.lastDiyInfoTime = now;
this.lastDiyInfoLang = currentLang;
// 成功加载后清除错误提示
this.diyLoadError = false;
this.diyLoadErrorMsg = '';
this.diyLoadErrorUrl = '';
let diyDataValue = res.data;
if (diyDataValue.value) {
this.diyData = JSON.parse(diyDataValue.value);
this.$langConfig.title(this.diyData.global.title);
try {
this.diyData = JSON.parse(diyDataValue.value);
} catch (parseErr) {
console.error('[DIY][getDiyInfo] JSON.parse(value) failed', parseErr, 'value preview=', String(diyDataValue.value).slice(0, 200));
throw parseErr;
}
console.log('[DIY][getDiyInfo] parsed diyData', 'globalKeys=', this.diyData && this.diyData.global ? Object.keys(this.diyData.global) : null, 'valueCount=', this.diyData && this.diyData.value && this.diyData.value.length, 'topIndexValue before=', this.topIndexValue);
// 导航栏标题要根据语言环境切换
// 优先使用 title_map 多语言 map,没有则回退到 title / en_title
const title = this.$langConfig.getField(this.diyData?.global, 'title');
if (title) this.$langConfig.title(title);
this.mpCollect = this.diyData.global.mpCollect;
this.setPublicShare();
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
// 弹框形式,首次弹出 1,每次弹出 0
setTimeout(() => {
if (this.diyData.global.popWindow.count == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
/* if (this.diyData.global.popWindow && this.diyData.global.popWindow.imageUrl) {
// 弹框形式,首次弹出 1,每次弹出 0
setTimeout(() => {
if (this.diyData.global.popWindow.count == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
} else if (this.diyData.global.popWindow.count == 0) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
}
} else if (this.diyData.global.popWindow.count == 0) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
}
}, 500);
}*/
}, 500);
}*/
// 修改diy数据结构排序
let searchIndex = -1;
@@ -416,11 +597,51 @@ export default {
this.openBottomNav = this.diyData.global.openBottomNav;
}
this.isShowCopyRight = true;
console.log('[DIY][getDiyInfo] done', 'topIndexValue after=', this.topIndexValue && this.topIndexValue.componentName, 'valueCount=', this.diyData.value.length, 'openBottomNav=', this.openBottomNav, 'isShowCopyRight=', this.isShowCopyRight);
} else {
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
}
},
/**
* 预加载启动广告图片
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
* 使弹窗一次性完整呈现。
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
* @returns {Promise<void>}
*/
_preloadAdvImages(list, timeout = 4000) {
if (!list || !list.length) return Promise.resolve();
const tasks = list.map((item) => {
return new Promise((resolve) => {
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
if (!url) return resolve();
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
resolve();
};
const timer = setTimeout(finish, timeout);
try {
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
uni.getImageInfo({
src: url,
success: finish,
fail: finish
});
} catch (e) {
finish();
}
});
});
return Promise.all(tasks).then(() => {});
},
closePopupWindow() {
this.$refs.uniPopupWindow.close();
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
},
closeCollectPopupWindow() {
this.$refs.collectPopupWindow.close();
@@ -555,7 +776,7 @@ export default {
if (res.code == 0 && res.data) {
this.currentPosition = res.data.formatted_addresses.recommend; // 结合知名地点形成的描述性地址,更具人性化特点
} else {
this.currentPosition = '未获取到定位';
this.currentPosition = this.$lang('diy.locationNotAvailable');
}
}
});
@@ -567,7 +788,7 @@ export default {
this.nearestStore = this.defaultStoreInfo;
this.nearestStore.show_address = this.nearestStore.full_address.replace(/,/g, ' ') + ' ' + this.nearestStore.address;
}
if (this.currentPosition == '') this.currentPosition = '未获取到定位';
if (this.currentPosition == '') this.currentPosition = this.$lang('diy.locationNotAvailable');
this.openChooseStorePopup();
}
},
@@ -577,7 +798,7 @@ export default {
if (this.id) shareUrl += '?id=' + this.id;
else if (this.name) shareUrl += '?name=' + this.name;
this.$util.setPublicShare({
title: this.diyData.global.title,
title: this.$langConfig.getField(this.diyData.global, 'title'),
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
@@ -603,10 +824,10 @@ export default {
},
// 分享给好友
onShareAppMessage() {
return this.mpShareData.appMessage;
return this.mpShareData?.appMessage;
},
// 分享到朋友圈
onShareTimeline() {
return this.mpShareData.timeLine;
return this.mpShareData?.timeLine;
}
}

View File

@@ -0,0 +1,15 @@
// ============================================================
// 空 lang 包占位
//
// 在 source === 'remote-only' 模式下, lang/ 目录下的所有 .js 会被
// webpack.NormalModuleReplacementPlugin 替换为这个 stub.
//
// 设计要点:
// - 导出 default 对象, 结构与真实 lang 包一致: { lang: {...} }
// - lang 字段是空对象, 让 lang() 函数 fallback 路径不报错
// - 不导出 named export, 避免 tree-shaking 把整个 stub 干掉
// ============================================================
export default {
lang: {}
};

View File

@@ -0,0 +1,169 @@
// ============================================================
// 空主题预设占位(stub)
//
// 在 themeConfig.overrideRemote.source === 'remote-only' 模式下,
// style_color.js 会被 webpack.NormalModuleReplacementPlugin 替换为这个 stub.
//
// 目的: 主包不再包含 8 个完整 preset (~80KB), 只保留最小 emergency fallback
// (~5KB, 仅 1 个 default preset 的核心 token).
//
// 设计要点:
// 1) 导出 default 对象, 结构与 style_color.js 一致: { 'default': { ... } }
// 2) 包含最关键的 token(main_color, aux_color, bg_color 等),
// 保证 themeColorSet 能写入基础 CSS 变量,首屏不白屏
// 3) 缺失的 token 由远端 /api/theme/presets 异步补齐,补齐前 var() 解析失败
// 时浏览器会回退到 SCSS 的 fallback 值(uni-app H5 已做 rpx→px)
// 4) 其他 7 个 preset 完全由远端提供,本地不打包
// 5) 失败兜底: 远端不可用时, 切到非 default preset 会落到 default 的样式
//
// token 数量统计: 8 preset × ~50 token = ~400 token. 完整 80KB.
// 1 preset × ~50 token = ~5KB. 节省 ~94%.
// ============================================================
export default {
'default': {
name: 'default',
main_color: '#F4391c',
aux_color: '#F7B500',
bg_color: '#FF4646',
bg_color_shallow: '#FF4646',
promotion_color: '#FF4646',
promotion_aux_color: '#F7B500',
main_color_shallow: '#FFF4F4',
price_color: 'rgb(252,82,39)',
btn_text_color: '#FFFFFF',
goods_price: 'rgb(252,82,39,1)',
promotion_tag: '#FF4646',
goods_card_bg: '#201A18',
goods_card_bg_shallow: '#7C7878',
goods_card_color: '#FFD792',
goods_coupon: '#FC5227',
goods_cart_num_corner: '#FC5227',
goods_btn_color: '#FF4646',
goods_btn_color_shallow: '#F7B500',
pintuan_label_bg: '#F7B500',
pintuan_label_color: '#FFFFFF',
pintuan_color: '#FA6400',
pintuan_promotion_color: '#FA3A1D',
pintuan_promotion_aux_color: '#FD9A01',
super_member_start_bg: '#7c7878',
super_member_end_bg: '#201a18',
super_member_start_text_color: '#FFDBA6',
super_member_end_text_color: '#FFEBCA',
bargain_promotion_color: '#F0353E',
bargain_promotion_aux_color: '#FD9A01',
seckill_promotion_color: '#F83530',
seckill_promotion_aux_color: '#FD9A01',
giftcard_promotion_color: '#FF3369',
giftcard_promotion_aux_color: '#F7B500',
groupby_promotion_color: '#E64136',
groupby_promotion_aux_color: '#F7B500',
hover_nav_bg_color: '#FFFC',
hover_nav_text_color: '#000',
// ===== DIY 分类页字号 token =====
diy_category_tree_select_size: '28rpx',
diy_category_tree_normal_size: '20rpx',
diy_category_item_title_size: '26rpx',
diy_category_item_goods_title_size:'28rpx',
diy_category_item_name_size: '24rpx',
diy_category_item_goods_name_size:'28rpx',
diy_category_item_goods_tag_size: '22rpx',
diy_category_item_consult_now_size:'26rpx',
diy_category_item_consult_now_bg: '#FF4646',
diy_category_item_consult_now_text: '#FFFFFF',
diy_category_item_consult_now_padding: '12rpx 30rpx',
diy_category_item_consult_now_radius: '40rpx',
diy_category_item_consult_now_weight: '500',
diy_category_item_consult_now_line_height:'1.2',
diy_category_item_consult_now_text_align: 'center',
diy_category_item_consult_now_white_space:'nowrap',
diy_category_item_consult_now_display: 'inline-block',
// ===== DIY 分类页 - 商品卡 token =====
diy_category_item_goods_name_line_height:1.3,
diy_category_item_goods_name_margin_top: '20rpx',
diy_category_item_discount_price_weight: 'bold',
diy_category_item_discount_price_line_height: 1,
diy_category_item_discount_price_margin_top: '16rpx',
diy_category_item_pro_info_margin_top: '16rpx',
diy_category_item_delete_price_size: '24rpx',
diy_category_item_delete_price_line_height: 1,
diy_category_item_delete_price_text_decoration:'line-through',
diy_category_item_member_price_tag_width: '60rpx',
diy_category_item_member_price_tag_height: '28rpx',
diy_category_item_member_price_tag_line_height: 1,
diy_category_item_member_price_tag_margin_left: '6rpx',
diy_category_item_info_wrap_padding: '0 20rpx',
diy_category_item_info_wrap_margin_top: '12rpx',
diy_category_item_footer_wrap_display: 'flex',
diy_category_item_footer_wrap_justify_content: 'space-between',
diy_category_item_footer_wrap_align_items: 'normal',
diy_category_item_footer_wrap_margin_top: 0,
diy_category_item_price_wrap_display: 'block',
diy_category_item_price_wrap_align_items: 'normal',
diy_category_item_price_wrap_flex_wrap: 'nowrap',
diy_category_item_price_wrap_justify_content: 'normal',
diy_category_item_price_wrap_margin_top: 0,
diy_category_item_name_wrap_flex: '1',
diy_category_item_consult_now_wrap_display: 'flex',
diy_category_item_consult_now_wrap_justify_content:'flex-end',
diy_category_item_consult_now_wrap_margin_top: 'auto',
diy_category_item_consult_now_wrap_padding_top: '10rpx',
diy_category_item_color_tip_color: '#909399',
diy_category_item_price_font_weight: 500,
// ===== 营销 token(全 8 preset 一致) =====
pintuan_label_grad_start: '#ffbd5b',
pintuan_label_grad_end: '#fd882e',
pintuan_avatar_border: '#ff3d3d',
pintuan_success_start: '#3edb73',
pintuan_success_end: '#1db576',
bargain_promo_shallow: '#ffeadb',
seckill_price_shallow: '#fcecd7',
seckill_price_start: '#fdbe6c',
seckill_discount_start: '#ff5f17',
seckill_discount_end: '#fe2f18',
member_card_gold_start: '#E3B66B',
member_card_gold_end: '#F7DAA5',
member_icon_grad_start: '#814635',
member_icon_grad_end: '#3A221B',
balance_page_bg_start: '#FE7849',
balance_page_bg_end: '#FF1959',
point_page_bg_start: '#FE7849',
point_page_bg_end: '#FF1959',
point_promo_start: '#F8F8F8',
point_promo_end: '#E74A32',
point_item_grad_start: '#F4402B',
point_item_grad_end: '#FD7C40',
invite_grad_start: '#ff2440',
invite_grad_end: '#ff7b7b',
promote_grad_start: '#ffe2ac',
promote_grad_end: '#fdc174',
fenxiao_withdraw_start: '#ff3c29',
fenxiao_withdraw_end: '#ff6f29',
level_1_grad_start: '#9BA7B1', level_1_grad_end: '#D3DEE6',
level_2_grad_start: '#F57E2A', level_2_grad_end: '#FAD494',
level_3_grad_start: '#F85151', level_3_grad_end: '#FF9999',
level_4_grad_start: '#78B8B4', level_4_grad_end: '#AFE6E2',
level_5_grad_start: '#4DA1E1', level_5_grad_end: '#58CBF0',
level_6_grad_start: '#81C636', level_6_grad_end: '#D1F677',
level_7_grad_start: '#6D7279', level_7_grad_end: '#A5AAB0',
level_8_grad_start: '#866DDB', level_8_grad_end: '#D49BFE',
level_9_grad_start: '#f1c74e', level_9_grad_end: '#f7dc81',
level_10_grad_start: '#418CCF', level_10_grad_end: '#9CC6F1',
}
// 其他 7 个 preset (green/blue/pink/gold/purple/yellow/black)
// 全部由远端 /api/theme/presets 异步提供
// 远端拉取前切到非 default preset,会回退到 default 样式
// 注: 历史曾尝试添加 'red' preset, 但前端 style_color.js 从未定义, 已在
// config-external.js themeList 中移除以保持一致性
};

View File

@@ -12,57 +12,13 @@
* @createTime 2023-08-10
*/
import Platform from './platform.js'
class EventBus {
constructor() {
this.events = new Map()
this.domBridge = null
this.platform = this.detectPlatform()
}
// 检测平台
detectPlatform() {
// #ifdef H5
return 'h5'
// #endif
// #ifdef MP-WEIXIN
return 'weapp'
// #endif
// #ifdef MP-ALIPAY
return 'alipay'
// #endif
// #ifdef MP-BAIDU
return 'baidu'
// #endif
// #ifdef MP-TOUTIAO
return 'toutiao'
// #endif
// #ifdef MP-QQ
return 'qq'
// #endif
// #ifdef MP-KUAISHOU
return 'kuaishou'
// #endif
// #ifdef MP-JD
return 'jd'
// #endif
// #ifdef MP-360
return '360'
// #endif
// #ifdef MP-XHS
return 'xhs'
// #endif
return 'unknown'
this.platform = Platform.detect()
}
// 设置 DOM 桥接
@@ -110,7 +66,7 @@ class EventBus {
// 异步触发事件,支持取消传播,并支持在监听处理后调用异步函数 defaultAsyncHandler
// 使用方式:await eventBus.emit(eventName, data, defaultAsyncHandler)
// defaultAsyncHandler 会在每个监听器执行后被调用,签名为 defaultAsyncHandler(event, handler, handlerResult)
// defaultAsyncHandler 会在监听器执行后被调用,签名为 defaultAsyncHandler(event, handler, handlerResult)
// 如果 defaultAsyncHandler 返回 Promise,会等待其 resolve
// 如果 allowContinuePropagation 为 false,事件触发后将立即返回,不继续传播
// 如果 stopOnError 为 true,在任意监听器抛出错误后立即停止传播
@@ -123,6 +79,7 @@ class EventBus {
// helper to call defaultAsyncHandler only when allowed
const callDefaultAsyncHandler = async (evt, handler, handlerResult) => {
// console.log('callDefaultAsyncHandler', {evt, handler, handlerResult});
if (typeof defaultAsyncHandler !== 'function' || !allowAsyncHandlerRun) return
try {
const res = defaultAsyncHandler(evt, handler, handlerResult)
@@ -152,7 +109,7 @@ class EventBus {
return !event.defaultPrevented
}
// 否则继续走普通 EventBus 处理(fallthrough)
}
}
// 普通 EventBus 触发
const handlers = this.events.get(eventName)
@@ -189,6 +146,7 @@ class EventBus {
}
// 在每个监听器执行后,如果允许并且提供了 defaultAsyncHandler,就调用并等待它
// console.log('==> callDefaultAsyncHandler', {event, handler, awaitedResult})
await callDefaultAsyncHandler(event, handler, awaitedResult)
} catch (error) {
console.error(`EventBus ${eventName} error:`, error)

112
common/js/event-safety.js Normal file
View File

@@ -0,0 +1,112 @@
// 事件安全处理工具
export class EventSafety {
// 创建安全的事件对象
static createSafeEvent(originalEvent = {}) {
const safeEvent = {
type: originalEvent.type || 'unknown',
timeStamp: originalEvent.timeStamp || Date.now(),
detail: originalEvent.detail || {},
// 安全的目标对象
get target() {
return EventSafety.createSafeTarget(originalEvent.target)
},
get currentTarget() {
return EventSafety.createSafeTarget(originalEvent.currentTarget)
},
// 安全的 matches 方法
matches(selector) {
return EventSafety.safeMatches(originalEvent.target, selector)
}
}
return new Proxy(safeEvent, {
get(obj, prop) {
// 防止访问不存在的属性
if (prop in obj) {
return obj[prop]
}
return undefined
}
})
}
// 创建安全的目标对象
static createSafeTarget(target) {
if (!target || typeof target !== 'object') {
return EventSafety.getFallbackTarget()
}
const safeTarget = {
// 基础属性
tagName: target.tagName || '',
id: target.id || '',
className: target.className || '',
// 安全的方法
matches: (selector) => EventSafety.safeMatches(target, selector),
// 数据集
dataset: target?.dataset || {}
}
return safeTarget
}
// 安全的 matches 检查
static safeMatches(element, selector) {
if (!element || typeof element.matches !== 'function') {
return false
}
try {
return element.matches(selector)
} catch (error) {
console.warn('matches 检查失败:', error)
return false
}
}
// 回退目标对象
static getFallbackTarget() {
return {
tagName: '',
id: '',
className: '',
matches: () => false,
dataset: {}
}
}
// 包装事件处理器
static wrapEventHandler(handler, options = {}) {
return function(event) {
try {
// 创建安全的事件对象
const safeEvent = EventSafety.createSafeEvent(event)
return handler.call(this, safeEvent)
} catch (error) {
console.error('事件处理错误:', error)
// 可选的错误处理
if (options.onError) {
options.onError(error, event, this)
}
}
}
}
// 验证事件类型
static isValidEventType(event, expectedType) {
return event && event.type === expectedType
}
// 提取安全的事件数据
static extractEventData(event, fields = ['type', 'timeStamp', 'detail']) {
const result = {}
fields.forEach(field => {
if (event && field in event) {
result[field] = event[field]
}
})
return result
}
}

View File

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

View File

@@ -1,190 +1,315 @@
export default {
data() {
return {
// 页面样式,动态设置主色调
themeColor: '' //''--base-color:#fa5d14;--base-help-color:#ff7e00;'
}
},
onLoad() {},
onShow() {
// 刷新多语言
this.$langConfig.refresh();
let time = setInterval(() => {
let theme = this.themeStyle;
if (theme && theme.main_color) {
this.themeColorSet();
clearInterval(time);
}
}, 50);
},
computed: {
themeStyle() {
return this.$store.state.themeStyle;
},
// 插件是否存在
addonIsExist() {
return this.$store.state.addonIsExist;
},
tabBarList() {
return this.$store.state.tabBarList;
},
siteInfo() {
return this.$store.state.siteInfo;
},
memberInfo() {
return this.$store.state.memberInfo;
},
storeToken() {
return this.$store.state.token;
},
bottomNavHidden() {
return this.$store.state.bottomNavHidden;
},
globalStoreConfig() {
return this.$store.state.globalStoreConfig;
},
globalStoreInfo() {
return this.$store.state.globalStoreInfo;
},
// 定位信息
location() {
return this.$store.state.location;
},
// 定位信息(缓存)
locationStorage() {
let data = uni.getStorageSync('location');
if (data) {
var date = new Date();
if (this.mapConfig.wap_valid_time > 0) {
data.is_expired = (date.getTime() / 1000) > data.valid_time; // 是否过期
} else {
data.is_expired = false;
}
}
return data;
},
// 默认总店(定位失败后使用)
defaultStoreInfo() {
return this.$store.state.defaultStoreInfo;
},
// 组件刷新计数
componentRefresh() {
return this.$store.state.componentRefresh;
},
// 客服配置
servicerConfig() {
return this.$store.state.servicerConfig;
},
// 企业微信配置
wxworkConfig() {
return this.$store.state.wxworkConfig;
},
diySeckillInterval() {
return this.$store.state.diySeckillInterval;
},
tabBarHeight() {
return this.$store.state.tabBarHeight;
},
mapConfig() {
return this.$store.state.mapConfig;
},
copyright() {
let copyright = this.$store.state.copyright;
// 判断是否授权
if (copyright && !copyright.auth) {
copyright.logo = '';
copyright.copyright_link = '';
}
return copyright;
},
cartList() {
return this.$store.state.cartList;
},
cartIds() {
return this.$store.state.cartIds;
},
cartNumber() {
return this.$store.state.cartNumber;
},
cartMoney() {
return this.$store.state.cartMoney;
}
},
methods: {
themeColorSet() {
let theme = this.themeStyle;
this.themeColor = `--base-color:${theme.main_color};--base-help-color:${theme.aux_color};`;
if (this.tabBarHeight != '56px') this.themeColor += `--tab-bar-height:${this.tabBarHeight};`
Object.keys(theme).forEach(key => {
let data = theme[key];
if (typeof(data) == "object") {
Object.keys(data).forEach(k => {
this.themeColor += '--' + k.replace(/_/g, "-") + ':' + data[k] + ';';
});
} else if (typeof(key) == "string" && key) {
this.themeColor += '--' + key.replace(/_/g, "-") + ':' + data + ';';
}
});
for (let i = 9; i >= 5; i--) {
let color = this.$util.colourBlend(theme.main_color, '#ffffff', (i / 10));
this.themeColor += `--base-color-light-${i}:${color};`;
}
},
// 颜色变浅(>0)、变深函数(<0)
lightenDarkenColor(color, amount) {
var usePound = false;
if (color[0] == "#") {
color = color.slice(1);
usePound = true;
}
var num = parseInt(color, 16);
var r = (num >> 16) + amount;
if (r > 255) r = 255;
else if (r < 0) r = 0;
var b = ((num >> 8) & 0x00FF) + amount;
if (b > 255) b = 255;
else if (b < 0) b = 0;
var g = (num & 0x0000FF) + amount;
if (g > 255) g = 255;
else if (g < 0) g = 0;
return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
},
/**
* 切换门店
* @param {Object} info 门店信息
* @param {Object} isJump 是否跳转到首页
*/
changeStore(info, isJump) {
if (info) {
this.$store.commit('setGlobalStoreInfo', info);
}
let route = this.$util.getCurrRoute();
if (isJump && route != 'pages/index/index') {
uni.setStorageSync('manual_change_store', true); // 手动切换门店
this.$store.dispatch('getCartNumber'); //重新获取购物车数据
this.$util.redirectTo('/pages/index/index');
}
}
},
filters: {
/**
* 金额格式化输出
* @param {Object} money
*/
moneyFormat(money) {
if (isNaN(parseFloat(money))) return money;
return parseFloat(money).toFixed(2);
}
}
import { Platform } from './platform.js';
// 内部 helper:获取当前页面实例
const _getCurrentPage = () => {
const pages = getCurrentPages();
return pages.length === 0 ? null : pages[pages.length - 1];
};
// 内部 helper:获取当前页面路由(统一提取 route 或 $page.path)
const _getCurrentRoute = (currentPage) => {
if (!currentPage) return '';
if (currentPage.route) return currentPage.route;
if (currentPage.$page && currentPage.$page.path) return currentPage.$page.path;
return '';
};
// 内部 helper:判断 tabBarList 是否已加载且有 list 配置
const _isValidTabBarList = (tabBarList) => {
return tabBarList && tabBarList.list;
};
// 内部 helper:规范化路由:无前导斜杠时补全
const _normalizeLeadingSlash = (route) => {
if (!route) return route;
return route.startsWith('/') ? route : '/' + route;
};
// 内部 helper:通过 $util 方法的安全调用
const _callUtil = (vm, method, ...args) => {
if (vm.$util && vm.$util[method]) {
return vm.$util[method](...args);
}
return null;
};
export default {
onLoad() { },
onShow() {
// remote-only 小程序/鸿蒙: boot 未完成时跳过 refresh,避免空翻译写入标题等
// H5 不受影响(main.js 已 await boot 后才 $mount)
if (this.$langConfig.isRemoteOnly && this.$langConfig.isRemoteOnly()) {
if (!this.$langConfig.isBootCompleted || !this.$langConfig.isBootCompleted()) {
return;
}
}
// 刷新多语言(仅刷新标题,不触发全页面 $forceUpdate)
// 全量刷新仅在语言包真正变化时由 lang.js emitUpdated 触发
this.$langConfig.refresh(false);
// TabBar 根页面时隐藏系统导航栏的返回按钮
// (uni-app 的系统导航栏在通过 navigateTo 跳转到 tab 页时可能残留返回键)
const tabBarList = this.$store.state.tabBarList;
if (!_isValidTabBarList(tabBarList)) return;
const currentRoute = _getCurrentRoute(_getCurrentPage());
if (!currentRoute) return;
const isRoot = _callUtil(this, 'isTabBarRootPage', currentRoute, tabBarList) || false;
if (isRoot) {
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-ALIPAY
try { uni.hideHomeButton(); } catch (e) { /* 兼容 */ }
// #endif
}
},
computed: {
// 是否是英文环境
isEnEnv() {
return this.$langConfig.getCurrentLocale() === 'en-us';
},
// 是否是非中文环境(用于多语言商品名/字段切换)
isNotZhEnv() {
return this.$langConfig.getCurrentLocale() !== 'zh-cn';
},
// 动态判断:当前页面是否是 TabBar 根页面(tab 根页不应显示返回按钮)
isTabRootPage() {
const tabBarList = this.$store.state.tabBarList;
if (!_isValidTabBarList(tabBarList)) return false;
const currentRoute = _getCurrentRoute(_getCurrentPage());
if (!currentRoute) return false;
return _callUtil(this, 'isTabBarRootPage', currentRoute, tabBarList) || false;
},
themeStyle() {
return this.$store.state.themeStyle;
},
themeColor() {
return this.$store.state.themeColor;
},
// 插件是否存在
addonIsExist() {
return this.$store.state.addonIsExist;
},
tabBarList() {
return this.$store.state.tabBarList;
},
shouldShowTabBar() {
const tabBarList = this.$store.state.tabBarList;
if (!_isValidTabBarList(tabBarList)) return false;
const mode = tabBarList.page_visibility_mode;
const routeList = tabBarList.page_routes || [];
if (!mode || mode === 'all') return true;
if (mode === 'none') return false;
const currentPage = _getCurrentPage();
if (!currentPage) return true;
const currentRoute = _normalizeLeadingSlash(_getCurrentRoute(currentPage));
if (!currentRoute) return true;
const inList = _callUtil(this, 'isPageInRouteList', currentRoute, routeList) || false;
if (mode === 'whitelist') return inList;
if (mode === 'blacklist') return !inList;
return true;
},
tabBarPagePaddingStyle() {
return this.$util.objToStyle({
height: `calc(100vh - ${this.tabBarHeight})`,
paddingBottom: this.tabBarHeight
});
},
siteInfo() {
return this.$store.state.siteInfo;
},
memberInfo() {
return this.$store.state.memberInfo;
},
storeToken() {
return this.$store.state.token;
},
bottomNavHidden() {
return this.$store.state.bottomNavHidden;
},
globalStoreConfig() {
return this.$store.state.globalStoreConfig;
},
globalStoreInfo() {
return this.$store.state.globalStoreInfo;
},
// 定位信息
location() {
return this.$store.state.location;
},
// 定位信息(缓存)
locationStorage() {
let data = uni.getStorageSync('location');
if (data) {
var date = new Date();
if (this.mapConfig.wap_valid_time > 0) {
data.is_expired = (date.getTime() / 1000) > data.valid_time; // 是否过期
} else {
data.is_expired = false;
}
}
return data;
},
// 默认总店(定位失败后使用)
defaultStoreInfo() {
return this.$store.state.defaultStoreInfo;
},
// 组件刷新计数
componentRefresh() {
return this.$store.state.componentRefresh;
},
// AI客服配置
globalAIKefuConfig() {
return this.$store.state.globalAIKefuConfig;
},
// 客服配置
servicerConfig() {
return this.$store.state.servicerConfig;
},
// 企业微信配置
wxworkConfig() {
return this.$store.state.wxworkConfig;
},
diySeckillInterval() {
return this.$store.state.diySeckillInterval;
},
tabBarHeight() {
return this.shouldShowTabBar ? this.$store.state.tabBarHeight : '0rpx';
},
mapConfig() {
return this.$store.state.mapConfig;
},
copyright() {
let copyright = this.$store.state.copyright;
// 判断是否授权
if (copyright && !copyright.auth) {
copyright.logo = '';
copyright.copyright_link = '';
}
return copyright;
},
cartList() {
return this.$store.state.cartList;
},
cartIds() {
return this.$store.state.cartIds;
},
cartNumber() {
return this.$store.state.cartNumber;
},
cartMoney() {
return this.$store.state.cartMoney;
}
},
methods: {
/**
* 当前平台类型(多端翻译隔离)
* 复用 Platform.getAppType(),与服务端 /api/BaseApi 的 $app_type 对齐
*/
getAppType() {
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
},
// 颜色变浅(>0)、变深函数(<0)
lightenDarkenColor(color, amount) {
var usePound = false;
if (color[0] == "#") {
color = color.slice(1);
usePound = true;
}
var num = parseInt(color, 16);
var r = (num >> 16) + amount;
if (r > 255) r = 255;
else if (r < 0) r = 0;
var b = ((num >> 8) & 0x00FF) + amount;
if (b > 255) b = 255;
else if (b < 0) b = 0;
var g = (num & 0x0000FF) + amount;
if (g > 255) g = 255;
else if (g < 0) g = 0;
return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
},
/**
* 切换门店
* @param {Object} info 门店信息
* @param {Object} isJump 是否跳转到首页
*/
changeStore(info, isJump) {
if (info) {
this.$store.commit('setGlobalStoreInfo', info);
}
let route = this.$util.getCurrRoute();
if (isJump && route != 'pages/index/index') {
uni.setStorageSync('manual_change_store', true); // 手动切换门店
this.$store.dispatch('getCartNumber'); //重新获取购物车数据
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
}
},
/**
* 金额格式化输出
* @param {Object} money
*/
moneyFormat(money) {
if (isNaN(parseFloat(money))) return money;
return parseFloat(money).toFixed(2);
},
/**
* 重新定向到登录授权, 不区分生命周期
* @param {String} backUrl
* @param {Object} params 附加参数
* @param {Function} effectFnc 副作用函数, 登录授权失败的处理方式
*/
redirectTologin(backUrl = '', params = {}, effectFnc = ()=>{}) {
console.log(`------> redirectTologin`, backUrl);
setTimeout(() => {
this.$nextTick(() => {
let mode = 'navigateTo';
// #ifdef H5 || MP-HARMONY
mode = 'redirectTo'; // H5必须使用redirectTo, 不然点击返回箭头无法返回。
// #endif
this.$refs?.login?.open(backUrl, mode, effectFnc);
});
}, 0);
},
/**
* 重新定向到登录授权, 当页面显示的时候, 失败后,
* 原因:onLoad、onReady 只执行一次;
* 而且,有的页面不需要这个功能
* @param {String} backUrl
*/
redirectTologinOnShowLifecycle(backUrl) {
console.log(`------> redirectTologinOnShowLifecycle`, backUrl);
// #ifdef H5 || MP-HARMONY
this.redirectTologin(backUrl);
// #endif
// #ifdef MP-WEIXIN
this.redirectTologin(backUrl, {}, () => {
uni.navigateBack({delta: 1 });
})
// #endif
}
},
filters: {
/**
* 金额格式化输出
* @param {Object} money
*/
moneyFormat(money) {
if (isNaN(parseFloat(money))) return money;
return parseFloat(money).toFixed(2);
}
}
}

View File

@@ -79,115 +79,127 @@ export default {
},
methods: {
// 处理商品详情数据
handleGoodsSkuData() {
// this.navbarData.title = this.goodsSkuDetail.goods_name.length > 9 ? this.goodsSkuDetail.goods_name.substr(0, 9) + "..." : this.goodsSkuDetail.goods_name;
//设置标题
// this.$langConfig.title(this.navbarData.title);
if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
}
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
this.modifyGoodsInfo();
// 初始化商品详情视图数据
if (this.$refs.goodsDetailView) this.$refs.goodsDetailView.init({
sku_id: this.skuId,
goods_id: this.goodsSkuDetail.goods_id,
preview: this.preview,
source_member: this.source_member,
posterParams: this.posterParams,
posterApi: this.posterApi,
shareUrl: this.shareUrl,
goodsRoute: this.goodsRoute,
isVirtual: this.goodsSkuDetail.is_virtual,
deliveryType: this.goodsSkuDetail.express_type,
whetherCollection: this.goodsSkuDetail.is_collect,
evaluateConfig: this.goodsSkuDetail.evaluate_config,
evaluateList: this.goodsSkuDetail.evaluate_list,
evaluateCount: this.goodsSkuDetail.evaluate_count
});
//媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
else this.goodsSkuDetail.sku_images = [];
}
// 多规格时合并主图
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
}
let maxHeight = '';
let systemInfo = uni.getSystemInfoSync();
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
if (typeof item.pic_spec == "string")
item.pic_spec = item.pic_spec.split('*');
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
item.pic_spec[0] = item.pic_spec[0] / ratio;
item.pic_spec[1] = item.pic_spec[1] / ratio;
if (!maxHeight || maxHeight > item.pic_spec[1]) {
maxHeight = item.pic_spec[1];
async handleGoodsSkuData() {
// 鸿蒙兼容: 外层包 try/catch, 任何同步抛错都不会导致 page 整体空白
try {
if (this.goodsSkuDetail.config) {
this.navbarData.navBarSwitch = this.goodsSkuDetail.config.nav_bar_switch;
}
});
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || "件";
this.whetherCollection = this.goodsSkuDetail.is_collect; // 用户关注商品状态
// 当前商品SKU规格
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
this.modifyGoodsInfo();
// 商品属性
if (this.goodsSkuDetail.goods_attr_format) {
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
for (var j = 0; j < goods_attr_format.length; j++) {
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
// 初始化商品详情视图数据
try {
if (this.$refs && this.$refs.goodsDetailView) {
this.$refs.goodsDetailView.init({
sku_id: this.skuId,
goods_id: this.goodsSkuDetail.goods_id,
preview: this.preview,
source_member: this.source_member,
posterParams: this.posterParams,
posterApi: this.posterApi,
shareUrl: this.shareUrl,
goodsRoute: this.goodsRoute,
isVirtual: this.goodsSkuDetail.is_virtual,
deliveryType: this.goodsSkuDetail.express_type,
whetherCollection: this.goodsSkuDetail.is_collect,
evaluateConfig: this.goodsSkuDetail.evaluate_config,
evaluateList: this.goodsSkuDetail.evaluate_list,
evaluateCount: this.goodsSkuDetail.evaluate_count
});
}
} catch (e) {
console.error('goodsDetailView.init failed:', e);
}
//媒体
if (this.goodsSkuDetail.video_url) this.switchMedia = "video";
if (!Array.isArray(this.goodsSkuDetail.sku_images)) {
if (this.goodsSkuDetail.sku_images) this.goodsSkuDetail.sku_images = this.goodsSkuDetail.sku_images.split(",");
else this.goodsSkuDetail.sku_images = [];
}
// 多规格时合并主图
if (this.goodsSkuDetail.goods_spec_format && this.goodsSkuDetail.goods_image) {
if (!Array.isArray(this.goodsSkuDetail.goods_image)) this.goodsSkuDetail.goods_image = this.goodsSkuDetail.goods_image.split(",");
this.goodsSkuDetail.sku_images = this.goodsSkuDetail.goods_image.concat(this.goodsSkuDetail.sku_images);
}
let maxHeight = '';
let systemInfo = uni.getSystemInfoSync();
this.goodsSkuDetail.goods_image_list.forEach((item, index) => {
if (typeof item.pic_spec == "string")
item.pic_spec = item.pic_spec.split('*');
let ratio = item.pic_spec[0] / systemInfo.windowWidth;
item.pic_spec[0] = item.pic_spec[0] / ratio;
item.pic_spec[1] = item.pic_spec[1] / ratio;
if (!maxHeight || maxHeight > item.pic_spec[1]) {
maxHeight = item.pic_spec[1];
}
});
this.goodsSkuDetail.swiperHeight = maxHeight + 'px';
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
// 当前商品SKU规格
if (this.goodsSkuDetail.sku_spec_format) this.goodsSkuDetail.sku_spec_format = JSON.parse(this.goodsSkuDetail.sku_spec_format);
// 商品属性
if (this.goodsSkuDetail.goods_attr_format) {
let goods_attr_format = JSON.parse(this.goodsSkuDetail.goods_attr_format);
this.goodsSkuDetail.goods_attr_format = this.$util.unique(goods_attr_format, "attr_id");
for (var i = 0; i < this.goodsSkuDetail.goods_attr_format.length; i++) {
for (var j = 0; j < goods_attr_format.length; j++) {
if (this.goodsSkuDetail.goods_attr_format[i].attr_id == goods_attr_format[j].attr_id && this.goodsSkuDetail.goods_attr_format[i].attr_value_id != goods_attr_format[j].attr_value_id) {
this.goodsSkuDetail.goods_attr_format[i].attr_value_name += "、" + goods_attr_format[j].attr_value_name;
}
}
}
}
}
// 商品SKU格式
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
// 商品SKU格式
if (this.goodsSkuDetail.goods_spec_format) this.goodsSkuDetail.goods_spec_format = JSON.parse(this.goodsSkuDetail.goods_spec_format);
// 商品详情
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
console.log(this.goodsSkuDetail.goods_content)
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
// 商品详情
if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = (this.goodsSkuDetail.goods_content);
// if (this.goodsSkuDetail.goods_content) this.goodsSkuDetail.goods_content = htmlParser(this.goodsSkuDetail.goods_content);
//商品服务
if (this.goodsSkuDetail.goods_service) {
for (let i in this.goodsSkuDetail.goods_service) {
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
//商品服务
if (this.goodsSkuDetail.goods_service) {
for (let i in this.goodsSkuDetail.goods_service) {
this.goodsSkuDetail.goods_service[i]['icon'] = this.goodsSkuDetail.goods_service[i]['icon'] ? JSON.parse(this.goodsSkuDetail.goods_service[i]['icon']) : '';
}
}
}
this.contactData = {
title: this.goodsSkuDetail.sku_name,
path: this.shareUrl,
img: this.$util.img(this.goodsSkuDetail.sku_image, {
size: 'big'
})
};
if (this.$refs.goodsPromotion) this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
this.contactData = {
title: this.goodsSkuDetail.sku_name,
path: this.shareUrl,
img: this.$util.img(this.goodsSkuDetail.sku_image, {
size: 'big'
})
};
try {
if (this.$refs && this.$refs.goodsPromotion) {
this.$refs.goodsPromotion.refresh(this.goodsSkuDetail.goods_promotion);
}
} catch (e) { console.error('goodsPromotion refresh failed:', e); }
if (this.goodsRoute != '/pages/goods/detail') this.setPublicShare();
// this.getBarrageData();
if (this.addonIsExist.form) {
this.getGoodsForm();
// 性能优化: 商品表单(留言表单)仅在商品详情打开"立即留言"弹窗时才需要,
// 不再阻塞首次渲染。改为在后台并发请求, 用户真正打开弹窗时数据已就绪。
try {
this._goodsFormPromise = this.getGoodsForm();
} catch (e) {
console.error('getGoodsForm kicked off failed:', e);
}
} catch (e) {
console.error('handleGoodsSkuData failed:', e);
}
},
/**
@@ -200,7 +212,7 @@ export default {
if (this.$refs.goodsDetailView) {
// 初始化商品详情视图数据
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || "件";
this.goodsSkuDetail.unit = this.goodsSkuDetail.unit || this.$lang('unit.piece');
// 解决轮播图数量不一致时,切换到第一个
if (this.swiperCurrent > this.goodsSkuDetail.sku_images.length) {
@@ -240,11 +252,11 @@ export default {
},
goHome() {
if (this.preview) return; // 开启预览,禁止任何操作和跳转
this.$util.redirectTo('/pages/index/index');
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
},
goCart() {
if (this.preview) return; // 开启预览,禁止任何操作和跳转
this.$util.redirectTo('/pages/goods/cart');
this.$util.redirectTo('/pages_goods/cart');
},
//-------------------------------------关注-------------------------------------
//更新商品信息
@@ -322,17 +334,76 @@ export default {
},
/**
* 获取商品表单
* 后端 goodsform 接口与 info() 对齐,返回 {json_data, form_config(camelCase),
* form_name, form_tip, form_banner, show_banner, show_tip}。
* 全部字段都落到 goodsSkuDetail 上:表单字段 / 样式 / banner / tip 元数据,
* 给 detail.vue 的 diy-form 直接消费。所有分支都做兜底初始化,避免下游拿到 undefined。
*/
getGoodsForm() {
this.$api.sendRequest({
url: "/form/api/form/goodsform",
data: {
goods_id: this.goodsSkuDetail.goods_id
},
success: res => {
if (res.code == 0 && res.data) this.$set(this.goodsSkuDetail, 'goods_form', res.data);
}
});
async getGoodsForm() {
// 防御: sku 切换/数据未到时 goods_id 可能为 0/空, 此时直接走空数据分支,
// 避免后端拿到非法 goods_id 导致 4xx/5xx 后还把脏数据塞给前端。
if (!this.goodsSkuDetail || !this.goodsSkuDetail.goods_id) {
const emptyFn = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
};
emptyFn();
return;
}
let res;
try {
res = await this.$api.sendRequest({
url: "/form/api/form/goodsform",
data: {
goods_id: this.goodsSkuDetail.goods_id,
format: 'object'
},
async: false
});
} catch (e) {
console.warn('getGoodsForm request failed', e);
res = null;
}
const empty = () => {
this.$set(this.goodsSkuDetail, 'goods_form', []);
this.$set(this.goodsSkuDetail, 'goods_form_config', {});
this.$set(this.goodsSkuDetail, 'goods_form_name', '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', '');
this.$set(this.goodsSkuDetail, 'goods_form_show_banner', false);
this.$set(this.goodsSkuDetail, 'goods_form_show_tip', false);
};
if (!res || res.code != 0 || !res.data) {
empty();
return;
}
if (Array.isArray(res.data)) {
// 旧版扁平数组兼容
empty();
this.$set(this.goodsSkuDetail, 'goods_form', res.data);
return;
}
this.$set(this.goodsSkuDetail, 'goods_form', res.data.json_data || []);
this.$set(this.goodsSkuDetail, 'goods_form_config', res.data.form_config || {});
this.$set(this.goodsSkuDetail, 'goods_form_name', res.data.form_name || '');
this.$set(this.goodsSkuDetail, 'goods_form_tip', res.data.form_tip || '');
this.$set(this.goodsSkuDetail, 'goods_form_banner', res.data.form_banner || '');
// show_banner/show_tip 后端存 0/1,归一化 boolean
this.$set(
this.goodsSkuDetail,
'goods_form_show_banner',
res.data.show_banner == 1 ? true : (res.data.show_banner === 0 ? false : true)
);
this.$set(
this.goodsSkuDetail,
'goods_form_show_tip',
res.data.show_tip == 1 ? true : (res.data.show_tip === 0 ? false : true)
);
}
},
/**

View File

@@ -316,11 +316,9 @@ function replaceVideo(html){
let rep = `<video style="width:100% !important;display:block;max-width: ${screenWidth} !important;"`;
var html = html.replace(/\\/g, '').replace(/<video/g, rep);
console.log(html)
html = html.replace(/<video [^>]*src=['"]([^'"]+)[^>]*>/gi, (match, capture) => {
return rep + ' src="' + util.img(capture) + '"/>';
});
// console.log(html)
return html;
}

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

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

View File

@@ -1,64 +1,188 @@
import Config from './config.js'
import Util from './util.js'
import store from '@/store/index.js'
import Platform from './platform.js'
// #ifdef H5
const app_type = Util.isWeiXin() ? 'wechat' : 'h5';
const app_type_name = Util.isWeiXin() ? '微信公众号' : 'H5';
// #endif
const { appType, appTypeName } = Platform.getPlatformInfo();
// #ifdef MP-WEIXIN
const app_type = 'weapp';
const app_type_name = '微信小程序';
// #endif
let _Lang = null;
function getLang() {
if (!_Lang) {
_Lang = require('./lang.js').default;
}
return _Lang;
}
// #ifdef MP-ALIPAY
const app_type = 'aliapp';
const app_type_name = '支付宝小程序';
// #endif
const DEFAULT_TIMEOUT = 15000;
const DEFAULT_MAX_RETRIES = 2;
const RETRY_DELAY_BASE = 2000;
// #ifdef MP-BAIDU
const app_type = 'baiduapp';
const app_type_name = '百度小程序';
// #endif
function _shouldRetry(errMsg) {
if (!errMsg) return false;
if (errMsg === 'request:fail url not in domain list') return false;
if (errMsg === 'request:fail abort') return false;
const retryableErrors = [
'request:fail timeout',
'request:fail connect',
'request:fail network'
];
return retryableErrors.some(err => errMsg.startsWith(err));
}
// #ifdef MP-TOUTIAO
const app_type = 'MP-TOUTIAO';
const app_type_name = '头条小程序';
// #endif
function _classifyError(errMsg, statusCode) {
if (!errMsg && !statusCode) return 'unknown';
if (errMsg === 'request:fail abort') return 'aborted';
if (errMsg && errMsg.indexOf('timeout') !== -1) return 'timeout';
if (errMsg && (errMsg.indexOf('connect') !== -1 || errMsg.indexOf('network') !== -1)) return 'networkError';
if (statusCode && statusCode >= 500) return 'serverError';
if (errMsg && errMsg.indexOf('url not in domain list') !== -1) return 'domainNotInList';
return 'unknown';
}
// #ifdef MP-QQ
const app_type = 'MP-QQ';
const app_type_name = 'QQ小程序';
// #endif
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// #ifdef APP-PLUS
const app_type = 'app';
const app_type_name = 'APP';
// #endif
function _makeRequest(url, method, data, header, dataType, responseType, timeout) {
const reqId = 'req_' + Date.now() + '_' + Math.floor(Math.random() * 10000);
const reqStart = Date.now();
console.log('[HTTP][REQUEST]', reqId, method, url, JSON.stringify({
data: data,
header: header,
dataType: dataType,
responseType: responseType,
timeout: timeout
}));
return new Promise((resolve, reject) => {
uni.request({
url: url,
method: method,
data: data,
header: header,
dataType: dataType,
responseType: responseType,
timeout: timeout,
success: (res) => {
console.log('[HTTP][RESPONSE]', reqId, method, url, 'statusCode=', res.statusCode, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res.data));
if (res.statusCode && [200, 500].indexOf(res.statusCode) == -1) {
reject({ errMsg: `request:fail statusCode=${res.statusCode}`, statusCode: res.statusCode });
return;
}
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
resolve(res.data);
},
fail: (res) => {
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
reject(res);
}
});
});
}
async function _requestWithRetry(params, url, method, data, header, dataType, responseType) {
const timeout = params.timeout || DEFAULT_TIMEOUT;
const maxRetries = params.noRetry ? 0 : (params.maxRetries || DEFAULT_MAX_RETRIES);
let lastError = null;
let lastStatusCode = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
console.log('[HTTP][RETRY]', method, url, 'attempt=', attempt, 'maxRetries=', maxRetries);
}
const result = await _makeRequest(url, method, data, header, dataType, responseType, timeout);
return result;
} catch (err) {
lastError = err;
lastStatusCode = err && err.statusCode;
console.warn('[HTTP][RETRY-FAIL]', method, url, 'attempt=', attempt, 'errMsg=', err && err.errMsg, 'statusCode=', lastStatusCode);
if (attempt >= maxRetries || !_shouldRetry(err.errMsg)) {
break;
}
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt);
console.log('[HTTP][RETRY-WAIT]', method, url, 'delay=', delay + 'ms');
await _sleep(delay);
}
}
if (params.silentError) {
throw lastError;
}
const errorType = _classifyError(lastError && lastError.errMsg, lastStatusCode);
const lang = getLang();
if (errorType === 'domainNotInList') {
uni.showToast({
title: lang.lang('http.domainNotInList', { url: Config.baseUrl }),
'icon': 'none',
duration: 10000
});
} else {
const i18nKey = 'http.' + errorType;
uni.showToast({
title: lang.lang(i18nKey),
'icon': 'none',
duration: 10000
});
}
throw lastError;
}
export default {
sendRequest(params) {
if (!Config.baseUrl) {
uni.showToast({
title: '未配置请求域名',
title: getLang().lang('http.baseUrlNotConfigured'),
'icon': 'none',
duration: 10000
});
return;
}
var method = params.data != undefined ? 'POST' : 'GET', // 请求方式
url = Config.baseUrl + params.url, // 请求路径
const _isRemoteOnly = getLang().isRemoteOnly && getLang().isRemoteOnly();
const _bootDone = getLang().isBootCompleted && getLang().isBootCompleted();
if (_isRemoteOnly && !_bootDone) {
let bootPromise = null;
try {
const app = getApp();
if (app && app.$langReady) bootPromise = app.$langReady;
} catch (e) {}
if (!bootPromise) bootPromise = getLang().boot();
if (params.async === false) {
return bootPromise.then(() => this._sendRequestNow(params));
} else {
bootPromise.then(() => this._sendRequestNow(params));
return;
}
}
return this._sendRequestNow(params);
},
_sendRequestNow(params) {
var method = params.data != undefined ? 'POST' : 'GET',
url = (Config.baseUrl + params.url).replace(/(?<!:)\/+/g, '/'),
data = {
app_type,
app_type_name
app_type: appType,
app_type_name: appTypeName
};
// token
data.token = store.state.token || '';
data.uniacid = Config.uniacid
// 门店id
data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid;
var default_store_info = store.state.defaultStoreInfo;
if (default_store_info) {
data.store_id = default_store_info.store_id;
@@ -68,126 +192,38 @@ export default {
if (store_info) data.store_id = store_info.store_id;
// 参数
if (params.data != undefined) Object.assign(data, params.data);
const header = params.header || {
'content-type': 'application/x-www-form-urlencoded;application/json'
};
const dataType = params.dataType || 'json';
const responseType = params.responseType || 'text';
if (params.async === false) {
//同步
return new Promise((resolve, reject) => {
uni.request({
url: url,
method: method,
data: data,
header: params.header || {
// 'Accept': 'application/json',
'content-type': 'application/x-www-form-urlencoded;application/json'
},
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
// try {
// res.data = JSON.parse(res.data);
// } catch (e) {
// //TODO handle the exception
// console.log('api error:', e);
// }
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
resolve(res.data);
},
fail: (res) => {
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
uni.showToast({
title: Config.baseUrl + '不在request 合法域名列表中',
'icon': 'none',
duration: 10000
});
return;
}
reject(res);
},
complete: (res) => {
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
uni.showToast({
title: Config.baseUrl + '请求失败',
'icon': 'none',
duration: 10000
});
return;
}
reject(res.data);
}
return _requestWithRetry(params, url, method, data, header, dataType, responseType)
.then(res => {
typeof params.complete == 'function' && params.complete(res);
return res;
})
.catch(err => {
typeof params.complete == 'function' && params.complete();
throw err;
});
});
} else {
//异步
uni.request({
url: url,
method: method,
data: data,
header: params.header || {
// 'Accept': 'application/json',
'content-type': 'application/x-www-form-urlencoded;application/json'
},
dataType: params.dataType || 'json',
responseType: params.responseType || 'text',
success: (res) => {
// try {
// res.data = JSON.parse(res.data);
// } catch (e) {
// //TODO handle the exception
// console.log('api error:', e);
// }
if (res.data.code == -3 && store.state.siteState > 0) {
store.commit('setSiteState', -3);
Util.redirectTo('/pages_tool/storeclose/storeclose', {}, 'reLaunch');
return;
}
if (res.data.refreshtoken) {
store.commit('setToken', res.data.refreshtoken);
}
if (res.data.code == -10009 || res.data.code == -10010) {
store.commit('setToken', '');
store.commit('setMemberInfo', '');
}
typeof params.success == 'function' && params.success(res.data);
},
fail: (res) => {
if (res.errMsg && res.errMsg == 'request:fail url not in domain list') {
uni.showToast({
title: Config.baseUrl + '不在request 合法域名列表中',
'icon': 'none',
duration: 10000
});
return;
}
typeof params.fail == 'function' && params.fail(res);
},
complete: (res) => {
if ((res.errMsg && res.errMsg != "request:ok") || (res.statusCode && [200, 500].indexOf(res.statusCode) == -1)) {
uni.showToast({
title: Config.baseUrl + '请求失败',
'icon': 'none',
duration: 10000
});
return;
}
typeof params.complete == 'function' && params.complete(res.data);
}
});
_requestWithRetry(params, url, method, data, header, dataType, responseType)
.then(res => {
typeof params.success == 'function' && params.success(res);
typeof params.complete == 'function' && params.complete(res);
})
.catch(err => {
typeof params.fail == 'function' && params.fail(err);
typeof params.complete == 'function' && params.complete();
});
}
},
uploadBase64(params) {
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
uni.request({
url: Config.baseUrl + '/api/upload/headimgBase64',
method: 'POST',
@@ -195,21 +231,24 @@ export default {
'content-type': 'application/x-www-form-urlencoded;application/json'
},
data: {
app_type,
app_type_name,
app_type: appType,
app_type_name: appTypeName,
images: params.base64
},
dataType: 'json',
responseType: 'text',
success: res => {
console.log('[HTTP][RESPONSE] uploadBase64', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data);
},
fail: () => {
console.error('[HTTP][FAIL] uploadBase64', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
pullImage(params) {
console.log('[HTTP][REQUEST] pullImage', Config.baseUrl + '/api/upload/headimgPull', 'path=', params.path);
uni.request({
url: Config.baseUrl + '/api/upload/headimgPull',
method: 'POST',
@@ -217,37 +256,42 @@ export default {
'content-type': 'application/x-www-form-urlencoded;application/json'
},
data: {
app_type,
app_type_name,
app_type: appType,
app_type_name: appTypeName,
path: params.path
},
dataType: 'json',
responseType: 'text',
success: res => {
console.log('[HTTP][RESPONSE] pullImage', 'statusCode=', res.statusCode, JSON.stringify(res.data));
typeof params.success == 'function' && params.success(res.data);
},
fail: () => {
console.error('[HTTP][FAIL] pullImage', JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});
},
upload(params) {
console.log('[HTTP][REQUEST] upload', Config.baseUrl + params.url, 'name=', params.name || 'file');
uni.uploadFile({
url: Config.baseUrl + params.url,
filePath: params.filePath,
name: params.name || 'file',
fileType: params.fileType || 'image',
formData: {
app_type,
app_type_name,
app_type: appType,
app_type_name: appTypeName,
},
header: {
'content-type': 'application/x-www-form-urlencoded;application/json'
},
success: (res) => {
console.log('[HTTP][RESPONSE] upload', Config.baseUrl + params.url, 'statusCode=', res.statusCode, res.data);
typeof params.success == 'function' && params.success(JSON.parse(res.data));
},
fail: (res) => {
console.error('[HTTP][FAIL] upload', Config.baseUrl + params.url, JSON.stringify(res));
typeof params.fail == 'function' && params.fail(res);
}
});

15
common/js/image-config.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* 图片配置文件
* 将大图片上传到服务器后,请在此处配置网络URL
*/
// 替换为你上传后的实际图片URL
const IMAGE_CONFIG = {
// AI客服头像
aiAvatar: 'public/static/img/s-agent.jpg',
// 用户默认头像
userAvatar: 'public/static/img/s-user.jpg'
};
export default IMAGE_CONFIG;

View File

@@ -0,0 +1,11 @@
/**
* remote-only 模式下的空语言包 stub
*
* 当 source === 'remote-only' 时,webpack NormalModuleReplacementPlugin
* 会把所有 @/lang/ 下的 require 替换为这个文件,
* 避免本地 lang/ 目录被打进产物。
*
* 导出格式与真实语言包一致: { lang: {} }
*/
export const lang = {};
export default { lang };

455
common/js/lang-loader.js Normal file
View File

@@ -0,0 +1,455 @@
/**
* 远端语言包 bundle 加载器
*
* 职责:
* 1. 从后端 /api/lang/bundle 拉取完整语言树
* 2. 做本地 storage 缓存 + 内存缓存
* 3. 支持签名校验(可选)
* 4. 支持 diff 增量更新(预留)
* 5. 对外暴露 loadBundleTree / getCachedBundle / clearBundleCache
*
* 与 lang.js 的关系:
* 当 langConfig.source === 'remote-only' 时,lang.js 不再 require 本地 lang/ 文件,
* 而是直接复用本模块加载的 tree。
*/
import { langConfig } from './config-external.js';
import Config from './config.js';
import { Platform } from './platform.js';
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
// 内存缓存: { [memKey]: { version, tree, ts } }
const bundleCache = {};
// 关键修复: 缓存 fetchBundle 迟到的成功结果
// 即使 Promise.race 已 timeout, uni.request 内部可能继续完成
// 缓存这份迟到结果, 给上层 catch 后的"补救"逻辑使用
// 结构: { `${lang}_${appType}_${uniacid}`: { data, ts, lang, appType, uniacid } }
const _lastSuccessfulResult = {};
// 缓存 TTL(与 BUNDLE_CACHE_TTL 一致): 10 分钟
const _LAST_RESULT_TTL = 10 * 60 * 1000;
// 并发去重
const bundleInflight = {};
/**
* 三维隔离 key: <uniacid>_<appType>_<lang>
*/
function getBundleMemKey(uniacid, appType, lang) {
return `${uniacid || 0}_${appType || '*'}_${lang}`;
}
/**
* storage key: 与 memKey 维度一致
*/
function getBundleStorageKey(uniacid, appType, lang) {
return `${langConfig.remote.storagePrefix || 'LANG_BUNDLE_'}${getBundleMemKey(uniacid, appType, lang)}`;
}
function getCurrentLocale() {
try {
return uni.getStorageSync('lang') || langConfig.defaultLang || 'zh-cn';
} catch (e) {
return langConfig.defaultLang || 'zh-cn';
}
}
function getCurrentUniacid() {
try {
const u = uni.getStorageSync('uniacid');
return u ? parseInt(u, 10) || 0 : 0;
} catch (e) {
return 0;
}
}
function getCurrentAppType() {
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
}
/**
* 深度合并两个对象,source 覆盖 target
*/
function deepMerge(target, source) {
if (target === null || typeof target !== 'object') return source;
if (source === null || typeof source !== 'object') return source;
if (Array.isArray(target) || Array.isArray(source)) return source;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
if (FORBIDDEN_KEYS.has(key)) continue;
const srcVal = source[key];
const tgtVal = target[key];
if (
srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
) {
target[key] = deepMerge(tgtVal, srcVal);
} else {
target[key] = srcVal;
}
}
return target;
}
/**
* 简易签名校验
* 后端: hash_hmac('sha256', version + json_encode(tree), secret)
* 前端: 同样算法
*/
function validateSign(tree, version, sign, secret) {
if (!secret || !sign) return true;
// 前端没有 crypto-js,用简单字符串拼接做校验(仅基础防篡改)
// 如需强校验,建议引入 crypto-js 或小程序/APP 端使用原生加密 API
// 关键修复: 用 try-catch 保护 btoa(),避免在小程序环境(无 btoa)中抛 ReferenceError
let _btoaResult = '';
try {
const payload = JSON.stringify(tree);
// 兼容小程序环境: 没有原生 btoa 时降级
if (typeof btoa === 'function') {
_btoaResult = btoa(version + ':' + payload).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
// 微信小程序/uni-app 提供 arrayBufferToBase64
const _encoder = (typeof TextEncoder !== 'undefined')
? new TextEncoder().encode(version + ':' + payload)
: null;
if (_encoder) {
_btoaResult = uni.arrayBufferToBase64(_encoder.buffer).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
}
}
} catch (e) {
// 签名计算失败也不应阻断 bundle 加载,静默失败
}
// 后端返回的是 hash_hmac('sha256', version + payload, secret)
// 这里只做格式校验,不强校验(因为无 crypto 库)
return typeof sign === 'string' && sign.length > 0;
}
// 缓存 TTL(ms): 与后端 Cache::set 600s 对齐,过期后后台刷新
const BUNDLE_CACHE_TTL = (langConfig.remote.cacheTtl || 600) * 1000;
/**
* 从 storage 读取缓存(检查 TTL 过期)
* @returns {Object|null} 未过期返回 {tree,version,ts}, 过期返回 null
*/
function readStorageCache(sk) {
try {
const cached = uni.getStorageSync(sk);
if (cached && cached.tree && typeof cached.tree === 'object') {
// TTL 检查: 超过 cacheTtl 视为过期
if (cached.ts && (Date.now() - cached.ts < BUNDLE_CACHE_TTL)) {
return cached;
}
}
} catch (e) {}
return null;
}
/**
* 检查内存缓存是否过期
*/
function isMemoryCacheExpired(record) {
if (!record || !record.ts) return true;
return (Date.now() - record.ts) >= BUNDLE_CACHE_TTL;
}
/**
* 写入 storage 缓存
*/
function writeStorageCache(sk, data) {
try {
uni.setStorageSync(sk, data);
} catch (e) {
// storage 写满时静默失败
}
}
/**
* 从后端拉取 bundle
*
* 关键修复: 即使 Promise.race 在 timeout 时已经 reject,
* uni.request 内部仍可能继续执行并成功返回。
* 之前这些"迟到"的成功响应被完全丢弃,导致:
* 1. fetchBundle 抛 timeout 错
* 2. _bootImpl 进 catch, 不写 remoteTreeCache
* 3. uni.request 终于成功, 但没人接收, 结果被 GC
* 现在改为: uni.request 完成后, **无论 Promise.race 状态如何**,
* 都把结果写入 _lastSuccessfulResult 缓存。
* _bootImpl 可以在 catch 后**主动等待**这份迟到数据(最多 3 秒),
* 从而真正用上远端返回的完整 bundle。
*/
async function fetchBundle(lang, appType, uniacid, timeout) {
const url = langConfig.remote.bundleUrl || '/api/lang/bundle';
const data = { lang };
if (appType && appType !== '*') data.app_type = appType;
// uniacid 必须传给后端,后端按 (uniacid × app_type) 二维查询租户专属覆写
if (uniacid !== undefined && uniacid !== null) data.uniacid = uniacid;
// 直接用 uni.request 包装 Promise,不依赖 http.sendRequest
// (sendRequest 的异步分支不返回 Promise,只有 async:false 分支才返回)
// 去重双斜杠: baseUrl 末尾可能带 /, bundleUrl 开头也带 /
const fullUrl = (Config.baseUrl || '').replace(/\/+$/, '') + url;
// 关键修复: 用一个 "completionLatch" 跟踪 uni.request 是否最终完成(成功或失败)
// 即使 Promise.race 已经 timeout reject, uni.request 内部仍可能继续完成
// 我们保留这份迟到结果到 _lastSuccessfulResult, 给上层补救的机会
const inflightKey = `${lang}_${appType}_${uniacid}`;
const res = await Promise.race([
new Promise((resolve, reject) => {
uni.request({
url: fullUrl,
method: 'POST',
data: data,
header: { 'content-type': 'application/x-www-form-urlencoded' },
dataType: 'json',
success: (response) => {
// 关键: 即使 Promise.race 已 reject, 仍然把成功结果写入全局缓存
// 给 loadBundleTree catch 后 "补救" 的机会
try {
if (response && response.data && response.data.code === 0 && response.data.data && response.data.data.tree) {
_lastSuccessfulResult[inflightKey] = {
data: response.data,
ts: Date.now(),
lang, appType, uniacid,
};
}
} catch (e) {}
resolve(response.data);
},
fail: (err) => {
reject(new Error(err.errMsg || 'bundle request failed'));
},
});
}),
new Promise((_, reject) => setTimeout(() => {
reject(new Error('timeout'));
}, timeout)),
]);
if (!res || res.code !== 0 || !res.data) {
throw new Error(res && res.message ? res.message : 'bundle fetch failed');
}
return res.data;
}
/**
* 加载 bundle tree
*
* @param {Object} options
* @param {string} lang 语言
* @param {string} [appType] 平台类型
* @param {number} [uniacid] 租户 id
* @param {boolean} [allowCache=true] 是否允许使用缓存
* @param {boolean} [force=false] 是否强制刷新
* @returns {Promise<{tree:Object, version:string, source:string}>}
*/
export async function loadBundleTree(options = {}) {
const lang = options.lang || getCurrentLocale();
const appType = options.appType || getCurrentAppType();
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
const allowCache = options.allowCache !== false;
const force = options.force === true;
const mk = getBundleMemKey(uniacid, appType, lang);
const sk = getBundleStorageKey(uniacid, appType, lang);
if (bundleInflight[mk]) {
return bundleInflight[mk];
}
const task = (async () => {
try {
// 1) 内存缓存未过期直接返回
if (!force && allowCache && bundleCache[mk] && !isMemoryCacheExpired(bundleCache[mk])) {
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'memory' };
}
// 2) 读取 storage 缓存(已含 TTL 检查)
let cached = null;
if (!force && allowCache) {
cached = readStorageCache(sk);
if (cached && !bundleCache[mk]) {
bundleCache[mk] = cached;
}
}
// 3) 远端拉取
const timeout = langConfig.remote.requestTimeout || 4000;
const payload = await fetchBundle(lang, appType, uniacid, timeout);
const tree = payload.tree || {};
const version = String(payload.version || '');
const sign = payload.sign || '';
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
console.warn(
'[lang-loader] bundle tree INCOMPLETE. ' +
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
' lang=' + lang +
' appType=' + appType +
' uniacid=' + uniacid +
' version=' + version +
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
);
}
// 4) 签名校验(可选)
const secret = langConfig.remote.signSecret || '';
const signValid = validateSign(tree, version, sign, secret);
if (!signValid) {
throw new Error('bundle sign invalid');
}
// 5) 更新缓存
const record = { version, tree, ts: Date.now() };
bundleCache[mk] = record;
writeStorageCache(sk, record);
return { tree, version, source: 'remote' };
} catch (e) {
// 关键修复: 补救迟到数据
// fetchBundle 因 timeout 抛错时, uni.request 内部可能仍然成功(只是没 Promise 接收)
// 之前这些"迟到"数据被完全丢弃,导致 boot 失败后永远等不到远端数据
// 现在: catch 后等待 _lastSuccessfulResult 出现(最多 3 秒), 期间如果迟到数据到达则用之
const inflightKey = `${lang}_${appType}_${uniacid}`;
// 等待 _lastSuccessfulResult 出现(超时后 uni.request 内部 success 回调可能稍后才触发)
const waitStart = Date.now();
const WAIT_MAX_MS = 3000;
const WAIT_POLL_MS = 100;
while (Date.now() - waitStart < WAIT_MAX_MS) {
const lateResult = _lastSuccessfulResult[inflightKey];
if (lateResult && (Date.now() - lateResult.ts) < _LAST_RESULT_TTL) {
// 有"迟到"数据, 直接用
const resp = lateResult.data;
// resp 结构: { code, message, data: { version, tree, sign } }
const dataNode = resp && resp.data;
const tree = (dataNode && dataNode.tree) || {};
const version = String((dataNode && dataNode.version) || '');
const sign = (dataNode && dataNode.sign) || '';
const signValid = validateSign(tree, version, sign, langConfig.remote.signSecret || '');
if (signValid) {
const record = { version, tree, ts: Date.now() };
bundleCache[mk] = record;
writeStorageCache(sk, record);
return { tree, version, source: 'remote-late' };
}
}
// 等待 WAIT_POLL_MS
await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
}
// 6) 失败时回退到缓存
// 关键修复: force=true 时**不回退**到 bundleCache
// 旧逻辑: force 失败 → 回退到旧 cache → 标记新 ts → 永远不被刷新 → bundle 缺 key
// 新逻辑: force 失败 → 抛错, 让上层 _bootImpl 进入 catch 块走真正的"无 cache 兜底"
if (force) {
throw e;
}
if (allowCache && bundleCache[mk]) {
return { tree: bundleCache[mk].tree, version: bundleCache[mk].version, source: 'cache', error: e.message };
}
throw e;
} finally {
delete bundleInflight[mk];
}
})();
bundleInflight[mk] = task;
return task;
}
/**
* 获取已缓存的 bundle(不触发网络请求)
* 注意: 受 TTL 限制,过期缓存返回 null
*/
export function getCachedBundle(options = {}) {
const lang = options.lang || getCurrentLocale();
const appType = options.appType || getCurrentAppType();
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
const mk = getBundleMemKey(uniacid, appType, lang);
const sk = getBundleStorageKey(uniacid, appType, lang);
if (bundleCache[mk]) return bundleCache[mk];
const cached = readStorageCache(sk);
if (cached) {
bundleCache[mk] = cached;
return cached;
}
return null;
}
/**
* 获取任意已缓存的 bundle(不检查 TTL),仅用于 boot 失败兜底
*
* 与 getCachedBundle 的区别:
* - getCachedBundle: 受 BUNDLE_CACHE_TTL 限制,过期返回 null(用于日常 $lang 渲染)
* - getCachedBundleAny: 不检查 TTL,只要内存或 storage 中有就返回(用于失败兜底)
*
* 设计意图:
* boot() 强制重试失败时,如果用户本地还有哪怕过期的 bundle,
* 也比"$lang() 全返回空串"用户体验更好 — 至少能展示残缺文案。
* 这是显式的"宁可用过期缓存,也不空白"的取舍。
*
* 优先返回内存缓存(更快),其次返回 storage 缓存。
*
* @returns {{version:string, tree:Object, ts:number, source:'memory'|'storage'}|null}
*/
export function getCachedBundleAny(options = {}) {
const lang = options.lang || getCurrentLocale();
const appType = options.appType || getCurrentAppType();
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
const mk = getBundleMemKey(uniacid, appType, lang);
const sk = getBundleStorageKey(uniacid, appType, lang);
// 1) 内存缓存优先(任意 TTL,过期也返回)
if (bundleCache[mk] && bundleCache[mk].tree) {
return { ...bundleCache[mk], source: 'memory' };
}
// 2) storage 兜底(任意 TTL,不调用 readStorageCache 因为它内部会查 TTL)
try {
const raw = uni.getStorageSync(sk);
if (raw && raw.tree && typeof raw.tree === 'object') {
// 写回内存缓存,方便下次 getCachedBundle / getCachedBundleAny 命中
bundleCache[mk] = raw;
return { ...raw, source: 'storage' };
}
} catch (e) { /* 忽略 */ }
return null;
}
/**
* 清除 bundle 缓存
*/
export function clearBundleCache(options = {}) {
const lang = options.lang || getCurrentLocale();
const appType = options.appType || getCurrentAppType();
const uniacid = options.uniacid !== undefined ? options.uniacid : getCurrentUniacid();
const mk = getBundleMemKey(uniacid, appType, lang);
const sk = getBundleStorageKey(uniacid, appType, lang);
delete bundleCache[mk];
try { uni.removeStorageSync(sk); } catch (e) {}
}
/**
* 检查是否有缓存可用(用于启动时快速判断)
*/
export function hasBundleCache(options = {}) {
return getCachedBundle(options) !== null;
}
/**
* 合并本地语言包与远端 bundle tree(混合模式用)
*/
export function mergeLocalWithBundle(localTree, bundleTree) {
if (!localTree || typeof localTree !== 'object') return bundleTree || {};
if (!bundleTree || typeof bundleTree !== 'object') return localTree || {};
return deepMerge(deepMerge({}, localTree), bundleTree);
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,12 @@
*/
import Util from './util.js'
import Lang from './lang.js';
var ERROR_CONF = {
KEY_ERR: 311,
KEY_ERR_MSG: 'key格式错误',
PARAM_ERR: 310,
PARAM_ERR_MSG: '请求参数信息有误',
SYSTEM_ERR: 600,
SYSTEM_ERR_MSG: '系统错误',
WX_ERR_CODE: 1000,
WX_OK_CODE: 200
};
@@ -385,7 +383,7 @@ var Utils = {
*/
checkParamKeyEmpty(param, key) {
if (!param[key]) {
var errconf = this.buildErrorConfig(ERROR_CONF.PARAM_ERR, ERROR_CONF.PARAM_ERR_MSG + key +'参数格式有误');
var errconf = this.buildErrorConfig(ERROR_CONF.PARAM_ERR, Lang.lang('common.mapWxJssdk.paramFormatError', { param: key }));
param.fail(errconf);
param.complete(errconf);
return true;
@@ -410,7 +408,7 @@ var Utils = {
checkLocation(param) {
var location = this.getLocationParam(param.location);
if (!location || !location.latitude || !location.longitude) {
var errconf = this.buildErrorConfig(ERROR_CONF.PARAM_ERR, ERROR_CONF.PARAM_ERR_MSG + ' location参数格式有误');
var errconf = this.buildErrorConfig(ERROR_CONF.PARAM_ERR, Lang.lang('common.mapWxJssdk.locationParamFormatError'));
param.fail(errconf);
param.complete(errconf);
return false;
@@ -623,7 +621,7 @@ var Utils = {
break;
}
default:{
param.complete(that.buildErrorConfig(ERROR_CONF.SYSTEM_ERR, ERROR_CONF.SYSTEM_ERR_MSG));
param.complete(that.buildErrorConfig(ERROR_CONF.SYSTEM_ERR, Lang.lang('common.mapWxJssdk.systemError')));
}
}
@@ -664,7 +662,7 @@ class QQMapWX {
*/
constructor(options) {
if (!options.key) {
throw Error('key值不能为空');
throw Error(Lang.lang('common.mapWxJssdk.keyCannotBeEmpty'));
}
this.key = options.key;
};

View File

@@ -1,4 +1,6 @@
import util from '@/common/js/util.js'
import TransformCoordinate from './transformCoordinate.js'
import Lang from '@/common/js/lang.js'
function openMapByDefault(latitude, longitude, name) {
uni.openLocation({
@@ -7,7 +9,7 @@ function openMapByDefault(latitude, longitude, name) {
name: name,
fail: (e) => {
uni.showModal({
content: '打开地图失败,请稍后重试'
content: Lang.lang('common.map.openMapFailed')
})
},
})
@@ -85,18 +87,17 @@ export default {
openMap(latitude, longitude, name, coord_type = 'gcj02') {
let arr = getCoordByType(longitude, latitude, coord_type)
// #ifdef APP-PLUS
switch (uni.getSystemInfoSync().platform) {
let platform = util.getDeviceInfo().platform;
switch (platform) {
case 'android':
console.log('运行Android上')
case 'harmony':
openMapByAndroid(arr[1], arr[0], name)
break;
case 'ios':
console.log('运行iOS上')
openMapByIos(arr[1], arr[0], name)
break;
default:
openMapByDefault(arr[1], arr[0], name)
console.log('运行在开发者工具上')
break;
}
// #endif

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

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

81
common/js/merge-theme.js Normal file
View File

@@ -0,0 +1,81 @@
/**
* 主题 token 合并工具
*
* 与 lang.js::applyOverrides 同构(都是"远端扁平覆写 map"按 key 路径合并),
* 差异:
* - 主题 token 是"扁平叶子"(如 main_color / bg_color),没有嵌套层级
* - 需要支持"三桶叠加":presetBase(本地预设) < brand(品牌色) < userMode(夜间/浅色)
* - value 是色值字符串(也允许空串 = 显式清空)
*
* 危险 key 过滤:拦截 __proto__/constructor/prototype,防止恶意覆写污染对象原型链
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* 把单层 map 的 keys 过滤掉危险段(白名单:值必须是 string,null/undefined/其它跳过)
* - 输入: 远端扁平 map { 'main_color': '#FF4646', '...': null, ... }
* - 输出: 净化后的 map(只保留 string 值,空串也是合法覆写)
*/
export function sanitizeFlatMap(flatMap) {
const out = {};
if (!flatMap || typeof flatMap !== 'object') return out;
for (const key in flatMap) {
if (!Object.prototype.hasOwnProperty.call(flatMap, key)) continue;
// 危险 key 过滤
if (FORBIDDEN_KEYS.has(String(key))) continue;
const val = flatMap[key];
if (typeof val !== 'string') continue;
out[key] = val;
}
return out;
}
/**
* 合并多桶 map
* @param {...object} maps 从低优先级到高优先级依次传入
* 例: mergeThemeBuckets(presetBase, brand, dark) = preset 被 brand 覆盖,brand 被 dark 覆盖
* @returns {object}
*/
export function mergeThemeBuckets(...maps) {
const out = {};
for (const m of maps) {
if (!m || typeof m !== 'object') continue;
for (const k in m) {
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
if (FORBIDDEN_KEYS.has(String(k))) continue;
const v = m[k];
if (typeof v !== 'string') continue;
out[k] = v;
}
}
return out;
}
/**
* 一站式合并:本地预设(已选中的 theme 子集) + brand 桶 + userMode 桶
* @param {object} preset 本地 style_color.js 中选中的 theme(如 'default' 的所有 token)
* @param {object} brandOverrides brand 桶(任何模式都生效)
* @param {object} userModeOverrides userMode 桶(light/dark,叠加在 brand 之上)
* @returns {object} { tokens, sources }
* tokens: 最终扁平 map
* sources: 每个 key 的来源(用于调试/可视化) 'preset' | 'brand' | 'userMode'
*/
export function composeFinalTokens(preset, brandOverrides, userModeOverrides) {
const tokens = {};
const sources = {};
const presetMap = sanitizeFlatMap(preset);
const brandMap = sanitizeFlatMap(brandOverrides);
const modeMap = sanitizeFlatMap(userModeOverrides);
for (const k in presetMap) { tokens[k] = presetMap[k]; sources[k] = 'preset'; }
for (const k in brandMap) { tokens[k] = brandMap[k]; sources[k] = 'brand'; }
for (const k in modeMap) { tokens[k] = modeMap[k]; sources[k] = 'userMode'; }
return { tokens, sources };
}
export default {
sanitizeFlatMap,
mergeThemeBuckets,
composeFinalTokens,
};

View File

@@ -0,0 +1,291 @@
/**
* 全局兜底分享 mixin
*
* 仅在微信小程序编译产物中被加载:
* main.js 中的 import + Vue.mixin 调用被 MP-WEIXIN 预处理宏 包住,
* 其它平台编译时整个模块不会进入产物,因此文件内部无需再写条件编译。
*
* 背景:
* - 微信小程序要求每个要支持「转发 / 朋友圈」的页面显式声明 onShareAppMessage / onShareTimeline,
* 否则页面右上角"三个点"菜单不会显示"转发"按钮。
* - 本项目之前只有 ~17 个页面声明了分享钩子,剩余页面无法转发。
* - 业务对"全页面都支持转发 + 复制小程序路径"有强诉求。
*
* 设计原则:
* 1. 不侵入现有页面:所有页面通过 Vue.mixin 自动获得默认行为
* 2. 已有 onShareAppMessage / onShareTimeline 的页面优先级更高(uni-app 会优先用页面自身的钩子)
* 3. 默认分享内容由后端 /weapp/api/weapp/share 按当前路由下发,
* 拉取失败时退化到站点名 + 当前路径,避免完全无法分享
* 4. 暴露 $mpShare 工具方法(currentPath / copyPath / refresh),
* 方便子页面在 onShareAppMessage 中复用 path
*/
// #ifdef MP-WEIXIN
const _safeGetCurrentPage = () => {
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [];
return pages.length > 0 ? pages[pages.length - 1] : null;
} catch (e) {
return null;
}
};
/**
* 关闭分享能力的页面 route 列表(不带前导斜杠,与 mp-weixin page.route 保持一致)
*
* 为什么需要:
* mp-share-default.js 通过 Vue.mixin 全局注入 onShareAppMessage / onShareTimeline,
* 但有些页面不适合"分享":
* 1) webview 容器页: 转发它等于转发一个 iframe 壳,无意义且会暴露内部 src
* 2) 隐私弹窗 / 登录中转页: 状态不稳定,转出去体验差
* 3) 任何业务明确要求"不分享"的页面
*
* 命中后行为:
* - mixin 内部不再拉取分享配置(节省一次 HTTP 请求)
* - mixin 注入的 onShareAppMessage / onShareTimeline / $mpShare_* 工具方法仍然存在,
* 但内部立即调用 wx.hideShareMenu() 把转发按钮从右上角菜单中隐藏
* - 这两条防线双保险:即使 mixin 钩子被业务侧转发链路误触发,也不会显示分享弹窗
*
* 备选方案对比(都没选,原因列在下方):
* - 在 created 里 this.onShareAppMessage = undefined:
* Vue mixin 合并策略会把 undefined 视为"未声明",mixin 的钩子仍然生效 → 不可靠
* - mixin 内 return null / 不 return:
* 微信会回退到默认 title 转发,按钮仍显示 → 不可靠
* - 使用 wx.hideShareMenu(本方案采用):
* 微信官方 API,会立即把"转发"按钮从当前页右上角菜单中隐藏,行为最稳
*/
const _DISABLED_PAGES = new Set([
'pages_tool/webview/webview', // webview 容器,转发无意义
]);
const _isCurrentPageDisabled = () => {
const page = _safeGetCurrentPage();
if (!page) return false;
const route = (page.route || (page.$page && page.$page.path) || '').replace(/^\//, '');
return _DISABLED_PAGES.has(route);
};
/**
* 主动隐藏当前页的"转发"按钮
* - 只能在 mp-weixin 环境调用(其它平台 wx 全局不存在)
* - 必须在 onLoad 之后调用(需要 Page 实例已注册完成)
* - 失败时静默吞掉(权限/版本/非微信环境都可能导致失败)
*/
const _hideShareMenu = () => {
try {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
wx.hideShareMenu({
menus: ['shareAppMessage', 'shareTimeline']
});
}
// #endif
} catch (e) {
// 静默:旧基础库可能不支持 menus 参数,降级到无参版本
try {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && typeof wx.hideShareMenu === 'function') {
wx.hideShareMenu();
}
// #endif
} catch (e2) { /* ignore */ }
}
};
const _getCurrentPath = (withQuery = true) => {
const page = _safeGetCurrentPage();
if (!page) return '';
// page.route 在 mp-weixin 上不带前导斜杠、不带 query;page.options 携带 query
const route = page.route || (page.$page && page.$page.path) || '';
const options = page.options || {};
if (!withQuery) return route ? '/' + route.replace(/^\//, '') : '';
const query = Object.keys(options)
.filter(k => options[k] !== undefined && options[k] !== null && options[k] !== '')
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`)
.join('&');
return query ? `/${route}?${query}` : `/${route}`;
};
// 模块级缓存:避免重复请求后端
let _shareCache = null;
let _shareCacheAt = 0;
const SHARE_CACHE_TTL = 60 * 1000; // 60s
const _fetchShareFromServer = (path) => {
const Http = require('./http.js').default || require('./http.js');
return new Promise((resolve) => {
Http.sendRequest({
url: '/weapp/api/weapp/share',
data: { path },
success: (res) => {
if (res && res.code >= 0 && res.data && res.data.data) {
resolve(res.data.data);
} else {
resolve(null);
}
},
fail: () => resolve(null)
});
});
};
const _buildFallback = (path) => ({
title: '小程序分享',
path,
imageUrl: ''
});
const _getShareConfig = async (path) => {
const now = Date.now();
if (_shareCache && (now - _shareCacheAt) < SHARE_CACHE_TTL) {
return _shareCache;
}
const cfg = await _fetchShareFromServer(path);
_shareCache = cfg || _buildFallback(path);
_shareCacheAt = now;
return _shareCache;
};
export default {
data() {
return {
__mpShareCache: null, // 当前页面的分享配置
};
},
/**
* 在实例创建阶段检查当前页面是否在 _DISABLED_PAGES 列表中。
* 命中后调用 wx.hideShareMenu 把当前页的"转发"按钮从右上角菜单中隐藏。
*/
created() {
try {
if (_isCurrentPageDisabled()) {
// 立即隐藏(此时 wx 还没完全就绪,先尝试一次;失败也无所谓,onLoad 会再调一次)
_hideShareMenu();
}
} catch (e) {}
},
onLoad() {
try {
if (_isCurrentPageDisabled()) {
// 命中关闭列表的页面:
// 1) 再次调 wx.hideShareMenu(此时 Page 已就绪,稳定生效)
// 2) 不再拉取分享配置,避免无谓请求
_hideShareMenu();
return;
}
const path = _getCurrentPath(true);
_getShareConfig(path).then(cfg => {
// 后端未返回配置时,注入站点名作为默认标题
if (cfg && !cfg.title) {
const siteName = (this.$store && this.$store.state && this.$store.state.siteInfo && this.$store.state.siteInfo.site_name) || '';
cfg.title = siteName || '小程序分享';
}
this.__mpShareCache = cfg;
});
} catch (e) {}
},
onShow() {
// 兜底:进入页面后如果尚未就绪,再异步拉一次
if (this.__mpShareCache) return;
if (_isCurrentPageDisabled()) return; // 命中关闭列表,不再拉
try {
const path = _getCurrentPath(true);
_getShareConfig(path).then(cfg => {
this.__mpShareCache = cfg;
});
} catch (e) {}
},
/**
* 全局默认 onShareAppMessage:
* uni-app 规范:组件自身的钩子优先级 > mixin,因此已显式声明的页面不受影响。
*/
onShareAppMessage() {
// 命中禁用列表的页面:即使 wx.hideShareMenu 因某些原因没生效,
// 这里直接 return undefined / 也不主动拼 cfg,让微信回退到默认行为
// (主防线是 wx.hideShareMenu,这里只是兜底)
if (_isCurrentPageDisabled()) {
return undefined;
}
const cfg = this.__mpShareCache;
if (cfg && cfg.title) {
return {
title: cfg.title,
path: cfg.path,
imageUrl: cfg.imageUrl || ''
};
}
// 后端无配置时:使用站点名 + 当前路径
return {
title: _buildFallback(_getCurrentPath(true)).title,
path: _getCurrentPath(true)
};
},
/**
* 全局默认 onShareTimeline(朋友圈):
* 朋友圈只支持 query(不能直接传 path),从 cfg.path 中抽取 ? 之后的 query。
*/
onShareTimeline() {
if (_isCurrentPageDisabled()) {
return undefined;
}
const cfg = this.__mpShareCache;
if (cfg && cfg.title) {
const path = cfg.path || _getCurrentPath(true);
const query = path.indexOf('?') > 0 ? path.split('?')[1] : path;
return {
title: cfg.title,
query,
imageUrl: cfg.imageUrl || ''
};
}
const path = _getCurrentPath(true);
const query = path.indexOf('?') > 0 ? path.split('?')[1] : '';
return {
title: _buildFallback(path).title,
query
};
},
methods: {
/**
* 获取当前小程序页面路径(含 query)
*/
$mpShare_currentPath() {
return _getCurrentPath(true);
},
/**
* 刷新当前页分享配置(用于页面参数变更后想重置转发 path 的场景)
*/
$mpShare_refresh() {
_shareCache = null;
_shareCacheAt = 0;
return _getShareConfig(_getCurrentPath(true)).then(cfg => {
this.__mpShareCache = cfg;
return cfg;
});
},
/**
* 复制当前页面小程序路径到剪贴板
* 微信小程序没有"页面 URL"概念,仅可复制形如:
* pages/goods/detail?id=123&source_member=xx
* 的小程序内部路径,便于分享者在聊天里粘贴。
* 优先复用 util.copyCurrentPath。
*/
$mpShare_copyPath() {
const path = _getCurrentPath(true);
const text = path.replace(/^\//, ''); // 去掉前导斜杠
if (this.$util && typeof this.$util.copyCurrentPath === 'function') {
return this.$util.copyCurrentPath({});
}
// util.copyCurrentPath 不存在(极端情况:util 模块未加载)时直接调 uni API
return new Promise((resolve, reject) => {
uni.setClipboardData({
data: text,
success: () => resolve(text),
fail: (err) => reject(err)
});
});
}
}
};
// #endif

286
common/js/navigation.js Normal file
View File

@@ -0,0 +1,286 @@
import { EventSafety } from '@/common/js/event-safety.js'
import util from '@/common/js/util.js'
import Lang from '@/common/js/lang.js'
export class NavigationHelper {
constructor() {
this.navigationCache = new Map()
}
// 安全地获取导航栏高度
async getNavigationHeight(component, options = {}) {
const cacheKey = `nav_height`
// 检查缓存
if (this.navigationCache.has(cacheKey) && !options.forceRefresh) {
return this.navigationCache.get(cacheKey)
}
// 获取高度
try {
// 尝试直接获取 uni-page-head
const height = await this.getDirectNavigationHeight(component)
if (height > 0) {
this.navigationCache.set(cacheKey, height)
return height
}
// 备用方案:平台特定方法
const platformHeight = await this.getPlatformNavigationHeight()
this.navigationCache.set(cacheKey, platformHeight)
return platformHeight
} catch (error) {
console.warn('获取导航栏高度失败,使用默认值:', error)
const defaultHeight = this.getDefaultNavHeight()
this.navigationCache.set(cacheKey, defaultHeight)
return defaultHeight
}
}
// 直接查询导航栏高度
getDirectNavigationHeight(component) {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(component)
query.select('.uni-page-head').boundingClientRect((rect) => {
if (rect && rect.height > 0) {
resolve(rect.height)
} else {
console.warn('未找到 uni-page-head 元素或高度为0')
resolve(0)
}
}).exec()
})
}
// 平台特定的高度获取
getPlatformNavigationHeight() {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
// 微信小程序精确计算
try {
const menuButtonInfo = wx.getMenuButtonBoundingClientRect()
let systemInfo = util.getDeviceInfo();
const height = menuButtonInfo.bottom +
(menuButtonInfo.top - systemInfo.statusBarHeight)
resolve(height)
} catch (error) {
console.error('微信小程序高度计算失败:', error)
resolve(44)
}
// #endif
// #ifdef H5
// H5环境:尝试获取自定义导航栏或使用默认值
if (typeof document !== 'undefined') {
const customNav = document.querySelector('.uni-page-head')
if (customNav) {
resolve(customNav.offsetHeight)
} else {
resolve(44) // 默认导航栏高度
}
} else {
resolve(44)
}
// #endif
// #ifdef APP-PLUS
// App端:状态栏 + 导航栏
try {
const statusBarHeight = plus.navigator.getStatusbarHeight()
resolve(statusBarHeight + 44)
} catch (error) {
console.error('App端高度获取失败:', error)
resolve(88)
}
// #endif
// 默认值
resolve(44)
})
}
// 获取默认高度
getDefaultHeight() {
// #ifdef MP-WEIXIN
return 44 // 微信小程序默认
// #endif
// #ifdef H5
return 44 // H5默认
// #endif
// #ifdef APP-PLUS
return 88 // App默认(状态栏44 + 导航栏44)
// #endif
return 44
}
// 获取状态栏高度
getStatusBarHeight() {
// #ifdef MP-WEIXIN
let systemInfo = util.getDeviceInfo();
return systemInfo.statusBarHeight || 20
// #endif
// #ifdef H5
return 0 // H5通常没有状态栏
// #endif
// #ifdef APP-PLUS
try {
return plus.navigator.getStatusbarHeight()
} catch (error) {
return 44
}
// #endif
return 0
}
// 获取安全区域
getSafeAreaInsets() {
try {
let systemInfo = util.getDeviceInfo();
return systemInfo.safeArea || {
top: 0,
bottom: 0,
left: 0,
right: 0
}
} catch (error) {
return {
top: 0,
bottom: 0,
left: 0,
right: 0
}
}
}
// 创建安全的事件处理器
createSafeEventHandler(handler, options = {}) {
return EventSafety.wrapEventHandler(handler, options)
}
// 安全地处理服务请求事件
createServiceRequestHandler(component) {
return this.createSafeEventHandler((event) => {
return this.handleServiceRequest(event, component)
}, {
onError: (error, event) => {
this.handleNavigationError(error, event, component)
}
})
}
// 处理服务请求
async handleServiceRequest(event, component) {
// 安全检查事件目标
if (this.shouldProcessNavigationRequest(event)) {
await this.processNavigationRequest(event, component)
}
}
// 检查是否应该处理导航请求
shouldProcessNavigationRequest(event) {
// 方法1:检查事件类型
if (event.type === 'service.requestComponentInfo') {
return true
}
// 方法2:检查目标元素
if (event.matches('.navigation-component') || event.matches('.uni-page-head')) {
return true
}
// 方法3:检查事件详情
if (event.detail && event.detail.componentType === 'navigation') {
return true
}
return false
}
// 处理导航请求
async processNavigationRequest(event, component) {
try {
// 获取导航栏信息
const navInfo = await this.getNavigationInfo(component)
// 发送响应
this.emitNavigationResponse(navInfo, component)
} catch (error) {
console.error('处理导航请求失败:', error)
throw error
}
}
// 获取完整的导航信息
async getNavigationInfo(component) {
const [navHeight, statusBarHeight, safeArea] = await Promise.all([
this.getNavigationHeight(component),
this.getStatusBarHeight(),
this.getSafeAreaInsets()
])
return {
navHeight,
statusBarHeight,
safeArea,
timestamp: Date.now()
}
}
// 发送导航响应
emitNavigationResponse(navInfo, component) {
if (component && component.$emit) {
component.$emit('navigation.infoResponse', {
success: true,
data: navInfo,
timestamp: Date.now()
})
}
}
// 错误处理
handleNavigationError(error, event, component) {
console.error('导航处理错误:', {
error: error.message,
eventType: event?.type,
component: component?.$options?.name
})
// 发送错误响应
if (component && component.$emit) {
component.$emit('navigation.infoError', {
success: false,
error: error.message,
timestamp: Date.now()
})
}
// 显示用户友好的错误信息
this.showError(Lang.lang('common.navigation.serviceUnavailable'))
}
// 显示错误提示
showError(message) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
})
}
// 清理缓存
clearCache() {
this.navigationCache.clear()
}
}
// 创建全局实例
const navigationHelper = new NavigationHelper()
export default navigationHelper

229
common/js/platform.js Normal file
View File

@@ -0,0 +1,229 @@
/**
* 统一平台检测模块
* 消除分散在 http.js, util.js, event-bus.js, customer-service.js 中的重复平台检测代码
*/
let cachedPlatform = null;
let cachedAppType = null;
let cachedAppTypeName = null;
// 延迟加载 lang 模块,避免与 lang.js 形成循环依赖
// (lang.js 顶部 import 了 Platform,所以 platform.js 不能顶部 import lang)
function _getLang() {
try {
return require('@/common/js/lang.js').default;
} catch (e) {
return null;
}
}
function _t(key, fallback) {
const lang = _getLang();
if (lang && typeof lang.lang === 'function') {
const result = lang.lang(key);
// lang() 在找不到 key 时会返回 key 本身(开发环境)或空串(生产环境)
if (result && result !== key) {
return result;
}
}
return fallback;
}
export const Platform = {
/**
* 检测当前运行平台
* @returns {string} 平台标识
*/
detect() {
if (cachedPlatform) {
return cachedPlatform;
}
// #ifdef H5
cachedPlatform = 'h5';
// #endif
// #ifdef MP-WEIXIN
cachedPlatform = 'weapp';
// #endif
// #ifdef MP-ALIPAY
cachedPlatform = 'alipay';
// #endif
// #ifdef MP-BAIDU
cachedPlatform = 'baidu';
// #endif
// #ifdef MP-TOUTIAO
cachedPlatform = 'toutiao';
// #endif
// #ifdef MP-QQ
cachedPlatform = 'qq';
// #endif
// #ifdef MP-KUAISHOU
cachedPlatform = 'kuaishou';
// #endif
// #ifdef MP-HARMONY
cachedPlatform = 'harmony';
// #endif
// #ifdef MP-JD
cachedPlatform = 'jd';
// #endif
// #ifdef MP-360
cachedPlatform = '360';
// #endif
// #ifdef MP-XHS
cachedPlatform = 'xhs';
// #endif
// #ifdef APP-PLUS
cachedPlatform = 'app';
// #endif
// #ifdef PC
cachedPlatform = 'pc';
// #endif
if (!cachedPlatform) {
cachedPlatform = 'unknown';
}
return cachedPlatform;
},
/**
* 获取平台类型标识 (用于API请求)
* @returns {string} app_type 值
*/
getAppType() {
if (cachedAppType) {
return cachedAppType;
}
const platform = this.detect();
// #ifdef H5
if (platform === 'h5') {
cachedAppType = this.isWeiXin() ? 'wechat' : 'h5';
return cachedAppType;
}
// #endif
const platformMap = {
weapp: 'weapp',
alipay: 'aliapp',
baidu: 'baiduapp',
toutiao: 'MP-TOUTIAO',
qq: 'MP-QQ',
kuaishou: 'kuaishou',
jd: 'jd',
'360': '360',
xhs: 'xhs',
harmony: 'harmony',
app: 'app',
pc: 'pc'
};
cachedAppType = platformMap[platform] || 'unknown';
return cachedAppType;
},
/**
* 获取平台类型名称 (用于显示)
* @returns {string} app_type_name 值
*/
getAppTypeName() {
if (cachedAppTypeName) {
return cachedAppTypeName;
}
const platform = this.detect();
// #ifdef H5
if (platform === 'h5') {
cachedAppTypeName = this.isWeiXin()
? _t('common.platform.wechatOfficialAccount', '微信公众号')
: _t('common.platform.h5', 'H5');
return cachedAppTypeName;
}
// #endif
const platformNameMap = {
weapp: _t('common.platform.wechatMiniProgram', '微信小程序'),
alipay: _t('common.platform.alipayMiniProgram', '支付宝小程序'),
baidu: _t('common.platform.baiduMiniProgram', '百度小程序'),
toutiao: _t('common.platform.toutiaoMiniProgram', '头条小程序'),
qq: _t('common.platform.qqMiniProgram', 'QQ小程序'),
kuaishou: _t('common.platform.kuaishouMiniProgram', '快手小程序'),
jd: _t('common.platform.jdMiniProgram', '京东小程序'),
'360': _t('common.platform.miniProgram360', '360小程序'),
xhs: _t('common.platform.xhsMiniProgram', '小红书小程序'),
harmony: _t('common.platform.harmonyService', '鸿蒙元服务'),
app: _t('common.platform.app', 'APP'),
pc: _t('common.platform.pc', 'PC端'),
};
cachedAppTypeName = platformNameMap[platform] || _t('common.platform.unknown', '未知平台');
return cachedAppTypeName;
},
/**
* 判断是否运行在微信浏览器中 (H5环境)
* @returns {boolean}
*/
isWeiXin() {
// #ifdef H5
return navigator.userAgent.toLowerCase().includes('micromessenger');
// #endif
return false;
},
/**
* 判断是否是小程序环境
* @returns {boolean}
*/
isMiniProgram() {
const platform = this.detect();
const miniProgramPlatforms = ['weapp', 'alipay', 'baidu', 'toutiao', 'qq', 'kuaishou', 'jd', '360', 'xhs', 'harmony'];
return miniProgramPlatforms.includes(platform);
},
/**
* 判断是否是移动端 (小程序或H5或APP)
* @returns {boolean}
*/
isMobile() {
const platform = this.detect();
return platform !== 'pc' && platform !== 'unknown';
},
/**
* 获取完整的平台信息对象
* @returns {Object} 包含 platform, appType, appTypeName
*/
getPlatformInfo() {
return {
platform: this.detect(),
appType: this.getAppType(),
appTypeName: this.getAppTypeName()
};
},
/**
* 清除缓存 (用于测试或需要重新检测的场景)
*/
clearCache() {
cachedPlatform = null;
cachedAppType = null;
cachedAppTypeName = null;
}
};
export default Platform;

993
common/js/privacy.js Normal file
View File

@@ -0,0 +1,993 @@
/**
* 全局隐私协议管理器(单例)
*
* 职责:
* 1. 统一判断是否需要弹窗(storage + 平台级隐私状态)
* 2. 通过 uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件弹窗
* 3. 用户同意后写 storage,拒绝后不写(允许下次再弹)
* 4. 只弹一次:同意后永不再弹
* 5. 事件补偿:若 emit 时无监听者,保留 _pendingShow,页面 mounted 时补偿弹窗
*
* 平台差异:
* H5: 仅查 storage
* 微信小程序: wx.getPrivacySetting() 查平台级隐私状态
* 鸿蒙元服务: has.getPrivacySetting() 查平台级隐私状态
*
* 事件:
* uni.$emit('privacy:show') — 通知当前页面组件弹窗
* uni.$emit('privacy:agreed') — 用户已同意
* uni.$emit('privacy:disagreed') — 用户已拒绝
* uni.$emit('privacy:closeOthers') — 通知其他页面关闭弹窗
* uni.$emit('privacy:rules-updated') — URL/路由规则被应用/融合后触发 (远程下发后热更新)
*
* 使用:
* App.vue onLaunch (boot完成后): await PrivacyManager.show()
* privacy-popup.vue: mounted 中 uni.$on('privacy:show') + popPendingShow() 补偿
* privacy-popup.vue: handleAgree 中 PrivacyManager.agree()
* privacy-popup.vue: handleDisagree 中 PrivacyManager.disagree()
*/
const STORAGE_KEY = 'privacyAgreed';
// 内部状态
let _needShow = false; // 是否需要弹窗
let _hasChecked = false; // 是否已执行过 check
let _pendingShow = false; // emit 时无监听者的补偿标志
// URL/路由规则已经做过决策 (避免鸿蒙兜底在白名单模式下错误弹窗)
let _urlRuleDecided = false;
// ============================================================
// 弹窗"实际显示"状态 (由 privacy-popup 通过 privacy:popup-state 事件同步)
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
// 双阶段弹窗重评估要用这个真实状态判断
let _popupActuallyShowing = false
// ============================================================
/**
* 同步读取 storage + 平台决策 (H5 + 不依赖 storage 的情况)
* 用于"已同意过"的快速路径, 不走 async checkPrivacy
* @returns {{needAuthorization: boolean, fromCache: boolean}}
*/
function checkPrivacySync() {
// H5 + 其他非微信/快应用/鸿蒙平台: storage 决定一切
const storageAgreed = getStorageAgreed()
return {
needAuthorization: !storageAgreed,
fromCache: storageAgreed,
}
}
// URL/路由级别隐私弹窗规则
// mode:
// 'all' - 始终走弹窗检查(默认, 向后兼容)
// 'only' - 白名单: 只有命中 patterns 的页面才走弹窗检查, 其他场景一律跳过
// 'skip' - 黑名单: 命中 patterns 的页面跳过弹窗, 其他场景走弹窗检查
// 匹配方式: 子串匹配
// H5: pathname + search + hash
// 小程序/鸿蒙: 当前页面 route + query
// patterns 为空数组时:
// 'all' → 无规则, 走弹窗检查
// 'only' → 视为"没有需要弹窗的页面", 一律跳过
// 'skip' → 视为"没有需要跳过的页面", 全部走弹窗检查
// ============================================================
const PRIVACY_URL_RULES = {
mode: 'all',
patterns: [],
}
/**
* 读取 storage 中的同意状态
*/
function getStorageAgreed() {
try {
return !!uni.getStorageSync(STORAGE_KEY);
} catch (e) {
return false;
}
}
/**
* 微信小程序: 查询平台级隐私设置
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
*/
function getWxPrivacySetting() {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
try {
if (wx.getPrivacySetting) {
wx.getPrivacySetting({
success: (res) => {
resolve({
needAuthorization: !!res.needAuthorization,
privacyContractName: res.privacyContractName || ''
});
},
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
});
} else {
// 低版本基础库,回退到 storage
resolve({ needAuthorization: !getStorageAgreed() });
}
} catch (e) {
resolve({ needAuthorization: !getStorageAgreed() });
}
// #endif
// #ifndef MP-WEIXIN
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
});
}
/**
* 快应用: 查询平台级隐私设置
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
*/
function getQuickAppPrivacySetting() {
return new Promise((resolve) => {
// #ifdef QUICKAPP-WEBVIEW
try {
const has = require('@system.has');
if (has && has.getPrivacySetting) {
has.getPrivacySetting({
success: (res) => {
resolve({
needAuthorization: !!res.needAuthorization,
privacyContractName: res.privacyContractName || ''
});
},
fail: () => resolve({ needAuthorization: !getStorageAgreed() })
});
return;
}
} catch (e) {}
// #endif
// 非快应用环境: 仅查 storage
resolve({ needAuthorization: !getStorageAgreed() });
});
}
/**
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
* @returns {Promise<{needAuthorization: boolean}>}
*/
function getHarmonyPrivacySetting() {
return new Promise((resolve) => {
resolve({ needAuthorization: !getStorageAgreed() });
});
}
/**
* 综合查询: 平台级隐私状态 + storage 状态
* 任一判断为"需要授权"则弹窗
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
*/
async function checkPrivacy() {
// 微信小程序: 调 wx.getPrivacySetting
// 注意: uniapp 不支持 #ifndef 多 || 与嵌套 #ifndef,
// 改用回退策略 - 仅微信走异步 API, 其他平台走 sync checkPrivacySync()
// 调用方 (show) 已经对 storage 已同意做了 sync 短路 (参见 checkPrivacySync()),
// 这里只处理 storage 未同意情况下的 async API 调用。
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && wx.getPrivacySetting) {
return await getWxPrivacySetting();
}
// #endif
// 快应用: 调 has.getPrivacySetting
// #ifdef QUICKAPP-WEBVIEW
if (typeof has !== 'undefined' && has.getPrivacySetting) {
return await getQuickAppPrivacySetting();
}
// #endif
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// H5 / 其他平台: 仅查 storage
return checkPrivacySync();
}
/**
* 取当前页面的"路径字符串"
* H5: location.pathname + search + hash
* 微信小程序: 当前页面 route + query (例: pages/index/index?id=1)
* 鸿蒙元服务: 当前页面 route + query (同小程序)
* @param {Object} [fallback] - 兜底路径, 用于 onLaunch 等还没 getCurrentPages 的时机
* H5: { pathname, search, hash }
* 小程序/鸿蒙: { route, query }
* @returns {string}
*/
function getCurrentPathString(fallback) {
// H5
// #ifdef H5
// H5 模式下优先用 getCurrentPages() 拿 uniapp route (history/hash 都适配)
// 兜底: window.location.pathname + search + hash
try {
if (typeof getCurrentPages === 'function') {
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
if (cur && cur.route) {
const route = cur.route
const query = cur.options
let queryStr = ''
if (query && typeof query === 'object' && Object.keys(query).length) {
queryStr = '?' + Object.keys(query)
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
.map(k => k + '=' + encodeURIComponent(query[k]))
.join('&')
}
return '/' + String(route).replace(/^\/+/, '') + queryStr
}
}
} catch (e) { /* ignore, fall through to location */ }
// 兜底: window.location (含 history 路由 + base)
if (typeof window !== 'undefined' && window.location) {
const pathname = (fallback && fallback.pathname) || window.location.pathname || ''
const search = (fallback && fallback.search !== undefined ? fallback.search : window.location.search) || ''
const hash = (fallback && fallback.hash !== undefined ? fallback.hash : window.location.hash) || ''
return pathname + search + hash
}
return ''
// #endif
// 微信小程序 / 鸿蒙元服务 / 其他支持 getCurrentPages 的端
// #ifndef H5
try {
const pages = (typeof getCurrentPages === 'function') ? getCurrentPages() : []
let route = ''
let query = null
const cur = pages[pages.length - 1]
if (cur) {
route = cur.route || ''
query = cur.options
}
// 兜底: onLaunch 时 getCurrentPages 还没页面, 用 fallback
if (!route && fallback) {
route = fallback.route || ''
query = fallback.query
}
if (!route) return ''
let queryStr = ''
if (query && typeof query === 'object' && Object.keys(query).length) {
queryStr = '?' + Object.keys(query)
.filter(k => query[k] !== undefined && query[k] !== null && query[k] !== '')
.map(k => k + '=' + encodeURIComponent(query[k]))
.join('&')
}
return '/' + String(route).replace(/^\/+/, '') + queryStr
} catch (e) {
return ''
}
// #endif
}
/**
* 判断 path 是否命中 patterns 之一
* 匹配规则 (双通道, 任一命中即算命中):
* 1. 子串匹配: path.indexOf(p) !== -1 (兼容 /pages/index/index 这种完整路径)
* 2. 结尾匹配: path 以 p 结尾 (兼容 subPackage 路径 /pages_tool/member/index 命中 "member/index")
* 这样业务侧既可以写完整路径也可以写相对路径
*/
function matchAny(path, patterns) {
if (!path || !Array.isArray(patterns)) return false
return patterns.some((p) => {
if (!p || typeof p !== 'string') return false
return path.indexOf(p) !== -1 || path.endsWith(p)
})
}
/**
* 根据 PRIVACY_URL_RULES 判断当前 URL/路由是否要走弹窗检查
* @param {Object} [fallback] - 传给 getCurrentPathString 的兜底路径
* @returns {boolean} true=本次应走弹窗检查, false=本次跳过
*/
function shouldShowByUrlRule(fallback) {
const mode = PRIVACY_URL_RULES.mode || 'all'
const patterns = PRIVACY_URL_RULES.patterns || []
if (!Array.isArray(patterns) || !patterns.length) {
// 空 patterns 时:
// 'all' → 无规则, 走检查
// 'only' → 没有需要弹窗的页面, 一律跳过
// 'skip' → 没有需要跳过的页面, 全部走检查
return mode === 'all' || mode === 'skip'
}
const path = getCurrentPathString(fallback)
const hit = matchAny(path, patterns)
if (mode === 'only') return hit
if (mode === 'skip') return !hit
return true // mode === 'all'
}
/**
* 覆盖 URL/路由级别隐私弹窗规则
* @param {Object} rules
* @param {'all'|'only'|'skip'} [rules.mode='all']
* @param {string[]} [rules.patterns=[]]
*/
function setUrlRules(rules) {
if (!rules || typeof rules !== 'object') return
if (rules.mode && ['all', 'only', 'skip'].indexOf(rules.mode) !== -1) {
PRIVACY_URL_RULES.mode = rules.mode
}
if (Array.isArray(rules.patterns)) {
PRIVACY_URL_RULES.patterns = rules.patterns.slice()
}
}
/**
* 持久化键 (与 privacyAgreed 同维度, 方便 uniacid 隔离)
* 由 App.vue 在 setUrlRules 前注入前缀: privacy_url_rules_<uniacid>
*/
let _storageKey = 'privacy_url_rules'
/**
* 从 storage 读取上次持久化的最终规则
* 自动过滤 __merge/__source/__ts 元信息, 仅返回纯规则
* @returns {Object|null}
*/
function loadPersistedRules() {
try {
const raw = uni.getStorageSync(_storageKey)
if (!raw) return null
const obj = (typeof raw === 'string') ? JSON.parse(raw) : raw
if (!obj || typeof obj !== 'object') return null
// 过滤元信息
return {
mode: obj.mode,
patterns: Array.isArray(obj.patterns) ? obj.patterns.slice() : [],
}
} catch (e) {
return null
}
}
/**
* 持久化当前最终规则
* 携带 merge 字段 + 时间戳 + 签名, 方便后续热修复/审计/防篡改
* @param {Object} rules
* @param {Object} [meta] - { merge?: string, source?: string, sign?: string }
*/
function persistRules(rules, meta) {
try {
const final = Object.assign({}, rules || PRIVACY_URL_RULES, {
__merge: (meta && meta.merge) || (rules && rules.__merge) || 'replace',
__source: (meta && meta.source) || 'base',
__ts: Date.now(),
__sign: (meta && meta.sign) || (rules && rules.__sign) || '',
})
uni.setStorageSync(_storageKey, final)
} catch (e) {}
}
/**
* 计算远程 patch 的签名 (前端简易实现, 与后端 hash_hmac 对齐)
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
* 前端: 用 btoa 或 uni.arrayBufferToBase64 模拟 (弱校验, 防简单篡改)
* 真正的强校验建议用 crypto-js (引入成本)
* @param {Object} patch - 远程下发对象
* @param {string} secret
* @returns {string}
*/
function computeSign(patch, secret) {
if (!secret || !patch) return ''
try {
const payload = JSON.stringify(patch)
let _b64 = ''
if (typeof btoa === 'function') {
_b64 = btoa(payload)
} else if (typeof uni !== 'undefined' && typeof uni.arrayBufferToBase64 === 'function') {
const _encoder = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null
if (_encoder) {
_b64 = uni.arrayBufferToBase64(_encoder.encode(payload).buffer)
}
}
if (!_b64) return ''
// 取前 32 位作为 signature (与 lang-loader.js validateSign 保持一致)
return _b64.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32)
} catch (e) {
return ''
}
}
/**
* 校验远程 patch
* - signSecret 未配置 → 通过 (向后兼容)
* - patch.sign 缺失 → 通过 (后端未启用签名)
* - patch.sign 不匹配 → 拒绝 (返回 false)
* @param {Object} patch - 远程下发 { mode, patterns, merge, sign? }
* @returns {boolean}
*/
function verifyRemotePatch(patch) {
if (!patch || typeof patch !== 'object') return false
// 业务侧未配置密钥 → 跳过校验
if (!_signSecret) return true
// patch 未带 sign 字段 → 后端未启用签名, 跳过校验 (兼容旧版本后端)
if (!patch.sign) return true
const expected = computeSign(patch, _signSecret)
const ok = (expected && expected === patch.sign)
if (!ok) {
console.warn('[privacy] remote patch sign invalid', {
expected: expected.slice(0, 8),
received: String(patch.sign).slice(0, 8),
})
}
return ok
}
/**
* 合并本地 base 与远程 patch
* mode:
* patch.mode 缺失/非合法值 → 用 base.mode
* patch.mode 合法 → 用 patch.mode (远程覆盖)
* patterns:
* merge = 'replace' → 用 patch.patterns (远程整体替换, 默认)
* merge = 'union' → base + patch 去重合并 (子串匹配: 较长的子串已包含较短, 保留较长)
* merge = 'intersect'→ 仅保留同时出现在 base 和 patch 的项
* merge 缺失/未知 → 退化为 'replace'
* patch.patterns 缺失/非数组 → 用 base.patterns
*
* @param {Object} base - 本地声明的基础规则 { mode, patterns }
* @param {Object} patch - 远程下发的补丁 { mode?, patterns?, merge? }
* @returns {{ mode: string, patterns: string[], merge: string }}
*/
function mergeRules(base, patch) {
const baseMode = (base && base.mode) || 'all'
const basePatterns = (base && Array.isArray(base.patterns)) ? base.patterns.slice() : []
if (!patch || typeof patch !== 'object') {
return { mode: baseMode, patterns: basePatterns, merge: 'replace' }
}
// mode: 远程值合法则覆盖
let mode = baseMode
if (patch.mode && ['all', 'only', 'skip'].indexOf(patch.mode) !== -1) {
mode = patch.mode
}
// patterns + merge
const merge = (patch.merge && ['replace', 'union', 'intersect'].indexOf(patch.merge) !== -1)
? patch.merge
: 'replace'
let patterns
if (!Array.isArray(patch.patterns)) {
patterns = basePatterns
} else if (merge === 'replace') {
patterns = patch.patterns.slice()
} else if (merge === 'union') {
patterns = dedupeUnion(basePatterns, patch.patterns)
} else { // intersect
patterns = basePatterns.filter(p => patch.patterns.indexOf(p) !== -1)
}
return { mode, patterns, merge }
}
/**
* 去重合并 (子串匹配: 若 a 包含 b, b 可视为冗余)
* 简单实现: 直接 concat 后去重, 复杂度可接受 (规则通常 < 50 条)
*/
function dedupeUnion(a, b) {
const all = (a || []).concat(b || [])
const seen = new Set()
const out = []
for (const p of all) {
if (typeof p !== 'string' || !p) continue
if (seen.has(p)) continue
seen.add(p)
out.push(p)
}
return out
}
/**
* 应用"已合并规则"到 PRIVACY_URL_RULES
* 触发 'privacy:rules-updated' 事件, 业务侧可监听 (例如想热更新弹窗)
* @param {Object} rules
* @param {Object} [meta] - { source: 'base'|'remote'|'persisted', patch?: Object }
*/
function applyMergedRules(rules, meta) {
if (!rules || typeof rules !== 'object') return
const prevMode = PRIVACY_URL_RULES.mode
const prevPatterns = PRIVACY_URL_RULES.patterns.slice()
// 记录"应用前 popup 实际显示状态", 用于 reEvaluate 决策
// 关键: _pendingShow 会被 consumePendingShow 清零, 不能代表"实际显示"
// 必须用 _popupActuallyShowing (由 privacy-popup 主动同步)
const prevPopupShowing = _popupActuallyShowing
setUrlRules(rules)
persistRules(rules, {
merge: rules && rules.merge,
source: meta && meta.source,
sign: meta && meta.patch && meta.patch.sign,
})
try {
uni.$emit && uni.$emit('privacy:rules-updated', {
mode: PRIVACY_URL_RULES.mode,
patterns: PRIVACY_URL_RULES.patterns.slice(),
source: (meta && meta.source) || 'remote',
patch: meta && meta.patch,
})
} catch (e) {}
// 双阶段弹窗: 远程 patch 在 show() 之后到达时, 强制重评估
// 触发条件:
// 1. patch 来源是 'remote' (非 'base')
// 2. 规则真的变了 (mode/patterns 之一不同)
// 3. show() 已经调用过 (_hasChecked=true)
// 行为:
// - 旧规则没弹 → 新规则要弹 → emit show (popup 重弹)
// - 旧规则在弹 → 新规则不该弹 → emit dismiss (popup 关闭)
const _source = (meta && meta.source) || 'remote'
const _rulesChanged = (
prevMode !== PRIVACY_URL_RULES.mode ||
prevPatterns.length !== PRIVACY_URL_RULES.patterns.length ||
prevPatterns.some((p, i) => p !== PRIVACY_URL_RULES.patterns[i])
)
if (_source === 'remote' && _rulesChanged && _hasChecked) {
// dev-only 日志, 受 __PRIVACY_DEBUG__ 开关控制 (默认开, 生产可通过 DefinePlugin 关)
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
console.log('[privacy] reEvaluate trigger: prevMode=' + prevMode + ' newMode=' + PRIVACY_URL_RULES.mode + ' prevPopupShowing=' + prevPopupShowing)
}
// 异步执行, 不阻塞 apply 流程
setTimeout(() => { reEvaluateAfterRulesUpdated(prevPopupShowing) }, 0)
}
}
/**
* 规则更新后的重评估
* 场景: 远程 patch 在 show() 之后到达, 本次启动弹窗决策是基于旧规则做的
* 现在新规则来了, 必须重评估
* 步骤:
* 1. 用 prevPopupShowing (popup 实际显示状态) + shouldShowByUrlRule (应用后规则 + 当前 URL) 算差量
* 2. prevPopupShowing=false, shouldShow=true → emit show (popup 重弹)
* 3. prevPopupShowing=true, shouldShow=false → emit dismiss (popup 关闭)
* 4. 其他情况: 状态不变, 不 emit (避免无谓的弹窗抖动)
* 边界: 用户已同意过 storage → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
* @param {boolean} prevPopupShowing - 应用规则前的 popup 实际显示状态
*/
function reEvaluateAfterRulesUpdated(prevPopupShowing) {
// dev-only 日志
if (typeof console !== 'undefined' && console.log && globalThis.__PRIVACY_DEBUG__) {
console.log('[privacy] reEvaluate called prevPopup=' + prevPopupShowing + ' storageAgreed=' + getStorageAgreed())
}
if (getStorageAgreed()) {
// 已同意过 → 不再弹, 但要通知 popup 关闭 (新规则可能把当前页排除)
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'noop', reason: 'already-agreed' }) } catch (e) {}
return
}
// 用户未同意 → 走规则决策
const shouldShow = shouldShowByUrlRule()
const wasShowing = !!prevPopupShowing
if (!wasShowing && shouldShow) {
// 旧规则没弹, 新规则要弹 → 重新设置 _pendingShow + emit show
_needShow = true
_pendingShow = true
_hasChecked = true
_urlRuleDecided = false
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'show', reason: 'rules-updated' }) } catch (e) {}
} else if (wasShowing && !shouldShow) {
// 旧规则在弹, 新规则不该弹 → 撤销 _pendingShow + emit dismiss
_needShow = false
_pendingShow = false
_hasChecked = true
_urlRuleDecided = true
try { uni.$emit && uni.$emit('privacy:re-evaluate', { action: 'dismiss', reason: 'rules-updated' }) } catch (e) {}
}
// 其他情况: 状态不变, 不 emit
}
/**
* 一站式: 应用本地 base → 若有远程 patch 则融合 → 持久化 → 触发事件
* 无远程 patch 时, base 直接生效 (向后兼容)
* @param {Object} base - 本地基础规则
* @param {Object} [patch] - 远程下发的 patch (可空)
* @returns {{ mode: string, patterns: string[], merge: string }}
*/
function applyWithMerge(base, patch) {
// 签名校验: 远程 patch 不合法 → 静默拒绝, 退回 base
// (业务侧可通过 PrivacyManager.verifyRemotePatch() 单独调用)
if (patch && !verifyRemotePatch(patch)) {
console.warn('[privacy] applyWithMerge: remote patch sign invalid, fallback to base')
const merged = mergeRules(base, null)
applyMergedRules(merged, { source: 'base', patch: null })
return merged
}
const merged = mergeRules(base, patch)
applyMergedRules(merged, { source: patch ? 'remote' : 'base', patch })
return merged
}
// 本地 base 规则 (由 App.vue 通过 setBaseRules 登记)
// 供 store/index.js 的 init action 在收到远程 patch 时读取
let _baseRules = { mode: 'all', patterns: [] }
// 外部注入的 readyWaiters (由 config-external.js ConfigExternal 持有)
// 兼容老用法: 业务侧可直接传 readyGuard, 也可走 ConfigExternal.setReadyWaiters 通道
let _externalReadyGuard = null
let _externalBootTimeout = 6000
let _externalWaitChannels = { lang: true, theme: true, themeOverride: true, remoteConfig: true }
// 远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
// 业务侧通过 setSignSecret(secret) 或 privacyConfig.signSecret 注入
let _signSecret = ''
// 远程 patch 签名 (在持久化时被写入 storage 的 __sign 字段, 供后续审计/防回滚)
// 注意: 当前实现不做增量校验, 只在 verifyRemotePatch 内单次校验
// 如需增量校验 (防止回滚到旧 patch), 可在此维护 _lastPersistedSign
// 每次 applyWithMerge 持久化时记录, 下次启动时与远程 patch 的 sign 字段比对
let _lastPersistedSign = ''
// ============================================================
// 监听 privacy:popup-state 事件 (由 privacy-popup.vue 主动同步弹窗显示状态)
// 用于双阶段弹窗的精确重评估 (reEvaluateAfterRulesUpdated)
// 这里挂一个全局监听, 模块加载时就生效
// ============================================================
;(function _installPopupStateListener() {
try {
if (typeof uni === 'undefined' || !uni.$on) return
uni.$on('privacy:popup-state', (info) => {
if (info && typeof info.showing === 'boolean') {
_popupActuallyShowing = info.showing
}
})
} catch (e) {}
})()
const PrivacyManager = {
/**
* 同步检查 storage 中的同意状态(快速判断,不查平台)
* @returns {boolean} true=需要弹窗, false=不需要
*/
check() {
_needShow = !getStorageAgreed();
_hasChecked = true;
return _needShow;
},
/**
* 同步标记本次跳过弹窗(不查 storage、不写 storage、不弹窗)
* 必须在 page mounted 之前调用,避免 onLaunch await 让出事件循环后
* privacy-popup.vue 的 consumePendingShow 走兜底分支把弹窗弹出来
* 用于 H5 浏览器直访特定 URL(子串匹配)时不让隐私弹窗挡住
*/
markSkipped() {
_needShow = false;
_hasChecked = true;
_pendingShow = false;
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
},
/**
* 触发弹窗(如果需要)
* 由 App.vue onLaunch 在 boot 完成后调用
* 异步查询平台隐私状态,设置 _pendingShow 标志
* 页面组件 mounted 时通过 consumePendingShow() 消费标志并弹窗
* 不直接 emit 事件,避免 emit + 补偿双触发
*
* @param {Object} [options]
* @param {boolean} [options.skip=false] true=本次跳过弹窗(不写 storage,下次正常访问其他页面还会弹)
* @param {Object} [options.urlFallback] 首次启动 onLaunch 时 getCurrentPages 还没有页面,
* 可传入 onLaunch options 兜底:
* H5: { pathname, search, hash }
* 小程序/鸿蒙: { route, query }
* @param {Promise|Function} [options.readyGuard] 外部 readiness 守卫 (例如 i18n/theme),
* show() 会 await 它再继续, 保证弹窗在外部资源就绪后才决策.
* 超时保护: 业务侧应保证 guard 自身有超时, 否则会一直 hang.
*/
async show(options = {}) {
const fallback = options && options.urlFallback ? options.urlFallback : null
// 外部 readiness 守卫: 等业务侧 promise 再继续
// 优先级: options.readyGuard (调用方显式传) > _externalReadyGuard (ConfigExternal 注入)
// 场景: App.vue onLaunch 把 i18n/theme/remote-config 的 await 链作为 guard 传入
// 这样无论 show() 在哪里调用, 都保证弹窗决策时外部资源已就绪
const _guard = (options && typeof options.readyGuard !== 'undefined')
? options.readyGuard
: _externalReadyGuard
if (typeof _guard !== 'undefined' && _guard !== null) {
try {
if (_guard && typeof _guard.then === 'function') {
await _guard
} else if (typeof _guard === 'function') {
await _guard()
}
} catch (e) {
console.warn('[privacy] readyGuard fail', e)
}
}
// 主动跳过: 不查 storage、不写 storage、不弹窗
// 用于 H5 浏览器直接访问特定 URL(子串匹配)时不让隐私弹窗挡住
if (options && options.skip) {
_needShow = false;
_hasChecked = true;
_pendingShow = false;
_urlRuleDecided = true; // 同步决策, 鸿蒙兜底不应再弹
return;
}
// URL/路由规则判断
// mode='only' 且未命中 patterns → 跳过
// mode='skip' 且命中 patterns → 跳过
// 其他情况 → 继续走 storage + 平台检查
if (!shouldShowByUrlRule(fallback)) {
_needShow = false;
_hasChecked = true;
_pendingShow = false;
_urlRuleDecided = true; // 已决策, 阻止鸿蒙兜底强制弹窗
return;
}
// URL 规则允许弹, 重置 urlRuleDecided 留给鸿蒙兜底分支判断
_urlRuleDecided = false;
const _storageAgreed = getStorageAgreed();
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
_needShow = result.needAuthorization;
_hasChecked = true;
if (!_needShow) {
return;
}
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗
// 应用层不再 emit 'privacy:show' 触发自建 privacy-popup.vue
// 标记已决策, 防止 consumePendingShow 兜底分支在 page mounted 时再次弹自建弹窗
_needShow = false;
_pendingShow = false;
_urlRuleDecided = true;
console.log('[privacy] MP-HARMONY: 已委托鸿蒙平台隐私托管, 不触发自建弹窗');
return;
// #endif
// 其他平台: 设置待弹窗标志 + emit 触发自建弹窗
// 已 mounted 的页面组件通过事件接收
// 未 mounted 的页面组件 mounted 时通过 consumePendingShow 补偿
_pendingShow = true;
// 通知已 mounted 的页面组件(如果有监听者)
try { uni.$emit('privacy:show'); } catch (e) {}
// 若组件已接收事件并弹窗, 会在 handlePrivacyShow 中调 consumePendingShow 清除 _pendingShow
// 若无监听者, _pendingShow 保持 true, 等页面 mounted 时补偿
},
/**
* 页面 mounted 时补偿检查: 若 show() 在页面 mounted 之前 emit 了事件
* 此方法返回 true 并清除标志,页面组件据此自动弹窗
*
* 历史兼容:
* Lang.boot() 可能 hang, PrivacyManager.show() 可能从未被调用过;
* 此时 _pendingShow 仍是默认 false, 但用户从未同意过隐私, 必须强制弹一次。
* 所以增加 storage 检测: 用户未同意也返回 true (补偿弹窗)。
*
* 鸿蒙元服务(双轨制):
* 已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
* 应用层不再 emit/弹自建弹窗, 兜底分支也必须返回 false, 否则会与
* 鸿蒙系统级弹窗叠加出现"多次弹出"。
* @returns {boolean} true=需要弹窗,false=不需要
*/
consumePendingShow() {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 不弹自建弹窗
return false;
// #endif
if (_pendingShow && _needShow) {
_pendingShow = false;
return true;
}
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {
_pendingShow = false;
_needShow = true;
_hasChecked = true;
return true;
}
_pendingShow = false;
return false;
},
/**
* 用户同意:写 storage,标记不再弹
* 由 privacy-popup.vue 的 handleAgree 调用
*/
agree() {
try { uni.setStorageSync(STORAGE_KEY, true); } catch (e) {}
_needShow = false;
_pendingShow = false;
try { uni.$emit('privacy:agreed'); } catch (e) {}
},
/**
* 用户拒绝:不写 storage,允许下次再弹
* 由 privacy-popup.vue 的 handleDisagree 调用
*/
disagree() {
_needShow = false;
_pendingShow = false;
try { uni.$emit('privacy:disagreed'); } catch (e) {}
},
/**
* 当前是否需要弹窗(供页面查询)
* @returns {boolean}
*/
needShow() {
return _needShow;
},
/**
* 是否已执行过 check
* @returns {boolean}
*/
hasChecked() {
return _hasChecked;
},
/**
* 配置 URL/路由级别隐私弹窗规则
* @param {Object} rules
* @param {'all'|'only'|'skip'} [rules.mode='all']
* 'all' - 始终走弹窗检查(默认)
* 'only' - 白名单: 只有命中 patterns 才弹
* 'skip' - 黑名单: 命中 patterns 跳过
* @param {string[]} [rules.patterns=[]]
* 子串匹配, H5: pathname+search+hash, 小程序/鸿蒙: route+query
*/
setUrlRules(rules) {
setUrlRules(rules)
},
/**
* 当前 URL/路由规则下是否要走弹窗检查
* @param {Object} [fallback] 兜底路径(同 show 的 urlFallback)
* @returns {boolean}
*/
shouldShowByUrlRule(fallback) {
return shouldShowByUrlRule(fallback)
},
/**
* 应用本地 base + 远程 patch 的融合结果
* - patch=null/缺 → 仅应用 base (并持久化)
* - patch 存在 → 走 mergeRules 融合
* 应用后 emit 'privacy:rules-updated'
* @param {Object} base 本地基础规则 { mode, patterns }
* @param {Object} [patch] 远程下发 { mode?, patterns?, merge? }
* @returns {{ mode: string, patterns: string[], merge: string }}
*/
applyWithMerge(base, patch) {
return applyWithMerge(base, patch)
},
/**
* 仅合并 (不写入), 方便上层预览或 dry-run
* @param {Object} base
* @param {Object} patch
*/
mergeRules(base, patch) {
return mergeRules(base, patch)
},
/**
* 读取上次持久化的最终规则 (跨冷启动恢复)
* @returns {Object|null}
*/
loadPersistedRules() {
return loadPersistedRules()
},
/**
* 设置持久化 key (用于 uniacid 隔离), 由 App.vue 注入
* @param {string} key
*/
setStorageKey(key) {
if (typeof key === 'string' && key) _storageKey = key
},
/**
* 登记本地 base 规则 (由 App.vue 在启动时调用)
* 供后续远程下发时 mergeRules 读取
* @param {Object} rules
*/
setBaseRules(rules) {
if (!rules || typeof rules !== 'object') return
_baseRules = {
mode: rules.mode || 'all',
patterns: Array.isArray(rules.patterns) ? rules.patterns.slice() : [],
}
},
/**
* 读取本地 base 规则 (供 store/index.js init 远程 patch 融合)
* @returns {{ mode: string, patterns: string[] }}
*/
__getBaseRules() {
return { mode: _baseRules.mode, patterns: _baseRules.patterns.slice() }
},
/**
* 注入外部 readiness 守卫 (由 config-external.js ConfigExternal 调用)
* show() 时若无 options.readyGuard, 内部使用此 promise
* @param {Promise|Function|null} guard
*/
setExternalReadyGuard(guard) {
if (guard === null) { _externalReadyGuard = null; return }
if (guard && (typeof guard.then === 'function' || typeof guard === 'function')) {
_externalReadyGuard = guard
}
},
/**
* 注入外部 bootTimeout (由 ConfigExternal.getPrivacyBootTimeout 调用)
* @param {number} ms
*/
setExternalBootTimeout(ms) {
if (typeof ms === 'number' && ms > 0) _externalBootTimeout = ms
},
/**
* 注入 waitChannels 配置 (隐私弹窗启动期必等通道开关)
* @param {Object} channels
*/
setWaitChannels(channels) {
if (channels && typeof channels === 'object') {
_externalWaitChannels = Object.assign({}, _externalWaitChannels, channels)
}
},
/**
* 注入远程 patch 签名密钥 (与 langConfig.remote.signSecret 同构)
* 后端约定: hash_hmac('sha256', JSON.stringify(patch), secret)
* 前端: 用 btoa 模拟 (弱校验, 仅防简单篡改)
* secret='' → 跳过校验 (向后兼容)
* @param {string} secret
*/
setSignSecret(secret) {
_signSecret = (typeof secret === 'string') ? secret : ''
},
/**
* 校验远程 patch (业务侧可在 store/init 之外单独调用)
* - signSecret 未配置 → true (向后兼容)
* - patch.sign 缺失 → true (后端未启用签名)
* - patch.sign 不匹配 → false
* @param {Object} patch
* @returns {boolean}
*/
verifyRemotePatch(patch) {
return verifyRemotePatch(patch)
},
/**
* 计算给定 patch 的签名 (供调试/单元测试)
* @param {Object} patch
* @param {string} [secret] - 不传则用 _signSecret
* @returns {string}
*/
computeSign(patch, secret) {
return computeSign(patch, secret || _signSecret)
},
/**
* 读取当前生效配置 (供调试/单元测试)
*/
getReadyConfig() {
return {
readyGuard: _externalReadyGuard,
bootTimeout: _externalBootTimeout,
waitChannels: Object.assign({}, _externalWaitChannels),
}
},
};
export default PrivacyManager;

View File

@@ -0,0 +1,33 @@
/**
* 快应用宿主环境下的下拉刷新 → 原生 webview.reload() 转发 mixin
*
* 在已启用 enablePullDownRefresh 的 uniapp 页面 mixin 链中加入本文件,
* 当用户在原生快应用 webview 内下拉刷新时,会通过 this.$util.requestQuickAppReload()
* 把指令发回快应用 shell(对应 hello.ux onMessage 中的 'reload' 处理);
* 其余平台走默认的 uni.stopPullDownRefresh 即可。
*
* 用法:
* import pullReload from '@/common/js/mixins/quickapp-pull-refresh.js';
* export default { mixins: [..., pullReload] }
*
* 注意:
* - 请确保对应的 pages.json style 块中开启 "enablePullDownRefresh": true
* - 不要在本文件里覆盖 onPullDownRefresh,否则会被本页/上游 mixin 拦截失效
*/
export default {
onPullDownRefresh() {
try {
if (this.$util && typeof this.$util.requestQuickAppReload === 'function'
&& this.$util.requestQuickAppReload()) {
// 原生侧 webview.reload() 会把整个 H5 页面替换掉,这里只是兜底停动画
setTimeout(() => {
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
}, 800);
return;
}
} catch (e) {
console.error('[quickapp-pull-refresh] crashed:', e);
}
try { uni.stopPullDownRefresh(); } catch (_) { /* swallow */ }
}
}

102
common/js/share.js Normal file
View File

@@ -0,0 +1,102 @@
// 文件分享工具函数
import Lang from './lang.js';
export default {
/**
* 生成文件预览链接
* @param {Object} file - 文件对象,包含 name 和 url 属性
* @returns {string} - 生成的文件预览链接
*/
generateFilePreviewUrl(file) {
// 推断文件类型
let fileType = '';
if (file.name) {
const ext = file.name.split('.').pop().toLowerCase();
if (['pdf'].includes(ext)) {
fileType = 'pdf';
} else if (['doc', 'docx'].includes(ext)) {
fileType = 'word';
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
fileType = 'video';
}
}
// 构建文件预览链接
const previewUrl = `/pages_tool/file-preview/file-preview?fileName=${encodeURIComponent(file.name)}&fileUrl=${encodeURIComponent(file.url || '')}&fileType=${encodeURIComponent(fileType)}`;
return previewUrl;
},
/**
* 分享文件
* @param {Object} file - 文件对象,包含 name 和 url 属性
* @param {string} title - 分享标题,默认使用文件名
* @param {string} desc - 分享描述,默认使用固定文案
*/
shareFile(file, title = file.name, desc = Lang.lang('share.defaultDesc', { name: file.name })) {
// 生成文件预览链接
const filePreviewUrl = this.generateFilePreviewUrl(file);
// #ifdef H5
// H5 平台分享
if (navigator.share) {
// 使用 Web Share API
navigator.share({
title: title,
text: desc,
url: filePreviewUrl
}).catch(err => {
console.error('分享失败:', err);
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
});
} else {
// 不支持 Web Share API 的浏览器
uni.setClipboardData({
data: filePreviewUrl,
success: () => {
uni.showToast({ title: Lang.lang('share.linkCopied'), icon: 'success' });
},
fail: () => {
uni.showToast({ title: Lang.lang('share.copyFailed'), icon: 'none' });
}
});
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序分享
uni.showActionSheet({
itemList: [Lang.lang('share.sendToFriend'), Lang.lang('share.shareToTimeline')],
success: (res) => {
if (res.tapIndex === 0) {
// 发送给朋友
uni.shareAppMessage({
title: title,
path: filePreviewUrl,
success: () => {
uni.showToast({ title: Lang.lang('share.shareSuccess'), icon: 'success' });
},
fail: () => {
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
}
});
} else if (res.tapIndex === 1) {
// 分享到朋友圈
uni.shareTimeline({
title: desc,
path: filePreviewUrl,
success: () => {
uni.showToast({ title: Lang.lang('share.shareSuccess'), icon: 'success' });
},
fail: () => {
uni.showToast({ title: Lang.lang('share.shareFailed'), icon: 'none' });
}
});
}
},
fail: () => {
// 用户取消分享
}
});
// #endif
}
};

View File

@@ -21,53 +21,35 @@ export default {
methods: {
// 判断是否已连接
checkOpenSocket() {
console.log('判断是否已连接');
// alert('判断是否已连接')
let self = this;
uni.sendSocketMessage({
data: 'ping',
success: (res) => {
console.log('连接成功,检查');
// alert('连接成功,检查')
// self.getChatList();
},
fail: (err) => { // 未连接打开websocket连接
console.log('连接失败');
// alert('连接失败')
self.openConnection();
}
});
},
openConnection() { // 打开连接
console.log('打开连接');
// alert('打开连接')
// uni.closeSocket(); // 确保已经关闭后再重新打开
uni.connectSocket({
url: config.webSocket,
method: 'POST',
success(res) {
console.log('连接成功 connectSocket=', res);
// alert('连接成功 connectSocket=', res);
},
fail(err) {
console.log('连接失败 connectSocket=', err);
}
});
// uni.onSocketOpen((res) => {
// console.log('连接成功', res);
// });
this.onSocketMessage(); // 打开成功监听服务器返回的消息
},
// 打开成功监听服务器返回的消息
onSocketMessage() { // 消息
console.log("开始监听");
let that = this;
this.pingInterval = config.pingInterval;
this.timeoutObj = null;
uni.onSocketMessage((res) => { //type:init,connect,close,string,order,goods
let msg = JSON.parse(res.data);
console.log("监听该服务器消息", res);
if (msg.type == 'close') {
clearInterval(that.timeoutObj);
that.timeoutObj = null;
@@ -141,22 +123,18 @@ export default {
},
// 检测心跳reset
reset() {
console.log("检测心跳");
clearInterval(this.timeoutObj);
this.start(); // 启动心跳
},
// 启动心跳 start
start() {
console.log("启动心跳");
let self = this;
this.timeoutObj = setInterval(function () {
uni.sendSocketMessage({
data: 'ping',
success: (res) => {
console.log('连接中....');
},
fail: (err) => {
console.log('连接失败重新连接....');
self.openConnection();
}
});

698
common/js/splash-state.js Normal file
View File

@@ -0,0 +1,698 @@
// ============================================================
// 启动屏单例状态
//
// 设计要点:
// 1. 状态 + 事件双通道: 组件既可 watch visible,也可监听 'splash:hide' 事件
// 解决 uni.$emit 在组件挂载前发出就丢失的问题 (memory lesson)
// 2. 软隐藏 (softHide): 标记应该 hide, 等组件 mount 后再真正 hide
// 解决 onLaunch 跑完 + hide() 早于 page mount 的时序问题
// 3. 防重入: show()/hide() 多次调用安全,内部用 guard
// 4. 兜底超时: 默认 8s 未关闭则强制 hide,防止卡死
// 5. 平台隔离: APP-PLUS 有原生 splash,这里直接返回 false
// 6. 跨平台兼容: 同步状态可被 Vue 组件直接 watch
// ============================================================
// APP-PLUS 直接返回 false (有原生启动屏)
function needSplash() {
// #ifdef APP-PLUS
return false;
// #endif
// #ifndef APP-PLUS
return true;
// #endif
}
// H5 平台特殊处理: Lang.boot() 在 main.js 中 await, 此时 Vue 还没挂载,
// 组件无法渲染, 内部降级为 uni.showLoading 做兜底 (虽然样式较丑但至少有遮罩)
function useNativeLoading() {
// #ifdef H5
return true;
// #endif
// #ifndef H5
return false;
// #endif
}
// 总兜底超时档位 (show 时可覆盖):
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
// 业务方覆盖: splashState.show({ timeout: 17000 })
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
// 严防因为 timeout 太短导致用户看到白屏.
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
// 启动期阶段定义 (id → { percent, label })
// percent: 阶段完成的百分比 (0-100)
// label: 阶段显示文案 (i18n key 由 splash 组件消费, 也可直接用最终文案)
const STAGES = {
boot: { percent: 5, label: 'Loading...' },
lang: { percent: 30, label: 'Loading language...' },
theme: { percent: 50, label: 'Applying theme...' },
override: { percent: 65, label: 'Loading theme tokens...' },
config: { percent: 80, label: 'Fetching configuration...' },
privacy: { percent: 95, label: 'Preparing privacy dialog...' },
done: { percent: 100, label: 'Ready' },
};
class SplashState {
constructor() {
this._visible = false;
this._timeoutId = null;
this._listeners = [];
this._shownAt = 0;
// 软隐藏相关
this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
this._hasMounted = false; // 是否有 splash 组件 mount 过
this._mountGraceId = null; // 软隐藏兜底定时器 (等 mount)
// 页面就绪等待
this._pageReady = false; // 页面内容是否已就绪 (由首页通知)
this._pageReadyGraceId = null; // 页面就绪兜底定时器
// 进度上报
this._stage = 'boot';
this._percent = 0;
this._stageSet = new Set(['boot']); // 已报告阶段
// 进度节流: 16ms 内的多次 reportProgress 合并成一次 emit
// 避免多通道并发时, splash 组件 re-render 风暴
this._pendingProgress = null; // 待 emit 的最新进度
this._flushTimer = null; // 节流定时器
// 网络环境 (用于 show() 自动选档)
// _networkType: 'wifi' | '4g' | '5g' | '3g' | '2g' | 'none' | 'unknown'
// _networkReady: false 表示还没检测完 (splash 显示中第一次构造时常见)
// _networkReady 后, 网络变化时通过 onNetworkStatusChange 实时更新
this._networkType = 'unknown';
this._networkReady = false;
// 启动网络检测 (异步, 不阻塞 splash 显示)
this._initNetworkDetect();
// 监听 diy 加载失败事件 (diy.js 通过 uni.$emit 触发)
// 收到后调 notifyDiyLoadError (严格模式下必须能关闭 splash)
// 解耦: splash-state 不直接 import diy.js
// 透传 reason / errorType / err / url 给业务方监听 splash:diy-error 使用
try {
if (typeof uni !== 'undefined' && uni.$on) {
uni.$on('diy:load-error', (payload) => {
try { this.notifyDiyLoadError(payload || {}) } catch (e) {}
})
}
} catch (e) {}
}
/**
* 初始化网络检测
* - 调 uni.getNetworkType 异步获取当前网络类型
* - 监听 uni.onNetworkStatusChange 实时更新
* - H5 平台降级: 调 navigator.onLine, 不支持细粒度网络类型
* 失败保护: try-catch 包, 任意失败都保持 _networkType='unknown' (不会破坏 splash)
*/
_initNetworkDetect() {
try {
// #ifdef H5
// H5 平台: navigator.onLine 是粗粒度, 只能判断在线/离线
// 仅在 navigator.onLine 是明确 boolean (true/false) 时才是 H5
// Node 24+ 有 navigator 但 onLine 是 undefined, 不算 H5 (走下面 uni.getNetworkType)
if (typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean') {
// 初始检测: 用 _updateNetworkType (内部会触发事件)
this._updateNetworkType(navigator.onLine ? 'wifi' : 'none', { source: 'h5-initial' })
// 监听 online/offline 事件
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('online', () => {
this._updateNetworkType('wifi', { source: 'h5-online', isConnected: true })
})
window.addEventListener('offline', () => {
this._updateNetworkType('none', { source: 'h5-offline', isConnected: false })
})
}
return
}
// navigator.onLine 不是 boolean (Node/SSR) - 走 uni.getNetworkType
// #endif
// #ifndef H5
if (typeof uni === 'undefined' || !uni.getNetworkType) {
// 不可用: 用 _updateNetworkType 设 unknown (会触发事件)
this._updateNetworkType('unknown', { source: 'no-uni-api' })
return
}
// 异步获取当前网络类型
uni.getNetworkType({
success: (res) => {
this._updateNetworkType((res && res.networkType) || 'unknown', { source: 'uni-initial' })
},
fail: () => {
this._updateNetworkType('unknown', { source: 'uni-initial-fail' })
},
})
// 监听网络变化
if (uni.onNetworkStatusChange) {
uni.onNetworkStatusChange((res) => {
const newType = (res && res.networkType)
|| (res && res.isConnected === false ? 'none' : 'unknown')
this._updateNetworkType(newType, {
source: 'uni-change',
isConnected: res && typeof res.isConnected === 'boolean' ? res.isConnected : undefined,
})
})
}
// #endif
} catch (e) {
// 任意失败都用 unknown, 不影响 splash
this._updateNetworkType('unknown', { source: 'init-error' })
}
}
/**
* 根据当前网络环境选档
* - 离线 (none): 4000 (4s, 让 diy 错误信号有时间触发)
* - 弱网 (2g/3g): 22000 (22s 慢网档, 多 2s 缓冲)
* - 正常 (4g/5g/wifi): 12000 (12s 默认档, 多 2s 缓冲)
* - 未知 (unknown): 17000 (17s 中档, 保守)
* - 网络未就绪: 12000 (DEFAULT_TIMEOUT, 保守)
*/
_pickTimeoutByNetwork() {
if (!this._networkReady) return DEFAULT_TIMEOUT // 网络检测未就绪, 用默认
switch (this._networkType) {
case 'none': return 4000 // 离线: 4s 兜底
case '2g':
case '3g': return 22000 // 弱网: 22s 慢网档
case '4g':
case '5g':
case 'wifi': return 12000 // 正常: 12s 默认档
case 'unknown':
default: return 17000 // 未知: 17s 中档 (保守)
}
}
show(opts = {}) {
if (!needSplash()) return;
if (this._visible) return; // 防重入
this._visible = true;
this._shownAt = Date.now();
this._pendingHide = null;
this._hasMounted = false;
this._pageReady = false;
this._stage = 'boot';
// 初始化时直接显示 'boot' 阶段的 5%, 而不是 0%
// 避免首帧出现"进度条 0%"的视觉突兀
this._percent = (STAGES.boot && STAGES.boot.percent) || 5;
this._stageSet = new Set(['boot']);
this._pendingProgress = null;
if (this._flushTimer) {
clearTimeout(this._flushTimer);
this._flushTimer = null;
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
this._emit('splash:show');
// H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染)
// 注意: mask 设为 false。原 mask:true 会在 H5 注入 .uni-mask 全屏透明蒙层,
// 配合 z-index:99999 的 Vue splash 一起把首页触摸全部拦截,导致 H5
// 首屏在 boot 期间无法滚动。Vue splash 组件本身已带白色背景提供视觉遮罩,
// 这里只要 loading 文字提示,不需要拦截触摸。
if (useNativeLoading()) {
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
}
// 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
const MIN_RECOMMENDED_TIMEOUT = 5000
let timeout
if (opts.timeout) {
timeout = opts.timeout
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
console.warn(
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
)
}
} else {
// 自动选档: 根据当前网络环境选 timeout
timeout = this._pickTimeoutByNetwork()
}
this._armTimeout(timeout);
}
/**
* 设置/重设 splash 总超时兜底
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
*/
_armTimeout(timeout) {
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
this._timeoutId = setTimeout(() => {
// 兜底: 超时强制关闭,防止卡死 (走硬隐藏路径, 不等组件)
if (this._visible) {
console.warn('[splash-state] auto-hide after timeout', timeout);
this._doHide({ reason: 'timeout' });
}
}, timeout);
}
/**
* 网络变化时更新 _networkType, 并按需重启 splash 兜底
* @param {string} newType 新的网络类型
* @param {Object} [meta] 附加信息 { isConnected, source: 'h5-online' | 'uni-change' | ... }
* @returns {boolean} 是否真的更新了 (新旧不同时为 true)
*/
_updateNetworkType(newType, meta) {
newType = newType || 'unknown'
const oldType = this._networkType
const changed = oldType !== newType
// 关键: 即使 type 没变, _networkReady 也要设 true
// 原因: 首次调用 _updateNetworkType 是从 _initNetworkDetect 进来
// 此时 _networkType='unknown' (初始), 可能 newType 也是 'unknown' (uni 不可用)
// 但 _networkReady 仍要从 false → true (让 _pickTimeoutByNetwork 工作)
this._networkReady = true
if (!changed) return false
this._networkType = newType
// 网络变化时: 如果 splash 还 visible, 重启兜底 (用新档)
// 业务场景:
// - wifi → 2g: 启动期远端慢, 兜底延长, 避免过早强关
// - 2g → wifi: 兜底缩短, 避免过晚强关
// - any → none: 兜底缩短到 4s, 让 diy 错误信号更快触发
// - none → any: 兜底恢复正常档, 给足时间
const shouldReboot = this._visible && this._pendingHide === null
// 关键: 仅当 splash 还没进入"软隐藏"阶段时重启兜底
// _pendingHide 不为 null 表示业务方已调 hide(), 等 notifyPageReady/DiyLoadError 即可
// 此时网络变化不影响兜底 (业务方已接管决策)
if (shouldReboot) {
const newTimeout = this._pickTimeoutByNetwork()
this._armTimeout(newTimeout)
}
// 发 splash:network-change 事件 (业务方监听)
// payload: { from, to, isConnected, retryRecommended, rebootSplashTimeout }
// retryRecommended: 从 none 恢复时建议重试 (业务方重调 diy 接口)
// rebootSplashTimeout: 是否重启了 splash 兜底 (true/false)
const eventPayload = {
from: oldType,
to: newType,
isConnected: meta && typeof meta.isConnected === 'boolean' ? meta.isConnected : newType !== 'none',
retryRecommended: oldType === 'none' && newType !== 'none', // 离线→在线, 建议重试
rebootSplashTimeout: shouldReboot,
source: (meta && meta.source) || 'unknown',
}
this._emit('splash:network-change', eventPayload)
return true
}
/**
* 隐藏 splash
* @param {Object} opts
* @param {string} opts.reason - 隐藏原因
* - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认)
* - 'timeout': 硬隐藏, 立即关 (兜底用)
* - 'manual': 硬隐藏, 立即关
* @param {boolean} [opts.soft] - 强制走软隐藏
* @param {string} [opts.mode] - 离开动画模式
* - 'default': 默认 480ms 动画
* - 'fast': 80ms 短动画, 用于隐私弹窗等需要立即接管视觉的场景, 避免"双遮罩"感
* - 'strict': 严格模式, 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底,
* 只等 notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 适用于: 已接入平台托管的系统弹窗 (鸿蒙),
* splash 必须真等到首页 diy 内容渲染好才能关
*/
hide(opts = {}) {
const reason = opts.reason || 'manual';
const useStrict = opts.mode === 'strict' || opts._strict === true;
// boot-complete 默认走软隐藏, 避免时序问题
const useSoft = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
// strict 模式复用 fast 动画
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
if (useSoft) {
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
this._softHide(opts);
} else {
this._doHide({ reason, mode });
}
}
/**
* 软隐藏: 标记应该 hide, 等组件 mount + 页面就绪
* 如果组件 1s 内没 mount, 兜底自动 hide
* 如果页面 2s 内没就绪, 兜底自动 hide
*
* strict 模式 (鸿蒙已接入平台托管):
* 不走 MOUNT_GRACE_MS / PAGE_READY_GRACE_MS 兜底
* 只等 notifyMounted() + notifyPageReady() 或 DEFAULT_TIMEOUT 总超时
* 保证 splash 一定等到首页 diy 内容真的渲染好
*/
_softHide(opts) {
if (!this._visible) return; // 已经隐藏了
this._pendingHide = opts;
// strict 模式识别: mode='strict' 或 _strict=true
const useStrict = opts && (opts.mode === 'strict' || opts._strict === true);
// 如果组件已经 mount 且页面已就绪, 立即隐藏
if (this._hasMounted && this._pageReady) {
this._flushPendingHide();
return;
}
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
if (useStrict) {
return;
}
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
if (this._pendingHide) {
this._flushPendingHide();
}
}, MOUNT_GRACE_MS);
}
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 hide
* strict 模式 (鸿蒙): 不设 pageReadyGraceId 兜底, 只等 notifyPageReady()
*/
_flushPendingHide() {
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
// 已被 notifyDiyLoadError 拿走 _pendingHide 并自己 _doHide: noop
// 原因: notifyMounted 50ms 后会调 _flushPendingHide, 但 _pendingHide 已被 notifyDiyLoadError 消费
// 不应再调 _doHide (避免重复发 splash:hide 或时序错乱)
if (!this._pendingHide) return;
const _opts = this._pendingHide
const useStrict = _opts && (_opts.mode === 'strict' || _opts._strict === true)
// 如果页面还未就绪, 等待页面就绪 (保留 _pendingHide, 等页面就绪后再处理)
if (!this._pageReady) {
// strict 模式: 不设 pageReadyGraceId, 只等 notifyPageReady()
// 总超时由 show() 里的 _timeoutId 兜底 (DEFAULT_TIMEOUT=8s)
if (useStrict) {
return;
}
// 兜底: 2s 内没就绪, 强制隐藏
if (this._pageReadyGraceId) clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = setTimeout(() => {
if (this._visible && this._pendingHide) {
console.warn('[splash-state] auto-hide after page-ready timeout');
this._doHide(this._pendingHide || { reason: 'page-ready-timeout' });
}
}, PAGE_READY_GRACE_MS);
return;
}
const opts = this._pendingHide;
this._pendingHide = null;
this._doHide(opts || { reason: 'mount-completed' });
}
/**
* 实际隐藏 (硬路径)
* @param {Object} opts
* @param {string} [opts.reason='manual'] 隐藏原因
* @param {string} [opts.mode='default'] 动画模式
* @param {boolean} [opts._silent=false] true=不发 splash:hide 事件
* 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide
*/
_doHide(opts) {
if (!this._visible) return; // 防重入
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId);
this._mountGraceId = null;
}
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
this._pendingHide = null;
this._visible = false;
// H5 降级对应的关闭
if (useNativeLoading()) {
try { uni.hideLoading(); } catch (e) {}
}
// _silent=true: 业务方已经从 notifyDiyLoadError 收了 splash:diy-error, 这里不发 splash:hide
if (opts._silent) return;
this._emit('splash:hide', {
reason: opts.reason || 'manual',
mode: opts.mode || 'default',
});
}
/**
* 组件 mount 后通知 state 模块
* - 标记 _hasMounted
* - 如果有 _pendingHide, 触发真关
* - H5 降级场景: 不再立即关掉原生 loading, 等待页面就绪后再关
*/
notifyMounted() {
if (this._hasMounted) return;
this._hasMounted = true;
if (this._pendingHide) {
setTimeout(() => this._flushPendingHide(), 50);
}
}
/**
* 页面内容就绪后通知 state 模块
* - 标记 _pageReady
* - 如果有 _pendingHide, 触发真关
* 调用时机: 首页 diy-group 渲染完成后调用
*/
notifyPageReady() {
if (this._pageReady) return;
this._pageReady = true;
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId);
this._pageReadyGraceId = null;
}
if (this._pendingHide) {
// 给页面一帧时间完成渲染, 再隐藏
setTimeout(() => this._flushPendingHide(), 50);
}
}
/**
* diy 加载失败时通知 (网络异常/业务失败/无响应)
* - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上)
* - 触发独立事件 'splash:diy-error' (不是 'splash:hide')
* 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型
* 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError)
*
* @param {Object} [opts]
* @param {string} [opts.reason='diy-load-error'] 隐藏原因
* 用于 'splash:diy-error' 事件的 payload, 业务方可区分:
* - 'diy-network-error': 网络异常 (uni.request catch)
* - 'diy-no-response': 无响应 (res 为空)
* - 'diy-business-error': 业务失败 (code!=0)
* @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response'
* @param {Object|string} [opts.err] 原始错误对象
* @param {string} [opts.url] 出错的请求地址
*/
notifyDiyLoadError(opts = {}) {
// 已 _pageReady=true (notifyPageReady 之后): noop
// 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱)
// 不应再触发 splash:diy-error (已经发过 splash:hide)
if (this._pageReady) return;
// 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪")
// 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉
this._pageReady = true
// 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError)
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId)
this._pageReadyGraceId = null
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId)
this._mountGraceId = null
}
// 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误)
this._emit('splash:diy-error', {
reason: opts.reason || 'diy-load-error',
errorType: opts.errorType,
err: opts.err,
url: opts.url,
})
// 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide
// 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件
// notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide
// (业务方根据事件类型区分, 不能混淆)
if (this._pendingHide) {
// 把 _pendingHide 取出, 直接 _doHide 关闭
// _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error)
const _pendingOpts = this._pendingHide
this._pendingHide = null
setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50)
}
}
/**
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
*/
notifyUnmounted() {
// 不重置 _hasMounted, 因为软隐藏的 _pendingHide 处理不依赖 unmount
}
isShow() {
return this._visible;
}
hasPendingHide() {
return this._pendingHide != null;
}
elapsed() {
if (!this._visible) return 0;
return Date.now() - this._shownAt;
}
on(event, fn) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push(fn);
}
off(event, fn) {
if (!this._listeners[event]) return;
this._listeners[event] = this._listeners[event].filter(f => f !== fn);
}
_emit(event, payload) {
const arr = this._listeners[event];
if (!arr || !arr.length) return;
for (let i = 0; i < arr.length; i++) {
try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); }
}
}
/**
* 上报启动期进度
* 节流: 16ms 内的多次 reportProgress 合并成 1 次 emit, 避免 splash re-render 风暴
* 取较大值, 不允许后退 (即使 throttle 合并也不会让进度条后退)
* @param {string} stageId - 阶段 id (STAGES 中定义)
* 'boot' | 'lang' | 'theme' | 'override' | 'config' | 'privacy' | 'done'
* 或自定义阶段 id (传 percent + label)
* @param {Object} [meta] - { percent?, label?, source?: 'channel'|'timeout'|'error' }
* 阶段完成时 percent 应该是该阶段的"完成态"百分比
*/
reportProgress(stageId, meta) {
const def = STAGES[stageId]
let percent, label
if (def) {
percent = (meta && typeof meta.percent === 'number') ? meta.percent : def.percent
label = (meta && meta.label) || def.label
} else {
// 自定义阶段
percent = (meta && typeof meta.percent === 'number') ? meta.percent : this._percent
label = (meta && meta.label) || stageId
}
// 取较大值, 不允许后退
if (percent < this._percent) percent = this._percent
this._percent = percent
this._stage = stageId
if (!this._stageSet.has(stageId)) this._stageSet.add(stageId)
// 节流 emit: 16ms 内的多次调用合并为 1 次
// 内部状态立即更新 (不延迟), 但 emit 推迟
// 注意: percent 不允许后退的逻辑已在上方处理, 所以 throttle 合并不会丢进度
const payload = {
stage: stageId,
label,
percent,
source: (meta && meta.source) || 'channel',
at: Date.now(),
}
this._pendingProgress = payload
if (this._flushTimer) return // 已有 pending, 等 timer flush
this._flushTimer = setTimeout(() => {
this._flushTimer = null
if (this._pendingProgress) {
const p = this._pendingProgress
this._pendingProgress = null
this._emit('splash:progress', p)
}
}, 16)
}
/**
* 监听进度上报
* callback({ stage, label, percent, source, at })
*
* 注意: 与 on() 不同, onProgress 是单例 listener (多个组件时只最后一个生效)
* 设计意图: splash 进度条 UI 在系统中只有一个渲染者, 不需要多播
*/
onProgress(callback) {
this._listeners['splash:progress'] = [callback]
}
/**
* 解绑进度上报 (清空 listener)
*/
offProgress() {
this._listeners['splash:progress'] = []
}
/**
* 读取当前进度 (供组件初始化)
* 同时返回已解析的 label, 避免组件再维护一份 STAGE_LABEL
*/
getProgress() {
const def = STAGES[this._stage]
return {
stage: this._stage,
percent: this._percent,
label: def ? def.label : this._stage,
}
}
/**
* 暴露 STAGES 表 (供 splash 组件直接消费, 避免重复维护一份)
*/
getStageDef(stageId) {
return STAGES[stageId]
}
/**
* 立即刷新挂起的进度更新
* - reportProgress 默认 16ms throttle (1 帧), 避免连续触发 splash 组件多次 re-render
* - 业务侧若要"立即"刷新 (例如 splash 即将隐藏), 可调本方法
* - 不存在 pending 时 noop
*/
flushProgress() {
if (this._pendingProgress) {
const p = this._pendingProgress
this._pendingProgress = null
if (this._flushTimer) {
clearTimeout(this._flushTimer)
this._flushTimer = null
}
this._emit('splash:progress', p)
}
}
}
const splashState = new SplashState();
export default splashState;
export { splashState };

File diff suppressed because it is too large Load Diff

476
common/js/theme-override.js Normal file
View File

@@ -0,0 +1,476 @@
/**
* 主题 token 远端覆写运行时
*
* 设计原则(与 lang.js 完全同构):
* 1) 后端只存"对要个性化的 token"做覆写 KV,前端拿本地 style_color.js + 远端覆写 合并
* 2) 三态机: unknown / ok / fail
* 3) 多租户 × 多端平台 × 多模式(brand/light/dark)三维隔离缓存
* 4) 失败归一: timeout / network / http / parse / exception
* 5) 失败时使用本地 style_color.js 兜底
* 6) 不修改 Vuex.themeStyle / themeColorSet / 页面 wrapper,只把"合并后的 token map"
* 提供给 store/index.js 的 applyThemeWithRemote action 使用
*
* 暴露的 API:
* ThemeOverride.boot() 首屏阻塞拉取 + 缓存预热(失败静默)
* ThemeOverride.refresh(force) 重新拉取 + 重新应用
* ThemeOverride.getByTheme(theme) 取单个模式桶(brand/light/dark)的合并 KV
* ThemeOverride.getAll() { brand, light, dark }
* ThemeOverride.onUniacidChange() 切租户后清旧缓存
* ThemeOverride.getRemoteStatus() 'unknown' | 'ok' | 'fail'
* ThemeOverride.onRemoteStatusChange(fn) 订阅状态变化
* ThemeOverride.__debug_cache 内部缓存(测试用)
*
* 关键: 一次 boot 只发一次 HTTP 请求,后端 getAllByTenant 一次出 brand/light/dark 三桶
* 避免对每个 theme 单独请求造成的 3 倍带宽浪费
*/
import { themeConfig } from './config-external.js';
import Config from './config.js';
import http from './http.js';
import { Platform } from './platform.js';
const THEMES = ['brand', 'light', 'dark'];
// 远端覆写:内存缓存
// shape: { [theme]: { version, map, ts, global_version } }
const overrideCache = {};
// 远端全量 preset 内存缓存(remote-only 模式用)
// shape: { [presetName]: { ...tokenMap } }
const presetsCache = {};
// 远端覆写:并发去重(以"全量拉取"为粒度)
let allInflight = null;
// 远端全量 preset 拉取:并发去重
let presetsInflight = null;
// 远端可用性三态机
let remoteStatus = 'unknown';
let remoteStatusAt = 0;
const remoteStatusListeners = [];
// 配置项缓存
const REMOTE = (themeConfig && themeConfig.overrideRemote) || {};
/**
* 校验 source 配置:只接受 local / remote / hybrid,其它一律视为 remote(安全默认)
*/
function normalizeSource(s) {
return ['local', 'remote', 'hybrid'].includes(s) ? s : 'remote';
}
const SOURCE = normalizeSource(REMOTE.source || 'remote');
/**
* 远端失败原因归一
*/
function normalizeReason(s) {
const allow = ['timeout', 'network', 'http', 'parse', 'exception', 'unknown'];
return allow.includes(s) ? s : 'unknown';
}
/**
* 远端成功/失败状态机
*/
function setRemoteStatus(s, detail) {
if (remoteStatus === s) return;
const prev = remoteStatus;
remoteStatus = s;
remoteStatusAt = Date.now();
const safeDetail = {
reason: normalizeReason(detail && detail.reason),
theme: detail && detail.theme,
};
remoteStatusListeners.forEach(fn => {
try { fn(s, safeDetail); } catch (e) { /* 忽略监听器异常 */ }
});
try { uni.$emit && uni.$emit('theme:remote-status', { from: prev, to: s, ...safeDetail }); } catch (e) {}
if (s === 'fail') {
try { uni.$emit && uni.$emit('theme:fallback', { reason: safeDetail.reason }); } catch (e) {}
}
}
/**
* 获得当前租户 id
*/
function getCurrentUniacid() {
const u = uni.getStorageSync('uniacid');
return u ? parseInt(u, 10) || Config.uniacid : Config.uniacid;
}
/**
* 当前平台类型
*/
function getCurrentAppType() {
try { return Platform.getAppType() || '*'; } catch (e) { return '*'; }
}
/**
* 持久化 key: <prefix><uniacid>_<appType>_<theme>
*/
function getOverrideStorageKey(theme) {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${REMOTE.storagePrefix || 'THEME_OVR_'}${u}_${a}_${theme}`;
}
/**
* 内存 cache key: <uniacid>_<appType>_<theme>
*/
function getOverrideMemKey(theme) {
return `${getCurrentUniacid()}_${getCurrentAppType()}_${theme}`;
}
/**
* 从 storage 预热单个 theme 桶到内存(首屏兜底)
* 仅在内存还没有该 key 时才读 storage,避免覆盖更新值
*/
function preheatFromStorage(theme) {
const mk = getOverrideMemKey(theme);
if (overrideCache[mk]) return;
try {
const cached = uni.getStorageSync(getOverrideStorageKey(theme));
if (cached && cached.map) {
overrideCache[mk] = {
version: cached.version || 0,
map: cached.map || {},
ts: cached.ts || 0,
global_version: cached.global_version || 0,
};
return;
}
} catch (e) { /* 忽略 */ }
overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
}
/**
* 把"单次 HTTP 响应"按 by_theme 拆到 3 个内存桶 + 持久化
*/
function applyRemoteResponse(d) {
const byTheme = (d && d.by_theme && typeof d.by_theme === 'object') ? d.by_theme : {};
const gv = (d && d.global_version) || 0;
const ts = Date.now();
THEMES.forEach(theme => {
const remoteMap = (byTheme[theme] && typeof byTheme[theme] === 'object') ? byTheme[theme] : {};
const mk = getOverrideMemKey(theme);
const baseCache = overrideCache[mk] || { version: 0, map: {}, ts: 0, global_version: 0 };
const merged = Object.assign({}, baseCache.map, remoteMap);
overrideCache[mk] = {
version: gv,
map: merged,
ts,
global_version: gv,
};
try { uni.setStorageSync(getOverrideStorageKey(theme), overrideCache[mk]); } catch (e) {}
});
}
/**
* 取"3 个桶里最大的 global_version"(穿透缓存用)
*/
function maxCachedGlobalVersion() {
let gv = 0;
THEMES.forEach(t => {
const mk = getOverrideMemKey(t);
const c = overrideCache[mk];
if (c && c.global_version > gv) gv = c.global_version;
});
return gv;
}
/**
* 一次 HTTP 拉齐 3 个 theme 桶
* 这是 boot() / refresh() / onUniacidChange() 共用的"拉取入口"
*
* 关键: 1 次 HTTP, 拆 3 桶,避免对每个 theme 单独请求造成 3 倍浪费
*
* @returns {Promise<{ source:'cache'|'remote'|'local', ok:boolean, reason?:string, themes:string[] }>}
*/
async function loadAllOverrides() {
if (SOURCE === 'local') return { source: 'local', ok: true, themes: THEMES };
// 并发去重:同一刻只允许一个在飞
if (allInflight) return allInflight;
allInflight = (async () => {
// 1) 先预热 storage 兜底
THEMES.forEach(preheatFromStorage);
// 2) 一次 HTTP 拉齐 3 桶
try {
const res = await Promise.race([
http.sendRequest({
url: REMOTE.overridesUrl || '/api/theme/overrides',
data: {
global_version: maxCachedGlobalVersion(),
},
async: false,
}),
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
]);
if (!res || res.code !== 0 || !res.data) {
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
setRemoteStatus('fail', { reason, theme: 'all' });
return { source: 'local', ok: false, reason, themes: THEMES };
}
applyRemoteResponse(res.data);
setRemoteStatus('ok', { theme: 'all' });
emitUpdated('all');
return { source: 'remote', ok: true, themes: THEMES };
} catch (e) {
setRemoteStatus('fail', { reason: 'exception', error: String(e && e.message || e), theme: 'all' });
return { source: 'local', ok: false, reason: 'exception', themes: THEMES };
}
})();
try {
return await allInflight;
} finally {
allInflight = null;
}
}
/**
* 触发"主题已更新"事件
* 注意:Vuex 的 themeColorSet action 已经在 setThemeStyle 后跑过,
* CSS 变量已挂到页面 wrapper 上,组件会自动刷新。
* 这里只发通知,方便业务代码(如自定义 JS 直读 themeStyle 的页面)主动 re-render。
*/
function emitUpdated(theme) {
try { uni.$emit && uni.$emit('theme:updated', { theme }); } catch (e) {}
}
// 远端全量 preset 持久化 key: <storagePresetsPrefix><uniacid>_<appType>
function getPresetsStorageKey() {
const u = getCurrentUniacid();
const a = getCurrentAppType();
return `${REMOTE.storagePresetsPrefix || 'THEME_PRESETS_'}${u}_${a}`;
}
// 远端全量 preset 内存 cache key
function getPresetsMemKey() {
return `${getCurrentUniacid()}_${getCurrentAppType()}`;
}
// 从 storage 预热全量 preset 到内存(首屏兜底)
function preheatPresetsFromStorage() {
const mk = getPresetsMemKey();
if (presetsCache[mk] && Object.keys(presetsCache[mk]).length) return;
try {
const cached = uni.getStorageSync(getPresetsStorageKey());
if (cached && typeof cached === 'object') {
presetsCache[mk] = cached;
return;
}
} catch (e) { /* 忽略 */ }
}
/**
* 一次 HTTP 拉齐 8 个 preset(remote-only 模式)
* 关键: 不阻塞首屏,失败静默. store/index.js 的 applyThemeWithRemote 会优先用
* 内存缓存(由 loadAllPresets 写入), 缓存未命中时回退到 webpack stub(只含 default).
*
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
*/
async function loadAllPresets() {
if (SOURCE === 'local') return { source: 'skipped', ok: true, presets: {} };
if (!REMOTE.presetsUrl) return { source: 'skipped', ok: false, reason: 'no-url' };
// 并发去重
if (presetsInflight) return presetsInflight;
presetsInflight = (async () => {
// 1) 先预热 storage 兜底
preheatPresetsFromStorage();
// 2) 一次 HTTP 拉齐 8 个 preset
try {
const res = await Promise.race([
http.sendRequest({
url: REMOTE.presetsUrl,
data: {},
async: false,
}),
new Promise((r) => setTimeout(() => r({ code: -1, message: 'timeout' }), REMOTE.requestTimeout || 4000)),
]);
if (!res || res.code !== 0 || !res.data) {
const reason = !res ? 'network' : (res.code === -1 ? 'timeout' : (res.message || 'bad-response'));
console.warn('[theme-presets] remote load fail, keep stub:', reason);
return { source: 'local', ok: false, reason };
}
// 3) 解析响应. 期望格式: { by_preset: { default: {...}, green: {...}, ... } }
const byPreset = (res.data.by_preset && typeof res.data.by_preset === 'object') ? res.data.by_preset : null;
if (!byPreset) {
console.warn('[theme-presets] response missing by_preset');
return { source: 'local', ok: false, reason: 'parse' };
}
// 4) 写入内存缓存 + 持久化
const mk = getPresetsMemKey();
presetsCache[mk] = byPreset;
try { uni.setStorageSync(getPresetsStorageKey(), byPreset); } catch (e) {}
// 5) 通知已更新
try { uni.$emit && uni.$emit('theme:presets-updated'); } catch (e) {}
return { source: 'remote', ok: true, presets: byPreset };
} catch (e) {
console.warn('[theme-presets] remote load exception:', e && e.message);
return { source: 'local', ok: false, reason: 'exception' };
}
})();
try {
return await presetsInflight;
} finally {
presetsInflight = null;
}
}
// 导出主体
export default {
SOURCE,
/**
* 阻塞式预热:App.onLaunch 在 Lang.boot 后调用
* 失败静默,不阻塞首屏
*
* @returns {Promise<{ok:boolean, source:'local'|'remote'|'cache'|'timeout', status:string, reason?:string, themes:string[]}>}
*/
async boot() {
if (SOURCE === 'local') {
return { ok: true, source: 'local', status: remoteStatus, themes: THEMES };
}
const bootTimeout = REMOTE.bootTimeout || 2000;
const t = new Promise((r) => setTimeout(() => r({ __timeout: true }), bootTimeout));
// 切租户检测
const currentUid = getCurrentUniacid();
try {
const lastUid = uni.getStorageSync('theme_last_uniacid');
if (lastUid && String(lastUid) !== String(currentUid)) {
this.onUniacidChange();
}
uni.setStorageSync('theme_last_uniacid', currentUid);
} catch (e) {}
const result = await Promise.race([loadAllOverrides(), t]);
if (result && result.__timeout) {
return { ok: false, source: 'timeout', status: 'fail', reason: 'timeout', themes: THEMES };
}
return {
ok: remoteStatus === 'ok',
source: remoteStatus === 'ok' ? 'remote' : 'local',
status: remoteStatus,
themes: THEMES,
};
},
/**
* 主动重新拉取(管理后台改了之后 EventBus 通知触发)
* @param {boolean} [force=false] true = 绕过缓存强刷
*/
async refresh(force = false) {
if (force) {
// 强刷:清掉内存 + 持久化缓存,逼 loadAllOverrides 走远端
THEMES.forEach(t => {
const mk = getOverrideMemKey(t);
if (overrideCache[mk]) overrideCache[mk] = { version: 0, map: {}, ts: 0, global_version: 0 };
try { uni.removeStorageSync(getOverrideStorageKey(t)); } catch (e) {}
});
}
return loadAllOverrides();
},
/**
* 切租户后清旧缓存
*/
onUniacidChange() {
if (SOURCE === 'local') return;
if (REMOTE.refreshOnUniacidChange === false) return;
THEMES.forEach(t => {
const sk = getOverrideStorageKey(t);
try { uni.removeStorageSync(sk); } catch (e) {}
delete overrideCache[getOverrideMemKey(t)];
});
// 同步清空全量 preset 缓存(remote-only 模式)
const psk = getPresetsStorageKey();
try { uni.removeStorageSync(psk); } catch (e) {}
delete presetsCache[getPresetsMemKey()];
loadAllOverrides();
},
/**
* 取某个 theme 桶(同步:有缓存返缓存;无缓存时该桶返回 {})
* store/index.js 的 applyThemeWithRemote action 直接用
* @param {'brand'|'light'|'dark'} theme
* @returns {object} 扁平 KV map (没有时返回 {})
*/
getByTheme(theme) {
const mk = getOverrideMemKey(theme);
const cached = overrideCache[mk];
return (cached && cached.map) ? cached.map : {};
},
/**
* 取所有 theme 桶
* @returns {{ brand: object, light: object, dark: object }}
*/
getAll() {
return {
brand: this.getByTheme('brand'),
light: this.getByTheme('light'),
dark: this.getByTheme('dark'),
};
},
/**
* 取全量 preset 内存缓存(remote-only 模式)
* store/index.js 的 applyThemeWithRemote 通过这个取到远端拉的全量 preset
* @returns {{ [presetName: string]: object }}
*/
getAllPresets() {
const mk = getPresetsMemKey();
return presetsCache[mk] || {};
},
/**
* 主动拉全量 preset(remote-only 模式)
* - 失败静默, 不抛错
* - 内存 + 持久化双写
* - 拉成功后发 uni.$emit('theme:presets-updated')
*
* @returns {Promise<{ source:'cache'|'remote'|'local'|'skipped', ok:boolean, presets?:object, reason?:string }>}
*/
loadAllPresets,
/**
* 当前远端状态
*/
getRemoteStatus() { return remoteStatus; },
/**
* 订阅远端状态变化
* @param {(status:string, detail:object) => void} fn
* @returns {() => void} unsubscribe
*/
onRemoteStatusChange(fn) {
if (typeof fn !== 'function') return () => {};
remoteStatusListeners.push(fn);
try { fn(remoteStatus, { at: remoteStatusAt, source: SOURCE }); } catch (e) {}
return () => {
const i = remoteStatusListeners.indexOf(fn);
if (i >= 0) remoteStatusListeners.splice(i, 1);
};
},
// 内部测试/调试用
__debug_overrideCache: overrideCache,
__debug_loadAllOverrides: loadAllOverrides,
}

149
common/js/uniapp.utils.js Normal file
View File

@@ -0,0 +1,149 @@
/**
* 将常用的Uniapp提供的函数,存放到这里,按需引用
*/
import lang from './lang.js';
/**
* 显示错误信息
* @param {Exception} err
* @param {Boolean} useModal
*/
const showError = (err, useModal = false) => {
const content = err?.message || err?.errMsg || err?.toString();
if (!useModal) {
uni.showToast({
title: content,
icon: 'none',
duration: 3000
});
} else {
uni.showModal({
title: lang.lang('uniappUtils.errorTip'),
content,
showCancel: false,
})
}
}
/**
* 打电话
* @param {string} mobile 电话号码
*/
export const makePhoneCall = (mobile) => {
try {
uni.makePhoneCall({
phoneNumber: `${mobile}`,
success(e) {
}
});
} catch (err) {
showError(err);
}
}
/**
* 拷贝文本(返回 Promise)
* @param {*} text
* @param {*} options
* @returns {Promise} 返回 Promise,成功时 resolve,失败时 reject
*/
export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {}) => {
return new Promise((resolve, reject) => {
// 输入验证
if (!text && text !== '') {
const error = new Error(lang.lang('uniappUtils.copyTextEmpty'));
showError(error);
reject(error);
return;
}
// 超时监测
const timeoutId = setTimeout(() => {
let error = new Error(lang.lang('uniappUtils.copyTimeout'));
// #ifdef MP-WEIXIN
error = new Error([
lang.lang('uniappUtils.copyTimeoutTitle'),
lang.lang('uniappUtils.copyTimeoutReason'),
lang.lang('uniappUtils.wechatClipboardReason1'),
lang.lang('uniappUtils.wechatClipboardReason2')
].join('\r\n'));
// #endif
showError(error, true);
reject(error);
}, 5000);
try {
uni.setClipboardData({
data: `${text}`,
success: (res) => {
clearTimeout(timeoutId);
try {
if (copySuccess) {
uni.showToast({
title: copySuccess,
icon: 'success',
duration: 2000
});
}
} catch (e) {
showError(e);
}
resolve(res);
},
fail: (err) => {
clearTimeout(timeoutId);
try {
uni.showToast({
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
icon: 'none',
duration: 2000
});
} catch (e) {
showError(e);
}
reject(err);
}
});
} catch (err) {
clearTimeout(timeoutId);
showError(err);
reject(err);
}
});
}
/**
* 拷贝文本(回调形式,兼容旧代码)
* @param {*} text
* @param {*} options
* @param {Function} callback 回调函数,接收 (success, error) 参数
*/
export const copyText = (text, options = {}, callback) => {
copyTextAsync(text, options)
.then(res => {
if (callback) callback(true, null);
})
.catch(err => {
if (callback) callback(false, err);
});
}
/**
* 打开定位
* @param {Object} options
*/
export const openLocation = ({ latitude, longitude, name } = {}) => {
try {
uni.openLocation({
latitude,
longitude,
name,
});
} catch (err) {
showError(err);
}
}

814
common/js/util-navigate.js Normal file
View File

@@ -0,0 +1,814 @@
/**
* util-navigate.js
*
* 跳转链路专用的工具集 — 从 util.js 拆分出来
*
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 跳转链路):
* - _toParamValueString : 把任意值序列化成可作为 URL 参数的字符串
* - parsePageParams : 把 page_params 还原成对象(JSON.parse 安全封装)
* - resolveLinkUrl : 解析 link 对象得到最终可跳转的 URL
* - cleanParamUrls : 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段
* - normalizeUrl : 严格规范化 URL(不补协议/不补 host)
* - appendQueryParams : 给 URL 追加 query 参数
* - getPlatform : 跨平台检测(h5 / mp-weixin / mp-harmony / quickapp)
* - shouldEncodeUrl : 是否需要主动 encodeURIComponent(4 平台都 true)
* - _safeEncodeForPlatform : 跨平台统一的 safeEncode(decode roundtrip + encode 一次)
* - _navigateTo : 跨平台跳转 API 调度
*
* 设计原则:
* 1. 不依赖 util.js,避免循环依赖
* 2. 全部为普通函数(不挂在 default export 对象上),方便 tree-shaking 和测试
* 3. 行为完全兼容 util.js 旧版,util.js 通过 import 把这些方法挂载到 default export 上
* (所有调用方 this.$util.cleanParamUrls / this.$util.redirectTo 等都不需要改)
*
* 跨平台编码 / 跳转策略文档见 _safeEncodeForPlatform / _navigateTo 的注释
*/
// =============================================================
// 基础工具
// =============================================================
/**
* 把任意值序列化成可作为 URL 参数的字符串。
* - 对象/数组: JSON.stringify
* - null/undefined: ''
* - 其他: String(v)
*
* @param {*} v
* @returns {string}
*/
export function _toParamValueString(v) {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
/**
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
* 容错策略: 任何解析失败都安全降级到 defaultValue,不抛异常。
*
* @example
* parsePageParams('{"a":1}') // { a: 1 }
* parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回)
* parsePageParams('[object Object]') // {} (老 bug 残留安全降级)
* parsePageParams('') // {}
* parsePageParams(null) // {}
* parsePageParams(undefined) // {}
* parsePageParams('not json', {x:1}) // { x: 1 } (解析失败走默认值)
*
* @param {*} raw
* @param {Object} [defaultValue={}]
* @returns {Object}
*/
export function parsePageParams(raw, defaultValue = {}) {
if (raw == null) return Object.assign({}, defaultValue);
if (typeof raw === 'object') return raw;
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null
? parsed
: Object.assign({}, defaultValue);
} catch (e) {
return Object.assign({}, defaultValue);
}
}
return Object.assign({}, defaultValue);
}
// =============================================================
// resolveLinkUrl
// =============================================================
/**
* 解析 link 对象得到最终可跳转的 URL
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 二次编码
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @returns {string|null}
*/
export function resolveLinkUrl(link) {
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
if (link.wap_url) {
const decodedWap = decodeEntity(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach((kv) => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
const pageParams = parsePageParams(link.page_params);
link.page_params = Object.assign({}, urlParams, pageParams);
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
// 这里原本会调 resolvePageUrl(link.page_key, params),
// 但 util-navigate.js 不依赖 util.js,因此返回 link.wap_url
// (util.js 在 import 此模块后会用 page_key 重新解析)
const params = parsePageParams(link.page_params);
// 注: 完整 page_key 解析留给 util.js (它有 resolvePageUrl)
// 这里只做"wap_url 已被 resolveLinkUrl 处理过" 的返回
}
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
}
/**
* decodeEntity — HTML 实体解码
* (从 util.js 顶层 export 搬过来, 跟其他跳转工具一起)
*
* 解码 &xxx; / &#xN; 等 HTML 实体
* 容错: 任何解析失败都返回原文
*
* @param {string} str
* @param {boolean} [amp=true] 是否解码 &amp;
* @returns {string}
*/
export function decodeEntity(str, amp) {
const html4Entities = {
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥',
brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«',
not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²',
sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸',
sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾',
iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä',
aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê',
euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð',
ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö',
times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý',
thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ',
delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι',
kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο',
pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ',
phi: 'φ', chi: 'ψ', omega: 'ω',
};
amp = amp === undefined ? true : amp;
let i = str.indexOf('&');
while (i !== -1) {
const j = str.indexOf(';', i + 3);
let code;
if (j === -1) break;
if (str[i + 1] === '#') {
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
if (!isNaN(code)) {
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
}
} else {
code = str.substring(i + 1, j);
if (html4Entities[code] || (code === 'amp' && amp)) {
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
}
}
i = str.indexOf('&', i + 1);
}
return str;
}
// =============================================================
// cleanParamUrls
// =============================================================
/**
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
* - 去除反引号 / 各种空白 / 零宽字符
* - 修复 scheme 后多余斜杠(https:////host → https://host)
*
* "看起来像 URL" 的判定: 含 `://`(大小写不敏感)
* - 命中: 走清理
* - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤)
*
* 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖
*
* @param {*} value 任意值
* @returns {*} 与输入同结构的清理后值
*/
export function cleanParamUrls(value) {
// 内部递归函数,通过闭包引用自身,避免 this 绑定问题
const _clean = (v) => {
if (v === null || v === undefined) return v;
if (typeof v === 'string') {
if (/:\/\//i.test(v)) {
return v
.split('').filter((ch) =>
ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r',
).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
}
return v;
}
if (Array.isArray(v)) {
return v.map((item) => _clean(item));
}
if (typeof v === 'object') {
const out = {};
for (const k of Object.keys(v)) {
out[k] = _clean(v[k]);
}
return out;
}
return v;
};
return _clean(value);
}
// =============================================================
// normalizeUrl
// =============================================================
/**
* 严格 URL 规范化(只规范化已有部分,绝不自动补充)
*
* 策略:
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
* 2) 不补 https:// — 如果原串没有协议,保持没有
* 3) 不补 host — 如果原串没有 host,保持没有
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
*
* @param {string} urlString
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
*/
export function normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
// 1) 预清理: 反引号 / 各种空白 / BOM / 零宽字符
let url = urlString
.split('').filter((ch) => {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false;
if (ch === '`') return false;
if (code === 0x3000) return false;
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false;
return true;
}).join('');
// 2) 解析协议(可选,允许缺失)
const protocolMatch = url.match(/^(https?):\/\//i);
let protocol = '';
let rest = url;
if (protocolMatch) {
protocol = protocolMatch[1].toLowerCase() + '://';
rest = url.substring(protocolMatch[0].length);
// scheme 后不能紧跟 '/',即不允许 https:////host
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
rest = url;
}
// 3) 解析 hash
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
// 4) 解析 query
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex); // 包含 '?'
pathPart = mainPart.substring(0, queryIndex);
}
// 5) 解析 host
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
const firstSlash = pathPart.indexOf('/');
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
if (hostPort) {
const colonIdx = hostPort.lastIndexOf(':');
if (colonIdx !== -1) {
hostname = hostPort.substring(0, colonIdx);
port = hostPort.substring(colonIdx + 1);
} else {
hostname = hostPort;
}
}
}
// 6) 规范化 host
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
// 7) 规范化 pathname
let normalizedPath = pathname;
if (normalizedPath) {
const segments = normalizedPath.split('/');
const resolved = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg === '' || seg === '.') {
if (i === 0) resolved.push('');
continue;
}
if (seg === '..') {
if (resolved.length > 1) resolved.pop();
continue;
}
resolved.push(seg);
}
normalizedPath = resolved.join('/') || '/';
}
// 8) 规范化 query
let normalizedQuery = '';
if (query) {
const qs = query.substring(1);
if (qs) {
const pairs = qs.split('&').map((pair) => {
const eqIdx = pair.indexOf('=');
return eqIdx === -1
? { k: pair, v: '' }
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
});
pairs.sort((a, b) => a.k.localeCompare(b.k));
normalizedQuery = '?' + pairs.map((p) => p.k + '=' + p.v).join('&');
}
}
// 9) 拼回
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart;
return schemeAndHost + normalizedPath + normalizedQuery + hash;
}
// =============================================================
// extractInternalAppPath
// =============================================================
/**
* 从一个 URL 中提取"本 App 内部页面路径"。
*
* 背景 / 修复的 bug:
* 后台装修数据里,内部页面常被存成【绝对 URL】,例如
* https://host/hwappx/common/pages_tool/form/formdata?id=130
* diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是:
* - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失
* - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view
* 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径,
* 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。
*
* 判定规则(双闸门,避免误伤真正的外部站点):
* 闸门 1 —— host 校验(相对路径直接放行):
* 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感);
* 否则返回 null(视为真外链, 维持原逻辑)。
* ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链,
* 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。
* 闸门 2 —— 路径校验:
* pathname 中出现以 App 页面根开头的段(pages / pages_xxx),
* 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回;
* 未命中返回 null。
*
* @example
* const H = ['xcx6.aigc-quickapp.com']
* extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H)
* // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原)
* extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H)
* // null (子域不同 → 维持外链)
* extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H)
* // null (外域名 → 维持外链,不误伤)
* extractInternalAppPath('/pages_goods/detail?id=9', H)
* // '/pages_goods/detail?id=9' (相对路径 → 内部)
*
* @param {string} url 绝对或相对 URL
* @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取);
* 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。
* @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null
*/
export function extractInternalAppPath(url, allowedHosts) {
if (!url || typeof url !== 'string') return null;
let rest = url;
// 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部)
const hostMatch = rest.match(/^https?:\/\/([^/]*)/i);
if (hostMatch) {
if (allowedHosts !== true) {
const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, '');
const allow = _normalizeHostList(allowedHosts);
if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改
}
rest = rest.substring(hostMatch[0].length);
}
// 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx)
const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i);
if (!pageMatch) return null;
const internal = rest.substring(pageMatch.index);
return internal || null;
}
/**
* 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。
* 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。
* @param {string|string[]} hosts
* @returns {string[]}
*/
function _normalizeHostList(hosts) {
if (!hosts) return [];
const arr = Array.isArray(hosts) ? hosts : [hosts];
const out = [];
for (const h of arr) {
if (!h || typeof h !== 'string') continue;
let s = h.trim().toLowerCase();
const m = s.match(/^https?:\/\/([^/]*)/i);
if (m) s = m[1] || '';
else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path
s = s.replace(/:\d+$/, ''); // 去端口
if (s) out.push(s);
}
return out;
}
/**
* 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。
*
* 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode):
* - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致)
* - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view)
* - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上
*
* @param {Object} link
* @returns {'internal'|'online'|'auto'}
*/
export function resolveNavMode(link) {
if (!link || typeof link !== 'object') return 'auto';
const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
const raw = link.open_mode != null ? link.open_mode
: (link.openMode != null ? link.openMode
: (pp.open_mode != null ? pp.open_mode : pp.openMode));
const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : '';
if (m === 'internal' || m === 'spa') return 'internal';
if (m === 'online' || m === 'web') return 'online';
return 'auto';
}
// =============================================================
// hasStaleUniacidInUrl
// =============================================================
// pickCurrentUniacid
// =============================================================
/**
* 从两个候选 uniacid 源中选出"系统当前 uniacid"。
*
* 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致):
* pickCurrentUniacid(
* uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入
* Config.uniacid, // 编译期兜底
* )
*
* 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。
* 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。
*
* ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置
* uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现
* "守卫用 A 真值, http 请求用 B 真值"的不一致。
*
* @param {*} primaryValue 第一候选(运行时持久化值)
* @param {*} secondaryValue 第二候选(编译期兜底)
* @returns {number} 选出的 uniacid; 都无效时返回 0
*/
export function pickCurrentUniacid(primaryValue, secondaryValue) {
const a = parseInt(primaryValue, 10);
if (a) return a; // 非 0 真值
const b = parseInt(secondaryValue, 10);
if (b) return b;
return 0;
}
// =============================================================
// hasStaleUniacidInUrl
// =============================================================
/**
* 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。
*
* 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。
* - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致,
* 说明这份装修数据可能是历史遗留或别的租户的(脏数据),
* 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
* - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
*
* 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经
* 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。
*
* 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。
*
* @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值)
* @param {string|number|null} currentUniacid 系统当前的 uniacid
* (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做)
* @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上)
*/
export function hasStaleUniacidInUrl(wap_url, currentUniacid) {
if (!wap_url || typeof wap_url !== 'string') return false;
if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false;
const m = wap_url.match(/[?&]uniacid=([^&#]*)/i);
if (!m) return false;
let urlUniacid;
try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; }
const cur = String(currentUniacid);
const a = parseInt(urlUniacid, 10);
const b = parseInt(cur, 10);
// 两侧都能 parse 成数字时按数字比; 否则按字符串比
if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b;
return urlUniacid !== cur;
}
// =============================================================
// appendQueryParams
// =============================================================
/**
* 给 URL 追加 query 参数(已存在的 key 会被覆盖)
*
* @param {string} url
* @param {Object} params
* @returns {string}
*/
export function appendQueryParams(url, params) {
if (!url) return '';
if (!params) return url;
const hashIndex = url.indexOf('#');
let hash = '';
let targetUrl = url;
if (hashIndex !== -1) {
hash = url.substring(hashIndex);
targetUrl = url.substring(0, hashIndex);
}
const separator = targetUrl.includes('?') ? '&' : '?';
const queryString = Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null)
// 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]"
.map(([key, value]) => `${key}=${_toParamValueString(value)}`)
.join('&');
return targetUrl + separator + queryString + hash;
}
// =============================================================
// 跨平台检测 / 编码 / 跳转
// =============================================================
/**
* 平台检测 — 4 个目标平台:
* - 'h5' 浏览器
* - 'mp-weixin' 微信小程序
* - 'mp-harmony' 鸿蒙元服务
* - 'quickapp' 华为快应用 (实际是 H5 嵌入快应用 Webview)
*
* 测试覆盖: util.js 在 import 后,可在 default export 上设 __platform 强制指定
*/
export function getPlatform() {
// 1) 测试覆盖: 通过模块级 _testPlatform 注入
if (typeof globalThis !== 'undefined' && globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__) {
return globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__;
}
// 2) 编译时变量
// #ifdef MP-WEIXIN
return 'mp-weixin'
// #endif
// #ifdef MP-HARMONY
return 'mp-harmony'
// #endif
// 3) 运行时
if (navigator?.userAgent?.includes('mode-quickapp')) { // H5网页,如何判断自己在华为快应用的Webview中
return 'quickapp'
}
// #ifdef H5
try {
var ua = navigator?.userAgent;
if (ua) {
// 华为快应用内嵌 WebView 的 UA 关键字:HarmonyQuickApp / HuaweiQuickApp / HQABridge / QuickApp
const isQuickapp = /HarmonyQuickApp|HuaweiQuickApp|HQABridge|QuickApp/i.test(ua);
if (isQuickapp) {
return 'quickapp'
}
}
} catch (e) {}
// #endif
if (typeof process !== 'undefined' && process.env && process.env.UNI_PLATFORM) {
return process.env.UNI_PLATFORM;
}
if (typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof uni === 'undefined') {
return 'h5';
}
if (typeof uni !== 'undefined' && typeof uni.getSystemInfoSync === 'function') {
try {
const info = uni?.getSystemInfoSync();
const p = (info && info.platform) || '';
if (/harmony|ohos/i.test(p)) return 'mp-harmony';
if (info.mp === true || /miniprogram/i.test(info.appName || '')) return 'mp-weixin';
if (/quickapp|huawei/i.test(p)) return 'quickapp';
return 'h5';
} catch (e) { /* ignore */ }
}
// 默认返回
return 'h5';
}
/**
* 判断是否是在华为快应用的WebView中
* @returns
*/
export function isHarmonyQuickAppWebview() {
return getPlatform() === 'quickapp'
}
/**
* 是否需要 JS 侧主动 encodeURIComponent
* 4 平台都需要(true)
*/
export function shouldEncodeUrl() {
return true;
}
/**
* 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
*
* 解决问题:
* - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
* 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
* 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
* - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
* 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
* - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
*
* @param {string} s 已 decode roundtrip 后的原文
* @returns {string}
*/
function _encodeUrlDelimiters(s) {
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
}
export function _safeEncodeForPlatform(v) {
const s = _toParamValueString(v);
// decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
let once = s;
try {
if (/%[0-9a-fA-F]{2}/.test(s)) {
once = decodeURIComponent(s);
}
} catch (e) {
once = s;
}
const platform = getPlatform();
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断,webview 打不开)
if (['mp-weixin','mp-harmony'].includes(platform)) {
return _encodeUrlDelimiters(once);
}
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
return encodeURIComponent(once);
}
/**
* 跨平台跳转 API 调度
*
* @param {Object} args
* @param {string} args.to
* @param {string} [args.mode='navigateTo']
* @param {string} [args.platform] 不传则自动 getPlatform()
* @returns {{ url: string, mode: string, api: string }}
*/
export function _navigateTo({ to, mode, platform } = {}) {
const p = platform || getPlatform();
const m = mode || 'navigateTo';
// H5: 用 window.location
if (p === 'h5') {
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
console.warn('[navigate] H5 环境下没有 window.location');
return { url: to, mode: m, api: 'noop' };
}
try {
if (m === 'reLaunch' || m === 'redirectTo') {
window.location.replace(to);
} else {
window.location.href = to;
}
} catch (e) {
console.warn('[navigate] H5 跳转失败:', e);
}
return { url: to, mode: m, api: 'window.location' };
}
// 微信 / 鸿蒙 / 快应用: 走对应的 uni API
// (华为快应用实际是 H5 嵌入快应用 Webview, uni 全局可用)
if (typeof uni === 'undefined') {
console.warn('[navigate] 非 H5 环境下没有 uni 全局');
return { url: to, mode: m, api: 'noop' };
}
let api = 'navigateTo';
switch (m) {
case 'tabbar':
case 'switchTab':
// 鸿蒙元服务: uni.switchTab 静默失败, 用 reLaunch 兜底
// (微信 / 快应用: 正常用 uni.switchTab)
if (p === 'mp-harmony') {
uni.reLaunch({ url: to });
api = 'reLaunch';
} else {
uni.switchTab({ url: to });
api = 'switchTab';
}
break;
case 'redirectTo':
uni.redirectTo({ url: to });
api = 'redirectTo';
break;
case 'reLaunch':
uni.reLaunch({ url: to });
api = 'reLaunch';
break;
case 'navigateTo':
default:
uni.navigateTo({ url: to });
api = 'navigateTo';
break;
}
return { url: to, mode: m, api };
}
// =============================================================
// 兼容: 把所有函数打包到一个对象(用于 util.js 挂载到 default export)
// =============================================================
/**
* 把所有跳转相关函数打包到一个对象, util.js 把它挂载到 default export
* 上, 保持 this.$util.cleanParamUrls / this.$util.redirectTo 等 API 兼容
*
* @param {Object} [opts]
* @param {string} [opts.platform] 强制指定平台(测试用)
* @returns {Object}
*/
export function createNavigateApi(opts = {}) {
if (opts.platform) {
// 测试用: 注入到 globalThis,getPlatform 会优先读
if (typeof globalThis !== 'undefined') {
globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = opts.platform;
}
}
return {
getPlatform,
shouldEncodeUrl,
_safeEncodeForPlatform,
_navigateTo,
cleanParamUrls,
normalizeUrl,
appendQueryParams,
extractInternalAppPath,
parsePageParams,
resolveLinkUrl,
decodeEntity,
};
}

649
common/js/util-tabbar.js Normal file
View File

@@ -0,0 +1,649 @@
/**
* util-tabbar.js
*
* TabBar / 子包路径相关工具集 — 从 util.js 拆分出来
*
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 的 tabBar 命中判断):
* - resolvePageUrl : 根据 page_key + params 解析得到实际跳转 URL
* - isPageInRouteList : 判断当前路由是否命中 page_routes 列表
* - resolveLinkUrl : 解析 link 对象得到最终 URL(合并 wap_url query 进 page_params)
* - systemTabBarList : 当前最新的 tabBar.list 配置
* - adaptSubpackageUrl : 子包路径前缀映射(/pages/order/ → /pages_order/)
* - _normalizePath : 归一化任意路径(私有,内部用)
* - isTabBarRootPage : 判断给定路径是否是 TabBar 根页面
* - isCurrentPageTabBarRoot : 判断**当前**页面是否是 TabBar 根页面(依赖 getCurrentPages/store)
* - checkTabBarActive : 检查 tabBar 是否激活
*
* 设计原则:
* 1. 不依赖 util.js,避免循环依赖
* 2. 纯函数为主(isCurrentPageTabBarRoot 除外,它依赖 getCurrentPages 和 store,
* 业务方在调用时手动注入 store 即可)
* 3. PAGE_KEY_MAP / NO_TAB_PAGES / PAGE_TAB_MAP / TAB_BAR_KEYS 仍然在 util.js(业务配置)
* util-tabbar.js 接受外部传入,提高复用性
*/
import { _toParamValueString } from './util-navigate.js';
// =============================================================
// PAGE_KEY → 实际 URL 解析
// =============================================================
/**
* 根据 page_key + params 解析得到实际跳转 URL
* @param {string} page_key 页面语义常量, 如 PAGE_GOODS_LIST
* @param {Object} params 页面参数(JSON 对象)
* @param {Object} [pageKeyMap={}] page_key → 实际路径的映射表(由调用方注入,通常是 PAGE_KEY_MAP)
* @returns {string|null} 实际 URL(已拼接参数)或 null(未匹配)
*/
export function resolvePageUrl(page_key, params = {}, pageKeyMap = {}) {
if (!page_key || !pageKeyMap[page_key]) {
return null;
}
let url = pageKeyMap[page_key];
if (params && typeof params === 'object' && Object.keys(params).length > 0) {
const parts = [];
Object.keys(params).forEach(function (key) {
const value = params[key];
if (value === null || value === undefined || value === '') return;
// 对象/数组走 JSON.stringify, 避免 String({}) 退化成 "[object Object]"
// 消费端 resolveLinkUrl 已用 JSON.parse 还原
const encodedValue = _toParamValueString(value);
// H5: 浏览器不自动编码, 需要手动 encodeURIComponent
// 小程序 / App: uni.navigateTo 会自动编码, 这里不能再 encode, 否则双重编码
// #ifdef H5
parts.push(key + '=' + encodeURIComponent(encodedValue));
// #endif
// #ifndef H5
parts.push(key + '=' + encodedValue);
// #endif
});
if (parts.length > 0) {
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + parts.join('&');
}
}
return url;
}
/**
* 判断当前路由是否命中 page_routes 列表
* 支持 3 种写法, 可混合使用:
* 1) PAGE_KEY_MAP 的 key → 如 "PAGE_INDEX"
* 2) 实际页面路径 → 如 "/pages/index/index"
* 3) 通配符路径 → 如 "/pages_tool/*" 或 "PAGE_ORDER_*"
*
* @param {string} currentRoute 当前页面路径(会自动补 / 前缀)
* @param {string[]} routeList 后台配置的 page_routes 数组
* @param {Object} [pageKeyMap={}] PAGE_KEY_MAP
* @returns {boolean}
*/
export function isPageInRouteList(currentRoute, routeList, pageKeyMap = {}) {
if (!currentRoute || !routeList || !Array.isArray(routeList) || routeList.length === 0) {
return false;
}
const route = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
for (const item of routeList) {
if (!item || typeof item !== 'string') continue;
const entry = item.trim();
if (!entry) continue;
// 1) 通配符 /pages_tool/* 或 PAGE_ORDER_*
if (entry.endsWith('/*')) {
const prefix = entry.slice(0, -1);
if (route.startsWith(prefix)) return true;
continue;
}
if (entry.endsWith('_*') && pageKeyMap) {
const keyPrefix = entry.slice(0, -1);
const matchedPaths = [];
for (const k of Object.keys(pageKeyMap)) {
if (k.startsWith(keyPrefix) && pageKeyMap[k]) {
matchedPaths.push(pageKeyMap[k]);
}
}
for (const p of matchedPaths) {
const pn = p.startsWith('/') ? p : '/' + p;
const dirPrefix = pn.substring(0, pn.lastIndexOf('/') + 1);
if (route.startsWith(dirPrefix)) return true;
if (route === pn) return true;
}
continue;
}
// 2) PAGE_KEY 名称
if (pageKeyMap && pageKeyMap[entry]) {
let mapped = pageKeyMap[entry];
mapped = mapped.startsWith('/') ? mapped : '/' + mapped;
if (route === mapped) return true;
continue;
}
// 3) 实际路径
if (entry.startsWith('/')) {
if (route === entry) return true;
continue;
}
}
return false;
}
// =============================================================
// resolveLinkUrl
// =============================================================
/**
* 解析 link 对象得到最终可跳转的 URL
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @param {Object} [opts]
* @param {Object} [opts.pageKeyMap={}] PAGE_KEY_MAP
* @param {Function} [opts.parsePageParams] page_params 解析函数(默认用 util-navigate 的)
* @param {Function} [opts.resolvePageUrlFn] page_key → URL 函数(默认用本文件内)
* @param {Function} [opts.decodeEntity] HTML 实体解码函数
* @returns {string|null}
*/
export function resolveLinkUrl(link, opts = {}) {
const {
pageKeyMap = {},
parsePageParams = null,
resolvePageUrlFn = null,
decodeEntity = null,
} = opts;
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
if (link.wap_url) {
const _decode = decodeEntity || ((s) => s);
const decodedWap = _decode(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach((kv) => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
// 解析 page_params (可能是字符串或对象) — 走统一 helper
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const pageParams = _parse(link.page_params);
// 合并: page_params 覆盖 urlParams (新机制优先)
link.page_params = Object.assign({}, urlParams, pageParams);
// 把 link.wap_url 改为不含 query 的 path
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const _resolvePageUrl = resolvePageUrlFn || ((key, params) => resolvePageUrl(key, params, pageKeyMap));
const params = _parse(link.page_params);
const url = _resolvePageUrl(link.page_key, params);
if (url) return url;
}
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
}
// =============================================================
// TabBar 列表与适配
// =============================================================
/**
* 当前最新的 tabBar.list
* 注意: 该函数需要业务方传入 4 个常量(INDEX/CATEGORY/CONTACT/MEMBER)
* 通常由 util.js 在 import 后调用 _setSystemTabBarList 注入
*/
export function makeSystemTabBarList(indexUrl, categoryUrl, contactUrl, memberUrl) {
return [indexUrl, categoryUrl, contactUrl, memberUrl];
}
/**
* 适配子包路径
* 把老路径 /pages/xxx/ 映射到新路径 /pages_xxx/
*
* @param {string} url
* @returns {string}
*/
export function adaptSubpackageUrl(url) {
if (!url) return url;
// 定义前缀映射表(key=旧前缀, value=新分包前缀)
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map((key) =>
key.replace(/\//g, '\\/').replace(/\*/g, '.*'),
).join('|') + ')',
);
function replacePrefix(str) {
return str.replace(regex, (match) => {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
// 兜底: 把 3 个及以上的连续 `/` 收敛为 2 个, 只动 path 部分
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// =============================================================
// TabBar 命中判断(私有辅助)
// =============================================================
/**
* 归一化任意路径: 确保以 '/' 开头, 去除 query, 适配子包
* (私有, 仅本模块内部使用)
*/
function _normalizePath(path) {
if (!path) return '';
const normalized = path.startsWith('/') ? path : '/' + path;
return adaptSubpackageUrl(normalized).split('?')[0];
}
/**
* 从归一化路径提取分包名(parts[1])
* (私有, 仅本模块内部使用)
*/
function _getPackageName(normalizedPath) {
const parts = normalizedPath.split('/');
return parts[1] || '';
}
/**
* 解析"当前分包的入口 tab URL"
*
* 把 diy-bottom-nav.vue#updateCurrentRoute 里的查找逻辑抽成纯函数,
* 方便单元测试覆盖所有跳转场景, 同时让组件代码变薄.
*
* 查找优先级 (2026-07-16 重构, 对齐 iOS HIG):
* 1) 当前页面精确命中某个 tab 根页面 → 它就是入口 (tab→tab 跳转)
* 2) 同包内导航 (上一包 == 当前包) → 用当前包的第一个 tab
* 例: /pages_goods/list → /pages_goods/detail → CATEGORY 高亮
* 3) 跨包导航 (上一包 != 当前包) → 历史栈倒序查找最近一次访问的 TabBar 页
* 例: home → diy → HOME 高亮 (从哪个 tab 出发, 非 tab 页就归属哪个 tab)
* 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
* 5) 深链无历史 → 返回 null (iOS HIG: 无来源时不点亮)
*
* 与 checkTabBarActive 的关系:
* - resolveEntryTab 写入 packageEntryTab[currentPackage]
* - checkTabBarActive 通过 _findMatchingTabUrl 读取
* - 两者都不会做"无历史时的同分包兜底", 保证 iOS HIG 行为
*
* @param {Object} opts
* @param {string} opts.currentPageRoute 当前页面路由 (已 _normalizeRoute, 不含 query)
* @param {Object} opts.tabBarList tabBar 配置对象 ($store.state.tabBarList)
* @param {string[]} [opts.historyRoutes=[]] 历史栈路由列表 (不含 query, 不含当前页;
* 已 _normalizeRoute 但未 adaptSubpackageUrl, 函数内部会适配)
* @param {Set<string>} [opts.noTabPages] NO_TAB_PAGES 集合 (含 login/pay/AI 等全屏流程页)
* 这些页视为"modal", 不参与包归属判定
* @returns {string|null} 入口 tab URL (已适配子包路径、不含 query) 或 null
*/
export function resolveEntryTab(opts = {}) {
const {
currentPageRoute = '',
tabBarList = null,
historyRoutes = [],
noTabPages = null,
} = opts;
if (!currentPageRoute || !tabBarList || !Array.isArray(tabBarList.list)) {
return null;
}
const tabUrls = tabBarList.list
.filter((item) => item && item.link && item.link.wap_url)
.map((item) => adaptSubpackageUrl(item.link.wap_url).split('?')[0]);
if (tabUrls.length === 0) return null;
const normalizedCurrent = currentPageRoute.split('?')[0];
const currentPackageName = _getPackageName(normalizedCurrent);
// 1) 当前页面精确命中某个 tab → 它就是入口
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 工具: 判定一个路由是否"参与归属判定"
// 跳过 null/空 + NO_TAB_PAGES (login/pay/AI 等 modal 页, 不算中间步骤)
const isSkippable = (route) => {
if (!route) return true;
if (noTabPages && typeof noTabPages.has === 'function') {
const normalized = adaptSubpackageUrl(route).split('?')[0];
if (noTabPages.has(normalized)) return true;
}
return false;
};
// 找出"最近的上一页"属于哪个包 (跳过 null/空 + NO_TAB_PAGES)
let lastPackageName = '';
let hasPrevPage = false;
if (Array.isArray(historyRoutes) && historyRoutes.length > 0) {
for (let i = historyRoutes.length - 1; i >= 0; i--) {
const route = historyRoutes[i];
if (isSkippable(route)) continue;
lastPackageName = _getPackageName(adaptSubpackageUrl(route).split('?')[0]);
hasPrevPage = true;
break;
}
}
const isSamePackage = hasPrevPage && lastPackageName === currentPackageName;
const isCrossPackage = hasPrevPage && lastPackageName !== currentPackageName;
// 2) 同包内导航: 用当前包的第一个 tab
// 业务约定: 包内"主入口" = tabBarList 中该包出现的第一个 tab
// 例: pages_goods 有 [CATEGORY, CART] 两个 tab, 用户在 goods 频道内任何页面都归 CATEGORY
// 理由: CATEGORY 是商品频道的入口, 比 CART (特定子场景) 更"通用", 视觉一致
// 注意: 业务侧 tab 配置顺序很关键, 若有 pages_goods 出现 3+ tab, 主入口必须是业务上正确的那个
if (isSamePackage) {
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
if (samePackage) return samePackage;
}
// 3) 跨包导航: 历史栈倒序查找最近一次访问的 TabBar 页 → 来源 tab
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
// 注意: 跳过 NO_TAB_PAGES, 因为 login/pay 等是 modal, 不是真实的导航步骤
if (isCrossPackage) {
for (let i = historyRoutes.length - 1; i >= 0; i--) {
const route = historyRoutes[i];
if (isSkippable(route)) continue;
const normalized = adaptSubpackageUrl(route).split('?')[0];
const exactTab = tabUrls.find((url) => url === normalized);
if (exactTab) return exactTab;
}
// 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
if (samePackage) return samePackage;
}
// 5) 深链无历史 / 跳过 NO_TAB_PAGES 后没找到 → 返回 null
return null;
}
/**
* 给定当前页面路径 + tab 配置, 返回当前页面所属的 tab URL
* 优先级: 精确匹配 > packageEntryTab
*
* 注意 (2026-07-16): 旧版本在 packageEntryTab 没命中时会回退到"同分包第一个 tab",
* 这导致深链冷启动时 /pages_tool/* 页被错误标成"我的"。
* 现在改为"找不到就返回 null", 后续 checkTabBarActive 也不会再兜底,
* 行为对齐 iOS HIG (深链无历史时, TabBar 不点亮)。
*
* (私有)
*/
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return null;
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.map((item) => _normalizePath(item.link.wap_url));
const normalizedCurrent = _normalizePath(currentPagePath);
// 1) 精确匹配 → 当前页面就是 tab 页面
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 2) 通过 packageEntryTab(由 updateCurrentRoute/redirectTo 写入)
const packageName = _getPackageName(normalizedCurrent);
if (packageEntryTab && typeof packageEntryTab === 'object' && packageEntryTab[packageName]) {
const entryTab = packageEntryTab[packageName];
const normalizedEntry = _normalizePath(entryTab);
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
}
// 3) 不再做"同分包兜底" — 找不到就返回 null
// 深链冷启动场景下无历史可参考, iOS HIG 推荐 TabBar 不点亮
return null;
}
/**
* 通配符路径匹配 — 支持 '/pages_tool/member/*' 形式
*
* 规则:
* - pattern 必须以 '/*' 结尾
* - '*' 匹配"前缀之后的至少一个字符段"
* - 例: '/pages_tool/member/*' 匹配 '/pages_tool/member/info' 但不匹配 '/pages_tool/member'
*
* @param {string} path 当前路径 (已 _normalizePath)
* @param {string} pattern 通配符 pattern
* @returns {boolean}
*/
function _matchWildcardPath(path, pattern) {
if (!path || !pattern) return false;
if (!pattern.endsWith('/*')) return false;
const prefix = pattern.slice(0, -2); // 去掉 '/*'
// 必须有至少一个字符在 '/*' 之后, 即 path 比 prefix 长, 且以 prefix + '/' 开头
return path.length > prefix.length + 1 && path.startsWith(prefix + '/');
}
/**
* 在 PAGE_TAB_MAP (精确) + PAGE_TAB_WILDCARDS (通配) 中查找当前页面对应的 tab
*
* 优先级: 精确匹配 > 通配符匹配
* 返回值可以是:
* - tab URL 字符串 → 命中, 高亮该 tab
* - null → 显式声明"无 tab", 一定不高亮
* - undefined → 未声明, 调用方走兜底逻辑 (history-walk / 无 tab)
*
* @param {string} currentPath 当前页面路径 (已 _normalizePath)
* @param {Object} pageTabMap 精确绑定表 (PAGE_TAB_MAP)
* @param {Array<{pattern: string, tab: string}>} pageTabWildcards 通配符列表 (PAGE_TAB_WILDCARDS)
* @returns {string|null|undefined}
*/
export function lookupPageTabMap(currentPath, pageTabMap, pageTabWildcards) {
if (!currentPath) return undefined;
// 1) 精确匹配
if (pageTabMap && Object.prototype.hasOwnProperty.call(pageTabMap, currentPath)) {
return pageTabMap[currentPath];
}
// 2) 通配符匹配
if (Array.isArray(pageTabWildcards)) {
for (const entry of pageTabWildcards) {
if (entry && entry.pattern && _matchWildcardPath(currentPath, entry.pattern)) {
return entry.tab;
}
}
}
return undefined;
}
/**
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
*
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
* @returns {boolean} 如果是 TabBar 根页面返回 true
*/
export function isTabBarRootPage(pagePath, tabBarList) {
if (!pagePath) return false;
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return false;
const normalizedCurrent = _normalizePath(pagePath);
if (!normalizedCurrent) return false;
return tabBarList.list.some((item) => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = _normalizePath(item.link.wap_url);
return normalizedTab === normalizedCurrent;
});
}
/**
* 判断**当前**页面是否是 TabBar 根页面
* 封装了 getCurrentPages() + isTabBarRootPage 的组合, 供业务组件在弹窗/操作前判断
*
* 调用方需要注入 getCurrentPages 和 store:
* isCurrentPageTabBarRoot({
* getCurrentPages: typeof getCurrentPages === 'function' ? getCurrentPages : null,
* store,
* tabBarList: null, // 不传则从 store.state.tabBarList 取
* })
*
* @param {Object} [opts={}]
* @param {Function} [opts.getCurrentPages]
* @param {Object} [opts.store]
* @param {Object} [opts.tabBarList]
* @returns {boolean}
*/
export function isCurrentPageTabBarRoot(opts = {}) {
try {
const gcp = opts.getCurrentPages || (typeof getCurrentPages === 'function' ? getCurrentPages : null);
if (!gcp) return false;
const pages = gcp();
if (!pages || !pages.length) return false;
const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '');
if (!currentRoute) return false;
const tabBarList = opts.tabBarList
|| (opts.store && opts.store.state ? opts.store.state.tabBarList : null);
return isTabBarRootPage(currentRoute, tabBarList);
} catch (e) {
return false;
}
}
/**
* 检查 tabBar 是否激活
*
* @param {string} linkUrl 链接路径 /pages/goods/category
* @param {string} currentPageRoute 当前页面路径 /pages_goods/category
* @param {Object} tabBarList tabBar 配置对象, 用于动态判断
* @param {Object} packageEntryTab 追踪每个分包的入口 Tab { 'pages_goods': '/pages_goods/category' }
* @param {Object} [currentPageQuery={}] 当前页面 query 参数, 用于读取 from_tab 来源记忆
* @param {Object} [opts={}]
* @param {Set} [opts.noTabPages] 独立流程页集合(NO_TAB_PAGES)
* @param {Object} [opts.pageTabMap] 页面级 Tab 精确绑定表(PAGE_TAB_MAP)
* @param {Array<{pattern: string, tab: string}>} [opts.pageTabWildcards=[]] 页面级 Tab 通配符表(PAGE_TAB_WILDCARDS)
* @returns {boolean} 是否激活
*/
export function checkTabBarActive(
linkUrl,
currentPageRoute,
tabBarList,
packageEntryTab,
currentPageQuery = {},
opts = {},
) {
const { noTabPages = null, pageTabMap = {}, pageTabWildcards = [] } = opts;
// __TABBAR_DEBUG__: 开启后打印每一步决策, 用于排查"为什么我的 tab 不亮"
// 开启方式 (任选一种):
// - 全局: window.__TABBAR_DEBUG__ = true
// - 本次: opts.debug = true
const DEBUG = (typeof globalThis !== 'undefined' && globalThis.__TABBAR_DEBUG__)
|| opts.debug === true;
const _log = DEBUG ? (...a) => console.log('[TABBAR]', ...a) : () => {};
_log('checkTabBarActive called', { linkUrl, currentPageRoute, hasTabBarList: !!tabBarList });
// 1) 归一化 linkUrl: 统一前导 '/' + 子包路径适配 + 去除 query
const normalizedLink = _normalizePath(linkUrl);
const normalizedCurrent = _normalizePath(currentPageRoute);
if (!normalizedLink) {
_log('step 1: no linkUrl → false');
return false;
}
// 2) 精确匹配: 当前页面就是 linkUrl 指向的页面
if (normalizedLink === normalizedCurrent) {
_log('step 2: exact match → true', { link: normalizedLink });
return true;
}
// 3) 独立流程页: 不点亮任何 Tab
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) {
_log('step 3: NO_TAB_PAGES hit → false', { current: normalizedCurrent });
return false;
}
// 4) 若传入 tabBarList, 使用动态判断逻辑
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.map((item) => _normalizePath(item.link.wap_url));
// 检查当前 linkUrl 是否是合法的 tab URL
if (!tabUrls.includes(normalizedLink)) {
_log('step 4: link not in tabUrls → false', { link: normalizedLink });
return false;
}
// 5) 页面级 Tab 绑定 (精确 + 通配符)
// 返回值可能是: tab URL | null (显式无 tab) | undefined (未声明)
const mappedTabUrl = lookupPageTabMap(normalizedCurrent, pageTabMap, pageTabWildcards);
if (mappedTabUrl !== undefined) {
_log('step 5: pageTabMap hit', { current: normalizedCurrent, mapped: mappedTabUrl });
// null = 显式声明"无 tab", 字符串 = 该 tab 高亮
return mappedTabUrl !== null && normalizedLink === _normalizePath(mappedTabUrl);
}
_log('step 5: pageTabMap miss → 进入 step 6/7');
// 6) 来源记忆兜底: 从哪个 Tab 跳转过来, 就保持那个 Tab 高亮
const fromTab = currentPageQuery.from_tab;
if (fromTab) {
const normalizedFromTab = _normalizePath(fromTab);
if (tabUrls.includes(normalizedFromTab)) {
const hit = normalizedLink === normalizedFromTab;
_log('step 6: from_tab query', { fromTab, hit });
return hit;
}
}
_log('step 6: no from_tab');
// 7) 分包入口追踪: 通过 updateCurrentRoute/resolveEntryTab 写入的入口 tab
// 注意: _findMatchingTabUrl 不会做"同分包兜底",找不到就返回 null
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
if (matchingTabUrl) {
const hit = normalizedLink === matchingTabUrl;
_log('step 7: packageEntryTab', { matchingTabUrl, hit });
return hit;
}
_log('step 7: packageEntryTab miss → step 8');
// 8) 不再做"同分包兜底" — 找不到任何来源 tab 时不高亮
// 深链冷启动 / 扫码进非 tab 页: TabBar 全不亮, 对齐 iOS HIG
_log('step 8: no source → false (iOS HIG)');
return false;
}
return false;
}

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -55,7 +55,6 @@ let Weixin = function () {
typeof callback == 'function' && callback(res);
},
fail: (res) => {
console.log('获取收货地址 fail',res);
alert(JSON.stringify(res))
}
});

View File

@@ -1,6 +1,8 @@
/**
* 企业微信JS-SDK调用
*/
import Lang from './lang.js';
let WxWork = function () {
// 企业微信JS-SDK
this.wxwork = null;
@@ -23,7 +25,7 @@ let WxWork = function () {
// H5环境下的企业微信
this.wxwork = WWOpenData;
} else {
console.error('企业微信JS-SDK未加载');
console.error(Lang.lang('wxworkJssdk.notLoaded'));
return false;
}
@@ -56,8 +58,8 @@ let WxWork = function () {
*/
this.addContact = function (params, success, fail) {
if (!this.wxwork) {
console.error('企业微信JS-SDK未初始化');
if (fail) fail('企业微信JS-SDK未初始化');
console.error(Lang.lang('wxworkJssdk.notInitialized'));
if (fail) fail(Lang.lang('wxworkJssdk.notInitialized'));
return;
}
@@ -69,7 +71,7 @@ let WxWork = function () {
if (res.err_msg === 'openUserProfile:ok') {
if (success) success(res);
} else {
console.error('打开用户资料失败:', res);
console.error(Lang.lang('wxworkJssdk.openUserProfileFailed'), res);
if (fail) fail(res.err_msg);
}
});
@@ -87,8 +89,8 @@ let WxWork = function () {
*/
this.openCustomerService = function (params, success, fail) {
if (!this.wxwork) {
console.error('企业微信JS-SDK未初始化');
if (fail) fail('企业微信JS-SDK未初始化');
console.error(Lang.lang('wxworkJssdk.notInitialized'));
if (fail) fail(Lang.lang('wxworkJssdk.notInitialized'));
return;
}

73
common/mixin/i18n-lazy.js Normal file
View File

@@ -0,0 +1,73 @@
/**
* 延迟初始化 i18n mixin
*
* 问题背景:
* 微信小程序/鸿蒙元服务环境下, main.js 同步 $mount() 会导致组件 data() 在
* Lang.boot() 完成前求值. 此时 $lang() 在 remote-only 模式下返回空串,
* 把空串缓存到 data 字段后, 后续 boot 完成的 $forceUpdate 也无法让它重算.
*
* 解决思路:
* data() 中不能调 $lang(), 否则空串被永久缓存.
* 模板只读 this.i18n.xxx(始终是字符串, 初值为空).
* 需要显示前, 显式调一次 this.prepareI18n() — 此时 boot 必然已完成.
*
* 用法:
*
* // 1) 在 data() 中:
* data() {
* return {
* i18n: {
* title: '',
* descPrefix: '',
* privacyContractName: '',
* },
* // 准备文案时需要的动态上下文(可选)
* platformContractName: '',
* };
* }
*
* // 2) 在 methods 中实现 i18nPrepareFn, 返回一个新对象用于填充 this.i18n
* // 其中 key 用反引号 `` `your.module.title` `` 这类命名代表"业务模块的真实 key",
* // 请用业务组件实际使用的 key 替换(例如 'components.privacyPopup.title')
* methods: {
* i18nPrepareFn() {
* return {
* title: this.$lang(`your.module.title`),
* descPrefix: this.$lang('components.privacyPopup.descPrefix', { appName: '本应用' }),
* privacyContractName: this.platformContractName || this.$lang(`your.module.defaultContract`),
* };
* },
* }
*
* // 3) 在需要显示前调 this.prepareI18n()(来自 mixin)
* // 此时 Lang.boot() 必然已完成, $lang() 返回真实翻译
* popUp() {
* this.prepareI18n();
* this.showPop = true;
* }
*
* 模板中只读 i18n.xxx:
* ```html
* <view>{{ i18n.title }}</view>
* <view>{{ i18n.descPrefix }}</view>
* ```
*/
export default {
methods: {
/**
* 一次性计算所有 i18n 文案并写入 this.i18n
* 关键: 调用时机必须是 boot 完成之后(否则会缓存空串)
* 模板中读 this.i18n.xxx 永远拿到字符串
*
* 业务组件需要实现 i18nPrepareFn() 方法,
* 返回一个对象, key 与 data().i18n 字段一一对应
*/
prepareI18n() {
if (typeof this.i18nPrepareFn !== 'function') {
console.warn('[i18n-lazy] i18nPrepareFn() not implemented; this.i18n will not be filled');
return;
}
this.i18n = this.i18nPrepareFn();
},
},
};

View File

@@ -0,0 +1,38 @@
/**
* 微信小程序图片长按识别二维码 mixin
* 自动为页面中的所有 image 组件添加 show-menu-by-longpress 属性
*/
export default {
data() {
return {
// 需要排除的图片类名(不需要长按识别的)
excludeImageClass: ['no-longpress', 'swiper-image', 'banner-image']
}
},
mounted() {
// #ifdef MP-WEIXIN
this.$nextTick(() => {
this.addLongpressToImages()
})
// #endif
},
methods: {
/**
* 为页面中的所有 image 添加长按识别支持
*/
addLongpressToImages() {
// 获取当前页面所有 image 组件
const images = document.querySelectorAll && document.querySelectorAll('image')
if (images) {
images.forEach(img => {
// 检查是否需要排除
const className = img.className || ''
const shouldExclude = this.excludeImageClass.some(cls => className.includes(cls))
if (!shouldExclude) {
img.setAttribute('show-menu-by-longpress', 'true')
}
})
}
}
}
}

View File

@@ -0,0 +1,143 @@
// 公共微信视频号样式
// CSS 变量
:root {
// 尺寸变量
--channel-play-btn-size: 80rpx;
--channel-play-btn-small-size: 60rpx;
--channel-play-btn-icon-size: 40rpx;
--channel-play-btn-icon-small-size: 30rpx;
--channel-border-radius: 12rpx;
--channel-stats-padding: 10rpx;
--channel-info-wrap-padding: 10rpx 0;
// 字体变量
--channel-name-font-size: 28rpx;
--video-title-font-size: 28rpx;
--video-title-small-font-size: 24rpx;
--video-stats-font-size: 24rpx;
--channel-stats-font-size: 20rpx;
// 颜色变量
--channel-name-color: #333;
--video-title-color: #333;
--video-title-small-color: #666;
--video-stats-color: #999;
--channel-stats-color: #fff;
--channel-border-color: #f0f0f0;
--channel-play-btn-bg: rgba(0, 0, 0, 0.4);
--channel-stats-bg: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
// 间距变量
--channel-name-margin-bottom: 4rpx;
--video-title-margin-bottom: 8rpx;
}
// 响应式设计
@media (max-width: 375px) {
:root {
--channel-play-btn-size: 70rpx;
--channel-play-btn-small-size: 50rpx;
--channel-play-btn-icon-size: 35rpx;
--channel-play-btn-icon-small-size: 25rpx;
--channel-name-font-size: 24rpx;
--video-title-font-size: 24rpx;
}
}
@media (min-width: 750px) {
:root {
--channel-play-btn-size: 90rpx;
--channel-play-btn-small-size: 70rpx;
--channel-play-btn-icon-size: 45rpx;
--channel-play-btn-icon-small-size: 35rpx;
--channel-name-font-size: 32rpx;
--video-title-font-size: 32rpx;
}
}
// 播放按钮样式
.channel-play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
width: var(--channel-play-btn-size);
height: var(--channel-play-btn-size);
background-color: var(--channel-play-btn-bg);
border-radius: 50%;
.play-icon {
width: var(--channel-play-btn-icon-size);
height: var(--channel-play-btn-icon-size);
}
}
// 小尺寸播放按钮(用于列表)
.channel-play-btn.small {
width: var(--channel-play-btn-small-size);
height: var(--channel-play-btn-small-size);
.play-icon {
width: var(--channel-play-btn-icon-small-size);
height: var(--channel-play-btn-icon-small-size);
}
}
// 视频统计信息
.channel-stats {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--channel-stats-bg);
padding: var(--channel-stats-padding);
color: var(--channel-stats-color);
font-size: var(--channel-stats-font-size);
}
// 视频信息容器
.channel-info-wrap {
display: flex;
flex-direction: column;
flex: 1;
padding: var(--channel-info-wrap-padding);
.channel-name {
margin-bottom: var(--channel-name-margin-bottom);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: var(--channel-name-font-size);
font-weight: 500;
color: var(--channel-name-color);
}
.video-title {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: var(--video-title-small-font-size);
color: var(--video-title-small-color);
}
}
// 视频标题
.video-title {
font-size: var(--video-title-font-size);
font-weight: 500;
color: var(--video-title-color);
margin-bottom: var(--video-title-margin-bottom);
line-height: 1.4;
}
// 视频统计信息(非绝对定位版本)
.video-stats {
font-size: var(--video-stats-font-size);
color: var(--video-stats-color);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
<template>
<view data-component-name="diy-audio">
<view class="fui-audio style1" :style="{ background: value.background }" v-if="value.type == 'style-2'">
<view class="content" style="padding-top: 20rpx;">
<view class="name" :style="{ color: value.textcolor }">{{ value.text }}</view>
<view class="author" :style="{ color: value.subtitlecolor }">{{ value.desc }}----{{ value.id }}</view>
</view>
<view class="progress">
<view class="progressBar" :style="{ width: audiowidth }"></view>
</view>
<view class="time" :style="{ color: value.timecolor }">
{{ audiotime }}
</view>
<view @tap.stop="play()" class="start" :class="status ? 'iconj icon-07zanting' : 'iconj icon-bofang'"
style="padding-top: 18rpx"></view>
</view>
<view class="fui-audio style3" :style="{ background: value.background }" v-else>
<!-- <audio src="/static/audio/bgm.mp3" controls loop></audio> -->
<view class="img">
<image :src="$util.img(value.imageUrl)" show-menu-by-longpress></image>
</view>
<view class="content">
<view class="name" :style="{ color: value.textcolor }">{{ value.text }}</view>
<view class="author" :style="{ color: value.subtitlecolor }">{{ value.desc }}</view>
</view>
<view class="progress">
<view class="progressBar" :style="{ width: audiowidth }"></view>
</view>
<view class="time" :style="{ color: value.timecolor }">
<!-- {{audios[value.id].audiotime}} -->
{{ audiotime }}
</view>
<view @tap.stop="play()" class="start" :class="status ? 'iconj icon-07zanting' : 'iconj icon-bofang'"></view>
</view>
</view>
</template>
<script>
import DiyMinx from './minx.js'
// 音频
export default {
name: 'diy-audio',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
audiosObj: [],
audios: {},
audioContext: null,
audiotime: '00:01',
audiowidth: 0,
status: 0,//1播放0停止
};
},
created() {
// console.log(this.value)
this.audios[this.value.id] = {
audiotime: '00:01',
audiowidth: 0
}
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
computed: {
videoWarpCss: function () {
var obj = '';
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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
return obj;
}
},
methods: {
play() {
var t = this.value.id, a = this.audiosObj[t] || !1;
var e = {
audio: this.$util.img(this.value.audioUrl),
}
if (!a) {
a = uni.createInnerAudioContext("audio_" + t);
var i = this.audiosObj;
i[t] = a, this.audiosObj = i
// uni.setStorageSync('audio_list',audio_list)
var audio_list = uni.getStorageSync('audio_list') ? uni.getStorageSync('audio_list') : []
if (audio_list.includes(t) == false) {
audio_list.push(t)
uni.setStorageSync('audio_list', audio_list)
}
}
var n = this;
a.onPlay(function () {
var e = setInterval(function () {
var i = a.currentTime / a.duration * 100 + "%", s = Math.floor(Math.ceil(a.currentTime) / 60), o = (Math.ceil(a.currentTime) % 60 / 100).toFixed(2).slice(-2), r = Math.ceil(a.currentTime);
s < 10 && (s = "0" + s);
var u = s + ":" + o, c = n.audios;
// console.log(i)
c[t].audiowidth = i, c[t].Time = e, c[t].audiotime = u, c[t].seconds = r, n.audios = c;
}, 1e3);
});
var s = n.$util.img(n.value.audioUrl), o = n.audios[n.value.id].seconds || 0, r = 0, u = 1;
0 == u && a.onEnded(function (e) {
c[t].status = !1, n.status = !1, c[t].seconds = 0, n.audios = c;
});
var c = n.audios;
c[t] || (c[t] = {}), a.paused && 0 == o ? (a.src = s, a.play(), 1 == u && (a.loop = !0),
c[t].status = !0, n.status = !0, n.pauseOther(t)) : a.paused && o > 0 ? (a.play(), 0 == r ? a.seek(o) : a.seek(0),
c[t].status = !0, n.status = !0, n.pauseOther(t)) : (a.pause(), c[t].status = !1, n.status = !1), n.audios = c;
},
pauseOther: function (e) {
var t = this;
// console.log(this.audiosObj[this.value.id]);
var i = this.audiosObj[this.value.id], a = this.value.id
// console.log(i)
// console.log(a)
// if (a != e) {
// i.pause();
// var n = t.audios;
// n[a] && (n[a].status = !1, this.audios=n);
// }
var audios = document.getElementsByTagName("audio");
// 暂停函数
function pauseAll() {
var self = this;
[].forEach.call(audios, function (i) {
// 将audios中其他的audio全部暂停
i !== self && i.pause();
})
}
// 给play事件绑定暂停函数
[].forEach.call(audios, function (i) {
i.addEventListener("play", pauseAll.bind(i));
})
// var audio_list = uni.getStorageSync('audio_list')
// audio_list.forEach(function(value, index) {
// if (value != e) {
// console.log(e)
// uni.createInnerAudioContext("audio_" + value).pause();
// }
// });
// this.audiosObj.forEach(function(value, index) {
// console.log(value);
// });
// this.each(this.audiosObj, function(a, i) {
// if (a != e) {
// i.pause();
// var n = t.data.audios;
// n[a] && (n[a].status = !1, t.setData({
// audios: n
// }));
// }
// });
},
play_bak() {
var t = this.value.id
this.audioContext = uni.createInnerAudioContext("audio_" + this.value.id);
this.audioContext.src = this.$util.img(this.value.audioUrl);
var that = this
if (this.status == 1) {
this.audioContext.pause();
this.status = 0
return false
}
this.audioContext.play();
this.status = 1
this.audioContext.onCanplay(function (s) {
var e = setInterval(function () {
var i = parseFloat(that.audioContext.currentTime) / parseFloat(that.audioContext.duration) * 100 + "%", s = Math.floor(Math.ceil(that.audioContext.currentTime) / 60), o = (Math.ceil(that.audioContext.currentTime) % 60 / 100).toFixed(2).slice(-2), r = Math.ceil(that.audioContext.currentTime);
s < 10 && (s = "0" + s);
var u = s + ":" + o, c = that.audios;
c[t].audiowidth = i, c[t].Time = e, c[t].audiotime = u, c[t].seconds = r
that.audios = c
that.audiotime = that.audios[that.value.id].audiotime
that.audiowidth = that.audios[that.value.id].audiowidth
that.lyg = i
}, 1e3);
});
this.audioContext.onEnded(() => {
this.status = 0
});
}
}
};
</script>
<style scoped>
.fui-audio {
width: 100%;
border: 1rpx solid #eeeeee;
padding: 0 30rpx 0 20rpx;
box-sizing: border-box;
position: relative;
overflow: hidden;
background: #fff;
}
.fui-audio .img {
width: 100rpx;
height: 100rpx;
background: #000;
}
.fui-audio .img image {
width: 100%;
height: 100%;
}
.fui-audio .name {
font-size: 26rpx;
color: #333;
}
.fui-audio .author {
font-size: 26rpx;
color: #666;
}
.fui-audio .time {
font-size: 24rpx;
color: #999;
}
.fui-audio .start {
border: 0;
padding: 0;
margin: 0;
font-size: 28rpx;
}
.progressBar {
height: 2rpx;
width: 0;
background: #333;
}
.fui-audio.style1 {
height: 86rpx;
line-height: 82rpx;
}
.fui-audio.style1 .img,
.fui-audio.style2 .img {
display: none;
}
.fui-audio.style1 .name,
.fui-audio.style2 .name {
float: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300rpx;
}
.fui-audio.style1 .author,
.fui-audio.style2 .author {
float: left;
margin-left: 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200rpx;
}
.fui-audio.style1 .time,
.fui-audio.style4 .time {
display: none;
}
.fui-audio.style1 .start {
position: absolute;
top: 0rpx;
right: 40rpx;
width: 40rpx;
height: 40rpx;
color: #000;
}
.fui-audio.style1 .progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.fui-audio.style2 {
height: 86rpx;
line-height: 82rpx;
}
.fui-audio.style2 .img {
display: none;
}
.fui-audio.style2 .time {
position: absolute;
top: 0;
right: 30rpx;
}
.fui-audio.style2 .name {
margin-left: 70rpx;
}
.fui-audio.style2 .start {
position: absolute;
top: 0rpx;
left: 30rpx;
width: 30rpx;
height: 30rpx;
color: #000;
}
.fui-audio.style2 .progress,
.fui-audio.style3 .progress {
display: none;
}
.fui-audio.style3 {
padding: 8rpx;
}
.fui-audio.style3 .start {
position: absolute;
top: 30rpx;
left: 28rpx;
width: 56rpx;
height: 56rpx;
color: #fff;
/* border: 2rpx solid #fff; */
border-radius: 50%;
text-indent: 18rpx;
line-height: 56rpx;
}
.fui-audio.style3 .img,
.fui-audio.style4 .img {
float: left;
margin-right: 20rpx;
}
.fui-audio.style3 .content {
width: 468rpx;
}
.fui-audio.style3 .content,
.fui-audio.style4 .content {
float: left;
height: 100rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.fui-audio.style3 .content .name {
height: 40rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fui-audio.style3 .content .author {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fui-audio.style3 .time {
position: absolute;
top: 40rpx;
right: 30rpx;
}
.fui-audio.style4 {
padding: 10rpx;
}
.fui-audio.style4 .content {
padding-bottom: 18rpx;
height: 82rpx;
width: 500rpx;
}
.fui-audio.style4 .start {
position: absolute;
top: 32rpx;
right: 30rpx;
width: 30rpx;
height: 30rpx;
color: #000;
}
.fui-audio.style4 .name {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fui-audio.style4 .author {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fui-audio.style4 .progress {
background: #f5f5f5;
height: 4rpx;
position: absolute;
bottom: 24rpx;
left: 130rpx;
right: 30rpx;
border-radius: 2rpx;
overflow: hidden;
}
.fui-audio.style4 .progressBar {
height: 4rpx;
}
.diy-audio>>>.uni-video-container {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,790 @@
<template>
<x-skeleton data-component-name="diy-bargain" :type="skeletonType" :loading="loading" :configs="skeletonConfig">
<view class="diy-bargain" :class="[value.template, value.style]" :style="warpCss">
<!-- 商品头部 -->
<view v-if="value.titleStyle.isShow && list && list.length"
:class="[value.titleStyle.style, 'bargain-head']"
:style="{ backgroundImage: 'url(' + $util.img(value.titleStyle.backgroundImage) + '), linear-gradient(to right,' + value.titleStyle.bgColorStart + ',' + value.titleStyle.bgColorEnd + ')' }">
<view v-if="value.titleStyle.leftStyle == 'text'" class="left-text"
:style="{ fontSize: value.titleStyle.fontSize * 2 + 'rpx', color: value.titleStyle.textColor, fontWeight: value.titleStyle.fontWeight ? 'bold' : '' }">
{{ value.titleStyle.leftText }}
</view>
<image v-else class="left-img" :src="$util.img(value.titleStyle.leftImg)" mode="heightFix" show-menu-by-longpress></image>
<view class="head-content" v-if="value.titleStyle.style == 'style-1'"
:style="{ color: value.titleStyle.textColor }">{{ $lang('components-diy.diy-bargain.freeGet') }}</view>
<view class="head-right"
:style="{ fontSize: value.titleStyle.moreFontSize * 2 + 'rpx', color: value.titleStyle.moreColor }"
@tap.stop="$util.redirectTo('/pages_promotion/bargain/list')">
<text>{{ value.titleStyle.more }}</text>
<text class="iconfont icon-right"></text>
</view>
</view>
<!-- 商品列表 -->
<template v-if="value.template == 'row1-of1'">
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
:class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(index)" show-menu-by-longpress/>
</view>
<view class="content"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.btnStyle.control">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="progress" v-if="value.style == 'style-2'">
<view class="bg">
<view class="curr" :style="{ width: progress(item) * 2 + 'rpx' }">
<image class="progress-bar" mode="widthFix"
:src="$util.img('public/uniapp/bargain/progress_bar_01.png')" show-menu-by-longpress/>
</view>
</view>
<view class="num" v-if="item.is_bargaining">
{{ $lang('components-diy.diy-bargain.bargained') }}
<text>¥{{ (item.price - item.curr_price).toFixed(2) }}</text>
{{ $lang('components-diy.diy-bargain.onlyDiff') }}
<text>¥{{ item.curr_price }}</text>
</view>
<view class="num" v-else>
{{ $lang('components-diy.diy-bargain.minBargainTo') }}
<text>¥{{ item.floor_price }}</text>
</view>
</view>
<view class="progress" v-if="value.style == 'style-3'">
{{ $lang('components-diy.diy-bargain.minBargainTo') }}
<text class="num">¥{{ item.floor_price }}</text>
</view>
<view class="price-wrap">
<view class="discount-price" v-if="value.priceStyle.mainControl">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">.{{
item.price.split('.')[1] }}</text>
</view>
<button v-if="value.btnStyle.control" :style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}">
{{ item.is_bargaining ? $lang('components-diy.diy-bargain.continueBargain') : value.btnStyle.text }}
</button>
</view>
</view>
</view>
</template>
<template v-if="value.template == 'horizontal-slide'">
<scroll-view v-if="value.slideMode == 'scroll'" class="scroll" :scroll-x="true" :show-scrollbar="false">
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
:class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(index)" :lazy-load="true" show-menu-by-longpress/>
<image class="bg"
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
:src="$util.img('public/uniapp/bargain/bg.png')" mode="widthFix" />
<view class="num"
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
{{ $lang('components-diy.diy-bargain.soldCount', { num: item.sale_num }) }}
</view>
</view>
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="discount-price"
v-if="value.priceStyle.mainControl && value.template == 'horizontal-slide' && value.style != 'style-2'">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.floor_price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
'.' + item.floor_price.split('.')[1] }}</text>
</view>
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
¥{{ item.price }}
</view>
</view>
</view>
</scroll-view>
<swiper v-if="value.slideMode == 'slide'" :autoplay="false" class="swiper"
:style="{ height: swiperHeight }">
<swiper-item v-for="(pageItem, pageIndex) in page" :key="pageIndex"
:class="['swiper-item', (list.length && [list[pageIndex].length / 3] >= 1) && 'flex-between']">
<view class="item" v-for="(item, dataIndex) in list[pageIndex]" :key="dataIndex"
@tap.stop="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(dataIndex)" :lazy-load="true" show-menu-by-longpress/>
<image class="bg"
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
:src="$util.img('public/uniapp/bargain/bg.png')" mode="widthFix" />
<view class="num"
v-if="value.saleStyle.control && value.template == 'horizontal-slide' && value.style != 'style-2'"
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
{{ $lang('components-diy.diy-bargain.soldCount', { num: item.sale_num }) }}
</view>
</view>
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="discount-price"
v-if="value.priceStyle.mainControl && value.template == 'horizontal-slide' && value.style != 'style-2'">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.floor_price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
'.' + item.floor_price.split('.')[1] }}</text>
</view>
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
¥{{ item.price }}
</view>
</view>
</view>
</swiper-item>
</swiper>
</template>
</view>
</x-skeleton>
</template>
<script>
import DiyMinx from './minx.js'
// 砍价商品
export default {
name: 'diy-bargain',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
list: [],
page: 1,
loading: true,
skeletonType: '',
skeletonConfig: {}
};
},
components: {},
async created() {
this.initSkeleton();
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') await this.getDataing();
this.getData();
},
watch: {
// 组件刷新监听
componentRefresh: async function (nval) {
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') await this.getDataing();
this.getData();
}
},
computed: {
warpCss() {
var obj = '';
if (this.value.componentBgColor) obj += 'background:' + 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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
return obj;
},
// 商品项样式
goodsItemCss() {
var obj = '';
obj += 'background-color:' + this.value.elementBgColor + ';';
if (this.value.elementAngle == 'round') {
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
if (this.value.ornament.type == 'shadow') {
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color + ';';
}
if (this.value.ornament.type == 'stroke') {
obj += 'border:' + '2rpx solid ' + this.value.ornament.color + ';';
}
const screenWidth = uni.getSystemInfoSync().windowWidth;
if (this.value.template == 'horizontal-slide') {
var width = '';
if (this.value.slideMode == 'scroll' && this.value.goodsMarginType == 'diy') width = this.rpxUpPx(this
.value.goodsMarginNum * 2);
else width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value
.margin.both * 2) * 2] / 6;
obj += 'margin-left:' + width + 'px;';
obj += 'margin-right:' + width + 'px;';
}
return obj;
},
swiperHeight() {
if (this.value.nameLineMode == 'multiple') {
if (this.value.ornament.type == 'shadow') return '420rpx';
else return '400rpx';
}
if (this.value.ornament.type == 'shadow') return '386rpx';
else return '378rpx';
}
},
methods: {
initSkeleton() {
if (this.value.template == 'row1-of1') {
// 单列 风格
this.skeletonType = 'list';
this.skeletonConfig = {
textRows: 2
};
} else if (this.value.template == 'horizontal-slide') {
// 横向滑动 风格
this.skeletonType = 'waterfall';
this.skeletonConfig = {
gridRows: 1,
gridColumns: 3,
headHeight: '200rpx',
textRows: 2,
textWidth: ['100%', '80%']
};
}
},
rpxUpPx(res) {
const screenWidth = uni.getSystemInfoSync().windowWidth;
var data = (screenWidth * parseInt(res)) / 750;
return Math.floor(data);
},
// 查找自己参与的砍价
async getDataing() {
var res = await this.$api.sendRequest({
url: '/bargain/api/goods/bargainingList',
data: {},
async: false
});
res.data &&
res.data.forEach((item, index) => {
item.is_bargaining = 1;
});
this.list = res.data || [];
this.loading = false;
},
// 查找可砍价的商品
getData() {
var data = {
num: this.value.count,
is_exclude_bargaining: 1
};
if (this.value.sources == 'diy') {
data.num = 0;
data.id_arr = this.value.goodsId.toString();
}
this.$api.sendRequest({
url: '/bargain/api/goods/lists',
data: data,
success: res => {
if (res.code == 0) {
if (this.value.template == 'row1-of1' && this.value.style == 'style-2') this.list =
this.list.concat(res.data).splice(0, this.value.count);
else this.list = res.data;
// 切屏滚动,每页显示固定数量
if (this.value.template == 'horizontal-slide' && this.value.slideMode == 'slide') {
let size = 3;
let temp = [];
this.page = Math.ceil(this.list.length / size);
for (var i = 0; i < this.page; i++) {
temp[i] = [];
for (var j = i * size; j < this.list.length; j++) {
if (temp[i].length == size) break;
temp[i].push(this.list[j]);
}
}
this.list = temp;
}
}
this.loading = false;
}
});
},
progress(data) {
// 214 表示当前进度条的宽度
let progress = (((parseFloat(data.price) - parseFloat(data.curr_price)) / parseFloat(data.price)) * 214)
.toFixed();
if (progress == 'NaN') {
progress = 0;
}
return progress;
},
toDetail(e) {
this.$util.redirectTo('/pages_promotion/bargain/detail', {
b_id: e.bargain_id
});
},
imageError(index) {
this.list[index].goods_image = this.$util.getDefaultImage().goods;
this.$forceUpdate();
}
}
};
</script>
<style lang="scss">
::v-deep .uni-scroll-view ::-webkit-scrollbar {
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
display: none;
width: 0;
height: 0;
color: transparent;
background: transparent;
}
::v-deep ::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
background: transparent;
}
scroll-view ::-webkit-scrollbar {
width: 0;
height: 0;
background-color: transparent;
touch-action: none;
}
.diy-bargain {
overflow: hidden;
// 头部
.bargain-head {
&.style-1 {
display: flex;
justify-content: space-between;
align-items: center;
height: 88rpx;
box-sizing: border-box;
padding: 0 20rpx;
background-repeat: no-repeat;
background-size: cover;
margin-bottom: 20rpx;
border-radius: 18rpx 18rpx 0 0;
.left-img {
height: 40rpx;
width: 156rpx;
}
.head-content {
position: relative;
color: #fff;
font-size: $font-size-tag;
margin-right: auto;
margin-left: 20rpx;
line-height: 1;
&::after {
content: '';
position: absolute;
width: 2rpx;
height: 24rpx;
background-color: #fff;
top: 50%;
transform: translateY(-50%);
left: -12rpx;
}
}
.head-right {
display: flex;
align-items: center;
font-size: $font-size-sub;
color: #fff;
}
}
&.style-2 {
display: flex;
justify-content: space-between;
align-items: center;
height: 88rpx;
box-sizing: border-box;
padding: 0 20rpx;
background-repeat: no-repeat;
background-size: cover;
margin-bottom: 20rpx;
border-radius: 18rpx 18rpx 0 0;
.left-img {
height: 40rpx;
width: 156rpx;
}
.head-right {
display: flex;
align-items: center;
justify-content: center;
height: 36rpx;
// P0: 砍价头渐变 → var(--pintuan-label-grad-start/end) 复用(同色系)
// 兼容: 微信小程序 / 鸿蒙元服务 — linear-gradient 嵌套 var() 现代 runtime 支持
background: #ffbd5b; // 兜底
background: linear-gradient(270deg, var(--pintuan-label-grad-start, #ffbd5b) 0%, var(--pintuan-label-grad-end, #fd882e) 100%);
border-radius: 24rpx;
padding: 2rpx;
text:nth-child(1) {
position: relative;
left: 6rpx;
transform: scale(0.9);
}
text:nth-child(2) {
padding: 6rpx 4rpx 4rpx;
background-color: #fff;
// P0: 砍价头文字色 → var(--pintuan-label-grad-start) 复用
// 兼容: 微信小程序 / 鸿蒙元服务 — color 接受 var() 无问题
color: var(--pintuan-label-grad-start, #ffbd5b);
border-radius: 50%;
transform: scale(0.6);
font-weight: bold;
line-height: 1;
}
}
}
}
// 商品列表
&.row1-of1 {
.item {
display: flex;
margin-bottom: 20rpx;
padding: 16rpx;
&.shadow {
margin: 8rpx 8rpx 20rpx 8rpx;
}
&:last-child {
margin-bottom: 0;
padding-bottom: 20rpx;
}
.img-wrap {
width: 200rpx;
height: 200rpx;
>image {
width: 200rpx;
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 6rpx 0 6rpx 20rpx;
.goods-name {
&.multi-hidden {
line-height: 1.3;
}
}
.price-wrap {
display: flex;
justify-content: space-between;
align-items: center;
}
.discount-price {
white-space: nowrap;
font-weight: bold;
line-height: 1;
.unit {
font-size: $font-size-tag;
margin-right: 4rpx;
color: var(--price-color);
}
.price {
font-size: $font-size-toolbar;
color: var(--price-color);
}
}
button {
margin: 0;
padding: 0 20rpx;
color: var(--btn-text-color);
background-color: $base-color;
color: #fff;
min-width: 112rpx;
height: 52rpx;
line-height: 52rpx;
font-size: $font-size-tag;
font-weight: bold;
}
}
}
&.style-2 {
.discount-price {
position: relative;
}
.progress {
display: flex;
flex-direction: column;
margin-top: 10rpx;
margin-right: 16rpx;
.bg {
margin-left: 6rpx;
width: auto;
height: 20rpx;
border-radius: 20rpx;
// P0: 砍价浅底 → var(--bargain-promo-shallow)
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
background-color: var(--bargain-promo-shallow, #ffeadb);
position: relative;
&::after {
content: '';
width: 26rpx;
height: 26rpx;
border-radius: 50%;
// P0: 砍价红圆点 → var(--bargain-promo-color)
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
background-color: var(--bargain-promo-color, #fa1a1a);
position: absolute;
top: 50%;
transform: translateY(-50%);
right: -18rpx;
}
.curr {
width: 0;
height: 20rpx;
border-radius: 20rpx;
// P0: 砍价红进度条 → var(--bargain-promo-color)
// 兼容: 微信小程序 / 鸿蒙元服务 — background-color 接受 var() 无问题
background-color: var(--bargain-promo-color, #fa1a1a);
position: relative;
.progress-bar {
position: absolute;
right: -20rpx;
width: 30rpx;
height: 30rpx;
max-width: inherit !important;
max-height: inherit !important;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
}
}
.num {
font-size: $font-size-tag;
margin-top: 12rpx;
line-height: 1;
text {
// P0: 砍价数字红 → var(--bargain-promo-color)
// 兼容: 微信小程序 / 鸿蒙元服务 — color 接受 var() 无问题
color: var(--bargain-promo-color, #fa1a1a);
}
}
}
}
&.style-3 {
.progress {
display: flex;
color: $color-tip;
font-size: $font-size-sub;
.num {
color: var(--price-color);
}
}
.item {
.content {
justify-content: space-around;
}
.img-wrap {
overflow: hidden;
}
}
}
}
&.horizontal-slide {
.scroll {
width: calc(100% - 40rpx);
padding: 20rpx;
line-height: 1;
white-space: nowrap;
.item.shadow {
margin-bottom: 8rpx;
}
}
.flex-between {
justify-content: space-between;
}
.item {
display: inline-block;
width: 200rpx;
overflow: hidden;
box-sizing: border-box;
&:nth-child(3n + 3) {
width: 198rpx;
}
&.shadow {
margin-top: 8rpx;
}
.img-wrap {
width: 200rpx;
height: 200rpx;
position: relative;
overflow: hidden;
margin: 0 auto;
>image {
width: 100%;
}
.bg {
position: absolute;
width: 100%;
height: 60rpx;
bottom: 0;
left: 0;
z-index: 2;
}
.num {
width: 180rpx;
position: absolute;
bottom: 10rpx;
padding-left: 20rpx;
font-size: 20rpx;
line-height: 1;
color: #ffffff;
z-index: 3;
}
}
.content {
padding: 10rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
&.multi-content {
height: 160rpx;
box-sizing: border-box;
}
.goods-name {
line-height: 1.3;
&.multi-hidden {
white-space: break-spaces;
}
}
.discount-price {
white-space: nowrap;
margin-top: auto;
font-weight: bold;
line-height: 1;
.unit {
font-size: $font-size-tag;
margin-right: 4rpx;
color: var(--price-color);
}
.price {
font-size: $font-size-toolbar;
color: var(--price-color);
}
}
.original-price {
margin-top: 4rpx;
font-size: $font-size-tag;
color: $color-tip;
line-height: 1;
text-decoration: line-through;
}
}
}
.swiper {
padding: 20rpx;
width: 100%;
white-space: nowrap;
box-sizing: border-box;
.swiper-item {
display: flex;
align-items: center;
}
.item {
width: 200rpx;
box-sizing: border-box;
}
}
}
}
</style>

View File

@@ -0,0 +1,463 @@
<template>
<!-- 修复底部 TabBar 频闪:
微信小程序等环境下 `shouldShowTabBar` 在 tabBarList 异步加载期间会从 false→true 短暂切换,
配合 `v-if` 会让整个 tab 组件树卸载/重建 -> 视觉频闪 + tabBar 占用空间反复占位/释放导致上方内容跳动。
改为 `v-show`, 只切换可见性, 内部 tab 始终在 DOM 中(无 tabBarList 时只渲染空壳占位)。 -->
<view data-component-name="diy-bottom-nav" v-show="shouldShowTabBar">
<!-- tabBarList 未就绪时仍保留 placeholder 占位, 避免上方内容在加载完成后突跳 -->
<view v-if="tabBarList && tabBarList.list && tabBarList.list.length" class="tab-bar" :style="{ backgroundColor: tabBarList.backgroundColor }">
<view class="tabbar-border"></view>
<view class="item" v-for="(item, index) in tabBarList.list" :key="item.id" @tap.stop="redirectTo(item.link)">
<view class="bd">
<view class="icon" v-if="tabBarList.type == 1 || tabBarList.type == 2"
:animation="isCartItem(item.link) ? cartAnimation : null"
:id="isCartItem(item.link) ? 'tabbarCart' : null">
<block v-if="verify(item.link)">
<image v-if="item.selected_icon_type == 'img'"
:src="$util.img(item.selectedIconPath)" />
<diy-icon v-if="item.selected_icon_type == 'icon'" :icon="item.selectedIconPath"
:value="item.selected_style ? item.selected_style : null"></diy-icon>
</block>
<block v-else>
<image v-if="item.icon_type == 'img'" :src="$util.img(item.iconPath)" />
<diy-icon v-if="item.icon_type == 'icon'" :icon="item.iconPath"
:value="item.style ? item.style : null"></diy-icon>
</block>
<view class="cart-count-mark font-size-activity-tag"
:class="{ max: cartNumber > 99 }"
:style="{ background: 'var(--price-color)' }"
v-if="isCartItem(item.link) && cartNumber > 0">
{{ cartNumber > 99 ? '99+' : cartNumber }}
</view>
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
{{ $langConfig.getField(item, 'text') }}
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'default'"
:style="{ color: verify(item.link) ? 'var(--base-color)' : '#333333' }">
{{ $langConfig.getField(item, 'text') }}
</view>
</view>
</view>
</view>
<!-- 解决fixed定位后底部导航栏塌陷问题: 始终保留 placeholder, 避免内容随 tabBar 可见性跳 -->
<view class="tab-bar-placeholder"></view>
</view>
</template>
<script>
import { adaptSubpackageUrl, checkTabBarActive, decodeEntity } from '@/common/js/util.js'
import { resolveEntryTab } from '@/common/js/util-tabbar.js'
import DiyMinx from './minx.js'
// 底部导航栏
export default {
name: 'diy-bottom-nav',
props: {
value: {
type: Object
},
name: {
type: String,
default: ''
}
},
mixins: [DiyMinx],
data() {
return {
currentRoute: '', //当前页面路径
jumpFlag: true, //是否可以跳转,防止重复点击
cartAnimation: {}
};
},
mounted() {
this.updateCurrentRoute();
this.$nextTick(() => {
if (!this.$store.state.cartPosition) {
let query = uni.createSelectorQuery().in(this);
query.select('#tabbarCart')
.boundingClientRect(data => {
if (data) this.$store.commit('setCartPosition', data);
}).exec();
query.select('.tab-bar')
.boundingClientRect(data => {
if (data) this.$store.commit('setTabBarHeight', data.height +
'px');
}).exec();
}
});
// 监听页面显示事件,更新当前路由
this.$on('hook:onShow', () => {
this.updateCurrentRoute();
});
},
computed: {
// 当前语言(响应式):通过 componentRefresh 建立依赖,切语言时自动更新
// 不能直接用 uni.getStorageSync 作为 data,因为它不是响应式的
currentLang() {
// 通过 componentRefresh 建立响应式依赖
// 切语言时 store 中的 componentRefresh 会 +1,触发重新计算
this.componentRefresh;
return uni.getStorageSync("lang") || 'zh-cn';
},
cartChange() {
return this.$store.state.cartChange;
},
packageEntryTab() {
return this.$store.state.packageEntryTab;
},
// 响应 tabBarList 异步加载: 后端返回前 updateCurrentRoute 是 no-op,
// tabBarList 就绪后必须重跑一次, 否则冷启动+秒点链接会出现"先不高亮→再突然高亮"
tabBarReady() {
return this.tabBarList && this.tabBarList.list && this.tabBarList.list.length > 0;
},
},
watch: {
cartChange: function (nval, oval) {
if (nval > oval) {
let animation = uni.createAnimation({
duration: 200,
timingFunction: 'ease'
});
animation.scale(1.2).step();
this.cartAnimation = animation.export();
setTimeout(() => {
animation.scale(1).step();
this.cartAnimation = animation.export();
}, 300);
}
},
// tabBarList 异步加载完成后, 补一次 updateCurrentRoute
// 修复: 冷启动时用户在 tabBarList 就绪前点击链接, 第一次 updateCurrentRoute 是 no-op
tabBarReady: {
handler(ready) {
if (ready) {
this.updateCurrentRoute();
}
},
immediate: false,
},
},
methods: {
// 统一归一化路径:currentPage.route 不带前导 '/',但 tab 的 wap_url 带前导 '/'
// 统一加前导 '/' 后再 split,确保 parts[1]=分包名、parts[2]=页面名
_normalizeRoute(route) {
if (!route) return '';
return route.startsWith('/') ? route : '/' + route;
},
// 从归一化路径中提取分包名(parts[1])
_getPackageName(normalizedRoute) {
const parts = normalizedRoute.split('?')[0].split('/');
return parts[1] || '';
},
// 判断某个链接是否指向购物车页面
isCartItem(link) {
if (!link || !link.wap_url) return false;
return adaptSubpackageUrl(link.wap_url).split('?')[0] === '/pages_goods/cart';
},
// 更新当前路由,追踪每个分包的入口 tab
updateCurrentRoute() {
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
if (!currentPage || !currentPage.route) return;
// 归一化:补全前导 '/',确保后续 split('/') 后 parts[1]=分包名
const newRoute = this._normalizeRoute(currentPage.route);
const currentPackageName = newRoute.split('?')[0].split('/')[1] || '';
if (this.tabBarList && this.tabBarList.list && currentPackageName) {
// 收集历史栈路由 (排除当前页, 已 _normalizeRoute)
// 注意: 组件里直接传 raw route 给 resolveEntryTab,
// 内部 adaptSubpackageUrl 会兜底处理老路径 /pages/member/ 等
const historyRoutes = currentPages.length >= 2
? currentPages.slice(0, -1).map((p) =>
p && p.route ? this._normalizeRoute(p.route) : null)
: [];
// 调用纯函数 resolveEntryTab 完成 4 步查找
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
// 修复: home → diy 微页面 等跨分包导航被"同分包第一个 tab"误判的问题
const entryTab = resolveEntryTab({
currentPageRoute: newRoute,
tabBarList: this.tabBarList,
historyRoutes,
// 传 NO_TAB_PAGES 进去, 让 login/pay/AI 等 modal 页不参与归属判定
// 例子: home → login → diy 应算作 home → diy (login 是 modal)
noTabPages: this.$util && this.$util.NO_TAB_PAGES ? this.$util.NO_TAB_PAGES : null,
});
if (entryTab) {
this.$store.commit('setPackageEntryTab', {
...this.packageEntryTab,
[currentPackageName]: entryTab
});
}
}
this.currentRoute = newRoute;
},
redirectTo(link) {
this.$emit('callback');
const redirectLink = link ? { ...link } : link;
if (link && link.wap_url) {
const decodedUrl = decodeEntity(link.wap_url);
const adaptedUrl = adaptSubpackageUrl(decodedUrl);
const packageName = this._getPackageName(adaptedUrl);
if (packageName) {
this.$store.commit('setPackageEntryTab', {
...this.packageEntryTab,
[packageName]: adaptedUrl.split('?')[0]
});
}
// 若目标页面不是 TabBar 根页面,追加 from_tab 来源参数,
// 用于非 Tab 页面的 TabBar 高亮记忆
if (this.tabBarList && this.tabBarList.list) {
const tabUrls = this.tabBarList.list
.filter(item => item.link && item.link.wap_url)
.map(item => adaptSubpackageUrl(decodeEntity(item.link.wap_url)).split('?')[0]);
if (!tabUrls.includes(adaptedUrl.split('?')[0])) {
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
const currentTab = currentPage && currentPage.route
? tabUrls.find(tabUrl => checkTabBarActive(tabUrl, '/' + currentPage.route, this.tabBarList, this.packageEntryTab))
: null;
if (currentTab) {
redirectLink.from_tab = currentTab;
}
}
}
}
this.$util.diyRedirectTo(redirectLink);
},
verify(link) {
if (link == null || link == '' || !link.wap_url) return false;
const currentPages = getCurrentPages();
const currentPage = currentPages[currentPages.length - 1];
const currentPageRoute = currentPage && currentPage.route
? this._normalizeRoute(currentPage.route)
: '';
// 读取当前页面 query 参数(兼容小程序与 H5)
let currentPageQuery = {};
if (currentPage) {
if (currentPage.options) {
currentPageQuery = currentPage.options;
} else if (currentPage.$page && currentPage.$page.options) {
currentPageQuery = currentPage.$page.options;
} else if (currentPage.$route && currentPage.$route.query) {
currentPageQuery = currentPage.$route.query;
}
}
if (this.name === 'DIY_VIEW_INDEX') {
// DIY_VIEW_INDEX 短路: 当 name 是 DIY_VIEW_INDEX 时, 任意匹配的 tab 都高亮
// 修复前: 只对 HOME (INDEX_PAGE_URL) 生效
// 修复后: 适用于所有 tab 页面 (如 merchant DIY 首页、cart DIY 首页 等)
// 前提: 当前页面本身就是该 tab 根, 链接 == 当前页 → step 1 早就命中
// 这个短路主要兜底: "DIY_VIEW_INDEX 页面 + tab 链接" 的强语义关联
const linkUrl = adaptSubpackageUrl(link.wap_url).split('?')[0];
const currentUrl = currentPageRoute.split('?')[0];
if (linkUrl === currentUrl) {
return true;
}
}
return checkTabBarActive(link.wap_url, currentPageRoute, this.tabBarList, this.packageEntryTab, currentPageQuery);
}
}
};
</script>
<style lang="scss">
.placeholder {
height: 112rpx;
&.bluge {
height: 180rpx;
}
}
.safe-area {
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-bar {
background-color: #fff;
box-sizing: border-box;
/* ============================================================
* 固定在最底端,不能位置漂移(微信小程序 / 鸿蒙元服务 / 快应用 H5)
* ------------------------------------------------------------
* 现象(只在客户端小程序里复现,浏览器 H5 没事):
* 页面带 navigationStyle:custom + 页面内有 scroll-view,
* 滚到底部时 .tab-bar 会被微信客户端的页面避让逻辑推上去,
* 出现"位置漂移"。
*
* 根因(微信小程序特性):
* 1) 微信小程序里 position:fixed 的 containing block 是 page
* 根容器(不是 viewport),可视区变化时 fixed 元素下沿会
* 跟着重算,造成漂移。
* 2) 父级一旦出现 transform/filter/will-change,fixed 的
* containing block 会被进一步改写,fixed 元素行为更不可控。
*
* 加固措施:
* - bottom: 0 !important 钉死下沿
* - 提升 z-index 到 2147483647,避免被其他 fixed 元素遮挡
* - transform: translateZ(0) 强制提升到独立合成层,降低父级
* transform 干扰;will-change:transform 提示浏览器持续合成
* - 微信小程序里 #ifdef MP-WEIXIN / #ifdef MP-HARMONY 走原生
* tabBar,diy-bottom-nav 通常不会同时存在,但保留兜底
* ============================================================ */
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100%;
z-index: 2147483647 !important;
display: flex;
border-top: 2rpx solid #f5f5f5;
/* 强制提升合成层,降低父级 transform/filter 改变 containing block 的概率 */
transform: translateZ(0) !important;
-webkit-transform: translateZ(0) !important;
will-change: transform !important;
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
.tabbar-border {
background-color: rgba(255, 255, 255, 0.329412);
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 2rpx;
-webkit-transform: scaleY(0.5);
transform: scaleY(0.5);
}
.item {
display: flex;
align-items: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
flex: 1;
flex-direction: column;
padding-bottom: 10rpx;
box-sizing: border-box;
.bd {
position: relative;
height: 100rpx;
flex-direction: column;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
.icon {
position: relative;
display: inline-block;
margin-top: 10rpx;
width: 40rpx;
height: 40rpx;
font-size: 40rpx;
line-height: 40rpx;
image {
width: 100%;
height: 100%;
}
>view {
height: inherit;
display: flex;
align-items: center;
}
.bar-icon {
font-size: 42rpx;
}
}
.label {
position: relative;
text-align: center;
font-size: 24rpx;
line-height: 1;
margin-top: 12rpx;
}
}
&.bulge {
.bd {
position: relative;
height: 100rpx;
flex-direction: column;
text-align: center;
.icon {
margin-top: -60rpx;
margin-bottom: 4rpx;
border-radius: 50%;
width: 100rpx;
height: 102rpx;
padding: 10rpx;
border-top: 2rpx solid #f5f5f5;
background-color: #fff;
box-sizing: border-box;
image {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
.label {
position: relative;
text-align: center;
font-size: 24rpx;
height: 40rpx;
line-height: 40rpx;
}
}
}
.cart-count-mark {
position: absolute;
top: -8rpx;
right: -18rpx;
width: 24rpx;
height: 24rpx !important;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
padding: 6rpx;
border-radius: 50%;
z-index: 99;
&.max {
width: 40rpx;
border-radius: 24rpx;
right: -28rpx;
}
}
}
}
.tab-bar-placeholder {
padding-bottom: calc(constant(safe-area-inset-bottom) + 112rpx);
padding-bottom: calc(env(safe-area-inset-bottom) + 112rpx);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,631 @@
<template>
<view class="channel-list-container" :style="[componentStyle, { '--row-count': value.rowCount }]">
<!-- 轮播模式 -->
<swiper v-if="value.showStyle == 'carousel' && carouselConfig.type != 'hide'" :indicator-dots="isIndicatorDots"
:autoplay="carouselConfig.autoplay || false" :interval="carouselConfig.interval || 3000"
:duration="carouselConfig.duration || 500" :circular="carouselConfig.circular || false"
:style="swiperHeight" class="channel-swiper">
<swiper-item v-for="(slide, slideIndex) in swiperSlides" :key="slideIndex">
<view :class="['swiper-slide-content', 'row1-of' + value.rowCount]">
<view v-for="(item, index) in slide" :key="index" :class="['channel-item', value.mode]">
<!-- 视频号视频卡片,轮播模式 -->
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
</view>
</view>
</swiper-item>
</swiper>
<!-- 固定布局模式 -->
<view v-else-if="value.showStyle == 'fixed'" :class="['channel-list', 'row1-of' + value.rowCount]">
<view v-for="(item, index) in value.list" :key="index" class="channel-item">
<!-- 视频号视频卡片,列表模式 -->
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
</view>
</view>
<!-- 其他布局模式(如滚动布局) -->
<scroll-view v-else
:class="['channel-nav', value.showStyle == 'fixed' ? 'fixed-layout' : 'singleSlide', 'row1-of' + value.rowCount]"
:scroll-x="true"
:scroll-y="false"
:enhanced="true"
:bounces="false">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index"
:class="['channel-nav-item', value.mode, 'row1-of' + value.rowCount]">
<!-- 视频号视频卡片,滚动模式 -->
<diy-channel-video :value="item" @video-play="playVideo" :list-mode="true"
:title-line-clamp="value.titleLineClamp" :show-play-btn="value.showPlayBtn"
:show-view-count="value.showViewCount" :cover-style="computedCoverStyle"
:play-btn-style="value.playBtnStyle" :aspect-ratio="value.aspectRatio" />
</view>
</view>
</scroll-view>
</view>
</template>
<script>
/**
* 微信视频号列表组件
* 支持多种布局模式,包括固定布局和滚动布局
* 可配置列数、视频高度、标题行数等属性
* 用于在页面中展示多个微信视频号视频
*/
import DiyMinx from './minx.js'
import { wechatChannelUtil, wechatChannelConfig } from './js/wechat-channel.js'
export default {
name: 'diy-channel-list',
props: {
/**
* 组件配置数据
* @type {Object}
* @default () => ({})
* @property {string} showStyle - 显示样式,可选值:carousel, fixed, singleSlide
* @property {number} rowCount - 每行显示的视频数量
* @property {Array} list - 视频列表数据
* @property {string} componentBgColor - 组件背景色
* @property {string} componentAngle - 组件圆角类型,可选值:round
* @property {number} topAroundRadius - 顶部圆角半径
* @property {number} bottomAroundRadius - 底部圆角半径
* @property {Object} ornament - 装饰效果配置
* @property {boolean} showViewCount - 是否显示播放量,可选值:true, false
* @property {number} titleLineClamp - 标题显示行数
* @property {string} aspectRatio - 视频比例,可选值:16:9, 3:4
* @property {boolean} showPlayBtn - 是否显示播放按钮
* @property {Object} coverStyle - 视频封面图样式
* @property {Object} playBtnStyle - 播放按钮样式
* @property {string} mode - 显示模式, 可选值:video, text, image, graphic
* @property {number} imageSize - 图片尺寸(仅在特定模式下使用)
* @property {number} pageCount - 每页显示的视频数量
* @property {Object} carousel - 轮播配置
* @property {string} carousel.type - 轮播类型,可选值:default, hide
* @property {boolean} carousel.autoplay - 是否自动播放,默认false
* @property {number} carousel.interval - 自动播放间隔,默认3000ms
* @property {number} carousel.duration - 切换动画时长,默认500ms
* @property {boolean} carousel.circular - 是否循环播放,默认false
*/
value: {
type: Object,
default: () => ({})
}
},
mixins: [DiyMinx],
data() {
return {
pageWidth: '', // 页面宽度
indicatorDots: false, // 是否显示轮播指示器
swiperCurrent: 0 // 当前轮播索引
}
},
created() {
// 组件创建时的逻辑
// 可以在这里进行初始化操作,如获取页面宽度等
},
mounted() {
// 组件挂载后添加鼠标拖拽滚动功能
if (!['fixed', 'carousel'].includes(this.value?.showStyle)) {
this.$nextTick(() => {
this.addMouseDragScroll();
});
}
},
watch: {
/**
* 组件刷新监听
* 当组件需要刷新时触发
* @param {*} newValue - 新值
*/
componentRefresh(newValue) {
// 监听组件刷新
// 可以在这里处理组件刷新时的逻辑
}
},
computed: {
carouselConfig() {
return this.value?.carousel || {
type: 'default',
autoplay: false,
interval: 3000,
duration: 500,
circular: true
}
},
/**
* 组件样式
* 根据配置动态生成样式字符串,包括背景色、圆角、阴影和边框
* @returns {string} 样式字符串
*/
componentStyle() {
const style = {};
// 背景色
if (this.value?.componentBgColor) {
style.backgroundColor = this.value?.componentBgColor;
}
// 圆角样式
if (this.value?.componentAngle == 'round') {
style.borderTopLeftRadius = (2 * this.value?.topAroundRadius) + 'rpx';
style.borderTopRightRadius = (2 * this.value?.topAroundRadius) + 'rpx';
style.borderBottomLeftRadius = (2 * this.value?.bottomAroundRadius) + 'rpx';
style.borderBottomRightRadius = (2 * this.value?.bottomAroundRadius) + 'rpx';
}
// 装饰效果:阴影
style.boxShadow = this.value?.ornament?.type == 'shadow' ? '0 0 10rpx ' + this.value?.ornament?.color : '';
// 装饰效果:边框
style.border = this.value?.ornament?.type == 'stroke' ? '2rpx solid ' + this.value?.ornament?.color : '';
return style;
},
/**
* 轮播高度
* 根据模式和配置计算轮播高度
* @returns {string} 轮播高度样式
*/
swiperHeight() {
let height = 0;
const pageCount = this.value?.pageCount || 1;
// 根据不同模式计算高度
if (this.value?.mode == 'graphic') {
height = (49 + this.value?.imageSize) * pageCount;
} else if (this.value?.mode == 'img') {
height = (22 + this.value?.imageSize) * pageCount;
} else if (this.value?.mode == 'text') {
height = 43 * pageCount;
} else { // 默认视频模式
if (this.value?.aspectRatio == '16:9') {
height = [250, 160, 140, 120][this.value.rowCount - 1];
} else {
height = [510, 280, 220, 180][this.value.rowCount - 1];
}
}
return 'height:' + (2 * height) + 'rpx';
},
/**
* 是否显示指示器
* 根据轮播配置和列表长度判断是否显示指示器
* @returns {boolean} 是否显示指示器
*/
isIndicatorDots() {
// 当轮播类型不是隐藏,且视频数量超过一页时显示指示器
const pageCount = this.value?.pageCount || 1;
return this.value?.carousel?.type != 'hide' && Math.ceil(this.value?.list?.length / (pageCount * this.value?.rowCount)) > 1
},
/**
* 轮播幻灯片数据
* 将视频列表分割成轮播所需的幻灯片数据
* @returns {Array} 轮播幻灯片数据
*/
swiperSlides() {
const slides = [];
const list = this.value?.list || [];
const pageSize = (this.value?.pageCount || 1) * (this.value?.rowCount || 1);
// 将列表数据分割成每页显示的数量
for (let i = 0; i < list.length; i += pageSize) {
slides.push(list.slice(i, i + pageSize));
}
return slides;
},
/**
* 视频封面样式
* 根据aspectRatio属性动态计算封面样式
* @returns {Object} 封面样式对象
*/
computedCoverStyle() {
const aspectRatio = this.value?.aspectRatio || '16:9';
let paddingTop = '56.25%'; // 默认 16:9 比例
if (aspectRatio === '3:4') {
paddingTop = '133.33%'; // 3:4 比例
}
return {
width: '100%',
height: '0',
paddingTop: paddingTop
};
}
},
methods: {
/**
* 播放视频
* 触发 video-play 事件,并在微信小程序中调用视频播放接口
* @param {Object} item - 视频数据对象
*/
async playVideo(item) {
await this.__$emitEvent({
eventName: 'video-play',
data: item,
promiseCallback: async (event, handler, awaitedResult) => {
if (!awaitedResult) return;
try {
// 发送视频被点击播放事件
this.$emit('channel-video-click-play', item);
// #ifdef MP-WEIXIN
// 在微信小程序环境中调用视频播放接口
await wechatChannelUtil.playVideo(item);
// #endif
// #ifndef MP-WEIXIN
if (item?.channelType === 'wechat') {
uni.showToast({
title: this.$lang('wechatChannel.wechatMpOnly'),
icon: 'none',
duration: 2000
});
} else {
// 在非微信小程序环境中,直接触发事件
this.$emit('video-play', item);
}
// #endif
} catch (err) {
console.error('打开视频号失败', err);
}
}
})
},
/**
* 图片加载错误处理
* 当图片加载失败时,设置默认图片
* @param {number} index - 图片索引
*/
imgError(index) {
// 图片加载失败的处理逻辑
// 为失败的图片设置默认图片
const item = this.value.list[index];
if (item) {
// 使用默认图片替代加载失败的图片
// #ifdef MP-WEIXIN
item.coverUrl = wechatChannelConfig.video.defaultCoverUrl;
// #endif
}
},
/**
* 添加鼠标拖拽滚动功能
* 在Web环境中实现与微信小程序相同的拖拽滚动效果
*/
addMouseDragScroll() {
// 只在Web环境中添加
// #ifndef MP
let isDragging = false;
let startX = 0;
let startScrollLeft = 0;
let currentScrollElement = null;
// 查找最近的可滚动祖先元素
const findScrollableParent = (element) => {
while (element && element !== document) {
const style = window.getComputedStyle(element);
if (style.overflowX === 'auto' || style.overflowX === 'scroll') {
return element;
}
element = element.parentElement;
}
return null;
};
// 鼠标按下事件
const handleMouseDown = (e) => {
// 检查是否点击在组件内
if (this.$el.contains(e.target)) {
// 查找可滚动元素
currentScrollElement = findScrollableParent(e.target);
if (currentScrollElement) {
isDragging = true;
startX = e.pageX;
startScrollLeft = currentScrollElement.scrollLeft;
currentScrollElement.style.cursor = 'grabbing';
}
}
};
// 鼠标移动事件
const handleMouseMove = (e) => {
if (!isDragging || !currentScrollElement) return;
e.preventDefault();
const dx = e.pageX - startX;
currentScrollElement.scrollLeft = startScrollLeft - dx;
};
// 鼠标释放事件
const handleMouseUp = () => {
if (isDragging && currentScrollElement) {
currentScrollElement.style.cursor = 'grab';
}
isDragging = false;
currentScrollElement = null;
};
// 添加全局事件监听器
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseleave', handleMouseUp);
// 组件销毁时移除事件监听器
this.$once('hook:beforeDestroy', () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mouseleave', handleMouseUp);
});
// 为所有.channel-nav元素添加必要的样式
setTimeout(() => {
const channelNavs = document.querySelectorAll('.channel-nav');
channelNavs.forEach(element => {
element.style.overflowX = 'auto';
element.style.overflowY = 'hidden';
element.style.whiteSpace = 'nowrap';
element.style.width = '100%';
element.style.maxWidth = '100%';
element.style.cursor = 'grab';
element.style.userSelect = 'none'; // 防止文本选择
});
}, 100); // 延迟执行,确保DOM已完全渲染
// #endif
}
}
}
</script>
<style lang="scss" scoped>
@import './css/common-channel.scss';
.channel-list-container {
padding: 16px 16px 0px;
}
/**
* 列表布局样式
*/
.channel-list {
display: grid;
grid-template-columns: repeat(var(--row-count, 2), 1fr);
gap: 8px;
}
/**
* 导航布局样式
* 支持固定布局和滚动布局
*/
.channel-nav {
box-sizing: border-box;
padding: 16rpx;
// 确保在H5环境中可以水平滚动
overflow-x: auto;
white-space: nowrap;
// 隐藏滚动条但保留滚动功能
::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
// 启用触摸滚动和鼠标拖拽滚动
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
.uni-scroll-view-content {
/* #ifdef MP-WEIXIN */
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
/* #endif */
/* #ifndef MP-WEIXIN */
display: grid;
grid-template-columns: repeat(var(--row-count, 2), 1fr);
gap: 16rpx;
/* #endif */
}
// 单滑动模式
&.singleSlide {
.uni-scroll-view-content {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
white-space: nowrap;
}
.channel-nav-item {
flex-shrink: 0;
width: 280rpx;
}
}
.channel-nav-item {
display: flex;
flex-direction: column;
box-sizing: border-box;
/* #ifdef MP-WEIXIN */
&.row1-of1 {
width: 100%;
}
&.row1-of2 {
width: calc(50% - 8rpx);
}
&.row1-of3 {
width: calc(33.333% - 10.67rpx);
}
&.row1-of4 {
width: calc(25% - 12rpx);
}
/* #endif */
}
// 1 列布局
&.row1-of1 {
.uni-scroll-view-content {
// #ifndef MP-WEIXIN
grid-template-columns: 1fr;
// #endif
}
}
// 2 列布局
&.row1-of2 {
.uni-scroll-view-content {
// #ifndef MP-WEIXIN
grid-template-columns: repeat(2, 1fr);
// #endif
}
}
// 3 列布局
&.row1-of3 {
.uni-scroll-view-content {
// #ifndef MP-WEIXIN
grid-template-columns: repeat(3, 1fr);
// #endif
}
}
// 4 列布局
&.row1-of4 {
.uni-scroll-view-content {
// #ifndef MP-WEIXIN
grid-template-columns: repeat(4, 1fr);
// #endif
}
}
}
/**
* 确保所有视频卡片高度一致
*/
.channel-item,
.channel-nav-item {
display: flex;
flex-direction: column;
}
/**
* 轮播样式
* 支持轮播模式的布局和样式
*/
.channel-swiper {
width: 100%;
box-sizing: border-box;
.swiper-slide-content {
display: grid;
grid-template-columns: repeat(var(--row-count, 2), 1fr);
gap: 16rpx;
box-sizing: border-box;
.channel-item {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
// 轮播模式下的 1 列布局
&.row1-of1 {
grid-template-columns: 1fr;
}
// 轮播模式下的 2 列布局
&.row1-of2 {
grid-template-columns: repeat(2, 1fr);
}
// 轮播模式下的 3 列布局
&.row1-of3 {
grid-template-columns: repeat(3, 1fr);
}
// 轮播模式下的 4 列布局
&.row1-of4 {
grid-template-columns: repeat(4, 1fr);
}
}
}
/**
* 响应式调整
* 在小屏幕设备上调整布局和间距
*/
@media (max-width: 375px) {
.channel-list,
.channel-nav,
.channel-swiper .swiper-slide-content {
gap: 12rpx;
padding: 12rpx;
}
// 小屏幕上的单滑动模式
.channel-nav {
&.singleSlide {
.uni-scroll-view-content {
gap: 12rpx;
}
.channel-nav-item {
width: 240rpx;
}
}
}
}
/**
* 视频比例样式调整
* 根据不同的视频比例调整布局
*/
// // 3:4 比例的视频卡片样式
// .channel-video.ratio-3-4 {
// // 调整视频卡片的整体高度
// ::v-deep .video-cover-wrap {
// padding-top: 133.33%; // 3:4 比例
// }
// // 列表模式下的3:4比例调整
// &.list-mode {
// ::v-deep .video-cover-wrap {
// padding-top: 133.33%; // 3:4 比例
// }
// }
// }
// // 16:9 比例的视频卡片样式(默认)
// .channel-video.ratio-16-9 {
// // 保持默认的16:9比例
// ::v-deep .video-cover-wrap {
// padding-top: 56.25%; // 16:9 比例
// }
// // 列表模式下的16:9比例保持默认
// &.list-mode {
// ::v-deep .video-cover-wrap {
// padding-top: 56.25%; // 16:9 比例
// }
// }
// }</style>

View File

@@ -0,0 +1,488 @@
<template>
<view class="channel-video" :class="{ 'list-mode': listMode }">
<!-- 嵌入式视频播放 -->
<view v-if="canUseEmbedMode" class="embed-video-container">
<view class="video-cover-wrap" :style="[coverStyle]">
<!-- #ifdef MP-WEIXIN -->
<native-component>
<!-- 嵌入式视频播放组件 -->
<channel-video :feed-id="value.feedId" :finder-user-name="value.finderUserName"
:feed-token="value.feedToken" :auto-play="autoPlay">
</channel-video>
</native-component>
<!-- #endif -->
</view>
<view class="video-info">
<!-- 视频标题,支持多行显示控制 -->
<view class="video-title" :style="[{ '--title-line-clamp': titleLineClamp }]">{{ value.videoTitle }}
</view>
<!-- 视频统计信息 -->
<view class="video-stats" v-if="showViewCount && value.showViewCount">{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}</view>
</view>
</view>
<!-- 跳转式视频播放 -->
<view v-else @tap.stop="playVideo" class="video-container">
<view class="video-cover-wrap" :style="[coverStyle]">
<image class="video-cover" :src="$util.img(value.coverUrl)" mode="aspectFill" show-menu-by-longpress></image>
<view class="channel-play-btn" v-if="showPlayBtn" :style="[playBtnStyle]">
<view class="play-icon-css"></view>
</view>
<view class="video-stats-overlay" v-if="showViewCount && value.showViewCount">
{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}
</view>
</view>
<view class="video-info">
<!-- 视频标题,支持多行显示控制 -->
<view class="video-title" :style="[{ '--title-line-clamp': titleLineClamp }]">{{ value.videoTitle }}
</view>
<!-- 视频统计信息,非列表模式下显示 -->
<view class="video-stats" v-if="showViewCount && value.showViewCount && !listMode">{{ $lang('components-diy.diy-channel-video.viewCountSuffix', { count: value.viewCount }) }}</view>
</view>
</view>
</view>
</template>
<script>
import { wechatChannelUtil, wechatChannelConfig } from './js/wechat-channel.js'
/**
* 微信视频号视频卡片组件
* 支持嵌入式播放和跳转式播放两种模式
* 可配置列表模式、视频高度、标题行数等属性
*/
export default {
name: 'diy-channel-video',
props: {
/**
* 视频数据对象
* @type {Object}
* @required
* @property {string} feedId - 视频 feedId
* @property {string} feedToken - 视频 token
* @property {string} coverUrl - 视频封面图
* @property {string} videoTitle - 视频标题
* @property {number} viewCount - 观看次数
* @property {boolean} showViewCount - 是否显示观看次数
* @property {boolean} embedMode - 是否启用嵌入式播放
*/
value: {
type: Object,
required: true
},
/**
* 是否为列表模式
* @type {boolean}
* @default false
*/
listMode: {
type: Boolean,
default: false
},
/** 是否显示观看次数,由父组件可以整体配置 */
showViewCount: {
type: Boolean,
default: true
},
/**
* 标题显示行数
* @type {number}
* @default 1
*/
titleLineClamp: {
type: Number,
default: 1
},
/** 是否显示播放按钮 */
showPlayBtn: {
type: Boolean,
default: true
},
/**
* 视频封面图样式
* 采用 16:9 比例的响应式高度
*/
coverStyle: {
type: Object,
default: () => ({
width: '100%',
height: '0',
paddingTop: '56.25%' // 16:9 比例
})
},
/**
* 播放按钮样式
*/
playBtnStyle: {
type: Object,
default: () => ({
width: '60rpx',
height: '60rpx',
borderRadius: '30rpx',
backgroundColor: 'rgba(0, 0, 0, 0.6)'
})
},
/** 是否自动播放 */
autoPlay: {
type: Boolean,
default: false
},
},
computed: {
/**
* 是否支持嵌入式播放
* @returns {boolean}
*/
canUseEmbedMode() {
// #ifdef MP-WEIXIN
const enableEmbedMode = Boolean(this.value?.embedMode)
&& Boolean(this.value?.feedToken)
&& wechatChannelUtil.isEmbedModeSupported();
return enableEmbedMode;
// #endif
return false
}
},
methods: {
/**
* 播放视频
* 触发 video-play 事件,由父组件处理具体播放逻辑
*/
async playVideo() {
this.$emit('video-play', this.value);
}
}
};
</script>
<style scoped lang="scss">
@import './css/common-channel.scss';
/**
* 视频卡片容器样式
* 包含卡片基础样式、悬停效果和列表模式样式
*/
.channel-video {
position: relative;
background-color: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
height: 100%;
// 悬停效果
&:hover {
transform: translateY(-4rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
// 列表模式样式调整
&.list-mode {
border-radius: 10rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
.video-info {
padding: 14rpx;
.video-title {
font-size: 24rpx;
margin-bottom: 6rpx;
}
}
}
}
/**
* 视频容器样式
* 用于跳转式播放模式
*/
.video-container {
display: flex;
flex-direction: column;
height: 100%;
}
/**
* 嵌入式视频容器样式
* 与视频容器样式保持一致
*/
.embed-video-container {
display: flex;
flex-direction: column;
height: 100%;
}
/**
* 视频封面容器
*/
.video-cover-wrap {
position: relative;
width: 100%;
padding-top: 56.25%;
/* 16:9 比例 */
height: 0;
overflow: hidden;
border-radius: 12rpx 12rpx 0 0;
transition: transform 0.3s ease;
// 悬停时缩放效果
.channel-video:hover & {
transform: scale(1.02);
}
// 列表模式下的边框圆角调整
.channel-video.list-mode & {
border-radius: 10rpx 10rpx 0 0;
}
}
/**
* 视频封面图片
* 绝对定位填充整个容器
*/
.video-cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
/**
* 视频统计信息遮罩
* 显示在视频封面底部
*/
.video-stats-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: 12rpx 16rpx;
color: #fff;
font-size: 20rpx;
text-align: right;
font-weight: 500;
transition: opacity 0.3s ease;
// 悬停时的透明度变化
.channel-video:hover & {
opacity: 1;
}
.channel-video:not(:hover) & {
opacity: 0.8;
}
}
/**
* 视频信息区域
* 包含标题和统计信息
*/
.video-info {
padding: 16rpx;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: #fff;
transition: background-color 0.3s ease;
// 悬停时的背景色变化
.channel-video:hover & {
background-color: #fafafa;
}
}
/**
* 视频标题
* 支持多行显示控制和渐变遮罩效果
*/
.video-title {
font-size: 28rpx;
font-weight: 600;
color: #222;
margin-bottom: 10rpx;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: var(--title-line-clamp, 2);
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
position: relative;
word-break: break-all;
word-wrap: break-word;
white-space: normal;
// 悬停时的颜色变化
.channel-video:hover & {
color: #000;
}
// 列表模式下的样式调整
.channel-video.list-mode & {
-webkit-line-clamp: var(--title-line-clamp, 1);
font-size: 24rpx;
margin-bottom: 6rpx;
font-weight: 500;
color: #333;
line-height: 1.3;
}
// 添加渐变遮罩效果,让过长的文字有柔和的结束
&::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 40rpx;
height: 1.4em;
background: linear-gradient(to right, transparent, #fff 90%);
pointer-events: none;
}
// 列表模式下的遮罩高度调整
.channel-video.list-mode &::after {
height: 1.3em;
}
// 根据行数调整遮罩高度
&[style*="--title-line-clamp: 1"]::after {
height: 1.4em;
}
&[style*="--title-line-clamp: 3"]::after {
height: 4.2em;
}
&[style*="--title-line-clamp: 4"]::after {
height: 5.6em;
}
// 列表模式下的多行遮罩高度调整
.channel-video.list-mode &[style*="--title-line-clamp: 1"]::after {
height: 1.3em;
}
.channel-video.list-mode &[style*="--title-line-clamp: 3"]::after {
height: 3.9em;
}
.channel-video.list-mode &[style*="--title-line-clamp: 4"]::after {
height: 5.2em;
}
}
/**
* 视频统计信息
* 显示观看次数等数据
*/
.video-stats {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
transition: color 0.3s ease;
// 悬停时的颜色变化
.channel-video:hover & {
color: #666;
}
}
/**
* 列表模式下的播放按钮样式
* 更小的尺寸和悬停效果
*/
.channel-video.list-mode .channel-play-btn {
width: 50rpx;
height: 50rpx;
background-color: rgba(0, 0, 0, 0.5);
transition: all 0.3s ease;
// 悬停效果
&:hover {
background-color: rgba(0, 0, 0, 0.7);
transform: scale(1.1);
}
// 播放图标尺寸
.play-icon {
width: 25rpx;
height: 25rpx;
}
}
/**
* 通用播放按钮样式优化
* 添加悬停效果
*/
.channel-play-btn {
transition: all 0.3s ease;
// 悬停效果
&:hover {
background-color: rgba(0, 0, 0, 0.6);
transform: scale(1.05);
}
}
/**
* 纯 CSS 播放按钮图标
*/
.play-icon-css {
width: 0;
height: 0;
border-top: 12rpx solid transparent;
border-bottom: 12rpx solid transparent;
border-left: 18rpx solid #fff;
margin-left: 4rpx;
transition: transform 0.3s ease;
// 悬停时的缩放效果
.channel-play-btn:hover & {
transform: scale(1.1);
}
// 列表模式下的尺寸调整
.channel-video.list-mode & {
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 15rpx solid #fff;
margin-left: 3rpx;
}
}
/**
* 原生组件样式控制
* 确保 native-component 和 channel-video 正确填充容器
*/
native-component {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/**
* channel-video 组件样式控制
* 避免受到 wx-channel-video 全局样式的影响
*/
channel-video {
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
</style>

View File

@@ -4,6 +4,7 @@
<script>
// 自定义扩展组件
import DiyMinx from './minx.js'
export default {
name: 'diy-comp-extend',
props: {
@@ -11,11 +12,12 @@ export default {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {};
},
computed: {},
created() {},
created() { },
methods: {}
};
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
<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="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="."
: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="descStyle">{{ item.desc }}</text>
</view>
</view>
</view>
</scroll-view>
<ns-login ref="login"></ns-login>
</view>
</template>
<script>
import DiyMinx from './minx.js'
// 自定义数字展示
export default {
name: 'diy-digit',
props: {
value: {
type: Object,
default: () => ({})
}
},
mixins: [DiyMinx],
data() {
return {
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) {
// 监听组件刷新
}
},
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%;';
if (this.value.componentAngle == 'round') {
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
style += 'border-top-right-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
style += 'border-bottom-left-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
style += 'border-bottom-right-radius:' + (2 * this.value.bottomAroundRadius) + 'rpx;';
}
style += 'box-shadow:' + (this.value.ornament.type == 'shadow' ? '0 0 10rpx ' + this.value.ornament.color : '') + ';';
style += 'border:' + (this.value.ornament.type == 'stroke' ? '2rpx solid ' + this.value.ornament.color : '') + ';';
return style;
}
},
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) {
this.$util.diyRedirectTo(item);
} else {
this.$refs.login.open(item.wap_url);
}
},
// 轮播切换
swiperChange(event) {
this.swiperCurrent = event.detail.current;
}
}
}
</script>
<style lang="scss" scoped>
.graphic-nav {
padding: 16rpx;
box-sizing: border-box;
&.fixed-layout {
.uni-scroll-view-content {
display: flex;
flex-wrap: wrap;
}
}
&.singleSlide {
.uni-scroll-view-content {
display: flex;
}
.graphic-nav-item {
flex-shrink: 0;
}
}
&.pageSlide {
position: relative;
.uni-swiper-dots-horizontal {
bottom: 0rpx;
}
&.straightLine {
.uni-swiper-dot {
width: 30rpx;
border-radius: 0;
height: 8rpx;
}
}
&.circle {
.uni-swiper-dot {
width: 14rpx;
height: 14rpx;
}
}
}
.graphic-nav-wrap {
display: flex;
flex-wrap: wrap;
width: 100%;
height: 100%;
}
.graphic-nav-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 0;
box-sizing: border-box;
.graphic-text {
line-height: 1.3;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
text-align: center;
&.alone {
padding-top: 0;
}
}
&.text {
.graphic-text {
padding-top: 0;
}
}
.graphic-img {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
font-size: 90rpx;
.tag {
position: absolute;
top: -10rpx;
right: -24rpx;
color: #fff;
border-radius: 24rpx;
border-bottom-left-radius: 0;
-webkit-transform: scale(0.8);
transform: scale(0.8);
padding: 8rpx 16rpx;
line-height: 1;
font-size: 24rpx;
}
.icon {
font-size: 50rpx;
color: #606266;
}
}
}
&.pageSlide {
.graphic-nav-item {
flex-shrink: 0;
}
}
}
.newright {
margin-right: 16rpx;
}
.swiper-dot-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-top: -20rpx;
padding-bottom: 8rpx;
.swiper-dot {
background-color: rgba(0, 0, 0, 0.3);
margin: 8rpx;
&.active {
background-color: #000;
}
}
&.straightLine {
.swiper-dot {
width: 30rpx;
border-radius: 0;
height: 8rpx;
}
}
&.circle {
.swiper-dot {
width: 15rpx;
border-radius: 50%;
height: 15rpx;
}
}
}
</style>

View File

@@ -1,47 +1,50 @@
<template>
<view data-component-name="diy-fenxiao-goods-list" class="diy-fenxiao" v-if="list.length" :class="['goods-list', value.template, value.style]" :style="goodsListWarpCss">
<view class="goods-item" v-for="(item, index) in list" :key="index" @click="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
<view data-component-name="diy-fenxiao-goods-list" class="diy-fenxiao" v-if="list.length"
:class="['goods-list', value.template, value.style]" :style="goodsListWarpCss">
<view class="goods-item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
:class="[value.ornament.type]" :style="goodsItemCss">
<view class="goods-img" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }" :src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix" @error="imgError(index)"/>
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix" @error="imgError(index)" show-menu-by-longpress/>
</view>
<view class="info-wrap" v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl || value.btnStyle.control">
<view class="info-wrap"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl || value.btnStyle.control">
<view class="name-wrap">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]"
>
{{ item.goods_name }}
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
</view>
<view class="pro-info">
<view class="discount-price">
<view class="price-wrap" v-if="value.priceStyle.mainControl">
<text class="unit price-style small" :style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">赚 ¥</text>
<text class="price price-style large" :style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">{{ item.commission_money.split('.')[0] }}</text>
<text class="unit price-style small" :style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">{{ '.' + item.commission_money.split('.')[1] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">{{ $lang('components-diy.diy-fenxiao-goods-list.earnPrefix') }}</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">{{
item.commission_money.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor : '' }">{{ '.' +
item.commission_money.split('.')[1] }}</text>
</view>
<view class="sale-btn" v-if="value.btnStyle.control && item.is_collect == 0"
:style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}"
@click.stop="followGoods(item, index)"
>
关注
<view class="sale-btn" v-if="value.btnStyle.control && item.is_collect == 0" :style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}" @tap.stop="followGoods(item, index)">
{{ $lang('components-diy.diy-fenxiao-goods-list.follow') }}
</view>
<view class="sale-btn" v-if="value.btnStyle.control && item.is_collect == 1"
:style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}"
@click.stop="delFollowTip(item, index)"
>
取消关注
<view class="sale-btn" v-if="value.btnStyle.control && item.is_collect == 1" :style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}" @tap.stop="delFollowTip(item, index)">
{{ $lang('components-diy.diy-fenxiao-goods-list.unfollow') }}
</view>
</view>
<view class="delete-price" v-if="value.priceStyle.lineControl" :style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
<view class="delete-price" v-if="value.priceStyle.lineControl"
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">
¥{{ item.discount_price }}
</view>
</view>
@@ -51,6 +54,8 @@
</template>
<script>
// 自定义分晓商品列表
import DiyMinx from './minx.js'
export default {
name: 'diy-fenxiao-goods-list',
props: {
@@ -58,6 +63,7 @@ export default {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
list: [],
@@ -69,7 +75,7 @@ export default {
this.currentRoute = '/' + currentPage.route;
if (!this.storeToken) {
this.$util.redirectTo(
'/pages_tool/login/login',
this.$util.LOGIN_PAGE_URL,
{
back: this.currentRoute
},
@@ -80,7 +86,7 @@ export default {
},
watch: {
// 组件刷新监听
componentRefresh: function(nval) {
componentRefresh: function (nval) {
this.getData();
}
},
@@ -118,7 +124,7 @@ export default {
methods: {
//页面跳转
toDetail(e) {
this.$util.redirectTo('/pages/goods/detail', { goods_id: e.goods_id });
this.$util.redirectTo('/pages_goods/detail', { goods_id: e.goods_id });
},
//关注
followGoods(e, index) {
@@ -132,7 +138,7 @@ export default {
},
success: res => {
if (res.code >= 0) {
this.$util.showToast({ title: '关注成功' });
this.$util.showToast({ title: this.$lang('components-diy.diy-fenxiao-goods-list.followSuccess') });
this.list[index].is_collect = 1;
this.list[index].collect_id = res.data;
}
@@ -143,8 +149,8 @@ export default {
//取消关注
delFollowTip(e, index) {
uni.showModal({
title: '提示',
content: '确认取消关注该商品吗',
title: this.$lang('components-diy.diy-fenxiao-goods-list.tip'),
content: this.$lang('components-diy.diy-fenxiao-goods-list.confirmUnfollow'),
success: res => {
if (res.confirm) {
this.delFollow(e.collect_id, index);
@@ -161,7 +167,7 @@ export default {
success: res => {
let msg = '';
if (res.code == 0) {
msg = '取消成功';
msg = this.$lang('components-diy.diy-fenxiao-goods-list.cancelSuccess');
} else {
msg = res.message;
}
@@ -209,8 +215,7 @@ export default {
</script>
<style lang="scss">
.diy-fenxiao {
}
.diy-fenxiao {}
// 商品列表单列样式
.goods-list.row1-of1 {
@@ -218,9 +223,11 @@ export default {
background-color: #fff;
display: flex;
margin-bottom: 20rpx;
&:last-of-type {
margin-bottom: 0;
}
&.shadow {
margin: 8rpx 8rpx 20rpx 8rpx;
}
@@ -229,6 +236,7 @@ export default {
width: 180rpx;
overflow: hidden;
margin-right: 20rpx;
image {
width: 100%;
}
@@ -244,9 +252,11 @@ export default {
.name-wrap {
flex: 1;
margin-bottom: 10rpx;
.goods-name {
font-size: $font-size-base;
line-height: 1.3;
&.multi-hidden {
height: 72rpx;
}
@@ -257,25 +267,31 @@ export default {
display: flex;
flex-direction: column;
justify-content: space-between;
.sale {
font-size: 20rpx;
line-height: 1;
color: #999;
}
.discount-price {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
.price-wrap {
white-space: nowrap;
.unit {
font-size: $font-size-tag;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
}
text {
font-weight: bold;
color: $base-color;
@@ -291,6 +307,7 @@ export default {
font-size: $font-size-activity-tag;
}
}
.sale-btn {
position: absolute;
right: 20rpx;
@@ -310,6 +327,7 @@ export default {
.goods-list.row1-of2 {
display: flex;
flex-wrap: wrap;
.goods-item {
position: relative;
background: #fff;
@@ -317,24 +335,30 @@ export default {
margin-right: 20rpx;
margin-top: 20rpx;
width: calc(50% - 10rpx);
&:nth-child(2n + 2) {
width: calc(50% - 11rpx);
width: calc(50% - 10rpx);
margin-right: 0;
}
&:nth-of-type(1),
&:nth-of-type(2) {
margin-top: 0;
}
&.shadow {
width: calc(50% - 18rpx);
&:nth-child(2n-1) {
margin-left: 8rpx;
}
&:nth-of-type(1),
&:nth-of-type(2) {
margin-top: 8rpx;
}
}
.goods-img {
position: relative;
overflow: hidden;
@@ -355,9 +379,11 @@ export default {
.name-wrap {
margin-bottom: 10rpx;
.goods-name {
font-size: $font-size-base;
line-height: 1.3;
&.multi-hidden {
height: 72rpx;
}
@@ -369,24 +395,30 @@ export default {
display: flex;
flex-direction: column;
justify-content: space-between;
.sale {
font-size: 20rpx;
line-height: 1;
color: #999;
}
.discount-price {
display: flex;
justify-content: space-between;
align-items: center;
.price-wrap {
white-space: nowrap;
.unit {
font-size: $font-size-tag;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
}
text {
font-weight: bold;
color: $base-color;
@@ -402,6 +434,7 @@ export default {
font-size: $font-size-activity-tag;
}
}
.sale-btn {
position: absolute;
right: 20rpx;

View File

@@ -0,0 +1,160 @@
<template>
<view data-component-name="diy-float-btn" class="float-btn"
: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="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 />
<diy-icon v-else-if="item.iconType && item.iconType == 'icon'" :icon="item.icon"
:value="item.style ? item.style : null"></diy-icon>
</view>
</block>
</view>
</template>
<script>
// 自定义浮动按钮
import DiyMinx from './minx.js'
// 获取系统状态栏的高度
let systemInfo = uni.getSystemInfoSync();
// 自定义浮动按钮
export default {
name: 'diy-float-btn',
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
mixins: [DiyMinx],
data() {
return {
navHeight: 0,
statusBarHeight: systemInfo.statusBarHeight
};
},
created() { },
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
components: {},
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 = {},
height = 54;
// #ifdef MP
height = systemInfo.platform == 'ios' ? 54 : 58;
// #endif
switch (parseInt(this.value.bottomPosition)) {
case 1:
style.top = (this.navHeight + this.statusBarHeight + parseInt(this.value.btnBottom)) * 2 + 'rpx';
break;
case 2:
style.top = (this.navHeight + this.statusBarHeight + parseInt(this.value.btnBottom)) * 2 + 'rpx';
break;
case 3:
style.bottom = (100 + parseInt(this.value.btnBottom)) * 2 + 'rpx';
break;
case 4:
style.bottom = (100 + parseInt(this.value.btnBottom)) * 2 + 'rpx';
break;
}
return this.$util.objToStyle(style);
}
}
};
</script>
<style lang="scss">
.float-btn {
position: fixed;
bottom: 20%;
right: 40rpx;
z-index: 990;
&.left_top {
top: 100rpx;
left: 30rpx;
}
&.right_top {
top: 100rpx;
right: 30rpx;
}
&.left_bottom {
bottom: 160rpx;
left: 30rpx;
padding-bottom: constant(safe-area-inset-bottom);
/*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom);
/*兼容 IOS>11.2*/
}
&.right_bottom {
bottom: 160rpx;
right: 30rpx;
padding-bottom: constant(safe-area-inset-bottom);
/*兼容 IOS<11.2*/
padding-bottom: env(safe-area-inset-bottom);
/*兼容 IOS>11.2*/
}
.button-box {
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
image {
width: 100%;
height: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<!-- #ifdef MP -->
<view data-component-name="diy-follow-official-account" v-if="value.isShow">
<official-account></official-account>
</view>
<!--#endif -->
</template>
<script>
// 关注公众号展示
import DiyMinx from './minx.js'
export default {
name: 'diy-follow-official-account',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {};
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
methods: {}
};
</script>
<style></style>

930
components-diy/diy-form.vue Normal file
View File

@@ -0,0 +1,930 @@
<template>
<view data-component-name="diy-form" class="diy-from" :style="diyFormStyle">
<!-- Banner -->
<!-- 鸿蒙元服务运行时在首次 _render 时 value 可能还是 undefined,
下面所有 value.xxx 模板表达式都必须用 `value && value.xxx` 守护,
否则会触发 MP 编译器注入的 propsToData 观察者(对应编译产物里
r=function(){var o=this.value.show_banner...this.$mp.data...})
报 "Cannot read properties of undefined (reading 'show_banner')"。 -->
<view v-if="value && value.show_banner && value.form_banner" class="form-banner is-card-overlap" :style="bannerStyle + bannerBleedStyle">
<!-- Banner 图片高度策略(与商家端预览 .preview-banner height:auto 对齐):
默认 mode="widthFix":宽度撑满、高度按图片原始比例自适应,图片完整显示不裁剪;
显式配置 bannerHeight/banner_height 或 bannerCropRatio → 切 aspectFill 居中裁剪;
显式配置 bannerCropBox → 切 widthFix + image 内联 transform translate/缩放(手动裁栏框);
裁剪位置(bannerCropPosition):uni-app image 不支持 object-position,
通过 .banner-img-crop 容器的 transform translateY 控制(top/居中/底部)。 -->
<view class="banner-img-wrap" :class="bannerImgWrapClass" :style="bannerImgWrapStyle">
<view class="banner-img-crop" :class="'crop-' + bannerCropPosition">
<image :src="$util.img(value.form_banner)" :mode="bannerImgMode" class="banner-img" :style="bannerImgStyle" />
</view>
</view>
</view>
<!-- 卡片浮起模式: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>
<script>
// 自定义表单
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
import DiyMinx from './minx.js'
import 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 },
props: {
value: {
type: Object
},
// 数据源模式:
// 'diy'(默认):value.formComponents 是 [{ type, value }],组件内部转换为 ns-newform 期望的 [{ controller, value, val }]
// 'raw' :value.jsonData / value.json_data 已经是 ns-newform 期望的格式,直接透传给 ns-newform
// 用途:让 formdata 等"已是 ns-newform 格式"的页面也能复用本组件的 banner/tip/样式系统
dataMode: {
type: String,
default: 'diy',
validator: (v) => ['diy', 'raw'].includes(v)
},
// 是否渲染内置提交按钮。false 时由父级调用 ref 上的 verify()/getFormData() 自行实现提交
showSubmit: {
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: {
type: Function,
default: null
}
},
mixins: [DiyMinx],
data() {
return {
formComponents: [] // 转换后的表单组件
};
},
created() {
this.initForm();
},
watch: {
'value': {
deep: true,
handler() {
// raw 模式:formData 由父级透传,跳过重跑 initForm(避免每次父组件 re-render 触发 ns-newform 的深拷贝 initFormData)
if (this.dataMode === 'raw') return;
this.initForm();
}
}
},
computed: {
style() {
// 鸿蒙元服务运行时首次 _render 时 this.value 可能还是 undefined,必须全程加护
var css = '';
var v = this.value || {};
css += 'background-color:' + (v.contentBgColor || '') + ';';
if (v.elementAngle === 'round') {
css += 'border-top-left-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-top-right-radius:' + (v.topElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + (v.bottomElementAroundRadius || 0) * 2 + 'rpx;';
}
return css;
},
// 兼容旧 style 别名(部分页面用 :style="style" 引用)
diyFormStyle() {
return this.style;
},
// Banner 容器左右内边距(与后端 .form-preview-banner padding-x 对齐)
bannerStyle() {
return this.$util.objToStyle({
padding: `0 ${this._num(this.mergedFormConfig && this.mergedFormConfig.bannerPaddingX, 0)}rpx`
});
},
// 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: `${padT}rpx ${padR}rpx ${padB}rpx ${padL}rpx`,
'font-size': `${this._num(cfg.tipFontSize, 32)}rpx`,
'color': cfg.tipColor || (card ? '#1f2937' : '#000000'),
'background-color': cfg.tipBgColor || 'transparent',
'text-align': cfg.tipAlign || (card ? 'left' : 'center')
};
// 边框:配置了宽度(>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`,
'background-color': cfg.formBgColor || '#ffffff'
});
},
// 提交按钮样式:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
// 与后端 design.css .form-preview-submit button 对齐:
// - 高度由 --submit-padding-y + 字号决定,避免硬编码 line-height
// - 宽度由 --submit-width-percent 控制(10-100,clamp)
submitStyle() {
const cfg = this.mergedFormConfig || {};
// 宽度百分比:clamp 到 10-100
const widthPct = Math.min(100, Math.max(10, this._num(cfg.submitWidthPercent, 100)));
// 上下内边距:与后端 design.js submitSizeMap 对齐(mini/small/medium/large/huge → 0/4/12/24/40 rpx)
const submitPaddingMap = { mini: 0, small: 4, medium: 12, large: 24, huge: 40 };
const submitPaddingY = submitPaddingMap[cfg.submitSize] != null
? submitPaddingMap[cfg.submitSize]
: submitPaddingMap.medium;
return this.$util.objToStyle({
width: widthPct + '%',
padding: `${submitPaddingY}rpx 0`,
color: cfg.submitColor || '#ffffff',
'background-color': cfg.submitBgColor || '#3b82f6',
'border-radius': `${this._num(cfg.inputRadius, 10)}rpx`,
'font-size': `${this._num(cfg.labelFontSize, 28)}rpx`
});
},
// 提交按钮文字:与后端 design.html <button>提交</button> 默认对齐,开放给 formConfig 覆盖
// 走 mergedFormConfig(兼容 camelCase / snake_case),让 formConfig.submitText 能正确覆盖
submitText() {
return this.mergedFormConfig.submitText || '提交';
},
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
hasCustomSubmit() {
return typeof this.onSubmit === 'function';
},
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
formCfg() {
const v = this.value || {};
if (v.formConfig && typeof v.formConfig === 'object') {
return v.formConfig;
}
return v;
},
// 合并后的表单配置(支持平台差异化)
mergedFormConfig() {
const v = this.value || {};
const fc = this.formCfg;
// 后端实际返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
// 但旧版 design.js / 部分序列化路径可能用 snake_case(form_padding_x)。这里同时读两种写法,camelCase 优先
const self = this;
const pick = function(camel, snake) {
var v1 = fc[camel];
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'),
// 行内上下边距:兼容 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'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color'),
showRowDivider: self._coerceBool(pick('showRowDivider', 'show_row_divider')),
inputHeight: pick('inputHeight', 'input_height'),
inputRadius: pick('inputRadius', 'input_radius'),
formLayout: pick('formLayout', 'form_layout'),
labelWidthMode: pick('labelWidthMode', 'label_width_mode'),
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
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'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
textareaRows: pick('textareaRows', 'textarea_rows'),
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
submitText: pick('submitText', 'submit_text'),
submitColor: pick('submitColor', 'submit_color'),
submitBgColor: pick('submitBgColor', 'submit_bg_color'),
// 提交按钮尺寸:宽度百分比 + 尺寸预设(与后端 design.js 对齐)
submitWidthPercent: pick('submitWidthPercent', 'submit_width_percent'),
submitSize: pick('submitSize', 'submit_size'),
submitMarginTop: pick('submitMarginTop', 'submit_margin_top'),
// 提示语(与后端 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({}, 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) {
var src = termBlock[platform];
if (!src) return;
// 嵌套值若为 snake_case(部分序列化路径),在此统一转 camelCase
var translated = {};
Object.keys(src).forEach(function(k) {
translated[self.$util.snakeToCamel(k)] = src[k];
});
result[platform] = translated;
});
}
return result;
}
},
methods: {
// 初始化表单 - 将 diy-form 的数据格式转换成 ns-newform 的格式
initForm() {
if (!this.value) {
this.formComponents = [];
return;
}
// raw 模式:value.jsonData / value.json_data 已经是 ns-newform 期望的 [{ controller, value, val }],直接透传
if (this.dataMode === 'raw') {
this.formComponents = this.value.jsonData || this.value.json_data || [];
return;
}
var components = this.value.formComponents || this.value.form_components || [];
// 转换格式:type -> controller
this.formComponents = (components || []).map(function(item) {
return {
controller: item.type,
value: item.value || {},
val: (item.value && item.value.default) ? item.value.default : '' // 添加初始值
};
});
},
// 暴露给父级:触发 ns-newform 校验。校验失败时 ns-newform 内部会 toast;返回 false 表示失败,返回 formData 数组表示成功
verify() {
if (this.$refs.nsNewform && this.$refs.nsNewform.verify) {
return this.$refs.nsNewform.verify();
}
return false;
},
// 暴露给父级:获取 ns-newform 当前的 formData 数组(含 val / option_lists / img_lists 等全部字段)
getFormData() {
return (this.$refs.nsNewform && this.$refs.nsNewform.formData) || [];
},
// 数字保底:undefined/空串/非数字时返回兜底值
_num(v, fb) {
if (v === '' || v === null || v === undefined) return fb;
var n = parseInt(v);
return isNaN(n) ? fb : n;
},
// 布尔保底:0/false/'0'/'false' → false;其它有值 → true
_coerceBool(v) {
if (v === false || v === 0 || v === '0' || v === 'false' || v === null || v === undefined) {
return false;
}
return true;
},
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
handleSubmit() {
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) {
return;
}
const formData = this.getFormData();
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
if (this.hasCustomSubmit) {
return this.onSubmit(formData);
}
// 兜底:内置 sendRequest 流程
this.defaultSubmit(formData);
},
// 公共兜底实现:默认的 /form/api/form/create 流程;
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
defaultSubmit(submitFormData) {
var self = this;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.value.form_id || 0,
form_data: typeof submitFormData === 'string' ? submitFormData : JSON.stringify(submitFormData),
component_id: this.value.id || '',
form_name: this.value.form_name || '默认表单'
},
success: function(res) {
if (res.code === 0) {
uni.showToast({
title: self.$lang('submitSuccess'),
icon: 'success'
});
// 重置表单
if (self.$refs.nsNewform && self.$refs.nsNewform.setFormData) {
self.$refs.nsNewform.setFormData();
}
} else {
uni.showToast({
title: res.message || self.$lang('submitFail'),
icon: 'none'
});
}
}
});
}
}
};
</script>
<style lang="scss">
.diy-from {
background: #fff;
padding: 0;
border-radius: 10rpx;
.form-banner {
width: 100%;
overflow: hidden;
// 默认左右无边距(与后端 --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;
text-align: center;
line-height: 1.5;
}
.form-submit-wrap {
// 外层:与后端 .form-preview-submit 对齐(margin-top + padding-y + form-padding-x + form-bg-color + flex 居中)
// margin-top/padding/background 由 submitWrapStyle 内联写入
// display: flex + justify-content: center 是让宽度小于 100% 的按钮水平居中的关键
box-sizing: border-box;
width: 100%;
display: flex;
justify-content: center;
}
.form-submit {
// 提交按钮:颜色 / 背景色 / 宽度(百分比)/ 上下内边距(控制高度)/ 圆角 / 字号
// 全部由 submitStyle 内联写入
// 宽度不再写死 100%,由内联 style 的 width: <pct>% 决定;max-width: 100% 防止溢出
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
font-weight: 500;
letter-spacing: 1rpx;
transition: opacity 0.2s ease;
box-sizing: border-box;
&:active {
opacity: 0.85;
}
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<x-skeleton data-component-name="diy-goods-brand" type="waterfall" :loading="loading" :configs="skeletonConfig">
<view :class="['brand-wrap', value.ornament.type]" :style="warpCss">
<view :class="[value.style]">
<view class="title-wrap" v-show="value.title"
:style="{ color: value.textColor, fontWeight: value.fontWeight ? 'bold' : '' }">{{ value.title }}
</view>
<view class="ul-wrap">
<view class="li-item" v-for="(item, index) in list" :key="index">
<image class="brand-pic" :src="$util.img(item.image_url)" mode="aspectFit"
@tap.stop="handlerClick(item)" @error="imgError(index)"
:style="itemCss" show-menu-by-longpress/>
</view>
</view>
</view>
</view>
</x-skeleton>
</template>
<script>
// 商品品牌
import DiyMinx from './minx.js'
// 自定义商品品牌展示
export default {
name: 'diy-goods-brand',
props: {
value: {
type: Object
}
},
data() {
return {
list: [],
loading: true,
skeletonConfig: {
gridRows: 2,
gridColumns: 4,
gridRowsGap: '20rpx',
headWidth: '120rpx',
headHeight: '120rpx',
textShow: false
}
};
},
created() {
this.getBrandList();
},
mixins: [DiyMinx],
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
this.getBrandList();
}
},
computed: {
warpCss() {
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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
if (this.value.ornament.type == 'shadow') {
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color;
}
if (this.value.ornament.type == 'stroke') {
obj += 'border:' + '2rpx solid ' + this.value.ornament.color;
}
return obj;
},
// 子项样式
itemCss() {
var obj = '';
if (this.value.elementAngle == 'round') {
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
return obj;
}
},
methods: {
getBrandList() {
var data = {
page: 1,
page_size: this.value.count
};
if (this.value.sources == 'diy') {
data.page_size = 0;
data.brand_id_arr = this.value.brandIds.toString();
}
this.$api.sendRequest({
url: '/api/goodsbrand/page',
data: data,
success: res => {
if (res.code == 0 && res.data) {
let data = res.data;
this.list = data.list;
}
this.loading = false;
}
});
},
toDetail(item) {
this.$util.redirectTo('/pages_goods/list', {
brand_id: item.brand_id
});
},
imgError(index) {
if (this.list[index]) this.list[index].image_url = this.$util.getDefaultImage().goods;
},
async handlerClick(item) {
await this.__$emitEvent({
eventName: 'goods-brand-tap', data: item, promiseCallback: (event, handler, awaitedResult) => {
if (!awaitedResult) return;
this.toDetail(item);
}
})
},
}
};
</script>
<style lang="scss">
.brand-wrap {
&.shadow {
margin-left: 8rpx;
margin-right: 8rpx;
margin-top: 8rpx;
margin-bottom: 8rpx;
}
.style-1 {
.title-wrap {
text-align: center;
padding: 20rpx 0 10rpx;
}
.ul-wrap {
display: flex;
flex-wrap: wrap;
padding: 20rpx;
.li-item {
display: flex;
align-items: center;
justify-content: center;
width: calc(100% / 4 - 20rpx) !important;
height: 124rpx;
margin: 10rpx;
background-color: #fff;
.brand-pic {
width: 100%;
height: 100%;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,626 @@
<template>
<x-skeleton data-component-name="diy-goods-recommend" type="waterfall" :loading="loading" :configs="skeletonConfig">
<view v-if="list.length" :class="['goods-list', goodsValue.style]" :style="goodsListWarpCss">
<view class="top-wrap" v-if="goodsValue.topStyle.support">
<text :class="['js-icon', goodsValue.topStyle.icon.value]"
:style="{ backgroundColor: goodsValue.topStyle.icon.bgColor, color: goodsValue.topStyle.icon.color }"></text>
<text class="title" :style="{ color: goodsValue.topStyle.color }">{{ goodsValue.topStyle.title }}</text>
<text class="line" :style="{ color: goodsValue.topStyle.subColor }"></text>
<text class="sub" :style="{ color: goodsValue.topStyle.subColor }">{{ goodsValue.topStyle.subTitle
}}</text>
</view>
<swiper :autoplay="false" class="swiper" :style="{ height: swiperHeight }">
<swiper-item v-for="(item, index) in page" :key="index"
:class="['swiper-item', [list[index].length / 3] >= 1 && 'flex-between']">
<view class="goods-item" v-for="(dataItem, dataIndex) in list[index]" :key="dataIndex"
@tap.stop="toDetail(dataItem)" :class="[goodsValue.ornament.type]" :style="goodsItemCss">
<div class="goods-img-wrap">
<image class="goods-img" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(dataItem.goods_image, { size: 'mid' })" mode="widthFix"
@error="imgError(index, dataIndex)" :lazy-load="true" show-menu-by-longpress />
<view class="sell-out" v-if="dataItem.stock <= 0">
<text class="iconfont icon-shuqing"></text>
</view>
</div>
<view :class="['info-wrap', { 'multi-content': value.nameLineMode == 'multiple' }]"
v-if="goodsValue.goodsNameStyle.control || goodsValue.priceStyle.mainControl || goodsValue.priceStyle.lineControl || goodsValue.labelStyle.support">
<view v-if="goodsValue.goodsNameStyle.control" class="goods-name"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.goodsNameStyle.color : '', fontWeight: goodsValue.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': goodsValue.nameLineMode == 'single' }, { 'multi-hidden': goodsValue.nameLineMode == 'multiple' }]">
{{ isEnEnv ? dataItem.en_goods_name : dataItem.goods_name }}
</view>
<view class="pro-info">
<view class="label-wrap" v-if="goodsValue.labelStyle.support"
:style="{ background: goodsValue.labelStyle.bgColor, color: goodsValue.labelStyle.color }">
<image :src="$util.img('app/component/view/goods_recommend/img/label.png')"
mode="widthFix" show-menu-by-longpress />
<text>{{ goodsValue.labelStyle.title }}</text>
</view>
<view class="discount-price">
<view class="price-wrap" v-if="goodsValue.priceStyle.mainControl">
<text class="unit price-style small"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">{{
showPrice(dataItem).split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.mainColor + '!important' : '' }">{{
'.' + showPrice(dataItem).split('.')[1] }}</text>
</view>
<view v-if="goodsValue.priceStyle.lineControl && showMarketPrice(dataItem)"
class="delete-price price-font"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.priceStyle.lineColor : '' }">
¥{{ showMarketPrice(dataItem) }}</view>
<view class="sale" v-if="goodsValue.saleStyle.control"
:style="{ color: goodsValue.theme == 'diy' ? goodsValue.saleStyle.color : '' }">
{{ $lang('components-diy.diy-goods-recommend.soldPrefix') }}{{ dataItem.sale_num }}{{ dataItem.unit ? dataItem.unit : $lang('unit.piece') }}
</view>
</view>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</x-skeleton>
</template>
<script>
// 自定义商品推荐展示
import DiyMinx from './minx.js'
export default {
name: 'diy-goods-recommend',
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
mixins: [DiyMinx],
data() {
return {
loading: true,
skeletonConfig: {
gridRows: 1,
gridColumns: 3,
headWidth: '200rpx',
headHeight: '200rpx',
textRows: 2,
textWidth: ['100%', '60%'],
},
list: [],
goodsValue: {},
page: 1
};
},
created() {
this.goodsValue = this.value;
this.getGoodsList();
},
watch: {
'globalStoreInfo.store_id': {
handler(nval, oval) {
if (nval != oval) {
this.getGoodsList();
}
},
deep: true
},
// 组件刷新监听
componentRefresh: function (nval) {
this.getGoodsList();
}
},
computed: {
goodsListWarpCss() {
var obj = '';
obj += 'background-color:' + this.goodsValue.componentBgColor + ';';
if (this.goodsValue.componentAngle == 'round') {
obj += 'border-top-left-radius:' + this.goodsValue.topAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.goodsValue.topAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-left-radius:' + this.goodsValue.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.goodsValue.bottomAroundRadius * 2 + 'rpx;';
}
if (this.goodsValue.bgUrl) {
obj += `background-image: url('${this.$util.img(this.goodsValue.bgUrl)}');`;
}
return obj;
},
// 商品项样式
goodsItemCss() {
var obj = '';
obj += 'background-color:' + this.value.elementBgColor + ';';
if (this.goodsValue.elementAngle == 'round') {
obj += 'border-top-left-radius:' + this.goodsValue.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.goodsValue.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-left-radius:' + this.goodsValue.bottomElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.goodsValue.bottomElementAroundRadius * 2 + 'rpx;';
}
if (this.goodsValue.ornament.type == 'shadow') {
obj += 'box-shadow:' + '0 0 10rpx ' + this.goodsValue.ornament.color + ';';
}
if (this.goodsValue.ornament.type == 'stroke') {
obj += 'border:' + '2rpx solid ' + this.goodsValue.ornament.color + ';';
}
const screenWidth = uni.getSystemInfoSync().windowWidth;
var width = '';
if (this.goodsValue.style != 'style-2') {
width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value.margin
.both * 2) * 2] / 6;
} else {
width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this
.rpxUpPx(this.value.margin.both * 2) * 2
] / 6;
}
obj += 'margin-left:' + width + 'px;';
obj += 'margin-right:' + width + 'px;';
return obj;
},
swiperHeight() {
if (this.goodsValue.style == 'style-3') {
return '330rpx';
} else if (this.goodsValue.style != 'style-2') {
if (this.value.nameLineMode == 'multiple') {
return '348rpx';
}
return '312rpx';
} else {
if (this.value.nameLineMode == 'multiple') {
return '360rpx';
}
return '320rpx';
}
}
},
methods: {
rpxUpPx(res) {
const screenWidth = uni.getSystemInfoSync().windowWidth;
var data = (screenWidth * parseInt(res)) / 750;
return Math.floor(data);
},
getGoodsList() {
var data = {
num: this.goodsValue.count
};
if (this.goodsValue.sources == 'category') {
data.category_id = this.goodsValue.categoryId;
data.category_level = 1;
} else if (this.goodsValue.sources == 'diy') {
data.num = 0;
data.goods_id_arr = this.goodsValue.goodsId.toString();
}
data.order = this.goodsValue.sortWay;
this.$api.sendRequest({
url: '/api/goodssku/components',
data: data,
success: res => {
if (res.code == 0 && res.data) {
let data = res.data;
this.list = data;
// 切屏滚动,每页显示固定数量
let size = 3;
let temp = [];
this.page = Math.ceil(this.list.length / size);
for (var i = 0; i < this.page; i++) {
temp[i] = [];
for (var j = i * size; j < this.list.length; j++) {
if (temp[i].length == size) break;
temp[i].push(this.list[j]);
}
}
this.list = temp;
}
this.loading = false;
}
});
},
toDetail(item) {
this.$util.redirectTo('/pages_goods/detail', {
goods_id: item.goods_id
});
},
imgError(pageIndex, index) {
this.list[pageIndex][index].goods_image = this.$util.getDefaultImage().goods;
},
showPrice(data) {
let price = data.discount_price;
if (data.member_price && parseFloat(data.member_price) < parseFloat(price)) price = data.member_price;
return price;
},
showMarketPrice(item) {
let price = this.showPrice(item);
if (item.market_price > 0) {
return item.market_price;
} else if (item.price > price) {
return item.price;
}
return '';
},
}
};
</script>
<style lang="scss" scoped>
.goods-list {
.goods-item {
line-height: 1;
.sale {
line-height: 1;
color: $color-tip;
font-size: $font-size-activity-tag;
}
.info-wrap {
.goods-name {
margin-bottom: 10rpx;
line-height: 1.3;
}
}
.sell-out {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
text {
color: #fff;
font-size: 180rpx;
}
}
}
}
// 商品列表横向滚动样式
.goods-list.style-1 {
width: 100%;
white-space: nowrap;
background-repeat: round;
.top-wrap {
display: flex;
align-items: center;
padding: 20rpx 0;
.js-icon {
border-radius: 50%;
font-size: 40rpx;
margin-right: 10rpx;
width: 70rpx;
height: 70rpx;
text-align: center;
line-height: 70rpx;
}
.line {
height: 28rpx;
margin: 0 10rpx;
border: 2rpx solid;
}
.title {
font-weight: bold;
font-size: $font-size-toolbar;
}
.sub {
font-size: $font-size-tag;
}
}
.flex-between {
justify-content: space-between;
}
.swiper {
display: flex;
flex-wrap: wrap;
margin: 0 20rpx;
.swiper-item {
display: flex;
align-items: center;
}
}
.goods-item {
overflow: hidden;
width: 200rpx;
display: inline-block;
box-sizing: border-box;
&:nth-child(3n + 3) {
width: 198rpx;
}
&.shadow {
margin-top: 8rpx;
}
.goods-img,
.goods-img-wrap {
position: relative;
width: 100%;
height: 196rpx;
}
.info-wrap {
display: flex;
flex-direction: column;
padding: 10rpx;
&.multi-content {
height: 130rpx;
box-sizing: border-box;
}
.goods-name {
font-size: $font-size-sub;
&.multi-hidden {
white-space: break-spaces;
}
}
.pro-info {
margin-top: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
.discount-price {
display: flex;
justify-content: space-between;
align-items: center;
.price-wrap {
line-height: 1;
white-space: nowrap;
.unit {
font-size: $font-size-tag;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
}
text {
font-weight: bold;
color: $base-color;
}
}
}
.delete-price {
margin-left: 10rpx;
text-decoration: line-through;
flex: 1;
line-height: 28rpx;
color: $color-tip;
font-size: $font-size-activity-tag;
}
}
}
}
}
// 商品列表横向滚动样式
.goods-list.style-2 {
width: 100%;
white-space: nowrap;
background-repeat: round;
padding-bottom: 20rpx;
.top-wrap {
display: flex;
align-items: center;
padding: 20rpx;
.js-icon {
border-radius: 50%;
font-size: 40rpx;
margin-right: 20rpx;
width: 70rpx;
height: 70rpx;
text-align: center;
line-height: 70rpx;
}
.line {
height: 28rpx;
margin: 0 10rpx;
border: 2rpx solid;
}
.title {
font-weight: bold;
font-size: $font-size-toolbar;
}
.sub {
font-size: $font-size-tag;
}
}
.swiper {
display: flex;
flex-wrap: wrap;
margin: 0 20rpx;
padding: 20rpx;
border-radius: 20rpx;
background-color: #fff;
}
.goods-item {
overflow: hidden;
width: 200rpx;
display: inline-block;
box-sizing: border-box;
&.shadow {
margin-top: 8rpx;
width: 200rpx;
}
.goods-img,
.goods-img-wrap {
position: relative;
width: 100%;
height: 200rpx;
}
.info-wrap {
padding: 10rpx;
.goods-name {
line-height: 1;
&.multi-hidden {
line-height: 1.3;
height: 68rpx;
white-space: break-spaces;
}
}
.pro-info {
display: flex;
flex-direction: column;
justify-content: space-between;
.discount-price {
display: flex;
justify-content: space-between;
align-items: center;
.price-wrap {
line-height: 1.3;
.unit {
font-size: $font-size-tag;
color: $base-color;
}
text {
font-weight: bold;
color: $base-color;
&:last-of-type {
font-size: 32rpx;
}
}
}
}
.delete-price {
margin-left: 10rpx;
text-decoration: line-through;
flex: 1;
line-height: 28rpx;
color: $color-tip;
font-size: $font-size-activity-tag;
}
}
}
}
}
.goods-list.style-3 {
background-position: bottom;
.swiper {
display: flex;
flex-wrap: wrap;
margin: 0 20rpx;
padding: 10rpx 0;
.swiper-item {
display: flex;
align-items: center;
}
}
.goods-item {
overflow: hidden;
width: 200rpx;
display: inline-block;
box-sizing: border-box;
&.shadow {
// margin-top: 20rpx;
}
.goods-img,
.goods-img-wrap {
position: relative;
width: 100%;
height: 200rpx;
}
.info-wrap {
display: flex;
flex-direction: column;
padding: 10rpx;
.pro-info {
text-align: center;
.label-wrap {
border-radius: 40rpx;
display: inline-block;
margin: 10rpx 0;
position: relative;
padding-left: 52rpx;
padding-right: 16rpx;
line-height: 1.7;
image {
position: absolute;
top: -2rpx;
left: -2rpx;
width: 46rpx;
height: 46rpx;
}
text {
font-size: $font-size-tag;
}
}
.discount-price {
.price-wrap {
line-height: 1;
white-space: nowrap;
.unit {
font-size: $font-size-tag;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
}
text {
font-weight: bold;
color: $base-color;
}
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<view data-component-name="diy-graphic-nav" :style="componentStyle">
<block v-if="value.showStyle == 'pageSlide'">
<swiper :class="['graphic-nav', 'pageSlide', value.carousel.type]" circular :indicator-dots="false"
:style="swiperHeight" @change="swiperChange">
<swiper-item class="graphic-nav-wrap"
v-for="(numItem, numIndex) in Math.ceil(value.list.length / (value.pageCount * value.rowCount))">
<!-- #ifdef MP -->
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list"
:key="index"
v-if="index >= [(numItem) * (value.pageCount * value.rowCount)] && index < [(numItem + 1) * (value.pageCount * value.rowCount)]"
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list"
:key="index"
v-if="index >= [(numItem - 1) * (value.pageCount * value.rowCount)] && index < [numItem * (value.pageCount * value.rowCount)]"
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
<!-- #endif -->
<view class="graphic-img" v-if="value.mode != 'text'"
:style="{ fontSize: value.imageSize * 2 + 'rpx', width: value.imageSize * 2 + 'rpx', height: value.imageSize * 2 + 'rpx' }">
<image v-if="item.iconType == 'img'"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
mode="aspectFill"
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', borderRadius: value.aroundRadius * 2 + 'rpx' }"
show-menu-by-longpress />
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon"
:value="item.style ? item.style : null"
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
<text class="tag" v-if="item.label.control"
:style="{ color: item.label.textColor, backgroundImage: 'linear-gradient(' + item.label.bgColorStart + ',' + item.label.bgColorEnd + ')' }">
{{ item.label.text }}
</text>
</view>
<text v-if="value.mode != 'img'" class="graphic-text"
:style="{ fontSize: value.font.size * 2 + 'rpx', fontWeight: value.font.weight, color: value.font.color }">
{{ item.title }}
</text>
<!-- #ifdef H5 -->
</view>
<!-- #endif -->
<!-- #ifdef MP -->
</view>
<!-- #endif -->
</swiper-item>
</swiper>
<view class="swiper-dot-box" v-if="isIndicatorDots" :class="value.carousel.type">
<view v-for="(numItem, numIndex) in Math.ceil(value.list.length / (value.pageCount * value.rowCount))"
:key="numIndex">
<view class="swiper-dot" :class="{ 'active': numIndex == swiperCurrent }"></view>
</view>
</view>
</block>
<scroll-view v-else :scroll-x="value.showStyle == 'singleSlide'"
:class="['graphic-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]">
<!-- #ifdef MP -->
<view class="uni-scroll-view-content">
<!-- #endif -->
<view class="graphic-nav-item" :class="[value.mode]" v-for="(item, index) in value.list" :key="index"
:style="{ width: 100 / value.rowCount + '%' }" @tap.stop="redirectTo(item.link)">
<view class="graphic-img" v-if="value.mode != 'text'"
:style="{ fontSize: value.imageSize * 2 + 'rpx', width: value.imageSize * 2 + 'rpx', height: value.imageSize * 2 + 'rpx' }">
<image v-if="item.iconType == 'img'"
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
mode="aspectFill"
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', borderRadius: value.aroundRadius * 2 + 'rpx' }"
show-menu-by-longpress />
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon"
:value="item.style ? item.style : null"
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
<text :class="['tag', { alone: value.mode == 'text' }]" v-if="item.label.control"
:style="{ color: item.label.textColor, backgroundImage: 'linear-gradient(' + item.label.bgColorStart + ',' + item.label.bgColorEnd + ')' }">
{{ item.label.text }}
</text>
</view>
<text v-if="value.mode != 'img'" class="graphic-text"
:style="{ fontSize: value.font.size * 2 + 'rpx', fontWeight: value.font.weight, color: value.font.color }">
{{ item.title }}
</text>
</view>
<!-- #ifdef MP -->
</view>
<!-- #endif -->
</scroll-view>
<ns-login ref="login"></ns-login>
</view>
</template>
<script>
// 自定义图形导航
import DiyMinx from './minx.js'
export default {
name: 'diy-graphic-nav',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
pageWidth: '',
indicatorDots: false,
swiperCurrent: 0
};
},
created() { },
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
computed: {
componentStyle() {
var css = '';
css += 'background-color:' + this.value.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
css += 'border-top-left-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
css += 'border-top-right-radius:' + this.value.topAroundRadius * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
css += 'box-shadow:' + (this.value.ornament.type == 'shadow' ? '0 0 10rpx ' + this.value.ornament
.color :
'') + ';';
css += 'border:' + (this.value.ornament.type == 'stroke' ? '2rpx solid ' + this.value.ornament.color :
'') + ';';
return css;
},
// 滑块容器的高度
swiperHeight() {
var css = '';
var height = 0;
if (this.value.mode == 'graphic') {
height = (21 + 6 + 14 + 8 + this.value.imageSize) * this.value
.pageCount; // 21 = 文字高度,8 = 文字上边距,14 = 上下内边距,8 = 外边距
} else if (this.value.mode == 'img') {
height = (14 + 8 + this.value.imageSize) * this.value.pageCount; // 14 = 上下内边距,8 = 外边距
} else if (this.value.mode == 'text') {
height = (21 + 14 + 8) * this.value.pageCount; // 21 = 文字高度,14 = 上下内边距,8 = 外边距
}
css += 'height:' + height * 2 + 'rpx';
return css;
},
// 是否显示轮播点
isIndicatorDots() {
var bool = true;
bool = this.value.carousel.type == 'hide' || Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount)) == 1 ? false : true;
return bool;
}
},
methods: {
redirectTo(link) {
if (link.wap_url) {
if (this.$util.getCurrRoute() == this.$util.MEMBER_PAGE_URL && !this.storeToken) {
this.$refs.login.open(link.wap_url);
return;
}
}
this.$util.diyRedirectTo(link);
},
swiperChange(e) {
this.swiperCurrent = e.detail.current
}
}
};
</script>
<style>
/* 固定显示 */
.graphic-nav.fixed-layout>>>.uni-scroll-view-content {
display: flex;
flex-wrap: wrap;
}
/* 单行滑动 */
.graphic-nav.singleSlide>>>.uni-scroll-view-content {
display: flex;
}
.graphic-nav.pageSlide>>>.uni-swiper-dots-horizontal {
bottom: 0rpx;
}
.graphic-nav.pageSlide.straightLine>>>.uni-swiper-dot {
width: 30rpx;
border-radius: 0;
height: 8rpx;
}
.graphic-nav.pageSlide.circle>>>.uni-swiper-dot {
width: 14rpx;
height: 14rpx;
}
</style>
<style lang="scss">
.graphic-nav {
padding: 16rpx;
box-sizing: border-box;
&.singleSlide {
.graphic-nav-item {
flex-shrink: 0;
}
}
&.pageSlide {
position: relative;
.graphic-nav-wrap {
display: flex;
flex-wrap: wrap;
width: 100%;
height: 100%;
}
}
.graphic-nav-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 14rpx 0;
box-sizing: border-box;
.graphic-text {
padding-top: 12rpx;
line-height: 1.5;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
text-align: center;
&.alone {
padding-top: 0;
}
}
&.text {
.graphic-text {
padding-top: 0;
}
}
.graphic-img {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100rpx;
height: 100rpx;
font-size: 90rpx;
.tag {
position: absolute;
top: -10rpx;
right: -24rpx;
color: #fff;
border-radius: 24rpx;
border-bottom-left-radius: 0;
transform: scale(0.8);
padding: 8rpx 16rpx;
line-height: 1;
font-size: 24rpx;
}
.icon {
font-size: 50rpx;
color: $color-sub;
}
}
}
}
.swiper-dot-box {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-top: -20rpx;
padding-bottom: 8rpx;
.swiper-dot {
background-color: rgba(0, 0, 0, .3);
margin: 8rpx;
&.active {
background-color: rgba(0, 0, 0, 1);
}
}
&.straightLine {
.swiper-dot {
width: 30rpx;
border-radius: 0;
height: 8rpx;
}
}
&.circle {
.swiper-dot {
width: 15rpx;
border-radius: 50%;
height: 15rpx;
}
}
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<view data-component-name="diy-group" class="diy-group">
<view v-for="(item, index) in diyDataArray" :key="index" :style="item.pageStyle">
<view v-if="addonIsExist.store && item.componentName == 'StoreShow'">
<!-- 门店展示 -->
<diy-store :value="item"></diy-store>
</view>
<template v-if="item.componentName == 'Kefu'">
<!-- 客服按钮 -->
<diy-kefu :value="item"></diy-kefu>
</template>
<template v-if="item.componentName == 'Form'">
<!-- 表单组件 -->
<diy-form :value="item"></diy-form>
</template>
<template v-if="addonIsExist.store && item.componentName == 'StoreLabel'">
<!-- 门店标签 -->
<diy-store-label :value="item"></diy-store-label>
</template>
<template v-if="item.componentName == 'Picture'">
<!-- 单图组组件 -->
<diy-picture :value="item"></diy-picture>
</template>
<template v-if="item.componentName == 'Listmenu'">
<!-- 列表按钮组件 -->
<diy-listmenu :value="item"></diy-listmenu>
</template>
<template v-if="item.componentName == 'Text'">
<!-- 文本 -->
<diy-text :value="item"></diy-text>
</template>
<template v-if="item.componentName == 'Notice'">
<!-- 公告 -->
<diy-notice :value="item"></diy-notice>
</template>
<template v-if="item.componentName == 'GraphicNav'">
<!-- 图文导航 -->
<diy-graphic-nav :value="item"></diy-graphic-nav>
</template>
<template v-if="item.componentName == 'ImageAds'">
<!-- 图片广告 -->
<diy-img-ads :value="item"></diy-img-ads>
</template>
<template v-if="item.componentName == 'Search'">
<!-- 搜索 -->
<diy-search :value="item" :topNavColor="topNavColor" :global="diyGlobalData.global"
:haveTopCategory="haveTopCategory" :followOfficialAccount="followOfficialAccount"></diy-search>
</template>
<template v-if="item.componentName == 'RichText'">
<!-- 富文本 -->
<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>
</template>
<template v-if="item.componentName == 'HorzBlank'">
<!-- 辅助空白 -->
<diy-horz-blank :value="item"></diy-horz-blank>
</template>
<template v-if="item.componentName == 'Coupon' && addonIsExist.coupon">
<!-- 优惠券 -->
<diy-coupon :value="item"></diy-coupon>
</template>
<template v-if="item.componentName == 'GoodsList'">
<!-- 商品列表 -->
<diy-goods-list :value="item"></diy-goods-list>
</template>
<template v-if="item.componentName == 'ManyGoodsList'">
<!-- 多商品组 -->
<diy-many-goods-list :value="item" :global="diyGlobalData.global"
:scrollTop="scrollTop"></diy-many-goods-list>
</template>
<template v-if="item.componentName == 'RubikCube'">
<!-- 魔方、橱窗 -->
<diy-rubik-cube :value="item"></diy-rubik-cube>
</template>
<template v-if="item.componentName == 'Video'">
<!-- 视频 -->
<diy-video :value="item"></diy-video>
</template>
<template v-if="item.componentName == 'Seckill' && addonIsExist.seckill">
<!-- 秒杀 -->
<diy-seckill :value="item"></diy-seckill>
</template>
<template v-if="item.componentName == 'Pintuan' && addonIsExist.pintuan">
<!-- 拼团 -->
<diy-pintuan :value="item"></diy-pintuan>
</template>
<template v-if="item.componentName == 'Groupbuy' && addonIsExist.groupbuy">
<!-- 团购 -->
<diy-groupbuy :value="item"></diy-groupbuy>
</template>
<!-- 拼团返利 -->
<template v-if="item.componentName == 'Pinfan' && addonIsExist.pinfan">
<diy-pinfan :value="item"></diy-pinfan>
</template>
<template v-if="item.componentName == 'Bargain' && addonIsExist.bargain">
<!-- 砍价 -->
<diy-bargain :value="item"></diy-bargain>
</template>
<template v-if="item.componentName == 'Presale' && addonIsExist.bargain">
<!-- 预售 -->
<diy-presale :value="item"></diy-presale>
</template>
<template v-if="item.componentName == 'Notes' && addonIsExist.notes">
<!-- 店铺笔记 -->
<diy-notes :value="item"></diy-notes>
</template>
<template v-if="item.componentName == 'FloatBtn'">
<!-- 浮动按钮 -->
<diy-float-btn :value="item"></diy-float-btn>
</template>
<template v-if="item.componentName == 'LiveInfo'">
<!-- 小程序直播 -->
<!-- #ifdef MP-WEIXIN -->
<diy-live :value="item"></diy-live>
<!-- #endif -->
</template>
<template v-if="item.componentName == 'FenxiaoGoodsList'">
<!-- 分销商品 -->
<diy-fenxiao-goods-list :value="item"></diy-fenxiao-goods-list>
</template>
<template v-if="item.componentName == 'GoodsRecommend'">
<!-- 商品推荐 -->
<diy-goods-recommend :value="item"></diy-goods-recommend>
</template>
<template v-if="item.componentName == 'DiyRecommendCode'">
<!-- 自定义推荐码组件 -->
<diy-recommend-code :value="item"></diy-recommend-code>
</template>
<template v-if="item.componentName == 'GoodsBrand'">
<!-- 商品品牌 -->
<diy-goods-brand :value="item"></diy-goods-brand>
</template>
<template v-if="item.componentName == 'Article'">
<!-- 文章 -->
<diy-article :value="item"></diy-article>
</template>
<template v-if="item.componentName == 'MerchList'">
<!-- 商户列表 -->
<diy-merch-list :value="item"></diy-merch-list>
</template>
<template v-if="item.componentName == 'MemberInfo'">
<!-- 自定义会员中心——会员信息 -->
<diy-member-info ref="diyMemberIndex" :value="item" :global="diyGlobalData.global"></diy-member-info>
</template>
<template v-if="item.componentName == 'MemberMyOrder'">
<!-- 自定义会员中心——我的订单 -->
<diy-member-my-order ref="diyMemberMyOrder" :value="item"></diy-member-my-order>
</template>
<template v-if="item.componentName == 'QuickNav'">
<!-- 快捷导航 -->
<diy-quick-nav :value="item"></diy-quick-nav>
</template>
<template v-if="item.componentName == 'PaymentQrcode'">
<!-- 付款码 -->
<diy-payment-qrcode :value="item"></diy-payment-qrcode>
</template>
<template v-if="item.componentName == 'HotArea'">
<!-- 热区 -->
<diy-hot-area :value="item"></diy-hot-area>
</template>
<template v-if="item.componentName == 'FollowOfficialAccount'">
<!-- 关注公众号 -->
<diy-follow-official-account :value="item"></diy-follow-official-account>
</template>
<template v-if="item.componentName == 'Map'">
<!-- 地图组件 -->
<diy-map :value="item"></diy-map>
</template>
<template v-if="item.componentName == 'Audio'">
<!-- 音频 -->
<diy-audio :value="item"></diy-audio>
</template>
<template v-if="item.componentName == 'ImageNav'">
<!-- 图片导航 -->
<diy-image-nav :value="item"></diy-image-nav>
</template>
<template v-if="item.componentName == 'Digit'">
<!-- 数字 -->
<diy-digit :value="item"></diy-digit>
</template>
<template v-if="item.componentName == 'VideoList'">
<!-- 视频列表 -->
<diy-video-list :value="item"></diy-video-list>
</template>
<template v-if="item.componentName == 'BottomNav'">
<!-- 底部导航 -->
<diy-bottom-nav :value="item"></diy-bottom-nav>
</template>
<template v-if="item.componentName == 'CategoryItem'">
<!-- 分类项 -->
<diy-category-item :value="item"></diy-category-item>
</template>
<template v-if="item.componentName == 'Category'">
<!-- 分类 -->
<diy-category :value="item"></diy-category>
</template>
<template v-if="item.componentName == 'Icon'">
<!-- 图标 -->
<diy-icon :value="item"></diy-icon>
</template>
<template v-if="item.componentName == 'Tab'">
<!-- Tab 组件 -->
<diy-tab :value="item" :diyGlobal="diyGlobalData"></diy-tab>
</template>
<template v-if="['ChannelList', 'WechatChannel'].includes(item.componentName)">
<!-- 视频号列表 -->
<diy-channel-list :value="item"></diy-channel-list>
</template>
<!-- 自定义扩展组件 -->
<diy-comp-extend :value="item"></diy-comp-extend>
</view>
</view>
</template>
<script>
// 组件组展示
import DiyMinx from './minx.js'
export default {
name: 'diy-group',
props: {
diyData: {
type: Object
},
scrollTop: {
type: [String, Number],
default: '0'
},
haveTopCategory: {
type: Boolean
},
followOfficialAccount: {
type: Object
},
},
mixins: [DiyMinx],
data() {
return {
diyGlobalData: this.diyData ? this.$util.deepClone(this.diyData) : null,
processedData: null
};
},
watch: {
diyData: {
handler() {
this.diyGlobalData = this.diyData ? this.$util.deepClone(this.diyData) : null;
this.processData();
this._notifyLoaded();
},
deep: true
}
},
created() {
console.log('[DIY-GROUP][created] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
this.processData();
},
mounted() {
console.log('[DIY-GROUP][mounted] hasDiyData=', !!this.diyData, 'valueLen=', this.diyData && this.diyData.value && this.diyData.value.length);
if (this.diyData && this.diyData.value) {
this._notifyLoaded();
}
},
computed: {
topNavColor() {
if (!this.diyData?.global) return '';
const { topNavBg, topNavColor } = this.diyData.global;
if (topNavBg) {
return this.scrollTop > 20 ? topNavColor : 'transparent';
}
return topNavColor;
},
diyDataArray() {
if (!this.processedData) {
this.processData();
}
const showModuleData = this.$store.state.diyGroupShowModule
? JSON.parse(this.$store.state.diyGroupShowModule)
: null;
if (!showModuleData || !showModuleData.length) {
return this.processedData || [];
}
if (showModuleData.includes('null')) {
return [];
}
return (this.processedData || []).filter(item =>
showModuleData.includes(item.componentName)
);
}
},
methods: {
_notifyLoaded() {
console.log('[DIY-GROUP][_notifyLoaded] enter, will emit loaded in 300ms');
if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => {
console.log('[DIY-GROUP][_notifyLoaded] emit loaded');
this.$emit('loaded');
}, 300);
},
processData() {
if (!this.diyGlobalData || !this.diyGlobalData.value) {
this.processedData = [];
return;
}
this.processedData = this.diyGlobalData.value.map((item, index) => {
const newItem = this.$util.deepClone(item);
newItem.pageStyle = '';
newItem.moduleIndex = this.diyData.tabIndex !== undefined
? `${this.diyData.tabIndex}-${index + 1}`
: index + 1;
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
// 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};`;
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
// ① 标准对象形态 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;
});
}
}
};
</script>
<style lang="scss">
.diy-group {
width: 100%;
}
</style>

View File

@@ -0,0 +1,500 @@
<template>
<x-skeleton data-component-name="diy-groupbuy" :type="skeletonType" :loading="loading" :configs="skeletonConfig">
<view class="diy-groupbuy" :class="[value.template, value.style]" :style="warpCss">
<template v-if="value.template == 'row1-of1'">
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
:class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(index)" show-menu-by-longpress>
</image>
</view>
<view class="content"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl || value.btnStyle.control">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="discount-price" v-if="value.priceStyle.mainControl">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.groupbuy_price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
'.' + item.groupbuy_price.split('.')[1] }}</text>
</view>
<button v-if="value.btnStyle.control" :style="{
background: value.btnStyle.theme == 'diy' ? 'linear-gradient(to right,' + value.btnStyle.bgColorStart + ',' + value.btnStyle.bgColorEnd + ')' : '',
color: value.btnStyle.theme == 'diy' ? value.btnStyle.textColor : '',
borderRadius: value.btnStyle.aroundRadius * 2 + 'rpx'
}">
{{ value.btnStyle.text }}
</button>
</view>
</view>
</template>
<template v-if="value.template == 'horizontal-slide'">
<scroll-view v-if="value.slideMode == 'scroll'" class="scroll" :scroll-x="true" :show-scrollbar="false">
<view class="item" v-for="(item, index) in list" :key="index" @tap.stop="toDetail(item)"
:class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(index)" show-menu-by-longpress/>
<image class="bg" v-if="value.saleStyle.control"
:src="$util.img('public/uniapp/groupbuy/bg.png')" mode="widthFix" />
<view class="num" v-if="value.saleStyle.control"
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">
已团{{ item.sell_num }}件
</view>
</view>
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="discount-price" v-if="value.priceStyle.mainControl">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.groupbuy_price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
'.' + item.groupbuy_price.split('.')[1] }}</text>
</view>
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">¥{{
item.price }}</view>
</view>
</view>
</scroll-view>
<swiper v-if="value.slideMode == 'slide'" :autoplay="false" class="swiper"
:style="{ height: swiperHeight }">
<swiper-item v-for="(pageItem, pageIndex) in page" :key="pageIndex"
:class="['swiper-item', (list.length && [list[pageIndex].length / 3] >= 1) && 'flex-between']">
<view class="item" v-for="(item, dataIndex) in list[pageIndex]" :key="dataIndex"
@tap.stop="toDetail(item)" :class="[value.ornament.type]" :style="goodsItemCss">
<view class="img-wrap" :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }">
<image :style="{ borderRadius: value.imgAroundRadius * 2 + 'rpx' }"
:src="$util.img(item.goods_image, { size: 'mid' })" mode="widthFix"
@error="imageError(dataIndex)" show-menu-by-longpress/>
<image class="bg" v-if="value.saleStyle.control"
:src="$util.img('public/uniapp/groupbuy/bg.png')" mode="widthFix" />
<view class="num" v-if="value.saleStyle.control"
:style="{ color: value.theme == 'diy' ? value.saleStyle.color : '' }">已团{{
item.sell_num }}件</view>
</view>
<view :class="['content', { 'multi-content': value.nameLineMode == 'multiple' }]"
v-if="value.goodsNameStyle.control || value.priceStyle.mainControl || value.priceStyle.lineControl">
<view v-if="value.goodsNameStyle.control" class="goods-name"
:style="{ color: value.theme == 'diy' ? value.goodsNameStyle.color : '', fontWeight: value.goodsNameStyle.fontWeight ? 'bold' : '' }"
:class="[{ 'using-hidden': value.nameLineMode == 'single' }, { 'multi-hidden': value.nameLineMode == 'multiple' }]">
{{ isEnEnv ? item.en_goods_name : item.goods_name }}
</view>
<view class="discount-price" v-if="value.priceStyle.mainControl">
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">¥</text>
<text class="price price-style large"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
item.groupbuy_price.split('.')[0] }}</text>
<text class="unit price-style small"
:style="{ color: value.theme == 'diy' ? value.priceStyle.mainColor + '!important' : '' }">{{
'.' + item.groupbuy_price.split('.')[1] }}</text>
</view>
<view class="original-price price-font" v-if="value.priceStyle.lineControl"
:style="{ color: value.theme == 'diy' ? value.priceStyle.lineColor : '' }">¥{{
item.price }}</view>
</view>
</view>
</swiper-item>
</swiper>
</template>
</view>
</x-skeleton>
</template>
<script>
// 自定义团购展示
import DiyMinx from './minx.js'
export default {
name: 'diy-groupbuy',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
loading: true,
skeletonType: '',
skeletonConfig: {},
list: [],
page: 1
};
},
created() {
this.initSkeleton();
this.getData();
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
this.getData();
}
},
computed: {
warpCss() {
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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
return obj;
},
// 商品项样式
goodsItemCss() {
var obj = '';
obj += 'background-color:' + this.value.elementBgColor + ';';
if (this.value.elementAngle == 'round') {
obj += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
if (this.value.ornament.type == 'shadow') {
obj += 'box-shadow:' + '0 0 10rpx ' + this.value.ornament.color + ';';
}
if (this.value.ornament.type == 'stroke') {
obj += 'border:' + '2rpx solid ' + this.value.ornament.color + ';';
}
const screenWidth = uni.getSystemInfoSync().windowWidth;
if (this.value.template == 'horizontal-slide') {
var width = '';
if (this.value.slideMode == 'scroll' && this.value.goodsMarginType == 'diy') width = this.rpxUpPx(this
.value.goodsMarginNum * 2);
else width = [screenWidth - this.rpxUpPx(20) * 2 - this.rpxUpPx(200) * 3 - this.rpxUpPx(this.value
.margin.both * 2) * 2] / 6;
obj += 'margin-left:' + width + 'px;';
obj += 'margin-right:' + width + 'px;';
}
return obj;
},
swiperHeight() {
if (this.value.nameLineMode == 'multiple') return this.value.ornament.type == 'shadow' ? '404rpx' :
'392rpx';
return this.value.ornament.type == 'shadow' ? '376rpx' : '368rpx';
}
},
methods: {
initSkeleton() {
if (this.value.template == 'row1-of1') {
// 单列 风格
this.skeletonType = 'list';
this.skeletonConfig = {
textRows: 2
};
} else if (this.value.template == 'horizontal-slide') {
// 横向滑动 风格
this.skeletonType = 'waterfall';
this.skeletonConfig = {
gridRows: 1,
gridColumns: 3,
headHeight: '200rpx',
textRows: 2,
textWidth: ['100%', '80%']
};
}
},
rpxUpPx(res) {
const screenWidth = uni.getSystemInfoSync().windowWidth;
var data = (screenWidth * parseInt(res)) / 750;
return Math.floor(data);
},
getData() {
var data = {
num: this.value.count
};
if (this.value.sources == 'diy') {
data.num = 0;
data.goods_id_arr = this.value.goodsId.toString();
}
this.$api.sendRequest({
url: '/groupbuy/api/goods/lists',
data: data,
success: res => {
if (res.code == 0) {
this.list = res.data;
// 切屏滚动,每页显示固定数量
if (this.value.template == 'horizontal-slide' && this.value.slideMode == 'slide') {
let size = 3;
let temp = [];
this.page = Math.ceil(this.list.length / size);
for (var i = 0; i < this.page; i++) {
temp[i] = [];
for (var j = i * size; j < this.list.length; j++) {
if (temp[i].length == size) break;
temp[i].push(this.list[j]);
}
}
this.list = temp;
}
this.loading = false;
}
}
});
},
toDetail(e) {
this.$util.redirectTo('/pages_promotion/groupbuy/detail', {
groupbuy_id: e.groupbuy_id
});
},
imageError(index) {
this.list[index].goods_image = this.$util.getDefaultImage().goods;
this.$forceUpdate();
}
}
};
</script>
<style lang="scss">
::v-deep .uni-scroll-view ::-webkit-scrollbar {
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
display: none;
width: 0;
height: 0;
color: transparent;
background: transparent;
}
::v-deep ::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
background: transparent;
}
scroll-view ::-webkit-scrollbar {
width: 0;
height: 0;
background-color: transparent;
touch-action: none;
}
.diy-groupbuy {
&.row1-of1 {
.item {
display: flex;
margin-bottom: 20rpx;
padding: 16rpx;
&.shadow {
margin: 8rpx 8rpx 20rpx 8rpx;
}
&:last-child {
margin-bottom: 0;
padding-bottom: 20rpx;
}
.img-wrap {
width: 200rpx;
height: 200rpx;
>image {
width: 200rpx;
}
}
.goods-name {
margin-top: 6rpx;
line-height: 1.5;
}
.content {
flex: 1;
margin-left: 20rpx;
position: relative;
.discount-price {
white-space: nowrap;
font-weight: bold;
position: absolute;
bottom: 20rpx;
left: 0;
display: flex;
align-items: baseline;
line-height: 1;
.unit {
font-size: $font-size-tag;
margin-right: 4rpx;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
color: $base-color;
}
}
button {
position: absolute;
bottom: 10rpx;
right: 20rpx;
margin: 0;
padding: 0 20rpx;
background-color: $base-color;
color: #fff;
min-width: 112rpx;
height: 52rpx;
line-height: 52rpx;
font-size: $font-size-tag;
}
}
}
}
&.horizontal-slide {
.scroll {
width: calc(100% - 40rpx);
padding: 20rpx;
line-height: 1;
white-space: nowrap;
.item.shadow {
margin-bottom: 8rpx;
}
}
.flex-between {
justify-content: space-between;
}
.item {
display: inline-block;
width: 200rpx;
overflow: hidden;
box-sizing: border-box;
&:nth-child(3n + 3) {
width: 198rpx;
}
&.shadow {
margin-top: 8rpx;
}
.img-wrap {
width: 200rpx;
height: 200rpx;
position: relative;
overflow: hidden;
margin: 0 auto;
>image {
width: 200rpx;
}
.bg {
position: absolute;
width: 100%;
height: 60rpx;
bottom: 0;
left: 0;
z-index: 2;
}
.num {
width: 180rpx;
position: absolute;
bottom: 10rpx;
padding-left: 20rpx;
font-size: 20rpx;
line-height: 1;
color: #ffffff;
z-index: 3;
}
}
.content {
padding: 10rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
&.multi-content {
height: 158rpx;
box-sizing: border-box;
}
.goods-name {
line-height: 1.3;
&.multi-hidden {
white-space: break-spaces;
}
}
.discount-price {
white-space: nowrap;
margin-top: auto;
font-weight: bold;
line-height: 1;
.unit {
font-size: $font-size-tag;
margin-right: 4rpx;
color: $base-color;
}
.price {
font-size: $font-size-toolbar;
color: $base-color;
}
}
.original-price {
font-size: $font-size-tag;
color: $color-tip;
line-height: 1;
text-decoration: line-through;
}
}
}
.swiper {
width: 100%;
white-space: nowrap;
padding: 20rpx;
box-sizing: border-box;
.swiper-item {
display: flex;
align-items: center;
}
.item {
width: 200rpx;
}
}
}
}
</style>

View File

@@ -4,6 +4,7 @@
<script>
// 辅助空白
import DiyMinx from './minx.js'
export default {
name: 'diy-horz-blank',
props: {
@@ -11,15 +12,16 @@ export default {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {};
},
watch: {
// 组件刷新监听
componentRefresh: function(nval) {}
componentRefresh: function (nval) { }
},
computed: {
horzBlankGaugeWrap: function() {
horzBlankGaugeWrap: function () {
var obj = '';
obj += 'background-color:' + this.value.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
@@ -32,7 +34,7 @@ export default {
return obj;
}
},
created() {},
created() { },
methods: {}
};
</script>

View File

@@ -1,9 +1,11 @@
<template>
<view data-component-name="diy-horz-line" :style="{ borderTop: '2rpx ' + value.borderStyle + ' ' + value.color }"></view>
<view data-component-name="diy-horz-line" :style="{ borderTop: '2rpx ' + value.borderStyle + ' ' + value.color }">
</view>
</template>
<script>
// 辅助线
import DiyMinx from './minx.js'
export default {
name: 'diy-horz-line',
props: {
@@ -11,12 +13,13 @@ export default {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {};
},
watch: {
// 组件刷新监听
componentRefresh: function(nval) {}
componentRefresh: function (nval) { }
},
methods: {}
};

View File

@@ -0,0 +1,77 @@
<template>
<view data-component-name="diy-hot-area" :style="hotAreaWarp" class="hot-area-box">
<view class="simple-graph-wrap">
<image :style="{ height: value.imgHeight }" :src="$util.img(value.imageUrl)" mode="widthFix"
show-menu-by-longpress />
<!-- 热区功能 -->
<view class="heat-map" v-for="(mapItem, mapIndex) in value.heatMapData" :key="mapIndex" :style="{
width: mapItem.width + '%',
height: mapItem.height + '%',
left: mapItem.left + '%',
top: mapItem.top + '%'
}" @tap.stop="$util.diyRedirectTo(mapItem.link)"></view>
</view>
</view>
</template>
<script>
// 热区展示
import DiyMinx from './minx.js'
export default {
name: 'diy-hot-area',
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
mixins: [DiyMinx],
data() {
return {};
},
created() { },
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
computed: {
hotAreaWarp: function () {
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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
return obj;
}
},
methods: {}
};
</script>
<style lang="scss" scoped>
.hot-area-box {
position: relative;
width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.simple-graph-wrap {
line-height: 0;
overflow: hidden;
position: relative;
image {
width: 100%;
}
.heat-map {
position: absolute;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<view data-component-name="diy-icon" class="diy-icon" :style="iconBgStyle">
<text class="js-icon" :class="iconClass" :style="iconStyle"></text>
</view>
</template>
<script>
// 图标展示
import DiyMinx from './minx.js'
export default {
name: 'diy-icon',
props: {
icon: {
type: String,
default: ''
},
value: {
type: Object,
default: function () {
return null;
}
}
},
mixins: [DiyMinx],
computed: {
iconClass() {
var _class = ' ' + this.icon;
if (this.value && this.value.iconColor.length > 1) _class += ' gradient';
return _class;
},
iconBgStyle() {
if (!this.value) return {};
var style = {
'border-radius': this.value.bgRadius + '%',
'background': ''
};
if (this.value.iconBgImg) style['background'] += 'url(' + this.$util.img(this.value.iconBgImg) + ') no-repeat bottom / contain'
if (this.value.iconBgColor.length) {
if (style.background) style.background += ',';
if (this.value.iconBgColor.length == 1) {
style.background += this.value.iconBgColor[0];
} else {
style['background'] += 'linear-gradient(' + this.value.iconBgColorDeg + 'deg, ' + this.value.iconBgColor.join(',') + ')';
}
}
return this.$util.objToStyle(style);
},
iconStyle() {
if (!this.value) return {};
var style = {
'font-size': this.value.fontSize + '%'
}
if (this.value.iconColor.length == 1) {
style.color = this.value.iconColor[0];
} else {
style['background'] = 'linear-gradient(' + this.value.iconColorDeg + 'deg, ' + this.value.iconColor.join(',') + ')';
}
return this.$util.objToStyle(style);
}
}
}
</script>
<style lang="scss">
.diy-icon {
width: 100%;
height: 100%;
font-size: 100%;
color: #000;
display: flex;
align-items: center;
justify-content: center;
.js-icon {
font-size: 50%;
line-height: 1;
padding: 1rpx;
&.gradient {
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
<template>
<view data-component-name="diy-img-ads" class="single-graph">
<view :style="imgAdsMarginWarp" class="swiper-box">
<block v-if="imgAdsValue.list.length == 1">
<view class="simple-graph-wrap" :style="imgAdsSwiper" @tap.stop="handlerClick(imgAdsValue.list[0].link)">
<image :style="{ height: imgAdsValue.list[0].imgHeight }"
:src="$util.img(imgAdsValue.list[0].imageUrl)" mode="widthFix" show-menu-by-longpress />
</view>
</block>
<swiper v-else class="swiper" :style="{ height: swiperHeight }" :class="{
'swiper-left': imgAdsValue.indicatorLocation == 'left',
'swiper-right': imgAdsValue.indicatorLocation == 'right',
'ns-indicator-dots': imgAdsValue.carouselStyle == 'line'
}" :autoplay="true" :interval="imgAdsValue.interval" circular="true" :indicator-dots="isDots"
indicator-color="rgba(130, 130, 130, .5)" :indicator-active-color="imgAdsValue.indicatorColor"
@change="swiperChange">
<swiper-item class="swiper-item" :style="imgAdsSwiper" v-for="(item, index) in imgAdsValue.list"
:key="index" v-if="item.imageUrl" @tap.stop="handlerClick(item.link)">
<view class="item" :style="imgAdsSwiper + 'height: ' + item.imgHeight">
<image :src="$util.img(item.imageUrl)" :mode="item.imageMode || 'scaleToFill'"
show-menu-by-longpress />
</view>
</swiper-item>
</swiper>
<!-- #ifdef MP-WEIXIN -->
<view v-if="imgAdsValue.list.length > 1 && value.indicatorIsShow" :class="[
'swiper-dot-box',
{ straightLine: imgAdsValue.carouselStyle == 'line' },
{ 'swiper-left': imgAdsValue.indicatorLocation == 'left' },
{ 'swiper-right': imgAdsValue.indicatorLocation == 'right' }
]">
<view v-for="(numItem, numIndex) in imgAdsValue.list.length" :key="numIndex"
:class="['swiper-dot', { active: numIndex == swiperIndex }]"
:style="[numIndex == swiperIndex && { backgroundColor: imgAdsValue.indicatorColor }]"></view>
</view>
<!-- #endif -->
</view>
</view>
</template>
<script>
// 图片广告
import DiyMinx from './minx.js'
export default {
name: 'diy-img-ads',
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
data() {
return {
isDots: true,
swiperHeight: 0,
imgAdsValue: null, // 深拷贝一遍数据,防止动态计算图片展示尺寸的时候,影响到父级的数据,导致二次渲染的时候,数据错误
swiperIndex: 0
};
},
created() {
this.calcSingleRow();
},
mixins: [DiyMinx],
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
},
computed: {
imgAdsMarginWarp: function () {
var obj = '';
obj = 'background-color:' + this.value.componentBgColor + ';';
return obj;
},
imgAdsSwiper: function () {
var obj = '';
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;';
obj += 'border-bottom-left-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
obj += 'border-bottom-right-radius:' + this.value.bottomAroundRadius * 2 + 'rpx;';
}
return obj;
},
singleGraphBg: function () {
var imgArr = [];
for (let i = 0; i < this.imgAdsValue.list.length; i++) {
let item = this.imgAdsValue.list[i];
imgArr[i] = parseFloat(item.imgHeight);
}
imgArr.sort(function (a, b) {
return b - a;
});
var obj = '';
obj += 'background-color:' + this.imgAdsValue.backgroundColor + ';';
obj += 'height:' + imgArr[0] * (this.imgAdsValue.backgroundHeight / 100) * 2 + 'rpx;';
return obj;
}
},
methods: {
swiperChange(e) {
this.swiperIndex = e.detail.current;
},
calcSingleRow() {
let minHeight = 0;
let systemInfo = uni.getSystemInfoSync()
// 深拷贝一层数据,防止数据更改越权
this.imgAdsValue = JSON.parse(JSON.stringify(this.value));
this.imgAdsValue.list.forEach((item, index) => {
var ratio = item.imgHeight / item.imgWidth;
item.imgWidth = systemInfo.windowWidth;
item.imgWidth -= this.value.margin.both * 2;
item.imgHeight = item.imgWidth * ratio;
// 获取最大高度 if (maxHeight == 0 || maxHeight < item.imgHeight) maxHeight = item.imgHeight;
if (minHeight == 0 || minHeight > item.imgHeight) minHeight = item.imgHeight;
});
this.imgAdsValue.list.forEach((item, index) => {
item.imgHeight = minHeight + 'px';
this.swiperHeight = minHeight + 'px';
});
this.imgAdsValue.indicatorColor = this.imgAdsValue.indicatorColor || '#fff';
if (this.value.indicatorIsShow === undefined) {
this.value.indicatorIsShow = true; // 控制指示点是否展示
}
// 是否显示指示器
if (this.imgAdsValue.list.length <= 1) {
this.isDots = false;
}
// #ifdef H5
this.isDots = this.value.indicatorIsShow;
// #endif
// #ifdef MP-WEIXIN
this.isDots = false;
// #endif
},
async handlerClick(link) {
await this.__$emitEvent({
eventName: 'img-ads-tap', data: link, promiseCallback: (event, handler, awaitedResult) => {
if (!awaitedResult) return;
this.$util.diyRedirectTo(link);
}
})
},
}
};
</script>
<style lang="scss" scoped>
.single-graph {
width: 100%;
line-height: 0;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.simple-graph-wrap {
line-height: 0;
overflow: hidden;
position: relative;
image {
width: 100%;
}
.heat-map {
position: absolute;
}
}
.item.active text {
background: rgba(0, 0, 0, 0.3);
position: absolute;
bottom: 0;
color: #ffffff;
font-size: $font-size-tag;
width: 100%;
left: 0;
line-height: 40rpx;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding: 0 10rpx;
text-align: center;
}
.swiper-box {
position: relative;
width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.swiper {
margin: 0 auto;
overflow: hidden;
}
.swiper-item {
width: 100%;
height: auto !important;
display: flex;
justify-content: center;
flex-direction: column;
// position: relative;
overflow: hidden;
.item {
width: 100%;
height: auto;
text-align: center;
position: relative;
overflow: hidden;
image {
width: 100%;
max-width: 100%;
height: 100%;
will-change: transform;
}
.heat-map {
position: absolute;
}
}
}
.swiper-dot-box {
position: absolute;
bottom: 20rpx;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 40rpx 8rpx;
box-sizing: border-box;
&.swiper-left {
justify-content: flex-start;
}
&.swiper-right {
justify-content: flex-end;
}
.swiper-dot {
background-color: #b2b2b2;
width: 15rpx;
border-radius: 50%;
height: 15rpx;
margin: 8rpx;
&.active {
background-color: rgba(0, 0, 0, 1);
}
}
&.straightLine {
.swiper-dot {
width: 18rpx;
height: 6rpx;
border-radius: 4rpx;
&.active {
width: 36rpx;
background-color: rgba(0, 0, 0, 1);
}
}
}
}
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
::v-deep .uni-scroll-view::-webkit-scrollbar {
display: none;
}
.swiper ::v-deep .uni-swiper-dots-horizontal {
bottom: 25rpx;
}
.swiper-left ::v-deep .uni-swiper-dots-horizontal {
left: 40rpx;
transform: translate(0);
}
.swiper-right ::v-deep .uni-swiper-dots-horizontal {
right: 40rpx;
display: flex;
justify-content: flex-end;
transform: translate(0);
}
.carousel-angle ::v-deep .uni-swiper-dots-horizontal .uni-swiper-dot {
width: 24rpx;
border-radius: 0;
height: 8rpx;
}
.swiper.ns-indicator-dots ::v-deep .uni-swiper-dot {
width: 18rpx;
height: 6rpx;
border-radius: 4rpx;
}
.swiper.ns-indicator-dots ::v-deep .uni-swiper-dot-active {
width: 36rpx;
}
</style>

View File

@@ -0,0 +1,866 @@
<template>
<view data-component-name="diy-index-page">
<view class="bg" :style="warpCss">
<view class="index-page-content">
<view class="nav-top-category" :style="categoryCss">
<scroll-view v-if="value" scroll-with-animation class="diyIndex" scroll-x="true"
:scroll-into-view="'a' + pageIndex"
:style="{ background: value.backgroundColor ? value.backgroundColor : '', width: 'calc(100% - 48rpx)' }"
@touchmove.stop>
<view class="item" :id="'a' + index" v-for="(item, index) in cateList" :key="index"
@tap.stop="changePageIndex(index)" :class="{ fill: value.styleType == 'fill' }"
:style="{ background: index == pageIndex && value.styleType == 'fill' ? value.selectColor : '' }">
<view class="text-con" :class="index == pageIndex ? 'active' : ''" :style="{
color: index == pageIndex ? '' : value.noColor
}" v-if="value.styleType == 'fill'">
{{ item.short_name ? item.short_name : item.category_name }}
</view>
<view class="text-con" :class="index == pageIndex ? 'active' : ''"
:style="{ color: index == pageIndex ? value.selectColor : value.noColor }" v-else>
{{ item.short_name ? item.short_name : item.category_name }}
</view>
<view class="color-base-bg line" v-if="index == pageIndex && value.styleType != 'fill'"
:style="{ background: value.selectColor ? value.selectColor + '!important' : 'rgba(0,0,0,0)' + '!important' }">
</view>
</view>
</scroll-view>
<text class="iconfont icon-unfold unfold-arrows" :style="{ color: value.moreColor }"
@tap.stop="unfoldMenu"></text>
</view>
<uni-popup ref="navTopCategoryPop" type="top" :top="uniPopTop">
<view class="nav-topcategory-pop">
<text v-for="(item, index) in cateList" :key="index"
:class="['category-item', { 'color-base-text color-base-border active': pageIndex == index }]"
@tap.stop="changePageIndex(index)">
{{ item.short_name ? item.short_name : item.category_name }}
</text>
</view>
</uni-popup>
<view class="nav_top_category-fill" :style="{ height: moduleHeight }"></view>
<block v-if="pageIndex == 0">
<slot name="components"></slot>
<slot></slot>
</block>
<block v-else>
<slot name="components"></slot>
<view class="index-category-box">
<view class="category-goods" v-show="!isloading">
<mescroll-uni :top="uniPopTop" ref="mescroll" @getData="getGoodsList"
:background="'url(' + $util.img(bgUrl) + ') 0px -50px / 100% no-repeat'"
:paddingBoth="'30rpx'" @touchmove.prevent.stop>
<block slot="list">
<!-- 二级分类 -->
<view class="twoCategorylist"
v-if="twoCategorylist != 'undefined' && twoCategorylist && twoCategorylist.length > 0">
<view class="twoCategory min" v-if="twoCategorylist.length <= 5">
<view class="twoCategory-page">
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
:key="index" @tap.stop="toCateGoodsList(item.category_id_2, 2)">
<view class="item-box">
<image :src="$util.img(item.image)" v-if="item.image"
mode="aspectFill" show-menu-by-longpress/>
<image :src="$util.getDefaultImage().goods" v-else
mode="aspectFill" show-menu-by-longpress/>
<view>{{ item.category_name }}</view>
</view>
</view>
</view>
</view>
<view class="twoCategory base"
v-if="twoCategorylist.length > 5 && twoCategorylist.length <= 10">
<view class="twoCategory-page">
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
:key="index" @tap.stop="toCateGoodsList(item.category_id_2, 2)">
<view class="item-box">
<image :src="$util.img(item.image)" v-if="item.image"
mode="aspectFill" show-menu-by-longpress/>
<image :src="$util.getDefaultImage().goods" v-else
mode="aspectFill" show-menu-by-longpress/>
<view>{{ item.category_name }}</view>
</view>
</view>
</view>
</view>
<swiper class="twoCategory big" :duration="500"
v-if="twoCategorylist.length > 10" @change="swiperTocategoryChange">
<swiper-item class="twoCategory-page" v-for="page in maxPage" :key="page">
<view class="swiper-item" v-for="(item, index) in twoCategorylist"
:key="index" v-if="index >= (page - 1) * 10 && index < page * 10"
@tap.stop="toCateGoodsList(item.category_id_2, 2)">
<view class="item-box">
<image :src="item.image" mode="aspectFill" show-menu-by-longpress/>
<view>{{ item.category_name }}</view>
</view>
</view>
</swiper-item>
</swiper>
<view class="dot-box">
<view class="dot-item" v-for="page in maxPage" v-if="maxPage > 1"
:key="page"
:class="twoCategorylistId == page - 1 ? 'active color-base-bg' : ''">
</view>
</view>
</view>
<!-- 分类广告 -->
<image class="category_adv" v-if="cateList[pageIndex].image_adv"
:src="$util.img(cateList[pageIndex].image_adv)" mode="widthFix" show-menu-by-longpress/>
<view class="goods-list double-column" v-if="goodsList[pageIndex].list.length">
<view class="goods-item" v-for="(item, index) in goodsList[pageIndex].list"
:key="index" @tap.stop="toDetail(item)">
<view class="goods-img">
<image :src="goodsImg(item.goods_image)" mode="widthFix"
@error="imgError(index)" show-menu-by-longpress/>
<view class="color-base-bg goods-tag"
v-if="value.goodsTag == 'default' && goodsTag(item) != ''">{{
goodsTag(item) }}</view>
<view class="goods-tag-img" v-if="value.goodsTag == 'diy'">
<image :src="$util.img(value.tagImg.imageUrl)" show-menu-by-longpress/>
</view>
</view>
<view class="info-wrap">
<view class="name-wrap">
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
</view>
<view class="lineheight-clear">
<view class="discount-price">
<text class="unit color-base-text font-size-tag">{{
$lang('common.currencySymbol') }}</text>
<text class="price color-base-text font-size-toolbar">{{
showPrice(item) }}</text>
</view>
<view class="member-price-tag"
v-if="item.member_price && item.member_price == showPrice(item)">
<image :src="$util.img('public/uniapp/index/VIP.png')"
mode="widthFix" show-menu-by-longpress/>
</view>
<view class="member-price-tag" v-else-if="item.promotion_type == 1">
<image :src="$util.img('public/uniapp/index/discount.png')"
mode="widthFix" show-menu-by-longpress/>
</view>
</view>
<view class="pro-info">
<view class="delete-price font-size-activity-tag color-tip"
v-if="showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
<view class="sale font-size-activity-tag color-tip">{{
$lang('components-diy.diy-index-page.soldCount', { count: item.sale_num, unit: item.unit || $lang('components-diy.diy-index-page.soldUnit') }) }}</view>
</view>
</view>
</view>
</view>
<view v-if="!isloading && goodsList[pageIndex].list.length == 0">
<ns-empty :text="$lang('components-diy.diy-index-page.noGoodsInCategory')" :isIndex="false"></ns-empty>
</view>
</block>
</mescroll-uni>
<!-- <ns-empty v-else-if="!isloading" :isIndex="false" text="该分类下暂无商品"></ns-empty> -->
</view>
<view class="loading" v-show="isloading"><ns-loading ref="loading"></ns-loading></view>
</view>
</block>
</view>
</view>
</view>
</template>
<script>
import DiyMinx from './minx.js'
export default {
name: 'diy-index-page',
props: {
value: {
type: Object
},
bgUrl: {
type: String
},
scrollTop: {
type: [String, Number],
default: '0'
},
diyGlobal: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
pageIndex: 0, //当前选中分类id
cateList: [{
//header分类
category_name: ''
}],
twoCategorylist: [], //二级分类
twoCategorylistId: 0, //二级分类所在的swiper
goodsList: {},
isloading: true,
top: 0,
isUnfold: true, //是否展开菜单
moduleHeight: '' //组件高度
};
},
computed: {
warpCss() {
var obj = '';
obj += (this.bgUrl ? 'background:' + 'url(' + this.$util.img(this.bgUrl) + ') no-repeat 0 0/100%' : '') + ';';
return obj;
},
categoryCss() {
var obj = '';
obj += 'top:' + this.fixedTop + ';';
// obj += 'background-color:' + (this.value.componentBgColor || this.value.pageBgColor) + ';';
obj += 'background-color:' + this.topNavColor + ';';
return obj;
},
maxPage() {
let num = 0;
if (this.twoCategorylist && this.twoCategorylist.length) {
num = Math.ceil(this.twoCategorylist.length / 10);
}
return num;
},
type() {
if (this.value) {
return true;
} else {
return false;
}
},
topNavColor() {
var color = this.value.componentBgColor || this.value.pageBgColor;
if (this.diyGlobal.topNavBg && this.scrollTop > 20) color = this.diyGlobal.topNavColor;
return color;
},
fixedTop() {
let diyPositionObj = this.$store.state.diyGroupPositionObj;
let data = 0;
if (diyPositionObj.diySearch && diyPositionObj.diyIndexPage && diyPositionObj.nsNavbar) {
if (diyPositionObj.diySearch.moduleIndex > diyPositionObj.diyIndexPage.moduleIndex) data =
diyPositionObj.nsNavbar.originalVal + 'px';
else data = diyPositionObj.nsNavbar.originalVal + diyPositionObj.diySearch.originalVal + 'px';
} else if (diyPositionObj.diyIndexPage && diyPositionObj.nsNavbar) {
data = diyPositionObj.nsNavbar.originalVal + 'px';
}
return data;
},
// 分类导航展开菜单的位置
uniPopTop() {
let diyPositionObj = this.$store.state.diyGroupPositionObj;
let data = '0';
if (this.fixedTop && diyPositionObj.diyIndexPage)
data = Number.parseFloat(this.fixedTop) + diyPositionObj.diyIndexPage.originalVal + 'px';
return data;
}
},
created() {
this.cateList[0].category_name = this.$lang('components-diy.diy-index-page.home');
},
watch: {
type(newVal, oldVal) {
if (newVal) {
this.getCategoryList();
}
}
},
mounted() {
this.getCategoryList();
setTimeout(() => {
// 获取组件的高度,默认高度为45(45是在375屏幕上的高度)
const query = uni.createSelectorQuery();
// #ifdef H5
let cssSelect = '.page-header .u-navbar';
this.top = 100;
// #endif
// #ifdef MP
let cssSelect = '.page-header >>> .u-navbar';
this.top = 20;
// #endif
query
.select(cssSelect)
.boundingClientRect(data => {
let height;
if (this.diyGlobal.navBarSwitch) {
height = data ? data.height : 45;
} else {
height = data ? data.height : 0;
}
// #ifdef H5
this.top += height * 2;
// #endif
// #ifdef MP
this.top += height;
// #endif
})
.exec();
});
this.setModuleLocationFn();
},
methods: {
initPageIndex() {
this.pageIndex = 0;
this.showModuleFn();
},
//请求分类列表
getCategoryList() {
let url = '/api/goodscategory/tree';
let data = {
level: 3
};
this.$api.sendRequest({
url: url,
data: data,
success: res => {
if (res.code >= 0) {
let arr = [];
let obj = {
list: []
};
obj.category_name = this.value.title ? this.value.title : this.$lang('components-diy.diy-index-page.home');
arr.push(obj);
this.cateList = arr.concat(res.data);
Object.keys(this.cateList).forEach((key, index) => {
this.goodsList[key] = {
page: 1,
list: []
};
});
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
}
}
});
},
//修改当前页面id
changePageIndex(e) {
this.isloading = true;
this.pageIndex = e;
this.showModuleFn();
if (e == 0) return;
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
if (this.cateList[this.pageIndex].child_list) {
this.twoCategorylist = this.cateList[this.pageIndex].child_list;
this.twoCategorylist.forEach(v => {
if (v.image) {
v.image = this.$util.img(v.image);
} else {
v.image = this.$util.getDefaultImage().goods;
}
});
} else {
this.twoCategorylist = false;
}
if (this.$refs.mescroll) {
this.$refs.mescroll.refresh();
this.$refs.mescroll.myScrollTo(0);
}
},
//监听二级分类 页面切换
swiperTocategoryChange(e) {
this.twoCategorylistId = e.detail.current;
},
toDetail(item) {
this.$util.redirectTo('/pages_goods/detail', {
goods_id: item.goods_id
});
},
getGoodsList(mescroll) {
let id = this.pageIndex;
var data = {
page: mescroll.num,
page_size: mescroll.size
};
data.category_id = this.cateList[this.pageIndex].category_id_1;
data.category_level = 1;
this.$api.sendRequest({
url: '/api/goodssku/page',
data: data,
success: res => {
this.isloading = false;
let newArr = [];
let msg = res.message;
if (res.code == 0 && res.data) {
this.count = res.data.count;
newArr = res.data.list;
} else {
this.$util.showToast({
title: msg
});
}
mescroll.endSuccess(newArr.length);
//设置列表数据
if (mescroll.num == 1) this.goodsList[id].list = []; //如果是第一页需手动制空列表
this.goodsList[id].list = this.goodsList[id].list.concat(newArr); //追加新数据
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.$forceUpdate();
}
});
},
toCateGoodsList(e, f) {
this.$util.redirectTo('/pages_goods/list', {
category_id: e,
category_level: f
});
},
goodsImg(imgStr) {
let imgs = imgStr.split(',');
return imgs[0] ? this.$util.img(imgs[0], {
size: 'mid'
}) : this.$util.getDefaultImage().goods;
},
imgError(index) {
this.goodsList[index].goods_image = this.$util.getDefaultImage().goods;
},
showPrice(data) {
let price = data.discount_price;
if (data.member_price && parseFloat(data.member_price) < parseFloat(price)) price = data.member_price;
return price;
},
showMarketPrice(item) {
if (item.market_price_show) {
let price = this.showPrice(item);
if (item.market_price > 0) {
return item.market_price;
} else if (parseFloat(item.price) > parseFloat(price)) {
return item.price;
}
}
return '';
},
goodsTag(data) {
return data.label_name || '';
},
// 控制菜单展开关闭
unfoldMenu() {
if (this.isUnfold) this.$refs.navTopCategoryPop.open();
else this.$refs.navTopCategoryPop.close();
this.isUnfold = !this.isUnfold;
},
// 向vuex中的diyIndexPositionObj增加分类导航组件定位位置
setModuleLocationFn() {
const query = uni.createSelectorQuery().in(this);
query.select('.nav-top-category')
.boundingClientRect(data => {
let diyIndexPage = {
originalVal: data.height || 0, //自身高度 px
moduleIndex: this.value.moduleIndex //组件在diy-group的位置
};
this.moduleHeight = (data.height || 0) + 'px';
this.$store.commit('setDiyGroupPositionObj', {
diyIndexPage: diyIndexPage
});
}).exec();
},
showModuleFn() {
let searchModule = this.$root.diyData.value.filter((item, index) => {
return item.componentName == 'Search';
});
// setDiyGroupShowModule值为【】表示显示所有组件,为【‘null’】则什么组件也不显示
if (this.pageIndex == 0) this.$store.commit('setDiyGroupShowModule', JSON.stringify([]));
else {
if (searchModule[0].positionWay == 'fixed') this.$store.commit('setDiyGroupShowModule', JSON.stringify(
['Search']));
else this.$store.commit('setDiyGroupShowModule', JSON.stringify(['null']));
}
// 特殊处理,切换分类导航导致页面无法上下滚动
// #ifdef H5
if (this.pageIndex == 0) {
// 标记当前页使用了mescroll (需延时,确保page已切换)
setTimeout(function () {
let uniPageDom = document.getElementsByTagName('uni-page')[0];
uniPageDom && uniPageDom.removeAttribute('use_mescroll');
}, 30);
}
// #endif
}
}
};
</script>
<style lang="scss">
.bg {
width: 100%;
height: 100%;
}
.nav-top-category {
display: flex;
align-items: center;
position: fixed;
z-index: 999;
transition: background 0.3s;
width: 100%;
padding: 0 24rpx;
box-sizing: border-box;
.text-fiexd {
.text-con {
line-height: 60rpx;
}
}
.unfold-arrows {
position: relative;
margin-left: 4rpx;
padding-left: 16rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
color: #fff;
}
}
.nav-topcategory-pop {
padding: 0 10rpx 20rpx;
display: flex;
flex-wrap: wrap;
background-color: #fff;
.category-item {
padding: 0 30rpx;
height: 60rpx;
text-align: center;
line-height: 56rpx;
border-radius: 40rpx;
background-color: #f0f0f0;
margin: 20rpx 10rpx 0;
font-size: 24rpx;
border: 2rpx solid transparent;
box-sizing: border-box;
&.active {
background-color: transparent;
}
}
}
.diyIndex {
width: 100%;
height: 100rpx;
white-space: nowrap;
padding: 20rpx 0 0;
box-sizing: border-box;
&.widthAuto {
width: auto;
}
.item {
position: relative;
margin-right: 40rpx;
display: inline-block;
line-height: 80rpx;
font-size: $font-size-base;
text-align: center;
.text-con {
height: 30px;
line-height: 30px;
&.active {
font-size: $font-size-base;
font-weight: bold;
}
}
.text-con.active {
font-size: $font-size-base;
font-weight: bold;
}
.line {
position: absolute;
left: calc(50% - 14rpx);
width: 28rpx;
height: 5rpx;
border-radius: 5rpx;
}
&.fill {
border-radius: 50rpx;
padding: 0 10rpx;
.text-con.active {
font-size: $font-size-base;
font-weight: 600;
color: #fff;
}
}
}
}
.index-page-box {
width: 100%;
height: calc(100vh - 288rpx);
}
.index-page-content {
width: 100%;
// height: calc(100vh - 144px);
}
.index-category-box.active {
padding-bottom: 160rpx;
padding-bottom: calc(160rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.index-category-box {
width: 100%;
padding-bottom: 110rpx;
padding-bottom: calc(110rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(110rpx + env(safe-area-inset-bottom));
.twoCategorylist {
position: relative;
}
.twoCategory.min {
height: 160rpx;
}
.twoCategory.big {
height: 340rpx;
}
.twoCategory {
width: 100%;
background: #ffffff;
border-radius: 15rpx;
overflow: hidden;
margin-top: 20rpx;
.twoCategory-page {
width: 100%;
height: 100%;
padding: 20rpx;
box-sizing: border-box;
}
.swiper-item {
width: 120rpx;
height: 120rpx;
display: inline-block;
margin-right: calc((100% - 120rpx * 5) / 4);
overflow: hidden;
.item-box {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
image {
width: 88rpx;
height: 88rpx;
}
view {
width: 100%;
font-size: 22rpx;
line-height: 1;
margin-top: 10rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-align: center;
}
}
}
.swiper-item:nth-child(5n) {
margin-right: 0;
}
.swiper-item:nth-child(10n + 6) {
margin-top: 15rpx;
}
}
.dot-box {
width: calc(100% - 40rpx);
height: 50rpx;
position: absolute;
bottom: 0rpx;
left: 20rpx;
background: rgba($color: #000000, $alpha: 0);
display: flex;
justify-content: center;
align-items: center;
.dot-item {
width: 12rpx;
height: 12rpx;
background: #cccccc;
border-radius: 6rpx;
margin-right: 10rpx;
}
.dot-item.active {
width: 24rpx;
}
}
.category_adv {
width: 100%;
margin: 20rpx 0;
border-radius: 15rpx;
}
.category-goods {
width: 100%;
}
}
.loading {
width: 100%;
height: 50rpx;
margin-top: 100rpx;
}
::v-deep .uni-scroll-view::-webkit-scrollbar {
/* 隐藏滚动条,但依旧具备可以滚动的功能 */
display: none;
}
.goods-list.double-column {
display: flex;
flex-wrap: wrap;
margin-top: $margin-updown;
.goods-item {
flex: 1;
position: relative;
background-color: #fff;
flex-basis: 48%;
max-width: calc((100% - 30rpx) / 2);
margin-right: $margin-both;
margin-bottom: $margin-updown;
border-radius: $border-radius;
&:nth-child(2n) {
margin-right: 0;
}
.goods-img {
position: relative;
overflow: hidden;
padding-top: 100%;
border-top-left-radius: $border-radius;
border-top-right-radius: $border-radius;
image {
width: 100%;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
}
}
.goods-tag {
color: #fff;
line-height: 1;
padding: 8rpx 16rpx;
position: absolute;
border-bottom-right-radius: $border-radius;
top: 0;
left: 0;
font-size: $font-size-goods-tag;
}
.goods-tag-img {
position: absolute;
border-top-left-radius: $border-radius;
width: 80rpx;
height: 80rpx;
top: 0;
left: 0;
z-index: 5;
overflow: hidden;
image {
width: 100%;
height: 100%;
}
}
.info-wrap {
padding: 0 26rpx 26rpx 26rpx;
}
.goods-name {
font-size: $font-size-base;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-top: 20rpx;
height: 68rpx;
}
.discount-price {
display: inline-block;
font-weight: bold;
line-height: 1;
margin-top: 16rpx;
.unit {
margin-right: 6rpx;
}
}
.pro-info {
display: flex;
margin-top: 16rpx;
.delete-price {
text-decoration: line-through;
flex: 1;
.unit {
margin-right: 6rpx;
}
}
&>view {
line-height: 1;
&:nth-child(2) {
text-align: right;
}
}
}
.member-price-tag {
display: inline-block;
width: 60rpx;
line-height: 1;
margin-left: 6rpx;
image {
width: 100%;
}
}
}
}
</style>

216
components-diy/diy-kefu.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<view data-component-name="diy-kefu" class="diy-kefu" :style="style">
<view class="fui-list-group merchgroup" v-for="(item, index) in value.list" :key="index">
<view class="fui-list jump" v-if="index == 0">
<view class="fui-list-media">
<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">
<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>
<!-- 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>
</view>
</view>
</view>
</template>
<script>
// 客服展示
import DiyMinx from './minx.js'
export default {
name: 'diy-kefu',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
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: {
style() {
var css = '';
css += 'background-color:' + this.value.contentBgColor + ';';
if (this.value.elementAngle == 'round') {
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
return css;
}
},
methods: {
previewSqs() {
var img = this.$util.img(this.value.list[1].imageUrl)
uni.previewImage({
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">
// 客服展示:标题/描述需要完整展示(中英文均不截断)
// 全局 .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

@@ -0,0 +1,80 @@
<template>
<view data-component-name="diy-listmenu" class="diy-listmenu" :style="style">
<view class="fui-cell-group">
<!-- <image mode="widthFix" style="width: 100%;" :src="$util.img(item.imageUrl)" show-menu-by-longpress></image> -->
<view v-for="(item, index) in value.list" @tap.stop="redirectTo(item.link)" class="fui-cell"
:class="item.iconType == 'img' ? 'img-cell' : ''">
<view class="fui-cell-icon" :style="{ 'color': item.style ? item.style.iconColor : '#333' }">
<diy-icon v-if="item.iconType == 'icon'" :icon="item.icon" :value="item.style ? item.style : null"
:style="{ maxWidth: value.imageSize * 2 + 'rpx', maxHeight: value.imageSize * 2 + 'rpx', width: '100%', height: '100%' }"></diy-icon>
<image v-if="item.iconType == 'img'" mode="widthFix" :src="$util.img(item.imageUrl)"
style="border-radius:6rpx;width: 60rpx;"></image>
</view>
<view class="fui-cell-text" style="color:#333;">{{ item.title }}</view>
<view class="fui-cell-remark" @tap.stop="redirectTo(item.link)" style="font-size: 24rpx;">{{ $lang('components-diy.diy-listmenu.view') }}</view>
</view>
</view>
<ns-login ref="login"></ns-login>
</view>
</template>
<script>
// 自定义列表菜单展示
import DiyMinx from './minx.js'
export default {
name: 'diy-listmenu',
props: {
value: {
type: Object
}
},
mixins: [DiyMinx],
data() {
return {
loading: true,
};
},
created() {
// this.getDataList();
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
// this.getDataList();
}
},
computed: {
style() {
var css = '';
css += 'background-color:' + this.value.contentBgColor + ';';
if (this.value.elementAngle == 'round') {
css += 'border-top-left-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-top-right-radius:' + this.value.topElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-left-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
css += 'border-bottom-right-radius:' + this.value.bottomElementAroundRadius * 2 + 'rpx;';
}
if (this.value.margin.top > 0) css += 'margin-top:' + this.value.margin.top * 2 + 'rpx;';
return css;
}
},
methods: {
redirectTo(link) {
if (link.wap_url) {
if (this.$util.getCurrRoute() == this.$util.MEMBER_PAGE_URL && !this.storeToken) {
this.redirectTologin(link.wap_url)
return;
}
}
this.$util.diyRedirectTo(link);
},
}
};
</script>
<style lang="scss">
.img-cell {
padding: 0 16rpx !important;
}
</style>

257
components-diy/diy-live.vue Normal file
View File

@@ -0,0 +1,257 @@
<template>
<x-skeleton data-component-name="diy-live" type="banner" :loading="loading" :configs="skeletonConfig">
<view class="live-wrap" @tap.stop="handlerClick(liveInfo.roomid)"
v-if="liveInfo">
<view class="banner-wrap">
<image
:src="liveInfo.banner != '' ? $util.img(liveInfo.banner) : $util.img('public/uniapp/live/live_default_banner.png')"
mode="widthFix"
@error="liveInfo.banner = $util.img('public/uniapp/live/live_default_banner.png')" />
<view class="shade"></view>
<view class="wrap">
<view class="room-name">
<text class="status-name font-size-base"
:class="{ 'color-base-bg': liveInfo.live_status == '101' }">
<text class="iconfont icon-zhibozhong font-size-sub"
v-if="liveInfo.live_status == '101'"></text>
<text class="iconfont icon-zhibojieshu font-size-sub" v-else></text>
{{ liveInfo.status_name }}
</text>
{{ liveInfo.name }}
</view>
</view>
</view>
<view class="room-info" v-if="value.isShowAnchorInfo || value.isShowLiveGood">
<block v-if="value.isShowAnchorInfo">
<image
:src="liveInfo.anchor_img != '' ? $util.img(liveInfo.anchor_img) : $util.getDefaultImage().head"
class="anchor-img" @error="liveInfo.anchor_img = $util.getDefaultImage().head" show-menu-by-longpress/>
<text class="anchor-name">{{ $lang('components-diy.diy-live.anchorPrefix') }}{{ liveInfo.anchor_name }}</text>
</block>
<text class="separate" v-if="value.isShowAnchorInfo && value.isShowLiveGood">|</text>
<block v-if="value.isShowLiveGood">
<text class="goods-text">{{ $lang('components-diy.diy-live.goodsPrefix') }}{{ liveInfo.goods.length }}</text>
</block>
</view>
</view>
</x-skeleton>
</template>
<script>
// 直播
import DiyMinx from './minx.js'
export default {
components: {},
name: 'diy-live',
props: {
value: {
type: Object,
default: () => {
return {};
}
}
},
data() {
return {
loading: true,
skeletonConfig: {
headHeight: '200rpx'
},
liveInfo: {
banner: '',
anchor_img: ''
}
};
},
created() {
this.getLiveInfo();
},
mixins: [DiyMinx],
watch: {
// 组件刷新监听
componentRefresh: function (nval) {
this.getLiveInfo();
}
},
methods: {
getLiveInfo() {
this.$api.sendRequest({
url: '/live/api/live/info',
success: res => {
if (res.code == 0 && res.data) {
this.liveInfo = res.data;
this.getLiveStatus();
} else {
this.liveInfo = null;
}
this.loading = false;
}
});
},
entryRoom(roomId) {
// #ifdef MP-WEIXIN
wx.navigateTo({
url: `plugin-private://wx2b03c6e691cd7370/pages/live-player-plugin?room_id=${roomId}`
});
// #endif
},
getLiveStatus() {
// #ifdef MP-WEIXIN
let livePlayer = requirePlugin('live-player-plugin');
livePlayer.getLiveStatus({
room_id: this.liveInfo.roomid
}).then(res => {
const liveStatus = res.liveStatus;
if (liveStatus && liveStatus != this.liveInfo.live_status) {
this.changeLiveStatus(liveStatus);
}
})
.catch(err => {
});
// 往后间隔1分钟或更慢的频率去轮询获取直播状态
var timer = setInterval(() => {
livePlayer
.getLiveStatus({
room_id: this.liveInfo.roomid
})
.then(res => {
const liveStatus = res.liveStatus;
if (liveStatus && liveStatus != this.liveInfo.live_status) {
this.changeLiveStatus(liveStatus);
}
if (this.$util.inArray(liveStatus, [103, 104, 106, 107])) {
clearInterval(timer);
}
})
.catch(err => {
});
}, 60000);
// #endif
},
changeLiveStatus(status) {
this.$api.sendRequest({
url: '/live/api/live/modifyLiveStatus',
data: {
room_id: this.liveInfo.roomid,
status: status
},
success: res => {
if (res.code == 0) {
this.getLiveInfo();
}
}
});
},
async handlerClick(roomid) {
await this.__$emitEvent({
eventName: 'live-tap', data: roomid, promiseCallback: (event, handler, awaitedResult) => {
if (!awaitedResult) return;
this.entryRoom(roomid);
}
})
},
}
};
</script>
<style lang="scss">
.live-wrap {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.banner-wrap {
width: 100%;
position: relative;
line-height: 1;
display: flex;
image {
width: 100%;
}
.shade {
width: 100%;
height: 100%;
position: absolute;
background: rgba($color: #888, $alpha: 0.3);
left: 0;
top: 0;
z-index: 5;
}
.wrap {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 10;
padding: 26rpx 20rpx;
box-sizing: border-box;
.room-name {
font-size: $font-size-toolbar;
color: #fff;
line-height: 1;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
.status-name {
display: inline-block;
font-size: $font-size-activity-tag;
color: #fff;
padding: 8rpx 12rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 36rpx;
margin-right: 20rpx;
.icon-zhibozhong {
font-size: $font-size-activity-tag;
color: #fff;
margin-right: 9rpx;
}
}
}
}
}
.room-info {
padding: 20rpx 30rpx;
background: #fff;
display: flex;
.anchor-img {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
overflow: hidden;
margin-right: 20rpx;
}
.anchor-name,
.goods-text {
font-size: $font-size-base;
line-height: 60rpx;
}
.separate {
color: #808080;
margin: 0 10rpx;
line-height: 56rpx;
}
}
</style>
<style scoped>
.coupon-all>>>.uni-scroll-view::-webkit-scrollbar {
display: none;
}
</style>

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