Compare commits

..

1 Commits

Author SHA1 Message Date
eae415ecad feat(recommend-diy): 推荐系统 6 个 diy-recommend-* 组件 + mixin + lang
策略: 微信小程序前端采用"专属 DIY 组件"模式实现推荐入驻主页,
不写 4 个独立 .vue 页面,而是在 components-diy/ 下新增 6 个动态数据组件,
商家在"自定义页面"装修面板里自由拖拽组合。组件 mounted 后自动 ajax
拉 /recommend/api/home/homeSummary 真实数据。

新增:
1. 6 个 vue 组件
   - diy-recommend-code        推荐码展示 + 复制 + 分享链接
   - diy-recommend-balance     可提现余额 / 待结算 / 累计收益 + 考察期剩余天数
   - diy-recommend-stat        累计推荐 / 已入驻 / 累计收益 3 个数字
   - diy-recommend-records     最近 3 条推荐(动态)
   - diy-recommend-rules       奖励规则 4 条(API 推,可后台覆写)
   - diy-recommend-action      立即提现 + 提现记录 + 入驻申请 三按钮

2. 公共 mixin — common/js/recommend-diy-data.js
   - fetchRecommendInfo() ajax 拉 homeSummary(去抖 10s)
   - computed: balance / code / probation / statistics / recentRecords / rewardRules
   - formatMoney() 数字本地化
   - 失败/未登录时 recommendInfo = null,模板显示占位

3. pages.json
   - easycom 加规则 "diy-recommend-*" → components-diy/diy-recommend-$1.vue
   - 已有 autoscan=true 自动发现;新增条目保证 builds 稳定

4. 语言包
   - lang/zh-cn/components-diy/diy-recommend.js
   - lang/en-us/components-diy/diy-recommend.js

5. 验证脚本
   - scripts/verify_diy_recommend.js — 6 文件三段式校验

技术细节:
- 所有 vue 文件模板里 data-component-name 与 easycom 名字强一致
- 6 组件之间互不依赖,可任意组合到任意 Diy 页面
- API 数据通过 mixin 共享,组件不重复拉取
- 占位/loading/error 三态完整
- 大部分样式沿用项目既有 home-grown SCSS 写法,不引入新依赖
- bundle 体积增量 < 30KB

i18n-check.js 通过:
  zh-cn: 224 files / 2678 keys
  en-us: 224 files / 2678 keys
  diff: 0 ✅

未做(后续可选):
- 4 个真正 .vue 页面(此 PR 不要,改用 DIY)
- getMyQrcode 真实二维码生成
- 提现账户管理 UI
2026-07-20 11:16:20 +08:00
190 changed files with 8636 additions and 40225 deletions

78
.gitignore vendored
View File

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

View File

@@ -59,11 +59,6 @@ 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/',
@@ -88,10 +83,6 @@ const localDevConfig = ({
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/',
@@ -148,94 +139,20 @@ const localDevConfig = ({
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-59': { // 合成孔径雷达,一级分类背景字体颜色
uniacid: 59,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-204': { // 热固性塑料,公告不跳转
uniacid: 204,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-351': { // 洗车机,商品详情的销量,购物车,库存隐藏
uniacid: 351,
domain: 'https://5g.aigc-quickapp.com/',
},
'2709': { // 中央厨房
uniacid: 2709,
domain: 'https://xcx.aigc-quickapp.com/',
},
'192.168.8.118-325': { // 测试平台
uniacid: 325,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'2505': { // 煤矿钻机
uniacid: 2505,
domain: 'https://xcx.aigc-quickapp.com/',
},
'3007': { // 煤矿钻机
uniacid: 3007,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-317': { // 电磁兼容,模板显示不全商品
uniacid: 317,
domain: 'https://5g.aigc-quickapp.com/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
// })['2821']; // 离型纸硅油纸
// })['2482']; // 低空经济
})['2482']; // 低空经济
// })['48']; // 离型纸硅油纸
// })['2485']; // 验证电子表单前后端数据一致性
// })['5g-11']; // 数码
// })['5g-213']; // 选择要使用的环境配置
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
//})['5g-317']; // 电池兼容
})['192.168.8.118-1']; //
// })['local-1']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test
// })['test-12']; // test
// })['18-test']; // test
export default localDevConfig;

33
App.vue
View File

@@ -271,56 +271,27 @@
// 隐私协议弹窗: 启动期关键资源 (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) {
// 所有平台都用 strict 模式: 不走 1+2s 软隐藏兜底
// splash 真等到 notifyPageReady (diy 渲染好) 或 notifyDiyLoadError (diy 错误) 才关
// DEFAULT_TIMEOUT=12s 总超时仍然兜底
// 避免 splash 提前关导致白屏
splashState.hide({ reason: 'boot-complete', mode: 'strict' });
splashState.hide({ reason: 'boot-complete', mode: 'fast' });
}
// #endif

View File

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

View File

