Compare commits
32 Commits
custom/351
...
166a284359
| Author | SHA1 | Date | |
|---|---|---|---|
| 166a284359 | |||
| 5efae5c38b | |||
| 4168c24fd6 | |||
| 762401777b | |||
| 24c4e40df9 | |||
| c58734fcda | |||
| bbc9fdd6ea | |||
| 4cd21ade51 | |||
| 0799e47233 | |||
| 306f2f7ff7 | |||
| fff10a2e19 | |||
| 9d990e6aac | |||
| 74468e2c9d | |||
| 0ee97f902d | |||
| 3b996485e8 | |||
| d18df73849 | |||
| bac5583ecb | |||
| 942d85de19 | |||
| ae5921aa90 | |||
| c104f6c443 | |||
| 747a15f16e | |||
| b93752d58c | |||
| 619e1dc9ef | |||
| 500328ef55 | |||
| 6fa0603783 | |||
| 34c712db32 | |||
| b038175f07 | |||
| f18577a46c | |||
| 9915fb9bb1 | |||
| bb34e32f87 | |||
| 45e997adae | |||
| 2e4f11d108 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,8 +5,9 @@
|
||||
/iconfont-preview.html
|
||||
|
||||
.codebuddy
|
||||
.workbuddy
|
||||
.vscode
|
||||
.trae
|
||||
.trae*
|
||||
# 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓)
|
||||
# 注意: 如果 .local.config.js 已经被之前的 commit 入仓,
|
||||
# 需要手动 `git rm --cached .local.config.js` 并用 filter-branch
|
||||
|
||||
13
App.vue
13
App.vue
@@ -9,6 +9,11 @@
|
||||
// 登录态恢复后通知 aiService 清旧 user key,避免串会话
|
||||
import aiService from 'common/js/ai-service.js';
|
||||
|
||||
// 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化),
|
||||
// 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。
|
||||
// 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。
|
||||
let _appHasShown = false;
|
||||
|
||||
import {
|
||||
Weixin
|
||||
} from 'common/js/wx-jssdk.js';
|
||||
@@ -502,6 +507,14 @@
|
||||
},
|
||||
onShow: function(options) {
|
||||
// #ifdef MP
|
||||
// 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在,
|
||||
// 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示,
|
||||
// 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。
|
||||
if (_appHasShown) {
|
||||
try { Lang.onAppReentry(); } catch (e) {}
|
||||
}
|
||||
_appHasShown = true;
|
||||
|
||||
// 自动授权登录
|
||||
this.getAuthInfo();
|
||||
if (this.$store.state.token) {
|
||||
|
||||
@@ -523,6 +523,9 @@ export default {
|
||||
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: {
|
||||
@@ -588,6 +591,8 @@ export default {
|
||||
user_id: getUserId(),
|
||||
conversation_id: this.getConversationId() || '',
|
||||
attachments: opts.attachments || [],
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 透传位置/时间/页面上下文
|
||||
context: (opts && opts.context) || null,
|
||||
...getDomainParams()
|
||||
})
|
||||
});
|
||||
@@ -608,7 +613,11 @@ export default {
|
||||
*/
|
||||
async _fallbackToHttp(message, onEvent, onComplete, opts = {}) {
|
||||
try {
|
||||
const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] });
|
||||
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 = {
|
||||
@@ -666,6 +675,8 @@ export default {
|
||||
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()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) {
|
||||
const version = String(payload.version || '');
|
||||
const sign = payload.sign || '';
|
||||
|
||||
// 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时,
|
||||
// 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败
|
||||
// (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因)
|
||||
if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) {
|
||||
console.warn(
|
||||
'[lang-loader] bundle tree INCOMPLETE. ' +
|
||||
'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) +
|
||||
' lang=' + lang +
|
||||
' appType=' + appType +
|
||||
' uniacid=' + uniacid +
|
||||
' version=' + version +
|
||||
' hasPayloadTree=' + ((payload && payload.tree) !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
// 4) 签名校验(可选)
|
||||
const secret = langConfig.remote.signSecret || '';
|
||||
const signValid = validateSign(tree, version, sign, secret);
|
||||
|
||||
@@ -619,6 +619,26 @@ function loadLangPackSync(lang, path) {
|
||||
* 获得当前本地语言
|
||||
* @returns
|
||||
*/
|
||||
/**
|
||||
* 语言切换开关钩子(由后台 shop_info.ischia 决定,经 store/index.js 的 setShopInfo 写入)。
|
||||
*
|
||||
* 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischia
|
||||
* 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。
|
||||
*
|
||||
* 行为:
|
||||
* - 后台关闭双语/小程序(ischia 为假): 清掉用户此前持久化到 storage 的语种 + 缓存,
|
||||
* 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足
|
||||
* "后台关闭双语后, 刷新重新进入必须是中文" 的需求。
|
||||
* - 后台开启双语(ischia 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。
|
||||
*/
|
||||
function setLangSwitchEnabled(enabled) {
|
||||
if (!enabled) {
|
||||
// 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文
|
||||
invalidateStorageCache('lang');
|
||||
try { uni.removeStorageSync('lang'); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentLocale() {
|
||||
// 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归
|
||||
const val = cachedStorageGet('lang', null);
|
||||
@@ -964,6 +984,13 @@ export default {
|
||||
*/
|
||||
getCurrentLocale,
|
||||
|
||||
/**
|
||||
* 语言切换功能开关(后台 shop_info.ischia 决定)。
|
||||
* 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。
|
||||
* 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。
|
||||
*/
|
||||
setLangSwitchEnabled,
|
||||
|
||||
/**
|
||||
* 从后端数据中提取当前语言的文案(向后兼容)
|
||||
* 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式
|
||||
@@ -1045,6 +1072,13 @@ export default {
|
||||
// force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch
|
||||
let isComplete1 = isBundleComplete(result.tree);
|
||||
if (!isComplete1) {
|
||||
// [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components
|
||||
try {
|
||||
console.warn('[i18n:boot] bundle incomplete (1st fetch). ' +
|
||||
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||
', source=' + (result && result.source) +
|
||||
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||
} catch (e) {}
|
||||
// 强制重试前清掉所有 cache, 避免 force 路径残留旧数据
|
||||
delete remoteTreeCache[_preloadMk];
|
||||
result = await loadBundleTree({ lang, appType, uniacid, force: true });
|
||||
@@ -1055,6 +1089,13 @@ export default {
|
||||
// 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除)
|
||||
let isComplete2 = isBundleComplete(result.tree);
|
||||
if (!isComplete2) {
|
||||
// [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree
|
||||
try {
|
||||
console.warn('[i18n:boot] bundle incomplete (after force retry). ' +
|
||||
'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid +
|
||||
', source=' + (result && result.source) +
|
||||
', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {})));
|
||||
} catch (e) {}
|
||||
throw new Error('lang bundle unusable after force retry');
|
||||
}
|
||||
|
||||
@@ -1094,10 +1135,14 @@ export default {
|
||||
setRemoteStatus('fail', { reason: _reason, lang });
|
||||
bootCompleted = true;
|
||||
emitUpdated(lang);
|
||||
// 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail',
|
||||
// 否则 App.vue 会弹"Failed to load i18n"误报 toast。
|
||||
// remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。
|
||||
return {
|
||||
ok: true,
|
||||
source: 'cache',
|
||||
status: 'fail',
|
||||
status: 'ok',
|
||||
degraded: true,
|
||||
reason: _errMsg || 'fallback to cache',
|
||||
lang,
|
||||
cacheAge: cached.ts ? Date.now() - cached.ts : 0,
|
||||
@@ -1167,6 +1212,42 @@ export default {
|
||||
loadOverrides(lang);
|
||||
},
|
||||
|
||||
/**
|
||||
* 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用:
|
||||
* 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启),
|
||||
* 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在:
|
||||
* - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid
|
||||
* - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果
|
||||
* - remote-only 模式下远端 bundle 不会被重新拉取
|
||||
* 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前
|
||||
* storage 的 locale 重新读取并合并,不再沿用旧值。
|
||||
* 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。
|
||||
*/
|
||||
onAppReentry() {
|
||||
// [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry
|
||||
try {
|
||||
console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY +
|
||||
', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) +
|
||||
', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null)));
|
||||
} catch (e) {}
|
||||
|
||||
// 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid)
|
||||
invalidateStorageCache();
|
||||
// 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并
|
||||
invalidateLangCaches();
|
||||
// [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存)
|
||||
try {
|
||||
console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() +
|
||||
', freshUniacid=' + getCurrentUniacid());
|
||||
} catch (e) {}
|
||||
// 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染
|
||||
if (IS_REMOTE_ONLY) {
|
||||
this.boot();
|
||||
} else {
|
||||
this.refresh(true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类)
|
||||
* @param {(status:string, detail:object) => void} fn
|
||||
|
||||
@@ -40,10 +40,16 @@ const EXT_MIME_MAP = {
|
||||
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',
|
||||
|
||||
@@ -5,24 +5,6 @@
|
||||
import lang from './lang.js';
|
||||
|
||||
|
||||
/**
|
||||
* 判断是否为「微信剪贴板隐私声明缺失」导致的复制失败
|
||||
*
|
||||
* 微信自 2023-09-15 起把剪贴板接口纳入《小程序用户隐私保护指引》,
|
||||
* 若小程序后台未声明「剪贴板」(或声明未审核通过),调用会返回:
|
||||
* { errno: 112, errMsg: 'setClipboardData:fail api scope is not declared in the privacy agreement' }
|
||||
* 这是平台侧配置问题,不改代码也能复现,因此不能把原始英文 errMsg 直接展示给用户。
|
||||
*
|
||||
* @param {*} err uni.setClipboardData 的 fail 回调参数
|
||||
* @returns {Boolean} true=剪贴板隐私声明缺失
|
||||
*/
|
||||
export const isClipboardPrivacyError = (err) => {
|
||||
if (!err) return false;
|
||||
if (Number(err.errno) === 112) return true;
|
||||
const msg = `${err.errMsg || err.message || ''}`;
|
||||
return /privacy agreement/i.test(msg) || /api scope is not declared/i.test(msg);
|
||||
};
|
||||
|
||||
/**
|
||||
* 显示错误信息
|
||||
* @param {Exception} err
|
||||
@@ -115,12 +97,8 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {})
|
||||
fail: (err) => {
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
// 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误
|
||||
const tip = isClipboardPrivacyError(err)
|
||||
? lang.lang('uniappUtils.clipboardPrivacyNotDeclared')
|
||||
: (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'));
|
||||
uni.showToast({
|
||||
title: tip,
|
||||
title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'),
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
578
common/js/voice-recorder.js
Normal file
578
common/js/voice-recorder.js
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
// 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
|
||||
this._format = opts.format || 'mp3'
|
||||
this._sampleRate = opts.sampleRate || 16000
|
||||
this._channels = opts.numberOfChannels || 1
|
||||
this._bitRate = opts.encodeBitRate || 48000
|
||||
|
||||
this._listeners = { progress: [], error: [] }
|
||||
this._isRecording = false
|
||||
this._startTs = 0
|
||||
this._autoStopTimer = null
|
||||
|
||||
// 平台相关句柄
|
||||
this._h5 = null // { mediaRecorder, stream, audioCtx, analyser, chunks, mime, rafId }
|
||||
this._uniRec = null // uni.getRecorderManager() 返回值
|
||||
}
|
||||
|
||||
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 (isH5()) {
|
||||
return this._startH5()
|
||||
}
|
||||
if (isMpOrApp()) {
|
||||
return this._startUni()
|
||||
}
|
||||
return Promise.reject(_err('NOT_SUPPORTED', 'voice input not supported on this platform'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录音并返回产物。
|
||||
* resolve(null) 表示已取消 / 无产物,调用方应忽略。
|
||||
*/
|
||||
stop() {
|
||||
if (!this._isRecording) return Promise.resolve(null)
|
||||
if (isH5()) return this._stopH5()
|
||||
return this._stopUni()
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断录音并丢弃产物。
|
||||
*/
|
||||
cancel() {
|
||||
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() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let rec = null
|
||||
try {
|
||||
rec = uni.getRecorderManager()
|
||||
} catch (e) {
|
||||
reject(_err('NO_RECORDER_MANAGER', 'uni.getRecorderManager unavailable'))
|
||||
return
|
||||
}
|
||||
if (!rec) {
|
||||
reject(_err('NO_RECORDER_MANAGER', 'recorder manager unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
this._uniRec = rec
|
||||
|
||||
rec.onStart(() => {
|
||||
this._isRecording = true
|
||||
this._startTs = Date.now()
|
||||
this._setAutoStopTimer()
|
||||
resolve()
|
||||
})
|
||||
|
||||
rec.onStop((res) => {
|
||||
// res = { tempFilePath, duration, fileSize }
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
const mime = this._format === 'wav' ? 'audio/wav'
|
||||
: this._format === 'aac' ? 'audio/aac'
|
||||
: 'audio/mpeg'
|
||||
if (this._uniResolve) {
|
||||
const r = this._uniResolve
|
||||
this._uniResolve = null
|
||||
r({
|
||||
filePath: res.tempFilePath,
|
||||
blob: null,
|
||||
fileName: '',
|
||||
duration: typeof res.duration === 'number' ? res.duration : (Date.now() - this._startTs),
|
||||
mime,
|
||||
size: res.fileSize || 0,
|
||||
cancelled: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rec.onError((err) => {
|
||||
const e = _err('UNI_RECORD_ERROR', (err && (err.errMsg || err.message)) || 'record failed')
|
||||
this._uniRec = null
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
this._emit('error', e)
|
||||
if (this._uniResolve) {
|
||||
const r = this._uniResolve
|
||||
this._uniResolve = null
|
||||
r(null)
|
||||
} else {
|
||||
// 启动阶段的 reject
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
|
||||
// 振幅事件 (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({
|
||||
duration: this._maxDuration / 1000, // uni-app 单位:秒
|
||||
sampleRate: this._sampleRate,
|
||||
numberOfChannels: this._channels,
|
||||
encodeBitRate: this._bitRate,
|
||||
format: this._format,
|
||||
frameSize: 4 // KB,触发 onFrameRecorded 频率
|
||||
})
|
||||
} catch (e) {
|
||||
this._uniRec = null
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_stopUni() {
|
||||
return new Promise((resolve) => {
|
||||
const rec = this._uniRec
|
||||
if (!rec) { resolve(null); return }
|
||||
this._uniResolve = resolve
|
||||
try { rec.stop() } catch (e) { resolve(null) }
|
||||
})
|
||||
}
|
||||
|
||||
_cancelUni() {
|
||||
return new Promise((resolve) => {
|
||||
const rec = this._uniRec
|
||||
if (!rec) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this._uniResolve = () => resolve()
|
||||
try { rec.stop() } catch (e) {
|
||||
this._isRecording = false
|
||||
this._clearAutoStopTimer()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default createVoiceRecorder
|
||||
@@ -105,38 +105,4 @@ components/mescroll/components/mescroll-empty.json
|
||||
|
||||
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
|
||||
|
||||
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
|
||||
|
||||
#### 4. `setClipboardData:fail api scope is not declared in the privacy agreement`
|
||||
|
||||
**症状**:AI 智能(`pages_tool/ai-chat`)复制消息时 Toast 提示
|
||||
`复制失败:setClipboardData:fail api scope is not declared in the privacy agreement`,
|
||||
H5 正常,**只有微信小程序失败**。
|
||||
|
||||
**根因**:微信自 2023-09-15 起把剪贴板接口(`setClipboardData` / `getClipboardData`)纳入
|
||||
《小程序用户隐私保护指引》。小程序后台未声明「剪贴板」,或声明还在审核中时,调用直接失败,
|
||||
返回 `errno: 112`。这是**平台侧配置问题,不是代码问题**:
|
||||
`app.json` 的 `permission` / `requiredPrivateInfos` 都不涉及剪贴板,改代码无法绕过。
|
||||
|
||||
**正确处理**:
|
||||
- 微信公众平台 → 设置 → 基本设置 → 服务内容声明 → 用户隐私保护指引 → 更新 →
|
||||
在「开发者收集你的……信息」中勾选「剪贴板」并填写用途 → 提交审核;
|
||||
**审核通过后才生效**,通过后重新编译/发版即恢复。
|
||||
- 代码侧只做兜底:`common/js/uniapp.utils.js` 的 `isClipboardPrivacyError()` 识别
|
||||
`errno === 112` / errMsg 含 `privacy agreement`,用 i18n 文案替换微信原始英文错误
|
||||
(AI 聊天页 `copyMessage` 同样处理),不要把平台英文报错抛给用户。
|
||||
- 开发期想临时绕过校验:把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改成 `false`
|
||||
(**仅限本地调试**,上线前必须还原,并按上面的后台配置完成声明)。
|
||||
|
||||
**注意**:根目录的 `privacy.json`(内容为 `Scope.writeClipboard`)不是微信的隐私声明机制,
|
||||
未被任何代码或构建流程引用,不要把它当成剪贴板隐私的解决方案。
|
||||
|
||||
**代码侧备选(无法改后台、只想先跑起来时)**:
|
||||
- 写剪贴板在微信里只有 `wx.setClipboardData` 这一个 JS API,平台拦截 errno 112 时**任何代码都绕不过**,
|
||||
所以「复制」按钮在没声明剪贴板权限前必然失败。
|
||||
- AI 回复的**展示**走 `<rich-text>` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由
|
||||
`parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 `<rich-text :nodes>`。
|
||||
- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`),
|
||||
因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false`
|
||||
(仅本地调试,上线前必须还原)。
|
||||
- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。
|
||||
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
|
||||
@@ -2625,7 +2625,7 @@
|
||||
},
|
||||
"ai-chat": {
|
||||
"index": {
|
||||
"title": "AI智能",
|
||||
"title": "AI智能客服",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"send": "发送",
|
||||
@@ -2642,7 +2642,7 @@
|
||||
"loadMoreHistory": "加载更多历史消息",
|
||||
"loadingHistory": "加载历史消息中...",
|
||||
"aiThinking": "AI正在思考",
|
||||
"receivingReply": "思考中",
|
||||
"receivingReply": "正在接收回复",
|
||||
"aiThinkingStatus1": "AI正在思考",
|
||||
"aiThinkingStatus2": "AI正在分析",
|
||||
"aiThinkingStatus3": "AI正在检索",
|
||||
@@ -2652,10 +2652,6 @@
|
||||
"noContentToCopy": "无可复制的内容",
|
||||
"copySuccess": "已复制",
|
||||
"copySelected": "已复制选中内容",
|
||||
"selectTextToCopy": "选取文字复制",
|
||||
"selectedText": "已选文字",
|
||||
"copyAll": "复制全部",
|
||||
"selectTextTip": "长按文字拖动两端手柄,可自由选择复制范围",
|
||||
"copyFail": "复制失败",
|
||||
"copyFailWithReason": "复制失败:{reason}",
|
||||
"openingFile": "打开文件中...",
|
||||
|
||||
@@ -22,14 +22,25 @@ export const lang = {
|
||||
// ====== 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',
|
||||
loadingHistory: 'Loading history...',
|
||||
aiThinking: 'AI is thinking',
|
||||
receivingReply: 'Thinking',
|
||||
receivingReply: 'Receiving reply',
|
||||
aiThinkingStatus1: 'AI is thinking',
|
||||
aiThinkingStatus2: 'AI is analyzing',
|
||||
aiThinkingStatus3: 'AI is searching',
|
||||
@@ -41,7 +52,6 @@ export const lang = {
|
||||
copySelected: 'Copied selected content',
|
||||
copyFail: 'Copy failed',
|
||||
copyFailWithReason: 'Copy failed: {reason}',
|
||||
copyFailPrivacyReason: 'clipboard permission not declared, copy unavailable',
|
||||
openingFile: 'Opening file...',
|
||||
openingFileName: 'Opening file: {name}',
|
||||
openingFileTitle: 'Opening: {title}',
|
||||
@@ -130,12 +140,36 @@ export const lang = {
|
||||
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',
|
||||
// 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',
|
||||
@@ -150,9 +184,9 @@ export const lang = {
|
||||
logEventHandleError: 'Event handling error',
|
||||
logInitNavbarFailed: 'Failed to initialize navigation bar',
|
||||
logChatComponentNotInit: 'Chat component not initialized',
|
||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...',
|
||||
|
||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
||||
aiAgentDefaultTitle: 'AI Agent',
|
||||
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
|
||||
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...'
|
||||
|
||||
// ====== AI Agent general copy (customer service is the first agent) ======
|
||||
aiAgentDefaultTitle: 'AI Agent',
|
||||
aiAgentOpenFailed: 'Failed to open AI Agent, please try again later',
|
||||
aiAgentNotConfigured: 'This AI Agent is not enabled'}
|
||||
|
||||
@@ -126,8 +126,6 @@ export const lang = {
|
||||
copyTimeoutReason: 'Reason:',
|
||||
wechatClipboardReason1: '1. WeChat Platform -> User Privacy Protection Guidelines -> "Clipboard" feature not added or review not passed;',
|
||||
wechatClipboardReason2: '2. WeChat platform has restrictions on clipboard API call frequency',
|
||||
// readable tip when clipboard privacy scope is not declared (errno 112)
|
||||
clipboardPrivacyNotDeclared: 'Clipboard privacy permission is not configured',
|
||||
copyFailed: 'Copy failed',
|
||||
},
|
||||
|
||||
|
||||
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
83
lang/en-us/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
||||
export const lang = {
|
||||
// diy-recommend-code referral code component
|
||||
// Title
|
||||
title: 'My Referral Code',
|
||||
// Share now button
|
||||
buttonText: 'Share Now',
|
||||
// Copy code button
|
||||
copyButtonText: 'Copy Code',
|
||||
// Get a new code
|
||||
placeholderRegenerateText: 'Get a New Code',
|
||||
// Consent description
|
||||
consentText: 'Your referral code is used to track enterprise registrations via your shared link. We record each application completed through your link for commission settlement.',
|
||||
// Agree and get code button
|
||||
consentButtonText: 'Agree & Get Code',
|
||||
// Login guide tip
|
||||
loginTipText: 'Log in to view your referral code',
|
||||
// Log in button
|
||||
loginBtnText: 'Log In',
|
||||
// Poster QR tip
|
||||
posterQrTip: 'Scan to enter and fill in registration info',
|
||||
// Exclusive code label
|
||||
exclusiveCode: 'Exclusive Code',
|
||||
// Code pending label
|
||||
pendingCode: 'Code Pending',
|
||||
// Pending
|
||||
pending: 'Pending',
|
||||
// Loading code
|
||||
loading: 'Loading code...',
|
||||
// Code obtained label
|
||||
obtainedCode: 'Code Obtained',
|
||||
// Obtained
|
||||
obtained: 'Obtained',
|
||||
// Share invite reward description
|
||||
shareInviteReward: 'Share with friends and earn rewards for invites',
|
||||
// Active
|
||||
activated: '✓ Active',
|
||||
// Code generated description
|
||||
codeGenerated: 'Code generated. Use it to invite friends or bind referral for enterprise registration',
|
||||
// Submitting
|
||||
submitting: 'Submitting...',
|
||||
// Share title
|
||||
shareTitle: 'Inviting you to join the low-altitude economy platform',
|
||||
// Get failed
|
||||
getFail: 'Failed to get code. Please retry later',
|
||||
// Get failed (business code)
|
||||
getFailCode: 'Failed to get code (business code={code})',
|
||||
// Get success
|
||||
getSuccess: 'Obtained',
|
||||
// Network error
|
||||
netError: 'Network error. Please retry later',
|
||||
// Renew code modal title
|
||||
regenTitle: 'Renew Code',
|
||||
// Renew code modal content
|
||||
regenContent: 'After renewal, the old code becomes invalid immediately. All registrations via the old link will no longer be attributed to you. Continue?',
|
||||
// Confirm renew
|
||||
regenConfirm: 'Confirm Renew',
|
||||
// Cancel
|
||||
regenCancel: 'Cancel',
|
||||
// Renew failed
|
||||
regenFail: 'Renewal failed. Please retry later',
|
||||
// Renew success (old disabled)
|
||||
regenSuccessOld: 'New code issued. Old code {old} disabled',
|
||||
// Renew success
|
||||
regenSuccess: 'Renewed',
|
||||
// Code not ready yet
|
||||
codeNotReady: 'Code not ready yet. Please wait',
|
||||
// Code copied
|
||||
codeCopied: 'Code copied',
|
||||
// Copy failed
|
||||
copyFail: 'Copy failed',
|
||||
// Copy content is empty
|
||||
copyEmpty: 'Content is empty',
|
||||
// Copy after code finishes loading
|
||||
loadThenCopy: 'Copy after code finishes loading',
|
||||
// Share link copied
|
||||
linkCopied: 'Share link copied',
|
||||
// Poster subtitle
|
||||
posterShareInvite: 'Share with friends, earn rewards for invites',
|
||||
// Poster link prefix
|
||||
posterLinkPrefix: 'Link:',
|
||||
// Poster link not generated
|
||||
posterLinkEmpty: '(not generated)',
|
||||
}
|
||||
5
lang/en-us/components-diy/diy-webview.js
Normal file
5
lang/en-us/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const lang = {
|
||||
// diy-webview webview component
|
||||
emptyTip: 'Please configure the web link',
|
||||
loadError: 'Failed to load web page',
|
||||
}
|
||||
@@ -22,14 +22,24 @@ export const lang = {
|
||||
// ====== 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: '加载更多历史消息',
|
||||
loadingHistory: '加载历史消息中...',
|
||||
aiThinking: 'AI正在思考',
|
||||
receivingReply: '思考中',
|
||||
receivingReply: '正在接收回复',
|
||||
aiThinkingStatus1: 'AI正在思考',
|
||||
aiThinkingStatus2: 'AI正在分析',
|
||||
aiThinkingStatus3: 'AI正在检索',
|
||||
@@ -41,7 +51,6 @@ export const lang = {
|
||||
copySelected: '已复制选中内容',
|
||||
copyFail: '复制失败',
|
||||
copyFailWithReason: '复制失败:{reason}',
|
||||
copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用',
|
||||
openingFile: '打开文件中...',
|
||||
openingFileName: '正在打开文件:{name}',
|
||||
openingFileTitle: '正在打开:{title}',
|
||||
@@ -130,12 +139,36 @@ export const lang = {
|
||||
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: '当前平台不支持语音输入',
|
||||
// PR-FRONTEND-VOICE-INPUT (2026-09-24): 上传失败 (录音产物上传到云端)
|
||||
uploadFailed: '上传失败',
|
||||
uploadFailedWithReason: '上传失败:{reason}',
|
||||
|
||||
// ====== index.vue - 菜单 ======
|
||||
menuClearChat: '清空聊天',
|
||||
@@ -150,7 +183,7 @@ export const lang = {
|
||||
logEventHandleError: '事件处理错误',
|
||||
logInitNavbarFailed: '初始化导航栏失败',
|
||||
logChatComponentNotInit: '聊天组件未初始化',
|
||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...',
|
||||
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
|
||||
|
||||
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
|
||||
aiAgentDefaultTitle: 'AI 智能体',
|
||||
|
||||
@@ -126,8 +126,6 @@ export const lang = {
|
||||
copyTimeoutReason: '原因:',
|
||||
wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;',
|
||||
wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制',
|
||||
// 微信剪贴板隐私声明缺失(errno 112)时的可读提示
|
||||
clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置',
|
||||
copyFailed: '复制失败',
|
||||
},
|
||||
|
||||
|
||||
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
83
lang/zh-cn/components-diy/diy-recommend-code.js
Normal file
@@ -0,0 +1,83 @@
|
||||
export const lang = {
|
||||
// diy-recommend-code 推荐码组件
|
||||
// 标题
|
||||
title: '我的推荐码',
|
||||
// 立即分享按钮
|
||||
buttonText: '立即分享',
|
||||
// 复制推荐码按钮
|
||||
copyButtonText: '复制推荐码',
|
||||
// 换一个新码
|
||||
placeholderRegenerateText: '换一个新码',
|
||||
// 同意说明文案
|
||||
consentText: '您的推荐码将用于企业入驻来源追踪,我们会记录每一次通过您分享链接完成的入驻申请,用于您的推广收益结算。',
|
||||
// 同意并获取推荐码按钮
|
||||
consentButtonText: '同意并获取推荐码',
|
||||
// 未登录引导提示
|
||||
loginTipText: '登录后查看我的推荐码',
|
||||
// 去登录按钮
|
||||
loginBtnText: '去登录',
|
||||
// 海报二维码提示
|
||||
posterQrTip: '扫码进入并填写入驻信息',
|
||||
// 专属推荐码标签
|
||||
exclusiveCode: '专属推荐码',
|
||||
// 待获取推荐码标签
|
||||
pendingCode: '待获取推荐码',
|
||||
// 待获取
|
||||
pending: '待获取',
|
||||
// 推荐码加载中
|
||||
loading: '推荐码加载中...',
|
||||
// 已获得推荐码标签
|
||||
obtainedCode: '已获得推荐码',
|
||||
// 已获得
|
||||
obtained: '已获得',
|
||||
// 分享邀请奖励描述
|
||||
shareInviteReward: '分享给好友,邀请注册即可获得奖励',
|
||||
// 已生效
|
||||
activated: '✓ 已生效',
|
||||
// 推荐码已生成描述
|
||||
codeGenerated: '推荐码已生成,可用于邀请好友或企业入驻绑定推荐关系',
|
||||
// 提交中
|
||||
submitting: '提交中...',
|
||||
// 分享标题
|
||||
shareTitle: '邀您入驻低空经济产业平台',
|
||||
// 获取失败
|
||||
getFail: '推荐码获取失败,请稍后重试',
|
||||
// 获取失败(业务码)
|
||||
getFailCode: '推荐码获取失败(业务码={code})',
|
||||
// 获取成功
|
||||
getSuccess: '获取成功',
|
||||
// 网络异常
|
||||
netError: '网络异常,请稍后重试',
|
||||
// 换新码弹窗标题
|
||||
regenTitle: '换新码',
|
||||
// 换新码弹窗内容
|
||||
regenContent: '换新码后旧码会立即失效,后续所有使用旧分享链接的入驻申请都不会归属到您,确认继续?',
|
||||
// 确认换新
|
||||
regenConfirm: '确认换新',
|
||||
// 取消
|
||||
regenCancel: '取消',
|
||||
// 换新失败
|
||||
regenFail: '换新失败,请稍后重试',
|
||||
// 换新成功(旧码停用)
|
||||
regenSuccessOld: '已换新码,旧码 {old} 已停用',
|
||||
// 换新成功
|
||||
regenSuccess: '换新成功',
|
||||
// 推荐码还未生成
|
||||
codeNotReady: '推荐码还未生成,请稍候',
|
||||
// 推荐码已复制
|
||||
codeCopied: '推荐码已复制',
|
||||
// 复制失败
|
||||
copyFail: '复制失败',
|
||||
// 复制内容为空
|
||||
copyEmpty: '内容为空',
|
||||
// 推荐码加载完成后再复制
|
||||
loadThenCopy: '推荐码加载完成后再复制',
|
||||
// 推广链接已复制
|
||||
linkCopied: '推广链接已复制',
|
||||
// 海报副标题
|
||||
posterShareInvite: '分享给好友,邀请入驻获得奖励',
|
||||
// 海报链接前缀
|
||||
posterLinkPrefix: '链接:',
|
||||
// 海报链接未生成
|
||||
posterLinkEmpty: '(未生成)',
|
||||
}
|
||||
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
5
lang/zh-cn/components-diy/diy-webview.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export const lang = {
|
||||
// diy-webview 网页组件
|
||||
emptyTip: '请配置网页链接',
|
||||
loadError: '网页加载失败',
|
||||
}
|
||||
@@ -79,10 +79,13 @@
|
||||
},
|
||||
"scope.writePhotosAlbum" : {
|
||||
"desc" : "用于从相册选择和保存头像"
|
||||
},
|
||||
"scope.record" : {
|
||||
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
|
||||
"__usePrivacyCheck__" : false,
|
||||
"__usePrivacyCheck__" : true,
|
||||
"optimization" : {
|
||||
"subPackages" : true
|
||||
}
|
||||
|
||||
@@ -1086,7 +1086,7 @@
|
||||
{
|
||||
"path": "ai-chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "AI智能",
|
||||
"navigationBarTitleText": "AI智能客服",
|
||||
"componentPlaceholder": {
|
||||
"diy-article": "view",
|
||||
"diy-audio": "view",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -220,7 +220,8 @@ export default {
|
||||
if (type.indexOf('audio/') === 0) return 'audio'
|
||||
if (type.indexOf('video/') === 0) return 'video'
|
||||
const ext = String((file && file.name) || '').split('.').pop().toLowerCase()
|
||||
const audioExt = ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'oga', 'amr', 'wma', 'aiff', 'aif', 'opus', 'ape', 'mid']
|
||||
// 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'
|
||||
@@ -297,7 +298,7 @@ export default {
|
||||
chooseFileApi({
|
||||
count: 1,
|
||||
type: 'file',
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr', 'wma', 'aiff'],
|
||||
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
|
||||
@@ -315,7 +316,7 @@ export default {
|
||||
// #ifdef APP-PLUS
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
extension: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg', 'amr'],
|
||||
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
|
||||
@@ -392,6 +393,8 @@ export default {
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
@@ -1,239 +1,402 @@
|
||||
<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)
|
||||
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 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>
|
||||
@@ -40,20 +40,39 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
list: []
|
||||
list: [],
|
||||
// 从跳转 URL 上携带的参数
|
||||
categoryId: '',
|
||||
title: '',
|
||||
pageKey: '',
|
||||
pageParams: {}
|
||||
};
|
||||
},
|
||||
onLoad(options) {
|
||||
// 还原跳转时携带的过滤/展示参数,避免请求 API 时丢失
|
||||
if (options.category_id) this.categoryId = options.category_id;
|
||||
if (options.title) {
|
||||
this.title = decodeURIComponent(options.title);
|
||||
if (this.title) uni.setNavigationBarTitle({ title: this.title });
|
||||
}
|
||||
if (options.page_key) this.pageKey = options.page_key;
|
||||
this.pageParams = this.$util.parsePageParams(options.page_params) || {};
|
||||
},
|
||||
onShow() {
|
||||
this.setPublicShare();
|
||||
},
|
||||
methods: {
|
||||
getData(mescroll) {
|
||||
// 合并 page_params 与显式参数,保证后端拿到完整过滤条件
|
||||
let data = Object.assign({}, this.pageParams, {
|
||||
page_size: mescroll.size,
|
||||
page: mescroll.num
|
||||
});
|
||||
if (this.categoryId) data.category_id = this.categoryId;
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/article/page',
|
||||
data: {
|
||||
page_size: mescroll.size,
|
||||
page: mescroll.num
|
||||
},
|
||||
data: data,
|
||||
success: res => {
|
||||
let newArr = [];
|
||||
let msg = res.message;
|
||||
@@ -87,17 +106,32 @@ export default {
|
||||
// 设置公众号分享
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/article/list';
|
||||
let query = this.shareQuery();
|
||||
if (query) shareUrl += '?' + query;
|
||||
this.$util.setPublicShare({
|
||||
title: this.$lang('article.list.title'),
|
||||
title: this.title || this.$lang('article.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
});
|
||||
},
|
||||
// 组装带回过滤参数的 query 串,保证分享/跳转后参数不丢失
|
||||
shareQuery() {
|
||||
let params = {};
|
||||
if (this.categoryId) params.category_id = this.categoryId;
|
||||
if (this.title) params.title = encodeURIComponent(this.title);
|
||||
if (this.pageKey) params.page_key = this.pageKey;
|
||||
if (Object.keys(this.pageParams).length) params.page_params = encodeURIComponent(JSON.stringify(this.pageParams));
|
||||
return Object.keys(params)
|
||||
.map(k => k + '=' + params[k])
|
||||
.join('&');
|
||||
}
|
||||
},
|
||||
onShareAppMessage(res) {
|
||||
var title = this.$lang('article.list.title');
|
||||
var title = this.title || this.$lang('article.list.title');
|
||||
var path = '/pages_tool/article/list';
|
||||
var query = this.shareQuery();
|
||||
if (query) path += '?' + query;
|
||||
return {
|
||||
title: title,
|
||||
path: path,
|
||||
@@ -107,8 +141,10 @@ export default {
|
||||
},
|
||||
//分享到朋友圈
|
||||
onShareTimeline() {
|
||||
var title = this.$lang('article.list.title');
|
||||
var title = this.title || this.$lang('article.list.title');
|
||||
var query = '/pages_tool/article/list';
|
||||
var q = this.shareQuery();
|
||||
if (q) query += '?' + q;
|
||||
return {
|
||||
title: title,
|
||||
query: query,
|
||||
|
||||
@@ -8,7 +8,7 @@ const paths = [
|
||||
['pages_promotion/merch/detail','商户首页'],
|
||||
['pages_promotion/fenxiao/ranking_list','排行榜'],
|
||||
['pages_promotion/fenxiao/child_fenxiao','分销商'],
|
||||
['pages_tool/ai-chat/index','AI智能'],
|
||||
['pages_tool/ai-chat/index','AI智能客服'],
|
||||
['pages_tool/contact/contact','电子名片'],
|
||||
['pages_tool/member/info','个人资料'],
|
||||
['pages_tool/order/activist','退款'],
|
||||
|
||||
119
scripts/i18n_recommend.py
Normal file
119
scripts/i18n_recommend.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import re
|
||||
|
||||
path = r"D:\0.daima\lucky_shop\components-diy\diy-recommend-code.vue"
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
s = f.read()
|
||||
|
||||
BASE = "components-diy.diy-recommend-code."
|
||||
|
||||
# ---- 1. value.xxx || '中文' / nc.xxx || '中文' ----
|
||||
TEXT_FIELDS = {
|
||||
'title','buttonText','copyButtonText','consentText','consentButtonText',
|
||||
'loginTipText','loginBtnText','posterQrTip','placeholderRegenerateText',
|
||||
}
|
||||
def repl_value_nc(m):
|
||||
prefix = m.group(1) # e.g. "value.title || "
|
||||
field = m.group(2)
|
||||
if field in TEXT_FIELDS:
|
||||
return prefix + "$lang('" + BASE + field + "')"
|
||||
return m.group(0)
|
||||
s = re.sub(r"((?:value|nc)\.(\w+)\s*\|\|\s*)'([^']*)'", repl_value_nc, s)
|
||||
|
||||
# ---- 2. standalone template text nodes ----
|
||||
standalone = {
|
||||
'\u4e13\u5c5e\u63a8\u8350\u7801': 'exclusiveCode',
|
||||
'\u5f85\u83b7\u53d6\u63a8\u8350\u7801': 'pendingCode',
|
||||
'\u5f85\u83b7\u53d6': 'pending',
|
||||
'\u63a8\u8350\u7801\u52a0\u8f7d\u4e2d...': 'loading',
|
||||
'\u5df2\u83b7\u5f97\u63a8\u8350\u7801': 'obtainedCode',
|
||||
'\u5df2\u83b7\u5f97': 'obtained',
|
||||
'\u5206\u4eab\u7ed9\u597d\u53cb\uff0c\u9080\u8bf7\u6ce8\u518c\u5373\u53ef\u83b7\u5f97\u5956\u52b1': 'shareInviteReward',
|
||||
'\u2713 \u5df2\u751f\u6548': 'activated',
|
||||
'\u63a8\u8350\u7801\u5df2\u751f\u6210\uff0c\u53ef\u7528\u4e8e\u9080\u8bf7\u597d\u53cb\u6216\u4f01\u4e1a\u5165\u9a7b\u7ed1\u5b9a\u63a8\u8350\u5173\u7cfb': 'codeGenerated',
|
||||
}
|
||||
for zh, key in standalone.items():
|
||||
s = s.replace(">%s</text>" % zh, ">{{ $lang('%s%s') }}</text>" % (BASE, key))
|
||||
|
||||
# ---- 3. '提交中...' standalone in template ternary ----
|
||||
s = s.replace("'\u63d0\u4ea4\u4e2d...'", "$lang('%ssubmitting')" % BASE)
|
||||
|
||||
# ---- 4. default() Chinese literals -> '' ----
|
||||
default_map = {
|
||||
"title: '\u6211\u7684\u63a8\u8350\u7801'": "title: ''",
|
||||
"buttonText: '\u7acb\u5373\u5206\u4eab'": "buttonText: ''",
|
||||
"copyButtonText: '\u590d\u5236\u63a8\u8350\u7801'": "copyButtonText: ''",
|
||||
"placeholderRegenerateText: '\u6362\u4e00\u4e2a\u65b0\u7801'": "placeholderRegenerateText: ''",
|
||||
"consentText: '\u60a8\u7684\u63a8\u8350\u7801\u5c06\u7528\u4e8e\u4f01\u4e1a\u5165\u9a7b\u6765\u6e90\u8ffd\u8e2a\uff0c\u6211\u4eec\u4f1a\u8bb0\u5f55\u6bcf\u4e00\u6b21\u901a\u8fc7\u60a8\u5206\u4eab\u94fe\u63a5\u5b8c\u6210\u7684\u5165\u9a7b\u7533\u8bf7\uff0c\u7528\u4e8e\u60a8\u7684\u63a8\u5e7f\u6536\u76ca\u7ed3\u7b97\u3002'": "consentText: ''",
|
||||
"consentButtonText: '\u540c\u610f\u5e76\u83b7\u53d6\u63a8\u8350\u7801'": "consentButtonText: ''",
|
||||
"loginTipText: '\u767b\u5f55\u540e\u67e5\u770b\u6211\u7684\u63a8\u8350\u7801'": "loginTipText: ''",
|
||||
"loginBtnText: '\u53bb\u767b\u5f55'": "loginBtnText: ''",
|
||||
"posterQrTip: '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f'": "posterQrTip: ''",
|
||||
}
|
||||
for a, b in default_map.items():
|
||||
s = s.replace(a, b)
|
||||
|
||||
# ---- 5. JS user-facing strings ----
|
||||
# rcCopyToClipboard util (uses vm.$lang)
|
||||
s = s.replace("cb && cb(false, '\u5185\u5bb9\u4e3a\u7a7a')",
|
||||
"cb && cb(false, vm.$lang('%scopyEmpty'))" % BASE)
|
||||
s = s.replace("fail: () => cb && cb(false, '\u590d\u5236\u5931\u8d25')",
|
||||
"fail: () => cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
|
||||
s = s.replace("cb && cb(ok ? true : false, ok ? null : '\u590d\u5236\u5931\u8d25')",
|
||||
"cb && cb(ok ? true : false, ok ? null : vm.$lang('%scopyFail'))" % BASE)
|
||||
s = s.replace("cb && cb(false, '\u590d\u5236\u5931\u8d25')",
|
||||
"cb && cb(false, vm.$lang('%scopyFail'))" % BASE)
|
||||
|
||||
# getFail / getFailCode / getSuccess / netError
|
||||
s = s.replace("var lastMsg = '\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5';",
|
||||
"var lastMsg = this.$lang('%sgetFail');" % BASE)
|
||||
s = s.replace("('\u63a8\u8350\u7801\u83b7\u53d6\u5931\u8d25(\u4e1a\u52a1\u7801=' + unpacked.businessCode + ')')",
|
||||
"this.$lang('%sgetFailCode', { code: unpacked.businessCode })" % BASE)
|
||||
s = s.replace("uni.showToast({ title: '\u83b7\u53d6\u6210\u529f', icon: 'success' })",
|
||||
"uni.showToast({ title: this.$lang('%sgetSuccess'), icon: 'success' })" % BASE)
|
||||
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff1a' + failMsg.slice(0, 30)",
|
||||
"this.$lang('%snetError')" % BASE)
|
||||
s = s.replace("'\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5'",
|
||||
"this.$lang('%snetError')" % BASE)
|
||||
|
||||
# regen modal
|
||||
s = s.replace("title: '\u6362\u65b0\u7801',", "title: this.$lang('%sregenTitle')," % BASE)
|
||||
s = s.replace("content: '\u6362\u65b0\u7801\u540e\u65e7\u7801\u4f1a\u7acb\u5373\u5931\u6548\uff0c\u540e\u7eed\u6240\u6709\u4f7f\u7528\u65e7\u5206\u4eab\u94fe\u63a5\u7684\u5165\u9a7b\u7533\u8bf7\u90fd\u4e0d\u4f1a\u5f52\u5c5e\u5230\u60a8\uff0c\u786e\u8ba4\u7ee7\u7eed\uff1f',",
|
||||
"content: this.$lang('%sregenContent')," % BASE)
|
||||
s = s.replace("confirmText: '\u786e\u8ba4\u6362\u65b0',", "confirmText: this.$lang('%sregenConfirm')," % BASE)
|
||||
s = s.replace("cancelText: '\u53d6\u6d88',", "cancelText: this.$lang('%sregenCancel')," % BASE)
|
||||
s = s.replace("title: unpacked.msg ? String(unpacked.msg) : '\u6362\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5',",
|
||||
"title: unpacked.msg ? String(unpacked.msg) : this.$lang('%sregenFail')," % BASE)
|
||||
s = s.replace("title: old ? ('\u5df2\u6362\u65b0\u7801\uff0c\u65e7\u7801 ' + old + ' \u5df2\u505c\u7528') : '\u6362\u65b0\u6210\u529f',",
|
||||
"title: old ? this.$lang('%sregenSuccessOld', { old: old }) : this.$lang('%sregenSuccess')," % (BASE, BASE))
|
||||
s = s.replace("uni.showToast({ title: '\u7f51\u7edc\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5', icon: 'none' })",
|
||||
"uni.showToast({ title: this.$lang('%snetError'), icon: 'none' })" % BASE)
|
||||
|
||||
# share title (L1128 specific) then global for the rest
|
||||
s = s.replace("(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || '\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
|
||||
"(vm.nc && (vm.nc.shareTitle || vm.nc.title)) || this.$lang('%sshareTitle')" % BASE)
|
||||
s = s.replace("'\u9080\u60a8\u5165\u9a7b\u4f4e\u7a7a\u7ecf\u6d4e\u4ea7\u4e1a\u5e73\u53f0'",
|
||||
"this.$lang('%sshareTitle')" % BASE)
|
||||
|
||||
# copy / link toasts
|
||||
s = s.replace("title: ok ? '\u63a8\u8350\u7801\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
|
||||
"title: ok ? this.$lang('%scodeCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
|
||||
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u8fd8\u672a\u751f\u6210\uff0c\u8bf7\u7a0d\u5019', icon: 'none' })",
|
||||
"uni.showToast({ title: this.$lang('%scodeNotReady'), icon: 'none' })" % BASE)
|
||||
s = s.replace("uni.showToast({ title: '\u63a8\u8350\u7801\u52a0\u8f7d\u5b8c\u6210\u540e\u518d\u590d\u5236', icon: 'none' })",
|
||||
"uni.showToast({ title: this.$lang('%sloadThenCopy'), icon: 'none' })" % BASE)
|
||||
s = s.replace("title: ok ? '\u63a8\u5e7f\u94fe\u63a5\u5df2\u590d\u5236' : (err || '\u590d\u5236\u5931\u8d25')",
|
||||
"title: ok ? this.$lang('%slinkCopied') : (err || this.$lang('%scopyFail'))" % (BASE, BASE))
|
||||
|
||||
# poster title + canvas
|
||||
s = s.replace("const title = v.title || '\u6211\u7684\u63a8\u8350\u7801';",
|
||||
"const title = v.title || this.$lang('%stitle');" % BASE)
|
||||
s = s.replace("ctx.fillText('\u5206\u4eab\u7ed9\u597d\u53cb,\u9080\u8bf7\u5165\u9a7b\u83b7\u5f97\u5956\u52b1', 24, 70);",
|
||||
"ctx.fillText(this.$lang('%sposterShareInvite'), 24, 70);" % BASE)
|
||||
s = s.replace("ctx.fillText('\u94fe\u63a5:' + (linkText || '(\u672a\u751f\u6210)'), 24, urlY);",
|
||||
"ctx.fillText(this.$lang('%sposterLinkPrefix') + (linkText || this.$lang('%sposterLinkEmpty')), 24, urlY);" % (BASE, BASE))
|
||||
s = s.replace("ctx.fillText(v.posterQrTip || '\u626b\u7801\u8fdb\u5165\u5e76\u586b\u5199\u5165\u9a7b\u4fe1\u606f', W / 2, qrY + qrSize + 24);",
|
||||
"ctx.fillText(v.posterQrTip || this.$lang('%sposterQrTip'), W / 2, qrY + qrSize + 24);" % BASE)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(s)
|
||||
print("done")
|
||||
@@ -121,7 +121,7 @@
|
||||
],
|
||||
[
|
||||
"pages_tool/ai-chat/index",
|
||||
"AI智能"
|
||||
"AI智能客服"
|
||||
],
|
||||
[
|
||||
"pages_tool/contact/contact",
|
||||
|
||||
@@ -9,6 +9,7 @@ import util from '../common/js/util.js'
|
||||
import ThemeOverride from '../common/js/theme-override.js'
|
||||
import { sanitizeFlatMap, mergeThemeBuckets } from '../common/js/merge-theme.js'
|
||||
import PrivacyManager from '../common/js/privacy.js'
|
||||
import LangConfig from '../common/js/lang.js'
|
||||
|
||||
// themeColor 结果缓存(避免每次都全量遍历 theme 对象)
|
||||
// key: JSON.stringify(theme) + tabBarHeight, value: CSS变量字符串
|
||||
@@ -163,6 +164,10 @@ const store = new Vuex.Store({
|
||||
setShopInfo(state, value) {
|
||||
state.shopInfo = value;
|
||||
uni.setStorageSync('shopInfo', value); // 初始化数据调用
|
||||
// 语言切换开关: 后台 shop_info.ischia 决定。
|
||||
// 关闭(ischia 为假)时强制返回默认中文,并清掉用户此前持久化到 storage 的语种,
|
||||
// 满足"后台关闭双语/小程序后,刷新重新进入必须是中文"的需求。
|
||||
try { LangConfig.setLangSwitchEnabled(!!(value && value.ischia)); } catch (e) {}
|
||||
},
|
||||
setCartChange(state) {
|
||||
state.cartChange += 1;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user