diff --git a/.gitignore b/.gitignore index 021a738..634879d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/App.vue b/App.vue index 2fbeceb..0d3ff14 100644 --- a/App.vue +++ b/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) { diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js index 7d7316e..98aa425 100644 --- a/common/js/lang-loader.js +++ b/common/js/lang-loader.js @@ -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); diff --git a/common/js/lang.js b/common/js/lang.js index d120e7f..0ce0669 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -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 diff --git a/common/js/uniapp.utils.js b/common/js/uniapp.utils.js index 5344c44..045e801 100644 --- a/common/js/uniapp.utils.js +++ b/common/js/uniapp.utils.js @@ -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 }); diff --git a/docs/weixin-bug.md b/docs/weixin-bug.md index 303f73c..064f809 100644 --- a/docs/weixin-bug.md +++ b/docs/weixin-bug.md @@ -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 回复的**展示**走 `` 富文本(豆包风格:加粗标题、悬挂缩进列表、彩色链接),由 - `parseMarkdown()` 生成 HTML、`getAIDisplayContent()` 输出,H5 用 `v-html`、微信小程序用 ``。 -- 富文本**不可长按选区复制**(微信 rich-text 不支持),复制只能走「复制」按钮(即 `wx.setClipboardData`), - 因此必须声明剪贴板权限,或开发期把 `manifest.json` → `mp-weixin` → `"__usePrivacyCheck__"` 改 `false` - (仅本地调试,上线前必须还原)。 -- 失败时提示会引导用户复制不可用(`isClipboardPrivacyError()` 识别 errno 112)。 +**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。 \ No newline at end of file diff --git a/lang-bundle.json b/lang-bundle.json index abae112..5a26840 100644 --- a/lang-bundle.json +++ b/lang-bundle.json @@ -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": "打开文件中...", diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index 75a0e10..9ee74a3 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -40,7 +40,7 @@ export const lang = { 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', @@ -52,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}', @@ -185,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'} diff --git a/lang/en-us/common.js b/lang/en-us/common.js index b7cfd32..c6dabe9 100644 --- a/lang/en-us/common.js +++ b/lang/en-us/common.js @@ -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', }, diff --git a/lang/en-us/components-diy/diy-recommend-code.js b/lang/en-us/components-diy/diy-recommend-code.js new file mode 100644 index 0000000..00bcfc7 --- /dev/null +++ b/lang/en-us/components-diy/diy-recommend-code.js @@ -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)', +} diff --git a/lang/en-us/components-diy/diy-webview.js b/lang/en-us/components-diy/diy-webview.js new file mode 100644 index 0000000..0c6a7a9 --- /dev/null +++ b/lang/en-us/components-diy/diy-webview.js @@ -0,0 +1,5 @@ +export const lang = { + // diy-webview webview component + emptyTip: 'Please configure the web link', + loadError: 'Failed to load web page', +} diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 1961f96..20a1059 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -39,7 +39,7 @@ export const lang = { loadMoreHistory: '加载更多历史消息', loadingHistory: '加载历史消息中...', aiThinking: 'AI正在思考', - receivingReply: '思考中', + receivingReply: '正在接收回复', aiThinkingStatus1: 'AI正在思考', aiThinkingStatus2: 'AI正在分析', aiThinkingStatus3: 'AI正在检索', @@ -51,7 +51,6 @@ export const lang = { copySelected: '已复制选中内容', copyFail: '复制失败', copyFailWithReason: '复制失败:{reason}', - copyFailPrivacyReason: '小程序未声明剪贴板权限,复制不可用', openingFile: '打开文件中...', openingFileName: '正在打开文件:{name}', openingFileTitle: '正在打开:{title}', @@ -184,7 +183,7 @@ export const lang = { logEventHandleError: '事件处理错误', logInitNavbarFailed: '初始化导航栏失败', logChatComponentNotInit: '聊天组件未初始化', - logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...', + logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...' // ====== AI Agent 通用文案(智能客服是首个 agent) ====== aiAgentDefaultTitle: 'AI 智能体', diff --git a/lang/zh-cn/common.js b/lang/zh-cn/common.js index 951e931..d4cf588 100644 --- a/lang/zh-cn/common.js +++ b/lang/zh-cn/common.js @@ -126,8 +126,6 @@ export const lang = { copyTimeoutReason: '原因:', wechatClipboardReason1: '1.微信平台->用户隐私保护指引->"剪贴板"功能未添加或审核未通过;', wechatClipboardReason2: '2.微信平台对剪贴板API调用频率有限制', - // 微信剪贴板隐私声明缺失(errno 112)时的可读提示 - clipboardPrivacyNotDeclared: '剪贴板隐私权限未配置', copyFailed: '复制失败', }, diff --git a/lang/zh-cn/components-diy/diy-recommend-code.js b/lang/zh-cn/components-diy/diy-recommend-code.js new file mode 100644 index 0000000..e13c2df --- /dev/null +++ b/lang/zh-cn/components-diy/diy-recommend-code.js @@ -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: '(未生成)', +} diff --git a/lang/zh-cn/components-diy/diy-webview.js b/lang/zh-cn/components-diy/diy-webview.js new file mode 100644 index 0000000..d34979f --- /dev/null +++ b/lang/zh-cn/components-diy/diy-webview.js @@ -0,0 +1,5 @@ +export const lang = { + // diy-webview 网页组件 + emptyTip: '请配置网页链接', + loadError: '网页加载失败', +} diff --git a/manifest.json b/manifest.json index dd002d1..82f4503 100644 --- a/manifest.json +++ b/manifest.json @@ -85,7 +85,7 @@ } }, "requiredPrivateInfos" : [ "chooseLocation", "getLocation", "chooseAddress" ], - "__usePrivacyCheck__" : false, + "__usePrivacyCheck__" : true, "optimization" : { "subPackages" : true } diff --git a/pages.json b/pages.json index d4ba971..53f3c2b 100644 --- a/pages.json +++ b/pages.json @@ -1086,7 +1086,7 @@ { "path": "ai-chat/index", "style": { - "navigationBarTitleText": "AI智能", + "navigationBarTitleText": "AI智能客服", "componentPlaceholder": { "diy-article": "view", "diy-audio": "view", diff --git a/pages_tool/article/list.vue b/pages_tool/article/list.vue index 286ecac..82880b9 100644 --- a/pages_tool/article/list.vue +++ b/pages_tool/article/list.vue @@ -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, diff --git a/scripts/check-title-keys.js b/scripts/check-title-keys.js index ffc3f7c..e061d07 100644 --- a/scripts/check-title-keys.js +++ b/scripts/check-title-keys.js @@ -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','退款'], diff --git a/scripts/i18n_recommend.py b/scripts/i18n_recommend.py new file mode 100644 index 0000000..9e21a8c --- /dev/null +++ b/scripts/i18n_recommend.py @@ -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" % zh, ">{{ $lang('%s%s') }}" % (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") diff --git a/scripts/lang_audit_report_v2.json b/scripts/lang_audit_report_v2.json index 5fdcb12..bf8133e 100644 --- a/scripts/lang_audit_report_v2.json +++ b/scripts/lang_audit_report_v2.json @@ -121,7 +121,7 @@ ], [ "pages_tool/ai-chat/index", - "AI智能" + "AI智能客服" ], [ "pages_tool/contact/contact", diff --git a/store/index.js b/store/index.js index 7e98ffa..59eebcb 100644 --- a/store/index.js +++ b/store/index.js @@ -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;