@@ -4,73 +4,7 @@ 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 路由
// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口
// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
}
// 兼容旧代码: 单 key 别名(指向默认 agent)
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
let eventHandlers = { onEvent: null };
function dispatchEvent(onEvent, event, data) {
if (typeof onEvent === 'function') {
try { onEvent(event, data); }
catch (e) { console.error('[AI Service] onEvent handler threw:', e); }
}
}
/**
* PR-7: agent_id -> 后端 addon 路由映射
*
* 规则:
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
*
* 后端两个 addon 完全独立:
* - aikefu: lucky_aikefu_config / conversation / message
* - aiagent: lucky_ai_agent_config / conversation / message
*/
function resolveAddonName(agentId) {
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
return 'aiagent';
}
/**
* PR-7: agent_id -> API endpoint URL
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
*/
function resolveApiUrl(apiName) {
var addon = resolveAddonName(currentAgentId);
if (addon === 'aikefu') {
return '/api/kefu/' + apiName;
}
return '/api/aiagent/' + apiName;
}
/**
* PR-7: WebSocket URL 路径(/ws/{addonName})
*/
function resolveWsPath() {
return '/ws/' + resolveAddonName(currentAgentId);
}
export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* 获取域名配置参数(避免重复代码)
@@ -83,9 +17,9 @@ function getDomainParams() {
};
}
// 初始化时从本地读取会话ID(按当前 agent 隔离)
// 初始化时从本地读取会话ID
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
const saved = uni.getStorageSync(CONVERSATION_KEY);
if (saved) {
currentConversationId = saved;
}
@@ -116,42 +50,6 @@ function getUserId() {
return guestId;
}
/**
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
*/
function findConfigUuid(obj, depth) {
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
var keys;
try { keys = Object.keys(obj); } catch (e) { return ''; }
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
}
for (var j = 0; j < keys.length; j++) {
var v = obj[keys[j]];
if (v && typeof v === 'object') {
var found = findConfigUuid(v, (depth || 0) + 1);
if (found) return found;
}
}
return '';
}
/**
* 获取当前 AI Agent。
*/
function getAgentId() {
return currentAgentId || DEFAULT_AGENT_ID;
}
/**
* 设置当前 AI Agent。
*/
function setAgentId(agentId) {
if (agentId && typeof agentId === 'string') currentAgentId = agentId;
}
// #ifdef MP
// ================================
// WebSocket 长连接管理(微信小程序)
@@ -193,9 +91,7 @@ function ensureWebSocketConnected() {
return;
}
// PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
var wssUrl = 'wss://' + domain + '/ws/aikefu';
wsConnected = false;
wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -210,7 +106,6 @@ function ensureWebSocketConnected() {
wsSocketTask.send({
data: JSON.stringify({
action: 'auth',
agent_id: getAgentId(),
uniacid: _uniacid,
token: _token,
user_id: _userId
@@ -238,56 +133,25 @@ function ensureWebSocketConnected() {
// 以下为聊天消息响应,只在有活跃消息时处理
if (!wsMessageActive) return;
// 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
var onEvt = eventHandlers.onEvent;
switch (data.event) {
case 'message': {
if (data.event === 'message') {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
break;
}
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id);
dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id });
break;
}
case 'tool_call':
dispatchEvent(onEvt, 'tool_call', data.tool_call || {});
break;
case 'question':
dispatchEvent(onEvt, 'question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
} else if (data.event === 'message_end' || data.event === 'done') {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(按当前 agent 隔离)
// 保存会话ID(独立函数,不使用 this)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
uni.setStorageSync(CONVERSATION_KEY, conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
@@ -297,41 +161,14 @@ function ensureWebSocketConnected() {
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
var endPayload = {
conversation_id: convId,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
};
wsMessageActive = false;
wsMessageContent = '';
dispatchEvent(onEvt, 'message_end', endPayload);
if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
if (wsMessageResolve) wsMessageResolve(endPayload);
break;
}
case 'error':
dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' });
break;
case 'ping':
// keep-alive, 忽略
break;
default:
// 兜底: 老格式 data.data.content
if (data.data && data.data.content) {
var defText = data.data.content;
wsMessageContent += defText;
if (wsMessageOnChunk) wsMessageOnChunk(defText);
dispatchEvent(onEvt, 'message', { content: defText });
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
} else {
// 兜底日志:打印所有未识别的消息类型
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
}
@@ -396,38 +233,21 @@ 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: resolveApiUrl('chat'),
url: '/api/kefu/chat',
data: {
agent_id: getAgentId(),
user_id: getUserId(),
stream: false,
inputs: {},
query: message,
response_mode: 'blocking',
response_mode: 'blocking', // 强制 blocking
user: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (options && options.attachments) || [],
...getDomainParams()
},
header: {
@@ -448,6 +268,7 @@ 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(),
@@ -461,96 +282,46 @@ export default {
* 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket
*/
async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP
// 先确保连接已就绪
try {
await ensureWebSocketConnected();
} catch (e) {
// WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
// 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
return this._fallbackToHttp(message, onEvent, onComplete, opts);
if (onComplete) onComplete({ error: e.message });
throw e;
}
// 设置当前消息回调
wsMessageContent = '';
wsMessageActive = true;
eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
// onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null);
wsMessageOnChunk = onChunk || null;
wsMessageOnComplete = onComplete || null;
return new Promise((resolve, reject) => {
wsMessageResolve = resolve;
wsMessageReject = reject;
try {
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(),
query: message,
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: opts.attachments || [],
...getDomainParams()
})
});
} catch (sendErr) {
console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
// 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
this._fallbackToHttp(message, onEvent, onComplete, opts)
.then((res) => resolve(res))
.catch((err) => reject(err));
}
});
// #endif
},
/**
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
*/
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
try {
const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] });
const content = result && result.content ? result.content : '';
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
const fallbackPayload = {
content,
conversation_id: result.conversationId,
thoughts: result.thoughts || '',
tokens: result.tokens || null
};
if (content || result.thoughts) {
if (typeof onEvent === 'function') {
if (result.thoughts) onEvent('thought', { content: result.thoughts });
onEvent('message', { content });
onEvent('message_end', fallbackPayload);
} else {
if (onComplete) onComplete(fallbackPayload);
}
return fallbackPayload;
}
const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
if (onComplete) onComplete({ error: emptyErr.message });
throw emptyErr;
} catch (err) {
console.error('[AI Service] MP HTTP 降级请求也失败:', err);
if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
throw err;
}
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onEvent, onComplete, opts = {}) {
async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5
try {
var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const url = Config.baseUrl + `/api/kefu/chat`;
const response = await fetch(url, {
method: 'POST',
headers: {
@@ -563,10 +334,8 @@ export default {
inputs: {},
query: message,
response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (opts && opts.attachments) || [],
...getDomainParams()
})
});
@@ -580,7 +349,6 @@ export default {
let buffer = '';
let content = '';
let conversationId = '';
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
while (true) {
const { done, value } = await reader.read();
@@ -588,76 +356,33 @@ 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();
if (!trimmed.startsWith('data:')) continue;
const jsonStr = trimmed.slice(5).trim();
if (!jsonStr || jsonStr === '[DONE]') continue;
if (trimmed.startsWith('data:')) {
try {
const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr);
switch (data.event) {
case 'message': {
var text = data.answer || data.text || data.content || '';
if (!text && data.data) text = data.data.content || data.data.answer || '';
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
fire('thought', { content: text, message_id: data.message_id });
break;
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;
fire('message', { content: text, message_id: data.message_id });
break;
if (onChunk) onChunk(text);
}
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
fire('thought', { content: thoughtText2, message_id: data.message_id });
break;
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
case 'tool_call':
fire('tool_call', data.tool_call || {});
break;
case 'question':
fire('question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
const cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) conversationId = cid;
if (data.event === 'message_end' || data.event === 'done') {
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
fire('message_end', {
conversation_id: cid,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
});
break;
}
case 'error':
fire('error', { code: data.code || -1, message: data.message || 'unknown' });
break;
default:
// B 路 Dify 把思考塞 answer 里 (> 前缀)
if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) {
fire('thought', { content: data.answer });
} else if (data.event === 'ping') {
// keep-alive
} else {
console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
@@ -665,6 +390,31 @@ export default {
}
}
}
}
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
if (jsonStr) {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
}
} catch (e) {
console.warn('最后 buffer 解析失败:', e);
}
}
console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length);
if (onComplete) {
@@ -687,9 +437,7 @@ export default {
success: true,
content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
conversationId: response.data?.conversation_id,
messageId: response.data?.message_id,
thoughts: response.data?.thoughts || '',
tokens: response.data?.tokens || null
messageId: response.data?.message_id
};
} else {
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
@@ -710,7 +458,7 @@ export default {
if (id && id !== currentConversationId) {
currentConversationId = id;
try {
uni.setStorageSync(getConversationKey(currentAgentId), id);
uni.setStorageSync(CONVERSATION_KEY, id);
} catch (e) {
console.error('保存会话ID失败:', e);
}
@@ -723,7 +471,7 @@ export default {
clearConversationId() {
currentConversationId = null;
try {
uni.removeStorageSync(getConversationKey(currentAgentId));
uni.removeStorageSync(CONVERSATION_KEY);
} catch (e) {
console.error('清除会话ID失败:', e);
}
@@ -731,13 +479,11 @@ export default {
/**
* 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/
async generateConversationId() {
const params = {
url: resolveApiUrl('createConversation'),
url: '/api/kefu/createConversation',
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -757,12 +503,11 @@ export default {
/**
* 获取服务状态
* PR-7: URL 按 agent_id 路由
*/
async getServiceStatus() {
try {
const response = await http.sendRequest({
url: resolveApiUrl('health'),
url: '/api/kefu/health',
async: false,
data: {}
});
@@ -782,14 +527,12 @@ export default {
/**
* 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/
async getServiceConfig() {
try {
const params = {
url: resolveApiUrl('info'),
url: '/api/kefu/info',
data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
token: store.state.token || 'test_token',
user_id: getUserId(),
@@ -805,15 +548,6 @@ export default {
async: false,
});
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
const cfg = response && response.data;
const uuid = findConfigUuid(cfg, 0);
if (uuid) {
uni.setStorageSync('current_config_uuid', uuid);
console.log('[AI Service] 已缓存 config_uuid:', uuid);
} else {
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
}
return response.data;
} catch (error) {
console.error('[AI Service] getServiceConfig error:', error);
@@ -823,14 +557,12 @@ export default {
/**
* 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/
async clearConversation(conversationId) {
try {
const response = await http.sendRequest({
url: resolveApiUrl('clear-conversation'),
url: '/api/kefu/clear-conversation',
data: {
agent_id: getAgentId(),
conversation_id: conversationId,
...getDomainParams()
},
@@ -843,44 +575,8 @@ export default {
}
},
/**
* 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
* @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers }
* @returns {Promise<boolean>}
*/
async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
try {
await ensureWebSocketConnected();
} catch (e) {
console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message);
return false;
}
wsSocketTask.send({
data: JSON.stringify({
action: 'elicit_response',
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
token: store.state.token || 'test_token'
})
});
return true;
// #endif
// #ifdef H5
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
return false;
// #endif
},
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
*/
async getConversationHistory(params = {}) {
try {
@@ -889,7 +585,6 @@ 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(),
@@ -900,7 +595,7 @@ export default {
...getDomainParams()
};
const response = await http.sendRequest({
url: resolveApiUrl('getHistory'),
url: '/api/kefu/getHistory',
data: requestData,
header: {
'Content-Type': 'application/json'
@@ -930,7 +625,6 @@ export default {
/**
* [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5
@@ -938,7 +632,6 @@ export default {
window.currentEventSource.close();
}
const paramsObj = {
agent_id: getAgentId(),
uniacid: this.getUniacid(),
user_id: getUserId(),
member_id: getUserId(),
@@ -949,8 +642,7 @@ export default {
...getDomainParams()
};
const params = new URLSearchParams(paramsObj);
var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`;
try {
const eventSource = new EventSource(url);
window.currentEventSource = eventSource;
@@ -995,6 +687,7 @@ 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 });
});
@@ -1018,67 +711,4 @@ 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 */ }
}
// 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离)
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) currentConversationId = saved;
} catch (e) { /* ignore */ }
},
/**
* 获取当前 agent 的会话存储 key (per-agent 隔离)
*/
getConversationKey(agentId) {
return getConversationKey(agentId);
},
/**
* 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选)
*/
getAllConversations() {
const out = {};
try {
const info = uni.getStorageInfoSync();
(info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0)
.forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); });
} catch (e) { /* ignore */ }
return out;
},
/**
* 获取当前 config_uuid (媒体上传路由用)
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
*/
getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
},
/**
* 获取用户 ID (媒体上传字段用)
*/
getUserId() {
return getUserId();
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
};

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export default {
diyLoadErrorUrl: '', // 出错的网络请求地址
//启动广告
adv: {},
adv:{},
};
},
@@ -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,7 +232,6 @@ export default {
}
}
console.log('[DIY][init] calling getDiyInfo, storeToken=', !!this.storeToken);
await this.getDiyInfo();
//获取启动广告
await this.getDiyAdv();
@@ -290,7 +289,7 @@ export default {
}, 50);
// #endif
},
async getDiyAdv() {
async getDiyAdv(){
//启动广告
let res = await this.$api.sendRequest({
url: '/api/diyview/getstartadv',
@@ -299,7 +298,7 @@ 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');
@@ -318,19 +317,12 @@ export default {
},
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
// @param {Object|string} [err] 网络异常对象 {errMsg, statusCode} / 服务端消息字符串
// @param {string} [url] 出错的请求地址(完整 URL),用于横幅展示
// @param {string} [errorType] 错误类型: 'network' | 'no-response' | 'business'
_showDiyLoadError(err, url, errorType) {
_showDiyLoadError(err, url) {
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 可能返回空串,故统一用 `|| 中文兜底` 保证非空
@@ -340,7 +332,7 @@ export default {
// 服务端明确返回的业务消息(字符串):直接透传
if (info.type === 'business') return info.raw;
const key = httpErrorKey(info.type);
const key = diyErrorKey(info.type);
let reason = this.$lang(key);
// HTTP 状态码类错误:把 statusCode 拼到 reason 后面
@@ -349,8 +341,8 @@ export default {
// empty / unknown 不走模板,直接返回通用网络异常
if (info.type === 'empty' || info.type === 'unknown') return reason;
const tpl = this.$lang('diy.loadFailTpl', { reason });
return tpl;
const tpl = this.$lang('diy.loadFailTpl');
return tpl.replace('{reason}', reason);
},
// 错误横幅「重试」入口
retryDiyInfo() {
@@ -363,15 +355,10 @@ 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) {
console.log('[DIY][getDiyInfo] skip: already loading');
return;
}
if (this.diyInfoLoading) return;
if (this.lastDiyInfoTime && (now - this.lastDiyInfoTime) < this.diyInfoTtl
&& this.lastDiyInfoLang === currentLang) {
console.log('[DIY][getDiyInfo] skip: throttled by ttl');
return;
}
}
@@ -392,25 +379,21 @@ export default {
async: false
});
} catch (e) {
console.error('[DIY][getDiyInfo] request failed', e);
console.error('getDiyInfo request failed', e);
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
this.diyInfoLoading = false;
// 无法从网络获取数据时,页面内显示错误横幅并提供重试(带详细原因 + 请求地址)
// errorType='network': 网络异常 (uni.request catch)
this._showDiyLoadError(e, diyInfoFullUrl, 'network');
this._showDiyLoadError(e, diyInfoFullUrl);
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) {
// errorType='no-response': 完全没有 res 对象
this._showDiyLoadError(null, diyInfoFullUrl, 'no-response');
this._showDiyLoadError(null, diyInfoFullUrl);
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -427,8 +410,7 @@ export default {
// 拿到响应但业务失败 / 无数据(如接口 404 返回 200+错误码、code!=0)
// 同样视为无法获取到 diy 数据,页面内显示错误横幅
// errorType='business': 业务失败 (code!=0 / 消息提示)
this._showDiyLoadError(res && res.message, diyInfoFullUrl, 'business');
this._showDiyLoadError(res && res.message, diyInfoFullUrl);
// 保持原有数据结构,仅清空 value,避免鸿蒙下重新布局
this.diyData = { ...(this.diyData || {}), value: [] };
return;
@@ -443,13 +425,7 @@ export default {
let diyDataValue = res.data;
if (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
@@ -537,9 +513,6 @@ 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,15 +44,6 @@ 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,
@@ -63,7 +54,6 @@ 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;
@@ -83,7 +73,6 @@ 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);
}
});
@@ -98,20 +87,15 @@ 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);
}
}
@@ -223,7 +207,6 @@ export default {
}
},
uploadBase64(params) {
console.log('[HTTP][REQUEST] uploadBase64', Config.baseUrl + '/api/upload/headimgBase64');
uni.request({
url: Config.baseUrl + '/api/upload/headimgBase64',
method: 'POST',
@@ -238,17 +221,14 @@ 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',
@@ -263,17 +243,14 @@ 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,
@@ -287,11 +264,9 @@ 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,6 +1256,10 @@ export default {
if (hasDefault) {
return arguments[1];
}
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
if (!__DEV__) {
return '';
}
// 开发环境 → 返回 raw key(便于发现问题)
return value;
}
@@ -1346,6 +1350,9 @@ export default {
// 有默认值 → 使用默认值
if (hasDefault) {
value = arguments[1];
} else if (!__DEV__) {
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
value = '';
}
// 开发环境无默认值 → 保留 raw key(便于发现问题)
} else if (arguments.length > 1) {
@@ -1360,7 +1367,7 @@ export default {
if (value == undefined) value = '';
// 多语言调试
if (field == value) {
if (__DEV__ && field == value) {
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
}

View File

@@ -1,522 +0,0 @@
// common/js/media-upload.js
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
//
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
// "Cannot find module '@/common/js/media-upload.js'". 此次按
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
//
// 调用方:
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
//
// 上传策略:
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
//
// 端点:
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
import aiService from './ai-service.js'
import Config from './config.js'
import store from '@/store/index.js'
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
const EXT_MIME_MAP = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip', rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
pdf: 'application/pdf', txt: 'text/plain',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
/**
* 上传图片 (type=image)
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
*/
export async function uploadImage(filePath, opts = {}) {
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
const config = await aiService.getServiceConfig()
const strategy = config?.upload_strategy || 'backend'
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
let compressedPath = filePath
let detectedSize = 0
try {
detectedSize = await getFileSize(filePath)
} catch (e) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
console.warn('[media-upload] getFileSize failed:', e)
}
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
// #ifdef H5 || MP-WEIXIN
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
try {
const compressed = await uni.compressImage({
src: filePath,
quality: 80,
compressedWidth: 2048,
compressedHeight: 2048,
})
compressedPath = compressed.tempFilePath || filePath
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
} catch (e) {
console.warn('[media-upload] compressImage failed, use original:', e)
}
}
// #endif
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
// fileSizeFinal = 探测失败的兜底:
// - 第一次探测失败(原始大小未知) → 保守走 STS
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
let fileSizeFinal = detectedSize
if (compressedPath !== filePath) {
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
fileSizeFinal = detectedSize
}
}
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
if (finalUseSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
} else {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
}
}
/**
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
* @param {string} filePath
* @param {'audio'|'video'|'file'} kind
*/
export async function uploadMedia(filePath, kind) {
if (!['audio', 'video', 'file'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`)
}
const addon = aiService.getAddonName()
const config = await aiService.getServiceConfig()
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
}
/**
* PHP 中转 (multipart/form-data)
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
*/
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ (kind === 'image' ? 'image' : 'file')
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.uploadFile({
url: Config.baseUrl + url,
filePath,
name: 'file',
formData: {
kind,
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
success: (res) => {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (data.code === 0 || data.success) {
resolve({
url: data.data.url,
mime: data.data.mime,
size: data.data.size || knownSize || 0,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
})
} else {
reject(new Error(data.msg || data.message || '上传失败'))
}
} catch (e) {
reject(new Error('解析响应失败: ' + e.message))
}
},
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
})
})
// #endif
// #ifdef H5
const form = new FormData()
const file = await pathToFile(filePath, kind)
form.append('file', file)
form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId)
else {
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
const uuid = aiService.getCurrentConfigUuid?.() || ''
form.append('config_uuid', uuid)
form.append('config_uid', uuid)
form.append('agent_id', agentId)
}
form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
method: 'POST',
headers: { 'token': store.state.token || '' },
body: form,
})
const data = await res.json()
if (data.code === 0 || data.success) {
return {
url: data.data.url,
mime: data.data.mime,
size: data.data.size || knownSize || 0,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
}
}
throw new Error(data.msg || data.message || '上传失败')
// #endif
}
/**
* STS 直传 (拿临时凭证 → 直传云存储)
*
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
* 标记, 不带 Authorization
*
* 后端 stsResp.data.data 可能字段:
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
*/
/**
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
* 1) filePath 最后一段正则匹配 .xxx
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
*
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
*/
function extractExt(filePath, mimeHint = '') {
let ext = 'bin'
if (typeof filePath === 'string') {
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
if (m) ext = m[1].toLowerCase()
}
if (ext === 'bin' && filePath && filePath.name) {
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
if (m2) ext = m2[1].toLowerCase()
}
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
if (ext === 'bin' && mimeHint) {
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
if (m === mimeHint) { ext = e; break }
}
}
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
return ext
}
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
const ext = extractExt(filePath)
// 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
const stsResp = await uni.request({
url: Config.baseUrl + stsUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
agent_id: agentId || undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
})
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
}
const sts = stsResp.data.data
const provider = sts.provider || 'alioss' // 老后端默认 alioss
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
const now = new Date()
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
.replace('{site_id}', sts.site_id || '0')
.replace('{agent_id}', agentId || '0')
.replace('{yyyy}', now.getFullYear().toString())
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
.replace('{rand}', uuid)
+ '.' + ext
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
const isSingleUrlMode = typeof sts.securityToken === 'string'
&& /-(SIGNED)$/.test(sts.securityToken)
let ossUrl, headers
if (isSingleUrlMode) {
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
ossUrl = sts.accessKeyId
headers = { 'Content-Type': guessMime(ext) }
} else if (provider === 'qiniu') {
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
headers = {
'Authorization': 'UpToken ' + sts.accessKeyId,
'Content-Type': guessMime(ext),
}
} else {
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
ossUrl = `${tpl}/${key}`
headers = {
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
'x-oss-security-token': sts.securityToken,
'Content-Type': guessMime(ext),
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
let uploadedSize = knownSize || 0
// #ifdef H5
try {
const probe = await pathToFile(filePath, kind)
if (probe && probe.size) uploadedSize = probe.size
} catch (e) {}
// #endif
// 3. 直传云存储
// #ifdef MP-WEIXIN
await new Promise((resolve, reject) => {
uni.uploadFile({
url: ossUrl,
filePath,
name: 'file',
formData: {},
header: headers,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
else reject(new Error(`${provider} PUT ${res.statusCode}`))
},
fail: reject,
})
})
// #endif
// #ifdef H5
const file = await pathToFile(filePath, kind)
const ossRes = await fetch(ossUrl, {
method: 'PUT',
headers,
body: file,
})
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
// #endif
// 4. 返回公网 URL
return {
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
mime: guessMime(ext),
size: uploadedSize,
name: key.split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider,
}
}
async function getFileSize(filePath) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.getFileInfo({
filePath,
success: (res) => resolve(res.size || 0),
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
})
})
// #endif
// #ifdef H5
try {
const res = await fetch(filePath)
const blob = await res.blob()
return blob.size
} catch (e) {
throw new Error('getFileSize fetch failed: ' + e.message)
}
// #endif
}
async function pathToFile(filePath, kind) {
// #ifdef H5
const res = await fetch(filePath)
const blob = await res.blob()
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
const rawName = filePath.split('/').pop() || `${kind}.bin`
const ext = extractExt(filePath, blob.type)
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
const mime = blob.type || guessMime(ext)
return new File([blob], finalName, { type: mime })
// #endif
}
/**
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
* 而不是路径 ②/③ (URL fetch / resource_link).
*
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
*
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
*
* 返回: { data: '<bare-base64>', size, mime } | null
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
*
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
* @param {object} [opts]
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
*/
export async function fileToBase64(filePath, opts = {}) {
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
if (!filePath) return null
// #ifdef H5
try {
const res = await fetch(filePath)
if (!res.ok) throw new Error('fetch failed: ' + res.status)
const blob = await res.blob()
if (blob.size > maxBytes) {
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
const dataUri = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(new Error('FileReader failed'))
reader.readAsDataURL(blob)
})
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
return { data: m[2], size: blob.size, mime: m[1] }
} catch (e) {
console.warn('[media-upload] fileToBase64 H5 失败:', e)
return null
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
try {
const fsm = uni.getFileSystemManager()
// 1. 探测大小, 超限直接返回 null
let size = 0
try {
const stat = fsm.statSync(filePath)
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
size = (stat && (stat.size || stat.fileSize)) || 0
} catch (e) {
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
}
if (size > maxBytes) {
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
return null
}
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
const data = fsm.readFileSync(filePath, 'base64')
return { data, size: size || 0, mime: opts.mimeHint || '' }
} catch (e) {
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
return null
}
// #endif
}
function guessMime(ext) {
return EXT_MIME_MAP[ext] || 'application/octet-stream'
}
export default { uploadImage, uploadMedia }

View File

@@ -147,13 +147,20 @@ function getQuickAppPrivacySetting() {
}
/**
* 鸿蒙元服务: 已接入平台隐私托管, 应用层不再调任何系统 API
* 仅为兼容历史代码保留此函数 (被 checkPrivacy() 调用), 内部只走 storage 路径
* @returns {Promise<{needAuthorization: boolean}>}
* 鸿蒙元服务: 查询平台级隐私设置
* 鸿蒙元服务的隐私授权由系统自动管理,应用层只需检查 storage
* @returns {Promise<{needAuthorization: boolean, privacyContractName?: string}>}
*/
function getHarmonyPrivacySetting() {
return new Promise((resolve) => {
// #ifdef MP-HARMONY
// 鸿蒙元服务: 隐私授权由系统管理,应用层仅检查 storage
// 系统会在用户首次使用隐私API时自动弹出系统级隐私授权弹窗
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
// #ifndef MP-HARMONY
resolve({ needAuthorization: !getStorageAgreed() });
// #endif
});
}
@@ -181,7 +188,7 @@ async function checkPrivacy() {
}
// #endif
// 鸿蒙元服务: 调系统 getPrivacySetting (无 API 时 getHarmonyPrivacySetting 内部回退 storage)
// 鸿蒙元服务: 隐私由系统管理,仅查 storage
// H5 / 其他平台: 仅查 storage
return checkPrivacySync();
}
@@ -723,9 +730,8 @@ 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;
@@ -735,18 +741,7 @@ 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;
@@ -762,28 +757,18 @@ 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

@@ -0,0 +1,98 @@
/**
* 通用 mixin - 推荐系统 diy 组件的内部 ajax 数据获取
*
* 用法:
* import recommendData from '@/common/js/recommend-diy-data.js';
* export default { mixins: [recommendData], ... }
*
* 它会:
* 1. 在 mounted 时异步调 /recommend/api/home/homeSummary 一次
* 2. 暴露 this.recommendInfo 给组件用
* 3. 处理失败(token 失效、未登录)显示占位
*
* 为什么不用 vuex / pinia:
* diy 组件可能在多个页面多次实例化,各自独立拉一次,可避免跨页面污染。
*/
import http from '@/common/js/http.js';
import { $lang } from '@/common/js/lang.js';
export default {
data() {
return {
recommendInfo: null, // 后端原始响应 data
recommendLoading: false,
recommendError: '',
_lastFetchTs: 0, // 防抖:同时多次 mounted 不重复请求
};
},
computed: {
// 余额(可提现 + 冻结 + 累计收益)
balance() {
return (this.recommendInfo && this.recommendInfo.balance) || {};
},
// 累计统计
statistics() {
return (this.recommendInfo && this.recommendInfo.statistics) || {};
},
// 推荐码
code() {
return (this.recommendInfo && this.recommendInfo.code) || {};
},
// 考察期
probation() {
return (this.recommendInfo && this.recommendInfo.probation) || {};
},
// 最近推荐
recentRecords() {
return (this.recommendInfo && this.recommendInfo.recent_records) || [];
},
// 奖励规则
rewardRules() {
return (this.recommendInfo && this.recommendInfo.reward_rules) || [];
},
// 是否登录(可避免显示错误的余额)
hasRecommendData() {
return !!this.recommendInfo;
},
},
mounted() {
this.fetchRecommendInfo();
},
methods: {
// 异步拉首页聚合数据(去抖,每 10s 最多一次)
fetchRecommendInfo(force = false) {
const now = Date.now();
if (!force && now - this._lastFetchTs < 10000) {
return;
}
this._lastFetchTs = now;
this.recommendLoading = true;
this.recommendError = '';
// 通过 http 通用方法走 token(与全局其他请求一致)
http.get('/recommend/api/home/homeSummary').then(res => {
this.recommendLoading = false;
if (res && res.code === 0 && res.data) {
this.recommendInfo = res.data;
} else {
// 未登录或 token 失败,留 recommendInfo=null 让模板显示占位
this.recommendError = (res && res.message) || $lang ? $lang('components-diy.diy-recommend.loadFail') : '加载失败';
}
}).catch(err => {
this.recommendLoading = false;
this.recommendError = (err && (err.errMsg || err.message)) || 'network error';
});
},
// 刷新(用户下拉触发)
refreshRecommendInfo() {
return this.fetchRecommendInfo(true);
},
// 数字格式化工具
formatMoney(n) {
if (n == null || n === '') return '0';
const num = parseFloat(n);
if (isNaN(num)) return '0';
return num.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
},
},
};

View File

@@ -33,16 +33,7 @@ function useNativeLoading() {
// #endif
}
// 总兜底超时档位 (show 时可覆盖):
// 12000 (12s): 默认档, 启动体验优先 (大多数网络环境够用, 略比 10s 宽松)
// 17000 (17s): 中档, 慢网/冷启动 (1s+ 启动 + 3-5s i18n 远端 + 1-3s diy 拉取 + 缓冲)
// 22000 (22s): 慢网档, 极弱网环境 (1s+ 启动 + 5-8s i18n 远端 + 3-5s diy 拉取 + 缓冲)
// 低于 12000 不推荐: 用户感知的"启动卡"窗口期不够, 但业务方仍可传 < 12000 测试用
// 业务方覆盖: splashState.show({ timeout: 17000 })
// 注意: DEFAULT_TIMEOUT 仍是兜底 (无 opts.timeout), 不能被业务方传更低
// 严防因为 timeout 太短导致用户看到白屏.
// 设计思路: 各档位比"刚好够用"多 2s 缓冲, 弱网/慢网络环境兜底更稳
const DEFAULT_TIMEOUT = 12000; // 总兜底超时 (默认 12s)
const DEFAULT_TIMEOUT = 8000; // 总兜底超时
const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
const PAGE_READY_GRACE_MS = 2000; // 软隐藏后等页面就绪的宽限时间
@@ -80,111 +71,6 @@ 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 = {}) {
@@ -225,34 +111,7 @@ class SplashState {
try { uni.showLoading({ title: opts.title || '加载中...', mask: false }); } catch (e) {}
}
// 业务方可通过 opts.timeout 覆盖, 但不能低于 5000ms (低于此值给 warn, 仍生效)
// 原因: 启动期 i18n/theme/remote-config 远端加载最少 1-2s, diy 加载 1-3s
// timeout 低于 5s 会经常触发兜底强关, 用户看到白屏
// 测试场景允许传 < 5000ms (因为 mock 环境快), 仍生效但 warn
// 优先级: opts.timeout (业务方显式) > _pickTimeoutByNetwork() (自动选档) > DEFAULT_TIMEOUT
const MIN_RECOMMENDED_TIMEOUT = 5000
let timeout
if (opts.timeout) {
timeout = opts.timeout
if (opts.timeout < MIN_RECOMMENDED_TIMEOUT) {
console.warn(
`[splash-state] timeout=${opts.timeout}ms 低于推荐下限 ${MIN_RECOMMENDED_TIMEOUT}ms, ` +
`可能导致启动期白屏. 生产环境建议 10000-20000ms.`
)
}
} else {
// 自动选档: 根据当前网络环境选 timeout
timeout = this._pickTimeoutByNetwork()
}
this._armTimeout(timeout);
}
/**
* 设置/重设 splash 总超时兜底
* - 由 show() 初始化, 或 _updateNetworkType() 在网络变化时调用
* - 关键: 网络从好→差时延长, 差→好时缩短, 保证用户不会过早/过晚看到白屏
*/
_armTimeout(timeout) {
const timeout = opts.timeout || DEFAULT_TIMEOUT;
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
@@ -266,55 +125,6 @@ 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
@@ -326,22 +136,15 @@ 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 = useStrict || opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
// strict 模式复用 fast 动画
const mode = useStrict ? 'fast' : (opts.mode === 'fast' ? 'fast' : 'default');
const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
const mode = opts.mode === 'fast' ? 'fast' : 'default';
if (useSoft) {
// 传递完整 opts, _softHide 内部能识别 mode='strict' 或 _strict
this._softHide(opts);
this._softHide({ reason, mode });
} else {
this._doHide({ reason, mode });
}
@@ -351,17 +154,10 @@ 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) {
@@ -369,12 +165,6 @@ class SplashState {
return;
}
// strict 模式: 不设 mountGraceId 兜底, 只等 notifyPageReady
// (DEFAULT_TIMEOUT 在 show() 设置的 _timeoutId 兜底)
if (useStrict) {
return;
}
// 兜底: 1s 内没 mount, 强制隐藏
if (this._mountGraceId) clearTimeout(this._mountGraceId);
this._mountGraceId = setTimeout(() => {
@@ -387,27 +177,15 @@ 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(() => {
@@ -426,11 +204,6 @@ 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; // 防重入
@@ -452,8 +225,6 @@ 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',
@@ -493,60 +264,6 @@ 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,10 +71,6 @@ 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',// 分类项名称
@@ -268,10 +264,6 @@ 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',// 分类项名称
@@ -464,10 +456,6 @@ 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',// 分类项名称
@@ -660,10 +648,6 @@ 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',// 分类项名称
@@ -856,10 +840,6 @@ 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',// 分类项名称
@@ -1052,10 +1032,6 @@ 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',// 分类项名称
@@ -1248,10 +1224,6 @@ 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',// 分类项名称
@@ -1444,10 +1416,6 @@ export default {
...{
diy_category_tree_select_size: '28rpx',// 左侧分类树选中态字号
diy_category_tree_normal_size: '20rpx',// 左侧分类树未选中态字号
diy_category_tree_select_text_color: '#333',// 左侧分类树选中态字体颜色(后台可自定义)
diy_category_tree_select_bg_color: '#fff',// 左侧分类树选中态背景颜色(后台可自定义)
diy_category_tree_normal_text_color: '#222222',// 左侧分类树未选中态字体颜色(后台可自定义)
diy_category_tree_normal_bg_color: '#f5f5f5',// 左侧分类树未选中态背景颜色(后台可自定义)
diy_category_item_title_size: '26rpx',// 分类标题(如"热销推荐")
diy_category_item_goods_title_size:'28rpx',// 商品模式分类标题(比普通标题大 2rpx)
diy_category_item_name_size: '24rpx',// 分类项名称

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,6 @@
<template>
<view data-component-name="diy-category" :class="['category-page-wrap', 'category-template-' + value.template]"
:style="{ height: 'calc(100vh - var(--window-top, 0px) - ' + tabBarHeight + ')' }">
<!-- 高度说明: 100vh - H5顶部导航栏(--window-top, 小程序为0) - 底部导航高度(tabBarHeight),
保证底部购物车结算栏始终落在可视区内, 点击加购后无需向下翻即可看到 -->
<!-- 模板4: 顶部搜索框(图片样式) -->
<view class="search-box search-box-template-4" v-if="value.template == 4 && value.search"
@tap.stop="onClickSearch()">
<view class="search-content">
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')"
confirm-type="search" @tap.stop="onClickSearch()" disabled="true" />
<text class="iconfont icon-sousuo3"></text>
</view>
</view>
:style="{ height: 'calc(100vh - ' + tabBarHeight + ')' }">
<!-- #ifdef MP-WEIXIN -->
<!-- <block v-if="value.template == 4">
<view class="search-box" v-if="value.search" @tap.stop="$util.redirectTo('/pages_tool/goods/search')" :style="navbarInnerStyle">
@@ -39,7 +28,7 @@
<!-- #endif -->
<!-- #ifdef H5 || MP-HARMONY -->
<view class="search-box" v-if="value.search && value.template != 4" @tap.stop="onClickSearch()">
<view class="search-box" v-if="value.search" @tap.stop="onClickSearch()">
<view class="search-content">
<input type="text" class="uni-input" maxlength="50" :placeholder="$lang('components-diy.diy-search.searchBtn')" confirm-type="search"
@tap.stop="onClickSearch()" disabled="true" />
@@ -95,39 +84,25 @@
<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="n"></view>
<view class="skeleton-tree-item" v-for="n in 8" :key="'sk-tree-' + n"></view>
</view>
<view class="skeleton-grid">
<view class="skeleton-card" v-for="n in 6" :key="n"></view>
<view class="skeleton-card" v-for="n in 6" :key="'sk-card-' + n"></view>
</view>
</view>
</view>
<view class="content-box" v-if="categoryTree">
<view class="content-box" v-else-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="index" :class="[
{ select: oneCategorySelect == index },
{ 'border-bottom': oneCategorySelect + 1 === index },
{ 'border-top': oneCategorySelect - 1 === index }
]" @tap.stop="templateFourOneFn(index)">
<view class="">{{ item.category_name }}</view>
</view>
</view>
<!-- 其他模板: 左侧显示二级分类 -->
<view v-else>
<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 },
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1) }
{ 'border-top': value.template == 4 && select - 1 === index }
]" @tap.stop="switchOneCategory(index)">
<view class="">{{ item.category_name }}</view>
</view>
</view>
</view>
</scroll-view>
<view class="right-flex-wrap">
@@ -143,20 +118,7 @@
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</scroll-view>
<!-- 模板1: 仿京东/淘宝商品分类 - 按 template 2 的 part-load 逻辑, 切换一级分类时只渲染选中分类的子分类网格 -->
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1"
:refresher-enabled="true" refresher-default-style="none"
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</view>
</scroll-view>
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 1 || value.template == 99 || loadType == 'all'"
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
refresher-default-style="none" :refresher-triggered="triggered" @refresherrefresh="onRefresh"
@@ -166,7 +128,6 @@
<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>
@@ -180,7 +141,6 @@
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:last="index == categoryTree.length - 1 ? true : false" :select="select"
:oneCategorySelect="oneCategorySelect" :isList="isList" @tologin="toLogin"
:currentOneCategory="value.template == 4 && templateFourData[oneCategorySelect] ? templateFourData[oneCategorySelect] : null"
@selectsku="selectSku($event, index)" @addCart="addCartPoint"
@switch="switchOneCategory"></diy-category-item>
</view>
@@ -193,19 +153,9 @@
</view>
</view>
<!-- 风格2/模板4: 底部购物车栏
1) 不用 position:fixed: 小程序/H5/鸿蒙下 fixed 受页面避让与父级 transform 影响,
会"跟在内容后面"只有滚到底才看得见。
.category-page-wrap 是固定高度(calc(100vh - tabBarHeight))的纵向 flex 容器,
content-box 为 flex:1 height:0, 把结算栏作为普通子项排在末尾,
无论右侧商品列表怎么滚动, 结算栏始终固定显示在底部
2) cartNumber > 0: 只在购物车里有商品时才出现(点加购即出现, 减到 0 即消失),
商品加购后会立即 dispatch cartCalculate 同步 cartNumber, 不依赖接口回调 -->
<view class="cart-box cart-box-template-4"
v-if="(value.template == 2 || value.template == 4) && showCartSettlement && storeToken && cartNumber > 0 && categoryTree && categoryTree.length">
<!-- <view class="cart-box" v-if="(value.template == 2 || value.template == 4) && value.quickBuy && storeToken && categoryTree && categoryTree.length">
<view class="left-wrap">
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation"
@tap.stop="$util.redirectTo('/pages_goods/cart')">
<view class="cart-icon" ref="cartIcon" :animation="cartAnimation" @tap.stop="$util.redirectTo('/pages_goods/cart')">
<text class="iconfont icon-ziyuan1"></text>
<view class="num" v-if="cartNumber">{{ cartNumber < 99 ? cartNumber : '99+' }}</view>
</view>
@@ -216,10 +166,9 @@
<text class="unit font-size-tag price-font">.{{ cartTotalMoney[1] ? cartTotalMoney[1] : '00' }}</text>
</view>
</view>
<view class="right-wrap"><button type="primary" class="settlement-btn"
@tap.stop="settlement">去结算</button>
</view>
<view class="right-wrap"><button type="primary" class="settlement-btn" @tap.stop="settlement">去结算</button>
</view>
</view> -->
<view class="cart-point" v-for="(item, index) in carIconList" :key="index"
:style="{ left: item.left + 'px', top: item.top + 'px' }"></view>
@@ -274,17 +223,12 @@ export default {
// 修复体验: 进入页面初期显示 "暂未分类" 然后又变成有数据的体验很差
// 用 categoryTreeLoading 区分"还在请求"和"真的没分类", 请求未完成时
// 显示骨架占位而非空态文案
categoryTreeLoading: true,
categoryTreeLoading: true
};
},
created() {
this.getCategoryTree();
this.loadType = this.value.goodsLevel == 1 && this.value.loadType == 'all' ? 'all' : 'part';
// 模板2/4: 默认单列列表布局(左图右文卡片), 点右上角图标切换为双列宫格
if (this.value.template == 2 || this.value.template == 4) {
this.isList = true;
}
},
mounted() {
query = uni.createSelectorQuery().in(this);
@@ -312,34 +256,13 @@ export default {
},
watch: {
// 组件刷新监听
componentRefresh: function (nval) { },
categoryTreeLoading: function (nval, oval) {
if (nval === false && oval === true) {
this.$nextTick(() => {
if (!this.heightArea.length) this.getHeightArea(-1);
});
}
}
componentRefresh: function (nval) { }
},
computed: {
cartTotalMoney() {
let money = parseFloat(this.cartMoney).toFixed(2);
return money.split('.');
},
// 后台开关:快捷购买(显示购物车及结算按钮) = quickBuy; 加购按钮 = cartBtn
// 后台开启哪个就显示哪个, 关闭哪个就不显示哪个; 未下发 cartBtn 时兼容旧字段 quickBuy
showCartSettlement() {
const t = this.getDiyToggle(['showCartSettlement', 'show_cart_settlement']);
if (t !== undefined) return t;
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
return legacy !== undefined ? legacy : false;
},
showAddCart() {
const t = this.getDiyToggle(['showAddCart', 'show_add_cart', 'cartBtn', 'cart_btn']);
if (t !== undefined) return t;
const legacy = this.getDiyToggle(['quickBuy', 'quick_buy']);
return legacy !== undefined ? legacy : false;
},
// 转换字符数值为真正的数值
navbarHeight() {
// #ifdef APP-PLUS || H5
@@ -407,7 +330,6 @@ export default {
this.$store.dispatch('getCartNumber');
if (!this.heightArea.length) this.getHeightArea(-1);
},
/**
* 获取高度区间
*
@@ -493,16 +415,16 @@ export default {
});
},
/**
* 汇总所有二级分类供 template99 渲染按钮(按名称去重)
* 注意: 保留"无商品"的二级分类也显示出来, 不再按是否有商品过滤,
* 避免一级分类下 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
* 全局查询所有有商品的二级分类, 按名称去重, 汇总成虚拟分类供 template99 渲染按钮
* 只要任何商品属于该二级分类, 按钮就显示; 切换一级分类时按钮不变(一直显示)
* 新增的二级分类只要有商品也会自动包含
*/
loadGlobalSecondCategoryBtns() {
if (!this.categoryTree || !this.categoryTree.length) {
this.template99Category = { category_name: '', child_list: [] };
return;
}
// 收集所有二级分类(保留原始顺序, 不去重, 汇总后去重)
// 收集所有二级分类(保留原始顺序, 不去重, 查询所有同名后再去重)
const allSecond = [];
this.categoryTree.forEach(one => {
if (one.child_list && one.child_list.length) {
@@ -515,10 +437,16 @@ export default {
this.template99Category = { category_name: '', child_list: [] };
return;
}
// 按名称去重, 同名只保留第一个, 保持原始顺序
let pending = allSecond.length;
const results = [];
const finishOne = () => {
pending--;
if (pending <= 0) {
results.sort((a, b) => a._origIndex - b._origIndex);
// 按名称去重, 同名只保留第一个有商品的(只要任一同名有商品就显示)
const deduped = [];
const seenNames = {};
allSecond.forEach(item => {
results.forEach(item => {
if (!seenNames[item.category_name]) {
seenNames[item.category_name] = true;
deduped.push(item);
@@ -528,6 +456,26 @@ export default {
category_name: '',
child_list: deduped
};
}
};
allSecond.forEach(two => {
this.$api.sendRequest({
url: '/api/goodssku/pageByCategory',
data: {
category_id: two.category_id,
page: 1,
page_size: 1
},
success: res => {
if (res && res.code == 0 && res.data && res.data.list && res.data.list.length > 0) {
results.push(two);
}
},
complete: () => {
finishOne();
}
});
});
},
/**
* 切换一级分类
@@ -649,8 +597,7 @@ export default {
* @param {Object} top
*/
addCartPoint(left, top) {
if (this.value.template != 2 && this.value.template != 4) return;
if (!this.showCartSettlement) return;
if (this.value.template != 2 && !this.value.quickBuy) return;
let key = new Date().getTime();
this.$set(this.carIconList, key, {
@@ -741,7 +688,6 @@ export default {
flex: 1;
height: 0;
display: flex;
align-items: flex-start;
.tree-wrap {
width: 170rpx;
@@ -782,10 +728,10 @@ export default {
padding: 26rpx 28rpx;
box-sizing: border-box;
position: relative;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
background-color: #f5f5f5;
view {
color: var(--diy-category-tree-normal-text-color, #222222);
color: #222222;
width: 100%;
white-space: break-word;
overflow: hidden;
@@ -802,10 +748,10 @@ export default {
}
&.select {
background: var(--diy-category-tree-select-bg-color, #fff);
background: #fff;
view {
color: var(--diy-category-tree-select-text-color, #333);
color: #333;
font-weight: bold;
font-size: var(--diy-category-tree-select-size, 28rpx);
}
@@ -1059,8 +1005,6 @@ export default {
}
}
@keyframes category-skeleton-shimmer {
0% {
background-position: 100% 50%;
@@ -1072,144 +1016,17 @@ export default {
// 风格四
.category-template-4 {
// 模板4 顶部搜索框(图片样式)
.search-box-template-4 {
padding: 20rpx 30rpx 16rpx;
background: #fff;
.search-content {
height: 64rpx;
background-color: #f5f5f5;
border-radius: 40rpx;
input {
height: 64rpx;
line-height: 64rpx;
padding: 0 30rpx 0 30rpx;
background: #f5f5f5;
border-radius: 40rpx;
font-size: 26rpx;
color: #333;
}
.iconfont {
font-size: 32rpx;
color: #89899a;
width: 70rpx;
text-align: center;
right: 10rpx;
}
}
}
// 模板4 底部购物车栏(图片样式)
.cart-box-template-4 {
height: 110rpx;
width: 100%;
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #f0f0f0;
position: relative;
z-index: 10;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
.left-wrap {
display: flex;
align-items: center;
}
.cart-icon {
width: 90rpx;
height: 90rpx;
position: relative;
margin-top: -10rpx;
.iconfont {
color: var(--btn-text-color, #fff);
width: 80rpx;
height: 80rpx;
background-color: $base-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
}
.num {
position: absolute;
top: 0;
right: 0;
transform: translate(20%, 0);
display: inline-block;
box-sizing: border-box;
color: #fff;
line-height: 1.2;
text-align: center;
font-size: 24rpx;
padding: 0 8rpx;
min-width: 32rpx;
border-radius: 18rpx;
background-color: var(--price-color, #FF6B35);
border: 2rpx solid #fff;
}
}
.price {
margin-left: 16rpx;
display: flex;
align-items: baseline;
line-height: 1;
.title {
color: #333;
font-size: 28rpx;
}
.unit,
.money {
color: var(--price-color, #FF6B35);
font-weight: bold;
}
.unit {
font-size: 24rpx;
}
.money {
font-size: 36rpx;
}
}
.right-wrap {
display: flex;
align-items: center;
}
.settlement-btn {
margin: 0;
width: 220rpx;
font-weight: bold;
border-radius: 50rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
background: $base-color;
color: var(--btn-text-color, #fff);
&::after {
border: none;
}
}
}
.search-box {
.search-content input {
background-color: #f1f1f1;
}
}
.cart-box {
position: relative;
z-index: 2;
}
::v-deep .template-four {
position: relative;
z-index: 1;
@@ -1416,111 +1233,6 @@ export default {
}
}
// 风格2 复用模板4 的底部购物车栏样式
.category-template-2 {
// 模板4 底部购物车栏(图片样式)
.cart-box-template-4 {
height: 110rpx;
width: 100%;
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #f0f0f0;
position: relative;
z-index: 10;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.04);
.left-wrap {
display: flex;
align-items: center;
}
.cart-icon {
width: 90rpx;
height: 90rpx;
position: relative;
margin-top: -10rpx;
.iconfont {
color: var(--btn-text-color, #fff);
width: 80rpx;
height: 80rpx;
background-color: $base-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.35);
}
.num {
position: absolute;
top: 0;
right: 0;
transform: translate(20%, 0);
display: inline-block;
box-sizing: border-box;
color: #fff;
line-height: 1.2;
text-align: center;
font-size: 24rpx;
padding: 0 8rpx;
min-width: 32rpx;
border-radius: 18rpx;
background-color: var(--price-color, #FF6B35);
border: 2rpx solid #fff;
}
}
.price {
margin-left: 16rpx;
display: flex;
align-items: baseline;
line-height: 1;
.title {
color: #333;
font-size: 28rpx;
}
.unit,
.money {
color: var(--price-color, #FF6B35);
font-weight: bold;
}
.unit {
font-size: 24rpx;
}
.money {
font-size: 36rpx;
}
}
.right-wrap {
display: flex;
align-items: center;
}
.settlement-btn {
margin: 0;
width: 220rpx;
font-weight: bold;
border-radius: 50rpx;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
background: $base-color;
color: var(--btn-text-color, #fff);
&::after {
border: none;
}
}
}
}
.content-wrap .categoty-goods-wrap .goods-list {
margin-top: 30rpx;
}
@@ -1531,149 +1243,6 @@ export default {
}
}
// ===================== 模板1 仿京东/淘宝商品分类样式 =====================
.category-template-1 {
.search-box {
padding: 20rpx 24rpx;
background: #fff;
.search-content {
background-color: #f5f5f5;
border-radius: 40rpx;
input {
background: #f5f5f5;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 26rpx;
color: #333;
}
.iconfont {
right: 16rpx;
color: #89899a;
font-size: 32rpx;
}
}
}
.content-box {
// 模板1 + 三级分类: 标题行 (离型纸 ----) 所在整片右半边需要去掉白色,
// 把 .content-box 也改为透明, 让右侧不再被强制白; 再让 .right-flex-wrap 显式
// 染 #f5f5f5, 与左侧 tree-wrap 默认色一致, 标题行只显示文字+短横线.
background: transparent;
.tree-wrap {
width: 170rpx;
background-color: #fff;
}
.right-flex-wrap {
background: #f5f5f5;
.content-wrap {
background: transparent;
}
}
}
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
::v-deep .category-adv {
padding: 0 20rpx;
margin-top: 0;
}
::v-deep .category-list {
margin: 0 20rpx;
border-radius: 0;
background: transparent;
padding: 0;
}
// 穿透到子组件 diy-category-item: 模板1标题行定高, 与左侧第一个一级分类等高对齐
::v-deep .category-title-with-lines {
height: 100rpx;
box-sizing: border-box;
padding: 30rpx 0 20rpx;
}
// 模板1: 左侧第一个一级分类定高, 与右侧标题行(如"离型纸 ----")垂直对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
overflow: hidden;
view {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 穿透修复: 图片不需要灰色背景框,与目标图保持一致
::v-deep .img-box {
background: transparent !important;
border-radius: 0 !important;
width: 70% !important;
padding-bottom: 70% !important;
margin-bottom: 16rpx !important;
}
::v-deep .img-box image {
width: 100% !important;
height: 100% !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
}
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
&.border-top,
&.border-bottom {
border-radius: 0;
}
&.select {
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
&::before {
content: ' ';
width: 6rpx;
height: 32rpx;
background: var(--base-color, #FF6B35);
display: block;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 0 6rpx 6rpx 0;
}
}
}
}
// ===================== 模板99 商品分类样式 =====================
.category-template-99 {
.search-box {
@@ -1726,22 +1295,19 @@ export default {
.tree-wrap .category-item {
padding: 30rpx 16rpx;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
white-space: normal;
word-break: break-all;
background-color: #f5f5f5;
view {
color: var(--diy-category-tree-normal-text-color, #222222);
color: #333;
font-size: 26rpx;
line-height: 1.5;
line-height: 1.3;
}
&.select {
background: var(--diy-category-tree-select-bg-color, #fff);
border-bottom: 1rpx solid #f0f0f0;
background: #fff;
view {
color: var(--diy-category-tree-select-text-color, #333);
color: #FF6B35;
font-weight: bold;
font-size: 28rpx;
}
@@ -1750,7 +1316,7 @@ export default {
content: ' ';
width: 6rpx;
height: 36rpx;
background: #FF4646;
background: #FF6B35;
display: block;
position: absolute;
left: 0;
@@ -1761,32 +1327,9 @@ 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;
// #ifdef H5
::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
background: transparent;
}
// #endif
}
.child-category {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<template>
<view data-component-name="diy-recommend-action" class="diy-recommend-action" :style="warpCss">
<!-- 主按钮:立即提现 -->
<view class="primary-btn" hover-class="btn-hover" @tap="goWithdraw">
<text>{{ $lang('components-diy.diy-recommend-action.withdrawNow') }}</text>
</view>
<!-- 副按钮:提现记录(横排两列) -->
<view class="secondary-row">
<view class="secondary-btn" hover-class="btn-hover" @tap="goWithdrawList">
<text class="iconfont icon-calendar"></text>
<text>{{ $lang('components-diy.diy-recommend-action.withdrawList') }}</text>
</view>
<view class="secondary-btn" hover-class="btn-hover" @tap="goApply">
<text class="iconfont icon-edit"></text>
<text>{{ $lang('components-diy.diy-recommend-action.applyEnterprise') }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'diy-recommend-action',
computed: {
warpCss() {
return {
padding: '24rpx',
backgroundColor: '#fff',
};
},
},
methods: {
goWithdraw() {
uni.navigateTo({ url: '/pages_recommend/withdraw/apply' });
},
goWithdrawList() {
uni.navigateTo({ url: '/pages_recommend/withdraw/list' });
},
goApply() {
uni.navigateTo({ url: '/pages_recommend/apply/apply' });
},
},
};
</script>
<style lang="scss" scoped>
.diy-recommend-action {
.primary-btn {
background: linear-gradient(135deg, #1976d2, #0d47a1);
color: #fff;
text-align: center;
border-radius: 50rpx;
padding: 24rpx 0;
font-size: 32rpx;
font-weight: 600;
letter-spacing: 4rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(25,118,210,0.32);
}
.secondary-row {
display: flex;
gap: 16rpx;
}
.secondary-btn {
flex: 1;
background: #f5f5f5;
color: #444;
text-align: center;
border-radius: 12rpx;
padding: 20rpx 0;
font-size: 26rpx;
.iconfont {
margin-right: 8rpx;
color: #1976d2;
}
}
}
.btn-hover {
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<view data-component-name="diy-recommend-balance" class="diy-recommend-balance" :style="warpCss">
<view class="balance-card">
<view class="balance-row">
<view class="balance-half available">
<text class="label">{{ $lang('components-diy.diy-recommend-balance.available') }}</text>
<text class="big-num price-font">¥{{ formatMoney(balance.available) }}</text>
<text class="sub-tip" v-if="balance.available > 0 && probation.total_days">
{{ $lang('components-diy.diy-recommend-balance.passedProbation') }}
</text>
</view>
<view class="balance-half probation">
<text class="label">{{ $lang('components-diy.diy-recommend-balance.frozen') }}</text>
<text class="big-num price-font">¥{{ formatMoney(balance.frozen) }}</text>
<text class="sub-tip" v-if="probation.remaining_days > 0">
{{ $lang('components-diy.diy-recommend-balance.probationDays', { days: probation.remaining_days }) }}
</text>
</view>
</view>
<!-- 累计总收益(底部) -->
<view class="total-row">
<text class="label">{{ $lang('components-diy.diy-recommend-balance.totalEarned') }}</text>
<text class="big-num price-font">¥{{ formatMoney(balance.total_earned) }}</text>
</view>
</view>
</view>
</template>
<script>
import recommendData from '@/common/js/recommend-diy-data.js';
export default {
name: 'diy-recommend-balance',
mixins: [recommendData],
computed: {
warpCss() {
return {
padding: '24rpx',
backgroundColor: '#fff',
};
},
},
};
</script>
<style lang="scss" scoped>
.diy-recommend-balance {
.balance-card {
background: linear-gradient(180deg, #d9f2ff, #f0f9ff);
border-radius: 16rpx;
padding: 32rpx 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0,102,204,0.08);
}
.balance-row {
display: flex;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 24rpx;
}
.balance-half {
flex: 1;
display: flex;
flex-direction: column;
padding: 16rpx 20rpx;
border-radius: 12rpx;
background: rgba(255,255,255,0.65);
&.available {
.big-num { color: #1976d2; }
}
&.probation {
background: rgba(255,243,224,0.85);
.big-num { color: #e65100; }
}
.label {
font-size: 24rpx;
color: #666;
}
.big-num {
font-size: 44rpx;
font-weight: 700;
margin: 8rpx 0;
}
.sub-tip {
font-size: 22rpx;
color: #888;
}
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1rpx solid #e0e0e0;
padding-top: 16rpx;
.label {
font-size: 26rpx;
color: #444;
}
.big-num {
font-size: 36rpx;
font-weight: 600;
color: #d81b60;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
<template>
<view data-component-name="diy-recommend-records" class="diy-recommend-records" :style="warpCss">
<view class="header-row">
<text class="title">{{ $lang('components-diy.diy-recommend-records.recent') }}</text>
<text class="link" @tap="goAllRecords">{{ $lang('components-diy.diy-recommend-records.viewAll') }} ></text>
</view>
<view v-if="recentRecords.length === 0" class="empty">
<text>{{ $lang('components-diy.diy-recommend-records.empty') }}</text>
</view>
<view v-else class="record-list">
<view v-for="(r, i) in recentRecords" :key="r.id || i" class="record-item">
<view class="left">
<view class="avatar"></view>
<view class="info">
<text class="name">{{ r.company_name || r.id }}</text>
<text class="time">{{ formatTime(r.apply_time) }}</text>
</view>
</view>
<view class="right">
<text class="amount price-font" v-if="r.amount_text">{{ r.amount_text }}</text>
<view class="status-tag" :class="'status-' + r.status">{{ r.status_text }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
import recommendData from '@/common/js/recommend-diy-data.js';
export default {
name: 'diy-recommend-records',
mixins: [recommendData],
computed: {
warpCss() {
return {
padding: '16rpx 24rpx 24rpx',
backgroundColor: '#fff',
};
},
},
methods: {
goAllRecords() {
uni.navigateTo({ url: '/pages_recommend/records/list' });
},
formatTime(ts) {
if (!ts) return '';
const d = new Date(parseInt(ts) * 1000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
},
},
};
</script>
<style lang="scss" scoped>
.diy-recommend-records {
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.link {
font-size: 24rpx;
color: #999;
}
}
.empty {
padding: 40rpx;
text-align: center;
color: #999;
font-size: 24rpx;
}
.record-list {
.record-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child { border-bottom: none; }
}
.left {
display: flex;
align-items: center;
flex: 1;
overflow: hidden;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, #bbdefb, #64b5f6);
margin-right: 16rpx;
flex-shrink: 0;
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-size: 26rpx;
color: #333;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
.time {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
.right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6rpx;
}
.amount {
font-size: 30rpx;
font-weight: 700;
color: #e65100;
}
.status-tag {
font-size: 22rpx;
padding: 4rpx 14rpx;
border-radius: 16rpx;
background: #f5f5f5;
color: #666;
&.status-0 { background: #fff3e0; color: #e65100; } // 待审核
&.status-1 { background: #e3f2fd; color: #1976d2; } // 审核通过(待缴费)
&.status-2 { background: #e8f5e9; color: #4caf50; } // 审核通过(已缴费)
&.status-3 { background: #ffebee; color: #c62828; } // 审核不通过
}
}
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<view data-component-name="diy-recommend-rules" class="diy-recommend-rules" :style="warpCss">
<view class="header-row">
<text class="iconfont icon-book"></text>
<text class="title">{{ $lang('components-diy.diy-recommend-rules.title') }}</text>
</view>
<view class="rules-list">
<view v-for="(r, i) in displayRules" :key="i" class="rule-row">
<view class="rule-icon" :class="'icon-' + (r.icon || 'money')">
<text class="iconfont" :class="iconFontClass(r.icon)"></text>
</view>
<view class="rule-text">{{ r.title || r.text }}</view>
</view>
</view>
</view>
</template>
<script>
import recommendData from '@/common/js/recommend-diy-data.js';
export default {
name: 'diy-recommend-rules',
mixins: [recommendData],
computed: {
// 可被后台 value.rules 覆盖,默认用 API 返回
displayRules() {
if (this.value && Array.isArray(this.value.rules) && this.value.rules.length) {
return this.value.rules;
}
return this.rewardRules || [];
},
warpCss() {
return {
padding: '24rpx',
backgroundColor: '#fff',
};
},
},
methods: {
iconFontClass(icon) {
const map = {
money: 'icon-money',
calendar: 'icon-calendar',
wallet: 'icon-wallet',
infinite: 'icon-infinite',
};
return map[icon] || 'icon-money';
},
},
};
</script>
<style lang="scss" scoped>
.diy-recommend-rules {
.header-row {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.iconfont {
font-size: 32rpx;
color: #1976d2;
margin-right: 8rpx;
}
.title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
.rules-list {
background: #f3f9ff;
border-radius: 12rpx;
padding: 16rpx 20rpx;
}
.rule-row {
display: flex;
align-items: flex-start;
padding: 12rpx 0;
border-bottom: 1rpx dashed #d6e9f8;
&:last-child { border-bottom: none; }
}
.rule-icon {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: #1976d2;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
margin-right: 16rpx;
flex-shrink: 0;
.iconfont { font-size: 24rpx; }
}
.rule-text {
font-size: 24rpx;
color: #444;
line-height: 1.5;
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view data-component-name="diy-recommend-stat" class="diy-recommend-stat" :style="warpCss">
<view class="stat-row">
<view class="stat-item">
<text class="num price-font">{{ statistics.total_recommend || 0 }}</text>
<text class="label">{{ $lang('components-diy.diy-recommend-stat.totalRecommend') }}</text>
</view>
<view class="stat-item">
<text class="num price-font color-green">{{ statistics.total_entered || 0 }}</text>
<text class="label">{{ $lang('components-diy.diy-recommend-stat.totalEntered') }}</text>
</view>
<view class="stat-item">
<text class="num price-font color-orange">¥{{ formatMoney(statistics.total_earned) }}</text>
<text class="label">{{ $lang('components-diy.diy-recommend-stat.totalEarned') }}</text>
</view>
</view>
</view>
</template>
<script>
import recommendData from '@/common/js/recommend-diy-data.js';
export default {
name: 'diy-recommend-stat',
mixins: [recommendData],
computed: {
warpCss() {
return {
padding: '24rpx',
backgroundColor: '#fff',
};
},
},
};
</script>
<style lang="scss" scoped>
.diy-recommend-stat {
.stat-row {
display: flex;
background: #fff;
border-radius: 12rpx;
padding: 24rpx 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1rpx solid #f0f0f0;
&:last-child { border-right: none; }
}
.num {
font-size: 36rpx;
font-weight: 700;
color: #333;
&.color-green { color: #4caf50; }
&.color-orange { color: #ff9800; }
}
.label {
font-size: 22rpx;
color: #888;
margin-top: 6rpx;
}
}
}
</style>

View File

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

View File

@@ -55,7 +55,6 @@
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
<view class="video-container" :style="popupVideoStyle">
<video
:key="video_url"
ref="popupVideoRef"
class="adaptive-video"
:autoPauseIfNavigate="true"
@@ -66,9 +65,6 @@
:src="video_url"
:controls="true"
object-fit="contain"
playsinline
webkit-playsinline
x5-playsinline
@loadedmetadata="onPopupVideoLoaded"
@error="onPopupVideoError"
></video>
@@ -148,21 +144,41 @@ export default {
// 弹窗视频容器样式
popupVideoStyle() {
// 容器横向铺满,黑色背景填满左右边界
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
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
return style;
}
},
methods: {
// 显示视频播放弹窗
showVideo(item) {
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.video_url = item.videoUrl;
this.popupVideoWidth = 0;
this.popupVideoHeight = 0;
this.$refs.videoPopup.open();
@@ -609,6 +625,22 @@ 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,9 +11,6 @@
:style="videoWarpCss"
controls
preload="metadata"
playsinline
webkit-playsinline
x5-playsinline
@loadedmetadata="onVideoLoaded"
@click="handlerClick(videoUrl)"
></video>
@@ -109,11 +106,11 @@ export default {
}
// #ifdef H5
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-WEIXIN
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
style += 'padding-top: ' + (ratio * 100) + '%;';
// #endif
// #ifdef MP-HARMONY

View File

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

View File

@@ -743,53 +743,7 @@ 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

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

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,9 +67,6 @@
}
},
mounted() {
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
if (!this.config.text) this.config.text = this.$lang('buyNow');
if (this.auto) {
this.getLikeList();
}

View File

@@ -56,18 +56,10 @@
<!-- 手机号授权按钮 -->
<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">
@@ -75,27 +67,6 @@
</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') }}
@@ -138,20 +109,11 @@
<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>
@@ -212,11 +174,7 @@ export default {
iv: '',
encryptedData: ''
},
site_name: '',
// #ifdef MP-HARMONY
// 鸿蒙专属:控制手机号+验证码表单显示
showHarmonyMobileForm: false,
// #endif
site_name: ''
};
},
options: {
@@ -245,7 +203,7 @@ export default {
if (this.forceBindingMobileControl) {
if (this.formData.mobile.length == 0) return true;
// #ifdef H5 || MP-HARMONY
// #ifdef H5
// 验证码
if (this.isOpenCaptcha == 1 && this.formData.vercode.length == 0) return true;
// 动态码
@@ -325,69 +283,14 @@ export default {
// #ifdef MP-WEIXIN
// 真机 __usePrivacyCheck__ 开启时 getPhoneNumber 属于微信隐私接口,
// 必须先完成平台级隐私协议授权,否则点击"授权手机号"按钮会直接报
// "privacy permission is not authorized or buttonId is invalid"。
// 这里在登录链路前主动检查并弹隐私弹窗,授权完成后再继续登录。
this.ensureWxPrivacyAuthorized(() => {
this.getCode(authData => {
this.authLogin(authData, 'authOnlyLogin');
}, (err) => {
callback && callback(err);
});
});
// #endif
},
// 微信小程序: 确保平台级隐私协议已授权后再执行回调
ensureWxPrivacyAuthorized(done) {
try {
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
// 低版本基础库无隐私 API, 直接放行
done();
return;
}
wx.getPrivacySetting({
success: (res) => {
if (!res || !res.needAuthorization) {
// 已授权或无隐私指引配置, 直接放行
done();
return;
}
// 需要授权: 优先弹自建隐私弹窗(内含 agreePrivacyAuthorization 按钮,
// 点击同意即完成平台级授权); 用户同意后通过 privacy:agreed 事件继续
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
const onAgreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
done();
};
const onDisagreed = () => {
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
// 用户拒绝隐私协议: 不继续登录链路(隐私接口也不可用)
};
uni.$off('privacy:agreed', onAgreed);
uni.$off('privacy:disagreed', onDisagreed);
uni.$on('privacy:agreed', onAgreed);
uni.$on('privacy:disagreed', onDisagreed);
popup.showPrivacyPopup();
} else if (typeof wx.requirePrivacyAuthorize === 'function') {
// 无 privacy-popup 实例时退回微信官方隐私弹窗
wx.requirePrivacyAuthorize({
success: () => done(),
fail: () => { }
});
}
},
fail: () => done()
});
} catch (e) {
done();
}
},
// 跳转去登录页
toLoginH5Page(mode = 'navigateTo') {
if (this.url) this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {
@@ -440,7 +343,7 @@ export default {
openCompleteInfoPop() {
this.getRegisterConfig();
// #ifdef H5 || MP-HARMONY
// #ifdef H5
if (!this.storeToken) this.getCaptchaConfig();
// #endif
@@ -669,24 +572,8 @@ export default {
}
});
} else {
const errMsg = (e.detail && e.detail.errMsg) || '';
// #ifdef MP-WEIXIN
// 隐私协议未授权时 getPhoneNumber 会直接失败:
// "getPhoneNumber:fail privacy permission is not authorized or buttonId is invalid"
// 此时不再抛原始错误, 引导用户同意隐私弹窗后重新点授权按钮
if (errMsg.indexOf('privacy') !== -1) {
this.$util.showToast({
title: this.$lang('components.ns-login.pleaseAgreePrivacy')
});
const popup = this.$refs.privacyPopup;
if (popup && typeof popup.showPrivacyPopup === 'function') {
popup.showPrivacyPopup();
}
return;
}
// #endif
this.$util.showToast({
title: errMsg
title: e.detail.errMsg
})
}
},
@@ -789,26 +676,6 @@ 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 || MP-HARMONY
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ
menuButtonInfo = uni.getMenuButtonBoundingClientRect();
// #endif

File diff suppressed because it is too large Load Diff

View File

@@ -70,11 +70,37 @@ export default {
return {
isIphoneX: false,
payIndex: 0,
payTypeList: [],
// #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'
}
],
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,
// 预售页面判断
@@ -82,30 +108,6 @@ 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,13 +9,9 @@
<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>
@@ -26,8 +22,6 @@
</template>
<script>
import { playVideoInContext } from '@/common/js/video-helper.js';
export default {
name: 'ns-video-player-popup',
props: {
@@ -82,10 +76,6 @@ 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,16 +95,6 @@
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,13 +1,4 @@
<template>
<!-- #ifdef MP-HARMONY -->
<!--
鸿蒙元服务已接入平台隐私托管, 由鸿蒙系统自动弹出系统级隐私弹窗,
应用层不再渲染自建 privacy-popup (避免出现"多次弹出隐私弹窗"被驳回)。
此处保留空模板, 组件实例仍存在 (供其他平台 share 同一份代码),
但模板不会渲染任何内容, 鸿蒙用户看不到自建弹窗。
-->
<!-- #endif-->
<!-- #ifndef MP-HARMONY -->
<view>
<view class="privacy-mask" v-if="showPop">
<view class="privacy-wrap">
@@ -15,7 +6,7 @@
<view class="privacy-desc">
{{ i18n.descPrefix }}
<text class="privacy-link" @tap="openPrivacyContract">{{ i18n.privacyContractName }}</text>
<!-- #ifdef QUICKAPP-WEBVIEW || H5 -->
<!-- #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY -->
<text class="privacy-link" @tap="openPrivacyService">{{ i18n.privacyServiceName }}</text>
<!-- #endif -->
{{ i18n.descSuffix }}
@@ -26,15 +17,13 @@
<button id="agree-btn" class="privacy-button-btn bg-agree" open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgree">{{ i18n.agreeBtn }}</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn
}}</button>
<!-- #ifdef H5 || MP-HARMONY -->
<button id="agree-btn" class="privacy-button-btn bg-agree" @tap="handleAgree">{{ i18n.agreeBtn }}</button>
<!-- #endif -->
</view>
</view>
</view>
</view>
<!-- #endif -->
</template>
<script>
@@ -59,12 +48,6 @@ 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() 完成,
@@ -87,22 +70,10 @@ 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
@@ -114,28 +85,13 @@ 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
// 鸿蒙元服务: 已接入平台隐私托管, 隐私协议名称由鸿蒙系统管理
// 应用层不再调 uni.getPrivacySetting (避免和系统托管弹窗冲突)
// 立即 resolve, 让 popUp 的 await 不永久阻塞
this._resolvePlatformContractName && this._resolvePlatformContractName()
// #endif
// #ifdef H5
// H5 无平台隐私 API, 立即 resolve
this._resolvePlatformContractName && this._resolvePlatformContractName()
// 鸿蒙元服务: 隐私授权由系统管理, 无需应用层查询 privacyContractName
// #endif
// 提前注册 listener (晚于 created 早于 mounted, 减少事件丢失窗口)
@@ -223,14 +179,14 @@ export default {
* 统一弹窗 + 关闭其他页面的弹窗
* 通过 uni.$emit('privacy:forceClose') 通知其他实例关闭
*/
async showPrivacyPopup() {
showPrivacyPopup() {
// 先通知其他实例关闭
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
// 直接弹窗: boot 阶段已经保证 bundle 完整
// 完整性校验在 lang.js _bootImpl() 中完成(检查 defaultAppNameMp 等关键 key)
// 不完整时 force 重试, 仍不完整则报错不静默回退
// 所以这里无需再检测/自愈, 信任 boot 已就绪
await this.popUp()
this.popUp()
},
/**
@@ -281,18 +237,18 @@ export default {
// #ifdef MP-WEIXIN
if (wx.onNeedPrivacyAuthorization) {
wx.onNeedPrivacyAuthorization((resolve) => {
// 关键: 微信规定 resolve({ event: 'agree' }) 必须携带 buttonId
// (值为页面上 open-type="agreePrivacyAuthorization" 按钮的 id),
// 缺 buttonId 的 agree 会被判定非法, 被拦截的隐私接口(如 getPhoneNumber)
// 直接报 "privacy permission is not authorized or buttonId is invalid"。
// 所以无论 storage 是否标记过同意, 都必须弹自定义弹窗让用户点同意按钮。
// 检查是否已同意
if (!PrivacyManager.needShow()) {
// 已同意, 直接放行
resolve({ event: 'agree' })
} else {
// 需要弹窗, 存 resolve 等用户操作
if (typeof this.privacyAuthorization === 'function') {
// 存 resolve 等用户操作, 由 handleAgree 携带 buttonId='agree-btn' 放行
this.privacyAuthorization(resolve)
} else {
// 兜底: 组件未就绪, disagree 放行避免平台 API 永久挂起
// (disagree 无需 buttonId, 不会产生非法 resolve)
resolve({ event: 'disagree' })
// 兜底: 组件未就绪, 直接放行避免 API 卡死
resolve({ event: 'agree' })
}
}
})
}
@@ -309,8 +265,7 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 已接入平台隐私托管, 系统级弹窗由鸿蒙自动弹出
// 不再监听 uni.onPrivacyAuthorizationChange (避免和系统托管冲突)
// 鸿蒙元服务: 隐私授权由系统自动管理, 无需应用层监听
// #endif
},
@@ -329,22 +284,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);
@@ -352,8 +307,8 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
// (隐私弹窗本身不再在鸿蒙上弹出, 这里也基本不会被触发)
// 鸿蒙元服务: 使用应用内协议页面
this.$util.redirectTo(privacyContractPage);
// #endif
// #ifdef H5
@@ -365,8 +320,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);
@@ -374,7 +329,7 @@ export default {
// #endif
// #ifdef MP-HARMONY
// 鸿蒙元服务: 走平台隐私托管, 协议查看由鸿蒙系统接管
this.$util.redirectTo(privacyServicePage);
// #endif
// #ifdef H5
@@ -389,7 +344,7 @@ export default {
this.privacyResolves.clear()
// 先通知其他实例关闭, 再关闭自己, 避免叠层
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
this.disPopUp()
// 修复: 拒绝时不写 privacyAgreed, 允许下次再弹
@@ -414,7 +369,7 @@ export default {
this.privacyResolves.clear()
// 先通知其他实例关闭, 再关闭自己
try { uni.$emit('privacy:forceClose'); } catch (e) { }
try { uni.$emit('privacy:forceClose'); } catch (e) {}
this.disPopUp()
// 由 PrivacyManager 管理 storage
@@ -437,7 +392,7 @@ export default {
let appName, privacyContractName, privacyServiceName;
// 关键: appName 必须先解析为字符串, 才能作为 descPrefix/descSuffix 的参数
// #ifdef H5
// #ifdef H5 || MP-HARMONY
appName = this.$lang('components.privacyPopup.defaultAppNameH5');
privacyContractName = this.platformContractName || this.$lang('components.privacyPopup.defaultContractNameH5');
privacyServiceName = this.$lang('components.privacyPopup.defaultServiceNameH5');
@@ -461,33 +416,20 @@ export default {
return result;
},
async popUp() {
popUp() {
// 弹窗前**一次性**计算所有 i18n 文案
// 必须先等两类数据准备好:
// 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 默认文案)
}
}
// boot 阶段已用 isBundleComplete() 保证 bundle 完整
// $lang() 返回真实翻译(效果与 H5 一致)
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() {
@@ -496,11 +438,6 @@ 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>
@@ -554,7 +491,7 @@ export default {
.privacy-button-btn {
color: #FFF;
font-size: 30rpx;
/* #ifdef QUICKAPP-WEBVIEW || H5 */
/* #ifdef QUICKAPP-WEBVIEW || H5 || MP-HARMONY */
font-size: 28rpx;
/* #endif */
font-weight: 500;

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ export const lang = {
loadMoreHistory: 'Load more history',
loadingHistory: 'Loading history...',
aiThinking: 'AI is thinking',
receivingReply: 'Thinking',
receivingReply: 'Receiving reply',
aiThinkingStatus1: 'AI is thinking',
aiThinkingStatus2: 'AI is analyzing',
aiThinkingStatus3: 'AI is searching',
@@ -35,7 +35,6 @@ export const lang = {
copySelected: 'Copied selected content',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}',
@@ -136,9 +135,5 @@ export const lang = {
logEventHandleError: 'Event handling error',
logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...',
// ====== 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'}
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
}

View File

@@ -2,10 +2,10 @@ export const lang = {
// 底部选项卡
tabBar: {
home: 'Home',
category: 'Category',
cart: 'Cart',
member: 'Me',
home: 'index',
category: 'category',
cart: 'cart',
member: 'member',
},
//公共
@@ -57,7 +57,6 @@ 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',
@@ -112,7 +111,6 @@ 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
@@ -126,8 +124,6 @@ export const lang = {
copyTimeoutReason: 'Reason:',
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
// readable tip when clipboard privacy scope is not declared (errno 112)
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
copyFailed: 'Copy failed',
},
@@ -260,14 +256,7 @@ 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

@@ -0,0 +1,38 @@
/**
* 推荐系统 diy 组件 6 个组件共用 i18n 文案 (English)
* 同 zh-cn 一致,文案翻译为英文
*/
export const lang = {
'diy-recommend-code': {
myCode: 'My Referral Code',
copy: 'Copy',
copySuccess: 'Copied',
shareTip: 'Copy the link and share it with enterprises to bind',
},
'diy-recommend-balance': {
available: 'Withdrawable',
frozen: 'Frozen',
probationDays: '{days} days remaining',
passedProbation: 'Probation passed',
totalEarned: 'Total earned',
},
'diy-recommend-stat': {
totalRecommend: 'Referred',
totalEntered: 'Entered',
totalEarned: 'Earned',
},
'diy-recommend-records': {
recent: 'Recent Referrals',
viewAll: 'View all',
empty: 'No referrals yet',
},
'diy-recommend-rules': {
title: 'Reward Rules',
},
'diy-recommend-action': {
withdrawNow: 'Withdraw Now',
withdrawList: 'Records',
applyEnterprise: 'Apply',
},
loadFail: 'Failed to load, please retry',
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能',
title: 'AI智能客服',
// ====== 通用 ======
cancel: '取消',
@@ -23,7 +23,7 @@ export const lang = {
loadMoreHistory: '加载更多历史消息',
loadingHistory: '加载历史消息中...',
aiThinking: 'AI正在思考',
receivingReply: '思考中',
receivingReply: '正在接收回复',
aiThinkingStatus1: 'AI正在思考',
aiThinkingStatus2: 'AI正在分析',
aiThinkingStatus3: 'AI正在检索',
@@ -35,7 +35,6 @@ export const lang = {
copySelected: '已复制选中内容',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}',
@@ -136,9 +135,5 @@ export const lang = {
logEventHandleError: '事件处理错误',
logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
}

View File

@@ -64,7 +64,6 @@ export const lang = {
// diy 自定义页面(diy 专属文案,通用网络/接口错误复用 http.*)
diy: {
networkErrorTitle: '提示',
networkError: '网络异常,请稍后重试',
retry: '重试',
noDiyData: '未配置自定义页面数据',
locationNotAvailable: '未获取到定位',
@@ -112,7 +111,6 @@ export const lang = {
notSupported: '当前环境不支持微信视频号',
missingFields: '缺少必要字段: {field}',
playFailed: '微信视频播放失败!',
wechatMpOnly: '视频号仅支持在微信小程序环境中播放',
},
// uniapp 工具函数
@@ -126,8 +124,6 @@ export const lang = {
copyTimeoutReason: '原因:',
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
copyFailed: '复制失败',
},
@@ -258,14 +254,6 @@ export const lang = {
addToCartFailMin: '已达到最小购买数量',
addToCartFailStock: '库存不足',
// 通用提交/版本/loading 文案(原本该走 common.* 命名空间,因 common.js 整体即 common namespace,放在 root)
submitSuccess: '提交成功',
submitFail: '提交失败',
versionInfo: '版本信息',
commitId: '提交 ID',
buildTime: '构建时间',
loading: '加载中...',
consult: '立即咨询',
consultSuccess: '咨询成功',
consultFail: '咨询失败',

View File

@@ -0,0 +1,52 @@
/**
* 推荐系统 diy 组件 6 个组件共用 i18n 文案
* 加载路径: pages-tool 中 $lang('components-diy.diy-recommend-code.xxx')
* 模块前缀已经写好,这里只导出组件名下的 key
*/
export const lang = {
// ---- diy-recommend-code ----
'diy-recommend-code': {
myCode: '我的推荐码',
copy: '复制',
copySuccess: '已复制',
shareTip: '复制推荐链接分享给企业,对方填表时填入即可绑定',
},
// ---- diy-recommend-balance ----
'diy-recommend-balance': {
available: '可提现余额',
frozen: '待结算(冻结中)',
probationDays: '考察期 {days} 天',
passedProbation: '已过考察期',
totalEarned: '累计总收益',
},
// ---- diy-recommend-stat ----
'diy-recommend-stat': {
totalRecommend: '累计推荐',
totalEntered: '已入驻',
totalEarned: '累计收益',
},
// ---- diy-recommend-records ----
'diy-recommend-records': {
recent: '最近推荐',
viewAll: '查看全部',
empty: '暂无推荐记录',
},
// ---- diy-recommend-rules ----
'diy-recommend-rules': {
title: '奖励规则',
},
// ---- diy-recommend-action ----
'diy-recommend-action': {
withdrawNow: '立即提现',
withdrawList: '提现记录',
applyEnterprise: '入驻申请',
},
// ---- 通用 ----
loadFail: '加载失败,请稍后重试',
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,18 +80,10 @@ 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

@@ -82,7 +82,7 @@
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
"__usePrivacyCheck__" : false,
"__usePrivacyCheck__" : true,
"optimization" : {
"subPackages" : true
}
@@ -136,23 +136,7 @@
"sassImplementationName" : "dart-sass",
"mp-harmony" : {
"distribute" : {
"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"
}
"bundleName" : "com.atomicservice.6917577984608507983"
}
}
}

View File

@@ -66,26 +66,6 @@
"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,7 +51,6 @@
"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",
@@ -1086,64 +1085,7 @@
{
"path": "ai-chat/index",
"style": {
"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"
}
"navigationBarTitleText": "AI智能客服"
}
},
{
@@ -1155,10 +1097,7 @@
{
"path": "vr/index",
"style": {
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "工厂展示"
"navigationBarTitleText": ""
}
},
{
@@ -1352,7 +1291,6 @@
"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",
@@ -2203,72 +2141,13 @@
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
"navigationBarTitleText": ""
}
},
{
"path": "form/formdata",
"style": {
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
// #ifdef APP-PLUS || MP
// #ifdef APP-PLUS
"navigationStyle": "custom",
// #endif
"navigationBarTitleText": "",
@@ -2502,8 +2381,7 @@
{
"path": "file-preview/file-preview",
"style": {
"navigationBarTitleText": "文件预览",
"navigationStyle": "custom"
"navigationBarTitleText": "文件预览"
}
}
]
@@ -2544,7 +2422,8 @@
"uni-*(\\W.*)": "@/uni_modules/uni$1/components/uni$1/uni$1.vue",
"mescroll-uni": "@/components/mescroll/my-list-mescroll.vue",
"ns-goods-sku-*(\\W.*)": "@/components/ns-goods-sku/ns-goods-sku$1.vue",
"diy-*(\\W.*)": "@/components-diy/diy$1.vue"
"diy-*(\\W.*)": "@/components-diy/diy$1.vue",
"diy-recommend-*": "@/components-diy/diy-recommend-$1.vue"
}
},
"preloadRule": {

View File

@@ -160,8 +160,8 @@ export default {
} catch (e) {}
if (v && v.commitId) {
uni.showModal({
title: this.$lang('versionInfo'),
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
title: '版本信息',
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
showCancel: false
});
} else {

View File

@@ -1,29 +1,7 @@
<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 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客服悬浮按钮 -->
@@ -56,11 +34,10 @@
</view>
</view>
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
<!-- 商品视频 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
playsinline webkit-playsinline x5-playsinline></video>
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"></video>
</view>
<!-- 切换视频、图片 -->
@@ -484,7 +461,7 @@
<text>{{ $lang('goods.detail.detailView.generateSharePoster') }}</text>
</button>
</view>
<!-- #ifdef H5 || MP-HARMONY -->
<!-- #ifdef H5 -->
<view class="share-box" @click="copyUrl">
<button class="share-btn" :plain="true">
<view class="iconfont icon-fuzhilianjie"></view>
@@ -501,8 +478,9 @@
<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>
@@ -525,142 +503,11 @@ 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);
@@ -679,11 +526,6 @@ 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]
@@ -692,79 +534,6 @@ 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

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

View File

@@ -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="index">
<block v-for="(item, index) in diyData.value" :key="item.id || item.componentName || 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" :loading="loading" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<goods-detail-view :goodsSkuDetail="goodsSkuDetail" ref="goodsDetailView" @aiChatClick="sendToAIChat(goodsSkuDetail)">
<!-- 价格区域 -->
<template v-slot:price>
<!-- 限时折扣 -->
@@ -17,7 +17,7 @@
<view class="icon-box"><text class="iconfont icon-tutechan"></text></view>
<view class="price-box">
<view class="promotion-text">{{ $lang('goods.detail.limitedTimeDiscount') }}</view>
<view class="sale-num" v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
<view class="sale-num" v-if="goodsSkuDetail.sale_show">{{ $lang('goods.detail.sold') }}{{ goodsSkuDetail.sale_num }}{{ goodsSkuDetail.unit }}</view>
</view>
</view>
@@ -74,9 +74,7 @@
</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>
@@ -94,8 +92,8 @@
<text class="tag-item">{{ goodsSkuDetail.label_name }}</text>
</view>
<view class="logistics-wrap">
<text v-if="goodsSkuDetail.stock_show && !isPhoneConsult">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
<text v-if="goodsSkuDetail.sale_show && !isPhoneConsult">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
<text v-if="goodsSkuDetail.stock_show">{{$lang('goods.detail.stock')}}: {{ goodsSkuDetail.stock +goodsSkuDetail.unit}}</text>
<text v-if="goodsSkuDetail.sale_show">{{$lang('goods.detail.sales')}}: {{ goodsSkuDetail.sale_num+goodsSkuDetail.unit }}</text>
</view>
<!-- 会员卡 -->
<!-- <view class="member-card-wrap" @click="$util.redirectTo('/pages_tool/member/card_buy')" v-if="membercard">
@@ -462,7 +460,6 @@
<!-- #ifdef MP-WEIXIN -->
<ns-goods-action-icon
v-if="!isPhoneConsult"
:text="$lang('goods.detail.cart')"
:cornerMarkBg="themeStyle.goods_cart_num_corner"
icon="icon-gouwuche2"
@@ -471,9 +468,6 @@
/>
<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 -->
@@ -529,9 +523,7 @@
</goods-detail-view>
<to-top v-if="showTop" @toTop="scrollToTopNative()"></to-top>
<ns-login ref="login"></ns-login>
<!-- 商品详情页已有骨架屏, 不再启用 loading-cover 全屏遮罩(initShow=false),
否则骨架屏会被遮罩盖住、用户看不到任何占位过渡 -->
<loading-cover ref="loadingCover" :initShow="false"></loading-cover>
<loading-cover ref="loadingCover"></loading-cover>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
@@ -566,10 +558,6 @@ export default {
},
mixins: [base, detail, scroll],
computed: {
// 后台选择"电话立即咨询"模式(isinformation == 1)时,隐藏购物车图标、库存和销量
isPhoneConsult() {
return this.goodsSkuDetail && this.goodsSkuDetail.isinformation == 1;
},
// diy-form raw 模式 value:banner/tip 由独立列归一化后的 goods_form_* 提供,
// 与 formdata.vue 的 diyValue 同源;本页 :show-submit="false" 走外部"确定"按钮提交。
formDiyValue() {
@@ -608,7 +596,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
url: '/pages_tool/ai-chat/index'
});
},

View File

@@ -4,9 +4,6 @@ export default {
goodsRoute: '/pages_goods/detail',
couponList: [], //优惠券列表
couponBtnSwitch: false, //获取优惠券防止重复提交
// 商品详情骨架屏:true 显示骨架,false 显示真实内容
// 首屏进入即为 true,商品详情数据首屏渲染完成后(与 loading-cover 同时机)置 false
loading: true,
posterApi: '/api/goods/poster',
@@ -106,12 +103,13 @@ export default {
await this.getGoodsSkuDetail();
} catch (e) {
console.error('getGoodsSkuDetail failed:', e);
// 失败时不关闭骨架屏, 由视图层 hasRealData(goods_id>0) 自动保留占位,
// 避免错误地把页面切到"空详情"状态
uni.showToast({
title: this.$lang ? this.$lang('common.networkError') : '网络异常,请重试',
icon: 'none'
});
// 即使失败也要保证 loading-cover 关闭, 避免空白屏
setTimeout(() => {
try {
const ref = this.$refs && this.$refs.loadingCover;
if (ref && typeof ref.hide === 'function') ref.hide();
} catch (e2) { /* swallow */ }
}, 0);
}
},
onHide() {
@@ -181,10 +179,7 @@ export default {
this.chatRoomParams = {
sku_id: this.goodsSkuDetail.sku_id
};
// 鸿蒙兼容: 后端在部分场景下可能不返回 goods_promotion, 缺字段直接 .[0] 会抛 TypeError,
// 整个 onShow 走 catch 路径无法关闭骨架屏, 表现为"数据已回来但永远显示骨架屏"
const promotionList = this.goodsSkuDetail?.goods_promotion || [];
let typeId = promotionList.length > 0 ? promotionList[0] : undefined;
let typeId = this.goodsSkuDetail.goods_promotion[0];
if (typeId) {
// 限时折扣
if (typeId.discount_id) {
@@ -202,22 +197,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();
// 关闭商品详情骨架屏: 在真实数据首屏渲染完成后再隐藏骨架屏,
// 用户会看到骨架屏 → 真实详情 的清晰过渡
// 最短展示 300ms: 数据请求很快时也保留足够感知时间, 否则一闪而过用户根本看不到骨架屏
setTimeout(() => {
this.loading = false;
}, 300);
// #endif
// 关闭 loading-cover 提前到 nextTick 后, 让用户看到首屏内容
// 用 setTimeout 0 推迟到下一个 task, 避开当前 sync 阶段 vendor 报错导致 hide 失败
setTimeout(() => {
try {
const ref = this.$refs && this.$refs.loadingCover;
if (ref && typeof ref.hide === 'function') ref.hide();
} catch (e) { /* swallow */ }
}, 0);
// 处理商品数据 (同步字段变换 + 后台并发 getGoodsForm)
await this.handleGoodsSkuData();
@@ -255,7 +249,7 @@ export default {
}
// 优惠券
if (Array.isArray(this.goodsSkuDetail.coupon_list)) {
if (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.$lang('buyNow');
this.config.text = this.$lang('goods.list.buyText');
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 && orderCreateData.coupon[calculateGoodsData.merch_id] && orderCreateData.coupon[calculateGoodsData.merch_id].coupon_id)">
<template v-if="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="selectCouponMap[merchCoupon.merch_id] == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
<view class="iconfont" :class="selectCouponId == couponItem.coupon_id ? 'icon-yuan_checked color-base-text' : 'icon-yuan_checkbox'"></view>
</view>
</view>
</view>

View File

@@ -55,8 +55,7 @@ export default {
merch_id: 0,
data: []
},
// 按 merch_id 隔离,避免多商户之间互相污染
selectCouponMap: {}
selectCouponId:0
}
},
inject: ['promotion'],
@@ -86,7 +85,7 @@ export default {
goodsData() {
if (this.paymentData) {
this.paymentData.goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return this.paymentData;
}
@@ -94,7 +93,7 @@ export default {
calculateGoodsData() {
if (this.calculateData) {
this.calculateData.goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return this.calculateData;
}
@@ -190,7 +189,7 @@ export default {
//多商户商品分类
goodsSpecFormat(goods_list){
goods_list.forEach(item => {
if (item.sku_spec_format && typeof item.sku_spec_format === 'string') item.sku_spec_format = JSON.parse(item.sku_spec_format);
if (item.sku_spec_format) item.sku_spec_format = JSON.parse(item.sku_spec_format);
})
return goods_list
},
@@ -250,11 +249,7 @@ export default {
this.modules = data.modules;
//记录多商户优惠券
// 兜底为对象,避免后续被当作数组处理
if (!data.coupon || typeof data.coupon !== 'object' || Array.isArray(data.coupon)) {
data.coupon = {};
}
this.orderCreateData.coupon = data.coupon;
this.orderCreateData.coupon = data.coupon
//查询优惠券
this.getCouponList();
@@ -370,17 +365,7 @@ export default {
}
});
//多商户
// 保留用户已选的 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 };
}
});
}
this.orderCreateData.coupon = res.data.coupon
// if (!res.data.coupon_id) this.orderCreateData.coupon.coupon_id = 0;
// else this.orderCreateData.coupon.coupon_id = res.data.coupon_id;
@@ -755,40 +740,32 @@ export default {
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.selectCouponId = this.orderCreateData.coupon[merch_id].coupon_id;
// this.selectCouponMoney = this.orderCreateData.coupon[siteId].coupon_money;
// 兜底: 列表为空时给一个空数组,弹窗不会渲染空指针
this.merchCoupon.data = (this.coupon_list && this.coupon_list[merch_id]) || [];
this.merchCoupon.data = this.coupon_list[merch_id];
this.$refs.couponPopup.open();
this.$forceUpdate();
},
/**商户选择券*/
selectCoupon(data, merch_id) {
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;
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() {
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.orderCreateData.coupon[this.merchCoupon.merch_id].coupon_id = this.selectCouponId;
this.$refs.couponPopup.close();
this.calculate();
},

View File

@@ -39,7 +39,7 @@ export default {
fontWeight: false,
padding: 0,
cartEvent: "detail",
text: '',
text: this.$lang('merch.list.buyText'),
textColor: "#FFFFFF",
theme: "default",
aroundRadius: 25,
@@ -66,7 +66,6 @@ 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

@@ -39,7 +39,6 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
@@ -75,137 +74,73 @@ export default {
},
selectImage() {
const that = this
uni.chooseImage({
count: 9,
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
that.hideToolsPanel()
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
// 用户可继续输入文字, 点发送时图文一起发出
that.$emit('image-picked', res.tempFilePaths || [])
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = async (e) => {
input.onchange = (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
}
input.click()
// #endif
// #ifdef MP-WEIXIN
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
? uni.chooseMessageFile
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
if (!chooseFileApi) {
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
return
}
chooseFileApi({
count: 1,
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
success: async (res) => {
that.hideToolsPanel()
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
if (!file) return
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
await that._uploadOneFile(file, kind)
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选文件失败', icon: 'none' })
}
},
})
// #endif
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: async (res) => {
that.hideToolsPanel()
success: (res) => {
const file = res.tempFiles[0]
await that._uploadOneFile(file, 'file')
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
url: file.path,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
})
// #endif
},
async _uploadOneFile(file, kind) {
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
// 统一: H5 创建 blob URL 用于预览, 上传完 (成功/失败都) revoke
let blobUrl = ''
let displayPath = ''
// #ifdef H5
// H5 file 对象没有 .path, 用 blob URL 给 imageUrl 预览, 并把 file 本身传给 uploadImage
blobUrl = file.name ? URL.createObjectURL(file) : ''
displayPath = blobUrl
// #endif
// #ifndef H5
// MP-WEIXIN / APP-PLUS 都用 file.path (tempFilePath)
displayPath = file.path || ''
// #endif
const pending = {
id: ++this.messageId,
role: 'user',
type: kind,
content: '',
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
fileSize: file.size || 0,
fileType: file.type || '',
imageUrl: displayPath,
uploading: true,
uploadError: null,
uploaded: null,
timestamp: Date.now()
}
this.$emit('file-selected', pending)
try {
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
// 非 H5 传 file.path (uni.uploadFile 需要)
const inputArg = blobUrl || file.path || file
const uploaded = kind === 'image'
? await uploadImage(inputArg)
: await uploadMedia(inputArg, kind)
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
} finally {
// PR-FRONTEND-MEDIA-UPLOAD-FIX: blob URL 用完即释放 (无论成败)
if (blobUrl) {
try { URL.revokeObjectURL(blobUrl) } catch (e) {}
}
}
},
startRecord() {
this.$emit('voice-start')
this.hideToolsPanel()
@@ -274,7 +209,6 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
@@ -291,9 +225,7 @@ export default {
align-items: center;
justify-content: center;
border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;

View File

@@ -13,8 +13,7 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<view class="chat-content">
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
@@ -69,10 +68,6 @@
</view>
</view>
</view>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
</view>
</template>
@@ -92,9 +87,6 @@ export default {
return {
initialMessages: [],
fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -113,12 +105,7 @@ export default {
keyboardHeight: 0,
// 事件处理器引用(用于清理)
safeEventHandlers: new Map(),
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
isFromFloatBtn: false,
safeEventHandlers: new Map()
}
},
@@ -140,21 +127,6 @@ 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) {
@@ -162,19 +134,7 @@ export default {
// return;
// }
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
// 原因:AI 智能页是全屏聊天体验,不论是
// 1) 底部导航栏"智能客服"项直接点击进入
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.$langConfig.title(this.$lang('ai-chat.index.title'));
this.initChat();
},
@@ -220,15 +180,6 @@ 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: {
@@ -696,9 +647,7 @@ export default {
/* #endif */
left: 0;
right: 0;
/* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
bottom: 0;
}
/* 底部tabBar占位样式 */

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,32 +114,6 @@ 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: {
tourl(url) {

View File

@@ -1,6 +1,6 @@
export default {
methods: {
// 清除登录态(token / 会员信息 / 购物车 / 本地存储)
// 清除登录态(token / 用户信息 / 购物车 / 本地存储)
clearLoginState() {
this.$store.commit('setToken', '');
this.$store.commit('setMemberInfo', '');
@@ -9,48 +9,6 @@ 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({
@@ -58,8 +16,8 @@ export default {
content: this.$lang('logoutContent'),
success: (res) => {
if (res.confirm) {
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});
@@ -92,8 +50,8 @@ export default {
this.$util.redirectTo('/pages_tool/member/cancellation');
}
} else if (res.code == -1) {
// 统一的跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
}
}
});
@@ -111,15 +69,11 @@ export default {
url: '/membercancel/api/membercancel/apply',
success: rres => {
if (rres.code >= 0) {
// 注销申请提交成功后立刻清空登录态,
// 避免用户通过标题栏/导航栏返回到会员中心等页面时仍能看到账号信息
if (options.clearStateAndRedirect) {
// 跳登录入口: 内部带防抖锁, 避免连发 reLaunch
this.redirectToLoginOnce();
} else {
// 跳 cancelstatus (审核中), 也走 reLaunch 清栈
this.clearLoginState();
this.$util.redirectTo('/pages_tool/member/cancelstatus', {}, 'reLaunch');
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
} else {
this.$util.redirectTo('/pages_tool/member/cancelstatus');
}
} 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: this.$lang('notice.list.title'),
title: '公告',
desc: '',
link: shareUrl,
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
@@ -85,7 +85,7 @@ export default {
}
},
onShareAppMessage(res) {
var title = this.$lang('notice.list.title');
var 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: this.$lang('pay.index.payInfoNotFound')
title: '未获取到支付信息!'
});
setTimeout(() => {
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);

View File

@@ -1,6 +1,9 @@
<template>
<view :style="themeColor" class="vr-page">
<web-view :src="src" class="vr-webview" @titleUpdate="forceCustomTitle"></web-view>
<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>
</template>
@@ -8,73 +11,72 @@
export default {
data() {
return {
src: '',
title: '',
customTitle: '',
titleTimer: null
src: ''
};
},
onLoad(option) {
this.customTitle = option?.title || '工厂展示';
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
// this.src = decodeURIComponent(option.src);
this.$api.sendRequest({
url: '/api/config/defaultvr',
success: res => {
if (res.code == 0) {
this.customTitle = res.data.title;
this.title = this.customTitle;
uni.setNavigationBarTitle({ title: this.customTitle });
this.src = res.data.url;
this.startTitleGuard();
uni.setNavigationBarTitle({
title:res.data.title
})
this.src = res.data.url
}
},
fail: res => {
}
});
},
onShow() {
if (this.customTitle) {
uni.setNavigationBarTitle({ title: this.customTitle });
}
},
onUnload() {
this.clearTitleGuard();
},
methods: {
forceCustomTitle() {
uni.setNavigationBarTitle({ title: this.customTitle });
},
startTitleGuard() {
this.clearTitleGuard();
let count = 0;
this.titleTimer = setInterval(() => {
uni.setNavigationBarTitle({ title: this.customTitle });
count++;
if (count >= 10) {
this.clearTitleGuard();
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
});
}
}, 100);
},
clearTitleGuard() {
if (this.titleTimer) {
clearInterval(this.titleTimer);
this.titleTimer = null;
}
goHome() {
uni.reLaunch({
url: this.$util.PAGE_KEY_MAP.PAGE_INDEX
});
}
}
};
</script>
<style lang="scss">
.vr-page {
height: 100vh;
width: 100%;
background: #fff;
.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-webview {
width: 100%;
height: 100%;
.vr-nav-back {
left: 34rpx;
line-height: 60rpx;
}
.vr-nav-home {
right: 34rpx;
font-size: 40rpx;
line-height: 72rpx;
}
</style>

View File

@@ -1,62 +0,0 @@
$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

@@ -1,209 +0,0 @@
#!/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

@@ -1,58 +0,0 @@
// 检查 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

@@ -1,429 +0,0 @@
#!/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

@@ -1,264 +0,0 @@
{
"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
}
]
}

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