Преглед изворни кода

feat(ui): 语音输入的 4 处 window.alert 换成 AntD 轻提示

用户要求:「另外那几处语音的 window.alert 一同更换」—— 衔接上一轮(全局提示改成
Ant Design 的 message),当时语音这几处按「不在要求范围内」留着没动并记进了待办。

改动(4 处 alert → showToast,文案与收尾动作一字未改):
- BusinessAssistantPC.vue 1 处:toggleVoiceInput 的 catch(error.message 兜底「语音输入初始化失败」)
- BusinessAssistantMobile.vue 3 处:麦克风权限失败 ×2(抛错路径 + openMicroPhone 返回 false 路径)、
  初始化失败 ×1(「语音输入初始化失败,请稍后重试」)
- 两者本来就已从 useBusinessAssistantChat 解构 showToast(续 25 接通上传提示时暴露的),无新增 UI

验证:新增 harness/tools/verify-toast-channel.mjs 27/27 ——
全仓库 74 个源文件 0 处 alert((先剥注释再查)、两端语音段逐处 showToast 且数量卡死、
showToast 解构位置在 toggleVoiceInput 之前(否则运行时 ReferenceError)、
hook→showAppMessage→antd 链路各性质、上传 notify 通道未带偏、
清空输入的 window.confirm 保留(模态确认 ≠ 轻提示)。

🐛 变异测试抓到断言恒真:第一版 ALERT_RE 的排除集把 `window.alert(` 一起排掉了,
「语音段里没有 alert」永远为真;把 4 处改回 alert 才发现该断言仍全绿。
修正后再变异 → 9 项失败(含 4 条 alert 断言),还原 27/27。

回归:npm run build 通过;verify-attachment-question 39/39(它静态读这两个壳组件);
validate-harness 通过。需真实后端的脚本未跑(改动与被测面不相交)。

⚠️ 已知残留(未擅自补,已报告):PC 的 openMicroPhone 返回 false 时是静默 return,
Mobile 同样情况会弹「未获取到麦克风权限」。

顺带:APP_MESSAGE_DURATION 3s → 2s(工作区里已有的手改,一并提交)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
2878b509d9

+ 9 - 0
README.md

@@ -373,3 +373,12 @@
     顶部弹出、3 秒自动消失、错误为粉底红感叹号 —— 就是设计稿那种小条
   - 调用方签名不变(`showToast(text)`);顺带发现 `BusinessRecord.vue` 里那份
     `<Toast>` 与 `showToast` **没有任何调用点、也没有子组件 inject**(死代码),一并停用
+- **语音输入的 4 处 `window.alert` 一并换成轻提示**(用户 2026-09-23:「另外那几处语音的
+  window.alert 一同更换」):PC 1 处(初始化失败)+ Mobile 3 处(麦克风权限 ×2、初始化失败 ×1)
+  - 换成 `showToast(...)` 后与全站提示同一条链路(顶部弹出、自动消失),
+    不再有阻塞式弹窗;**文案与收尾动作一字未动**(仍会释放语音实例、复位状态)
+  - PC 端 `openMicroPhone` 返回 `false` 时仍是**静默 return**(无任何提示)——
+    与 Mobile 不一致,属已报告的待定项,**未擅自补文案**
+  - 保留项:清空输入框那句 `window.confirm("可能会清空之前的文字…")` 不换 ——
+    它是要用户做决定的**模态确认**,不是轻提示
+- **轻提示时长 3s → 2s**(工作区改动,随本次一并提交;改 `APP_MESSAGE_DURATION` 一处即可)

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
harness/feature_list.json


+ 43 - 0
harness/progress.md

@@ -3816,3 +3816,46 @@ SVG / BMP / TIFF / HEIC 全被当图片收下,与白名单和提示都矛盾
 ⚠️ **外观只能由用户在浏览器确认**(harness 不渲染 UI):顶部居中、3 秒消失、错误为粉底红图标。
 ⚠️ 旧的 `Toast.vue` 组件本身**保留未删**(已无人使用),按约定留作参考。
 ⚠️ 仍未动:语音输入那几处 `window.alert`(PC 1 处 + Mobile 3 处)。
+
+### 续 27:语音输入的 window.alert 一并换成轻提示(用户要求)
+
+**用户要求**:「另外那几处语音的 window.alert 一同更换」(衔接续 26 —— 上一轮把全局提示换成
+AntD message 时,语音这几处按「不在要求范围内」留着没动,并写进了待办)。
+
+**改了什么(4 处,全是 `window.alert(...)` → `showToast(...)`)**:
+- `BusinessAssistantPC.vue` 1 处:`toggleVoiceInput` 的 catch(`error.message` 兜底「语音输入初始化失败」)
+- `BusinessAssistantMobile.vue` 3 处:麦克风权限失败 ×2(`openMicroPhone` 抛错路径 + 返回 false 路径)、
+  初始化失败 ×1(「语音输入初始化失败,请稍后重试」)
+- **文案、收尾动作一字未改**(仍 `releaseVoiceInputInstance()` / 复位 `isVoiceInputMode`);
+  没有新增 UI,只是换了提示通道 —— `showToast` 两个壳组件本来就已解构(续 25 接通上传提示时暴露的)
+
+**运行过的验证**:新增 `harness/tools/verify-toast-channel.mjs` —— **27/27**:
+- 【1】全仓库 74 个源文件 **0 处** `alert(`(注释里提「不要用 window.alert」不算:先剥注释再查)
+- 【2】两端 `toggleVoiceInput` 函数体内:PC 1 处、Mobile 3 处 showToast(**数量卡死**,漏一处就红)、
+  函数体内 0 处 alert、麦克风失败后仍释放实例
+- 【3】链路与可见性:两端 `showToast` 确实由 `useBusinessAssistantChat` 解构且**在 toggleVoiceInput 之前**
+  (否则运行时会 ReferenceError);hook 的 `showToast → showAppMessage`;app-message 仍是 antd 的
+  `message`、四类型齐、时长 ≤5s、空文案早返回、Promise/异常都被吞
+- 【4】别被带偏:上传 notify 通道仍接 showToast;清空输入的 `window.confirm` **保留**(模态确认 ≠ 轻提示)
+- 【5】已知残留(只提示不判失败):**PC 的 `openMicroPhone` 返回 false 时仍是静默 return**,
+  Mobile 同样情况会弹「未获取到麦克风权限」。用户只要求替换 alert,**未擅自补**,已报告等决定
+
+**🐛 变异测试抓到我自己写的哑弹(重要)**:脚本第一版 `ALERT_RE = /(^|[^.\w$])alert\s*\(/`,
+本意排除 `onAlert(`/`myAlert(`,结果连 `window.alert(` 一起排掉了(点号也在排除集里)——
+**「语音段里没有 alert」这条断言永远为真**。把 4 处改回 `window.alert` 做变异测试才发现:
+第一次注入只报 3 项失败(全是 showToast 数量类),alert 类断言**全绿**。修正为
+`/window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/` 后重跑变异:**9 项失败**(含 4 条 alert 断言,
+且报出了注入的 4 个行号),还原后 27/27。
+教训:**「断言恒真」比「断言漏写」更难发现 —— 每条新断言都要靠变异测试证明它会红**。
+
+回归:`npm run build` 通过;`verify-attachment-question.mjs` 39/39(它静态读两个壳组件,
+正好覆盖本次改动的文件);`validate-harness` 通过(功能 23 项,in_progress 1 项)。
+未跑需要真实后端的脚本(DMS 上传 / 答案流式等)—— 本次改动只碰两个壳组件的提示调用点,
+与它们的被测面不相交。
+
+**顺带**:`BusinessAssistantPC.vue` / `BusinessAssistantMobile.vue` 里各有一行
+(`currentSessionGenerating,`)在 HEAD 里是 `\r\r\n`(双 CR,历史遗留)—— 编辑后规范成 `\r\n`,
+属无害清理,也是 `git diff` 显示「整文件重写」的一部分原因(这两个文件本来就以 CRLF 入库)。
+
+**未提交的既有改动一并带上**:`APP_MESSAGE_DURATION` 3 → 2(工作区里已有的手改)。
+

+ 177 - 0
harness/tools/verify-toast-channel.mjs

@@ -0,0 +1,177 @@
+/**
+ * 验证「全站提示统一走 Ant Design 的 message」这条链路 —— 重点是**语音输入那几处**。
+ *
+ * 用户 2026-09-23:先要求全局 toast 换成顶部弹出的轻提示(AntD message),
+ * 随后要求「另外那几处语音的 window.alert 一同更换」。
+ *
+ * 为什么用静态检查而不是跑 UI:语音失败路径要真触发得**拒绝麦克风权限**,
+ * 没法在 harness 里稳定复现;而这次改动恰恰是「**调用点**从阻塞弹窗换成轻提示」,
+ * 用源码断言正好覆盖它的失效方式(改回 alert、漏掉某一处、提示通道串线)。
+ *
+ * 怎么跑(无需打包):
+ *   node harness/tools/verify-toast-channel.mjs
+ */
+
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+const ROOT = 'src';
+const read = (p) => readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
+
+/** 去掉注释再判 —— 注释里写「不要用 window.alert」是**说明**,不该被当成调用点 */
+const stripComments = (src) =>
+  src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1 ');
+
+/**
+ * 阻塞式弹窗调用点:`window.alert(...)` 或裸 `alert(...)`。
+ *
+ * ⚠️ 两条分支缺一不可 —— 第一版只写了 `(^|[^.\w$])alert\(`,本意是排除 `onAlert(` / `myAlert(`,
+ * 结果把 `window.alert(`(点号也在排除集里)一起排掉了,断言**永远为真**。
+ * 是「阉掉修复即失败」的变异测试把这枚哑弹抓出来的(见 progress.md 续 27)。
+ */
+const ALERT_RE = /window\s*\.\s*alert\s*\(|(^|[^.\w$])alert\s*\(/;
+
+const walk = (dir, out = []) => {
+  for (const name of readdirSync(dir)) {
+    const full = join(dir, name);
+    if (statSync(full).isDirectory()) walk(full, out);
+    else if (/\.(ts|vue|js|tsx)$/.test(name)) out.push(full);
+  }
+  return out;
+};
+
+const files = walk(ROOT);
+console.log(`\n【1】全仓库(${files.length} 个源文件)没有阻塞式 alert 调用点`);
+{
+  const hits = [];
+  for (const file of files) {
+    stripComments(read(file))
+      .split('\n')
+      .forEach((line, i) => {
+        if (ALERT_RE.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1} ${line.trim()}`);
+      });
+  }
+  check(`src 下 0 处 alert(`, hits.length === 0, hits.join(' | '));
+
+  // 用户 2026-09-23 之前确实有 4 处(PC 1 + Mobile 3),钉住数量为 0 而不是"变少了"
+  const voiceFiles = ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue'];
+  for (const f of voiceFiles) {
+    check(`${f} 里没有 alert(`, !ALERT_RE.test(stripComments(read(join(ROOT, f)))));
+  }
+}
+
+/** 取出一个顶层 `const xxx = async () => { ... };` 的函数体(到顶格 `};` 为止) */
+const extractFn = (src, startMarker) => {
+  const start = src.indexOf(startMarker);
+  if (start < 0) return '';
+  const end = src.indexOf('\n};', start);
+  return end < 0 ? src.slice(start) : src.slice(start, end);
+};
+
+console.log('\n【2】语音输入 toggleVoiceInput:失败路径逐处换成 showToast');
+const voice = {
+  PC: extractFn(read(join(ROOT, 'components/BusinessAssistantPC.vue')), 'const toggleVoiceInput = async () => {'),
+  Mobile: extractFn(
+    read(join(ROOT, 'components/BusinessAssistantMobile.vue')),
+    'const toggleVoiceInput = async () => {'
+  ),
+};
+{
+  check('两端的 toggleVoiceInput 都取到了(不是空片段)', voice.PC.length > 200 && voice.Mobile.length > 200,
+    `PC=${voice.PC.length} Mobile=${voice.Mobile.length}`);
+
+  const micText = '未获取到麦克风权限,请在系统或浏览器设置中启用麦克风';
+  const count = (s, sub) => s.split(sub).length - 1;
+
+  // PC:1 处(catch 里的 error.message,原来就是 alert)
+  check('PC:catch 里改成 showToast(含 error.message 兜底)',
+    voice.PC.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败")'));
+  check('PC:语音段里没有 alert', !ALERT_RE.test(voice.PC));
+
+  // Mobile:3 处(麦克风权限 ×2 + 初始化失败 ×1)
+  check('Mobile:麦克风失败提示出现 2 次(抛错路径 + openMicroPhone 返回 false 路径)',
+    count(voice.Mobile, `showToast("${micText}")`) === 2,
+    `实际 showToast ${count(voice.Mobile, `showToast("${micText}")`)} 次`);
+  check('Mobile:初始化失败改成 showToast(含「请稍后重试」文案)',
+    voice.Mobile.includes('showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试")'));
+  check('Mobile:语音段里 showToast 恰好 3 处(不多不少)', count(voice.Mobile, 'showToast(') === 3,
+    `实际 ${count(voice.Mobile, 'showToast(')} 处`);
+  check('Mobile:语音段里没有 alert', !ALERT_RE.test(voice.Mobile));
+
+  // 提示不能把原来的收尾动作挤掉:麦克风失败后仍要释放实例 / 复位状态
+  check('Mobile:麦克风失败后仍 releaseVoiceInputInstance()', count(voice.Mobile, 'releaseVoiceInputInstance()') >= 2,
+    `实际 ${count(voice.Mobile, 'releaseVoiceInputInstance()')} 处`);
+}
+
+console.log('\n【3】showToast 的可见性与实现链路');
+{
+  const chatHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantChat.ts'));
+  const appMessage = read(join(ROOT, 'utils/app-message.ts'));
+
+  for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
+    const src = read(join(ROOT, f));
+    // showToast 必须从 hook 解构出来(否则语音里那句 showToast 会 ReferenceError)
+    const destructured = /}\s*=\s*useBusinessAssistantChat\(\{/.test(src) && /^\s*showToast,\s*$/m.test(src);
+    check(`${f}:showToast 由 useBusinessAssistantChat 解构(setup 顶层可见)`, destructured);
+    // 解构必须发生在 toggleVoiceInput 之前(TDZ 保护;顺序反了运行时会炸)
+    check(`${f}:解构位置在 toggleVoiceInput 之前`, src.indexOf('showToast,') < src.indexOf('const toggleVoiceInput'));
+  }
+
+  check('hook:showToast 调 showAppMessage(不再自绘 toastMessage)', /showToast = \(message: string\) => \{\s*showAppMessage\(message\);/.test(chatHook));
+  check('app-message:用 ant-design-vue 的 message', /import \{ message \} from "ant-design-vue"/.test(appMessage));
+  check('app-message:四种类型齐全(error/warning/success/info)',
+    ["error", "warning", "success", "info"].every((t) => appMessage.includes(`"${t}"`)));
+  const duration = appMessage.match(/APP_MESSAGE_DURATION = (\d+(?:\.\d+)?)/);
+  check('app-message:时长常量可解析且是短提示(≤5s)', duration && Number(duration[1]) <= 5,
+    duration ? duration[1] : '没匹配到');
+  check('app-message:空文案早返回(不弹空气泡)', /if \(!content\) return;/.test(appMessage));
+  // 之前踩过:antd message 返回 Promise,不吞掉会变成 unhandled rejection
+  check('app-message:antd 的 Promise 与异常都被吞掉(不会 unhandled rejection)',
+    appMessage.includes('.catch(') && appMessage.includes('try {') && appMessage.includes('catch (err)'));
+  check('app-message:clearAppMessages 仍导出(切会话清场用)', /export const clearAppMessages/.test(appMessage));
+}
+
+console.log('\n【4】同类的另两条通道没被这次改动带偏');
+{
+  const uploadHook = read(join(ROOT, 'components/business-assistant/useBusinessAssistantUpload.ts'));
+  check('上传校验模块没有 alert(附件提示走 notify 回调)', !ALERT_RE.test(stripComments(uploadHook)));
+
+  for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
+    const src = read(join(ROOT, f));
+    check(`${f}:notifyUpload 仍接通 showToast`, /notifyUpload = showToast;/.test(src));
+  }
+
+  // 保留项:这两处「确认要清空吗」是需要用户做决定的**模态确认**,不是轻提示 —— 不要顺手换掉
+  for (const f of ['components/BusinessAssistantPC.vue', 'components/BusinessAssistantMobile.vue']) {
+    check(`${f}:清空输入的 window.confirm 保留(模态确认 ≠ 轻提示)`,
+      read(join(ROOT, f)).includes('window.confirm("可能会清空之前的文字,确认要清空吗?")'));
+  }
+}
+
+console.log('\n【5】已知残留(不算失败,但要自己知道)');
+{
+  const pc = read(join(ROOT, 'components/BusinessAssistantPC.vue'));
+  const silent = /const success = await asrInstance.openMicroPhone\(\);\s*\n\s*if \(!success\) return;/.test(pc);
+  console.log(
+    silent
+      ? '  ⚠  PC:openMicroPhone 返回 false 时仍是静默 return(无任何提示)——\n' +
+        '     Mobile 同样情况会弹「未获取到麦克风权限」。**未擅自补**(用户只要求替换 alert),\n' +
+        '     已报告,等用户决定是否补齐。'
+      : '  (PC 的 openMicroPhone 失败分支已不是静默 return,脚本可删掉这段提示)'
+  );
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 2728 - 2726
src/components/BusinessAssistantMobile.vue

@@ -1,2727 +1,2729 @@
-<template>
-  <div class="ba-mobile-container">
-    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
+<template>
+  <div class="ba-mobile-container">
+    <!-- 全局提示(按会话并行生成后,发送冲突/后端 409 等都要给用户可见反馈) -->
     <!-- [已停用] 全局提示改由 Ant Design 的 message 弹出(见 useBusinessAssistantChat 的 showToast)
-
-         —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
-    <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
-      :class="{ 'chat-header': currentPage === 'chat' }">
-      <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
-        @click="isDrawerOpen = true">
-        <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-          <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
-        </svg>
-      </button>
-      <div class="header-copy">
-        <button v-if="currentPage === 'chat'" type="button" class="icon-btn menu-btn header-copy-menu"
-          aria-label="打开会话菜单" @click="isDrawerOpen = true">
-          <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-            <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
-          </svg>
-        </button>
-        <div class="header-content">
-          <div class="chat-header-title">青浦区营商助手</div>
-          <div v-if="currentPage === 'chat'" class="header-subtitle">{{ currentSessionTitle }}</div>
-        </div>
-      </div>
-    </header>
-
-    <main ref="scrollHostRef" class="mobile-main" :class="`page-${currentPage}`">
-      <!-- <ParticleBackground
-        v-if="showParticleBackground"
-        mouse-target-selector=".ba-mobile-container"
-      /> -->
-      <template v-if="currentPage === 'home'">
-        <div class="home-content-shell">
-          <section class="welcome-section">
-            <div class="overlay-icon" aria-hidden="true">
-              <img src="@/assets/qingpu_logo_2_3x.png" alt="logo"
-                style="width: 100%; height: 100%; object-fit: cover; border-radius: 999px;" />
-            </div>
-            <h1 class="heading">你好,有什么我可以帮您的?</h1>
-            <p class="desc">我是您的青浦区营商智能助手,可以为您提供惠企政策匹配以及出海、人才、科创、金融、法治方面的办事指引,请问有什么可以帮助您?</p>
-          </section>
-
-        <section class="suggestion-chips" aria-label="推荐问题">
-          <button v-for="(item, index) in suggestions" :key="item" type="button" class="chip"
-            @click="sendSuggestion(item)">
-              <span class="chip-icon" aria-hidden="true">
-                <svg v-if="index === 0" width="10" height="12" viewBox="0 0 10 12" fill="none">
-                  <path
-                    d="M4.66667 11.66667c-1.35139-0.34028-2.46701-1.11562-3.34688-2.32605-0.87986-1.21042-1.31979-2.55451-1.31979-4.03229l0-3.55833 4.66667-1.75 4.66666 1.75 0 3.55833c0 0.82639-0.14097 1.62118-0.42291 2.38438-0.28194 0.76319-0.68542 1.43646-1.21042 2.01979l-1.86667-1.86667c-0.175 0.10694-0.36215 0.18715-0.56146 0.24063-0.19931 0.05347-0.40104 0.08021-0.6052 0.08021-0.64167 0-1.19097-0.22847-1.64792-0.68542-0.45694-0.45694-0.68542-1.00625-0.68542-1.64792 0-0.64167 0.22847-1.19097 0.68542-1.64791 0.45694-0.45694 1.00625-0.68542 1.64792-0.68542 0.64167 0 1.19097 0.22847 1.64791 0.68542 0.45694 0.45694 0.68542 1.00625 0.68542 1.64791 0 0.21389-0.02674 0.42049-0.08021 0.6198-0.05347 0.19931-0.13368 0.39132-0.24062 0.57604l0.875 0.875c0.19444-0.39861 0.34514-0.81667 0.45208-1.25417 0.10694-0.4375 0.16042-0.88472 0.16042-1.34167l0-2.75625-3.5-1.3125-3.5 1.3125 0 2.75625c0 1.17639 0.33056 2.24583 0.99166 3.20834 0.66111 0.9625 1.49722 1.60417 2.50834 1.925 0.25278-0.07778 0.4934-0.17743 0.72187-0.29896 0.22847-0.12153 0.45451-0.26493 0.67813-0.43021l0.81666 0.81667c-0.32083 0.2625-0.6684 0.49097-1.04271 0.68541-0.37431 0.19444-0.76563 0.34514-1.17395 0.45209l0 0m0-4.66667c0.32083 0 0.59549-0.11424 0.82395-0.34271 0.22847-0.22847 0.34271-0.50313 0.34271-0.82396 0-0.32083-0.11424-0.59549-0.34271-0.82395-0.22847-0.22847-0.50313-0.34271-0.82395-0.34271-0.32083 0-0.59549 0.11424-0.82396 0.34271-0.22847 0.22847-0.34271 0.50313-0.34271 0.82395 0 0.32083 0.11424 0.59549 0.34271 0.82396 0.22847 0.22847 0.50312 0.34271 0.82396 0.34271l0 0m0.11666-1.12292l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"
-                    fill="currentColor" />
-                </svg>
-                <svg v-else-if="index === 1" width="13" height="13" viewBox="0 0 13 13" fill="none">
-                  <path
-                    d="M4.43333 12.25l-1.10833-1.86667-2.1-0.46666 0.20417-2.15834-1.42917-1.63333 1.42917-1.63333-0.20417-2.15834 2.1-0.46666 1.10833-1.86667 1.98334 0.84583 1.98333-0.84583 1.10833 1.86667 2.1 0.46666-0.20416 2.15834 1.42916 1.63333-1.42916 1.63333 0.20416 2.15834-2.1 0.46666-1.10833 1.86667-1.98333-0.84583-1.98334 0.84583 0 0m0.49584-1.4875l1.4875-0.64167 1.51666 0.64167 0.81667-1.4 1.60417-0.37917-0.14584-1.63333 1.07917-1.225-1.07917-1.25417 0.14584-1.63333-1.60417-0.35-0.84583-1.4-1.4875 0.64167-1.51667-0.64167-0.81667 1.4-1.60416 0.35 0.14583 1.63333-1.07917 1.25417 1.07917 1.225-0.14583 1.6625 1.60416 0.35 0.84584 1.4 0 0m1.4875-4.6375l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0m-0.6125 2.07083l3.29583-3.29583-0.81667-0.84583-2.47916 2.47916-1.25417-1.225-0.81667 0.81667 2.07084 2.07083 0 0"
-                    fill="currentColor" />
-                </svg>
-                <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
-                  <path
-                    d="M1.16667 6.41667l0 1.16666 0 0 0 0 9.33333 0 0 0 0 0 0-1.16666-9.33333 0 0 0m0-6.41667l9.33333 0c0.32083 0 0.59549 0.11424 0.82396 0.34271 0.22847 0.22847 0.34271 0.50312 0.34271 0.82396l0 6.41666c0 0.32083-0.11424 0.59549-0.34271 0.82396-0.22847 0.22847-0.50312 0.34271-0.82396 0.34271l-2.33333 0 0 2.91667-2.33334-1.16667-2.33333 1.16667 0-2.91667-2.33333 0c-0.32083 0-0.59549-0.11424-0.82396-0.34271-0.22847-0.22847-0.34271-0.50312-0.34271-0.82396l0-6.41666c0-0.32083 0.11424-0.59549 0.34271-0.82396 0.22847-0.22847 0.50312-0.34271 0.82396-0.34271l0 0m0 4.66667l9.33333 0 0-3.5 0 0 0 0-9.33333 0 0 0 0 0 0 3.5 0 0m0 2.91666l0-6.41666 0 0 0 0 0 0 0 0 0 0 0 6.41666 0 0 0 0 0 0 0 0 0 0 0 0"
-                    fill="currentColor" />
-                </svg>
-              </span>
-              {{ item }}
-            </button>
-          </section>
-        </div>
-
-        <div class="home-fixed-input" v-show="!isFreeformInputActive">
-          <BusinessAssistantComposer
-            v-model="inputText"
-            :uploaded-files="uploadedFiles"
-            :is-generating="currentSessionGenerating"
-            :is-deep-think="isDeepThink"
-            :is-voice-input-mode="isVoiceInputMode"
-            :is-voice-input-starting="isVoiceInputStarting"
-            :suggestions="[]"
-            variant="mobile"
-            :show-mock-stream-test-button="isMockStreamTestButtonVisible"
-            @update:is-deep-think="isDeepThink = $event"
-            @send="sendMobileMessage"
-            @stop="handleStopGenerate"
-            @image-upload="triggerImageUpload"
-            @file-upload="triggerFileUpload"
-            @voice-toggle="toggleVoiceInput"
-            @remove-file="removeUploadedFile"
-            @test-mock-stream="sendChatStreamReplayTestMessage"
-            @test-replay="sendChatStreamReplayTestMessage"
-            @test-typewriter="sendTypewriterTestMessage"
-            @test-scope-merge="sendScopeMergeStreamTestMessage"
-            @test-input="sendDocumentLineStreamTestMessage"
-            @test-ref-links="sendRefLinksStreamTestMessage"
-            @test-question-card="sendQuestionCardTestMessage"
-          />
-        </div>
-      </template>
-
-      <template v-else-if="currentPage === 'chat'">
-        <!-- <div class="chat-ambient" aria-hidden="true">
-          <span></span>
-          <span></span>
-        </div> -->
-        <section class="chat-stage">
-          <!-- <div class="date-indicator">{{ todayLabel }}</div> -->
-          <ScrollList v-if="currentSession" ref="scrollListRef" :data="currentSession.messages" :height="listHeight"
-            :maxHeight="listHeight" :topPadding="8" :bottomPadding="chatSuggestionsBottomPadding" :native-scroll-mode="true"
-            @scrollPositionChange="handleBusinessScrollPositionChange">
-            <template #item="{ data, isLast }">
-              <div class="message-row" :ref="(el) => setMessageRowRef(data.id, el)"
-                :class="[data.role, { 'is-last': isLast }]" :style="getMessageRowStyle(data.id, isLast, data.role)">
-                <BusinessRecord :record="data" :state="data.state" :content="data.content" :isLastRecord="isLast"
-                  :sessionId="currentSession?.id" :sessionViewedBefore="currentSessionVisitedBefore"
-                  enableFeedback
-                  @refreshScroll="handleBusinessRecordRefreshScroll"
-                  @scopeHeightLockChange="handleScopeHeightLockChange"
-                  @typewriterStateChange="handleBusinessRecordTypewriterStateChange" />
-                <Transition name="chat-suggestions-fade" appear>
-                  <div v-if="isLast && data.role === 'ai' && currentSessionSuggestions.length > 0"
-                    ref="chatSuggestionsContainerRef" class="chat-suggestions-container">
-                    <div class="chat-suggestions-gradient" aria-hidden="true"></div>
-                    <section class="chat-suggestion-chips" aria-label="跟进问题">
-                      <button v-for="(item, index) in currentSessionSuggestions.slice(0, 2)"
-                        :key="`${item.text}-${index}`" type="button" class="chip" @click="handleSuggestionClick(item)">
-                        {{ item.text }}
-                      </button>
-                    </section>
-                  </div>
-                </Transition>
-              </div>
-            </template>
-          </ScrollList>
-          <div v-else class="empty-chat">打开一次咨询,助手会在这里继续服务。</div>
-        </section>
-
-        <div class="fixed-input" v-show="!isFreeformInputActive">
-          <BusinessAssistantComposer
-            v-model="inputText"
-            :uploaded-files="uploadedFiles"
-            :is-generating="currentSessionGenerating"
-            :is-deep-think="isDeepThink"
-            :is-voice-input-mode="isVoiceInputMode"
-            :is-voice-input-starting="isVoiceInputStarting"
-            :suggestions="[]"
-            variant="mobile"
-            :show-mock-stream-test-button="isMockStreamTestButtonVisible"
-            @update:is-deep-think="isDeepThink = $event"
-            @send="sendMobileMessage"
-            @stop="handleStopGenerate"
-            @image-upload="triggerImageUpload"
-            @file-upload="triggerFileUpload"
-            @voice-toggle="toggleVoiceInput"
-            @remove-file="removeUploadedFile"
-            @test-mock-stream="sendChatStreamReplayTestMessage"
-            @test-replay="sendChatStreamReplayTestMessage"
-            @test-typewriter="sendTypewriterTestMessage"
-            @test-scope-merge="sendScopeMergeStreamTestMessage"
-            @test-input="sendDocumentLineStreamTestMessage"
-            @test-ref-links="sendRefLinksStreamTestMessage"
-            @test-question-card="sendQuestionCardTestMessage"
-          />
-          <div class="ai-disclaimer">所有内容均由 AI 生成,仅供参考</div>
-        </div>
-      </template>
-
-      <template v-else-if="currentPage === 'policy-list'">
-        <section class="policy-page">
-          <button type="button" class="page-close-btn" aria-label="关闭政策列表" @click="goHome">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-              <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
-            </svg>
-          </button>
-          <div class="policy-title-block">
-            <h1>政策事项列表</h1>
-            <p>浏览、搜索及筛选青浦最新的惠企政策</p>
-          </div>
-
-          <div class="policy-filter">
-            <label class="policy-search">
-              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
-                  stroke-width="2" stroke-linecap="round" />
-              </svg>
-              <input v-model="policyKeyword" type="search" placeholder="输入政策关键词" />
-            </label>
-            <div class="policy-filter-row">
-              <select v-model="policyCategory" class="policy-select" aria-label="政策分类">
-                <option value="">所有分类</option>
-                <option value="科技创新">科技创新</option>
-                <option value="金融扶持">金融扶持</option>
-                <option value="人才服务">人才服务</option>
-                <option value="税费优惠">税费优惠</option>
-              </select>
-              <button type="button" class="policy-filter-btn">
-                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                  <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
-                </svg>
-                筛选
-              </button>
-            </div>
-          </div>
-
-          <div class="policy-list">
-            <article v-for="policy in filteredPolicies" :key="policy.id" class="policy-card"
-              @click="openPolicyDetail(policy)">
-              <div class="policy-card-main">
-                <div class="policy-meta">
-                  <span>{{ policy.category }}</span>
-                  <time>{{ policy.date }}</time>
-                </div>
-                <h2>{{ policy.title }}</h2>
-                <p>{{ policy.summary }}</p>
-              </div>
-              <div class="match-panel" aria-hidden="true">
-                <div class="match-ring">{{ policy.match }}%</div>
-                <div class="match-label">匹配度</div>
-                <button type="button">查看</button>
-              </div>
-            </article>
-          </div>
-
-          <div class="pagination-row" aria-label="分页">
-            <button type="button" disabled>‹</button>
-            <button type="button" class="active">1</button>
-            <button type="button">2</button>
-            <button type="button">3</button>
-            <span>...</span>
-            <button type="button">12</button>
-            <button type="button">›</button>
-          </div>
-        </section>
-      </template>
-
-      <template v-else-if="currentPage === 'policy-detail' && selectedPolicy">
-        <section class="policy-detail">
-          <button type="button" class="page-close-btn" aria-label="关闭政策详情" @click="goHome">
-            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-              <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
-            </svg>
-          </button>
-          <button type="button" class="back-policy-btn" @click="openPolicyList">
-            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-              <path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
-                stroke-linejoin="round" />
-            </svg>
-            返回政策列表
-          </button>
-
-          <div class="policy-detail-hero">
-            <h1>{{ selectedPolicy.title }}</h1>
-            <div class="detail-tags">
-              <span>{{ selectedPolicy.category }}</span>
-              <span>{{ selectedPolicy.date }}</span>
-              <span>匹配度 {{ selectedPolicy.match }}%</span>
-            </div>
-          </div>
-
-          <div class="detail-grid">
-            <section class="detail-box">
-              <h2>支持对象</h2>
-              <ul>
-                <li v-for="item in selectedPolicy.targets" :key="item">{{ item }}</li>
-              </ul>
-            </section>
-            <section class="detail-box">
-              <h2>支持方式</h2>
-              <p>{{ selectedPolicy.method }}</p>
-            </section>
-          </div>
-
-          <section class="conditions-section">
-            <h2>申报条件</h2>
-            <div class="condition-lead">申报条件:{{ selectedPolicy.condition }}</div>
-            <div class="condition-card">
-              <div class="condition-status">符合</div>
-              <p>{{ selectedPolicy.condition }}</p>
-            </div>
-          </section>
-        </section>
-      </template>
-    </main>
-
-    <Transition name="drawer-fade">
-      <div v-if="isDrawerOpen" class="drawer-layer" @click.self="closeDrawer">
-        <aside class="session-drawer">
-          <div class="drawer-head">
-            <button type="button" class="new-chat-btn" @click="startNewChat">
-              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
-                <path d="M6 8H0V6h6V0h2v6h6v2H8v6H6V8Z" fill="currentColor" />
-              </svg>
-              开启新的咨询
-            </button>
-            <button type="button" class="icon-btn" aria-label="关闭会话菜单" @click="closeDrawer">
-              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
-              </svg>
-            </button>
-          </div>
-
-          <label class="drawer-search">
-            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-              <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
-                stroke-width="2" stroke-linecap="round" />
-            </svg>
-            <input v-model="sessionKeyword" type="search" placeholder="搜索历史咨询" />
-          </label>
-
-          <section class="drawer-list">
-            <h2>历史咨询</h2>
-            <div v-if="filteredChatHistory.length === 0" class="drawer-empty">还没有历史咨询,先发起一次咨询吧。</div>
-            <div v-for="session in filteredChatHistory" :key="session.id" class="drawer-session" role="button"
-              tabindex="0" :class="{
-                active: currentPage === 'chat' && currentSession?.id === session.id,
-                'is-pressing': longPressSessionId === session.id,
-              }" @click="handleSessionTap(session.id)" @keydown.enter.prevent="switchSessionById(session.id)"
-              @keydown.space.prevent="switchSessionById(session.id)"
-              @pointerdown="handleSessionPressStart(session.id, $event)" @pointerup="handleSessionPressEnd"
-              @pointerleave="handleSessionPressEnd" @pointercancel="handleSessionPressEnd" @contextmenu.prevent>
-              <span>{{ session.title || '新对话' }}</span>
-            </div>
-          </section>
-
-          <AccountInfo @logout="handleAccountLogout" />
-
-          <!-- <div class="drawer-footer">
-            <span>青浦区营商助手</span>
-            <button type="button" class="icon-btn" aria-label="打开政策列表" @click="openPolicyListFromDrawer">
-              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                <path d="M7 3h7l5 5v13H7V3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
-              </svg>
-            </button>
-          </div> -->
-        </aside>
-
-      </div>
-    </Transition>
-
-    <Teleport to="body">
-      <Transition name="session-menu-fade">
-        <div v-if="activeSessionMenuId && sessionMenuAnchor" class="session-menu-portal">
-          <div class="session-menu-overlay" aria-hidden="true" @click="closeSessionMenu"></div>
-          <div class="session-popover" :style="sessionMenuPopoverStyle" @click.stop @pointerdown.stop>
-            <button type="button" @click="handleRenameFromSheet">重命名</button>
-            <button type="button" class="danger" @click="handleDeleteFromSheet">删除</button>
-          </div>
-        </div>
-      </Transition>
-    </Teleport>
-
-    <Teleport to="body">
-      <div v-if="isRenameModalOpen" class="modal-overlay">
-        <div class="mobile-modal">
-          <div class="modal-header">
-            <h2>编辑对话名称</h2>
-            <button type="button" class="icon-btn" aria-label="关闭" @click="closeRenameModal">
-              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
-              </svg>
-            </button>
-          </div>
-          <input v-model="renameInput" class="modal-input" type="text" placeholder="输入名称" />
-          <div class="modal-actions">
-            <button type="button" @click="closeRenameModal">取消</button>
-            <button type="button" class="primary" @click="confirmRename">确定</button>
-          </div>
-        </div>
-      </div>
-
-      <div v-if="isDeleteModalOpen" class="modal-overlay">
-        <div class="mobile-modal">
-          <div class="modal-header">
-            <h2>提示</h2>
-            <button type="button" class="icon-btn" aria-label="关闭" @click="closeDeleteModal">
-              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
-                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
-              </svg>
-            </button>
-          </div>
-          <p class="delete-copy">确认删除该会话记录吗?</p>
-          <div class="modal-actions">
-            <button type="button" @click="closeDeleteModal">取消</button>
-            <button type="button" class="danger" @click="confirmDeleteSession">删除</button>
-          </div>
-        </div>
-      </div>
-    </Teleport>
-
-    <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden
-      @change="handleImageSelected" />
-    <input ref="fileInputRef" type="file" accept=".xls,.xlsx,.docx,.pdf" multiple hidden
-      @change="handleFileSelected" />
-  </div>
-</template>
-
-<script setup lang="ts">
-import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
-import BusinessRecord from "./Chat/BusinessRecord.vue";
-// [已停用] import Toast from "./Common/Toast.vue";
-import ScrollList from './Common/ScrollList.vue';
-import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
-import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
-import { useBusinessAssistantUpload } from './business-assistant/useBusinessAssistantUpload';
-import {
-  maybeStopAutoScrollAtLastAiRowTop,
-} from './business-assistant/auto-scroll-top-stop';
-import { scrollBusinessAssistantListToBottom } from './business-assistant/scroll-list-bottom';
-import ASR from '@/three-libs/asr/index';
-import AccountInfo from "./Chat/AccountInfo.vue";
-import { useEnterpriseAuth } from '@/components/useEnterpriseAuth';
-import { createModalMutex, MODAL_MUTEX_KEY } from './business-assistant/modal-mutex';
-import {
-  createFreeformInputFocusControl,
-  FREEFORM_INPUT_FOCUS_KEY,
-} from './business-assistant/freeform-focus-control';
-import {
-  calculateMobileListHeight,
-} from './business-assistant/mobile-viewport-height';
-
-provide(MODAL_MUTEX_KEY, createModalMutex());
-
-provide('formOptionsControl', {
-  setFormOptions: () => { },
-});
-
-provide('speechControl', {
-  stopSpeech: () => { },
-  handleStopSpeech: () => { },
-  handleEndSpeech: () => { },
-});
-
-const freeformInputFocusControl = createFreeformInputFocusControl();
-const isFreeformInputActive = freeformInputFocusControl.active;
-provide(FREEFORM_INPUT_FOCUS_KEY, freeformInputFocusControl);
-
-interface PolicyItem {
-  id: string;
-  title: string;
-  category: string;
-  date: string;
-  summary: string;
-  match: number;
-  targets: string[];
-  method: string;
-  condition: string;
-}
-
-const currentPage = ref<'home' | 'chat' | 'policy-list' | 'policy-detail'>('home');
-const isDrawerOpen = ref(false);
-const activeSessionMenuId = ref<string | null>(null);
-const sessionKeyword = ref('');
-const renameInput = ref('');
-const renameSessionId = ref<string | null>(null);
-const deleteSessionId = ref<string | null>(null);
-const isRenameModalOpen = ref(false);
-const isDeleteModalOpen = ref(false);
-const scrollHostRef = ref<HTMLElement | null>(null);
-const scrollListRef = ref<any>(null);
-const listHeight = ref(520);
-const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
-const messageRowMinHeights = reactive<Record<string, number>>({});
-const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
-const chatSuggestionsBottomPadding = ref(0);
-const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
-const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
-/**
- * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
- * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
- */
-const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
-/** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
-const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
-const hasStoppedAtLastAiRowTop = ref(false);
-const lastAiRowTopConsumedRecordId = ref<string | null>(null);
-const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
-const lastAiRowTypewriterStates = ref<Record<string, boolean>>({});
-let lastAiRowTypingReleaseTimer: number | null = null;
-
-const policyKeyword = ref('');
-const policyCategory = ref('');
-const selectedPolicy = ref<PolicyItem | null>(null);
-const isVoiceInputMode = ref(false);
-const isVoiceInputStarting = ref(false);
-const longPressSessionId = ref<string | null>(null);
-const sessionMenuAnchor = ref<{ left: number; bottom: number } | null>(null);
-let asrInstance: ASR | null = null;
-const mobileScrollAnimationDurationMs = 300;
-let mobileScrollAnimationFrameId = 0;
-let mobileScrollAnimationRevision = 0;
-let mobileScrollAnimationResolve: ((value?: unknown) => void) | null = null;
-
-const cancelMobileScrollAnimation = () => {
-  if (mobileScrollAnimationFrameId) {
-    cancelAnimationFrame(mobileScrollAnimationFrameId);
-    mobileScrollAnimationFrameId = 0;
-  }
-  if (mobileScrollAnimationResolve) {
-    mobileScrollAnimationResolve();
-    mobileScrollAnimationResolve = null;
-  }
-  mobileScrollAnimationRevision += 1;
-};
-let sessionPressTimer: number | null = null;
-let sessionPressTriggeredId: string | null = null;
-let sessionPressPointerId: number | null = null;
-const isMockStreamTestButtonVisible = import.meta.env.MODE === 'development'
-// const todayLabel = computed(() => {
-//   const now = new Date();
-//   const hours = String(now.getHours()).padStart(2, '0');
-//   const minutes = String(now.getMinutes()).padStart(2, '0');
-//   return `今天 ${hours}:${minutes}`;
-// });
-
-const imageInputRef = ref<HTMLInputElement | null>(null);
-const fileInputRef = ref<HTMLInputElement | null>(null);
-const {
-  uploadedFiles,
-  triggerImageUpload,
-  triggerFileUpload,
-  handleImageSelected,
-  handleFileSelected,
-  removeUploadedFile,
-  consumeSuccessfulFileTransmission,
-  setUploadDraftKey,
-  clearUploadDraft,
-} = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
-
-/**
- * 上传校验失败时的提示通道。
- * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
- * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
- */
-let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
-
-const {
-  suggestions,
-  chatHistory,
-  currentSessionVisitedBefore,
-  currentSession,
-  currentSessionTitle,
-  currentSessionSuggestions,
-  inputText,
-  isGenerating,
-  currentSessionGenerating,

-  showToast,
-  stopAllTasks,
-  isDeepThink,
-  loadHistory,
-  sendMessage,
-  sendChatStreamReplayTestMessage,
-  sendTypewriterTestMessage,
-  sendScopeMergeStreamTestMessage,
-  sendDocumentLineStreamTestMessage,
-  sendRefLinksStreamTestMessage,
-  sendQuestionCardTestMessage,
-  handleStopGenerate,
-  startNewChat: startSharedNewChat,
-  switchSessionById: switchSharedSessionById,
-  renameSessionById,
-  deleteSessionById,
-  mergeRemoteSessions,
-  loadRemoteSessionRecords,
-} = useBusinessAssistantChat({
-  // 新协调器自行读取 VITE_CHAT_API,此参数仅保留签名兼容
-  apiBaseUrl: import.meta.env.VITE_API || "",
-  beforeSend: async () => {
-    if (isVoiceInputMode.value) {
-      await stopVoiceInput();
-    }
-  },
-  afterSend: async () => {
-    currentPage.value = 'chat';
-    await scrollToBottom(true);
-  },
-  onMessageUpdated: () => scrollToBottom(),
-  getInputFileTransmission: consumeSuccessfulFileTransmission,
-});
-
-// 上传校验的提示通道接通(见上面 notifyUpload 的说明)
-notifyUpload = showToast;
-
-const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
-
-const handleAccountLogout = () => {
-  // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
-  // 旧账户的任务继续写会把内容落到新身份下
-  stopAllTasks();
-  closeDrawer();
-  currentPage.value = 'home';
-  loadHistory();
-  startSharedNewChat();
-};
-
-const policies: PolicyItem[] = [
-  {
-    id: 'policy-1',
-    title: '关于支持青浦自贸区科技创新企业发展的若干意见',
-    category: '科技创新',
-    date: '2026-06-12',
-    summary: '围绕研发投入、成果转化、创新平台建设给予综合支持。',
-    match: 92,
-    targets: ['青浦区注册科技型企业', '拥有研发活动或知识产权成果的企业'],
-    method: '对符合条件的企业给予研发补贴、平台建设资助和成果转化奖励。',
-    condition: '企业需在青浦区依法登记纳税,具备持续研发能力,项目资料真实完整。',
-  },
-  {
-    id: 'policy-2',
-    title: '青浦自贸区中小微企业贷款贴息申报指南',
-    category: '金融扶持',
-    date: '2026-05-28',
-    summary: '降低中小微企业融资成本,支持稳经营、扩产能。',
-    match: 88,
-    targets: ['青浦区中小微企业', '已获得银行经营性贷款的企业'],
-    method: '按照实际贷款利息的一定比例给予贴息支持。',
-    condition: '贷款用途须用于生产经营,企业信用记录良好,申报材料与贷款凭证一致。',
-  },
-  {
-    id: 'policy-3',
-    title: '2026年度“青峰”人才计划领军人物评选通知',
-    category: '人才服务',
-    date: '2026-05-14',
-    summary: '聚焦高层次人才引育,为重点团队提供人才服务与项目支持。',
-    match: 84,
-    targets: ['重点产业领域领军人才', '承担核心研发或经营管理职责的人才'],
-    method: '提供人才补贴、项目资助、服务保障和资源对接。',
-    condition: '申报人需符合青浦区重点产业方向,并由所在单位提交推荐材料。',
-  },
-  {
-    id: 'policy-4',
-    title: '高新技术企业所得税优惠政策延续执行说明',
-    category: '税费优惠',
-    date: '2026-04-30',
-    summary: '明确高新技术企业税收优惠延续执行口径和申报注意事项。',
-    match: 79,
-    targets: ['有效期内高新技术企业', '拟申报高新技术企业认定的企业'],
-    method: '对符合条件的高新技术企业按规定享受企业所得税优惠。',
-    condition: '企业需保持高新技术企业资格有效,研发费用、收入结构等指标满足要求。',
-  },
-];
-
-const filteredChatHistory = computed(() => {
-  const keyword = sessionKeyword.value.trim().toLowerCase();
-  if (!keyword) {
-    return chatHistory.value;
-  }
-  return chatHistory.value.filter((session) => (session.title || '').toLowerCase().includes(keyword));
-});
-const filteredPolicies = computed(() => {
-  const keyword = policyKeyword.value.trim();
-  return policies.filter((policy) => {
-    const matchedKeyword = !keyword || `${policy.title}${policy.summary}`.includes(keyword);
-    const matchedCategory = !policyCategory.value || policy.category === policyCategory.value;
-    return matchedKeyword && matchedCategory;
-  });
-});
-
-const setMessageRowRef = (id: string | number, el: HTMLElement | null) => {
-  const key = String(id);
-  if (el) {
-    messageRowRefs[key] = el;
-  } else {
-    delete messageRowRefs[key];
-  }
-};
-
-const setMessageRowMinHeight = (recordId: string | number, height: number | null | undefined) => {
-  const key = String(recordId);
-  const nextHeight = Number(height);
-  if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
-    delete messageRowMinHeights[key];
-    return;
-  }
-
-  messageRowMinHeights[key] = Math.ceil(nextHeight);
-};
-
-const getMessageRowMinHeight = (recordId: string | number) => {
-  const height = messageRowMinHeights[String(recordId)];
-  return Number.isFinite(height) && height > 0 ? height : null;
-};
-
-const getMessageRowStyle = (recordId: string | number, isLast: boolean, role: string) => {
-  if (!isLast || role !== 'ai') {
-    return undefined;
-  }
-
-  const minHeight = getMessageRowMinHeight(recordId);
-  if (!minHeight) {
-    return undefined;
-  }
-
-  return {
-    minHeight: `${minHeight}px`,
-  };
-};
-
-const getLastAiMessage = () => {
-  const messages = currentSession.value?.messages || [];
-  for (let index = messages.length - 1; index >= 0; index -= 1) {
-    const message = messages[index];
-    if (message?.role === 'ai') {
-      return message;
-    }
-  }
-  return null;
-};
-
-const getRowScrollTop = (scrollElement: HTMLElement, rowElement: HTMLElement) => {
-  const scrollRect = scrollElement.getBoundingClientRect();
-  const rowRect = rowElement.getBoundingClientRect();
-  return rowRect.top - scrollRect.top + scrollElement.scrollTop;
-};
-
-const isLastAiRowTyping = () => {
-  const lastAiMessage = getLastAiMessage();
-  if (!lastAiMessage) {
-    return false;
-  }
-
-  const isTyping = Boolean(lastAiRowTypewriterStates.value[String(lastAiMessage.id)]);
-  return isTyping;
-};
-
-const clearLastAiRowTopSnapshot = () => {
-  lastAiRowTopSnapshot.value = null;
-};
-
-const consumeLastAiRowTopForRecord = (recordId: string | number | null | undefined) => {
-  if (recordId === undefined || recordId === null) {
-    return;
-  }
-
-  lastAiRowTopConsumedRecordId.value = String(recordId);
-  hasStoppedAtLastAiRowTop.value = true;
-};
-
-const clearLastAiRowTypingReleaseTimer = () => {
-  if (lastAiRowTypingReleaseTimer !== null) {
-    window.clearTimeout(lastAiRowTypingReleaseTimer);
-    lastAiRowTypingReleaseTimer = null;
-  }
-};
-
-const setLastAiRowTopPinState = (active: boolean) => {
-  const scrollList = scrollListRef.value;
-  if (scrollList && typeof scrollList.setAutoScrollTopPinned === "function") {
-    scrollList.setAutoScrollTopPinned(active);
-  }
-};
-
-const isLastAiRowTopPinned = () => {
-  const scrollList = scrollListRef.value;
-  if (!scrollList || typeof scrollList.isAutoScrollTopPinned !== "function") {
-    return false;
-  }
-
-  return scrollList.isAutoScrollTopPinned();
-};
-
-const releaseLastAiRowTopPin = (reason: "typewriter-finished" | "session-switch" | "user-scroll") => {
-  clearLastAiRowTypingReleaseTimer();
-  setLastAiRowTopPinState(false);
-  clearLastAiRowTopSnapshot();
-
-  if (reason === "user-scroll") {
-    return;
-  }
-};
-
-const getOrCreateLastAiRowTopSnapshot = (recordId: string, rowOffsetTop: number) => {
-  if (
-    lastAiRowTopSnapshot.value?.recordId !== recordId ||
-    !Number.isFinite(lastAiRowTopSnapshot.value?.rowOffsetTop)
-  ) {
-    lastAiRowTopSnapshot.value = {
-      recordId,
-      rowOffsetTop,
-    };
-  }
-
-  return lastAiRowTopSnapshot.value;
-};
-
-const stopAutoScrollAtLastAiRowTopIfNeeded = () => {
-  const scrollList = scrollListRef.value;
-  if (scrollList && typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" && scrollList.isAutoScrollTopPinSuppressedByUser()) {
-    return false;
-  }
-
-  if (!isLastAiRowTyping()) {
-    clearLastAiRowTopSnapshot();
-    if (isLastAiRowTopPinned()) {
-      releaseLastAiRowTopPin("typewriter-finished");
-    }
-    return false;
-  }
-
-  if (!scrollList || typeof scrollList.getScrollElement !== "function") {
-    return false;
-  }
-
-  const scrollElement = scrollList.getScrollElement();
-  const lastAiMessage = getLastAiMessage();
-  if (!scrollElement || !lastAiMessage) {
-    return false;
-  }
-
-  const lastAiMessageId = String(lastAiMessage.id);
-  if (lastAiRowTopConsumedRecordId.value === lastAiMessageId) {
-    return false;
-  }
-
-  const rowElement = messageRowRefs[String(lastAiMessage.id)];
-  if (!rowElement) {
-    return false;
-  }
-
-  if (typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
-    scrollList.enableAutoScroll();
-  }
-
-  const isAutoScrollActive =
-    typeof scrollList.isAutoScrollEnabled === "function" ? scrollList.isAutoScrollEnabled() : true;
-  const rowScrollTop = getRowScrollTop(scrollElement, rowElement);
-  const snapshot = getOrCreateLastAiRowTopSnapshot(String(lastAiMessage.id), rowScrollTop);
-
-  const result = maybeStopAutoScrollAtLastAiRowTop({
-    isLastAiRowTyping: true,
-    isAutoScrollActive,
-    hasStoppedAtLastAiRowTop: hasStoppedAtLastAiRowTop.value,
-    scrollTop: scrollElement.scrollTop,
-    rowOffsetTop: snapshot.rowOffsetTop,
-    disableAutoScroll: () => {
-      if (typeof scrollList.disableAutoScroll === "function") {
-        scrollList.disableAutoScroll();
-      }
-    },
-  });
-
-  hasStoppedAtLastAiRowTop.value = result.hasStoppedAtLastAiRowTop;
-  if (result.didStop) {
-    consumeLastAiRowTopForRecord(lastAiMessage.id);
-    setLastAiRowTopPinState(true);
-  }
-  if (result.didStop && result.nextScrollTop !== null) {
-    if (typeof scrollList.scrollToPositionInstant === "function") {
-      scrollList.scrollToPositionInstant(result.nextScrollTop);
-    } else {
-      scrollElement.scrollTop = result.nextScrollTop;
-    }
-  }
-
-  return result.didStop;
-};
-
-const syncAutoScrollFollowState = () => {
-  const scrollList = scrollListRef.value;
-  if (!scrollList || typeof scrollList.getScrollElement !== "function") {
-    return;
-  }
-
-  const scrollElement = scrollList.getScrollElement();
-  const lastAiMessage = getLastAiMessage();
-  if (!scrollElement || !lastAiMessage) {
-    return;
-  }
-
-  const rowElement = messageRowRefs[String(lastAiMessage.id)];
-  if (!rowElement) {
-    return;
-  }
-
-  const distanceToBottom =
-    typeof scrollList.getDistanceToBottom === "function" ? scrollList.getDistanceToBottom() : Number.POSITIVE_INFINITY;
-
-  const isUserScrollSuppressed =
-    Boolean(
-      typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
-      scrollList.isAutoScrollTopPinSuppressedByUser()
-    );
-
-  if (isUserScrollSuppressed) {
-    if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
-      scrollList.enableAutoScroll();
-    }
-    return;
-  }
-
-  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
-    return;
-  }
-
-  if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
-    scrollList.enableAutoScroll();
-  }
-};
-
-let chatSuggestionsResizeObserver: ResizeObserver | null = null;
-let listHeightRecoveryFrame = 0;
-let listHeightRecoveryFallbackTimer: ReturnType<typeof setTimeout> | undefined;
-const listHeightRecoveryMaxDuration = 2000;
-const listHeightRecoveryFallbackDelay = 3000;
-const listHeightStableFramesRequired = 4;
-
-const stopListHeightRecovery = () => {
-  if (listHeightRecoveryFrame) {
-    cancelAnimationFrame(listHeightRecoveryFrame);
-    listHeightRecoveryFrame = 0;
-  }
-  if (listHeightRecoveryFallbackTimer) {
-    clearTimeout(listHeightRecoveryFallbackTimer);
-    listHeightRecoveryFallbackTimer = undefined;
-  }
-};
-
-const startListHeightRecovery = () => {
-  stopListHeightRecovery();
-  const startedAt = performance.now();
-  const initialViewportHeight = Math.floor(window.innerHeight);
-  let lastViewportHeight = initialViewportHeight;
-  let stableFrameCount = 0;
-  let viewportRecoveryObserved = false;
-
-  const recover = (timestamp: number) => {
-    const viewportHeight = Math.floor(window.innerHeight);
-    updateListHeight();
-
-    if (viewportHeight > initialViewportHeight + 1) {
-      viewportRecoveryObserved = true;
-    }
-    if (Math.abs(viewportHeight - lastViewportHeight) <= 1) {
-      stableFrameCount += 1;
-    } else {
-      stableFrameCount = 0;
-      lastViewportHeight = viewportHeight;
-    }
-
-    const recoveredAndStable = viewportRecoveryObserved && stableFrameCount >= listHeightStableFramesRequired;
-    if (!recoveredAndStable && timestamp - startedAt < listHeightRecoveryMaxDuration) {
-      listHeightRecoveryFrame = requestAnimationFrame(recover);
-      return;
-    }
-    listHeightRecoveryFrame = 0;
-  };
-
-  listHeightRecoveryFallbackTimer = setTimeout(() => {
-    listHeightRecoveryFallbackTimer = undefined;
-    updateListHeight();
-  }, listHeightRecoveryFallbackDelay);
-  listHeightRecoveryFrame = requestAnimationFrame(recover);
-};
-
-onMounted(() => {
-  loadHistory();
-  if ((currentSession.value?.messages.length || 0) > 0) {
-    currentPage.value = 'chat';
-  }
-  updateListHeight();
-  void observeChatSuggestionsHeight();
-  window.addEventListener('resize', updateListHeight);
-  window.visualViewport?.addEventListener('resize', updateListHeight);
-  window.visualViewport?.addEventListener('scroll', updateListHeight);
-  const fetchAndMergeRemoteSessions = async () => {
-    const { fetchRemoteSessions } = await import('@/network/api/chat-sessions');
-    const remoteSessions = await fetchRemoteSessions(creditCode.value);
-    if (remoteSessions.length > 0) {
-      mergeRemoteSessions(remoteSessions);
-    }
-  };
-  initAuth().then(() => {
-    if (isLoggedIn.value) {
-      loadHistory();
-      void fetchAndMergeRemoteSessions();
-    }
-  });
-});
-
-onBeforeUnmount(() => {
-  window.removeEventListener('resize', updateListHeight);
-  window.visualViewport?.removeEventListener('resize', updateListHeight);
-  window.visualViewport?.removeEventListener('scroll', updateListHeight);
-  stopListHeightRecovery();
-  chatSuggestionsResizeObserver?.disconnect();
-  void stopVoiceInput();
-  clearSessionPressTimer();
-  clearLastAiRowTypingReleaseTimer();
-  releaseLastAiRowTopPin("session-switch");
-  cancelMobileScrollAnimation();
-});
-
-const updateListHeight = () => {
-  listHeight.value = calculateMobileListHeight(window.innerHeight, isFreeformInputActive.value);
-  updateChatSuggestionsBottomPadding();
-};
-
-const updateChatSuggestionsBottomPadding = () => {
-  if (currentPage.value !== 'chat' || currentSessionSuggestions.value.length === 0) {
-    chatSuggestionsBottomPadding.value = 0;
-    return;
-  }
-
-  chatSuggestionsBottomPadding.value = Math.ceil(
-    chatSuggestionsContainerRef.value?.getBoundingClientRect().height || 0,
-  ) + 16;
-};
-
-const observeChatSuggestionsHeight = async () => {
-  await nextTick();
-  chatSuggestionsResizeObserver?.disconnect();
-  chatSuggestionsResizeObserver = null;
-
-  if (currentPage.value === 'chat' && chatSuggestionsContainerRef.value) {
-    chatSuggestionsResizeObserver = new ResizeObserver(updateChatSuggestionsBottomPadding);
-    chatSuggestionsResizeObserver.observe(chatSuggestionsContainerRef.value);
-  }
-
-  updateChatSuggestionsBottomPadding();
-};
-
-watch(
-  [currentPage, () => currentSessionSuggestions.value.length],
-  () => {
-    void observeChatSuggestionsHeight();
-  },
-  { flush: 'post' },
-);
-
-watch(isFreeformInputActive, (isFocused) => {
-  updateListHeight();
-  if (isFocused) {
-    stopListHeightRecovery();
-    return;
-  }
-  startListHeightRecovery();
-}, { flush: 'post' });
-
-watch(
-  [currentPage, () => currentSession.value?.id || null],
-  ([page, sessionId]) => {
-    setUploadDraftKey(page === 'chat' ? sessionId : null);
-  },
-  { immediate: true },
-);
-
-const sendSuggestion = (text: string) => {
-  inputText.value = text;
-  void sendMobileMessage();
-};
-
-const handleSuggestionClick = (item: { text: string }) => {
-  inputText.value = item.text;
-  void sendMessage();
-};
-
-const sendMobileMessage = async () => {
-  if (currentPage.value === 'home' && currentSession.value) {
-    const pendingText = inputText.value;
-    startSharedNewChat();
-    inputText.value = pendingText;
-  }
-
-  await sendMessage();
-};
-
-const releaseVoiceInputInstance = () => {
-  if (!asrInstance) {
-    return;
-  }
-
-  asrInstance.close();
-  asrInstance.clear();
-  asrInstance = null;
-};
-
-const stopVoiceInput = async () => {
-  const wasVoiceInputActive = isVoiceInputMode.value;
-  isVoiceInputMode.value = false;
-  isVoiceInputStarting.value = false;
-
-  if (!asrInstance) {
-    return;
-  }
-
-  if (wasVoiceInputActive) {
-    try {
-      const result = await asrInstance.transform();
-      if (result?.text) {
-        inputText.value = result.text;
-      }
-    } catch (error) {
-      console.error("Mobile voice input transform error:", error);
-    }
-  }
-
-  releaseVoiceInputInstance();
-};
-
-const toggleVoiceInput = async () => {
-  if (isVoiceInputStarting.value) {
-    return;
-  }
-
-  if (isVoiceInputMode.value) {
-    await stopVoiceInput();
-    return;
-  }
-
-  if (inputText.value.trim().length > 0) {
-    const confirmClear = window.confirm("可能会清空之前的文字,确认要清空吗?");
-    if (!confirmClear) {
-      return;
-    }
-    inputText.value = "";
-  }
-
-  isVoiceInputStarting.value = true;
-
-  try {
-    asrInstance = new ASR("ce6cb2020037ed20f8a09a4822e869f380a0de96", {
-      audio: true,
-      video: false,
-    });
-
-    let success = false;
-    try {
-      success = await asrInstance.openMicroPhone();
-    } catch (error) {
-      console.error("Mobile voice input microphone error:", error);
-      window.alert("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
-      releaseVoiceInputInstance();
-      return;
-    }
-
-    if (!success) {
-      window.alert("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
-      releaseVoiceInputInstance();
-      return;
-    }
-
-    asrInstance.addEventListener("transform", (arg: any) => {
-      inputText.value = arg.transformText;
-    });
-
-    asrInstance.startRecord();
-    await asrInstance.openSocket();
-    isVoiceInputMode.value = true;
-  } catch (error) {
-    console.error("Mobile voice input error:", error);
-    window.alert(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试");
-    isVoiceInputMode.value = false;
-    releaseVoiceInputInstance();
-  } finally {
-    isVoiceInputStarting.value = false;
-  }
-};
-
-const scrollToBottom = async (force = false) => {
-  await nextTick();
-  if (scrollListRef.value) {
-    if (force && typeof scrollListRef.value.enableAutoScroll === 'function') {
-      scrollListRef.value.enableAutoScroll();
-      releaseLastAiRowTopPin("session-switch");
-    }
-    scrollListRef.value.scrollToBottom();
-  }
-  if (scrollHostRef.value) {
-    scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
-  }
-};
-
-const scrollToBottomAfterSessionSwitch = async () => {
-  await scrollToBottom(true);
-  await nextTick();
-
-  scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
-
-  if (scrollHostRef.value) {
-    scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
-  }
-};
-
-const animateScrollElementBy = (element: HTMLElement | null, delta: number, duration = mobileScrollAnimationDurationMs) => {
-  if (!element || !Number.isFinite(delta) || delta === 0) {
-    return Promise.resolve();
-  }
-
-  cancelMobileScrollAnimation();
-
-  const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
-  const startTop = element.scrollTop;
-  const targetTop = Math.max(0, Math.min(maxScrollTop, startTop + delta));
-
-  if (targetTop === startTop) {
-    return Promise.resolve();
-  }
-
-  return new Promise((resolve) => {
-    const startTime = performance.now();
-    const revision = ++mobileScrollAnimationRevision;
-
-    const finish = () => {
-      if (mobileScrollAnimationResolve) {
-        mobileScrollAnimationResolve();
-        mobileScrollAnimationResolve = null;
-      }
-      mobileScrollAnimationFrameId = 0;
-    };
-
-    mobileScrollAnimationResolve = resolve;
-
-    const step = (now: number) => {
-      if (revision !== mobileScrollAnimationRevision) {
-        finish();
-        return;
-      }
-
-      const progress = Math.min(1, (now - startTime) / duration);
-      const eased = 1 - Math.pow(1 - progress, 3);
-      element.scrollTop = startTop + (targetTop - startTop) * eased;
-
-      if (progress < 1) {
-        mobileScrollAnimationFrameId = requestAnimationFrame(step);
-      } else {
-        finish();
-      }
-    };
-
-    mobileScrollAnimationFrameId = requestAnimationFrame(step);
-  });
-};
-
-const getLastAiRowBodyHeight = () => {
-  const lastAiMessage = getLastAiMessage();
-  if (!lastAiMessage) {
-    return 0;
-  }
-
-  const rowElement = messageRowRefs[String(lastAiMessage.id)];
-  const bodyElement = rowElement?.querySelector(".business-record-body") as HTMLElement | null;
-  return bodyElement ? Math.ceil(bodyElement.getBoundingClientRect().height) : 0;
-};
-
-const releaseScopeHeightLockIfReady = () => {
-  const lastAiMessage = getLastAiMessage();
-  if (!lastAiMessage) {
-    return false;
-  }
-
-  const key = String(lastAiMessage.id);
-  const lockHeight = messageRowMinHeights[key];
-  if (!Number.isFinite(lockHeight) || lockHeight <= 0) {
-    return false;
-  }
-
-  const bodyHeight = getLastAiRowBodyHeight();
-  if (bodyHeight < lockHeight) {
-    return false;
-  }
-
-  delete messageRowMinHeights[key];
-  return true;
-};
-
-/**
- * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
- * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
- */
-const anchorLargeBlockTop = (heightGrowth: number): boolean => {
-  const scrollList = scrollListRef.value;
-  const lastAiMessage = getLastAiMessage();
-  if (!scrollList || typeof scrollList.getScrollElement !== 'function' || !lastAiMessage) {
-    return false;
-  }
-
-  const scrollElement = scrollList.getScrollElement();
-  const rowElement = messageRowRefs[String(lastAiMessage.id)];
-  if (!scrollElement || !rowElement) {
-    return false;
-  }
-
-  const rowTop = getRowScrollTop(scrollElement, rowElement);
-  const rowHeightAfter = rowElement.getBoundingClientRect().height;
-  const blockTop = rowTop + Math.max(0, rowHeightAfter - heightGrowth) - BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET;
-
-  if (typeof scrollList.scrollToPositionInstant === 'function') {
-    scrollList.scrollToPositionInstant(blockTop);
-  } else {
-    scrollElement.scrollTop = blockTop;
-  }
-  return true;
-};
-
-const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
-  const scrollList = scrollListRef.value;
-  if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
-    payload?.onComplete?.();
-    return;
-  }
-
-  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
-    syncAutoScrollFollowState();
-    payload?.onComplete?.();
-    return;
-  }
-
-  const isUserScrollSuppressed =
-    Boolean(
-      scrollList &&
-      typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
-      scrollList.isAutoScrollTopPinSuppressedByUser()
-    );
-
-  if (scrollList && typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
-    if (isUserScrollSuppressed) {
-      const distanceToBottom =
-        typeof scrollList.getDistanceToBottom === "function"
-          ? scrollList.getDistanceToBottom()
-          : Number.POSITIVE_INFINITY;
-
-      if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE) {
-        scrollList.enableAutoScroll();
-      }
-      payload?.onComplete?.();
-      return;
-    }
-
-    scrollList.enableAutoScroll();
-    payload?.onComplete?.();
-    return;
-  }
-
-  const releasedLock = releaseScopeHeightLockIfReady();
-  if (releasedLock) {
-    syncAutoScrollFollowState();
-  }
-
-  const scrollDelta = Number(payload?.scrollDelta || 0);
-  if (Number.isFinite(scrollDelta) && scrollDelta !== 0) {
-    const scrollTasks: Promise<unknown>[] = [];
-    if (scrollListRef.value && typeof scrollListRef.value.scrollByAnimated === "function") {
-      scrollTasks.push(scrollListRef.value.scrollByAnimated(scrollDelta, mobileScrollAnimationDurationMs));
-    } else if (scrollListRef.value && typeof scrollListRef.value.scrollBy === "function") {
-      scrollListRef.value.scrollBy(scrollDelta);
-    }
-
-    scrollTasks.push(animateScrollElementBy(scrollHostRef.value, scrollDelta));
-
-    await Promise.all(scrollTasks);
-
-    syncAutoScrollFollowState();
-    payload?.onComplete?.();
-
-    return;
-  }
-
-  await nextTick();
-  if (!scrollListRef.value) {
-    return;
-  }
-
-  if (!releasedLock) {
-    const lastAiMessage = getLastAiMessage();
-    if (lastAiMessage) {
-      const lockHeight = messageRowMinHeights[String(lastAiMessage.id)];
-      if (
-        Number.isFinite(lockHeight) &&
-        lockHeight > 0 &&
-        getLastAiRowBodyHeight() < lockHeight &&
-        !isLastAiRowTyping()
-      ) {
-        return;
-      }
-    }
-  }
-
-  const heightGrowth = Number(payload?.heightGrowth || 0);
-  const distanceAfterGrowth =
-    typeof scrollListRef.value.getDistanceToBottom === 'function'
-      ? scrollListRef.value.getDistanceToBottom()
-      : Number.POSITIVE_INFINITY;
-
-  const distanceBeforeGrowth = Math.max(0, distanceAfterGrowth - heightGrowth);
-
-  if (!Number.isFinite(heightGrowth) || heightGrowth <= 0) {
-    if (distanceAfterGrowth <= 24) {
-      scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
-    } else {
-      scrollBusinessAssistantListToBottom(scrollListRef.value);
-    }
-    return;
-  }
-
-  if (
-    distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
-    heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
-  ) {
-    // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
-    // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
-    if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
-      payload?.onComplete?.();
-      return;
-    }
-
-    if (distanceAfterGrowth <= 24) {
-      scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
-    } else {
-      scrollBusinessAssistantListToBottom(scrollListRef.value, 'once');
-    }
-  }
-
-  syncAutoScrollFollowState();
-};
-
-const handleBusinessScrollPositionChange = (payload?: { isTrusted?: boolean; isProgrammaticScroll?: boolean }) => {
-  if (payload?.isTrusted && !payload?.isProgrammaticScroll) {
-    cancelMobileScrollAnimation();
-    consumeLastAiRowTopForRecord(getLastAiMessage()?.id ?? null);
-    releaseLastAiRowTopPin("user-scroll");
-  }
-
-  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
-    syncAutoScrollFollowState();
-    return;
-  }
-
-  syncAutoScrollFollowState();
-};
-
-const handleScopeHeightLockChange = (payload?: { recordId?: string | number; active?: boolean; height?: number }) => {
-  const recordId = payload?.recordId;
-  if (recordId === undefined || recordId === null) {
-    return;
-  }
-
-  if (payload?.active) {
-    Object.keys(messageRowMinHeights).forEach((key) => {
-      delete messageRowMinHeights[key];
-    });
-    setMessageRowMinHeight(recordId, payload.height);
-    return;
-  }
-
-  setMessageRowMinHeight(recordId, null);
-};
-
-const handleBusinessRecordTypewriterStateChange = (payload?: { recordId?: string | number; isTypingActive?: boolean }) => {
-  const recordId = payload?.recordId;
-  if (recordId === undefined || recordId === null) {
-    return;
-  }
-
-  const key = String(recordId);
-  if (payload?.isTypingActive) {
-    if (lastAiRowTopConsumedRecordId.value && lastAiRowTopConsumedRecordId.value !== key) {
-      lastAiRowTopConsumedRecordId.value = null;
-      hasStoppedAtLastAiRowTop.value = false;
-      clearLastAiRowTopSnapshot();
-    }
-
-    clearLastAiRowTypingReleaseTimer();
-    lastAiRowTypewriterStates.value[key] = true;
-    syncAutoScrollFollowState();
-    return;
-  }
-
-  clearLastAiRowTypingReleaseTimer();
-  lastAiRowTypingReleaseTimer = window.setTimeout(() => {
-    if (lastAiRowTypewriterStates.value[key]) {
-      return;
-    }
-
-    delete lastAiRowTypewriterStates.value[key];
-    releaseLastAiRowTopPin("typewriter-finished");
-    syncAutoScrollFollowState();
-  }, 180);
-};
-
-const goHome = () => {
-  currentPage.value = 'home';
-};
-
-const openPolicyList = () => {
-  currentPage.value = 'policy-list';
-  selectedPolicy.value = null;
-};
-
-
-const openPolicyDetail = (policy: PolicyItem) => {
-  selectedPolicy.value = policy;
-  currentPage.value = 'policy-detail';
-};
-
-const closeDrawer = () => {
-  isDrawerOpen.value = false;
-  activeSessionMenuId.value = null;
-  longPressSessionId.value = null;
-};
-
-const startNewChat = () => {
-  setUploadDraftKey(null);
-  clearUploadDraft(null);
-  startSharedNewChat();
-  currentPage.value = 'home';
-  closeDrawer();
-};
-
-const switchSessionById = (id: string) => {
-  const session = chatHistory.value.find((s) => s.id === id);
-
-  const doSwitch = () => {
-    if (switchSharedSessionById(id)) {
-      currentPage.value = 'chat';
-      closeDrawer();
-      void scrollToBottomAfterSessionSwitch();
-    }
-  };
-
-  if (session && session.messages.length === 0 && isLoggedIn.value) {
-    void loadRemoteSessionRecords(id).then(() => {
-      doSwitch();
-    });
-    return;
-  }
-
-  doSwitch();
-};
-
-
-const clearSessionPressTimer = () => {
-  if (sessionPressTimer === null) {
-    return;
-  }
-
-  window.clearTimeout(sessionPressTimer);
-  sessionPressTimer = null;
-};
-
-const closeSessionMenu = () => {
-  activeSessionMenuId.value = null;
-  longPressSessionId.value = null;
-  sessionPressTriggeredId = null;
-  sessionPressPointerId = null;
-  sessionMenuAnchor.value = null;
-};
-
-const handleSessionTap = (id: string) => {
-  if (activeSessionMenuId.value === id) {
-    return;
-  }
-
-  switchSessionById(id);
-};
-
-const handleSessionPressStart = (id: string, event: PointerEvent) => {
-  if (event.pointerType === 'mouse' && event.button !== 0) {
-    return;
-  }
-
-  clearSessionPressTimer();
-  sessionPressTriggeredId = null;
-  sessionPressPointerId = event.pointerId;
-  longPressSessionId.value = id;
-  const target = event.currentTarget as HTMLElement | null;
-  const targetRect = target?.getBoundingClientRect() || null;
-
-  sessionPressTimer = window.setTimeout(() => {
-    sessionPressTimer = null;
-    sessionPressTriggeredId = id;
-    activeSessionMenuId.value = id;
-    if (targetRect) {
-      sessionMenuAnchor.value = {
-        left: targetRect.left + 8,
-        bottom: targetRect.bottom + 6,
-      };
-    }
-  }, 420);
-};
-
-const handleSessionPressEnd = (event?: PointerEvent) => {
-  if (event && sessionPressPointerId !== null && event.pointerId !== sessionPressPointerId) {
-    return;
-  }
-
-  clearSessionPressTimer();
-  sessionPressPointerId = null;
-  if (sessionPressTriggeredId !== longPressSessionId.value) {
-    longPressSessionId.value = null;
-  }
-};
-
-const handleRenameFromSheet = () => {
-  const id = activeSessionMenuId.value;
-  if (!id) {
-    return;
-  }
-
-  closeSessionMenu();
-  openRenameModal(id);
-};
-
-const handleDeleteFromSheet = () => {
-  const id = activeSessionMenuId.value;
-  if (!id) {
-    return;
-  }
-
-  closeSessionMenu();
-  openDeleteModal(id);
-};
-
-const sessionMenuPopoverStyle = computed(() => {
-  const anchor = sessionMenuAnchor.value;
-  if (!anchor) {
-    return {};
-  }
-
-  return {
-    left: `${Math.max(8, anchor.left)}px`,
-    top: `${anchor.bottom}px`,
-  };
-});
-
-const openRenameModal = (id: string) => {
-  const session = chatHistory.value.find((item) => item.id === id);
-  if (!session) {
-    return;
-  }
-  renameSessionId.value = id;
-  renameInput.value = session.title || '';
-  closeSessionMenu();
-  isRenameModalOpen.value = true;
-};
-
-const closeRenameModal = () => {
-  isRenameModalOpen.value = false;
-  renameSessionId.value = null;
-};
-
-const confirmRename = () => {
-  const title = renameInput.value.trim();
-  if (!renameSessionId.value || !title) {
-    return;
-  }
-  renameSessionById(renameSessionId.value, title);
-  closeRenameModal();
-};
-
-const openDeleteModal = (id: string) => {
-  deleteSessionId.value = id;
-  closeSessionMenu();
-  isDeleteModalOpen.value = true;
-};
-
-const closeDeleteModal = () => {
-  isDeleteModalOpen.value = false;
-  deleteSessionId.value = null;
-};
-
-const confirmDeleteSession = () => {
-  if (!deleteSessionId.value) {
-    return;
-  }
-  deleteSessionById(deleteSessionId.value);
-  if (!currentSession.value) {
-    currentPage.value = 'home';
-  }
-  closeDeleteModal();
-};
-</script>
-
-<style scoped>
-.ba-mobile-container {
-  position: relative;
-  width: 100vw;
-  height: 100dvh;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-  background: linear-gradient(180deg, #ffffff 0%, #f9f9ff 100%);
-  color: #141b2b;
-  font-family: "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
-}
-
-.mobile-header {
-  height: 52px;
-  flex-shrink: 0;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 0 56px 0 16px;
-  background: #f9f9ff;
-  z-index: 10;
-}
-
-.mobile-header.chat-header {
-  justify-content: center;
-  padding: 0 16px;
-}
-
-.icon-btn {
-  width: 40px;
-  height: 40px;
-  border: 0;
-  border-radius: 999px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  background: transparent;
-  color: #464555;
-  cursor: pointer;
-}
-
-.icon-btn:active {
-  background: rgba(79, 70, 229, 0.1);
-  color: #3525cd;
-}
-
-.header-copy {
-  position: relative;
-  min-width: 0;
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  text-align: center;
-}
-
-.chat-header .header-copy {
-  width: 100%;
-}
-
-.header-copy-menu {
-  position: absolute;
-  left: 0;
-  top: calc(50% - 0.5px);
-  transform: translateY(-50%);
-}
-
-.header-content {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-}
-
-:deep(.record--business.ba-record-me) {
-  background-color: rgba(65, 45, 252, 1);
-  color: #ffffff;
-  width: fit-content;
-  max-width: 300px;
-  opacity: 1;
-  padding: 16px 22.3px 16px 16px;
-  border-radius: 12px 4px 12px 12px;
-  box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
-}
-
-:deep(.record--business.ba-record-me .text-content) {
-  color: #ffffff;
-}
-
-:deep(.business-record .content a) {
-  color: #3525cd;
-}
-
-
-.chat-header-title {
-  height: 26px;
-  color: #141b2b;
-  font-family: "HarmonyOS Sans SC", sans-serif;
-  font-size: 20px;
-  font-weight: 500;
-  line-height: 1.3;
-  letter-spacing: -0.2px;
-  display: flex;
-  align-items: center;
-}
-
-.header-subtitle {
-  color: #3525cd;
-  font-size: 12px;
-  font-weight: 500;
-  line-height: 1.4;
-  letter-spacing: 0.12px;
-  opacity: 0.8;
-  padding-bottom: 0.8px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.mobile-main {
-  flex: 1;
-  /* overflow-y: auto; */
-  position: relative;
-  padding: 24px 8px 28px;
-}
-
-.page-chat {
-  padding: 0 16px 198px;
-  background: #ffffff;
-}
-
-.page-policy-list,
-.page-policy-detail {
-  padding: 32px 16px 64px;
-  background: #ffffff;
-}
-
-.bg-image-container {
-  position: absolute;
-  top: 76px;
-  left: 50%;
-  z-index: 0;
-  width: 580px;
-  height: 382px;
-  opacity: 0.1;
-  transform: translateX(-50%);
-  pointer-events: none;
-}
-
-.bg-image {
-  width: 100%;
-  max-width: none;
-  height: 100%;
-  object-fit: fill;
-}
-
-.particle-bg {
-  position: absolute;
-  top: 76px;
-  left: 50%;
-  z-index: 1;
-  width: 580px;
-  height: 382px;
-  transform: translateX(-50%);
-  pointer-events: none;
-}
-
-.welcome-section {
-  position: relative;
-  z-index: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 6px;
-  margin: 0;
-  /* 两侧留白:标题与介绍文案都不贴屏幕边(原先几乎顶满,观感很挤) */
-  padding: 0 28px;
-  box-sizing: border-box;
-  width: 100%;
-  text-align: center;
-}
-
-.overlay-icon {
-  width: 96px;
-  height: 65px;
-  border-radius: 999px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-bottom: 20px;
-}
-
-.heading {
-  margin: 0;
-  color: #141b2b;
-  /* 先给老内核一个固定值(= 上限),再用 clamp 让更窄的屏继续收一点。
-     目的:这行字**恒定单行**不换行。
-     ⚠️ 两行必须同进同退:clamp 这行在后面、会覆盖上一行,只改基础值等于没改。
-     2026-09-18 演进:20px → 16px(用户反馈「太小了」)→ 20px → **22px(当前)**。
-     clamp 取 5.7vw:≥386px 的屏恒为 22px,更窄的屏按比例收缩以免标题被顶出屏幕。 */
-  font-size: 22px;
-  font-size: clamp(17px, 5.7vw, 22px);
-  font-weight: 500;
-  line-height: 1.2;
-  letter-spacing: -0.64px;
-  margin-bottom: 12px;
-  white-space: nowrap;
-}
-
-.desc {
-  margin: 0;
-  color: #464555;
-  /* 加了两侧留白后可用宽度变窄,字号同步收一点,避免这段介绍多折一行 */
-  font-size: 13px;
-  font-weight: 500;
-  line-height: 1.5;
-}
-
-.chat-input-area {
-  width: 100%;
-  padding: 0 16px 16px;
-  overflow: hidden;
-}
-
-.mobile-main>.chat-input-area {
-  width: calc(100% + 48px);
-  margin-left: -24px;
-  margin-right: -24px;
-}
-
-.page-home>.chat-input-area {
-  position: absolute;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 2;
-  width: 100%;
-  margin: 0;
-}
-
-.fixed-input .chat-input-area {
-  padding-bottom: 0;
-}
-
-.home-content-shell {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-  width: 100%;
-  height: 100%;
-  gap: 0;
-  padding-bottom: 180px;
-}
-
-.suggestion-chips {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 10px;
-  width: 100%;
-  padding: 18px 32px 0;
-}
-
-.chip {
-  min-height: 38px;
-  border: 1px solid rgba(199, 196, 216, 0.3);
-  border-radius: 12px;
-  padding: 9px 16px;
-  background: #ffffff;
-  color: #464555;
-  font-size: 12px;
-  font-weight: 500;
-  line-height: 1.4;
-  letter-spacing: 0.12px;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-  cursor: pointer;
-}
-
-.chip-icon {
-  width: 16px;
-  height: 16px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.chat-stage {
-  position: relative;
-  z-index: 1;
-  padding-top: 12px;
-}
-
-.chat-ambient {
-  position: fixed;
-  inset: 64px 0 0;
-  z-index: 0;
-  pointer-events: none;
-  overflow: hidden;
-  opacity: 0.3;
-}
-
-.chat-ambient span {
-  position: absolute;
-  display: block;
-  border-radius: 999px;
-  filter: blur(70px);
-}
-
-.chat-ambient span:first-child {
-  top: 0;
-  left: 90px;
-  width: 300px;
-  height: 300px;
-  background: #3525cd;
-}
-
-.chat-ambient span:last-child {
-  left: 0;
-  bottom: -80px;
-  width: 250px;
-  height: 250px;
-  background: #4648d4;
-}
-
-.date-indicator {
-  display: flex;
-  justify-content: center;
-  padding: 12px 0;
-  color: #777587;
-  font-size: 12px;
-  font-weight: 500;
-  line-height: 1.4;
-}
-
-.message-row {
-  width: 100%;
-  display: flex;
-  margin-bottom: 16px;
-}
-
-.message-row.user {
-  justify-content: flex-end;
-}
-
-.message-row.ai {
-  justify-content: flex-start;
-}
-
-.message-row.ai.is-last {
-  position: relative;
-  /* padding-bottom: 32px; */
-}
-
-:deep(.chat-record-line) {
-  /* max-width: min(322px, 100%) !important; */
-  min-width: 80px !important;
-  min-height: 55px !important;
-  font-size: 15px !important;
-  line-height: 1.6 !important;
-}
-
-:deep(.policy-item) {
-  width: 100% !important;
-  box-sizing: border-box;
-}
-
-:deep(.text-right) {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-end;
-}
-
-:deep(.text-left) {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-}
-
-.empty-chat {
-  margin-top: 120px;
-  text-align: center;
-  color: #777587;
-  font-size: 14px;
-}
-
-.chat-suggestion-chips {
-  display: flex;
-  gap: 8px;
-  flex-wrap: wrap;
-  width: 100%;
-  justify-content: flex-start;
-  position: relative;
-  z-index: 1;
-  pointer-events: auto;
-}
-
-.fixed-input {
-  position: fixed;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 8;
-  padding: 12px 0 16px;
-  background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
-}
-
-.home-fixed-input {
-  position: fixed;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 9;
-  padding: 12px 0 calc(16px + env(safe-area-inset-bottom));
-  background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
-}
-
-.chat-suggestions-container {
-  position: absolute;
-  top: calc(100% + 12px);
-  left: 0;
-  right: 0;
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  pointer-events: none;
-  z-index: 3;
-}
-
-.chat-suggestions-gradient {
-  display: none;
-}
-
-.ai-disclaimer {
-  color: #4f46e5;
-  font-size: 12px;
-  text-align: center;
-}
-
-.policy-page,
-.policy-detail {
-  position: relative;
-  display: flex;
-  flex-direction: column;
-}
-
-.policy-page {
-  gap: 16px;
-}
-
-.policy-detail {
-  gap: 32px;
-}
-
-.page-close-btn {
-  position: absolute;
-  top: -5px;
-  right: 0;
-  z-index: 2;
-  width: 40px;
-  height: 40px;
-  border: 0;
-  border-radius: 999px;
-  background: transparent;
-  color: #464555;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.policy-title-block {
-  padding-right: 16px;
-}
-
-.policy-title-block h1,
-.policy-detail-hero h1 {
-  margin: 0;
-  color: #141b2b;
-  font-size: 24px;
-  font-weight: 500;
-  line-height: 1.35;
-  letter-spacing: -0.24px;
-}
-
-.policy-title-block p {
-  margin: 7px 0 0;
-  color: #464555;
-  font-size: 16px;
-  font-weight: 500;
-  line-height: 1.6;
-}
-
-.policy-filter {
-  display: flex;
-  flex-direction: column;
-  gap: 16px;
-  padding: 16px;
-  border: 1px solid #e9edff;
-  border-radius: 12px;
-  background: #ffffff;
-  box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
-}
-
-.policy-search,
-.drawer-search {
-  height: 48px;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 0 16px;
-  border: 1px solid #c7c4d8;
-  border-radius: 8px;
-  background: #f9f9ff;
-  color: #777587;
-}
-
-.policy-filter-row {
-  display: grid;
-  grid-template-columns: 1fr 112px;
-  gap: 16px;
-}
-
-.policy-search input,
-.drawer-search input {
-  min-width: 0;
-  flex: 1;
-  border: 0;
-  outline: 0;
-  background: transparent;
-  color: #141b2b;
-  font: inherit;
-  font-size: 14px;
-}
-
-.policy-select {
-  height: 48px;
-  border: 1px solid #c7c4d8;
-  border-radius: 8px;
-  background: #f9f9ff;
-  color: #141b2b;
-  padding: 0 16px;
-  font: inherit;
-  font-size: 14px;
-  min-width: 0;
-}
-
-.policy-filter-btn {
-  height: 48px;
-  border: 0;
-  border-radius: 8px;
-  background: #3525cd;
-  color: #ffffff;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-  padding: 0 18px;
-  font: inherit;
-  font-size: 14px;
-  font-weight: 500;
-}
-
-.policy-list {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.policy-card {
-  display: grid;
-  grid-template-columns: 1fr 98px;
-  gap: 12px;
-  min-height: 186px;
-  padding: 16px;
-  border: 1px solid #e9edff;
-  border-radius: 12px;
-  background: #ffffff;
-  cursor: pointer;
-}
-
-.policy-meta,
-.detail-tags {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  color: #464555;
-  font-size: 12px;
-}
-
-.policy-meta span,
-.detail-tags span {
-  padding: 2px 8px;
-  border-radius: 4px;
-  background: #e2dfff;
-}
-
-.policy-card h2 {
-  margin: 8px 0 0;
-  color: #141b2b;
-  font-size: 20px;
-  font-weight: 500;
-  line-height: 1.3;
-  letter-spacing: -0.2px;
-}
-
-.policy-card p {
-  margin: 8px 0 0;
-  color: #464555;
-  font-size: 14px;
-  font-weight: 500;
-  line-height: 1.6;
-}
-
-.match-panel {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-}
-
-.match-ring {
-  width: 64px;
-  height: 64px;
-  border-radius: 50%;
-  border: 6px solid #dce2f7;
-  color: #3525cd;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 15px;
-  font-weight: 700;
-}
-
-.match-label {
-  color: #464555;
-  font-size: 12px;
-}
-
-.match-panel button,
-.back-policy-btn {
-  height: 34px;
-  border: 1px solid #3525cd;
-  border-radius: 8px;
-  background: #ffffff;
-  color: #3525cd;
-  padding: 0 16px;
-  font: inherit;
-  font-size: 12px;
-}
-
-.pagination-row {
-  display: grid;
-  grid-template-columns: repeat(7, minmax(0, 1fr));
-  gap: 8px;
-  align-items: center;
-  text-align: center;
-}
-
-.pagination-row button {
-  height: 40px;
-  border: 1px solid #c7c4d8;
-  border-radius: 8px;
-  background: transparent;
-  color: #464555;
-  font: inherit;
-}
-
-.pagination-row button.active {
-  border-color: #3525cd;
-  background: #3525cd;
-  color: #ffffff;
-}
-
-.back-policy-btn {
-  align-self: flex-start;
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-  border: 0;
-  padding: 0;
-  height: auto;
-  color: #3525cd;
-}
-
-.policy-detail-hero {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-.detail-grid,
-.conditions-section {
-  display: flex;
-  flex-direction: column;
-  gap: 16px;
-}
-
-.detail-box,
-.condition-card {
-  padding: 16px;
-  border: 1px solid rgba(199, 196, 216, 0.3);
-  border-radius: 12px;
-  background: #ffffff;
-}
-
-.detail-box h2,
-.conditions-section h2 {
-  margin: 0 0 12px;
-  color: #141b2b;
-  font-size: 16px;
-  font-weight: 600;
-}
-
-.detail-box p,
-.detail-box li,
-.condition-card p,
-.condition-lead {
-  color: #464555;
-  font-size: 14px;
-  font-weight: 500;
-  line-height: 1.6;
-}
-
-.detail-box ul {
-  margin: 0;
-  padding-left: 18px;
-}
-
-.condition-lead {
-  padding-left: 12px;
-  border-left: 1px solid #a1a1a1;
-  opacity: 0.65;
-}
-
-.condition-status {
-  align-self: flex-end;
-  display: inline-flex;
-  padding: 4px 10px;
-  border-radius: 999px;
-  background: rgba(53, 37, 205, 0.1);
-  color: #3525cd;
-  font-size: 12px;
-  font-weight: 600;
-}
-
-.drawer-layer {
-  position: fixed;
-  inset: 0;
-  z-index: 30;
-  background: rgba(0, 0, 0, 0.5);
-}
-
-.session-drawer {
-  width: min(331.5px, 86vw);
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  background: #ffffff;
-  box-shadow: 0 25px 43.75px -12px rgba(0, 0, 0, 0.25);
-}
-
-.drawer-head {
-  min-height: 73px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 16px;
-  padding: 16px;
-  border-bottom: 1px solid #c7c4d8;
-}
-
-.new-chat-btn {
-  height: 40px;
-  border: 0;
-  border-radius: 999px;
-  background: #3525cd;
-  color: #ffffff;
-  display: inline-flex;
-  align-items: center;
-  gap: 8px;
-  padding: 0 16px;
-  font: inherit;
-  font-size: 16px;
-  font-weight: 500;
-}
-
-.drawer-search {
-  margin: 16px;
-  height: 40px;
-  border: 0;
-}
-
-.drawer-list {
-  position: relative;
-  flex: 1;
-  min-height: 0;
-  overflow-y: auto;
-  padding: 7px 8px 16px;
-  -webkit-touch-callout: none;
-}
-
-.drawer-list h2 {
-  margin: 0;
-  padding: 0 8px 8px;
-  color: #777587;
-  font-size: 12px;
-  font-weight: 500;
-  letter-spacing: 0.12px;
-}
-
-.drawer-empty {
-  padding: 16px;
-  color: #777587;
-  font-size: 13px;
-}
-
-.drawer-session {
-  position: relative;
-  width: 100%;
-  min-height: 48px;
-  border: 0;
-  border-radius: 12px;
-  background: transparent;
-  color: #464555;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 10px;
-  padding: 12px;
-  font: inherit;
-  font-size: 14px;
-  text-align: left;
-  user-select: none;
-  -webkit-user-select: none;
-  -webkit-touch-callout: none;
-  -webkit-tap-highlight-color: transparent;
-  transition:
-    background 0.18s ease,
-    color 0.18s ease,
-    transform 0.18s ease;
-}
-
-.drawer-session.active {
-  background: rgba(79, 70, 229, 0.1);
-  color: #3525cd;
-}
-
-.drawer-session.is-pressing {
-  background: rgba(53, 37, 205, 0.08);
-  transform: scale(0.99);
-}
-
-.drawer-session span {
-  min-width: 0;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.session-popover {
-  position: absolute;
-  left: 8px;
-  top: calc(100% + 6px);
-  z-index: 50;
-  width: 108px;
-  overflow: hidden;
-  border-radius: 10px;
-  background: rgba(250, 251, 255, 0.98);
-  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
-  user-select: none;
-  -webkit-user-select: none;
-  -webkit-touch-callout: none;
-  -webkit-tap-highlight-color: transparent;
-}
-
-.session-popover button {
-  width: 100%;
-  height: 38px;
-  border: 0;
-  background: transparent;
-  color: #141b2b;
-  font: inherit;
-  font-size: 14px;
-  font-weight: 500;
-  text-align: left;
-  padding: 0 12px;
-  user-select: none;
-  -webkit-user-select: none;
-  -webkit-touch-callout: none;
-  -webkit-tap-highlight-color: transparent;
-}
-
-.session-popover button:active {
-  background: rgba(53, 37, 205, 0.08);
-}
-
-.session-popover .danger {
-  color: #e82437;
-}
-
-.session-menu-overlay {
-  position: fixed;
-  inset: 0;
-  z-index: 40;
-  background: rgba(14, 18, 38, 0.18);
-  backdrop-filter: blur(3px);
-  pointer-events: auto;
-}
-
-.modal-overlay {
-  position: fixed;
-  inset: 0;
-  z-index: 60;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 24px;
-  background: rgba(0, 0, 0, 0.3);
-}
-
-.mobile-modal {
-  width: min(100%, 360px);
-  border-radius: 12px;
-  background: #ffffff;
-  box-shadow: 4px 4px 14px rgba(0, 0, 0, 0.12);
-  padding: 16px;
-}
-
-.modal-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16px;
-}
-
-.modal-header h2 {
-  margin: 0;
-  color: #000;
-  font-size: 16px;
-  font-weight: 700;
-}
-
-.modal-input {
-  width: 100%;
-  height: 42px;
-  border: 1px solid #cecece;
-  border-radius: 8px;
-  padding: 0 12px;
-  color: #000;
-  font: inherit;
-  outline: none;
-}
-
-.delete-copy {
-  margin: 0 0 24px;
-  color: #464555;
-  font-size: 15px;
-}
-
-.modal-actions {
-  display: flex;
-  justify-content: flex-end;
-  gap: 12px;
-  margin-top: 18px;
-}
-
-.modal-actions button {
-  min-width: 84px;
-  height: 36px;
-  border: 0;
-  border-radius: 8px;
-  background: #cecece;
-  color: #ffffff;
-  font: inherit;
-}
-
-.modal-actions .primary {
-  background: #3525cd;
-}
-
-.modal-actions .danger {
-  background: #f97c7c;
-}
-
-.chat-suggestions-fade-enter-active,
-.chat-suggestions-fade-leave-active,
-.drawer-fade-enter-active,
-.drawer-fade-leave-active,
-.session-popover-fade-enter-active,
-.session-popover-fade-leave-active,
-.session-overlay-fade-enter-active,
-.session-overlay-fade-leave-active {
-  transition: opacity 0.22s ease, transform 0.22s ease;
-}
-
-.chat-suggestions-fade-enter-from,
-.chat-suggestions-fade-leave-to {
-  opacity: 0;
-  transform: translateY(10px);
-}
-
-.drawer-fade-enter-from,
-.drawer-fade-leave-to {
-  opacity: 0;
-}
-
-.session-popover-fade-enter-from,
-.session-popover-fade-leave-to {
-  opacity: 0;
-  transform: translateY(6px) scale(0.96);
-}
-
-.session-overlay-fade-enter-from,
-.session-overlay-fade-leave-to {
-  opacity: 0;
-}
-
-@media (max-width: 360px) {
-  .mobile-main {
-    padding-left: 16px;
-    padding-right: 16px;
-  }
-
-  /* [已移除 2026-09-18] 此处原为 .heading { font-size: 28px }。
-     它最初与基础值同值(那时基础值是 28px),是个空操作;
-     2026-09-18 基础值改小后它反而变成**窄屏上把标题放大回 28px** 的覆盖——
-     而标题是 white-space: nowrap,13 个字 × 28px ≈ 356px,远超窄屏可用宽度
-     (360px 屏约 272px)—— 会把标题顶出屏幕约 84px。
-     窄屏的收缩已由 .heading 自己的 clamp(13px, 4.2vw, 16px) 负责,故移除。
-  .heading {
-    font-size: 28px;
-  }
-  */
-
-  .suggestion-chips {
-    padding-left: 12px;
-    padding-right: 12px;
-  }
-
-  .policy-card {
-    grid-template-columns: 1fr;
-  }
-
-  .match-panel {
-    flex-direction: row;
-    justify-content: flex-start;
-  }
-}
-</style>
+
+         —— 原来的 <Toast> 是全屏黑遮罩 + 居中白框,用户说"太丑了" -->
+    <header v-if="currentPage === 'home' || currentPage === 'chat'" class="mobile-header"
+      :class="{ 'chat-header': currentPage === 'chat' }">
+      <button v-if="currentPage === 'home'" type="button" class="icon-btn menu-btn" aria-label="打开会话菜单"
+        @click="isDrawerOpen = true">
+        <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+          <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
+        </svg>
+      </button>
+      <div class="header-copy">
+        <button v-if="currentPage === 'chat'" type="button" class="icon-btn menu-btn header-copy-menu"
+          aria-label="打开会话菜单" @click="isDrawerOpen = true">
+          <svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <path d="M0 12v-2h18v2H0Zm0-5V5h18v2H0Zm0-5V0h18v2H0Z" fill="currentColor" />
+          </svg>
+        </button>
+        <div class="header-content">
+          <div class="chat-header-title">青浦区营商助手</div>
+          <div v-if="currentPage === 'chat'" class="header-subtitle">{{ currentSessionTitle }}</div>
+        </div>
+      </div>
+    </header>
+
+    <main ref="scrollHostRef" class="mobile-main" :class="`page-${currentPage}`">
+      <!-- <ParticleBackground
+        v-if="showParticleBackground"
+        mouse-target-selector=".ba-mobile-container"
+      /> -->
+      <template v-if="currentPage === 'home'">
+        <div class="home-content-shell">
+          <section class="welcome-section">
+            <div class="overlay-icon" aria-hidden="true">
+              <img src="@/assets/qingpu_logo_2_3x.png" alt="logo"
+                style="width: 100%; height: 100%; object-fit: cover; border-radius: 999px;" />
+            </div>
+            <h1 class="heading">你好,有什么我可以帮您的?</h1>
+            <p class="desc">我是您的青浦区营商智能助手,可以为您提供惠企政策匹配以及出海、人才、科创、金融、法治方面的办事指引,请问有什么可以帮助您?</p>
+          </section>
+
+        <section class="suggestion-chips" aria-label="推荐问题">
+          <button v-for="(item, index) in suggestions" :key="item" type="button" class="chip"
+            @click="sendSuggestion(item)">
+              <span class="chip-icon" aria-hidden="true">
+                <svg v-if="index === 0" width="10" height="12" viewBox="0 0 10 12" fill="none">
+                  <path
+                    d="M4.66667 11.66667c-1.35139-0.34028-2.46701-1.11562-3.34688-2.32605-0.87986-1.21042-1.31979-2.55451-1.31979-4.03229l0-3.55833 4.66667-1.75 4.66666 1.75 0 3.55833c0 0.82639-0.14097 1.62118-0.42291 2.38438-0.28194 0.76319-0.68542 1.43646-1.21042 2.01979l-1.86667-1.86667c-0.175 0.10694-0.36215 0.18715-0.56146 0.24063-0.19931 0.05347-0.40104 0.08021-0.6052 0.08021-0.64167 0-1.19097-0.22847-1.64792-0.68542-0.45694-0.45694-0.68542-1.00625-0.68542-1.64792 0-0.64167 0.22847-1.19097 0.68542-1.64791 0.45694-0.45694 1.00625-0.68542 1.64792-0.68542 0.64167 0 1.19097 0.22847 1.64791 0.68542 0.45694 0.45694 0.68542 1.00625 0.68542 1.64791 0 0.21389-0.02674 0.42049-0.08021 0.6198-0.05347 0.19931-0.13368 0.39132-0.24062 0.57604l0.875 0.875c0.19444-0.39861 0.34514-0.81667 0.45208-1.25417 0.10694-0.4375 0.16042-0.88472 0.16042-1.34167l0-2.75625-3.5-1.3125-3.5 1.3125 0 2.75625c0 1.17639 0.33056 2.24583 0.99166 3.20834 0.66111 0.9625 1.49722 1.60417 2.50834 1.925 0.25278-0.07778 0.4934-0.17743 0.72187-0.29896 0.22847-0.12153 0.45451-0.26493 0.67813-0.43021l0.81666 0.81667c-0.32083 0.2625-0.6684 0.49097-1.04271 0.68541-0.37431 0.19444-0.76563 0.34514-1.17395 0.45209l0 0m0-4.66667c0.32083 0 0.59549-0.11424 0.82395-0.34271 0.22847-0.22847 0.34271-0.50313 0.34271-0.82396 0-0.32083-0.11424-0.59549-0.34271-0.82395-0.22847-0.22847-0.50313-0.34271-0.82395-0.34271-0.32083 0-0.59549 0.11424-0.82396 0.34271-0.22847 0.22847-0.34271 0.50313-0.34271 0.82395 0 0.32083 0.11424 0.59549 0.34271 0.82396 0.22847 0.22847 0.50312 0.34271 0.82396 0.34271l0 0m0.11666-1.12292l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"
+                    fill="currentColor" />
+                </svg>
+                <svg v-else-if="index === 1" width="13" height="13" viewBox="0 0 13 13" fill="none">
+                  <path
+                    d="M4.43333 12.25l-1.10833-1.86667-2.1-0.46666 0.20417-2.15834-1.42917-1.63333 1.42917-1.63333-0.20417-2.15834 2.1-0.46666 1.10833-1.86667 1.98334 0.84583 1.98333-0.84583 1.10833 1.86667 2.1 0.46666-0.20416 2.15834 1.42916 1.63333-1.42916 1.63333 0.20416 2.15834-2.1 0.46666-1.10833 1.86667-1.98333-0.84583-1.98334 0.84583 0 0m0.49584-1.4875l1.4875-0.64167 1.51666 0.64167 0.81667-1.4 1.60417-0.37917-0.14584-1.63333 1.07917-1.225-1.07917-1.25417 0.14584-1.63333-1.60417-0.35-0.84583-1.4-1.4875 0.64167-1.51667-0.64167-0.81667 1.4-1.60416 0.35 0.14583 1.63333-1.07917 1.25417 1.07917 1.225-0.14583 1.6625 1.60416 0.35 0.84584 1.4 0 0m1.4875-4.6375l0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0m-0.6125 2.07083l3.29583-3.29583-0.81667-0.84583-2.47916 2.47916-1.25417-1.225-0.81667 0.81667 2.07084 2.07083 0 0"
+                    fill="currentColor" />
+                </svg>
+                <svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
+                  <path
+                    d="M1.16667 6.41667l0 1.16666 0 0 0 0 9.33333 0 0 0 0 0 0-1.16666-9.33333 0 0 0m0-6.41667l9.33333 0c0.32083 0 0.59549 0.11424 0.82396 0.34271 0.22847 0.22847 0.34271 0.50312 0.34271 0.82396l0 6.41666c0 0.32083-0.11424 0.59549-0.34271 0.82396-0.22847 0.22847-0.50312 0.34271-0.82396 0.34271l-2.33333 0 0 2.91667-2.33334-1.16667-2.33333 1.16667 0-2.91667-2.33333 0c-0.32083 0-0.59549-0.11424-0.82396-0.34271-0.22847-0.22847-0.34271-0.50312-0.34271-0.82396l0-6.41666c0-0.32083 0.11424-0.59549 0.34271-0.82396 0.22847-0.22847 0.50312-0.34271 0.82396-0.34271l0 0m0 4.66667l9.33333 0 0-3.5 0 0 0 0-9.33333 0 0 0 0 0 0 3.5 0 0m0 2.91666l0-6.41666 0 0 0 0 0 0 0 0 0 0 0 6.41666 0 0 0 0 0 0 0 0 0 0 0 0"
+                    fill="currentColor" />
+                </svg>
+              </span>
+              {{ item }}
+            </button>
+          </section>
+        </div>
+
+        <div class="home-fixed-input" v-show="!isFreeformInputActive">
+          <BusinessAssistantComposer
+            v-model="inputText"
+            :uploaded-files="uploadedFiles"
+            :is-generating="currentSessionGenerating"
+            :is-deep-think="isDeepThink"
+            :is-voice-input-mode="isVoiceInputMode"
+            :is-voice-input-starting="isVoiceInputStarting"
+            :suggestions="[]"
+            variant="mobile"
+            :show-mock-stream-test-button="isMockStreamTestButtonVisible"
+            @update:is-deep-think="isDeepThink = $event"
+            @send="sendMobileMessage"
+            @stop="handleStopGenerate"
+            @image-upload="triggerImageUpload"
+            @file-upload="triggerFileUpload"
+            @voice-toggle="toggleVoiceInput"
+            @remove-file="removeUploadedFile"
+            @test-mock-stream="sendChatStreamReplayTestMessage"
+            @test-replay="sendChatStreamReplayTestMessage"
+            @test-typewriter="sendTypewriterTestMessage"
+            @test-scope-merge="sendScopeMergeStreamTestMessage"
+            @test-input="sendDocumentLineStreamTestMessage"
+            @test-ref-links="sendRefLinksStreamTestMessage"
+            @test-question-card="sendQuestionCardTestMessage"
+          />
+        </div>
+      </template>
+
+      <template v-else-if="currentPage === 'chat'">
+        <!-- <div class="chat-ambient" aria-hidden="true">
+          <span></span>
+          <span></span>
+        </div> -->
+        <section class="chat-stage">
+          <!-- <div class="date-indicator">{{ todayLabel }}</div> -->
+          <ScrollList v-if="currentSession" ref="scrollListRef" :data="currentSession.messages" :height="listHeight"
+            :maxHeight="listHeight" :topPadding="8" :bottomPadding="chatSuggestionsBottomPadding" :native-scroll-mode="true"
+            @scrollPositionChange="handleBusinessScrollPositionChange">
+            <template #item="{ data, isLast }">
+              <div class="message-row" :ref="(el) => setMessageRowRef(data.id, el)"
+                :class="[data.role, { 'is-last': isLast }]" :style="getMessageRowStyle(data.id, isLast, data.role)">
+                <BusinessRecord :record="data" :state="data.state" :content="data.content" :isLastRecord="isLast"
+                  :sessionId="currentSession?.id" :sessionViewedBefore="currentSessionVisitedBefore"
+                  enableFeedback
+                  @refreshScroll="handleBusinessRecordRefreshScroll"
+                  @scopeHeightLockChange="handleScopeHeightLockChange"
+                  @typewriterStateChange="handleBusinessRecordTypewriterStateChange" />
+                <Transition name="chat-suggestions-fade" appear>
+                  <div v-if="isLast && data.role === 'ai' && currentSessionSuggestions.length > 0"
+                    ref="chatSuggestionsContainerRef" class="chat-suggestions-container">
+                    <div class="chat-suggestions-gradient" aria-hidden="true"></div>
+                    <section class="chat-suggestion-chips" aria-label="跟进问题">
+                      <button v-for="(item, index) in currentSessionSuggestions.slice(0, 2)"
+                        :key="`${item.text}-${index}`" type="button" class="chip" @click="handleSuggestionClick(item)">
+                        {{ item.text }}
+                      </button>
+                    </section>
+                  </div>
+                </Transition>
+              </div>
+            </template>
+          </ScrollList>
+          <div v-else class="empty-chat">打开一次咨询,助手会在这里继续服务。</div>
+        </section>
+
+        <div class="fixed-input" v-show="!isFreeformInputActive">
+          <BusinessAssistantComposer
+            v-model="inputText"
+            :uploaded-files="uploadedFiles"
+            :is-generating="currentSessionGenerating"
+            :is-deep-think="isDeepThink"
+            :is-voice-input-mode="isVoiceInputMode"
+            :is-voice-input-starting="isVoiceInputStarting"
+            :suggestions="[]"
+            variant="mobile"
+            :show-mock-stream-test-button="isMockStreamTestButtonVisible"
+            @update:is-deep-think="isDeepThink = $event"
+            @send="sendMobileMessage"
+            @stop="handleStopGenerate"
+            @image-upload="triggerImageUpload"
+            @file-upload="triggerFileUpload"
+            @voice-toggle="toggleVoiceInput"
+            @remove-file="removeUploadedFile"
+            @test-mock-stream="sendChatStreamReplayTestMessage"
+            @test-replay="sendChatStreamReplayTestMessage"
+            @test-typewriter="sendTypewriterTestMessage"
+            @test-scope-merge="sendScopeMergeStreamTestMessage"
+            @test-input="sendDocumentLineStreamTestMessage"
+            @test-ref-links="sendRefLinksStreamTestMessage"
+            @test-question-card="sendQuestionCardTestMessage"
+          />
+          <div class="ai-disclaimer">所有内容均由 AI 生成,仅供参考</div>
+        </div>
+      </template>
+
+      <template v-else-if="currentPage === 'policy-list'">
+        <section class="policy-page">
+          <button type="button" class="page-close-btn" aria-label="关闭政策列表" @click="goHome">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+              <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
+            </svg>
+          </button>
+          <div class="policy-title-block">
+            <h1>政策事项列表</h1>
+            <p>浏览、搜索及筛选青浦最新的惠企政策</p>
+          </div>
+
+          <div class="policy-filter">
+            <label class="policy-search">
+              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
+                  stroke-width="2" stroke-linecap="round" />
+              </svg>
+              <input v-model="policyKeyword" type="search" placeholder="输入政策关键词" />
+            </label>
+            <div class="policy-filter-row">
+              <select v-model="policyCategory" class="policy-select" aria-label="政策分类">
+                <option value="">所有分类</option>
+                <option value="科技创新">科技创新</option>
+                <option value="金融扶持">金融扶持</option>
+                <option value="人才服务">人才服务</option>
+                <option value="税费优惠">税费优惠</option>
+              </select>
+              <button type="button" class="policy-filter-btn">
+                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                  <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
+                </svg>
+                筛选
+              </button>
+            </div>
+          </div>
+
+          <div class="policy-list">
+            <article v-for="policy in filteredPolicies" :key="policy.id" class="policy-card"
+              @click="openPolicyDetail(policy)">
+              <div class="policy-card-main">
+                <div class="policy-meta">
+                  <span>{{ policy.category }}</span>
+                  <time>{{ policy.date }}</time>
+                </div>
+                <h2>{{ policy.title }}</h2>
+                <p>{{ policy.summary }}</p>
+              </div>
+              <div class="match-panel" aria-hidden="true">
+                <div class="match-ring">{{ policy.match }}%</div>
+                <div class="match-label">匹配度</div>
+                <button type="button">查看</button>
+              </div>
+            </article>
+          </div>
+
+          <div class="pagination-row" aria-label="分页">
+            <button type="button" disabled>‹</button>
+            <button type="button" class="active">1</button>
+            <button type="button">2</button>
+            <button type="button">3</button>
+            <span>...</span>
+            <button type="button">12</button>
+            <button type="button">›</button>
+          </div>
+        </section>
+      </template>
+
+      <template v-else-if="currentPage === 'policy-detail' && selectedPolicy">
+        <section class="policy-detail">
+          <button type="button" class="page-close-btn" aria-label="关闭政策详情" @click="goHome">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+              <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
+            </svg>
+          </button>
+          <button type="button" class="back-policy-btn" @click="openPolicyList">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+              <path d="m15 18-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+            </svg>
+            返回政策列表
+          </button>
+
+          <div class="policy-detail-hero">
+            <h1>{{ selectedPolicy.title }}</h1>
+            <div class="detail-tags">
+              <span>{{ selectedPolicy.category }}</span>
+              <span>{{ selectedPolicy.date }}</span>
+              <span>匹配度 {{ selectedPolicy.match }}%</span>
+            </div>
+          </div>
+
+          <div class="detail-grid">
+            <section class="detail-box">
+              <h2>支持对象</h2>
+              <ul>
+                <li v-for="item in selectedPolicy.targets" :key="item">{{ item }}</li>
+              </ul>
+            </section>
+            <section class="detail-box">
+              <h2>支持方式</h2>
+              <p>{{ selectedPolicy.method }}</p>
+            </section>
+          </div>
+
+          <section class="conditions-section">
+            <h2>申报条件</h2>
+            <div class="condition-lead">申报条件:{{ selectedPolicy.condition }}</div>
+            <div class="condition-card">
+              <div class="condition-status">符合</div>
+              <p>{{ selectedPolicy.condition }}</p>
+            </div>
+          </section>
+        </section>
+      </template>
+    </main>
+
+    <Transition name="drawer-fade">
+      <div v-if="isDrawerOpen" class="drawer-layer" @click.self="closeDrawer">
+        <aside class="session-drawer">
+          <div class="drawer-head">
+            <button type="button" class="new-chat-btn" @click="startNewChat">
+              <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
+                <path d="M6 8H0V6h6V0h2v6h6v2H8v6H6V8Z" fill="currentColor" />
+              </svg>
+              开启新的咨询
+            </button>
+            <button type="button" class="icon-btn" aria-label="关闭会话菜单" @click="closeDrawer">
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
+              </svg>
+            </button>
+          </div>
+
+          <label class="drawer-search">
+            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+              <path d="m21 21-4.35-4.35M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor"
+                stroke-width="2" stroke-linecap="round" />
+            </svg>
+            <input v-model="sessionKeyword" type="search" placeholder="搜索历史咨询" />
+          </label>
+
+          <section class="drawer-list">
+            <h2>历史咨询</h2>
+            <div v-if="filteredChatHistory.length === 0" class="drawer-empty">还没有历史咨询,先发起一次咨询吧。</div>
+            <div v-for="session in filteredChatHistory" :key="session.id" class="drawer-session" role="button"
+              tabindex="0" :class="{
+                active: currentPage === 'chat' && currentSession?.id === session.id,
+                'is-pressing': longPressSessionId === session.id,
+              }" @click="handleSessionTap(session.id)" @keydown.enter.prevent="switchSessionById(session.id)"
+              @keydown.space.prevent="switchSessionById(session.id)"
+              @pointerdown="handleSessionPressStart(session.id, $event)" @pointerup="handleSessionPressEnd"
+              @pointerleave="handleSessionPressEnd" @pointercancel="handleSessionPressEnd" @contextmenu.prevent>
+              <span>{{ session.title || '新对话' }}</span>
+            </div>
+          </section>
+
+          <AccountInfo @logout="handleAccountLogout" />
+
+          <!-- <div class="drawer-footer">
+            <span>青浦区营商助手</span>
+            <button type="button" class="icon-btn" aria-label="打开政策列表" @click="openPolicyListFromDrawer">
+              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                <path d="M7 3h7l5 5v13H7V3Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
+              </svg>
+            </button>
+          </div> -->
+        </aside>
+
+      </div>
+    </Transition>
+
+    <Teleport to="body">
+      <Transition name="session-menu-fade">
+        <div v-if="activeSessionMenuId && sessionMenuAnchor" class="session-menu-portal">
+          <div class="session-menu-overlay" aria-hidden="true" @click="closeSessionMenu"></div>
+          <div class="session-popover" :style="sessionMenuPopoverStyle" @click.stop @pointerdown.stop>
+            <button type="button" @click="handleRenameFromSheet">重命名</button>
+            <button type="button" class="danger" @click="handleDeleteFromSheet">删除</button>
+          </div>
+        </div>
+      </Transition>
+    </Teleport>
+
+    <Teleport to="body">
+      <div v-if="isRenameModalOpen" class="modal-overlay">
+        <div class="mobile-modal">
+          <div class="modal-header">
+            <h2>编辑对话名称</h2>
+            <button type="button" class="icon-btn" aria-label="关闭" @click="closeRenameModal">
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
+              </svg>
+            </button>
+          </div>
+          <input v-model="renameInput" class="modal-input" type="text" placeholder="输入名称" />
+          <div class="modal-actions">
+            <button type="button" @click="closeRenameModal">取消</button>
+            <button type="button" class="primary" @click="confirmRename">确定</button>
+          </div>
+        </div>
+      </div>
+
+      <div v-if="isDeleteModalOpen" class="modal-overlay">
+        <div class="mobile-modal">
+          <div class="modal-header">
+            <h2>提示</h2>
+            <button type="button" class="icon-btn" aria-label="关闭" @click="closeDeleteModal">
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+                <path d="M18 6 6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
+              </svg>
+            </button>
+          </div>
+          <p class="delete-copy">确认删除该会话记录吗?</p>
+          <div class="modal-actions">
+            <button type="button" @click="closeDeleteModal">取消</button>
+            <button type="button" class="danger" @click="confirmDeleteSession">删除</button>
+          </div>
+        </div>
+      </div>
+    </Teleport>
+
+    <input ref="imageInputRef" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden
+      @change="handleImageSelected" />
+    <input ref="fileInputRef" type="file" accept=".xls,.xlsx,.docx,.pdf" multiple hidden
+      @change="handleFileSelected" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onBeforeUnmount, onMounted, provide, reactive, ref, watch } from 'vue';
+import BusinessRecord from "./Chat/BusinessRecord.vue";
+// [已停用] import Toast from "./Common/Toast.vue";
+import ScrollList from './Common/ScrollList.vue';
+import BusinessAssistantComposer from './business-assistant/BusinessAssistantComposer.vue';
+import { useBusinessAssistantChat } from './business-assistant/useBusinessAssistantChat';
+import { useBusinessAssistantUpload } from './business-assistant/useBusinessAssistantUpload';
+import {
+  maybeStopAutoScrollAtLastAiRowTop,
+} from './business-assistant/auto-scroll-top-stop';
+import { scrollBusinessAssistantListToBottom } from './business-assistant/scroll-list-bottom';
+import ASR from '@/three-libs/asr/index';
+import AccountInfo from "./Chat/AccountInfo.vue";
+import { useEnterpriseAuth } from '@/components/useEnterpriseAuth';
+import { createModalMutex, MODAL_MUTEX_KEY } from './business-assistant/modal-mutex';
+import {
+  createFreeformInputFocusControl,
+  FREEFORM_INPUT_FOCUS_KEY,
+} from './business-assistant/freeform-focus-control';
+import {
+  calculateMobileListHeight,
+} from './business-assistant/mobile-viewport-height';
+
+provide(MODAL_MUTEX_KEY, createModalMutex());
+
+provide('formOptionsControl', {
+  setFormOptions: () => { },
+});
+
+provide('speechControl', {
+  stopSpeech: () => { },
+  handleStopSpeech: () => { },
+  handleEndSpeech: () => { },
+});
+
+const freeformInputFocusControl = createFreeformInputFocusControl();
+const isFreeformInputActive = freeformInputFocusControl.active;
+provide(FREEFORM_INPUT_FOCUS_KEY, freeformInputFocusControl);
+
+interface PolicyItem {
+  id: string;
+  title: string;
+  category: string;
+  date: string;
+  summary: string;
+  match: number;
+  targets: string[];
+  method: string;
+  condition: string;
+}
+
+const currentPage = ref<'home' | 'chat' | 'policy-list' | 'policy-detail'>('home');
+const isDrawerOpen = ref(false);
+const activeSessionMenuId = ref<string | null>(null);
+const sessionKeyword = ref('');
+const renameInput = ref('');
+const renameSessionId = ref<string | null>(null);
+const deleteSessionId = ref<string | null>(null);
+const isRenameModalOpen = ref(false);
+const isDeleteModalOpen = ref(false);
+const scrollHostRef = ref<HTMLElement | null>(null);
+const scrollListRef = ref<any>(null);
+const listHeight = ref(520);
+const messageRowRefs = reactive<Record<string, HTMLElement | null>>({});
+const messageRowMinHeights = reactive<Record<string, number>>({});
+const chatSuggestionsContainerRef = ref<HTMLElement | null>(null);
+const chatSuggestionsBottomPadding = ref(0);
+const BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE = 24;
+const BUSINESS_RECORD_LARGE_HEIGHT_GROWTH = 80;
+/**
+ * 「一整块到达」的高度阈值:超过它就按**块**处理 —— 把块的**顶部**对到视口顶部,
+ * 而不是贴到底部(用户 2026-09-21 选择的做法)。与 PC 端同一口径(说明见 BusinessAssistantPC.vue)。
+ */
+const BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH = 240;
+/** 块对顶时留的空隙(别把块的顶部贴死在视口最上沿) */
+const BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET = 8;
+const hasStoppedAtLastAiRowTop = ref(false);
+const lastAiRowTopConsumedRecordId = ref<string | null>(null);
+const lastAiRowTopSnapshot = ref<{ recordId: string; rowOffsetTop: number } | null>(null);
+const lastAiRowTypewriterStates = ref<Record<string, boolean>>({});
+let lastAiRowTypingReleaseTimer: number | null = null;
+
+const policyKeyword = ref('');
+const policyCategory = ref('');
+const selectedPolicy = ref<PolicyItem | null>(null);
+const isVoiceInputMode = ref(false);
+const isVoiceInputStarting = ref(false);
+const longPressSessionId = ref<string | null>(null);
+const sessionMenuAnchor = ref<{ left: number; bottom: number } | null>(null);
+let asrInstance: ASR | null = null;
+const mobileScrollAnimationDurationMs = 300;
+let mobileScrollAnimationFrameId = 0;
+let mobileScrollAnimationRevision = 0;
+let mobileScrollAnimationResolve: ((value?: unknown) => void) | null = null;
+
+const cancelMobileScrollAnimation = () => {
+  if (mobileScrollAnimationFrameId) {
+    cancelAnimationFrame(mobileScrollAnimationFrameId);
+    mobileScrollAnimationFrameId = 0;
+  }
+  if (mobileScrollAnimationResolve) {
+    mobileScrollAnimationResolve();
+    mobileScrollAnimationResolve = null;
+  }
+  mobileScrollAnimationRevision += 1;
+};
+let sessionPressTimer: number | null = null;
+let sessionPressTriggeredId: string | null = null;
+let sessionPressPointerId: number | null = null;
+const isMockStreamTestButtonVisible = import.meta.env.MODE === 'development'
+// const todayLabel = computed(() => {
+//   const now = new Date();
+//   const hours = String(now.getHours()).padStart(2, '0');
+//   const minutes = String(now.getMinutes()).padStart(2, '0');
+//   return `今天 ${hours}:${minutes}`;
+// });
+
+const imageInputRef = ref<HTMLInputElement | null>(null);
+const fileInputRef = ref<HTMLInputElement | null>(null);
+const {
+  uploadedFiles,
+  triggerImageUpload,
+  triggerFileUpload,
+  handleImageSelected,
+  handleFileSelected,
+  removeUploadedFile,
+  consumeSuccessfulFileTransmission,
+  setUploadDraftKey,
+  clearUploadDraft,
+} = useBusinessAssistantUpload({ imageInputRef, fileInputRef, notify: (message) => notifyUpload(message) });
+
+/**
+ * 上传校验失败时的提示通道。
+ * 创建顺序上 `useBusinessAssistantChat`(它提供 showToast)在后面,而且它依赖上传模块的
+ * `consumeSuccessfulFileTransmission`,没法调换顺序 —— 所以这里先用占位,拿到 showToast 再接通。
+ */
+let notifyUpload: (message: string) => void = (message) => console.warn('[upload]', message);
+
+const {
+  suggestions,
+  chatHistory,
+  currentSessionVisitedBefore,
+  currentSession,
+  currentSessionTitle,
+  currentSessionSuggestions,
+  inputText,
+  isGenerating,
+  currentSessionGenerating,
+  showToast,
+  stopAllTasks,
+  isDeepThink,
+  loadHistory,
+  sendMessage,
+  sendChatStreamReplayTestMessage,
+  sendTypewriterTestMessage,
+  sendScopeMergeStreamTestMessage,
+  sendDocumentLineStreamTestMessage,
+  sendRefLinksStreamTestMessage,
+  sendQuestionCardTestMessage,
+  handleStopGenerate,
+  startNewChat: startSharedNewChat,
+  switchSessionById: switchSharedSessionById,
+  renameSessionById,
+  deleteSessionById,
+  mergeRemoteSessions,
+  loadRemoteSessionRecords,
+} = useBusinessAssistantChat({
+  // 新协调器自行读取 VITE_CHAT_API,此参数仅保留签名兼容
+  apiBaseUrl: import.meta.env.VITE_API || "",
+  beforeSend: async () => {
+    if (isVoiceInputMode.value) {
+      await stopVoiceInput();
+    }
+  },
+  afterSend: async () => {
+    currentPage.value = 'chat';
+    await scrollToBottom(true);
+  },
+  onMessageUpdated: () => scrollToBottom(),
+  getInputFileTransmission: consumeSuccessfulFileTransmission,
+});
+
+// 上传校验的提示通道接通(见上面 notifyUpload 的说明)
+notifyUpload = showToast;
+
+const { isLoggedIn, creditCode, initAuth } = useEnterpriseAuth();
+
+const handleAccountLogout = () => {
+  // ⚠️ 必须先停掉全部生成任务:退出登录会切换 localStorage key,
+  // 旧账户的任务继续写会把内容落到新身份下
+  stopAllTasks();
+  closeDrawer();
+  currentPage.value = 'home';
+  loadHistory();
+  startSharedNewChat();
+};
+
+const policies: PolicyItem[] = [
+  {
+    id: 'policy-1',
+    title: '关于支持青浦自贸区科技创新企业发展的若干意见',
+    category: '科技创新',
+    date: '2026-06-12',
+    summary: '围绕研发投入、成果转化、创新平台建设给予综合支持。',
+    match: 92,
+    targets: ['青浦区注册科技型企业', '拥有研发活动或知识产权成果的企业'],
+    method: '对符合条件的企业给予研发补贴、平台建设资助和成果转化奖励。',
+    condition: '企业需在青浦区依法登记纳税,具备持续研发能力,项目资料真实完整。',
+  },
+  {
+    id: 'policy-2',
+    title: '青浦自贸区中小微企业贷款贴息申报指南',
+    category: '金融扶持',
+    date: '2026-05-28',
+    summary: '降低中小微企业融资成本,支持稳经营、扩产能。',
+    match: 88,
+    targets: ['青浦区中小微企业', '已获得银行经营性贷款的企业'],
+    method: '按照实际贷款利息的一定比例给予贴息支持。',
+    condition: '贷款用途须用于生产经营,企业信用记录良好,申报材料与贷款凭证一致。',
+  },
+  {
+    id: 'policy-3',
+    title: '2026年度“青峰”人才计划领军人物评选通知',
+    category: '人才服务',
+    date: '2026-05-14',
+    summary: '聚焦高层次人才引育,为重点团队提供人才服务与项目支持。',
+    match: 84,
+    targets: ['重点产业领域领军人才', '承担核心研发或经营管理职责的人才'],
+    method: '提供人才补贴、项目资助、服务保障和资源对接。',
+    condition: '申报人需符合青浦区重点产业方向,并由所在单位提交推荐材料。',
+  },
+  {
+    id: 'policy-4',
+    title: '高新技术企业所得税优惠政策延续执行说明',
+    category: '税费优惠',
+    date: '2026-04-30',
+    summary: '明确高新技术企业税收优惠延续执行口径和申报注意事项。',
+    match: 79,
+    targets: ['有效期内高新技术企业', '拟申报高新技术企业认定的企业'],
+    method: '对符合条件的高新技术企业按规定享受企业所得税优惠。',
+    condition: '企业需保持高新技术企业资格有效,研发费用、收入结构等指标满足要求。',
+  },
+];
+
+const filteredChatHistory = computed(() => {
+  const keyword = sessionKeyword.value.trim().toLowerCase();
+  if (!keyword) {
+    return chatHistory.value;
+  }
+  return chatHistory.value.filter((session) => (session.title || '').toLowerCase().includes(keyword));
+});
+const filteredPolicies = computed(() => {
+  const keyword = policyKeyword.value.trim();
+  return policies.filter((policy) => {
+    const matchedKeyword = !keyword || `${policy.title}${policy.summary}`.includes(keyword);
+    const matchedCategory = !policyCategory.value || policy.category === policyCategory.value;
+    return matchedKeyword && matchedCategory;
+  });
+});
+
+const setMessageRowRef = (id: string | number, el: HTMLElement | null) => {
+  const key = String(id);
+  if (el) {
+    messageRowRefs[key] = el;
+  } else {
+    delete messageRowRefs[key];
+  }
+};
+
+const setMessageRowMinHeight = (recordId: string | number, height: number | null | undefined) => {
+  const key = String(recordId);
+  const nextHeight = Number(height);
+  if (!Number.isFinite(nextHeight) || nextHeight <= 0) {
+    delete messageRowMinHeights[key];
+    return;
+  }
+
+  messageRowMinHeights[key] = Math.ceil(nextHeight);
+};
+
+const getMessageRowMinHeight = (recordId: string | number) => {
+  const height = messageRowMinHeights[String(recordId)];
+  return Number.isFinite(height) && height > 0 ? height : null;
+};
+
+const getMessageRowStyle = (recordId: string | number, isLast: boolean, role: string) => {
+  if (!isLast || role !== 'ai') {
+    return undefined;
+  }
+
+  const minHeight = getMessageRowMinHeight(recordId);
+  if (!minHeight) {
+    return undefined;
+  }
+
+  return {
+    minHeight: `${minHeight}px`,
+  };
+};
+
+const getLastAiMessage = () => {
+  const messages = currentSession.value?.messages || [];
+  for (let index = messages.length - 1; index >= 0; index -= 1) {
+    const message = messages[index];
+    if (message?.role === 'ai') {
+      return message;
+    }
+  }
+  return null;
+};
+
+const getRowScrollTop = (scrollElement: HTMLElement, rowElement: HTMLElement) => {
+  const scrollRect = scrollElement.getBoundingClientRect();
+  const rowRect = rowElement.getBoundingClientRect();
+  return rowRect.top - scrollRect.top + scrollElement.scrollTop;
+};
+
+const isLastAiRowTyping = () => {
+  const lastAiMessage = getLastAiMessage();
+  if (!lastAiMessage) {
+    return false;
+  }
+
+  const isTyping = Boolean(lastAiRowTypewriterStates.value[String(lastAiMessage.id)]);
+  return isTyping;
+};
+
+const clearLastAiRowTopSnapshot = () => {
+  lastAiRowTopSnapshot.value = null;
+};
+
+const consumeLastAiRowTopForRecord = (recordId: string | number | null | undefined) => {
+  if (recordId === undefined || recordId === null) {
+    return;
+  }
+
+  lastAiRowTopConsumedRecordId.value = String(recordId);
+  hasStoppedAtLastAiRowTop.value = true;
+};
+
+const clearLastAiRowTypingReleaseTimer = () => {
+  if (lastAiRowTypingReleaseTimer !== null) {
+    window.clearTimeout(lastAiRowTypingReleaseTimer);
+    lastAiRowTypingReleaseTimer = null;
+  }
+};
+
+const setLastAiRowTopPinState = (active: boolean) => {
+  const scrollList = scrollListRef.value;
+  if (scrollList && typeof scrollList.setAutoScrollTopPinned === "function") {
+    scrollList.setAutoScrollTopPinned(active);
+  }
+};
+
+const isLastAiRowTopPinned = () => {
+  const scrollList = scrollListRef.value;
+  if (!scrollList || typeof scrollList.isAutoScrollTopPinned !== "function") {
+    return false;
+  }
+
+  return scrollList.isAutoScrollTopPinned();
+};
+
+const releaseLastAiRowTopPin = (reason: "typewriter-finished" | "session-switch" | "user-scroll") => {
+  clearLastAiRowTypingReleaseTimer();
+  setLastAiRowTopPinState(false);
+  clearLastAiRowTopSnapshot();
+
+  if (reason === "user-scroll") {
+    return;
+  }
+};
+
+const getOrCreateLastAiRowTopSnapshot = (recordId: string, rowOffsetTop: number) => {
+  if (
+    lastAiRowTopSnapshot.value?.recordId !== recordId ||
+    !Number.isFinite(lastAiRowTopSnapshot.value?.rowOffsetTop)
+  ) {
+    lastAiRowTopSnapshot.value = {
+      recordId,
+      rowOffsetTop,
+    };
+  }
+
+  return lastAiRowTopSnapshot.value;
+};
+
+const stopAutoScrollAtLastAiRowTopIfNeeded = () => {
+  const scrollList = scrollListRef.value;
+  if (scrollList && typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" && scrollList.isAutoScrollTopPinSuppressedByUser()) {
+    return false;
+  }
+
+  if (!isLastAiRowTyping()) {
+    clearLastAiRowTopSnapshot();
+    if (isLastAiRowTopPinned()) {
+      releaseLastAiRowTopPin("typewriter-finished");
+    }
+    return false;
+  }
+
+  if (!scrollList || typeof scrollList.getScrollElement !== "function") {
+    return false;
+  }
+
+  const scrollElement = scrollList.getScrollElement();
+  const lastAiMessage = getLastAiMessage();
+  if (!scrollElement || !lastAiMessage) {
+    return false;
+  }
+
+  const lastAiMessageId = String(lastAiMessage.id);
+  if (lastAiRowTopConsumedRecordId.value === lastAiMessageId) {
+    return false;
+  }
+
+  const rowElement = messageRowRefs[String(lastAiMessage.id)];
+  if (!rowElement) {
+    return false;
+  }
+
+  if (typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
+    scrollList.enableAutoScroll();
+  }
+
+  const isAutoScrollActive =
+    typeof scrollList.isAutoScrollEnabled === "function" ? scrollList.isAutoScrollEnabled() : true;
+  const rowScrollTop = getRowScrollTop(scrollElement, rowElement);
+  const snapshot = getOrCreateLastAiRowTopSnapshot(String(lastAiMessage.id), rowScrollTop);
+
+  const result = maybeStopAutoScrollAtLastAiRowTop({
+    isLastAiRowTyping: true,
+    isAutoScrollActive,
+    hasStoppedAtLastAiRowTop: hasStoppedAtLastAiRowTop.value,
+    scrollTop: scrollElement.scrollTop,
+    rowOffsetTop: snapshot.rowOffsetTop,
+    disableAutoScroll: () => {
+      if (typeof scrollList.disableAutoScroll === "function") {
+        scrollList.disableAutoScroll();
+      }
+    },
+  });
+
+  hasStoppedAtLastAiRowTop.value = result.hasStoppedAtLastAiRowTop;
+  if (result.didStop) {
+    consumeLastAiRowTopForRecord(lastAiMessage.id);
+    setLastAiRowTopPinState(true);
+  }
+  if (result.didStop && result.nextScrollTop !== null) {
+    if (typeof scrollList.scrollToPositionInstant === "function") {
+      scrollList.scrollToPositionInstant(result.nextScrollTop);
+    } else {
+      scrollElement.scrollTop = result.nextScrollTop;
+    }
+  }
+
+  return result.didStop;
+};
+
+const syncAutoScrollFollowState = () => {
+  const scrollList = scrollListRef.value;
+  if (!scrollList || typeof scrollList.getScrollElement !== "function") {
+    return;
+  }
+
+  const scrollElement = scrollList.getScrollElement();
+  const lastAiMessage = getLastAiMessage();
+  if (!scrollElement || !lastAiMessage) {
+    return;
+  }
+
+  const rowElement = messageRowRefs[String(lastAiMessage.id)];
+  if (!rowElement) {
+    return;
+  }
+
+  const distanceToBottom =
+    typeof scrollList.getDistanceToBottom === "function" ? scrollList.getDistanceToBottom() : Number.POSITIVE_INFINITY;
+
+  const isUserScrollSuppressed =
+    Boolean(
+      typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
+      scrollList.isAutoScrollTopPinSuppressedByUser()
+    );
+
+  if (isUserScrollSuppressed) {
+    if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
+      scrollList.enableAutoScroll();
+    }
+    return;
+  }
+
+  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
+    return;
+  }
+
+  if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE && typeof scrollList.enableAutoScroll === "function") {
+    scrollList.enableAutoScroll();
+  }
+};
+
+let chatSuggestionsResizeObserver: ResizeObserver | null = null;
+let listHeightRecoveryFrame = 0;
+let listHeightRecoveryFallbackTimer: ReturnType<typeof setTimeout> | undefined;
+const listHeightRecoveryMaxDuration = 2000;
+const listHeightRecoveryFallbackDelay = 3000;
+const listHeightStableFramesRequired = 4;
+
+const stopListHeightRecovery = () => {
+  if (listHeightRecoveryFrame) {
+    cancelAnimationFrame(listHeightRecoveryFrame);
+    listHeightRecoveryFrame = 0;
+  }
+  if (listHeightRecoveryFallbackTimer) {
+    clearTimeout(listHeightRecoveryFallbackTimer);
+    listHeightRecoveryFallbackTimer = undefined;
+  }
+};
+
+const startListHeightRecovery = () => {
+  stopListHeightRecovery();
+  const startedAt = performance.now();
+  const initialViewportHeight = Math.floor(window.innerHeight);
+  let lastViewportHeight = initialViewportHeight;
+  let stableFrameCount = 0;
+  let viewportRecoveryObserved = false;
+
+  const recover = (timestamp: number) => {
+    const viewportHeight = Math.floor(window.innerHeight);
+    updateListHeight();
+
+    if (viewportHeight > initialViewportHeight + 1) {
+      viewportRecoveryObserved = true;
+    }
+    if (Math.abs(viewportHeight - lastViewportHeight) <= 1) {
+      stableFrameCount += 1;
+    } else {
+      stableFrameCount = 0;
+      lastViewportHeight = viewportHeight;
+    }
+
+    const recoveredAndStable = viewportRecoveryObserved && stableFrameCount >= listHeightStableFramesRequired;
+    if (!recoveredAndStable && timestamp - startedAt < listHeightRecoveryMaxDuration) {
+      listHeightRecoveryFrame = requestAnimationFrame(recover);
+      return;
+    }
+    listHeightRecoveryFrame = 0;
+  };
+
+  listHeightRecoveryFallbackTimer = setTimeout(() => {
+    listHeightRecoveryFallbackTimer = undefined;
+    updateListHeight();
+  }, listHeightRecoveryFallbackDelay);
+  listHeightRecoveryFrame = requestAnimationFrame(recover);
+};
+
+onMounted(() => {
+  loadHistory();
+  if ((currentSession.value?.messages.length || 0) > 0) {
+    currentPage.value = 'chat';
+  }
+  updateListHeight();
+  void observeChatSuggestionsHeight();
+  window.addEventListener('resize', updateListHeight);
+  window.visualViewport?.addEventListener('resize', updateListHeight);
+  window.visualViewport?.addEventListener('scroll', updateListHeight);
+  const fetchAndMergeRemoteSessions = async () => {
+    const { fetchRemoteSessions } = await import('@/network/api/chat-sessions');
+    const remoteSessions = await fetchRemoteSessions(creditCode.value);
+    if (remoteSessions.length > 0) {
+      mergeRemoteSessions(remoteSessions);
+    }
+  };
+  initAuth().then(() => {
+    if (isLoggedIn.value) {
+      loadHistory();
+      void fetchAndMergeRemoteSessions();
+    }
+  });
+});
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', updateListHeight);
+  window.visualViewport?.removeEventListener('resize', updateListHeight);
+  window.visualViewport?.removeEventListener('scroll', updateListHeight);
+  stopListHeightRecovery();
+  chatSuggestionsResizeObserver?.disconnect();
+  void stopVoiceInput();
+  clearSessionPressTimer();
+  clearLastAiRowTypingReleaseTimer();
+  releaseLastAiRowTopPin("session-switch");
+  cancelMobileScrollAnimation();
+});
+
+const updateListHeight = () => {
+  listHeight.value = calculateMobileListHeight(window.innerHeight, isFreeformInputActive.value);
+  updateChatSuggestionsBottomPadding();
+};
+
+const updateChatSuggestionsBottomPadding = () => {
+  if (currentPage.value !== 'chat' || currentSessionSuggestions.value.length === 0) {
+    chatSuggestionsBottomPadding.value = 0;
+    return;
+  }
+
+  chatSuggestionsBottomPadding.value = Math.ceil(
+    chatSuggestionsContainerRef.value?.getBoundingClientRect().height || 0,
+  ) + 16;
+};
+
+const observeChatSuggestionsHeight = async () => {
+  await nextTick();
+  chatSuggestionsResizeObserver?.disconnect();
+  chatSuggestionsResizeObserver = null;
+
+  if (currentPage.value === 'chat' && chatSuggestionsContainerRef.value) {
+    chatSuggestionsResizeObserver = new ResizeObserver(updateChatSuggestionsBottomPadding);
+    chatSuggestionsResizeObserver.observe(chatSuggestionsContainerRef.value);
+  }
+
+  updateChatSuggestionsBottomPadding();
+};
+
+watch(
+  [currentPage, () => currentSessionSuggestions.value.length],
+  () => {
+    void observeChatSuggestionsHeight();
+  },
+  { flush: 'post' },
+);
+
+watch(isFreeformInputActive, (isFocused) => {
+  updateListHeight();
+  if (isFocused) {
+    stopListHeightRecovery();
+    return;
+  }
+  startListHeightRecovery();
+}, { flush: 'post' });
+
+watch(
+  [currentPage, () => currentSession.value?.id || null],
+  ([page, sessionId]) => {
+    setUploadDraftKey(page === 'chat' ? sessionId : null);
+  },
+  { immediate: true },
+);
+
+const sendSuggestion = (text: string) => {
+  inputText.value = text;
+  void sendMobileMessage();
+};
+
+const handleSuggestionClick = (item: { text: string }) => {
+  inputText.value = item.text;
+  void sendMessage();
+};
+
+const sendMobileMessage = async () => {
+  if (currentPage.value === 'home' && currentSession.value) {
+    const pendingText = inputText.value;
+    startSharedNewChat();
+    inputText.value = pendingText;
+  }
+
+  await sendMessage();
+};
+
+const releaseVoiceInputInstance = () => {
+  if (!asrInstance) {
+    return;
+  }
+
+  asrInstance.close();
+  asrInstance.clear();
+  asrInstance = null;
+};
+
+const stopVoiceInput = async () => {
+  const wasVoiceInputActive = isVoiceInputMode.value;
+  isVoiceInputMode.value = false;
+  isVoiceInputStarting.value = false;
+
+  if (!asrInstance) {
+    return;
+  }
+
+  if (wasVoiceInputActive) {
+    try {
+      const result = await asrInstance.transform();
+      if (result?.text) {
+        inputText.value = result.text;
+      }
+    } catch (error) {
+      console.error("Mobile voice input transform error:", error);
+    }
+  }
+
+  releaseVoiceInputInstance();
+};
+
+const toggleVoiceInput = async () => {
+  if (isVoiceInputStarting.value) {
+    return;
+  }
+
+  if (isVoiceInputMode.value) {
+    await stopVoiceInput();
+    return;
+  }
+
+  if (inputText.value.trim().length > 0) {
+    const confirmClear = window.confirm("可能会清空之前的文字,确认要清空吗?");
+    if (!confirmClear) {
+      return;
+    }
+    inputText.value = "";
+  }
+
+  isVoiceInputStarting.value = true;
+
+  try {
+    asrInstance = new ASR("ce6cb2020037ed20f8a09a4822e869f380a0de96", {
+      audio: true,
+      video: false,
+    });
+
+    let success = false;
+    try {
+      success = await asrInstance.openMicroPhone();
+    } catch (error) {
+      console.error("Mobile voice input microphone error:", error);
+      // 提示统一走 showToast(Ant Design 的 message)—— window.alert 会阻塞页面、
+      // 样式也和全站其他提示不一致(用户 2026-09-23 要求语音这几处一并换掉)
+      showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
+      releaseVoiceInputInstance();
+      return;
+    }
+
+    if (!success) {
+      showToast("未获取到麦克风权限,请在系统或浏览器设置中启用麦克风");
+      releaseVoiceInputInstance();
+      return;
+    }
+
+    asrInstance.addEventListener("transform", (arg: any) => {
+      inputText.value = arg.transformText;
+    });
+
+    asrInstance.startRecord();
+    await asrInstance.openSocket();
+    isVoiceInputMode.value = true;
+  } catch (error) {
+    console.error("Mobile voice input error:", error);
+    showToast(error instanceof Error ? error.message : "语音输入初始化失败,请稍后重试");
+    isVoiceInputMode.value = false;
+    releaseVoiceInputInstance();
+  } finally {
+    isVoiceInputStarting.value = false;
+  }
+};
+
+const scrollToBottom = async (force = false) => {
+  await nextTick();
+  if (scrollListRef.value) {
+    if (force && typeof scrollListRef.value.enableAutoScroll === 'function') {
+      scrollListRef.value.enableAutoScroll();
+      releaseLastAiRowTopPin("session-switch");
+    }
+    scrollListRef.value.scrollToBottom();
+  }
+  if (scrollHostRef.value) {
+    scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
+  }
+};
+
+const scrollToBottomAfterSessionSwitch = async () => {
+  await scrollToBottom(true);
+  await nextTick();
+
+  scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
+
+  if (scrollHostRef.value) {
+    scrollHostRef.value.scrollTop = scrollHostRef.value.scrollHeight;
+  }
+};
+
+const animateScrollElementBy = (element: HTMLElement | null, delta: number, duration = mobileScrollAnimationDurationMs) => {
+  if (!element || !Number.isFinite(delta) || delta === 0) {
+    return Promise.resolve();
+  }
+
+  cancelMobileScrollAnimation();
+
+  const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
+  const startTop = element.scrollTop;
+  const targetTop = Math.max(0, Math.min(maxScrollTop, startTop + delta));
+
+  if (targetTop === startTop) {
+    return Promise.resolve();
+  }
+
+  return new Promise((resolve) => {
+    const startTime = performance.now();
+    const revision = ++mobileScrollAnimationRevision;
+
+    const finish = () => {
+      if (mobileScrollAnimationResolve) {
+        mobileScrollAnimationResolve();
+        mobileScrollAnimationResolve = null;
+      }
+      mobileScrollAnimationFrameId = 0;
+    };
+
+    mobileScrollAnimationResolve = resolve;
+
+    const step = (now: number) => {
+      if (revision !== mobileScrollAnimationRevision) {
+        finish();
+        return;
+      }
+
+      const progress = Math.min(1, (now - startTime) / duration);
+      const eased = 1 - Math.pow(1 - progress, 3);
+      element.scrollTop = startTop + (targetTop - startTop) * eased;
+
+      if (progress < 1) {
+        mobileScrollAnimationFrameId = requestAnimationFrame(step);
+      } else {
+        finish();
+      }
+    };
+
+    mobileScrollAnimationFrameId = requestAnimationFrame(step);
+  });
+};
+
+const getLastAiRowBodyHeight = () => {
+  const lastAiMessage = getLastAiMessage();
+  if (!lastAiMessage) {
+    return 0;
+  }
+
+  const rowElement = messageRowRefs[String(lastAiMessage.id)];
+  const bodyElement = rowElement?.querySelector(".business-record-body") as HTMLElement | null;
+  return bodyElement ? Math.ceil(bodyElement.getBoundingClientRect().height) : 0;
+};
+
+const releaseScopeHeightLockIfReady = () => {
+  const lastAiMessage = getLastAiMessage();
+  if (!lastAiMessage) {
+    return false;
+  }
+
+  const key = String(lastAiMessage.id);
+  const lockHeight = messageRowMinHeights[key];
+  if (!Number.isFinite(lockHeight) || lockHeight <= 0) {
+    return false;
+  }
+
+  const bodyHeight = getLastAiRowBodyHeight();
+  if (bodyHeight < lockHeight) {
+    return false;
+  }
+
+  delete messageRowMinHeights[key];
+  return true;
+};
+
+/**
+ * 大块内容(卡片 / 参考资料)到达时,把**它自己的顶部**对到视口顶部。
+ * 与 PC 端同实现(说明见 BusinessAssistantPC.vue 里同名函数)。
+ */
+const anchorLargeBlockTop = (heightGrowth: number): boolean => {
+  const scrollList = scrollListRef.value;
+  const lastAiMessage = getLastAiMessage();
+  if (!scrollList || typeof scrollList.getScrollElement !== 'function' || !lastAiMessage) {
+    return false;
+  }
+
+  const scrollElement = scrollList.getScrollElement();
+  const rowElement = messageRowRefs[String(lastAiMessage.id)];
+  if (!scrollElement || !rowElement) {
+    return false;
+  }
+
+  const rowTop = getRowScrollTop(scrollElement, rowElement);
+  const rowHeightAfter = rowElement.getBoundingClientRect().height;
+  const blockTop = rowTop + Math.max(0, rowHeightAfter - heightGrowth) - BUSINESS_RECORD_BLOCK_ANCHOR_OFFSET;
+
+  if (typeof scrollList.scrollToPositionInstant === 'function') {
+    scrollList.scrollToPositionInstant(blockTop);
+  } else {
+    scrollElement.scrollTop = blockTop;
+  }
+  return true;
+};
+
+const handleBusinessRecordRefreshScroll = async (payload?: { heightGrowth?: number; scrollDelta?: number; onComplete?: () => void }) => {
+  const scrollList = scrollListRef.value;
+  if (scrollList && typeof scrollList.isAutoScrollTopPinned === "function" && scrollList.isAutoScrollTopPinned()) {
+    payload?.onComplete?.();
+    return;
+  }
+
+  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
+    syncAutoScrollFollowState();
+    payload?.onComplete?.();
+    return;
+  }
+
+  const isUserScrollSuppressed =
+    Boolean(
+      scrollList &&
+      typeof scrollList.isAutoScrollTopPinSuppressedByUser === "function" &&
+      scrollList.isAutoScrollTopPinSuppressedByUser()
+    );
+
+  if (scrollList && typeof scrollList.enableAutoScroll === "function" && !isLastAiRowTopPinned()) {
+    if (isUserScrollSuppressed) {
+      const distanceToBottom =
+        typeof scrollList.getDistanceToBottom === "function"
+          ? scrollList.getDistanceToBottom()
+          : Number.POSITIVE_INFINITY;
+
+      if (distanceToBottom < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE) {
+        scrollList.enableAutoScroll();
+      }
+      payload?.onComplete?.();
+      return;
+    }
+
+    scrollList.enableAutoScroll();
+    payload?.onComplete?.();
+    return;
+  }
+
+  const releasedLock = releaseScopeHeightLockIfReady();
+  if (releasedLock) {
+    syncAutoScrollFollowState();
+  }
+
+  const scrollDelta = Number(payload?.scrollDelta || 0);
+  if (Number.isFinite(scrollDelta) && scrollDelta !== 0) {
+    const scrollTasks: Promise<unknown>[] = [];
+    if (scrollListRef.value && typeof scrollListRef.value.scrollByAnimated === "function") {
+      scrollTasks.push(scrollListRef.value.scrollByAnimated(scrollDelta, mobileScrollAnimationDurationMs));
+    } else if (scrollListRef.value && typeof scrollListRef.value.scrollBy === "function") {
+      scrollListRef.value.scrollBy(scrollDelta);
+    }
+
+    scrollTasks.push(animateScrollElementBy(scrollHostRef.value, scrollDelta));
+
+    await Promise.all(scrollTasks);
+
+    syncAutoScrollFollowState();
+    payload?.onComplete?.();
+
+    return;
+  }
+
+  await nextTick();
+  if (!scrollListRef.value) {
+    return;
+  }
+
+  if (!releasedLock) {
+    const lastAiMessage = getLastAiMessage();
+    if (lastAiMessage) {
+      const lockHeight = messageRowMinHeights[String(lastAiMessage.id)];
+      if (
+        Number.isFinite(lockHeight) &&
+        lockHeight > 0 &&
+        getLastAiRowBodyHeight() < lockHeight &&
+        !isLastAiRowTyping()
+      ) {
+        return;
+      }
+    }
+  }
+
+  const heightGrowth = Number(payload?.heightGrowth || 0);
+  const distanceAfterGrowth =
+    typeof scrollListRef.value.getDistanceToBottom === 'function'
+      ? scrollListRef.value.getDistanceToBottom()
+      : Number.POSITIVE_INFINITY;
+
+  const distanceBeforeGrowth = Math.max(0, distanceAfterGrowth - heightGrowth);
+
+  if (!Number.isFinite(heightGrowth) || heightGrowth <= 0) {
+    if (distanceAfterGrowth <= 24) {
+      scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
+    } else {
+      scrollBusinessAssistantListToBottom(scrollListRef.value);
+    }
+    return;
+  }
+
+  if (
+    distanceBeforeGrowth < BUSINESS_RECORD_BOTTOM_STICKY_DISTANCE ||
+    heightGrowth > BUSINESS_RECORD_LARGE_HEIGHT_GROWTH
+  ) {
+    // 一整块到达(卡片 / 参考资料):把块的**顶部**对到视口顶,而不是滚过它
+    // (用户 2026-09-21 选定;说明见 BusinessAssistantPC.vue 的 BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH)
+    if (heightGrowth >= BUSINESS_RECORD_BLOCK_ANCHOR_GROWTH && anchorLargeBlockTop(heightGrowth)) {
+      payload?.onComplete?.();
+      return;
+    }
+
+    if (distanceAfterGrowth <= 24) {
+      scrollBusinessAssistantListToBottom(scrollListRef.value, 'instant');
+    } else {
+      scrollBusinessAssistantListToBottom(scrollListRef.value, 'once');
+    }
+  }
+
+  syncAutoScrollFollowState();
+};
+
+const handleBusinessScrollPositionChange = (payload?: { isTrusted?: boolean; isProgrammaticScroll?: boolean }) => {
+  if (payload?.isTrusted && !payload?.isProgrammaticScroll) {
+    cancelMobileScrollAnimation();
+    consumeLastAiRowTopForRecord(getLastAiMessage()?.id ?? null);
+    releaseLastAiRowTopPin("user-scroll");
+  }
+
+  if (stopAutoScrollAtLastAiRowTopIfNeeded()) {
+    syncAutoScrollFollowState();
+    return;
+  }
+
+  syncAutoScrollFollowState();
+};
+
+const handleScopeHeightLockChange = (payload?: { recordId?: string | number; active?: boolean; height?: number }) => {
+  const recordId = payload?.recordId;
+  if (recordId === undefined || recordId === null) {
+    return;
+  }
+
+  if (payload?.active) {
+    Object.keys(messageRowMinHeights).forEach((key) => {
+      delete messageRowMinHeights[key];
+    });
+    setMessageRowMinHeight(recordId, payload.height);
+    return;
+  }
+
+  setMessageRowMinHeight(recordId, null);
+};
+
+const handleBusinessRecordTypewriterStateChange = (payload?: { recordId?: string | number; isTypingActive?: boolean }) => {
+  const recordId = payload?.recordId;
+  if (recordId === undefined || recordId === null) {
+    return;
+  }
+
+  const key = String(recordId);
+  if (payload?.isTypingActive) {
+    if (lastAiRowTopConsumedRecordId.value && lastAiRowTopConsumedRecordId.value !== key) {
+      lastAiRowTopConsumedRecordId.value = null;
+      hasStoppedAtLastAiRowTop.value = false;
+      clearLastAiRowTopSnapshot();
+    }
+
+    clearLastAiRowTypingReleaseTimer();
+    lastAiRowTypewriterStates.value[key] = true;
+    syncAutoScrollFollowState();
+    return;
+  }
+
+  clearLastAiRowTypingReleaseTimer();
+  lastAiRowTypingReleaseTimer = window.setTimeout(() => {
+    if (lastAiRowTypewriterStates.value[key]) {
+      return;
+    }
+
+    delete lastAiRowTypewriterStates.value[key];
+    releaseLastAiRowTopPin("typewriter-finished");
+    syncAutoScrollFollowState();
+  }, 180);
+};
+
+const goHome = () => {
+  currentPage.value = 'home';
+};
+
+const openPolicyList = () => {
+  currentPage.value = 'policy-list';
+  selectedPolicy.value = null;
+};
+
+
+const openPolicyDetail = (policy: PolicyItem) => {
+  selectedPolicy.value = policy;
+  currentPage.value = 'policy-detail';
+};
+
+const closeDrawer = () => {
+  isDrawerOpen.value = false;
+  activeSessionMenuId.value = null;
+  longPressSessionId.value = null;
+};
+
+const startNewChat = () => {
+  setUploadDraftKey(null);
+  clearUploadDraft(null);
+  startSharedNewChat();
+  currentPage.value = 'home';
+  closeDrawer();
+};
+
+const switchSessionById = (id: string) => {
+  const session = chatHistory.value.find((s) => s.id === id);
+
+  const doSwitch = () => {
+    if (switchSharedSessionById(id)) {
+      currentPage.value = 'chat';
+      closeDrawer();
+      void scrollToBottomAfterSessionSwitch();
+    }
+  };
+
+  if (session && session.messages.length === 0 && isLoggedIn.value) {
+    void loadRemoteSessionRecords(id).then(() => {
+      doSwitch();
+    });
+    return;
+  }
+
+  doSwitch();
+};
+
+
+const clearSessionPressTimer = () => {
+  if (sessionPressTimer === null) {
+    return;
+  }
+
+  window.clearTimeout(sessionPressTimer);
+  sessionPressTimer = null;
+};
+
+const closeSessionMenu = () => {
+  activeSessionMenuId.value = null;
+  longPressSessionId.value = null;
+  sessionPressTriggeredId = null;
+  sessionPressPointerId = null;
+  sessionMenuAnchor.value = null;
+};
+
+const handleSessionTap = (id: string) => {
+  if (activeSessionMenuId.value === id) {
+    return;
+  }
+
+  switchSessionById(id);
+};
+
+const handleSessionPressStart = (id: string, event: PointerEvent) => {
+  if (event.pointerType === 'mouse' && event.button !== 0) {
+    return;
+  }
+
+  clearSessionPressTimer();
+  sessionPressTriggeredId = null;
+  sessionPressPointerId = event.pointerId;
+  longPressSessionId.value = id;
+  const target = event.currentTarget as HTMLElement | null;
+  const targetRect = target?.getBoundingClientRect() || null;
+
+  sessionPressTimer = window.setTimeout(() => {
+    sessionPressTimer = null;
+    sessionPressTriggeredId = id;
+    activeSessionMenuId.value = id;
+    if (targetRect) {
+      sessionMenuAnchor.value = {
+        left: targetRect.left + 8,
+        bottom: targetRect.bottom + 6,
+      };
+    }
+  }, 420);
+};
+
+const handleSessionPressEnd = (event?: PointerEvent) => {
+  if (event && sessionPressPointerId !== null && event.pointerId !== sessionPressPointerId) {
+    return;
+  }
+
+  clearSessionPressTimer();
+  sessionPressPointerId = null;
+  if (sessionPressTriggeredId !== longPressSessionId.value) {
+    longPressSessionId.value = null;
+  }
+};
+
+const handleRenameFromSheet = () => {
+  const id = activeSessionMenuId.value;
+  if (!id) {
+    return;
+  }
+
+  closeSessionMenu();
+  openRenameModal(id);
+};
+
+const handleDeleteFromSheet = () => {
+  const id = activeSessionMenuId.value;
+  if (!id) {
+    return;
+  }
+
+  closeSessionMenu();
+  openDeleteModal(id);
+};
+
+const sessionMenuPopoverStyle = computed(() => {
+  const anchor = sessionMenuAnchor.value;
+  if (!anchor) {
+    return {};
+  }
+
+  return {
+    left: `${Math.max(8, anchor.left)}px`,
+    top: `${anchor.bottom}px`,
+  };
+});
+
+const openRenameModal = (id: string) => {
+  const session = chatHistory.value.find((item) => item.id === id);
+  if (!session) {
+    return;
+  }
+  renameSessionId.value = id;
+  renameInput.value = session.title || '';
+  closeSessionMenu();
+  isRenameModalOpen.value = true;
+};
+
+const closeRenameModal = () => {
+  isRenameModalOpen.value = false;
+  renameSessionId.value = null;
+};
+
+const confirmRename = () => {
+  const title = renameInput.value.trim();
+  if (!renameSessionId.value || !title) {
+    return;
+  }
+  renameSessionById(renameSessionId.value, title);
+  closeRenameModal();
+};
+
+const openDeleteModal = (id: string) => {
+  deleteSessionId.value = id;
+  closeSessionMenu();
+  isDeleteModalOpen.value = true;
+};
+
+const closeDeleteModal = () => {
+  isDeleteModalOpen.value = false;
+  deleteSessionId.value = null;
+};
+
+const confirmDeleteSession = () => {
+  if (!deleteSessionId.value) {
+    return;
+  }
+  deleteSessionById(deleteSessionId.value);
+  if (!currentSession.value) {
+    currentPage.value = 'home';
+  }
+  closeDeleteModal();
+};
+</script>
+
+<style scoped>
+.ba-mobile-container {
+  position: relative;
+  width: 100vw;
+  height: 100dvh;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  background: linear-gradient(180deg, #ffffff 0%, #f9f9ff 100%);
+  color: #141b2b;
+  font-family: "HarmonyOS Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+.mobile-header {
+  height: 52px;
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 56px 0 16px;
+  background: #f9f9ff;
+  z-index: 10;
+}
+
+.mobile-header.chat-header {
+  justify-content: center;
+  padding: 0 16px;
+}
+
+.icon-btn {
+  width: 40px;
+  height: 40px;
+  border: 0;
+  border-radius: 999px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: transparent;
+  color: #464555;
+  cursor: pointer;
+}
+
+.icon-btn:active {
+  background: rgba(79, 70, 229, 0.1);
+  color: #3525cd;
+}
+
+.header-copy {
+  position: relative;
+  min-width: 0;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+.chat-header .header-copy {
+  width: 100%;
+}
+
+.header-copy-menu {
+  position: absolute;
+  left: 0;
+  top: calc(50% - 0.5px);
+  transform: translateY(-50%);
+}
+
+.header-content {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+:deep(.record--business.ba-record-me) {
+  background-color: rgba(65, 45, 252, 1);
+  color: #ffffff;
+  width: fit-content;
+  max-width: 300px;
+  opacity: 1;
+  padding: 16px 22.3px 16px 16px;
+  border-radius: 12px 4px 12px 12px;
+  box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05);
+}
+
+:deep(.record--business.ba-record-me .text-content) {
+  color: #ffffff;
+}
+
+:deep(.business-record .content a) {
+  color: #3525cd;
+}
+
+
+.chat-header-title {
+  height: 26px;
+  color: #141b2b;
+  font-family: "HarmonyOS Sans SC", sans-serif;
+  font-size: 20px;
+  font-weight: 500;
+  line-height: 1.3;
+  letter-spacing: -0.2px;
+  display: flex;
+  align-items: center;
+}
+
+.header-subtitle {
+  color: #3525cd;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1.4;
+  letter-spacing: 0.12px;
+  opacity: 0.8;
+  padding-bottom: 0.8px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.mobile-main {
+  flex: 1;
+  /* overflow-y: auto; */
+  position: relative;
+  padding: 24px 8px 28px;
+}
+
+.page-chat {
+  padding: 0 16px 198px;
+  background: #ffffff;
+}
+
+.page-policy-list,
+.page-policy-detail {
+  padding: 32px 16px 64px;
+  background: #ffffff;
+}
+
+.bg-image-container {
+  position: absolute;
+  top: 76px;
+  left: 50%;
+  z-index: 0;
+  width: 580px;
+  height: 382px;
+  opacity: 0.1;
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+
+.bg-image {
+  width: 100%;
+  max-width: none;
+  height: 100%;
+  object-fit: fill;
+}
+
+.particle-bg {
+  position: absolute;
+  top: 76px;
+  left: 50%;
+  z-index: 1;
+  width: 580px;
+  height: 382px;
+  transform: translateX(-50%);
+  pointer-events: none;
+}
+
+.welcome-section {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6px;
+  margin: 0;
+  /* 两侧留白:标题与介绍文案都不贴屏幕边(原先几乎顶满,观感很挤) */
+  padding: 0 28px;
+  box-sizing: border-box;
+  width: 100%;
+  text-align: center;
+}
+
+.overlay-icon {
+  width: 96px;
+  height: 65px;
+  border-radius: 999px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 20px;
+}
+
+.heading {
+  margin: 0;
+  color: #141b2b;
+  /* 先给老内核一个固定值(= 上限),再用 clamp 让更窄的屏继续收一点。
+     目的:这行字**恒定单行**不换行。
+     ⚠️ 两行必须同进同退:clamp 这行在后面、会覆盖上一行,只改基础值等于没改。
+     2026-09-18 演进:20px → 16px(用户反馈「太小了」)→ 20px → **22px(当前)**。
+     clamp 取 5.7vw:≥386px 的屏恒为 22px,更窄的屏按比例收缩以免标题被顶出屏幕。 */
+  font-size: 22px;
+  font-size: clamp(17px, 5.7vw, 22px);
+  font-weight: 500;
+  line-height: 1.2;
+  letter-spacing: -0.64px;
+  margin-bottom: 12px;
+  white-space: nowrap;
+}
+
+.desc {
+  margin: 0;
+  color: #464555;
+  /* 加了两侧留白后可用宽度变窄,字号同步收一点,避免这段介绍多折一行 */
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1.5;
+}
+
+.chat-input-area {
+  width: 100%;
+  padding: 0 16px 16px;
+  overflow: hidden;
+}
+
+.mobile-main>.chat-input-area {
+  width: calc(100% + 48px);
+  margin-left: -24px;
+  margin-right: -24px;
+}
+
+.page-home>.chat-input-area {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 2;
+  width: 100%;
+  margin: 0;
+}
+
+.fixed-input .chat-input-area {
+  padding-bottom: 0;
+}
+
+.home-content-shell {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  gap: 0;
+  padding-bottom: 180px;
+}
+
+.suggestion-chips {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 18px 32px 0;
+}
+
+.chip {
+  min-height: 38px;
+  border: 1px solid rgba(199, 196, 216, 0.3);
+  border-radius: 12px;
+  padding: 9px 16px;
+  background: #ffffff;
+  color: #464555;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1.4;
+  letter-spacing: 0.12px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  cursor: pointer;
+}
+
+.chip-icon {
+  width: 16px;
+  height: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.chat-stage {
+  position: relative;
+  z-index: 1;
+  padding-top: 12px;
+}
+
+.chat-ambient {
+  position: fixed;
+  inset: 64px 0 0;
+  z-index: 0;
+  pointer-events: none;
+  overflow: hidden;
+  opacity: 0.3;
+}
+
+.chat-ambient span {
+  position: absolute;
+  display: block;
+  border-radius: 999px;
+  filter: blur(70px);
+}
+
+.chat-ambient span:first-child {
+  top: 0;
+  left: 90px;
+  width: 300px;
+  height: 300px;
+  background: #3525cd;
+}
+
+.chat-ambient span:last-child {
+  left: 0;
+  bottom: -80px;
+  width: 250px;
+  height: 250px;
+  background: #4648d4;
+}
+
+.date-indicator {
+  display: flex;
+  justify-content: center;
+  padding: 12px 0;
+  color: #777587;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1.4;
+}
+
+.message-row {
+  width: 100%;
+  display: flex;
+  margin-bottom: 16px;
+}
+
+.message-row.user {
+  justify-content: flex-end;
+}
+
+.message-row.ai {
+  justify-content: flex-start;
+}
+
+.message-row.ai.is-last {
+  position: relative;
+  /* padding-bottom: 32px; */
+}
+
+:deep(.chat-record-line) {
+  /* max-width: min(322px, 100%) !important; */
+  min-width: 80px !important;
+  min-height: 55px !important;
+  font-size: 15px !important;
+  line-height: 1.6 !important;
+}
+
+:deep(.policy-item) {
+  width: 100% !important;
+  box-sizing: border-box;
+}
+
+:deep(.text-right) {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+}
+
+:deep(.text-left) {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+.empty-chat {
+  margin-top: 120px;
+  text-align: center;
+  color: #777587;
+  font-size: 14px;
+}
+
+.chat-suggestion-chips {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+  width: 100%;
+  justify-content: flex-start;
+  position: relative;
+  z-index: 1;
+  pointer-events: auto;
+}
+
+.fixed-input {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 8;
+  padding: 12px 0 16px;
+  background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
+}
+
+.home-fixed-input {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9;
+  padding: 12px 0 calc(16px + env(safe-area-inset-bottom));
+  background: linear-gradient(180deg, rgba(249, 249, 255, 0) 0%, #f9f9ff 26%, #f9f9ff 100%);
+}
+
+.chat-suggestions-container {
+  position: absolute;
+  top: calc(100% + 12px);
+  left: 0;
+  right: 0;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  pointer-events: none;
+  z-index: 3;
+}
+
+.chat-suggestions-gradient {
+  display: none;
+}
+
+.ai-disclaimer {
+  color: #4f46e5;
+  font-size: 12px;
+  text-align: center;
+}
+
+.policy-page,
+.policy-detail {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+}
+
+.policy-page {
+  gap: 16px;
+}
+
+.policy-detail {
+  gap: 32px;
+}
+
+.page-close-btn {
+  position: absolute;
+  top: -5px;
+  right: 0;
+  z-index: 2;
+  width: 40px;
+  height: 40px;
+  border: 0;
+  border-radius: 999px;
+  background: transparent;
+  color: #464555;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.policy-title-block {
+  padding-right: 16px;
+}
+
+.policy-title-block h1,
+.policy-detail-hero h1 {
+  margin: 0;
+  color: #141b2b;
+  font-size: 24px;
+  font-weight: 500;
+  line-height: 1.35;
+  letter-spacing: -0.24px;
+}
+
+.policy-title-block p {
+  margin: 7px 0 0;
+  color: #464555;
+  font-size: 16px;
+  font-weight: 500;
+  line-height: 1.6;
+}
+
+.policy-filter {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  padding: 16px;
+  border: 1px solid #e9edff;
+  border-radius: 12px;
+  background: #ffffff;
+  box-shadow: 0 1px 1.75px rgba(0, 0, 0, 0.05);
+}
+
+.policy-search,
+.drawer-search {
+  height: 48px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 16px;
+  border: 1px solid #c7c4d8;
+  border-radius: 8px;
+  background: #f9f9ff;
+  color: #777587;
+}
+
+.policy-filter-row {
+  display: grid;
+  grid-template-columns: 1fr 112px;
+  gap: 16px;
+}
+
+.policy-search input,
+.drawer-search input {
+  min-width: 0;
+  flex: 1;
+  border: 0;
+  outline: 0;
+  background: transparent;
+  color: #141b2b;
+  font: inherit;
+  font-size: 14px;
+}
+
+.policy-select {
+  height: 48px;
+  border: 1px solid #c7c4d8;
+  border-radius: 8px;
+  background: #f9f9ff;
+  color: #141b2b;
+  padding: 0 16px;
+  font: inherit;
+  font-size: 14px;
+  min-width: 0;
+}
+
+.policy-filter-btn {
+  height: 48px;
+  border: 0;
+  border-radius: 8px;
+  background: #3525cd;
+  color: #ffffff;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 0 18px;
+  font: inherit;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.policy-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.policy-card {
+  display: grid;
+  grid-template-columns: 1fr 98px;
+  gap: 12px;
+  min-height: 186px;
+  padding: 16px;
+  border: 1px solid #e9edff;
+  border-radius: 12px;
+  background: #ffffff;
+  cursor: pointer;
+}
+
+.policy-meta,
+.detail-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  color: #464555;
+  font-size: 12px;
+}
+
+.policy-meta span,
+.detail-tags span {
+  padding: 2px 8px;
+  border-radius: 4px;
+  background: #e2dfff;
+}
+
+.policy-card h2 {
+  margin: 8px 0 0;
+  color: #141b2b;
+  font-size: 20px;
+  font-weight: 500;
+  line-height: 1.3;
+  letter-spacing: -0.2px;
+}
+
+.policy-card p {
+  margin: 8px 0 0;
+  color: #464555;
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 1.6;
+}
+
+.match-panel {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+}
+
+.match-ring {
+  width: 64px;
+  height: 64px;
+  border-radius: 50%;
+  border: 6px solid #dce2f7;
+  color: #3525cd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 15px;
+  font-weight: 700;
+}
+
+.match-label {
+  color: #464555;
+  font-size: 12px;
+}
+
+.match-panel button,
+.back-policy-btn {
+  height: 34px;
+  border: 1px solid #3525cd;
+  border-radius: 8px;
+  background: #ffffff;
+  color: #3525cd;
+  padding: 0 16px;
+  font: inherit;
+  font-size: 12px;
+}
+
+.pagination-row {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(0, 1fr));
+  gap: 8px;
+  align-items: center;
+  text-align: center;
+}
+
+.pagination-row button {
+  height: 40px;
+  border: 1px solid #c7c4d8;
+  border-radius: 8px;
+  background: transparent;
+  color: #464555;
+  font: inherit;
+}
+
+.pagination-row button.active {
+  border-color: #3525cd;
+  background: #3525cd;
+  color: #ffffff;
+}
+
+.back-policy-btn {
+  align-self: flex-start;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  border: 0;
+  padding: 0;
+  height: auto;
+  color: #3525cd;
+}
+
+.policy-detail-hero {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.detail-grid,
+.conditions-section {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.detail-box,
+.condition-card {
+  padding: 16px;
+  border: 1px solid rgba(199, 196, 216, 0.3);
+  border-radius: 12px;
+  background: #ffffff;
+}
+
+.detail-box h2,
+.conditions-section h2 {
+  margin: 0 0 12px;
+  color: #141b2b;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.detail-box p,
+.detail-box li,
+.condition-card p,
+.condition-lead {
+  color: #464555;
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 1.6;
+}
+
+.detail-box ul {
+  margin: 0;
+  padding-left: 18px;
+}
+
+.condition-lead {
+  padding-left: 12px;
+  border-left: 1px solid #a1a1a1;
+  opacity: 0.65;
+}
+
+.condition-status {
+  align-self: flex-end;
+  display: inline-flex;
+  padding: 4px 10px;
+  border-radius: 999px;
+  background: rgba(53, 37, 205, 0.1);
+  color: #3525cd;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.drawer-layer {
+  position: fixed;
+  inset: 0;
+  z-index: 30;
+  background: rgba(0, 0, 0, 0.5);
+}
+
+.session-drawer {
+  width: min(331.5px, 86vw);
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #ffffff;
+  box-shadow: 0 25px 43.75px -12px rgba(0, 0, 0, 0.25);
+}
+
+.drawer-head {
+  min-height: 73px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 16px;
+  border-bottom: 1px solid #c7c4d8;
+}
+
+.new-chat-btn {
+  height: 40px;
+  border: 0;
+  border-radius: 999px;
+  background: #3525cd;
+  color: #ffffff;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 0 16px;
+  font: inherit;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+.drawer-search {
+  margin: 16px;
+  height: 40px;
+  border: 0;
+}
+
+.drawer-list {
+  position: relative;
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding: 7px 8px 16px;
+  -webkit-touch-callout: none;
+}
+
+.drawer-list h2 {
+  margin: 0;
+  padding: 0 8px 8px;
+  color: #777587;
+  font-size: 12px;
+  font-weight: 500;
+  letter-spacing: 0.12px;
+}
+
+.drawer-empty {
+  padding: 16px;
+  color: #777587;
+  font-size: 13px;
+}
+
+.drawer-session {
+  position: relative;
+  width: 100%;
+  min-height: 48px;
+  border: 0;
+  border-radius: 12px;
+  background: transparent;
+  color: #464555;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  padding: 12px;
+  font: inherit;
+  font-size: 14px;
+  text-align: left;
+  user-select: none;
+  -webkit-user-select: none;
+  -webkit-touch-callout: none;
+  -webkit-tap-highlight-color: transparent;
+  transition:
+    background 0.18s ease,
+    color 0.18s ease,
+    transform 0.18s ease;
+}
+
+.drawer-session.active {
+  background: rgba(79, 70, 229, 0.1);
+  color: #3525cd;
+}
+
+.drawer-session.is-pressing {
+  background: rgba(53, 37, 205, 0.08);
+  transform: scale(0.99);
+}
+
+.drawer-session span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.session-popover {
+  position: absolute;
+  left: 8px;
+  top: calc(100% + 6px);
+  z-index: 50;
+  width: 108px;
+  overflow: hidden;
+  border-radius: 10px;
+  background: rgba(250, 251, 255, 0.98);
+  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
+  user-select: none;
+  -webkit-user-select: none;
+  -webkit-touch-callout: none;
+  -webkit-tap-highlight-color: transparent;
+}
+
+.session-popover button {
+  width: 100%;
+  height: 38px;
+  border: 0;
+  background: transparent;
+  color: #141b2b;
+  font: inherit;
+  font-size: 14px;
+  font-weight: 500;
+  text-align: left;
+  padding: 0 12px;
+  user-select: none;
+  -webkit-user-select: none;
+  -webkit-touch-callout: none;
+  -webkit-tap-highlight-color: transparent;
+}
+
+.session-popover button:active {
+  background: rgba(53, 37, 205, 0.08);
+}
+
+.session-popover .danger {
+  color: #e82437;
+}
+
+.session-menu-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 40;
+  background: rgba(14, 18, 38, 0.18);
+  backdrop-filter: blur(3px);
+  pointer-events: auto;
+}
+
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 60;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: rgba(0, 0, 0, 0.3);
+}
+
+.mobile-modal {
+  width: min(100%, 360px);
+  border-radius: 12px;
+  background: #ffffff;
+  box-shadow: 4px 4px 14px rgba(0, 0, 0, 0.12);
+  padding: 16px;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+
+.modal-header h2 {
+  margin: 0;
+  color: #000;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.modal-input {
+  width: 100%;
+  height: 42px;
+  border: 1px solid #cecece;
+  border-radius: 8px;
+  padding: 0 12px;
+  color: #000;
+  font: inherit;
+  outline: none;
+}
+
+.delete-copy {
+  margin: 0 0 24px;
+  color: #464555;
+  font-size: 15px;
+}
+
+.modal-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-top: 18px;
+}
+
+.modal-actions button {
+  min-width: 84px;
+  height: 36px;
+  border: 0;
+  border-radius: 8px;
+  background: #cecece;
+  color: #ffffff;
+  font: inherit;
+}
+
+.modal-actions .primary {
+  background: #3525cd;
+}
+
+.modal-actions .danger {
+  background: #f97c7c;
+}
+
+.chat-suggestions-fade-enter-active,
+.chat-suggestions-fade-leave-active,
+.drawer-fade-enter-active,
+.drawer-fade-leave-active,
+.session-popover-fade-enter-active,
+.session-popover-fade-leave-active,
+.session-overlay-fade-enter-active,
+.session-overlay-fade-leave-active {
+  transition: opacity 0.22s ease, transform 0.22s ease;
+}
+
+.chat-suggestions-fade-enter-from,
+.chat-suggestions-fade-leave-to {
+  opacity: 0;
+  transform: translateY(10px);
+}
+
+.drawer-fade-enter-from,
+.drawer-fade-leave-to {
+  opacity: 0;
+}
+
+.session-popover-fade-enter-from,
+.session-popover-fade-leave-to {
+  opacity: 0;
+  transform: translateY(6px) scale(0.96);
+}
+
+.session-overlay-fade-enter-from,
+.session-overlay-fade-leave-to {
+  opacity: 0;
+}
+
+@media (max-width: 360px) {
+  .mobile-main {
+    padding-left: 16px;
+    padding-right: 16px;
+  }
+
+  /* [已移除 2026-09-18] 此处原为 .heading { font-size: 28px }。
+     它最初与基础值同值(那时基础值是 28px),是个空操作;
+     2026-09-18 基础值改小后它反而变成**窄屏上把标题放大回 28px** 的覆盖——
+     而标题是 white-space: nowrap,13 个字 × 28px ≈ 356px,远超窄屏可用宽度
+     (360px 屏约 272px)—— 会把标题顶出屏幕约 84px。
+     窄屏的收缩已由 .heading 自己的 clamp(13px, 4.2vw, 16px) 负责,故移除。
+  .heading {
+    font-size: 28px;
+  }
+  */
+
+  .suggestion-chips {
+    padding-left: 12px;
+    padding-right: 12px;
+  }
+
+  .policy-card {
+    grid-template-columns: 1fr;
+  }
+
+  .match-panel {
+    flex-direction: row;
+    justify-content: flex-start;
+  }
+}
+</style>

Разлика између датотеке није приказан због своје велике величине
+ 3 - 71
src/components/BusinessAssistantPC.vue


+ 1 - 1
src/utils/app-message.ts

@@ -17,7 +17,7 @@ import { message } from "ant-design-vue";
 export type AppMessageType = "error" | "warning" | "success" | "info";
 
 /** 自动消失时间(秒)。AntD 默认 3s,这里显式写出来便于统一调整 */
-export const APP_MESSAGE_DURATION = 3;
+export const APP_MESSAGE_DURATION = 2;
 
 /**
  * 弹一条轻提示。`text` 为空时什么都不做(避免弹出一个空气泡)。

Неке датотеке нису приказане због велике количине промена