Compare commits

...

34 Commits

Author SHA1 Message Date
9b0a2ce9b6 feat: 添加components-diy\diy-webview.vue 2026-09-29 11:16:08 +08:00
954e8cad0b chore: 重新整理依赖及revert lang.js 2026-09-29 11:12:58 +08:00
166a284359 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp221
# Conflicts:
#	.local.config.js
#	common/js/ai-service.js
#	common/js/media-upload.js
#	docs/ai-agent-integration.md
#	pages_tool/ai-chat/ai-chat-message.vue
#	pages_tool/ai-chat/components/ai-chat-tools-panel.vue
#	pages_tool/ai-chat/index.vue
2026-09-29 11:02:53 +08:00
5efae5c38b feat(ai-chat): 增加自动定位权限提示及处理逻辑,优化用户体验 2026-09-28 13:57:46 +08:00
4168c24fd6 feat(ai-chat): 增强位置消息处理,支持透传位置、时间、页面上下文,优化用户体验 2026-09-28 13:48:56 +08:00
762401777b feat(ai-chat): 增强多媒体消息处理,支持多条语音和视频同时发送,优化消息气泡显示逻辑 2026-09-28 11:30:40 +08:00
24c4e40df9 Merge remote-tracking branch 'remotes/origin/temp221' into temp221 2026-09-28 11:12:05 +08:00
c58734fcda feat(ai-chat): 增强语音消息处理,支持多条语音同时发送及中央播放面板 2026-09-28 11:10:26 +08:00
bbc9fdd6ea feat(ai-chat): 动态切换输入框 placeholder 文案,支持语音和媒体附件提示 2026-09-28 10:18:10 +08:00
4cd21ade51 fix(ai-chat): 修复 v-if/v-else 配对问题,优化输入框和语音图标布局对称性 2026-09-28 10:16:09 +08:00
0799e47233 feat(ai-chat): 在流式回答中添加"思考中..."指示,优化操作栏显示逻辑 2026-09-28 10:05:52 +08:00
306f2f7ff7 feat(voice-input): 改进音频消息处理,支持音频时长和指令文本,优化后端交互 2026-09-28 09:33:57 +08:00
fff10a2e19 feat(media-upload): 增加全量音视频格式支持,确保与后端一致 2026-09-24 18:03:08 +08:00
9d990e6aac feat: implement voice input feature with press-and-hold gesture
- Added new language strings for voice input functionality in English and Chinese.
- Updated manifest to include microphone permission for voice recording.
- Enhanced ai-chat-message component to support voice input with visual feedback for recording and cancellation gestures.
- Integrated voice recorder functionality in ai-chat-voice-panel, allowing users to record audio with a maximum duration of 60 seconds.
- Implemented audio handling logic to upload recorded audio files and display them in the chat interface.
- Added error handling for voice recording failures and user notifications.
- Updated styles for improved UI/UX during voice recording interactions.
2026-09-24 17:47:37 +08:00
74468e2c9d feat(ai-chat): 暂时隐藏模型选择器,统一使用 Auto 模式 2026-09-24 17:31:15 +08:00
0ee97f902d chore:跳转文章列表链接可以分类 2026-09-23 11:15:47 +08:00
3b996485e8 Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0
# Conflicts:
#	components-diy/diy-image-nav.vue
2026-09-22 15:44:27 +08:00
d18df73849 fix(diy-image-nav): 修复小程序端水平滚动条始终显示/滑动时显示不生效 2026-09-22 15:22:15 +08:00
bac5583ecb 强制合并 2026-09-22 10:52:56 +08:00
942d85de19 chore:要实现后台开启双语模式,微信小程序真机切换英文模式,此时显示的是英文,后台关闭双语模式,微信小程序刷新重新进入要是中文模式 2026-09-20 17:58:16 +08:00
ae5921aa90 fix(lang): 恢复 common 和 components 节点校验 2026-09-20 16:15:48 +08:00
c104f6c443 chore: 忽略 .trae 相关文件 2026-09-20 16:15:19 +08:00
747a15f16e chore: 忽略 .workbuddy 目录 2026-09-20 16:14:42 +08:00
b93752d58c 更新lang 2026-09-20 15:27:45 +08:00
619e1dc9ef Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into dev/1.0
# Conflicts:
#	components-diy/diy-category-item.vue
#	components-diy/diy-category.vue
2026-09-20 15:25:15 +08:00
500328ef55 chore :真机可以支持测试 2026-09-20 14:27:51 +08:00
6fa0603783 fix(diy-search): 支持卡片底板内边距/外边距/圆角/四边样式后台字段 2026-09-20 10:39:49 +08:00
34c712db32 chore: 同步搜索框与滑动图片相关5个文件到 dev/1.0 2026-09-19 16:03:13 +08:00
b038175f07 Merge branch 'gaibao-2821' into dev/1.0 2026-07-14 08:47:48 +08:00
f18577a46c chore:切换左侧一级分类时,右侧会自动选中第一个二级分类按钮(如"离型纸")
如果用户之前点击过某个二级分类(如"离型膜"),切换一级分类后会自动选中同名的二级分类
2026-07-13 18:05:36 +08:00
9915fb9bb1 chore:切换左侧一级分类时,右侧默认选中第一个按钮 2026-07-13 18:00:48 +08:00
bb34e32f87 chore:删除按钮上方的“什么行业” 2026-07-13 17:26:58 +08:00
45e997adae chore:实现左侧一级分类点击哪个行业,右面就只显示这个行业,不显示其他的行业 2026-07-13 17:21:14 +08:00
2e4f11d108 chore:增加分类按钮,把商品列表的商品放在按钮下面 2026-07-13 17:14:15 +08:00
31 changed files with 3804 additions and 453 deletions

3
.gitignore vendored
View File

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

13
App.vue
View File

@@ -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) {

View File

@@ -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()
})
});

View File

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

View File

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

View File

@@ -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',

View File

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

578
common/js/voice-recorder.js Normal file
View 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

View 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>

View File

@@ -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 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。

View File

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

View File

@@ -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'}

View File

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

View File

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

View File

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

View File

@@ -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 智能体',

View File

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

View File

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

View File

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

View File

@@ -79,10 +79,13 @@
},
"scope.writePhotosAlbum" : {
"desc" : "用于从相册选择和保存头像"
},
"scope.record" : {
"desc" : "用于语音录制功能,录制完成后发送到 AI 助手"
}
},
"requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ],
"__usePrivacyCheck__" : false,
"__usePrivacyCheck__" : true,
"optimization" : {
"subPackages" : true
}

View File

@@ -13,8 +13,7 @@
"zion-uniapp-mp-load-package": "^1.0.13"
},
"dependencies": {
"jweixin-module": "^1.6.0",
"playwright-core": "^1.63.0"
"jweixin-module": "^1.6.0"
},
"uni-app": {
"scripts": {

View File

@@ -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

View File

@@ -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()
},

View File

@@ -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>

View File

@@ -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,

View File

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

119
scripts/i18n_recommend.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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"
}