Compare commits

..

168 Commits

Author SHA1 Message Date
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
161 changed files with 29714 additions and 6803 deletions

78
.gitignore vendored
View File

@@ -7,8 +7,80 @@
.codebuddy
.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/ascf/ascf_build
stub-app-projects/mp-harmony/entry/src/main/resources
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

View File

@@ -21,7 +21,7 @@ const localDevConfig = ({
'2881': { // 防爆电器
uniacid: 2881,
domain: 'https://xcx6.aigc-quickapp.com/',
},
},
'2724': { // 生物菌肥
uniacid: 2724,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -45,11 +45,11 @@ const localDevConfig = ({
'11-test': { // 测试平台
uniacid: 11,
domain: 'https://test.aigc-quickapp.com',
},
},
'18-test': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
@@ -59,14 +59,15 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'local-11': { // 测试平台
uniacid: 11,
domain: 'http://localhost:8050/',
},
'local-18': { // 人工晶体
uniacid: 18,
domain: 'http://localhost:8050/',
},
'100': {
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
@@ -82,11 +83,15 @@ const localDevConfig = ({
'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/',
@@ -94,74 +99,108 @@ const localDevConfig = ({
'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://xcx3.aigc-quickapp.com/',
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',
},
'54': { // 测试平台
uniacid: 54,
domain: 'https://test.aigc-quickapp.com',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'test-12': { // 户外鞋,测试优惠券
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //气雾栽培机,视频组件安卓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-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
})['2821']; // 离型纸硅油纸
// })['2821']; // 离型纸硅油纸
// })['2482']; // 低空经济
// })['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码
// })['5g-213']; // 选择要使用的环境配置
// })['5g-213']; // 选择要使用的环境配置
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
// })['local-1']; // 本地
// })['local-18']; // 本地-人工晶体-有商品
})['2811']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test
// })['18-test']; // test
// })['test-12']; // test
export default localDevConfig;

33
App.vue
View File

@@ -271,27 +271,56 @@
// 隐私协议弹窗: 启动期关键资源 (i18n/theme/remote-config) 就绪后触发
// 通过 PrivacyManager → uni.$emit('privacy:show') 通知当前页面的 privacy-popup 组件
// 只弹一次: 同意后写 storage,后续不再弹
// 异步查询平台隐私状态(微信/鸿蒙), H5 同步返回
// 异步查询平台隐私状态(微信/快应用), 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) {
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
// 避免 splash 提前关导致白屏
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
}
// #endif

View File

@@ -118,6 +118,8 @@
.goods-video video {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
.goods-video .uni-video-cover {

View File

@@ -4,8 +4,56 @@ import store from '@/store/index.js'
import lang from './lang.js'
let currentConversationId = null;
// ====== AI Agent Generalization (customer service is the first agent) ======
// setAgentId updates currentAgentId; default is agent_aikefu.
// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically.
//
// PR-7: 按 agent_id 路由 endpoint
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* PR-7: agent_id -> 后端 addon 路由映射
*
* 规则:
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
*
* 后端两个 addon 完全独立:
* - aikefu: lucky_aikefu_config / conversation / message
* - aiagent: lucky_ai_agent_config / conversation / message
*/
function resolveAddonName(agentId) {
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
return 'aiagent';
}
/**
* PR-7: agent_id -> API endpoint URL
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
*/
function resolveApiUrl(apiName) {
var addon = resolveAddonName(currentAgentId);
if (addon === 'aikefu') {
return '/api/kefu/' + apiName;
}
return '/api/aiagent/' + apiName;
}
/**
* PR-7: WebSocket URL 路径(/ws/{addonName})
*/
function resolveWsPath() {
return '/ws/' + resolveAddonName(currentAgentId);
}
/**
* 获取域名配置参数(避免重复代码)
*/
@@ -35,12 +83,12 @@ const GUEST_ID_KEY = 'ai_guest_user_id';
function getUserId() {
const memberId = store.state.memberInfo?.id;
if (memberId) return memberId;
let guestId;
try {
guestId = uni.getStorageSync(GUEST_ID_KEY);
} catch (e) { /* ignore */ }
if (!guestId) {
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
try {
@@ -50,6 +98,20 @@ function getUserId() {
return guestId;
}
/**
* 获取当前 AI Agent。
*/
function getAgentId() {
return currentAgentId || DEFAULT_AGENT_ID;
}
/**
* 设置当前 AI Agent。
*/
function setAgentId(agentId) {
if (agentId && typeof agentId === 'string') currentAgentId = agentId;
}
// #ifdef MP
// ================================
// WebSocket 长连接管理(微信小程序)
@@ -91,7 +153,9 @@ function ensureWebSocketConnected() {
return;
}
var wssUrl = 'wss://' + domain + '/ws/aikefu';
// PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
wsConnected = false;
wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -106,6 +170,7 @@ function ensureWebSocketConnected() {
wsSocketTask.send({
data: JSON.stringify({
action: 'auth',
agent_id: getAgentId(),
uniacid: _uniacid,
token: _token,
user_id: _userId
@@ -233,19 +298,35 @@ export default {
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
},
/**
* PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent)
*/
getAddonName() {
return resolveAddonName(currentAgentId);
},
/**
* PR-7: 获取当前 agent 对应的 API endpoint 前缀
*/
getApiPrefix() {
return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent';
},
/**
* 发送普通消息(blocking 模式)
* PR-7: URL 按 agent_id 路由
*/
async sendMessage(message, options = {}) {
try {
const params = {
url: '/api/kefu/chat',
url: resolveApiUrl('chat'),
data: {
agent_id: getAgentId(),
user_id: getUserId(),
stream: false,
inputs: {},
query: message,
response_mode: 'blocking', // 强制 blocking
response_mode: 'blocking',
user: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
@@ -268,7 +349,6 @@ export default {
} catch (error) {
console.error('[AI Service] AiChat API请求失败,详细错误:', error);
console.error('[AI Service] 错误堆栈:', error.stack);
// 打印更多信息帮助调试
console.error('[AI Service] store 状态:', {
memberInfo: store.state.memberInfo,
uniacid: this.getUniacid(),
@@ -284,7 +364,6 @@ export default {
*/
async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP
// 先确保连接已就绪
try {
await ensureWebSocketConnected();
} catch (e) {
@@ -292,7 +371,6 @@ export default {
throw e;
}
// 设置当前消息回调
wsMessageContent = '';
wsMessageActive = true;
wsMessageOnChunk = onChunk || null;
@@ -305,6 +383,7 @@ export default {
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
@@ -315,13 +394,16 @@ export default {
});
// #endif
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5
try {
const url = Config.baseUrl + `/api/kefu/chat`;
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -334,6 +416,7 @@ export default {
inputs: {},
query: message,
response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
...getDomainParams()
@@ -356,9 +439,8 @@ export default {
buffer += decoder.decode(value, { stream: true });
// 按行分割,保留不完整的最后一行
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 未完成的行留到下次
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
@@ -369,14 +451,12 @@ export default {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
// 兼容嵌套 data.data 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
@@ -392,7 +472,6 @@ export default {
}
}
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
@@ -479,11 +558,13 @@ export default {
/**
* 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/
async generateConversationId() {
const params = {
url: '/api/kefu/createConversation',
url: resolveApiUrl('createConversation'),
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -503,11 +584,12 @@ export default {
/**
* 获取服务状态
* PR-7: URL 按 agent_id 路由
*/
async getServiceStatus() {
try {
const response = await http.sendRequest({
url: '/api/kefu/health',
url: resolveApiUrl('health'),
async: false,
data: {}
});
@@ -527,12 +609,14 @@ export default {
/**
* 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/
async getServiceConfig() {
try {
const params = {
url: '/api/kefu/info',
url: resolveApiUrl('info'),
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -542,7 +626,7 @@ export default {
header: {
'Content-Type': 'application/json'
}
};
};
const response = await http.sendRequest({
...params,
async: false,
@@ -557,12 +641,14 @@ export default {
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
const response = await http.sendRequest({
url: '/api/kefu/clear-conversation',
url: resolveApiUrl('clear-conversation'),
data: {
agent_id: getAgentId(),
conversation_id: conversationId,
...getDomainParams()
},
@@ -577,6 +663,7 @@ export default {
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
*/
async getConversationHistory(params = {}) {
try {
@@ -585,6 +672,7 @@ export default {
}
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
const requestData = {
agent_id: params.agent_id || getAgentId(),
uniacid: params.uniacid || this.getUniacid(),
conversation_id: params.conversation_id,
user_id: params.user_id || getUserId(),
@@ -595,7 +683,7 @@ export default {
...getDomainParams()
};
const response = await http.sendRequest({
url: '/api/kefu/getHistory',
url: resolveApiUrl('getHistory'),
data: requestData,
header: {
'Content-Type': 'application/json'
@@ -625,6 +713,7 @@ export default {
/**
* [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
@@ -632,6 +721,7 @@ export default {
window.currentEventSource.close();
}
const paramsObj = {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
user_id: getUserId(),
member_id: getUserId(),
@@ -642,7 +732,8 @@ export default {
...getDomainParams()
};
const params = new URLSearchParams(paramsObj);
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`;
var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
try {
const eventSource = new EventSource(url);
window.currentEventSource = eventSource;
@@ -687,7 +778,6 @@ export default {
*/
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id });
});
@@ -711,4 +801,26 @@ export default {
// H5 无 WebSocket 长连接,无需操作
},
// #endif
};
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
* @param {string} agentId
*/
setAgentId(agentId) {
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
};

View File

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

View File

@@ -57,10 +57,10 @@ export default {
diyLoadError: false,
diyLoadErrorMsg: '',
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告
adv:{},
adv: {},
};
},
onLoad(option) {
@@ -123,10 +123,10 @@ export default {
// 关闭定位
this.enterDefaultStore();
}
},
onShow() {
this.init();
},
onHide() {
@@ -211,12 +211,12 @@ export default {
}
},
methods: {
play(){
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) => {
// 拒绝定位,进入默认总店
@@ -232,6 +232,7 @@ export default {
}
}
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
await this.getDiyInfo();
//获取启动广告
await this.getDiyAdv();
@@ -289,7 +290,7 @@ export default {
}, 50);
// #endif
},
async getDiyAdv(){
async getDiyAdv() {
//启动广告
let res = await this.$api.sendRequest({
url: '/api/diyview/getstartadv',
@@ -298,13 +299,13 @@ export default {
});
this.adv = res.value
// 弹框形式,首次弹出 1,每次弹出 0
if(this.adv.advshow == 1){
if (this.adv.advshow == 1) {
setTimeout(() => {
if (res.value.advtype == 1) {
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
this.$refs.uniPopupWindow && popwindow_count == 1)) {
this.$refs.uniPopupWindow.open();
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
}
@@ -314,15 +315,22 @@ export default {
}
}, 500);
}
},
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
_showDiyLoadError(err, 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 可能返回空串,故统一用 `|| 中文兜底` 保证非空
@@ -341,8 +349,8 @@ export default {
// empty / unknown 不走模板,直接返回通用网络异常
if (info.type === 'empty' || info.type === 'unknown') return reason;
const tpl = this.$lang('diy.loadFailTpl');
return tpl.replace('{reason}', reason);
const tpl = this.$lang('diy.loadFailTpl', { reason });
return tpl;
},
// 错误横幅「重试」入口
retryDiyInfo() {
@@ -355,10 +363,15 @@ export default {
// 节流:正在请求 / 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) return;
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;
}
}
@@ -379,21 +392,25 @@ export default {
async: false
});
} catch (e) {
console.error('getDiyInfo request failed', e);
console.error('[DIY][getDiyInfo] request failed', e);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.diyInfoLoading = false;
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
this._showDiyLoadError(e, diyInfoFullUrl);
// 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) {
this._showDiyLoadError(null, diyInfoFullUrl);
// errorType='no-response': 完全没有 res 对象
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -410,7 +427,8 @@ export default {
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
// errorType='business': 业务失败 (code!=0 / 消息提示)
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -425,7 +443,13 @@ export default {
let diyDataValue = res.data;
if (diyDataValue.value) {
this.diyData = JSON.parse(diyDataValue.value);
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
@@ -434,22 +458,22 @@ export default {
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;
@@ -513,6 +537,9 @@ 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));
}
},
closePopupWindow() {

View File

@@ -44,6 +44,15 @@ function _sleep(ms) {
}
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,
@@ -54,6 +63,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
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;
@@ -73,6 +83,7 @@ function _makeRequest(url, method, data, header, dataType, responseType, timeout
resolve(res.data);
},
fail: (res) => {
console.error('[HTTP][FAIL]', reqId, method, url, 'cost=', (Date.now() - reqStart) + 'ms', JSON.stringify(res));
reject(res);
}
});
@@ -87,15 +98,20 @@ async function _requestWithRetry(params, url, method, data, header, dataType, re
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);
}
}
@@ -207,6 +223,7 @@ export default {
}
},
uploadBase64(params) {
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
uni.request({
url: Config.baseUrl + '/api/upload/headimgBase64',
method: 'POST',
@@ -221,14 +238,17 @@ export default {
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',
@@ -243,14 +263,17 @@ export default {
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,
@@ -264,9 +287,11 @@ export default {
'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);
}
});

View File

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

View File

@@ -147,20 +147,13 @@ function getQuickAppPrivacySetting() {
}
/**
* 鸿蒙元服务: 查询平台级隐私设置
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
* @returns {Promise<{needAuthorization: boolean}>}
*/
function getHarmonyPrivacySetting() {
return new Promise((resolve) => {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
// #ifndef MP-HARMONY
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
});
}
@@ -188,7 +181,7 @@ async function checkPrivacy() {
}
// #endif
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// H5 / 其他平台: 仅查 storage
return checkPrivacySync();
}
@@ -730,8 +723,9 @@ const PrivacyManager = {
// 优化: storage 已同意时直接走同步快速路径, 不调 async checkPrivacy()
// 节省 1 个 microtask (~0-4ms, dev 模式更明显)
// 注意: 微信小程序/快应用/鸿蒙走 checkPrivacy (平台 API 异步)
// 注意: 微信小程序/快应用走 checkPrivacy (平台 API 异步)
// H5 下 checkPrivacy 内部已 sync 化 (修过), 直接 await 即可
// 鸿蒙元服务: 不调任何系统 API, 不弹自建弹窗 (走平台托管)
const result = _storageAgreed ? checkPrivacySync() : await checkPrivacy();
_needShow = result.needAuthorization;
@@ -741,7 +735,18 @@ const PrivacyManager = {
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;
@@ -757,18 +762,28 @@ const PrivacyManager = {
* 页面 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 → 必须弹
// 兜底: PrivacyManager.show() 没跑过 + 用户没同意过 storage → 必须弹
// 同一次进程内只补一次,避免隐私弹窗被反复触发
// 关键: URL/路由规则已经决策过 "本次跳过" 时, 不应触发兜底弹窗 (白名单场景)
if (!_hasChecked && !_pendingShow && !_urlRuleDecided && !getStorageAgreed()) {

View File

@@ -33,7 +33,16 @@ function useNativeLoading() {
// #endif
}
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
// 总兜底超时档位 (show 时可覆盖):
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
// 业务方覆盖: splashState.show({ timeout: 17000 })
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
// 严防因为 timeout 太短导致用户看到白屏.
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
@@ -71,6 +80,111 @@ class SplashState {
// 避免多通道并发时, 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 = {}) {
@@ -111,7 +225,34 @@ class SplashState {
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
}
const timeout = opts.timeout || DEFAULT_TIMEOUT;
// 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
const MIN_RECOMMENDED_TIMEOUT = 5000
let timeout
if (opts.timeout) {
timeout = opts.timeout
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
console.warn(
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
)
}
} else {
// 自动选档: 根据当前网络环境选 timeout
timeout = this._pickTimeoutByNetwork()
}
this._armTimeout(timeout);
}
/**
* 设置/重设 splash 总超时兜底
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
*/
_armTimeout(timeout) {
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
@@ -125,6 +266,55 @@ class SplashState {
}, 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
@@ -136,15 +326,22 @@ class SplashState {
* @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 = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default';
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) {
this._softHide({ reason, mode });
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
this._softHide(opts);
} else {
this._doHide({ reason, mode });
}
@@ -154,10 +351,17 @@ class SplashState {
* 软隐藏: 标记应该 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) {
@@ -165,6 +369,12 @@ class SplashState {
return;
}
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
if (useStrict) {
return;
}
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
@@ -177,15 +387,27 @@ class SplashState {
/**
* 真正触发软隐藏
* 如果页面还未就绪, 等待页面就绪后再 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(() => {
@@ -204,6 +426,11 @@ class SplashState {
/**
* 实际隐藏 (硬路径)
* @param {Object} opts
* @param {string} [opts.reason='manual'] 隐藏原因
* @param {string} [opts.mode='default'] 动画模式
* @param {boolean} [opts._silent=false] true=不发 splash:hide 事件
* 适用于 notifyDiyLoadError: 已经发了 splash:diy-error, 不能再发 splash:hide
*/
_doHide(opts) {
if (!this._visible) return; // 防重入
@@ -225,6 +452,8 @@ class SplashState {
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',
@@ -264,6 +493,60 @@ class SplashState {
}
}
/**
* diy 加载失败时通知 (网络异常/业务失败/无响应)
* - 任何模式: 真关 splash (不能无限等, 错误已显示在页面横幅上)
* - 触发独立事件 'splash:diy-error' (不是 'splash:hide')
* 业务方可监听此事件做: 错误上报 / 自定义错误展示 / 区分错误类型
* 调用时机: diy 错误横幅出现后 (diy.js._showDiyLoadError)
*
* @param {Object} [opts]
* @param {string} [opts.reason='diy-load-error'] 隐藏原因
* 用于 'splash:diy-error' 事件的 payload, 业务方可区分:
* - 'diy-network-error': 网络异常 (uni.request catch)
* - 'diy-no-response': 无响应 (res 为空)
* - 'diy-business-error': 业务失败 (code!=0)
* @param {string} [opts.errorType] 错误类型: 'network' | 'business' | 'no-response'
* @param {Object|string} [opts.err] 原始错误对象
* @param {string} [opts.url] 出错的请求地址
*/
notifyDiyLoadError(opts = {}) {
// 已 _pageReady=true (notifyPageReady 之后): noop
// 原因: 业务方先调 notifyPageReady (diy 渲染好) 之后又调 notifyDiyLoadError (重复调用或时序错乱)
// 不应再触发 splash:diy-error (已经发过 splash:hide)
if (this._pageReady) return;
// 复用 notifyPageReady 的关闭逻辑 (页面就绪 = 错误状态也算"就绪")
// 原因: 错误状态页 (diyLoadError=true) 已经有 UI 反馈, splash 真关不卡视觉
this._pageReady = true
// 清除所有兜底, 真关 (strict 模式严格依赖 notifyPageReady/notifyDiyLoadError)
if (this._pageReadyGraceId) {
clearTimeout(this._pageReadyGraceId)
this._pageReadyGraceId = null
}
if (this._mountGraceId) {
clearTimeout(this._mountGraceId)
this._mountGraceId = null
}
// 触发独立事件 (不发 splash:hide, 让业务方区分正常 hide 和 diy 错误)
this._emit('splash:diy-error', {
reason: opts.reason || 'diy-load-error',
errorType: opts.errorType,
err: opts.err,
url: opts.url,
})
// 关键: 直接 _doHide 关闭 splash, **不**走 _flushPendingHide
// 原因: _flushPendingHide 内部会 _doHide → 触发 splash:hide 事件
// notifyDiyLoadError 路径**只**应发 splash:diy-error, 不应发 splash:hide
// (业务方根据事件类型区分, 不能混淆)
if (this._pendingHide) {
// 把 _pendingHide 取出, 直接 _doHide 关闭
// _silent=true 让 _doHide 不发 splash:hide (因为已经发了 splash:diy-error)
const _pendingOpts = this._pendingHide
this._pendingHide = null
setTimeout(() => this._doHide({ ..._pendingOpts, _silent: true }), 50)
}
}
/**
* 组件 unmount 时通知 (用于 _hasMounted 状态管理)
* 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减

View File

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

View File

@@ -102,6 +102,36 @@ export function parsePageParams(raw, defaultValue = {}) {
}
/**
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
*
* 设计动机:URL query 中布尔值的合法写法不止一种——
* ?show_home=1 -> '1' -> true
* ?show_home=true -> 'true' -> true
* ?show_home=true -> true -> true
* ?show_home=0 -> '0' -> false
* ?show_home=false -> 'false' -> false
* ?show_home= -> '' -> false
* 未传 -> undefined -> false
*
* 与裸 `Boolean(x)` 的差异:
* Boolean('false') === true (任何非空字符串都是 true)
* Boolean('0') === true (同上)
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
*
* @param {*} val 任意值
* @returns {boolean}
*/
export function coerceBool(val) {
if (val === true || val === 1) return true;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
return s === '1' || s === 'true';
}
return false;
}
// 关于页面路径,使用常量导出
export const INDEX_PAGE_URL = '/pages/index/index';
export const CATEGORY_PAGE_URL = '/pages_goods/category';
@@ -109,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -457,6 +488,9 @@ export default {
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
parsePageParams,
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
coerceBool,
/** HTML 实体解码 */
decodeEntity,
@@ -511,6 +545,27 @@ export default {
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
//
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
// 修复场景:
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
// onShow 死循环跳登录页.
// 排除场景 (重要, 避免影响):
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
if (cleanPath === LOGIN_PAGE_URL) {
mode = 'reLaunch';
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
}
}
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
@@ -538,18 +593,18 @@ export default {
merged.page_params = page_params;
}
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
const queryStr = Object.keys(merged)
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&');
@@ -615,30 +670,33 @@ export default {
const diyTabBar = store.state.tabBarList;
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
const cleanUrl = url.split('?')[0];
const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
return normalizedTab === normalizedUrl;
});
if (isDiyTabRoot) {
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
// #ifdef MP-HARMONY
navApi = uni.reLaunch;
// #endif
navApi({
url,
fail: (err) => {
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
}
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
if (!isExcludedPage) {
const isDiyTabRoot = diyTabBar.list.some(item => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
return normalizedTab === normalizedUrl;
});
return;
if (isDiyTabRoot) {
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
// #ifdef MP-HARMONY
navApi = uni.reLaunch;
// #endif
navApi({
url,
fail: (err) => {
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
}
});
return;
}
}
}
@@ -669,6 +727,41 @@ export default {
break;
case 'reLaunch':
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
// H5 平台修复 (2026-07-23):
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
// 注销/退出登录后用户能看到旧会员信息。
// location.replace 不产生新历史条目, 直接替换当前条目,
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
// #ifdef H5
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
&& typeof window.location.replace === 'function') {
window.location.replace(url);
return;
}
// #endif
// 小程序平台修复 (2026-07-23):
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
// 解决方案:
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
// #ifdef MP
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
return;
}
globalThis.__reLaunchLock = true;
setTimeout(() => {
uni.reLaunch({ url });
// 300ms 后释放锁
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
}, 0);
return;
// #endif
// APP / 其他平台: 原生 reLaunch
uni.reLaunch({
url
});
@@ -1111,6 +1204,23 @@ export default {
* @param {Object} link
*/
diyRedirectTo(link) {
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
const title = link.title || 'AI 智能体';
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
return this.redirectTo(AI_AGENT_PAGE_URL, {
agent_id: agentId,
title: encodeURIComponent(title),
enable_set_title: link.enable_set_title ? 1 : 0,
...(dyuEntry ? { __dyu: dyuEntry } : {}),
});
}
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
//if (link == null || Object.keys(link).length == 1) return;

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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -58,7 +58,7 @@
<view :class="['category-title', 'category-title-with-lines']">{{ one.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list double-column show" v-if="template1ChildGoodsList[one.category_id] && template1ChildGoodsList[one.category_id].length">
<view class="goods-item" v-for="(item, index) in template1ChildGoodsList[one.category_id]" :key="one.category_id + '-' + index">
<view class="goods-item" v-for="(item, index) in template1ChildGoodsList[one.category_id]" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(one.category_id, index)" show-menu-by-longpress />
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
@@ -76,6 +76,9 @@
</block>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
</view>
</view>
</view>
</view>
@@ -110,6 +113,9 @@
</block>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
</view>
</view>
</view>
</view>
@@ -173,6 +179,9 @@
</block>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
</view>
</view>
</view>
</view>
@@ -516,7 +525,7 @@ export default {
currentOneCategory: {
type: Object,
default: null
}
},
},
mixins: [DiyMinx],
data() {
@@ -582,7 +591,7 @@ export default {
*/
isGroupedDisplay() {
return this.value.template == 1 && this.value.level == 3;
}
},
},
created() {
this.type = this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
@@ -1420,6 +1429,26 @@ export default {
border-radius: 12rpx;
overflow: hidden;
margin-bottom: 20rpx;
.footer-wrap-template1 {
padding: 16rpx 4rpx 0rpx;
display: flex;
justify-content: flex-end;
.detail-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32rpx;
height: 55rpx;
background-color: #ff4545;
color: #fff;
font-size: 25rpx;
border-radius: 26rpx;
box-sizing: border-box;
white-space: nowrap;
}
}
}
}
}
@@ -2388,14 +2417,13 @@ export default {
.second-category-btns {
display: flex;
flex-wrap: wrap;
// #ifdef H5
padding: 20rpx 10rpx 6rpx 24rpx;
// #endif
// #ifndef H5
padding: 20rpx 20rpx 10rpx;
// #endif
align-items: center;
justify-content: flex-start;
padding: 20rpx 20rpx 20rpx 24rpx;
height: 100rpx;
background: #fff;
border-bottom: 1rpx solid #f0f0f0;
box-sizing: border-box;
.second-category-btn {
padding: 10rpx 24rpx;
@@ -2403,7 +2431,7 @@ export default {
color: #666;
border-radius: 30rpx;
font-size: 20rpx;
margin: 0 14rpx 17rpx 0;
margin: 0 14rpx 0rpx 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;

View File

@@ -93,20 +93,20 @@
<view class="category-page-skeleton" v-if="categoryTreeLoading">
<view class="skeleton-content">
<view class="skeleton-tree">
<view class="skeleton-tree-item" v-for="n in 8" :key="'sk-tree-' + n"></view>
<view class="skeleton-tree-item" v-for="n in 8" :key="n"></view>
</view>
<view class="skeleton-grid">
<view class="skeleton-card" v-for="n in 6" :key="'sk-card-' + n"></view>
<view class="skeleton-card" v-for="n in 6" :key="n"></view>
</view>
</view>
</view>
<view class="content-box" v-else-if="categoryTree">
<view class="content-box" v-if="categoryTree">
<block v-if="categoryTree.length">
<scroll-view scroll-y="true" class="tree-wrap">
<view class="category-item-wrap">
<!-- 模板4: 左侧显示一级分类(顶部轮播中的分类) -->
<view v-if="value.template == 4">
<view class="category-item" v-for="(item, index) in templateFourData" :key="'one-' + index" :class="[
<view class="category-item" v-for="(item, index) in templateFourData" :key="index" :class="[
{ select: oneCategorySelect == index },
{ 'border-bottom': oneCategorySelect + 1 === index },
{ 'border-top': oneCategorySelect - 1 === index }
@@ -119,7 +119,8 @@
<view class="category-item" v-for="(item, index) in categoryTree" :key="index" :class="[
{ select: select == index },
{ 'border-bottom': value.template == 4 && select + 1 === index },
{ 'border-top': value.template == 4 && select - 1 === index }
{ 'border-top': value.template == 4 && select - 1 === index },
{ 'first-category-item': index == 0 && value.template == 99 }
]" @tap.stop="switchOneCategory(index)">
<view class="">{{ item.category_name }}</view>
</view>
@@ -163,6 +164,7 @@
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
:lastSecondCategoryName="value.template == 99 ? lastSecondCategoryName : ''"
:currentOneCategory="categoryTree && categoryTree[select] ? categoryTree[select] : null"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</view>
@@ -263,7 +265,8 @@ export default {
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
// 显示骨架占位而非空态文案
categoryTreeLoading: true
categoryTreeLoading: true,
};
},
created() {
@@ -300,7 +303,14 @@ export default {
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) { }
componentRefresh: function (nval) { },
categoryTreeLoading: function (nval, oval) {
if (nval === false && oval === true) {
this.$nextTick(() => {
if (!this.heightArea.length) this.getHeightArea(-1);
});
}
}
},
computed: {
cartTotalMoney() {
@@ -374,6 +384,7 @@ export default {
this.$store.dispatch('getCartNumber');
if (!this.heightArea.length) this.getHeightArea(-1);
},
/**
* 获取高度区间
*
@@ -729,11 +740,12 @@ export default {
}
.content-box {
flex: 1;
height: 0;
display: flex;
flex: 1;
height: 0;
display: flex;
align-items: flex-start;
.tree-wrap {
.tree-wrap {
width: 170rpx;
height: 100%;
background-color: #f5f5f5;
@@ -772,10 +784,10 @@ export default {
padding: 26rpx 28rpx;
box-sizing: border-box;
position: relative;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
view {
color: #222222;
color: var(--diy-category-tree-normal-text-color, #222222);
width: 100%;
white-space: break-word;
overflow: hidden;
@@ -792,10 +804,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: var(--diy-category-tree-select-size, 28rpx);
}
@@ -1049,6 +1061,8 @@ export default {
}
}
@keyframes category-skeleton-shimmer {
0% {
background-position: 100% 50%;
@@ -1498,11 +1512,11 @@ export default {
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: #fff;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: #666;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
@@ -1513,10 +1527,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--base-color, #FF6B35);
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
@@ -1589,19 +1603,22 @@ export default {
.tree-wrap .category-item {
padding: 30rpx 16rpx;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
white-space: normal;
word-break: break-all;
view {
color: #333;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
line-height: 1.5;
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
border-bottom: 1rpx solid #f0f0f0;
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
@@ -1621,6 +1638,21 @@ export default {
}
}
// 模板99: 第一个分类项固定高度,与右侧二级分类按钮区域对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
view {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.content-wrap {
background: #f5f5f5;
padding: 0;

View File

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

View File

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

View File

@@ -22,13 +22,18 @@
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
</view>
</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'
@@ -52,6 +57,12 @@ export default {
showSubmit: {
type: Boolean,
default: true
},
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
onSubmit: {
type: Function,
default: null
}
},
mixins: [DiyMinx],
@@ -148,6 +159,10 @@ export default {
submitText() {
return this.mergedFormConfig.submitText || '提交';
},
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
hasCustomSubmit() {
return typeof this.onSubmit === 'function';
},
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
formCfg() {
@@ -187,11 +202,22 @@ export default {
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'),
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'),
@@ -279,24 +305,26 @@ export default {
return true;
},
// 提交表单
submitForm() {
var self = this;
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
handleSubmit() {
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
if (!this.verify()) {
return;
}
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
if (this.$listeners && this.$listeners.submit) {
this.$emit('submit', this.getFormData());
return;
const formData = this.getFormData();
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
if (this.hasCustomSubmit) {
return this.onSubmit(formData);
}
// 兜底:内置 sendRequest 流程
this.defaultSubmit(formData);
},
// 获得保单数据
const submitFormData = this.getFormData();
// 提交到后端
// 公共兜底实现:默认的 /form/api/form/create 流程;
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
defaultSubmit(submitFormData) {
var self = this;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
@@ -308,7 +336,7 @@ export default {
success: function(res) {
if (res.code === 0) {
uni.showToast({
title: '提交成功',
title: self.$lang('submitSuccess'),
icon: 'success'
});
// 重置表单
@@ -317,7 +345,7 @@ export default {
}
} else {
uni.showToast({
title: res.message || '提交失败',
title: res.message || self.$lang('submitFail'),
icon: 'none'
});
}

View File

@@ -159,7 +159,10 @@
<!-- 商品推荐 -->
<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>
@@ -306,9 +309,11 @@ export default {
}
},
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();
}
@@ -349,11 +354,13 @@ export default {
},
methods: {
_notifyLoaded() {
if (this._loadedTimer) clearTimeout(this._loadedTimer);
this._loadedTimer = setTimeout(() => {
this.$emit('loaded');
}, 300);
},
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 = [];

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -26,24 +26,17 @@
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box">
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
<textarea
:rows="item.value.rows || 2"
:rows="item.value.rows || textareaRowsValue"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
:style="textareaStyle"
></textarea>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<textarea
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="inputStyle"
></textarea>
<!-- #endif -->
</view>
</view>
</view>
@@ -70,8 +63,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box check-group-box">
<checkbox-group @change="checkboxChange($event, index)">
<view :class="['box', 'check-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked" />
<view class="checkbox">
@@ -90,8 +83,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view class="box radio-group-box">
<radio-group @change="radioChange($event, index)">
<view :class="['box', 'radio-group-box', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<radio :value="v.value" :checked="item.val == v.value" />
<view class="radio-box">
@@ -281,10 +274,22 @@
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
labelFontWeight: 600,
labelAutoMaxWidth: 200,
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#909399',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-newform 对齐)
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
textareaRows: 4,
textareaLineHeight: 44,
// 输入框视觉增强(与 ns-newform 对齐)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputFocusBgColor: '#ffffff',
showRowDivider: true,
rowDividerColor: '#f0f0f0'
};
@@ -308,10 +313,17 @@
labelFixedWidth: pick('labelFixedWidth', 'label_fixed_width'),
labelFontSize: pick('labelFontSize', 'label_font_size'),
labelColor: pick('labelColor', 'label_color'),
labelFontWeight: pick('labelFontWeight', 'label_font_weight'),
requiredColor: pick('requiredColor', 'required_color'),
checkedColor: pick('checkedColor', 'checked_color'),
placeholderColor: pick('placeholderColor', 'placeholder_color'),
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
textareaRows: pick('textareaRows', 'textarea_rows'),
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
inputBgColor: pick('inputBgColor', 'input_bg_color'),
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
inputFocusBgColor: pick('inputFocusBgColor', 'input_focus_bg_color'),
showRowDivider: pick('showRowDivider', 'show_row_divider'),
rowDividerColor: pick('rowDividerColor', 'row_divider_color')
};
@@ -363,10 +375,18 @@
'--label-fixed-width': this.$util.rpx(cfg.labelFixedWidth),
'--label-font-size': this.$util.rpx(num(cfg.labelFontSize, 28)),
'--label-color': cfg.labelColor,
'--label-font-weight': cfg.labelFontWeight,
'--required-color': cfg.requiredColor,
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
'--textarea-rows': num(cfg.textareaRows, 4),
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
// 输入框视觉增强(与 ns-newform 对齐)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--row-divider-color': cfg.rowDividerColor,
'--show-row-divider': cfg.showRowDivider === false ? '0' : '1'
});
@@ -402,6 +422,42 @@
'line-height': this.$util.rpx(this.config.inputHeight)
});
},
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
textareaRowsValue() {
const n = parseInt(this.config.textareaRows, 10);
return Number.isFinite(n) && n > 0 ? n : 4;
},
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出固定可视高度)
textareaLineHeightValue() {
const n = parseInt(this.config.textareaLineHeight, 10);
return Number.isFinite(n) && n > 0 ? n : 44;
},
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
// 设计要点:
// 1) 不复用 inputStyle 的 min-height / line-height:那两个值取自 inputHeight(默认 60rpx),
// 对 textarea 太小,会让原生 textarea 单行过高、可视区域过小;
// 2) 不使用 auto-height:原生 textarea 上 auto-height 会让组件随内容无限扩展,
// 遮挡后续组件,导致"看不到下面的其他组件";
// 3) 高度 = rows × lineHeight(两个值都由 formConfig 动态下发,不再写死 176rpx):
// - H5 浏览器 / 微信原生层:原生 <textarea> 同时识别 :rows 和 CSS line-height,
// 高度由浏览器自然算出,与 :rows 完全一致;
// - 鸿蒙 ArkUI:原生 TextArea 只识别 :rows,不识别 CSS height,所以我们在 JS 里
// 显式算 height = rows × lineHeight 反向写出,保证三端可视高度像素级一致。
textareaStyle() {
const lh = this.$util.rpx(this.textareaLineHeightValue);
const rows = this.textareaRowsValue;
// 加上 padding(12rpx 上下) 和 border(1rpx 上下) 的占用,避免与 :rows 算出的高度不一致时被截断
const paddings = this.$util.rpx(24) + this.$util.rpx(2);
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
return this.$util.objToStyle({
'height': h,
'min-height': h,
'line-height': lh,
'border-radius': this.$util.rpx(this.config.inputRadius),
'font-size': this.$util.rpx(this.config.labelFontSize),
'color': this.config.labelColor
});
},
// 必填星号颜色
requiredColor() {
return this.config.requiredColor;
@@ -431,10 +487,33 @@
}
},
methods: {
// 预计算每项的 option_layout inline style(与 ns-newform 对齐)
// 跨端兼容:小程序 / 鸿蒙元服务 WXML 不支持模板里的方法调用,必须预计算到 item 上
recomputeItemStyles() {
if (!this.formData || this.formData.length === 0) return;
this.formData.forEach((item) => {
const itemValue = (item && item.value) || {};
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
initFormData() {
// 深拷贝 prop 数据到本地状态,避免 mutate 上游
this.formData = JSON.parse(JSON.stringify(this.data || []));
this.processFormData();
// 预计算每项 inline style(与 ns-newform 对齐,跨端兼容)
this.recomputeItemStyles();
},
setFormData() {
this.initFormData();
@@ -442,7 +521,9 @@
},
processFormData() {
this.formData.forEach(item => {
if (!item.val) item.val = item.value.default ? item.value.default : '';
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) item.val = item.value.default ? item.value.default : '';
if (item.value.options) {
item.option_lists = [];
@@ -877,10 +958,19 @@
--label-fixed-width: 180rpx;
--label-font-size: 28rpx;
--label-color: #1f2937;
--label-font-weight: 600;
--required-color: #ef4444;
--placeholder-font-size: 28rpx;
--checked-color: #ff4544;
--placeholder-color: #909399;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 44rpx;
// 输入框视觉增强(与 ns-newform 对齐)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-focus-bg-color: #ffffff;
--row-divider-color: #f0f0f0;
--show-row-divider: 1;
}
@@ -944,6 +1034,7 @@
text-align: left;
font-size: var(--label-font-size, $font-size-base);
color: var(--label-color, #1f2937);
font-weight: var(--label-font-weight, 600);
text {
font-size: var(--label-font-size, $font-size-base);
@@ -965,6 +1056,8 @@
line-height: inherit;
text-align: left;
// H5 端 <input> 可能被 uni-app 编译为 <uni-input> 包装组件,::v-deep 穿透 scoped 命中真实原生 input
::v-deep input,
input {
font-size: var(--label-font-size, $font-size-base);
text-align: left;
@@ -976,8 +1069,16 @@
min-height: var(--input-height, 60rpx);
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 0;
background: transparent;
padding: 0 12rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
// 原生 placeholder 样式(WebView / H5 生效;小程序/鸿蒙的 placeholder
// 通过 placeholder-class="placeholder" 钩子走 .placeholder 规则)
@@ -995,20 +1096,30 @@
line-height: var(--input-height, 60rpx);
}
::v-deep textarea,
textarea {
font-size: var(--label-font-size, $font-size-base);
width: 100%;
color: var(--label-color, #1f2937);
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
min-height: 88rpx;
height: 88rpx;
line-height: 44rpx;
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
// 真正做到了"rows 改 → 高度自动改"。
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
text-align: left;
border-radius: var(--input-radius, 0rpx);
box-sizing: border-box;
padding: 0;
background: transparent;
padding: 12rpx;
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
transition: all 0.3s ease;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
}
.uni-input {
@@ -1016,6 +1127,11 @@
color: var(--label-color, #1f2937);
line-height: var(--input-height, 60rpx);
min-height: var(--input-height, 60rpx);
// 输入框视觉增强(与 ns-newform 对齐)
border: 1rpx solid var(--input-border-color, #e5e7eb);
background: var(--input-bg-color, #f9fafb);
border-radius: var(--input-radius, 0rpx);
transition: all 0.3s ease;
}
.select-address {
@@ -1037,6 +1153,15 @@
flex-wrap: wrap;
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
}
}
label {
display: flex;
align-items: center;

View File

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

View File

@@ -56,10 +56,18 @@
<!-- 手机号授权按钮 -->
<block v-if="isAgree">
<!-- #ifdef MP-WEIXIN -->
<button class="auth-login save-btn border-0" style="border: none;" open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙元服务无 getPhoneNumber 微信 API,改为弹出手机号+验证码表单 -->
<button class="auth-login save-btn border-0" style="border: none;" @tap="showHarmonyMobileForm = true">
<text style="color: #fff; font-size: 30rpx;">{{ $lang('components.ns-login.agreeAndAuthorizeMobile') }}</text>
</button>
<!-- #endif -->
</block>
<block v-else>
<button class="auth-login save-btn border-0" style="border: none;" @tap="authno">
@@ -67,6 +75,27 @@
</button>
</block>
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙专属:手机号+验证码表单 -->
<view v-if="showHarmonyMobileForm" class="harmony-mobile-form" @touchmove.prevent.stop>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.mobilePlaceholder')" v-model="formData.mobile" maxlength="11" />
</view>
<view class="item-wrap" v-if="isOpenCaptcha == 1">
<text class="label">{{ $lang('components.ns-login.captchaLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.captchaPlaceholder')" v-model="formData.vercode" maxlength="4" />
<image :src="captcha.img" class="captcha" @tap="getCaptcha" show-menu-by-longpress></image>
</view>
<view class="item-wrap">
<text class="label">{{ $lang('components.ns-login.dynacodeLabel') }}</text>
<input type="number" :placeholder="$lang('components.ns-login.dynacodePlaceholder')" v-model="formData.dynacode" maxlength="4" />
<view class="send color-base-text" @tap="sendMobileCode">{{ dynacodeData.codeText }}</view>
</view>
<button type="default" class="save-btn" @tap="harmonySaveMobile" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</view>
<!-- #endif -->
<view @tap="closeLogin"
style="font-size: 30rpx; color: #888; text-align: center; padding-top: 10rpx;">
{{ $lang('components.ns-login.notNow') }}
@@ -109,11 +138,20 @@
<view class="item-wrap" v-if="forceBindingMobileControl">
<text class="label">{{ $lang('components.ns-login.mobileLabel') }}</text>
<!-- #ifdef MP-WEIXIN -->
<button open-type="getPhoneNumber" :plain="true" class="auth-login border-0"
@getphonenumber="getPhoneNumber">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else>{{ $lang('components.ns-login.getMobile') }}</text>
</button>
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙无 getPhoneNumber,展示手动输入的手机号,没有时显示"获取手机号"提示用户去上面表单录入 -->
<view class="auth-login border-0" :plain="true">
<text class="mobile" v-if="formData.mobile">{{ formData.mobile }}</text>
<text class="color-base-text" v-else @tap="showHarmonyMobileForm = true">{{ $lang('components.ns-login.getMobile') }}</text>
</view>
<!-- #endif -->
</view>
<button type="default" class="save-btn" @tap="saveMp" :disabled="isDisabled">{{ $lang('components.ns-login.save') }}</button>
</template>
@@ -170,12 +208,16 @@ export default {
isSend: false
},
// 小程序获取手机号所需数据
authMobileData: {
iv: '',
encryptedData: ''
},
site_name: ''
};
authMobileData: {
iv: '',
encryptedData: ''
},
site_name: '',
// #ifdef MP-HARMONY
// 鸿蒙专属:控制手机号+验证码表单显示
showHarmonyMobileForm: false,
// #endif
};
},
options: {
styleIsolation: 'shared'
@@ -203,7 +245,7 @@ export default {
if (this.forceBindingMobileControl) {
if (this.formData.mobile.length == 0) return true;
// #ifdef H5
// #ifdef H5 || MP-HARMONY
// 验证码
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
// 动态码
@@ -343,7 +385,7 @@ export default {
openCompleteInfoPop() {
this.getRegisterConfig();
// #ifdef H5
// #ifdef H5 || MP-HARMONY
if (!this.storeToken) this.getCaptchaConfig();
// #endif
@@ -676,6 +718,26 @@ export default {
this.forceBindMobile();
},
// #ifdef MP-HARMONY
// 鸿蒙元服务保存手机号:复用 H5 的强制绑定手机号流程
harmonySaveMobile() {
if (this.forceBindingMobileControl) {
let data = {
mobile: this.formData.mobile,
key: this.formData.key,
code: this.formData.dynacode
};
if (this.captcha.id != '') {
data.captcha_id = this.captcha.id;
data.captcha_code = this.formData.vercode;
}
if (!this.verify(data)) return;
}
// 鸿蒙环境下通常也开启了 forceBindingMobileControl(走 forceBindMobile 接口)
this.forceBindMobile();
},
// #endif
// 微信小程序保存数据
saveMp() {
if (this.formData.mobile === '') {

View File

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

View File

@@ -9,10 +9,10 @@
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<input
type="text"
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
<input
type="text"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
/>
</view>
@@ -27,10 +27,16 @@
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
<textarea
:placeholder="item.value.placeholder || $lang('components.ns-newform.inputPlaceholder')"
placeholder-class="placeholder color-tip"
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
- H5 / 微信:原生 textarea 识别 :rows 属性;
- 鸿蒙:渲染为 ArkUI 原生 TextArea,CSS height 不生效,必须靠 :rows 控制。
rows 全部由 formConfig 动态下发,与 ns-form 行为保持一致。 -->
<textarea
:rows="item.value.rows || textareaRowsValue"
:placeholder="item.value.placeholder"
placeholder-class="placeholder color-tip"
v-model="item.val"
:style="textareaStyle"
></textarea>
</view>
</view>
@@ -64,8 +70,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'check-group-box', 'align-left']">
<checkbox-group @change="checkboxChange($event, index)">
<view :class="['box', 'check-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<checkbox-group @change="checkboxChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<checkbox :value="v.value" :checked="v.checked"></checkbox>
<view class="checkbox">
@@ -92,8 +98,8 @@
<text class="tit">{{ item.value.title }}</text>
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
</view>
<view :class="['box', 'radio-group-box', 'align-left']">
<radio-group @change="radioChange($event, index)">
<view :class="['box', 'radio-group-box', 'align-left', 'option-layout-' + (item.value.option_layout || 'wrap')]">
<radio-group @change="radioChange($event, index)" :style="item._optionLayoutStyle">
<label v-for="(v, k) in item.option_lists" :key="k">
<radio :value="v.value" :checked="item.val == v.value"></radio>
<view class="radio-box">
@@ -371,10 +377,22 @@ export default {
labelFixedWidth: 180,
labelFontSize: 28,
labelColor: '#1f2937',
labelFontWeight: 600,
labelAutoMaxWidth: 200, // auto 模式下 label 宽度上限(rpx),超过后 ellipsis 截断
requiredColor: '#ef4444',
checkedColor: '#ff4544',
placeholderColor: '#ddd',
placeholderFontSize: 28,
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
textareaRows: 4,
textareaLineHeight: 44,
// 输入框视觉增强(可由后端 form_config 覆盖)
inputBorderColor: '#e5e7eb',
inputBgColor: '#f9fafb',
inputFocusBorderColor: '#ff4544',
inputFocusBgColor: '#ffffff',
labelFontWeight: 600,
// 提交按钮
submitColor: '#ffffff',
submitBgColor: '#ff4544',
@@ -444,6 +462,15 @@ export default {
'--checked-color': cfg.checkedColor,
'--placeholder-color': cfg.placeholderColor,
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
// textarea 行数 / 行高(CSS 兜底 + JS 算高度都用得到)
'--textarea-rows': num(cfg.textareaRows, 4),
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
'--input-border-color': cfg.inputBorderColor,
'--input-bg-color': cfg.inputBgColor,
'--input-focus-border-color': cfg.inputFocusBorderColor,
'--input-focus-bg-color': cfg.inputFocusBgColor,
'--label-font-weight': cfg.labelFontWeight,
'--submit-color': cfg.submitColor,
'--submit-bg-color': cfg.submitBgColor,
'--submit-margin-top': this.$util.rpx(cfg.submitMarginTop || 0),
@@ -483,6 +510,30 @@ export default {
'border-radius': this.$util.rpx(this.config.inputRadius)
});
},
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
textareaRowsValue() {
const n = parseInt(this.config.textareaRows, 10);
return Number.isFinite(n) && n > 0 ? n : 4;
},
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出可视高度)
textareaLineHeightValue() {
const n = parseInt(this.config.textareaLineHeight, 10);
return Number.isFinite(n) && n > 0 ? n : 44;
},
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
// 高度 = rows × lineHeight + padding + border,由 formConfig 动态控制,与 ns-form 同公式
textareaStyle() {
const lh = this.$util.rpx(this.textareaLineHeightValue);
const rows = this.textareaRowsValue;
const paddings = this.$util.rpx(24) + this.$util.rpx(2); // padding 12rpx 上下 + border 1rpx 上下
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
return this.$util.objToStyle({
'height': h,
'min-height': h,
'line-height': lh,
'border-radius': this.$util.rpx(this.config.inputRadius)
});
},
labelStyle() {
const style = {
fontSize: this.$util.rpx(this.config.labelFontSize),
@@ -536,6 +587,23 @@ export default {
// item 级别 box 对齐方式(与原 getItemBoxAlignStyle 逻辑一致)
const align = itemValue.input_align;
this.$set(item, '_boxAlignStyle', align ? { 'text-align': align } : {});
// 选项排列样式:vertical 时给 checkbox-group / radio-group 加 flex-direction
// 走 inline style(不依赖 dev 编译产物的 SCSS 类,跨端兼容更稳)
// 直接写 flex-direction 等 CSS 属性,inline style 优先级最高
const optLayout = itemValue.option_layout;
const optStyle = {};
if (optLayout === 'vertical') {
optStyle['flex-direction'] = 'column';
optStyle['align-items'] = 'flex-start';
optStyle['gap'] = '8px';
} else {
// wrap 模式:用 display: flex 基础属性兜底(不强制,让浏览器按 SCSS 默认渲染)
optStyle['flex-direction'] = 'row';
optStyle['align-items'] = 'center';
optStyle['gap'] = '6px';
}
this.$set(item, '_optionLayoutStyle', optStyle);
});
},
// 容器样式(提供给外部 diy-form 使用)
@@ -559,8 +627,13 @@ export default {
if (len > maxChars) maxChars = len;
});
// 与后端公式一致:chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2)
const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
this.maxLabelWidth = Math.round(widthPx * 2);
let widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12);
let widthRpx = Math.round(widthPx * 2);
// auto 模式下加宽度上限:超长 title 撑爆布局时,按 labelAutoMaxWidth 截断(rpx)
// 上限内 ellipsis 截断,保证控件区有足够空间;上限外也不会让 label 占满整行
const maxWidthRpx = parseInt(this.config.labelAutoMaxWidth) || 200;
if (widthRpx > maxWidthRpx) widthRpx = maxWidthRpx;
this.maxLabelWidth = widthRpx;
// 宽度变了,需要把全局 nameWidthStyle 重新合到每项 _labelStyle 里
this.recomputeItemStyles();
},
@@ -578,7 +651,9 @@ export default {
processFormData() {
this.formData.forEach((item) => {
if (!item.val) {
// Text / Textarea:当 placeholder 为空字符串或没有值时,不设置默认值
const skipDefault = (item.controller == 'Text' || item.controller == 'Textarea') && !item.value.placeholder;
if (!item.val && !skipDefault) {
item.val = item.value.default ? item.value.default : '';
}
@@ -959,6 +1034,15 @@ export default {
--submit-bg-color: #ff4544;
--placeholder-color: #ddd;
--placeholder-font-size: 28rpx;
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
--textarea-rows: 4;
--textarea-line-height: 44rpx;
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
--input-border-color: #e5e7eb;
--input-bg-color: #f9fafb;
--input-focus-border-color: #ff4544;
--input-focus-bg-color: #ffffff;
--label-font-weight: 600;
--show-row-divider: 1;
// 与后端 form-preview-wrap.no-divider-all 对齐:整组隐藏所有行分割线
@@ -1010,13 +1094,13 @@ export default {
&.vertical-layout {
flex-direction: column;
align-items: flex-start;
.name {
width: 100%;
margin-bottom: 8rpx;
margin-bottom: var(--label-gap, 16rpx);
margin-right: 0;
}
.box {
width: 100%;
}
@@ -1066,7 +1150,7 @@ export default {
text-align: left;
font-size: var(--item-label-font-size, var(--label-font-size, 28rpx));
color: var(--item-label-color, var(--label-color, #1f2937));
font-weight: 500;
font-weight: var(--item-label-font-weight, var(--label-font-weight, 600));
letter-spacing: 0.2rpx;
text {
@@ -1134,49 +1218,107 @@ export default {
width: 100%;
}
input {
// H5 端 <input> 被 uni-app 编译为 <uni-input> 自定义组件,
// 真实 <input> 是其子节点,需要 ::v-deep 穿透 scoped 才能命中
::v-deep input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
border: none;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0;
background: transparent;
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: transparent;
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
textarea {
// 非 H5 端(小程序 / 鸿蒙 / App)<input> 是直接原生元素,普通选择器即可
input {
font-size: var(--label-font-size, 28rpx);
text-align: left;
height: var(--input-height, 60rpx);
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
line-height: var(--input-height, 60rpx);
padding: 0 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
width: 100%;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
::v-deep textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
min-height: 120rpx;
line-height: 1.5;
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
text-align: left;
border: none;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
padding: 0;
background: transparent;
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
height: auto;
&:focus {
background: transparent;
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
}
}
textarea {
font-size: var(--label-font-size, 28rpx);
width: 100%;
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
line-height: var(--textarea-line-height, 44rpx);
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
text-align: left;
border: 1rpx solid var(--input-border-color, #e5e7eb);
border-radius: var(--input-radius, 10rpx);
padding: 12rpx;
background: var(--input-bg-color, #f9fafb);
color: var(--label-color, #1f2937);
transition: all 0.3s ease;
box-sizing: border-box;
&:focus {
background: var(--input-focus-bg-color, #ffffff);
border-color: var(--input-focus-border-color, #ff4544);
}
&::placeholder {
color: var(--placeholder-color, #ddd);
font-size: var(--placeholder-font-size, 28rpx);
@@ -1239,6 +1381,16 @@ export default {
}
}
// 选项排列样式:vertical = 每个选项独立一行;wrap(默认)= 横向自动换行
.option-layout-vertical {
checkbox-group,
radio-group {
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
}
}
label {
display: inline-flex;
align-items: center;

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
}
// ====== AI Agent general copy (customer service is the first agent) ======
aiAgentDefaultTitle: 'AI Agent',
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
aiAgentNotConfigured: 'This AI Agent is not enabled'}

View File

@@ -2,10 +2,10 @@ export const lang = {
// 底部选项卡
tabBar: {
home: 'index',
category: 'category',
cart: 'cart',
member: 'member',
home: 'Home',
category: 'Category',
cart: 'Cart',
member: 'Me',
},
//公共
@@ -57,6 +57,7 @@ export const lang = {
// diy custom page (diy-specific copy, generic HTTP errors reuse http.*)
diy: {
networkErrorTitle: 'Notice',
networkError: 'Network error, please try again later',
retry: 'Retry',
noDiyData: 'Custom page data not configured',
locationNotAvailable: 'Location not available',
@@ -111,6 +112,7 @@ export const lang = {
notSupported: 'Current environment does not support WeChat Channel',
missingFields: 'Missing required field: {field}',
playFailed: 'WeChat video playback failed!',
wechatMpOnly: 'WeChat Channel only plays in WeChat Mini Program',
},
// uniapp utility functions
@@ -256,7 +258,14 @@ export const lang = {
addToCartFailMin: 'Minimum purchase quantity not met',
addToCartFailStock: 'Insufficient Stock',
// 咨询
// generic submit/version/loading text (key lives in root because common.js's lang object IS the common namespace)
submitSuccess: 'Submit Success',
submitFail: 'Submit Failed',
versionInfo: 'Version Info',
commitId: 'Commit ID',
buildTime: 'Build Time',
loading: 'Loading...',
consult: 'Consult Now',
consultSuccess: 'Consultation Successful',
consultFail: 'Consultation Failed',

View File

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

View File

@@ -1,6 +1,6 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能客服',
title: 'AI智能',
// ====== 通用 ======
cancel: '取消',
@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
}
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}

View File

@@ -64,6 +64,7 @@ export const lang = {
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
diy: {
networkErrorTitle: '提示',
networkError: '网络异常,请稍后重试',
retry: '重试',
noDiyData: '未配置自定义页面数据',
locationNotAvailable: '未获取到定位',
@@ -111,6 +112,7 @@ export const lang = {
notSupported: '当前环境不支持微信视频号',
missingFields: '缺少必要字段: {field}',
playFailed: '微信视频播放失败!',
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
},
// uniapp 工具函数
@@ -254,6 +256,14 @@ export const lang = {
addToCartFailMin: '已达到最小购买数量',
addToCartFailStock: '库存不足',
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
submitSuccess: '提交成功',
submitFail: '提交失败',
versionInfo: '版本信息',
commitId: '提交 ID',
buildTime: '构建时间',
loading: '加载中...',
consult: '立即咨询',
consultSuccess: '咨询成功',
consultFail: '咨询失败',

View File

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

View File

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

View File

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

View File

@@ -1,72 +1,92 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",
"terser-webpack-plugin": "^5.3.10",
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
},
"uni-app": {
"scripts": {
"weixin": {
"title": "微信小程序自定义发行",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP_WEIXIN": true,
"PRODUCTION": true
}
},
"h5-xcx20.5g-quickapp.com": {
"title": "H5-xcx20.5g-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-xcx.aigc-quickapp.com": {
"title": "H5-xcx.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-5g.aigc-quickapp.com": {
"title": "H5-5g.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-test.aigc-quickapp.com": {
"title": "H5-test.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
}
}
}
}
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",
"terser-webpack-plugin": "^5.3.10",
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0"
},
"uni-app": {
"scripts": {
"weixin": {
"title": "微信小程序自定义发行",
"env": {
"UNI_PLATFORM": "mp-weixin"
},
"define": {
"MP_WEIXIN": true,
"PRODUCTION": true
}
},
"h5-xcx20.5g-quickapp.com": {
"title": "H5-xcx20.5g-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx20.5g-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-xcx.aigc-quickapp.com": {
"title": "H5-xcx.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://xcx.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-5g.aigc-quickapp.com": {
"title": "H5-5g.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://5g.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"h5-test.aigc-quickapp.com": {
"title": "H5-test.aigc-quickapp.com",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "https://test.aigc-quickapp.com/"
},
"define": {
"PRODUCTION": true
}
},
"http://localhost:8050/": {
"title": "H5-localhost:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://localhost:8050/"
},
"define": {
"PRODUCTION": true
}
},
"http://192.168.8.118:8050/": {
"title": "H5-192.168.8.118:8050",
"env": {
"UNI_PLATFORM": "h5",
"UNI_APP_DOMAIN": "http://192.168.8.118:8050/"
},
"define": {
"PRODUCTION": true
}
}
}
}
}

View File

@@ -51,6 +51,7 @@
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
@@ -1085,7 +1086,64 @@
{
"path": "ai-chat/index",
"style": {
"navigationBarTitleText": "AI智能客服"
"navigationBarTitleText": "AI智能客服",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{
@@ -1097,7 +1155,10 @@
{
"path": "vr/index",
"style": {
"navigationBarTitleText": ""
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "工厂展示"
}
},
{
@@ -1291,6 +1352,7 @@
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
@@ -2147,7 +2209,9 @@
{
"path": "form/formdata",
"style": {
// #ifdef APP-PLUS
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
// #ifdef APP-PLUS || MP
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",

View File

@@ -55,7 +55,7 @@ export default {
onShow() {
// 获取关注公众号二维码
this.getFollowQrcode();
// 重新获取店铺信息
this.shopInfo = uni.getStorageSync('shopInfo');
},
@@ -160,10 +160,10 @@ export default {
} catch (e) {}
if (v && v.commitId) {
uni.showModal({
title: '版本信息',
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
title: this.$lang('versionInfo'),
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
} else {
console.error('未获取到构建版本信息');
}

View File

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

View File

@@ -2,7 +2,7 @@
<view :style="themeColor">
<!-- 使用 item.id / componentName 稳定 key 避免 v-for 在 diyData 整体重赋值时把组件整个销毁重建, 引发频闪 -->
<view v-if="diyData && diyData.value && diyData.value.length">
<block v-for="(item, index) in diyData.value" :key="item.id || item.componentName || index">
<block v-for="(item, index) in diyData.value" :key="index">
<view v-if="item.componentName == 'GoodsCategory'">
<diy-category @tologin="toLogin" ref="category" :value="item"></diy-category>
</view>

View File

@@ -8,7 +8,7 @@
</view> -->
<!-- #endif -->
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<!-- 价格区域 -->
<template v-slot:price>
<!-- 限时折扣 -->
@@ -74,7 +74,9 @@
</view>
<view class="follow-and-share">
<text class="follow iconfont icon-share" @click="openSharePopup()"></text>
<!-- #ifndef MP-HARMONY -->
<text class="share iconfont" @click="editCollection()" :class="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'"></text>
<!-- #endif -->
</view>
</block>
<block v-else>
@@ -457,7 +459,7 @@
/>
<block v-else>
<ns-goods-action-icon :text="$lang('goods.detail.home')" icon="icon-shouye1" @click="goHome" />
<!-- #ifdef MP-WEIXIN -->
<ns-goods-action-icon
:text="$lang('goods.detail.cart')"
@@ -465,9 +467,12 @@
icon="icon-gouwuche2"
:corner-mark="cartNumber > 0 ? cartNumber + '' : ''"
@click="goCart"
/>
/>
<ns-goods-action-icon :text="$lang('goods.detail.kefu')" icon="icox icox-kefu" :send-data="contactData" :chatParam="chatRoomParams" />
<!-- #endif -->
<!-- #ifdef MP-HARMONY -->
<!-- 鸿蒙平台: 隐藏购物车、客服、收藏按钮 -->
<!-- #endif -->
<!-- #ifdef H5 -->
<ns-goods-action-icon :text="$lang('goods.detail.follow')" :icon="whetherCollection == 1 ? 'icon-likefill color-base-text' : 'icon-guanzhu'" @click="editCollection" />
<!-- #endif -->
@@ -523,7 +528,9 @@
</goods-detail-view>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login>
<loading-cover ref="loadingCover"></loading-cover>
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
@@ -596,7 +603,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({
url: '/pages_tool/ai-chat/index'
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
});
},

View File

@@ -4,7 +4,10 @@ export default {
goodsRoute: '/pages_goods/detail',
couponList: [], //优惠券列表
couponBtnSwitch: false, //获取优惠券防止重复提交
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
loading: true,
posterApi: '/api/goods/poster',
//满减活动
@@ -103,13 +106,12 @@ export default {
await this.getGoodsSkuDetail();
} catch (e) {
console.error('getGoodsSkuDetail failed:', e);
// 即使失败也要保证 loading-cover 关闭, 避免空白屏
setTimeout(() => {
try {
const ref = this.$refs && this.$refs.loadingCover;
if (ref && typeof ref.hide === 'function') ref.hide();
} catch (e2) { /* swallow */ }
}, 0);
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
// 避免错误地把页面切到"空详情"状态
uni.showToast({
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
icon: 'none'
});
}
},
onHide() {
@@ -179,7 +181,10 @@ export default {
this.chatRoomParams = {
sku_id: this.goodsSkuDetail.sku_id
};
let typeId = this.goodsSkuDetail.goods_promotion[0];
// 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
if (typeId) {
// 限时折扣
if (typeId.discount_id) {
@@ -197,20 +202,21 @@ export default {
// 兼容鸿蒙元服务: $nextTick 在某些 JSVM 实现下不可靠, 用 setTimeout 兜底
// #ifdef MP-HARMONY
await new Promise(resolve => setTimeout(resolve, 16));
// 修复鸿蒙元服务: 不依赖 setTimeout 关闭骨架屏.
// 鸿蒙 JSVM 在 onShow 早期窗口的 setTimeout 回调有时被推迟/丢弃, 表现为"数据已回来但永远显示骨架屏".
// 数据已赋值 + 首屏让步, 同步直接关闭骨架屏, 由 hasRealData 兜底避免空详情.
this.loading = false;
// #endif
// #ifndef MP-HARMONY
await this.$nextTick();
// #endif
// 关闭 loading-cover 提前到 nextTick 后, 让用户看到首屏内容
// 用 setTimeout 0 推迟到下一个 task, 避开当前 sync 阶段 vendor 报错导致 hide 失败
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
// 用户会看到骨架屏 → 真实详情 的清晰过渡
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
setTimeout(() => {
try {
const ref = this.$refs && this.$refs.loadingCover;
if (ref && typeof ref.hide === 'function') ref.hide();
} catch (e) { /* swallow */ }
}, 0);
this.loading = false;
}, 300);
// #endif
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
await this.handleGoodsSkuData();
@@ -249,7 +255,7 @@ export default {
}
// 优惠券
if (this.goodsSkuDetail.coupon_list) {
if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
this.couponList = this.goodsSkuDetail.coupon_list;
this.couponList.forEach(v => {
if (v.count == v.lead_count) v.useState = 2;

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content">
<view class="chat-content" :style="{ bottom: tabBarHeight }">
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
@@ -68,6 +68,15 @@
</view>
</view>
</view>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
</view>
</template>
@@ -87,6 +96,9 @@ export default {
return {
initialMessages: [],
fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -105,7 +117,15 @@ export default {
keyboardHeight: 0,
// 事件处理器引用(用于清理)
safeEventHandlers: new Map()
safeEventHandlers: new Map(),
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
isFromFloatBtn: false,
}
},
@@ -127,6 +147,21 @@ export default {
async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
// 当前 onShow/initChat 不区分 agent 行为,后续按业务扩展按 agentId 路由即可。
try {
this.agentId = (options && options.agent_id) || 'agent_aikefu';
const rawTitle = (options && options.title) || this.$lang('ai-chat.index.title');
this.pageTitle = decodeURIComponent(rawTitle);
// 同步 agentId 给 ai-service,后续 sendMessage / WS auth 自动带新 agent_id
try { aiService.setAgentId(this.agentId); } catch (e) { /* 兼容旧 ai-service 版本 */ }
} catch (e) {
this.agentId = 'agent_aikefu';
this.pageTitle = this.$lang('ai-chat.index.title');
}
// ✅ 修改:取消权限校验,允许所有客服类型访问此页面
// const hasAccess = this.checkAccessPermission();
// if (!hasAccess) {
@@ -134,7 +169,27 @@ export default {
// return;
// }
this.$langConfig.title(this.$lang('ai-chat.index.title'));
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
this.initChat();
},
@@ -180,6 +235,15 @@ export default {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup();
aiService.disconnectWebSocket();
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
if (this.isFromFloatBtn) {
try {
this.$store.commit('setBottomNavHidden', false);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.isFromFloatBtn = false;
}
},
methods: {
@@ -647,7 +711,9 @@ export default {
/* #endif */
left: 0;
right: 0;
bottom: 0;
/* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
}
/* 底部tabBar占位样式 */

View File

@@ -29,7 +29,8 @@
detail: null,
isRepeat: false,
complete: false,
scroll:true
scroll: true,
urlParams: {}
};
},
onLoad(data) {
@@ -39,12 +40,27 @@
// #endif
this.id = data.id || 0;
// 通用 URL 参数收集:除 id/scene 外的所有参数都透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene'];
this.urlParams = {};
for (let key in data) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = data[key];
}
if (data.scene) {
var sceneParams = decodeURIComponent(data.scene);
sceneParams = sceneParams.split('&');
if (sceneParams.length) {
sceneParams.forEach(item => {
if (item.indexOf('id') != -1) this.id = item.split('-')[1];
// scene 中也可能携带其他参数
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
}
});
}
}
@@ -89,7 +105,9 @@
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData)
form_data: typeof this.$refs.form.formData === 'string' ? this.$refs.form.formData : JSON.stringify(this.$refs.form.formData),
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
},
success: res => {
if (res.code == 0) {
@@ -162,4 +180,4 @@
bottom: 180rpx !important;
/* #endif */
}
</style>
</style>

View File

@@ -1,21 +1,60 @@
<template>
<!-- #ifdef MP-HARMONY -->
<view :style="themeColor" class="harmony-root">
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view :style="themeColor">
<!-- #endif -->
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
- 左侧:hasBackStack 为 true 时显示返回箭头(点击 navigateBack),showHomeBtn=true 时再叠加 Home 胶囊
- 中央:表单标题(form_name)
- 整体高度 = 状态栏高度 + 44px 内容区,由 customNavbarStyle 动态算
H5 端保留系统原生 navbar,不渲染此块
-->
<!-- #ifndef H5 -->
<view class="form-home-navbar" :style="customNavbarStyle">
<view class="form-home-navbar__left">
<view v-if="hasBackStack" class="form-home-navbar__back" @tap="goBack" hover-class="form-home-navbar__btn--hover">
<text class="form-home-navbar__back-icon">‹</text>
</view>
<view v-if="showHomeBtn" class="form-home-capsule" @tap="goHome" hover-class="form-home-capsule--hover">
<text class="iconfont icon-shouye1 form-home-capsule__icon"></text>
</view>
</view>
<view class="form-home-navbar__title">{{ navbarTitle }}</view>
<view class="form-home-navbar__placeholder" />
</view>
<!-- #endif -->
<block v-if="detail">
<!-- 鸿蒙元服务:用原生 scroll-view 包裹表单,替代 CSS overflow 滚动(ArkUI 不识别 WebKit overflow 语义)。
根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余,scroll-view 在其内滚动。
提交按钮由 diy-form 内部渲染,处于 scroll-view 内,用户可滚到末尾点击。 -->
<!-- #ifdef MP-HARMONY -->
<view class="page harmony-page">
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
<view class="system-form-wrap">
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
</view>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view class="page">
<view class="system-form-wrap">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
@submit 接管提交逻辑(带 uniacid + showModal) -->
<diy-form
ref="form"
:value="diyValue"
dataMode="raw"
@submit="create"
/>
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
</view>
</view>
<!-- #endif -->
</block>
<block v-else>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')" :isIndex="false"></ns-empty>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
:isIndex="false"></ns-empty>
</block>
<loading-cover ref="loadingCover"></loading-cover>
<ns-login ref="login"></ns-login>
@@ -25,13 +64,9 @@
<script>
import diyForm from '@/components-diy/diy-form.vue';
import scroll from '@/common/js/scroll-view.js';
export default {
components: {
diyForm
},
mixins: [scroll],
data() {
return {
@@ -43,6 +78,16 @@ export default {
uniacid: 0,
isFromMpWebView: false, // 是否是从微信小程序的WebView跳转过来的
hideNavbar: false, // 查询参数中支持隐藏Navbar
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
urlParams: {}, // 通用 URL 透传参数收集
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
// #ifndef H5
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
statusBarHeight: 20, // 系统状态栏高度,自绘 navbar 时需要预留顶部空隙
navbarTitle: '', // 自绘 navbar 中央显示的标题
hasBackStack: false, // 自绘 navbar 左上角是否显示返回箭头:页面栈 > 1 时显示
// #endif
// #ifdef H5
formTitle: '', // 查询参数传入的Title
@@ -50,7 +95,30 @@ export default {
// #endif
}
},
created() {
// 预绑定 this:create 重度依赖本组件 this(id/uniacid/memberInfo/$api/$lang/$util 等)。
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
// bind 后无论子组件如何调用,this 始终是 formdata 实例。
this.handleSubmit = this.create.bind(this);
},
computed: {
// #ifndef H5
// 自绘 navbar 的高度样式
// - 内容区高度 = navbarHeight(兜底 44px)
// - 整体高度 = 状态栏高度 + 内容区高度
// - padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡
customNavbarStyle() {
try {
const sys = uni.getSystemInfoSync();
const sb = sys.statusBarHeight || 0;
const h = this.navbarHeight || 44;
return `height: ${sb + h}px; padding-top: ${sb}px;`;
} catch (e) {
return 'height: 64px; padding-top: 20px;';
}
},
// #endif
// 构造 diy-form raw 模式期望的 value 形态:
// - jsonData:透传给 ns-newform(已是 ns-newform 期望的 [{ controller, value, val }])
// - formConfig:后端返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
@@ -70,6 +138,7 @@ export default {
}
},
onLoad(options) {
console.log("=====表单页面====", options)
if (!options.uniacid) {
uni.showModal({
title: this.$lang('form.formdata.tip'),
@@ -90,7 +159,41 @@ export default {
this.uniacid = options.uniacid
this.id = options.id || 0
this.isFromMpWebView = options.from === 'mp-webview';
this.hideNavbar = Boolean(options?.hide_navbar);
this.hideNavbar = this.$util.coerceBool(options?.hide_navbar);
// show_home=1 时在系统标题栏左侧显示 Home 按钮,点击回到首页 INDEX_PAGE_URL
// 解析规则统一走 this.$util.coerceBool:支持 '1' / 'true' / 1 / true 四种写法
// 仅在 navigationStyle:custom 端生效(APP / 微信小程序 / 华为快应用 / 鸿蒙元服务);
// H5 保留系统原生 navbar,不支持 show_home,参数被忽略(这是预期行为)
// 注:H5 嵌入微信小程序 webview(from=mp-webview)的场景也属于 H5 范畴,
// 此场景下也没有 Home 入口——后续如需支持,需要单独处理(导航靠宿主小程序接管)
this.showHomeBtn = this.$util.coerceBool(options?.show_home);
// #ifndef H5
// 自绘 navbar 需要用到状态栏高度 / navbar 内容区高度(跨端兜底)
try {
const sys = uni.getSystemInfoSync();
if (sys.statusBarHeight) this.statusBarHeight = sys.statusBarHeight;
// uniapp 在不同端 menuButton / navigationBar 高度略有差异,给个安全兜底即可
if (typeof sys.navbarHeight === 'number' && sys.navbarHeight > 0) {
this.navbarHeight = sys.navbarHeight;
}
} catch (e) { /* keep defaults */ }
// 自绘 navbar 是否显示返回箭头:getCurrentPages().length > 1 即页面有栈
try {
const pages = getCurrentPages ? getCurrentPages() : [];
this.hasBackStack = Array.isArray(pages) && pages.length > 1;
} catch (e) { /* keep default */ }
// #endif
// 通用 URL 参数收集:除业务保留字段外,其余全部透传给后端
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
const reservedKeys = ['uniacid', 'id', 'scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
this.urlParams = {};
for (let key in options) {
if (reservedKeys.indexOf(key) !== -1) continue;
this.urlParams[key] = options[key];
}
if (options.scene) {
const scene = decodeURIComponent(options.scene)
@@ -99,6 +202,11 @@ export default {
if (item.indexOf('id') != -1) {
this.id = item.split('-')[1]
}
// scene 中也可能携带其他参数
let pair = item.split('=');
if (pair.length === 2 && pair[0] !== 'id') {
this.urlParams[pair[0]] = pair[1];
}
})
}
@@ -113,7 +221,7 @@ export default {
this.getData()
},
watch: {
storeToken(newVal, oldVal) {
newVal && this.getData()
@@ -123,6 +231,20 @@ export default {
this.updateNavbarTitle();
},
methods: {
// #ifndef H5
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
goBack() {
uni.navigateBack({ delta: 1 });
},
// #endif
// 返回首页;
// INDEX_PAGE_URL 是 TabBar 根页面,不能用裸 uni.redirectTo(会报"can not redirectTo a tabBar page")
// 必须用 this.$util.redirectTo,内部对 TabBar 页面自动走 uni.switchTab
// (鸿蒙元服务下还会用 reLaunch 兜底,见 common/js/util.js)
// H5 端保留 show_home / goHome(虽然模板里不渲染自绘 navbar,但保留方法调用不报错)
goHome() {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL, {}, 'redirectTo');
},
getData() {
this.$api.sendRequest({
url: '/form/api/form/info',
@@ -150,22 +272,37 @@ export default {
formName = this.formTitle;
}
// #endif
// H5 端:系统原生 navbar —— uni.setNavigationBarTitle 改原生标题
// #ifdef H5
uni.setNavigationBarTitle({ title: formName });
// #endif
// 非 H5 端:custom 模式下没有原生 navbar,直接写 data.navbarTitle,
// 由模板里的 .form-home-navbar__title 显示
// #ifndef H5
this.navbarTitle = formName;
// #endif
},
// 由 diy-form 内置提交按钮触发:diy-form 已先做 verify(),仅在通过时才 emit('submit', formData)
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
// 因此内部所有 this.xxx 都指向 formdata 实例,不会漂移到子组件。
create(formData) {
if (this.isRepeat) return
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
if (this.isRepeat) return;
this.isRepeat = true
this.isRepeat = true;
this.$api.sendRequest({
url: '/form/api/form/create',
data: {
form_id: this.id,
form_data: typeof formData === 'string' ? formData : JSON.stringify(formData),
uniacid: this.uniacid
uniacid: this.uniacid,
url_params: JSON.stringify(this.urlParams),
member_id: this.memberInfo ? this.memberInfo.id : 0
},
success: res => {
if (res.code == 0) {
this.complete = true
uni.showModal({
title: this.$lang('form.formdata.tip'),
content: this.$lang('form.formdata.submitSuccess'),
@@ -178,6 +315,10 @@ export default {
title: res.message
})
}
},
fail: () => {
// 网络失败时复位防重复标记,允许用户重试
this.isRepeat = false
}
})
}
@@ -185,15 +326,162 @@ export default {
}
</script>
<style lang="scss" scoped>
/*
* 自绘 navbar(非 H5 端:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
* - pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
* - 整体高度由 inline style 注入:statusBarHeight + 44px
* - 左侧 Home 胶囊 + 中央标题 + 右侧占位,标题过长自动省略
*/
/* #ifndef H5 */
/* --- 自绘 navbar 容器 --- */
.form-home-navbar {
position: relative;
width: 100%;
box-sizing: border-box;
background: #ffffff;
/* 高度由 inline style 通过 customNavbarStyle 注入:statusBarHeight + 44px */
/* padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡 */
/* 浮在表单内容之上,避免被 diy-form 等组件覆盖 */
z-index: 100;
/* 让内容靠 flex 自动落到 padding-top 之下的区域 */
display: flex;
flex-direction: row;
align-items: center;
}
.form-home-navbar__left {
/* 不再用 absolute,跟着 navbar 的 padding-top 下移 */
flex: none;
/* 宽度自适应:返回按钮 56rpx + Home 胶囊 56rpx + 间距 12rpx + 左 padding 16rpx = 最多 ~140rpx */
min-width: 88rpx;
max-width: 200rpx;
display: flex;
align-items: center;
justify-content: flex-start;
padding-left: 16rpx;
box-sizing: border-box;
gap: 12rpx;
}
.form-home-navbar__back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-navbar__back-icon {
font-size: 48rpx;
line-height: 1;
color: #000;
font-weight: 300;
}
.form-home-navbar__btn--hover {
background: rgba(0, 0, 0, 0.06);
}
.form-home-navbar__title {
flex: 1;
height: 44px;
line-height: 44px;
text-align: center;
font-size: 32rpx;
color: #000;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
box-sizing: border-box;
}
.form-home-navbar__placeholder {
/* 与 __left 对称,保持标题视觉居中 */
flex: none;
min-width: 88rpx;
max-width: 200rpx;
}
/* --- Home 胶囊主体 --- */
.form-home-capsule {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
}
.form-home-capsule--hover {
background: rgba(0, 0, 0, 0.06);
}
/*
* Home 图标 —— 使用项目统一的 iconfont icon-shouye1(unicode \e02d)
* 与 pages_goods/detail.vue 的首页按钮共用同一个图标,跨端视觉一致
*/
.form-home-capsule__icon {
font-size: 32rpx;
line-height: 1;
color: #000;
display: block;
}
/* #endif */
</style>
<style lang="scss" scoped>
page {
background: #fff;
}
.system-form-wrap {
overflow: hidden;
margin: 0 0 60rpx 0;
// ===== 非鸿蒙端(H5 / 微信小程序 / App):保留原有 WebKit 滚动 / 安全区处理 =====
// 鸿蒙走下方 MP-HARMONY 专属的 scroll-view 方案,不编译这些 WebKit 属性
/* #ifdef H5 || MP-WEIXIN || APP-PLUS */
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
}
/* #endif */
// ===== 鸿蒙元服务:用原生 scroll-view 做滚动容器 =====
// ArkUI 不识别 WebKit 的 overflow / -webkit-overflow-scrolling,必须用 scroll-view 组件(编译为 ArkUI Scroll)。
// 根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余空间,
// scroll-view 在 page 内 flex:1 获得确定高度后内部滚动。提交按钮由 diy-form 渲染在 scroll-view 内,可滚动到。
/* #ifdef MP-HARMONY */
.harmony-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.harmony-page {
flex: 1;
min-height: 0; // 允许内容收缩,内部 scroll-view 才能拿到确定高度并正确滚动
display: flex;
flex-direction: column;
}
.form-scroll {
flex: 1;
height: 100%;
}
.system-form-wrap {
// 不再用 overflow:hidden 裁剪;提交按钮在 scroll-view 内,滚动可见
overflow: visible;
margin: 0 0 60rpx 0;
padding-bottom: 40rpx; // 鸿蒙不识别 env(safe-area-inset-bottom),固定值兜底
}
/* #endif */
// 穿透 diy-form 包装层:消除其自带的 10rpx 圆角与白色背景,
// 让 form 内容区域保持与原始 ns-newform 接入时一致的视觉

View File

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

View File

@@ -7,7 +7,7 @@
</view>
</view>
<view class="body-wrap">
<view class="form-wrap">
<view class="form-wrap">
<view class="input-wrap" v-show="loginMode == 'mobile'">
<view class="content">
<!-- <view class="area-code">+86</view> -->
@@ -324,16 +324,16 @@ export default {
}
},
mobileAuthLogin(e) {
if (e.detail.errMsg == 'getPhoneNumber:ok') {
var data = {
iv: e.detail.iv,
encryptedData: e.detail.encryptedData
};
if (Object.keys(this.authInfo).length) {
Object.assign(data, this.authInfo);
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
}
if (e.detail.errMsg == 'getPhoneNumber:ok') {
var data = {
iv: e.detail.iv,
encryptedData: e.detail.encryptedData
};
if (Object.keys(this.authInfo).length) {
Object.assign(data, this.authInfo);
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
}
if (uni.getStorageSync('source_member')) data.source_member = uni.getStorageSync('source_member');
if (this.isSub) return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,6 +113,32 @@ export default {
},
onShow() {
this.$util.hideHomeButton();
},
// 覆写 mixin 的分享钩子:优先使用推荐码组件存储的分享数据
onShareAppMessage() {
try {
var shareDataStr = uni.getStorageSync('_recommendShareData');
if (shareDataStr) {
uni.removeStorageSync('_recommendShareData');
return JSON.parse(shareDataStr);
}
} catch(e) {}
return this.mpShareData?.appMessage;
},
onShareTimeline() {
try {
var shareDataStr = uni.getStorageSync('_recommendShareData');
if (shareDataStr) {
uni.removeStorageSync('_recommendShareData');
var d = JSON.parse(shareDataStr);
var query = '';
if (d.path && d.path.indexOf('?') >= 0) {
query = d.path.split('?')[1];
}
return { title: d.title, query: query, imageUrl: d.imageUrl || '' };
}
} catch(e) {}
return this.mpShareData?.timeLine;
},
methods: {

View File

@@ -1,6 +1,6 @@
export default {
methods: {
// 清除登录态(token / 用户信息 / 购物车 / 本地存储)
// 清除登录态(token / 会员信息 / 购物车 / 本地存储)
clearLoginState() {
this.$store.commit('setToken', '');
this.$store.commit('setMemberInfo', '');
@@ -9,6 +9,48 @@ export default {
uni.removeStorageSync('privacyAgreed');
},
// 跨平台登录入口:小程序下用 ns-login 原生授权弹窗 (走 wx.login / getPhoneNumber),
// H5 / 快应用 / APP 下用原生登录页 /pages_tool/login/login (手机号+动态码表单).
//
// 修复背景: 之前所有跳转统一用 LOGIN_PAGE_URL (/pages_tool/login/login),
// 这个原生登录页在微信小程序下是 form 提交 (像 H5), 没有走 wx.login 授权。
// 期望行为:
// - 微信小程序 / 鸿蒙元服务 / 其他小程序: 跳到首页 /pages/index/index,
// 首页挂载了 <ns-login ref="login">, 用户后续操作触发 _requireHomeAuth
// 时会自动弹 ns-login 弹窗, 走原生 wx.login / getPhoneNumber 授权.
// - H5 / 快应用 / APP: 保持跳 LOGIN_PAGE_URL (原生登录页), 表单流程正常.
getLoginEntryUrl() {
// #ifdef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
return this.$util.INDEX_PAGE_URL;
// #endif
// #ifndef MP-WEIXIN || MP-HARMONY || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU || MP-JD || MP-360 || MP-XHS
return this.$util.LOGIN_PAGE_URL;
// #endif
},
// 统一的"跳登录入口"封装, 包含组件级防抖锁 + 清登录态 + reLaunch
//
// 为什么需要防抖: 微信小程序下, 短时间内连发 reLaunch 会触发
// "appLaunch with non-empty page stack" native 错误 (页面销毁未完成, 新 launch 时栈非空).
// 注销/退出流程中常见并发场景:
// - cancelsuccess.vue onLoad 调 getStatus 异步回调 + 用户点 "完成" 按钮
// - 多个 submitCancelApply 同时被触发
// 防抖策略: 同一组件实例 800ms 内只能跳一次, 之后忽略
// (util.js reLaunch 分支还有 300ms 全局锁作为第二道防线)
redirectToLoginOnce() {
// 组件级防抖: 800ms 内重复调用直接忽略
const now = Date.now();
if (this._lastLoginRedirectAt && now - this._lastLoginRedirectAt < 800) {
console.warn('[member-public] redirectToLoginOnce 防抖: 800ms 内重复调用已忽略');
return;
}
this._lastLoginRedirectAt = now;
this.clearLoginState();
// 小程序下 getLoginEntryUrl() 返回首页 (走 ns-login 原生授权弹窗),
// H5 / APP 下返回原生登录页 (form 提交)
this.$util.redirectTo(this.getLoginEntryUrl(), {}, 'reLaunch');
},
// 退出登录
logout() {
uni.showModal({
@@ -16,8 +58,8 @@ export default {
content: this.$lang('logoutContent'),
success: (res) => {
if (res.confirm) {
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
}
}
});
@@ -50,8 +92,8 @@ export default {
this.$util.redirectTo('/pages_tool/member/cancellation');
}
} else if (res.code == -1) {
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
}
}
});
@@ -69,11 +111,15 @@ export default {
url: '/membercancel/api/membercancel/apply',
success: rres => {
if (rres.code >= 0) {
// 注销申请提交成功后立刻清空登录态,
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
if (options.clearStateAndRedirect) {
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
// 跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
} else {
this.$util.redirectTo('/pages_tool/member/cancelstatus');
// 跳 cancelstatus (审核中), 也走 reLaunch 清栈
this.clearLoginState();
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
}
} else {
this.$util.showToast({

View File

@@ -77,7 +77,7 @@ export default {
setPublicShare() {
let shareUrl = this.$config.h5Domain + '/pages_tool/notice/list';
this.$util.setPublicShare({
title: '公告',
title: this.$lang('notice.list.title'),
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
@@ -85,7 +85,7 @@ export default {
}
},
onShareAppMessage(res) {
var title = '公告';
var title = this.$lang('notice.list.title');
var path = '/pages_tool/notice/list';
return {
title: title,

View File

@@ -51,7 +51,7 @@ export default {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
} else {
this.$util.showToast({
title: '未获取到支付信息!'
title: this.$lang('pay.index.payInfoNotFound')
});
setTimeout(() => {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);

View File

@@ -1,9 +1,6 @@
<template>
<view :style="themeColor">
<web-view :src="src"></web-view>
<!-- 顶部导航覆盖层:使用 cover-view 兼容微信小程序的 web-view 原生组件 -->
<cover-view class="vr-nav-back icondiy icon-system-arrow-go-back-line" @click="navigateBack"></cover-view>
<cover-view class="vr-nav-home icondiy icon-system-home-2-line" @click="goHome"></cover-view>
<view :style="themeColor" class="vr-page">
<web-view :src="src" class="vr-webview" @titleUpdate="forceCustomTitle"></web-view>
</view>
</template>
@@ -11,72 +8,73 @@
export default {
data() {
return {
src: ''
src: '',
title: '',
customTitle: '',
titleTimer: null
};
},
onLoad(option) {
// this.src = decodeURIComponent(option.src);
this.customTitle = option?.title || '工厂展示';
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
this.$api.sendRequest({
url: '/api/config/defaultvr',
success: res => {
if (res.code == 0) {
uni.setNavigationBarTitle({
title:res.data.title
})
this.src = res.data.url
this.customTitle = res.data.title;
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
this.src = res.data.url;
this.startTitleGuard();
}
},
fail: res => {
}
});
},
onShow() {
if (this.customTitle) {
uni.setNavigationBarTitle({ title: this.customTitle });
}
},
onUnload() {
this.clearTitleGuard();
},
methods: {
navigateBack() {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack({
delta: 1
});
} else {
// 直接进入的场景(分享链接 / 外部链接 / 扫码等),页面栈只有当前页,
// navigateBack 会失败。重定向到首页,保证用户能离开 VR 页面。
uni.reLaunch({
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
});
}
forceCustomTitle() {
uni.setNavigationBarTitle({ title: this.customTitle });
},
goHome() {
uni.reLaunch({
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
});
startTitleGuard() {
this.clearTitleGuard();
let count = 0;
this.titleTimer = setInterval(() => {
uni.setNavigationBarTitle({ title: this.customTitle });
count++;
if (count >= 10) {
this.clearTitleGuard();
}
}, 100);
},
clearTitleGuard() {
if (this.titleTimer) {
clearInterval(this.titleTimer);
this.titleTimer = null;
}
}
}
};
</script>
<style lang="scss">
.vr-nav-back,
.vr-nav-home {
position: absolute;
top: 34rpx;
z-index: 5;
width: 72rpx;
height: 72rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
text-align: center;
box-sizing: border-box;
.vr-page {
height: 100vh;
width: 100%;
background: #fff;
}
.vr-nav-back {
left: 34rpx;
line-height: 60rpx;
}
.vr-nav-home {
right: 34rpx;
font-size: 40rpx;
line-height: 72rpx;
.vr-webview {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,62 @@
$ErrorActionPreference = 'Stop'
$target = 'C:\Program Files\Huawei QuickApp IDE\resources\app\extensions\deveco-debug\lib\toolkit\nodeTool'
$before = [Environment]::GetEnvironmentVariable('Path', 'Machine')
Write-Host '=== Before: target entries in Machine PATH ==='
$before -split ';' | ForEach-Object {
if ($_.Trim() -ieq $target) {
Write-Host (" [" + $_ + "]")
}
}
$entries = $before -split ';'
$kept = New-Object System.Collections.Generic.List[string]
$removed = New-Object System.Collections.Generic.List[string]
foreach ($e in $entries) {
$t = $e.Trim()
if ($t -ieq $target) {
$removed.Add($e)
} elseif ($t -ne '') {
$kept.Add($e)
}
}
$after = [string]::Join(';', $kept.ToArray())
if ($removed.Count -eq 0) {
Write-Host 'Target entry not found, nothing to do.'
exit 0
}
try {
[Environment]::SetEnvironmentVariable('Path', $after, 'Machine')
Write-Host ''
Write-Host '=== Result ==='
Write-Host (" Removed entries: " + $removed.Count)
foreach ($r in $removed) { Write-Host (" - " + $r) }
Write-Host (" Remaining entries: " + $kept.Count)
Write-Host ' Write OK.'
} catch {
Write-Host ''
Write-Host (' Write FAILED (admin required?): ' + $_.Exception.Message)
exit 1
}
Write-Host ''
Write-Host '=== Verify after write ==='
$verify = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$stillThere = 0
foreach ($e in ($verify -split ';')) {
if ($e.Trim() -ieq $target) { $stillThere++ }
}
if ($stillThere -gt 0) {
Write-Host ' FAIL: target still present.'
exit 1
} else {
Write-Host ' OK: target no longer in Machine PATH.'
}
Write-Host ''
Write-Host 'Note: env var change does NOT affect already-running processes.'
Write-Host 'Reopen cmd / Git Bash / HBuilderX to see updated where node.'

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* scripts/check-subpackage-sync.js
*
* 检查 mp-harmony 项目的子包 HAR 配置在 4 个地方是否同步:
* 1. mp-harmony/build-profile.json5 的 modules 数组
* 2. entry/oh-package.json5 的 dependencies
* 3. entry/har-deps-required.json5 的 requiredHarDeps (单一事实源)
* 4. ascf_build 实际目录
*
* 任何一处不一致 → 立即报错并指出哪一处需要修改。
* 防止新增/删除子包时漏改某处导致 build/runtime 故障。
*
* 使用:
* node scripts/check-subpackage-sync.js
* node scripts/check-subpackage-sync.js --strict # 不一致即 exit 1 (CI 用)
*
* 输出:
* ✓ 5 个子包配置完全一致
* 或
* ✘ 缺失 [pages_extra] 在以下位置:
* - build-profile.json5 (mp-harmony)
* - entry/oh-package.json5
* 建议操作: ...
*/
'use strict';
const fs = require('fs');
const path = require('path');
const JSON5 = require('json5');
function loadJson5(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON5.parse(raw);
}
function exists(p) {
try {
fs.accessSync(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function listDirs(p) {
try {
return fs.readdirSync(p, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [];
}
}
function diff(a, b) {
const aSet = new Set(a);
const bSet = new Set(b);
const onlyA = [...aSet].filter(x => !bSet.has(x));
const onlyB = [...bSet].filter(x => !aSet.has(x));
return { onlyA, onlyB };
}
function color(s, c) {
if (!process.stdout.isTTY) return s;
const codes = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', reset: '\x1b[0m' };
return codes[c] + s + codes.reset;
}
function main() {
const ROOT = path.resolve(__dirname, '..');
const strict = process.argv.includes('--strict');
// === 单一事实源: har-deps-required.json5 ===
const configPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'har-deps-required.json5');
if (!exists(configPath)) {
console.error(color(`✘ 缺少白名单配置: ${configPath}`, 'red'));
process.exit(strict ? 1 : 0);
}
const config = loadJson5(configPath);
const canonical = (config.requiredHarDeps || []).slice().sort();
console.log(color('=== mp-harmony 子包配置一致性检查 ===', 'cyan'));
console.log(`白名单 (har-deps-required.json5): ${canonical.join(', ')}\n`);
const errors = [];
const warnings = [];
// === 检查 1: mp-harmony/build-profile.json5 的 modules 数组 ===
const buildProfilePath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'build-profile.json5');
const buildProfileModules = [];
if (!exists(buildProfilePath)) {
errors.push(`build-profile.json5 不存在: ${buildProfilePath}`);
} else {
try {
const bp = loadJson5(buildProfilePath);
// modules 是顶层数组, 包含 entry + 5 个子包
// entry 也算一个 module, 但它不在我们的白名单里
const moduleNames = (bp.modules || []).map(m => m.name).filter(n => n && n !== 'entry');
buildProfileModules.push(...moduleNames);
const d = diff(canonical, buildProfileModules);
if (d.onlyA.length) {
errors.push(`build-profile.json5 缺失子包: [${d.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} build-profile.json5: 缺失 [${d.onlyA.join(', ')}]`);
} else if (d.onlyB.length) {
errors.push(`build-profile.json5 多了子包: [${d.onlyB.join(', ')}]`);
console.log(` ${color('✘', 'red')} build-profile.json5: 多了 [${d.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} build-profile.json5: ${buildProfileModules.length} 个子包模块与白名单一致`);
}
} catch (e) {
errors.push(`build-profile.json5 解析失败: ${e.message}`);
}
}
// === 检查 2: entry/oh-package.json5 的 dependencies ===
const ohPkgPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'oh-package.json5');
const ohPkgDeps = [];
if (!exists(ohPkgPath)) {
errors.push(`entry/oh-package.json5 不存在: ${ohPkgPath}`);
} else {
try {
const op = loadJson5(ohPkgPath);
const deps = op.dependencies || {};
// 提取 5 个 file: 依赖 (key 是包名)
ohPkgDeps.push(...Object.keys(deps).filter(k => deps[k] && deps[k].startsWith('file:')));
const d = diff(canonical, ohPkgDeps);
if (d.onlyA.length) {
errors.push(`entry/oh-package.json5 缺失 file: 依赖: [${d.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} entry/oh-package.json5: 缺失 [${d.onlyA.join(', ')}]`);
} else if (d.onlyB.length) {
warnings.push(`entry/oh-package.json5 多了 file: 依赖: [${d.onlyB.join(', ')}] (可能是新的子包还未更新白名单)`);
console.log(` ${color('⚠', 'yellow')} entry/oh-package.json5: 多了 [${d.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} entry/oh-package.json5: ${ohPkgDeps.length} 个 file: 依赖与白名单一致`);
}
} catch (e) {
errors.push(`entry/oh-package.json5 解析失败: ${e.message}`);
}
}
// === 检查 3: ascf_build 实际目录 ===
const ascfBuildRoot = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const ascfBuildDirs = listDirs(ascfBuildRoot);
const ascfBuildD = diff(canonical, ascfBuildDirs);
if (ascfBuildD.onlyA.length) {
errors.push(`ascf_build/ 目录缺失子包: [${ascfBuildD.onlyA.join(', ')}]`);
console.log(` ${color('✘', 'red')} ascf_build/: 缺失 [${ascfBuildD.onlyA.join(', ')}]`);
} else if (ascfBuildD.onlyB.length) {
warnings.push(`ascf_build/ 多了子包: [${ascfBuildD.onlyB.join(', ')}] (可能是历史遗留)`);
console.log(` ${color('⚠', 'yellow')} ascf_build/: 多了 [${ascfBuildD.onlyB.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} ascf_build/: ${ascfBuildDirs.length} 个子包目录与白名单一致`);
}
// === 检查 4: 每个子包的关键文件是否齐全 ===
console.log(color('--- 子包关键文件检查 ---', 'cyan'));
for (const pkg of canonical) {
const pkgRoot = path.join(ascfBuildRoot, pkg);
const requiredFiles = [
'oh-package.json5',
'build-profile.json5',
'hvigorfile.ts',
'obfuscation-rules.txt',
'src/main/module.json5',
];
const missing = requiredFiles.filter(f => !exists(path.join(pkgRoot, f)));
if (missing.length) {
errors.push(`${pkg} 缺失文件: [${missing.join(', ')}]`);
console.log(` ${color('✘', 'red')} ${pkg}: 缺失 [${missing.join(', ')}]`);
} else {
console.log(` ${color('✔', 'green')} ${pkg}: 关键文件齐备`);
}
}
// === 总结 ===
console.log();
if (warnings.length) {
console.log(color(`警告 (${warnings.length}):`, 'yellow'));
for (const w of warnings) console.log(' - ' + w);
}
if (errors.length) {
console.log();
console.log(color(`错误 (${errors.length}):`, 'red'));
for (const e of errors) console.log(' - ' + e);
console.log();
console.log(color('✘ 子包配置不一致, 鸿蒙构建可能失败。', 'red'));
console.log(color(' 修复步骤:', 'gray'));
console.log(color(' 1. 修改 entry/har-deps-required.json5 的 requiredHarDeps (单一事实源)', 'gray'));
console.log(color(' 2. 在 ascf_build/<pkg>/ 创建新子包模板 (含 oh-package.json5 / build-profile.json5 / ...) ', 'gray'));
console.log(color(' 3. 在 mp-harmony/build-profile.json5 的 modules 数组加新模块', 'gray'));
console.log(color(' 4. 在 entry/oh-package.json5 的 dependencies 加 "file:../ascf/ascf_build/<pkg>"', 'gray'));
console.log(color(' 5. 在 DevEco Sync 项目 (让 ohpm 处理 file: 依赖)', 'gray'));
console.log(color(' 6. 跑 node scripts/check-subpackage-sync.js --strict 验证', 'gray'));
process.exit(strict ? 1 : 0);
}
console.log(color(`✔ 5 个子包配置完全一致 (${canonical.length} 个包)`, 'green'));
process.exit(0);
}
try {
main();
} catch (e) {
console.error(color(`脚本异常: ${e.stack || e.message}`, 'red'));
process.exit(1);
}

View File

@@ -0,0 +1,58 @@
// 检查 20 个硬编码导航标题的页面,其语言包是否都有 title key
const paths = [
['pages_goods/category','商品分类'],
['pages_goods/cart','购物车'],
['pages_goods/detail','商品详情'],
['pages_goods/list','商品列表'],
['pages_promotion/merch/merchcategory','入驻商户'],
['pages_promotion/merch/detail','商户首页'],
['pages_promotion/fenxiao/ranking_list','排行榜'],
['pages_promotion/fenxiao/child_fenxiao','分销商'],
['pages_tool/ai-chat/index','AI智能客服'],
['pages_tool/contact/contact','电子名片'],
['pages_tool/member/info','个人资料'],
['pages_tool/order/activist','退款'],
['pages_tool/login/login','登录'],
['pages_tool/weapp/order_shipping','小程序发货'],
['pages_tool/seal/structure','结构选型'],
['pages_tool/seal/medium/search','介质材料搜索'],
['pages_tool/seal/medium/detail','搜索结果'],
['pages_tool/cases/index','案例展示'],
['pages_tool/files/list','查看文件'],
['pages_tool/file-preview/file-preview','文件预览'],
];
function toLang(p) {
const parts = p.split('/');
if (parts[0] === 'pages_tool') return parts.slice(1).join('/');
if (parts[0] === 'pages_goods') return 'goods/' + parts.slice(1).join('/');
if (parts[0] === 'pages_member') return 'member/' + parts.slice(1).join('/');
if (parts[0] === 'pages_order') return 'order/' + parts.slice(1).join('/');
if (parts[0] === 'pages_promotion') {
if (['point','fenxiao','merch'].includes(parts[1])) return parts.slice(1).join('/');
return parts.slice(1).join('/');
}
return p;
}
const fs = require('fs');
const path = require('path');
for (const [p, hardcoded] of paths) {
const lp = toLang(p);
for (const loc of ['zh-cn','en-us']) {
const fp = path.join('lang', loc, lp + '.js');
if (!fs.existsSync(fp)) {
console.log(`MISSING ${loc}/${lp}`);
continue;
}
try {
const m = require(path.resolve(fp)).lang;
const title = m && m.title;
const status = title === undefined ? 'NO title' : (title === hardcoded ? 'ZH-only?' : 'OK');
console.log(`${loc}/${lp}.title = ${JSON.stringify(title)} [${status}]`);
} catch (e) {
console.log(`ERR ${loc}/${lp}: ${e.message}`);
}
}
}

View File

@@ -0,0 +1,429 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* scripts/fix-subpackage-module-names.js
*
* 修正 ascf_build/<pkg>/ 子包 HAR 元数据中的 name 字段,确保鸿蒙工具链
* (ohpm install / hvigor schema / 运行时依赖解析) 一致通过。
*
* 涉及两个文件:
* 1. ascf_build/<pkg>/oh-package.json5 — top-level `name`
* ohpm install 会读取这个字段,空字符串会报:
* "00604007 Field IS Empty Error: The name of package .../pages_tool cannot be empty"
*
* 2. ascf_build/<pkg>/src/main/module.json5 — `module.name`
* hvigor build 时 schema 校验要求匹配 ^[a-zA-Z][0-9a-zA-Z_.]*$,
* 空字符串会报:
* "Schema validate failed: must match pattern ^[a-zA-Z][0-9a-zA-Z_.]*$"
*
* 修复策略:
* 遍历 SUBPACKAGE_NAMES 数组里的包, 用包目录名覆盖两处的 name,
* 保留其他字段 (version / description / dependencies / deviceTypes 等)。
* 如果文件不存在, 按合理默认值新建一份。
*
* 使用方式:
* # 命令 1: 直接跑这个脚本
* node scripts/fix-subpackage-module-names.js
*
* # 命令 2: 作为模块 require
* node -e "require('./scripts/fix-subpackage-module-names').fixSubpackageModuleNames()"
*
* # 命令 3: 由 vue.config.js 顶层 IIFE 自动调用
* # (HBuilderX / vue-cli-service / node -e 触发时, 自动执行)
*
* # CI 兜底校验:
* node stub-app-projects/mp-harmony/entry/scripts/check-har-deps.js --strict
*/
'use strict';
const fs = require('fs');
const path = require('path');
const SUBPACKAGE_NAMES = [
'components_diy',
'pages_goods',
'pages_order',
'pages_promotion',
'pages_tool',
];
/**
* 修正 ascf_build/<pkg>/ 子包的 name 字段 (oh-package.json5 + module.json5)
*
* @param {object} [opts]
* @param {string} [opts.ascfBuildRoot] - ascf_build 根目录, 默认基于本脚本位置推算
* @param {boolean} [opts.silent=false] - true 时不打日志
* @returns {{fixedCount: number, results: Array<{pkg: string, file: string, status: 'created'|'fixed'|'unchanged'|'error', message?: string}>}}
*/
function fixSubpackageModuleNames({ ascfBuildRoot, silent = false } = {}) {
const root = ascfBuildRoot
|| path.resolve(__dirname, '..', 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const log = silent ? () => {} : console.log;
log(`\n🔧 [fix-subpackage-module-names] 修正 ascf_build 子包 name (oh-package.json5 + module.json5)...`);
const results = [];
for (const pkg of SUBPACKAGE_NAMES) {
results.push(...fixOhPackageJson5({ pkg, root, silent: true, log }));
results.push(...fixModuleJson5({ pkg, root, silent: true, log }));
}
const fixedCount = results.filter(r => r.status === 'fixed' || r.status === 'created').length;
log(` 📊 修正完成: ${fixedCount} 个文件被更新\n`);
return { fixedCount, results };
}
/**
* 修正 ascf_build/<pkg>/oh-package.json5 的 top-level `name` 字段
*
* 默认内容参考项目内已有 HAR (components_diy / pages_goods 等):
* {
* "name": "<pkg>",
* "version": "1.0.0",
* "description": "Please describe the basic information.",
* "main": "Index.ets",
* "author": "",
* "license": "Apache-2.0",
* "packageType": "InterfaceHar",
* "dependencies": {}
* }
*/
function fixOhPackageJson5({ pkg, root, silent, log }) {
const ohPkgJson5Path = path.join(root, pkg, 'oh-package.json5');
const defaultDoc = {
name: pkg,
version: '1.0.0',
description: 'Please describe the basic information.',
main: 'Index.ets',
author: '',
license: 'Apache-2.0',
packageType: 'InterfaceHar',
dependencies: {},
};
let current = null;
if (fs.existsSync(ohPkgJson5Path)) {
try {
// oh-package.json5 是 JSON5, 可能含 // 注释。Node 内置 JSON.parse 不能处理,
// 用 stripJson5Comments + JSON.parse 处理最常见情况。
const raw = fs.readFileSync(ohPkgJson5Path, 'utf8');
const cleaned = stripJson5Comments(raw);
current = JSON.parse(cleaned);
} catch (e) {
if (!silent) log(` ⚠️ ${pkg}/oh-package.json5 解析失败, 使用默认: ${e.message}`);
current = null;
}
}
const relPath = `${pkg}/oh-package.json5`;
if (!current || typeof current !== 'object') {
try {
fs.mkdirSync(path.dirname(ohPkgJson5Path), { recursive: true });
fs.writeFileSync(ohPkgJson5Path, JSON.stringify(defaultDoc, null, 2) + '\n', 'utf8');
if (!silent) log(` 🆕 ${relPath}: 已创建 (name="${pkg}")`);
return [{ pkg, file: 'oh-package.json5', status: 'created' }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 创建失败: ${e.message}`);
return [{ pkg, file: 'oh-package.json5', status: 'error', message: e.message }];
}
}
const oldName = current.name;
if (oldName !== pkg) {
current.name = pkg;
try {
fs.writeFileSync(ohPkgJson5Path, JSON.stringify(current, null, 2) + '\n', 'utf8');
if (!silent) log(` ✅ ${relPath}: name "${oldName || '(空)'}" → "${pkg}"`);
return [{ pkg, file: 'oh-package.json5', status: 'fixed', oldName }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 写入失败: ${e.message}`);
return [{ pkg, file: 'oh-package.json5', status: 'error', message: e.message }];
}
}
if (!silent) log(` ✔️ ${relPath}: name = "${pkg}" (无须修正)`);
return [{ pkg, file: 'oh-package.json5', status: 'unchanged' }];
}
/**
* 修正 ascf_build/<pkg>/src/main/module.json5 的 `module.name` 字段
*/
function fixModuleJson5({ pkg, root, silent, log }) {
const moduleJson5Path = path.join(root, pkg, 'src', 'main', 'module.json5');
const defaultDoc = {
module: {
name: pkg,
type: 'shared',
description: 'subpackage',
deviceTypes: ['phone', 'tablet'],
deliveryWithInstall: true,
installationFree: true,
},
};
let current = null;
if (fs.existsSync(moduleJson5Path)) {
try {
const raw = fs.readFileSync(moduleJson5Path, 'utf8');
const cleaned = stripJson5Comments(raw);
current = JSON.parse(cleaned);
} catch (e) {
if (!silent) log(` ⚠️ ${pkg}/src/main/module.json5 解析失败, 使用默认: ${e.message}`);
current = null;
}
}
const relPath = `${pkg}/src/main/module.json5`;
if (!current || !current.module || typeof current.module !== 'object') {
try {
fs.mkdirSync(path.dirname(moduleJson5Path), { recursive: true });
fs.writeFileSync(moduleJson5Path, JSON.stringify(defaultDoc, null, 2) + '\n', 'utf8');
if (!silent) log(` 🆕 ${relPath}: 已创建 (name="${pkg}")`);
return [{ pkg, file: 'module.json5', status: 'created' }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 创建失败: ${e.message}`);
return [{ pkg, file: 'module.json5', status: 'error', message: e.message }];
}
}
const oldName = current.module.name;
if (oldName !== pkg) {
current.module.name = pkg;
try {
fs.writeFileSync(moduleJson5Path, JSON.stringify(current, null, 2) + '\n', 'utf8');
if (!silent) log(` ✅ ${relPath}: module.name "${oldName || '(空)'}" → "${pkg}"`);
return [{ pkg, file: 'module.json5', status: 'fixed', oldName }];
} catch (e) {
if (!silent) log(` ❌ ${relPath}: 写入失败: ${e.message}`);
return [{ pkg, file: 'module.json5', status: 'error', message: e.message }];
}
}
if (!silent) log(` ✔️ ${relPath}: module.name = "${pkg}" (无须修正)`);
return [{ pkg, file: 'module.json5', status: 'unchanged' }];
}
/**
* 简单 JSON5 注释剥离
* 处理 // 单行注释 和 /* 块注释 *\/, 跳过字符串字面量内的 //
* 注意: 不处理裸 key、单引号字符串、Unicode 转义等 JSON5 扩展
* (本项目实际遇到的文件都是标准 JSON + 注释, 足够覆盖)
*/
function stripJson5Comments(input) {
let out = '';
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i];
const next = i + 1 < n ? input[i + 1] : '';
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
i += 2;
continue;
}
if (c === '/' && next === '/') {
i += 2;
while (i < n && input[i] !== '\n') i++;
continue;
}
if (c === '"' || c === "'") {
const quote = c;
out += c;
i++;
while (i < n) {
const sc = input[i];
if (sc === '\\') {
out += sc + (input[i + 1] || '');
i += 2;
continue;
}
out += sc;
i++;
if (sc === quote) break;
}
continue;
}
out += c;
i++;
}
return out;
}
// 顶层 IIFE: 直接执行脚本时自动跑 (CLI 行为)
function main() {
try {
fixSubpackageModuleNames();
} catch (e) {
console.error('[fix-subpackage-module-names] 执行失败:', e.message);
process.exit(1);
}
}
/**
* 同步子包配置一致性检查 (被 vue.config.js 顶层 IIFE 调用)
*
* 用途: HBuilderX 触发 require('./vue.config.js') 时, 在修完 name 之后
* 跑一次 4 处一致性检查, 立即告诉用户哪些地方没改。
*
* 检查项 (复用 SUBPACKAGE_NAMES 数组):
* 1. mp-harmony/build-profile.json5 的 modules 数组 (排除 entry)
* 2. entry/oh-package.json5 的 file: 依赖
* 3. ascf_build 实际目录
*
* 与 scripts/check-subpackage-sync.js 的区别:
* - check-subpackage-sync.js: CLI/CI 用, --strict 可 exit 1
* - runSyncCheck: vue.config.js IIFE 用, 默认不抛错, 仅打印
*
* @param {object} [opts]
* @param {boolean} [opts.silent=false] - true 时不打日志
* @param {boolean} [opts.exitOnError=false] - true 时不一致即抛错 (CLI 模式)
* @returns {{ok: boolean, errors: string[], warnings: string[]}}
*/
function runSyncCheck({ silent = false, exitOnError = false } = {}) {
const log = silent ? () => {} : console.log;
const ROOT = path.resolve(__dirname, '..');
// 读取白名单 (从 har-deps-required.json5 拿, 与 check-subpackage-sync.js 保持一致)
// 注: 优先用 json5 npm 包 (项目已装), 失败则回退到内置 SUBPACKAGE_NAMES
const configPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'har-deps-required.json5');
let canonical = SUBPACKAGE_NAMES.slice().sort();
try {
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf8');
let cfg = null;
try {
// 优先: json5 包 (支持裸 key, 注释, 单引号字符串)
cfg = require('json5').parse(raw);
} catch (e1) {
// 回退: 手写 JSON5 解析 (剥注释后用 JSON.parse)
try {
const cleaned = stripJson5Comments(raw);
cfg = JSON.parse(cleaned);
} catch (e2) {
if (!silent) console.warn(` ⚠️ har-deps-required.json5 解析失败 (json5: ${e1.message}; json: ${e2.message}), 用内置 SUBPACKAGE_NAMES`);
}
}
if (cfg && Array.isArray(cfg.requiredHarDeps) && cfg.requiredHarDeps.length) {
canonical = cfg.requiredHarDeps.slice().sort();
}
}
} catch (e) {
if (!silent) console.warn(' ⚠️ 读取 har-deps-required.json5 失败, 用内置 SUBPACKAGE_NAMES:', e.message);
}
const canonicalSet = new Set(canonical);
const errors = [];
const warnings = [];
// 工具: 列出某目录下的子目录
function listSubdirs(p) {
try {
return fs.readdirSync(p, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [];
}
}
function diff(b) {
const bSet = new Set(b);
return {
onlyA: [...canonicalSet].filter(x => !bSet.has(x)),
onlyB: [...bSet].filter(x => !canonicalSet.has(x)),
};
}
// === 检查 1: build-profile.json5 ===
const buildProfilePath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'build-profile.json5');
let buildProfileModules = [];
if (!fs.existsSync(buildProfilePath)) {
warnings.push(`build-profile.json5 不存在: ${buildProfilePath}`);
} else {
try {
const raw = fs.readFileSync(buildProfilePath, 'utf8').replace(/\/\/.*$/gm, '');
const bp = JSON.parse(raw);
buildProfileModules = (bp.modules || []).map(m => m.name).filter(n => n && n !== 'entry');
} catch (e) {
errors.push(`build-profile.json5 解析失败: ${e.message}`);
}
}
const d1 = diff(buildProfileModules);
if (d1.onlyA.length) errors.push(`build-profile.json5 缺失子包: [${d1.onlyA.join(', ')}]`);
if (d1.onlyB.length) errors.push(`build-profile.json5 多了子包: [${d1.onlyB.join(', ')}]`);
// === 检查 2: entry/oh-package.json5 ===
const ohPkgPath = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'entry', 'oh-package.json5');
let ohPkgDeps = [];
if (!fs.existsSync(ohPkgPath)) {
warnings.push(`entry/oh-package.json5 不存在: ${ohPkgPath}`);
} else {
try {
const raw = fs.readFileSync(ohPkgPath, 'utf8').replace(/\/\/.*$/gm, '');
const op = JSON.parse(raw);
const deps = op.dependencies || {};
ohPkgDeps = Object.keys(deps).filter(k => deps[k] && deps[k].startsWith('file:'));
} catch (e) {
errors.push(`entry/oh-package.json5 解析失败: ${e.message}`);
}
}
const d2 = diff(ohPkgDeps);
if (d2.onlyA.length) errors.push(`entry/oh-package.json5 缺失 file: 依赖: [${d2.onlyA.join(', ')}]`);
if (d2.onlyB.length) warnings.push(`entry/oh-package.json5 多了 file: 依赖: [${d2.onlyB.join(', ')}] (可能是新子包未更新白名单)`);
// === 检查 3: ascf_build 实际目录 ===
const ascfBuildRoot = path.join(ROOT, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_build');
const ascfBuildDirs = listSubdirs(ascfBuildRoot);
const d3 = diff(ascfBuildDirs);
if (d3.onlyA.length) errors.push(`ascf_build/ 目录缺失子包: [${d3.onlyA.join(', ')}]`);
if (d3.onlyB.length) warnings.push(`ascf_build/ 多了子包: [${d3.onlyB.join(', ')}] (可能是历史遗留)`);
// === 输出 ===
if (silent) {
// 静默模式仅返回结果
return { ok: errors.length === 0, errors, warnings };
}
const c = (s, color) => {
if (!process.stdout.isTTY) return s;
const codes = { red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', reset: '\x1b[0m' };
return codes[color] + s + codes.reset;
};
if (errors.length === 0 && warnings.length === 0) {
log(c(`\n🔍 [sync check] ✓ ${canonical.length} 个子包在 3 处配置完全一致 (build-profile / oh-package / ascf_build)`, 'green'));
} else {
log(c(`\n🔍 [sync check] ${errors.length === 0 ? '⚠️' : '✘'} 4 处子包配置不一致:`, errors.length === 0 ? 'yellow' : 'red'));
for (const w of warnings) log(c(` ⚠️ ${w}`, 'yellow'));
for (const e of errors) log(c(` ✘ ${e}`, 'red'));
if (errors.length > 0) {
log(c(`\n 修复: 同步更新以下 4 个文件 (whitelist 是 har-deps-required.json5):`, 'gray'));
log(c(` 1. entry/har-deps-required.json5 (whitelist, 单一事实源)`, 'gray'));
log(c(` 2. mp-harmony/build-profile.json5 modules 数组`, 'gray'));
log(c(` 3. entry/oh-package.json5 dependencies 加 "file:../ascf/ascf_build/<pkg>"`, 'gray'));
log(c(` 4. ascf_build/<pkg>/ 实际目录`, 'gray'));
log(c(` DevEco Build 仍会因 ohpm 校验而失败, 修完再发`, 'gray'));
}
}
log('');
if (exitOnError && errors.length > 0) {
throw new Error(`[sync check] ${errors.length} 个不一致`);
}
return { ok: errors.length === 0, errors, warnings };
}
// 判断是否作为 CLI 直接执行 (vs 作为模块被 require)
if (require.main === module) {
main();
}
module.exports = {
fixSubpackageModuleNames,
runSyncCheck,
SUBPACKAGE_NAMES,
};

View File

@@ -0,0 +1,264 @@
{
"uni_modules/uni-calendar/components/uni-calendar/uni-calendar.vue": [
{
"kind": "template",
"tag": "text",
"text": "取消",
"line": 6
},
{
"kind": "template",
"tag": "text",
"text": "确定",
"line": 7
},
{
"kind": "template",
"tag": "text",
"text": "{{ (nowDate.year || '') + '年' + (nowDate.month || '') + '月' }}",
"line": 15
},
{
"kind": "template",
"tag": "text",
"text": "日",
"line": 27
},
{
"kind": "template",
"tag": "text",
"text": "一",
"line": 28
},
{
"kind": "template",
"tag": "text",
"text": "二",
"line": 29
},
{
"kind": "template",
"tag": "text",
"text": "三",
"line": 30
},
{
"kind": "template",
"tag": "text",
"text": "四",
"line": 31
},
{
"kind": "template",
"tag": "text",
"text": "五",
"line": 32
},
{
"kind": "template",
"tag": "text",
"text": "六",
"line": 33
}
],
"pages_promotion/fenxiao/withdraw_list.vue": [
{
"kind": "js-text",
"match": "已转账",
"line": 45
},
{
"kind": "js-text",
"match": "待审核",
"line": 49
},
{
"kind": "js-text",
"match": "已审核",
"line": 53
},
{
"kind": "js-text",
"match": "已拒绝",
"line": 57
}
],
"uni_modules/mp-html/components/mp-html/node/node.vue": [
{
"kind": "js-text",
"match": "uni.showActionSheet({\n itemList: ['保存图片'],\n success: () => {\n const save = path => {\n uni.saveImageToPhotosAlbum({\n ",
"line": 289
},
{
"kind": "js-text",
"match": "uni.showToast({\n title: '链接已复制'\n })",
"line": 368
},
{
"kind": "js-text",
"match": "保存成功",
"line": 297
},
{
"kind": "js-text",
"match": "链接已复制",
"line": 369
}
],
"components-diy/diy-form.vue": [
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: '提交成功',\n\t\t\t\t\t\t\ticon: 'success'\n\t\t\t\t\t\t})",
"line": 305
},
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: res.message || '提交失败',\n\t\t\t\t\t\t\ticon: 'none'\n\t\t\t\t\t\t})",
"line": 314
},
{
"kind": "js-text",
"match": "提交成功",
"line": 306
}
],
"uni_modules/uni-count-down/components/uni-count-down/uni-count-down.vue": [
{
"kind": "template",
"tag": "view",
"text": "{{ showColon ? ':' : '时' }}",
"line": 19
},
{
"kind": "template",
"tag": "view",
"text": "{{ showColon ? ':' : '分' }}",
"line": 27
},
{
"kind": "template",
"tag": "view",
"text": "秒",
"line": 35
}
],
"components/ns-goods-recommend/ns-goods-recommend.vue": [
{
"kind": "js-text",
"match": "猜你喜欢",
"line": 13
},
{
"kind": "js-text",
"match": "购买",
"line": 20
}
],
"components-diy/diy-channel-list.vue": [
{
"kind": "js-text",
"match": "uni.showToast({\n\t\t\t\t\t\t\t\ttitle: '视频号仅支持在微信小程序环境中播放',\n\t\t\t\t\t\t\t\ticon: 'none',\n\t\t\t\t\t\t\t\tduration: 2000\n\t\t\t\t\t\t\t})",
"line": 207
},
{
"kind": "js-text",
"match": "视频号仅支持在微信小程序环境中播放",
"line": 208
}
],
"pages/index/public/js/index.js": [
{
"kind": "js-text",
"match": "uni.showModal({\n\t\t\t\t\ttitle: '版本信息',\n\t\t\t\t\tcontent: `提交 ID: ${v.commitId}\\n构建时间: ${v.buildTime}\\n${Config.baseUrl}`,\n\t\t\t\t\tshowCancel: false\n\t\t\t\t})",
"line": 160
},
{
"kind": "js-text",
"match": "版本信息",
"line": 161
}
],
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/calendar.vue": [
{
"kind": "template",
"tag": "text",
"text": "{{ (nowDate.year||'') + ' 年 ' + ( nowDate.month||'') +' 月'}}",
"line": 13
},
{
"kind": "template",
"tag": "view",
"text": "确认",
"line": 87
}
],
"uni_modules/uni-number-box/components/uni-number-box/uni-number-box.vue": [
{
"kind": "js-text",
"match": "商品库存不足",
"line": 131
},
{
"kind": "js-text",
"match": "商品最少购买",
"line": 135
}
],
"common/js/mp-share-default.js": [
{
"kind": "js-text",
"match": "小程序分享",
"line": 85
}
],
"common/js/splash-state.js": [
{
"kind": "js-text",
"match": "uni.showLoading({ title: opts.title || '加载中...', mask: false })",
"line": 111
}
],
"pages/index/index.vue": [
{
"kind": "template",
"tag": "text",
"text": "{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}",
"line": 8
}
],
"pages_goods/public/js/list.js": [
{
"kind": "js-text",
"match": "购买",
"line": 40
}
],
"pages_tool/help/list.vue": [
{
"kind": "js-text",
"match": "帮助中心",
"line": 82
}
],
"pages_tool/notice/list.vue": [
{
"kind": "js-text",
"match": "公告",
"line": 80
}
],
"pages_tool/pay/index.vue": [
{
"kind": "js-text",
"match": "未获取到支付信息!",
"line": 54
}
],
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/time-picker.vue": [
{
"kind": "js-text",
"match": "日期和时间",
"line": 112
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,347 @@ async function syncSiteToStub(mode = 'production') {
}
}
/**
* 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json
*
* 背景:
* 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段,
* 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。
* 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段,
* 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。
*
* 同步策略:
* 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID
* 2. 从 site.js 解析 baseUrl (域名) + uniacid
* 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json
* 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容)
* 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户
*
* 设计取舍:
* - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest)
* - site.js 永远是真实值来源 (这是项目里已经存在的部署配置)
* - 同步在构建时执行, 一份配置适配所有 uniacid
*
* 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证)
*/
/**
* 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释)
* 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时,
* 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面,
* 引入换行符导致 JSON.parse 失败 (Bad control character)。
* 必须逐字符扫描, 跳过字符串内的 /*、//。
*/
function stripJsonComments(input) {
let out = '';
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i];
const next = i + 1 < n ? input[i + 1] : '';
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
i += 2;
continue;
}
if (c === '/' && next === '/') {
i += 2;
while (i < n && input[i] !== '\n') i++;
continue;
}
if (c === '"') {
out += c;
i++;
while (i < n) {
const sc = input[i];
if (sc === '\\') {
out += sc + (input[i + 1] || '');
i += 2;
continue;
}
out += sc;
i++;
if (sc === '"') break;
}
continue;
}
out += c;
i++;
}
return out;
}
/**
* 从 site.js 提取 baseUrl + uniacid
*
* site.js 形如:
* module.exports = {
* baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名
* uniacid: 2482,//后台对应uniacid
* };
*
* 不直接 require, 因为:
* 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...)
* 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖
* 3. regex 提取足够安全, 避免执行任意 JS
*
* @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null
*/
function parseSiteConfig(siteJsSource) {
// baseUrl: 单/双引号字符串, 可带尾斜杠
const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/)
// uniacid: 数字或字符串
const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/)
if (!baseUrlMatch || !uniacidMatch) {
return null
}
return {
baseUrl: baseUrlMatch[1],
uniacid: uniacidMatch[1],
}
}
/**
* 把 baseUrl 拆成 protocol + host (无尾斜杠)
* 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN
* 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接
* 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)".
*
* @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/"
* @returns {string} 例: "https://xcx.aigc-quickapp.com"
*/
function normalizeBaseUrlForPlaceholder(baseUrl) {
// 去掉尾斜杠
let url = baseUrl.replace(/\/+$/, '')
// 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议)
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url
}
return url
}
/**
* 递归替换隐私配置对象中的占位符
* @param {*} value 任意 JSON 值
* @param {object} siteConfig {baseUrl, uniacid}
* @returns {*} 替换后的新值 (不修改原对象)
*/
function replacePlaceholders(value, siteConfig) {
if (typeof value === 'string') {
return value
.replace(/YOUR_DOMAIN/g, siteConfig.baseUrl)
.replace(/YOUR_UNIACID/g, siteConfig.uniacid)
}
if (Array.isArray(value)) {
return value.map(v => replacePlaceholders(v, siteConfig))
}
if (value !== null && typeof value === 'object') {
const out = {}
for (const k of Object.keys(value)) {
out[k] = replacePlaceholders(value[k], siteConfig)
}
return out
}
return value
}
/**
* 自定义错误: 验证 app.json 写后内容失败时抛出
* 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败
*/
class AppJsonVerificationError extends Error {
constructor(messages) {
super('app.json 写后验证失败:\n - ' + messages.join('\n - '))
this.name = 'AppJsonVerificationError'
this.messages = messages
}
}
/**
* 验证写完的 app.json 内容正确
*
* 关键字段:
* - 顶级 privacy 块存在
* - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头
* - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID
* - URL 不含双协议头 (https://https://)
* - URL 不含双斜杠 (https://x.com//api)
* - URL 的 uniacid= 参数值 == siteConfig.uniacid
* - JSON 仍能正确 parse (没有序列化失败)
*
* 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项
*
* @param {string} appJsonPath
* @param {{baseUrl: string, uniacid: string}} siteConfig
*/
async function verifyWrittenAppJson(appJsonPath, siteConfig) {
const errors = []
// 1. 重新读取 + parse
let written
try {
const raw = await fsp.readFile(appJsonPath, 'utf8')
written = JSON.parse(raw)
} catch (e) {
throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`])
}
// 2. 顶级 privacy 块
const privacy = written.privacy
if (!privacy || typeof privacy !== 'object') {
throw new AppJsonVerificationError(['privacy 块缺失或不是对象'])
}
// 3. URL 字段检查
const urlFields = ['privacyContractUrl', 'serviceContractUrl']
for (const field of urlFields) {
const url = privacy[field]
if (typeof url !== 'string') {
errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`)
continue
}
// 不含占位符
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) {
errors.push(`${field} 仍含占位符: ${url}`)
}
// 不含双协议头
if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) {
errors.push(`${field} 含双协议头: ${url}`)
}
// 不含双斜杠 (协议头之后, 路径开始之前)
// 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //)
const afterProtocol = url.replace(/^https?:\/\//i, '')
const queryStart = afterProtocol.search(/[?#]/)
const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart)
if (beforeQuery.includes('//')) {
errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`)
}
// 必须以 https:// + siteConfig.baseUrl 开头
const expectedPrefix = siteConfig.baseUrl + '/'
if (!url.startsWith(expectedPrefix)) {
errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`)
}
// uniacid= 参数值 == siteConfig.uniacid
const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/)
if (!uniacidMatch) {
errors.push(`${field} 缺少 uniacid= 参数: ${url}`)
} else if (uniacidMatch[1] !== siteConfig.uniacid) {
errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`)
}
}
// 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留)
const nameFields = ['privacyContractName', 'serviceContractName']
for (const field of nameFields) {
const name = privacy[field]
if (!name || typeof name !== 'object' || !name.zh || !name.en) {
errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`)
}
}
if (errors.length > 0) {
throw new AppJsonVerificationError(errors)
}
}
/**
* 同步 privacy 到**任意**目标 app.json 路径
*
* @param {string} appJsonPath 目标 app.json 文件绝对路径
* @param {string} [label] 标识 (用于日志, 例 "stub" / "build")
* @returns {Promise<{written: boolean, siteConfig?: object}>}
*
* 应用场景:
* - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub')
* → 写 stub ascf_src/app.json (HBuilderX 之外的源)
* - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build')
* → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前
* → 否则发行 ZIP 里的 app.json 仍是占位符
*/
async function syncPrivacyToPath(appJsonPath, label = '') {
try {
const cwd = path.join(__dirname, '..');
const manifestPath = path.join(cwd, 'manifest.json');
const siteJsPath = path.join(cwd, 'site.js');
if (!await exists(manifestPath)) {
console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步');
return { written: false };
}
if (!await exists(appJsonPath)) {
console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`);
return { written: false };
}
// 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符)
const manifestRaw = await fsp.readFile(manifestPath, 'utf8');
const manifestClean = stripJsonComments(manifestRaw);
const manifest = JSON.parse(manifestClean);
const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy;
if (!privacyConfigTemplate) {
return { written: false };
}
// 2. 读 site.js, 提取真实 baseUrl + uniacid
if (!await exists(siteJsPath)) {
console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步');
return { written: false };
}
const siteJsSource = await fsp.readFile(siteJsPath, 'utf8');
const siteConfigRaw = parseSiteConfig(siteJsSource);
if (!siteConfigRaw) {
console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步');
return { written: false };
}
const siteConfig = {
baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl),
uniacid: siteConfigRaw.uniacid,
};
// 3. 占位符替换
const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig)
// 4. 校验替换结果
const finalStr = JSON.stringify(privacyConfig)
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) {
console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置')
return { written: false };
}
// 5. 写目标 app.json
const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(appJsonRaw);
appJson.privacy = privacyConfig;
await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
const labelSuffix = label ? ` (${label})` : '';
console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`);
// 6. 写后验证
await verifyWrittenAppJson(appJsonPath, siteConfig)
return { written: true, siteConfig }
} catch (err) {
console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err);
process.exitCode = 1;
return { written: false }
}
}
/**
* 同步 privacy 到 stub ascf_src/app.json (兼容旧调用)
*/
async function syncPrivacyToStub() {
const cwd = path.join(__dirname, '..');
const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json');
return syncPrivacyToPath(stubAppJsonPath, 'stub');
}
async function copyAssets(mode = 'production') {
try {
const cwd = path.join(__dirname, '..');
@@ -96,7 +437,8 @@ async function main() {
const argv = process.argv.slice(2);
const options = {
noZip: argv.includes('--no-zip'),
mode: 'production'
mode: 'production',
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
};
const modeIndex = argv.indexOf('--mode');
@@ -104,18 +446,46 @@ async function main() {
options.mode = argv[modeIndex + 1];
}
// 子命令: --sync-privacy-only
// vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json
// 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。
// vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。
if (options.syncPrivacyOnly) {
console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)');
await syncPrivacyToStub();
return;
}
// 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入
await syncSiteToStub(options.mode);
// 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步)
await syncPrivacyToStub();
await copyAssets(options.mode);
// 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json
// HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP,
// 发行包里的 app.json 还是占位符, 上架会被审核驳回。
// 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json');
if (await exists(buildAppJsonPath)) {
console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)');
await syncPrivacyToPath(buildAppJsonPath, 'build');
} else {
console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`);
}
}
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony');
const destDir = path.join(cwd, 'unpackage', 'dist', 'build');
const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony');
console.log(`ZIP 文件路径: ${zipFilePath}`);
await openFileDirectory(zipFilePath);
} else {
console.log('跳过创建 ZIP 文件和打开目录');

View File

@@ -1,14 +1,77 @@
{
"bundle_version": "1782901586",
"bundle_version": "1784767380",
"bundle_lang": "zh-cn",
"bundle_leaves": 2649,
"code_calls_total": 2677,
"code_unique_keys": 2083,
"found_in_bundle": 2082,
"bundle_leaves": 2659,
"code_calls_total": 2693,
"code_unique_keys": 2097,
"found_in_bundle": 2086,
"missing_summary": {
"TRULY_MISSING": 12,
"SYNTAX_BUG": 1
},
"missing_classified": {
"TRULY_MISSING": [
{
"key": "common.buyNow",
"file": "components\\ns-goods-recommend\\ns-goods-recommend.vue",
"reason": "not in bundle, not local"
},
{
"key": "wechatChannel.wechatMpOnly",
"file": "components-diy\\diy-channel-list.vue",
"reason": "not in bundle, not local"
},
{
"key": "common.submitSuccess",
"file": "components-diy\\diy-form.vue",
"reason": "not in bundle, not local"
},
{
"key": "submitFail",
"file": "components-diy\\diy-form.vue",
"reason": "not in bundle, not local"
},
{
"key": "diy.networkError",
"file": "pages\\index\\index.vue",
"reason": "not in bundle, not local"
},
{
"key": "versionInfo",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "commitId",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "buildTime",
"file": "pages\\index\\public\\js\\index.js",
"reason": "not in bundle, not local"
},
{
"key": "diy.networkError",
"file": "pages_goods\\category.vue",
"reason": "not in bundle, not local"
},
{
"key": "goods.detail.formEmptyTip",
"file": "pages_goods\\detail.vue",
"reason": "not in bundle, not local"
},
{
"key": "common.buyNow",
"file": "pages_goods\\public\\js\\list.js",
"reason": "not in bundle, not local"
},
{
"key": "pay.index.payInfoNotFound",
"file": "pages_tool\\pay\\index.vue",
"reason": "not in bundle, not local"
}
],
"SYNTAX_BUG": [
{
"key": "order.list.status.",
@@ -19,18 +82,8 @@
},
"placeholder_issues": [],
"data_lang_issues": {
"components\\ns-payment\\ns-payment.vue": [
"components.nsPayment.alipay"
],
"pages_promotion\\fenxiao\\apply.vue": [
"fenxiao.apply.pleaseEnterName"
],
"pages_promotion\\merch\\public\\js\\list.js": [
"merch.list.buyText"
],
"pages_tool\\ai-chat\\ai-chat-message.vue": [
"ai-chat.index.loadMoreHistory",
"ai-chat.index.defaultNickname"
]
},
"hardcoded_navbar_titles": [
@@ -121,6 +174,11 @@
"common.tabBar.cart",
"common.tabBar.member"
],
"local_only_keys": [],
"local_only_keys": [
"buildTime",
"commitId",
"submitFail",
"versionInfo"
],
"bundle_only_keys_in_unique": []
}

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# 精筛 v2:剥离 HTML 注释和 JS 注释后,再扫硬编码中文(只关注真实会被渲染的)
import os, re, json
from collections import defaultdict
ROOT = r'D:\projects\shop-projects\frontend'
SKIP_DIR = {'node_modules', '.unpackage', 'unpackage', '.git', 'dist', 'stub-app-projects', 'lang', 'tests', 'docs', 'scripts', 'common/mixin'}
CN = re.compile(r'[\u4e00-\u9fff]')
LANG_RE = re.compile(r'\$lang\(')
TPL_RE = re.compile(r'<template[^>]*>([\s\S]*?)</template>', re.I)
TAG_RE = re.compile(r'<([a-zA-Z][\w-]*)\b[^>]*>([^<>\n]+)</\1>')
HTML_COMMENT_RE = re.compile(r'<!--[\s\S]*?-->')
JS_TRIGGERS = [
re.compile(r'uni\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
re.compile(r'wx\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
re.compile(r'\btitle\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bcontent\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\btext\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bplaceholder\s*:\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\balert\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
re.compile(r'\bconfirm\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
]
def strip_all_comments(text, is_vue=False):
text = HTML_COMMENT_RE.sub(lambda m: ' ' * len(m.group()), text)
text = re.sub(r'/\*[\s\S]*?\*/', lambda m: ' ' * len(m.group()), text)
text = re.sub(r'//[^\n]*', lambda m: ' ' * len(m.group()), text)
return text
results = defaultdict(list)
for dirpath, dirnames, filenames in os.walk(ROOT):
if any(s in dirpath.replace('\\', '/') for s in SKIP_DIR):
continue
for fn in filenames:
if not fn.endswith(('.vue', '.js')):
continue
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, ROOT).replace('\\', '/')
try:
content = open(fp, 'r', encoding='utf-8').read()
except UnicodeDecodeError:
continue
stripped = strip_all_comments(content)
if fn.endswith('.vue'):
for m in TPL_RE.finditer(stripped):
tpl = m.group(1)
for tag in TAG_RE.finditer(tpl):
inner = tag.group(2).strip()
if CN.search(inner) and not LANG_RE.search(inner):
line_no = stripped[:m.start(1) + tpl.find(inner)].count('\n') + 1
results[rel].append({'kind': 'template', 'tag': tag.group(1), 'text': inner[:160], 'line': line_no})
for trig in JS_TRIGGERS:
for m in trig.finditer(stripped):
text = (m.group(1) if m.lastindex else m.group(0))
if not CN.search(text):
continue
if LANG_RE.search(text):
continue
line_no = stripped[:m.start()].count('\n') + 1
results[rel].append({'kind': 'js-text', 'match': text[:160], 'line': line_no})
ranked = sorted(results.items(), key=lambda kv: -len(kv[1]))
print(f'total files: {len(ranked)}')
print(f'total hits: {sum(len(v) for v in ranked)}')
out = []
for f, hits in ranked[:60]:
out.append({'file': f, 'hits': len(hits), 'samples': hits[:3]})
print(json.dumps(out, ensure_ascii=False, indent=2))
with open(os.path.join(ROOT, 'scripts', 'hardcoded_cn_real.json'), 'w', encoding='utf-8') as f:
json.dump({k: v for k, v in ranked}, f, ensure_ascii=False, indent=2)
print('saved hardcoded_cn_real.json')

View File

@@ -1,4 +1,4 @@
module.exports = {
baseUrl: "https://xcx2.aigc-quickapp.com/",//修改域名
uniacid: 2482,//后台对应uniacid
baseUrl: "https://5g.aigc-quickapp.com/",//修改域名
uniacid: 248,//后台对应uniacid
};

View File

@@ -1,6 +1,6 @@
{
"app": {
"bundleName": "com.atomicservice.6917600524822452448",
"bundleName": "com.atomicservice.6917610986330791161",
"bundleType": "atomicService",
"vendor": "example",
"versionCode": 1000000,

View File

@@ -0,0 +1,25 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -0,0 +1,6 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -0,0 +1,18 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -0,0 +1,10 @@
{
"name": "components_diy",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -0,0 +1,12 @@
{
"module": {
"name": "components_diy",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -0,0 +1,8 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -0,0 +1,6 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -0,0 +1,18 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -0,0 +1,10 @@
{
"name": "pages_goods",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -0,0 +1,12 @@
{
"module": {
"name": "pages_goods",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -0,0 +1,8 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

View File

@@ -0,0 +1,6 @@
import { hspTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hspTasks,
plugins: []
}

View File

@@ -0,0 +1,18 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
#
# For more details, see
# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope

View File

@@ -0,0 +1,10 @@
{
"name": "pages_order",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "Index.ets",
"author": "",
"license": "Apache-2.0",
"packageType": "InterfaceHar",
"dependencies": {}
}

View File

@@ -0,0 +1,12 @@
{
"module": {
"name": "pages_order",
"type": "shared",
"description": "subpackage",
"deviceTypes": [
"phone"
],
"deliveryWithInstall": true,
"installationFree": true
}
}

View File

@@ -0,0 +1,8 @@
{
"string": [
{
"name": "shared_desc",
"value": "description"
}
]
}

View File

@@ -0,0 +1,25 @@
{
"apiType": "stageMode",
"buildOption": {
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [
"./obfuscation-rules.txt"
]
}
}
},
},
],
"targets": [
{
"name": "default"
}
]
}

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