强制合并

This commit is contained in:
2026-09-22 10:52:56 +08:00
parent 3f29116345
commit bac5583ecb
34 changed files with 4370 additions and 2050 deletions

3
.gitignore vendored
View File

@@ -5,8 +5,9 @@
/iconfont-preview.html
.codebuddy
.workbuddy
.vscode
.trae
.trae*
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch

View File

@@ -68,7 +68,7 @@ const localDevConfig = ({
uniacid: 11,
domain: 'http://localhost:8050/',
},
'100': {
'100': {// 口译教学
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -76,15 +76,15 @@ const localDevConfig = ({
uniacid: 1,
domain: 'https://5g.aigc-quickapp.com/',
},
'115': {
'115': {// 低压断路器
uniacid: 115,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-11': {
'5g-11': {// 铝箔袋包装
uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-206': {
'5g-206': {// 气体探测
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -92,19 +92,19 @@ const localDevConfig = ({
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
'47': { // 修改弹窗
'47': { // 免疫细胞干细胞外泌体 ,修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
},
'145': { // 表单链接转义问题
'145': { //昆山昆创净化科技有限公司 , 表单链接转义问题
uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-213': { // 单图组点击链接无法跳转
'5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转
uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-4': { // 公告点击链接无法跳转
'5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转
uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -116,11 +116,11 @@ const localDevConfig = ({
uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-237': { // 商品分类,没有立即咨询文字
'5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字
uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/',
},
'2847': { //
'2847': { // 家居装饰品
uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/',
},
@@ -128,7 +128,7 @@ const localDevConfig = ({
uniacid: 2482,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2485': { // 验证电子表单前后端数据一致性
'2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性
uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/',
},
@@ -152,11 +152,11 @@ const localDevConfig = ({
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
'248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
'228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -164,7 +164,7 @@ const localDevConfig = ({
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-95': { // 商品分类,没有立即咨询文字
'5g-95': { // 加热器 ,商品分类,没有立即咨询文字
uniacid: 95,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -185,7 +185,7 @@ const localDevConfig = ({
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'5g-59': { // 合成孔径雷达,一级分类背景字体颜色
'5g-59': { // 脱模剂粘合剂乐瑞固Lorechem
uniacid: 59,
domain: 'https://5g.aigc-quickapp.com/',
},
@@ -210,7 +210,7 @@ const localDevConfig = ({
uniacid: 2505,
domain: 'https://xcx.aigc-quickapp.com/',
},
'3007': { // 煤矿钻机
'3007': { // 钎焊材料
uniacid: 3007,
domain: 'https://xcx.aigc-quickapp.com/',
},
@@ -218,6 +218,10 @@ const localDevConfig = ({
uniacid: 317,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-312': { // 天然植物功效活性物
uniacid: 312,
domain: 'https://5g.aigc-quickapp.com/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -230,10 +234,16 @@ const localDevConfig = ({
// })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['5g-351']; //洗车机
// })['5g-204']; //热固性塑料
// })['5g-59']; //合成孔径雷达
// })['2709']; //中央厨房
// })['3007']; //钎焊材料
// })['2505']; //煤矿钻机
// })['local-11']; // 选择要使用的环境配置
// })['2811']; // 本地
})['5g-317']; // 本地
//})['5g-317']; // 电磁兼容
})['5g-351']; //
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

13
App.vue
View File

@@ -6,6 +6,11 @@
import PrivacyManager from 'common/js/privacy.js';
import splashState from 'common/js/splash-state.js';
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
let _appHasShown = false;
import {
Weixin
} from 'common/js/wx-jssdk.js';
@@ -491,6 +496,14 @@
},
onShow: function(options) {
// #ifdef MP
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
if (_appHasShown) {
try { Lang.onAppReentry(); } catch (e) {}
}
_appHasShown = true;
// 自动授权登录
this.getAuthInfo();
if (this.$store.state.token) {

View File

@@ -13,28 +13,10 @@ let currentConversationId = null;
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口
// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存
export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_';
export function getConversationKey(agentId) {
return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
}
// 兼容旧代码: 单 key 别名(指向默认 agent)
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
let eventHandlers = { onEvent: null };
function dispatchEvent(onEvent, event, data) {
if (typeof onEvent === 'function') {
try { onEvent(event, data); }
catch (e) { console.error('[AI Service] onEvent handler threw:', e); }
}
}
export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* PR-7: agent_id -> 后端 addon 路由映射
@@ -83,9 +65,9 @@ function getDomainParams() {
};
}
// 初始化时从本地读取会话ID(按当前 agent 隔离)
// 初始化时从本地读取会话ID
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
const saved = uni.getStorageSync(CONVERSATION_KEY);
if (saved) {
currentConversationId = saved;
}
@@ -216,56 +198,25 @@ function ensureWebSocketConnected() {
// 以下为聊天消息响应,只在有活跃消息时处理
if (!wsMessageActive) return;
// 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
var onEvt = eventHandlers.onEvent;
switch (data.event) {
case 'message': {
if (data.event === 'message') {
var text = data.answer || data.content || data.text || '';
// 兼容嵌套 data.data.content 格式
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id });
break;
}
wsMessageContent += text;
if (wsMessageOnChunk) wsMessageOnChunk(text);
dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
break;
}
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id);
dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id });
break;
}
case 'tool_call':
dispatchEvent(onEvt, 'tool_call', data.tool_call || {});
break;
case 'question':
dispatchEvent(onEvt, 'question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
} else if (data.event === 'message_end' || data.event === 'done') {
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
// 保存会话ID(按当前 agent 隔离)
// 保存会话ID(独立函数,不使用 this)
if (conversationId) {
if (conversationId !== currentConversationId) {
currentConversationId = conversationId;
try {
uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
uni.setStorageSync(CONVERSATION_KEY, conversationId);
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
} catch (e) {
console.error('保存会话ID失败:', e);
@@ -275,41 +226,14 @@ function ensureWebSocketConnected() {
var content = wsMessageContent;
var convId = conversationId;
// 重置消息状态,但不断开连接
var endPayload = {
conversation_id: convId,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
};
wsMessageActive = false;
wsMessageContent = '';
dispatchEvent(onEvt, 'message_end', endPayload);
if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
if (wsMessageResolve) wsMessageResolve(endPayload);
break;
}
case 'error':
dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' });
break;
case 'ping':
// keep-alive, 忽略
break;
default:
// 兜底: 老格式 data.data.content
if (data.data && data.data.content) {
var defText = data.data.content;
wsMessageContent += defText;
if (wsMessageOnChunk) wsMessageOnChunk(defText);
dispatchEvent(onEvt, 'message', { content: defText });
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
} else {
// 兜底日志:打印所有未识别的消息类型
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
}
@@ -405,7 +329,6 @@ export default {
response_mode: 'blocking',
user: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (options && options.attachments) || [],
...getDomainParams()
},
header: {
@@ -439,29 +362,24 @@ export default {
* 流式消息入口(自动适配平台)
* 微信小程序:复用长连接 WebSocket
*/
async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP
try {
await ensureWebSocketConnected();
} catch (e) {
// WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
// 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
return this._fallbackToHttp(message, onEvent, onComplete, opts);
if (onComplete) onComplete({ error: e.message });
throw e;
}
wsMessageContent = '';
wsMessageActive = true;
eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
// onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null);
wsMessageOnChunk = onChunk || null;
wsMessageOnComplete = onComplete || null;
return new Promise((resolve, reject) => {
wsMessageResolve = resolve;
wsMessageReject = reject;
try {
wsSocketTask.send({
data: JSON.stringify({
action: 'chat',
@@ -470,61 +388,18 @@ export default {
query: message,
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: opts.attachments || [],
...getDomainParams()
})
});
} catch (sendErr) {
console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
// 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
this._fallbackToHttp(message, onEvent, onComplete, opts)
.then((res) => resolve(res))
.catch((err) => reject(err));
}
});
// #endif
},
/**
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
*/
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
try {
const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] });
const content = result && result.content ? result.content : '';
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
const fallbackPayload = {
content,
conversation_id: result.conversationId,
thoughts: result.thoughts || '',
tokens: result.tokens || null
};
if (content || result.thoughts) {
if (typeof onEvent === 'function') {
if (result.thoughts) onEvent('thought', { content: result.thoughts });
onEvent('message', { content });
onEvent('message_end', fallbackPayload);
} else {
if (onComplete) onComplete(fallbackPayload);
}
return fallbackPayload;
}
const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
if (onComplete) onComplete({ error: emptyErr.message });
throw emptyErr;
} catch (err) {
console.error('[AI Service] MP HTTP 降级请求也失败:', err);
if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
throw err;
}
},
/**
* HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/
async sendHttpStream(message, onEvent, onComplete, opts = {}) {
async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5
try {
var chatUrl = resolveApiUrl('chat');
@@ -544,7 +419,6 @@ export default {
agent_id: getAgentId(),
user_id: getUserId(),
conversation_id: this.getConversationId() || '',
attachments: (opts && opts.attachments) || [],
...getDomainParams()
})
});
@@ -558,7 +432,6 @@ export default {
let buffer = '';
let content = '';
let conversationId = '';
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
while (true) {
const { done, value } = await reader.read();
@@ -571,71 +444,25 @@ export default {
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const jsonStr = trimmed.slice(5).trim();
if (!jsonStr || jsonStr === '[DONE]') continue;
if (trimmed.startsWith('data:')) {
try {
const jsonStr = trimmed.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
const data = JSON.parse(jsonStr);
switch (data.event) {
case 'message': {
var text = data.answer || data.text || data.content || '';
if (!text && data.data) text = data.data.content || data.data.answer || '';
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
if (text && text.startsWith('> ')) {
fire('thought', { content: text, message_id: data.message_id });
break;
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
fire('message', { content: text, message_id: data.message_id });
break;
if (onChunk) onChunk(text);
}
case 'thought':
case 'thinking':
case 'reasoning': {
// 兼容字段: thought / thinking / reasoning / content / answer
var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
fire('thought', { content: thoughtText2, message_id: data.message_id });
break;
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
case 'tool_call':
fire('tool_call', data.tool_call || {});
break;
case 'question':
fire('question', {
pending_question: data.pending_question || (data.data && data.data.pending_question),
message_id: data.message_id,
conversation_id: data.conversation_id
});
break;
case 'message_end':
case 'done': {
const cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) conversationId = cid;
if (data.event === 'message_end' || data.event === 'done') {
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
fire('message_end', {
conversation_id: cid,
message_id: data.message_id,
content: data.content || content,
thoughts: data.thoughts || '',
tool_calls: data.tool_calls || [],
tokens: data.tokens || null,
phase: data.phase || 'completed',
stop_reason: data.stop_reason || '',
effective_model: data.effective_model || ''
});
break;
}
case 'error':
fire('error', { code: data.code || -1, message: data.message || 'unknown' });
break;
default:
// B 路 Dify 把思考塞 answer 里 (> 前缀)
if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) {
fire('thought', { content: data.answer });
} else if (data.event === 'ping') {
// keep-alive
} else {
console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200));
}
}
} catch (e) {
@@ -643,6 +470,30 @@ export default {
}
}
}
}
if (buffer.trim().startsWith('data:')) {
try {
const jsonStr = buffer.trim().slice(5);
if (jsonStr) {
const data = JSON.parse(jsonStr);
if (data.event === 'message') {
var text = data.answer || data.text || '';
if (!text && data.data) {
text = data.data.content || data.data.answer || '';
}
content += text;
if (onChunk) onChunk(text);
}
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) {
conversationId = cid;
}
}
} catch (e) {
console.warn('最后 buffer 解析失败:', e);
}
}
console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length);
if (onComplete) {
@@ -665,9 +516,7 @@ export default {
success: true,
content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
conversationId: response.data?.conversation_id,
messageId: response.data?.message_id,
thoughts: response.data?.thoughts || '',
tokens: response.data?.tokens || null
messageId: response.data?.message_id
};
} else {
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
@@ -688,7 +537,7 @@ export default {
if (id && id !== currentConversationId) {
currentConversationId = id;
try {
uni.setStorageSync(getConversationKey(currentAgentId), id);
uni.setStorageSync(CONVERSATION_KEY, id);
} catch (e) {
console.error('保存会话ID失败:', e);
}
@@ -701,7 +550,7 @@ export default {
clearConversationId() {
currentConversationId = null;
try {
uni.removeStorageSync(getConversationKey(currentAgentId));
uni.removeStorageSync(CONVERSATION_KEY);
} catch (e) {
console.error('清除会话ID失败:', e);
}
@@ -783,14 +632,6 @@ export default {
async: false,
});
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
// 媒体上传需要 config_uuid (后端校验 config_uid/config_uuid 不能为空)。
// 此前从未写入过该缓存, 导致上传表单里 config_uuid 恒为空。
// 这里从 info 响应中提取并持久化, 兼容多种字段名。
const cfg = response && response.data;
if (cfg && typeof cfg === 'object') {
const uuid = cfg.config_uuid || cfg.config_uid || cfg.uuid || (cfg.info && (cfg.info.config_uuid || cfg.info.uuid)) || '';
if (uuid) uni.setStorageSync('current_config_uuid', uuid);
}
return response.data;
} catch (error) {
console.error('[AI Service] getServiceConfig error:', error);
@@ -820,41 +661,6 @@ export default {
}
},
/**
* 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
* @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers }
* @returns {Promise<boolean>}
*/
async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
try {
await ensureWebSocketConnected();
} catch (e) {
console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message);
return false;
}
wsSocketTask.send({
data: JSON.stringify({
action: 'elicit_response',
conversation_id,
message_id,
tool_call_id,
rpc_id,
answers,
uniacid: this.getUniacid(),
token: store.state.token || 'test_token'
})
});
return true;
// #endif
// #ifdef H5
// H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点
// 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底
console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底');
return false;
// #endif
},
/**
* 获取会话历史
* PR-7: URL 按 agent_id 路由
@@ -1008,46 +814,6 @@ export default {
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
// 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离)
currentConversationId = null;
try {
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
if (saved) currentConversationId = saved;
} catch (e) { /* ignore */ }
},
/**
* 获取当前 agent 的会话存储 key (per-agent 隔离)
*/
getConversationKey(agentId) {
return getConversationKey(agentId);
},
/**
* 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选)
*/
getAllConversations() {
const out = {};
try {
const info = uni.getStorageInfoSync();
(info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0)
.forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); });
} catch (e) { /* ignore */ }
return out;
},
/**
* 获取当前 config_uuid (媒体上传路由用)
*/
getCurrentConfigUuid() {
return uni.getStorageSync('current_config_uuid') || '';
},
/**
* 获取用户 ID (媒体上传字段用)
*/
getUserId() {
return getUserId();
},
/**

View File

@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
const version = String(payload.version || '');
const sign = payload.sign || '';
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
console.warn(
'[lang-loader] bundle tree INCOMPLETE. ' +
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
' lang=' + lang +
' appType=' + appType +
' uniacid=' + uniacid +
' version=' + version +
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
);
}
// 4) 签名校验(可选)
const secret = langConfig.remote.signSecret || '';
const signValid = validateSign(tree, version, sign, secret);

View File

@@ -628,6 +628,26 @@ function loadLangPackSync(lang, path) {
* 获得当前本地语言
* @returns
*/
/**
* 语言切换开关钩子(由后台 shop_info.ischia 决定,经 store/index.js 的 setShopInfo 写入)。
*
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischia
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
*
* 行为:
* - 后台关闭双语/小程序(ischia 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
* - 后台开启双语(ischia 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
*/
function setLangSwitchEnabled(enabled) {
if (!enabled) {
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
invalidateStorageCache('lang');
try { uni.removeStorageSync('lang'); } catch (e) {}
}
}
function getCurrentLocale() {
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
const val = cachedStorageGet('lang', null);
@@ -973,6 +993,13 @@ export default {
*/
getCurrentLocale,
/**
* 语言切换功能开关(后台 shop_info.ischia 决定)。
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
*/
setLangSwitchEnabled,
/**
* 从后端数据中提取当前语言的文案(向后兼容)
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
@@ -1054,6 +1081,13 @@ export default {
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
let isComplete1 = isBundleComplete(result.tree);
if (!isComplete1) {
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
try {
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
', source=' + (result && result.source) +
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
} catch (e) {}
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
delete remoteTreeCache[_preloadMk];
result = await loadBundleTree({ lang, appType, uniacid, force: true });
@@ -1064,6 +1098,13 @@ export default {
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
let isComplete2 = isBundleComplete(result.tree);
if (!isComplete2) {
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
try {
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
', source=' + (result && result.source) +
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
} catch (e) {}
throw new Error('lang bundle unusable after force retry');
}
@@ -1103,10 +1144,14 @@ export default {
setRemoteStatus('fail', { reason: _reason, lang });
bootCompleted = true;
emitUpdated(lang);
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
return {
ok: true,
source: 'cache',
status: 'fail',
status: 'ok',
degraded: true,
reason: _errMsg || 'fallback to cache',
lang,
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
@@ -1176,6 +1221,42 @@ export default {
loadOverrides(lang);
},
/**
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
* - remote-only 模式下远端 bundle 不会被重新拉取
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
* storage 的 locale 重新读取并合并,不再沿用旧值。
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
*/
onAppReentry() {
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
try {
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
} catch (e) {}
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
invalidateStorageCache();
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
invalidateLangCaches();
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
try {
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
', freshUniacid=' + getCurrentUniacid());
} catch (e) {}
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
if (IS_REMOTE_ONLY) {
this.boot();
} else {
this.refresh(true);
}
},
/**
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
* @param {(status:string, detail:object) => void} fn

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

@@ -0,0 +1,325 @@
// common/js/media-upload.js
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
//
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
// "Cannot find module '@/common/js/media-upload.js'". 此次按
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
//
// 调用方:
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
//
// 上传策略:
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
//
// 端点:
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
import aiService from './ai-service.js'
import Config from './config.js'
import store from '@/store/index.js'
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
/**
* 上传图片 (type=image)
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
*/
export async function uploadImage(filePath) {
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
const config = await aiService.getServiceConfig()
const strategy = config?.upload_strategy || 'backend'
// H5 端: >10MB 先压
let compressedPath = filePath
// #ifdef H5
const fileSize = await getFileSize(filePath)
if (fileSize > 10 * 1024 * 1024) {
try {
const compressed = await uni.compressImage({
src: filePath,
quality: 80,
compressedWidth: 2048,
compressedHeight: 2048,
})
compressedPath = compressed.tempFilePath || filePath
} catch (e) {
console.warn('[media-upload] compressImage failed, use original:', e)
}
}
// #endif
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
const fileSizeFinal = await getFileSize(compressedPath)
const useSts = strategy === 'oss_sts'
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
if (useSts) {
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config)
} else {
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId())
}
}
/**
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
* @param {string} filePath
* @param {'audio'|'video'|'file'} kind
*/
export async function uploadMedia(filePath, kind) {
if (!['audio', 'video', 'file'].includes(kind)) {
throw new Error(`invalid kind: ${kind}`)
}
const addon = aiService.getAddonName()
const config = await aiService.getServiceConfig()
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
}
/**
* PHP 中转 (multipart/form-data)
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
*/
async function uploadViaPhp(filePath, kind, addon, agentId) {
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
+ (kind === 'image' ? 'image' : 'file')
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.uploadFile({
url: Config.baseUrl + url,
filePath,
name: 'file',
formData: {
kind,
agent_id: addon === 'aiagent' ? agentId : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
success: (res) => {
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (data.code === 0 || data.success) {
resolve({
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
})
} else {
reject(new Error(data.msg || data.message || '上传失败'))
}
} catch (e) {
reject(new Error('解析响应失败: ' + e.message))
}
},
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
})
})
// #endif
// #ifdef H5
const form = new FormData()
const file = await pathToFile(filePath, kind)
form.append('file', file)
form.append('kind', kind)
if (addon === 'aiagent') form.append('agent_id', agentId)
else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '')
form.append('uniacid', aiService.getUniacid())
form.append('user_id', aiService.getUserId?.() || '')
const res = await fetch(Config.baseUrl + url, {
method: 'POST',
headers: { 'token': store.state.token || '' },
body: form,
})
const data = await res.json()
if (data.code === 0 || data.success) {
return {
url: data.data.url,
mime: data.data.mime,
size: data.data.size,
name: data.data.name,
kind: data.data.kind || kind,
used_strategy: data.data.used_strategy || 'backend',
oss_sts_required: data.data.oss_sts_required || false,
}
}
throw new Error(data.msg || data.message || '上传失败')
// #endif
}
/**
* STS 直传 (拿临时凭证 → 直传云存储)
*
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
* 标记, 不带 Authorization
*
* 后端 stsResp.data.data 可能字段:
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
*/
async function uploadViaSts(filePath, kind, addon, agentId, config) {
// 1. 拿 STS 凭证 (addon 模式 URL)
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
const stsResp = await uni.request({
url: Config.baseUrl + stsUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': store.state.token || ''
},
data: {
agent_id: addon === 'aiagent' ? agentId : undefined,
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
uniacid: aiService.getUniacid(),
user_id: aiService.getUserId?.() || '',
},
})
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
}
const sts = stsResp.data.data
const provider = sts.provider || 'alioss' // 老后端默认 alioss
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
const ext = (filePath.split('.').pop() || 'bin').toLowerCase()
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
const now = new Date()
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
.replace('{site_id}', sts.site_id || '0')
.replace('{agent_id}', agentId || '0')
.replace('{yyyy}', now.getFullYear().toString())
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
.replace('{rand}', uuid)
+ '.' + ext
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
const isSingleUrlMode = typeof sts.securityToken === 'string'
&& /-(SIGNED)$/.test(sts.securityToken)
let ossUrl, headers
if (isSingleUrlMode) {
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
ossUrl = sts.accessKeyId
headers = { 'Content-Type': guessMime(ext) }
} else if (provider === 'qiniu') {
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
headers = {
'Authorization': 'UpToken ' + sts.accessKeyId,
'Content-Type': guessMime(ext),
}
} else {
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
ossUrl = `${tpl}/${key}`
headers = {
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
'x-oss-security-token': sts.securityToken,
'Content-Type': guessMime(ext),
}
}
// 3. 直传云存储
// #ifdef MP-WEIXIN
await new Promise((resolve, reject) => {
uni.uploadFile({
url: ossUrl,
filePath,
name: 'file',
formData: {},
header: headers,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
else reject(new Error(`${provider} PUT ${res.statusCode}`))
},
fail: reject,
})
})
// #endif
// #ifdef H5
const file = await pathToFile(filePath, kind)
const ossRes = await fetch(ossUrl, {
method: 'PUT',
headers,
body: file,
})
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
// #endif
// 4. 返回公网 URL
return {
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
mime: guessMime(ext),
size: file.size || 0,
name: key.split('/').pop(),
kind,
used_strategy: 'oss_sts',
provider,
}
}
async function getFileSize(filePath) {
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
uni.getFileInfo({
filePath,
success: (res) => resolve(res.size),
fail: () => resolve(0),
})
})
// #endif
// #ifdef H5
try {
const res = await fetch(filePath)
const blob = await res.blob()
return blob.size
} catch (e) { return 0 }
// #endif
}
async function pathToFile(filePath, kind) {
// #ifdef H5
const res = await fetch(filePath)
const blob = await res.blob()
return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type })
// #endif
}
function guessMime(ext) {
const map = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
mp4: 'video/mp4', mov: 'video/quicktime',
pdf: 'application/pdf', txt: 'text/plain',
}
return map[ext] || 'application/octet-stream'
}
export default { uploadImage, uploadMedia }

View File

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

View File

@@ -98,7 +98,8 @@
</block>
</block>
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
<!-- 模板1: 后台配置二级分类 + 有子分类时, 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式)
模板5 不走这里: 它有专属分支 value.template == 5(后台"展示分类"决定渲染商品网格或二级分类网格) -->
<view v-else-if="value.template == 1">
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
@@ -139,8 +140,51 @@
</view>
</view>
<!-- 非模板1, 2级分类: 显示二级分类网格,点击跳转到列表页 -->
<block v-else-if="categoryLevel == 2">
<!-- 模板5 专属: 后台"展示分类"决定右侧渲染内容(对应图2/图3)
二级分类(value.level == 2) → 一级分类标题 + 商品网格(图2)
三级分类(value.level == 3) → 一级分类标题 + 二级分类网格(图3) -->
<block v-else-if="value.template == 5">
<block v-if="value.level == 2">
<view :class="['category-title', '']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
<view class="goods-item" v-for="(item, index) in template1GoodsList" :key="index">
<view class="goods-img" @tap.stop="toDetail(item)">
<image :src="goodsImg(item.goods_image)" mode="widthFix" @error="template1ImgError(index)" show-menu-by-longpress />
<view class="color-base-bg goods-tag" v-if="item.label_name">{{ item.label_name }}</view>
</view>
<view class="info-wrap">
<view class="name-wrap" @tap.stop="toDetail(item)">
<view class="goods-name">{{ isEnEnv ? item.en_goods_name : item.goods_name }}</view>
</view>
<view class="price-wrap">
<view class="discount-price">
<block v-if="item.isinformation == 0">
<text class="unit price-style small">{{ $lang('common.currencySymbol') }}</text>
<text class="price price-style large">{{ parseFloat(showPrice(item)).toFixed(2).split('.')[0] }}</text>
<text class="unit price-style small">.{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }}</text>
</block>
</view>
<view class="delete-price font-size-activity-tag color-tip price-font"
v-if="item.isinformation == 0 && showMarketPrice(item)">
<text class="unit">{{ $lang('common.currencySymbol') }}</text>
<text>{{ showMarketPrice(item) }}</text>
</view>
</view>
<view class="footer-wrap footer-wrap-template1">
<view class="detail-btn" @tap.stop="toDetail(item)">{{ $lang('components-diy.diy-category-item.consultNow') }}</view>
</view>
</view>
</view>
</view>
<view class="category-goods-empty" v-else-if="!template1Loading">
<image :src="$util.img('public/uniapp/category/empty.png')" mode="widthFix" show-menu-by-longpress></image>
<view class="tips">暂无商品</view>
</view>
</view>
</block>
<block v-else>
<!-- 三级分类(及更深层级): 一级分类标题 + 二级分类网格(图3) -->
<view :class="['category-title', '']">{{ category.category_name }}</view>
<view class="category-list">
<view class="category-item" v-for="(one, oneIndex) in category.child_list" :key="oneIndex"
@@ -152,25 +196,11 @@
</view>
</view>
</block>
<block v-else-if="categoryLevel == 3">
<block v-for="(one, oneIndex) in category.child_list" :key="oneIndex">
<view :class="['category-title', '']">{{ one.category_name }}</view>
<view class="category-list">
<view class="category-item" v-for="(two, twoIndex) in one.child_list" :key="twoIndex"
@tap.stop="$util.redirectTo('/pages_goods/list', { category_id: two.category_id })">
<view class="img-box">
<image :src="categoryImg(two.image)" mode="aspectFit" @error="categoryImgError(3, oneIndex, twoIndex)" show-menu-by-longpress />
</view>
<view class="name">{{ two.category_name }}</view>
</view>
</view>
</block>
</block>
</block>
<!-- 模板1: 无子分类时(外层块不进入), 在一级分类标题下以双列网格显示所有商品(图 c2df0f3d29 样式) -->
<view v-else-if="value.template == 1 && type == 'category'">
<view v-else-if="(value.template == 1 || value.template == 5) && type == 'category'">
<view :class="['category-title', 'category-title-with-lines']">{{ category.category_name }}</view>
<view class="template1-goods-wrap">
<view class="goods-list show" :class="{ 'double-column': !isList, 'single-column': isList }" v-if="template1GoodsList.length">
@@ -617,13 +647,13 @@ export default {
* 三级分类 (value.level == 3) → true → 按二级分类分别显示各自商品
*/
isGroupedDisplay() {
// 仅模板1 走"按二级分类分组显示商品"; 模板5 的层级由 value.level 直接决定(见下方 value.template == 5 分支)
return this.value.template == 1 && this.value.level == 3;
},
/**
* 模板1三级分类: 渲染所有二级分类(包括无商品的空分类)
* 不再按"是否有商品"过滤, 保证一级分类下的二级分类全部显示,
* 避免 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题
* 加载中(template1ChildLoading[id] === true)的二级分类暂不渲染, 避免闪烁
* 模板1三级分类: 只渲染"有商品"的二级分类
* 无商品的二级分类整块(标题 + 网格)都不渲染; 加载中的二级分类也暂不渲染(避免闪烁)
* 判定时机: 该二级分类加载完成(template1ChildLoading[id] === false)且商品列表非空
*/
filteredGroupedChildren() {
if (!this.isGroupedDisplay) return [];
@@ -631,8 +661,9 @@ export default {
return this.category.child_list.filter(one => {
// 还在加载中: 暂不渲染(避免闪烁)
if (this.template1ChildLoading[one.category_id]) return false;
// 加载完成(无论是否有商品)都渲染, 无商品时显示空状态
return true;
// 加载完成: 只有该二级分类下有商品时才显示, 无商品则整块隐藏
const list = this.template1ChildGoodsList[one.category_id];
return !!(list && list.length);
});
},
/**
@@ -642,7 +673,7 @@ export default {
* 若 type 不跟随更新, 模板1 会误走 goods 分支, 导致"资讯类商品=否"的商品不显示立即咨询按钮
*/
type() {
return this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods';
return this.value.template == 1 || this.value.template == 5 || this.value.template == 99 ? 'category' : 'goods';
},
},
created() {
@@ -664,7 +695,14 @@ export default {
// 模板1:根据后台"展示分类"配置 + 数据层级决定加载方式
// 三级分类模式 (isGroupedDisplay) + 有二级分类时,按二级分类分别加载各自商品
// 其他情况(二级分类模式 / 无二级分类),直接加载当前一级分类下的所有商品
if (this.value.template == 1 && this.category && this.category.category_id) {
// 模板5 渲染规则:
// 二级分类(value.level == 2) → 商品网格(图2), 需要加载该一级分类下所有商品(合并其二级分类)
// 三级分类(value.level == 3) → 二级分类网格(图3), 静态网格无需加载商品
// 若该一级分类下没有子分类(兜底), 仍需要加载商品
const template5NeedGoods = this.value.template == 5 &&
(this.value.level == 2 ||
!(this.category && this.category.child_list && this.category.child_list.length));
if ((this.value.template == 1 || template5NeedGoods) && this.category && this.category.category_id) {
if (this.isGroupedDisplay && this.category.child_list && this.category.child_list.length) {
this.category.child_list.forEach(child => {
this.getTemplate1ChildGoods(child.category_id);
@@ -739,6 +777,8 @@ export default {
}
// 模板1:切换一级分类时重新加载该分类下的商品
// 三级分类模式 + 有二级分类时,按二级分类分别重新加载,否则直接加载当前分类
// 注意: 模板5 是"全部分类纵向平铺", 商品由 created 首次加载 + 父级链式懒加载提供,
// 滚动时 select 会随 listenScroll 变化, 若此处也重载会导致滚动中反复清空/重复请求, 故排除 5
if (this.value.template == 1 && this.index == this.select && this.category && this.category.category_id) {
this.template1GoodsList = [];
this.template1ChildGoodsList = {};
@@ -1489,42 +1529,42 @@ export default {
}
}
// 分类网格(模板5 / legacy 商品分类): 白底卡片 + 3 列 图片 + 名称
// 与 Branch_5cfee417 的商品分类保持一致
.category-list {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
background: transparent;
display: flex;
flex-wrap: wrap;
background: #fff;
margin: 0 20rpx;
border-radius: 0;
padding: 0;
gap: 20rpx 0;
border-radius: 10rpx;
padding: 10rpx 0;
.category-item {
width: 100% !important;
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
padding: 16rpx;
box-sizing: border-box;
.img-box {
width: 70% !important;
width: 80%;
height: 0;
padding-bottom: 70%;
padding-top: 80%;
margin: 0 auto;
overflow: hidden;
margin-bottom: 16rpx;
margin-bottom: 20rpx;
position: relative;
background: transparent !important;
border-radius: 0 !important;
background: transparent;
image {
position: absolute;
width: 100% !important;
height: 100% !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
width: 100%;
height: 100%;
left: 0;
top: 0;
transform: none;
}
}
@@ -1539,7 +1579,6 @@ export default {
font-size: var(--diy-category-item-name-size, 24rpx);
}
// 模板1选中状态
&.selected {
.img-box {
border: 2rpx solid var(--base-color, #FF6B35);

View File

@@ -122,7 +122,7 @@
{ select: select == index },
{ 'border-bottom': value.template == 4 && select + 1 === index },
{ 'border-top': value.template == 4 && select - 1 === index },
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1) }
{ 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) }
]" @tap.stop="switchOneCategory(index)">
<view class="">{{ item.category_name }}</view>
</view>
@@ -152,10 +152,43 @@
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"></diy-category-item>
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
</view>
</scroll-view>
<!-- 模板5 + 二级分类(value.level==2): 单面板切换 - 右侧只显示"当前选中"的一级分类,
点左侧分类切换, 其它分类隐藏(display:none); 每个一级分类: 标题 + 商品网格 -->
<scroll-view scroll-y="true" class="content-wrap"
v-else-if="value.template == 5 && value.level == 2"
:refresher-enabled="true" refresher-default-style="none"
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore">
<view class="child-category-wrap" v-for="(item, index) in categoryTree" :key="index"
:id="'category-' + index" :style="{ display: select == index ? 'block' : 'none' }">
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@saveSecondCategory="saveSecondCategoryName"> </diy-category-item>
</view>
</scroll-view>
<!-- 模板5 + 三级分类(value.level!=2): 左侧分类树 + 右侧纵向平铺"全部一级分类",
每个一级分类: 标题 + 二级分类网格; 左侧点击分类用 scroll-into-view 定位,
滚动时由 listenScroll 反推并高亮左侧分类 -->
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 5"
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
:refresher-enabled="true" refresher-default-style="none"
:refresher-triggered="triggered" @refresherrefresh="onRefresh" @refresherrestore="onRestore"
@scroll="listenScroll" @touchstart="touchStart">
<view class="child-category" v-for="(item, index) in categoryTree" :key="index"
:id="'category-' + index">
<diy-category-item :category="item" :value="value" ref="categoryItem" :index="index"
:select="select" :oneCategorySelect="oneCategorySelect" :isList="isList"
@tologin="toLogin" @selectsku="selectSku($event, index)" @addCart="addCartPoint"
@loadfinish="getHeightArea" @saveSecondCategory="saveSecondCategoryName"></diy-category-item>
</view>
<view class="end-tips" ref="endTips" :style="{ opacity: endTips }">{{ $lang('components-diy.diy-category.reachedBottom') }}</view>
</scroll-view>
<scroll-view scroll-y="true" class="content-wrap" v-else-if="value.template == 99 || loadType == 'all'"
ref="contentWrap" :scroll-into-view="categoryId" :scroll-with-animation="true"
@scroll="listenScroll" @touchstart="touchStart" :refresher-enabled="true"
@@ -308,6 +341,7 @@ export default {
})
.exec();
if (this.value.template == 1) this.getHeightArea(-1);
if (this.value.template == 5) this.getHeightArea(-1);
}, 500);
},
watch: {
@@ -317,6 +351,9 @@ export default {
if (nval === false && oval === true) {
this.$nextTick(() => {
if (!this.heightArea.length) this.getHeightArea(-1);
// 分类渲染完成后重新对齐到当前选中的一级分类顶部,
// 避免首屏第一个分类的标题被顶出可视区
if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select);
});
}
}
@@ -536,8 +573,30 @@ export default {
switchOneCategory(index) {
if (index >= this.categoryTree.length) return;
this.select = index;
this.categoryId = 'category-' + index;
this.scrollLock = true;
this.alignToCategory(index);
},
/**
* 把右侧滚动区定位到指定一级分类的顶部
*
* uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动,
* 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写;
* 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0)
* 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区
* (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。
* 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。
* @param {Number} index 一级分类下标
*/
alignToCategory(index) {
const target = 'category-' + index;
if (this.categoryId === target) {
this.categoryId = '';
this.$nextTick(() => {
this.categoryId = target;
});
} else {
this.categoryId = target;
}
},
saveSecondCategoryName(name) {
this.lastSecondCategoryName = name;
@@ -559,7 +618,10 @@ export default {
break;
}
}
if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] -
// 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载,
// 并补上 heightArea[this.select] 越界保护
if (this.value.template != 1 && this.value.loadType == 'all' &&
this.heightArea[this.select] && this.heightArea[this.select][1] -
scrollTop - contentWrapHeight < 300) {
if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList();
}
@@ -1674,6 +1736,134 @@ export default {
}
}
// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) =====================
.category-template-5 {
.search-box {
padding: 20rpx 24rpx;
background: #fff;
.search-content {
background-color: #f5f5f5;
border-radius: 40rpx;
input {
background: #f5f5f5;
border-radius: 40rpx;
padding: 0 30rpx;
font-size: 26rpx;
color: #333;
}
.iconfont {
right: 16rpx;
color: #89899a;
font-size: 32rpx;
}
}
}
.content-box {
// 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致
background: transparent;
.tree-wrap {
width: 170rpx;
background-color: #fff;
}
.right-flex-wrap {
background: #f5f5f5;
.content-wrap {
background: transparent;
}
// 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致)
.child-category {
width: 100%;
box-sizing: border-box;
}
}
}
// 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明
::v-deep .category-adv {
padding: 0 20rpx;
margin-top: 0;
}
// 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明)
// 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐
::v-deep .category-title-with-lines {
height: 100rpx;
box-sizing: border-box;
padding: 30rpx 0 20rpx;
}
// 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐
.tree-wrap .category-item.first-category-item {
height: 100rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
overflow: hidden;
view {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称),
// 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
&.border-top,
&.border-bottom {
border-radius: 0;
}
&.select {
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
&::before {
content: ' ';
width: 6rpx;
height: 32rpx;
background: var(--base-color, #FF6B35);
display: block;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 0 6rpx 6rpx 0;
}
}
}
}
// ===================== 模板99 商品分类样式 =====================
.category-template-99 {
.search-box {

View File

@@ -377,14 +377,41 @@ export default {
: index + 1;
if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') {
newItem.pageStyle = `background-color:${newItem.pageBgColor};`;
// PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色
// 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体;
// 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。
const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor)
? newItem.componentBgColor
: newItem.pageBgColor;
newItem.pageStyle = `background-color:${wrapBgColor};`;
if (newItem.margin) {
newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`;
newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`;
newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`;
// 边距归一化: 兼容后台两种数据结构(否则边距不生效)
// ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件
// ② 扁平字段形态 marginTop / marginBottom / marginBoth
// —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里
// both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致)
const rawMargin = newItem.margin;
const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null;
const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; };
const pickMargin = (objKey, flatKey) => {
if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') {
return toMarginNum(objMargin[objKey]);
}
return toMarginNum(newItem[flatKey]);
};
const marginTopNum = pickMargin('top', 'marginTop');
const marginBottomNum = pickMargin('bottom', 'marginBottom');
const marginBothNum = pickMargin('both', 'marginBoth');
// 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致)
const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left)
: (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum);
const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right)
: (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum);
newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`;
newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`;
newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`;
newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`;
}
return newItem;

View File

@@ -1,10 +1,16 @@
<template>
<view :style="componentStyle" data-component-name="diy-image-nav">
<scroll-view :class="['image-nav', value.showStyle == 'fixed' ? 'fixed-layout' : value.showStyle]"
:scroll-x="value.showStyle == 'singleSlide'">
<!-- 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="margin-right: 28rpx;">
:style="imageItemStyle">
<!-- 图片部分 -->
<view v-if="value.mode != 'text'" class="image-img" :style="{
'font-size': (value.imageSize * 2 + 'rpx') + ';',
@@ -60,7 +66,10 @@ export default {
slideDidDrag: false, // 5px 阈值后置 true,用于判断 mouseup 后是否拦截 click
slideStartX: 0,
slideStartScrollLeft: 0,
_suppressNextClick: false // 拖拽结束后拦截一次 click,防止误触发跳转
_suppressNextClick: false, // 拖拽结束后拦截一次 click,防止误触发跳转
// PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): "滑动时显示"模式专用
// 滚动中为 true → 给 scroll-view 加 is-scrolling 类展示滚动条, 停止 800ms 后隐藏
slideScrolling: false
}
},
created() {
@@ -75,12 +84,14 @@ export default {
var self = this;
this.$nextTick(function() {
self.bindSlideMouseEvents();
self.bindSlideScrollbar();
});
// #endif
},
beforeDestroy() {
// #ifdef H5
this.unbindSlideMouseEvents();
this.unbindSlideScrollbar();
// #endif
},
watch: {
@@ -89,9 +100,47 @@ export default {
}
},
computed: {
// PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 组件背景色
// 后台"组件背景"面板下发的字段是 componentBgColor(与图片轮播/搜索等组件一致);
// 兼容别名/数组形态/嵌套容器(避免后台换名或存成数组时前端读不到而完全不上色),
// 最后兜底组件级 pageBgColor(部分组件后台把组件背景存在这里)。
// 空值/非法值(图片URL等)返回空串, 此时不输出 background-color, 保持历史透明视觉。
componentBgColor() {
const v = this.value || {};
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
const asColor = val => {
if (Array.isArray(val)) val = val[0];
if (typeof val !== 'string') return '';
const s = val.trim();
if (!s) return '';
// 排除图片/URL 等非颜色值
if (/^(url\(|https?:|data:|upload\/|addon\/|\/)/i.test(s)) return '';
return s;
};
const sources = [v];
['style', 'config', 'setting', 'data'].forEach(k => {
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
});
const candidates = [
'componentbgcolor', 'componentbackgroundcolor', 'componentcolor', 'componentbg',
'bgcolor', 'backgroundcolor', 'background',
'pagebgcolor'
];
for (let c = 0; c < candidates.length; c++) {
for (let s = 0; s < sources.length; s++) {
for (const key in sources[s]) {
if (norm(key) === candidates[c]) {
const color = asColor(sources[s][key]);
if (color) return color;
}
}
}
}
return '';
},
componentStyle() {
let style = '';
style += 'background-color:' + this.value.componentBgColor + ';';
if (this.componentBgColor) style += 'background-color:' + this.componentBgColor + ';';
if (this.value.componentAngle == 'round') {
style += 'border-top-left-radius:' + (2 * this.value.topAroundRadius) + 'rpx;';
@@ -123,9 +172,219 @@ export default {
isIndicatorDots() {
return this.value.carousel.type != 'hide' &&
1 != Math.ceil(this.value.list.length / (this.value.pageCount * this.value.rowCount));
},
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
// 后台"水平滚动条"配置: 滑动时显示 / 始终显示 / 永不显示 → 归一化为 slide / always / never
// 字段名兼容: scrollBar / scrollbar / scrollBarType / horizontalScrollBar / horizontalScrollbar
// (忽略大小写/下划线/中划线差异, 并向下探测 value.scrollBar / value.style 等嵌套容器)
// 值兼容: 英文枚举(slide/auto/always/never/none...) / 中文枚举(滑动时显示/始终显示/永不显示)
// / 数字(0=滑动时显示 1=始终显示 2=永不显示) / 布尔(true=始终显示 false=永不显示)
// 兜底: 未下发或无法识别 → 'slide' (滑动时显示, 与历史行为一致)
scrollBarMode() {
const v = this.value || {};
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
// 从配置对象中取"滚动条"字段的原始值
const pickRaw = obj => {
if (!obj || typeof obj != 'object') return undefined;
for (const key in obj) {
const k = norm(key);
if (k.indexOf('scrollbar') != -1 || k.indexOf('scrolltype') != -1) {
const val = obj[key];
if (val && typeof val === 'object') {
// 子配置对象形态: { scrollBar: { type: 'always' } }
return val.type !== undefined ? val.type : (val.value !== undefined ? val.value : val.mode);
}
if (val !== undefined && val !== null && val !== '') return val;
}
}
return undefined;
};
let raw = pickRaw(v);
// 嵌套容器兜底
if (raw === undefined) {
const containers = ['scrollBar', 'scrollbar', 'style', 'config', 'setting', 'data'];
for (let i = 0; i < containers.length; i++) {
if (v[containers[i]] && typeof v[containers[i]] === 'object') {
raw = pickRaw(v[containers[i]]);
if (raw !== undefined) break;
}
}
}
if (raw === undefined || raw === null || raw === '') return 'slide';
if (typeof raw === 'boolean') return raw ? 'always' : 'never';
if (raw === 0 || raw === '0') return 'slide';
if (raw === 1 || raw === '1') return 'always';
if (raw === 2 || raw === '2') return 'never';
const map = {
slide: 'slide', scroll: 'slide', auto: 'slide', scrolling: 'slide', whilescrolling: 'slide', moveslide: 'slide',
always: 'always', alwaysdisplay: 'always', show: 'always',
never: 'never', none: 'never', hidden: 'never', hide: 'never',
// 后台直出中文枚举时兜底
'滑动时显示': 'slide', '滑动时': 'slide', '滚动时显示': 'slide',
'始终显示': 'always', '一直显示': 'always', '永久显示': 'always',
'永不显示': 'never', '从不显示': 'never', '不显示': 'never'
};
const hit = map[String(raw).toLowerCase().trim()] || map[norm(raw)];
return hit || 'slide';
},
// scroll-view 上的滚动条显隐 class: scrollbar-slide / scrollbar-always / scrollbar-never
// "滑动时显示"在滚动中追加 is-scrolling (由 scroll 监听维护)
scrollBarClass() {
const cls = ['scrollbar-' + this.scrollBarMode];
if (this.scrollBarMode == 'slide' && this.slideScrolling) cls.push('is-scrolling');
return cls;
},
// ========== PR-FRONTEND-DIY-IMAGE-NAV-SPACING (2026-09-18): 图片自定义间距 ==========
// 后台"滑动图片"组件(componentName = ImageNav)的 3 项间距配置。
// 字段名已用线上接口 POST /api/diyview/info(uniacid=312, DIY_VIEW_INDEX) 核对, 实际为:
// imageSpacing 图片横向间距: 相邻图片项之间的横向间隔
// imageSpacingTop 图片上间距 : 图片区域内侧的上内边距
// imageSpacingBottom 图片下间距 : 图片区域内侧的下内边距
// 单位: 按项目惯例(与 diy-group 的 margin.top、图片轮播的 margin.both 一致)换算 rpx = 后台数值 × 2
// 容错: 除后台现用命名外, 仍按归一化(忽略大小写/下划线/中划线)兼容其它命名,
// 并向下探测 value.imageSpacing / value.margin / value.padding 等嵌套容器。
// 兜底: 字段缺失/非法时返回 null, 由 imageSpacingStyle / imageItemStyle 回退历史视觉
// (横向 28rpx, 上/下沿用 CSS 的 16rpx), 老装修数据不受影响。
imageSpacingConfig() {
const v = this.value || {};
const RATIO = 2; // 后台数值 → rpx 换算比例
const norm = k => String(k).toLowerCase().replace(/[\s_-]/g, '');
// 非数字(如 'diy'/枚举)或空值 → null, 交给兜底
const toRpx = val => {
if (val === undefined || val === null || val === '') return null;
const n = Number(val);
if (isNaN(n)) return null;
return Math.max(0, n) * RATIO;
};
// 查找源: value 自身 + 常见嵌套容器(顶层优先)
// 注意: 后端偶发把 margin 存成数组(线上该组件 margin: []), 数组不能当容器取裸键, 直接跳过
const sources = [v];
['imageSpacing', 'imageSpace', 'spacing', 'space', 'margin', 'padding', 'style', 'config', 'setting']
.forEach(k => {
if (v[k] && typeof v[k] === 'object' && !Array.isArray(v[k])) sources.push(v[k]);
});
// 三级查找: ① 全源精确名 → ② 全源模糊包含名 → ③ 嵌套容器内的裸键
// (如 imageSpacing: { horizontal: 3, top: 0, bottom: 0 })
const pick = (exact, fuzzy, nested) => {
for (let s = 0; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
if (exact.indexOf(norm(key)) != -1) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
for (let s = 0; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
const k = norm(key);
if (fuzzy.some(f => k.indexOf(f) != -1)) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
// 裸键(top/bottom/both...)只在嵌套容器内解释, 避免误取顶层同名字段
for (let s = 1; s < sources.length; s++) {
const src = sources[s];
for (const key in src) {
if (nested.indexOf(norm(key)) != -1) {
const rpx = toRpx(src[key]);
if (rpx !== null) return rpx;
}
}
}
return null;
};
return {
// 图片横向间距 —— 后台现用命名 imageSpacing(首项命中即可, 后面都是兼容别名)
horizontal: pick(
['imagespacing', 'imagespacinghorizontal', 'imagehorizontalspacing', 'imagespacingboth', 'imagespacenum',
'imagemargin', 'imagemarginnum', 'imagespace', 'horizontalspacing', 'horizontalspacenum', 'imagegap',
'spacenum', 'marginnum', 'imageverticalspace', 'space', 'gap'],
['horizontalspacing', 'imagespacing', 'imagespace', 'imagemargin', 'spacenum', 'marginnum', 'imagegap', 'gap'],
['horizontal', 'horizontalnum', 'lr', 'leftright', 'both', 'bothnum', 'imagespacing', 'num', 'margin', 'space', 'gap']
),
// 图片上间距 —— 后台现用命名 imageSpacingTop
paddingTop: pick(
['imagespacingtop', 'imagetopspacing', 'imagespacetop', 'imagetopspace', 'imagepaddingtop',
'paddingtop', 'spacingtop', 'topspace', 'margintop'],
['imagespacingtop', 'paddingtop', 'spacingtop', 'topspacing', 'topspace', 'margintop'],
['top', 'topnum', 'paddingtop', 'margintop', 'spacetop', 'spacingtop']
),
// 图片下间距 —— 后台现用命名 imageSpacingBottom
paddingBottom: pick(
['imagespacingbottom', 'imagebottomspacing', 'imagespacebottom', 'imagebottomspace', 'imagepaddingbottom',
'paddingbottom', 'spacingbottom', 'bottomspace', 'marginbottom'],
['imagespacingbottom', 'paddingbottom', 'spacingbottom', 'bottomspacing', 'bottomspace', 'marginbottom'],
['bottom', 'bottomnum', 'paddingbottom', 'marginbottom', 'spacebottom', 'spacingbottom']
)
};
},
// 图片区域上/下内边距(作用在滚动容器上); 未配置时不输出内联样式, 沿用 CSS padding
imageSpacingStyle() {
const cfg = this.imageSpacingConfig;
let style = '';
if (cfg.paddingTop !== null) style += 'padding-top:' + cfg.paddingTop + 'rpx;';
if (cfg.paddingBottom !== null) style += 'padding-bottom:' + cfg.paddingBottom + 'rpx;';
return style;
},
// 图片项之间的横向间距; 未配置时回退原写死的 28rpx
imageItemStyle() {
const horizontal = this.imageSpacingConfig.horizontal;
return 'margin-right:' + (horizontal === null ? 28 : horizontal) + 'rpx;';
}
},
methods: {
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): H5 "滑动时显示"滚动条控制 ==========
// 背景: H5 端 scroll-view 的横向滚动条默认被 CSS 隐藏; "滑动时显示"需要监听 scroll,
// 滚动中给容器加 is-scrolling 类展示滚动条, 停止滚动 800ms 后移除类再隐藏。
// "始终显示/永不显示"为纯 CSS 类控制(scrollbar-always/scrollbar-never), 无需 JS。
bindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollBound) return;
var el = this.getSlideContainer();
if (!el) return;
this._slideScrollEl = el;
this._slideScrollBound = true;
var self = this;
this._onSlideScroll = function() {
if (self.scrollBarMode != 'slide') return;
if (!self.slideScrolling) self.slideScrolling = true;
clearTimeout(self._slideScrollTimer);
self._slideScrollTimer = setTimeout(function() {
self.slideScrolling = false;
}, 800);
};
el.addEventListener('scroll', this._onSlideScroll, { passive: true });
// #endif
},
unbindSlideScrollbar() {
// #ifdef H5
if (this._slideScrollEl && this._onSlideScroll) {
this._slideScrollEl.removeEventListener('scroll', this._onSlideScroll);
}
clearTimeout(this._slideScrollTimer);
this._slideScrollEl = null;
this._onSlideScroll = null;
this._slideScrollBound = false;
this.slideScrolling = false;
// #endif
},
// 页面跳转
redirectTo(link) {
if (!link.wap_url || this.$util.getCurrRoute() != this.$util.MEMBER_PAGE_URL || this.storeToken) {
@@ -327,17 +586,61 @@ export default {
// #ifdef H5
overflow-x: auto;
overflow-y: hidden;
// 隐藏滚动条,视觉上仍保持单页布局;用户用鼠标拖拽/滚轮即可滚动
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge legacy
&::-webkit-scrollbar {
display: none;
}
// 禁止鼠标选中文字(拖拽时选中会显得很别扭)
user-select: none;
-webkit-user-select: none;
// 拖拽时关闭过渡,避免 Chrome 的惯性滚动与 JS 抢控制权
-webkit-overflow-scrolling: touch;
// ========== PR-FRONTEND-DIY-SCROLLBAR (2026-09-18): 水平滚动条显隐三态 ==========
// 默认态(滑动时显示的静止态 / 永不显示): 隐藏滚动条,视觉上仍保持单页布局;
// 用户用鼠标拖拽/滚轮即可滚动(滚动能力不受滚动条显隐影响)
scrollbar-width: none; // Firefox
-ms-overflow-style: none; // IE/Edge legacy
&::-webkit-scrollbar {
display: none;
}
// uni scroll-view 在 H5 编译后真滚动容器是内层 .uni-scroll-view, 同步隐藏
::v-deep .uni-scroll-view {
scrollbar-width: none;
-ms-overflow-style: none;
}
::v-deep .uni-scroll-view::-webkit-scrollbar {
display: none;
}
// "始终显示" 与 "滑动时显示"(滚动中, 由 is-scrolling 类驱动): 展示细滚动条
&.scrollbar-always,
&.scrollbar-slide.is-scrolling {
scrollbar-width: thin; // Firefox
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
&::-webkit-scrollbar {
display: block;
height: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25);
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
::v-deep .uni-scroll-view {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
}
::v-deep .uni-scroll-view::-webkit-scrollbar {
display: block;
height: 4px;
}
::v-deep .uni-scroll-view::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25);
border-radius: 2px;
}
::v-deep .uni-scroll-view::-webkit-scrollbar-track {
background-color: transparent;
}
}
// #endif
}

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>
@@ -42,6 +44,7 @@
</block>
</view>
</view>
</view>
<!-- 解决fixed定位后导航栏塌陷的问题 -->
<view v-if="value.positionWay == 'fixed'" class="u-navbar-placeholder"
:style="{ width: '100%', paddingTop: moduleHeight }"></view>
@@ -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;

2149
docs/ai-agent-integration.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2625,7 +2625,7 @@
},
"ai-chat": {
"index": {
"title": "AI智能",
"title": "AI智能客服",
"cancel": "取消",
"save": "保存",
"send": "发送",
@@ -2642,7 +2642,7 @@
"loadMoreHistory": "加载更多历史消息",
"loadingHistory": "加载历史消息中...",
"aiThinking": "AI正在思考",
"receivingReply": "思考中",
"receivingReply": "正在接收回复",
"aiThinkingStatus1": "AI正在思考",
"aiThinkingStatus2": "AI正在分析",
"aiThinkingStatus3": "AI正在检索",
@@ -2652,10 +2652,6 @@
"noContentToCopy": "无可复制的内容",
"copySuccess": "已复制",
"copySelected": "已复制选中内容",
"selectTextToCopy": "选取文字复制",
"selectedText": "已选文字",
"copyAll": "复制全部",
"selectTextTip": "长按文字拖动两端手柄,可自由选择复制范围",
"copyFail": "复制失败",
"copyFailWithReason": "复制失败:{reason}",
"openingFile": "打开文件中...",

View File

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

View File

@@ -126,8 +126,6 @@ export const lang = {
copyTimeoutReason: 'Reason:',
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
// readable tip when clipboard privacy scope is not declared (errno 112)
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
copyFailed: 'Copy failed',
},

View File

@@ -0,0 +1,83 @@
export const lang = {
// diy-recommend-code referral code component
// Title
title: 'My Referral Code',
// Share now button
buttonText: 'Share Now',
// Copy code button
copyButtonText: 'Copy Code',
// Get a new code
placeholderRegenerateText: 'Get a New Code',
// Consent description
consentText: 'Your referral code is used to track enterprise registrations via your shared link. We record each application completed through your link for commission settlement.',
// Agree and get code button
consentButtonText: 'Agree & Get Code',
// Login guide tip
loginTipText: 'Log in to view your referral code',
// Log in button
loginBtnText: 'Log In',
// Poster QR tip
posterQrTip: 'Scan to enter and fill in registration info',
// Exclusive code label
exclusiveCode: 'Exclusive Code',
// Code pending label
pendingCode: 'Code Pending',
// Pending
pending: 'Pending',
// Loading code
loading: 'Loading code...',
// Code obtained label
obtainedCode: 'Code Obtained',
// Obtained
obtained: 'Obtained',
// Share invite reward description
shareInviteReward: 'Share with friends and earn rewards for invites',
// Active
activated: '✓ Active',
// Code generated description
codeGenerated: 'Code generated. Use it to invite friends or bind referral for enterprise registration',
// Submitting
submitting: 'Submitting...',
// Share title
shareTitle: 'Inviting you to join the low-altitude economy platform',
// Get failed
getFail: 'Failed to get code. Please retry later',
// Get failed (business code)
getFailCode: 'Failed to get code (business code={code})',
// Get success
getSuccess: 'Obtained',
// Network error
netError: 'Network error. Please retry later',
// Renew code modal title
regenTitle: 'Renew Code',
// Renew code modal content
regenContent: 'After renewal, the old code becomes invalid immediately. All registrations via the old link will no longer be attributed to you. Continue?',
// Confirm renew
regenConfirm: 'Confirm Renew',
// Cancel
regenCancel: 'Cancel',
// Renew failed
regenFail: 'Renewal failed. Please retry later',
// Renew success (old disabled)
regenSuccessOld: 'New code issued. Old code {old} disabled',
// Renew success
regenSuccess: 'Renewed',
// Code not ready yet
codeNotReady: 'Code not ready yet. Please wait',
// Code copied
codeCopied: 'Code copied',
// Copy failed
copyFail: 'Copy failed',
// Copy content is empty
copyEmpty: 'Content is empty',
// Copy after code finishes loading
loadThenCopy: 'Copy after code finishes loading',
// Share link copied
linkCopied: 'Share link copied',
// Poster subtitle
posterShareInvite: 'Share with friends, earn rewards for invites',
// Poster link prefix
posterLinkPrefix: 'Link:',
// Poster link not generated
posterLinkEmpty: '(not generated)',
}

View File

@@ -0,0 +1,5 @@
export const lang = {
// diy-webview webview component
emptyTip: 'Please configure the web link',
loadError: 'Failed to load web page',
}

View File

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

View File

@@ -126,8 +126,6 @@ export const lang = {
copyTimeoutReason: '原因:',
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
copyFailed: '复制失败',
},

View File

@@ -0,0 +1,83 @@
export const lang = {
// diy-recommend-code 推荐码组件
// 标题
title: '我的推荐码',
// 立即分享按钮
buttonText: '立即分享',
// 复制推荐码按钮
copyButtonText: '复制推荐码',
// 换一个新码
placeholderRegenerateText: '换一个新码',
// 同意说明文案
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
// 同意并获取推荐码按钮
consentButtonText: '同意并获取推荐码',
// 未登录引导提示
loginTipText: '登录后查看我的推荐码',
// 去登录按钮
loginBtnText: '去登录',
// 海报二维码提示
posterQrTip: '扫码进入并填写入驻信息',
// 专属推荐码标签
exclusiveCode: '专属推荐码',
// 待获取推荐码标签
pendingCode: '待获取推荐码',
// 待获取
pending: '待获取',
// 推荐码加载中
loading: '推荐码加载中...',
// 已获得推荐码标签
obtainedCode: '已获得推荐码',
// 已获得
obtained: '已获得',
// 分享邀请奖励描述
shareInviteReward: '分享给好友,邀请注册即可获得奖励',
// 已生效
activated: '✓ 已生效',
// 推荐码已生成描述
codeGenerated: '推荐码已生成,可用于邀请好友或企业入驻绑定推荐关系',
// 提交中
submitting: '提交中...',
// 分享标题
shareTitle: '邀您入驻低空经济产业平台',
// 获取失败
getFail: '推荐码获取失败,请稍后重试',
// 获取失败(业务码)
getFailCode: '推荐码获取失败(业务码={code})',
// 获取成功
getSuccess: '获取成功',
// 网络异常
netError: '网络异常,请稍后重试',
// 换新码弹窗标题
regenTitle: '换新码',
// 换新码弹窗内容
regenContent: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
// 确认换新
regenConfirm: '确认换新',
// 取消
regenCancel: '取消',
// 换新失败
regenFail: '换新失败,请稍后重试',
// 换新成功(旧码停用)
regenSuccessOld: '已换新码,旧码 {old} 已停用',
// 换新成功
regenSuccess: '换新成功',
// 推荐码还未生成
codeNotReady: '推荐码还未生成,请稍候',
// 推荐码已复制
codeCopied: '推荐码已复制',
// 复制失败
copyFail: '复制失败',
// 复制内容为空
copyEmpty: '内容为空',
// 推荐码加载完成后再复制
loadThenCopy: '推荐码加载完成后再复制',
// 推广链接已复制
linkCopied: '推广链接已复制',
// 海报副标题
posterShareInvite: '分享给好友,邀请入驻获得奖励',
// 海报链接前缀
posterLinkPrefix: '链接:',
// 海报链接未生成
posterLinkEmpty: '(未生成)',
}

View File

@@ -0,0 +1,5 @@
export const lang = {
// diy-webview 网页组件
emptyTip: '请配置网页链接',
loadError: '网页加载失败',
}

View File

@@ -82,7 +82,7 @@
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
"__usePrivacyCheck__" : false,
"__usePrivacyCheck__" : true,
"optimization" : {
"subPackages" : true
}

View File

@@ -1086,7 +1086,7 @@
{
"path": "ai-chat/index",
"style": {
"navigationBarTitleText": "AI智能",
"navigationBarTitleText": "AI智能客服",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",

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>

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,6 @@
</template>
<script>
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
/**
* ai-chat-tools-panel - 更多工具面板组件
*
@@ -75,57 +74,46 @@ export default {
},
selectImage() {
const that = this
uni.chooseImage({
count: 9,
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
that.hideToolsPanel()
for (const p of res.tempFilePaths) {
const pending = {
id: ++that.messageId,
success: (res) => {
const imageMessage = {
id: ++this.messageId,
role: 'user',
type: 'image',
content: '',
imageUrl: p,
uploading: true,
uploadError: null,
uploaded: null,
imageUrl: res.tempFilePaths[0],
timestamp: Date.now()
}
that.$emit('image-selected', pending)
try {
const uploaded = await uploadImage(p)
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploaded', uploaded)
that.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
that.$set(pending, 'uploading', false)
that.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '图片上传失败: ' + (e.message || ''), icon: 'none' })
}
}
},
fail: (err) => {
if (err.errMsg && !err.errMsg.includes('cancel')) {
uni.showToast({ title: '选图失败', icon: 'none' })
}
this.hideToolsPanel()
this.$emit('image-selected', imageMessage)
}
})
},
selectFile() {
const that = this
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '*/*'
input.onchange = async (e) => {
input.onchange = (e) => {
const file = e.target.files[0]
if (!file) return
that.hideToolsPanel()
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
if (file) {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
timestamp: Date.now()
}
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
}
input.click()
// #endif
@@ -133,44 +121,24 @@ export default {
// #ifdef APP-PLUS
uni.chooseFile({
count: 1,
success: async (res) => {
that.hideToolsPanel()
success: (res) => {
const file = res.tempFiles[0]
await that._uploadOneFile(file, 'file')
}
})
// #endif
},
async _uploadOneFile(file, kind) {
const path = file.path || (file.name ? URL.createObjectURL(file) : '')
const pending = {
const fileMessage = {
id: ++this.messageId,
role: 'user',
type: kind,
type: 'file',
content: '',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
imageUrl: path,
uploading: true,
uploadError: null,
uploaded: null,
url: file.path,
timestamp: Date.now()
}
this.$emit('file-selected', pending)
try {
const uploaded = kind === 'image'
? await uploadImage(file.path || file)
: await uploadMedia(file.path || file, kind)
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploaded', uploaded)
if (uploaded.url) this.$set(pending, 'imageUrl', uploaded.url)
} catch (e) {
this.$set(pending, 'uploading', false)
this.$set(pending, 'uploadError', e.message || '上传失败')
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
this.hideToolsPanel()
this.$emit('file-selected', fileMessage)
}
})
// #endif
},
startRecord() {
@@ -241,7 +209,6 @@ export default {
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
position: relative; // 为 .close-btn 绝对定位提供参考
text {
font-size: 34rpx;
@@ -258,9 +225,7 @@ export default {
align-items: center;
justify-content: center;
border: none;
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
top: -8rpx;
right: 10rpx; //
.iconfont {
font-size: 28rpx;
color: $color-text-light;

View File

@@ -13,8 +13,7 @@
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
<view class="chat-content" :style="{ bottom: '0rpx' }">
<view class="chat-content" :style="{ bottom: tabBarHeight }">
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
@@ -70,9 +69,14 @@
</view>
</view>
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
<diy-bottom-nav v-if="false"></diy-bottom-nav>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
</view>
</template>
@@ -115,9 +119,12 @@ export default {
// 事件处理器引用(用于清理)
safeEventHandlers: new Map(),
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
isFromFloatBtn: false,
}
},
@@ -164,16 +171,24 @@ export default {
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
// 原因:AI 智能页是全屏聊天体验,不论是
// 1) 底部导航栏"智能客服"项直接点击进入
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
this.initChat();
},

View File

@@ -8,7 +8,7 @@ const paths = [
['pages_promotion/merch/detail','商户首页'],
['pages_promotion/fenxiao/ranking_list','排行榜'],
['pages_promotion/fenxiao/child_fenxiao','分销商'],
['pages_tool/ai-chat/index','AI智能'],
['pages_tool/ai-chat/index','AI智能客服'],
['pages_tool/contact/contact','电子名片'],
['pages_tool/member/info','个人资料'],
['pages_tool/order/activist','退款'],

119
scripts/i18n_recommend.py Normal file
View File

@@ -0,0 +1,119 @@
import re
path = r"D:\0.daima\lucky_shop\components-diy\diy-recommend-code.vue"
with open(path, "r", encoding="utf-8") as f:
s = f.read()
BASE = "components-diy.diy-recommend-code."
# ---- 1. value.xxx || '中文' / nc.xxx || '中文' ----
TEXT_FIELDS = {
'title','buttonText','copyButtonText','consentText','consentButtonText',
'loginTipText','loginBtnText','posterQrTip','placeholderRegenerateText',
}
def repl_value_nc(m):
prefix = m.group(1) # e.g. "value.title || "
field = m.group(2)
if field in TEXT_FIELDS:
return prefix + "$lang('" + BASE + field + "')"
return m.group(0)
s = re.sub(r"((?:value|nc)\.(\w+)\s*\|\|\s*)'([^']*)'", repl_value_nc, s)
# ---- 2. standalone template text nodes ----
standalone = {
'\u4e13\u5c5e\u63a8\u8350\u7801': 'exclusiveCode',
'\u5f85\u83b7\u53d6\u63a8\u8350\u7801': 'pendingCode',
'\u5f85\u83b7\u53d6': 'pending',
'\u63a8\u8350\u7801\u52a0\u8f7d\u4e2d...': 'loading',
'\u5df2\u83b7\u5f97\u63a8\u8350\u7801': 'obtainedCode',
'\u5df2\u83b7\u5f97': 'obtained',
'\u5206\u4eab\u7ed9\u597d\u53cb\uff0c\u9080\u8bf7\u6ce8\u518c\u5373\u53ef\u83b7\u5f97\u5956\u52b1': 'shareInviteReward',
'\u2713 \u5df2\u751f\u6548': 'activated',
'\u63a8\u8350\u7801\u5df2\u751f\u6210\uff0c\u53ef\u7528\u4e8e\u9080\u8bf7\u597d\u53cb\u6216\u4f01\u4e1a\u5165\u9a7b\u7ed1\u5b9a\u63a8\u8350\u5173\u7cfb': 'codeGenerated',
}
for zh, key in standalone.items():
s = s.replace(">%s</text>" % zh, ">{{ $lang('%s%s') }}</text>" % (BASE, key))
# ---- 3. '提交中...' standalone in template ternary ----
s = s.replace("'\u63d0\u4ea4\u4e2d...'", "$lang('%ssubmitting')" % BASE)
# ---- 4. default() Chinese literals -> '' ----
default_map = {
"title: '\u6211\u7684\u63a8\u8350\u7801'": "title: ''",
"buttonText: '\u7acb\u5373\u5206\u4eab'": "buttonText: ''",
"copyButtonText: '\u590d\u5236\u63a8\u8350\u7801'": "copyButtonText: ''",
"placeholderRegenerateText: '\u6362\u4e00\u4e2a\u65b0\u7801'": "placeholderRegenerateText: ''",
"consentText: '\u60a8\u7684\u63a8\u8350\u7801\u5c06\u7528\u4e8e\u4f01\u4e1a\u5165\u9a7b\u6765\u6e90\u8ffd\u8e2a\uff0c\u6211\u4eec\u4f1a\u8bb0\u5f55\u6bcf\u4e00\u6b21\u901a\u8fc7\u60a8\u5206\u4eab\u94fe\u63a5\u5b8c\u6210\u7684\u5165\u9a7b\u7533\u8bf7\uff0c\u7528\u4e8e\u60a8\u7684\u63a8\u5e7f\u6536\u76ca\u7ed3\u7b97\u3002'": "consentText: ''",
"consentButtonText: '\u540c\u610f\u5e76\u83b7\u53d6\u63a8\u8350\u7801'": "consentButtonText: ''",
"loginTipText: '\u767b\u5f55\u540e\u67e5\u770b\u6211\u7684\u63a8\u8350\u7801'": "loginTipText: ''",
"loginBtnText: '\u53bb\u767b\u5f55'": "loginBtnText: ''",
"posterQrTip: '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f'": "posterQrTip: ''",
}
for a, b in default_map.items():
s = s.replace(a, b)
# ---- 5. JS user-facing strings ----
# rcCopyToClipboard util (uses vm.$lang)
s = s.replace("cb && cb(false, '\u5185\u5bb9\u4e3a\u7a7a')",
"cb && cb(false, vm.$lang('%scopyEmpty'))" % BASE)
s = s.replace("fail: () => cb && cb(false, '\u590d\u5236\u5931\u8d25')",
"fail: () => cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
s = s.replace("cb && cb(ok ? true : false, ok ? null : '\u590d\u5236\u5931\u8d25')",
"cb && cb(ok ? true : false, ok ? null : vm.$lang('%scopyFail'))" % BASE)
s = s.replace("cb && cb(false, '\u590d\u5236\u5931\u8d25')",
"cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
# getFail / getFailCode / getSuccess / netError
s = s.replace("var lastMsg = '\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5';",
"var lastMsg = this.$lang('%sgetFail');" % BASE)
s = s.replace("('\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25(\u4e1a\u52a1\u7801=' + unpacked.businessCode + ')')",
"this.$lang('%sgetFailCode', { code: unpacked.businessCode })" % BASE)
s = s.replace("uni.showToast({ title: '\u83b7\u53d6\u6210\u529f', icon: 'success' })",
"uni.showToast({ title: this.$lang('%sgetSuccess'), icon: 'success' })" % BASE)
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff1a' + failMsg.slice(0, 30)",
"this.$lang('%snetError')" % BASE)
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'",
"this.$lang('%snetError')" % BASE)
# regen modal
s = s.replace("title: '\u6362\u65b0\u7801',", "title: this.$lang('%sregenTitle')," % BASE)
s = s.replace("content: '\u6362\u65b0\u7801\u540e\u65e7\u7801\u4f1a\u7acb\u5373\u5931\u6548\uff0c\u540e\u7eed\u6240\u6709\u4f7f\u7528\u65e7\u5206\u4eab\u94fe\u63a5\u7684\u5165\u9a7b\u7533\u8bf7\u90fd\u4e0d\u4f1a\u5f52\u5c5e\u5230\u60a8\uff0c\u786e\u8ba4\u7ee7\u7eed\uff1f',",
"content: this.$lang('%sregenContent')," % BASE)
s = s.replace("confirmText: '\u786e\u8ba4\u6362\u65b0',", "confirmText: this.$lang('%sregenConfirm')," % BASE)
s = s.replace("cancelText: '\u53d6\u6d88',", "cancelText: this.$lang('%sregenCancel')," % BASE)
s = s.replace("title: unpacked.msg ? String(unpacked.msg) : '\u6362\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5',",
"title: unpacked.msg ? String(unpacked.msg) : this.$lang('%sregenFail')," % BASE)
s = s.replace("title: old ? ('\u5df2\u6362\u65b0\u7801\uff0c\u65e7\u7801 ' + old + ' \u5df2\u505c\u7528') : '\u6362\u65b0\u6210\u529f',",
"title: old ? this.$lang('%sregenSuccessOld', { old: old }) : this.$lang('%sregenSuccess')," % (BASE, BASE))
s = s.replace("uni.showToast({ title: '\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', icon: 'none' })",
"uni.showToast({ title: this.$lang('%snetError'), icon: 'none' })" % BASE)
# share title (L1128 specific) then global for the rest
s = s.replace("(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || '\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
"(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || this.$lang('%sshareTitle')" % BASE)
s = s.replace("'\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
"this.$lang('%sshareTitle')" % BASE)
# copy / link toasts
s = s.replace("title: ok ? '\u63a8\u8350\u7801\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
"title: ok ? this.$lang('%scodeCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u8fd8\u672a\u751f\u6210\uff0c\u8bf7\u7a0d\u5019', icon: 'none' })",
"uni.showToast({ title: this.$lang('%scodeNotReady'), icon: 'none' })" % BASE)
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u52a0\u8f7d\u5b8c\u6210\u540e\u518d\u590d\u5236', icon: 'none' })",
"uni.showToast({ title: this.$lang('%sloadThenCopy'), icon: 'none' })" % BASE)
s = s.replace("title: ok ? '\u63a8\u5e7f\u94fe\u63a5\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
"title: ok ? this.$lang('%slinkCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
# poster title + canvas
s = s.replace("const title = v.title || '\u6211\u7684\u63a8\u8350\u7801';",
"const title = v.title || this.$lang('%stitle');" % BASE)
s = s.replace("ctx.fillText('\u5206\u4eab\u7ed9\u597d\u53cb,\u9080\u8bf7\u5165\u9a7b\u83b7\u5f97\u5956\u52b1', 24, 70);",
"ctx.fillText(this.$lang('%sposterShareInvite'), 24, 70);" % BASE)
s = s.replace("ctx.fillText('\u94fe\u63a5:' + (linkText || '(\u672a\u751f\u6210)'), 24, urlY);",
"ctx.fillText(this.$lang('%sposterLinkPrefix') + (linkText || this.$lang('%sposterLinkEmpty')), 24, urlY);" % (BASE, BASE))
s = s.replace("ctx.fillText(v.posterQrTip || '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f', W / 2, qrY + qrSize + 24);",
"ctx.fillText(v.posterQrTip || this.$lang('%sposterQrTip'), W / 2, qrY + qrSize + 24);" % BASE)
with open(path, "w", encoding="utf-8") as f:
f.write(s)
print("done")

View File

@@ -121,7 +121,7 @@
],
[
"pages_tool/ai-chat/index",
"AI智能"
"AI智能客服"
],
[
"pages_tool/contact/contact",

View File

@@ -9,6 +9,7 @@ import util from '../common/js/util.js'
import ThemeOverride from '../common/js/theme-override.js'
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
import PrivacyManager from '../common/js/privacy.js'
import LangConfig from '../common/js/lang.js'
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
@@ -163,6 +164,10 @@ const store = new Vuex.Store({
setShopInfo(state, value) {
state.shopInfo = value;
uni.setStorageSync('shopInfo', value); // 初始化数据调用
// 语言切换开关: 后台 shop_info.ischia 决定。
// 关闭(ischia 为假)时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
// 满足"后台关闭双语/小程序后,刷新重新进入必须是中文"的需求。
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischia)); } catch (e) {}
},
setCartChange(state) {
state.cartChange += 1;