Compare commits
99 Commits
be40bb19b3
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
| 8901e302e4 | |||
| 68bcd3ae7c | |||
| 9f89172829 | |||
| ddd1ec3056 | |||
| d305268cb8 | |||
| 1efb866e41 | |||
| b8182a0f97 | |||
| 37463a173c | |||
| 422ca8cdb5 | |||
| f92b1fb5b7 | |||
| a0043418c8 | |||
| 927f763d0a | |||
| 69ad814985 | |||
| 92599453c2 | |||
| 5d7facc94d | |||
| f6eb1fad11 | |||
| 83b253bced | |||
| 19cb332645 | |||
| e5406a27ba | |||
| ffa170ff13 | |||
| 3b81ecdf98 | |||
| c536fac4d2 | |||
| 475449f04e | |||
| e40b46a888 | |||
| 8a95b0310d | |||
| a81386d336 | |||
| b69eeba74b | |||
| f656be789d | |||
| 82139e616d | |||
| ed6d9f563d | |||
| 3a8ad7d871 | |||
| ec960a29c4 | |||
| 8312de5d09 | |||
| 5833a244de | |||
| 92fe95d131 | |||
| 07718235c3 | |||
| 183181a15d | |||
| a4f2c81528 | |||
| 30b66ad7b3 | |||
| 077fac40c2 | |||
| 10d45b1c3b | |||
| 60dc054383 | |||
| ef8c7568ac | |||
| 551d6c103d | |||
| 4a92d9bec2 | |||
| ff4340c3a9 | |||
| 4303a93278 | |||
| 12e4493a17 | |||
| c94e214d05 | |||
| afede79047 | |||
| ab727eef1d | |||
| ee050f5df9 | |||
| 855fbb2fe5 | |||
| b706e67e82 | |||
| 557addb76b | |||
| dacc25b281 | |||
| ec4fb3e688 | |||
| 75e40c752b | |||
| 24df6f0dd5 | |||
| a8ab093eae | |||
| 7f4b1d6d13 | |||
| 1420b7435e | |||
| 8b331e53d3 | |||
| 10fd785b4c | |||
| 2c0b8ed076 | |||
| ab8625ebaf | |||
| bbef0ecf55 | |||
| 671c350d95 | |||
| 2d19a653c9 | |||
| 5f60504c75 | |||
| 289e582ba1 | |||
| c26280a4d3 | |||
| 619ca3a236 | |||
| c35d0e2505 | |||
| a6e9176dbf | |||
| f4517be6c3 | |||
| 408c40fbe9 | |||
| b46dc248d5 | |||
| 1fb2b470da | |||
| 81ff205d2c | |||
| 1b3dd41a4b | |||
| 670d3f99fc | |||
| eb2839eeb8 | |||
| b0f7c7a15c | |||
| f1487e60bb | |||
| 74c24d084e | |||
| 5e8a1a48dd | |||
| 66deecdcf8 | |||
| 37d72381c7 | |||
| 9d187bb8a4 | |||
| 24cb3a096d | |||
| 1610f3f75d | |||
| 837bfbfd77 | |||
| e24a6b25f2 | |||
| 318ed4028e | |||
| 1cda06573e | |||
| be1e175471 | |||
| 0598f97efe | |||
| cd91ff9d49 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -61,6 +61,21 @@ stub-app-projects/mp-harmony/hvigor
|
||||
# Trae/IDE 临时文件
|
||||
.hvigor/outputs
|
||||
.trae_permission_check_*
|
||||
|
||||
# 本地测试脚本与产物归档(不入 git, 留本地复用)
|
||||
# 内容: Playwright 截图 / 测试脚本 / 对比图 / 临时提交草稿
|
||||
# 复用方式: cd _test_archive && python xxx.py
|
||||
_test_archive/
|
||||
|
||||
# 兜底:防止任何临时测试文件被遗漏在根目录
|
||||
# 名称以 _test_/_compare_/_auth_ 开头, 或 check_button.py 等
|
||||
_test_*.png
|
||||
_test_*.html
|
||||
_compare*.png
|
||||
_compare*.py
|
||||
_auth_*.json
|
||||
_commit_msg.txt
|
||||
/check_button.py
|
||||
# 鸿蒙签名材料 (敏感, 绝不能提交到公共 git)
|
||||
# 仓库有 origin (github.com/Zajd/jsysq-uniapp) 是公开仓库, 签名材料必须 gitignore
|
||||
stub-app-projects/mp-harmony/**/cert-profile
|
||||
|
||||
116
.local.config.js
116
.local.config.js
@@ -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,27 +45,29 @@ 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/',
|
||||
},
|
||||
'local-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
'192.168.197.1-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.197.1:8050/',
|
||||
},
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'192.168.8.118-1': { // 测试平台
|
||||
uniacid: 1,
|
||||
domain: 'http://192.168.8.118:8050/',
|
||||
// domain: 'http://localhost:8050/',
|
||||
},
|
||||
'local-11': { // 测试平台
|
||||
uniacid: 11,
|
||||
domain: 'http://localhost:8050/',
|
||||
},
|
||||
},
|
||||
'100': {
|
||||
uniacid: 100,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
@@ -81,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/',
|
||||
@@ -97,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/',
|
||||
@@ -130,30 +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/',
|
||||
},
|
||||
},
|
||||
'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']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -167,10 +198,9 @@ const localDevConfig = ({
|
||||
// })['2724']; // 生物菌肥
|
||||
// })['2811']; // 选择要使用的环境配置
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['248']; // 本地
|
||||
})['2811']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
})['local-1']; // 本地
|
||||
// })['1-test']; // test
|
||||
// })['test-12']; // test
|
||||
export default localDevConfig;
|
||||
@@ -118,6 +118,8 @@
|
||||
.goods-video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.goods-video .uni-video-cover {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',// 分类项名称
|
||||
|
||||
@@ -102,6 +102,36 @@ export function parsePageParams(raw, defaultValue = {}) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
|
||||
*
|
||||
* 设计动机:URL query 中布尔值的合法写法不止一种——
|
||||
* ?show_home=1 -> '1' -> true
|
||||
* ?show_home=true -> 'true' -> true
|
||||
* ?show_home=true -> true -> true
|
||||
* ?show_home=0 -> '0' -> false
|
||||
* ?show_home=false -> 'false' -> false
|
||||
* ?show_home= -> '' -> false
|
||||
* 未传 -> undefined -> false
|
||||
*
|
||||
* 与裸 `Boolean(x)` 的差异:
|
||||
* Boolean('false') === true (任何非空字符串都是 true)
|
||||
* Boolean('0') === true (同上)
|
||||
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
|
||||
*
|
||||
* @param {*} val 任意值
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function coerceBool(val) {
|
||||
if (val === true || val === 1) return true;
|
||||
if (typeof val === 'string') {
|
||||
const s = val.trim().toLowerCase();
|
||||
return s === '1' || s === 'true';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// 关于页面路径,使用常量导出
|
||||
export const INDEX_PAGE_URL = '/pages/index/index';
|
||||
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
||||
@@ -109,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 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||||
@@ -457,6 +488,9 @@ export default {
|
||||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||||
parsePageParams,
|
||||
|
||||
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
|
||||
coerceBool,
|
||||
|
||||
/** HTML 实体解码 */
|
||||
decodeEntity,
|
||||
|
||||
@@ -1170,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
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
92
common/js/video-helper.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,9 @@
|
||||
</block>
|
||||
</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>
|
||||
@@ -110,6 +113,9 @@
|
||||
</block>
|
||||
</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>
|
||||
@@ -173,6 +179,9 @@
|
||||
</block>
|
||||
</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>
|
||||
@@ -1420,6 +1429,26 @@ export default {
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.footer-wrap-template1 {
|
||||
padding: 16rpx 4rpx 0rpx;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.detail-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32rpx;
|
||||
height: 55rpx;
|
||||
background-color: #ff4545;
|
||||
color: #fff;
|
||||
font-size: 25rpx;
|
||||
border-radius: 26rpx;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -22,13 +22,18 @@
|
||||
<!-- 提交按钮(外层 = form-bg + form-padding-x;按钮 = submit-bg + submit-color + input-height + input-radius)。
|
||||
showSubmit=false 时由父级页面渲染自己的提交按钮并通过 ref 调用本组件的 verify()/getFormData()。 -->
|
||||
<view v-if="showSubmit" class="form-submit-wrap" :style="submitWrapStyle">
|
||||
<view @click.stop="submitForm" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
<view @click.stop="handleSubmit" class="form-submit" :style="submitStyle">{{ submitText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 自定义表单
|
||||
// 提交接管三档(不依赖 $listeners / 事件机制,跨 H5 / MP / 鸿蒙一致):
|
||||
// 1) 默认 → showSubmit=true + 不传 onSubmit → 内置按钮 + 内置 defaultSubmit
|
||||
// 2) 复用按钮、自定义逻辑 → showSubmit=true + :onSubmit="fn" → 内置按钮 + 父级 fn
|
||||
// 3) 完全自定义 UI → showSubmit=false + :onSubmit="fn" 或 ref.verify()/getFormData() → 父级全控
|
||||
// 注:type: Function prop 在 uni-app MP 端走 Vue runtime 注入,机制与 ns-navbar.customBack 一致
|
||||
import DiyMinx from './minx.js'
|
||||
import NsNewform from '@/components/ns-newform/ns-newform.vue'
|
||||
|
||||
@@ -52,6 +57,12 @@ export default {
|
||||
showSubmit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 父级可选地接管提交:传入函数 prop 时,verify 通过后由它处理 formData;
|
||||
// 不传时走组件内置 defaultSubmit(兜底)。default 必须是 null,不能写成函数返回 null
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
@@ -148,6 +159,10 @@ export default {
|
||||
submitText() {
|
||||
return this.mergedFormConfig.submitText || '提交';
|
||||
},
|
||||
// 父级是否接管提交(集中判断,便于以后替换机制;避免多个调用点各自 typeof 校验)
|
||||
hasCustomSubmit() {
|
||||
return typeof this.onSubmit === 'function';
|
||||
},
|
||||
// 后端把表单样式配置统一存放在 data.formConfig 子对象中(与后端 design.js getDefaultFormData().formConfig 字段名一致)。
|
||||
// 这里做一次归一化:优先取 formConfig;如果历史数据是扁平结构(直接挂在 value 上),也做兼容。
|
||||
formCfg() {
|
||||
@@ -194,6 +209,10 @@ export default {
|
||||
// 占位符:遗漏会被 ns-newform SCSS 默认值回退覆盖
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
// textarea 显示行数 / 行高(透传给 ns-newform,与 ns-form 保持一致)
|
||||
// 不传时 ns-newform 走自己的默认 4 行 / 44rpx
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
||||
@@ -286,24 +305,26 @@ export default {
|
||||
return true;
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
submitForm() {
|
||||
var self = this;
|
||||
// 统一提交入口:内置按钮 / 父级 ref 调用都走这里
|
||||
// 不依赖 $listeners / 事件机制(MP 端 $listeners 为 undefined),跨端一致
|
||||
handleSubmit() {
|
||||
// 校验(同时返回 formData 数组;校验失败时 ns-newform 内部 toast 提示)
|
||||
if (!this.verify()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 若父级监听了 @submit,则把提交控制权交给父级(用于父级有自定义提交字段/反馈逻辑的页面,如 formdata.vue 传 uniacid)
|
||||
if (this.$listeners && this.$listeners.submit) {
|
||||
this.$emit('submit', this.getFormData());
|
||||
return;
|
||||
const formData = this.getFormData();
|
||||
// 父级显式接管:function prop 在 H5 / MP / 鸿蒙一致可用
|
||||
if (this.hasCustomSubmit) {
|
||||
return this.onSubmit(formData);
|
||||
}
|
||||
// 兜底:内置 sendRequest 流程
|
||||
this.defaultSubmit(formData);
|
||||
},
|
||||
|
||||
// 获得保单数据
|
||||
const submitFormData = this.getFormData();
|
||||
|
||||
// 提交到后端
|
||||
// 公共兜底实现:默认的 /form/api/form/create 流程;
|
||||
// 父级若需"先自定义、失败回退",可经 ref.defaultSubmit(formData) 调用
|
||||
defaultSubmit(submitFormData) {
|
||||
var self = this;
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
|
||||
@@ -159,7 +159,10 @@
|
||||
<!-- 商品推荐 -->
|
||||
<diy-goods-recommend :value="item"></diy-goods-recommend>
|
||||
</template>
|
||||
|
||||
<template v-if="item.componentName == 'DiyRecommendCode'">
|
||||
<!-- 自定义推荐码组件 -->
|
||||
<diy-recommend-code :value="item"></diy-recommend-code>
|
||||
</template>
|
||||
<template v-if="item.componentName == 'GoodsBrand'">
|
||||
<!-- 商品品牌 -->
|
||||
<diy-goods-brand :value="item"></diy-goods-brand>
|
||||
|
||||
1929
components-diy/diy-recommend-code.vue
Normal file
1929
components-diy/diy-recommend-code.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,17 +54,21 @@
|
||||
<!-- 视频播放弹窗 -->
|
||||
<uni-popup ref="videoPopup" type="center" style="background:transparent;width:100%;height:100%;">
|
||||
<view class="video-container" :style="popupVideoStyle">
|
||||
<video
|
||||
<video
|
||||
:key="video_url"
|
||||
ref="popupVideoRef"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
class="adaptive-video"
|
||||
:autoPauseIfNavigate="true"
|
||||
:autoPauseIfOpenNative="true"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:autoplay="false"
|
||||
:enableAutoRotation="true"
|
||||
id="myVideo"
|
||||
:src="video_url"
|
||||
:controls="true"
|
||||
object-fit="contain"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onPopupVideoLoaded"
|
||||
@error="onPopupVideoError"
|
||||
></video>
|
||||
@@ -144,41 +148,21 @@ export default {
|
||||
|
||||
// 弹窗视频容器样式
|
||||
popupVideoStyle() {
|
||||
let style = 'position:fixed;width:100%;left:0;';
|
||||
const defaultRatio = 9 / 16;
|
||||
|
||||
let ratio = defaultRatio;
|
||||
if (this.popupVideoWidth && this.popupVideoHeight) {
|
||||
ratio = this.popupVideoHeight / this.popupVideoWidth;
|
||||
}
|
||||
|
||||
const isPortrait = ratio > 1;
|
||||
|
||||
if (isPortrait) {
|
||||
style += 'top:50%;transform:translateY(-50%);';
|
||||
} else {
|
||||
style += 'top:20%;';
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
style += 'height: ' + (this.windowWidth * ratio) + 'px;';
|
||||
// #endif
|
||||
|
||||
// 容器横向铺满,黑色背景填满左右边界
|
||||
// 视频用 object-fit:contain 保持原比例居中,左右黑边由容器黑色背景填充
|
||||
let style = 'position:fixed;width:100%;height:50vh;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:0;';
|
||||
return style;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 显示视频播放弹窗
|
||||
showVideo(item) {
|
||||
this.video_url = item.videoUrl;
|
||||
let url = item.videoUrl || '';
|
||||
// 处理视频URL:完整链接直接使用,否则通过 $util.img() 转换
|
||||
if (url && url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
url = this.$util.img(url);
|
||||
}
|
||||
this.video_url = url;
|
||||
this.popupVideoWidth = 0;
|
||||
this.popupVideoHeight = 0;
|
||||
this.$refs.videoPopup.open();
|
||||
@@ -625,22 +609,6 @@ export default {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* #ifdef MP-WEIXIN */
|
||||
.adaptive-video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
/* #ifdef MP-HARMONY */
|
||||
.adaptive-video {
|
||||
position: static;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* #endif */
|
||||
|
||||
.video-nav {
|
||||
padding: 16rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
:style="videoWarpCss"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
@loadedmetadata="onVideoLoaded"
|
||||
@click="handlerClick(videoUrl)"
|
||||
></video>
|
||||
@@ -106,11 +109,11 @@ export default {
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
style += 'padding-top: ' + (ratio * 100) + '%;';
|
||||
style += 'height:0;padding-top: ' + (ratio * 100) + '%;';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-HARMONY
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -26,38 +26,17 @@
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view class="box">
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<!-- 鸿蒙元服务渲染为原生 ArkUI TextArea,必须用 :rows 控制初始显示行数;CSS height 在原生组件上不生效 -->
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
- H5 / 微信:原生 textarea 识别 :rows 属性,可视高度 = rows × line-height(CSS 控制 line-height);
|
||||
- 鸿蒙:渲染为原生 ArkUI TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||
行数和行高全部由 formConfig 动态下发,不再硬编码 176rpx / row=2。 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || 2"
|
||||
:rows="item.value.rows || textareaRowsValue"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信小程序原生 textarea:
|
||||
不用 auto-height(会随内容无限扩展遮挡后续组件),改用 textareaStyle 中
|
||||
固定 height 176rpx(约 4 行)+ line-height 44rpx,可视区域比原 88rpx 翻倍,
|
||||
内容多时内部滚动条 thumb 比例改善一倍,且高度可控。 -->
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<textarea
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="inputStyle"
|
||||
></textarea>
|
||||
<!-- #endif -->
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -301,6 +280,11 @@
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#909399',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-newform 对齐)
|
||||
// 改用 :rows + line-height 驱动高度:rows 给原生 <textarea>(H5/微信原生层/鸿蒙 ArkUI 都识别),
|
||||
// line-height 用于在 textareaStyle 里算出对应的 px 高度;不再写死 176rpx。
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
@@ -334,6 +318,8 @@
|
||||
checkedColor: pick('checkedColor', 'checked_color'),
|
||||
placeholderColor: pick('placeholderColor', 'placeholder_color'),
|
||||
placeholderFontSize: pick('placeholderFontSize', 'placeholder_font_size'),
|
||||
textareaRows: pick('textareaRows', 'textarea_rows'),
|
||||
textareaLineHeight: pick('textareaLineHeight', 'textarea_line_height'),
|
||||
inputBorderColor: pick('inputBorderColor', 'input_border_color'),
|
||||
inputBgColor: pick('inputBgColor', 'input_bg_color'),
|
||||
inputFocusBorderColor: pick('inputFocusBorderColor', 'input_focus_border_color'),
|
||||
@@ -394,6 +380,8 @@
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
'--input-border-color': cfg.inputBorderColor,
|
||||
'--input-bg-color': cfg.inputBgColor,
|
||||
@@ -434,21 +422,37 @@
|
||||
'line-height': this.$util.rpx(this.config.inputHeight)
|
||||
});
|
||||
},
|
||||
// textarea 专用 inline style(仅微信小程序端使用,见 template 中 #ifdef MP-WEIXIN 分支)
|
||||
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
|
||||
textareaRowsValue() {
|
||||
const n = parseInt(this.config.textareaRows, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 4;
|
||||
},
|
||||
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出固定可视高度)
|
||||
textareaLineHeightValue() {
|
||||
const n = parseInt(this.config.textareaLineHeight, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 44;
|
||||
},
|
||||
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
|
||||
// 设计要点:
|
||||
// 1) 不复用 inputStyle 的 min-height / line-height:这两个值取自 inputHeight(默认 60rpx),
|
||||
// 对 textarea 太小,会让微信小程序原生 textarea 单行过高、可视区域过小,
|
||||
// 内容稍多即溢出导致原生滚动条 thumb 异常小;
|
||||
// 2) 不使用 auto-height:在微信小程序原生 textarea 上,auto-height 会让 textarea 随内容
|
||||
// 无限扩展,遮挡后续组件,导致"看不到下面的其他组件";
|
||||
// 3) 改用固定 height 176rpx(约 4 行,line-height 44rpx),相比原 CSS height: 88rpx(2 行)
|
||||
// 可视区域翻倍,内容多时内部滚动条 thumb 比例改善一倍,且高度可控不会遮挡后续组件。
|
||||
// 1) 不复用 inputStyle 的 min-height / line-height:那两个值取自 inputHeight(默认 60rpx),
|
||||
// 对 textarea 太小,会让原生 textarea 单行过高、可视区域过小;
|
||||
// 2) 不使用 auto-height:原生 textarea 上 auto-height 会让组件随内容无限扩展,
|
||||
// 遮挡后续组件,导致"看不到下面的其他组件";
|
||||
// 3) 高度 = rows × lineHeight(两个值都由 formConfig 动态下发,不再写死 176rpx):
|
||||
// - H5 浏览器 / 微信原生层:原生 <textarea> 同时识别 :rows 和 CSS line-height,
|
||||
// 高度由浏览器自然算出,与 :rows 完全一致;
|
||||
// - 鸿蒙 ArkUI:原生 TextArea 只识别 :rows,不识别 CSS height,所以我们在 JS 里
|
||||
// 显式算 height = rows × lineHeight 反向写出,保证三端可视高度像素级一致。
|
||||
textareaStyle() {
|
||||
const h = this.$util.rpx(176);
|
||||
const lh = this.$util.rpx(this.textareaLineHeightValue);
|
||||
const rows = this.textareaRowsValue;
|
||||
// 加上 padding(12rpx 上下) 和 border(1rpx 上下) 的占用,避免与 :rows 算出的高度不一致时被截断
|
||||
const paddings = this.$util.rpx(24) + this.$util.rpx(2);
|
||||
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
|
||||
return this.$util.objToStyle({
|
||||
'height': h,
|
||||
'min-height': h,
|
||||
'line-height': this.$util.rpx(44),
|
||||
'line-height': lh,
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius),
|
||||
'font-size': this.$util.rpx(this.config.labelFontSize),
|
||||
'color': this.config.labelColor
|
||||
@@ -959,6 +963,9 @@
|
||||
--placeholder-font-size: 28rpx;
|
||||
--checked-color: #ff4544;
|
||||
--placeholder-color: #909399;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
// 输入框视觉增强(与 ns-newform 对齐)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #f9fafb;
|
||||
@@ -1094,11 +1101,12 @@
|
||||
font-size: var(--label-font-size, $font-size-base);
|
||||
width: 100%;
|
||||
color: var(--label-color, #1f2937);
|
||||
// 鸿蒙元服务渲染为原生 ArkUI TextArea,不识别 CSS height,由 :rows 控制行数;
|
||||
// 这里给所有端都设一个最小高度基线,避免空内容时塌缩
|
||||
min-height: 88rpx;
|
||||
height: 88rpx;
|
||||
line-height: 44rpx;
|
||||
// 行高由 formConfig.textareaLineHeight(兜底 44rpx)控制;
|
||||
// 高度 = rows × line-height + padding + border,所有值都从 CSS 变量读取,
|
||||
// 真正做到了"rows 改 → 高度自动改"。
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border-radius: var(--input-radius, 0rpx);
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -27,10 +27,16 @@
|
||||
<text class="required" :style="{ color: requiredColor }">{{ item.value.required ? '*' : '' }}</text>
|
||||
</view>
|
||||
<view :class="['box', 'align-left']" :style="item._boxAlignStyle">
|
||||
<!-- 所有平台统一通过 formConfig.textareaRows / item.value.rows 控制显示行数:
|
||||
- H5 / 微信:原生 textarea 识别 :rows 属性;
|
||||
- 鸿蒙:渲染为 ArkUI 原生 TextArea,CSS height 不生效,必须靠 :rows 控制。
|
||||
rows 全部由 formConfig 动态下发,与 ns-form 行为保持一致。 -->
|
||||
<textarea
|
||||
:rows="item.value.rows || textareaRowsValue"
|
||||
:placeholder="item.value.placeholder"
|
||||
placeholder-class="placeholder color-tip"
|
||||
v-model="item.val"
|
||||
:style="textareaStyle"
|
||||
></textarea>
|
||||
</view>
|
||||
</view>
|
||||
@@ -377,6 +383,10 @@ export default {
|
||||
checkedColor: '#ff4544',
|
||||
placeholderColor: '#ddd',
|
||||
placeholderFontSize: 28,
|
||||
// textarea 显示行数 / 行高(与 ns-form 保持一致,由 formConfig 覆盖)
|
||||
// 改用 :rows + line-height 驱动高度,rows 给原生 <textarea>,line-height 在 textareaStyle 算 px 高度
|
||||
textareaRows: 4,
|
||||
textareaLineHeight: 44,
|
||||
// 输入框视觉增强(可由后端 form_config 覆盖)
|
||||
inputBorderColor: '#e5e7eb',
|
||||
inputBgColor: '#f9fafb',
|
||||
@@ -452,6 +462,9 @@ export default {
|
||||
'--checked-color': cfg.checkedColor,
|
||||
'--placeholder-color': cfg.placeholderColor,
|
||||
'--placeholder-font-size': this.$util.rpx(num(cfg.placeholderFontSize, 28)),
|
||||
// textarea 行数 / 行高(CSS 兜底 + JS 算高度都用得到)
|
||||
'--textarea-rows': num(cfg.textareaRows, 4),
|
||||
'--textarea-line-height': this.$util.rpx(num(cfg.textareaLineHeight, 44)),
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
|
||||
'--input-border-color': cfg.inputBorderColor,
|
||||
'--input-bg-color': cfg.inputBgColor,
|
||||
@@ -497,6 +510,30 @@ export default {
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius)
|
||||
});
|
||||
},
|
||||
// textarea 显示行数(来自 formConfig.textareaRows,未配置时回退到默认 4)
|
||||
textareaRowsValue() {
|
||||
const n = parseInt(this.config.textareaRows, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 4;
|
||||
},
|
||||
// textarea 行高(来自 formConfig.textareaLineHeight,用于 :rows × line-height 算出可视高度)
|
||||
textareaLineHeightValue() {
|
||||
const n = parseInt(this.config.textareaLineHeight, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 44;
|
||||
},
|
||||
// textarea 专用 inline style(所有平台统一使用,见 template 中 textarea 节点)
|
||||
// 高度 = rows × lineHeight + padding + border,由 formConfig 动态控制,与 ns-form 同公式
|
||||
textareaStyle() {
|
||||
const lh = this.$util.rpx(this.textareaLineHeightValue);
|
||||
const rows = this.textareaRowsValue;
|
||||
const paddings = this.$util.rpx(24) + this.$util.rpx(2); // padding 12rpx 上下 + border 1rpx 上下
|
||||
const h = (this.$util.rpx(this.textareaLineHeightValue * (rows - 1)) + lh) + paddings;
|
||||
return this.$util.objToStyle({
|
||||
'height': h,
|
||||
'min-height': h,
|
||||
'line-height': lh,
|
||||
'border-radius': this.$util.rpx(this.config.inputRadius)
|
||||
});
|
||||
},
|
||||
labelStyle() {
|
||||
const style = {
|
||||
fontSize: this.$util.rpx(this.config.labelFontSize),
|
||||
@@ -997,6 +1034,9 @@ export default {
|
||||
--submit-bg-color: #ff4544;
|
||||
--placeholder-color: #ddd;
|
||||
--placeholder-font-size: 28rpx;
|
||||
// textarea 行数 / 行高(运行时被 formWrapStyle 注入的 inline 覆盖;这里是兜底)
|
||||
--textarea-rows: 4;
|
||||
--textarea-line-height: 44rpx;
|
||||
// 输入框视觉增强(边框 / 背景 / 聚焦 / label 字重)
|
||||
--input-border-color: #e5e7eb;
|
||||
--input-bg-color: #f9fafb;
|
||||
@@ -1234,8 +1274,10 @@ export default {
|
||||
::v-deep textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
line-height: 1.5;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(CSS 兜底公式与 JS textareaStyle 一致)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
@@ -1244,7 +1286,6 @@ export default {
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
@@ -1260,8 +1301,10 @@ export default {
|
||||
textarea {
|
||||
font-size: var(--label-font-size, 28rpx);
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
line-height: 1.5;
|
||||
// 行高 / 高度全部由 formConfig 动态决定(与 ::v-deep textarea 同步)
|
||||
line-height: var(--textarea-line-height, 44rpx);
|
||||
min-height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
height: calc(var(--textarea-rows, 4) * var(--textarea-line-height, 44rpx) + 26rpx);
|
||||
text-align: left;
|
||||
border: 1rpx solid var(--input-border-color, #e5e7eb);
|
||||
border-radius: var(--input-radius, 10rpx);
|
||||
@@ -1270,7 +1313,6 @@ export default {
|
||||
color: var(--label-color, #1f2937);
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
|
||||
&:focus {
|
||||
background: var(--input-focus-bg-color, #ffffff);
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
<view class="video-popup-body">
|
||||
<video
|
||||
v-if="videoUrl"
|
||||
id="popupVideoPlayer"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
x5-playsinline
|
||||
class="video-player"
|
||||
></video>
|
||||
<view v-else class="video-error">{{ $lang('components.ns-video-player-popup.videoUrlNotExist') }}</view>
|
||||
@@ -22,6 +26,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { playVideoInContext } from '@/common/js/video-helper.js';
|
||||
|
||||
export default {
|
||||
name: 'ns-video-player-popup',
|
||||
props: {
|
||||
@@ -76,6 +82,10 @@ export default {
|
||||
* @param {Object} e - 弹窗状态变化事件
|
||||
*/
|
||||
onVideoPopupChange(e) {
|
||||
// H5 端弹窗显示后触发播放:避免 autoplay 被浏览器拦截 / 弹窗动画未完成被 pause() 打断
|
||||
if (e && e.show) {
|
||||
playVideoInContext('popupVideoPlayer', this);
|
||||
}
|
||||
this.$emit('popup-change', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 智能体暂未启用'}
|
||||
|
||||
65
pages.json
65
pages.json
@@ -51,6 +51,7 @@
|
||||
"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",
|
||||
@@ -1085,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1294,6 +1352,7 @@
|
||||
"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",
|
||||
@@ -2150,7 +2209,9 @@
|
||||
{
|
||||
"path": "form/formdata",
|
||||
"style": {
|
||||
// #ifdef APP-PLUS
|
||||
// APP 端原本就是 custom;微信小程序 / 华为快应用 / 鸿蒙元服务
|
||||
// 的原生 navbar 不支持自定义左侧 Home 按钮,需要切到 custom 自绘
|
||||
// #ifdef APP-PLUS || MP
|
||||
"navigationStyle": "custom",
|
||||
// #endif
|
||||
"navigationBarTitleText": "",
|
||||
|
||||
@@ -56,10 +56,11 @@
|
||||
</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"></video>
|
||||
:poster="$util.img(goodsSkuDetail.sku_image, { size: 'big' })" objectFit="contain"
|
||||
playsinline webkit-playsinline x5-playsinline></video>
|
||||
</view>
|
||||
|
||||
<!-- 切换视频、图片 -->
|
||||
|
||||
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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占位样式 */
|
||||
|
||||
@@ -1,21 +1,60 @@
|
||||
<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
|
||||
- 左侧:hasBackStack 为 true 时显示返回箭头(点击 navigateBack),showHomeBtn=true 时再叠加 Home 胶囊
|
||||
- 中央:表单标题(form_name)
|
||||
- 整体高度 = 状态栏高度 + 44px 内容区,由 customNavbarStyle 动态算
|
||||
H5 端保留系统原生 navbar,不渲染此块
|
||||
-->
|
||||
<!-- #ifndef H5 -->
|
||||
<view class="form-home-navbar" :style="customNavbarStyle">
|
||||
<view class="form-home-navbar__left">
|
||||
<view v-if="hasBackStack" class="form-home-navbar__back" @tap="goBack" hover-class="form-home-navbar__btn--hover">
|
||||
<text class="form-home-navbar__back-icon">‹</text>
|
||||
</view>
|
||||
<view v-if="showHomeBtn" class="form-home-capsule" @tap="goHome" hover-class="form-home-capsule--hover">
|
||||
<text class="iconfont icon-shouye1 form-home-capsule__icon"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-home-navbar__title">{{ navbarTitle }}</view>
|
||||
<view class="form-home-navbar__placeholder" />
|
||||
</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 控制。
|
||||
@submit 接管提交逻辑(带 uniacid + showModal) -->
|
||||
<diy-form
|
||||
ref="form"
|
||||
:value="diyValue"
|
||||
dataMode="raw"
|
||||
@submit="create"
|
||||
/>
|
||||
:onSubmit 接管提交逻辑(带 uniacid + showModal);跨 H5 / MP 一致,不依赖 $listeners。
|
||||
注意:create 重度依赖本组件 this(id/uniacid/memberInfo/$api 等),必须在 created 阶段 bind this,
|
||||
否则 diy-form 通过 this.onSubmit(formData) 调用时 this 会漂移到子组件实例 -->
|
||||
<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')" :isIndex="false"></ns-empty>
|
||||
<ns-empty :text="complete ? $lang('form.formdata.submitSuccess') : $lang('form.formdata.noFormInfo')"
|
||||
:isIndex="false"></ns-empty>
|
||||
</block>
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<ns-login ref="login"></ns-login>
|
||||
@@ -25,13 +64,9 @@
|
||||
|
||||
<script>
|
||||
|
||||
import diyForm from '@/components-diy/diy-form.vue';
|
||||
import scroll from '@/common/js/scroll-view.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
diyForm
|
||||
},
|
||||
mixins: [scroll],
|
||||
data() {
|
||||
return {
|
||||
@@ -43,7 +78,16 @@ export default {
|
||||
uniacid: 0,
|
||||
isFromMpWebView: false, // 是否是从微信小程序的WebView跳转过来的
|
||||
hideNavbar: false, // 查询参数中支持隐藏Navbar
|
||||
showHomeBtn: false, // 查询参数 show_home=1 时,在系统标题栏左侧显示 Home 按钮
|
||||
urlParams: {}, // 通用 URL 透传参数收集
|
||||
handleSubmit: null, // create 的 this 绑定版本,作为 :onSubmit 传给 diy-form(见 created)
|
||||
|
||||
// #ifndef H5
|
||||
navbarHeight: 44, // 自绘 navbar 内容区高度(不含状态栏),跨端兜底 44px
|
||||
statusBarHeight: 20, // 系统状态栏高度,自绘 navbar 时需要预留顶部空隙
|
||||
navbarTitle: '', // 自绘 navbar 中央显示的标题
|
||||
hasBackStack: false, // 自绘 navbar 左上角是否显示返回箭头:页面栈 > 1 时显示
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
formTitle: '', // 查询参数传入的Title
|
||||
@@ -51,7 +95,30 @@ export default {
|
||||
// #endif
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 预绑定 this:create 重度依赖本组件 this(id/uniacid/memberInfo/$api/$lang/$util 等)。
|
||||
// 若直接传 :onSubmit="create",diy-form 通过 this.onSubmit(formData) 调用时,
|
||||
// 普通函数调用的 this 会指向调用者(子组件实例),导致字段全部读不到。
|
||||
// bind 后无论子组件如何调用,this 始终是 formdata 实例。
|
||||
this.handleSubmit = this.create.bind(this);
|
||||
},
|
||||
computed: {
|
||||
// #ifndef H5
|
||||
// 自绘 navbar 的高度样式
|
||||
// - 内容区高度 = navbarHeight(兜底 44px)
|
||||
// - 整体高度 = 状态栏高度 + 内容区高度
|
||||
// - padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡
|
||||
customNavbarStyle() {
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
const sb = sys.statusBarHeight || 0;
|
||||
const h = this.navbarHeight || 44;
|
||||
return `height: ${sb + h}px; padding-top: ${sb}px;`;
|
||||
} catch (e) {
|
||||
return 'height: 64px; padding-top: 20px;';
|
||||
}
|
||||
},
|
||||
// #endif
|
||||
// 构造 diy-form raw 模式期望的 value 形态:
|
||||
// - jsonData:透传给 ns-newform(已是 ns-newform 期望的 [{ controller, value, val }])
|
||||
// - formConfig:后端返回 camelCase(如 formPaddingX / formBgColor / labelFontSize),
|
||||
@@ -71,6 +138,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
console.log("=====表单页面====", options)
|
||||
if (!options.uniacid) {
|
||||
uni.showModal({
|
||||
title: this.$lang('form.formdata.tip'),
|
||||
@@ -91,11 +159,36 @@ export default {
|
||||
this.uniacid = options.uniacid
|
||||
this.id = options.id || 0
|
||||
this.isFromMpWebView = options.from === 'mp-webview';
|
||||
this.hideNavbar = Boolean(options?.hide_navbar);
|
||||
this.hideNavbar = this.$util.coerceBool(options?.hide_navbar);
|
||||
// show_home=1 时在系统标题栏左侧显示 Home 按钮,点击回到首页 INDEX_PAGE_URL
|
||||
// 解析规则统一走 this.$util.coerceBool:支持 '1' / 'true' / 1 / true 四种写法
|
||||
// 仅在 navigationStyle:custom 端生效(APP / 微信小程序 / 华为快应用 / 鸿蒙元服务);
|
||||
// H5 保留系统原生 navbar,不支持 show_home,参数被忽略(这是预期行为)
|
||||
// 注:H5 嵌入微信小程序 webview(from=mp-webview)的场景也属于 H5 范畴,
|
||||
// 此场景下也没有 Home 入口——后续如需支持,需要单独处理(导航靠宿主小程序接管)
|
||||
this.showHomeBtn = this.$util.coerceBool(options?.show_home);
|
||||
|
||||
// #ifndef H5
|
||||
// 自绘 navbar 需要用到状态栏高度 / navbar 内容区高度(跨端兜底)
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync();
|
||||
if (sys.statusBarHeight) this.statusBarHeight = sys.statusBarHeight;
|
||||
// uniapp 在不同端 menuButton / navigationBar 高度略有差异,给个安全兜底即可
|
||||
if (typeof sys.navbarHeight === 'number' && sys.navbarHeight > 0) {
|
||||
this.navbarHeight = sys.navbarHeight;
|
||||
}
|
||||
} catch (e) { /* keep defaults */ }
|
||||
|
||||
// 自绘 navbar 是否显示返回箭头:getCurrentPages().length > 1 即页面有栈
|
||||
try {
|
||||
const pages = getCurrentPages ? getCurrentPages() : [];
|
||||
this.hasBackStack = Array.isArray(pages) && pages.length > 1;
|
||||
} catch (e) { /* keep default */ }
|
||||
// #endif
|
||||
|
||||
// 通用 URL 参数收集:除业务保留字段外,其余全部透传给后端
|
||||
// 后端会做卫生过滤并广播事件,供 recommend 等插件消费
|
||||
const reservedKeys = ['uniacid', 'id', 'scene', 'from', 'hide_navbar', 'title', 'enable_set_title'];
|
||||
const reservedKeys = ['uniacid', 'id', 'scene', 'from', 'hide_navbar', 'show_home', 'title', 'enable_set_title'];
|
||||
this.urlParams = {};
|
||||
for (let key in options) {
|
||||
if (reservedKeys.indexOf(key) !== -1) continue;
|
||||
@@ -128,7 +221,7 @@ export default {
|
||||
|
||||
this.getData()
|
||||
},
|
||||
|
||||
|
||||
watch: {
|
||||
storeToken(newVal, oldVal) {
|
||||
newVal && this.getData()
|
||||
@@ -138,6 +231,20 @@ export default {
|
||||
this.updateNavbarTitle();
|
||||
},
|
||||
methods: {
|
||||
// #ifndef H5
|
||||
// 返回上一页;用 navigateBack,栈深度>1 时由 hasBackStack 控制按钮可见性
|
||||
goBack() {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
},
|
||||
// #endif
|
||||
// 返回首页;
|
||||
// INDEX_PAGE_URL 是 TabBar 根页面,不能用裸 uni.redirectTo(会报"can not redirectTo a tabBar page")
|
||||
// 必须用 this.$util.redirectTo,内部对 TabBar 页面自动走 uni.switchTab
|
||||
// (鸿蒙元服务下还会用 reLaunch 兜底,见 common/js/util.js)
|
||||
// H5 端保留 show_home / goHome(虽然模板里不渲染自绘 navbar,但保留方法调用不报错)
|
||||
goHome() {
|
||||
this.$util.redirectTo(this.$util.INDEX_PAGE_URL, {}, 'redirectTo');
|
||||
},
|
||||
getData() {
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/info',
|
||||
@@ -165,13 +272,25 @@ export default {
|
||||
formName = this.formTitle;
|
||||
}
|
||||
// #endif
|
||||
// H5 端:系统原生 navbar —— uni.setNavigationBarTitle 改原生标题
|
||||
// #ifdef H5
|
||||
uni.setNavigationBarTitle({ title: formName });
|
||||
// #endif
|
||||
// 非 H5 端:custom 模式下没有原生 navbar,直接写 data.navbarTitle,
|
||||
// 由模板里的 .form-home-navbar__title 显示
|
||||
// #ifndef H5
|
||||
this.navbarTitle = formName;
|
||||
// #endif
|
||||
},
|
||||
// 由 diy-form 内置提交按钮触发:diy-form 已先做 verify(),仅在通过时才 emit('submit', formData)
|
||||
// 由 diy-form 内置提交按钮触发:diy-form handleSubmit 已先做 verify(),
|
||||
// 仅在通过时才通过 :onSubmit 函数 prop 调用本方法(跨 H5 / MP 一致,不走 $emit / $listeners)。
|
||||
// 注意:本方法通过 created 中 this.handleSubmit = this.create.bind(this) 预绑定 this,
|
||||
// 因此内部所有 this.xxx 都指向 formdata 实例,不会漂移到子组件。
|
||||
create(formData) {
|
||||
if (this.isRepeat) return
|
||||
// 防重复提交:console 在拦截之前,避免日志噪声;拦截后立即置位
|
||||
if (this.isRepeat) return;
|
||||
|
||||
this.isRepeat = true
|
||||
this.isRepeat = true;
|
||||
this.$api.sendRequest({
|
||||
url: '/form/api/form/create',
|
||||
data: {
|
||||
@@ -183,6 +302,7 @@ export default {
|
||||
},
|
||||
success: res => {
|
||||
if (res.code == 0) {
|
||||
this.complete = true
|
||||
uni.showModal({
|
||||
title: this.$lang('form.formdata.tip'),
|
||||
content: this.$lang('form.formdata.submitSuccess'),
|
||||
@@ -195,6 +315,10 @@ export default {
|
||||
title: res.message
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 网络失败时复位防重复标记,允许用户重试
|
||||
this.isRepeat = false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -202,15 +326,162 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/*
|
||||
* 自绘 navbar(非 H5 端:APP / 微信小程序 / 华为快应用 / 鸿蒙元服务)
|
||||
* - pages.json 已为这几端开启 navigationStyle:custom,系统不再提供原生 navbar
|
||||
* - 整体高度由 inline style 注入:statusBarHeight + 44px
|
||||
* - 左侧 Home 胶囊 + 中央标题 + 右侧占位,标题过长自动省略
|
||||
*/
|
||||
/* #ifndef H5 */
|
||||
|
||||
/* --- 自绘 navbar 容器 --- */
|
||||
.form-home-navbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
/* 高度由 inline style 通过 customNavbarStyle 注入:statusBarHeight + 44px */
|
||||
/* padding-top 把内容区下移到状态栏之下,避免被刘海/水滴遮挡 */
|
||||
/* 浮在表单内容之上,避免被 diy-form 等组件覆盖 */
|
||||
z-index: 100;
|
||||
/* 让内容靠 flex 自动落到 padding-top 之下的区域 */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-home-navbar__left {
|
||||
/* 不再用 absolute,跟着 navbar 的 padding-top 下移 */
|
||||
flex: none;
|
||||
/* 宽度自适应:返回按钮 56rpx + Home 胶囊 56rpx + 间距 12rpx + 左 padding 16rpx = 最多 ~140rpx */
|
||||
min-width: 88rpx;
|
||||
max-width: 200rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: 16rpx;
|
||||
box-sizing: border-box;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.form-home-navbar__back {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.form-home-navbar__back-icon {
|
||||
font-size: 48rpx;
|
||||
line-height: 1;
|
||||
color: #000;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.form-home-navbar__btn--hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.form-home-navbar__title {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-home-navbar__placeholder {
|
||||
/* 与 __left 对称,保持标题视觉居中 */
|
||||
flex: none;
|
||||
min-width: 88rpx;
|
||||
max-width: 200rpx;
|
||||
}
|
||||
|
||||
/* --- Home 胶囊主体 --- */
|
||||
.form-home-capsule {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.form-home-capsule--hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/*
|
||||
* Home 图标 —— 使用项目统一的 iconfont icon-shouye1(unicode \e02d)
|
||||
* 与 pages_goods/detail.vue 的首页按钮共用同一个图标,跨端视觉一致
|
||||
*/
|
||||
.form-home-capsule__icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
color: #000;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* #endif */
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
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 接入时一致的视觉
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="body-wrap">
|
||||
<view class="form-wrap">
|
||||
<view class="form-wrap">
|
||||
<view class="input-wrap" v-show="loginMode == 'mobile'">
|
||||
<view class="content">
|
||||
<!-- <view class="area-code">+86</view> -->
|
||||
@@ -324,16 +324,16 @@ export default {
|
||||
}
|
||||
},
|
||||
mobileAuthLogin(e) {
|
||||
if (e.detail.errMsg == 'getPhoneNumber:ok') {
|
||||
var data = {
|
||||
iv: e.detail.iv,
|
||||
encryptedData: e.detail.encryptedData
|
||||
};
|
||||
if (Object.keys(this.authInfo).length) {
|
||||
Object.assign(data, this.authInfo);
|
||||
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
|
||||
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
|
||||
}
|
||||
if (e.detail.errMsg == 'getPhoneNumber:ok') {
|
||||
var data = {
|
||||
iv: e.detail.iv,
|
||||
encryptedData: e.detail.encryptedData
|
||||
};
|
||||
if (Object.keys(this.authInfo).length) {
|
||||
Object.assign(data, this.authInfo);
|
||||
if (this.authInfo.nickName) data.nickname = this.authInfo.nickName;
|
||||
if (this.authInfo.avatarUrl) data.headimg = this.authInfo.avatarUrl;
|
||||
}
|
||||
if (uni.getStorageSync('source_member')) data.source_member = uni.getStorageSync('source_member');
|
||||
|
||||
if (this.isSub) return;
|
||||
|
||||
@@ -113,6 +113,32 @@ export default {
|
||||
},
|
||||
onShow() {
|
||||
this.$util.hideHomeButton();
|
||||
},
|
||||
// 覆写 mixin 的分享钩子:优先使用推荐码组件存储的分享数据
|
||||
onShareAppMessage() {
|
||||
try {
|
||||
var shareDataStr = uni.getStorageSync('_recommendShareData');
|
||||
if (shareDataStr) {
|
||||
uni.removeStorageSync('_recommendShareData');
|
||||
return JSON.parse(shareDataStr);
|
||||
}
|
||||
} catch(e) {}
|
||||
return this.mpShareData?.appMessage;
|
||||
},
|
||||
onShareTimeline() {
|
||||
try {
|
||||
var shareDataStr = uni.getStorageSync('_recommendShareData');
|
||||
if (shareDataStr) {
|
||||
uni.removeStorageSync('_recommendShareData');
|
||||
var d = JSON.parse(shareDataStr);
|
||||
var query = '';
|
||||
if (d.path && d.path.indexOf('?') >= 0) {
|
||||
query = d.path.split('?')[1];
|
||||
}
|
||||
return { title: d.title, query: query, imageUrl: d.imageUrl || '' };
|
||||
}
|
||||
} catch(e) {}
|
||||
return this.mpShareData?.timeLine;
|
||||
},
|
||||
methods: {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user