Compare commits
43 Commits
8cfc144ba9
...
custom/351
| Author | SHA1 | Date | |
|---|---|---|---|
| f35f48434d | |||
| b57a457734 | |||
| 535ae732bc | |||
| 993b5c5cb0 | |||
| f40c4b2a69 | |||
| 2dae3adf66 | |||
| e079afc0a2 | |||
| 85b3e4db47 | |||
| 0197224c75 | |||
| 70f8fb867d | |||
| 86b87eeb9d | |||
| 297b545e55 | |||
| 3e2ae9b7ca | |||
| 2016b87b6e | |||
| a42ad4ebb3 | |||
| 158750b933 | |||
| 3abe65de2a | |||
| c0e432cc22 | |||
| f8115f47fd | |||
| 66b56387e6 | |||
| 3079cf4ba5 | |||
| aefb0c1550 | |||
| 235adb5ebd | |||
| 744981ed93 | |||
| ee20c214de | |||
| 8fd68033ed | |||
| 62b4b44f88 | |||
| 9d599d5f47 | |||
| 2b1f8a4fe2 | |||
| 7a81045290 | |||
| 67ce0f8b39 | |||
| 01df6c9116 | |||
| 480a109114 | |||
| 509fe203f7 | |||
| 0f9cc1087f | |||
| 0f24f47048 | |||
| a3d2ee562e | |||
| 1b1704f7c6 | |||
| 8b2670bf31 | |||
| e66c91acfc | |||
| 29699a66fd | |||
| 3b4051c775 | |||
| fffb5dae18 |
@@ -68,7 +68,7 @@ const localDevConfig = ({
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'100': {
|
||||
'100': {// 口译教学
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -76,15 +76,15 @@ const localDevConfig = ({
|
||||
uniacid: 1,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'115': {
|
||||
'115': {// 低压断路器
|
||||
uniacid: 115,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-11': {
|
||||
'5g-11': {// 铝箔袋包装
|
||||
uniacid: 11,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-206': {
|
||||
'5g-206': {// 气体探测
|
||||
uniacid: 206,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -92,19 +92,19 @@ const localDevConfig = ({
|
||||
uniacid: 165,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'47': { // 修改弹窗
|
||||
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
|
||||
uniacid: 47,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'145': { // 表单链接转义问题
|
||||
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
|
||||
uniacid: 145,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-213': { // 单图组点击链接无法跳转
|
||||
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
|
||||
uniacid: 213,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-4': { // 公告点击链接无法跳转
|
||||
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
|
||||
uniacid: 4,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -116,11 +116,11 @@ const localDevConfig = ({
|
||||
uniacid: 50,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-237': { // 商品分类,没有立即咨询文字
|
||||
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
|
||||
uniacid: 237,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'2847': { //
|
||||
'2847': { // 家居装饰品
|
||||
uniacid: 2847,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -128,7 +128,7 @@ const localDevConfig = ({
|
||||
uniacid: 2482,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
'2485': { // 验证电子表单前后端数据一致性
|
||||
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
|
||||
uniacid: 2485,
|
||||
domain: 'https://xcx6.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -152,11 +152,11 @@ const localDevConfig = ({
|
||||
uniacid: 12,
|
||||
domain: 'https://test.aigc-quickapp.com',
|
||||
},
|
||||
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'228': { // 商品分类,没有立即咨询文字
|
||||
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
|
||||
uniacid: 228,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -164,7 +164,7 @@ const localDevConfig = ({
|
||||
uniacid: 2797,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-95': { // 商品分类,没有立即咨询文字
|
||||
'5g-95': { // 加热器 ,商品分类,没有立即咨询文字
|
||||
uniacid: 95,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -185,7 +185,7 @@ const localDevConfig = ({
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
|
||||
'5g-59': { // 脱模剂粘合剂乐瑞固Lorechem
|
||||
uniacid: 59,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -210,7 +210,7 @@ const localDevConfig = ({
|
||||
uniacid: 2505,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'3007': { // 煤矿钻机
|
||||
'3007': { // 钎焊材料
|
||||
uniacid: 3007,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
@@ -218,6 +218,10 @@ const localDevConfig = ({
|
||||
uniacid: 317,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-312': { // 天然植物功效活性物
|
||||
uniacid: 312,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -230,10 +234,17 @@ const localDevConfig = ({
|
||||
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
|
||||
// })['2724']; // 生物菌肥
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['5g-351']; //洗车机
|
||||
// })['5g-204']; //热固性塑料
|
||||
// })['5g-59']; //合成孔径雷达
|
||||
// })['2709']; //中央厨房
|
||||
// })['3007']; //钎焊材料
|
||||
// })['2505']; //煤矿钻机
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['2811']; // 本地
|
||||
//})['5g-317']; // 电池兼容
|
||||
})['192.168.8.118-1']; //
|
||||
// })['5g-317']; // 电磁兼容
|
||||
// })['5g-351']; //
|
||||
})['5g-351']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
|
||||
11
App.vue
11
App.vue
@@ -5,6 +5,9 @@
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
import PrivacyManager from 'common/js/privacy.js';
|
||||
import splashState from 'common/js/splash-state.js';
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
||||
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
||||
import aiService from 'common/js/ai-service.js';
|
||||
|
||||
import {
|
||||
Weixin
|
||||
@@ -463,6 +466,14 @@
|
||||
if (uni.getStorageSync('memberInfo')) {
|
||||
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
||||
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
|
||||
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
|
||||
try {
|
||||
if (aiService && typeof aiService.onUserChange === 'function') {
|
||||
aiService.onUserChange();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 查询购物车信息
|
||||
this.$store.dispatch('getCartNumber');
|
||||
}
|
||||
|
||||
@@ -19,12 +19,38 @@ let currentConversationId = null;
|
||||
export const DEFAULT_AGENT_ID = 'agent_aikefu';
|
||||
let currentAgentId = DEFAULT_AGENT_ID;
|
||||
|
||||
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
|
||||
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_';
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
|
||||
// 老 key: ai_conversation_id_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
|
||||
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 uniacid × userId × agent 三维隔离)
|
||||
// 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理)
|
||||
export const CONVERSATION_KEY_PREFIX = 'ai_conv_';
|
||||
const LEGACY_KEY_PREFIX = 'ai_conversation_id_';
|
||||
|
||||
export function getConversationKey(agentId) {
|
||||
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
|
||||
let siteId = 0;
|
||||
let userId = 'anon';
|
||||
try {
|
||||
siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0;
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
userId = getUserId() || 'anon';
|
||||
} catch (e) { /* ignore */ }
|
||||
return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID);
|
||||
}
|
||||
// 兼容旧代码: 单 key 别名(指向默认 agent)
|
||||
|
||||
// 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底
|
||||
function migrateLegacyConversationKey(agentId) {
|
||||
try {
|
||||
const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
|
||||
const legacy = uni.getStorageSync(legacyKey);
|
||||
const newKey = getConversationKey(agentId);
|
||||
if (legacy && !uni.getStorageSync(newKey)) {
|
||||
uni.setStorageSync(newKey, legacy);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 兼容旧代码: 单 key 别名(指向默认 agent) — 给老代码引用,值是动态计算的
|
||||
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
|
||||
|
||||
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
|
||||
@@ -84,13 +110,24 @@ function getDomainParams() {
|
||||
}
|
||||
|
||||
// 初始化时从本地读取会话ID(按当前 agent 隔离)
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key
|
||||
// H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串
|
||||
// (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick
|
||||
// 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值
|
||||
try {
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) {
|
||||
currentConversationId = saved;
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) {
|
||||
currentConversationId = saved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
}
|
||||
}, 0);
|
||||
} catch (e) {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
console.warn('初始化会话ID任务派发失败:', e);
|
||||
}
|
||||
|
||||
const GUEST_ID_KEY = 'ai_guest_user_id';
|
||||
@@ -387,6 +424,64 @@ function disconnectWebSocket() {
|
||||
}
|
||||
// #endif
|
||||
|
||||
// PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存
|
||||
// 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费;
|
||||
// TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效
|
||||
var _serviceConfigCache = null;
|
||||
var _serviceConfigCacheAt = 0;
|
||||
var SERVICE_CONFIG_CACHE_TTL = 60 * 1000;
|
||||
|
||||
function resetServiceConfigCache() {
|
||||
_serviceConfigCache = null;
|
||||
_serviceConfigCacheAt = 0;
|
||||
}
|
||||
|
||||
// PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值
|
||||
// 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应
|
||||
// 改任何一项必须同步后端常量, 否则前后端默认分叉
|
||||
//
|
||||
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20):
|
||||
// ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG),
|
||||
// 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时,
|
||||
// 模型选择器仍可显示 Auto。
|
||||
// 数据源 (后端维护): https://docs.openclaw.ai/providers
|
||||
// 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。
|
||||
const CHAT_UI_DEFAULTS = {
|
||||
ui_show_thinking_process: true,
|
||||
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
|
||||
ui_input_max_length: 500,
|
||||
ui_available_models: ['Auto'],
|
||||
ui_enable_feedback: true,
|
||||
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
|
||||
ui_assistant_name: '',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
|
||||
ui_show_assistant_name: true,
|
||||
ui_show_assistant_avatar: true,
|
||||
ui_assistant_avatar: '',
|
||||
ui_show_message_time: true,
|
||||
ui_time_format: 'auto',
|
||||
ui_time_humanize: false
|
||||
};
|
||||
|
||||
// chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步);
|
||||
// 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它
|
||||
var _chatUiConfigCache = null;
|
||||
var _chatUiConfigCacheAt = 0;
|
||||
|
||||
function mergeChatUiConfig(remote) {
|
||||
// remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底
|
||||
// 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖
|
||||
var merged = {};
|
||||
Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) {
|
||||
if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) {
|
||||
merged[k] = remote[k];
|
||||
} else {
|
||||
merged[k] = CHAT_UI_DEFAULTS[k];
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 获取uniacid
|
||||
@@ -550,7 +645,11 @@ export default {
|
||||
// #ifdef H5
|
||||
try {
|
||||
var chatUrl = resolveApiUrl('chat');
|
||||
var url = Config.baseUrl + chatUrl;
|
||||
// PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25),
|
||||
// resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。
|
||||
// nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。
|
||||
// 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。
|
||||
var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -598,6 +697,20 @@ export default {
|
||||
if (!jsonStr || jsonStr === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
// PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558)
|
||||
// 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."}
|
||||
// 这种响应没 event 字段, 不应进 switch 的 default (会污染 console)
|
||||
if (data && typeof data === 'object' && !Array.isArray(data) &&
|
||||
data.code !== undefined && data.code !== 0 && !data.event) {
|
||||
console.warn('[AI SSE] 后端错误响应:', data.code, data.message);
|
||||
fire('error', { code: data.code, message: data.message || '请求失败' });
|
||||
break;
|
||||
}
|
||||
// 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段),
|
||||
// 不是事件, 静默跳过
|
||||
if (Array.isArray(data)) {
|
||||
continue;
|
||||
}
|
||||
switch (data.event) {
|
||||
case 'message': {
|
||||
var text = data.answer || data.text || data.content || '';
|
||||
@@ -705,11 +818,13 @@ export default {
|
||||
|
||||
/**
|
||||
* 设置并持久化会话ID
|
||||
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
|
||||
*/
|
||||
setConversationId(id) {
|
||||
if (id && id !== currentConversationId) {
|
||||
currentConversationId = id;
|
||||
try {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
uni.setStorageSync(getConversationKey(currentAgentId), id);
|
||||
} catch (e) {
|
||||
console.error('保存会话ID失败:', e);
|
||||
@@ -717,6 +832,36 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子
|
||||
* App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId
|
||||
* 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug
|
||||
*
|
||||
* 调用时机:
|
||||
* - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次
|
||||
* - 任何 setMemberInfo 提交后调一次
|
||||
* 行为:
|
||||
* - 比对新老 user_id,不同时移除"旧 user"维度的 storage key
|
||||
* - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建)
|
||||
* - 记录 last_user_id 用于下次对比
|
||||
*/
|
||||
onUserChange() {
|
||||
try {
|
||||
const newUid = String(getUserId() || 'anon');
|
||||
const oldUid = uni.getStorageSync('ai_last_user_id');
|
||||
if (oldUid && oldUid !== newUid) {
|
||||
// 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算)
|
||||
const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0)
|
||||
+ '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID);
|
||||
try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ }
|
||||
}
|
||||
uni.setStorageSync('ai_last_user_id', newUid);
|
||||
currentConversationId = null;
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] onUserChange failed:', e && e.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话ID
|
||||
*/
|
||||
@@ -785,6 +930,12 @@ export default {
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async getServiceConfig() {
|
||||
// PR-FIX (2026-09-20): 命中缓存直接返回
|
||||
const nowTs = Date.now();
|
||||
if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
|
||||
console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)');
|
||||
return _serviceConfigCache;
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
url: resolveApiUrl('info'),
|
||||
@@ -814,6 +965,9 @@ export default {
|
||||
} else {
|
||||
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
|
||||
}
|
||||
// PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试)
|
||||
_serviceConfigCache = response.data;
|
||||
_serviceConfigCacheAt = Date.now();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AI Service] getServiceConfig error:', error);
|
||||
@@ -821,14 +975,75 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-FIX (2026-09-20): 清除服务配置缓存
|
||||
* 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口)
|
||||
*/
|
||||
clearServiceConfigCache() {
|
||||
resetServiceConfigCache();
|
||||
_chatUiConfigCache = null;
|
||||
_chatUiConfigCacheAt = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关
|
||||
* 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底
|
||||
* 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用
|
||||
* 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新)
|
||||
*/
|
||||
async getChatUiConfig(force) {
|
||||
const nowTs = Date.now();
|
||||
if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
|
||||
return _chatUiConfigCache;
|
||||
}
|
||||
try {
|
||||
// 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段
|
||||
// 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache
|
||||
const serviceData = await this.getServiceConfig();
|
||||
const remote = (serviceData && serviceData.chat_ui_config) || null;
|
||||
const merged = mergeChatUiConfig(remote);
|
||||
_chatUiConfigCache = merged;
|
||||
_chatUiConfigCacheAt = Date.now();
|
||||
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时
|
||||
// getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁)
|
||||
try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ }
|
||||
return merged;
|
||||
} catch (e) {
|
||||
console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e);
|
||||
// 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试
|
||||
return Object.assign({}, CHAT_UI_DEFAULTS);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存)
|
||||
* 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用,
|
||||
* 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。
|
||||
* 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过)
|
||||
* 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新
|
||||
*/
|
||||
getChatUiConfigSync() {
|
||||
try {
|
||||
const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG');
|
||||
if (cached && typeof cached === 'object' && Object.keys(cached).length) {
|
||||
return mergeChatUiConfig(cached);
|
||||
}
|
||||
} catch (e) { /* 存储读取失败按无缓存处理 */ }
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话历史(调用后端接口)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async clearConversation(conversationId) {
|
||||
try {
|
||||
// PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase
|
||||
// ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换
|
||||
// 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists
|
||||
// 改 'clearConversation' → /api/kefu/clearConversation → 200
|
||||
const response = await http.sendRequest({
|
||||
url: resolveApiUrl('clear-conversation'),
|
||||
url: resolveApiUrl('clearConversation'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
conversation_id: conversationId,
|
||||
@@ -871,10 +1086,38 @@ export default {
|
||||
return true;
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
|
||||
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
|
||||
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
|
||||
return false;
|
||||
// PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
|
||||
// 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
|
||||
// (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
|
||||
// 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse()
|
||||
// (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱)
|
||||
// 必须用 resolveAddonName() 拿 addon 全名直接拼 URL
|
||||
try {
|
||||
const addon = resolveAddonName(currentAgentId); // aikefu / aiagent
|
||||
const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response';
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Token': store.state.token || 'test_token'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation_id,
|
||||
message_id,
|
||||
tool_call_id,
|
||||
rpc_id,
|
||||
answers,
|
||||
uniacid: this.getUniacid(),
|
||||
user_id: getUserId(),
|
||||
token: store.state.token || 'test_token'
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
return !!(data && (data.code === 0 || data.success));
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
|
||||
return false;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
|
||||
@@ -1022,20 +1265,37 @@ export default {
|
||||
/**
|
||||
* Set current AI Agent (default aikefu; link system passes agent_id).
|
||||
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
|
||||
*
|
||||
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
|
||||
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
|
||||
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
|
||||
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
|
||||
*
|
||||
* @param {string} agentId
|
||||
* @param {object} opts { silent: bool } true = 不回调, 默认 false
|
||||
*/
|
||||
setAgentId(agentId) {
|
||||
setAgentId(agentId, opts) {
|
||||
var oldAgentId = currentAgentId;
|
||||
var oldAddon = resolveAddonName(currentAgentId);
|
||||
setAgentId(agentId);
|
||||
var newAddon = resolveAddonName(currentAgentId);
|
||||
if (oldAddon !== newAddon) {
|
||||
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
// 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离)
|
||||
// PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存
|
||||
// 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话
|
||||
var agentChanged = oldAgentId && oldAgentId !== agentId;
|
||||
currentConversationId = null;
|
||||
try {
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) currentConversationId = saved;
|
||||
if (saved && !agentChanged) currentConversationId = saved;
|
||||
else if (saved && agentChanged) {
|
||||
// agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回
|
||||
try {
|
||||
const oldKey = getConversationKey(oldAgentId);
|
||||
if (oldKey) uni.removeStorageSync(oldKey);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
|
||||
@@ -489,36 +489,27 @@ function isBundleComplete(tree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2) tree 不能是空对象(空对象没有 common 也没有 components, 显然不可用)
|
||||
// 2) tree 不能是空对象(空对象没有任何可用文案, 显然不可用)
|
||||
const treeKeys = Object.keys(tree);
|
||||
if (treeKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3) tree 必须有 common 节点(基础文案, 几乎所有 UI 都依赖)
|
||||
if (!tree.common || typeof tree.common !== 'object') {
|
||||
return false;
|
||||
// === 粗粒度校验(与上方设计哲学一致) ===
|
||||
// 不再硬性要求 common / components 节点必须同时存在。
|
||||
// 远端可能在某些时刻少发某个节点(部署期间、缓存未刷新、租户未配置 common 等),
|
||||
// 此时仍应让 boot 成功写入 remoteTreeCache, 缺失字段由 $lang() 自然回落为空,
|
||||
// UI 降级显示(空文案/空按钮文字), 而不是让整个 boot 失败导致整站变英文 / raw key。
|
||||
|
||||
// 软校验: components.privacyPopup(节点存在才检查, 避免 tree.components 为 undefined 时报错)
|
||||
const privacyPopup = tree.components && tree.components.privacyPopup;
|
||||
if (privacyPopup && typeof privacyPopup === 'object') {
|
||||
// defaultAppNameMp 存在性仅作诊断, 不阻止 boot
|
||||
if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
|
||||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) tree 必须有 components 节点(组件级文案)
|
||||
if (!tree.components || typeof tree.components !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// === 以下为软校验(只 warn 不 return false) ===
|
||||
// 设计: 不再因为"缺某个 key"就让整个 boot 失败
|
||||
// 缺失的字段让 $lang() 自然返回空, UI 降级显示
|
||||
|
||||
// 软校验 1: components.privacyPopup
|
||||
const privacyPopup = tree.components.privacyPopup;
|
||||
if (!privacyPopup || typeof privacyPopup !== 'object') {
|
||||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||||
} else if (typeof privacyPopup.defaultAppNameMp !== 'string' || !privacyPopup.defaultAppNameMp) {
|
||||
// 软校验不阻止 boot, 仅记录(暂无控制台输出, 避免污染生产)
|
||||
}
|
||||
|
||||
// 软校验 2: 哪些 key 看起来像隐私弹窗,但在错误位置?(诊断)
|
||||
// 此处仅保留注释说明, 不在生产代码中输出
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@ const EXT_MIME_MAP = {
|
||||
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
||||
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
||||
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
||||
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
@@ -111,23 +116,87 @@ export async function uploadImage(filePath, opts = {}) {
|
||||
|
||||
if (finalUseSts) {
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||
} else {
|
||||
}
|
||||
try {
|
||||
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
|
||||
} catch (e) {
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
|
||||
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
|
||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
|
||||
* 上传 audio / video / file
|
||||
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
||||
* 未配云存储 (backend) → 直接 PHP 中转
|
||||
* @param {string} filePath
|
||||
* @param {'audio'|'video'|'file'} kind
|
||||
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
||||
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
||||
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
||||
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
||||
*/
|
||||
export async function uploadMedia(filePath, kind) {
|
||||
export async function uploadMedia(filePath, kind, opts = {}) {
|
||||
if (!['audio', 'video', 'file'].includes(kind)) {
|
||||
throw new Error(`invalid kind: ${kind}`)
|
||||
}
|
||||
const addon = aiService.getAddonName()
|
||||
const config = await aiService.getServiceConfig()
|
||||
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
|
||||
const agentId = aiService.getAgentId()
|
||||
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
||||
const size = opts.size || 0
|
||||
const strategy = (config && config.upload_strategy) || 'backend'
|
||||
|
||||
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
||||
const doPhpUpload = async (fallbackErr) => {
|
||||
let phpRes = null
|
||||
try {
|
||||
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
||||
} catch (err) {
|
||||
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
||||
}
|
||||
if (!phpRes || !phpRes.url) {
|
||||
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
||||
}
|
||||
return Object.assign({}, phpRes, { kind })
|
||||
}
|
||||
|
||||
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
||||
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
||||
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
||||
if (strategy === 'backend') {
|
||||
try {
|
||||
return await doPhpUpload()
|
||||
} catch (e) {
|
||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||
} catch (e) {
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
||||
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
|
||||
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
|
||||
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
||||
try {
|
||||
return await doPhpUpload(e)
|
||||
} catch (e2) {
|
||||
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
||||
throw e2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +239,11 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
oss_sts_required: data.data.oss_sts_required || false,
|
||||
})
|
||||
} else {
|
||||
reject(new Error(data.msg || data.message || '上传失败'))
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
|
||||
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
|
||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
|
||||
reject(err)
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('解析响应失败: ' + e.message))
|
||||
@@ -213,6 +286,12 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
oss_sts_required: data.data.oss_sts_required || false,
|
||||
}
|
||||
}
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
|
||||
if (data.oss_sts_required === true) {
|
||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||
err.code = 'OSS_STS_REQUIRED'
|
||||
throw err
|
||||
}
|
||||
throw new Error(data.msg || data.message || '上传失败')
|
||||
// #endif
|
||||
}
|
||||
@@ -241,7 +320,7 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
||||
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
||||
*/
|
||||
function extractExt(filePath, mimeHint = '') {
|
||||
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||
let ext = 'bin'
|
||||
if (typeof filePath === 'string') {
|
||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||
@@ -252,6 +331,11 @@ function extractExt(filePath, mimeHint = '') {
|
||||
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
||||
if (m2) ext = m2[1].toLowerCase()
|
||||
}
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
||||
if (ext === 'bin' && nameHint) {
|
||||
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
||||
if (m3) ext = m3[1].toLowerCase()
|
||||
}
|
||||
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
||||
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
||||
if (ext === 'bin' && mimeHint) {
|
||||
@@ -263,15 +347,163 @@ function extractExt(filePath, mimeHint = '') {
|
||||
return ext
|
||||
}
|
||||
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0) {
|
||||
/**
|
||||
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
|
||||
*
|
||||
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
|
||||
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
|
||||
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
|
||||
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
|
||||
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
|
||||
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
|
||||
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
|
||||
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
|
||||
*
|
||||
* 端点: POST /<addon>/api/upload/ossPresign
|
||||
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
|
||||
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
|
||||
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
|
||||
*/
|
||||
/**
|
||||
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
|
||||
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
|
||||
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
|
||||
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
|
||||
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
|
||||
*/
|
||||
function uniRequest(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request(Object.assign({}, opts, {
|
||||
success: resolve,
|
||||
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
|
||||
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
|
||||
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
|
||||
let mimeHint = hints.mime || ''
|
||||
let nameHint = hints.name || ''
|
||||
let probeFile = null
|
||||
// #ifdef H5
|
||||
try {
|
||||
probeFile = await pathToFile(filePath, kind)
|
||||
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
|
||||
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
|
||||
} catch (e) {
|
||||
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
|
||||
console.warn('[media-upload] presign probe failed:', e && e.message)
|
||||
}
|
||||
// #endif
|
||||
const ext = extractExt(filePath, mimeHint, nameHint)
|
||||
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
|
||||
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
|
||||
const contentType = mimeHint || guessMime(finalExt)
|
||||
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
|
||||
? nameHint
|
||||
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
|
||||
|
||||
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
|
||||
const preResp = await uniRequest({
|
||||
url: Config.baseUrl + preUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'token': store.state.token || ''
|
||||
},
|
||||
data: {
|
||||
filename,
|
||||
content_type: contentType,
|
||||
uniacid: aiService.getUniacid(),
|
||||
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
|
||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
},
|
||||
})
|
||||
if (preResp.statusCode === 404) {
|
||||
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
|
||||
err.code = 'PRESIGN_UNSUPPORTED'
|
||||
throw err
|
||||
}
|
||||
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
|
||||
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
|
||||
}
|
||||
const pre = preResp.data.data
|
||||
const uploadUrl = pre.upload_url
|
||||
const finalContentType = pre.content_type || contentType
|
||||
|
||||
let uploadedSize = knownSize || 0
|
||||
// #ifdef H5
|
||||
if (probeFile && probeFile.size) uploadedSize = probeFile.size
|
||||
// #endif
|
||||
|
||||
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
|
||||
// #ifdef H5
|
||||
const file = probeFile || await pathToFile(filePath, kind)
|
||||
const ossRes = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': finalContentType },
|
||||
body: file,
|
||||
})
|
||||
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
|
||||
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.getFileSystemManager().readFile({
|
||||
filePath,
|
||||
success: (res) => {
|
||||
uni.request({
|
||||
url: uploadUrl,
|
||||
method: 'PUT',
|
||||
header: { 'Content-Type': finalContentType },
|
||||
data: res.data,
|
||||
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
|
||||
? resolve()
|
||||
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
|
||||
fail: reject,
|
||||
})
|
||||
},
|
||||
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
return {
|
||||
url: (pre.public_url || uploadUrl.split('?')[0]),
|
||||
mime: finalContentType,
|
||||
size: uploadedSize,
|
||||
name: (pre.object_key || filename).split('/').pop(),
|
||||
kind,
|
||||
used_strategy: 'oss_sts',
|
||||
provider: pre.provider || 'alioss',
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
||||
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
|
||||
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
|
||||
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
|
||||
const providerHint = (config && config.oss_provider) || ''
|
||||
if (!providerHint || providerHint === 'alioss') {
|
||||
try {
|
||||
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
|
||||
} catch (e) {
|
||||
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
|
||||
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
|
||||
}
|
||||
}
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
||||
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
||||
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
||||
const ext = extractExt(filePath)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
||||
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
||||
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
||||
|
||||
// 1. 拿 STS 凭证 (addon 模式 URL)
|
||||
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
||||
const stsResp = await uni.request({
|
||||
const stsResp = await uniRequest({
|
||||
url: Config.baseUrl + stsUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
@@ -327,7 +559,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize =
|
||||
} 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`
|
||||
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
|
||||
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
|
||||
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
|
||||
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
|
||||
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
|
||||
const aliossTpl = sts.oss_url
|
||||
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
|
||||
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
|
||||
const tpl = (provider === 'alioss') ? aliossTpl
|
||||
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
|
||||
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
|
||||
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
</block>
|
||||
</block>
|
||||
|
||||
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
|
||||
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式)
|
||||
模板5 不走这里: 它有专属分支 value.template == 5(后台"展示分类"决定渲染商品网格或二级分类网格) -->
|
||||
<view v-else-if="value.template == 1">
|
||||
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
@@ -139,12 +140,56 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 非模板1, 2级分类: 显示二级分类网格,点击跳转到列表页 -->
|
||||
<block v-else-if="categoryLevel == 2">
|
||||
<!-- 模板5 专属: 后台"展示分类"决定右侧渲染内容(对应图2/图3)
|
||||
二级分类(value.level == 2) → 一级分类标题 + 商品网格(图2)
|
||||
三级分类(value.level == 3) → 一级分类标题 + 二级分类网格(图3) -->
|
||||
<block v-else-if="value.template == 5">
|
||||
<block v-if="value.level == 2">
|
||||
<view :class="['category-title', '']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
|
||||
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
|
||||
<view class="goods-img" @tap.stop="toDetail(item)">
|
||||
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
|
||||
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="name-wrap" @tap.stop="toDetail(item)">
|
||||
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
|
||||
</view>
|
||||
<view class="price-wrap">
|
||||
<view class="discount-price">
|
||||
<block v-if="item.isinformation == 0">
|
||||
<text class="unit price-style small">{{ $lang('common.currencySymbol') }}</text>
|
||||
<text class="price price-style large">{{ parseFloat(showPrice(item)).toFixed(2).split('.')[0] }}</text>
|
||||
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="delete-price font-size-activity-tag color-tip price-font"
|
||||
v-if="item.isinformation == 0 && showMarketPrice(item)">
|
||||
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
|
||||
<text>{{ showMarketPrice(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="footer-wrap footer-wrap-template1">
|
||||
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="category-goods-empty" v-else-if="!template1Loading">
|
||||
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
|
||||
<view class="tips">暂无商品</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<!-- 三级分类(及更深层级): 一级分类标题 + 二级分类网格(图3) -->
|
||||
<view :class="['category-title', '']">{{ category.category_name }}</view>
|
||||
<view class="category-list">
|
||||
<!-- 点二级分类进入商品列表: 带 list_style=grid 让列表页默认用竖版(双列宫格)视图, hide_sort=1 隐藏排序栏 -->
|
||||
<view class="category-item" v-for="(one, oneIndex) in category.child_list" :key="oneIndex"
|
||||
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: one.category_id })">
|
||||
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: one.category_id, list_style: 'grid', hide_sort: 1 })">
|
||||
<view class="img-box">
|
||||
<image :src="categoryImg(one.image)" mode="aspectFit" @error="categoryImgError(2, oneIndex, -1)" show-menu-by-longpress />
|
||||
</view>
|
||||
@@ -152,25 +197,11 @@
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="categoryLevel == 3">
|
||||
<block v-for="(one, oneIndex) in category.child_list" :key="oneIndex">
|
||||
<view :class="['category-title', '']">{{ one.category_name }}</view>
|
||||
<view class="category-list">
|
||||
<view class="category-item" v-for="(two, twoIndex) in one.child_list" :key="twoIndex"
|
||||
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: two.category_id })">
|
||||
<view class="img-box">
|
||||
<image :src="categoryImg(two.image)" mode="aspectFit" @error="categoryImgError(3, oneIndex, twoIndex)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="name">{{ two.category_name }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
</block>
|
||||
</block>
|
||||
|
||||
<!-- 模板1: 无子分类时(外层块不进入), 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
|
||||
<view v-else-if="value.template == 1 && type == 'category'">
|
||||
<view v-else-if="(value.template == 1 || value.template == 5) && type == 'category'">
|
||||
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
|
||||
<view class="template1-goods-wrap">
|
||||
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
|
||||
@@ -617,13 +648,13 @@ export default {
|
||||
* 三级分类 (value.level == 3) → true → 按二级分类分别显示各自商品
|
||||
*/
|
||||
isGroupedDisplay() {
|
||||
// 仅模板1 走"按二级分类分组显示商品"; 模板5 的层级由 value.level 直接决定(见下方 value.template == 5 分支)
|
||||
return this.value.template == 1 && this.value.level == 3;
|
||||
},
|
||||
/**
|
||||
* 模板1三级分类: 渲染所有二级分类(包括无商品的空分类)
|
||||
* 不再按"是否有商品"过滤, 保证一级分类下的二级分类全部显示,
|
||||
* 避免 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
|
||||
* 加载中(template1ChildLoading[id] === true)的二级分类暂不渲染, 避免闪烁
|
||||
* 模板1三级分类: 只渲染"有商品"的二级分类
|
||||
* 无商品的二级分类整块(标题 + 网格)都不渲染; 加载中的二级分类也暂不渲染(避免闪烁)
|
||||
* 判定时机: 该二级分类加载完成(template1ChildLoading[id] === false)且商品列表非空
|
||||
*/
|
||||
filteredGroupedChildren() {
|
||||
if (!this.isGroupedDisplay) return [];
|
||||
@@ -631,8 +662,9 @@ export default {
|
||||
return this.category.child_list.filter(one => {
|
||||
// 还在加载中: 暂不渲染(避免闪烁)
|
||||
if (this.template1ChildLoading[one.category_id]) return false;
|
||||
// 加载完成(无论是否有商品)都渲染, 无商品时显示空状态
|
||||
return true;
|
||||
// 加载完成: 只有该二级分类下有商品时才显示, 无商品则整块隐藏
|
||||
const list = this.template1ChildGoodsList[one.category_id];
|
||||
return !!(list && list.length);
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -642,7 +674,7 @@ export default {
|
||||
* 若 type 不跟随更新, 模板1 会误走 goods 分支, 导致"资讯类商品=否"的商品不显示立即咨询按钮
|
||||
*/
|
||||
type() {
|
||||
return this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
|
||||
return this.value.template == 1 || this.value.template == 5 || this.value.template == 99 ? 'category' : 'goods';
|
||||
},
|
||||
},
|
||||
created() {
|
||||
@@ -664,7 +696,14 @@ export default {
|
||||
// 模板1:根据后台"展示分类"配置 + 数据层级决定加载方式
|
||||
// 三级分类模式 (isGroupedDisplay) + 有二级分类时,按二级分类分别加载各自商品
|
||||
// 其他情况(二级分类模式 / 无二级分类),直接加载当前一级分类下的所有商品
|
||||
if (this.value.template == 1 && this.category && this.category.category_id) {
|
||||
// 模板5 渲染规则:
|
||||
// 二级分类(value.level == 2) → 商品网格(图2), 需要加载该一级分类下所有商品(合并其二级分类)
|
||||
// 三级分类(value.level == 3) → 二级分类网格(图3), 静态网格无需加载商品
|
||||
// 若该一级分类下没有子分类(兜底), 仍需要加载商品
|
||||
const template5NeedGoods = this.value.template == 5 &&
|
||||
(this.value.level == 2 ||
|
||||
!(this.category && this.category.child_list && this.category.child_list.length));
|
||||
if ((this.value.template == 1 || template5NeedGoods) && this.category && this.category.category_id) {
|
||||
if (this.isGroupedDisplay && this.category.child_list && this.category.child_list.length) {
|
||||
this.category.child_list.forEach(child => {
|
||||
this.getTemplate1ChildGoods(child.category_id);
|
||||
@@ -739,6 +778,8 @@ export default {
|
||||
}
|
||||
// 模板1:切换一级分类时重新加载该分类下的商品
|
||||
// 三级分类模式 + 有二级分类时,按二级分类分别重新加载,否则直接加载当前分类
|
||||
// 注意: 模板5 是"全部分类纵向平铺", 商品由 created 首次加载 + 父级链式懒加载提供,
|
||||
// 滚动时 select 会随 listenScroll 变化, 若此处也重载会导致滚动中反复清空/重复请求, 故排除 5
|
||||
if (this.value.template == 1 && this.index == this.select && this.category && this.category.category_id) {
|
||||
this.template1GoodsList = [];
|
||||
this.template1ChildGoodsList = {};
|
||||
@@ -1489,42 +1530,42 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 分类网格(模板5 / legacy 商品分类): 白底卡片 + 3 列 图片 + 名称
|
||||
// 与 Branch_5cfee417 的商品分类保持一致
|
||||
.category-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
margin: 0 20rpx;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
gap: 20rpx 0;
|
||||
border-radius: 10rpx;
|
||||
padding: 10rpx 0;
|
||||
|
||||
.category-item {
|
||||
width: 100% !important;
|
||||
width: 33.33%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.img-box {
|
||||
width: 70% !important;
|
||||
width: 80%;
|
||||
height: 0;
|
||||
padding-bottom: 70%;
|
||||
padding-top: 80%;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
position: relative;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent;
|
||||
|
||||
image {
|
||||
position: absolute;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1539,7 +1580,6 @@ export default {
|
||||
font-size: var(--diy-category-item-name-size, 24rpx);
|
||||
}
|
||||
|
||||
// 模板1选中状态
|
||||
&.selected {
|
||||
.img-box {
|
||||
border: 2rpx solid var(--base-color, #FF6B35);
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
{ 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) }
|
||||
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) }
|
||||
]" @tap.stop="switchOneCategory(index)">
|
||||
<view class="">{{ item.category_name }}</view>
|
||||
</view>
|
||||
@@ -152,11 +152,44 @@
|
||||
<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>
|
||||
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
|
||||
<!-- 模板5 + 二级分类(value.level==2): 单面板切换 - 右侧只显示"当前选中"的一级分类,
|
||||
点左侧分类切换, 其它分类隐藏(display:none); 每个一级分类: 标题 + 商品网格 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap"
|
||||
v-else-if="value.template == 5 && value.level == 2"
|
||||
: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>
|
||||
|
||||
<!-- 模板5 + 三级分类(value.level!=2): 左侧分类树 + 右侧纵向平铺"全部一级分类",
|
||||
每个一级分类: 标题 + 二级分类网格; 左侧点击分类用 scroll-into-view 定位,
|
||||
滚动时由 listenScroll 反推并高亮左侧分类 -->
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 5"
|
||||
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
|
||||
:refresher-enabled="true" refresher-default-style="none"
|
||||
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore"
|
||||
@scroll="listenScroll" @touchstart="touchStart">
|
||||
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
|
||||
:id="'category-' + index">
|
||||
<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"
|
||||
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
|
||||
</view>
|
||||
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y="true" class="content-wrap" v-else-if="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"
|
||||
@@ -308,6 +341,7 @@ export default {
|
||||
})
|
||||
.exec();
|
||||
if (this.value.template == 1) this.getHeightArea(-1);
|
||||
if (this.value.template == 5) this.getHeightArea(-1);
|
||||
}, 500);
|
||||
},
|
||||
watch: {
|
||||
@@ -317,6 +351,9 @@ export default {
|
||||
if (nval === false && oval === true) {
|
||||
this.$nextTick(() => {
|
||||
if (!this.heightArea.length) this.getHeightArea(-1);
|
||||
// 分类渲染完成后重新对齐到当前选中的一级分类顶部,
|
||||
// 避免首屏第一个分类的标题被顶出可视区
|
||||
if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -536,8 +573,30 @@ export default {
|
||||
switchOneCategory(index) {
|
||||
if (index >= this.categoryTree.length) return;
|
||||
this.select = index;
|
||||
this.categoryId = 'category-' + index;
|
||||
this.scrollLock = true;
|
||||
this.alignToCategory(index);
|
||||
},
|
||||
/**
|
||||
* 把右侧滚动区定位到指定一级分类的顶部
|
||||
*
|
||||
* uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动,
|
||||
* 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写;
|
||||
* 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0)
|
||||
* 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区
|
||||
* (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。
|
||||
* 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。
|
||||
* @param {Number} index 一级分类下标
|
||||
*/
|
||||
alignToCategory(index) {
|
||||
const target = 'category-' + index;
|
||||
if (this.categoryId === target) {
|
||||
this.categoryId = '';
|
||||
this.$nextTick(() => {
|
||||
this.categoryId = target;
|
||||
});
|
||||
} else {
|
||||
this.categoryId = target;
|
||||
}
|
||||
},
|
||||
saveSecondCategoryName(name) {
|
||||
this.lastSecondCategoryName = name;
|
||||
@@ -559,7 +618,10 @@ export default {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
|
||||
// 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载,
|
||||
// 并补上 heightArea[this.select] 越界保护
|
||||
if (this.value.template != 1 && this.value.loadType == 'all' &&
|
||||
this.heightArea[this.select] && this.heightArea[this.select][1] -
|
||||
scrollTop - contentWrapHeight < 300) {
|
||||
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
|
||||
}
|
||||
@@ -1674,6 +1736,134 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) =====================
|
||||
.category-template-5 {
|
||||
.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 {
|
||||
// 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致
|
||||
background: transparent;
|
||||
|
||||
.tree-wrap {
|
||||
width: 170rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.right-flex-wrap {
|
||||
background: #f5f5f5;
|
||||
|
||||
.content-wrap {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致)
|
||||
.child-category {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
|
||||
::v-deep .category-adv {
|
||||
padding: 0 20rpx;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明)
|
||||
|
||||
// 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐
|
||||
::v-deep .category-title-with-lines {
|
||||
height: 100rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 30rpx 0 20rpx;
|
||||
}
|
||||
|
||||
// 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称),
|
||||
// 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致
|
||||
|
||||
// 左侧分类栏样式
|
||||
.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 {
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
<diy-rich-text :value="item"></diy-rich-text>
|
||||
</template>
|
||||
|
||||
<!-- #ifdef H5 -->
|
||||
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
|
||||
<!-- 网页容器 -->
|
||||
<diy-webview :value="item"></diy-webview>
|
||||
</template>
|
||||
<!-- #endif -->
|
||||
|
||||
<template v-if="item.componentName == 'HorzLine'">
|
||||
<!-- 辅助线 -->
|
||||
<diy-horz-line :value="item"></diy-horz-line>
|
||||
@@ -377,14 +384,41 @@ export default {
|
||||
: index + 1;
|
||||
|
||||
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
|
||||
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
|
||||
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
|
||||
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
|
||||
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
|
||||
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
|
||||
? newItem.componentBgColor
|
||||
: newItem.pageBgColor;
|
||||
newItem.pageStyle = `background-color:${wrapBgColor};`;
|
||||
|
||||
if (newItem.margin) {
|
||||
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
|
||||
}
|
||||
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
|
||||
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
|
||||
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
|
||||
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
|
||||
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
|
||||
const rawMargin = newItem.margin;
|
||||
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
|
||||
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
|
||||
const pickMargin = (objKey, flatKey) => {
|
||||
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
|
||||
return toMarginNum(objMargin[objKey]);
|
||||
}
|
||||
return toMarginNum(newItem[flatKey]);
|
||||
};
|
||||
const marginTopNum = pickMargin('top', 'marginTop');
|
||||
const marginBottomNum = pickMargin('bottom', 'marginBottom');
|
||||
const marginBothNum = pickMargin('both', 'marginBoth');
|
||||
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
|
||||
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
|
||||
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
|
||||
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
|
||||
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
|
||||
|
||||
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
|
||||
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
|
||||
}
|
||||
|
||||
return newItem;
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
<template>
|
||||
<view :style="componentStyle" data-component-name="diy-image-nav">
|
||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
|
||||
:scroll-x="value.showStyle == 'singleSlide'">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||
style="margin-right: 28rpx;">
|
||||
<!-- 图片部分 -->
|
||||
<view v-if="value.mode != 'text'" class="image-img" :style="{
|
||||
'font-size': (value.imageSize * 2 + 'rpx') + ';',
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}">
|
||||
<image v-if="item.link.wap_url" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||
<image v-else :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'height': (item.imgHeight / 2 + 'rpx') + ';'
|
||||
}" :src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||||
</view>
|
||||
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
|
||||
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
|
||||
注意: 微信小程序端原生 show-scrollbar 属性被忽略(仅在 App 端生效),
|
||||
无法保证"始终显示/滑动时显示", 因此小程序端改用【自定义滚动条】(.img-nav-scrollbar),
|
||||
H5 端用 CSS 伪元素(见下方 #ifdef H5 样式) —— 两端视觉/逻辑保持一致。
|
||||
fix (2026-09-22): 小程序端"始终显示"失效 —— 见 measureScrollbar() 注释(内容宽度测错)。 -->
|
||||
<view class="image-nav-wrap">
|
||||
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
|
||||
:style="imageSpacingStyle"
|
||||
:scroll-x="value.showStyle == 'singleSlide'"
|
||||
:show-scrollbar="showScrollbarAttr"
|
||||
:enhanced="enhancedScrollbar"
|
||||
@scroll="onSlideScroll">
|
||||
<view class="uni-scroll-view-content">
|
||||
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
|
||||
:style="imageItemStyle">
|
||||
<!-- 图片部分 -->
|
||||
<!-- PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||||
图片按后台上传/配置的尺寸渲染,避免 uni <image> 取不到尺寸时
|
||||
回退到浏览器默认 320x240 而被"撑到最大";mode=aspectFit 保证不拉伸变形 -->
|
||||
<view v-if="value.mode != 'text'" class="image-img" :style="imageBoxStyle(item)">
|
||||
<image v-if="item.link.wap_url" class="image-content" :style="imageInnerStyle(item)"
|
||||
mode="aspectFit"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="redirectTo(item.link)"></image>
|
||||
<image v-else class="image-content" :style="imageInnerStyle(item)" mode="aspectFit"
|
||||
:src="$util.img(item.imageUrl) || $util.img('public/uniapp/default_img/goods.png')"
|
||||
show-menu-by-longpress @tap.stop="previewImg(item.imageUrl)"></image>
|
||||
</view>
|
||||
|
||||
<!-- 文字部分 -->
|
||||
<text class="image-text" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.color + ';'
|
||||
}">{{ item.title }}</text>
|
||||
<!-- 文字部分 -->
|
||||
<text class="image-text" :style="{
|
||||
'width': (item.imgWidth / 2 + 'rpx') + ';',
|
||||
'font-size': (value.font.size * 2 + 'rpx') + ';',
|
||||
'font-weight': value.font.weight + ';',
|
||||
'color': value.font.color + ';'
|
||||
}">{{ item.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- #ifdef MP -->
|
||||
<!-- 小程序端自定义水平滚动条: 原生 show-scrollbar 被忽略, 这里自绘以对齐后台/ H5 -->
|
||||
<view v-if="showCustomScrollbar" class="img-nav-scrollbar">
|
||||
<view class="img-nav-scrollbar-thumb" :style="scrollbarThumbStyle"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<ns-login ref="login"></ns-login>
|
||||
</view>
|
||||
</template>
|
||||
@@ -60,7 +74,12 @@ export default {
|
||||
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
|
||||
slideStartX: 0,
|
||||
slideStartScrollLeft: 0,
|
||||
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
|
||||
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
|
||||
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
|
||||
slideScrolling: false,
|
||||
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
|
||||
mpScroll: { viewW: 0, contentW: 0, left: 0 }
|
||||
}
|
||||
},
|
||||
created() {
|
||||
@@ -75,23 +94,94 @@ export default {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
self.bindSlideMouseEvents();
|
||||
self.bindSlideScrollbar();
|
||||
});
|
||||
// #endif
|
||||
// 小程序端:测量滚动条所需的视口/内容宽度(图片尺寸由内联样式决定, 渲染后稳定)
|
||||
// 多次(0/300/1000ms)重测: 首次渲染时节点可能尚未布局完成, 单次测量容易拿不到宽度
|
||||
// → 滚动条不显示; 之后的测量只覆盖有效值, 不会把已测得的宽度清零。
|
||||
// #ifdef MP
|
||||
var selfMp = this;
|
||||
this.$nextTick(function() {
|
||||
selfMp.measureScrollbar();
|
||||
});
|
||||
this._mpMeasureTimers = [300, 1000].map(function(delay) {
|
||||
return setTimeout(function() {
|
||||
selfMp.measureScrollbar();
|
||||
}, delay);
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
beforeDestroy() {
|
||||
// #ifdef H5
|
||||
this.unbindSlideMouseEvents();
|
||||
this.unbindSlideScrollbar();
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
if (this._mpMeasureTimers) {
|
||||
this._mpMeasureTimers.forEach(function(t) { clearTimeout(t); });
|
||||
this._mpMeasureTimers = null;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
watch: {
|
||||
componentRefresh(newValue) {
|
||||
// 监听组件刷新
|
||||
},
|
||||
// 小程序端: 列表/数量变化后, 内容宽度会变, 重新测量自定义滚动条
|
||||
'value.list'(newVal, oldVal) {
|
||||
// #ifdef MP
|
||||
if (newVal !== oldVal) {
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
self.measureScrollbar();
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
|
||||
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
|
||||
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
|
||||
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
|
||||
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
|
||||
componentBgColor() {
|
||||
const v = this.value || {};
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
const asColor = val => {
|
||||
if (Array.isArray(val)) val = val[0];
|
||||
if (typeof val !== 'string') return '';
|
||||
const s = val.trim();
|
||||
if (!s) return '';
|
||||
// 排除图片/URL 等非颜色值
|
||||
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
|
||||
return s;
|
||||
};
|
||||
const sources = [v];
|
||||
['style', 'config', 'setting', 'data'].forEach(k => {
|
||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||
});
|
||||
const candidates = [
|
||||
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
|
||||
'bgcolor', 'backgroundcolor', 'background',
|
||||
'pagebgcolor'
|
||||
];
|
||||
for (let c = 0; c < candidates.length; c++) {
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
for (const key in sources[s]) {
|
||||
if (norm(key) === candidates[c]) {
|
||||
const color = asColor(sources[s][key]);
|
||||
if (color) return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
componentStyle() {
|
||||
let style = '';
|
||||
style += 'background-color:' + this.value.componentBgColor + ';';
|
||||
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
|
||||
|
||||
if (this.value.componentAngle == 'round') {
|
||||
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
|
||||
@@ -123,9 +213,446 @@ export default {
|
||||
isIndicatorDots() {
|
||||
return this.value.carousel.type != 'hide' &&
|
||||
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
|
||||
},
|
||||
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
|
||||
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
|
||||
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
|
||||
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
|
||||
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
|
||||
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
|
||||
scrollBarMode() {
|
||||
const v = this.value || {};
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
// 从配置对象中取"滚动条"字段的原始值
|
||||
const pickRaw = obj => {
|
||||
if (!obj || typeof obj != 'object') return undefined;
|
||||
for (const key in obj) {
|
||||
const k = norm(key);
|
||||
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
// 子配置对象形态: { scrollBar: { type: 'always' } }
|
||||
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
|
||||
}
|
||||
if (val !== undefined && val !== null && val !== '') return val;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
let raw = pickRaw(v);
|
||||
// 嵌套容器兜底
|
||||
if (raw === undefined) {
|
||||
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
|
||||
raw = pickRaw(v[containers[i]]);
|
||||
if (raw !== undefined) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (raw === undefined || raw === null || raw === '') return 'slide';
|
||||
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
|
||||
if (raw === 0 || raw === '0') return 'slide';
|
||||
if (raw === 1 || raw === '1') return 'always';
|
||||
if (raw === 2 || raw === '2') return 'never';
|
||||
|
||||
const map = {
|
||||
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
|
||||
always: 'always', alwaysdisplay: 'always', show: 'always',
|
||||
never: 'never', none: 'never', hidden: 'never', hide: 'never',
|
||||
// 后台直出中文枚举时兜底
|
||||
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
|
||||
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
|
||||
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
|
||||
};
|
||||
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
|
||||
return hit || 'slide';
|
||||
},
|
||||
|
||||
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
|
||||
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
|
||||
scrollBarClass() {
|
||||
const cls = ['scrollbar-' + this.scrollBarMode];
|
||||
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
|
||||
return cls;
|
||||
},
|
||||
|
||||
// 原生 show-scrollbar / enhanced 仅在 App 端生效, 小程序端被忽略且会干扰原生行为,
|
||||
// 故小程序端一律关闭, 改用自定义滚动条; H5 端这两个属性为 no-op, 不影响。
|
||||
enhancedScrollbar() {
|
||||
// #ifndef MP
|
||||
return this.scrollBarMode != 'never';
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
return false;
|
||||
// #endif
|
||||
},
|
||||
showScrollbarAttr() {
|
||||
// #ifndef MP
|
||||
return this.scrollBarMode != 'never';
|
||||
// #endif
|
||||
// #ifdef MP
|
||||
return false;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 小程序端自定义滚动条的"是否显示"
|
||||
showCustomScrollbar() {
|
||||
// #ifdef MP
|
||||
if (this.scrollBarMode === 'never') return false;
|
||||
// 只有 singleSlide(横向可滚动)才有滚动条, 与 H5 的 .singleSlide 样式作用域保持一致
|
||||
if (this.value.showStyle != 'singleSlide') return false;
|
||||
// 内容没有溢出(无需横向滚动)则不显示
|
||||
if (!(this.mpScroll.contentW > this.mpScroll.viewW)) return false;
|
||||
// 滑动时显示: 仅在滚动中显示
|
||||
if (this.scrollBarMode === 'slide') return this.slideScrolling;
|
||||
// 始终显示: 一直显示
|
||||
return true;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 小程序端自定义滚动条 thumb 的内联样式(width + 位移)
|
||||
scrollbarThumbStyle() {
|
||||
// #ifdef MP
|
||||
const viewW = this.mpScroll.viewW;
|
||||
const contentW = this.mpScroll.contentW;
|
||||
const left = this.mpScroll.left;
|
||||
if (!viewW || !contentW || contentW <= viewW) return 'display:none;';
|
||||
const thumbW = Math.max(20, Math.floor(viewW * viewW / contentW));
|
||||
const maxLeft = contentW - viewW;
|
||||
const ratio = maxLeft > 0 ? Math.min(1, Math.max(0, left / maxLeft)) : 0;
|
||||
const thumbLeft = ratio * (viewW - thumbW);
|
||||
return 'width:' + thumbW + 'px;transform:translateX(' + thumbLeft + 'px);';
|
||||
// #endif
|
||||
},
|
||||
|
||||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
|
||||
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
|
||||
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
|
||||
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
|
||||
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
|
||||
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
|
||||
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
|
||||
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
|
||||
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
|
||||
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
|
||||
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
|
||||
imageSpacingConfig() {
|
||||
const v = this.value || {};
|
||||
const RATIO = 2; // 后台数值 → rpx 换算比例
|
||||
|
||||
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
|
||||
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
|
||||
const toRpx = val => {
|
||||
if (val === undefined || val === null || val === '') return null;
|
||||
const n = Number(val);
|
||||
if (isNaN(n)) return null;
|
||||
return Math.max(0, n) * RATIO;
|
||||
};
|
||||
|
||||
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
|
||||
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
|
||||
const sources = [v];
|
||||
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
|
||||
.forEach(k => {
|
||||
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
|
||||
});
|
||||
|
||||
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
|
||||
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
|
||||
const pick = (exact, fuzzy, nested) => {
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
if (exact.indexOf(norm(key)) != -1) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let s = 0; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
const k = norm(key);
|
||||
if (fuzzy.some(f => k.indexOf(f) != -1)) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
|
||||
for (let s = 1; s < sources.length; s++) {
|
||||
const src = sources[s];
|
||||
for (const key in src) {
|
||||
if (nested.indexOf(norm(key)) != -1) {
|
||||
const rpx = toRpx(src[key]);
|
||||
if (rpx !== null) return rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
|
||||
horizontal: pick(
|
||||
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
|
||||
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
|
||||
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
|
||||
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
|
||||
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
|
||||
),
|
||||
// 图片上间距 —— 后台现用命名 imageSpacingTop
|
||||
paddingTop: pick(
|
||||
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
|
||||
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
|
||||
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
|
||||
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
|
||||
),
|
||||
// 图片下间距 —— 后台现用命名 imageSpacingBottom
|
||||
paddingBottom: pick(
|
||||
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
|
||||
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
|
||||
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
|
||||
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
|
||||
)
|
||||
};
|
||||
},
|
||||
|
||||
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
|
||||
imageSpacingStyle() {
|
||||
const cfg = this.imageSpacingConfig;
|
||||
let style = '';
|
||||
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
|
||||
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
|
||||
return style;
|
||||
},
|
||||
|
||||
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
|
||||
imageItemStyle() {
|
||||
const horizontal = this.imageSpacingConfig.horizontal;
|
||||
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// ========== PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22): 图片展示尺寸 ==========
|
||||
// 目标: 图片按后台"上传/配置的尺寸"渲染, 不再被撑到最大(小程序/H5 表现一致)。
|
||||
// 背景: 原写法直接把 item.imgWidth / item.imgHeight(每项字段)当成内联尺寸,
|
||||
// 后台往往不下发这两个字段(或下发 "") → 生成 'NaNrpx' 这类非法值被忽略,
|
||||
// <image> 于是回退到 uni-app 默认尺寸 320px x 240px, 视觉上就是"被撑到最大"。
|
||||
// 取值优先级(后台数值单位按 rpx 约定, 经 $util.rpx() 转成当前端可用单位):
|
||||
// ① 每一项下发的 imgWidth / imgHeight —— 沿用历史约定: 数值 ÷ 2
|
||||
// ② 组件级 imgWidth / imgHeight —— 兜底, 同样 ÷ 2
|
||||
// ③ 组件级"图片尺寸"imageSize —— 与 diy-graphic-nav / diy-merch-list 一致, × 2
|
||||
// ④ 都没有 → 120 正方形, 只做最小安全兜底(远小于 320x240 默认值)
|
||||
//
|
||||
// 落地结构: 外层 .image-img(view) 与 <image> 都按解析出的尺寸渲染, 两端一致;
|
||||
// 样式统一由下面的方法输出"字符串"(见 imageBoxStyle 的说明, 对象在小程序端会失效)。
|
||||
rpxVal(val) {
|
||||
// 项目约定: 小程序/App 输出 'Nrpx', H5 输出 'Npx'(uni.upx2px)
|
||||
if (this.$util && typeof this.$util.rpx === 'function') return this.$util.rpx(val);
|
||||
return val + 'rpx';
|
||||
},
|
||||
resolveItemImgSize(item) {
|
||||
const valid = v => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return (!isNaN(n) && n > 0) ? n : null;
|
||||
};
|
||||
// ① 每一项图片自身尺寸
|
||||
const iw = valid(item && item.imgWidth);
|
||||
const ih = valid(item && item.imgHeight);
|
||||
if (iw !== null && ih !== null) return { width: iw / 2, height: ih / 2 };
|
||||
if (iw !== null) return { width: iw / 2, height: iw / 2 };
|
||||
if (ih !== null) return { width: ih / 2, height: ih / 2 };
|
||||
// ② 组件级 imgWidth / imgHeight
|
||||
const v = this.value || {};
|
||||
const ciw = valid(v.imgWidth);
|
||||
const cih = valid(v.imgHeight);
|
||||
if (ciw !== null && cih !== null) return { width: ciw / 2, height: cih / 2 };
|
||||
if (ciw !== null) return { width: ciw / 2, height: ciw / 2 };
|
||||
if (cih !== null) return { width: cih / 2, height: cih / 2 };
|
||||
// ③ 组件级"图片尺寸"
|
||||
const is = valid(v.imageSize);
|
||||
if (is !== null) return { width: is * 2, height: is * 2 };
|
||||
// ④ 安全兜底
|
||||
return { width: 120, height: 120 };
|
||||
},
|
||||
// 图片外层占位盒样式(决定图片最终尺寸)
|
||||
//
|
||||
// ⚠️ 必须返回"样式字符串", 不能返回对象!
|
||||
// 返回对象时 mp-weixin 的编译产物是 `style="{{(item.m0)}}"`(m0 是对象),
|
||||
// 小程序 WXML 不能把对象转成 style 字符串 → 整条样式直接失效:
|
||||
// · 只有 <image> 时 → 图片回退到默认 320x240(看起来"被撑到最大")
|
||||
// · 外层盒没尺寸 + 图片 width:100% 时 → 高度塌成 0(图片完全不显示)
|
||||
// diy-graphic-nav.vue 能在小程序正常显示, 正是因为它的 style 编译出来是字符串。
|
||||
imageBoxStyle(item) {
|
||||
const size = this.resolveItemImgSize(item);
|
||||
const imageSize = Number(this.value && this.value.imageSize);
|
||||
const font = (!isNaN(imageSize) && imageSize > 0) ? imageSize * 2 : size.height;
|
||||
return 'font-size:' + this.rpxVal(font) + ';'
|
||||
+ 'width:' + this.rpxVal(size.width) + ';'
|
||||
+ 'height:' + this.rpxVal(size.height) + ';';
|
||||
},
|
||||
// 图片自身样式(与占位盒同尺寸, 配合 mode="aspectFit" 等比完整显示)
|
||||
// max-width / max-height 与 diy-graphic-nav 的写法保持一致, 用于兜底夹住尺寸,
|
||||
// 保证图片不会超出后台配置的大小。
|
||||
imageInnerStyle(item) {
|
||||
const size = this.resolveItemImgSize(item);
|
||||
const w = this.rpxVal(size.width);
|
||||
const h = this.rpxVal(size.height);
|
||||
return 'width:' + w + ';height:' + h + ';max-width:' + w + ';max-height:' + h + ';';
|
||||
},
|
||||
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
|
||||
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
|
||||
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
|
||||
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
|
||||
bindSlideScrollbar() {
|
||||
// #ifdef H5
|
||||
if (this._slideScrollBound) return;
|
||||
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
|
||||
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return;
|
||||
this._slideScrollEl = el;
|
||||
this._slideScrollBound = true;
|
||||
var self = this;
|
||||
this._onSlideScroll = function() {
|
||||
if (self.scrollBarMode != 'slide') return;
|
||||
if (!self.slideScrolling) self.slideScrolling = true;
|
||||
clearTimeout(self._slideScrollTimer);
|
||||
self._slideScrollTimer = setTimeout(function() {
|
||||
self.slideScrolling = false;
|
||||
}, 800);
|
||||
};
|
||||
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
|
||||
// #endif
|
||||
},
|
||||
unbindSlideScrollbar() {
|
||||
// #ifdef H5
|
||||
if (this._slideScrollEl && this._onSlideScroll) {
|
||||
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
|
||||
}
|
||||
clearTimeout(this._slideScrollTimer);
|
||||
this._slideScrollEl = null;
|
||||
this._onSlideScroll = null;
|
||||
this._slideScrollBound = false;
|
||||
this.slideScrolling = false;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 统一的 scroll 事件处理(模板 @scroll 绑定, 小程序/H5 共用)
|
||||
// · 两端:"滑动时显示"模式 → 维护 slideScrolling(滚动中标记, H5 用于 is-scrolling 类)
|
||||
// · 小程序端: 驱动自定义滚动条 thumb 位置(原生 show-scrollbar 被忽略, 改用自绘)
|
||||
onSlideScroll(e) {
|
||||
if (this.scrollBarMode === 'slide') {
|
||||
if (!this.slideScrolling) this.slideScrolling = true;
|
||||
clearTimeout(this._slideScrollTimer);
|
||||
this._slideScrollTimer = setTimeout(() => {
|
||||
this.slideScrolling = false;
|
||||
}, 800);
|
||||
}
|
||||
// #ifdef MP
|
||||
// 首次滚动时若还没拿到尺寸, 先测量一次(图片尺寸由内联样式决定, 渲染后即稳定)
|
||||
if (!this.mpScroll.viewW || !this.mpScroll.contentW) this.measureScrollbar();
|
||||
// scroll-view 的 scroll 事件 detail 自带真实 scrollWidth(小程序原生返回),
|
||||
// 只要它比当前记录的更大(内容更长), 就更新 —— 保证滚动条长度比例准确
|
||||
if (e && e.detail && typeof e.detail.scrollWidth === 'number'
|
||||
&& e.detail.scrollWidth > this.mpScroll.contentW) {
|
||||
this.mpScroll.contentW = e.detail.scrollWidth;
|
||||
}
|
||||
let left = 0;
|
||||
if (e && e.detail && typeof e.detail.scrollLeft === 'number') left = e.detail.scrollLeft;
|
||||
this.mpScroll.left = left;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 小程序端: 测量 scroll-view 视口宽度与内容宽度(供自定义滚动条计算 thumb)
|
||||
//
|
||||
// ⚠️ fix (2026-09-22) 「始终显示水平滚动条」在小程序端不生效的根因:
|
||||
// 原实现只取 .uni-scroll-view-content 的 width 当"内容宽度"。但在 mp-weixin 中,
|
||||
// 该 view 是块级/flex 容器, 宽度恒等于 scroll-view 宽度(子项只是溢出它而不是撑开它),
|
||||
// 于是 contentW 永远 == viewW → showCustomScrollbar / scrollbarThumbStyle 判定
|
||||
// "内容没有溢出" → 滚动条永不渲染(后台选"始终显示"也无效)。
|
||||
// 现改为三级取样, 取到即可用, 且已被测得的有效值不会被后续空测量覆盖:
|
||||
// ① scroll-view 的 scrollOffset.scrollWidth —— WeChat 原生返回的真实滚动区宽度(首选, 含内边距)
|
||||
// ② 全部 .image-nav-item 的 rect 跨度 max(right) - min(left) + 左右内边距 —— 最可靠的 DOM 兜底
|
||||
// ③ 按后台下发的图片/文本尺寸累加推算(rpx → px) —— 极端情况(查询全部失败)下兜底
|
||||
measureScrollbar() {
|
||||
// #ifdef MP
|
||||
if (!uni || !uni.createSelectorQuery) return;
|
||||
var self = this;
|
||||
var query = uni.createSelectorQuery().in(this);
|
||||
query.select('.image-nav').fields({ size: true, scrollOffset: true });
|
||||
query.selectAll('.image-nav-item').boundingClientRect();
|
||||
query.exec(function(res) {
|
||||
var view = res && res[0];
|
||||
if (!view) return;
|
||||
var viewW = view.width || 0;
|
||||
var items = res && res[1];
|
||||
|
||||
// ① 原生 scrollWidth
|
||||
var contentW = view.scrollWidth || 0;
|
||||
|
||||
// ② 首尾 item 的 rect 跨度(与当前滚动位置无关: 两端同步平移, 差值稳定)
|
||||
if (contentW <= viewW && items && items.length) {
|
||||
var minLeft = Infinity;
|
||||
var maxRight = -Infinity;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var r = items[i];
|
||||
if (!r) continue;
|
||||
if (r.left < minLeft) minLeft = r.left;
|
||||
if (r.right > maxRight) maxRight = r.right;
|
||||
}
|
||||
if (maxRight > minLeft) {
|
||||
var padPx = (uni.upx2px ? uni.upx2px(16) : 8) * 2; // .image-nav 左右各 16rpx
|
||||
var spanW = (maxRight - minLeft) + padPx;
|
||||
if (spanW > contentW) contentW = spanW;
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 数据推算兜底
|
||||
if (!contentW) contentW = self.estimateContentWidthPx();
|
||||
|
||||
self.mpScroll.viewW = viewW;
|
||||
// 有效值只增不减: 避免后一次空测量把已测得的宽度清零(会导致滚动条突然消失)
|
||||
if (contentW > 0) self.mpScroll.contentW = contentW;
|
||||
else if (!self.mpScroll.contentW) self.mpScroll.contentW = 0;
|
||||
self.mpScroll.left = view.scrollLeft || 0;
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 小程序端: 按后台下发的尺寸推算内容总宽度(rpx → px), 仅在 DOM 测量失败时兜底
|
||||
estimateContentWidthPx() {
|
||||
// #ifdef MP
|
||||
const list = (this.value && this.value.list) || [];
|
||||
if (!list.length) return 0;
|
||||
const cfg = this.imageSpacingConfig;
|
||||
const gap = cfg.horizontal === null ? 28 : cfg.horizontal;
|
||||
let totalRpx = 32; // 容器左右 padding(16rpx × 2)
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (this.value.mode == 'text') {
|
||||
const w = Number(list[i] && list[i].imgWidth);
|
||||
totalRpx += (!isNaN(w) && w > 0) ? w / 2 : 0;
|
||||
} else {
|
||||
totalRpx += this.resolveItemImgSize(list[i]).width;
|
||||
}
|
||||
totalRpx += gap;
|
||||
}
|
||||
let winW = 375;
|
||||
try {
|
||||
if (uni && uni.getSystemInfoSync) winW = uni.getSystemInfoSync().windowWidth || 375;
|
||||
} catch (e) {}
|
||||
return totalRpx * winW / 750;
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 页面跳转
|
||||
redirectTo(link) {
|
||||
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
|
||||
@@ -153,14 +680,10 @@ export default {
|
||||
// #ifdef H5
|
||||
// 防止重复绑定
|
||||
if (this._slideEventsBound) return;
|
||||
var outerEl = this.getSlideOuterEl();
|
||||
var innerEl = this.getSlideContainer();
|
||||
if (!outerEl || !innerEl) return;
|
||||
// 关键发现:uni-app 的 <uni-scroll-view> web component 内部用了 BetterScroll,
|
||||
// 它会拦截 touch/mouse 事件;绑到 scroll-view 自身的 mousedown 收不到(目标被重定向到 HTML)。
|
||||
// 解决:document 级别 capture 阶段监听 + 用 clientX/clientY 落在容器 bounding box 内才处理。
|
||||
this._slideOuterEl = outerEl;
|
||||
this._slideEl = innerEl;
|
||||
// 注意:这里不再要求"挂载那一刻就能拿到滚动容器"再去绑定监听。
|
||||
// 之前只要 getSlideContainer() 返回 null(mounted 时 DOM/样式尚未就绪就会发生),
|
||||
// 就直接 return 且不再重试 → 整个拖拽能力静默失效(鼠标怎么拖都不动)。
|
||||
// 现在:监听一律先绑上,元素在每次事件里按需解析(见 ensureSlideEls)。
|
||||
this._slideEventsBound = true;
|
||||
// document 级别 capture 阶段,先于 uni-app 内部的滚动库触发
|
||||
document.addEventListener('mousedown', this.onSlideMouseDown, true);
|
||||
@@ -183,50 +706,79 @@ export default {
|
||||
this._slideEventsBound = false;
|
||||
// #endif
|
||||
},
|
||||
getSlideContainer() {
|
||||
// 本组件实例的根元素(<view data-component-name="diy-image-nav">)
|
||||
//
|
||||
// ⚠️ 必须用 this.$el 限定在"本实例"内查找元素!
|
||||
// 历史实现用 document.querySelector('[data-component-name="diy-image-nav"]'),
|
||||
// 而同一个页面可以放多个滑动图组件(如 首页的 合作伙伴 + 证书资质 + 服务资质),
|
||||
// document.querySelector 永远只返回页面上的**第一个**实例 ——
|
||||
// 于是拖拽/滚轮第 2、3 个组件时,"指针是否在容器内"用的是第 1 个组件的位置,
|
||||
// 判断不通过直接 return → 表现就是"鼠标拖不动、滚轮也没反应"。
|
||||
getSlideRootEl() {
|
||||
// #ifdef H5
|
||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> web component,
|
||||
// 嵌套结构:
|
||||
// <uni-scroll-view> ← 自定义元素
|
||||
// <div class="uni-scroll-view"> ← 第1层,visible
|
||||
// <div class="uni-scroll-view"> ← 第2层,overflowX=auto (真滚动容器)
|
||||
// 真正能响应 scrollLeft 的是第 2 层 div。
|
||||
// 通过 data-component-name 找到本组件根 view,然后向下找 .uni-scroll-view。
|
||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||
if (!componentRoot) return null;
|
||||
// 找 overflowX:auto 的 div.uni-scroll-view
|
||||
var candidates = componentRoot.querySelectorAll('.uni-scroll-view');
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var s = getComputedStyle(candidates[i]).overflowX;
|
||||
if (s === 'auto' || s === 'scroll') {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
// 兜底:取最后一个 .uni-scroll-view(经验上最后一个就是真滚动容器)
|
||||
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
||||
var el = this.$el;
|
||||
if (el && el.nodeType === 1) return el;
|
||||
return null;
|
||||
// #endif
|
||||
},
|
||||
// 用于判断 mousedown/wheel 是否落在容器内(bounding box)
|
||||
getSlideOuterEl() {
|
||||
// #ifdef H5
|
||||
// 用于判断 mousedown/wheel 是否落在容器内(用最外层 <uni-scroll-view> 的 bounding box)
|
||||
var componentRoot = document.querySelector('[data-component-name="diy-image-nav"]');
|
||||
if (!componentRoot) return null;
|
||||
var sc = componentRoot.querySelector('uni-scroll-view');
|
||||
return sc || null;
|
||||
return this.getSlideRootEl();
|
||||
// #endif
|
||||
},
|
||||
getSlideContainer() {
|
||||
// #ifdef H5
|
||||
// uni-app 的 scroll-view 在 H5 端编译为 <uni-scroll-view> 自定义元素,
|
||||
// 真正的滚动 div 是内部的 .uni-scroll-view。这里在**本实例范围内**查找:
|
||||
// ① 优先"内容宽度 > 可视宽度"判定真滚动容器(与 overflow 写法无关, 最可靠)
|
||||
// ② 其次 overflow-x: auto/scroll
|
||||
// ③ 再退化为最后一个 .uni-scroll-view
|
||||
// ④ 最后退化到 .uni-scroll-view-content 的父元素
|
||||
var root = this.getSlideRootEl();
|
||||
if (!root) return null;
|
||||
var candidates = root.querySelectorAll('.uni-scroll-view');
|
||||
var i;
|
||||
for (i = 0; i < candidates.length; i++) {
|
||||
if (candidates[i].scrollWidth > candidates[i].clientWidth + 1) return candidates[i];
|
||||
}
|
||||
for (i = 0; i < candidates.length; i++) {
|
||||
var s = getComputedStyle(candidates[i]).overflowX;
|
||||
if (s === 'auto' || s === 'scroll') return candidates[i];
|
||||
}
|
||||
if (candidates.length > 0) return candidates[candidates.length - 1];
|
||||
var content = root.querySelector('.uni-scroll-view-content');
|
||||
if (content && content.parentElement) return content.parentElement;
|
||||
return root.querySelector('uni-scroll-view');
|
||||
// #endif
|
||||
},
|
||||
// 按需解析本实例的"根元素 + 真滚动容器"(挂载时机不确定, 统一走这里)
|
||||
ensureSlideEls() {
|
||||
// #ifdef H5
|
||||
var root = this.getSlideRootEl();
|
||||
if (!root) return false;
|
||||
this._slideOuterEl = root;
|
||||
var el = this.getSlideContainer();
|
||||
if (!el) return false;
|
||||
this._slideEl = el;
|
||||
// 滚动条显隐依赖真滚动容器的 scroll 事件:mounted 时若还没拿到容器,
|
||||
// 这里补绑一次(否则"滑动时显示滚动条"也会失效)。
|
||||
if (!this._slideScrollBound) this.bindSlideScrollbar();
|
||||
return true;
|
||||
// #endif
|
||||
},
|
||||
onSlideMouseDown(e) {
|
||||
// #ifdef H5
|
||||
if (this.value.showStyle != 'singleSlide') return;
|
||||
// document 级别监听,只处理落在容器内的事件
|
||||
// 关键:e.target 可能是 HTML 本身(BetterScroll 内部 stopPropagation 导致),
|
||||
// document 级别监听,只处理落在"本实例"容器内的事件
|
||||
// 关键:e.target 可能是 HTML 本身(uni 内部事件重定向导致),
|
||||
// 所以用 clientX/clientY 判断是否落在容器 bounding box 内。
|
||||
if (!this._slideOuterEl) return;
|
||||
if (!this.ensureSlideEls()) return;
|
||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||
var el = this.getSlideContainer();
|
||||
var el = this._slideEl;
|
||||
if (!el) return;
|
||||
this.slideIsDragging = true;
|
||||
this.slideStartX = x;
|
||||
@@ -240,7 +792,7 @@ export default {
|
||||
onSlideMouseMove(e) {
|
||||
// #ifdef H5
|
||||
if (!this.slideIsDragging) return;
|
||||
var el = this.getSlideContainer();
|
||||
var el = this._slideEl;
|
||||
if (!el) return;
|
||||
var x = (e.clientX != null) ? e.clientX : 0;
|
||||
var deltaX = x - this.slideStartX;
|
||||
@@ -267,7 +819,7 @@ export default {
|
||||
// #ifdef H5
|
||||
if (!this._suppressNextClick) return;
|
||||
if (!e.target) return;
|
||||
var outer = this._slideOuterEl;
|
||||
var outer = this._slideOuterEl || this.getSlideOuterEl();
|
||||
if (!outer || !outer.contains(e.target)) return;
|
||||
this._suppressNextClick = false;
|
||||
if (e.preventDefault) e.preventDefault();
|
||||
@@ -278,13 +830,13 @@ export default {
|
||||
onSlideWheel(e) {
|
||||
// #ifdef H5
|
||||
if (this.value.showStyle != 'singleSlide') return;
|
||||
if (!this._slideOuterEl) return;
|
||||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在容器内
|
||||
// 同 mouseDown:用 clientX/clientY 判断 wheel 是否落在"本实例"容器内
|
||||
if (!this.ensureSlideEls()) return;
|
||||
var rect = this._slideOuterEl.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
var x = e.clientX, y = e.clientY;
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
|
||||
var el = this.getSlideContainer();
|
||||
var el = this._slideEl;
|
||||
if (!el) return;
|
||||
// 浏览器 wheel 事件的 deltaY 是垂直方向;此处把垂直滚轮映射到横向滚动
|
||||
var dx = e.deltaX || 0;
|
||||
@@ -301,6 +853,36 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 包裹 scroll-view 的容器(相对定位, 自定义滚动条绝对定位于其底部)
|
||||
.image-nav-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 小程序端自定义水平滚动条(MP 原生 show-scrollbar 被忽略, 改用自绘,
|
||||
// 与 H5 的 CSS 伪元素滚动条视觉一致; 元素仅在 #ifdef MP 下渲染)
|
||||
.img-nav-scrollbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 2rpx;
|
||||
height: 4rpx;
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 2rpx;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.img-nav-scrollbar-thumb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 4rpx;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 2rpx;
|
||||
transition: transform 0.08s linear;
|
||||
}
|
||||
|
||||
.image-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
@@ -327,17 +909,72 @@ export default {
|
||||
// #ifdef H5
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
|
||||
cursor: grab;
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
// 本行是 <uni-scroll-view> 自定义元素,真正产生滚动的是内部 div.uni-scroll-view,
|
||||
// 这里同步声明,保证内层容器一定是"可横向滚动"的(鼠标拖拽/滚轮/触屏滑动都依赖它)
|
||||
::v-deep .uni-scroll-view {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
|
||||
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
|
||||
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
|
||||
scrollbar-width: none; // Firefox
|
||||
-ms-overflow-style: none; // IE/Edge legacy
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
|
||||
::v-deep .uni-scroll-view {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
|
||||
&.scrollbar-always,
|
||||
&.scrollbar-slide.is-scrolling {
|
||||
scrollbar-width: thin; // Firefox
|
||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||
&::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
::v-deep .uni-scroll-view {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar {
|
||||
display: block;
|
||||
height: 4px;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
@@ -402,6 +1039,15 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 90rpx;
|
||||
overflow: hidden;
|
||||
|
||||
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
|
||||
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
|
||||
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
|
||||
.image-content {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.tag {
|
||||
position: absolute;
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="fui-list-inner">
|
||||
<view class="title" style="height: 75rpx;">
|
||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||
</view>
|
||||
<view class="title">
|
||||
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
||||
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
|
||||
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
|
||||
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
||||
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||
</view>
|
||||
@@ -35,15 +40,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
||||
mpTitleSize: 0,
|
||||
mpDescSize: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
||||
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
||||
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
||||
mounted() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
value: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +87,130 @@ export default {
|
||||
current: img,
|
||||
urls: [img]
|
||||
})
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
|
||||
// #ifdef H5
|
||||
fitOneLineText() {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
|
||||
if (!root) return
|
||||
const nodes = root.querySelectorAll('.title-text, .subtitle')
|
||||
nodes.forEach((el) => {
|
||||
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
|
||||
el.style.fontSize = ''
|
||||
let size = parseFloat(window.getComputedStyle(el).fontSize)
|
||||
const MIN_PX = 10 // 下限 10px, 再小可读性太差
|
||||
const target = el.clientWidth - 3
|
||||
let guard = 0
|
||||
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
|
||||
size -= 0.5
|
||||
el.style.fontSize = size + 'px'
|
||||
guard++
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
|
||||
fitOneLineText() {
|
||||
uni.createSelectorQuery().in(this)
|
||||
.select('.fui-list-inner').boundingClientRect()
|
||||
.select('.measure-title').boundingClientRect()
|
||||
.select('.measure-desc').boundingClientRect()
|
||||
.exec((rects) => {
|
||||
if (!rects || !rects[0]) return
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const pxPerRpx = (sys.windowWidth || 375) / 750
|
||||
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
|
||||
const avail = rects[0].width - 16 * pxPerRpx
|
||||
if (avail <= 0) return
|
||||
// 标题: 默认 28rpx, 最小 16rpx
|
||||
if (rects[1] && rects[1].width > avail) {
|
||||
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
|
||||
} else {
|
||||
this.mpTitleSize = 0
|
||||
}
|
||||
// 描述: 默认 24rpx, 最小 16rpx
|
||||
if (rects[2] && rects[2].width > avail) {
|
||||
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
|
||||
} else {
|
||||
this.mpDescSize = 0
|
||||
}
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
titleStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
},
|
||||
descStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
<style lang="scss">
|
||||
// 客服展示:标题/描述需要完整展示(中英文均不截断)
|
||||
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
|
||||
.diy-kefu {
|
||||
.fui-list-group {
|
||||
.fui-list {
|
||||
align-items: center;
|
||||
|
||||
.fui-list-inner {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
|
||||
padding-right: 8rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
|
||||
.title-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
|
||||
.measure-text {
|
||||
position: absolute;
|
||||
left: -9999rpx;
|
||||
top: 0;
|
||||
white-space: nowrap;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,10 @@
|
||||
<!-- #ifdef MP -->
|
||||
<block
|
||||
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
||||
@@ -39,7 +39,7 @@
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
}}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
@@ -69,7 +69,7 @@
|
||||
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="info">
|
||||
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
||||
</view>
|
||||
|
||||
@@ -367,6 +367,26 @@ export default {
|
||||
isDisabled() {
|
||||
if (this.nickName.length > 0) return false;
|
||||
return true;
|
||||
},
|
||||
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
|
||||
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
|
||||
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
|
||||
nameStyle() {
|
||||
let name = '';
|
||||
if (this.memberInfo && this.memberInfo.nickname) {
|
||||
name = this.memberInfo.nickname;
|
||||
} else {
|
||||
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
|
||||
}
|
||||
if (!name) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 10) return ''; // 短标题走默认 38rpx
|
||||
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
@error="imgError(index)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc">{{ item.desc }}</text>
|
||||
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
||||
<view class="read-wrap"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -179,6 +179,25 @@ export default {
|
||||
this.toDetail(item);
|
||||
}
|
||||
})
|
||||
},
|
||||
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
|
||||
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
|
||||
oneLineFontStyle(text, defaultSize, minSize) {
|
||||
if (!text) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 12) return '';
|
||||
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
},
|
||||
titleStyle(item) {
|
||||
return this.oneLineFontStyle(item.merch_name, 30, 20);
|
||||
},
|
||||
descStyle(item) {
|
||||
return this.oneLineFontStyle(item.desc, 24, 20);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -296,13 +315,19 @@ export default {
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
||||
|
||||
// flex: 1;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
.desc {
|
||||
color: #888
|
||||
color: #888;
|
||||
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -311,8 +336,9 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<view data-component-name="diy-search" class="diy-search">
|
||||
<view class="diy-search-wrap" :class="value.positionWay" :style="fixedCss">
|
||||
<!-- 卡片底板: 搜索框背后一圈底板, 与整体区域背景相互独立; 后台清空则透明(无底板) -->
|
||||
<view class="search-board" :style="boardLayerStyle">
|
||||
<view :class="['search-box', 'search-box-' + value.searchStyle]" :style="searchWrapCss"
|
||||
@tap.stop="handlerSearchClick">
|
||||
<block v-if="[1, 2].includes(value.searchStyle)">
|
||||
@@ -13,20 +15,20 @@
|
||||
<view class="search-content" :style="inputStyle">
|
||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||
:placeholderStyle="placeholderStyle" />
|
||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||
<text class="iconfont icon-sousuo3" @tap.stop="handlerSearchClick"
|
||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
||||
</view>
|
||||
</block>
|
||||
<block v-if="value.searchStyle == 3">
|
||||
<view class="search-content" :style="inputStyle"
|
||||
@tap.stop="handlerSearchClick">
|
||||
<text class="iconfont icon-sousuo3"
|
||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)' }"></text>
|
||||
:style="{ color: value.textColor ? value.textColor : 'rgba(0,0,0,0)', 'background-color': magnifierBg }"></text>
|
||||
<input type="text" class="uni-input ns-font-size-base" maxlength="50" :placeholder="value.title"
|
||||
v-model="searchText" @confirm="handlerSearchClick" disabled="true"
|
||||
@tap.stop="handlerSearchClick"
|
||||
:placeholderStyle="placeholderStyle" />
|
||||
:placeholderStyle="placeholderStyle" :style="inputElStyle" />
|
||||
<text class="search-content-btn" @tap.stop="handlerSearchClick"
|
||||
:style="{ 'backgroundColor': value.pageBgColor ? value.pageBgColor : 'rgba(0,0,0,0)' }"
|
||||
>{{ $lang('components-diy.diy-search.searchBtn') }}</text>
|
||||
@@ -41,6 +43,7 @@
|
||||
@tap.stop="handlerRedirectToClick(value.searchLink)"></diy-icon>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 解决fixed定位后导航栏塌陷的问题 -->
|
||||
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
|
||||
@@ -114,20 +117,90 @@ export default {
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 搜索行框(紧包搜索输入框的一行, 含左侧 logo/图标): 背景色优先取后台"行框背景色",
|
||||
// 未配置时回退到组件背景色(componentBgColor), 保持旧装修视觉; 兼容嵌套字段命名
|
||||
searchWrapCss() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.componentBgColor + ';';
|
||||
var rowBg = this._probe([
|
||||
'formBgColor', 'rowBgColor', 'lineBgColor', 'searchBoxBgColor', 'searchRowBgColor',
|
||||
'boxBgColor', 'rowBackground', 'lineBackground', 'searchLineBgColor'
|
||||
]) || this.value.componentBgColor || '';
|
||||
if (rowBg) obj += 'background-color:' + rowBg + ';';
|
||||
obj += 'text-align:' + this.value.textAlign + ';';
|
||||
obj += this._radiusStyle(['formBorderRadius', 'rowRadius', 'rowAngle', 'lineRadius', 'searchBoxRadius', 'boxRadius', 'rowRound']);
|
||||
obj += this._boxSpacingStyle('rowPadding', 'rowPaddingTop', 'rowPaddingBottom', 'rowPaddingLeft', 'rowPaddingRight');
|
||||
obj += this._boxSpacingStyle('rowMargin', 'rowMarginTop', 'rowMarginBottom', 'rowMarginLeft', 'rowMarginRight', 'margin');
|
||||
// 后台"搜索行框-内边距/外边距"真实字段 formPadding/formMargin 为带单位 shorthand(如 "0px")
|
||||
obj += this._cssShorthand(['formPadding', 'rowPaddingStr'], 'padding');
|
||||
obj += this._cssShorthand(['formMargin', 'rowMarginStr'], 'margin');
|
||||
// 后台"搜索行框-四边样式"真实字段 formBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||
obj += this._borderStyle('formBorder');
|
||||
return obj;
|
||||
},
|
||||
// 搜索输入框: 背景色优先取后台"输入框背景色"(含旧字段 elementBgColor), 文本色同步 textColor;
|
||||
// 高度/内边距/圆角(框体样式)取后台字段, 兼容嵌套命名与缺省
|
||||
inputStyle() {
|
||||
var obj = '';
|
||||
obj += 'background-color:' + this.value.elementBgColor + ';';
|
||||
if (this.value.borderType == 2) {
|
||||
obj += 'border-radius:' + '40rpx;';
|
||||
var inputBg = this._probe([
|
||||
'elementBgColor', 'inputBgColor', 'searchInputBgColor', 'inputBackground'
|
||||
]) || this.value.elementBgColor || '';
|
||||
if (inputBg) obj += 'background-color:' + inputBg + ';';
|
||||
if (this.value.textColor) obj += 'color:' + this.value.textColor + ';';
|
||||
obj += this._boxSpacingStyle('inputPadding', 'inputPaddingTop', 'inputPaddingBottom', 'inputPaddingLeft', 'inputPaddingRight');
|
||||
// 后台"输入框-内边距"真实字段 searchPadding 为带单位 shorthand(如 "0px")
|
||||
obj += this._cssShorthand(['searchPadding', 'inputPaddingStr'], 'padding');
|
||||
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
||||
if (h !== null) obj += 'height:' + (h * 2) + 'rpx;';
|
||||
// 框体样式(圆形): 新字段 round/circle/圆形 → 全圆角; 旧字段 borderType==2 → 40rpx
|
||||
var shape = this._probe(['boxStyle', 'inputShape', 'frameStyle', 'borderShape', 'searchShape']);
|
||||
if (shape && ['round', 'circle', '圆形', '1'].indexOf(String(shape).toLowerCase()) !== -1) {
|
||||
obj += 'border-radius:999rpx;';
|
||||
} else if (this.value.borderType == 2) {
|
||||
obj += 'border-radius:40rpx;';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 卡片底板: 搜索框背后一圈底板; 后台"底板背景色/卡片底版"真实字段名为 containerBgColor(历史命名亦兼容)
|
||||
boardLayerStyle() {
|
||||
var obj = '';
|
||||
// 探测候选名尽量覆盖后台各种命名(驼峰/snake_case/下划线都会被 _norm 归一化匹配)
|
||||
var bg = this._probe([
|
||||
'containerBgColor', 'containerBackground', 'containerColor',
|
||||
'cardBgColor', 'boardBgColor', 'cardBackground', 'boardBackground',
|
||||
'baseBgColor', 'panelBgColor', 'plateBgColor', 'bottomBoardBgColor', 'boardColor',
|
||||
'backgroundColor', 'bgColor', 'background_color', 'bg_color', 'plateColor',
|
||||
'bottomBgColor', 'basePlateBgColor', 'basePlateColor', 'cardPlateBgColor', 'cardBottomBgColor'
|
||||
]);
|
||||
if (bg) {
|
||||
obj += 'background-color:' + bg + ';';
|
||||
// 设了底板背景色却没单独配置内边距时, 搜索框会完全盖住底板 → 改了"没反应";
|
||||
// 因此未配置 boardPadding 时给默认内边距, 让底板颜色作为卡片显出来
|
||||
var hasBoardPad = this._probeNum(['boardPadding']) !== null
|
||||
|| this._probeNum(['boardPaddingTop']) !== null
|
||||
|| this._probeNum(['boardPaddingBottom']) !== null
|
||||
|| this._probeNum(['boardPaddingLeft']) !== null
|
||||
|| this._probeNum(['boardPaddingRight']) !== null
|
||||
|| this._probe(['containerPadding']) !== '';
|
||||
if (!hasBoardPad) obj += 'padding:16rpx 12rpx;';
|
||||
}
|
||||
obj += this._radiusStyle(['containerBorderRadius', 'boardRadius', 'boardAngle', 'cardRadius', 'baseRadius', 'boardRound']);
|
||||
obj += this._boxSpacingStyle('boardPadding', 'boardPaddingTop', 'boardPaddingBottom', 'boardPaddingLeft', 'boardPaddingRight');
|
||||
// 后台"底板-内边距"真实字段 containerPadding 为带单位 shorthand(如 "27px 10px 31px")
|
||||
obj += this._cssShorthand(['containerPadding', 'boardPaddingStr'], 'padding');
|
||||
obj += this._boxSpacingStyle('boardMargin', 'boardMarginTop', 'boardMarginBottom', 'boardMarginLeft', 'boardMarginRight', 'margin');
|
||||
// 后台"底板-外边距"真实字段 containerMargin 为带单位 shorthand(如 "0px")
|
||||
obj += this._cssShorthand(['containerMargin', 'boardMarginStr'], 'margin');
|
||||
// 后台"底板-四边样式"真实字段 containerBorder{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||
obj += this._borderStyle('containerBorder');
|
||||
return obj;
|
||||
},
|
||||
magnifierBg() {
|
||||
return this._probe(['magnifierBgColor', 'searchIconBgColor', 'iconBgColor', 'magnifierBackground']) || '';
|
||||
},
|
||||
inputElStyle() {
|
||||
var h = this._probeNum(['inputHeight', 'boxHeight', 'searchHeight', 'searchBoxHeight']);
|
||||
return h !== null ? { height: (h * 2) + 'rpx' } : {};
|
||||
},
|
||||
placeholderStyle() {
|
||||
var obj = '';
|
||||
if (this.value.textColor) {
|
||||
@@ -192,6 +265,93 @@ export default {
|
||||
this.setModuleLocationFn();
|
||||
},
|
||||
methods: {
|
||||
// —— 后台字段名探测(兼容多种命名 / 嵌套容器) ——
|
||||
_norm(k) {
|
||||
return String(k == null ? '' : k).toLowerCase().replace(/[_\-]/g, '');
|
||||
},
|
||||
// 在 value 自身及常见嵌套容器(style/config/setting/board/card/row/input/...)中按候选字段名探测,
|
||||
// 命中即返回原值(含 transparent); 未命中返回 ''
|
||||
_probe(candidates) {
|
||||
var self = this;
|
||||
var cands = candidates.map(function (c) { return self._norm(c); });
|
||||
var v = this.value || {};
|
||||
var sources = [v];
|
||||
['style', 'config', 'setting', 'styleSetting', 'board', 'card', 'row', 'line', 'input', 'element', 'search', 'box', 'margin', 'padding']
|
||||
.forEach(function (k) {
|
||||
var o = v[k];
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) sources.push(o);
|
||||
});
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var src = sources[i];
|
||||
if (!src || typeof src !== 'object') continue;
|
||||
var keys = Object.keys(src);
|
||||
for (var j = 0; j < keys.length; j++) {
|
||||
if (cands.indexOf(self._norm(keys[j])) !== -1) {
|
||||
var val = src[keys[j]];
|
||||
if (val !== undefined && val !== null && val !== '') return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
_probeNum(candidates) {
|
||||
var s = this._probe(candidates);
|
||||
var n = parseFloat(s);
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
// 后台带单位 shorthand 字段(如 "27px 10px 31px" / "0px"): 换算为 rpx(数值×2, 与组件内数值字段口径一致)后输出 prop:value;
|
||||
// 未命中候选名返回 ''。无 px 单位的纯数字/其他单位原样透传
|
||||
_cssShorthand(candidates, prop) {
|
||||
var raw = this._probe(candidates);
|
||||
if (raw === '') return '';
|
||||
var s = String(raw).replace(/(-?\d+(?:\.\d+)?)px/g, function (m, n) {
|
||||
return (parseFloat(n) * 2) + 'rpx';
|
||||
});
|
||||
return prop + ':' + s + ';';
|
||||
},
|
||||
// 后台四边独立边框字段: {prefix}{Top,Right,Bottom,Left}{Show,Color,Width,Style}
|
||||
// Show 为 true/1 才输出该边; 输出格式 border-xxx: 宽(rpx×2) 线型 颜色
|
||||
_borderStyle(prefix) {
|
||||
var sides = ['top', 'right', 'bottom', 'left'];
|
||||
var obj = '';
|
||||
for (var i = 0; i < sides.length; i++) {
|
||||
var s = sides[i];
|
||||
var show = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Show']);
|
||||
if (show === '' || show === 'false' || show === '0') continue;
|
||||
var color = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Color']);
|
||||
var width = this._probeNum([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Width']);
|
||||
var style = this._probe([prefix + s.charAt(0).toUpperCase() + s.slice(1) + 'Style']);
|
||||
if (width === null && !color) continue;
|
||||
obj += 'border-' + s + ':' + ((width === null ? 0 : width) * 2) + 'rpx ' + (style || 'solid') + ' ' + (color || 'transparent') + ';';
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
// 圆角: round/circle/圆形 → 全圆角; 数值 → 半径(rpx×2)
|
||||
_radiusStyle(cands) {
|
||||
var raw = this._probe(cands);
|
||||
if (raw === '') return '';
|
||||
var lower = String(raw).toLowerCase();
|
||||
if (['round', 'circle', '圆形'].indexOf(lower) !== -1) return 'border-radius:999rpx;';
|
||||
var n = parseFloat(raw);
|
||||
if (!isNaN(n)) return 'border-radius:' + (n * 2) + 'rpx;';
|
||||
return '';
|
||||
},
|
||||
// 内/外边距: 单值字段 → 四边; 否则上下左右独立字段。kind 默认 padding
|
||||
_boxSpacingStyle(single, top, bottom, left, right, kind) {
|
||||
kind = kind || 'padding';
|
||||
var singleV = this._probeNum([single]);
|
||||
if (singleV !== null) return kind + ':' + (singleV * 2) + 'rpx;';
|
||||
var s = '';
|
||||
var t = this._probeNum([top]);
|
||||
var b = this._probeNum([bottom]);
|
||||
var l = this._probeNum([left]);
|
||||
var r = this._probeNum([right]);
|
||||
if (t !== null) s += kind + '-top:' + (t * 2) + 'rpx;';
|
||||
if (b !== null) s += kind + '-bottom:' + (b * 2) + 'rpx;';
|
||||
if (l !== null) s += kind + '-left:' + (l * 2) + 'rpx;';
|
||||
if (r !== null) s += kind + '-right:' + (r * 2) + 'rpx;';
|
||||
return s;
|
||||
},
|
||||
search() {
|
||||
this.$util.redirectTo('/pages_tool/goods/search');
|
||||
},
|
||||
@@ -264,6 +424,11 @@ export default {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-board {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||
<view>
|
||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
||||
<view v-if="(pageCount === 1 || need) && hasVisibleFixBtn" class="fixed-box"
|
||||
:style="[customContainerStyle, {
|
||||
height: containerHeight,
|
||||
bottom: containerBottom,
|
||||
@@ -74,10 +74,11 @@
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
</button>
|
||||
|
||||
<!-- 电话按钮 -->
|
||||
<!-- 电话按钮 (已隐藏: 仅 display:none, DOM/事件/逻辑全部保留, 把 hidePhoneBtn 改回 false 即恢复) -->
|
||||
<view
|
||||
v-if="fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:class="{ 'btn-item-hide': hidePhoneBtn }"
|
||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
@click="call"
|
||||
>
|
||||
@@ -178,6 +179,9 @@ export default {
|
||||
langDisplayMap: { ...LANG_DISPLAY_NAMES }, // 后端 langDisplayNames 会覆盖默认值
|
||||
showLangPopup: false, // 语言选择弹窗显隐
|
||||
customerService: null,
|
||||
// 是否隐藏「电话」悬浮按钮: true = 隐藏(只隐藏不移除, DOM/点击事件/拨打逻辑都保留)
|
||||
// 需要恢复时改回 false 即可, 或按需接后端配置
|
||||
hidePhoneBtn: true,
|
||||
// ============================================================
|
||||
// 布局参数(从 App.vue 的 :root CSS 变量读取,权威源在 App.vue)
|
||||
// 关键设计:
|
||||
@@ -295,6 +299,12 @@ export default {
|
||||
const customerService = createCustomerService(this);
|
||||
return customerService.hasValidKefuConfig();
|
||||
},
|
||||
// 是否还有需要展示的浮动按钮:
|
||||
// 电话按钮被隐藏、且没有语言切换/客服按钮时, 整个浮动按钮容器都不渲染,
|
||||
// 避免留下一个透明的空容器挡住下面的页面内容点击
|
||||
hasVisibleFixBtn() {
|
||||
return !this.hidePhoneBtn || this.isLanguageSwitchEnabled || this.hasValidKefuConfig;
|
||||
},
|
||||
|
||||
//根据显示的按钮数量动态计算容器高度
|
||||
// 计算明细(buttonCount 按钮场景):
|
||||
@@ -309,7 +319,8 @@ export default {
|
||||
containerHeight() {
|
||||
if (!this.fixBtnShow) return '320rpx';
|
||||
|
||||
let buttonCount = 1;
|
||||
// 电话按钮隐藏时不再占用容器高度, 避免底部留白
|
||||
let buttonCount = this.hidePhoneBtn ? 0 : 1;
|
||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
||||
if (this.showWeappSystemKefu) buttonCount++;
|
||||
buttonCount++;
|
||||
@@ -864,6 +875,11 @@ export default {
|
||||
background-color: var(--hover-nav-bg-color);
|
||||
}
|
||||
|
||||
/* 电话悬浮按钮隐藏态: 只隐藏不移除(DOM、点击事件、拨打逻辑都还在), 去掉该类即恢复 */
|
||||
.btn-item-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-item text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
@@ -765,12 +765,27 @@ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answer
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5 端没有原生 WS 长连接, HTTP SSE 也没有 elicit_response 端点
|
||||
// 兜底: 复用 chat 端点 + action=elicit_response body
|
||||
// (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由)
|
||||
// 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent
|
||||
console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底');
|
||||
return false;
|
||||
// H5 端没有原生 WS 长连接, 走 PR-ELICIT-RESPONSE-HTTP (2026-09-18) 新端点:
|
||||
// POST /api/kefu/chat/elicit-response (aikefu)
|
||||
// POST /api/aiagent/chat/elicit-response (aiagent)
|
||||
// 后端会从 conversation 行读 wb_runtime {url,token,connection_id}, 然后 curl POST
|
||||
// jsonrpc 应答帧到 WB Runtime. blocking JSON, 不阻塞聊天主链路.
|
||||
try {
|
||||
const resp = await fetch(Config.baseUrl + resolveApiUrl('elicit-response'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id, message_id, tool_call_id, rpc_id, answers,
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || ''
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
return data && data.code === 0;
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
|
||||
return false;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
```
|
||||
@@ -2082,6 +2097,46 @@ curl -X POST 'https://your-domain.com/api/aiagent/clear-conversation' \
|
||||
- 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了
|
||||
- `clearConversation` 后端成功但前端没 clear → 下一条还带旧 id
|
||||
|
||||
#### 6.4.1 Storage key 三维化 (PR-MULTI-TURN-KEY-BINDING 2026-09-18)
|
||||
|
||||
**老 key (2026-09-18 前)**:
|
||||
```js
|
||||
ai_conversation_id_<agentId> // 例: ai_conversation_id_agent_aikefu
|
||||
```
|
||||
只按 agent 维度隔离 — 跨用户/跨 uniacid 都会串会话。
|
||||
|
||||
**新 key (2026-09-18 起)**:
|
||||
```js
|
||||
ai_conv_<uniacid>_<userId>_<agentId>
|
||||
// 例: ai_conv_1_guest_e2e_test_agent_aikefu
|
||||
```
|
||||
按 uniacid × userId × agentId 三维隔离。
|
||||
|
||||
**一次性迁移** (前端 `ai-service.js` 模块 init 时自动跑, 在 `setTimeout(0)` 里执行避开 uniapp H5 storage shim 时序坑):
|
||||
```js
|
||||
// ai-service.js module-level init
|
||||
setTimeout(() => {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) currentConversationId = saved;
|
||||
}, 0);
|
||||
```
|
||||
- 老 key 有值 + 新 key 为空 → 写入新 key
|
||||
- 老 key 保留 1 版本 (兜底, 不删)
|
||||
- 下次发版独立 PR `uni.removeStorageSync('ai_conversation_id_*')` 清老 key
|
||||
|
||||
**onUserChange 钩子** (用户登录态变化时清旧 user 的 key):
|
||||
```js
|
||||
// App.vue setMemberInfo 之后
|
||||
aiService.onUserChange();
|
||||
// 内部: 检测 ai_last_user_id 与新 userId 不同 → removeStorageSync 旧 key → setStorageSync ai_last_user_id
|
||||
```
|
||||
|
||||
**H5 时序坑** (E2E 2026-09-19 发现):
|
||||
uniapp H5 的 `uni.getStorageSync` 在 webpack module 顶层同步执行时返回空字符串,
|
||||
100ms 后才读到真值 (uniapp H5 runtime shim 在 bundle 之后才挂上 `window.uni`)。
|
||||
所有 ai-service.js 顶层的 storage 读/写必须包 `setTimeout(..., 0)` 或挂生命周期。
|
||||
|
||||
### 6.5 attachments 上传后 AI 端收不到
|
||||
|
||||
排查顺序:
|
||||
|
||||
@@ -12,6 +12,12 @@ export const lang = {
|
||||
timeFormatToday: 'Today',
|
||||
timeFormatYesterday: 'Yesterday',
|
||||
timeFormatBeforeYesterday: 'Day before yesterday',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
|
||||
timeJustNow: 'Just now',
|
||||
timeMinutesAgo: '{n} min ago',
|
||||
timeHoursAgo: '{n} h ago',
|
||||
timeMonthDay: '{m}/{d}',
|
||||
timeYearMonthDay: '{y}/{m}/{d}',
|
||||
|
||||
// ====== ai-chat-message ======
|
||||
aiNickname: 'AI Assistant',
|
||||
@@ -113,8 +119,16 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: 'More Features',
|
||||
image: 'Image',
|
||||
video: 'Video/Audio',
|
||||
file: 'File',
|
||||
voice: 'Voice',
|
||||
recordVoice: 'Record Voice',
|
||||
uploadAudio: 'Upload Audio',
|
||||
pickVideo: 'Choose Video',
|
||||
pickAudio: 'Choose Audio',
|
||||
chooseAudioFail: 'Failed to select audio',
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
location: 'Location',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
@@ -12,6 +12,12 @@ export const lang = {
|
||||
timeFormatToday: '今天',
|
||||
timeFormatYesterday: '昨天',
|
||||
timeFormatBeforeYesterday: '前天',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
|
||||
timeJustNow: '刚刚',
|
||||
timeMinutesAgo: '{n}分钟前',
|
||||
timeHoursAgo: '{n}小时前',
|
||||
timeMonthDay: '{m}月{d}日',
|
||||
timeYearMonthDay: '{y}年{m}月{d}日',
|
||||
|
||||
// ====== ai-chat-message ======
|
||||
aiNickname: '智能助手',
|
||||
@@ -113,8 +119,16 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: '更多功能',
|
||||
image: '图片',
|
||||
video: '视频/音频',
|
||||
file: '文件',
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
pickVideo: '选择视频',
|
||||
pickAudio: '选择音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"zion-uniapp-mp-load-package": "^1.0.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"jweixin-module": "^1.6.0"
|
||||
"jweixin-module": "^1.6.0",
|
||||
"playwright-core": "^1.63.0"
|
||||
},
|
||||
"uni-app": {
|
||||
"scripts": {
|
||||
@@ -89,4 +90,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,16 +90,17 @@ export default {
|
||||
}
|
||||
},
|
||||
// 跟随 goodsSkuDetail 变化重新决定默认 Tab
|
||||
// 解决 refreshGoodsSkuDetail 后续合并 pdf_url 时,Tab 已显示但 detailTab 仍为 'html' 的问题
|
||||
// 默认展示排在前面的「商品详情」, 只有在没有图文详情、仅有说明书(pdf_url)时才默认选中说明书;
|
||||
// 同时解决 refreshGoodsSkuDetail 后续合并数据时,Tab 已显示但 detailTab 未跟上的问题
|
||||
goodsSkuDetail: {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (!newVal) return;
|
||||
if (newVal.pdf_url) {
|
||||
this.detailTab = 'pdf';
|
||||
} else if (newVal.goods_content) {
|
||||
if (this.hasHtmlTab) {
|
||||
this.detailTab = 'html';
|
||||
} else if (newVal.pdf_url) {
|
||||
this.detailTab = 'pdf';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
|
||||
<view class="goods-detail-skeleton-action">
|
||||
<view class="skeleton-action-icons">
|
||||
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
|
||||
<view class="skeleton-action-icon" v-for="i in 2" :key="i"></view>
|
||||
</view>
|
||||
<view class="skeleton-action-btn"></view>
|
||||
<view class="skeleton-action-btn primary"></view>
|
||||
@@ -386,14 +386,15 @@
|
||||
<!-- 商品详情 -->
|
||||
<view v-if="hasDetailContent" class="goods-detail-tab">
|
||||
<view class="detail-tab">
|
||||
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
|
||||
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
|
||||
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
|
||||
</view>
|
||||
<!-- 商品详情 在前, 产品说明书(文档详情) 在后 -->
|
||||
<view v-if="hasHtmlTab" class="tab-item"
|
||||
:class="{ active: detailTab === 'html' }" @click="switchDetailTab('html')">
|
||||
<text class="tab-label">{{ $lang('goods.detail.details') }}</text>
|
||||
</view>
|
||||
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
|
||||
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
|
||||
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-content active">
|
||||
<!-- PDF文档 -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<view class="content" :style="themeColor">
|
||||
<view class="head-wrap">
|
||||
<view class="head-wrap" :class="{ 'head-wrap-extend': hideSort }">
|
||||
<!-- 搜索区域 -->
|
||||
<view class="search-wrap uni-flex uni-row">
|
||||
<view class="flex-item input-wrap">
|
||||
@@ -12,8 +12,8 @@
|
||||
@click="changeListStyle()"></view>
|
||||
</view>
|
||||
|
||||
<!-- 排序 -->
|
||||
<view class="sort-wrap">
|
||||
<!-- 排序 (商品分类模板5进入时隐藏, 只隐藏不移除) -->
|
||||
<view class="sort-wrap" :class="{ 'sort-wrap-hide': hideSort }">
|
||||
<view class="comprehensive-wrap" :class="{ 'color-base-text': orderType === '' }"
|
||||
@click="sortTabClick('')">
|
||||
<text :class="{ 'color-base-text': orderType === '' }">{{ $lang('goods.list.Random') }}</text>
|
||||
@@ -45,7 +45,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<mescroll-uni top="180" ref="mescroll" @getData="getGoodsList">
|
||||
<mescroll-uni :top="hideSort ? 120 : 180" ref="mescroll" @getData="getGoodsList">
|
||||
<block slot="list">
|
||||
<view v-if="isList" class="goods-list single-column" :class="{ show: isList }">
|
||||
<view class="goods-item margin-bottom" v-for="(item, index) in goodsList" :key="item.id"
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
|
||||
// 商品分类模板5进入时排序栏被隐藏, 让白色背景继续向下延伸(搜索框位置不变),
|
||||
// 一直覆盖到第一行商品卡片顶部, 避免搜索框下方出现灰色空白条
|
||||
&.head-wrap-extend {
|
||||
// 26rpx = mescroll top(100rpx) - 搜索区高度(94rpx) + 列表 padding-top($margin-updown: 20rpx)
|
||||
padding-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
flex: 0.5;
|
||||
padding: 30rpx 30rpx 0;
|
||||
@@ -60,6 +67,12 @@
|
||||
.sort-wrap {
|
||||
display: flex;
|
||||
padding: 10rpx 20rpx 10rpx 0;
|
||||
|
||||
// 商品分类模板5进入商品列表时隐藏排序栏(综合/销量/价格/筛选), 只隐藏不移除
|
||||
&.sort-wrap-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> view {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
@@ -19,6 +19,7 @@ export default {
|
||||
coupon: 0,
|
||||
emptyShow: false,
|
||||
isList: true, //列表样式
|
||||
hideSort: false, //是否隐藏排序栏(综合/销量/价格/筛选)
|
||||
//分享所需标题
|
||||
share_title: '',
|
||||
//搜索到多少件商品
|
||||
@@ -82,6 +83,15 @@ export default {
|
||||
});
|
||||
|
||||
this.categoryId = options.category_id || 0;
|
||||
// 商品分类(模板5)点二级分类进入时带 list_style=grid, 默认展示竖版(双列宫格)视图;
|
||||
// 其它入口(搜索/优惠券/品牌等)保持原有的单列列表视图
|
||||
if (options.list_style === 'grid') {
|
||||
this.isList = false;
|
||||
}
|
||||
// 商品分类(模板5)进入时隐藏排序栏(综合/销量/价格/筛选), 只隐藏不移除
|
||||
if (options.hide_sort == 1) {
|
||||
this.hideSort = true;
|
||||
}
|
||||
this.keyword = options.keyword || '';
|
||||
this.coupon = options.coupon || 0;
|
||||
this.goods_id_arr = options.goods_id_arr || 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,7 +263,10 @@ export default {
|
||||
// 搜索入口按钮
|
||||
.search-entrance {
|
||||
position: absolute;
|
||||
top: 44rpx;
|
||||
/* 与新对话按钮垂直居中对齐:
|
||||
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
|
||||
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
|
||||
top: 14rpx;
|
||||
right: 16rpx;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
|
||||
@@ -9,27 +9,37 @@
|
||||
</button>
|
||||
</view>
|
||||
<view class="tools-grid">
|
||||
<view class="tool-item" @click="selectImage">
|
||||
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
|
||||
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
|
||||
location 不属于附件, 不受此开关控制 -->
|
||||
<view v-if="isTypeEnabled('image')" class="tool-item" @click="selectImage">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-image"></text>
|
||||
<text class="icondiy icon-system-image-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectFile">
|
||||
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-file"></text>
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</view>
|
||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||
</view>
|
||||
<view v-if="isTypeEnabled('file')" class="tool-item" @click="selectFile">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-file-2-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="startRecord">
|
||||
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectLocation">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-location"></text>
|
||||
<text class="icondiy icon-system-map-pin-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||
</view>
|
||||
@@ -45,11 +55,14 @@ import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
|
||||
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
|
||||
* location 不属于附件, 不受此 prop 影响
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* image-selected - 选择图片,参数:图片消息对象
|
||||
* file-selected - 选择文件,参数:文件消息对象
|
||||
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||
* voice-start - 开始语音
|
||||
* location-selected - 选择位置,参数:位置消息对象
|
||||
*/
|
||||
@@ -60,6 +73,12 @@ export default {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
attachmentTypes: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return ['image', 'file', 'audio', 'video']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -74,6 +93,13 @@ export default {
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
|
||||
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
|
||||
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
|
||||
isTypeEnabled(kind) {
|
||||
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
|
||||
return this.attachmentTypes.indexOf(kind) !== -1
|
||||
},
|
||||
|
||||
selectImage() {
|
||||
const that = this
|
||||
uni.chooseImage({
|
||||
@@ -153,6 +179,153 @@ export default {
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
|
||||
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
|
||||
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
|
||||
selectVideo() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const mediaInput = document.createElement('input')
|
||||
mediaInput.type = 'file'
|
||||
mediaInput.accept = 'video/*,audio/*'
|
||||
mediaInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, that._guessMediaKind(file))
|
||||
}
|
||||
mediaInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
that.$lang('ai-chat.index.pickVideo', '选择视频'),
|
||||
that.$lang('ai-chat.index.pickAudio', '选择音频')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) that._chooseVideo()
|
||||
else if (res.tapIndex === 1) that.selectAudio()
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 按 mime / 扩展名判断选中的文件是视频还是音频
|
||||
_guessMediaKind(file) {
|
||||
const type = (file && file.type ? file.type : '').toLowerCase()
|
||||
if (type.indexOf('audio/') === 0) return 'audio'
|
||||
if (type.indexOf('video/') === 0) return 'video'
|
||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid']
|
||||
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||
return type ? 'video' : 'audio'
|
||||
},
|
||||
|
||||
// 选视频 (微信小程序 / App 共用)
|
||||
_chooseVideo() {
|
||||
const that = this
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||
selectVoiceOrAudio() {
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
this.$lang('ai-chat.index.recordVoice'),
|
||||
this.$lang('ai-chat.index.uploadAudio')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.startRecord()
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.selectAudio()
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
},
|
||||
|
||||
// 选择音频文件并上传 (H5 / 微信小程序 / App)
|
||||
selectAudio() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const audioInput = document.createElement('input')
|
||||
audioInput.type = 'file'
|
||||
audioInput.accept = 'audio/*'
|
||||
audioInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
audioInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
|
||||
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||
? uni.chooseMessageFile
|
||||
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||
if (!chooseFileApi) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr', 'wma', 'aiff'],
|
||||
success: async (res) => {
|
||||
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr'],
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
async _uploadOneFile(file, kind) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
|
||||
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
|
||||
@@ -177,6 +350,12 @@ export default {
|
||||
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
|
||||
fileSize: file.size || 0,
|
||||
fileType: file.type || '',
|
||||
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
|
||||
url: displayPath,
|
||||
duration: file.duration || 0,
|
||||
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
imageUrl: displayPath,
|
||||
uploading: true,
|
||||
uploadError: null,
|
||||
@@ -188,12 +367,18 @@ export default {
|
||||
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
|
||||
// 非 H5 传 file.path (uni.uploadFile 需要)
|
||||
const inputArg = blobUrl || file.path || file
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
|
||||
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
|
||||
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
|
||||
const uploaded = kind === 'image'
|
||||
? await uploadImage(inputArg)
|
||||
: await uploadMedia(inputArg, kind)
|
||||
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
|
||||
if (uploaded.url) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || '上传失败')
|
||||
@@ -325,7 +510,8 @@ export default {
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.iconfont {
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
|
||||
<view class="chat-content" :style="{ bottom: '0rpx' }">
|
||||
<ai-chat-message
|
||||
v-if="uiCfgReady"
|
||||
ref="chat"
|
||||
:preloaded-ui-config="preloadedUiCfg"
|
||||
:initial-messages="initialMessages"
|
||||
:fallback-welcome="fallbackWelcome"
|
||||
:user-avatar="userAvatar"
|
||||
@@ -96,6 +98,12 @@ export default {
|
||||
agentId: 'agent_aikefu',
|
||||
pageTitle: '',
|
||||
|
||||
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
|
||||
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
|
||||
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
|
||||
uiCfgReady: false,
|
||||
preloadedUiCfg: null,
|
||||
|
||||
// --- 系统导航栏/状态栏相关 ---
|
||||
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
||||
statusBarHeight: 0, // 状态栏高度,默认0
|
||||
@@ -140,6 +148,34 @@ export default {
|
||||
async onLoad(options) {
|
||||
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
||||
|
||||
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
|
||||
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
|
||||
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
|
||||
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
|
||||
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
|
||||
try {
|
||||
const cached = aiService.getChatUiConfigSync();
|
||||
if (cached) {
|
||||
this.preloadedUiCfg = cached;
|
||||
this.uiCfgReady = true;
|
||||
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
|
||||
}
|
||||
} catch (e) { /* 缓存读取失败走预取 */ }
|
||||
if (!this.uiCfgReady) {
|
||||
try {
|
||||
const cfg = await Promise.race([
|
||||
aiService.getChatUiConfig(true),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 6000))
|
||||
]);
|
||||
if (cfg) this.preloadedUiCfg = cfg;
|
||||
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
|
||||
} catch (e) { /* 失败兜底默认值 */ }
|
||||
this.uiCfgReady = true;
|
||||
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
|
||||
}
|
||||
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
|
||||
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
|
||||
|
||||
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
|
||||
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
|
||||
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
|
||||
|
||||
Reference in New Issue
Block a user