Compare commits

...

22 Commits

Author SHA1 Message Date
8901e302e4 style: 修复配置文件缩进格式不一致问题
统一调整配置文件内所有对象的缩进样式,移除多余的空格,修正代码格式对齐问题,提升代码可读性。
2026-08-17 14:43:54 +08:00
68bcd3ae7c Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797
# Conflicts:
#	.local.config.js
2026-08-17 14:41:25 +08:00
9f89172829 chore: 2026-08-17 08:48:14 +08:00
ddd1ec3056 chore(gitignore): 补充 Trae IDE 临时文件的忽略规则
新增匹配根目录下所有以 .trae_ 开头的临时文件和目录,完善git忽略配置
2026-08-15 17:32:16 +08:00
d305268cb8 Merge branch 'dev/1.0' of https://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0 2026-08-15 17:29:52 +08:00
1efb866e41 style(config): 格式化本地配置文件的缩进与空格
1. 统一所有配置项的缩进风格为4空格
2. 修正多余的尾随和冗余空格
3. 添加新增的5g-1测试配置项
4. 调整注释掉的配置项格式
2026-08-15 17:29:01 +08:00
b8182a0f97 chore: 2026-08-15 14:31:01 +08:00
37463a173c chore:一级分类的不选中的样式后台可以自定义 2026-08-15 10:37:55 +08:00
422ca8cdb5 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797 2026-08-15 09:53:01 +08:00
f92b1fb5b7 chore: 2026-08-15 09:52:30 +08:00
a0043418c8 chore: 2026-08-15 09:46:12 +08:00
927f763d0a chore:后台可以自定义配置一级分类选中后的字体颜色和字体大小 2026-08-15 09:28:15 +08:00
69ad814985 Merge remote-tracking branch 'remotes/origin/dev/1.0' into PR-1-ai-agent-link 2026-08-15 09:27:58 +08:00
92599453c2 chore:竖版视频可以正常显示正常大小比例 2026-08-14 15:10:03 +08:00
5d7facc94d chore:竖版视频可以正常在容器里 2026-08-14 15:03:23 +08:00
f6eb1fad11 Merge branch 'dev/1.0' into PR-1-ai-agent-link 2026-08-14 14:03:49 +08:00
83b253bced chore:表单鸿蒙做更改 2026-08-14 11:21:05 +08:00
19cb332645 refactor(ai-page): 统一页面路由管理并修复AI智能体路径
1.  将硬编码的页面路径替换为PAGE_KEY_MAP常量统一管理
2.  修正AI智能体页面路径为/pages_tool/ai-agent/index
3.  优化客服跳转逻辑,避免重复注入tabBar隐藏标记
4.  为AI聊天页面添加自定义组件占位配置
2026-08-14 09:36:04 +08:00
e5406a27ba chore:苹果ios表单显示正常,商品详情的视频可以支持竖版横板视频 2026-08-14 09:09:07 +08:00
ffa170ff13 feat(ai): PR-7 前端 ai-service 按 agent_id 路由 endpoint
背景:
- 后端 aikefu 和 ai-agent 两个独立 addon (PR-6 v2.0)
- 前端 ai-chat 之前所有 API endpoint 都 hardcode 到 /api/kefu/* + /ws/aikefu
- 5 个 agent (智能客服默认 + 4 个 AI智能体) 实际上无法被前端独立路由

改动:
- 新增 resolveAddonName(agentId) -> 'aikefu' / 'aiagent'
- 新增 resolveApiUrl(apiName) -> '/api/kefu/X' / '/api/aiagent/X'
- 新增 resolveWsPath() -> '/ws/aikefu' / '/ws/aiagent'

- 全量替换 sendMessage / sendHttpStream / sendStreamMessage / generateConversationId /
  getServiceStatus / getServiceConfig / clearConversation / getConversationHistory /
  chatWithEventSource / WebSocket connect URL:
  全部用 resolveApiUrl / resolveWsPath 动态计算

- setAgentId 跨 addon 切换时自动 disconnectWebSocket (路径变了)
- 新增 getAddonName() / getApiPrefix() 测试辅助方法

后端配套 PR-8+9:
- /api/aiagent/api/Chat/chat (HTTP, 含流式 SSE)
- /api/aiagent/api/Chat/{info,health,getHistory,clearConversation,createConversation}
- /ws/aiagent WebSocket 端点 (从 aikefu WebSocket 复制改名)

路由规则:
- agent_aikefu -> aikefu addon (老路径, 默认智能客服)
- 其他 agent (knowledge / quick_reply / sales / hr) -> aiagent addon (新路径)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-13 18:30:00 +08:00
8a95b0310d feat(ai-chat): 适配浮动按钮入口隐藏底部tabBar
实现浮动按钮跳转AI聊天页时自动隐藏底部tabBar,通过__dyu标记区分入口来源:
1. 浮动按钮点击时注入__dyu=FloatBtn来源标记
2. AI聊天页根据该标记临时隐藏tabBar并在卸载时恢复
3. 兼容旧store无对应mutation的场景
2026-08-12 18:18:19 +08:00
f656be789d feat(ai-agent): 新增AI智能体通用支持框架
1.  新增多语言文案,适配AI智能体通用场景
2.  新增AI智能体页面路由常量与跳转逻辑
3.  改造AI聊天页面支持动态agent_id和页面标题
4.  升级AI服务层,支持多agent_id自动传递
2026-08-12 15:12:00 +08:00
18 changed files with 2756 additions and 103 deletions

View File

@@ -21,7 +21,7 @@ const localDevConfig = ({
'2881': { // 防爆电器
uniacid: 2881,
domain: 'https://xcx6.aigc-quickapp.com/',
},
},
'2724': { // 生物菌肥
uniacid: 2724,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -45,11 +45,11 @@ const localDevConfig = ({
'11-test': { // 测试平台
uniacid: 11,
domain: 'https://test.aigc-quickapp.com',
},
},
'18-test': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'local-2': { // 测试平台
uniacid: 2,
domain: 'http://localhost:8050/',
@@ -59,15 +59,15 @@ const localDevConfig = ({
domain: 'http://192.168.197.1:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'192.168.8.118-1': { // 测试平台
uniacid: 1,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
'local-11': { // 测试平台
uniacid: 11,
domain: 'http://localhost:8050/',
},
},
'100': {
uniacid: 100,
domain: 'https://5g.aigc-quickapp.com/',
@@ -83,15 +83,15 @@ const localDevConfig = ({
'5g-11': {
uniacid: 11,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-206': {
uniacid: 206,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-165': { // 鸡肉调理品, 优惠卷显示不全,及逻辑错误
uniacid: 165,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'47': { // 修改弹窗
uniacid: 47,
domain: 'https://5g.aigc-quickapp.com/',
@@ -99,27 +99,27 @@ const localDevConfig = ({
'145': { // 表单链接转义问题
uniacid: 145,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-213': { // 单图组点击链接无法跳转
uniacid: 213,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-4': { // 公告点击链接无法跳转
uniacid: 4,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-97': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 97,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-50': { // 燃气安全产品机器人,购物车无反应
uniacid: 50,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'5g-237': { // 商品分类,没有立即咨询文字
uniacid: 237,
domain: 'https://5g.aigc-quickapp.com/',
},
},
'2847': { //
uniacid: 2847,
domain: 'https://xcx.aigc-quickapp.com/',
@@ -132,38 +132,59 @@ const localDevConfig = ({
uniacid: 2485,
domain: 'https://xcx6.aigc-quickapp.com/',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'2821': { // 离型纸硅油纸
uniacid: 2821,
domain: 'https://xcx.aigc-quickapp.com/',
},
'18': { // 测试平台
uniacid: 18,
domain: 'https://test.aigc-quickapp.com',
},
'2': { // 测试平台
uniacid: 2,
domain: 'https://test.aigc-quickapp.com',
},
'48': { // 测试平台
uniacid: 48,
domain: 'https://test.aigc-quickapp.com',
},
'test-12': { // 户外鞋,测试优惠券
uniacid: 12,
domain: 'https://test.aigc-quickapp.com',
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
},
'248': { // 大宗商品贸易,支持自定义字体大小、颜色
uniacid: 248,
domain: 'https://5g.aigc-quickapp.com/',
},
'228': { // 商品分类,没有立即咨询文字
uniacid: 228,
domain: 'https://5g.aigc-quickapp.com/',
},
'2797': { //气雾栽培机,视频组件安卓ios播放正常
uniacid: 2797,
domain: 'https://xcx.aigc-quickapp.com/',
},
'5g-95': { // 商品分类,没有立即咨询文字
uniacid: 95,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-160': { // 过瘤胃瘤胃保护,商品详情视频
uniacid: 160,
domain: 'https://5g.aigc-quickapp.com/',
},
'5g-268': { // 合成孔径雷达,一级分类背景字体颜色
uniacid: 268,
domain: 'https://5g.aigc-quickapp.com/',
},
'225-test': { // 测试平台
uniacid: 225,
domain: 'https://test.aigc-quickapp.com',
},
'192.168.8.118-321': { // 测试平台
uniacid: 321,
domain: 'http://192.168.8.118:8050/',
// domain: 'http://localhost:8050/',
},
// })['5g-237']; // 商品分类,没有立即咨询文字
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
@@ -177,7 +198,7 @@ const localDevConfig = ({
// })['2724']; // 生物菌肥
// })['2811']; // 选择要使用的环境配置
// })['local-11']; // 选择要使用的环境配置
})['2797']; // 本地
})['2811']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

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

View File

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

View File

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

View File

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

View File

@@ -139,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -1203,6 +1204,23 @@ export default {
* @param {Object} link
*/
diyRedirectTo(link) {
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
const title = link.title || 'AI 智能体';
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
return this.redirectTo(AI_AGENT_PAGE_URL, {
agent_id: agentId,
title: encodeURIComponent(title),
enable_set_title: link.enable_set_title ? 1 : 0,
...(dyuEntry ? { __dyu: dyuEntry } : {}),
});
}
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
//if (link == null || Object.keys(link).length == 1) return;

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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -784,10 +784,10 @@ export default {
padding: 26rpx 28rpx;
box-sizing: border-box;
position: relative;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
view {
color: #222222;
color: var(--diy-category-tree-normal-text-color, #222222);
width: 100%;
white-space: break-word;
overflow: hidden;
@@ -804,10 +804,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: var(--diy-category-tree-select-size, 28rpx);
}
@@ -1512,11 +1512,11 @@ export default {
// 左侧分类栏样式
.tree-wrap .category-item {
padding: 28rpx 16rpx;
background-color: #fff;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
border-radius: 0;
view {
color: #666;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.3;
}
@@ -1527,10 +1527,10 @@ export default {
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
view {
color: var(--base-color, #FF6B35);
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}
@@ -1603,22 +1603,22 @@ export default {
.tree-wrap .category-item {
padding: 30rpx 16rpx;
background-color: #f5f5f5;
background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5);
white-space: normal;
word-break: break-all;
view {
color: #333;
color: var(--diy-category-tree-normal-text-color, #222222);
font-size: 26rpx;
line-height: 1.5;
}
&.select {
background: #fff;
background: var(--diy-category-tree-select-bg-color, #fff);
border-bottom: 1rpx solid #f0f0f0;
view {
color: #333;
color: var(--diy-category-tree-select-text-color, #333);
font-weight: bold;
font-size: 28rpx;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1086,7 +1086,64 @@
{
"path": "ai-chat/index",
"style": {
"navigationBarTitleText": "AI智能客服"
"navigationBarTitleText": "AI智能客服",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
}
},
{

View File

@@ -56,10 +56,10 @@
</view>
</view>
<!-- 商品视频 -->
<!-- 商品视频:objectFit=contain 保留原始宽高比,在固定高度的图片容器内居中播放 -->
<view class="goods-video" :class="{ show: switchMedia == 'video' }">
<video id="goodsVideo" :src="$util.img(goodsSkuDetail.video_url)"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="cover"
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
playsinline webkit-playsinline x5-playsinline></video>
</view>

View File

@@ -603,7 +603,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({
url: '/pages_tool/ai-chat/index'
url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
});
},

View File

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

View File

@@ -1,5 +1,10 @@
<template>
<!-- #ifdef MP-HARMONY -->
<view :style="themeColor" class="harmony-root">
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view :style="themeColor">
<!-- #endif -->
<!--
自绘导航栏(仅在 navigationStyle:custom 的端渲染:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
- pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
@@ -23,6 +28,19 @@
</view>
<!-- #endif -->
<block v-if="detail">
<!-- 鸿蒙元服务:用原生 scroll-view 包裹表单,替代 CSS overflow 滚动(ArkUI 不识别 WebKit overflow 语义)。
根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余,scroll-view 在其内滚动。
提交按钮由 diy-form 内部渲染,处于 scroll-view 内,用户可滚到末尾点击。 -->
<!-- #ifdef MP-HARMONY -->
<view class="page harmony-page">
<scroll-view scroll-y="true" :show-scrollbar="false" class="form-scroll">
<view class="system-form-wrap">
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
</view>
</scroll-view>
</view>
<!-- #endif -->
<!-- #ifndef MP-HARMONY -->
<view class="page">
<view class="system-form-wrap">
<!-- 复用 diy-form:banner / tip / 表单字段 / 提交按钮 全部由其内部渲染,样式(bannerPaddingX/tipColor/submitBgColor 等)由 form_config 控制。
@@ -32,6 +50,7 @@
<diy-form ref="form" :value="diyValue" dataMode="raw" :onSubmit="handleSubmit" />
</view>
</view>
<!-- #endif -->
</block>
<block v-else>
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
@@ -420,10 +439,49 @@ page {
background: #fff;
}
.system-form-wrap {
overflow: hidden;
margin: 0 0 60rpx 0;
// ===== 非鸿蒙端(H5 / 微信小程序 / App):保留原有 WebKit 滚动 / 安全区处理 =====
// 鸿蒙走下方 MP-HARMONY 专属的 scroll-view 方案,不编译这些 WebKit 属性
/* #ifdef H5 || MP-WEIXIN || APP-PLUS */
.page {
min-height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.system-form-wrap {
overflow: visible;
margin: 0 0 60rpx 0;
padding-bottom: env(safe-area-inset-bottom, 40rpx);
}
/* #endif */
// ===== 鸿蒙元服务:用原生 scroll-view 做滚动容器 =====
// ArkUI 不识别 WebKit 的 overflow / -webkit-overflow-scrolling,必须用 scroll-view 组件(编译为 ArkUI Scroll)。
// 根容器 harmony-root 占满 100vh 并 flex 列布局,navbar 占顶部固定高度,page flex:1 占剩余空间,
// scroll-view 在 page 内 flex:1 获得确定高度后内部滚动。提交按钮由 diy-form 渲染在 scroll-view 内,可滚动到。
/* #ifdef MP-HARMONY */
.harmony-root {
height: 100vh;
display: flex;
flex-direction: column;
}
.harmony-page {
flex: 1;
min-height: 0; // 允许内容收缩,内部 scroll-view 才能拿到确定高度并正确滚动
display: flex;
flex-direction: column;
}
.form-scroll {
flex: 1;
height: 100%;
}
.system-form-wrap {
// 不再用 overflow:hidden 裁剪;提交按钮在 scroll-view 内,滚动可见
overflow: visible;
margin: 0 0 60rpx 0;
padding-bottom: 40rpx; // 鸿蒙不识别 env(safe-area-inset-bottom),固定值兜底
}
/* #endif */
// 穿透 diy-form 包装层:消除其自带的 10rpx 圆角与白色背景,
// 让 form 内容区域保持与原始 ns-newform 接入时一致的视觉