Compare commits

..

32 Commits

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

View File

@@ -242,8 +242,9 @@ const localDevConfig = ({
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
//})['5g-317']; // 电磁兼容
})['5g-351']; //
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['5g-351']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

11
App.vue
View File

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

View File

@@ -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 */ }
},

View File

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

View File

@@ -116,8 +116,17 @@ 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
}
}
@@ -159,8 +168,18 @@ export async function uploadMedia(filePath, kind, opts = {}) {
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
if (strategy === 'backend') {
return await doPhpUpload()
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 {
@@ -168,8 +187,15 @@ export async function uploadMedia(filePath, kind, opts = {}) {
} 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)
return await doPhpUpload(e)
try {
return await doPhpUpload(e)
} catch (e2) {
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
throw e2
}
}
}
@@ -213,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))
@@ -256,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
}
@@ -311,7 +347,153 @@ function extractExt(filePath, mimeHint = '', nameHint = '') {
return ext
}
/**
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
*
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
*
* 端点: POST /<addon>/api/upload/ossPresign
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
*/
/**
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
*/
function uniRequest(opts) {
return new Promise((resolve, reject) => {
uni.request(Object.assign({}, opts, {
success: resolve,
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
}))
})
}
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
let mimeHint = hints.mime || ''
let nameHint = hints.name || ''
let probeFile = null
// #ifdef H5
try {
probeFile = await pathToFile(filePath, kind)
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
} catch (e) {
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
console.warn('[media-upload] presign probe failed:', e && e.message)
}
// #endif
const ext = extractExt(filePath, mimeHint, nameHint)
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
const contentType = mimeHint || guessMime(finalExt)
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
? nameHint
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
const preResp = await uniRequest({
url: Config.baseUrl + preUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
filename,
content_type: contentType,
uniacid: aiService.getUniacid(),
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
},
})
if (preResp.statusCode === 404) {
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
err.code = 'PRESIGN_UNSUPPORTED'
throw err
}
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
}
const pre = preResp.data.data
const uploadUrl = pre.upload_url
const finalContentType = pre.content_type || contentType
let uploadedSize = knownSize || 0
// #ifdef H5
if (probeFile && probeFile.size) uploadedSize = probeFile.size
// #endif
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
// #ifdef H5
const file = probeFile || await pathToFile(filePath, kind)
const ossRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': finalContentType },
body: file,
})
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
// #endif
// #ifdef MP-WEIXIN
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath,
success: (res) => {
uni.request({
url: uploadUrl,
method: 'PUT',
header: { 'Content-Type': finalContentType },
data: res.data,
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
? resolve()
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
fail: reject,
})
},
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
})
})
// #endif
return {
url: (pre.public_url || uploadUrl.split('?')[0]),
mime: finalContentType,
size: uploadedSize,
name: (pre.object_key || filename).split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider: pre.provider || 'alioss',
}
}
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
const providerHint = (config && config.oss_provider) || ''
if (!providerHint || providerHint === 'alioss') {
try {
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
} catch (e) {
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
}
}
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
@@ -321,7 +503,7 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize =
// 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: {
@@ -377,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`

View File

@@ -187,8 +187,9 @@
<!-- 三级分类(及更深层级): 一级分类标题 + 二级分类网格(图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>

View File

@@ -64,6 +64,13 @@
<diy-rich-text :value="item"></diy-rich-text>
</template>
<!-- #ifdef H5 -->
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
<!-- 网页容器 -->
<diy-webview :value="item"></diy-webview>
</template>
<!-- #endif -->
<template v-if="item.componentName == 'HorzLine'">
<!-- 辅助线 -->
<diy-horz-line :value="item"></diy-horz-line>
@@ -385,12 +392,33 @@ export default {
: newItem.pageBgColor;
newItem.pageStyle = `background-color:${wrapBgColor};`;
if (newItem.margin) {
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
}
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
const rawMargin = newItem.margin;
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const pickMargin = (objKey, flatKey) => {
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
return toMarginNum(objMargin[objKey]);
}
return toMarginNum(newItem[flatKey]);
};
const marginTopNum = pickMargin('top', 'marginTop');
const marginBottomNum = pickMargin('bottom', 'marginBottom');
const marginBothNum = pickMargin('both', 'marginBoth');
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
}
return newItem;

View File

@@ -2,43 +2,51 @@
<view :style="componentStyle" data-component-name="diy-image-nav">
<!-- PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态(后台配置)
slide=滑动时显示(默认) / always=始终显示 / never=永不显示
小程序端: show-scrollbar 仅在 enhanced=true 时生效, 所以两者同步绑定 -->
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle, scrollBarClass]"
:style="imageSpacingStyle"
:scroll-x="value.showStyle == 'singleSlide'"
:show-scrollbar="scrollBarMode != 'never'"
:enhanced="scrollBarMode != 'never'">
<view class="uni-scroll-view-content">
<view v-for="(item, index) in value.list" :key="index" :class="['image-nav-item', value.mode]"
:style="imageItemStyle">
<!-- 图片部分 -->
<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>
注意: 微信小程序端原生 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>
@@ -69,7 +77,9 @@ export default {
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
slideScrolling: false
slideScrolling: false,
// 小程序端自定义滚动条度量(原生 show-scrollbar 被忽略, 改用自绘)
mpScroll: { viewW: 0, contentW: 0, left: 0 }
}
},
created() {
@@ -87,16 +97,47 @@ export default {
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: {
@@ -240,6 +281,55 @@ export default {
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) 核对, 实际为:
@@ -349,6 +439,75 @@ export default {
}
},
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 后移除类再隐藏。
@@ -356,6 +515,8 @@ export default {
bindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollBound) return;
// 挂载瞬间若还没拿到滚动容器,直接 return 不标记已绑定;
// ensureSlideEls() 会在用户首次交互时重试(见 emit 处),不会永久失效。
var el = this.getSlideContainer();
if (!el) return;
this._slideScrollEl = el;
@@ -385,6 +546,113 @@ export default {
// #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) {
@@ -412,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);
@@ -442,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;
@@ -499,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;
@@ -526,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();
@@ -537,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;
@@ -560,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;
@@ -586,6 +909,17 @@ export default {
// #ifdef H5
overflow-x: auto;
overflow-y: hidden;
// 拖拽手势提示(桌面端鼠标用户能看出这一行是可以拖动的)
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;
@@ -705,6 +1039,15 @@ export default {
align-items: center;
justify-content: center;
font-size: 90rpx;
overflow: hidden;
// PR-FRONTEND-DIY-IMAGE-NAV-IMG-SIZE (2026-09-22):
// 兜底夹住图片尺寸(不设 width/height:100%, 避免外层盒尺寸异常时高度塌成 0
// 导致图片完全不显示)。正常尺寸由 imageInnerStyle 输出的内联样式决定。
.image-content {
max-width: 100%;
max-height: 100%;
}
.tag {
position: absolute;

View File

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

View File

@@ -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;
}

View File

@@ -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 端收不到
排查顺序:

View File

@@ -12,6 +12,12 @@ export const lang = {
timeFormatToday: 'Today',
timeFormatYesterday: 'Yesterday',
timeFormatBeforeYesterday: 'Day before yesterday',
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
timeJustNow: 'Just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeMonthDay: '{m}/{d}',
timeYearMonthDay: '{y}/{m}/{d}',
// ====== ai-chat-message ======
aiNickname: 'AI Assistant',

View File

@@ -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: '智能助手',

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
<!-- 底部 action 骨架占位:与 ns-goods-action 同样 fixed bottom, 避免数据未到时底部跳变 -->
<view class="goods-detail-skeleton-action">
<view class="skeleton-action-icons">
<view class="skeleton-action-icon" v-for="i in 2" :key="'sk-act-icon-' + i"></view>
<view class="skeleton-action-icon" v-for="i in 2" :key="i"></view>
</view>
<view class="skeleton-action-btn"></view>
<view class="skeleton-action-btn primary"></view>
@@ -386,14 +386,15 @@
<!-- 商品详情 -->
<view v-if="hasDetailContent" class="goods-detail-tab">
<view class="detail-tab">
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
</view>
<!-- 商品详情 在前, 产品说明书(文档详情) 在后 -->
<view v-if="hasHtmlTab" class="tab-item"
:class="{ active: detailTab === 'html' }" @click="switchDetailTab('html')">
<text class="tab-label">{{ $lang('goods.detail.details') }}</text>
</view>
<view v-if="goodsSkuDetail.pdf_url" class="tab-item"
:class="{ active: detailTab === 'pdf' }" @click="switchDetailTab('pdf')">
<text class="tab-label">{{ $lang('goods.detail.document') }}</text>
</view>
</view>
<view class="detail-content active">
<!-- PDF文档 -->

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,12 @@
<!-- 顶部栏: 标题 + 新对话 -->
<view class="chat-topbar">
<text class="chat-title">{{ $lang('ai-chat.index.aiNickname') }}</text>
<!-- PR-CHAT-UI-CONFIG-V2 (2026-09-24): 智能体显示名走后端配置, 空则回落 i18n 默认
PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_name 控制顶栏名称显隐 -->
<text class="chat-title" v-if="chatUiConfig.ui_show_assistant_name">{{ chatUiConfig.ui_assistant_name || $lang('ai-chat.index.aiNickname') }}</text>
<view class="new-chat-btn" @click="onNewChat">
<text class="new-chat-icon">🔄</text>
<!-- 气泡+加号线性图标 (base64 SVG: 小程序不支持内联 SVG) -->
<view class="new-chat-icon"></view>
<text>新对话</text>
</view>
</view>
@@ -136,7 +139,8 @@
<!-- AI消息 -->
<view v-else class="ai-message">
<view class="avatar">
<!-- PR-CHAT-UI-CONFIG-V3 (2026-09-24): ui_show_assistant_avatar 控制头像显隐; 自定义头像在 aiAvatarUrl computed 优先 -->
<view class="avatar" v-if="chatUiConfig.ui_show_assistant_avatar">
<!-- 智能客服头像 -->
<image
v-if="aiAvatarUrl && !avatarError"
@@ -148,27 +152,30 @@
</view>
<view class="message-content">
<!-- 思考流折叠面板 (AI 思考过程) -->
<view v-if="message.role === 'ai' && message.thoughtsText" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }">
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_show_thinking_process, 默认 true 不变体感 -->
<view v-if="message.role === 'ai' && message.thoughtsText && chatUiConfig.ui_show_thinking_process" class="thought-panel" :class="{ streaming: message.isStreaming && message.thoughtsText }">
<view class="thought-header" @tap.stop="toggleThought(message)">
<text class="thought-icon">{{ message.isStreaming ? '🧠' : '💡' }}</text>
<text class="thought-label">{{ message.isStreaming ? '思考中...' : ('思考过程 (' + (message.thoughtSteps || 1) + ' 步)') }}</text>
<text v-if="!message.isStreaming" class="thought-toggle">{{ message.thoughtExpanded === false ? '▶' : '▼' }}</text>
</view>
<view v-if="message.isStreaming || message.thoughtExpanded !== false" class="thought-content">
<text>{{ message.thoughtsText }}</text>
<!-- PR-CHAT-SELECTABLE (2026-09-23): 思考过程文本同样支持长按选中复制 -->
<text selectable>{{ message.thoughtsText }}</text>
</view>
</view>
<!-- AI昵称固定,不可修改 - 左移到气泡上方 -->
<view class="message-nickname ai-nickname">
<text class="ai-nickname-text">{{ $lang('ai-chat.index.aiNickname') }}</text>
<!-- AI 回答上方只显示时间(昵称"智能助手"已按需求移除) -->
<view class="message-nickname ai-nickname" v-if="chatUiConfig.ui_show_message_time">
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view>
<view class="message-bubble">
<!-- 文本消息:小程序用 text selectable,H5 用可选择的 HTML 渲染 -->
<view v-if="message.type === 'text'" class="text-content">
<!-- #ifdef MP-WEIXIN -->
<rich-text class="ai-message-rich" :nodes="getAIDisplayContent(message.content)"></rich-text>
<!-- PR-CHAT-SELECTABLE (2026-09-23): rich-text 不支持文本选中, 改用 mp-html + selectable
开启原生长按选中(剪切/复制/粘贴/全选); force 同时兼容微信 iOS -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ai-message-text selectable-md" v-html="getAIDisplayContent(message.content)"></view>
@@ -183,7 +190,8 @@
</view>
<view class="markdown-body">
<!-- #ifdef MP-WEIXIN -->
<rich-text class="ai-message-rich" :nodes="getAIDisplayContent(message.content)"></rich-text>
<!-- PR-CHAT-SELECTABLE (2026-09-23): 同上, mp-html + selectable 支持长按选中复制 -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content)" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="selectable-md" v-html="getAIDisplayContent(message.content)"></view>
@@ -288,7 +296,22 @@
</text>
</view>
</view>
<!-- PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题
欢迎语气泡下方显示快捷问题 chips, 点击直接作为用户消息发送
一次性,点了之后从 message 上摘除 (避免重复点) -->
<view v-if="message.quickQuestions && message.quickQuestions.length > 0" class="quick-questions-row">
<view
v-for="(q, qi) in message.quickQuestions"
:key="qi"
class="quick-question-chip"
hover-class="quick-question-chip--hover"
@click="onQuickQuestionClick(message, q)">
<text class="quick-question-icon">💬</text>
<text class="quick-question-text">{{ getQuickQuestionText(q) }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="message-actions" v-if="message.actions && message.actions.length > 0">
<button
@@ -302,12 +325,9 @@
</view>
</view>
<!-- 工具调用 (调试可见性) -->
<view v-if="message.toolCalls && message.toolCalls.length" class="tool-calls">
<view v-for="tc in message.toolCalls" :key="tc.tool_call_id || tc.id" class="tool-call-item" :class="['status-' + (tc.status || 'pending')]">
<text>🔧 {{ tc.tool_name }}: {{ tc.status }}{{ tc.duration_ms ? (' (' + tc.duration_ms + 'ms)') : '' }}</text>
</view>
</view>
<!-- PR-CHAT-UI-HIDE-TOOL-CALLS (2026-09-23): 工具调用列表(Skill/Terminal: completed 等)
已按需求永久移除, AI 每次回答都不再显示该模块.
后端仍会下发 tool_calls 字段(仅存于 message.toolCalls 数据里, 不参与渲染) -->
<!-- 附件 (图片/文件) -->
<view v-if="message.attachments && message.attachments.length" class="attachments">
@@ -341,7 +361,8 @@
</view>
<!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading'">
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 后端可关闭 ui_enable_feedback (隐藏点赞/点踩/更多), 默认 true -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading' && chatUiConfig.ui_enable_feedback">
<view class="ai-action-btn" @click="onAiActionCopy(message)">
<text class="iconfont icon-copy">⎘</text>
</view>
@@ -371,7 +392,7 @@
出现时输入框自动向上撑高, 删除后恢复原高度。
图片与文件走同一套 pending 逻辑: 选完先在这里预览, 等用户点发送再随文字一起发出 -->
<view v-if="pendingImages.length || pendingFiles.length" class="pending-attachment">
<view v-for="p in pendingImages" :key="'img-' + p.localId" class="pending-item">
<view v-for="p in pendingImages" :key="p.localId" class="pending-item">
<image :src="p.uploaded ? p.uploaded.url : p.tempPath" mode="aspectFill" class="pending-image" />
<view v-if="p.uploading" class="pending-mask"><text>上传中</text></view>
<view v-else-if="p.uploadError" class="pending-mask error"><text>失败</text>
@@ -380,7 +401,7 @@
</view>
<view class="pending-remove" @tap="removePendingImage(p.localId)">×</view>
</view>
<view v-for="f in pendingFiles" :key="'file-' + f.localId" class="pending-item">
<view v-for="f in pendingFiles" :key="f.localId" class="pending-item">
<image v-if="f.type === 'image' && (f.uploaded ? f.uploaded.url : f.imageUrl)" :src="f.uploaded ? f.uploaded.url : f.imageUrl" mode="aspectFill" class="pending-image" />
<view v-else class="pending-file-thumb">
<text class="pending-file-icon">{{ f.type === 'video' ? '🎬' : (f.type === 'audio' ? '🎵' : '📄') }}</text>
@@ -426,7 +447,7 @@
class="message-input"
placeholder="发消息或按住说话"
placeholder-class="message-input-placeholder"
:maxlength="500"
:maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="adjustPosition"
@@ -508,8 +529,10 @@
</view>
<!-- 更多工具面板 -->
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 透传后端 ui_enable_attachment_types 控制各 tab 显隐 -->
<ai-chat-tools-panel
:visible.sync="showToolsPanel"
:attachment-types="chatUiConfig.ui_enable_attachment_types"
@image-picked="onImagePicked"
@file-selected="onFileSelected"
@voice-start="showVoicePanel"
@@ -532,11 +555,11 @@
<text>×</text>
</button>
</view>
<textarea
v-model="expandInputText"
<textarea
v-model="expandInputText"
class="expand-editor-input"
:placeholder="$lang('ai-chat.index.inputPlaceholder')"
:maxlength="500"
:maxlength="chatUiConfig.ui_input_max_length"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="true"
@@ -592,6 +615,13 @@ export default {
type: Array,
default: () => []
},
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面渲染前预取的聊天 UI 配置
// 传入后 created() 立即用作首帧值, 消除"默认值渲染 → 异步配置到达跳变"的闪烁;
// 之后 _loadChatUiConfig() 仍会拉最新值覆盖 (与父页面共享 60s TTL 缓存)
preloadedUiConfig: {
type: Object,
default: null
},
// 初始用户头像(外部传入,可选)
initUserAvatar: {
type: String,
@@ -683,6 +713,10 @@ export default {
streamInterval: null,
streamContent: '',
isStreaming: false,
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 正在回答时又发新问题, 用户选"单独处理"
// → 该消息排队, 等本轮流式回答结束后自动发送
// (避免后端把"上一轮未完成的请求"和"新问题"并行/合并处理, 导致答非所问串题)
pendingSendAfterStream: false,
// 本地用户头像(优先使用缓存,无缓存则用默认图)
localUserAvatar: '',
showAvatarSelector: false, // 头像选择弹窗控制
@@ -693,7 +727,38 @@ export default {
avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
selectedModel: 'Auto',
availableModels: ['Auto', 'GPT-4', 'Claude', '文心一言', '通义千问', 'Kimi'],
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20): availableModels 由后端 chat_ui_config.ui_available_models 注入
// 初始仅 'Auto' 兜底, created() 里后端 /info 返回后异步覆盖完整列表
// 数据源 (后端维护): https://docs.openclaw.ai/providers
availableModels: ['Auto'],
// PR-CHAT-UI-CONFIG (2026-09-20): 前端 UI 行为开关
// 字段与 ai-service.js::CHAT_UI_DEFAULTS 一一对应, 是后端 getChatUiConfig() 的缓存副本
// 5 个 P0 开关:
// ui_show_thinking_process - 控制是否显示 AI 思考过程面板
// ui_enable_attachment_types - 控制 tools-panel 4 类附件 tab 显隐 (image/file/audio/video)
// ui_input_max_length - 输入框最大字符数, 替代硬编 500
// ui_available_models - 模型选择器列表 (PR-CHAT-UI-CONFIG-SERVER-ONLY: 后端注入)
// ui_enable_feedback - 控制是否显示 AI 消息下方操作栏 (复制/点赞/点踩/更多)
// 加载失败/未配置时全走前端默认值 (ai-service.js 兜底), 这里手填一份做双重保险
// - ui_available_models 兜底仅 'Auto', 实际清单完全来自后端 (Config.php::DEFAULT_UI_CONFIG)
chatUiConfig: {
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
},
chatUiConfigLoaded: false,
// AiChat API相关
currentConversationId: null,
@@ -722,13 +787,18 @@ export default {
}
},
computed: {
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 后台配置了自定义头像则优先 ($util.img 兼容 pic_path 与完整 URL)
aiAvatar() {
const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar;
if (custom) return this.$util.img(custom);
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
defaultAvatar() {
return this.$util.img(IMAGE_CONFIG.userAvatar);
},
aiAvatarUrl() {
const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar;
if (custom) return this.$util.img(custom);
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
chatMessagesStyle() {
@@ -817,8 +887,25 @@ export default {
this.currentConversationId = aiService.getConversationId();
console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length);
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面预取配置优先作为首帧值
// 父页面已保证渲染本组件时配置就绪 (缓存或预取), 此处同步落位;
// _loadChatUiConfig() 随后仍拉最新值 (TTL 缓存命中则同值, 无视觉跳变)
if (this.preloadedUiConfig && typeof this.preloadedUiConfig === 'object') {
this.chatUiConfig = Object.assign({}, this.chatUiConfig, this.preloadedUiConfig)
if (Array.isArray(this.preloadedUiConfig.ui_available_models) && this.preloadedUiConfig.ui_available_models.length) {
this.availableModels = this.preloadedUiConfig.ui_available_models.slice()
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
console.log('[AI Chat] chatUiConfig 使用父页面预取值 (首帧即真实配置)')
}
// 异步获取服务配置(含欢迎语)
this._loadWelcomeMessage()
// PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created)
this._loadChatUiConfig()
},
mounted() {
// 初始加载时滚底(仅一次)
@@ -1001,23 +1088,56 @@ export default {
},
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadChatUiConfig() {
// PR-CHAT-UI-CONFIG (2026-09-20): 拉取前端 UI 行为配置 (5 个 P0 开关)
// ai-service.getChatUiConfig() 内部已带 60s TTL + 与 getServiceConfig 共享 /info 调用
// 这里失败不影响主流程, 失败时保留 data() 里的初始默认值
try {
const cfg = await aiService.getChatUiConfig()
if (cfg && typeof cfg === 'object') {
this.chatUiConfig = cfg
// ui_available_models 同步覆盖 availableModels (UI 模型选择器用)
if (Array.isArray(cfg.ui_available_models) && cfg.ui_available_models.length) {
this.availableModels = cfg.ui_available_models.slice()
// 当前 selectedModel 已不在新列表里 → 回落 'Auto' 或第一项
if (!this.availableModels.includes(this.selectedModel)) {
this.selectedModel = this.availableModels[0] || 'Auto'
}
}
this.chatUiConfigLoaded = true
}
} catch (e) {
console.warn('[AI Chat] 加载 chatUiConfig 失败, 使用前端默认:', (e && e.message) || e)
}
},
async _loadWelcomeMessage() {
// PR-FIX (2026-09-20): 防重入 (created 异步加载期间 mounted/loadHistoryIfNeeded
// 等路径再触发会造成欢迎气泡重复 push; 网络慢时尤其容易)
if (this._welcomeLoading) return
this._welcomeLoading = true
// 仅全新会话才需要欢迎语
if (this.currentConversationId) {
console.log('[AI Chat] 已有会话,跳过欢迎语')
this._welcomeLoading = false
return
}
let welcomeText = ''
let isMarkdown = false
let welcomeLoaded = false
// PR-FIX (2026-09-20): welcomeInfo 原为 if 块内 const,第 3 步拼 quickQuestions
// 时在块外引用 → ReferenceError, 提升到函数作用域
// PR-WELCOME-INFO-CONSOLIDATE (2026-09-20): 字段名统一为 welcome, 移除 welcome_info
// 后端 KefuGetInfo 只下发 welcome 一个字段 (结构不变)
let welcomeInfo = null
// 1. 尝试从 API 获取远程欢迎语
try {
const config = await aiService.getServiceConfig()
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
if (config) {
const welcomeInfo = config.welcome_info
welcomeInfo = config.welcome
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled
@@ -1041,6 +1161,7 @@ export default {
console.log('[AI Chat] 使用降级欢迎语')
} else {
console.log('[AI Chat] 无可用欢迎语')
this._welcomeLoading = false
return
}
}
@@ -1053,12 +1174,69 @@ export default {
type: isMarkdown ? 'markdown' : 'text',
content: welcomeText,
timestamp: Date.now(),
actions: []
actions: [],
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 (chips)
// 后端可存 string[] 或 {id,text}[] ,前端统一归一化为 string[]
// message.quickQuestions 数组非空 → 模板渲染 chips;点击调 onQuickQuestionClick
quickQuestions: this.normalizeQuickQuestions(welcomeInfo && welcomeInfo.quick_questions)
}
this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg)
}
this._welcomeLoading = false
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 归一化 quick_questions 数据格式
// 后端可能存 string[] ("如何下单?" / "退换货流程")
// 也可能存 {id, text}[] ({id:'q1',text:'如何下单?'})
// PR-WELCOME-QUICK-NORMALIZE (2026-09-20): 兼容 textarea 时代的 string 格式
// 老数据 PR-WELCOME-SAVE-SANITIZE 上线前存的是 newline-separated string
// 例如 "如何下单?\n退换货流程\n价格说明",前端按 \n 切分兼容
// 后端 KefuGetInfo 已做归一化 (2026-09-20),前端是双保险
// 前端统一抽出 text,空值过滤
normalizeQuickQuestions(raw) {
if (Array.isArray(raw)) {
return raw
.map(function (q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
})
.filter(function (s) { return s && s.trim() !== '' })
}
// 兼容老数据 string 格式 (PR-WELCOME-SAVE-SANITIZE 上线前)
if (typeof raw === 'string' && raw !== '') {
return raw.split(/\r\n|\r|\n/)
.map(function (s) { return s.trim() })
.filter(function (s) { return s !== '' })
}
return []
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 渲染文本提取
getQuickQuestionText(q) {
if (q && typeof q === 'object') return q.text || q.label || ''
return String(q || '')
},
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 点击 chip
// 1. 把文本塞进 inputText,模拟"用户已输入"
// 2. 清空 welcome bubble 的 chips (一次性)
// 3. 调 sendMessage() 走正常 chat 流 (建 user bubble + AI 回复)
async onQuickQuestionClick(message, q) {
const text = this.getQuickQuestionText(q)
if (!text || !text.trim()) return
try {
// 一次性:点了就摘掉 chips,避免重复点
message.quickQuestions = []
// 走现有 sendMessage 流程 (inputText → 校验 → sendStream/Normal)
this.inputText = text
// 让 DOM 先更新再发,避免用户视觉上"消息凭空出现"
await this.$nextTick()
await this.sendMessage()
} catch (e) {
console.warn('[AI Chat] onQuickQuestionClick failed:', e && e.message)
}
},
// 生成消息的唯一 key(兼容非 H5 平台)
@@ -1241,6 +1419,37 @@ async loadChatHistoryIfExist() {
}
},
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 还在回答上一个问题时又发新消息的"是否合并"确认
// 背景: AI 是"并行处理"的智能体, 上一轮未完成的任务 + 新问题一起丢给它时,
// 它会自作主张把两件事并行处理(思考流里会出现 "do these in parallel"),
// 结果就是答非所问/串题(例如问"王维怎么玩"却回答上一个公司的信息)。
// 策略: 发出去之前先问用户 —— 合并处理(立即发, 与当前回答并行) / 单独处理(排队, 本轮结束后自动发)
_confirmParallelMerge() {
return new Promise((resolve) => {
uni.showModal({
title: 'AI 正在回答上一个问题',
content: '是否把这条新消息与当前回答合并处理?\n· 合并处理:立即发送,AI 会并行处理两个问题\n· 单独处理:等当前回答结束后自动发送',
confirmText: '合并处理',
cancelText: '单独处理',
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false)
})
})
},
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): 本轮回答结束后, 自动发送排队中的消息
// 多个结束路径(正常结束/报错/降级)都会调, 用标记位保证只触发一次
_flushQueuedSend() {
if (!this.pendingSendAfterStream) return
this.pendingSendAfterStream = false
// 排队期间用户可能把输入框清空了/删掉了附件 → 没有可发内容就放弃, 不弹空消息
const hasText = this.inputText && this.inputText.trim() !== ''
const hasAttach = (this.pendingImages && this.pendingImages.length) || (this.pendingFiles && this.pendingFiles.length)
if (!hasText && !hasAttach) return
uni.showToast({ title: '正在发送排队中的消息', icon: 'none' })
this.$nextTick(() => { this.sendMessage() })
},
// 发送消息
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): pendingImages 数组版
// 原 pendingImage 单数, 改支持 1-9 张图一起发
@@ -1266,6 +1475,25 @@ async loadChatHistoryIfExist() {
if (!hasContent && !hasEmoji && !allPending.length) return
// PR-CHAT-PARALLEL-CONFIRM (2026-09-23): AI 还在回答时又发新消息 → 先问用户是否合并处理
// 合并: 立即发出(与当前回答并行, 可能出现串题) 单独: 排队, 本轮流式回答结束后自动发送
if (this.isStreaming) {
const mergeNow = await this._confirmParallelMerge()
if (!mergeNow) {
// 输入框内容与待发附件原样保留, 等本轮回答结束由 _flushQueuedSend 自动发出
this.pendingSendAfterStream = true
uni.showToast({ title: '已排队,本轮回答结束后自动发送', icon: 'none' })
return
}
}
// PR-FIX (2026-09-20): 手动发送消息后清掉欢迎气泡上的快捷问题 chips
// 原来只有点击 chip 才摘除, 用户手动打字发消息后 chips 一直挂着,
// 与"引导一次性"的语义不符
this.messages.forEach(msg => {
if (msg.quickQuestions && msg.quickQuestions.length) msg.quickQuestions = []
})
// 待发送文件/视频/音频附件 (与图片一并带出给后端)
const fileAttachments = pendingFilesList
.filter(p => p.uploaded)
@@ -1578,12 +1806,16 @@ async sendStreamMessage(userMessage, attachments = []) {
}
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
// PR-CHAT-PARALLEL-CONFIRM: 本轮结束 → 若有排队消息, 自动发出
this._flushQueuedSend()
break
}
case 'error':
msg.isStreaming = false
this.isStreaming = false
msg.content = (msg.content || '') + '\n[错误] ' + (data.message || '未知错误')
// PR-CHAT-PARALLEL-CONFIRM: 报错也算本轮结束, 排队消息照常发出
this._flushQueuedSend()
break
case 'attachments':
if (!msg.attachments) this.$set(msg, 'attachments', [])
@@ -1606,6 +1838,8 @@ async sendStreamMessage(userMessage, attachments = []) {
this.isStreaming = false
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
// PR-CHAT-PARALLEL-CONFIRM: 本轮结束 → 若有排队消息, 自动发出
this._flushQueuedSend()
}
try {
@@ -1635,6 +1869,8 @@ async sendStreamMessage(userMessage, attachments = []) {
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
// PR-CHAT-PARALLEL-CONFIRM: 请求失败也算本轮结束, 排队消息照常发出
this._flushQueuedSend()
}
},
@@ -1668,24 +1904,34 @@ async submitQuestionAnswer(msg) {
uni.showToast({ title: 'rpc_id 缺失, 无法回传', icon: 'none' })
return
}
this.$set(msg, 'questionAnswered', true)
// PR-FA-PRE (2026-09-18): 仅在 elicitResponse 真返回 true 时才置 questionAnswered=true
// 老逻辑先置 true 再 await,后端返回 false (H5 后端未就绪) 时也已被 UI 标记为已答,
// 用户看不到正确答案的继续流 → 体感"假性中断"。修:成功后才标记;失败提示并保留未答状态
// 由 30s pending_stale 兜底超时。
this.$set(msg, 'questionTimedOut', false)
if (msg.id && this.questionTimers[msg.id]) {
clearInterval(this.questionTimers[msg.id])
this.$delete(this.questionTimers, msg.id)
}
let ok = false
try {
await aiService.elicitResponse({
ok = await aiService.elicitResponse({
conversation_id: msg.questionConversationId || aiService.getConversationId(),
message_id: msg.questionMessageId,
tool_call_id: msg.pendingQuestion && msg.pendingQuestion.tool_call_id,
rpc_id: msg.rpc_id,
answers: msg.selectedAnswers
})
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} catch (e) {
ok = false
uni.showToast({ title: '选项提交失败: ' + (e.message || ''), icon: 'none' })
this.$set(msg, 'questionAnswered', false)
}
if (ok) {
this.$set(msg, 'questionAnswered', true)
// 后端会接着上次的 tool_call 上下文继续流, 无需前端额外处理
} else {
// H5 后端暂未就绪 或 网络失败:保留 questionAnswered=false,30s 由 pending_stale 兜底
uni.showToast({ title: '选项已展示,等待后端确认…', icon: 'none' })
}
},
@@ -1940,11 +2186,14 @@ _handleStreamError(msg) {
})
// 5) 行内代码 / 加粗 / 斜体 / 删除线 (此时链接已抽离, 不会被误改)
// PR-FIX-AI-TEXT-COLOR (2026-09-24): AI 回答统一配色 —— 正文/标题/加粗/行内代码/引用/删除线
// 一律黑色(TEXT), 只有"网址/链接"是蓝色(LINK)。
// 之前行内代码是粉色(#d63384)、删除线是灰色(#999), 回答里颜色太杂。
t = t
.replace(/`([^`]+)`/g, '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>')
.replace(/`([^`]+)`/g, '<code style="background:#f2f3f5;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:' + TEXT + ';">$1</code>')
.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:700;color:' + TEXT + ';">$1</strong>')
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;">$1</em>')
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:#999;">$1</del>')
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;color:' + TEXT + ';">$1</em>')
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:' + TEXT + ';">$1</del>')
return t
}
@@ -1962,7 +2211,7 @@ _handleStreamError(msg) {
const safe = code.replace(/</g, '&lt;').replace(/>/g, '&gt;')
codeBlocks.push(
`<pre style="background:#f7f8fa;padding:20rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:12rpx 0;white-space:pre-wrap;">` +
`<code style="background:transparent;padding:0;font-family:monospace;color:#333;font-size:24rpx;">${safe}</code></pre>`
`<code style="background:transparent;padding:0;font-family:monospace;color:${TEXT};font-size:24rpx;">${safe}</code></pre>`
)
return `%%CODEBLOCK_${idx}%%`
})
@@ -2058,7 +2307,7 @@ _handleStreamError(msg) {
const bqMatch = line.match(/^>\s?(.+)$/)
if (bqMatch) {
flushBuf(); out.push(closeList())
out.push(`<blockquote style="border-left:4rpx solid #d0d7de;padding:8rpx 16rpx;margin:${BQ_MARGIN};background:#f6f8fa;color:#57606a;line-height:${BQ_LH};font-size:28rpx;border-radius:0 6rpx 6rpx 0;">${parseInline(bqMatch[1])}</blockquote>`)
out.push(`<blockquote style="border-left:4rpx solid #d0d7de;padding:8rpx 16rpx;margin:${BQ_MARGIN};background:#f6f8fa;color:${TEXT};line-height:${BQ_LH};font-size:28rpx;border-radius:0 6rpx 6rpx 0;">${parseInline(bqMatch[1])}</blockquote>`)
continue
}
@@ -2109,7 +2358,10 @@ _handleStreamError(msg) {
return html
},
// 格式化时间(人性化显示)
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 时间格式化支持后端配置
// ui_time_format = 'auto' 智能分段(默认, 与历史行为一致): 今天 HH:mm / 昨天 / 前天 / 本年 M/D / 跨年 Y/M/D
// ui_time_format = 'full' 始终完整 YYYY-MM-DD HH:mm
// ui_time_humanize = true 微信式相对时间(优先于 format): 刚刚 / N分钟前 / N小时前 / 昨天 / 前天 / M月D日 / Y年M月D日
formatTime(timestamp) {
if (!timestamp) return '';
@@ -2145,6 +2397,19 @@ _handleStreamError(msg) {
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
// 人性化相对时间 (开启后优先于 ui_time_format)
if (this.chatUiConfig && this.chatUiConfig.ui_time_humanize) {
return this.humanizeTime(date, msgDate, today, timeStr);
}
// 完整时间格式: 始终 YYYY-MM-DD HH:mm
if (this.chatUiConfig && this.chatUiConfig.ui_time_format === 'full') {
const mm = (date.getMonth() + 1).toString().padStart(2, '0');
const dd = date.getDate().toString().padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd} ${timeStr}`;
}
// auto: 智能分段 (原有逻辑)
if (msgDate.getTime() === today.getTime()) {
return timeStr;
}
@@ -2166,6 +2431,42 @@ _handleStreamError(msg) {
const yearStr = date.getFullYear().toString();
return `${yearStr}/${monthStr}/${dayStr} ${timeStr}`;
},
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 微信式人性化相对时间
// 刚刚 / N分钟前 / 今天内N小时前 / 昨天 HH:mm / 前天 HH:mm / 本年 M月D日 HH:mm / 跨年 Y年M月D日 HH:mm
humanizeTime(date, msgDate, today, timeStr) {
const diff = Date.now() - date.getTime();
const oneMin = 60 * 1000;
const oneHour = 60 * oneMin;
if (diff >= 0 && diff < oneMin) {
return this.$lang('ai-chat.index.timeJustNow');
}
if (diff >= 0 && diff < oneHour) {
return this.$lang('ai-chat.index.timeMinutesAgo', { n: Math.floor(diff / oneMin) });
}
// 今天内且已超过 1 小时
if (msgDate.getTime() === today.getTime()) {
if (diff < 24 * oneHour) {
return this.$lang('ai-chat.index.timeHoursAgo', { n: Math.floor(diff / oneHour) });
}
return timeStr; // 兜底(理论不可达: 同一天 diff 不可能 >= 24h)
}
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
if (msgDate.getTime() === yesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`;
}
const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
if (msgDate.getTime() === beforeYesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`;
}
const m = date.getMonth() + 1;
const d = date.getDate();
if (date.getFullYear() === new Date().getFullYear()) {
return `${this.$lang('ai-chat.index.timeMonthDay', { m, d })} ${timeStr}`;
}
return `${this.$lang('ai-chat.index.timeYearMonthDay', { y: date.getFullYear(), m, d })} ${timeStr}`;
},
// 格式化文件大小
formatFileSize(bytes) {
@@ -2195,17 +2496,20 @@ _handleStreamError(msg) {
return m + 'm' + (s < 10 ? '0' + s : s) + 's'
},
// 滚动到底部 - 统一使用 scroll-into-view
// 滚动到底部 - 流式长回答时持续跟随最新文字
// 关键: 之前用 scroll-into-view 把最后一条消息滚入视野, 但流式过程中 AI 消息 id 不变,
// scroll-into-view 只在 id 变化/重新赋值时触发, 且会把元素"顶到顶部对齐"而非贴底,
// 导致长回答越写越长时, 最新文字被挤出可视区、看不到(你反馈的问题)。
// 改法: 改用 scroll-top 赋一个不断递增且恒大于内容高度的值, 强制滚动条贴底,
// 每个新文字块到达都重设 → 视口随答案增长缓缓上移, 始终显示 AI 最新回复。
scrollToBottom() {
if (this.messages.length === 0) return
// 取真实最后一条消息(包括空内容的流式消息)
const lastMsg = this.messages[this.messages.length - 1]
const lastIndex = this.messages.length - 1
const key = lastMsg.id || lastMsg.timestamp || lastIndex
// 先清空再设置,确保 scroll-into-view 重新触发
// 取消可能冲突的 scroll-into-view (搜索跳转用, 不走这里)
this.scrollIntoViewId = ''
this.$nextTick(() => {
this.scrollIntoViewId = 'msg-' + key + '-' + lastIndex
// 递增超大值: 保证每次调用都触发重新滚动(同值在部分平台不重排), 且恒大于内容高度=贴底
this._bottomCounter = (this._bottomCounter || 0) + 100000
this.scrollTop = this._bottomCounter
})
},
@@ -3450,16 +3754,18 @@ _handleStreamError(msg) {
.markdown-body {
line-height: 1.8;
font-size: 26rpx;
color: $color-text;
/* PR-FIX-AI-TEXT-COLOR (2026-09-24): 统一配色 —— 正文/加粗/斜体/代码全部黑色,
只有链接(a)是蓝色; 之前正文是灰蓝($color-text)、代码是蓝灰($color-primary) */
color: #1a1a1a;
strong {
font-weight: 600;
color: $color-text;
color: #1a1a1a;
}
em {
font-style: italic;
color: $color-text-light;
color: #1a1a1a;
}
code {
@@ -3468,17 +3774,18 @@ _handleStreamError(msg) {
border-radius: $radius-sm;
font-family: monospace;
font-size: 24rpx;
color: $color-primary;
color: #1a1a1a;
}
a {
color: $color-primary;
/* 网址/链接: 蓝色 (与 parseMarkdown 里的 LINK 常量保持一致) */
color: #3a7afe;
text-decoration: underline;
text-underline-offset: 4rpx;
transition: color 0.2s ease;
&:hover {
color: #5a7cb0;
color: #2b6cf0;
}
}
}
@@ -3817,6 +4124,53 @@ _handleStreamError(msg) {
}
}
// PR-WELCOME-QUICK-CHIPS (2026-09-19): 欢迎语配套快捷问题 chip 样式
// PR-FIX (2026-09-20): flex gap 在低版本微信基础库/部分安卓 WebView(小米) 不生效,
// 间距全部改用 margin 实现, 不依赖 gap
.quick-questions-row {
margin-top: 16rpx;
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
max-width: 100%;
.quick-question-chip {
display: inline-flex;
align-items: center;
margin-right: 16rpx;
margin-bottom: 16rpx;
padding: 12rpx 24rpx;
background: #ffffff;
border: 1rpx solid #1a1a1a;
border-radius: 32rpx;
font-size: 26rpx;
color: #1a1a1a;
cursor: pointer;
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
.quick-question-icon {
font-size: 24rpx;
flex-shrink: 0;
margin-right: 8rpx;
}
.quick-question-text {
flex: 1;
word-break: break-word;
white-space: pre-wrap;
}
}
.quick-question-chip--hover {
background: #1a1a1a;
color: #ffffff;
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(26, 26, 26, 0.2);
}
}
.message-actions {
margin-top: 24rpx;
display: flex;
@@ -3824,7 +4178,7 @@ _handleStreamError(msg) {
justify-content: flex-start;
flex-wrap: nowrap;
width: 100%;
.action-btn {
border-radius: $radius-lg;
font-size: 22rpx;
@@ -3903,13 +4257,8 @@ _handleStreamError(msg) {
position: relative;
gap: 12rpx;
.ai-nickname-text {
position: relative;
z-index: 1;
}
.message-time {
/* 时间显示在 AI 智能昵称的右面 */
/* AI 回答上方只保留时间, 左对齐显示 */
position: static;
margin-top: 0;
margin-left: 0;
@@ -3983,9 +4332,20 @@ _handleStreamError(msg) {
}
/* #ifdef H5 */
/* H5 用户消息气泡加大内边距,文字大小保持不变 */
.user-message .message-bubble {
padding: 36rpx 60rpx !important;
/* H5 用户【纯文字】消息气泡: 气泡要明显比文字大(与小程序一致)
- 内边距加大 -> 气泡比文字大, 文字与气泡边缘留出间距
- 宽度 auto + inline-block -> 气泡宽度贴着内容, 不拉满整行
- 只调整气泡自身, 气泡内文字字号不变
- 用 :not() 排除图片/文件/视频/音频气泡, 避免影响它们原有的紧凑样式
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 不再排除 .image-caption-bubble ——
图文一起发时的配文气泡要和普通文字气泡一样大(之前被排除 → 内边距只有 30rpx) */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 10rpx 30rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
@@ -4009,13 +4369,6 @@ _handleStreamError(msg) {
gap: 12rpx;
margin-bottom: 4rpx;
.ai-nickname-text {
font-size: 24rpx;
font-weight: 500;
color: #666;
line-height: 1;
}
.message-time {
margin-top: 0;
font-size: 22rpx;
@@ -4105,6 +4458,13 @@ _handleStreamError(msg) {
&.icon-like {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNNyAxMXY4YTEgMSAwIDAgMSAtMSAxaC0yYTEgMSAwIDAgMSAtMSAtMXYtN2ExIDEgMCAwIDEgMSAtMWgzYTQgNCAwIDAgMCA0IC00di0xYTIgMiAwIDAgMSA0IDB2NWgzYTIgMiAwIDAgMSAyIDJsLTEgNWEyIDIgMCAwIDEgLTIgMmgtN2E0IDQgMCAwIDEgLTQgLTQiLz48L3N2Zz4=");
/* 屏蔽全局 iconfont.css 的 .icon-like:before { content:"\e041" },
避免点赞图标额外渲染出一个心形/方框伪元素;此处不设置 content 值,
只让 ::before 不显示 */
&::before {
display: none !important;
}
}
&.icon-dislike {
@@ -4774,10 +5134,26 @@ _handleStreamError(msg) {
}
}
/* 用户消息气泡: 保持浅灰气泡 + 加大内边距(文字大小不变), 覆盖上面的 padding:0 */
.user-message .message-content .message-bubble {
padding: 36rpx 60rpx !important;
/* 用户消息气泡: 覆盖上面的 padding:0
- H5: 气泡要明显比文字大(与小程序一致), 只改气泡, 不改文字字号;
用 :not() 排除图片/文件/视频/音频气泡
- 小程序: 保持原有浅灰气泡 + 加大内边距(不动)
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 同上层, 配文气泡不再排除, 与普通文字气泡同尺寸 */
/* #ifdef H5 */
.user-message .message-content .message-bubble:not(.user-image-bubble):not(.user-file-bubble):not(.user-video-bubble):not(.user-audio-bubble) {
padding: 20rpx 40rpx !important;
border-radius: 16rpx !important;
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
text-align: left !important;
}
/* #endif */
/* #ifndef H5 */
.user-message .message-content .message-bubble {
padding: 20rpx 40rpx !important;
}
/* #endif */
}
}
}
@@ -4956,9 +5332,11 @@ _handleStreamError(msg) {
}
}
// H5 下允许 rich-text / v-html 内容文本选择(鼠标拖选复制)
// H5 下允许 rich-text / v-html 内容文本选择(鼠标/长按拖选, 复制、全选等由浏览器原生菜单提供)
.text-content,
.markdown-content,
.markdown-body,
.ai-message-text,
.selectable-md {
user-select: text;
-webkit-user-select: text;
@@ -4988,15 +5366,6 @@ _handleStreamError(msg) {
/* ========== AI 回答: 无气泡 ==========
AI 回答不做任何卡片/气泡, 背景透明、无边框、无阴影、无内边距, 文字直接渲染在白色页面底上 */
/* 昵称更精致 */
.ai-chat-container .chat-messages .message-item .ai-message .message-content .message-nickname .ai-nickname-text {
color: #3a4353 !important;
font-weight: 600;
letter-spacing: 0.4rpx;
}
/* ========== 思考流 / 工具调用 / 选项卡片 / 附件 样式 ========== */
/* 顶部栏: 标题 + 新对话 */
@@ -5004,7 +5373,9 @@ _handleStreamError(msg) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx;
/* 右侧预留搜索入口(56rpx 圆形按钮, right:16rpx)的宽度,
把"新对话"按钮左移, 避免被搜索按钮遮挡 */
padding: 16rpx 88rpx 16rpx 24rpx;
border-bottom: 1rpx solid rgba(17, 24, 39, 0.06);
background: #ffffff;
.chat-title {
@@ -5021,7 +5392,17 @@ _handleStreamError(msg) {
background: #f4f5f7;
color: #1f6cff;
font-size: 24rpx;
.new-chat-icon { font-size: 26rpx; }
/* 新对话图标: 圆角气泡 + 中间加号 + 左下角小尾巴的线性图标
微信小程序不支持内联 SVG, 沿用 base64 背景图方案 (与 svg-icon / voice-icon-bg 一致) */
.new-chat-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWY2Y2ZmIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNOC41IDIuNUgxNkE1LjUgNS41IDAgMCAxIDIxLjUgOFYxMi41QTUuNSA1LjUgMCAwIDEgMTYgMThIOC42TDMgMjJWOEE1LjUgNS41IDAgMCAxIDguNSAyLjVaIi8+PHBhdGggZD0iTTEyIDcuNVYxMy41TTkuMiAxMC41SDE0LjgiLz48L3N2Zz4=");
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
}
}
@@ -5054,18 +5435,7 @@ _handleStreamError(msg) {
}
}
/* 工具调用 */
.tool-calls {
margin-top: 8rpx;
padding: 8rpx 12rpx;
background: #f0f4f8;
border-radius: 4rpx;
font-size: 22rpx;
.tool-call-item { padding: 4rpx 0; color: #666; }
.status-completed { color: #2e7d32; }
.status-failed { color: #c62828; }
.status-pending, .status-in_progress, .status-pending { color: #f57c00; }
}
/* 工具调用列表样式已随模板一起移除 (PR-CHAT-UI-HIDE-TOOL-CALLS 2026-09-23) */
/* 附件 */
.attachments {
@@ -5262,19 +5632,16 @@ _handleStreamError(msg) {
}
}
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方) */
/* 图片消息配文气泡 (图文一起发出时, 独立气泡显示在图片下方)
PR-FIX-CAPTION-BUBBLE-SIZE (2026-09-24): 这里原先单独定义了
background/border-radius/padding/max-width/字号, 使配文气泡比普通文字气泡小一圈
(H5 下又被 :not() 规则排除, 内边距只有 30rpx)。
现在只保留"与图片的间距 + 右对齐", 其余(内边距/圆角/背景/字号)全部复用普通文字气泡,
保证图文消息下方的问题气泡和纯文字消息的气泡一样大。 */
.image-caption-bubble {
margin-top: 10rpx;
background: #f5f5f5;
border-radius: 20rpx;
padding: 14rpx 22rpx;
max-width: 460rpx;
.message-text {
font-size: 28rpx;
color: #111827;
line-height: 1.5;
word-break: break-all;
}
align-self: flex-end;
width: auto;
}
/* 输入框内待发送图片缩略图: 位于文字输入行上方, 出现时输入框自动向上撑高 */
@@ -5360,4 +5727,5 @@ _handleStreamError(msg) {
border-radius: 0 0 0 14rpx;
}
}
</style>

View File

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

View File

@@ -9,26 +9,29 @@
</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="icondiy icon-system-image-line"></text>
</view>
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
</view>
<view class="tool-item" @click="selectVideo">
<view v-if="isTypeEnabled('video')" class="tool-item" @click="selectVideo">
<view class="tool-icon">
<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 class="tool-item" @click="selectFile">
<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="selectVoiceOrAudio">
<view v-if="isTypeEnabled('audio')" class="tool-item" @click="selectVoiceOrAudio">
<view class="tool-icon">
<text class="icondiy icon-system-chat-voice-line"></text>
</view>
@@ -52,6 +55,9 @@ 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 - 同步关闭状态
@@ -67,6 +73,12 @@ export default {
visible: {
type: Boolean,
default: false
},
attachmentTypes: {
type: Array,
default: function () {
return ['image', 'file', 'audio', 'video']
}
}
},
@@ -81,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({

View File

@@ -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 传不同值,