Compare commits
104 Commits
0ee97f902d
...
temp221
| Author | SHA1 | Date | |
|---|---|---|---|
| fc407935d2 | |||
| df5be6045b | |||
| 571452cd30 | |||
| 1c0444b1ec | |||
| 09909f0dcf | |||
| b14032149e | |||
| 447f7cd7b5 | |||
| dd15ff8ea4 | |||
| 3ab78f5369 | |||
| e223e58425 | |||
| 5ef5f275f0 | |||
| e7dd6aff62 | |||
| 8488aa39bc | |||
| 817d2081d7 | |||
| 0a0ce88d82 | |||
| 34c0df5320 | |||
| 356a6d2bbf | |||
| 3b354992e3 | |||
| fe0381a298 | |||
| 733c33a41c | |||
| e8ff0e9be4 | |||
| 0faecf3603 | |||
| e9c8bac60b | |||
| a72e1b733d | |||
| 132ecbb127 | |||
| 5173b5d38e | |||
| 4172da6201 | |||
| 53be343159 | |||
| 1de5f925ee | |||
| 95b5484629 | |||
| 454bc3b522 | |||
| 1223ec9202 | |||
| 4f75347103 | |||
| 6e7dbdde94 | |||
| 04b5ed06a3 | |||
| 0507d600a0 | |||
| cf902e0440 | |||
| f555b8a946 | |||
| ce705d9d61 | |||
| 5f4dc370f8 | |||
| a978858254 | |||
| 10e6402ad6 | |||
| bf5e32df2b | |||
| 9b0a2ce9b6 | |||
| 954e8cad0b | |||
| 166a284359 | |||
| 5efae5c38b | |||
| 4168c24fd6 | |||
| 762401777b | |||
| 24c4e40df9 | |||
| c58734fcda | |||
| bbc9fdd6ea | |||
| 4cd21ade51 | |||
| 0799e47233 | |||
| 306f2f7ff7 | |||
| fff10a2e19 | |||
| 9d990e6aac | |||
| 74468e2c9d | |||
| f40c4b2a69 | |||
| 2dae3adf66 | |||
| e079afc0a2 | |||
| 85b3e4db47 | |||
| 0197224c75 | |||
| 70f8fb867d | |||
| 86b87eeb9d | |||
| 297b545e55 | |||
| 3e2ae9b7ca | |||
| 2016b87b6e | |||
| a42ad4ebb3 | |||
| 158750b933 | |||
| 3abe65de2a | |||
| c0e432cc22 | |||
| f8115f47fd | |||
| 66b56387e6 | |||
| 3079cf4ba5 | |||
| aefb0c1550 | |||
| 235adb5ebd | |||
| 744981ed93 | |||
| ee20c214de | |||
| 8fd68033ed | |||
| 62b4b44f88 | |||
| 9d599d5f47 | |||
| 2b1f8a4fe2 | |||
| 7a81045290 | |||
| 67ce0f8b39 | |||
| 01df6c9116 | |||
| 480a109114 | |||
| 509fe203f7 | |||
| 0f9cc1087f | |||
| 0f24f47048 | |||
| a3d2ee562e | |||
| 1b1704f7c6 | |||
| 8b2670bf31 | |||
| e66c91acfc | |||
| 29699a66fd | |||
| 3b4051c775 | |||
| fffb5dae18 | |||
| 8cfc144ba9 | |||
| 065fba7cf8 | |||
| f2cb69cb06 | |||
| fe2d3dcfe2 | |||
| 78973c99a3 | |||
| 171190c1cd | |||
| 7952f5a722 |
@@ -214,14 +214,27 @@ const localDevConfig = ({
|
||||
uniacid: 3007,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'2843': { // 蔬菜种子网
|
||||
uniacid: 2843,
|
||||
domain: 'https://xcx.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-317': { // 电磁兼容,模板显示不全商品
|
||||
uniacid: 317,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
|
||||
'5g-312': { // 天然植物功效活性物
|
||||
uniacid: 312,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-248': { // 工业称重
|
||||
uniacid: 248,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
'5g-325': { // 检验检测机构
|
||||
uniacid: 325,
|
||||
domain: 'https://5g.aigc-quickapp.com/',
|
||||
},
|
||||
// })['5g-237']; // 商品分类,没有立即咨询文字
|
||||
// })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色
|
||||
// })['5g-50']; // 燃气安全产品机器人,购物车无反应
|
||||
@@ -242,8 +255,9 @@ const localDevConfig = ({
|
||||
// })['2505']; //煤矿钻机
|
||||
// })['local-11']; // 选择要使用的环境配置
|
||||
// })['2811']; // 本地
|
||||
//})['5g-317']; // 电磁兼容
|
||||
})['5g-351']; //
|
||||
// })['5g-317']; // 电磁兼容
|
||||
// })['5g-351']; //
|
||||
})['5g-97']; // 本地
|
||||
// })['local-2']; // 本地
|
||||
// })['local-11']; // 本地
|
||||
// })['1-test']; // test
|
||||
|
||||
14
App.vue
14
App.vue
@@ -5,6 +5,10 @@
|
||||
import ThemeOverride from 'common/js/theme-override.js';
|
||||
import PrivacyManager from 'common/js/privacy.js';
|
||||
import splashState from 'common/js/splash-state.js';
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): AI 客服会话 key 切换
|
||||
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
||||
import aiService from 'common/js/ai-service.js';
|
||||
import { markNeedStartupAd } from 'common/js/startup-ad.js';
|
||||
|
||||
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
|
||||
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
|
||||
@@ -468,6 +472,14 @@
|
||||
if (uni.getStorageSync('memberInfo')) {
|
||||
this.$store.commit('setMemberInfo', uni.getStorageSync('memberInfo'));
|
||||
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 登录态恢复后清旧 user key
|
||||
// 避免"A 退出 → B 登录 → 直接复用 A 的会话 ID" 串会话
|
||||
try {
|
||||
if (aiService && typeof aiService.onUserChange === 'function') {
|
||||
aiService.onUserChange();
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 查询购物车信息
|
||||
this.$store.dispatch('getCartNumber');
|
||||
}
|
||||
@@ -501,6 +513,8 @@
|
||||
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
||||
if (_appHasShown) {
|
||||
try { Lang.onAppReentry(); } catch (e) {}
|
||||
// 重新进入小程序(从后台切回 / "重新进入小程序"菜单): 标记需要弹出启动广告
|
||||
try { markNeedStartupAd(); } catch (e) {}
|
||||
}
|
||||
_appHasShown = true;
|
||||
|
||||
|
||||
@@ -13,10 +13,54 @@ let currentConversationId = null;
|
||||
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
|
||||
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
|
||||
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
|
||||
// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口
|
||||
// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。
|
||||
|
||||
export const DEFAULT_AGENT_ID = 'agent_aikefu';
|
||||
let currentAgentId = DEFAULT_AGENT_ID;
|
||||
export const CONVERSATION_KEY = 'ai_conversation_id';
|
||||
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): key 格式变更 + 三维隔离
|
||||
// 老 key: ai_conversation_id_<agentId> (只按 agent 隔离, 跨用户/跨 uniacid 串会话)
|
||||
// 新 key: ai_conv_<uniacid>_<userId>_<agentId> (按 uniacid × userId × agent 三维隔离)
|
||||
// 一次性迁移: 读老 key → 新 key 为空 → 写入新 key 后保留老 key (1 版本后清理)
|
||||
export const CONVERSATION_KEY_PREFIX = 'ai_conv_';
|
||||
const LEGACY_KEY_PREFIX = 'ai_conversation_id_';
|
||||
|
||||
export function getConversationKey(agentId) {
|
||||
let siteId = 0;
|
||||
let userId = 'anon';
|
||||
try {
|
||||
siteId = uni.getStorageSync('uniacid') || Config.uniacid || 0;
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
userId = getUserId() || 'anon';
|
||||
} catch (e) { /* ignore */ }
|
||||
return CONVERSATION_KEY_PREFIX + siteId + '_' + userId + '_' + (agentId || DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
// 一次性老 key 迁移: 新 key 为空且老 key 有值 → 写入新 key, 保留老 key 1 版本兜底
|
||||
function migrateLegacyConversationKey(agentId) {
|
||||
try {
|
||||
const legacyKey = LEGACY_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID);
|
||||
const legacy = uni.getStorageSync(legacyKey);
|
||||
const newKey = getConversationKey(agentId);
|
||||
if (legacy && !uni.getStorageSync(newKey)) {
|
||||
uni.setStorageSync(newKey, legacy);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 兼容旧代码: 单 key 别名(指向默认 agent) — 给老代码引用,值是动态计算的
|
||||
export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID);
|
||||
|
||||
// 统一 SSE/WS 事件分发器 (透出所有事件类型)
|
||||
let eventHandlers = { onEvent: null };
|
||||
function dispatchEvent(onEvent, event, data) {
|
||||
if (typeof onEvent === 'function') {
|
||||
try { onEvent(event, data); }
|
||||
catch (e) { console.error('[AI Service] onEvent handler threw:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-7: agent_id -> 后端 addon 路由映射
|
||||
@@ -65,14 +109,25 @@ function getDomainParams() {
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化时从本地读取会话ID
|
||||
// 初始化时从本地读取会话ID(按当前 agent 隔离)
|
||||
// PR-MULTI-TURN-KEY-BINDING (2026-09-18): 先迁移老 key 再读新 key
|
||||
// H5 时序坑: 模块顶层同步执行时 uni.getStorageSync 仍返回空字符串
|
||||
// (uniapp H5 runtime shim 在 webpack bundle 之后才注入),延迟 1 个 tick
|
||||
// 让 uni runtime 就绪后再读 — 实测 100ms 后能读到值
|
||||
try {
|
||||
const saved = uni.getStorageSync(CONVERSATION_KEY);
|
||||
if (saved) {
|
||||
currentConversationId = saved;
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) {
|
||||
currentConversationId = saved;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
}
|
||||
}, 0);
|
||||
} catch (e) {
|
||||
console.warn('读取会话ID失败:', e);
|
||||
console.warn('初始化会话ID任务派发失败:', e);
|
||||
}
|
||||
|
||||
const GUEST_ID_KEY = 'ai_guest_user_id';
|
||||
@@ -98,6 +153,28 @@ function getUserId() {
|
||||
return guestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归在 info 响应中查找配置 uuid (媒体上传必填)。
|
||||
* 后端字段名/层级不确定, 深度 4 内匹配 config_uuid / config_uid / uuid 任意字段。
|
||||
*/
|
||||
function findConfigUuid(obj, depth) {
|
||||
if (!obj || typeof obj !== 'object' || (depth || 0) > 4) return '';
|
||||
var keys;
|
||||
try { keys = Object.keys(obj); } catch (e) { return ''; }
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var k = keys[i];
|
||||
if (/^(config_uuid|config_uid|uuid)$/i.test(k) && obj[k]) return String(obj[k]);
|
||||
}
|
||||
for (var j = 0; j < keys.length; j++) {
|
||||
var v = obj[keys[j]];
|
||||
if (v && typeof v === 'object') {
|
||||
var found = findConfigUuid(v, (depth || 0) + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 AI Agent。
|
||||
*/
|
||||
@@ -198,41 +275,99 @@ function ensureWebSocketConnected() {
|
||||
// 以下为聊天消息响应,只在有活跃消息时处理
|
||||
if (!wsMessageActive) return;
|
||||
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.content || data.text || '';
|
||||
// 兼容嵌套 data.data.content 格式
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
// 优先走 onEvent 事件分发(透出 thought/tool_call/question 等)
|
||||
var onEvt = eventHandlers.onEvent;
|
||||
|
||||
switch (data.event) {
|
||||
case 'message': {
|
||||
var text = data.answer || data.content || data.text || '';
|
||||
// 兼容嵌套 data.data.content 格式
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
|
||||
if (text && text.startsWith('> ')) {
|
||||
dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
wsMessageContent += text;
|
||||
if (wsMessageOnChunk) wsMessageOnChunk(text);
|
||||
dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
wsMessageContent += text;
|
||||
if (wsMessageOnChunk) wsMessageOnChunk(text);
|
||||
} else if (data.event === 'message_end' || data.event === 'done') {
|
||||
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
|
||||
// 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中
|
||||
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
|
||||
// 保存会话ID(独立函数,不使用 this)
|
||||
if (conversationId) {
|
||||
if (conversationId !== currentConversationId) {
|
||||
currentConversationId = conversationId;
|
||||
try {
|
||||
uni.setStorageSync(CONVERSATION_KEY, conversationId);
|
||||
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
|
||||
} catch (e) {
|
||||
console.error('保存会话ID失败:', e);
|
||||
case 'thought':
|
||||
case 'thinking':
|
||||
case 'reasoning': {
|
||||
// 兼容字段: thought / thinking / reasoning / content / answer
|
||||
var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
|
||||
console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id);
|
||||
dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
dispatchEvent(onEvt, 'tool_call', data.tool_call || {});
|
||||
break;
|
||||
case 'question':
|
||||
dispatchEvent(onEvt, 'question', {
|
||||
pending_question: data.pending_question || (data.data && data.data.pending_question),
|
||||
message_id: data.message_id,
|
||||
conversation_id: data.conversation_id
|
||||
});
|
||||
break;
|
||||
case 'message_end':
|
||||
case 'done': {
|
||||
// 兼容两种结束信号:message_end(流结束)或 done(服务端完成)
|
||||
var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId);
|
||||
// 保存会话ID(按当前 agent 隔离)
|
||||
if (conversationId) {
|
||||
if (conversationId !== currentConversationId) {
|
||||
currentConversationId = conversationId;
|
||||
try {
|
||||
uni.setStorageSync(getConversationKey(currentAgentId), conversationId);
|
||||
console.log('[AI WS] 会话ID已持久化到 storage:', conversationId);
|
||||
} catch (e) {
|
||||
console.error('保存会话ID失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
var content = wsMessageContent;
|
||||
var convId = conversationId;
|
||||
// 重置消息状态,但不断开连接
|
||||
var endPayload = {
|
||||
conversation_id: convId,
|
||||
message_id: data.message_id,
|
||||
content: data.content || content,
|
||||
thoughts: data.thoughts || '',
|
||||
tool_calls: data.tool_calls || [],
|
||||
tokens: data.tokens || null,
|
||||
phase: data.phase || 'completed',
|
||||
stop_reason: data.stop_reason || '',
|
||||
effective_model: data.effective_model || ''
|
||||
};
|
||||
wsMessageActive = false;
|
||||
wsMessageContent = '';
|
||||
dispatchEvent(onEvt, 'message_end', endPayload);
|
||||
if (wsMessageOnComplete) wsMessageOnComplete(endPayload);
|
||||
if (wsMessageResolve) wsMessageResolve(endPayload);
|
||||
break;
|
||||
}
|
||||
var content = wsMessageContent;
|
||||
var convId = conversationId;
|
||||
// 重置消息状态,但不断开连接
|
||||
wsMessageActive = false;
|
||||
wsMessageContent = '';
|
||||
if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId });
|
||||
if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId });
|
||||
} else {
|
||||
// 兜底日志:打印所有未识别的消息类型
|
||||
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
|
||||
case 'error':
|
||||
dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' });
|
||||
break;
|
||||
case 'ping':
|
||||
// keep-alive, 忽略
|
||||
break;
|
||||
default:
|
||||
// 兜底: 老格式 data.data.content
|
||||
if (data.data && data.data.content) {
|
||||
var defText = data.data.content;
|
||||
wsMessageContent += defText;
|
||||
if (wsMessageOnChunk) wsMessageOnChunk(defText);
|
||||
dispatchEvent(onEvt, 'message', { content: defText });
|
||||
} else {
|
||||
console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data);
|
||||
@@ -289,6 +424,64 @@ function disconnectWebSocket() {
|
||||
}
|
||||
// #endif
|
||||
|
||||
// PR-FIX (2026-09-20): getServiceConfig 短 TTL 缓存
|
||||
// 服务配置(含欢迎语)变更频率极低, 每次进聊天页都打 /api/kefu/info 纯属浪费;
|
||||
// TTL 60s + 失败不缓存, 配置保存后最多延迟 1 分钟生效
|
||||
var _serviceConfigCache = null;
|
||||
var _serviceConfigCacheAt = 0;
|
||||
var SERVICE_CONFIG_CACHE_TTL = 60 * 1000;
|
||||
|
||||
function resetServiceConfigCache() {
|
||||
_serviceConfigCache = null;
|
||||
_serviceConfigCacheAt = 0;
|
||||
}
|
||||
|
||||
// PR-CHAT-UI-CONFIG (2026-09-20): chat_ui_config 前端 fallback 默认值
|
||||
// 与后端 addon/aikefu/model/Config.php::DEFAULT_UI_CONFIG 字段一一对应
|
||||
// 改任何一项必须同步后端常量, 否则前后端默认分叉
|
||||
//
|
||||
// PR-CHAT-UI-CONFIG-SERVER-ONLY (2026-09-20):
|
||||
// ui_available_models 必须完全来自后端 (Config.php::DEFAULT_UI_CONFIG),
|
||||
// 前端不维护实际清单。仅保留 'Auto' 兜底: 后端 /info 调用失败 / 返回空数组时,
|
||||
// 模型选择器仍可显示 Auto。
|
||||
// 数据源 (后端维护): https://docs.openclaw.ai/providers
|
||||
// 商家可在后台自由增删任意自定义模型名 (自部署/微调), 仅 trim + 去空 + 去重 + 单条 ≤30 字符 + 总数 ≤100。
|
||||
const CHAT_UI_DEFAULTS = {
|
||||
ui_show_thinking_process: true,
|
||||
ui_enable_attachment_types: ['image', 'file', 'audio', 'video'],
|
||||
ui_input_max_length: 500,
|
||||
ui_available_models: ['Auto'],
|
||||
ui_enable_feedback: true,
|
||||
// PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
|
||||
ui_assistant_name: '',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步
|
||||
ui_show_assistant_name: true,
|
||||
ui_show_assistant_avatar: true,
|
||||
ui_assistant_avatar: '',
|
||||
ui_show_message_time: true,
|
||||
ui_time_format: 'auto',
|
||||
ui_time_humanize: false
|
||||
};
|
||||
|
||||
// chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步);
|
||||
// 不在 _serviceConfigCache 里读是因为 chat 组件可能在 getServiceConfig 还没返回前就调它
|
||||
var _chatUiConfigCache = null;
|
||||
var _chatUiConfigCacheAt = 0;
|
||||
|
||||
function mergeChatUiConfig(remote) {
|
||||
// remote 是 /info 响应里的 chat_ui_config 对象, 缺字段用 defaults 兜底
|
||||
// 注意: 不深拷贝数组 → 后端返回的就是最终数组, 不再覆盖
|
||||
var merged = {};
|
||||
Object.keys(CHAT_UI_DEFAULTS).forEach(function(k) {
|
||||
if (remote && Object.prototype.hasOwnProperty.call(remote, k) && remote[k] !== null && remote[k] !== undefined) {
|
||||
merged[k] = remote[k];
|
||||
} else {
|
||||
merged[k] = CHAT_UI_DEFAULTS[k];
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 获取uniacid
|
||||
@@ -329,6 +522,10 @@ export default {
|
||||
response_mode: 'blocking',
|
||||
user: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
attachments: (options && options.attachments) || [],
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||
// 给 WbAgentOsClient 提供 timestamp/timezone/location/page/metadata 等基础元素
|
||||
context: (options && options.context) || null,
|
||||
...getDomainParams()
|
||||
},
|
||||
header: {
|
||||
@@ -362,48 +559,106 @@ export default {
|
||||
* 流式消息入口(自动适配平台)
|
||||
* 微信小程序:复用长连接 WebSocket
|
||||
*/
|
||||
async sendStreamMessage(message, onChunk, onComplete) {
|
||||
async sendStreamMessage(message, onEvent, onComplete, opts = {}) {
|
||||
// #ifdef MP
|
||||
try {
|
||||
await ensureWebSocketConnected();
|
||||
} catch (e) {
|
||||
if (onComplete) onComplete({ error: e.message });
|
||||
throw e;
|
||||
// WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署)
|
||||
// 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口
|
||||
console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message);
|
||||
return this._fallbackToHttp(message, onEvent, onComplete, opts);
|
||||
}
|
||||
|
||||
wsMessageContent = '';
|
||||
wsMessageActive = true;
|
||||
wsMessageOnChunk = onChunk || null;
|
||||
wsMessageOnComplete = onComplete || null;
|
||||
eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null;
|
||||
// onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete
|
||||
wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null);
|
||||
wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
wsMessageResolve = resolve;
|
||||
wsMessageReject = reject;
|
||||
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'chat',
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
query: message,
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
...getDomainParams()
|
||||
})
|
||||
});
|
||||
try {
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'chat',
|
||||
agent_id: getAgentId(),
|
||||
uniacid: this.getUniacid(),
|
||||
query: message,
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
attachments: opts.attachments || [],
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||
context: (opts && opts.context) || null,
|
||||
...getDomainParams()
|
||||
})
|
||||
});
|
||||
} catch (sendErr) {
|
||||
console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message);
|
||||
// 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡
|
||||
this._fallbackToHttp(message, onEvent, onComplete, opts)
|
||||
.then((res) => resolve(res))
|
||||
.catch((err) => reject(err));
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复
|
||||
* 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名
|
||||
*/
|
||||
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
|
||||
try {
|
||||
const result = await this.sendMessage(message, {
|
||||
attachments: (opts && opts.attachments) || [],
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): MP HTTP 降级路径同步传递 context
|
||||
context: (opts && opts.context) || null
|
||||
});
|
||||
const content = result && result.content ? result.content : '';
|
||||
// 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)"
|
||||
const fallbackPayload = {
|
||||
content,
|
||||
conversation_id: result.conversationId,
|
||||
thoughts: result.thoughts || '',
|
||||
tokens: result.tokens || null
|
||||
};
|
||||
if (content || result.thoughts) {
|
||||
if (typeof onEvent === 'function') {
|
||||
if (result.thoughts) onEvent('thought', { content: result.thoughts });
|
||||
onEvent('message', { content });
|
||||
onEvent('message_end', fallbackPayload);
|
||||
} else {
|
||||
if (onComplete) onComplete(fallbackPayload);
|
||||
}
|
||||
return fallbackPayload;
|
||||
}
|
||||
const emptyErr = new Error(lang.lang('aiService.serviceUnavailable'));
|
||||
if (onComplete) onComplete({ error: emptyErr.message });
|
||||
throw emptyErr;
|
||||
} catch (err) {
|
||||
console.error('[AI Service] MP HTTP 降级请求也失败:', err);
|
||||
if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* HTTP 流式请求(仅 H5 使用)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async sendHttpStream(message, onChunk, onComplete) {
|
||||
async sendHttpStream(message, onEvent, onComplete, opts = {}) {
|
||||
// #ifdef H5
|
||||
try {
|
||||
var chatUrl = resolveApiUrl('chat');
|
||||
var url = Config.baseUrl + chatUrl;
|
||||
// PR-AI-CHAT-URL-NORMALIZE (2026-09-19): Config.baseUrl 文档约定以 '/' 结尾 (config.js:25),
|
||||
// resolveApiUrl 返 '/api/kefu/chat' 也以 '/' 开头 → 直接拼接会出 '//api/kefu/chat' (双斜杠)。
|
||||
// nginx 实际会 normalize, 但 console 会脏、第三方抓包看不懂, 单点 normalize 消除连续 //。
|
||||
// 只动 URL 拼接本身, 不改 baseUrl 约定 (整个项目所有 API 都依赖这个约定)。
|
||||
var url = (Config.baseUrl + chatUrl).replace(/([^:])\/\//g, '$1/');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -419,6 +674,9 @@ export default {
|
||||
agent_id: getAgentId(),
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
attachments: (opts && opts.attachments) || [],
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||
context: (opts && opts.context) || null,
|
||||
...getDomainParams()
|
||||
})
|
||||
});
|
||||
@@ -432,6 +690,7 @@ export default {
|
||||
let buffer = '';
|
||||
let content = '';
|
||||
let conversationId = '';
|
||||
const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); };
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -444,54 +703,90 @@ export default {
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (jsonStr && jsonStr !== '[DONE]') {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.text || '';
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
}
|
||||
content += text;
|
||||
if (onChunk) onChunk(text);
|
||||
}
|
||||
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) {
|
||||
conversationId = cid;
|
||||
}
|
||||
if (data.event === 'message_end' || data.event === 'done') {
|
||||
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析失败:', e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim().startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = buffer.trim().slice(5);
|
||||
if (jsonStr) {
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (!jsonStr || jsonStr === '[DONE]') continue;
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
if (data.event === 'message') {
|
||||
var text = data.answer || data.text || '';
|
||||
if (!text && data.data) {
|
||||
text = data.data.content || data.data.answer || '';
|
||||
// PR-SSE-ERROR-PASSTHROUGH (2026-09-19): blocking 错误响应 (aikefu Kefu.php:558)
|
||||
// 或 aiagent Chat.php catch 块会在 stream 模式下也返 {"code":-1,"message":"..."}
|
||||
// 这种响应没 event 字段, 不应进 switch 的 default (会污染 console)
|
||||
if (data && typeof data === 'object' && !Array.isArray(data) &&
|
||||
data.code !== undefined && data.code !== 0 && !data.event) {
|
||||
console.warn('[AI SSE] 后端错误响应:', data.code, data.message);
|
||||
fire('error', { code: data.code, message: data.message || '请求失败' });
|
||||
break;
|
||||
}
|
||||
// 空数组是 PHP json_encode([]) 的产物 (如 attachments 或 history 字段),
|
||||
// 不是事件, 静默跳过
|
||||
if (Array.isArray(data)) {
|
||||
continue;
|
||||
}
|
||||
switch (data.event) {
|
||||
case 'message': {
|
||||
var text = data.answer || data.text || data.content || '';
|
||||
if (!text && data.data) text = data.data.content || data.data.answer || '';
|
||||
// B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识
|
||||
if (text && text.startsWith('> ')) {
|
||||
fire('thought', { content: text, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
content += text;
|
||||
fire('message', { content: text, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
content += text;
|
||||
if (onChunk) onChunk(text);
|
||||
}
|
||||
var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) {
|
||||
conversationId = cid;
|
||||
case 'thought':
|
||||
case 'thinking':
|
||||
case 'reasoning': {
|
||||
// 兼容字段: thought / thinking / reasoning / content / answer
|
||||
var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || '';
|
||||
fire('thought', { content: thoughtText2, message_id: data.message_id });
|
||||
break;
|
||||
}
|
||||
case 'tool_call':
|
||||
fire('tool_call', data.tool_call || {});
|
||||
break;
|
||||
case 'question':
|
||||
fire('question', {
|
||||
pending_question: data.pending_question || (data.data && data.data.pending_question),
|
||||
message_id: data.message_id,
|
||||
conversation_id: data.conversation_id
|
||||
});
|
||||
break;
|
||||
case 'message_end':
|
||||
case 'done': {
|
||||
const cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
|
||||
if (cid) conversationId = cid;
|
||||
console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid);
|
||||
fire('message_end', {
|
||||
conversation_id: cid,
|
||||
message_id: data.message_id,
|
||||
content: data.content || content,
|
||||
thoughts: data.thoughts || '',
|
||||
tool_calls: data.tool_calls || [],
|
||||
tokens: data.tokens || null,
|
||||
phase: data.phase || 'completed',
|
||||
stop_reason: data.stop_reason || '',
|
||||
effective_model: data.effective_model || ''
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
fire('error', { code: data.code || -1, message: data.message || 'unknown' });
|
||||
break;
|
||||
default:
|
||||
// B 路 Dify 把思考塞 answer 里 (> 前缀)
|
||||
if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) {
|
||||
fire('thought', { content: data.answer });
|
||||
} else if (data.event === 'ping') {
|
||||
// keep-alive
|
||||
} else {
|
||||
console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析失败:', e, line);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('最后 buffer 解析失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +811,9 @@ export default {
|
||||
success: true,
|
||||
content: response.data?.answer || response.data?.content || response.data?.reply || response.data,
|
||||
conversationId: response.data?.conversation_id,
|
||||
messageId: response.data?.message_id
|
||||
messageId: response.data?.message_id,
|
||||
thoughts: response.data?.thoughts || '',
|
||||
tokens: response.data?.tokens || null
|
||||
};
|
||||
} else {
|
||||
throw new Error(response.message || lang.lang('aiService.serviceResponseError'));
|
||||
@@ -532,25 +829,57 @@ export default {
|
||||
|
||||
/**
|
||||
* 设置并持久化会话ID
|
||||
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 写入前先迁移老 key
|
||||
*/
|
||||
setConversationId(id) {
|
||||
if (id && id !== currentConversationId) {
|
||||
currentConversationId = id;
|
||||
try {
|
||||
uni.setStorageSync(CONVERSATION_KEY, id);
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
uni.setStorageSync(getConversationKey(currentAgentId), id);
|
||||
} catch (e) {
|
||||
console.error('保存会话ID失败:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-MULTI-TURN-KEY-BINDING (2026-09-18): 用户切换钩子
|
||||
* App.vue 在 setMemberInfo 之后调用,触发清旧 user key + 重置 currentConversationId
|
||||
* 避免"用户 A 退出 → 用户 B 登录 → 直接复用 A 的会话 ID" 串会话 bug
|
||||
*
|
||||
* 调用时机:
|
||||
* - App.vue onLaunch 时如果检测到 memberInfo 恢复 → 调一次
|
||||
* - 任何 setMemberInfo 提交后调一次
|
||||
* 行为:
|
||||
* - 比对新老 user_id,不同时移除"旧 user"维度的 storage key
|
||||
* - 重置 currentConversationId 为 null (强制下一轮 chat 用新 user 的会话或新建)
|
||||
* - 记录 last_user_id 用于下次对比
|
||||
*/
|
||||
onUserChange() {
|
||||
try {
|
||||
const newUid = String(getUserId() || 'anon');
|
||||
const oldUid = uni.getStorageSync('ai_last_user_id');
|
||||
if (oldUid && oldUid !== newUid) {
|
||||
// 清旧用户 key 维度 (按当前 agent 即可,下次 setAgentId 会重新计算)
|
||||
const oldKey = CONVERSATION_KEY_PREFIX + (uni.getStorageSync('uniacid') || Config.uniacid || 0)
|
||||
+ '_' + oldUid + '_' + (currentAgentId || DEFAULT_AGENT_ID);
|
||||
try { uni.removeStorageSync(oldKey); } catch (e) { /* ignore */ }
|
||||
}
|
||||
uni.setStorageSync('ai_last_user_id', newUid);
|
||||
currentConversationId = null;
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] onUserChange failed:', e && e.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话ID
|
||||
*/
|
||||
clearConversationId() {
|
||||
currentConversationId = null;
|
||||
try {
|
||||
uni.removeStorageSync(CONVERSATION_KEY);
|
||||
uni.removeStorageSync(getConversationKey(currentAgentId));
|
||||
} catch (e) {
|
||||
console.error('清除会话ID失败:', e);
|
||||
}
|
||||
@@ -612,6 +941,12 @@ export default {
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async getServiceConfig() {
|
||||
// PR-FIX (2026-09-20): 命中缓存直接返回
|
||||
const nowTs = Date.now();
|
||||
if (_serviceConfigCache && (nowTs - _serviceConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
|
||||
console.log('[AI Service] getServiceConfig 命中缓存 (age=' + Math.round((nowTs - _serviceConfigCacheAt) / 1000) + 's)');
|
||||
return _serviceConfigCache;
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
url: resolveApiUrl('info'),
|
||||
@@ -632,6 +967,18 @@ export default {
|
||||
async: false,
|
||||
});
|
||||
console.log('[AI Service] getServiceConfig response:', JSON.stringify(response));
|
||||
// 媒体上传需要 config_uuid (后端校验不能为空)。递归查找并持久化。
|
||||
const cfg = response && response.data;
|
||||
const uuid = findConfigUuid(cfg, 0);
|
||||
if (uuid) {
|
||||
uni.setStorageSync('current_config_uuid', uuid);
|
||||
console.log('[AI Service] 已缓存 config_uuid:', uuid);
|
||||
} else {
|
||||
console.warn('[AI Service] info 响应中未找到 config_uuid/config_uid/uuid 字段, 上传将缺少该参数。响应结构:', JSON.stringify(cfg));
|
||||
}
|
||||
// PR-FIX (2026-09-20): 成功响应写缓存 (失败不缓存, 下次重试)
|
||||
_serviceConfigCache = response.data;
|
||||
_serviceConfigCacheAt = Date.now();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AI Service] getServiceConfig error:', error);
|
||||
@@ -639,14 +986,75 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-FIX (2026-09-20): 清除服务配置缓存
|
||||
* 供配置变更后主动调用 (目前 60s TTL 已足够, 预留显式失效入口)
|
||||
*/
|
||||
clearServiceConfigCache() {
|
||||
resetServiceConfigCache();
|
||||
_chatUiConfigCache = null;
|
||||
_chatUiConfigCacheAt = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-CHAT-UI-CONFIG (2026-09-20): 获取前端 UI 行为开关
|
||||
* 5 个 P0 开关由后端 Config::getChatUiConfig() 下发, 字段缺失用前端 CHAT_UI_DEFAULTS 兜底
|
||||
* 缓存策略: 60s TTL, 失败不缓存 (下次重试), 与 getServiceConfig 共享 /info 调用
|
||||
* 入参: force=true 跳过缓存 (用于用户切换站点/账号后强制刷新)
|
||||
*/
|
||||
async getChatUiConfig(force) {
|
||||
const nowTs = Date.now();
|
||||
if (!force && _chatUiConfigCache && (nowTs - _chatUiConfigCacheAt) < SERVICE_CONFIG_CACHE_TTL) {
|
||||
return _chatUiConfigCache;
|
||||
}
|
||||
try {
|
||||
// 复用 getServiceConfig() 触发 /info; 它的缓存会带回 chat_ui_config 字段
|
||||
// 若 service_config 缓存未命中, getServiceConfig() 内部会发请求并填充 _serviceConfigCache
|
||||
const serviceData = await this.getServiceConfig();
|
||||
const remote = (serviceData && serviceData.chat_ui_config) || null;
|
||||
const merged = mergeChatUiConfig(remote);
|
||||
_chatUiConfigCache = merged;
|
||||
_chatUiConfigCacheAt = Date.now();
|
||||
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时
|
||||
// getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁)
|
||||
try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ }
|
||||
return merged;
|
||||
} catch (e) {
|
||||
console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e);
|
||||
// 失败也要返回可用配置 (前端默认), 但不写缓存 — 下次重试
|
||||
return Object.assign({}, CHAT_UI_DEFAULTS);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存)
|
||||
* 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用,
|
||||
* 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。
|
||||
* 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过)
|
||||
* 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新
|
||||
*/
|
||||
getChatUiConfigSync() {
|
||||
try {
|
||||
const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG');
|
||||
if (cached && typeof cached === 'object' && Object.keys(cached).length) {
|
||||
return mergeChatUiConfig(cached);
|
||||
}
|
||||
} catch (e) { /* 存储读取失败按无缓存处理 */ }
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除会话历史(调用后端接口)
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
*/
|
||||
async clearConversation(conversationId) {
|
||||
try {
|
||||
// PR-AI-CLEAR-CONV-FIX (2026-09-19): URL 必须用 camelCase
|
||||
// ThinkPHP 后端方法名 = clearConversation, URL 不做 kebab→camel 转换
|
||||
// 之前 'clear-conversation' 拼出 /api/kefu/clear-conversation → 404 method not exists
|
||||
// 改 'clearConversation' → /api/kefu/clearConversation → 200
|
||||
const response = await http.sendRequest({
|
||||
url: resolveApiUrl('clear-conversation'),
|
||||
url: resolveApiUrl('clearConversation'),
|
||||
data: {
|
||||
agent_id: getAgentId(),
|
||||
conversation_id: conversationId,
|
||||
@@ -661,6 +1069,69 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项
|
||||
* @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers }
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||
try {
|
||||
await ensureWebSocketConnected();
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message);
|
||||
return false;
|
||||
}
|
||||
wsSocketTask.send({
|
||||
data: JSON.stringify({
|
||||
action: 'elicit_response',
|
||||
conversation_id,
|
||||
message_id,
|
||||
tool_call_id,
|
||||
rpc_id,
|
||||
answers,
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || 'test_token'
|
||||
})
|
||||
});
|
||||
return true;
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
// PR-ELICIT-RESPONSE-HTTP (2026-09-18, 修 2026-09-19): H5 端改 fetch POST 新端点
|
||||
// 后端 InitRoute 注册的是 addon 模式 URL /{addon}/api/chat/elicit-response
|
||||
// (aikefu/api/... 或 aiagent/api/...),不能用 resolveApiUrl 因为它走 /api/{short}/...
|
||||
// 会拆出 module=api, controller=kefu/aiagent, action=chat → 命中 chat() 而非 elicitResponse()
|
||||
// (PR-WB-MP-MEDIA 注释里记录的同款 thinkphp 路由陷阱)
|
||||
// 必须用 resolveAddonName() 拿 addon 全名直接拼 URL
|
||||
try {
|
||||
const addon = resolveAddonName(currentAgentId); // aikefu / aiagent
|
||||
const url = Config.baseUrl + '/' + addon + '/api/chat/elicit-response';
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Token': store.state.token || 'test_token'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation_id,
|
||||
message_id,
|
||||
tool_call_id,
|
||||
rpc_id,
|
||||
answers,
|
||||
uniacid: this.getUniacid(),
|
||||
user_id: getUserId(),
|
||||
token: store.state.token || 'test_token'
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
return !!(data && (data.code === 0 || data.success));
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
|
||||
return false;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取会话历史
|
||||
* PR-7: URL 按 agent_id 路由
|
||||
@@ -805,15 +1276,73 @@ export default {
|
||||
/**
|
||||
* Set current AI Agent (default aikefu; link system passes agent_id).
|
||||
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
|
||||
*
|
||||
* PR-AGENT-LIFECYCLE-CLEANUP (2026-09-19): agent 真的"变了"(从 A → B) 时
|
||||
* 主动清当前会话 ID 缓存, 不展示陈旧会话 → "无痛苦无疑惑"
|
||||
* - 后端 L2 会兜底: 老 agent 被删 → 静默开新会话 (前端什么都不用做也对)
|
||||
* - 但前端主动清更安全: 即便后端没回 404, 用户也不看到"我换 agent 了为什么还是上次的对话"
|
||||
*
|
||||
* @param {string} agentId
|
||||
* @param {object} opts { silent: bool } true = 不回调, 默认 false
|
||||
*/
|
||||
setAgentId(agentId) {
|
||||
setAgentId(agentId, opts) {
|
||||
var oldAgentId = currentAgentId;
|
||||
var oldAddon = resolveAddonName(currentAgentId);
|
||||
setAgentId(agentId);
|
||||
var newAddon = resolveAddonName(currentAgentId);
|
||||
if (oldAddon !== newAddon) {
|
||||
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
// PR-AGENT-LIFECYCLE-CLEANUP: agent 字符串真变化 (A → B) → 清 conv_id 缓存
|
||||
// 不变化 (aikefu → aikefu 同 string) → 保留缓存, 让用户继续之前的多轮对话
|
||||
var agentChanged = oldAgentId && oldAgentId !== agentId;
|
||||
currentConversationId = null;
|
||||
try {
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved && !agentChanged) currentConversationId = saved;
|
||||
else if (saved && agentChanged) {
|
||||
// agent 变了, 把老 agent 的 conv_id 也清掉, 避免老 user 换个 product page 又用回
|
||||
try {
|
||||
const oldKey = getConversationKey(oldAgentId);
|
||||
if (oldKey) uni.removeStorageSync(oldKey);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前 agent 的会话存储 key (per-agent 隔离)
|
||||
*/
|
||||
getConversationKey(agentId) {
|
||||
return getConversationKey(agentId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选)
|
||||
*/
|
||||
getAllConversations() {
|
||||
const out = {};
|
||||
try {
|
||||
const info = uni.getStorageInfoSync();
|
||||
(info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0)
|
||||
.forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); });
|
||||
} catch (e) { /* ignore */ }
|
||||
return out;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前 config_uuid (媒体上传路由用)
|
||||
* 优先级: info 接口缓存 > 本地配置 (.local.config.js aikefuConfigUuid)
|
||||
*/
|
||||
getCurrentConfigUuid() {
|
||||
return uni.getStorageSync('current_config_uuid') || Config.aikefuConfigUuid || '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户 ID (媒体上传字段用)
|
||||
*/
|
||||
getUserId() {
|
||||
return getUserId();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ if (process?.env?.NODE_ENV === 'development') {
|
||||
}
|
||||
// #endif
|
||||
|
||||
const { uniacid = 0, domain = defaultDomain } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||
const { uniacid = 0, domain = defaultDomain, aikefuConfigUuid = '' } = localDevConfig ?? { uniacid: 0, domain: defaultDomain }
|
||||
|
||||
export default {
|
||||
/**
|
||||
@@ -80,6 +80,10 @@ export default {
|
||||
//api请求地址
|
||||
baseUrl: domain,
|
||||
|
||||
// aikefu 智能客服配置 uuid (媒体上传必填; 后端 /api/kefu/info 目前不返回该值,
|
||||
// 需在 .local.config.js 对应环境配置里手动填写 aikefuConfigUuid: 'xxx')
|
||||
aikefuConfigUuid: aikefuConfigUuid,
|
||||
|
||||
// 图片域名
|
||||
imgDomain: domain,
|
||||
|
||||
|
||||
125
common/js/diy.js
125
common/js/diy.js
@@ -2,6 +2,7 @@ import WxMap from 'common/js/map-wx-jssdk.js';
|
||||
import Config from '@/common/js/config.js';
|
||||
import util from '@/common/js/util.js';
|
||||
import { classifyHttpError, httpErrorKey } from '@/common/js/http-error.js';
|
||||
import { consumeNeedStartupAd, isColdLaunchPending } from '@/common/js/startup-ad.js';
|
||||
|
||||
let systemInfo = util.getDeviceInfo();
|
||||
|
||||
@@ -297,25 +298,84 @@ export default {
|
||||
data: {},
|
||||
async: false
|
||||
});
|
||||
this.adv = res.value
|
||||
// 弹框形式,首次弹出 1,每次弹出 0
|
||||
// 接口把启动广告配置放在 data 字段(少数环境也可能放在 value), 两者兼容,
|
||||
// 避免取到 undefined 后模板 v-if 抛错导致 uni-popup 不渲染、真机广告弹不出来
|
||||
let advData = (res && (res.data != null ? res.data : res.value)) || {};
|
||||
this.adv = advData;
|
||||
// 后台配置: advshow=1 开启启动广告; advtype 决定弹出策略
|
||||
// advtype == 1: 首次弹出 —— 仅第一次进入小程序弹出(持久化标记, 清缓存后可再弹)
|
||||
// advtype == 0: 每次弹出 —— 每次"重新进入小程序"都弹出(不含 Tab 切换回首页)
|
||||
if (this.adv.advshow == 1) {
|
||||
setTimeout(() => {
|
||||
if (res.value.advtype == 1) {
|
||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||
if ((this.$refs.uniPopupWindow && popwindow_count == '') || (
|
||||
this.$refs.uniPopupWindow && popwindow_count == 1)) {
|
||||
setTimeout(async () => {
|
||||
// 隐私协议未同意时, 先等用户点击隐私协议(同意/拒绝)关闭弹窗,
|
||||
// 否则广告弹窗会被隐私弹窗(z-index:5000)盖住, 真机上表现为"点了隐私协议后广告不弹"
|
||||
await this._waitPrivacyResolved();
|
||||
if (!this.$refs.uniPopupWindow) return;
|
||||
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
// 先判定本次是否需要弹出, 避免无谓的图片预加载
|
||||
let shouldOpen = false;
|
||||
let isFirstType = false;
|
||||
if (this.adv.advtype == 1) {
|
||||
// 首次弹出: 从未展示过才弹
|
||||
var popwindow_count = uni.getStorageSync(this.id + this.name + '_popwindow_count');
|
||||
if (popwindow_count == '' || popwindow_count == 0) {
|
||||
shouldOpen = true;
|
||||
isFirstType = true;
|
||||
}
|
||||
} else if (res.value.advtype == 0) {
|
||||
this.$refs.uniPopupWindow.open();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 0);
|
||||
} else {
|
||||
// 每次弹出: 冷启动首次 或 真正"重新进入小程序"(App 前后台切换)时弹出,
|
||||
// 由 App.onShow 在重新进入时置位的信号驱动, 切 Tab 不会触发 App.onShow 故不弹
|
||||
if (consumeNeedStartupAd() || isColdLaunchPending()) {
|
||||
shouldOpen = true;
|
||||
}
|
||||
}
|
||||
if (!shouldOpen) return;
|
||||
|
||||
// 关键: 广告图片是网络图, 而 uni-popup 用 v-if 在 open() 时才渲染 slot,
|
||||
// 图片要等弹窗打开后才开始请求/解码, 真机网络慢时就会"一截一截"逐步显示。
|
||||
// 先把所有广告图片预加载进本地缓存, 全部就绪后再 open, 使弹窗一次性完整出现。
|
||||
await this._preloadAdvImages(this.adv.list);
|
||||
if (!this.$refs.uniPopupWindow) return;
|
||||
|
||||
this.$refs.uniPopupWindow.open();
|
||||
if (isFirstType) {
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', 1);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
},
|
||||
/**
|
||||
* 等待隐私协议流程结束后再弹启动广告
|
||||
* - 鸿蒙元服务: 隐私由系统托管, 应用层不弹自建弹窗, 直接放行
|
||||
* - 已同意过(storage privacyAgreed): 无需等待, 直接放行
|
||||
* - 未同意: 监听 PrivacyManager 广播的 privacy:agreed / privacy:disagreed,
|
||||
* 用户点击隐私协议(同意/拒绝)关闭弹窗后放行
|
||||
* - 兜底超时: 隐私协议因规则未弹等情况永不 emit 时也要弹广告, 避免卡死
|
||||
* @param {number} [maxWait=15000]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_waitPrivacyResolved(maxWait = 15000) {
|
||||
// 鸿蒙元服务: 隐私协议由系统托管, 应用层不弹自建隐私弹窗, 无需等待
|
||||
// #ifdef MP-HARMONY
|
||||
return Promise.resolve();
|
||||
// #endif
|
||||
// 已经同意过 → 隐私弹窗不会再出现, 直接放行
|
||||
if (uni.getStorageSync('privacyAgreed')) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
uni.$off('privacy:agreed', finish);
|
||||
uni.$off('privacy:disagreed', finish);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
uni.$on('privacy:agreed', finish);
|
||||
uni.$on('privacy:disagreed', finish);
|
||||
// 兜底: 隐私协议因规则未弹 / 一直未处理时, 超时后也要弹广告
|
||||
const timer = setTimeout(finish, maxWait);
|
||||
});
|
||||
},
|
||||
// 无法从网络获取 diy 数据时:在页面内渲染错误横幅(各端可靠、一定可见)
|
||||
// 同时通过 uni.$emit 通知 splash-state 关闭启动屏 (否则 strict 模式永远不关)
|
||||
@@ -542,9 +602,46 @@ export default {
|
||||
console.warn('[DIY][getDiyInfo] no value in res.data', JSON.stringify(diyDataValue).slice(0, 200));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 预加载启动广告图片
|
||||
* 广告弹窗由 uni-popup 在 open() 时才用 v-if 渲染内容, 图片是网络图,
|
||||
* 若等到弹窗打开后才开始加载, 真机网络慢时会出现"一截一截"逐步显示。
|
||||
* 这里提前把图片下载进本地缓存, 等全部加载完成(或超时)后再弹出,
|
||||
* 使弹窗一次性完整呈现。
|
||||
* @param {Array} list - adv.list, 元素形如 { imageUrl, link }
|
||||
* @param {number} [timeout=4000] - 单张兜底超时, 避免个别图片卡住导致广告永不弹出
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_preloadAdvImages(list, timeout = 4000) {
|
||||
if (!list || !list.length) return Promise.resolve();
|
||||
const tasks = list.map((item) => {
|
||||
return new Promise((resolve) => {
|
||||
const url = (item && item.imageUrl) ? this.$util.img(item.imageUrl) : '';
|
||||
if (!url) return resolve();
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(finish, timeout);
|
||||
try {
|
||||
// getImageInfo 会下载图片并写入本地缓存, 之后 image 组件可直接命中缓存
|
||||
uni.getImageInfo({
|
||||
src: url,
|
||||
success: finish,
|
||||
fail: finish
|
||||
});
|
||||
} catch (e) {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
});
|
||||
return Promise.all(tasks).then(() => {});
|
||||
},
|
||||
closePopupWindow() {
|
||||
this.$refs.uniPopupWindow.close();
|
||||
uni.setStorageSync(this.id + this.name + '_popwindow_count', -1);
|
||||
},
|
||||
closeCollectPopupWindow() {
|
||||
this.$refs.collectPopupWindow.close();
|
||||
|
||||
@@ -68,6 +68,10 @@ export default {
|
||||
|
||||
hover_nav_bg_color: '#FFFC',
|
||||
hover_nav_text_color: '#000',
|
||||
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||
hover_nav_phone_font_size: '36rpx', // 电话按钮内图标字号基准
|
||||
|
||||
// ===== DIY 分类页字号 token =====
|
||||
diy_category_tree_select_size: '28rpx',
|
||||
|
||||
134
common/js/floating-button.js
Normal file
134
common/js/floating-button.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 浮动侧边栏配置消费 (字段来源: /api/config/init → data.floating_button)
|
||||
* 纯函数模块, 无副作用, 可直接单测
|
||||
*/
|
||||
|
||||
/** 归一化路由: 'pages/index/index' → '/pages/index/index' */
|
||||
export function normalizeRoute(route) {
|
||||
if (!route) return '';
|
||||
return String(route).charAt(0) === '/' ? String(route) : '/' + String(route);
|
||||
}
|
||||
|
||||
/** 取当前页面路由 (兼容 H5 / 小程序; route 已含分包 root, 如 'pages_goods/detail') */
|
||||
export function getCurrentRoute() {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
const cur = pages[pages.length - 1];
|
||||
return normalizeRoute((cur && (cur.route || (cur.$page && cur.$page.path))) || '');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前缀命中判定
|
||||
* '/pages/index' 命中 '/pages/index' 与 '/pages/index/xxx'
|
||||
* '/pages_tool/' 命中 '/pages_tool/xxx' (前缀自带尾 /)
|
||||
* '/pages/order/payment' 只命中它自己及其子路径
|
||||
*/
|
||||
export function hitPrefix(route, prefix) {
|
||||
if (!route || !prefix) return false;
|
||||
const p = prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/';
|
||||
return route === prefix || route.indexOf(p) === 0;
|
||||
}
|
||||
|
||||
/** 任一前缀命中 */
|
||||
function hitAny(route, prefixes) {
|
||||
if (!Array.isArray(prefixes)) return false;
|
||||
for (let i = 0; i < prefixes.length; i++) {
|
||||
if (hitPrefix(route, prefixes[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 找出路由命中的页面分组 key (未命中返回 null) */
|
||||
export function matchPageGroup(route, prefixMap) {
|
||||
if (!prefixMap) return null;
|
||||
const keys = Object.keys(prefixMap);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (hitAny(normalizeRoute(route), prefixMap[keys[i]])) return keys[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏在当前页面是否显示 —— 唯一判定入口
|
||||
*
|
||||
* 优先级: 总开关 → 强制隐藏 → 显示范围
|
||||
* 1) fb 不存在 (老缓存响应) → fallbackVisible (默认 true, 保持老行为)
|
||||
* 2) enabled != 1 → false
|
||||
* 3) 命中 force_hidden_prefixes 任意前缀 → false (最高优先级)
|
||||
* 4) 命中 custom_pages / page_prefixes._custom → true
|
||||
* 5) 命中 pages[组] == 1 → true
|
||||
* 6) 都不命中 (如 /pages_goods/*) → false
|
||||
*/
|
||||
export function shouldShowOnPage(fb, route, fallbackVisible) {
|
||||
if (!fb || typeof fb !== 'object') return fallbackVisible !== false;
|
||||
if (Number(fb.enabled) !== 1) return false;
|
||||
|
||||
const r = normalizeRoute(route);
|
||||
if (!r) return false;
|
||||
|
||||
// 强制隐藏 — 优先级最高
|
||||
if (matchPageGroup(r, fb.force_hidden_prefixes)) return false;
|
||||
|
||||
// 自定义前缀优先判定
|
||||
if (hitAny(r, fb.custom_pages) || hitAny(r, (fb.page_prefixes || {})['_custom'])) return true;
|
||||
|
||||
// 预设分组
|
||||
const group = matchPageGroup(r, fb.page_prefixes);
|
||||
if (group) return Number((fb.pages || {})[group]) === 1;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前页面要渲染的按钮列表 (已按 button_order 排序 + 显隐过滤)
|
||||
* 返回 ['lang','kefu','phone'] 的子集, 空数组 = 整个侧边栏不渲染
|
||||
* 返回 null = 配置缺失, 由调用方决定老行为
|
||||
*/
|
||||
export function resolveVisibleButtons(fb) {
|
||||
if (!fb || typeof fb !== 'object') return null;
|
||||
const order = Array.isArray(fb.button_order) ? fb.button_order : ['lang', 'kefu', 'phone'];
|
||||
const map = fb.buttons || {};
|
||||
const out = [];
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const k = order[i];
|
||||
if (Number(map[k]) === 1) out.push(k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言按钮显示文案
|
||||
* @param {Object} fb data.floating_button
|
||||
* @param {Array} langList [{ value:'zh-cn', name:'CN' }, ...]
|
||||
* @param {Number} currentIdx 当前语言下标
|
||||
*/
|
||||
export function resolveLangButtonText(fb, langList, currentIdx) {
|
||||
const list = Array.isArray(langList) ? langList : [];
|
||||
if (!list.length) return '';
|
||||
const style = (fb && fb.lang_switch && fb.lang_switch.style) || 'target';
|
||||
|
||||
if (style === 'text') {
|
||||
const t = (fb && fb.lang_switch && fb.lang_switch.text) || '';
|
||||
if (t) return t; // 空文案兜底走 target
|
||||
}
|
||||
if (style === 'current' || list.length < 2) {
|
||||
return (list[currentIdx] && list[currentIdx].name) || '';
|
||||
}
|
||||
// target: 显示「点下去会切到的那个」语言
|
||||
// 2 种语言 → 另一个; >2 种 → 取第一个不是当前的那个
|
||||
let targetIdx = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (i !== currentIdx) { targetIdx = i; break; }
|
||||
}
|
||||
return list[targetIdx] ? (list[targetIdx].name || '') : '';
|
||||
}
|
||||
|
||||
/** 语言切换按钮是否可见 (含老「双语模式」fallback) */
|
||||
export function isLangButtonVisible(fb, shopInfo, langCount) {
|
||||
if (Number(langCount) < 2) return false;
|
||||
if (fb && fb.lang_switch) return Number(fb.lang_switch.enabled) === 1;
|
||||
return !!(shopInfo && shopInfo.ischina); // 老行为兜底
|
||||
}
|
||||
@@ -629,16 +629,16 @@ function loadLangPackSync(lang, path) {
|
||||
* @returns
|
||||
*/
|
||||
/**
|
||||
* 语言切换开关钩子(由后台 shop_info.ischia 决定,经 store/index.js 的 setShopInfo 写入)。
|
||||
* 语言切换开关钩子(由后台 shop_info.ischina 决定,经 store/index.js 的 setShopInfo 写入)。
|
||||
*
|
||||
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischia
|
||||
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischina
|
||||
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
|
||||
*
|
||||
* 行为:
|
||||
* - 后台关闭双语/小程序(ischia 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
||||
* - 后台关闭双语/小程序(ischina 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
||||
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
|
||||
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
|
||||
* - 后台开启双语(ischia 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
||||
* - 后台开启双语(ischina 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
||||
*/
|
||||
function setLangSwitchEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
@@ -994,7 +994,7 @@ export default {
|
||||
getCurrentLocale,
|
||||
|
||||
/**
|
||||
* 语言切换功能开关(后台 shop_info.ischia 决定)。
|
||||
* 语言切换功能开关(后台 shop_info.ischina 决定)。
|
||||
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
||||
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
||||
*/
|
||||
|
||||
@@ -24,22 +24,67 @@ import Config from './config.js'
|
||||
import store from '@/store/index.js'
|
||||
|
||||
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
|
||||
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
|
||||
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
|
||||
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
|
||||
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
|
||||
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
|
||||
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
|
||||
const EXT_MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
|
||||
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
||||
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
||||
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
|
||||
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
||||
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
||||
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
||||
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
|
||||
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
|
||||
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
|
||||
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip', rar: 'application/x-rar-compressed',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
|
||||
pdf: 'application/pdf', txt: 'text/plain',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片 (type=image)
|
||||
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
|
||||
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
|
||||
*/
|
||||
export async function uploadImage(filePath) {
|
||||
export async function uploadImage(filePath, opts = {}) {
|
||||
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
|
||||
const config = await aiService.getServiceConfig()
|
||||
const strategy = config?.upload_strategy || 'backend'
|
||||
|
||||
// H5 端: >10MB 先压
|
||||
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
|
||||
let compressedPath = filePath
|
||||
// #ifdef H5
|
||||
const fileSize = await getFileSize(filePath)
|
||||
if (fileSize > 10 * 1024 * 1024) {
|
||||
let detectedSize = 0
|
||||
try {
|
||||
detectedSize = await getFileSize(filePath)
|
||||
} catch (e) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
|
||||
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
|
||||
console.warn('[media-upload] getFileSize failed:', e)
|
||||
}
|
||||
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
|
||||
// #ifdef H5 || MP-WEIXIN
|
||||
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
|
||||
try {
|
||||
const compressed = await uni.compressImage({
|
||||
src: filePath,
|
||||
@@ -48,6 +93,7 @@ export async function uploadImage(filePath) {
|
||||
compressedHeight: 2048,
|
||||
})
|
||||
compressedPath = compressed.tempFilePath || filePath
|
||||
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] compressImage failed, use original:', e)
|
||||
}
|
||||
@@ -55,29 +101,108 @@ export async function uploadImage(filePath) {
|
||||
// #endif
|
||||
|
||||
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
|
||||
const fileSizeFinal = await getFileSize(compressedPath)
|
||||
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
|
||||
// fileSizeFinal = 探测失败的兜底:
|
||||
// - 第一次探测失败(原始大小未知) → 保守走 STS
|
||||
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
|
||||
let fileSizeFinal = detectedSize
|
||||
if (compressedPath !== filePath) {
|
||||
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
|
||||
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
|
||||
fileSizeFinal = detectedSize
|
||||
}
|
||||
}
|
||||
const useSts = strategy === 'oss_sts'
|
||||
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
||||
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
|
||||
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
|
||||
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
|
||||
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
|
||||
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
|
||||
|
||||
if (useSts) {
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config)
|
||||
} else {
|
||||
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId())
|
||||
if (finalUseSts) {
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||
}
|
||||
try {
|
||||
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
|
||||
} catch (e) {
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
|
||||
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
|
||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB)
|
||||
* 上传 audio / video / file
|
||||
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
||||
* 未配云存储 (backend) → 直接 PHP 中转
|
||||
* @param {string} filePath
|
||||
* @param {'audio'|'video'|'file'} kind
|
||||
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
||||
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
||||
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
||||
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
||||
*/
|
||||
export async function uploadMedia(filePath, kind) {
|
||||
export async function uploadMedia(filePath, kind, opts = {}) {
|
||||
if (!['audio', 'video', 'file'].includes(kind)) {
|
||||
throw new Error(`invalid kind: ${kind}`)
|
||||
}
|
||||
const addon = aiService.getAddonName()
|
||||
const config = await aiService.getServiceConfig()
|
||||
return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config)
|
||||
const agentId = aiService.getAgentId()
|
||||
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
||||
const size = opts.size || 0
|
||||
const strategy = (config && config.upload_strategy) || 'backend'
|
||||
|
||||
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
||||
const doPhpUpload = async (fallbackErr) => {
|
||||
let phpRes = null
|
||||
try {
|
||||
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
||||
} catch (err) {
|
||||
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
||||
}
|
||||
if (!phpRes || !phpRes.url) {
|
||||
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
||||
}
|
||||
return Object.assign({}, phpRes, { kind })
|
||||
}
|
||||
|
||||
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
||||
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
||||
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
||||
if (strategy === 'backend') {
|
||||
try {
|
||||
return await doPhpUpload()
|
||||
} catch (e) {
|
||||
if (e && e.code === 'OSS_STS_REQUIRED') {
|
||||
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
||||
} catch (e) {
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
||||
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
|
||||
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
|
||||
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
||||
try {
|
||||
return await doPhpUpload(e)
|
||||
} catch (e2) {
|
||||
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
||||
throw e2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +212,7 @@ export async function uploadMedia(filePath, kind) {
|
||||
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
|
||||
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
|
||||
*/
|
||||
async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
|
||||
+ (kind === 'image' ? 'image' : 'file')
|
||||
|
||||
@@ -99,8 +224,10 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
name: 'file',
|
||||
formData: {
|
||||
kind,
|
||||
agent_id: addon === 'aiagent' ? agentId : undefined,
|
||||
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
||||
agent_id: agentId || undefined,
|
||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
uniacid: aiService.getUniacid(),
|
||||
user_id: aiService.getUserId?.() || '',
|
||||
},
|
||||
@@ -111,14 +238,18 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
resolve({
|
||||
url: data.data.url,
|
||||
mime: data.data.mime,
|
||||
size: data.data.size,
|
||||
size: data.data.size || knownSize || 0,
|
||||
name: data.data.name,
|
||||
kind: data.data.kind || kind,
|
||||
used_strategy: data.data.used_strategy || 'backend',
|
||||
oss_sts_required: data.data.oss_sts_required || false,
|
||||
})
|
||||
} else {
|
||||
reject(new Error(data.msg || data.message || '上传失败'))
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
|
||||
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
|
||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
|
||||
reject(err)
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('解析响应失败: ' + e.message))
|
||||
@@ -135,7 +266,13 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
form.append('file', file)
|
||||
form.append('kind', kind)
|
||||
if (addon === 'aiagent') form.append('agent_id', agentId)
|
||||
else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '')
|
||||
else {
|
||||
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
||||
const uuid = aiService.getCurrentConfigUuid?.() || ''
|
||||
form.append('config_uuid', uuid)
|
||||
form.append('config_uid', uuid)
|
||||
form.append('agent_id', agentId)
|
||||
}
|
||||
form.append('uniacid', aiService.getUniacid())
|
||||
form.append('user_id', aiService.getUserId?.() || '')
|
||||
const res = await fetch(Config.baseUrl + url, {
|
||||
@@ -148,13 +285,19 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
return {
|
||||
url: data.data.url,
|
||||
mime: data.data.mime,
|
||||
size: data.data.size,
|
||||
size: data.data.size || knownSize || 0,
|
||||
name: data.data.name,
|
||||
kind: data.data.kind || kind,
|
||||
used_strategy: data.data.used_strategy || 'backend',
|
||||
oss_sts_required: data.data.oss_sts_required || false,
|
||||
}
|
||||
}
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
|
||||
if (data.oss_sts_required === true) {
|
||||
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
||||
err.code = 'OSS_STS_REQUIRED'
|
||||
throw err
|
||||
}
|
||||
throw new Error(data.msg || data.message || '上传失败')
|
||||
// #endif
|
||||
}
|
||||
@@ -173,10 +316,200 @@ async function uploadViaPhp(filePath, kind, addon, agentId) {
|
||||
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
|
||||
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
|
||||
*/
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
/**
|
||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
|
||||
* 1) filePath 最后一段正则匹配 .xxx
|
||||
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
|
||||
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
|
||||
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
|
||||
*
|
||||
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
||||
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
||||
*/
|
||||
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||
let ext = 'bin'
|
||||
if (typeof filePath === 'string') {
|
||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
|
||||
if (m) ext = m[1].toLowerCase()
|
||||
}
|
||||
if (ext === 'bin' && filePath && filePath.name) {
|
||||
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
||||
if (m2) ext = m2[1].toLowerCase()
|
||||
}
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
||||
if (ext === 'bin' && nameHint) {
|
||||
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
||||
if (m3) ext = m3[1].toLowerCase()
|
||||
}
|
||||
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
||||
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
||||
if (ext === 'bin' && mimeHint) {
|
||||
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
|
||||
if (m === mimeHint) { ext = e; break }
|
||||
}
|
||||
}
|
||||
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
|
||||
return ext
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
|
||||
*
|
||||
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
|
||||
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
|
||||
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
|
||||
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
|
||||
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
|
||||
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
|
||||
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
|
||||
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
|
||||
*
|
||||
* 端点: POST /<addon>/api/upload/ossPresign
|
||||
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
|
||||
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
|
||||
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
|
||||
*/
|
||||
/**
|
||||
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
|
||||
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
|
||||
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
|
||||
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
|
||||
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
|
||||
*/
|
||||
function uniRequest(opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request(Object.assign({}, opts, {
|
||||
success: resolve,
|
||||
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
|
||||
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
|
||||
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
|
||||
let mimeHint = hints.mime || ''
|
||||
let nameHint = hints.name || ''
|
||||
let probeFile = null
|
||||
// #ifdef H5
|
||||
try {
|
||||
probeFile = await pathToFile(filePath, kind)
|
||||
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
|
||||
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
|
||||
} catch (e) {
|
||||
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
|
||||
console.warn('[media-upload] presign probe failed:', e && e.message)
|
||||
}
|
||||
// #endif
|
||||
const ext = extractExt(filePath, mimeHint, nameHint)
|
||||
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
|
||||
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
|
||||
const contentType = mimeHint || guessMime(finalExt)
|
||||
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
|
||||
? nameHint
|
||||
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
|
||||
|
||||
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
|
||||
const preResp = await uniRequest({
|
||||
url: Config.baseUrl + preUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'token': store.state.token || ''
|
||||
},
|
||||
data: {
|
||||
filename,
|
||||
content_type: contentType,
|
||||
uniacid: aiService.getUniacid(),
|
||||
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
|
||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
},
|
||||
})
|
||||
if (preResp.statusCode === 404) {
|
||||
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
|
||||
err.code = 'PRESIGN_UNSUPPORTED'
|
||||
throw err
|
||||
}
|
||||
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
|
||||
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
|
||||
}
|
||||
const pre = preResp.data.data
|
||||
const uploadUrl = pre.upload_url
|
||||
const finalContentType = pre.content_type || contentType
|
||||
|
||||
let uploadedSize = knownSize || 0
|
||||
// #ifdef H5
|
||||
if (probeFile && probeFile.size) uploadedSize = probeFile.size
|
||||
// #endif
|
||||
|
||||
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
|
||||
// #ifdef H5
|
||||
const file = probeFile || await pathToFile(filePath, kind)
|
||||
const ossRes = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': finalContentType },
|
||||
body: file,
|
||||
})
|
||||
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
|
||||
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.getFileSystemManager().readFile({
|
||||
filePath,
|
||||
success: (res) => {
|
||||
uni.request({
|
||||
url: uploadUrl,
|
||||
method: 'PUT',
|
||||
header: { 'Content-Type': finalContentType },
|
||||
data: res.data,
|
||||
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
|
||||
? resolve()
|
||||
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
|
||||
fail: reject,
|
||||
})
|
||||
},
|
||||
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
|
||||
return {
|
||||
url: (pre.public_url || uploadUrl.split('?')[0]),
|
||||
mime: finalContentType,
|
||||
size: uploadedSize,
|
||||
name: (pre.object_key || filename).split('/').pop(),
|
||||
kind,
|
||||
used_strategy: 'oss_sts',
|
||||
provider: pre.provider || 'alioss',
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
||||
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
|
||||
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
|
||||
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
|
||||
const providerHint = (config && config.oss_provider) || ''
|
||||
if (!providerHint || providerHint === 'alioss') {
|
||||
try {
|
||||
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
|
||||
} catch (e) {
|
||||
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
|
||||
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
|
||||
}
|
||||
}
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
||||
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
||||
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
||||
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
||||
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
||||
|
||||
// 1. 拿 STS 凭证 (addon 模式 URL)
|
||||
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
||||
const stsResp = await uni.request({
|
||||
const stsResp = await uniRequest({
|
||||
url: Config.baseUrl + stsUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
@@ -184,8 +517,9 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
'token': store.state.token || ''
|
||||
},
|
||||
data: {
|
||||
agent_id: addon === 'aiagent' ? agentId : undefined,
|
||||
agent_id: agentId || undefined,
|
||||
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
||||
uniacid: aiService.getUniacid(),
|
||||
user_id: aiService.getUserId?.() || '',
|
||||
},
|
||||
@@ -197,7 +531,6 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
const provider = sts.provider || 'alioss' // 老后端默认 alioss
|
||||
|
||||
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
|
||||
const ext = (filePath.split('.').pop() || 'bin').toLowerCase()
|
||||
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||||
@@ -232,7 +565,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
} else {
|
||||
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
||||
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
|
||||
const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com`
|
||||
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
|
||||
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
|
||||
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
|
||||
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
|
||||
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
|
||||
const aliossTpl = sts.oss_url
|
||||
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
|
||||
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
|
||||
const tpl = (provider === 'alioss') ? aliossTpl
|
||||
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
|
||||
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
|
||||
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
|
||||
@@ -246,6 +587,16 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
}
|
||||
}
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
|
||||
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
|
||||
let uploadedSize = knownSize || 0
|
||||
// #ifdef H5
|
||||
try {
|
||||
const probe = await pathToFile(filePath, kind)
|
||||
if (probe && probe.size) uploadedSize = probe.size
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
// 3. 直传云存储
|
||||
// #ifdef MP-WEIXIN
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -277,7 +628,7 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
return {
|
||||
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
|
||||
mime: guessMime(ext),
|
||||
size: file.size || 0,
|
||||
size: uploadedSize,
|
||||
name: key.split('/').pop(),
|
||||
kind,
|
||||
used_strategy: 'oss_sts',
|
||||
@@ -286,12 +637,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config) {
|
||||
}
|
||||
|
||||
async function getFileSize(filePath) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
|
||||
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
|
||||
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
|
||||
// #ifdef MP-WEIXIN
|
||||
return new Promise((resolve) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getFileInfo({
|
||||
filePath,
|
||||
success: (res) => resolve(res.size),
|
||||
fail: () => resolve(0),
|
||||
success: (res) => resolve(res.size || 0),
|
||||
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
|
||||
})
|
||||
})
|
||||
// #endif
|
||||
@@ -300,7 +654,9 @@ async function getFileSize(filePath) {
|
||||
const res = await fetch(filePath)
|
||||
const blob = await res.blob()
|
||||
return blob.size
|
||||
} catch (e) { return 0 }
|
||||
} catch (e) {
|
||||
throw new Error('getFileSize fetch failed: ' + e.message)
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
@@ -308,18 +664,105 @@ async function pathToFile(filePath, kind) {
|
||||
// #ifdef H5
|
||||
const res = await fetch(filePath)
|
||||
const blob = await res.blob()
|
||||
return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type })
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
|
||||
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
|
||||
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
|
||||
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
|
||||
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
|
||||
const rawName = filePath.split('/').pop() || `${kind}.bin`
|
||||
const ext = extractExt(filePath, blob.type)
|
||||
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
|
||||
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
|
||||
const mime = blob.type || guessMime(ext)
|
||||
return new File([blob], finalName, { type: mime })
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
|
||||
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
|
||||
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
|
||||
* 而不是路径 ②/③ (URL fetch / resource_link).
|
||||
*
|
||||
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
|
||||
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
|
||||
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
|
||||
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
|
||||
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
|
||||
*
|
||||
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
|
||||
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
|
||||
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
|
||||
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
|
||||
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
|
||||
*
|
||||
* 返回: { data: '<bare-base64>', size, mime } | null
|
||||
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
|
||||
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
|
||||
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
|
||||
*
|
||||
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
|
||||
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
|
||||
*/
|
||||
export async function fileToBase64(filePath, opts = {}) {
|
||||
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
|
||||
if (!filePath) return null
|
||||
|
||||
// #ifdef H5
|
||||
try {
|
||||
const res = await fetch(filePath)
|
||||
if (!res.ok) throw new Error('fetch failed: ' + res.status)
|
||||
const blob = await res.blob()
|
||||
if (blob.size > maxBytes) {
|
||||
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||
return null
|
||||
}
|
||||
const dataUri = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = () => reject(new Error('FileReader failed'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
|
||||
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
|
||||
return { data: m[2], size: blob.size, mime: m[1] }
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] fileToBase64 H5 失败:', e)
|
||||
return null
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
try {
|
||||
const fsm = uni.getFileSystemManager()
|
||||
// 1. 探测大小, 超限直接返回 null
|
||||
let size = 0
|
||||
try {
|
||||
const stat = fsm.statSync(filePath)
|
||||
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
|
||||
size = (stat && (stat.size || stat.fileSize)) || 0
|
||||
} catch (e) {
|
||||
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
|
||||
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
|
||||
}
|
||||
if (size > maxBytes) {
|
||||
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
|
||||
return null
|
||||
}
|
||||
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
|
||||
const data = fsm.readFileSync(filePath, 'base64')
|
||||
return { data, size: size || 0, mime: opts.mimeHint || '' }
|
||||
} catch (e) {
|
||||
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
|
||||
return null
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function guessMime(ext) {
|
||||
const map = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime',
|
||||
pdf: 'application/pdf', txt: 'text/plain',
|
||||
}
|
||||
return map[ext] || 'application/octet-stream'
|
||||
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export default { uploadImage, uploadMedia }
|
||||
30
common/js/startup-ad.js
Normal file
30
common/js/startup-ad.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// 启动广告弹出信号
|
||||
// 用于判定"当前是否需要弹出启动广告":
|
||||
// - 小程序冷启动(首次进入): 由模块级 _coldLaunchedShown 标记判定, 整个 app 进程只记一次
|
||||
// - 小程序重新进入(从后台切回 / 微信"重新进入小程序"菜单):
|
||||
// 由 App.onShow 在"非首次展示"时调用 markNeedStartupAd() 置位
|
||||
// 信号消费一次即清除, 不落地到持久化存储, 因此无需清缓存即可每次重新进入都弹出。
|
||||
// 关键: 切换 Tab 回到首页只触发"页面级 onShow", 不会触发 App.onShow, 也不会重置
|
||||
// 模块级冷启动标记, 因此不会在 Tab 切换时误弹。
|
||||
|
||||
let _needStartupAd = false;
|
||||
let _coldLaunchedShown = false;
|
||||
|
||||
// App 层检测到"重新进入小程序"时调用, 标记本次需要弹出启动广告
|
||||
export function markNeedStartupAd() {
|
||||
_needStartupAd = true;
|
||||
}
|
||||
|
||||
// 页面层读取并消费该信号(消费后自动复位)
|
||||
export function consumeNeedStartupAd() {
|
||||
const v = _needStartupAd;
|
||||
_needStartupAd = false;
|
||||
return v;
|
||||
}
|
||||
|
||||
// 是否为本 app 进程的首次冷启动(只返回一次 true, 之后恒为 false)
|
||||
export function isColdLaunchPending() {
|
||||
if (_coldLaunchedShown) return false;
|
||||
_coldLaunchedShown = true;
|
||||
return true;
|
||||
}
|
||||
@@ -62,7 +62,13 @@ export default {
|
||||
},
|
||||
...{
|
||||
hover_nav_bg_color: '#FFFC', //背景色: 红色 '#c6251b', 白色: '#FFFC'
|
||||
hover_nav_text_color: '#000' // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
||||
hover_nav_text_color: '#000', // 文字颜色: 白色 '#FFFFFF', 黑色: '#000'
|
||||
hover_nav_font_size: '36rpx', // 悬浮按钮内文字/图标字号(后台可自定义, 默认与图标 36rpx 对齐)
|
||||
// ===== 逐按钮字号(与 hover_nav_lang/kefu/phone 的 bg/text/size 同约定, 基准 36rpx) =====
|
||||
// 未单独配置时回退到上面的 hover_nav_font_size(全局旋钮)
|
||||
hover_nav_lang_font_size: '36rpx', // 语言按钮内文字字号基准(文字实际按 0.667 缩放)
|
||||
hover_nav_kefu_font_size: '36rpx', // 客服按钮内图标/emoji字号基准
|
||||
hover_nav_phone_font_size: '36rpx' // 电话按钮内图标字号基准
|
||||
},
|
||||
|
||||
// ===== DIY 分类页字号 token(全 8 preset 一致, 远端可覆写) =====
|
||||
|
||||
@@ -1254,6 +1254,21 @@ export default {
|
||||
link.wap_url = resolvedUrl;
|
||||
}
|
||||
|
||||
// ===== 仅微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页 =====
|
||||
// 命中: page_key=PAGE_FILES_DETAIL / name=FILES_xx / wap_url 指向 files/detail
|
||||
// 且能提取到 files_id。拉取或打开失败时自动降级回原详情页(openDiyFileDirect 内部处理)。
|
||||
// #ifdef MP-WEIXIN
|
||||
if (link && (link.page_key === 'PAGE_FILES_DETAIL'
|
||||
|| (link.name && /^FILES_\d+$/i.test(String(link.name)))
|
||||
|| String(link.wap_url || '').indexOf('files/detail') !== -1)) {
|
||||
const filesId = this._extractFilesIdFromLink(link);
|
||||
if (filesId) {
|
||||
this.openDiyFileDirect(link, filesId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
||||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||||
@@ -1427,6 +1442,123 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从装修"文件"链接中提取 files_id(仅微信小程序直开链路使用)。
|
||||
* 兼容三种位置: 顶层字段(files_id/file_id/id) / page_params(JSON字符串或对象) / wap_url query。
|
||||
* @returns {String} 拿不到返回 ''
|
||||
*/
|
||||
_extractFilesIdFromLink(link) {
|
||||
if (!link) return '';
|
||||
let id = link.files_id || link.file_id || link.id || '';
|
||||
if (!id) {
|
||||
const params = _parsePageParams(link.page_params);
|
||||
id = params.files_id || params.file_id || params.id || '';
|
||||
}
|
||||
if (!id) {
|
||||
const url = String(link.wap_url || '');
|
||||
const qIdx = url.indexOf('?');
|
||||
if (qIdx !== -1) {
|
||||
url.slice(qIdx + 1).split('&').forEach(kv => {
|
||||
if (id) return;
|
||||
const eq = kv.indexOf('=');
|
||||
const k = eq === -1 ? kv : kv.slice(0, eq);
|
||||
if (k === 'files_id' || k === 'file_id' || k === 'id') {
|
||||
id = eq === -1 ? '' : kv.slice(eq + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return id === '' || id === undefined || id === null ? '' : String(id);
|
||||
},
|
||||
/**
|
||||
* 微信小程序: 装修"文件"链接直开, 不进 files/detail 中间页。
|
||||
* 链路: /api/files/getfile(files_url 已由后端 img() 补全域名)
|
||||
* → downloadFile → saveFile 用文件名重命名 → openDocument(原生 PDF 阅读器)
|
||||
* 任一环节失败 → 降级跳原详情页(files/detail), 保持旧体验兜底。
|
||||
* @param {Object} link 装修链接对象(用于取标题)
|
||||
* @param {String} filesId 文件 id
|
||||
*/
|
||||
openDiyFileDirect(link, filesId) {
|
||||
const fallback = () => {
|
||||
this.redirectTo(PAGE_KEY_MAP.PAGE_FILES_DETAIL, {
|
||||
files_id: filesId,
|
||||
title: link.title || '',
|
||||
enable_set_title: link.enable_set_title ? 1 : 0,
|
||||
page_params: Object.assign({}, _parsePageParams(link.page_params), { files_id: filesId })
|
||||
});
|
||||
};
|
||||
const safeTitle = String(link.title || '').replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
Http.sendRequest({
|
||||
url: '/api/files/getfile',
|
||||
data: { files_id: filesId },
|
||||
noRetry: true,
|
||||
silentError: true,
|
||||
success: res => {
|
||||
if (!res || res.code !== 0 || !res.data || !res.data.files_url) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
let url = String(res.data.files_url);
|
||||
// 相对路径补全域名($util.img 对含逗号路径会按多图截断, 规避)
|
||||
if (!/^https?:\/\//i.test(url) && url.indexOf(',') === -1) {
|
||||
url = this.img(url);
|
||||
}
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
const rawTitle = String(res.data.files_title || safeTitle || 'file');
|
||||
const title = rawTitle.replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
const matched = url.split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
|
||||
const ext = matched ? matched[1].toLowerCase() : 'pdf';
|
||||
|
||||
uni.showLoading({ title: Lang.lang('files.list.loading') || 'Loading', mask: true });
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: dres => {
|
||||
if (dres.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
const fs = uni.getFileSystemManager();
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
fs.saveFile({
|
||||
tempFilePath: dres.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: r.savedFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => fallback(),
|
||||
complete: () => {
|
||||
try { fs.removeSavedFile({ filePath: r.savedFilePath }); } catch (e) { }
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等), 直接用临时文件打开兜底
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: dres.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => fallback()
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
fallback();
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: () => fallback()
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 获取默认图
|
||||
*/
|
||||
|
||||
819
common/js/voice-recorder.js
Normal file
819
common/js/voice-recorder.js
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* voice-recorder.js — 跨端录音抽象层
|
||||
*
|
||||
* PR-FRONTEND-VOICE-INPUT (2026-09-24)
|
||||
* 统一 H5 / 微信小程序 / App-Plus / 鸿蒙元服务 四端的录音接口,
|
||||
* 把麦克风采集 + 振幅事件 + 自动停止 + 资源回收 收敛到一处。
|
||||
*
|
||||
* PR-FRONTEND-VOICE-WAV (2026-09-25)
|
||||
* H5 分支从 MediaRecorder (产出 webm/opus) 改为 AudioContext + ScriptProcessor
|
||||
* 采原始 PCM, 停止时手动封装成标准 WAV (PCM 16-bit LE mono, 16kHz).
|
||||
* 原因: Chrome MediaRecorder 只支持 webm/ogg 容器, 不支持 wav/mp3 录制;
|
||||
* 大多数 STT / 后端 Agent 框架无法解析 webm/opus.
|
||||
* 产 wav 后所有 STT 服务 (Whisper / 达摩院 / 腾讯 ASR / ...) 都吃.
|
||||
*
|
||||
* 平台分支:
|
||||
* - H5 : navigator.mediaDevices.getUserMedia → AudioContext
|
||||
* → ScriptProcessor 采 PCM Float32 → 停止时封装 WAV.
|
||||
* 振幅采样走 AnalyserNode (与采 PCM 并行, 不冲突).
|
||||
* - MP / APP / 鸿蒙: uni.getRecorderManager()(uni-app 鸿蒙 runtime 已映射)
|
||||
* 走 onFrameRecorded 推 progress 事件.
|
||||
* uni-app format 参数可指定 wav/mp3/aac.
|
||||
*
|
||||
* 统一事件:
|
||||
* progress → { level: 0..1, duration: ms } 持续采样, 用于驱动波形动画
|
||||
* error → Error 录音失败
|
||||
*
|
||||
* 统一结果 (stop() 解析):
|
||||
* {
|
||||
* filePath?: string, // MP/APP/鸿蒙: tempFilePath; H5: blob URL
|
||||
* blob?: Blob, // H5: WAV Blob (供 FileReader / uploadMedia 二次处理)
|
||||
* fileName?: string, // H5: voice_<ts>.wav; MP 按 uni 约定
|
||||
* duration: number, // ms
|
||||
* mime: string, // 录音产物 MIME: H5='audio/wav', MP/APP/鸿蒙按 format
|
||||
* size: number, // bytes
|
||||
* cancelled: false
|
||||
* }
|
||||
*
|
||||
* 取消时 resolve(null);调用方拿到 null 时不要 push 到 pendingFiles。
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_DURATION_MS = 60 * 1000
|
||||
|
||||
// PR-FRONTEND-VOICE-SINGLETON (2026-09-29): uni.getRecorderManager() / wx.getRecorderManager()
|
||||
// 是全局单例, 且微信内部状态极易因上一次未完全停止而残留, 导致下一次 start() 报
|
||||
// "operateRecorder:fail:audio is recording, don't start"。
|
||||
// 这里用模块级 Promise 把所有 start/stop 串行化, 确保前一个实例 onStop/onError 完成后
|
||||
// 才允许下一个实例 start()。
|
||||
let _singletonIdlePromise = Promise.resolve()
|
||||
let _singletonIdleResolver = null
|
||||
let _singletonRec = null
|
||||
let _singletonBusyInstance = null
|
||||
|
||||
function _getSingletonRecorder() {
|
||||
if (!_singletonRec) {
|
||||
_singletonRec = uni.getRecorderManager()
|
||||
}
|
||||
return _singletonRec
|
||||
}
|
||||
|
||||
let _singletonIdleTimer = null
|
||||
|
||||
function _setSingletonBusy(instance, maxDurationMs = DEFAULT_MAX_DURATION_MS) {
|
||||
if (_singletonIdleTimer) {
|
||||
clearTimeout(_singletonIdleTimer)
|
||||
_singletonIdleTimer = null
|
||||
}
|
||||
if (_singletonIdleResolver) {
|
||||
// 已经有未释放的 busy promise, 理论上不应发生; 先强制释放避免死锁
|
||||
_singletonIdleResolver()
|
||||
_singletonIdleResolver = null
|
||||
}
|
||||
_singletonIdlePromise = new Promise((resolve) => {
|
||||
_singletonIdleResolver = resolve
|
||||
})
|
||||
_singletonBusyInstance = instance
|
||||
// 保险: 即使 onStop/onError 因异常没触发, 最多等 10s 也要释放,
|
||||
// 避免死锁导致后续所有 start() 永远挂起。
|
||||
_singletonIdleTimer = setTimeout(() => {
|
||||
console.warn('[voice-recorder:singleton] idle timeout, force release')
|
||||
_setSingletonIdle(instance)
|
||||
}, Math.min(maxDurationMs + 5000, 10000))
|
||||
}
|
||||
|
||||
function _setSingletonIdle(instance) {
|
||||
if (_singletonIdleTimer) {
|
||||
clearTimeout(_singletonIdleTimer)
|
||||
_singletonIdleTimer = null
|
||||
}
|
||||
if (_singletonBusyInstance && _singletonBusyInstance !== instance) {
|
||||
// 不是当前占用者释放, 忽略
|
||||
return
|
||||
}
|
||||
if (_singletonIdleResolver) {
|
||||
_singletonIdleResolver()
|
||||
_singletonIdleResolver = null
|
||||
}
|
||||
_singletonIdlePromise = Promise.resolve()
|
||||
_singletonBusyInstance = null
|
||||
}
|
||||
|
||||
// H5 录音参数: 16kHz mono PCM → 16-bit LE WAV
|
||||
// 16kHz/16-bit 是大多数 STT 服务 (Whisper / 达摩院 / 腾讯 ASR 等) 最常见的输入规格.
|
||||
const H5_SAMPLE_RATE = 16000
|
||||
const H5_NUM_CHANNELS = 1
|
||||
const H5_SCRIPT_PROCESSOR_BUFFER_SIZE = 4096
|
||||
|
||||
/**
|
||||
* 构造带 code 字段的 Error
|
||||
* - code: 稳定的英文枚举, 供调用方按 code 走 $lang() 翻译
|
||||
* - message: 兜底英文 message, 用于开发/调试或未识别 code
|
||||
* 注意: voice-recorder.js 自身不持有 Vue 上下文, 翻译必须在调用方完成
|
||||
*/
|
||||
function _err(code, message) {
|
||||
const e = new Error(message || code)
|
||||
e.code = code
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前运行环境 (避免编译器把所有分支都打进去)
|
||||
* - true / false 都是运行时结果;条件编译靠注释里的 #ifdef
|
||||
*/
|
||||
function isH5() {
|
||||
// #ifdef H5
|
||||
return true
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
|
||||
function isMpOrApp() {
|
||||
// #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||
return true
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN || APP-PLUS || MP-HARMONY
|
||||
return false
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 mime 反推文件扩展名
|
||||
*/
|
||||
function mimeToExt(mime) {
|
||||
if (!mime) return 'wav'
|
||||
if (mime.indexOf('wav') !== -1) return 'wav'
|
||||
if (mime.indexOf('webm') !== -1) return 'webm'
|
||||
if (mime.indexOf('ogg') !== -1) return 'ogg'
|
||||
if (mime.indexOf('mp4') !== -1) return 'm4a'
|
||||
if (mime.indexOf('mpeg') !== -1) return 'mp3'
|
||||
if (mime.indexOf('aac') !== -1) return 'aac'
|
||||
return 'audio'
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 Float32Array (PCM 帧缓冲) 为单个 Float32Array
|
||||
*/
|
||||
function mergeFloat32Arrays(arrays) {
|
||||
let totalLength = 0
|
||||
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].length
|
||||
const out = new Float32Array(totalLength)
|
||||
let offset = 0
|
||||
for (let i = 0; i < arrays.length; i++) {
|
||||
out.set(arrays[i], offset)
|
||||
offset += arrays[i].length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Float32 PCM (-1..1) 编码为标准 WAV (PCM 16-bit LE, RIFF/WAVE 头).
|
||||
* 所有 STT 服务都吃这个格式; 浏览器 H5 录音不依赖 MediaRecorder 也能产出.
|
||||
*
|
||||
* 参考 RIFF/WAVE 规范:
|
||||
* https://docs.fileformat.com/audio/wav/
|
||||
* http://soundfile.sapp.org/doc/WaveFormat/
|
||||
*/
|
||||
function encodeWavPcm16LE(samples, sampleRate, numChannels) {
|
||||
const bytesPerSample = 2 // 16-bit
|
||||
const dataSize = samples.length * numChannels * bytesPerSample
|
||||
const buffer = new ArrayBuffer(44 + dataSize)
|
||||
const view = new DataView(buffer)
|
||||
|
||||
// RIFF chunk descriptor (12 bytes)
|
||||
writeAscii(view, 0, 'RIFF')
|
||||
view.setUint32(4, 36 + dataSize, true) // ChunkSize = 文件总字节 - 8
|
||||
writeAscii(view, 8, 'WAVE')
|
||||
|
||||
// fmt subchunk (24 bytes)
|
||||
writeAscii(view, 12, 'fmt ')
|
||||
view.setUint32(16, 16, true) // Subchunk1Size for PCM = 16
|
||||
view.setUint16(20, 1, true) // AudioFormat: 1 = PCM
|
||||
view.setUint16(22, numChannels, true)
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true) // ByteRate
|
||||
view.setUint16(32, numChannels * bytesPerSample, true) // BlockAlign
|
||||
view.setUint16(34, 16, true) // BitsPerSample
|
||||
|
||||
// data subchunk (8 + N bytes)
|
||||
writeAscii(view, 36, 'data')
|
||||
view.setUint32(40, dataSize, true)
|
||||
|
||||
// PCM 样本: Float32 (-1..1) → Int16 LE (clamp 到 [-1, 1] 防溢出)
|
||||
let offset = 44
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
let s = samples[i]
|
||||
if (s > 1) s = 1
|
||||
else if (s < -1) s = -1
|
||||
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7FFF)
|
||||
view.setInt16(offset, int16, true)
|
||||
offset += 2
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
function writeAscii(view, offset, str) {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建录音器实例。
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxDuration=60000] 自动停止时长 (ms)
|
||||
* @param {string} [opts.format] MP/APP/鸿蒙 'mp3'|'aac'|'wav' (uni-app 文档)
|
||||
* @param {number} [opts.sampleRate=16000]
|
||||
* @param {number} [opts.numberOfChannels=1]
|
||||
* @param {number} [opts.encodeBitRate=48000]
|
||||
*
|
||||
* @returns {VoiceRecorder}
|
||||
*/
|
||||
export function createVoiceRecorder(opts = {}) {
|
||||
return new VoiceRecorder(opts)
|
||||
}
|
||||
|
||||
class VoiceRecorder {
|
||||
constructor(opts) {
|
||||
this._maxDuration = opts.maxDuration || DEFAULT_MAX_DURATION_MS
|
||||
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信小程序在部分 Android 机型上对 mp3/aac 兼容性差,
|
||||
// 表现为一启动就 onStop(duration~100ms)。wav 是无压缩格式, 兼容性最好, 优先使用。
|
||||
this._format = opts.format || 'wav'
|
||||
this._sampleRate = opts.sampleRate || 16000
|
||||
this._channels = opts.numberOfChannels || 1
|
||||
this._bitRate = opts.encodeBitRate || 48000
|
||||
|
||||
this._listeners = { progress: [], error: [] }
|
||||
this._isRecording = false
|
||||
this._isStarting = false // start() 已调用, 但 onStart 尚未回调
|
||||
this._stopResolve = null // 当前 stop() 等待 onStop 解析的 resolve
|
||||
this._pendingStop = false // onStart 之前就调用了 stop() → 等开始后立即停
|
||||
this._startTs = 0
|
||||
this._id = Math.random().toString(36).slice(2, 8)
|
||||
this._autoStopTimer = null
|
||||
this._stopFallbackTimer = null // PR-FRONTEND-VOICE-FALLBACK: stop()/cancel() 兜底定时器
|
||||
this._earlyStopResult = null // PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发时缓存结果
|
||||
|
||||
// 平台相关句柄
|
||||
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||
console.log(`[voice-recorder:${this._id}] created`)
|
||||
}
|
||||
|
||||
on(event, cb) {
|
||||
if (!this._listeners[event]) this._listeners[event] = []
|
||||
this._listeners[event].push(cb)
|
||||
}
|
||||
|
||||
off(event, cb) {
|
||||
const arr = this._listeners[event]
|
||||
if (!arr) return
|
||||
const idx = arr.indexOf(cb)
|
||||
if (idx !== -1) arr.splice(idx, 1)
|
||||
}
|
||||
|
||||
_emit(event, payload) {
|
||||
const arr = this._listeners[event]
|
||||
if (!arr) return
|
||||
for (const cb of arr.slice()) {
|
||||
try { cb(payload) } catch (e) { /* swallow */ }
|
||||
}
|
||||
}
|
||||
|
||||
get isRecording() { return this._isRecording }
|
||||
|
||||
/**
|
||||
* 开始录音。
|
||||
* resolve() 仅表示「启动成功」,真正的产物在 stop()/cancel() 中拿到。
|
||||
*/
|
||||
start() {
|
||||
if (this._isRecording) {
|
||||
return Promise.reject(_err('ALREADY_STARTED', 'recorder already started'))
|
||||
}
|
||||
if (this._isStarting) {
|
||||
return Promise.reject(_err('ALREADY_STARTED', 'recorder is starting'))
|
||||
}
|
||||
|
||||
this._isStarting = true
|
||||
let p = null
|
||||
if (isH5()) {
|
||||
p = this._startH5()
|
||||
} else if (isMpOrApp()) {
|
||||
p = this._startUni()
|
||||
} else {
|
||||
this._isStarting = false
|
||||
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||
}
|
||||
// 启动失败时复位状态, 避免后续 stop()/cancel() 一直挂起
|
||||
p.catch((e) => {
|
||||
this._isStarting = false
|
||||
return Promise.reject(e)
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音并返回产物。
|
||||
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||
*/
|
||||
stop() {
|
||||
console.log(`[voice-recorder:${this._id}] stop() called`, { isRecording: this._isRecording, isStarting: this._isStarting, hasUniRec: !!this._uniRec, hasStopResolve: !!this._stopResolve, earlyResult: !!this._earlyStopResult })
|
||||
if (!this._isRecording) {
|
||||
// PR-FRONTEND-VOICE-RACE (2026-09-29): 真机常见"按住即松手"——
|
||||
// onStart 尚未回调 (录音未真正开始) 就调用 stop(), 原逻辑直接返回 null
|
||||
// 导致上层判定"录音失败,请重试"。这里改为挂起, 等 onStart 触发后立即停,
|
||||
// 产物仍走 onStop 正常返回 (太短的音轨后端可自行拒绝)。
|
||||
if (this._isStarting) {
|
||||
console.log(`[voice-recorder:${this._id}] stop() deferred: waiting for onStart`)
|
||||
return new Promise((resolve) => {
|
||||
this._pendingStop = true
|
||||
this._stopResolve = resolve
|
||||
})
|
||||
}
|
||||
// onStop 在 stop() 之前触发, 且有有效产物则返回缓存结果
|
||||
if (this._earlyStopResult) {
|
||||
const r = this._earlyStopResult
|
||||
this._earlyStopResult = null
|
||||
console.log(`[voice-recorder:${this._id}] stop() returning early onStop result`)
|
||||
return Promise.resolve(r)
|
||||
}
|
||||
console.log(`[voice-recorder:${this._id}] stop() early return null (not recording)`)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (isH5()) return this._stopH5()
|
||||
return this._stopUni()
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断录音并丢弃产物。
|
||||
*/
|
||||
cancel() {
|
||||
if (this._isStarting) {
|
||||
// PR-FRONTEND-VOICE-CANCEL-RACE (2026-09-29): onStart 尚未触发就取消,
|
||||
// 不能立即返回, 否则录音器启动后会继续录制。挂起 pendingStop,
|
||||
// 等 onStart 触发后立即 stop(), 产物被丢弃。
|
||||
console.log(`[voice-recorder:${this._id}] cancel() deferred: waiting for onStart`)
|
||||
return new Promise((resolve) => {
|
||||
this._pendingStop = true
|
||||
this._stopResolve = () => resolve()
|
||||
})
|
||||
}
|
||||
if (!this._isRecording) {
|
||||
this._clearAutoStopTimer()
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (isH5()) return this._cancelH5()
|
||||
return this._cancelUni()
|
||||
}
|
||||
|
||||
// ===================== H5 实现 =====================
|
||||
// PR-FRONTEND-VOICE-WAV (2026-09-25): 改为 AudioContext + ScriptProcessor 采 PCM,
|
||||
// 停止时手动封装标准 WAV. 不再依赖 MediaRecorder, 解决 Chrome 只能产 webm/opus
|
||||
// 导致后端 STT / Agent framework 无法解析的问题.
|
||||
//
|
||||
// 关键设计:
|
||||
// 1. PCM 采样率固定 16kHz mono, 浏览器会自动 resample 麦克风输入
|
||||
// (实际由 AudioContext sampleRate 参数保证)
|
||||
// 2. ScriptProcessor 必须 connect 到 audioCtx.destination 才会触发
|
||||
// onaudioprocess; 通过 GainNode(gain=0) 静音防止麦克风外放回声
|
||||
// 3. 振幅采样走 AnalyserNode (与 PCM 采样并行, 不冲突)
|
||||
// 4. onaudioprocess 每帧必须拷贝 channelData (new Float32Array(input)),
|
||||
// 否则下一帧会覆盖导致 PCM 数据丢失
|
||||
|
||||
_startH5() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
reject(_err('H5_NO_MIC_API', 'mic API not available (HTTPS or localhost required)'))
|
||||
return
|
||||
}
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext
|
||||
if (!Ctx) {
|
||||
reject(_err('H5_NO_AUDIO_CTX', 'AudioContext not available'))
|
||||
return
|
||||
}
|
||||
|
||||
const buffers = []
|
||||
let stream = null
|
||||
let audioCtx = null
|
||||
let source = null
|
||||
let processor = null
|
||||
let analyser = null
|
||||
let silentGain = null
|
||||
let rafId = null
|
||||
|
||||
// 清理 H5 资源 (失败 / 异常路径统一调用)
|
||||
const cleanup = () => {
|
||||
try { if (rafId) cancelAnimationFrame(rafId) } catch (e) { /* noop */ }
|
||||
try { if (processor) processor.disconnect() } catch (e) { /* noop */ }
|
||||
try { if (analyser) analyser.disconnect() } catch (e) { /* noop */ }
|
||||
try { if (source) source.disconnect() } catch (e) { /* noop */ }
|
||||
try { if (silentGain) silentGain.disconnect() } catch (e) { /* noop */ }
|
||||
try { if (audioCtx && audioCtx.state !== 'closed') audioCtx.close().catch(() => {}) } catch (e) { /* noop */ }
|
||||
try { if (stream) stream.getTracks().forEach(t => t.stop()) } catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: H5_NUM_CHANNELS,
|
||||
// 注意: 部分浏览器会忽略 sampleRate 约束, 实际采样率以 AudioContext 为准
|
||||
sampleRate: H5_SAMPLE_RATE,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
}
|
||||
})
|
||||
.then((s) => {
|
||||
stream = s
|
||||
audioCtx = new Ctx({ sampleRate: H5_SAMPLE_RATE })
|
||||
|
||||
// 麦克风 → ScriptProcessor → 静音 GainNode → destination
|
||||
// (ScriptProcessor 必须连到 destination 才会触发 onaudioprocess;
|
||||
// 用 GainNode(gain=0) 静音, 防止麦克风声音外放产生回声)
|
||||
source = audioCtx.createMediaStreamSource(stream)
|
||||
const BufferSize = H5_SCRIPT_PROCESSOR_BUFFER_SIZE
|
||||
processor = audioCtx.createScriptProcessor(BufferSize, H5_NUM_CHANNELS, H5_NUM_CHANNELS)
|
||||
processor.onaudioprocess = (e) => {
|
||||
const channelData = e.inputBuffer.getChannelData(0)
|
||||
// 必须拷贝: inputBuffer 数据会被下一帧覆盖, 不拷贝会导致 PCM 丢失
|
||||
buffers.push(new Float32Array(channelData))
|
||||
}
|
||||
silentGain = audioCtx.createGain()
|
||||
silentGain.gain.value = 0
|
||||
source.connect(processor)
|
||||
processor.connect(silentGain)
|
||||
silentGain.connect(audioCtx.destination)
|
||||
|
||||
// AnalyserNode 并行采振幅 (驱动波形动画)
|
||||
analyser = audioCtx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
source.connect(analyser)
|
||||
const freqBuf = new Uint8Array(analyser.frequencyBinCount)
|
||||
const tick = () => {
|
||||
if (!this._isRecording) return
|
||||
try {
|
||||
analyser.getByteFrequencyData(freqBuf)
|
||||
let sum = 0
|
||||
for (let i = 0; i < freqBuf.length; i++) sum += freqBuf[i]
|
||||
const level = Math.min(1, sum / (freqBuf.length * 255))
|
||||
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||
} catch (e) { /* noop */ }
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
|
||||
this._h5 = { stream, audioCtx, source, processor, analyser, silentGain, rafId, buffers, sampleRate: audioCtx.sampleRate, cleanup }
|
||||
this._isRecording = true
|
||||
this._startTs = Date.now()
|
||||
this._setAutoStopTimer()
|
||||
resolve()
|
||||
})
|
||||
.catch((err) => {
|
||||
// 麦克风权限拒绝 / 设备不可用
|
||||
cleanup()
|
||||
reject(_err('H5_MIC_PERMISSION', (err && err.message) || 'mic permission denied or device unavailable'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
_stopH5() {
|
||||
return new Promise((resolve) => {
|
||||
const h5 = this._h5
|
||||
if (!h5) { resolve(null); return }
|
||||
|
||||
// 1) 拆 audio graph + 停流 (避免 onprocess 继续累积 buffer)
|
||||
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||
|
||||
// 2) 合并 PCM 帧缓冲
|
||||
const sampleRate = h5.sampleRate || H5_SAMPLE_RATE
|
||||
const merged = mergeFloat32Arrays(h5.buffers || [])
|
||||
|
||||
// 3) 编码标准 WAV (PCM 16-bit LE mono)
|
||||
const wavBuffer = encodeWavPcm16LE(merged, sampleRate, H5_NUM_CHANNELS)
|
||||
const blob = new Blob([wavBuffer], { type: 'audio/wav' })
|
||||
|
||||
this._h5 = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
|
||||
if (merged.length === 0) {
|
||||
// 没有采到任何 PCM (例如用户没说话或立即 stop)
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = `voice_${Date.now()}.wav`
|
||||
const blobUrl = (typeof URL !== 'undefined' && URL.createObjectURL)
|
||||
? URL.createObjectURL(blob)
|
||||
: ''
|
||||
|
||||
resolve({
|
||||
filePath: blobUrl,
|
||||
blob,
|
||||
fileName,
|
||||
duration: Date.now() - this._startTs,
|
||||
mime: 'audio/wav',
|
||||
size: blob.size,
|
||||
cancelled: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
_cancelH5() {
|
||||
return new Promise((resolve) => {
|
||||
const h5 = this._h5
|
||||
if (!h5) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
try { h5.cleanup() } catch (e) { /* noop */ }
|
||||
this._h5 = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== uni-app (MP/APP/鸿蒙) 实现 =====================
|
||||
|
||||
_startUni() {
|
||||
// PR-FRONTEND-VOICE-SINGLETON: 串行化所有对微信录音单例的访问,
|
||||
// 防止"上一个 onStop 还没触发, 下一个 start() 就报 audio is recording"。
|
||||
return _singletonIdlePromise.then(() => {
|
||||
// 防竞态: 注册 .then() 后到回调执行前, 可能有其他实例先占用了单例, 需要重新排队。
|
||||
if (_singletonBusyInstance && _singletonBusyInstance !== this) {
|
||||
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy by ${_singletonBusyInstance._id}, re-queue`)
|
||||
return this._startUni()
|
||||
}
|
||||
_setSingletonBusy(this, this._maxDuration)
|
||||
console.log(`[voice-recorder:${this._id}] _startUni: singleton busy acquired`)
|
||||
return this._doStartUni()
|
||||
})
|
||||
}
|
||||
|
||||
_doStartUni() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let rec = null
|
||||
try {
|
||||
rec = _getSingletonRecorder()
|
||||
} catch (e) {
|
||||
_setSingletonIdle(this)
|
||||
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||
return
|
||||
}
|
||||
if (!rec) {
|
||||
_setSingletonIdle(this)
|
||||
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
this._uniRec = rec
|
||||
this._isStarting = true
|
||||
console.log(`[voice-recorder:${this._id}] _startUni: start() invoked on singleton`)
|
||||
|
||||
rec.onStart(() => {
|
||||
console.log(`[voice-recorder:${this._id}] onStart fired`)
|
||||
this._isRecording = true
|
||||
this._isStarting = false
|
||||
this._startTs = Date.now()
|
||||
this._setAutoStopTimer()
|
||||
if (this._pendingStop) {
|
||||
// 用户在录音真正开始前就松手: 立即结束, 产物经 onStop 返回
|
||||
console.log(`[voice-recorder:${this._id}] onStart: pendingStop detected, calling rec.stop()`)
|
||||
this._pendingStop = false
|
||||
try { rec.stop() } catch (e) { /* onStop 自会兜底 */ }
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
|
||||
rec.onStop((res) => {
|
||||
// res = { tempFilePath, duration, fileSize }
|
||||
console.log(`[voice-recorder:${this._id}] onStop fired`, res, 'hasStopResolve=', !!this._stopResolve)
|
||||
this._clearStopFallbackTimer()
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
// PR-FRONTEND-VOICE-FORMAT (2026-09-29): 微信临时文件后缀才是真实容器格式:
|
||||
// Android aac → .m4a, iOS aac → .aac, mp3 → .mp3。按实际后缀给 mime/文件名,
|
||||
// 避免播放器/后端因后缀与容器不符而失败。
|
||||
const tempPath = (res && res.tempFilePath) || ''
|
||||
const rawExt = (tempPath.split('?')[0].split('/').pop().match(/\.([a-z0-9]+)$/i) || [])[1] || ''
|
||||
const ext = rawExt.toLowerCase()
|
||||
const mime = ext === 'm4a' ? 'audio/mp4'
|
||||
: ext === 'aac' ? 'audio/aac'
|
||||
: ext === 'mp3' ? 'audio/mpeg'
|
||||
: ext === 'wav' ? 'audio/wav'
|
||||
: (this._format === 'wav' ? 'audio/wav'
|
||||
: this._format === 'aac' ? 'audio/aac'
|
||||
: 'audio/mpeg')
|
||||
// PR-FRONTEND-VOICE-DURATION-FIX (2026-09-29): 微信 onStop 的 duration
|
||||
// 偶尔为 0, 用 start→stop 真实时长兜底, 避免下游按 duration=0 丢弃。
|
||||
const realDuration = this._startTs ? Date.now() - this._startTs : 0
|
||||
const reportedDuration = (res && typeof res.duration === 'number') ? res.duration : 0
|
||||
const duration = reportedDuration > 0 ? reportedDuration : realDuration
|
||||
const result = {
|
||||
filePath: tempPath,
|
||||
blob: null,
|
||||
fileName: tempPath ? `voice_${Date.now()}.${ext || 'aac'}` : '',
|
||||
duration,
|
||||
mime,
|
||||
size: res.fileSize || 0,
|
||||
cancelled: false
|
||||
}
|
||||
if (this._stopResolve) {
|
||||
const r = this._stopResolve
|
||||
this._stopResolve = null
|
||||
r(result)
|
||||
} else if (result.filePath && result.duration > 0) {
|
||||
// PR-FRONTEND-VOICE-RACE: onStop 在 stop() 之前触发且产物有效, 缓存起来
|
||||
this._earlyStopResult = result
|
||||
}
|
||||
_setSingletonIdle(this)
|
||||
})
|
||||
|
||||
rec.onError((err) => {
|
||||
this._clearStopFallbackTimer()
|
||||
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
||||
console.error(`[voice-recorder:${this._id}] UNI_RECORD_ERROR raw:`, err, 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
this._emit('error', e)
|
||||
if (this._stopResolve) {
|
||||
const r = this._stopResolve
|
||||
this._stopResolve = null
|
||||
r(null)
|
||||
} else {
|
||||
// 启动阶段的 reject
|
||||
reject(e)
|
||||
}
|
||||
_setSingletonIdle(this)
|
||||
})
|
||||
|
||||
// 振幅事件 (uni-app 文档:onFrameRecorded, frameSize 单位 KB, frameBuffer ArrayBuffer)
|
||||
if (typeof rec.onFrameRecorded === 'function') {
|
||||
rec.onFrameRecorded((res) => {
|
||||
if (!res || !res.frameBuffer) return
|
||||
// frameBuffer 是 PCM 字节,简单用 |sample| / 0x8000 取平均振幅
|
||||
try {
|
||||
const view = new DataView(res.frameBuffer)
|
||||
let sum = 0
|
||||
const len = res.frameBuffer.byteLength
|
||||
const step = Math.max(2, Math.floor(len / 256))
|
||||
for (let i = 0; i < len; i += step) {
|
||||
const v = view.getInt16(i, true)
|
||||
sum += Math.abs(v)
|
||||
}
|
||||
const avg = sum / Math.max(1, Math.floor(len / step))
|
||||
const level = Math.min(1, avg / 16384)
|
||||
this._emit('progress', { level, duration: Date.now() - this._startTs })
|
||||
} catch (e) { /* noop */ }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
rec.start({
|
||||
// PR-FRONTEND-VOICE-DURATION-UNIT (2026-09-29): 微信 RecorderManager.start 的 duration
|
||||
// 单位是毫秒(ms), 不是秒。之前 /1000 导致只录 60ms 就被自动停止。
|
||||
duration: this._maxDuration,
|
||||
sampleRate: this._sampleRate,
|
||||
numberOfChannels: this._channels,
|
||||
encodeBitRate: this._bitRate,
|
||||
format: this._format
|
||||
// PR-FRONTEND-VOICE-FRAMESIZE (2026-09-29): 去掉 frameSize, 部分机型对此参数敏感,
|
||||
// 可能导致录音被提前中断。
|
||||
})
|
||||
} catch (e) {
|
||||
this._clearStopFallbackTimer()
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
_setSingletonIdle(this)
|
||||
// 启动阶段就被 stop()/cancel() 抢占, 需要 Fulfill 挂起的 Promise, 而不是 reject
|
||||
if (this._stopResolve && this._pendingStop) {
|
||||
const r = this._stopResolve
|
||||
this._stopResolve = null
|
||||
this._pendingStop = false
|
||||
r(null)
|
||||
return
|
||||
}
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_stopUni() {
|
||||
return new Promise((resolve) => {
|
||||
console.log(`[voice-recorder:${this._id}] _stopUni called`)
|
||||
const rec = this._uniRec
|
||||
if (!rec) { resolve(null); return }
|
||||
this._earlyStopResult = null
|
||||
this._stopResolve = resolve
|
||||
this._clearStopFallbackTimer()
|
||||
// PR-FRONTEND-VOICE-FALLBACK: 真机偶发 onStop 不回调, 导致 stop() 永远挂起,
|
||||
// UI 无法释放旧实例, 新录音提示"recorder already exists"。最多等 3s 强制 resolve。
|
||||
this._stopFallbackTimer = setTimeout(() => {
|
||||
console.warn(`[voice-recorder:${this._id}] _stopUni fallback timeout, force resolve`)
|
||||
this._stopResolve = null
|
||||
this._pendingStop = false
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
this._uniRec = null
|
||||
_setSingletonIdle(this)
|
||||
resolve(null)
|
||||
}, 3000)
|
||||
try { rec.stop() } catch (e) {
|
||||
this._clearStopFallbackTimer()
|
||||
this._stopResolve = null
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
this._uniRec = null
|
||||
_setSingletonIdle(this)
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_cancelUni() {
|
||||
return new Promise((resolve) => {
|
||||
console.log(`[voice-recorder:${this._id}] _cancelUni called`)
|
||||
this._earlyStopResult = null
|
||||
const rec = this._uniRec
|
||||
if (!rec) {
|
||||
// 挂起的 stop 需要一并了结, 避免松手后卡在 pending
|
||||
if (this._stopResolve && this._pendingStop) {
|
||||
const r = this._stopResolve
|
||||
this._stopResolve = null
|
||||
this._pendingStop = false
|
||||
r(null)
|
||||
}
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
_setSingletonIdle(this)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this._stopResolve = () => resolve()
|
||||
this._pendingStop = false
|
||||
this._clearStopFallbackTimer()
|
||||
// PR-FRONTEND-VOICE-FALLBACK: cancel() 同样可能因 onStop 不回调而挂起。
|
||||
this._stopFallbackTimer = setTimeout(() => {
|
||||
console.warn(`[voice-recorder:${this._id}] _cancelUni fallback timeout, force resolve`)
|
||||
this._stopResolve = null
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
this._uniRec = null
|
||||
_setSingletonIdle(this)
|
||||
resolve()
|
||||
}, 3000)
|
||||
try { rec.stop() } catch (e) {
|
||||
this._clearStopFallbackTimer()
|
||||
this._isRecording = false
|
||||
this._isStarting = false
|
||||
this._clearAutoStopTimer()
|
||||
this._uniRec = null
|
||||
_setSingletonIdle(this)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 工具 =====================
|
||||
|
||||
_setAutoStopTimer() {
|
||||
this._clearAutoStopTimer()
|
||||
this._autoStopTimer = setTimeout(() => {
|
||||
// 自动停止 (上限到达)
|
||||
if (this._isRecording) {
|
||||
this.stop().catch(() => { /* swallow */ })
|
||||
}
|
||||
}, this._maxDuration)
|
||||
}
|
||||
|
||||
_clearAutoStopTimer() {
|
||||
if (this._autoStopTimer) {
|
||||
clearTimeout(this._autoStopTimer)
|
||||
this._autoStopTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
_clearStopFallbackTimer() {
|
||||
if (this._stopFallbackTimer) {
|
||||
clearTimeout(this._stopFallbackTimer)
|
||||
this._stopFallbackTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createVoiceRecorder
|
||||
@@ -64,6 +64,13 @@
|
||||
<diy-rich-text :value="item"></diy-rich-text>
|
||||
</template>
|
||||
|
||||
<!-- #ifdef H5 -->
|
||||
<template v-if="['Webview', 'WebView'].includes(item.componentName)">
|
||||
<!-- 网页容器 -->
|
||||
<diy-webview :value="item"></diy-webview>
|
||||
</template>
|
||||
<!-- #endif -->
|
||||
|
||||
<template v-if="item.componentName == 'HorzLine'">
|
||||
<!-- 辅助线 -->
|
||||
<diy-horz-line :value="item"></diy-horz-line>
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
<image class="round" :src="$util.img(item.imageUrl)" style="border-radius:6rpx" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="fui-list-inner">
|
||||
<view class="title" style="height: 75rpx;">
|
||||
<text style="font-weight:600" :style="{ color: item.textColor }">{{ item.title }}</text>
|
||||
<view class="subtitle" style="font-size:24rpx" :style="{ color: item.textColor }">{{ item.desc }}
|
||||
</view>
|
||||
<view class="title">
|
||||
<text class="title-text" :style="titleStyle(item)">{{ item.title }}</text>
|
||||
<view class="subtitle" :style="descStyle(item)">{{ item.desc }}</view>
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点, 以默认字号实测文本宽度, 反推一行能放下的最大字号 -->
|
||||
<text class="measure-text measure-title" style="font-size:28rpx;">{{ item.title }}</text>
|
||||
<text class="measure-text measure-desc" style="font-size:24rpx;">{{ item.desc }}</text>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
<view class="fui-remark jump" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<!-- PR-DIY-KEFU-ONE-LINE (2026-09-17): 按钮颜色未配置时不再渲染/占位, 避免透明按钮挤窄文字区 -->
|
||||
<view class="fui-remark jump" v-if="item.BtBgColor || item.BtColor" style="padding-right: 20rpx; text-align: center; line-height: 140rpx;">
|
||||
<span style="font-size:24rpx;padding: 14rpx 18rpx;border-radius:8rpx"
|
||||
:style="{ background: item.BtBgColor, color: item.BtColor }" @tap.stop="previewSqs()">{{ $lang('components-diy.diy-kefu.addNow') }}</span>
|
||||
</view>
|
||||
@@ -35,15 +40,31 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端实测得出的一行自适应字号(rpx), 0 表示用默认字号
|
||||
mpTitleSize: 0,
|
||||
mpDescSize: 0
|
||||
};
|
||||
},
|
||||
created() {
|
||||
// this.getDataList();
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 英文公司名等长标题一行完整展示(不换行、不省略)
|
||||
// H5: 挂载/数据变化后实测每个文本的溢出宽度, 逐级缩小 font-size 直到恰好一行放下 —— 保证无省略号;
|
||||
// 小程序端无法测 DOM, 用字符宽度估算兜底(CJK 1em / 其他 0.6em), 极端超长时仍有省略号兜底
|
||||
mounted() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
watch: {
|
||||
// 组件刷新监听
|
||||
componentRefresh: function (nval) {
|
||||
// this.getDataList();
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
},
|
||||
value: {
|
||||
deep: true,
|
||||
handler() {
|
||||
this.$nextTick(this.fitOneLineText)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -66,9 +87,130 @@ export default {
|
||||
current: img,
|
||||
urls: [img]
|
||||
})
|
||||
},
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 单行自适应 —— 标题/描述一行完整展示(不换行), 字号取一行能放下的最大值
|
||||
// #ifdef H5
|
||||
fitOneLineText() {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$el && this.$el.querySelectorAll ? this.$el : null
|
||||
if (!root) return
|
||||
const nodes = root.querySelectorAll('.title-text, .subtitle')
|
||||
nodes.forEach((el) => {
|
||||
// 先还原 CSS 默认字号, 再实测是否溢出; 目标宽度留 3px 右侧空隙, 不贴边
|
||||
el.style.fontSize = ''
|
||||
let size = parseFloat(window.getComputedStyle(el).fontSize)
|
||||
const MIN_PX = 10 // 下限 10px, 再小可读性太差
|
||||
const target = el.clientWidth - 3
|
||||
let guard = 0
|
||||
while (el.scrollWidth > target && size > MIN_PX && guard < 60) {
|
||||
size -= 0.5
|
||||
el.style.fontSize = size + 'px'
|
||||
guard++
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// 小程序端: 隐藏测量节点以默认字号实测文本宽度, 宽度与字号成线性关系, 反推一行能放下的最大字号
|
||||
fitOneLineText() {
|
||||
uni.createSelectorQuery().in(this)
|
||||
.select('.fui-list-inner').boundingClientRect()
|
||||
.select('.measure-title').boundingClientRect()
|
||||
.select('.measure-desc').boundingClientRect()
|
||||
.exec((rects) => {
|
||||
if (!rects || !rects[0]) return
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const pxPerRpx = (sys.windowWidth || 375) / 750
|
||||
// 可用宽度 = 文字区宽度 - 容器右内边距(8rpx) - 与右侧元素(按钮)之间留 8rpx 空隙
|
||||
const avail = rects[0].width - 16 * pxPerRpx
|
||||
if (avail <= 0) return
|
||||
// 标题: 默认 28rpx, 最小 16rpx
|
||||
if (rects[1] && rects[1].width > avail) {
|
||||
this.mpTitleSize = Math.max(16, Math.floor(28 * avail / rects[1].width))
|
||||
} else {
|
||||
this.mpTitleSize = 0
|
||||
}
|
||||
// 描述: 默认 24rpx, 最小 16rpx
|
||||
if (rects[2] && rects[2].width > avail) {
|
||||
this.mpDescSize = Math.max(16, Math.floor(24 * avail / rects[2].width))
|
||||
} else {
|
||||
this.mpDescSize = 0
|
||||
}
|
||||
})
|
||||
},
|
||||
// #endif
|
||||
titleStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpTitleSize ? 'font-size:' + this.mpTitleSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
},
|
||||
descStyle() {
|
||||
// #ifndef H5
|
||||
return this.mpDescSize ? 'font-size:' + this.mpDescSize + 'rpx;' : ''
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
return ''
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
||||
<style lang="scss">
|
||||
// 客服展示:标题/描述需要完整展示(中英文均不截断)
|
||||
// 全局 .fui-list-inner .title 有 nowrap + ellipsis + overflow:hidden,这里用更高优先级的选择器覆盖
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 标题/描述强制单行(配合 JS 自适应字号保证一行放得下), 超长省略号兜底
|
||||
.diy-kefu {
|
||||
.fui-list-group {
|
||||
.fui-list {
|
||||
align-items: center;
|
||||
|
||||
.fui-list-inner {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
// 右侧留少量空隙, 避免单行文本贴着容器右边缘, 又能尽量铺满到按钮旁
|
||||
padding-right: 8rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
|
||||
.title-text {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// PR-DIY-KEFU-ONE-LINE (2026-09-17): 小程序端隐藏测量节点(不占布局、不可见)
|
||||
.measure-text {
|
||||
position: absolute;
|
||||
left: -9999rpx;
|
||||
top: 0;
|
||||
white-space: nowrap;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -12,10 +12,10 @@
|
||||
<!-- #ifdef MP -->
|
||||
<block
|
||||
v-if="(memberInfo.nickname.indexOf('u_') != -1 && memberInfo.nickname == memberInfo.username) || memberInfo.nickname == memberInfo.mobile">
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToAuthorizeAvatarNickname') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<text class="name" :style="nameStyle" @tap.stop="getWxAuth">{{ memberInfo.nickname }}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
<!-- <text class="icondiy icon-system-huangguan"></text> -->
|
||||
@@ -39,7 +39,7 @@
|
||||
<view class="nickname"><text class="name" @tap.stop="getWxAuth">{{ $lang('components-diy.diy-member-info.clickToGetWechatAvatar') }}</text></view>
|
||||
</block>
|
||||
<view class="nickname" v-else>
|
||||
<text class="name" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
<text class="name" :style="nameStyle" @tap.stop="redirect('/pages_tool/member/info')">{{ memberInfo.nickname
|
||||
}}</text>
|
||||
<view class="member-level"
|
||||
v-if="(value.style == 1 || value.style == 2) && memberInfo.member_level">
|
||||
@@ -69,7 +69,7 @@
|
||||
<image :src="$util.getDefaultImage().head" mode="widthFix" show-menu-by-longpress></image>
|
||||
</view>
|
||||
<view class="info">
|
||||
<view class="nickname"><text class="name">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="nickname"><text class="name" :style="nameStyle">{{ $lang('member.index.login') }}</text></view>
|
||||
<view class="desc">{{ $lang('member.index.loginTips') }}</view>
|
||||
</view>
|
||||
|
||||
@@ -367,6 +367,26 @@ export default {
|
||||
isDisabled() {
|
||||
if (this.nickName.length > 0) return false;
|
||||
return true;
|
||||
},
|
||||
// PR-DIY-MEMBER-INFO-NAME-FIT (2026-09-17): 英文公司名等长标题自动缩小字号保证一行放下(像后台预览一样一排)
|
||||
// 标题来源: 已登录取昵称, 未登录取 $lang('member.index.login') 文案(英文包里配置的是公司全称)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 标题可用宽度约 470rpx
|
||||
// 最小 20rpx, 超长时仍有 .name 的省略号兜底
|
||||
nameStyle() {
|
||||
let name = '';
|
||||
if (this.memberInfo && this.memberInfo.nickname) {
|
||||
name = this.memberInfo.nickname;
|
||||
} else {
|
||||
try { name = this.$lang('member.index.login') || ''; } catch (e) { name = ''; }
|
||||
}
|
||||
if (!name) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(name[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 10) return ''; // 短标题走默认 38rpx
|
||||
const size = Math.max(20, Math.min(34, Math.floor(470 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
@error="imgError(index)" show-menu-by-longpress />
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<text class="title" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc">{{ item.desc }}</text>
|
||||
<text class="title" :style="titleStyle(item)" @tap.stop="handlerClick(item)">{{ item.merch_name }}</text>
|
||||
<text class="desc" :style="descStyle(item)">{{ item.desc }}</text>
|
||||
<view class="read-wrap"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -179,6 +179,25 @@ export default {
|
||||
this.toDetail(item);
|
||||
}
|
||||
})
|
||||
},
|
||||
// PR-DIY-MERCH-LIST-ONE-LINE (2026-09-17): 英文公司名等长标题/描述自动缩小字号, 保证单行显示(像后台预览一样一排)
|
||||
// 估算宽度: CJK/全角字符按 1em, 其余(英文/数字/空格)按 0.55em; 文字区可用宽度约 500rpx
|
||||
// 短文本走 CSS 默认字号, 极端超长时仍有省略号兜底
|
||||
oneLineFontStyle(text, defaultSize, minSize) {
|
||||
if (!text) return '';
|
||||
let units = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
units += /[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff01-\uff60]/.test(text[i]) ? 1 : 0.55;
|
||||
}
|
||||
if (units <= 12) return '';
|
||||
const size = Math.max(minSize, Math.min(defaultSize, Math.floor(500 / units)));
|
||||
return 'font-size:' + size + 'rpx;';
|
||||
},
|
||||
titleStyle(item) {
|
||||
return this.oneLineFontStyle(item.merch_name, 30, 20);
|
||||
},
|
||||
descStyle(item) {
|
||||
return this.oneLineFontStyle(item.desc, 24, 20);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -296,13 +315,19 @@ export default {
|
||||
}
|
||||
|
||||
.info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0; /* PR-DIY-MERCH-LIST-ONE-LINE: 约束宽度让单行省略号生效 */
|
||||
|
||||
// flex: 1;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
.desc {
|
||||
color: #888
|
||||
color: #888;
|
||||
/* PR-DIY-MERCH-LIST-ONE-LINE: 描述单行, 超出省略 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -311,8 +336,9 @@ export default {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1; /* PR-DIY-MERCH-LIST-ONE-LINE: 标题一行(原为2行), 配合自适应字号保证放得下 */
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: nowrap;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
499
components-diy/diy-webview.vue
Normal file
499
components-diy/diy-webview.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<view data-component-name="diy-webview" class="diy-webview" :style="containerStyle" @tap="handleTap">
|
||||
<template v-if="src">
|
||||
<!-- #ifdef H5 -->
|
||||
<iframe
|
||||
v-if="!lazyPending"
|
||||
class="diy-webview-iframe"
|
||||
:src="src"
|
||||
:scrolling="scrollMode"
|
||||
referrerpolicy="no-referrer"
|
||||
frameborder="0"
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
@load="handleIframeLoad"
|
||||
@error="handleIframeError"
|
||||
></iframe>
|
||||
<!-- 后台配置的占位图 / 懒加载未进入视口时的展示 -->
|
||||
<image
|
||||
v-if="placeholderUrl && (lazyPending || !loaded)"
|
||||
class="diy-webview-placeholder"
|
||||
:src="placeholderUrl"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
<!-- 加载中遮罩:iframe 触发 load 前展示,避免白屏观感 -->
|
||||
<view v-if="!loaded && !loadError && !lazyPending" class="webview-loading" :style="errorStyle">
|
||||
<view class="webview-loading-spinner"></view>
|
||||
<text class="webview-loading-text">网页加载中...</text>
|
||||
</view>
|
||||
<!-- H5 下 iframe 内部点击不会冒泡到父容器,提供角标按钮保证"点击跳转"可用 -->
|
||||
<view class="webview-open-chip" @tap.stop="mpGo">
|
||||
<text class="iconfont icon-fuzhilianjie"></text>
|
||||
<text class="webview-open-chip-text">{{ mpGoText }}</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifndef H5 -->
|
||||
<!-- 小程序/APP:内嵌 web-view 会整页覆盖原生层,改为卡片展示 + 点击跳转 -->
|
||||
<view class='webview-mp-go' :style='mpGoStyle' @tap.stop='mpGo'>
|
||||
<text class='iconfont icon-fuzhilianjie'></text>
|
||||
<text class='webview-mp-go-text'>{{ mpGoText }}</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 加载失败兜底(如目标站禁止被嵌入/内核过旧加载失败):可点击直接打开 -->
|
||||
<view v-if="loadError" class="webview-error" :style="errorStyle" @tap.stop="mpGo">
|
||||
<text class="iconfont icon-fuzhilianjie"></text>
|
||||
<text class="error-text">{{ errorText }}</text>
|
||||
<text class="error-open">点击此处打开网页</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="webview-empty" :style="emptyStyle">
|
||||
<text class="iconfont icon-fuzhilianjie"></text>
|
||||
<text class="empty-text">请配置网页链接</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 网页/WebView 组件:后台配置 URL 后嵌入展示,H5 使用 iframe,小程序/APP 使用原生 web-view
|
||||
import DiyMinx from './minx.js'
|
||||
export default {
|
||||
name: 'diy-webview',
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
src: '',
|
||||
loaded: false,
|
||||
loadError: false,
|
||||
// 后台开启"懒加载"时,iframe 需等容器滚动进入视口后才真正渲染
|
||||
lazyPending: false
|
||||
};
|
||||
},
|
||||
mixins: [DiyMinx],
|
||||
created() {
|
||||
this.initWebview();
|
||||
},
|
||||
mounted() {
|
||||
this._mountedFlag = true;
|
||||
// #ifdef H5
|
||||
if (this.lazyPending) this._setupLazyObserver();
|
||||
// #endif
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this._observer) {
|
||||
try { this._observer.disconnect(); } catch (e) {}
|
||||
this._observer = null;
|
||||
}
|
||||
this._clearLoadTimer();
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.initWebview();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
containerStyle() {
|
||||
let style = '';
|
||||
// 后台高度为 px 值,与项目其他组件一致 ×2 转 rpx(diy-horz-blank 同款换算)
|
||||
const height = Number(this.value?.height) || 300;
|
||||
style += `height:${height * 2}rpx;`;
|
||||
// 组件角度 round 时按后台圆角值渲染(×2 rpx)
|
||||
if (this.value?.componentAngle === 'round') {
|
||||
const topR = Number(this.value?.topAroundRadius) || 0;
|
||||
const bottomR = Number(this.value?.bottomAroundRadius) || 0;
|
||||
style += `border-radius:${topR * 2}rpx ${topR * 2}rpx ${bottomR * 2}rpx ${bottomR * 2}rpx;`;
|
||||
}
|
||||
if (this.value?.bgColor) {
|
||||
style += `background:${this.value.bgColor};`;
|
||||
}
|
||||
if (this.value?.marginTop) {
|
||||
style += `margin-top:${this.value.marginTop}rpx;`;
|
||||
}
|
||||
if (this.value?.marginBottom) {
|
||||
style += `margin-bottom:${this.value.marginBottom}rpx;`;
|
||||
}
|
||||
if (this.value?.padding) {
|
||||
style += `padding:${this.value.padding}rpx;`;
|
||||
}
|
||||
return style;
|
||||
},
|
||||
emptyStyle() {
|
||||
let style = '';
|
||||
if (this.value?.bgColor) {
|
||||
style += `background:${this.value.bgColor};`;
|
||||
}
|
||||
return style;
|
||||
},
|
||||
errorStyle() {
|
||||
let style = '';
|
||||
if (this.value?.bgColor) {
|
||||
style += `background:${this.value.bgColor};`;
|
||||
}
|
||||
return style;
|
||||
},
|
||||
mpGoText() {
|
||||
// 注意:this.$lang(key) 在 key 不存在时返回 key 字符串本身,不能仅靠 this.$lang 是否存在判断。
|
||||
// 取到 key 与传入 key 不同才视为命中语言包,否则用 fallback。
|
||||
const t = this.$lang && this.$lang('components-diy.diy-webview.mpGo');
|
||||
return (t && t !== 'components-diy.diy-webview.mpGo') ? t : '点击查看网页内容';
|
||||
},
|
||||
mpGoStyle() {
|
||||
let style = '';
|
||||
if (this.value && this.value.bgColor) {
|
||||
style += 'background:' + this.value.bgColor + ';';
|
||||
}
|
||||
return style;
|
||||
},
|
||||
errorText() {
|
||||
const t = this.$lang && this.$lang('components-diy.diy-webview.loadError');
|
||||
return (t && t !== 'components-diy.diy-webview.loadError') ? t : '网页加载失败,请检查链接';
|
||||
},
|
||||
// iframe 滚动条开关(后台"滚动"配置:true 显示 / false 隐藏)
|
||||
scrollMode() {
|
||||
const s = this.value?.scrolling;
|
||||
return (s === false || s === 'false' || s === 0) ? 'no' : 'yes';
|
||||
},
|
||||
// 占位图(后台 imageUrl,iframe 加载完成前展示)
|
||||
placeholderUrl() {
|
||||
const url = this.value?.imageUrl;
|
||||
return url ? (this.$util.img ? this.$util.img(url) : url) : '';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initWebview() {
|
||||
const config = this.value || {};
|
||||
this.loadError = false;
|
||||
this.loaded = false;
|
||||
// 调试用:把后台下发的完整 value 对象打印到控制台,方便定位 URL 字段名
|
||||
console.log('[diy-webview] value:', JSON.stringify(config));
|
||||
// 兼容后台可能使用的多种链接字段名/嵌套结构
|
||||
const url = this.extractUrl(config);
|
||||
// 懒加载:开启时等容器进入视口再渲染 iframe
|
||||
this.lazyPending = !!(config.lazyRender || config.lazyLoad);
|
||||
// #ifdef H5
|
||||
// 数据热更新时(后台重新保存)若尚未进入过视口,需重建观察器(仅在挂载后才可建立)
|
||||
if (this.lazyPending && this._mountedFlag) this._setupLazyObserver();
|
||||
// #endif
|
||||
// 优先使用后台直接下发的 url;若配置的是接口来源(source=api),则通过接口拉取
|
||||
if (config.source === 'api' && config.apiUrl) {
|
||||
this.loadUrlFromApi(config.apiUrl);
|
||||
} else if (url) {
|
||||
this.src = this.buildSrc(url);
|
||||
console.log('[diy-webview] src:', this.src);
|
||||
this._startLoadTimer();
|
||||
} else {
|
||||
this.src = '';
|
||||
console.warn('[diy-webview] 未找到有效链接字段');
|
||||
}
|
||||
},
|
||||
// 在格式化后的 URL 基础上,拼接后台配置的"附加参数"与"携带登录态"(token / member_id)
|
||||
buildSrc(url) {
|
||||
let src = this.formatUrl(url);
|
||||
const config = this.value || {};
|
||||
const extra = [];
|
||||
// 附加参数(后台 urlParams 字段,格式 "a=1&b=2",多组参数用 & 连接)
|
||||
const rawExtra = config.urlParams || config.extraParams || config.appendParams || config.additionalParams || config.params;
|
||||
if (rawExtra && typeof rawExtra === 'string') {
|
||||
extra.push(rawExtra.replace(/^[?&]+/, ''));
|
||||
}
|
||||
// 携带登录态(后台 carryToken 字段:none 不携带 / token / member_id)
|
||||
const loginState = config.carryToken || config.loginState || config.login_state;
|
||||
if (loginState === 'token') {
|
||||
const token = (this.$store && this.$store.state && this.$store.state.token) || '';
|
||||
if (token) extra.push('token=' + encodeURIComponent(token));
|
||||
} else if (loginState === 'member_id') {
|
||||
const memberInfo = (this.$store && this.$store.state && this.$store.state.memberInfo) || {};
|
||||
const memberId = memberInfo.member_id || memberInfo.memberId || '';
|
||||
if (memberId) extra.push('member_id=' + memberId);
|
||||
}
|
||||
if (extra.length) {
|
||||
src += (src.indexOf('?') !== -1 ? '&' : '?') + extra.filter(Boolean).join('&');
|
||||
}
|
||||
return src;
|
||||
},
|
||||
// #ifdef H5
|
||||
// 懒加载:容器滚动进入视口(含预加载缓冲 200px)后才渲染 iframe
|
||||
_setupLazyObserver() {
|
||||
try {
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
this._observer = uni.createIntersectionObserver(this, { thresholds: [0] });
|
||||
this._observer.relativeToViewport({ bottom: 200 }).observe('.diy-webview', () => {
|
||||
this.lazyPending = false;
|
||||
if (this._observer) {
|
||||
this._observer.disconnect();
|
||||
this._observer = null;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// 观察器异常时直接渲染,避免内容永远空白
|
||||
this.lazyPending = false;
|
||||
}
|
||||
},
|
||||
// #endif
|
||||
extractUrl(config) {
|
||||
if (!config || typeof config !== 'object') return '';
|
||||
// 优先读取常见字段名(兼容后台不同命名习惯)
|
||||
let direct = config.url || config.link || config.linkUrl || config.pageUrl || config.webUrl || config.web_url || config.externalUrl || config.targetUrl || config.src || config.href || config.path || config.address;
|
||||
// 兼容装修链接选择器下发的对象形态:link = { wap_url / url / h5_url ... }
|
||||
if (direct && typeof direct === 'object') {
|
||||
direct = direct.wap_url || direct.h5_url || direct.url || direct.app_url || direct.link || '';
|
||||
}
|
||||
if (direct && typeof direct === 'string') return direct.trim();
|
||||
// 兼容嵌套结构:如 value.value.url / value.config.url / value.data.url
|
||||
for (const key of ['value', 'config', 'data', 'content', 'params']) {
|
||||
if (config[key] && typeof config[key] === 'object') {
|
||||
const nested = this.extractUrl(config[key]);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
formatUrl(url) {
|
||||
if (!url || typeof url !== 'string') return '';
|
||||
let formatted = url.trim();
|
||||
// 如果后台把 URL 编码存储,做一次解码
|
||||
if (formatted.indexOf('%') !== -1) {
|
||||
try { formatted = decodeURIComponent(formatted); } catch (e) {}
|
||||
}
|
||||
// 支持以 http:// 或 https:// 开头的完整地址
|
||||
if (/^https?:\/\//i.test(formatted)) return formatted;
|
||||
// 支持相对路径或站点内链接,自动补全
|
||||
return this.$util.img ? this.$util.img(formatted) : formatted;
|
||||
},
|
||||
loadUrlFromApi(apiUrl) {
|
||||
this.$api.sendRequest({
|
||||
url: apiUrl,
|
||||
success: res => {
|
||||
if (res.code == 0 && res.data) {
|
||||
const data = typeof res.data === 'string' ? { url: res.data } : res.data;
|
||||
this.src = this.formatUrl(data.url || data.link || '');
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.src = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
updateNavbarTitle(e) {
|
||||
// 参照 pages-tool/webview.vue:当 web-view 页面标题更新时,可同步设置导航栏标题
|
||||
const title = e?.detail?.title || e?.title || '';
|
||||
if (title) {
|
||||
uni.setNavigationBarTitle({ title });
|
||||
}
|
||||
this.$emit('titleUpdate', e);
|
||||
},
|
||||
// 点击网页容器整体 → 跳转到配置的链接(走统一 diyRedirectTo,外链自动进 web-view / 新窗口)
|
||||
handleTap() {
|
||||
if (this.src) this.mpGo();
|
||||
},
|
||||
mpGo() {
|
||||
if (!this.src) return;
|
||||
if (this.$util && this.$util.diyRedirectTo) {
|
||||
this.$util.diyRedirectTo({
|
||||
wap_url: this.src,
|
||||
title: (this.value && this.value.title) || (this.value && this.value.name) || '网页',
|
||||
name: (this.value && this.value.name) ? this.value.name : '网页'
|
||||
});
|
||||
} else {
|
||||
uni.navigateTo({ url: '/pages_tool/webview/webview?src=' + encodeURIComponent(this.src) });
|
||||
}
|
||||
},
|
||||
|
||||
// 注意:方法名不能叫 onLoad/onError —— uni-app 会把组件里的 onLoad 当页面
|
||||
// 生命周期提升出 methods,导致 @load="onLoad" 渲染时报 handler undefined。
|
||||
handleIframeLoad(e) {
|
||||
this.loaded = true;
|
||||
this.loadError = false;
|
||||
this._clearLoadTimer();
|
||||
this.$emit('load', e);
|
||||
},
|
||||
handleIframeError(e) {
|
||||
this.loadError = true;
|
||||
this.loaded = true;
|
||||
this._clearLoadTimer();
|
||||
console.error('[diy-webview] 加载失败:', this.src, e);
|
||||
this.$emit('error', e);
|
||||
},
|
||||
// 加载超时兜底:部分浏览器内核(如 HBuilderX 内置浏览器的老 CEF 内核)对跨域 iframe
|
||||
// 请求失败时不会触发 error 事件,用定时器兜住,避免容器一直白屏
|
||||
_startLoadTimer() {
|
||||
this._clearLoadTimer();
|
||||
// #ifdef H5
|
||||
this._loadTimer = setTimeout(() => {
|
||||
if (!this.loaded) {
|
||||
this.loadError = true;
|
||||
console.warn('[diy-webview] 加载超时(10s),显示兜底内容:', this.src);
|
||||
}
|
||||
}, 10000);
|
||||
// #endif
|
||||
},
|
||||
_clearLoadTimer() {
|
||||
if (this._loadTimer) {
|
||||
clearTimeout(this._loadTimer);
|
||||
this._loadTimer = null;
|
||||
}
|
||||
},
|
||||
onMessage(e) {
|
||||
// 接收 web-view / iframe 发来的消息,可用于与后台数据交互
|
||||
this.$emit('message', e);
|
||||
// 同时通过事件总线广播,方便页面级监听
|
||||
this.__$emitEvent({
|
||||
eventName: 'diy-webview-message',
|
||||
data: {
|
||||
componentName: this.value?.componentName,
|
||||
moduleIndex: this.value?.moduleIndex,
|
||||
message: e?.detail?.data || e?.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.diy-webview {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
.diy-webview-iframe,
|
||||
.diy-webview-native {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 占位图 / 懒加载未进入视口时的展示 */
|
||||
.diy-webview-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* H5 角标跳转按钮:iframe 内部点击不冒泡,用它兜底"点击跳转" */
|
||||
.webview-open-chip {
|
||||
position: absolute;
|
||||
right: 16rpx;
|
||||
bottom: 16rpx;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont {
|
||||
font-size: 24rpx;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载中遮罩 */
|
||||
.webview-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f7f8fa;
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.webview-loading-spinner {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border: 4rpx solid #e5e6eb;
|
||||
border-top-color: #576b95;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 16rpx;
|
||||
animation: webview-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes webview-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.webview-empty,
|
||||
.webview-mp-go {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #576B95;
|
||||
font-size: 26rpx;
|
||||
|
||||
.iconfont {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.webview-error {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
|
||||
.iconfont {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.webview-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #ff6b35;
|
||||
|
||||
.error-open {
|
||||
margin-top: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #576b95;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<!-- Vue 2 / uni-app 单根约束:用 view 包一层 -->
|
||||
<view>
|
||||
<view v-if="pageCount === 1 || need" class="fixed-box"
|
||||
<view v-if="(pageCount === 1 || need) && fbVisible && hasAnyButton" class="fixed-box"
|
||||
:style="[customContainerStyle, {
|
||||
height: containerHeight,
|
||||
width: containerWidth,
|
||||
bottom: containerBottom,
|
||||
right: containerRight,
|
||||
backgroundImage: bgUrl ? `url(${bgUrl})` : '',
|
||||
@@ -11,18 +12,18 @@
|
||||
}]">
|
||||
<!-- 多语言切换按钮:根据语言数智能切换交互 -->
|
||||
<view
|
||||
v-if="isLanguageSwitchEnabled && fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="isLanguageSwitchEnabled && hasLangButton"
|
||||
class="btn-item common-bg btn-lang"
|
||||
:style="[btnItemStyle('lang'), { order: orderIndex.lang }]"
|
||||
@click="handleLangButtonClick"
|
||||
>
|
||||
<view class="lang-btn-text">{{ currentLangDisplayName }}</view>
|
||||
<view class="lang-btn-text">{{ langButtonText }}</view>
|
||||
</view>
|
||||
<!-- 微信官方客服按钮-->
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && isWeappOfficialKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && isWeappOfficialKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
@@ -30,9 +31,9 @@
|
||||
|
||||
<!-- 其他类型客服按钮(智能客服、企业微信等)-->
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && !showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
@@ -48,9 +49,9 @@
|
||||
|
||||
<!-- 智能客服按钮(当同时显示小程序系统客服时)-->
|
||||
<view
|
||||
v-if="fixBtnShow && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && !isWeappOfficialKefu && showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
@click="handleUnifiedKefuClick"
|
||||
>
|
||||
<image
|
||||
@@ -66,9 +67,9 @@
|
||||
|
||||
<!-- 小程序系统客服按钮 -->
|
||||
<button
|
||||
v-if="fixBtnShow && hasValidKefuConfig && showWeappSystemKefu"
|
||||
class="btn-item common-bg"
|
||||
:style="btnItemStyle"
|
||||
v-if="hasKefuButton && hasValidKefuConfig && showWeappSystemKefu"
|
||||
class="btn-item common-bg btn-kefu"
|
||||
:style="[btnItemStyle('kefu'), { order: orderIndex.kefu }]"
|
||||
open-type="contact"
|
||||
>
|
||||
<text class="icox icox-kefu" v-if="!kefuimg"></text>
|
||||
@@ -76,9 +77,9 @@
|
||||
|
||||
<!-- 电话按钮 -->
|
||||
<view
|
||||
v-if="fixBtnShow"
|
||||
class="btn-item common-bg"
|
||||
:style="[btnItemStyle, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
v-if="hasPhoneButton"
|
||||
class="btn-item common-bg btn-phone"
|
||||
:style="[btnItemStyle('phone'), { order: orderIndex.phone }, { backgroundImage: phoneimg ? `url(${phoneimg})` : '', backgroundSize: '100% 100%' }, customButtonStyle]"
|
||||
@click="call"
|
||||
>
|
||||
<text class="iconfont icon-dianhua" v-if="!phoneimg"></text>
|
||||
@@ -109,6 +110,10 @@
|
||||
<script>
|
||||
import { createCustomerService } from '@/common/js/customer-service.js';
|
||||
import i18nLazyMixin from '@/common/mixin/i18n-lazy.js';
|
||||
import {
|
||||
getCurrentRoute, shouldShowOnPage, resolveVisibleButtons,
|
||||
resolveLangButtonText, isLangButtonVisible,
|
||||
} from '@/common/js/floating-button.js';
|
||||
|
||||
// 语言短码映射表(currentLangDisplayName 展示用,可按需扩展)
|
||||
// key: 语言 value,value: 显示短码(按钮上展示)
|
||||
@@ -161,6 +166,20 @@ function getLangShortCode(langValue, customMap) {
|
||||
return prefix.toUpperCase();
|
||||
}
|
||||
|
||||
// 跨页面/重挂载复用的悬浮按钮避让值(设计稿 rpx)。
|
||||
// H5 切换页面时 hover-nav 会重新挂载并重新测量公告栏: 无公告栏的页面(如分类页)
|
||||
// noticeRect 为 null, 若直接回落基础位置, 会与首页"公告下方"位置不一致,
|
||||
// 切回首页时按钮突然上/下跳。缓存上次算出的避让值, 让所有页面位置恒定。
|
||||
let cachedAvoidBottom = null;
|
||||
|
||||
// 需要隐藏悬浮按钮的页面路由(全平台生效: H5 + 小程序)
|
||||
// /pages_goods/category 商品分类
|
||||
// /pages_tool/contact/contact 电子名片(名片)
|
||||
const H5_HIDDEN_ROUTES = [
|
||||
'/pages_goods/category',
|
||||
'/pages_tool/contact/contact',
|
||||
];
|
||||
|
||||
export default {
|
||||
name: 'hover-nav',
|
||||
mixins: [i18nLazyMixin],
|
||||
@@ -170,7 +189,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
pageCount: 0,
|
||||
fixBtnShow: true,
|
||||
currentRoute: '', // 当前页面路由(onShow / created 时刷新, 供页面范围判定)
|
||||
shopInfo: null,
|
||||
currentLangIndex: 0,
|
||||
langIndexMap: {},
|
||||
@@ -190,6 +209,12 @@ export default {
|
||||
layoutTopBottom: 272, // --top-btn-bottom
|
||||
layoutTopSize: 72, // --top-btn-size(Top 按钮正方形边长)
|
||||
layoutFloatingWidth: 100, // --floating-btn-width
|
||||
// 公告栏(diy-notice 跑马灯)实时测量数据,用于把悬浮按钮避让到其下方
|
||||
// 注意: 字段名不能带 _ 前缀 —— Vue 2 不代理 _/$ 开头的 data 属性,
|
||||
// 也不具备响应式,计算属性将永远读不到测量结果(踩过的坑)
|
||||
noticeRect: null, // {top,bottom,height} 视口坐标(px)
|
||||
winW: 0, // 视口宽(px)
|
||||
winH: 0, // 视口高(px)
|
||||
// i18n-lazy mixin 要求:data 中预声明 i18n 字段(初值空串),
|
||||
// 模板只读 this.i18n.xxx,真实翻译在 openLangPicker() 中 prepareI18n() 后才填入
|
||||
i18n: {
|
||||
@@ -237,32 +262,75 @@ export default {
|
||||
customButtonStyle() {
|
||||
return this.shopInfo?.floatingButton?.button || {};
|
||||
},
|
||||
// ============================================================
|
||||
// 浮动侧边栏配置消费 (来源: /api/config/init → data.floating_button,
|
||||
// store/index.js 的 floatingButtonConfig, 初始 state 已声明 → 响应式)
|
||||
// fbConfig 为 null (老缓存响应缺字段) 时, 各判定全部走老行为兜底。
|
||||
// ============================================================
|
||||
// 接口下发的浮动侧边栏配置 (老缓存响应 → null)
|
||||
fbConfig() {
|
||||
return (this.$store && this.$store.state && this.$store.state.floatingButtonConfig) || null;
|
||||
},
|
||||
// 指定页面隐藏悬浮按钮(商品分类 / 电子名片), 全平台生效
|
||||
hiddenOnRoute() {
|
||||
const r = this.currentRoute;
|
||||
return H5_HIDDEN_ROUTES.some((p) => r === p || r.indexOf(p + '/') === 0);
|
||||
},
|
||||
// 总开关 + 强制隐藏 + 页面范围 判定结果
|
||||
fbVisible() {
|
||||
if (this.hiddenOnRoute) return false; // 指定页面直接隐藏
|
||||
return shouldShowOnPage(this.fbConfig, this.currentRoute, true);
|
||||
},
|
||||
// 最终要渲染的按钮 (已按 button_order 排序 + 显隐过滤); null = 用老行为
|
||||
visibleButtons() {
|
||||
return resolveVisibleButtons(this.fbConfig);
|
||||
},
|
||||
hasAnyButton() {
|
||||
const b = this.visibleButtons;
|
||||
return b ? b.length > 0 : true; // 老行为: 电话按钮恒显
|
||||
},
|
||||
hasLangButton() {
|
||||
const b = this.visibleButtons;
|
||||
return b ? b.indexOf('lang') !== -1
|
||||
: isLangButtonVisible(this.fbConfig, this.shopInfo, this.langList.length);
|
||||
},
|
||||
hasKefuButton() {
|
||||
const b = this.visibleButtons;
|
||||
return b ? b.indexOf('kefu') !== -1 : true;
|
||||
},
|
||||
hasPhoneButton() {
|
||||
const b = this.visibleButtons;
|
||||
return b ? b.indexOf('phone') !== -1 : true;
|
||||
},
|
||||
// 语言按钮文案: 商家可配「目标语言 / 当前语言 / 固定文案」。
|
||||
// 配置缺失时回退老行为 currentLangDisplayName(展示当前语言)。
|
||||
langButtonText() {
|
||||
if (!this.fbConfig) return this.currentLangDisplayName;
|
||||
return resolveLangButtonText(this.fbConfig, this.langList, this.currentLangIndex);
|
||||
},
|
||||
// 按钮渲染顺序 (button_order 自上而下); .fixed-box 是 flex-direction: column,
|
||||
// 用 CSS order 控制三段(语言/客服/电话)的先后, 配置缺失时退回老顺序。
|
||||
orderIndex() {
|
||||
const order = (this.fbConfig && Array.isArray(this.fbConfig.button_order))
|
||||
? this.fbConfig.button_order
|
||||
: ['lang', 'kefu', 'phone'];
|
||||
const map = {};
|
||||
for (let i = 0; i < order.length; i++) map[order[i]] = i + 1;
|
||||
map.lang = map.lang || 1; map.kefu = map.kefu || 2; map.phone = map.phone || 3;
|
||||
return map;
|
||||
},
|
||||
// 所有 .btn-item 的 inline-style 兜底:
|
||||
// 1) 测试发现 .btn-item 的 min-*/max-*/aspect-ratio/flex-shrink 等 !important
|
||||
// 规则在 scoped 样式下,被某些 flex 布局的隐式约束吃掉,只 width/height/border-radius
|
||||
// 生效,导致高度被压缩成 39.5px(从 41px),按钮显示为轻微椭圆。
|
||||
// 2) inline-style 优先级高于 CSS class,直接锁死 72x72 + 正圆。
|
||||
// 3) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||
btnItemStyle() {
|
||||
return {
|
||||
width: '72rpx',
|
||||
height: '72rpx',
|
||||
'min-width': '72rpx',
|
||||
'min-height': '72rpx',
|
||||
'max-width': '72rpx',
|
||||
'max-height': '72rpx',
|
||||
'aspect-ratio': '1',
|
||||
// border-radius 保留作 clip-path 不支持时的 fallback
|
||||
'border-radius': '50%',
|
||||
'flex-shrink': '0',
|
||||
'flex-grow': '0',
|
||||
'align-self': 'center',
|
||||
'box-sizing': 'border-box',
|
||||
padding: '0',
|
||||
margin: '14rpx 0',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
},
|
||||
// 2) inline-style 优先级高于 CSS class,直接锁死按钮尺寸 + 正圆。
|
||||
// 3) 尺寸来源(逐按钮可调, 见 btnSizeRpx):
|
||||
// hover_nav_lang_btn_size / hover_nav_lang_size
|
||||
// hover_nav_kefu_btn_size / hover_nav_kefu_size
|
||||
// hover_nav_phone_btn_size / hover_nav_phone_size
|
||||
// 未配置时回退 72rpx(与原行为一致)。
|
||||
// 4) 用 max-width/max-height + flex-shrink 0 三重兜底,防止被任何层级的样式覆盖。
|
||||
// (已从 computed 改为 method: 模板按按钮类型调用 btnItemStyle('lang'|'kefu'|'phone'))
|
||||
unreadCount() {
|
||||
const customerService = createCustomerService(this);
|
||||
return customerService.getUnreadCount();
|
||||
@@ -296,53 +364,101 @@ export default {
|
||||
return customerService.hasValidKefuConfig();
|
||||
},
|
||||
|
||||
//根据显示的按钮数量动态计算容器高度
|
||||
// 计算明细(buttonCount 按钮场景):
|
||||
// 每个按钮槽位:72rpx(按钮,与 to-top 一致) + 14rpx(上 margin) + 14rpx(下 margin) = 100rpx
|
||||
// 容器 padding:20rpx 上 + 20rpx 下 = 40rpx
|
||||
// 总高度 (rpx):buttonCount × 100 + 40
|
||||
// 修复:改返回 rpx 而非 px(原 *0.5 硬编码 iPhone 6 375px 转换比),
|
||||
// 在微信小程序/其他设备(iPhone 13 390px、iPad 等)下由 uni-app 框架
|
||||
// 按 windowWidth 动态换算,避免 4+ 按钮时容器偏小导致内容被裁切
|
||||
// 关联:按钮尺寸与 to-top 同步(72rpx),所以槽位高度也是 100rpx;
|
||||
// 之前按钮 80rpx 时槽位 108rpx,改为 72rpx 后槽位缩小 8rpx,容器整体更紧凑
|
||||
// 当前实际会渲染出来的按钮尺寸列表(rpx), 与模板渲染一一对应。
|
||||
// containerHeight / containerWidth 都以它为准, 后台把任一按钮调多大都能撑开容器。
|
||||
renderedBtnSizes() {
|
||||
const b = this.visibleButtons;
|
||||
if (b && b.length === 0) return [];
|
||||
|
||||
const sizes = [];
|
||||
if (this.isLanguageSwitchEnabled && this.hasLangButton) {
|
||||
sizes.push(this.btnSizeRpx('lang'));
|
||||
}
|
||||
if (this.hasKefuButton && this.hasValidKefuConfig) {
|
||||
sizes.push(this.btnSizeRpx('kefu'));
|
||||
// 小程序系统客服与客服主按钮并存时, 模板会多渲染一个按钮
|
||||
if (this.showWeappSystemKefu && !this.isWeappOfficialKefu) {
|
||||
sizes.push(this.btnSizeRpx('kefu'));
|
||||
}
|
||||
}
|
||||
if (this.hasPhoneButton) {
|
||||
sizes.push(this.btnSizeRpx('phone'));
|
||||
}
|
||||
return sizes;
|
||||
},
|
||||
// 根据显示的按钮数量与各自尺寸动态计算容器高度
|
||||
// 每个按钮槽位 = 按钮尺寸 + 14rpx(上 margin) + 14rpx(下 margin)
|
||||
// 容器 padding: 20rpx 上 + 20rpx 下 = 40rpx
|
||||
// 旧公式 buttonCount*100+40 是"所有按钮 72rpx"的特例, 现按逐按钮实际尺寸累加
|
||||
containerHeightNum() {
|
||||
const sizes = this.renderedBtnSizes;
|
||||
if (sizes.length === 0) return 0;
|
||||
return sizes.reduce((sum, s) => sum + s + 28, 0) + 40;
|
||||
},
|
||||
containerHeight() {
|
||||
if (!this.fixBtnShow) return '320rpx';
|
||||
|
||||
let buttonCount = 1;
|
||||
if (this.isLanguageSwitchEnabled) buttonCount++;
|
||||
if (this.showWeappSystemKefu) buttonCount++;
|
||||
buttonCount++;
|
||||
const totalRpx = buttonCount * 100 + 40;
|
||||
|
||||
return `${totalRpx}rpx`;
|
||||
return this.rpxStr(this.containerHeightNum);
|
||||
},
|
||||
// 容器宽度自适应: 至少保持原 --floating-btn-width(100rpx) 布局不变;
|
||||
// 后台把任一按钮调大(如 hover_nav_lang_btn_size=120rpx)时, 容器随之撑大,
|
||||
// 否则 .fixed-box 的 overflow:hidden 会把超宽按钮裁剪变形。
|
||||
containerWidthNum() {
|
||||
const maxBtn = this.renderedBtnSizes.reduce((m, s) => Math.max(m, s), 0);
|
||||
// 额外 +12rpx 呼吸余量: 避免 rpx→px 亚像素取整导致按钮与容器边缘严丝合缝,
|
||||
// 也给 bgUrl 背景胶囊留出按钮两侧各 6rpx 的边距; 按钮由 flex 居中, 不影响对齐。
|
||||
return Math.max(this.layoutFloatingWidth, maxBtn + 12);
|
||||
},
|
||||
containerWidth() {
|
||||
return this.rpxStr(this.containerWidthNum);
|
||||
},
|
||||
// 浮动按钮组底部 bottom 动态计算(在 Top 按钮上方):
|
||||
// Top 按钮是 72x72 正方形,顶部边界 = bottom + size = layoutTopBottom + layoutTopSize
|
||||
// containerBottom = Top 顶部边界 + space
|
||||
// ------------------------------------------------------------
|
||||
// space 是经验调优值(非数学推导),手调结果:
|
||||
// H5 : 18 * 5 = 90rpx
|
||||
// MP-WEIXIN: 18 * 1 = 18rpx
|
||||
// 为什么 H5 反而大、微信反而小:
|
||||
// 虽然 rpx 单位定义相同(windowWidth/750),但 Top 按钮
|
||||
// <image mode="widthFix"> 在不同平台渲染有视觉差异。
|
||||
// H5 浏览器(WebKit/Blink)渲染时,图片元素可能保留更多
|
||||
// 内边距/基线,视觉上比 72rpx 大,需要更大间距才不挤;
|
||||
// 微信 Skia 严格按 width=72rpx 渲染,视觉紧凑,
|
||||
// 18rpx 就能有清晰的呼吸感。
|
||||
// 90/18 是按"视觉上不挤"原则手调出来的,反过来都不行。
|
||||
// space 两端统一为 18rpx:
|
||||
// 旧的 H5=90rpx 特调是"H5 内联 rpx 全部失效、实际走 CSS fallback"年代的产物;
|
||||
// 现在内联样式经 rpxStr() 在 H5 输出精确 px,几何计算两端一致,
|
||||
// H5 必须与微信小程序保持相同位置(272+72+18 = 362rpx),不再需要平台特调。
|
||||
// ------------------------------------------------------------
|
||||
// 注意:安全区 var(--window-bottom) 由 CSS 的 margin-bottom 统一处理,
|
||||
// 与 Top 按钮的 .mescroll-totop { margin-bottom: var(--window-bottom) } 一致,
|
||||
// 两者会被安全区同步上推,space 保持不变
|
||||
containerBottom() {
|
||||
if (!this.fixBtnShow) return '240rpx';
|
||||
let space = 18 * 5;
|
||||
// #ifdef MP-WEIXIN
|
||||
space = 18 * 1;
|
||||
// #endif
|
||||
return `${this.layoutTopBottom + this.layoutTopSize + space}rpx`;
|
||||
const space = 18;
|
||||
const base = this.layoutTopBottom + this.layoutTopSize + space;
|
||||
// 避让公告栏(diy-notice 跑马灯): 把悬浮按钮整体放到公告栏"正下方"。
|
||||
// 关键:用「文档坐标」而非「视口坐标」算 bottom ——
|
||||
// 公告栏随页面内容滚动, 其视口坐标(rect.bottom)随滚动变化;
|
||||
// 若直接 (winH - rect.bottom) 算, 滚动时按钮会跟着公告栏上下移动。
|
||||
// 这里把 rect 加上当前滚动距离得到"公告栏在文档中的底边",
|
||||
// 再算 (winH - 文档底边): 该值与滚动无关, 按钮固定在"页面顶部时公告栏正下方",
|
||||
// 滚动时按钮静止(公告栏自行滚走), 不再来回滑动。
|
||||
const rect = this.noticeRect;
|
||||
let target = null;
|
||||
if (rect && this.winW > 0 && this.winH > 0 && rect.bottom > 0 && rect.top < this.winH) {
|
||||
// 当前滚动距离(H5: window.pageYOffset; 小程序无 window 时回退 0)
|
||||
let scrollY = 0;
|
||||
try {
|
||||
if (typeof window !== 'undefined' && 'pageYOffset' in window) {
|
||||
scrollY = window.pageYOffset || 0;
|
||||
} else if (typeof document !== 'undefined' && document.documentElement) {
|
||||
scrollY = document.documentElement.scrollTop || 0;
|
||||
}
|
||||
} catch (e) { scrollY = 0; }
|
||||
const scaleInv = 750 / this.winW; // px → 设计稿 rpx
|
||||
const gap = 12; // 按钮与公告栏之间的间距(设计稿 rpx)
|
||||
// 公告栏底边在文档中的坐标 = 视口坐标 + 滚动距离(滚动无关, 恒定)
|
||||
const docBottom = rect.bottom + scrollY;
|
||||
const distFromBottom = (this.winH - docBottom) * scaleInv;
|
||||
// 按钮 bottom = 文档底边距视口底部 + 间距 - 按钮自身高度
|
||||
const below = distFromBottom - gap - this.containerHeightNum;
|
||||
if (Number.isFinite(below)) {
|
||||
target = Math.max(0, below);
|
||||
cachedAvoidBottom = target; // 缓存: 跨页面/重挂载复用, 位置恒定
|
||||
}
|
||||
}
|
||||
// 无公告栏的页面(如分类页)复用上一次算出的避让值, 使悬浮按钮在所有页面位置一致
|
||||
if (target === null && cachedAvoidBottom !== null) target = cachedAvoidBottom;
|
||||
return this.rpxStr(target === null ? base : target);
|
||||
},
|
||||
// 浮动按钮组水平 right 动态计算(与 Top 按钮中心对齐):
|
||||
// 公式: containerRight = TopRight + TopSize/2 - FloatingWidth/2
|
||||
@@ -357,9 +473,10 @@ export default {
|
||||
// - right edge 对齐(46rpx):左侧突出 28rpx,中心错位 14rpx
|
||||
// center 对齐不仅中心精准,突出量也更小
|
||||
containerRight() {
|
||||
if (!this.fixBtnShow) return '0rpx';
|
||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.layoutFloatingWidth / 2;
|
||||
return `${offset}rpx`;
|
||||
// 用实际容器宽度(containerWidthNum, 随最大按钮自适应)做中心对齐,
|
||||
// 按钮调大后容器变宽时仍与 Top 按钮保持中心对齐
|
||||
const offset = this.layoutTopRight + this.layoutTopSize / 2 - this.containerWidthNum / 2;
|
||||
return this.rpxStr(offset);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -420,9 +537,14 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
// 页面生命周期: hover-nav 是常驻组件, 路由变化必须靠 onShow 通知,
|
||||
// 否则 currentRoute 停留在首次加载页, 页面范围判定(fbVisible)会失效。
|
||||
onShow() {
|
||||
this.refreshRoute();
|
||||
},
|
||||
created() {
|
||||
this.initLanguage();
|
||||
this.pageCount = getCurrentPages().length;
|
||||
this.refreshRoute();
|
||||
|
||||
// 三源兜底(store / 同步 storage / 异步 storage),
|
||||
// 任何一个先就绪就先使用,后续由 initStatus watcher 再次同步
|
||||
@@ -512,6 +634,23 @@ export default {
|
||||
// 关键: 手动同步当前 themeColor(覆盖 watch immediate 失效的场景)
|
||||
// 详见上面的注释。_applyThemeColor 内部已判 $el 存在性,所有平台都安全。
|
||||
this._applyThemeColor(this.$store && this.$store.state && this.$store.state.themeColor);
|
||||
|
||||
// 公告栏避让: 测量视口 + 公告栏(diy-notice)位置, 把悬浮按钮放到其正下方
|
||||
this.updateViewport();
|
||||
this.measureNotice();
|
||||
// 延迟复测: 页面 DIY 内容(含 diy-notice)可能晚于本组件挂载后才渲染
|
||||
this._noticeTimers = [
|
||||
setTimeout(() => this.measureNotice(), 300),
|
||||
setTimeout(() => this.measureNotice(), 800),
|
||||
];
|
||||
// #ifdef H5
|
||||
// 不再监听 scroll: containerBottom 已改用「文档坐标」(视口坐标 + 滚动距离)计算,
|
||||
// 得到与滚动无关的恒定 bottom —— 按钮固定在"页面顶部时公告栏正下方",
|
||||
// 滚动时按钮静止、公告栏自行滚走, 不再来回滑动。
|
||||
// 仅监听 resize(旋转/尺寸变化), 让按钮在视口尺寸变化时重新避让一次。
|
||||
this._scrollHandler = this._onScroll.bind(this);
|
||||
window.addEventListener('resize', this._scrollHandler);
|
||||
// #endif
|
||||
},
|
||||
beforeDestroy() {
|
||||
// H5 Teleport 还原:把元素从 body 移回原父节点,避免内存泄漏
|
||||
@@ -520,9 +659,163 @@ export default {
|
||||
this._origParent.appendChild(this.$el);
|
||||
this._origParent = null;
|
||||
}
|
||||
// 清理公告栏避让的滚动/缩放监听与定时器
|
||||
if (this._scrollHandler) {
|
||||
window.removeEventListener('scroll', this._scrollHandler);
|
||||
window.removeEventListener('resize', this._scrollHandler);
|
||||
this._scrollHandler = null;
|
||||
}
|
||||
if (this._noticeTimers) {
|
||||
this._noticeTimers.forEach((t) => clearTimeout(t));
|
||||
this._noticeTimers = null;
|
||||
}
|
||||
if (this._scrollRAF) {
|
||||
cancelAnimationFrame(this._scrollRAF);
|
||||
this._scrollRAF = null;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
methods: {
|
||||
// ============================================================
|
||||
// rpx 换算工具(内联样式单位跨端兼容的关键)
|
||||
// ------------------------------------------------------------
|
||||
// 问题: 动态 :style 绑定里的 rpx, 微信小程序原生支持,
|
||||
// 但 vue2 uni-app 的 H5 端不做编译转换 —— 浏览器不认识 rpx,
|
||||
// 整条内联声明被当作非法值忽略, 回落到旧 CSS(容器 100rpx),
|
||||
// 这就是"小程序正常、H5 大按钮右侧被裁"的根因。
|
||||
// 解决: H5 端所有内联尺寸一律用 upx2px 换算成 px 输出;
|
||||
// 小程序端保持 rpx 原样输出。
|
||||
// ============================================================
|
||||
// #ifdef H5
|
||||
rpxNum(n) {
|
||||
const n2 = Number(n) || 0;
|
||||
try {
|
||||
const w = (typeof window !== 'undefined' && window.innerWidth) || 375;
|
||||
return (n2 * w) / 750;
|
||||
} catch (e) {
|
||||
return n2 / 2; // window 异常时按 375px 视口兜底
|
||||
}
|
||||
},
|
||||
rpxStr(n) {
|
||||
return this.rpxNum(n) + 'px';
|
||||
},
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
rpxNum(n) {
|
||||
return Number(n) || 0;
|
||||
},
|
||||
rpxStr(n) {
|
||||
return (Number(n) || 0) + 'rpx';
|
||||
},
|
||||
// #endif
|
||||
|
||||
// ============================================================
|
||||
// 公告栏(diy-notice 跑马灯)实时测量: 让悬浮按钮避让到其正下方
|
||||
// ------------------------------------------------------------
|
||||
// 公告栏是页面流/固定底部的 DIY 组件(diy-notice), 高度与位置动态,
|
||||
// 无法用固定常量避让。这里用 uni 选择器读取其视口坐标(px),
|
||||
// containerBottom 据此把按钮放到公告栏底边之下。
|
||||
// 滚动/缩放时公告栏视口坐标会变化, 故监听 scroll/resize 重新测量。
|
||||
// ============================================================
|
||||
// 更新视口尺寸(换算 rpx 用)
|
||||
updateViewport() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.innerWidth) {
|
||||
this.winW = window.innerWidth;
|
||||
this.winH = window.innerHeight;
|
||||
} else {
|
||||
const info = uni.getSystemInfoSync();
|
||||
this.winW = info.windowWidth || 375;
|
||||
this.winH = info.windowHeight || 667;
|
||||
}
|
||||
} catch (e) {
|
||||
this.winW = this.winW || 375;
|
||||
this.winH = this.winH || 667;
|
||||
}
|
||||
},
|
||||
// 测量 .diy-notice 的视口坐标(无公告栏时置空, 走基础定位)
|
||||
measureNotice() {
|
||||
try {
|
||||
const q = uni.createSelectorQuery();
|
||||
q.select('.diy-notice').boundingClientRect((rect) => {
|
||||
if (rect && rect.height > 0) {
|
||||
this.noticeRect = {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
};
|
||||
} else {
|
||||
this.noticeRect = null; // 无公告栏 → 基础定位
|
||||
}
|
||||
}).exec();
|
||||
} catch (e) {
|
||||
this.noticeRect = null;
|
||||
}
|
||||
},
|
||||
// #ifdef H5
|
||||
// H5: 滚动/缩放用 rAF 节流重新测量, 让按钮始终跟随公告栏
|
||||
_onScroll() {
|
||||
if (this._scrollRAF) return;
|
||||
this._scrollRAF = requestAnimationFrame(() => {
|
||||
this._scrollRAF = null;
|
||||
this.updateViewport();
|
||||
this.measureNotice();
|
||||
});
|
||||
},
|
||||
// #endif
|
||||
|
||||
// 读取逐按钮尺寸 token(rpx 数值):
|
||||
// 优先 hover_nav_{type}_btn_size, 其次 hover_nav_{type}_size, 都未配置回退 72
|
||||
// 数据源: store.state.themeStyle(响应式, 后台覆写 merge 后即生效)
|
||||
// 兼容值格式: 97(数字) / '97rpx' / '97', 非法或 <=0 一律回退 72
|
||||
btnSizeRpx(type) {
|
||||
let theme = null;
|
||||
try {
|
||||
theme = (this.$store && this.$store.state) ? this.$store.state.themeStyle : null;
|
||||
} catch (e) { theme = null; }
|
||||
const keys = ['hover_nav_' + type + '_btn_size', 'hover_nav_' + type + '_size'];
|
||||
if (theme && typeof theme === 'object') {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const raw = theme[keys[i]];
|
||||
if (raw === undefined || raw === null || raw === '') continue;
|
||||
const n = parseFloat(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
}
|
||||
return 72;
|
||||
},
|
||||
// .btn-item 的 inline-style 锁定(按按钮类型取尺寸, 见 btnSizeRpx):
|
||||
// inline-style 优先级高于 CSS class, 锁死尺寸 + 正圆, 防止被 flex 布局压缩成椭圆
|
||||
btnItemStyle(type) {
|
||||
const s = this.rpxStr(this.btnSizeRpx(type));
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
'min-width': s,
|
||||
'min-height': s,
|
||||
'max-width': s,
|
||||
'max-height': s,
|
||||
// 内部图标(.iconfont/.icox)通过该变量跟随按钮尺寸:
|
||||
// 图标盒子里原本锁死 72rpx, 按钮调大后图标仍 72rpx,
|
||||
// 又因列 flex 横轴默认贴左 → 图标偏在按钮左上角
|
||||
'--btn-size': s,
|
||||
'aspect-ratio': '1',
|
||||
// border-radius 保留作 clip-path 不支持时的 fallback
|
||||
'border-radius': '50%',
|
||||
'flex-shrink': '0',
|
||||
'flex-grow': '0',
|
||||
'align-self': 'center',
|
||||
'box-sizing': 'border-box',
|
||||
padding: '0',
|
||||
margin: this.rpxStr(14) + ' 0',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
},
|
||||
// 刷新当前路由与页面栈深度(供 fbVisible 的页面范围判定 / pageCount 老判定)
|
||||
refreshRoute() {
|
||||
this.currentRoute = getCurrentRoute();
|
||||
this.pageCount = getCurrentPages().length;
|
||||
},
|
||||
// #ifdef H5
|
||||
/**
|
||||
* 从 :root 读取 CSS 变量(App.vue 中定义)到 data,
|
||||
@@ -534,9 +827,13 @@ export default {
|
||||
if (typeof document === 'undefined') return; // SSR/非浏览器环境兜底
|
||||
const root = document.documentElement;
|
||||
const computed = getComputedStyle(root);
|
||||
// CSS 变量在 H5 编译后是 px;组件内部统一按 750 设计稿 rpx 值计算,
|
||||
// 输出时再经 rpxStr() 换算回 px,避免 px/rpx 混算导致尺寸/位置错乱
|
||||
const scale = ((typeof window !== 'undefined' && window.innerWidth) || 375) / 750;
|
||||
const getNum = (name, fallback) => {
|
||||
const v = parseFloat(computed.getPropertyValue(name));
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
if (!Number.isFinite(v) || scale <= 0) return fallback;
|
||||
return v / scale; // px → 设计稿 rpx 值
|
||||
};
|
||||
this.layoutTopRight = getNum('--top-btn-right', 46);
|
||||
this.layoutTopBottom = getNum('--top-btn-bottom', 272);
|
||||
@@ -821,8 +1118,13 @@ export default {
|
||||
* 保证容器的实际宽度与 JS 计算用到的宽度始终一致。 */
|
||||
width: var(--floating-btn-width, 100rpx);
|
||||
box-sizing: border-box;
|
||||
transition: 0.3s;
|
||||
overflow: hidden;
|
||||
/* 去掉过渡动画: 切换页面重挂载时 bottom/width/right 变化会触发滑动,
|
||||
* 造成"突然移动"的观感; 位置用缓存保证恒定, 无需动画 */
|
||||
/* 关键: 不裁剪子元素。按钮尺寸由后台 token 任意调节(hover_nav_*_btn_size 可远超容器宽度),
|
||||
* 且 rpx→px 取整存在亚像素误差, overflow:hidden 会把按钮右侧/四角切掉一条(变形元凶)。
|
||||
* 按钮自身已有 border-radius:50% + overflow:hidden 自裁剪, 背景图圆角由本元素
|
||||
* border-radius 保证(背景不受 overflow 影响), 故这里必须放开为 visible。 */
|
||||
overflow: visible;
|
||||
/* 与 to-top.vue 的 .mescroll-totop 一致,适配 iPhoneX 等安全区 */
|
||||
margin-bottom: var(--window-bottom);
|
||||
}
|
||||
@@ -830,6 +1132,9 @@ export default {
|
||||
.btn-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* 横向居中: 列布局下横轴是 align-items, 固定宽度的子元素
|
||||
* (如锁死尺寸的 .iconfont 图标盒)默认贴左, 必须显式居中 */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
line-height: 1;
|
||||
@@ -864,22 +1169,42 @@ export default {
|
||||
background-color: var(--hover-nav-bg-color);
|
||||
}
|
||||
|
||||
/* 逐按钮背景/文字颜色覆写(与后台 9 个 key 对应, 未配置时回退全局 --hover-nav-*):
|
||||
* hover_nav_lang_bg_color / hover_nav_lang_text_color → --hover-nav-lang-bg-color / --hover-nav-lang-text-color
|
||||
* hover_nav_kefu_bg_color / hover_nav_kefu_text_color → --hover-nav-kefu-bg-color / --hover-nav-kefu-text-color
|
||||
* hover_nav_phone_bg_color / hover_nav_phone_text_color → --hover-nav-phone-bg-color / --hover-nav-phone-text-color
|
||||
* 文字色经 .btn-item 的 color 继承给内部 text/图标(.lang-btn-text 为 color: inherit) */
|
||||
.btn-lang {
|
||||
background-color: var(--hover-nav-lang-bg-color, var(--hover-nav-bg-color));
|
||||
color: var(--hover-nav-lang-text-color, var(--hover-nav-text-color));
|
||||
}
|
||||
.btn-kefu {
|
||||
background-color: var(--hover-nav-kefu-bg-color, var(--hover-nav-bg-color));
|
||||
color: var(--hover-nav-kefu-text-color, var(--hover-nav-text-color));
|
||||
}
|
||||
.btn-phone {
|
||||
background-color: var(--hover-nav-phone-bg-color, var(--hover-nav-bg-color));
|
||||
color: var(--hover-nav-phone-text-color, var(--hover-nav-text-color));
|
||||
}
|
||||
|
||||
.btn-item text {
|
||||
font-size: 28rpx;
|
||||
/* 悬浮按钮内文字/图标字号(后台 hover_nav_font_size 可调, 默认与图标 36rpx 对齐) */
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
}
|
||||
|
||||
.iconfont,
|
||||
.icox {
|
||||
font-size: 36rpx;
|
||||
/* 悬浮按钮内图标字号(后台 hover_nav_font_size 可调) */
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* 不再设 width: 100% / height: 100% 再覆盖为 72rpx 的写法,
|
||||
* 微信小程序/H5 对 width: 100% 在 flex 列布局里的高度计算有差异,
|
||||
* 直接固定 72x72 更稳。 */
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
/* 尺寸跟随所在按钮: 按钮内联样式写入 --btn-size(=按钮实际尺寸),
|
||||
* 图标盒不再锁死 72rpx —— 否则按钮调大后图标偏小且贴左上。
|
||||
* 72rpx 兜底 = 按钮未走 btnItemStyle 内联样式时的默认尺寸。 */
|
||||
width: var(--btn-size, 72rpx);
|
||||
height: var(--btn-size, 72rpx);
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
@@ -897,9 +1222,17 @@ export default {
|
||||
0 -1rpx 0 rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* 逐按钮字号覆写: 后台 hover_nav_phone/kefu_font_size 可调, 未设时回退全局 --hover-nav-font-size */
|
||||
.iconfont.icon-dianhua {
|
||||
font-size: var(--hover-nav-phone-font-size, var(--hover-nav-font-size, 36rpx));
|
||||
}
|
||||
.icox.icox-kefu {
|
||||
font-size: var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx));
|
||||
}
|
||||
|
||||
.iconfont text,
|
||||
.icox text {
|
||||
font-size: 36rpx;
|
||||
font-size: var(--hover-nav-font-size, 36rpx);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -911,7 +1244,8 @@ export default {
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 40rpx;
|
||||
/* emoji 客服图标: 基准字号用 hover_nav_kefu_font_size(回退全局), 再 ×1.11(原 40/36) */
|
||||
font-size: calc(var(--hover-nav-kefu-font-size, var(--hover-nav-font-size, 36rpx)) * 1.11);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -930,7 +1264,8 @@ export default {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 24rpx;
|
||||
/* 语言短码文字: 基准字号用 hover_nav_lang_font_size(回退全局 hover_nav_font_size), 文字实际按 0.667 缩放 */
|
||||
font-size: calc(var(--hover-nav-lang-font-size, var(--hover-nav-font-size, 36rpx)) * 0.667);
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
|
||||
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
247
components/ns-mp-navbar/ns-mp-navbar.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<!--
|
||||
自绘导航栏:把原生导航栏左侧的 "<" 换成 "‹ 返回" 文字
|
||||
|
||||
背景:微信小程序原生导航栏的返回按钮是客户端系统控件,pages.json 的任何配置、
|
||||
官方 <navigation-bar> 组件(只支持 title / front-color / background-color / loading)
|
||||
都无法修改它的内容。要显示"返回"二字,只能关闭原生栏后自绘。
|
||||
|
||||
使用前提:
|
||||
1. 该页面在 pages.json 中开启 "navigationStyle": "custom"(组件只负责渲染,不自动改配置)
|
||||
2. 该页面不能是 web-view 页面 —— 官方明确 "navigationStyle: custom 对 web-view 组件无效",
|
||||
且 web-view 会铺满整页覆盖其他组件,自绘的导航栏不会显示
|
||||
|
||||
布局:组件是普通流式节点(非 fixed),放在页面顶部即可把内容自然下推。
|
||||
-->
|
||||
<view
|
||||
v-if="visible"
|
||||
class="ns-mp-navbar"
|
||||
:class="{ 'is-fixed': fixed }"
|
||||
:style="{ background: bgColor, paddingTop: statusBarHeight + 'px' }"
|
||||
>
|
||||
<view class="ns-mp-navbar__inner" :style="{ height: navBarHeight + 'px' }">
|
||||
<view class="ns-mp-navbar__side">
|
||||
<view
|
||||
v-if="showBack && canBack"
|
||||
class="ns-mp-navbar__back"
|
||||
hover-class="ns-mp-navbar__back--hover"
|
||||
hover-stay-time="80"
|
||||
@tap="onBack"
|
||||
>
|
||||
<text class="iconfont icon-back_light ns-mp-navbar__arrow" :style="{ color }"></text>
|
||||
<text class="ns-mp-navbar__text" :style="{ color }">{{ backText }}</text>
|
||||
</view>
|
||||
<slot name="left"></slot>
|
||||
</view>
|
||||
|
||||
<view class="ns-mp-navbar__title" :style="{ color }">{{ title }}</view>
|
||||
|
||||
<view class="ns-mp-navbar__side ns-mp-navbar__side--right">
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 状态栏 / 胶囊信息:用于算出与原生导航栏一致的布局高度
|
||||
let systemInfo = {};
|
||||
let menuButtonInfo = { top: 0, height: 32, right: 0, width: 0 };
|
||||
|
||||
try {
|
||||
systemInfo = uni.getSystemInfoSync() || {};
|
||||
} catch (e) {
|
||||
systemInfo = {};
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-HARMONY
|
||||
try {
|
||||
menuButtonInfo = uni.getMenuButtonBoundingClientRect() || menuButtonInfo;
|
||||
} catch (e) {}
|
||||
// #endif
|
||||
|
||||
export default {
|
||||
name: 'ns-mp-navbar',
|
||||
props: {
|
||||
// 中间标题
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 导航栏背景色
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: '#ffffff'
|
||||
},
|
||||
// 文字 / 图标颜色
|
||||
color: {
|
||||
type: String,
|
||||
default: '#000000'
|
||||
},
|
||||
// 返回按钮文字(原生导航栏只能显示 "<",这里可自定义)
|
||||
backText: {
|
||||
type: String,
|
||||
default: '返回'
|
||||
},
|
||||
// 是否显示返回按钮
|
||||
showBack: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否悬浮在页面之上(沉浸式页面用;false 时占文档流,会把内容往下推)
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 无页面栈时(如分享直接进入)跳转的首页地址
|
||||
homePath: {
|
||||
type: String,
|
||||
default: '/pages/index/index'
|
||||
},
|
||||
// 自定义返回逻辑
|
||||
customBack: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// H5 端是否渲染(H5 由 uni-app 自带头像栏,默认不渲染,避免双导航栏)
|
||||
showOnH5: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
statusBarHeight: systemInfo.statusBarHeight || 20,
|
||||
navBarHeight: 44,
|
||||
visible: true,
|
||||
// 页面栈里是否还有上级页面(无上级页面时原生导航栏也不显示返回键)
|
||||
canBack: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 导航栏总高度(状态栏 + 内容区),页面如需自行占位可取该值
|
||||
navbarHeight() {
|
||||
return this.statusBarHeight + this.navBarHeight;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 导航栏内容区高度:MP 端按胶囊位置推算,与原生导航栏保持一致;其余端用 44
|
||||
// #ifdef MP
|
||||
const statusBarHeight = this.statusBarHeight;
|
||||
const h = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height;
|
||||
// 兜底:胶囊信息异常时退回 44
|
||||
this.navBarHeight = h > 0 ? h : 44;
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
this.visible = this.showOnH5;
|
||||
// #endif
|
||||
},
|
||||
mounted() {
|
||||
// 页面栈只有一页(分享/扫码直达)时,原生导航栏也无返回键,这里保持一致
|
||||
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||
this.canBack = pages.length > 1;
|
||||
// 把实际总高度抛给页面,便于页面修正内容区高度,避免多出一屏空白
|
||||
this.$emit('ready', this.statusBarHeight + this.navBarHeight);
|
||||
},
|
||||
methods: {
|
||||
onBack() {
|
||||
if (typeof this.customBack === 'function') {
|
||||
this.customBack();
|
||||
return;
|
||||
}
|
||||
const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [];
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
// 顶层页面(分享 / 扫码直接进入):回首页
|
||||
uni.switchTab({
|
||||
url: this.homePath,
|
||||
fail: () => {
|
||||
uni.reLaunch({ url: this.homePath });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ns-mp-navbar {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ns-mp-navbar.is-fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 990;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__side {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-width: 160rpx;
|
||||
padding-left: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__side--right {
|
||||
justify-content: flex-end;
|
||||
padding-left: 0;
|
||||
padding-right: 20rpx;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 20rpx 10rpx 0;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__back--hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__arrow {
|
||||
font-size: 34rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__text {
|
||||
margin-left: 2rpx;
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ns-mp-navbar__title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 160rpx;
|
||||
font-size: 32rpx;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -567,6 +567,12 @@ export default {
|
||||
flex: 1;
|
||||
margin-right: 30rpx;
|
||||
justify-content: center;
|
||||
/* 关键修复: 微信 <button> 原生默认 padding-left/right: 14px 没有被重置,
|
||||
* 会挤压按钮内容区。真机窄屏(375px 以下)上「同意并继续」5 个字被裁成
|
||||
* 「同意并继」。这里显式清零 padding 并锁定 border-box。 */
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.privacy-button-btn::after {
|
||||
|
||||
@@ -765,12 +765,27 @@ async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answer
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
// H5 端没有原生 WS 长连接, HTTP SSE 也没有 elicit_response 端点
|
||||
// 兜底: 复用 chat 端点 + action=elicit_response body
|
||||
// (此端点需后端补 — 当前后端 5 个 chat 入口 (aiagent Chat + aikefu Kefu B+A + 2 个 WS) 都没注册 elicit-response HTTP 路由)
|
||||
// 临时方案: 30s 超时后端 emit pending_stale, 用户的选项卡片就当 UI 提示展示, 不真回传给 Agent
|
||||
console.warn('[ai-service] H5 elicit_response 无可用通道, 仅展示用户选项 + 30s 后由 pending_stale 兜底');
|
||||
return false;
|
||||
// H5 端没有原生 WS 长连接, 走 PR-ELICIT-RESPONSE-HTTP (2026-09-18) 新端点:
|
||||
// POST /api/kefu/chat/elicit-response (aikefu)
|
||||
// POST /api/aiagent/chat/elicit-response (aiagent)
|
||||
// 后端会从 conversation 行读 wb_runtime {url,token,connection_id}, 然后 curl POST
|
||||
// jsonrpc 应答帧到 WB Runtime. blocking JSON, 不阻塞聊天主链路.
|
||||
try {
|
||||
const resp = await fetch(Config.baseUrl + resolveApiUrl('elicit-response'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation_id, message_id, tool_call_id, rpc_id, answers,
|
||||
uniacid: this.getUniacid(),
|
||||
token: store.state.token || ''
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
return data && data.code === 0;
|
||||
} catch (e) {
|
||||
console.warn('[ai-service] H5 elicitResponse HTTP failed:', e && e.message);
|
||||
return false;
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
```
|
||||
@@ -2082,6 +2097,46 @@ curl -X POST 'https://your-domain.com/api/aiagent/clear-conversation' \
|
||||
- 用户复制了 conversation_id 直接调后端 API (绕过前端) → 上下文乱了
|
||||
- `clearConversation` 后端成功但前端没 clear → 下一条还带旧 id
|
||||
|
||||
#### 6.4.1 Storage key 三维化 (PR-MULTI-TURN-KEY-BINDING 2026-09-18)
|
||||
|
||||
**老 key (2026-09-18 前)**:
|
||||
```js
|
||||
ai_conversation_id_<agentId> // 例: ai_conversation_id_agent_aikefu
|
||||
```
|
||||
只按 agent 维度隔离 — 跨用户/跨 uniacid 都会串会话。
|
||||
|
||||
**新 key (2026-09-18 起)**:
|
||||
```js
|
||||
ai_conv_<uniacid>_<userId>_<agentId>
|
||||
// 例: ai_conv_1_guest_e2e_test_agent_aikefu
|
||||
```
|
||||
按 uniacid × userId × agentId 三维隔离。
|
||||
|
||||
**一次性迁移** (前端 `ai-service.js` 模块 init 时自动跑, 在 `setTimeout(0)` 里执行避开 uniapp H5 storage shim 时序坑):
|
||||
```js
|
||||
// ai-service.js module-level init
|
||||
setTimeout(() => {
|
||||
migrateLegacyConversationKey(currentAgentId);
|
||||
const saved = uni.getStorageSync(getConversationKey(currentAgentId));
|
||||
if (saved) currentConversationId = saved;
|
||||
}, 0);
|
||||
```
|
||||
- 老 key 有值 + 新 key 为空 → 写入新 key
|
||||
- 老 key 保留 1 版本 (兜底, 不删)
|
||||
- 下次发版独立 PR `uni.removeStorageSync('ai_conversation_id_*')` 清老 key
|
||||
|
||||
**onUserChange 钩子** (用户登录态变化时清旧 user 的 key):
|
||||
```js
|
||||
// App.vue setMemberInfo 之后
|
||||
aiService.onUserChange();
|
||||
// 内部: 检测 ai_last_user_id 与新 userId 不同 → removeStorageSync 旧 key → setStorageSync ai_last_user_id
|
||||
```
|
||||
|
||||
**H5 时序坑** (E2E 2026-09-19 发现):
|
||||
uniapp H5 的 `uni.getStorageSync` 在 webpack module 顶层同步执行时返回空字符串,
|
||||
100ms 后才读到真值 (uniapp H5 runtime shim 在 bundle 之后才挂上 `window.uni`)。
|
||||
所有 ai-service.js 顶层的 storage 读/写必须包 `setTimeout(..., 0)` 或挂生命周期。
|
||||
|
||||
### 6.5 attachments 上传后 AI 端收不到
|
||||
|
||||
排查顺序:
|
||||
|
||||
@@ -12,12 +12,29 @@ export const lang = {
|
||||
timeFormatToday: 'Today',
|
||||
timeFormatYesterday: 'Yesterday',
|
||||
timeFormatBeforeYesterday: 'Day before yesterday',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize)
|
||||
timeJustNow: 'Just now',
|
||||
timeMinutesAgo: '{n} min ago',
|
||||
timeHoursAgo: '{n} h ago',
|
||||
timeMonthDay: '{m}/{d}',
|
||||
timeYearMonthDay: '{y}/{m}/{d}',
|
||||
|
||||
// ====== ai-chat-message ======
|
||||
aiNickname: 'AI Assistant',
|
||||
defaultNickname: 'Me',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): query text for attachment-only messages
|
||||
// audioMessage / videoMessage: short label for the user-bubble filename fallback (still used by bubble template)
|
||||
// *Instruction* keys: explicit directive sent to the AI as the query.
|
||||
// Just a label gets ignored — the AI needs an explicit "please listen/look"
|
||||
// to actually invoke STT/vision/file parsing on the attachment payload.
|
||||
audioMessage: 'Voice Message',
|
||||
videoMessage: 'Video Message',
|
||||
audioInstruction: 'I sent a voice message. Please listen to it and reply.',
|
||||
audioInstructionWithDuration: 'I sent a voice message ({seconds}s). Please listen to it and reply.',
|
||||
imageInstruction: 'I sent an image. Please look at it and reply.',
|
||||
videoInstruction: 'I sent a video. Please watch it and reply.',
|
||||
fileInstruction: 'I sent a file. Please review it and reply.',
|
||||
attachmentInstruction: 'I sent attachments. Please review them and reply.',
|
||||
inputPlaceholder: 'Please enter your question...',
|
||||
expandEditorTitle: 'Edit Content',
|
||||
loadMoreHistory: 'Load more history',
|
||||
@@ -112,15 +129,48 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: 'More Features',
|
||||
image: 'Image',
|
||||
video: 'Video/Audio',
|
||||
file: 'File',
|
||||
voice: 'Voice',
|
||||
recordVoice: 'Record Voice',
|
||||
uploadAudio: 'Upload Audio',
|
||||
pickVideo: 'Choose Video',
|
||||
pickAudio: 'Choose Audio',
|
||||
chooseAudioFail: 'Failed to select audio',
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
location: 'Location',
|
||||
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
|
||||
locationPermissionTitle: 'Location Permission Required',
|
||||
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
|
||||
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
|
||||
locationSettingBtn: 'Open Settings',
|
||||
locationSkipBtn: 'Not Now',
|
||||
locationH5GuideBtn: 'Got it',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
voiceInputTitle: 'Voice Input',
|
||||
recordingTip: 'Recording, release to end',
|
||||
startRecordTip: 'Tap to start recording',
|
||||
voiceInputFailed: 'Voice input failed',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): press-and-hold gesture + recorder error messages
|
||||
holdToTalk: 'Hold to talk',
|
||||
releaseToSend: 'Release to send',
|
||||
releaseToCancel: 'Release to cancel',
|
||||
slideUpToCancel: '↑ Slide up to cancel',
|
||||
slideUpToCancelHint: '↑ Slide up to cancel',
|
||||
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): dynamic input placeholder by context
|
||||
placeholderDefault: 'Type a message or hold to talk',
|
||||
placeholderWithAudio: 'Add a note for your voice (optional)',
|
||||
placeholderWithMedia: 'Add a caption (optional)',
|
||||
slideUpToCancelHint: '↑ Release to cancel',
|
||||
voiceStartFailed: 'Cannot start recording',
|
||||
voiceStopFailed: 'Recording failed, please try again',
|
||||
voiceNotSupported: 'Voice input is not supported on this platform',
|
||||
voiceTooShort: 'Speaking time too short',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): upload error (recorder result -> cloud)
|
||||
uploadFailed: 'Upload failed',
|
||||
uploadFailedWithReason: 'Upload failed: {reason}',
|
||||
|
||||
// ====== index.vue - Menu ======
|
||||
menuClearChat: 'Clear Chat',
|
||||
@@ -136,8 +186,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',
|
||||
|
||||
// ====== 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'}
|
||||
|
||||
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: 'File URL does not exist',
|
||||
// Video URL does not exist
|
||||
videoUrlNotExist: 'Video URL does not exist',
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): audio related
|
||||
audioUrlNotExist: 'Audio URL does not exist',
|
||||
audioDefaultTitle: 'Voice message',
|
||||
audioPlaying: 'Playing…',
|
||||
audioPaused: 'Paused',
|
||||
// This file type preview is not supported
|
||||
unsupportedFileType: 'This file type preview is not supported',
|
||||
}
|
||||
|
||||
4
lang/en-us/files/detail.js
Normal file
4
lang/en-us/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const lang = {
|
||||
//title为每个页面的标题
|
||||
title: 'File Details',
|
||||
}
|
||||
@@ -12,12 +12,28 @@ export const lang = {
|
||||
timeFormatToday: '今天',
|
||||
timeFormatYesterday: '昨天',
|
||||
timeFormatBeforeYesterday: '前天',
|
||||
// PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize)
|
||||
timeJustNow: '刚刚',
|
||||
timeMinutesAgo: '{n}分钟前',
|
||||
timeHoursAgo: '{n}小时前',
|
||||
timeMonthDay: '{m}月{d}日',
|
||||
timeYearMonthDay: '{y}年{m}月{d}日',
|
||||
|
||||
// ====== ai-chat-message ======
|
||||
aiNickname: '智能助手',
|
||||
defaultNickname: '我',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 纯附件时发给后端的 query 文案
|
||||
// audioMessage / videoMessage: 用户气泡里文件名的兜底 (短标签, 仍在 bubble 模板用)
|
||||
// *Instruction* 系列: 纯附件时发给 AI 的 query, 显式要求 AI "去看/听" 附件内容
|
||||
// AI 必须看到指令才会主动调 STT / 视觉 / 文件解析, 光有 label 容易被忽略
|
||||
audioMessage: '语音消息',
|
||||
videoMessage: '视频消息',
|
||||
audioInstruction: '我发送了一段语音,请听一下并回复我',
|
||||
audioInstructionWithDuration: '我发送了一段语音({seconds} 秒),请听一下并回复我',
|
||||
imageInstruction: '我发送了一张图片,请查看图片内容并回复我',
|
||||
videoInstruction: '我发送了一段视频,请查看视频内容并回复我',
|
||||
fileInstruction: '我发送了一个文件,请查看文件内容并回复我',
|
||||
attachmentInstruction: '我发送了附件,请查看附件内容并回复我',
|
||||
inputPlaceholder: '请输入您的问题...',
|
||||
expandEditorTitle: '编辑内容',
|
||||
loadMoreHistory: '加载更多历史消息',
|
||||
@@ -112,15 +128,49 @@ export const lang = {
|
||||
// ====== ai-chat-tools-panel ======
|
||||
moreFunctions: '更多功能',
|
||||
image: '图片',
|
||||
video: '视频/音频',
|
||||
file: '文件',
|
||||
voice: '语音',
|
||||
recordVoice: '录制语音',
|
||||
uploadAudio: '上传音频',
|
||||
pickVideo: '选择视频',
|
||||
pickAudio: '选择音频',
|
||||
chooseAudioFail: '选择音频失败',
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
||||
locationPermissionTitle: '需要位置权限',
|
||||
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
||||
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
||||
locationSettingBtn: '去设置',
|
||||
locationSkipBtn: '暂不开启',
|
||||
locationH5GuideBtn: '我知道了',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
voiceInputTitle: '语音输入',
|
||||
recordingTip: '正在录音,松开结束',
|
||||
startRecordTip: '点击开始录音',
|
||||
voiceInputFailed: '语音输入失败',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 按住说话手势 + 录音异常文案
|
||||
holdToTalk: '按住说话',
|
||||
releaseToSend: '松开发送',
|
||||
releaseToCancel: '松开取消',
|
||||
slideUpToCancel: '↑ 上滑取消',
|
||||
slideUpToCancelHint: '↑ 松开取消',
|
||||
// PR-INPUT-PLACEHOLDER-DYNAMIC (2026-09-28): 输入框 placeholder 按上下文动态切换
|
||||
// 默认 / 有语音附件 / 有其他媒体附件, 三态文案互不冲突
|
||||
placeholderDefault: '发消息或按住说话',
|
||||
placeholderWithAudio: '给语音留言加个说明(可选)',
|
||||
placeholderWithMedia: '添加文字说明(可选)',
|
||||
voiceStartFailed: '无法开始录音',
|
||||
voiceStopFailed: '录音失败,请重试',
|
||||
voiceNotSupported: '当前平台不支持语音输入',
|
||||
voiceTooShort: '说话时间太短',
|
||||
voiceRecorderBusy: '录音器正在初始化,请稍后再试',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||
uploadFailed: '上传失败',
|
||||
uploadFailedWithReason: '上传失败:{reason}',
|
||||
|
||||
// ====== index.vue - 菜单 ======
|
||||
menuClearChat: '清空聊天',
|
||||
|
||||
@@ -6,6 +6,11 @@ export const lang = {
|
||||
fileUrlNotExist: '文件地址不存在',
|
||||
// 视频地址不存在
|
||||
videoUrlNotExist: '视频地址不存在',
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频相关
|
||||
audioUrlNotExist: '音频地址不存在',
|
||||
audioDefaultTitle: '语音消息',
|
||||
audioPlaying: '播放中…',
|
||||
audioPaused: '已暂停',
|
||||
// 暂不支持该类型文件预览
|
||||
unsupportedFileType: '暂不支持该类型文件预览',
|
||||
}
|
||||
|
||||
4
lang/zh-cn/files/detail.js
Normal file
4
lang/zh-cn/files/detail.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const lang = {
|
||||
//title为每个页面的标题
|
||||
title: '文件详情',
|
||||
}
|
||||
@@ -79,6 +79,9 @@
|
||||
},
|
||||
"scope.writePhotosAlbum" : {
|
||||
"desc" : "用于从相册选择和保存头像"
|
||||
},
|
||||
"scope.record" : {
|
||||
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||
|
||||
@@ -89,4 +89,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2499,6 +2499,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "files/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "文件详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "file-preview/file-preview",
|
||||
"style": {
|
||||
@@ -2551,7 +2557,6 @@
|
||||
"pages/index/index": {
|
||||
"network": "all",
|
||||
"packages": [
|
||||
"pages_tool",
|
||||
"components-diy"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<ns-copyright v-show="isShowCopyRight" />
|
||||
</view>
|
||||
|
||||
<template v-if="adv.advshow != -1">
|
||||
<template v-if="adv && adv.advshow != -1">
|
||||
<view @touchmove.prevent.stop>
|
||||
<uni-popup ref="uniPopupWindow" type="center" class="wap-floating" :maskClick="false">
|
||||
<view class="small-bot">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,7 +263,10 @@ export default {
|
||||
// 搜索入口按钮
|
||||
.search-entrance {
|
||||
position: absolute;
|
||||
top: 44rpx;
|
||||
/* 与新对话按钮垂直居中对齐:
|
||||
.chat-topbar padding-top 16rpx + 按钮高 52rpx => 按钮中心 42rpx,
|
||||
本按钮 icon-only 高 56rpx, 故 top = 42 - 56/2 = 14rpx */
|
||||
top: 14rpx;
|
||||
right: 16rpx;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
|
||||
@@ -9,27 +9,38 @@
|
||||
</button>
|
||||
</view>
|
||||
<view class="tools-grid">
|
||||
<view class="tool-item" @click="selectImage">
|
||||
<!-- PR-CHAT-UI-CONFIG (2026-09-20): 每个附件 tab 由 attachmentTypes 控制显隐
|
||||
支持的 kind: image / file / audio / video, 缺失的 kind 自动隐藏
|
||||
location 不属于附件, 不受此开关控制 -->
|
||||
<view v-if="isTypeEnabled('image')" class="tool-item tool-item-new tool-item-image" @click="selectImage">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-image"></text>
|
||||
<view class="image-icon"></view>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.image') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectFile">
|
||||
<view v-if="isTypeEnabled('video')" class="tool-item tool-item-new tool-item-video" @click="selectVideo">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-file"></text>
|
||||
<text class="icondiy icon-system-video"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.file') }}</text>
|
||||
<!-- 第二个参数是 $lang 的默认值: 后端 bundle 尚未下发该 key 时也不会显示 raw key -->
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.video', '视频/音频') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="startRecord">
|
||||
<view v-if="isTypeEnabled('file')" class="tool-item tool-item-new tool-item-file" @click="selectFile">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
<view class="paperclip-icon"></view>
|
||||
</view>
|
||||
<text class="tool-text">添加文件</text>
|
||||
</view>
|
||||
<!-- 更多功能里的语音入口暂时隐藏 -->
|
||||
<view v-if="false" class="tool-item" @click="selectVoiceOrAudio">
|
||||
<view class="tool-icon">
|
||||
<text class="icondiy icon-system-chat-voice-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.voice') }}</text>
|
||||
</view>
|
||||
<view class="tool-item" @click="selectLocation">
|
||||
<view v-if="false" class="tool-item tool-item-new tool-item-location" @click="selectLocation">
|
||||
<view class="tool-icon">
|
||||
<text class="iconfont icon-location"></text>
|
||||
<text class="icondiy icon-system-map-pin-line"></text>
|
||||
</view>
|
||||
<text class="tool-text">{{ $lang('ai-chat.index.location') }}</text>
|
||||
</view>
|
||||
@@ -39,16 +50,20 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { uploadImage, uploadMedia } from '@/common/js/media-upload.js'
|
||||
/**
|
||||
* ai-chat-tools-panel - 更多工具面板组件
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
* attachmentTypes: Array<String> - PR-CHAT-UI-CONFIG (2026-09-20) 可启用的附件 kind 列表
|
||||
* 默认 ['image','file','audio','video'], 后端 chat_ui_config.ui_enable_attachment_types 可裁剪
|
||||
* location 不属于附件, 不受此 prop 影响
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* image-selected - 选择图片,参数:图片消息对象
|
||||
* file-selected - 选择文件,参数:文件消息对象
|
||||
* file-selected - 选择文件/视频/音频,参数:消息对象 (type: 'file' | 'video' | 'audio')
|
||||
* voice-start - 开始语音
|
||||
* location-selected - 选择位置,参数:位置消息对象
|
||||
*/
|
||||
@@ -59,6 +74,12 @@ export default {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
attachmentTypes: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return ['image', 'file', 'audio', 'video']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -73,75 +94,308 @@ export default {
|
||||
this.$emit('update:visible', false)
|
||||
},
|
||||
|
||||
// PR-CHAT-UI-CONFIG (2026-09-20): 判断某个附件 kind 是否启用
|
||||
// undefined/空数组 → 兜底全部启用 (保留老调用方的体感)
|
||||
isTypeEnabled(kind) {
|
||||
if (!this.attachmentTypes || !this.attachmentTypes.length) return true
|
||||
return this.attachmentTypes.indexOf(kind) !== -1
|
||||
},
|
||||
|
||||
selectImage() {
|
||||
const that = this
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
count: 9,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const imageMessage = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'image',
|
||||
content: '',
|
||||
imageUrl: res.tempFilePaths[0],
|
||||
timestamp: Date.now()
|
||||
that.hideToolsPanel()
|
||||
// 新交互: 选图后不直接进聊天区, 交给父组件作为输入框附件预览,
|
||||
// 用户可继续输入文字, 点发送时图文一起发出
|
||||
that.$emit('image-picked', res.tempFilePaths || [])
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '选图失败', icon: 'none' })
|
||||
}
|
||||
this.hideToolsPanel()
|
||||
this.$emit('image-selected', imageMessage)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
selectFile() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '*/*'
|
||||
input.onchange = (e) => {
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const fileMessage = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'file',
|
||||
content: '',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.hideToolsPanel()
|
||||
this.$emit('file-selected', fileMessage)
|
||||
}
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, file.type.startsWith('image/') ? 'image' : 'file')
|
||||
}
|
||||
input.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 微信小程序需要用 wx.chooseMessageFile
|
||||
// 原代码 selectFile 只有 H5 + APP-PLUS 分支, 微信小程序里 "文件" 按钮是死按钮
|
||||
// uni-app 3.x 暴露了 uni.chooseMessageFile; 老版本/部分编译目标只有 wx.chooseMessageFile, 都兼容
|
||||
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||
? uni.chooseMessageFile
|
||||
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||
if (!chooseFileApi) {
|
||||
uni.showToast({ title: '当前平台不支持文件选择', icon: 'none' })
|
||||
return
|
||||
}
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'all', // 'all' | 'video' | 'image' | 'file' — 'all' 最宽松, 用户可发任何文件
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
// wx.chooseMessageFile 返回 tempFiles; uni 端 res.tempFiles 兼容
|
||||
const file = (res.tempFiles && res.tempFiles[0]) || (res.tempFilePaths && res.tempFiles[0])
|
||||
if (!file) return
|
||||
// 微信选文件返回 path + size + name (无 type); 用 ext 推断
|
||||
const ext = (file.name || file.path || '').split('.').pop().toLowerCase()
|
||||
const kind = ['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','tif','tiff'].includes(ext) ? 'image' : 'file'
|
||||
await that._uploadOneFile(file, kind)
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: '选文件失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
const file = res.tempFiles[0]
|
||||
const fileMessage = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: 'file',
|
||||
content: '',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
url: file.path,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.hideToolsPanel()
|
||||
this.$emit('file-selected', fileMessage)
|
||||
await that._uploadOneFile(file, 'file')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 选择「视频/音频」并上传 (H5 / 微信小程序 / App)
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): 原来 H5 只写 accept='video/*',
|
||||
// 系统文件选择器会把本地 .mp3/.wav 等音频文件全部过滤掉, 用户看不到也选不了音频。
|
||||
// 现在 accept 同时包含 audio/*, 选中后按 mime / 扩展名自动判定 kind (video | audio)。
|
||||
selectVideo() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const mediaInput = document.createElement('input')
|
||||
mediaInput.type = 'file'
|
||||
mediaInput.accept = 'video/*,audio/*'
|
||||
mediaInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, that._guessMediaKind(file))
|
||||
}
|
||||
mediaInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN || APP-PLUS
|
||||
// 小程序/App 没有"音视频二合一"的选择器, 用二次弹窗让用户明确选视频还是音频
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
that.$lang('ai-chat.index.pickVideo', '选择视频'),
|
||||
that.$lang('ai-chat.index.pickAudio', '选择音频')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) that._chooseVideo()
|
||||
else if (res.tapIndex === 1) that.selectAudio()
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
// 按 mime / 扩展名判断选中的文件是视频还是音频
|
||||
_guessMediaKind(file) {
|
||||
const type = (file && file.type ? file.type : '').toLowerCase()
|
||||
if (type.indexOf('audio/') === 0) return 'audio'
|
||||
if (type.indexOf('video/') === 0) return 'video'
|
||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||
// PR-AV-FULL-SUPPORT (2026-09-24): 与后端 MediaUploadPolicy::allowedUploadExtensions 音频段对齐
|
||||
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3']
|
||||
if (audioExt.indexOf(ext) !== -1) return 'audio'
|
||||
// 兜底: mime 为空且扩展名不认识的 (如 Windows 未关联的音频) 交给后端按 URL 判断, 先按音频走
|
||||
return type ? 'video' : 'audio'
|
||||
},
|
||||
|
||||
// 选视频 (微信小程序 / App 共用)
|
||||
_chooseVideo() {
|
||||
const that = this
|
||||
uni.chooseVideo({
|
||||
sourceType: ['album', 'camera'],
|
||||
compressed: true,
|
||||
maxDuration: 60,
|
||||
success: async (res) => {
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile({
|
||||
path: res.tempFilePath,
|
||||
size: res.size || 0,
|
||||
duration: res.duration || 0,
|
||||
name: 'video_' + Date.now() + '.mp4',
|
||||
type: 'video/mp4'
|
||||
}, 'video')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseVideoFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// “语音/音频”: 提供 录制语音 / 上传音频 两种方式
|
||||
selectVoiceOrAudio() {
|
||||
uni.showActionSheet({
|
||||
itemList: [
|
||||
this.$lang('ai-chat.index.recordVoice'),
|
||||
this.$lang('ai-chat.index.uploadAudio')
|
||||
],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.startRecord()
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.selectAudio()
|
||||
}
|
||||
},
|
||||
fail: () => {}
|
||||
})
|
||||
},
|
||||
|
||||
// 选择音频文件并上传 (H5 / 微信小程序 / App)
|
||||
selectAudio() {
|
||||
const that = this
|
||||
// #ifdef H5
|
||||
const audioInput = document.createElement('input')
|
||||
audioInput.type = 'file'
|
||||
audioInput.accept = 'audio/*'
|
||||
audioInput.onchange = async (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
audioInput.click()
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序没有专门的音频选择 API, 用 chooseMessageFile 选文件后按扩展名过滤
|
||||
const chooseFileApi = (typeof uni.chooseMessageFile === 'function')
|
||||
? uni.chooseMessageFile
|
||||
: (typeof wx !== 'undefined' ? wx.chooseMessageFile : null)
|
||||
if (!chooseFileApi) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||
success: async (res) => {
|
||||
const file = (res.tempFiles && res.tempFiles[0]) || null
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
},
|
||||
fail: (err) => {
|
||||
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
|
||||
uni.showToast({ title: that.$lang('ai-chat.index.chooseAudioFail'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid', 'midi', 'caf', 'mka', 'ac3'],
|
||||
success: async (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
if (!file) return
|
||||
that.hideToolsPanel()
|
||||
await that._uploadOneFile(file, 'audio')
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
async _uploadOneFile(file, kind) {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): Blob URL 生命周期管理
|
||||
// 原代码 URL.createObjectURL 出的 blob URL 永不释放, 长期跑会泄漏
|
||||
// 统一: H5 创建 blob URL 用于预览, 上传完 (成功/失败都) revoke
|
||||
let blobUrl = ''
|
||||
let displayPath = ''
|
||||
// #ifdef H5
|
||||
// H5 file 对象没有 .path, 用 blob URL 给 imageUrl 预览, 并把 file 本身传给 uploadImage
|
||||
blobUrl = file.name ? URL.createObjectURL(file) : ''
|
||||
displayPath = blobUrl
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
// MP-WEIXIN / APP-PLUS 都用 file.path (tempFilePath)
|
||||
displayPath = file.path || ''
|
||||
// #endif
|
||||
|
||||
const pending = {
|
||||
id: ++this.messageId,
|
||||
role: 'user',
|
||||
type: kind,
|
||||
content: '',
|
||||
fileName: file.name || (file.path ? file.path.split('/').pop() : ''),
|
||||
fileSize: file.size || 0,
|
||||
fileType: file.type || '',
|
||||
// url 供音频/视频播放器使用; imageUrl 供图片/视频封面预览
|
||||
url: displayPath,
|
||||
duration: file.duration || 0,
|
||||
// 音频播放状态 (audio 气泡点击播放/暂停需要, 预先声明保证响应式)
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
imageUrl: displayPath,
|
||||
uploading: true,
|
||||
uploadError: null,
|
||||
uploaded: null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
this.$emit('file-selected', pending)
|
||||
try {
|
||||
// 传给 uploadImage/uploadMedia: H5 传 file 对象 (media-upload 内部 pathToFile 转),
|
||||
// 非 H5 传 file.path (uni.uploadFile 需要)
|
||||
const inputArg = blobUrl || file.path || file
|
||||
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传的是 blob URL (无扩展名),
|
||||
// 把原始 name/mime/size 传给 media-upload, 保证 STS 上传 key 保留 .mp3/.mp4 后缀,
|
||||
// 否则云端存成 .bin + application/octet-stream, 音频/视频播放器拿不到正确 Content-Type
|
||||
const uploaded = kind === 'image'
|
||||
? await uploadImage(inputArg)
|
||||
: await uploadMedia(inputArg, kind, { name: file.name, mime: file.type, size: file.size })
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploaded', uploaded)
|
||||
if (uploaded.url) {
|
||||
this.$set(pending, 'imageUrl', uploaded.url)
|
||||
this.$set(pending, 'url', uploaded.url)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$set(pending, 'uploading', false)
|
||||
this.$set(pending, 'uploadError', e.message || '上传失败')
|
||||
uni.showToast({ title: '上传失败: ' + (e.message || ''), icon: 'none' })
|
||||
} finally {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX: blob URL 用完即释放 (无论成败)
|
||||
if (blobUrl) {
|
||||
try { URL.revokeObjectURL(blobUrl) } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
startRecord() {
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 仅作转发, 真正的录音由 ai-chat-voice-panel.vue 接管
|
||||
// (panel 现在已经接通 common/js/voice-recorder.js, 这里不用直接调用 recorder)
|
||||
this.$emit('voice-start')
|
||||
this.hideToolsPanel()
|
||||
},
|
||||
@@ -209,11 +463,12 @@ export default {
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
position: relative; // 为 .close-btn 绝对定位提供参考
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
font-size: 30rpx;
|
||||
font-weight: 400;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@@ -225,17 +480,19 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
position: absolute; // 绝对定位: 紧贴 panel 顶部、放在"位置"图标左上方
|
||||
top: -8rpx;
|
||||
right: 10rpx; //
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24rpx;
|
||||
|
||||
.tool-item {
|
||||
@@ -257,7 +514,8 @@ export default {
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
.iconfont {
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
color: $color-primary;
|
||||
}
|
||||
@@ -269,6 +527,62 @@ export default {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 新样式: 图片 / 添加文件 大卡片, 一比一还原参考图
|
||||
.tool-item-new {
|
||||
background-color: #f2f3f5;
|
||||
border-radius: 28rpx;
|
||||
padding: 36rpx 0;
|
||||
|
||||
.tool-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
margin-bottom: 6rpx;
|
||||
line-height: 1;
|
||||
|
||||
.iconfont,
|
||||
.icondiy {
|
||||
font-size: 48rpx;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
// 图片图标: 左上角开口 + 大四角星在开口处 + 右侧实心圆点 (参考图样式)
|
||||
.image-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTEuNCA1IEgxNy41IEEzLjUgMy41IDAgMCAxIDIxIDguNSBWMTUuNSBBMy41IDMuNSAwIDAgMSAxNy41IDE5IEg2LjUgQTMuNSAzLjUgMCAwIDEgMyAxNS41IFY4LjUiLz48cGF0aCBkPSJNNS4yIDEuMyBRNS45IDQuMzUgOC45IDUgUTUuOSA1LjY1IDUuMiA4LjcgUTQuNSA1LjY1IDEuNSA1IFE0LjUgNC4zNSA1LjIgMS4zIFoiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxjaXJjbGUgY3g9IjE2IiBjeT0iOS44IiByPSIxLjUiIGZpbGw9IiMzMzMiIHN0cm9rZT0ibm9uZSIvPjxwYXRoIGQ9Ik01LjUgMTEuNiBRMTAgOS42IDE0LjUgMTYuNCIvPjwvc3ZnPg==");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
// 添加文件图标: 斜向回形针 (参考图样式)
|
||||
.paperclip-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzMzIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMjEuNDQgMTEuMDUgMTIuMjUgMjAuMjRhNiA2IDAgMCAxLTguNDktOC40OWw4LjU3LTguNTdhNCA0IDAgMSAxIDUuNjYgNS42NmwtOC41OSA4LjU3YTIgMiAwIDAgMS0yLjgzLTIuODNsOC40OS04LjQ4Ii8+PC9zdmc+");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 视频/音频图标小一号 (tool-item-video 与该元素同级, 必须用 & 连接)
|
||||
&.tool-item-video .tool-icon .icondiy {
|
||||
font-size: 40rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,239 +1,403 @@
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ voiceInputing ? $lang('ai-chat.index.recordingTip') : $lang('ai-chat.index.startRecordTip') }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* ai-chat-voice-panel - 语音输入面板组件
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
voiceInputing: false
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (!val) {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
this.voiceInputing = false
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
this.voiceInputing = !this.voiceInputing
|
||||
if (this.voiceInputing) {
|
||||
this.startVoiceInput()
|
||||
} else {
|
||||
this.stopVoiceInput()
|
||||
}
|
||||
},
|
||||
|
||||
startVoiceInput() {
|
||||
// #ifdef H5
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(stream => {
|
||||
// 录音中,可扩展实时转文字
|
||||
})
|
||||
.catch(error => {
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
this.voiceInputing = false
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
|
||||
stopVoiceInput() {
|
||||
this.voiceInputing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
height: 40rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
animation: none;
|
||||
|
||||
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
|
||||
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
|
||||
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
|
||||
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
|
||||
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
|
||||
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
|
||||
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
|
||||
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<view class="voice-popup" v-if="visible">
|
||||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||||
<view class="voice-input-panel">
|
||||
<view class="voice-header">
|
||||
<text>{{ $lang('ai-chat.index.voiceInputTitle') }}</text>
|
||||
<button class="close-btn" @click="hideVoicePanel">
|
||||
<text class="iconfont icon-close"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="voice-content">
|
||||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||||
<view
|
||||
v-for="i in 8"
|
||||
:key="i"
|
||||
class="wave-bar"
|
||||
:style="waveBarStyle(i)"></view>
|
||||
</view>
|
||||
<text class="voice-tip">{{ currentTip }}</text>
|
||||
<text v-if="voiceInputing" class="voice-duration">{{ formattedDuration }}</text>
|
||||
</view>
|
||||
<view class="voice-actions">
|
||||
<button class="voice-btn" :class="{ 'is-recording': voiceInputing }" @click="toggleVoiceInput">
|
||||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||||
</button>
|
||||
<text v-if="voiceInputing" class="cancel-link" @click="cancelVoiceInput">{{ $lang('ai-chat.index.cancel') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createVoiceRecorder } from '@/common/js/voice-recorder.js'
|
||||
/**
|
||||
* ai-chat-voice-panel - 语音输入面板组件
|
||||
*
|
||||
* PR-FRONTEND-VOICE-INPUT (2026-09-24): 真录音 UI。
|
||||
* - 接入 common/js/voice-recorder.js,跨 H5 / 微信小程序 / App-Plus / 鸿蒙元服务
|
||||
* - 波形振幅由 progress 事件 (level 0..1) 驱动
|
||||
* - 录音上限 60s,自动停止
|
||||
* - 关闭面板时如果还在录音,先 cancel 释放资源
|
||||
*
|
||||
* Props:
|
||||
* visible: Boolean - 控制显示/隐藏
|
||||
*
|
||||
* Events:
|
||||
* update:visible - 同步关闭状态
|
||||
* close - 请求关闭面板
|
||||
* audio-ready - 录音完成, 参数 { filePath, blob, duration, mime, size }
|
||||
*/
|
||||
export default {
|
||||
name: 'AiChatVoicePanel',
|
||||
|
||||
props: {
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
voiceInputing: false,
|
||||
// 录音中的振幅 0..1;8 根波形的高度按 level 抖动
|
||||
voiceLevel: 0,
|
||||
// 录音累计时长 (秒,整数)
|
||||
voiceDuration: 0,
|
||||
_durationTimer: null,
|
||||
_voiceRecorder: null,
|
||||
_onProgress: null,
|
||||
_onError: null,
|
||||
// 防止 stop 回调被多次触发 (录音器自己 stop + 用户手动 stop 重叠)
|
||||
_resolved: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentTip() {
|
||||
if (this.voiceInputing) {
|
||||
return this.$lang('ai-chat.index.recordingTip')
|
||||
}
|
||||
return this.$lang('ai-chat.index.startRecordTip')
|
||||
},
|
||||
formattedDuration() {
|
||||
const s = Math.max(0, Math.floor(this.voiceDuration))
|
||||
return s + '″'
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
// 每次重新打开都重置
|
||||
this.voiceInputing = false
|
||||
this.voiceLevel = 0
|
||||
this.voiceDuration = 0
|
||||
this._resolved = false
|
||||
} else {
|
||||
// 关闭时如果还在录音,丢弃
|
||||
this._discardRecorderIfAny()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this._discardRecorderIfAny()
|
||||
},
|
||||
|
||||
methods: {
|
||||
hideVoicePanel() {
|
||||
// 关闭时若还在录,默认作废;如需保留产物应先点 stop 按钮
|
||||
this._discardRecorderIfAny()
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
|
||||
toggleVoiceInput() {
|
||||
if (this.voiceInputing) {
|
||||
this._finishRecording()
|
||||
} else {
|
||||
this._beginRecording()
|
||||
}
|
||||
},
|
||||
|
||||
cancelVoiceInput() {
|
||||
this._discardRecorderIfAny()
|
||||
this.hideVoicePanel()
|
||||
},
|
||||
|
||||
waveBarStyle(i) {
|
||||
// 每根 bar 基础高度 24rpx + level*振幅 + 不同 bar 的 offset (错相)
|
||||
const base = 24
|
||||
const offset = ((i * 17) % 32) + 12 // 12..44
|
||||
const levelPx = Math.round(this.voiceLevel * 80)
|
||||
const h = Math.max(16, base + offset + levelPx) + 'rpx'
|
||||
const delay = (i * 0.08) + 's'
|
||||
return { height: h, animationDelay: delay }
|
||||
},
|
||||
|
||||
_beginRecording() {
|
||||
if (this._voiceRecorder) return
|
||||
const rec = createVoiceRecorder({ maxDuration: 60 * 1000 })
|
||||
this._voiceRecorder = rec
|
||||
this._onProgress = ({ level }) => {
|
||||
// 平滑一下,避免波形抖动太狠
|
||||
this.voiceLevel = this.voiceLevel * 0.4 + level * 0.6
|
||||
}
|
||||
this._onError = (err) => {
|
||||
console.warn('[voice-panel] recorder error:', err)
|
||||
console.error('[voice-panel] recorder error raw:', JSON.stringify(err), 'errMsg=', err && err.errMsg, 'message=', err && err.message)
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
this._discardRecorderIfAny()
|
||||
}
|
||||
rec.on('progress', this._onProgress)
|
||||
rec.on('error', this._onError)
|
||||
|
||||
rec.start()
|
||||
.then(() => {
|
||||
this.voiceInputing = true
|
||||
this.voiceDuration = 0
|
||||
this._startDurationTimer()
|
||||
})
|
||||
.catch((err) => {
|
||||
this._teardownRecorder()
|
||||
uni.showToast({ title: this._voiceErrMsg(err) || this.$lang('ai-chat.index.voiceStartFailed'), icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
async _finishRecording() {
|
||||
if (!this._voiceRecorder || this._resolved) return
|
||||
this._resolved = true
|
||||
this._stopDurationTimer()
|
||||
try {
|
||||
const result = await this._voiceRecorder.stop()
|
||||
if (!result || !result.filePath) {
|
||||
// 录音产物为空,可能是被系统打断
|
||||
uni.showToast({ title: this.$lang('ai-chat.index.voiceStopFailed'), icon: 'none' })
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
return
|
||||
}
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
this.$emit('audio-ready', {
|
||||
filePath: result.filePath,
|
||||
blob: result.blob || null,
|
||||
fileName: result.fileName || '',
|
||||
duration: result.duration,
|
||||
mime: result.mime,
|
||||
size: result.size
|
||||
})
|
||||
this.hideVoicePanel()
|
||||
} catch (e) {
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
uni.showToast({ title: this._voiceErrMsg(e) || this.$lang('ai-chat.index.voiceInputFailed'), icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
_discardRecorderIfAny() {
|
||||
if (!this._voiceRecorder) return
|
||||
try { this._voiceRecorder.cancel() } catch (e) { /* noop */ }
|
||||
this._teardownRecorder()
|
||||
this.voiceInputing = false
|
||||
},
|
||||
|
||||
_teardownRecorder() {
|
||||
this._stopDurationTimer()
|
||||
if (this._voiceRecorder) {
|
||||
if (this._onProgress) this._voiceRecorder.off('progress', this._onProgress)
|
||||
if (this._onError) this._voiceRecorder.off('error', this._onError)
|
||||
}
|
||||
this._voiceRecorder = null
|
||||
this._onProgress = null
|
||||
this._onError = null
|
||||
this._resolved = false
|
||||
this.voiceLevel = 0
|
||||
},
|
||||
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 把 voice-recorder 抛出的 code 翻译成 i18n 文案
|
||||
_voiceErrMsg(err) {
|
||||
if (!err) return ''
|
||||
switch (err.code) {
|
||||
case 'NOT_SUPPORTED':
|
||||
case 'NO_RECORDER_MANAGER':
|
||||
return this.$lang('ai-chat.index.voiceNotSupported')
|
||||
case 'H5_NO_MIC_API':
|
||||
case 'ALREADY_STARTED':
|
||||
return this.$lang('ai-chat.index.voiceStartFailed')
|
||||
case 'UNI_RECORD_ERROR':
|
||||
return (err && (err.errMsg || err.message)) || this.$lang('ai-chat.index.voiceStopFailed')
|
||||
default:
|
||||
return err.message || ''
|
||||
}
|
||||
},
|
||||
|
||||
_startDurationTimer() {
|
||||
this._stopDurationTimer()
|
||||
this._durationTimer = setInterval(() => {
|
||||
this.voiceDuration += 1
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
_stopDurationTimer() {
|
||||
if (this._durationTimer) {
|
||||
clearInterval(this._durationTimer)
|
||||
this._durationTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
|
||||
@import '../styles/ai-chat-global.scss';
|
||||
|
||||
.voice-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
|
||||
.voice-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.voice-input-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: 48rpx;
|
||||
width: 60%;
|
||||
max-width: 520rpx;
|
||||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
border-bottom: 2rpx solid #f0f4f8;
|
||||
padding-bottom: 16rpx;
|
||||
|
||||
text {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-main;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
|
||||
.iconfont {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.voice-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 32rpx;
|
||||
|
||||
.voice-wave {
|
||||
width: 280rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
&.recording .wave-bar {
|
||||
animation: wave 1.0s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
width: 8rpx;
|
||||
margin: 0 4rpx;
|
||||
background-color: $color-primary;
|
||||
border-radius: 4rpx;
|
||||
transition: height 0.12s ease-out;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tip {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-light;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.voice-btn {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||||
border: none;
|
||||
transition: transform 0.15s ease-out, background-color 0.15s ease-out;
|
||||
|
||||
&.is-recording {
|
||||
background-color: #e94e4e;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 56rpx;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-link {
|
||||
margin-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-light;
|
||||
padding: 8rpx 16rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.6); }
|
||||
50% { transform: scaleY(1.15); }
|
||||
}
|
||||
</style>
|
||||
@@ -13,9 +13,12 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
|
||||
<view class="chat-content" :style="{ bottom: tabBarHeight }">
|
||||
<ai-chat-message
|
||||
<!-- AI 智能页始终全屏,不让位给底部 tabBar,聊天区直接占满到底 -->
|
||||
<view class="chat-content" :style="{ bottom: '0rpx' }">
|
||||
<ai-chat-message
|
||||
v-if="uiCfgReady"
|
||||
ref="chat"
|
||||
:preloaded-ui-config="preloadedUiCfg"
|
||||
:initial-messages="initialMessages"
|
||||
:fallback-welcome="fallbackWelcome"
|
||||
:user-avatar="userAvatar"
|
||||
@@ -69,14 +72,9 @@
|
||||
</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>
|
||||
<!-- 底部导航栏:AI 智能页是全屏聊天体验,强制不渲染底部 tabBar,
|
||||
让输入框贴到屏幕底,不论从哪个入口进入都不显示。 -->
|
||||
<diy-bottom-nav v-if="false"></diy-bottom-nav>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -100,6 +98,12 @@ export default {
|
||||
agentId: 'agent_aikefu',
|
||||
pageTitle: '',
|
||||
|
||||
// PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 聊天 UI 配置预取
|
||||
// uiCfgReady 控制聊天组件渲染时机 — 配置就绪(缓存命中或预取完成)才挂载,
|
||||
// 避免"首帧用默认值渲染 → 异步配置到达后名称/头像/时间跳变"的闪烁
|
||||
uiCfgReady: false,
|
||||
preloadedUiCfg: null,
|
||||
|
||||
// --- 系统导航栏/状态栏相关 ---
|
||||
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
|
||||
statusBarHeight: 0, // 状态栏高度,默认0
|
||||
@@ -119,12 +123,9 @@ export default {
|
||||
// 事件处理器引用(用于清理)
|
||||
safeEventHandlers: new Map(),
|
||||
|
||||
// 是否从浮动按钮进入
|
||||
// 用来在运行时区分两种场景:
|
||||
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
|
||||
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
|
||||
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
|
||||
// 并在 onUnload 还原,避免影响其他页面。
|
||||
// 进入 AI 智能页时是否临时隐藏了底部 tabBar。
|
||||
// 标记进入时已调用 setBottomNavHidden(true),在 onUnload 中根据此标记还原,
|
||||
// 避免返回其他 tabBar 页时底部导航栏保持隐藏状态。
|
||||
isFromFloatBtn: false,
|
||||
}
|
||||
},
|
||||
@@ -147,6 +148,34 @@ export default {
|
||||
async onLoad(options) {
|
||||
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome');
|
||||
|
||||
// ====== PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 配置先行,再渲染聊天组件 ======
|
||||
// 1) 本地持久缓存命中 → 同步就绪, 立即渲染 (零延迟, 用上次会话的真实配置)
|
||||
// 2) 未命中(首次访问) → await 强制拉取 (race 2.5s 超时兜底), 拿到后再渲染
|
||||
// 3) 拉取失败/超时 → 也放行渲染, 组件内部仍有默认值兜底, 不白屏
|
||||
// force 拉取同时预热 /info 缓存, 欢迎语/模型列表等后续请求直接复用
|
||||
try {
|
||||
const cached = aiService.getChatUiConfigSync();
|
||||
if (cached) {
|
||||
this.preloadedUiCfg = cached;
|
||||
this.uiCfgReady = true;
|
||||
console.log('[AI Chat] chatUiConfig 命中本地缓存, 首帧即用真实配置');
|
||||
}
|
||||
} catch (e) { /* 缓存读取失败走预取 */ }
|
||||
if (!this.uiCfgReady) {
|
||||
try {
|
||||
const cfg = await Promise.race([
|
||||
aiService.getChatUiConfig(true),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 6000))
|
||||
]);
|
||||
if (cfg) this.preloadedUiCfg = cfg;
|
||||
else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新');
|
||||
} catch (e) { /* 失败兜底默认值 */ }
|
||||
this.uiCfgReady = true;
|
||||
console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg);
|
||||
}
|
||||
// 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取,
|
||||
// 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求
|
||||
|
||||
// ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ======
|
||||
// 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。
|
||||
// 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值,
|
||||
@@ -171,24 +200,16 @@ export default {
|
||||
|
||||
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;
|
||||
}
|
||||
// ====== 进入 AI 智能页始终隐藏底部 tabBar ======
|
||||
// 原因:AI 智能页是全屏聊天体验,不论是
|
||||
// 1) 底部导航栏"智能客服"项直接点击进入
|
||||
// 2) diy-float-btn 浮动按钮(注入 __dyu=FloatBtn)进入
|
||||
// 都不应被 tabBar 占用屏幕,否则输入框会被挤到 tabBar 上方。
|
||||
// 进入时 setBottomNavHidden(true) 临时隐藏,onUnload 还原,避免污染其他页面。
|
||||
this.isFromFloatBtn = true; // 标记进入时已隐藏,onUnload 据此还原
|
||||
try {
|
||||
this.$store.commit('setBottomNavHidden', true);
|
||||
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
|
||||
|
||||
this.initChat();
|
||||
},
|
||||
|
||||
@@ -36,6 +36,47 @@
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="content">
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览分支
|
||||
aikefu 测试对话附件 type='image' / AI 侧 attachments.image 跳转时传 fileType=image -->
|
||||
<view v-if="fileType === 'image'" class="image-viewer">
|
||||
<image v-if="fileUrl" :src="fileUrl" mode="aspectFit" class="image-element" @click="previewImageFullscreen" />
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.fileUrlNotExist') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览分支
|
||||
AI 侧 attachments.audio 跳转时传 fileType=audio, 用 createInnerAudioContext 播放 -->
|
||||
<view v-else-if="fileType === 'audio'" class="audio-viewer">
|
||||
<view v-if="fileUrl" class="audio-player-card">
|
||||
<view class="audio-icon">
|
||||
<text class="iconfont icon-voice"></text>
|
||||
</view>
|
||||
<view class="audio-info">
|
||||
<view class="audio-title">{{ fileName || $lang('file-preview.file-preview.audioDefaultTitle') }}</view>
|
||||
<view class="audio-status">{{ audioPlaying ? $lang('file-preview.file-preview.audioPlaying') : $lang('file-preview.file-preview.audioPaused') }}</view>
|
||||
<view class="audio-progress">
|
||||
<text class="audio-time">{{ formatAudioTime(audioCurrentTime) }}</text>
|
||||
<view class="audio-progress-bar">
|
||||
<view class="audio-progress-fill" :style="{ width: audioProgressPct + '%' }"></view>
|
||||
</view>
|
||||
<text class="audio-time">{{ formatAudioTime(audioDuration) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="audio-play-btn" @click="toggleAudioPlay">
|
||||
<text class="iconfont" :class="audioPlaying ? 'icon-pause' : 'icon-play'"></text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="error-message">{{ $lang('file-preview.file-preview.audioUrlNotExist') }}</view>
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="h5-action-bar">
|
||||
<view class="h5-action-tip">如无法播放,请使用下方按钮:</view>
|
||||
<view class="h5-action-btns">
|
||||
<button class="h5-action-btn primary" @click="openInNewTab">新标签页打开</button>
|
||||
<button class="h5-action-btn" @click="triggerDownload">下载文件</button>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- PDF 文件预览 -->
|
||||
<view v-if="fileType === 'pdf'" class="file-viewer">
|
||||
<!-- #ifdef H5 -->
|
||||
@@ -101,9 +142,20 @@ export default {
|
||||
fileUrl: '',
|
||||
fileType: '',
|
||||
statusBarHeight: 20,
|
||||
windowWidth: 375
|
||||
windowWidth: 375,
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频播放引擎
|
||||
audioCtx: null,
|
||||
audioPlaying: false,
|
||||
audioCurrentTime: 0,
|
||||
audioDuration: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
audioProgressPct() {
|
||||
if (!this.audioDuration || this.audioDuration <= 0) return 0;
|
||||
return Math.min(100, (this.audioCurrentTime / this.audioDuration) * 100);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 整个自定义 header 高度(状态栏 + 标题栏 88rpx),单位 px —— 用于占位 view 和内容区下移
|
||||
@@ -178,6 +230,13 @@ export default {
|
||||
}
|
||||
// #endif
|
||||
},
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 离开页面销毁音频 ctx
|
||||
onUnload() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.stop(); this.audioCtx.destroy(); } catch (e) {}
|
||||
this.audioCtx = null;
|
||||
}
|
||||
},
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序:右上角胶囊"分享给朋友" / button[open-type=share] 触发
|
||||
onShareAppMessage() {
|
||||
@@ -196,25 +255,24 @@ export default {
|
||||
// #endif
|
||||
methods: {
|
||||
inferFileType() {
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 加 image / audio 识别
|
||||
const classify = (ext) => {
|
||||
if (['pdf'].includes(ext)) return 'pdf';
|
||||
if (['doc', 'docx'].includes(ext)) return 'word';
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'aac', 'm4a', 'ogg', 'flac'].includes(ext)) return 'audio';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext)) return 'image';
|
||||
return null;
|
||||
};
|
||||
if (this.fileUrl) {
|
||||
const clean = String(this.fileUrl).split('?')[0];
|
||||
const ext = clean.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
} else if (this.fileName) {
|
||||
const ext = this.fileName.split('.').pop().toLowerCase();
|
||||
if (['pdf'].includes(ext)) {
|
||||
this.fileType = 'pdf';
|
||||
} else if (['doc', 'docx'].includes(ext)) {
|
||||
this.fileType = 'word';
|
||||
} else if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv'].includes(ext)) {
|
||||
this.fileType = 'video';
|
||||
}
|
||||
const t = classify(ext);
|
||||
if (t) this.fileType = t;
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
@@ -257,8 +315,89 @@ export default {
|
||||
// 兜底:新标签页打开
|
||||
this.openInNewTab();
|
||||
}
|
||||
}
|
||||
},
|
||||
// #endif
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片分支 — 点击全屏预览
|
||||
previewImageFullscreen() {
|
||||
if (!this.fileUrl) return;
|
||||
// #ifdef H5 || MP-WEIXIN
|
||||
uni.previewImage({ urls: [this.fileUrl], current: this.fileUrl });
|
||||
// #endif
|
||||
},
|
||||
|
||||
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频分支 — 切换播放/暂停
|
||||
toggleAudioPlay() {
|
||||
if (!this.fileUrl) {
|
||||
uni.showToast({ title: '音频地址为空', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (this.audioPlaying) {
|
||||
this._pauseAudio();
|
||||
} else {
|
||||
this._playAudio();
|
||||
}
|
||||
},
|
||||
|
||||
_ensureAudioCtx() {
|
||||
if (this.audioCtx) return this.audioCtx;
|
||||
try {
|
||||
const ctx = uni.createInnerAudioContext();
|
||||
ctx.src = this.fileUrl;
|
||||
ctx.onTimeUpdate(() => {
|
||||
this.audioCurrentTime = ctx.currentTime || 0;
|
||||
if (ctx.duration && !isNaN(ctx.duration)) {
|
||||
this.audioDuration = ctx.duration;
|
||||
}
|
||||
});
|
||||
ctx.onEnded(() => {
|
||||
this.audioPlaying = false;
|
||||
this.audioCurrentTime = 0;
|
||||
});
|
||||
ctx.onError((e) => {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
console.error('[file-preview audio] error:', e);
|
||||
});
|
||||
this.audioCtx = ctx;
|
||||
return ctx;
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '音频引擎初始化失败', icon: 'none' });
|
||||
console.error('[file-preview audio] createInnerAudioContext failed:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
_playAudio() {
|
||||
const ctx = this._ensureAudioCtx();
|
||||
if (!ctx) return;
|
||||
if (this.audioCurrentTime > 0) {
|
||||
try { ctx.seek(this.audioCurrentTime); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = true;
|
||||
try {
|
||||
ctx.play();
|
||||
} catch (e) {
|
||||
this.audioPlaying = false;
|
||||
uni.showToast({ title: '音频播放失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
|
||||
_pauseAudio() {
|
||||
if (this.audioCtx) {
|
||||
try { this.audioCtx.pause(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
this.audioPlaying = false;
|
||||
},
|
||||
|
||||
// 00:00 格式
|
||||
formatAudioTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '00:00';
|
||||
const total = Math.floor(seconds);
|
||||
const mm = String(Math.floor(total / 60)).padStart(2, '0');
|
||||
const ss = String(total % 60).padStart(2, '0');
|
||||
return mm + ':' + ss;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -486,6 +625,128 @@ export default {
|
||||
background-color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 图片预览 */
|
||||
.image-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
background-color: #000;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||||
|
||||
.image-element {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 音频预览 */
|
||||
.audio-viewer {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.audio-player-card {
|
||||
width: 90%;
|
||||
max-width: 560rpx;
|
||||
padding: 40rpx 32rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
.audio-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
.iconfont {
|
||||
font-size: 48rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.audio-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audio-status {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
}
|
||||
.audio-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 8rpx;
|
||||
.audio-time {
|
||||
font-size: 22rpx;
|
||||
color: #8a9fb8;
|
||||
flex-shrink: 0;
|
||||
min-width: 60rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.audio-progress-bar {
|
||||
flex: 1;
|
||||
height: 6rpx;
|
||||
background-color: #e8edf3;
|
||||
border-radius: 3rpx;
|
||||
overflow: hidden;
|
||||
.audio-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6b7c93 0%, #4a5a72 100%);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6b7c93 0%, #4a5a72 100%);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
&::after { border: none; }
|
||||
.iconfont {
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
589
pages_tool/files/detail.vue
Normal file
589
pages_tool/files/detail.vue
Normal file
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<view class="files-detail" :style="themeColor">
|
||||
<view class="file-card">
|
||||
<view class="pdf-icon">
|
||||
<text class="pdf-icon-p">P</text>
|
||||
<view class="pdf-icon-fold"></view>
|
||||
<view class="pdf-icon-lines">
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
<view class="pdf-icon-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-wrap">
|
||||
<view class="title">{{ displayTitle }}</view>
|
||||
<view v-if="!loaded" class="read-wrap">
|
||||
<text>{{ $lang('files.list.loading') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="fileUrl" class="action-area">
|
||||
<button class="action-btn primary" @tap="openFile">{{ $lang('files.list.viewPdf') }}</button>
|
||||
</view>
|
||||
|
||||
<view v-if="failed" class="error-tip">{{ $lang('files.list.fileNotExist') }}</view>
|
||||
|
||||
<loading-cover ref="loadingCover"></loading-cover>
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 文件详情页 —— 承接 DIY 装修里的文件链接: /pages_tool/files/detail?files_id=43&title=xxx
|
||||
*
|
||||
* 背景: 后台装修数据(如"图标导航/文本导航"的 FILES_xx 链接)把文件指向本页,
|
||||
* 此前页面缺失导致点击后打不开。本页负责:
|
||||
* 1) 按 files_id 取到文件信息(files_url / files_title)
|
||||
* 2) 进入页面即自动按平台打开文件(与 files/list 点击行为一致)
|
||||
* 3) 自动打开失败时,可点击"查看PDF文件"按钮重试
|
||||
*
|
||||
* 注1: 本页文案只使用语言包中已存在的 key(files.list.*),避免新增 key 未同步到服务端
|
||||
* (lang 为 remote-only 模式,运行时取 /api/lang/bundle)。
|
||||
* 注2: Files 控制器没有单文件详情接口,只能从列表接口里按 files_id 取,详见 fetchFile()。
|
||||
*/
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
filesId: '',
|
||||
filesTitle: '',
|
||||
detail: null,
|
||||
fileUrl: '',
|
||||
loaded: false,
|
||||
failed: false,
|
||||
// 排查用:记录列表接口扫过的文件(files_id:标题)
|
||||
scanned: [],
|
||||
titleTimers: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
displayTitle() {
|
||||
if (this.filesTitle) return this.filesTitle;
|
||||
if (this.detail && (this.detail.files_title || this.detail.title)) {
|
||||
return this.detail.files_title || this.detail.title;
|
||||
}
|
||||
return this.$lang('files.list.title');
|
||||
},
|
||||
themeColor() {
|
||||
if (this.$shop && this.$shop.themeColor) {
|
||||
return `--theme-color: ${this.$shop.themeColor};`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
options = options || {};
|
||||
// page_params 可能是 JSON 字符串 / 对象 / 数组(装修数据里常见 [])
|
||||
const params = this.$util.parsePageParams(options.page_params) || {};
|
||||
const rawId = options.files_id || options.file_id || options.filesId || options.id
|
||||
|| params.files_id || params.file_id || params.id || '';
|
||||
this.filesId = rawId === '' || rawId === undefined || rawId === null ? '' : String(rawId);
|
||||
|
||||
const rawTitle = options.title || params.title || '';
|
||||
if (rawTitle) this.filesTitle = this.decodeTitle(rawTitle);
|
||||
this.scheduleTitleSync();
|
||||
this.getData();
|
||||
},
|
||||
onUnload() {
|
||||
this.clearTitleTimers();
|
||||
},
|
||||
watch: {
|
||||
// main.js 的全局 mixin 监听 componentRefresh,变化时调用 $langConfig.refresh(),
|
||||
// 而 refresh() 会用 $lang('title') 重置导航标题。本页标题就是文件名、且远端语言包里
|
||||
// 没有 files/detail 词条(remote-only 模式),$lang('title') 会退化成字面量 "title"。
|
||||
// refresh() 对每个组件都会跑一遍、"最后一个生效",所以这里延时补写几次兜底。
|
||||
componentRefresh() {
|
||||
if (this.isCurrentPage()) this.scheduleTitleSync();
|
||||
}
|
||||
},
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.displayTitle,
|
||||
path: `/pages_tool/files/detail?files_id=${this.filesId}`
|
||||
};
|
||||
},
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.displayTitle,
|
||||
query: `files_id=${this.filesId}`
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
syncNavigationTitle() {
|
||||
this.$langConfig.title(this.displayTitle);
|
||||
},
|
||||
// 导航标题兜底:立即写一次,再延时补几次(等全局 refresh() 都跑完)
|
||||
scheduleTitleSync() {
|
||||
this.clearTitleTimers();
|
||||
this.syncNavigationTitle();
|
||||
[100, 500, 1500].forEach(delay => {
|
||||
this.titleTimers.push(setTimeout(() => {
|
||||
if (this.isCurrentPage()) this.syncNavigationTitle();
|
||||
}, delay));
|
||||
});
|
||||
},
|
||||
clearTitleTimers() {
|
||||
this.titleTimers.forEach(id => clearTimeout(id));
|
||||
this.titleTimers = [];
|
||||
},
|
||||
// 本页是否在最上层(避免本页在后台时去改别的页面标题)
|
||||
isCurrentPage() {
|
||||
try {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
if (!current) return false;
|
||||
const route = current.route || (current.$page && current.$page.path) || '';
|
||||
return route.indexOf('files/detail') !== -1;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
decodeTitle(value) {
|
||||
const str = String(value || '');
|
||||
// 多数端 uni 已自动 decode 一次,这里只在明显还是百分号编码时才解码,避免二次解码破坏文案
|
||||
if (!str || str.indexOf('%') === -1) return str;
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
} catch (e) {
|
||||
return str;
|
||||
}
|
||||
},
|
||||
hideLoadingCover() {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
},
|
||||
async getData() {
|
||||
if (!this.filesId) {
|
||||
this.finishLoad(null);
|
||||
return;
|
||||
}
|
||||
// 优先走后端单文件详情接口: 按 files_id 直接取, 无需翻列表(文件多时省几十个请求)
|
||||
let detail = await this.fetchGetfile(this.filesId);
|
||||
// 兜底: 列表逐页扫(可能为空/被过滤) / 企业文件接口
|
||||
if (!detail) detail = await this.fetchFile(this.filesId) || await this.fetchFromPersonnel();
|
||||
if (!detail || !this.normalizeUrl(detail.files_url)) {
|
||||
this.finishLoad(null);
|
||||
this.$util.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.detail = detail;
|
||||
if (!this.filesTitle) this.filesTitle = detail.files_title || detail.title || '';
|
||||
this.syncNavigationTitle();
|
||||
this.fileUrl = this.normalizeUrl(detail.files_url);
|
||||
this.finishLoad(detail);
|
||||
// 进入详情页即自动打开,与点击文件列表项行为一致
|
||||
this.openFile();
|
||||
},
|
||||
finishLoad(detail) {
|
||||
this.loaded = true;
|
||||
this.failed = !detail;
|
||||
this.hideLoadingCover();
|
||||
},
|
||||
/**
|
||||
* 取单个文件信息(优先链路)。
|
||||
* 后端接口: GET /api/files/getfile?files_id=xxx
|
||||
* 内部 getFilesInfo([['files_id','=',$files_id]]) → model('files')->getInfo(),
|
||||
* 返回整条记录(files_title / files_url / files_desc / category_id 等),
|
||||
* files_url 已 img() 补全域名; files 表带 site_id, bootSiteGuard 自动注入当前站点,多租户不串号。
|
||||
* 仅 files_id 一个参数即可,后端无需改造。
|
||||
*/
|
||||
async fetchGetfile(filesId) {
|
||||
const res = await this.request('/api/files/getfile', { files_id: filesId });
|
||||
if (!res || res.code !== 0 || !res.data) return null;
|
||||
const d = res.data;
|
||||
return {
|
||||
files_id: d.files_id || filesId,
|
||||
files_title: d.files_title || d.title || '',
|
||||
files_url: d.files_url || '',
|
||||
files_desc: d.files_desc,
|
||||
category_id: d.category_id,
|
||||
category_name: d.category_name
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 兜底链路(优先链路 fetchGetfile 失败时): 逐页拉列表按 files_id 精确匹配。
|
||||
* 该 addon 之前 Files 控制器只提供列表接口(实测 /api/files/info、/detail、/getinfo 均 404:
|
||||
* "method not exists:app\api\controller\Files->info()"),所以用 getpage 翻页扫。
|
||||
*/
|
||||
async fetchFile(filesId) {
|
||||
// 1) 有标题时先拿标题当关键词搜一次(命中就 1 个请求解决,不必深翻页)
|
||||
if (this.filesTitle) {
|
||||
const searched = await this.queryList({ page: 1, page_size: 100, keyword: this.filesTitle }, filesId, true);
|
||||
if (searched && searched.hit) return searched.hit;
|
||||
}
|
||||
// 2) 逐页遍历全量列表(后端只返回 10 条/页时最多翻 30 页)
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const queried = await this.queryList({ page: page, page_size: 100 }, filesId);
|
||||
if (queried && queried.hit) return queried.hit;
|
||||
if (!queried || queried.empty) return null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* 兜底链路: 从企业文件接口(/api/member/personnel, 电子名片页"企业文件"同源)取文件。
|
||||
* 该接口返回 res.file_list, 每项形如 { name, url }(可能带 files_id/file_id/id)。
|
||||
* 先按 files_id 精确匹配, 再按标题精确匹配, 最后按标题互相包含模糊匹配。
|
||||
*/
|
||||
async fetchFromPersonnel() {
|
||||
const res = await this.request('/api/member/personnel', {});
|
||||
if (!res || res.code !== 0) return null;
|
||||
const list = res.file_list || (res.data && res.data.file_list) || [];
|
||||
if (!Array.isArray(list)) return null;
|
||||
const title = (this.filesTitle || '').toString();
|
||||
const getId = item => item.files_id || item.file_id || item.id || '';
|
||||
let fuzzyHit = null;
|
||||
for (const item of list) {
|
||||
if (!item) continue;
|
||||
// 1) files_id 精确匹配
|
||||
const id = getId(item);
|
||||
if (id !== '' && String(id) === String(this.filesId)) return this.wrapPersonnelFile(item);
|
||||
// 2) 标题精确匹配
|
||||
const name = (item.name || item.files_title || item.title || '').toString();
|
||||
if (title && name === title) return this.wrapPersonnelFile(item);
|
||||
// 3) 标题互相包含(记下第一个命中, 优先级最低)
|
||||
if (title && !fuzzyHit && (name.indexOf(title) !== -1 || title.indexOf(name) !== -1)) {
|
||||
fuzzyHit = item;
|
||||
}
|
||||
}
|
||||
return fuzzyHit ? this.wrapPersonnelFile(fuzzyHit) : null;
|
||||
},
|
||||
wrapPersonnelFile(item) {
|
||||
return {
|
||||
files_id: item.files_id || item.file_id || item.id || this.filesId,
|
||||
files_title: item.name || item.files_title || item.title || '',
|
||||
files_url: item.url || item.files_url || ''
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 请求一页文件列表,并按 files_id 精确匹配(入参与 files/list 页保持一致)
|
||||
* @param {Boolean} allowTitleMatch 该页是标题搜索结果时,允许退一步用标题全等匹配
|
||||
* @returns {Object|null} 命中返回 { hit };本页无数据返回 { empty: true };请求失败返回 null
|
||||
*/
|
||||
async queryList(data, filesId, allowTitleMatch) {
|
||||
const res = await this.request('/api/files/getpage', Object.assign({
|
||||
category_id: '',
|
||||
keyword: ''
|
||||
}, data));
|
||||
if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) return null;
|
||||
const list = res.data.list;
|
||||
let titleHit = null;
|
||||
for (const item of list) {
|
||||
const src = item && item.$orig ? item.$orig : item;
|
||||
if (!src) continue;
|
||||
if (String(src.files_id) === String(filesId)) return { hit: src };
|
||||
// 兜底:装修链接里的 files_id 与接口不一致时,用标题全等再认一次
|
||||
if (allowTitleMatch && !titleHit && this.filesTitle && src.files_title === this.filesTitle) {
|
||||
titleHit = src;
|
||||
}
|
||||
}
|
||||
if (titleHit) return { hit: titleHit };
|
||||
return { empty: list.length === 0 };
|
||||
},
|
||||
async request(url, data) {
|
||||
try {
|
||||
return await this.$api.sendRequest({
|
||||
url,
|
||||
data,
|
||||
async: false,
|
||||
noRetry: true,
|
||||
// 失败静默返回 null,由调用方兜底,不弹全局报错
|
||||
silentError: true
|
||||
});
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
normalizeUrl(url) {
|
||||
if (!url) return '';
|
||||
const str = String(url);
|
||||
if (/^https?:\/\//i.test(str)) return str;
|
||||
// $util.img 对含逗号的路径会按"多图字段"截断,文件名可能含逗号,这里规避
|
||||
if (str.indexOf(',') !== -1) return str;
|
||||
// 相对路径补全域名(与图片同一套域名规则)
|
||||
return this.$util.img(str);
|
||||
},
|
||||
getFileExt() {
|
||||
let ext = 'pdf';
|
||||
if (this.fileUrl) {
|
||||
const clean = this.fileUrl.split('?')[0];
|
||||
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
|
||||
if (matched) ext = matched[1].toLowerCase();
|
||||
}
|
||||
return ext;
|
||||
},
|
||||
getFileName() {
|
||||
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
return `${title}.${this.getFileExt()}`;
|
||||
},
|
||||
getFileType() {
|
||||
const ext = this.getFileExt();
|
||||
return ['pdf'].includes(ext) ? 'pdf'
|
||||
: ['doc', 'docx'].includes(ext) ? 'word'
|
||||
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext) ? 'video'
|
||||
: 'pdf';
|
||||
},
|
||||
buildPreviewUrl() {
|
||||
const fileName = encodeURIComponent(this.getFileName());
|
||||
const fileUrl = encodeURIComponent(this.fileUrl);
|
||||
const fileType = encodeURIComponent(this.getFileType());
|
||||
return `/pages_tool/file-preview/file-preview?fileName=${fileName}&fileUrl=${fileUrl}&fileType=${fileType}`;
|
||||
},
|
||||
openFile() {
|
||||
if (!this.fileUrl) {
|
||||
this.failed = true;
|
||||
this.$util.showToast({
|
||||
title: this.$lang('files.list.fileNotExist'),
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.failed = false;
|
||||
const ext = this.getFileExt();
|
||||
const previewPage = this.buildPreviewUrl();
|
||||
|
||||
// ========== H5 端: 用预览页替换当前页(web-view 走 iframe),失败则新标签页打开 ==========
|
||||
// #ifdef H5
|
||||
uni.redirectTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
try {
|
||||
window.open(this.fileUrl, '_blank');
|
||||
} catch (e) {
|
||||
this.failed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
// #endif
|
||||
|
||||
// ========== 非 H5 端: 下载文件后按平台分支打开 ==========
|
||||
// #ifndef H5
|
||||
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
|
||||
uni.downloadFile({
|
||||
url: this.fileUrl,
|
||||
success: res => {
|
||||
if (res.statusCode !== 200) {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 微信小程序: saveFile 重命名 + openDocument(标题取短文件名) -----
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager();
|
||||
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
|
||||
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
|
||||
const openByPath = (filePath, cleanup) => {
|
||||
uni.openDocument({
|
||||
filePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: () => {
|
||||
if (cleanup) {
|
||||
try { fs.removeSavedFile({ filePath }); } catch (e) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
try {
|
||||
fs.saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
filePath: targetPath,
|
||||
success: r => {
|
||||
uni.hideLoading();
|
||||
openByPath(r.savedFilePath, true);
|
||||
},
|
||||
fail: () => {
|
||||
// saveFile 失败(配额等),直接用临时文件兜底
|
||||
uni.hideLoading();
|
||||
openByPath(res.tempFilePath, false);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
uni.hideLoading();
|
||||
openByPath(res.tempFilePath, false);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// ----- App / 其他小程序: 直接 openDocument;失败兜底跳转预览页 -----
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.hideLoading();
|
||||
uni.openDocument({
|
||||
filePath: res.tempFilePath,
|
||||
fileType: ext,
|
||||
showMenu: true,
|
||||
fail: () => {
|
||||
uni.navigateTo({
|
||||
url: previewPage,
|
||||
fail: () => {
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
},
|
||||
complete: res => {
|
||||
if (res && res.statusCode === 404) {
|
||||
uni.hideLoading();
|
||||
this.failed = true;
|
||||
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
|
||||
}
|
||||
}
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.files-detail {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 30rpx;
|
||||
box-sizing: border-box;
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.files-detail .file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon {
|
||||
margin-right: 24rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: #e64340;
|
||||
border-radius: 10rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-p {
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 10rpx;
|
||||
color: #ffffff;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-lines {
|
||||
position: absolute;
|
||||
right: 8rpx;
|
||||
bottom: 8rpx;
|
||||
width: 42rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line {
|
||||
width: 100%;
|
||||
height: 3rpx;
|
||||
background-color: #ffffff;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(2) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(3) {
|
||||
width: 60%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .title {
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .read-wrap {
|
||||
display: flex;
|
||||
color: #999ca7;
|
||||
align-items: center;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.files-detail .file-card .info-wrap .read-wrap text {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.files-detail .action-area {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.files-detail .action-area .action-btn {
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 30rpx;
|
||||
border-radius: 42rpx;
|
||||
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
|
||||
}
|
||||
|
||||
.files-detail .action-area .action-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.files-detail .error-tip {
|
||||
margin-top: 30rpx;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -98,6 +98,7 @@ const store = new Vuex.Store({
|
||||
wap_valid_time: 0,
|
||||
},
|
||||
copyright: null,
|
||||
floatingButtonConfig: null, // 来自 /api/config/init 的 data.floating_button (浮动侧边栏配置)
|
||||
initStatus: false,
|
||||
offlineWhiteList: ['pages_order/payment', 'pages_order/list', 'pages_order/detail'],//线下支付白名单
|
||||
canReceiveRegistergiftInfo: {
|
||||
@@ -164,10 +165,23 @@ const store = new Vuex.Store({
|
||||
setShopInfo(state, value) {
|
||||
state.shopInfo = value;
|
||||
uni.setStorageSync('shopInfo', value); // 初始化数据调用
|
||||
// 语言切换开关: 后台 shop_info.ischia 决定。
|
||||
// 关闭(ischia 为假)时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
|
||||
// 语言切换开关 fallback: 后台 shop_info.ischina 决定(老「双语模式」)。
|
||||
// 新配置下发后由 setFloatingButtonConfig 里的 lang_switch.enabled 接管,
|
||||
// 这里的 ischina 只是配置缺失(老缓存响应)时的兜底。
|
||||
// 关闭时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
|
||||
// 满足"后台关闭双语/小程序后,刷新重新进入必须是中文"的需求。
|
||||
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischia)); } catch (e) {}
|
||||
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischina)); } catch (e) {}
|
||||
},
|
||||
// 浮动侧边栏配置 (来自 /api/config/init → data.floating_button)
|
||||
setFloatingButtonConfig(state, value) {
|
||||
state.floatingButtonConfig = value || null;
|
||||
uni.setStorageSync('floatingButtonConfig', value || null);
|
||||
// 与 setShopInfo 的 setLangSwitchEnabled 配对: 新配置存在时以新配置为准
|
||||
try {
|
||||
if (value && value.lang_switch) {
|
||||
LangConfig.setLangSwitchEnabled(Number(value.lang_switch.enabled) === 1);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
setCartChange(state) {
|
||||
state.cartChange += 1;
|
||||
@@ -315,6 +329,8 @@ const store = new Vuex.Store({
|
||||
//联系我们
|
||||
|
||||
this.commit('setShopInfo', data.shop_info);
|
||||
// 浮动侧边栏配置; 缺失时显式置 null → 前端走老行为兜底
|
||||
this.commit('setFloatingButtonConfig', data.floating_button || null);
|
||||
this.commit('setWechatConfigStatus', data?.wechat_config_status);
|
||||
// 默认总店
|
||||
if (data.store_info) {
|
||||
|
||||
@@ -65,6 +65,16 @@
|
||||
"when": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限
|
||||
"name": "ohos.permission.MICROPHONE",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.VIBRATE"
|
||||
}
|
||||
|
||||
144
tests/media-upload-fix.test.js
Normal file
144
tests/media-upload-fix.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// tests/media-upload-fix.test.js
|
||||
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 单元测试核心逻辑修复
|
||||
// - getFileSize 失败抛错而不是 resolve(0)
|
||||
// - ext 提取: blob URL → 不取错 URL 整段
|
||||
// - guessMime 包含 HEIC/HEIF/AVIF/BMP/TIFF 等现代格式
|
||||
// - uploadViaSts 返回的 size 不再是 0 (用已知 size)
|
||||
//
|
||||
// 注意: 这是白盒 smoke, 不 mock uni.* API. 测试纯函数层逻辑.
|
||||
|
||||
const { test: nodeTest } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// 加载被测模块: 用 rewire 风格手动注入 mock
|
||||
// 因为 media-upload.js 直接 import ai-service, 这里只测可独立验证的 helper
|
||||
|
||||
// 我们直接 require 然后 inspect 内部状态. 不行就复制粘贴. 这里走第二种.
|
||||
|
||||
// === guessMime 等价实现 (复制自 media-upload.js, 测试时和源文件保持同步) ===
|
||||
const EXT_MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
||||
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
|
||||
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
||||
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
||||
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip', rar: 'application/x-rar-compressed',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
|
||||
pdf: 'application/pdf', txt: 'text/plain',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}
|
||||
function guessMime(ext) {
|
||||
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
// === ext 提取 (复制自 uploadViaSts 顶部) ===
|
||||
function extractExt(filePath) {
|
||||
let ext = 'bin'
|
||||
if (typeof filePath === 'string') {
|
||||
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
||||
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
|
||||
if (m) ext = m[1].toLowerCase()
|
||||
}
|
||||
if (ext === 'bin' && filePath && filePath.name) {
|
||||
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
||||
if (m2) ext = m2[1].toLowerCase()
|
||||
}
|
||||
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
|
||||
return ext
|
||||
}
|
||||
|
||||
nodeTest('guessMime 现代格式 (HEIC/AVIF/BMP/TIFF)', () => {
|
||||
assert.equal(guessMime('heic'), 'image/heic')
|
||||
assert.equal(guessMime('heif'), 'image/heif')
|
||||
assert.equal(guessMime('avif'), 'image/avif')
|
||||
assert.equal(guessMime('bmp'), 'image/bmp')
|
||||
assert.equal(guessMime('tif'), 'image/tiff')
|
||||
assert.equal(guessMime('tiff'), 'image/tiff')
|
||||
// 老格式不破坏
|
||||
assert.equal(guessMime('jpg'), 'image/jpeg')
|
||||
assert.equal(guessMime('png'), 'image/png')
|
||||
})
|
||||
|
||||
nodeTest('guessMime 未知扩展名降级', () => {
|
||||
assert.equal(guessMime('xyz'), 'application/octet-stream')
|
||||
assert.equal(guessMime(''), 'application/octet-stream')
|
||||
})
|
||||
|
||||
nodeTest('guessMime 办公格式', () => {
|
||||
assert.equal(guessMime('docx'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
assert.equal(guessMime('pptx'), 'application/vnd.openxmlformats-officedocument.presentationml.presentation')
|
||||
assert.equal(guessMime('zip'), 'application/zip')
|
||||
})
|
||||
|
||||
nodeTest('extractExt 普通路径', () => {
|
||||
assert.equal(extractExt('/tmp/upload/abc.heic'), 'heic')
|
||||
assert.equal(extractExt('blob:https://example.com/photo.png'), 'png')
|
||||
assert.equal(extractExt('http://x.com/y/test.jpg?ver=1'), 'jpg')
|
||||
assert.equal(extractExt('C:\\Users\\test\\photo.heic'), 'heic')
|
||||
})
|
||||
|
||||
nodeTest('extractExt blob URL 无扩展名时降级', () => {
|
||||
// 关键 case: blob:https://xxx/uuid 没有扩展名
|
||||
const ext = extractExt('blob:https://example.com/uuid-12345')
|
||||
// 不应该返回 'https' 或 'example' 这种 URL 片段
|
||||
assert.match(ext, /^[a-z0-9]{2,5}$/)
|
||||
assert.notEqual(ext, 'https')
|
||||
assert.notEqual(ext, 'example')
|
||||
// 应该是 'bin' (兜底)
|
||||
assert.equal(ext, 'bin')
|
||||
})
|
||||
|
||||
nodeTest('extractExt file 对象带 name', () => {
|
||||
const fileObj = { name: 'report.pdf', size: 1024 }
|
||||
assert.equal(extractExt(fileObj), 'pdf')
|
||||
assert.equal(extractExt({ name: 'image.HEIC' }), 'heic') // 大写也接受
|
||||
})
|
||||
|
||||
nodeTest('extractExt 极端情况', () => {
|
||||
assert.equal(extractExt(null), 'bin')
|
||||
assert.equal(extractExt(undefined), 'bin')
|
||||
assert.equal(extractExt(''), 'bin')
|
||||
assert.equal(extractExt(123), 'bin') // 非字符串
|
||||
})
|
||||
|
||||
// === 测试 useSts 决策逻辑 ===
|
||||
// 复制自 uploadImage 末尾 (行为必须和源文件保持同步)
|
||||
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024
|
||||
function decideSts(detectedSize, fileSizeFinal, totallyUnknown, strategy) {
|
||||
const useSts = strategy === 'oss_sts'
|
||||
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
||||
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
|
||||
return finalUseSts
|
||||
}
|
||||
|
||||
nodeTest('decideSts: oss_sts 永远走 STS', () => {
|
||||
assert.equal(decideSts(100, 100, false, 'oss_sts'), true)
|
||||
assert.equal(decideSts(0, 0, true, 'oss_sts'), true)
|
||||
})
|
||||
|
||||
nodeTest('decideSts: backend 永不 STS', () => {
|
||||
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'backend'), false)
|
||||
})
|
||||
|
||||
nodeTest('decideSts: auto + 小图走 PHP', () => {
|
||||
assert.equal(decideSts(500 * 1024, 500 * 1024, false, 'auto'), false)
|
||||
})
|
||||
|
||||
nodeTest('decideSts: auto + 大图走 STS', () => {
|
||||
assert.equal(decideSts(5 * 1024 * 1024, 5 * 1024 * 1024, false, 'auto'), true)
|
||||
})
|
||||
|
||||
nodeTest('decideSts: auto + 探测完全失败兜底走 STS (防 OOM 关键 case)', () => {
|
||||
// 修复前: fileSizeFinal=0 → useSts=false → 走 PHP 中转 → 大图 OOM
|
||||
// 修复后: totallyUnknown=true → finalUseSts=true → 走 STS 直传
|
||||
assert.equal(decideSts(0, 0, true, 'auto'), true)
|
||||
})
|
||||
|
||||
console.log('✓ 所有 PR-FRONTEND-MEDIA-UPLOAD-FIX 单测通过')
|
||||
Reference in New Issue
Block a user