Authored by zhurun

feat: 初始提交 - 嘉宝仁和样本流转系统前端

包含 Vue3 + TypeScript + Element Plus 全套前端代码,
含 vendorPGX 样本流转系统12个页面的完整 mock 实现。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing 37 changed files with 4555 additions and 0 deletions

Too many changes to show.

To preserve performance only 37 of 37+ files are displayed.

  1 +# Logs
  2 +logs
  3 +*.log
  4 +npm-debug.log*
  5 +yarn-debug.log*
  6 +yarn-error.log*
  7 +pnpm-debug.log*
  8 +lerna-debug.log*
  9 +
  10 +node_modules
  11 +framework_code
  12 +dist
  13 +dist-ssr
  14 +*.local
  15 +
  16 +# Playwright
  17 +e2e/.auth/
  18 +playwright-report/
  19 +test-results/
  20 +e2e/.env.test
  21 +
  22 +# Editor directories and files
  23 +.vscode/*
  24 +!.vscode/extensions.json
  25 +.idea
  26 +.DS_Store
  27 +*.suo
  28 +*.ntvs*
  29 +*.njsproj
  30 +*.sln
  31 +*.sw?
  32 +
  33 +# Large temp files
  34 +stream1.txt
  35 +stream2.txt
  1 +{
  2 + "recommendations": ["Vue.volar"]
  3 +}
  1 +# CLAUDE.md
  2 +
  3 +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
  4 +
  5 +## Commands
  6 +
  7 +This project uses **cnpm** as the package manager (not npm or yarn).
  8 +
  9 +**Development:**
  10 +```bash
  11 +cnpm run dev # Start dev server on port 5173
  12 +```
  13 +
  14 +**Building:**
  15 +```bash
  16 +cnpm run build # Production build (uses 8GB memory allocation)
  17 +```
  18 +
  19 +**Type Checking:**
  20 +```bash
  21 +cnpm run type-check # Run TypeScript type checking without emitting files
  22 +```
  23 +
  24 +**Preview:**
  25 +```bash
  26 +cnpm run preview # Preview production build locally
  27 +```
  28 +
  29 +## Tech Stack
  30 +
  31 +- **Frontend Framework**: Vue 3 with Composition API
  32 +- **Language**: TypeScript
  33 +- **Build Tool**: Vite 6
  34 +- **State Management**: Pinia (Composition API style with `defineStore`)
  35 +- **Routing**: Vue Router 4 with hash history mode
  36 +- **UI Components**: Element Plus (with auto-import)
  37 +- **Internationalization**: vue-i18n
  38 +- **Rich Text Editors**: Multiple options (BlockSuite, Univer.js, Cherry Markdown, Vditor)
  39 +- **Auto-imports**: Vue, Vue Router, Pinia, vue-i18n APIs are auto-imported (see `auto-imports.d.ts`)
  40 +
  41 +## Project Structure
  42 +
  43 +```
  44 +src/
  45 +├── api/ # API service modules (user, chat, files, etc.)
  46 +├── assets/ # Static assets (styles, images)
  47 +├── components/ # Reusable Vue components organized by feature
  48 +│ ├── auth/ # Authentication components
  49 +│ ├── Chat/ # Chat-related components
  50 +│ ├── KnowledgeBase/
  51 +│ ├── Settings/
  52 +│ ├── Workspace/
  53 +│ └── common/ # Shared components
  54 +├── layout/ # Layout components (MainLayout, etc.)
  55 +├── locales/ # i18n translation files
  56 +├── pages/ # Top-level page components (Auth, Welcome, Workspace, etc.)
  57 +├── router/ # Vue Router configuration
  58 +├── stores/ # Pinia stores (auth, user, chat, settings, etc.)
  59 +├── types/ # TypeScript type definitions
  60 +├── utils/ # Utility functions
  61 +├── App.vue # Root component
  62 +└── main.ts # Application entry point
  63 +```
  64 +
  65 +## Architecture Overview
  66 +
  67 +### State Management (Pinia)
  68 +
  69 +All stores use the Composition API style with `defineStore`:
  70 +- State is defined using `ref()` or `reactive()`
  71 +- Getters use `computed()`
  72 +- Actions are plain functions
  73 +- Store files are in `src/stores/`
  74 +
  75 +Example store pattern:
  76 +```typescript
  77 +export const useAuthStore = defineStore('auth', () => {
  78 + const token = ref<string>('')
  79 + const isAuthenticated = computed(() => !!token.value)
  80 +
  81 + async function login(credentials) {
  82 + // action logic
  83 + }
  84 +
  85 + return { token, isAuthenticated, login }
  86 +})
  87 +```
  88 +
  89 +### API Layer
  90 +
  91 +API services are organized by domain in `src/api/`:
  92 +- Each service exports functions that make HTTP requests
  93 +- Uses axios via `src/utils/request.ts` (configured as `service`)
  94 +- Mock API interceptors available in `src/api/mock.ts` for development
  95 +
  96 +### Routing
  97 +
  98 +- Uses hash history mode (`createWebHashHistory()`)
  99 +- Main layout at `/app` with nested routes
  100 +- Route meta includes:
  101 + - `requiresAuth`: boolean for auth guard
  102 + - `hideIntelligencePanel`: control panel visibility
  103 + - `title`, `icon`, `hidden`: UI metadata
  104 +- Global navigation guards in `src/router/index.ts`:
  105 + - Token-based authentication on `/auth?token=...`
  106 + - Redirect unauthenticated users to login
  107 +
  108 +### Components Organization
  109 +
  110 +Components are organized by feature:
  111 +- **Feature folders** (Chat, KnowledgeBase, Settings, Workspace, auth): Domain-specific components
  112 +- **common/**: Shared components used across features
  113 +- **layout/**: Application layout components
  114 +
  115 +### Auto-imports
  116 +
  117 +The project uses `unplugin-auto-import` and `unplugin-vue-components`:
  118 +- Vue APIs (ref, computed, watch, etc.) are auto-imported
  119 +- Vue Router APIs (useRouter, useRoute) are auto-imported
  120 +- Pinia APIs (storeToRefs, etc.) are auto-imported
  121 +- vue-i18n APIs are auto-imported
  122 +- Element Plus components are auto-imported
  123 +- Type definitions regenerated in dev mode: `src/auto-import.d.ts` and `src/components.d.ts`
  124 +
  125 +### Environment Configuration
  126 +
  127 +Environment variables are defined in `vite.config.ts` (not in `.env` files):
  128 +- `import.meta.env.VITE_APP_BASE_URL`: API base URL (defaults to https://ai.linkmed.cc)
  129 +- Dev server proxies `/api` and `/deepresearch` to the backend
  130 +
  131 +## Build System Notes
  132 +
  133 +The Vite configuration includes several **custom plugins** to handle module compatibility issues:
  134 +
  135 +1. **fixModuleNotDefined**: Handles CJS modules in development mode
  136 +2. **fixProblematicCjsModules**: Fixes specific problematic CJS modules (lodash, file-saver, etc.)
  137 +3. **fixBlockSuiteAccessor**: Transforms modern accessor syntax in BlockSuite packages during build
  138 +4. **fixHighlightJs**: Fixes highlight.js import issues
  139 +
  140 +These plugins exist because the project uses many complex dependencies (BlockSuite, Univer.js, React-based libraries) that have CJS/ESM compatibility issues.
  141 +
  142 +**Important build configuration:**
  143 +- Build target is `es2022` (for better class/decorator handling)
  144 +- Memory allocation increased to 8GB for production builds
  145 +- Manual chunk splitting for optimal bundle size
  146 +- React dependencies are included despite this being a Vue project (required by BlockSuite)
  147 +
  148 +## Development Guidelines
  149 +
  150 +### Adding New Features
  151 +
  152 +1. Create a new store in `src/stores/` if state management is needed
  153 +2. Add API functions in appropriate `src/api/` module
  154 +3. Create feature-specific components in `src/components/[feature]/`
  155 +4. Add page components in `src/pages/` if needed
  156 +5. Update routes in `src/router/index.ts`
  157 +
  158 +### Working with API
  159 +
  160 +The backend base URL is proxied in development:
  161 +- Local API calls use `/api` prefix (proxied to backend)
  162 +- Backend base URL: https://ai.linkmed.cc (can be changed in vite.config.ts line 206)
  163 +
  164 +### Type Safety
  165 +
  166 +- TypeScript strict mode is enabled
  167 +- Use the auto-generated type definitions in `src/auto-import.d.ts` and `src/components.d.ts`
  168 +- Type definitions for custom modules in `src/types/`
  169 +
  170 +### Authentication Flow
  171 +
  172 +1. User logs in via `/auth` route
  173 +2. Token stored in localStorage (key: `auth_token`)
  174 +3. Auth state managed by `useAuthStore` (src/stores/auth.ts)
  175 +4. Token refresh logic included
  176 +5. Router guard checks `isAuthenticated` computed property
  177 +
  178 +## Common Issues
  179 +
  180 +### Module Resolution Errors
  181 +If you encounter "module is not defined" or CJS/ESM errors:
  182 +- Check if the module needs to be added to `fixProblematicCjsModules` in vite.config.ts
  183 +- Consider adding to `resolve.dedupe` array if there are version conflicts
  184 +
  185 +### Build Memory Issues
  186 +If build fails with JavaScript heap out of memory:
  187 +- Memory is already allocated to 8GB in package.json build script
  188 +- If needed, increase further in the `NODE_OPTIONS=--max-old-space-size=XXXX` flag
  189 +
  190 +### BlockSuite/Univer Integration
  191 +These are complex dependencies with special requirements:
  192 +- Do not modify their import patterns
  193 +- They require React as a peer dependency
  194 +- Custom esbuild configuration is needed for decorators
  1 +# Vue 3 + TypeScript + Vite
  2 +
  3 +## 启动项目:
  4 +
  5 +`cnpm run dev`
  6 +
  7 +## 构建项目:
  8 +
  9 +`cnpm run build`
  1 +/* eslint-disable */
  2 +/* prettier-ignore */
  3 +// @ts-nocheck
  4 +// noinspection JSUnusedGlobalSymbols
  5 +// Generated by unplugin-auto-import
  6 +// biome-ignore lint: disable
  7 +export {}
  8 +declare global {
  9 + const EffectScope: typeof import('vue')['EffectScope']
  10 + const ElMessage: typeof import('element-plus/es').ElMessage
  11 + const ElNotification: typeof import('element-plus/es').ElNotification
  12 + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
  13 + const computed: typeof import('vue')['computed']
  14 + const createApp: typeof import('vue')['createApp']
  15 + const createPinia: typeof import('pinia')['createPinia']
  16 + const customRef: typeof import('vue')['customRef']
  17 + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
  18 + const defineComponent: typeof import('vue')['defineComponent']
  19 + const defineStore: typeof import('pinia')['defineStore']
  20 + const effectScope: typeof import('vue')['effectScope']
  21 + const getActivePinia: typeof import('pinia')['getActivePinia']
  22 + const getCurrentInstance: typeof import('vue')['getCurrentInstance']
  23 + const getCurrentScope: typeof import('vue')['getCurrentScope']
  24 + const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
  25 + const h: typeof import('vue')['h']
  26 + const inject: typeof import('vue')['inject']
  27 + const isProxy: typeof import('vue')['isProxy']
  28 + const isReactive: typeof import('vue')['isReactive']
  29 + const isReadonly: typeof import('vue')['isReadonly']
  30 + const isRef: typeof import('vue')['isRef']
  31 + const isShallow: typeof import('vue')['isShallow']
  32 + const mapActions: typeof import('pinia')['mapActions']
  33 + const mapGetters: typeof import('pinia')['mapGetters']
  34 + const mapState: typeof import('pinia')['mapState']
  35 + const mapStores: typeof import('pinia')['mapStores']
  36 + const mapWritableState: typeof import('pinia')['mapWritableState']
  37 + const markRaw: typeof import('vue')['markRaw']
  38 + const nextTick: typeof import('vue')['nextTick']
  39 + const onActivated: typeof import('vue')['onActivated']
  40 + const onBeforeMount: typeof import('vue')['onBeforeMount']
  41 + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
  42 + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
  43 + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
  44 + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
  45 + const onDeactivated: typeof import('vue')['onDeactivated']
  46 + const onErrorCaptured: typeof import('vue')['onErrorCaptured']
  47 + const onMounted: typeof import('vue')['onMounted']
  48 + const onRenderTracked: typeof import('vue')['onRenderTracked']
  49 + const onRenderTriggered: typeof import('vue')['onRenderTriggered']
  50 + const onScopeDispose: typeof import('vue')['onScopeDispose']
  51 + const onServerPrefetch: typeof import('vue')['onServerPrefetch']
  52 + const onUnmounted: typeof import('vue')['onUnmounted']
  53 + const onUpdated: typeof import('vue')['onUpdated']
  54 + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
  55 + const provide: typeof import('vue')['provide']
  56 + const reactive: typeof import('vue')['reactive']
  57 + const readonly: typeof import('vue')['readonly']
  58 + const ref: typeof import('vue')['ref']
  59 + const resolveComponent: typeof import('vue')['resolveComponent']
  60 + const setActivePinia: typeof import('pinia')['setActivePinia']
  61 + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
  62 + const shallowReactive: typeof import('vue')['shallowReactive']
  63 + const shallowReadonly: typeof import('vue')['shallowReadonly']
  64 + const shallowRef: typeof import('vue')['shallowRef']
  65 + const storeToRefs: typeof import('pinia')['storeToRefs']
  66 + const toRaw: typeof import('vue')['toRaw']
  67 + const toRef: typeof import('vue')['toRef']
  68 + const toRefs: typeof import('vue')['toRefs']
  69 + const toValue: typeof import('vue')['toValue']
  70 + const triggerRef: typeof import('vue')['triggerRef']
  71 + const unref: typeof import('vue')['unref']
  72 + const useAttrs: typeof import('vue')['useAttrs']
  73 + const useCssModule: typeof import('vue')['useCssModule']
  74 + const useCssVars: typeof import('vue')['useCssVars']
  75 + const useI18n: typeof import('vue-i18n')['useI18n']
  76 + const useId: typeof import('vue')['useId']
  77 + const useLink: typeof import('vue-router')['useLink']
  78 + const useModel: typeof import('vue')['useModel']
  79 + const useRoute: typeof import('vue-router')['useRoute']
  80 + const useRouter: typeof import('vue-router')['useRouter']
  81 + const useSlots: typeof import('vue')['useSlots']
  82 + const useTemplateRef: typeof import('vue')['useTemplateRef']
  83 + const watch: typeof import('vue')['watch']
  84 + const watchEffect: typeof import('vue')['watchEffect']
  85 + const watchPostEffect: typeof import('vue')['watchPostEffect']
  86 + const watchSyncEffect: typeof import('vue')['watchSyncEffect']
  87 +}
  88 +// for type re-export
  89 +declare global {
  90 + // @ts-ignore
  91 + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
  92 + import('vue')
  93 +}
  1 +# LinkMed Claude Code 自动研发使用指南
  2 +
  3 +借助 Claude Code 实现从需求描述到代码实现、自动验证的完整研发流程,减少人工手动验证环节。
  4 +
  5 +---
  6 +
  7 +## 目录
  8 +
  9 +1. [快速开始](#1-快速开始)
  10 +2. [整体工作流程](#2-整体工作流程)
  11 +3. [多端协作架构](#3-多端协作架构)
  12 +4. [使用方式](#4-使用方式)
  13 +5. [从 TAPD 获取任务详情](#5-从-tapd-获取任务详情)
  14 +6. [TAPD 脚本安装配置](#6-tapd-脚本安装配置)
  15 +7. [测试体系](#7-测试体系)
  16 +8. [单元测试规范(Vitest)](#8-单元测试规范vitest)
  17 +9. [E2E 测试规范(Playwright)](#9-e2e-测试规范playwright)
  18 +10. [提交规范](#10-提交规范)
  19 +11. [dev-sessions 文档规范](#11-dev-sessions-文档规范)
  20 +12. [后续规划](#12-后续规划)
  21 +
  22 +---
  23 +
  24 +## 1. 快速开始
  25 +
  26 +### 启动方式
  27 +
  28 +```bash
  29 +# 普通模式(每步操作需手动确认)
  30 +claude
  31 +
  32 +# 全自动模式(跳过所有权限确认,适合批量任务)
  33 +claude --dangerously-skip-permissions
  34 +
  35 +# 全自动模式 + 直接传入任务
  36 +claude --dangerously-skip-permissions -p "帮我打包并提交代码"
  37 +```
  38 +
  39 +> ⚠️ `--dangerously-skip-permissions` 为**高风险模式**,Claude 可直接执行文件读写、Git 操作、终端命令,**不会弹出确认框**,包括删除文件、强制推送、覆盖数据等破坏性操作。**仅在本地受信任环境中使用**,不要在生产服务器或共享环境中开启。建议配合 `CLAUDE.md` 中的约束说明,明确告知 Claude 哪些操作是禁止的。
  40 +
  41 +---
  42 +
  43 +## 2. 整体工作流程
  44 +
  45 +```
  46 + ┌─────────────────────────────────────────────────────┐
  47 + │ 1. 获取任务(需求 / 缺陷,含图片和评论) │
  48 + │ · 直接描述需求/缺陷内容 │
  49 + │ · 或提供 TAPD ID,脚本自动拉取详情+评论 │
  50 + │ 需求:node ~/.claude/tapd.mjs story <ID> │
  51 + │ 缺陷:node ~/.claude/tapd.mjs bug <ID> │
  52 + │ · 从描述/评论中提取图片 URL,下载并阅读 │
  53 + │ COOKIE=$(cat ~/.claude/tapd-cookie.txt) │
  54 + │ curl -s -L -H "Cookie: $COOKIE" \ │
  55 + │ -H "Referer: https://www.tapd.cn/" <URL> │
  56 + │ -o /tmp/req_img.png │
  57 + │ · 评论中若有关键信息(设计说明、补充要求等) │
  58 + │ 必须纳入分析,写入 dev-sessions 文档 │
  59 + └─────────────────────┬───────────────────────────────┘
  60 + │ 逐个取出
  61 + ┌─────────────────────▼───────────────────────────────┐
  62 + │ 2. 分析与规划 │
  63 + │ · 理解需求目标和验收标准 │
  64 + │ · 阅读相关代码,理解现有架构和模块 │
  65 + │ · 制定实现方案,必要时与开发者确认 │
  66 + └─────────────────────┬───────────────────────────────┘
  67 +
  68 + ┌─────────────────────────────────────────────────────┐
  69 + │ 3. 实现代码 │
  70 + │ · 每修改完一个文件,立刻运行 lint 和 type-check │
  71 + │ npm run lint && npm run type-check │
  72 + │ · 有错误自动修复,无需人工干预,修完再继续 │
  73 + │ · 遵循项目现有代码规范和架构模式 │
  74 + └─────────────────────┬───────────────────────────────┘
  75 +
  76 + ┌─────────────────────────────────────────────────────┐
  77 + │ 4. 单元测试(Vitest) │
  78 + │ · 针对 Store 逻辑、工具函数编写/更新测试 │
  79 + │ · npm run test:unit │
  80 + │ · 测试文件位于 src/test/**/*.test.ts │
  81 + └──────────┬──────────────────────────┬───────────────┘
  82 + ↓ 全部通过 ↓ 有失败
  83 + │ ┌───────────────────────┐
  84 + │ │ 定位失败原因 │
  85 + │ │ → 修复代码或测试用例 │
  86 + │ │ → 重新运行测试 │
  87 + │ └──────────┬────────────┘
  88 + │ ↑
  89 + │ └── 循环直到全部通过
  90 + ┌─────────────────────────────────────────────────────┐
  91 + │ 5. E2E 测试(Playwright) │
  92 + │ · 针对需要真实浏览器的交互场景编写测试 │
  93 + │ · 存放至 e2e/tests/{模块}/{需求ID}-描述.spec.ts │
  94 + │ · npm run test:e2e 自动运行验证 │
  95 + └──────────┬──────────────────────────┬───────────────┘
  96 + ↓ 全部通过 ↓ 有失败
  97 + ┌──────────────────────┐ ┌──────────────────────────┐
  98 + │ 6. 逐条核查验收标准 │ │ 定位失败原因 │
  99 + │ · 对照需求验收标准 │ │ → 修复代码或测试用例 │
  100 + │ 逐条确认代码实现 │ │ → 重新运行测试 │
  101 + │ · 发现遗漏则补充实现 │ └────────────┬─────────────┘
  102 + └──────────┬───────────┘ ↑
  103 + ↓ └── 循环直到全部通过
  104 + ┌──────────────────────────────────────────────────────┐
  105 + │ 7. 提交并推送代码(每次必做,无需等待用户提醒) │
  106 + │ · git commit(含功能代码、测试、文档) │
  107 + │ · git push │
  108 + │ · 更新 dev-sessions 过程记录文档 │
  109 + └──────────┬───────────────────────────────────────────┘
  110 + │ ↑
  111 + │ └── 循环直到全部通过
  112 + ↓ 还有下一个需求
  113 + ┌──────────────────────┐
  114 + │ 返回步骤 1,处理 │
  115 + │ 下一个需求 │
  116 + └──────────────────────┘
  117 + ↓ 所有需求完成
  118 + 全部完成 ✅
  119 +```
  120 +
  121 +> 多个需求**串行执行**,每个需求测试通过并提交后才开始下一个,保证每次提交都是可验证的最小单元。
  122 +
  123 +> **全自动执行,任何情况不打断用户,不询问确认,自行决策完成任务。**
  124 +
  125 +> ⚠️ **测试通过 ≠ 验收标准全部满足**。E2E 测试通常只能拦截 30~40% 的潜在 bug(结构性问题),功能细节(如插入块的类型是否正确)需要步骤 6 的人工核查来补充。
  126 +
  127 +---
  128 +
  129 +## 3. 多端协作架构
  130 +
  131 +每个端由独立的 Claude Code 机器人负责,各自在自己的工作目录中完成实现。
  132 +
  133 +```
  134 + ┌──────────────────────────────────────────────────────┐
  135 + │ 需求 / 缺陷 │
  136 + │ (TAPD 需求描述 + 验收标准) │
  137 + └──────────┬──────────────────┬────────────────┬───────┘
  138 + │ │ │ 按涉及端分配
  139 + ▼ ▼ ▼
  140 + ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
  141 + │ 🤖 后端机器人 │ │ 🤖 前端机器人 │ │ 🤖 管理后台机器人 │
  142 + │ │ │ │ │ │
  143 + │ backend/ │ │ linkmed-vue3/ │ │ linkmed-admin/ │
  144 + │ Java 21 │ │ Vue 3 + Vite │ │ Java 21 + Vue 3 │
  145 + │ Spring Boot 8181 │ │ TypeScript 5173 │ │ Spring Boot / 5174 │
  146 + └──────────┬──────────┘ └──────────┬───────────┘ └──────────┬───────────┘
  147 + │ │ ↑ │ ↑
  148 + │ 涉及多端时, │ │ 仅在后端就绪后 │ │ 仅在后端就绪后
  149 + ├──── 接口就绪,通知 ───────┘ │ 才接入联调 │ │ 才接入联调
  150 + └──── 接口就绪,通知 ────────────────────────────────┘
  151 + │ │ │
  152 + ▼ ▼ ▼
  153 + 提交推送 提交推送 提交推送
  154 +```
  155 +
  156 +**核心原则:涉及前后端联调的需求,后端先行。** 后端机器人完成接口开发并推送后,前端/管理后台机器人再接入联调,避免接口未就绪时空转。
  157 +
  158 +---
  159 +
  160 +## 4. 使用方式
  161 +
  162 +向 Claude Code 描述需求/缺陷时附带验证意图,Claude Code 会自动完成实现 + 测试:
  163 +
  164 +```
  165 +示例:"实现文件上传大小限制为 100MB,完成后帮我验证"
  166 +示例:"修复切换深度检索历史触发 cancel 请求的 bug,验证修复正确"
  167 +示例:"关闭 Dig Paper 知识库入口,确认页面上不再显示"
  168 +```
  169 +
  170 +也可以直接提供 TAPD 任务 ID,Claude Code 会通过脚本自动拉取详情(需求或缺陷均可):
  171 +
  172 +```
  173 +示例:"处理需求 1008229"
  174 +示例:"处理缺陷 1008906"
  175 +示例:"按顺序完成需求 1008251、1008252、1008253"
  176 +```
  177 +
  178 +---
  179 +
  180 +## 5. 从 TAPD 获取任务详情
  181 +
  182 +### 查看需求列表
  183 +
  184 +```bash
  185 +# LinkMed 项目最新需求(默认)
  186 +node ~/.claude/tapd.mjs stories
  187 +
  188 +# 关键词搜索
  189 +node ~/.claude/tapd.mjs stories 67139335 知识库
  190 +```
  191 +
  192 +输出示例:
  193 +```
  194 +Total: 614 | Showing: 20
  195 +[1008229] 知识库-对解析失败的文件新增重试按钮和功能
  196 + status=developing owner=张倩如;尹帮会; iteration=0.6.26.0(当前迭代)
  197 +[1008263] 【工作台】对话记录的优化
  198 + status=planning owner=尹帮会; iteration=0.6.27.0
  199 +```
  200 +
  201 +### 查看单个需求详情
  202 +
  203 +通过需求的 short_id(TAPD 列表中括号内的编号)获取完整信息:
  204 +
  205 +```bash
  206 +node ~/.claude/tapd.mjs story 1008229
  207 +```
  208 +
  209 +输出包含需求标题、描述、验收标准、状态、负责人等字段。
  210 +
  211 +### 查看缺陷(Bug)详情
  212 +
  213 +```bash
  214 +node ~/.claude/tapd.mjs bug 1008906
  215 +```
  216 +
  217 +输出包含缺陷标题、描述、重现步骤、严重程度、优先级、状态,以及**所有评论和回复**
  218 +
  219 +### 评论的重要性
  220 +
  221 +评论中常包含以下关键信息,**必须阅读**
  222 +- 产品/后端对实现方案的补充说明(如 TOS 路径、接口格式)
  223 +- 对原始描述的修正或追加要求
  224 +- 评论中的截图(格式同描述图片,需下载阅读)
  225 +- 关键约束(如「仅预览,不要支持修改!!!!」)
  226 +
  227 +脚本已自动输出评论内容,格式:
  228 +```
  229 +--- 评论(N 条)---
  230 +[时间] 作者:内容
  231 + ↳ [时间] 作者:回复内容(缩进表示回复)
  232 + [图片: URL](评论中的截图)
  233 +```
  234 +
  235 +### 在工作流中的用法
  236 +
  237 +告知 Claude Code 任务 ID 后,Claude Code 会自动判断类型并调用对应命令:
  238 +
  239 +```bash
  240 +# Claude Code 内部自动调用,无需手动执行
  241 +node ~/.claude/tapd.mjs story <需求ID> # 需求
  242 +node ~/.claude/tapd.mjs bug <缺陷ID> # 缺陷
  243 +```
  244 +
  245 +### 下载需求中的图片
  246 +
  247 +TAPD 需求描述中常含有设计图,格式为 `[图片: https://file.tapd.cn/...]`**必须下载并阅读**,不能仅凭文字描述实现。
  248 +
  249 +```bash
  250 +COOKIE=$(cat ~/.claude/tapd-cookie.txt)
  251 +curl -s -L \
  252 + -H "Cookie: $COOKIE" \
  253 + -H "Referer: https://www.tapd.cn/" \
  254 + -H "User-Agent: Mozilla/5.0" \
  255 + "https://file.tapd.cn//tfl/captures/..." \
  256 + -o /tmp/req_<需求ID>_1.png
  257 +# 然后用 Read 工具读取图片内容
  258 +```
  259 +
  260 +### Cookie 过期处理
  261 +
  262 +登录态通常可持续数周。若脚本报错或返回空数据,重新登录即可:
  263 +
  264 +```bash
  265 +node ~/.claude/tapd.mjs login your@linkingmed.com yourpassword
  266 +```
  267 +
  268 +### 常用项目 ID
  269 +
  270 +| 项目名 | workspace_id |
  271 +|--------|-------------|
  272 +| LinkMed | `67139335` |
  273 +| AI公共平台(AiPlan) | `21580481` |
  274 +| 科研项目管理 | `38189866` |
  275 +| 专病数据库 | `59607085` |
  276 +| RAIC.OIS信息管理系统 | `58951789` |
  277 +
  278 +完整列表通过 `node ~/.claude/tapd.mjs projects` 获取。
  279 +
  280 +---
  281 +
  282 +## 6. TAPD 脚本安装配置
  283 +
  284 +### 前置要求
  285 +
  286 +Node.js 18+(使用内置 `fetch` 和 `crypto`,无需安装任何 npm 包):
  287 +
  288 +```bash
  289 +node -v # 需要 v18.0.0 以上
  290 +```
  291 +
  292 +如果版本过低,可通过 [fnm](https://github.com/Schniz/fnm)[nvm](https://github.com/nvm-sh/nvm) 升级:
  293 +
  294 +```bash
  295 +# fnm
  296 +fnm install 20 && fnm use 20
  297 +
  298 +# nvm
  299 +nvm install 20 && nvm use 20
  300 +```
  301 +
  302 +### 安装脚本
  303 +
  304 +```bash
  305 +mkdir -p ~/.claude
  306 +nano ~/.claude/tapd.mjs # 或用 VS Code: code ~/.claude/tapd.mjs
  307 +```
  308 +
  309 +将以下内容保存为 `~/.claude/tapd.mjs`
  310 +
  311 +```js
  312 +#!/usr/bin/env node
  313 +import { readFileSync, writeFileSync, existsSync } from 'fs';
  314 +import { homedir } from 'os';
  315 +import { join } from 'path';
  316 +import { randomBytes, createCipheriv } from 'crypto';
  317 +
  318 +const COOKIE_FILE = join(homedir(), '.claude', 'tapd-cookie.txt');
  319 +const DEFAULT_WS = '67139335'; // LinkMed 项目
  320 +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
  321 +
  322 +const jar = {};
  323 +function loadCookie() {
  324 + if (existsSync(COOKIE_FILE)) {
  325 + for (const kv of readFileSync(COOKIE_FILE, 'utf8').trim().split('; ')) {
  326 + const [k, ...v] = kv.split('=');
  327 + if (k) jar[k.trim()] = v.join('=').trim();
  328 + }
  329 + }
  330 +}
  331 +function saveCookie() {
  332 + writeFileSync(COOKIE_FILE, Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '));
  333 +}
  334 +function getCookieStr() { return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '); }
  335 +function parseCookies(resp) {
  336 + for (const c of (resp.headers.getSetCookie?.() || [])) {
  337 + const eqIdx = c.indexOf('=');
  338 + const key = c.slice(0, eqIdx).trim();
  339 + const val = c.slice(eqIdx + 1).split(';')[0].trim();
  340 + if (val && val !== 'deleted') jar[key] = val;
  341 + else delete jar[key];
  342 + }
  343 +}
  344 +
  345 +async function req(url, opts = {}) {
  346 + const resp = await fetch(url, {
  347 + ...opts,
  348 + headers: { 'User-Agent': UA, 'Cookie': getCookieStr(), ...(opts.headers || {}) },
  349 + redirect: 'manual',
  350 + });
  351 + parseCookies(resp);
  352 + return resp;
  353 +}
  354 +async function get(path) {
  355 + const r = await req(`https://www.tapd.cn${path}`, {
  356 + headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*' }
  357 + });
  358 + return r.json();
  359 +}
  360 +async function post(path, body, wsId = DEFAULT_WS) {
  361 + const r = await req(`https://www.tapd.cn${path}`, {
  362 + method: 'POST',
  363 + headers: {
  364 + 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*',
  365 + 'Content-Type': 'application/json',
  366 + Referer: `https://www.tapd.cn/tapd_fe/${wsId}/prong/stories`,
  367 + Origin: 'https://www.tapd.cn',
  368 + },
  369 + body: JSON.stringify(body),
  370 + });
  371 + return r.json();
  372 +}
  373 +
  374 +function aesEncrypt(password) {
  375 + const key = randomBytes(32), iv = randomBytes(16);
  376 + const buf = Buffer.from(password, 'utf8');
  377 + const pad = 16 - (buf.length % 16);
  378 + const padded = pad === 16 ? buf : Buffer.concat([buf, Buffer.alloc(pad, 0)]);
  379 + const cipher = createCipheriv('aes-256-cbc', key, iv);
  380 + cipher.setAutoPadding(false);
  381 + const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
  382 + return { ciphertext: encrypted.toString('base64'), key: key.toString('base64'), iv: iv.toString('base64') };
  383 +}
  384 +
  385 +async function login(email, password) {
  386 + await req('https://www.tapd.cn/cloud_logins/login?site=TAPD&ref=https%3A%2F%2Fwww.tapd.cn%2F');
  387 + const enc = aesEncrypt(password);
  388 + const form = new URLSearchParams({
  389 + 'data[Login][email]': email, 'data[Login][password]': enc.ciphertext,
  390 + 'data[Login][encrypt_key]': enc.key, 'data[Login][encrypt_iv]': enc.iv,
  391 + 'data[Login][via]': 'encrypt_password', 'data[Login][type]': '2',
  392 + 'data[Login][ref]': 'https://www.tapd.cn/', 'data[Login][site]': 'TAPD',
  393 + 'data[Login][login]': 'login', 'data[protocol]': '1',
  394 + });
  395 + const p2 = await req('https://www.tapd.cn/cloud_logins/login', {
  396 + method: 'POST',
  397 + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://www.tapd.cn', Referer: 'https://www.tapd.cn/cloud_logins/login' },
  398 + body: form.toString(),
  399 + });
  400 + let location = p2.headers.get('location');
  401 + while (location) {
  402 + const r = await req(location);
  403 + location = r.headers.get('location');
  404 + }
  405 + saveCookie();
  406 + return !!jar['_wt'];
  407 +}
  408 +
  409 +const [,, cmd, arg1, arg2] = process.argv;
  410 +loadCookie();
  411 +
  412 +if (cmd === 'login') {
  413 + const ok = await login(arg1, arg2);
  414 + console.log(ok ? 'Login successful' : 'Login failed');
  415 +} else if (cmd === 'projects') {
  416 + const data = await get('/api/workspace/workspaces/get_all_my_projects');
  417 + const projects = data.data?.all_my_projects || [];
  418 + projects.forEach(p => console.log(`${p.id}\t${p.project_name}\t${p.status}`));
  419 +} else if (cmd === 'stories') {
  420 + const wsId = arg1 || DEFAULT_WS;
  421 + const keyword = arg2 || '';
  422 + const body = { workspace_ids: [wsId], page: 1, page_count: 20 };
  423 + if (keyword) body.search_data = { keyword };
  424 + const data = await post('/api/entity/stories/story_list_by_condition', body, wsId);
  425 + const list = data.data?.list || [];
  426 + console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
  427 + list.forEach(s => console.log(`[${s.short_id}] ${s.name}\n status=${s.status} owner=${s.owner} iteration=${s.iteration_name}`));
  428 +} else if (cmd === 'story') {
  429 + if (!arg1) { console.error('Usage: tapd.mjs story <story_id>'); process.exit(1); }
  430 + const data = await get(`/api/entity/stories/stories/get_info?workspace_id=${arg2 || DEFAULT_WS}&story_id=${arg1}`);
  431 + console.log(JSON.stringify(data, null, 2));
  432 +} else if (cmd === 'bugs') {
  433 + const data = await post('/api/entity/bugs/bug_list_by_condition', { workspace_ids: [arg1 || DEFAULT_WS], page: 1, page_count: 20 }, arg1 || DEFAULT_WS);
  434 + const list = data.data?.list || [];
  435 + console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
  436 + list.forEach(b => console.log(`[${b.id}] ${b.title}\n status=${b.status} owner=${b.owner}`));
  437 +} else {
  438 + console.log('Usage:');
  439 + console.log(' node ~/.claude/tapd.mjs login <email> <password>');
  440 + console.log(' node ~/.claude/tapd.mjs projects');
  441 + console.log(` node ~/.claude/tapd.mjs stories [workspace_id=${DEFAULT_WS}] [keyword]`);
  442 + console.log(' node ~/.claude/tapd.mjs story <story_id> [workspace_id]');
  443 + console.log(' node ~/.claude/tapd.mjs bugs [workspace_id]');
  444 +}
  445 +```
  446 +
  447 +### 首次登录
  448 +
  449 +```bash
  450 +node ~/.claude/tapd.mjs login your@linkingmed.com yourpassword
  451 +# 输出 "Login successful" 表示成功
  452 +
  453 +# 验证是否正常工作
  454 +node ~/.claude/tapd.mjs projects
  455 +```
  456 +
  457 +### 可选:设置别名
  458 +
  459 +```bash
  460 +echo "alias tapd='node ~/.claude/tapd.mjs'" >> ~/.zshrc
  461 +source ~/.zshrc
  462 +
  463 +# 之后可直接使用
  464 +tapd stories
  465 +tapd story 1008229
  466 +```
  467 +
  468 +### 技术实现说明
  469 +
  470 +TAPD 没有开放的公共 API,脚本通过逆向前端内部 API 实现:
  471 +
  472 +**登录流程:** TAPD 登录页用 **AES-256-CBC + ZeroPadding** 加密密码,key 和 iv 连同密文一起发给服务器。脚本用 Node.js 内置 `crypto` 复现,无需 CAPTCHA 即可完成登录。
  473 +
  474 +关键请求字段:
  475 +```
  476 +data[Login][email] 用户邮箱
  477 +data[Login][password] AES 加密后的密码(base64)
  478 +data[Login][encrypt_key] AES key(base64)
  479 +data[Login][encrypt_iv] AES IV(base64)
  480 +data[Login][via] encrypt_password
  481 +```
  482 +
  483 +**主要 API 接口:**
  484 +
  485 +| 接口 | 方法 | 说明 |
  486 +|------|------|------|
  487 +| `/api/workspace/workspaces/get_all_my_projects` | GET | 获取我的项目列表 |
  488 +| `/api/entity/stories/story_list_by_condition` | POST | 查询需求列表 |
  489 +| `/api/entity/stories/stories/get_info` | GET | 获取需求详情 |
  490 +| `/api/entity/bugs/bug_list_by_condition` | POST | 查询 Bug 列表 |
  491 +
  492 +所有接口均需携带登录 cookie 并设置 `X-Requested-With: XMLHttpRequest`
  493 +
  494 +**内部 ID 构造规则:**
  495 +
  496 +`story` 命令不依赖关键词搜索,而是直接构造内部 ID:
  497 +```
  498 +内部 ID = '11' + workspaceId + shortId.padStart(9, '0')
  499 +示例:'11' + '67139335' + '001008260' = '1167139335001008260'
  500 +```
  501 +
  502 +> 这些接口通过分析 TAPD 前端 JS bundle 发现,不在官方文档中,未来版本可能变更。
  503 +
  504 +---
  505 +
  506 +## 7. 测试体系
  507 +
  508 +本项目采用两层测试策略,分工明确:
  509 +
  510 +| 层级 | 工具 | 测什么 | 运行速度 | 命令 |
  511 +|------|------|--------|--------|------|
  512 +| 单元测试 | **Vitest** | Store 逻辑、工具函数、数据处理 | 毫秒级 | `npm run test:unit` |
  513 +| E2E 测试 | **Playwright** | 需要真实浏览器的交互流程 | 分钟级 | `npm run test:e2e` |
  514 +
  515 +**Vitest** — 把 API 替换为 mock,直接测 Store 内部的业务逻辑,不依赖浏览器和测试账号数据。适合测响应格式适配、状态变更、错误处理等纯逻辑。测试文件位于 `src/test/**/*.test.ts`
  516 +
  517 +**Playwright(E2E)** — 驱动真实浏览器执行完整用户操作,适合测纯 DOM 交互(如截图拖拽、编辑器焦点)。测试文件位于 `e2e/tests/`
  518 +
  519 +---
  520 +
  521 +## 8. 单元测试规范(Vitest)
  522 +
  523 +### 文件位置
  524 +
  525 +```
  526 +src/test/stores/ # Store 测试
  527 +src/test/utils/ # 工具函数测试
  528 +```
  529 +
  530 +### 适合写单元测试的场景
  531 +
  532 +- Store action 的成功/失败分支逻辑
  533 +- 响应数据的格式适配(多种格式兼容)
  534 +- 复杂的纯函数(如数学公式预处理)
  535 +- 状态变更后 computed 是否正确
  536 +
  537 +### 不适合写单元测试的场景
  538 +
  539 +- 需要真实浏览器渲染的 UI 交互
  540 +- 依赖第三方富文本编辑器(BlockSuite)的行为
  541 +
  542 +---
  543 +
  544 +## 9. E2E 测试规范(Playwright)
  545 +
  546 +### 文件命名
  547 +
  548 +```
  549 +e2e/tests/{模块}/{需求ID}-{简短描述}.spec.ts
  550 +```
  551 +
  552 +示例:
  553 +```
  554 +e2e/tests/welcome/1008878-cancel-api.spec.ts
  555 +e2e/tests/agent/1008229-file-upload-limit.spec.ts
  556 +```
  557 +
  558 +### 文件头部模板
  559 +
  560 +```typescript
  561 +/**
  562 + * 需求 ID:1008878
  563 + * 需求描述:切换深度检索历史不应触发 cancel 请求
  564 + *
  565 + * 验收标准:
  566 + * 1. 在 Welcome 页面点击历史记录,POST /cancel 不被调用
  567 + * 2. 主动点击终止按钮时,POST /cancel 正常调用
  568 + */
  569 +```
  570 +
  571 +### 断言强度要求
  572 +
  573 +E2E 测试断言应尽量验证**功能正确性**,而不只是**结构存在性**
  574 +
  575 +| 场景 | 弱断言(不推荐) | 强断言(推荐) |
  576 +|------|----------------|--------------|
  577 +| 插入块 | `afterCount > beforeCount` | 验证新块的 `data-block-flavour` / `data-level` 等属性 |
  578 +| 按钮点击 | `expect(btn).toBeVisible()` | 验证点击后产生的实际效果 |
  579 +| 表单提交 | 无错误即通过 | 验证提交后页面状态/数据变化 |
  580 +
  581 +### 运行前检查清单
  582 +
  583 +```bash
  584 +# 1. 确认 dev server 指向当前项目(pc1 用 5175,避免与 pc/ 的 5173 冲突)
  585 +lsof -i :5175 | grep node
  586 +# 若没有,启动:node node_modules/.bin/vite --port 5175 &
  587 +
  588 +# 2. 确认 playwright.config.ts 中 baseURL 与上面端口一致
  589 +grep baseURL playwright.config.ts
  590 +```
  591 +
  592 +### 工作台(Workspace)测试注意事项
  593 +
  594 +工作台使用**堆叠 tab 模式**,所有已打开文件的组件同时存在于 DOM,只用 CSS 控制显示:
  595 +
  596 +```typescript
  597 +// ❌ 错误:会匹配所有 tab 中的元素
  598 +page.locator(".icon-toolbar .icon-btn")
  599 +
  600 +// ✅ 正确:限定到当前激活的 tab
  601 +page.locator(".tab-content-layer.active .icon-toolbar .icon-btn")
  602 +```
  603 +
  604 +打开编辑器时应点击**已有文件**,不要用"新建文件"按钮(新建会触发 AI 文档生成面板,AffineEditor 不渲染):
  605 +
  606 +```typescript
  607 +const mdFile = page.locator(".node-content.is-file").filter({
  608 + has: page.locator(".node-name").filter({ hasText: /\.md$/i }),
  609 +}).first();
  610 +await mdFile.click();
  611 +await page.waitForSelector(".tab-content-layer.active .editor-toolbar", { timeout: 20000 });
  612 +```
  613 +
  614 +需要在编辑器中建立光标位置时,要点击 `rich-text` 元素并按 `End` 键(仅点击容器不能建立 BlockSuite TextSelection):
  615 +
  616 +```typescript
  617 +const richText = page.locator(".tab-content-layer.active rich-text, .tab-content-layer.active affine-paragraph").first();
  618 +await richText.click();
  619 +await page.keyboard.press("End");
  620 +```
  621 +
  622 +### 测试完成后的处理
  623 +
  624 +| 情况 | 处理方式 |
  625 +|------|---------|
  626 +| 核心逻辑,后续可能被改动 | 保留,防止回归 |
  627 +| 简单 UI 变更,一次性验证 | 验证后可删除 |
  628 +| 不稳定、依赖特定数据 | 加 `.skip` 或删除 |
  629 +
  630 +---
  631 +
  632 +## 10. 提交规范
  633 +
  634 +每次完成任务后,**不等用户提醒**,自动执行:
  635 +
  636 +```bash
  637 +# 1. 提交所有相关改动(功能代码 + E2E 测试 + 文档)
  638 +git add <相关文件>
  639 +git commit -m "feat/fix/test/docs: <需求ID>【模块】描述"
  640 +
  641 +# 2. 推送到远端
  642 +git push origin <当前分支>
  643 +
  644 +# 3. 更新 dev-sessions 过程记录(按具体日期分目录)
  645 +# claude-code/dev-sessions/{YYYY-MM-DD}/{需求ID}-{描述}.md
  646 +# 示例:claude-code/dev-sessions/2026-03-19/1008906-Safari登录报错.md
  647 +```
  648 +
  649 +**提交信息格式:**
  650 +
  651 +| 类型 | 前缀 | 示例 |
  652 +|------|------|------|
  653 +| 需求 | `feat:` | `feat: 1008265【工作台】编辑器新增格式化工具栏` |
  654 +| 缺陷修复 | `fix:` | `fix: 1008906【Safari兼容】修复旧版Safari命名捕获组报错` |
  655 +| 测试 | `test:` | `test: 1008265 E2E测试全部通过(15/15)` |
  656 +| 文档 | `docs:` | `docs: 新增 1008906 缺陷执行记录` |
  657 +
  658 +---
  659 +
  660 +## 11. dev-sessions 文档规范
  661 +
  662 +每个任务(需求或缺陷)执行完成后,需在 `claude-code/dev-sessions/{YYYY-MM-DD}/{ID}-{简短描述}.md` 创建或更新过程记录。
  663 +
  664 +**具体日期**分目录管理(如 `2026-03-19/`),避免单目录文件过多。
  665 +
  666 +### 需求文档结构
  667 +
  668 +```
  669 +# 需求 {ID} - {标题}
  670 +
  671 +## 原始需求(一字不差)
  672 + - 需求标题、状态、负责人、优先级、创建者、时间
  673 + - 原始描述:直接引用 TAPD 中的原文,包含图片 URL
  674 +
  675 +## 图片理解
  676 + - 每张图片单独一节,注明尺寸
  677 + - 画面内容:客观描述看到了什么
  678 + - 关键细节:对实现有指导意义的 UI 细节
  679 + - 悬停/交互行为(需推断):图片未直接展示但可推断的行为
  680 +
  681 +## 评论(若有)
  682 + - 每条评论的作者、时间、内容
  683 + - 评论中的图片需下载阅读,记录关键信息
  684 + - 对实现有约束或补充的评论要重点标注
  685 +
  686 +## 实现方案
  687 + - 数据流图(用代码块 ASCII 表示)
  688 + - 各修改文件的具体改动点
  689 +
  690 +## 修改的文件
  691 + - 文件路径列表
  692 +
  693 +## 测试结果
  694 + - type-check / lint 结果
  695 +
  696 +## 测试覆盖
  697 + - 单元测试(Vitest):测试文件、用例数、覆盖的测试组
  698 + - E2E 测试(Playwright):测试文件、主要用例
  699 +```
  700 +
  701 +### 缺陷(Bug)文档结构
  702 +
  703 +缺陷记录侧重根因分析和修复,而非功能实现:
  704 +
  705 +```
  706 +# 缺陷 {ID} - {标题}
  707 +
  708 +## 原始需求(一字不差)
  709 + - 缺陷标题、状态、负责人、严重程度、优先级、创建者、时间
  710 + - 原始描述:直接引用 TAPD 中的原文,包含图片 URL(控制台截图等)
  711 + - 重现步骤:原文引用
  712 +
  713 +## 图片理解
  714 + - 每张截图单独一节,注明尺寸
  715 + - 画面内容:控制台报错信息、调用栈、错误来源等关键信息
  716 + - 关键细节:对定位问题有指导意义的信息(文件名、行号、错误类型)
  717 +
  718 +## 评论(若有)
  719 + - 每条评论的作者、时间、内容
  720 + - 评论中的图片需下载阅读,记录关键信息
  721 + - 对修复方案有约束的评论要重点标注(如「仅预览,不要支持修改」)
  722 +
  723 +## 根因分析
  724 + - 错误类型及含义
  725 + - 为什么会产生这个错误(配置/依赖/版本问题等)
  726 + - 涉及文件(配置文件、出错 chunk、依赖包等)
  727 +
  728 +## 修复方案
  729 + - 修复思路(修改什么、为什么这样修)
  730 + - 修改的文件列表及具体改动说明
  731 +
  732 +## 测试结果
  733 + - type-check / lint 结果
  734 + - 验收条件(修复后如何验证)
  735 +```
  736 +
  737 +### 图片理解要求
  738 +
  739 +**必须**
  740 +- 下载 TAPD 图片并用 Read 工具阅读(不能只凭图片 URL 推测)
  741 +- 记录每张图的像素尺寸
  742 +- 区分「图片明确展示」和「需推断」的内容
  743 +
  744 +**图片下载方式**
  745 +```bash
  746 +COOKIE=$(cat ~/.claude/tapd-cookie.txt)
  747 +curl -s -L \
  748 + -H "Cookie: $COOKIE" \
  749 + -H "Referer: https://www.tapd.cn/" \
  750 + -H "User-Agent: Mozilla/5.0" \
  751 + "<TAPD图片URL>" -o /tmp/req_<需求ID>_<序号>.png
  752 +# 然后用 Read 工具读取图片
  753 +```
  754 +
  755 +### 范例文档
  756 +
  757 +参考 `claude-code/workflow/dev-sessions-范例-1008257.md`——该需求包含两张设计图,分别展示不同交互阶段,是目前图片理解最完整的范例:
  758 +
  759 +- **图片1**:PDF 文本选中后弹出菜单(展示「Quote in Chat」入口,菜单样式、图标、定位方式)
  760 +- **图片2**:引用标签出现在对话输入框上方(展示「PDF Quote ×」chip 样式 + tooltip 行为)
  761 +- 每张图单独分析,明确标注「需推断」的部分(如 tooltip 的触发行为)
  762 +- 图片细节直接指导实现(chip 的颜色、按钮标签文字、删除方式)
  763 +
  764 +---
  765 +
  766 +## 12. 后续规划
  767 +
  768 +- [x] 接入 TAPD,自动读取需求描述
  769 +- [ ] 接入 GitLab CI,push 时自动触发测试
  770 +- [ ] 测试通过后自动提交并创建 MR
  1 +# 需求 1008257 - PDF 浏览器引用文本到对话框上下文
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】PDF的浏览器-引用文本到对话框的上下文
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:31:11 | **修改时间**:2026-03-13 21:01:56
  10 +
  11 +**原始描述**
  12 +> 在PDF文件中选择文本,出现菜单,选择"引用文本到对话框":
  13 +>
  14 +> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210470_797.png]
  15 +>
  16 +> 对话框中会出现对PDF文件的引用,鼠标悬停的时候,还可以看到引用的文本内容:
  17 +>
  18 +> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210606_307.png]
  19 +>
  20 +> 然后进行提问,会得到针对性的结果
  21 +
  22 +**AI Plan(TAPD 自动生成,仅供参考)**
  23 +```
  24 +### 需求摘要
  25 +在 PDF 文件中选择文本,出现菜单,选择"引用文本到对话框":对话框中会出现对 PDF 文件的引用,
  26 +鼠标悬停时可以看到引用的文本内容,然后进行提问,会得到针对性的结果。
  27 +
  28 +### 改动文件清单
  29 +1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加文本选择和菜单功能
  30 +2. linkmed-vue3/src/components/AgentPanel/AgentChatAgent.vue:添加引用显示和悬停效果
  31 +3. linkmed-vue3/src/api/chat.ts:添加引用相关的接口
  32 +4. linkmed-vue3/src/stores/chatApi.ts:添加引用相关的状态
  33 +
  34 +### 验收用例
  35 +1. 文本选择和菜单:在 PDF 中选择文本,出现菜单
  36 +2. 引用到对话框:选择"引用文本到对话框",对话框中出现引用
  37 +3. 悬停显示:鼠标悬停在引用上时显示引用的文本内容
  38 +4. 提问和回复:提问后得到针对性的结果
  39 +
  40 +复杂度:Medium
  41 +```
  42 +
  43 +---
  44 +
  45 +## 图片理解
  46 +
  47 +### 图片1(1854 × 1602 像素)- PDF 文本选中菜单
  48 +
  49 +**画面内容**
  50 +- 左侧:PDF 查看器,显示一篇物理医学期刊论文(Physics in Medicine & Biology)
  51 +- 用户在 PDF 中选中了一段英文文本(蓝色高亮区域)
  52 +- 选中文本后弹出浮动菜单,菜单包含两个按钮:
  53 + 1. **"Copy and Cite"**(复制并引用)
  54 + 2. **"Quote in Chat"**(引用到对话)—— 这就是目标功能的入口
  55 +- 右侧:AI Chat 面板,显示正在进行的对话
  56 +
  57 +**关键细节**
  58 +- 菜单出现在选中文本附近(浮动定位)
  59 +- 菜单样式为白色背景卡片,带有圆形图标
  60 +- "Quote in Chat" 使用引号图标(💬)
  61 +
  62 +### 图片2(1854 × 1602 像素)- 对话框中的引用标签
  63 +
  64 +**画面内容**
  65 +- 左侧:同一 PDF 查看器(同一篇论文)
  66 +- 右侧 AI Chat 面板底部输入区域,显示引用成功加入上下文后的状态:
  67 + - 输入框上方出现了一个**引用标签/芯片(chip)**:显示 `📄 PDF Quote ×`
  68 + - 该标签代表已引用的 PDF 文本片段
  69 + - 右上角有 `×` 按钮可以移除引用
  70 +- 对话列表中还可见 **"1 source"** 标签(表明已有来源引用)
  71 +
  72 +**悬停行为**(需推断):
  73 +- 鼠标悬停在 `PDF Quote ×` 标签上时,应弹出 tooltip 显示引用的原始文本内容
  74 +
  75 +**关键细节**
  76 +- 引用标签样式:小型 chip,带文件类型图标 + "PDF Quote" 文字 + `×` 删除按钮
  77 +- 标签位于聊天输入框上方(与截图功能的缩略图区域并列)
  78 +- 标签颜色为浅灰/白色背景
  79 +
  80 +---
  81 +
  82 +## 实现方案
  83 +
  84 +### 数据流
  85 +```
  86 +PDFViewerIframe mouseup → 显示浮动菜单
  87 + → 点击「Quote in Chat」
  88 + → chatApiStore.addPendingTextReference(text, fileName, fileId)
  89 + → AgentChat.vue 渲染引用标签(.text-references chip)
  90 + → 鼠标悬停显示原始引用文本(el-tooltip)
  91 + → 用户发送时,引用文本格式化为 Markdown 引用块前缀拼入问题
  92 + → 发送后 clearPendingTextReferences()
  93 +```
  94 +
  95 +### 1. chatApi.ts(store)
  96 +- 新增 `PendingTextReference` 接口(text, fileName, fileId)
  97 +-`ChatApiState` 添加 `pendingTextReferences: PendingTextReference[]`
  98 +- 新增 actions:`addPendingTextReference`、`removePendingTextReference`、`clearPendingTextReferences`
  99 +
  100 +### 2. PDFViewerIframe.vue
  101 +- 引入 `useWorkspaceChatStore`
  102 +- 新增 `showTextMenu`、`textMenuPos`、`selectedTextContent` 状态
  103 +- `handleTextMouseUp`:mouseup 时读取 window.getSelection(),计算菜单位置
  104 +- `handleGlobalMouseDown`:点击菜单外关闭菜单
  105 +- `quoteTextToChat`:调用 store 添加引用,显示成功 ElMessage
  106 +- template 中添加 `.pdf-text-menu` 浮动菜单(含「Copy and Cite」和「Quote in Chat」按钮)
  107 +
  108 +### 3. AgentChat.vue
  109 +- template 中在输入框上方添加 `.text-references` 文本引用 chip 区域
  110 +- 每个 chip 显示:📄 PDF Quote + 删除 `×` 按钮 + el-tooltip 悬停显示原文
  111 +- `canSend` computed 增加 `pendingTextReferences.length > 0` 条件
  112 +- `handleSend` 中将文本引用格式化为 Markdown 引用块前缀,拼入最终问题
  113 +- 发送后调用 `chatApiStore.clearPendingTextReferences()`
  114 +
  115 +---
  116 +
  117 +## 修改的文件
  118 +- `src/stores/chatApi.ts`
  119 +- `src/components/Workspace/PDFViewerIframe.vue`
  120 +- `src/components/AgentPanel/AgentChat.vue`
  121 +
  122 +## 测试结果
  123 +- `npm run type-check` 通过,无 TypeScript 错误
  124 +
  125 +## 测试覆盖
  126 +
  127 +### 单元测试(vitest)
  128 +
  129 +**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 部分,28 个用例)
  130 +
  131 +| 测试组 | 用例 |
  132 +|------|------|
  133 +| pendingTextReferences 初始状态 | 初始为空数组 |
  134 +| addPendingTextReference | 添加单条;连续添加多条;保存数字/字符串 fileId |
  135 +| removePendingTextReference | 按索引删除;删除第一条;删除最后一条 |
  136 +| clearPendingTextReferences | 清空所有;对空数组操作不报错 |
  137 +| 互不干扰 | 清空文本引用不影响截图;删除截图不影响文本引用 |
  138 +
  139 +**文件2:`src/test/features/text-reference-format.test.ts`**(格式化逻辑部分,7 个用例)
  140 +
  141 +| 测试组 | 用例 |
  142 +|------|------|
  143 +| buildFinalQuestion | 无引用返回原始问题;有1条引用格式正确;多条引用用空行分隔;有引用无问题返回纯引用;文件名包在《》中;引用文本以>开头;保留特殊字符 |
  144 +
  145 +### E2E 测试(Playwright)
  146 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008257 相关 3 个用例)
  147 +- 工作台存在 PDF 查看器组件挂载点(.pdf-viewer-js 等)
  148 +- PDF 查看器工具栏存在功能按钮(.pdf-toolbar)
  149 +- AgentChat 文本引用区域初始状态正确隐藏(无引用时 .text-references 不渲染)
  1 +# 需求 1008258 - PDF 浏览器截图引用到对话框上下文
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】PDF的浏览器-截图引用到对话框的上下文
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:33:37 | **修改时间**:2026-03-13 20:07:40
  10 +
  11 +**原始描述**
  12 +> 1.点击按钮,开启截图功能
  13 +>
  14 +> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210728_165.png]
  15 +>
  16 +> 2.截完图之后,会把图片加入到对话框的上下文
  17 +>
  18 +> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210785_770.png]
  19 +
  20 +**AI Plan(TAPD 自动生成,仅供参考)**
  21 +```
  22 +### 需求摘要
  23 +在工作台的PDF浏览器中添加截图功能,截图后将图片加入到对话框的上下文。
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加截图按钮和截图功能
  27 +2. linkmed-vue3/src/components/Workspace/WorkspaceChat.vue:添加处理截图的逻辑
  28 +3. linkmed-vue3/src/stores/chatApi.ts:添加状态管理逻辑
  29 +4. linkmed-vue3/src/api/files.ts:可能需要添加文件上传相关接口
  30 +
  31 +### 验收用例
  32 +1. 截图功能:点击截图按钮可以开启截图功能
  33 +2. 图片添加到对话框:截图后图片会加入到对话框的上下文
  34 +3. 图片显示:对话框中可以正常显示截图图片
  35 +
  36 +复杂度:Medium
  37 +```
  38 +
  39 +---
  40 +
  41 +## 图片理解
  42 +
  43 +### 图片1(1860 × 1598 像素)- 截图按钮激活状态
  44 +
  45 +**画面内容**
  46 +- 左侧:PDF 查看器,显示物理医学期刊论文
  47 +- **红色方框标注**:PDF 查看器顶部工具栏右侧,有一个被红框圈出的区域,该区域显示一个 `×`(关闭/取消)图标
  48 +- 这说明:截图模式**已处于激活状态**,工具栏上显示的是「取消截图」的 `×` 按钮,而非激活前的相机图标
  49 +- 右侧:AI Chat 面板正常显示
  50 +
  51 +**关键理解**
  52 +- 截图模式激活后,工具栏上的截图按钮变为 `×`(取消截图)
  53 +- 截图激活时,整个 PDF 区域上方会叠加遮罩层,支持拖拽选区
  54 +
  55 +### 图片2(1856 × 1604 像素)- 截图加入对话框上下文
  56 +
  57 +**画面内容**
  58 +- 左侧:同一 PDF 查看器
  59 +- 右侧底部:AI Chat 输入区域
  60 +- **红色箭头标注**:输入框区域下方偏右位置,可以看到一个小图片缩略图(截图预览)
  61 +- 该截图缩略图出现在聊天输入框的上下文附件区域,说明截图已成功加入对话上下文
  62 +
  63 +**关键细节**
  64 +- 截图缩略图位于输入框上方的附件预览区
  65 +- 缩略图较小(约 80×60px),可能带有删除按钮
  66 +- 截图作为图片附件被加入到本次提问的上下文中
  67 +
  68 +---
  69 +
  70 +## 实现方案
  71 +
  72 +### 数据流
  73 +```
  74 +PDF 工具栏截图按钮(相机图标)点击
  75 + → toggleScreenshotMode() 激活,按钮变为 ×
  76 + → PDF 区域叠加透明遮罩层 .screenshot-overlay
  77 + → 用户 mousedown → 拖拽 → mouseup 形成选区
  78 + → captureScreenshotRegion():遍历 PDF canvas,合成选区截图(处理 devicePixelRatio)
  79 + → chatApiStore.addPendingScreenshot(base64DataURL)
  80 + → AgentChat.vue .screenshot-previews 显示缩略图
  81 + → 用户发送时,msg.screenshots 传给 AgentPanelBody
  82 + → 上传文件获取 fileId → 加入 context
  83 + → clearPendingScreenshots()
  84 +```
  85 +
  86 +### 1. chatApi.ts(store)
  87 +- 新增 `PendingScreenshot` 类型(base64 data URL 字符串)
  88 +-`ChatApiState` 添加 `pendingScreenshots: PendingScreenshot[]`
  89 +- 新增 actions:`addPendingScreenshot`、`removePendingScreenshot`、`clearPendingScreenshots`
  90 +
  91 +### 2. PDFViewerIframe.vue
  92 +- 工具栏新增截图按钮(相机图标 `fa-camera`),激活后变为 `×` 取消按钮
  93 +- 激活时在 PDF 查看器上方叠加全屏透明遮罩 `.screenshot-overlay`(cursor: crosshair)
  94 +- 鼠标 mousedown/mousemove/mouseup 事件实现拖拽选区(半透明蓝色选框)
  95 +- `captureScreenshotRegion`:遍历所有页面的 canvas,根据选区坐标合成截图(处理 devicePixelRatio)
  96 +- Esc 键监听取消截图模式
  97 +- 截图完成后自动关闭截图模式,调用 `chatApiStore.addPendingScreenshot`,显示成功提示
  98 +
  99 +### 3. AgentChat.vue
  100 +- 在输入框上方添加 `.screenshot-previews` 截图缩略图区域
  101 +- 每个缩略图:80×60px 图片预览 + 右上角 × 删除按钮
  102 +- `canSend` computed 增加 `pendingScreenshots.length > 0` 条件(有截图时也可发送)
  103 +- `handleSend` 中将截图数组通过 `msg.screenshots` 传给父组件,发送后调用 `clearPendingScreenshots`
  104 +
  105 +### 4. AgentPanelBody.vue
  106 +- 导入 `createUploadSession`、`uploadBatch` from `@/api/files`
  107 +- `handleSendAsk`:解析 `msg.screenshots`,将 base64 转为 File 对象,通过 `createUploadSession` + `uploadBatch` 上传,将得到的 fileId 加入 `contextArray`
  108 +- `handleSendAgent`:同上,将截图 fileId 加入 `newContextFileIds`
  109 +
  110 +---
  111 +
  112 +## 修改的文件
  113 +- `src/stores/chatApi.ts`
  114 +- `src/components/Workspace/PDFViewerIframe.vue`
  115 +- `src/components/AgentPanel/AgentChat.vue`
  116 +- `src/components/AgentPanel/AgentPanelBody.vue`
  117 +
  118 +## 测试结果
  119 +- `npm run type-check` 通过,无 TypeScript 错误
  120 +
  121 +## 测试覆盖
  122 +
  123 +### 单元测试(vitest)
  124 +
  125 +**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 截图部分,18 个相关用例)
  126 +
  127 +| 测试组 | 用例 |
  128 +|------|------|
  129 +| pendingScreenshots 初始状态 | 初始为空数组 |
  130 +| addPendingScreenshot | 添加单张截图;连续添加多张;base64 原样保存不处理 |
  131 +| removePendingScreenshot | 按索引删除;删除第一张;删除最后一张 |
  132 +| clearPendingScreenshots | 清空所有;对空数组操作不报错 |
  133 +| 互不干扰 | 清空截图不影响文本引用;清空文本引用不影响截图 |
  134 +
  135 +**文件2:`src/test/features/text-reference-format.test.ts`**(截图 base64 转 File 逻辑,7 个用例)
  136 +
  137 +| 测试组 | 用例 |
  138 +|------|------|
  139 +| base64ToFile | 转换为 File 实例;MIME 类型正确;文件名含 screenshot;文件名含 index;无 MIME 默认 image/png;文件大小>0;多张截图文件名不同 |
  140 +
  141 +### E2E 测试(Playwright)
  142 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008258 相关 3 个用例)
  143 +- PDF 查看器工具栏存在截图按钮(.screenshot-btn 或 fa-camera)
  144 +- AgentChat 截图预览区域初始状态正确隐藏(无截图时 .screenshot-previews 不渲染)
  145 +- 对话框输入区域整体结构完整(.chat-input-container 存在)
  1 +# 需求 1008260 - 对话框添加到文章中
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】对话框,添加到文章中
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:High | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:41:03 | **修改时间**:2026-03-12 17:32:34
  10 +
  11 +**原始描述**
  12 +> 点击按钮之后,把对话上面的内容,添加到当前编辑器打开的Markdown文件的光标处。
  13 +>
  14 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773211208_459.png]
  15 +
  16 +**AI Plan(TAPD 自动生成,仅供参考)**
  17 +```
  18 +### 需求摘要
  19 +在 LinkMed 工作台,点击对话框中的「添加到文章」按钮,将当前对话上方的指定内容插入到当前已打开 Markdown 编辑器的光标位置。
  20 +
  21 +### 涉及模块
  22 +- linkmed-vue3/workspace
  23 +- linkmed-vue3/shared
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/pages/Workspace.vue
  27 +
  28 +### 需要新增/修改的接口
  29 +无(不涉及后端接口调用)
  30 +
  31 +### 数据库变更(Flyway)
  32 +
  33 +
  34 +### 开发步骤
  35 +1. 确认当前 Workspace.vue 中是否存在对话框组件、Markdown 编辑器组件及其对外暴露的 insertContentAtCursor 方法
  36 +2. 在对话框组件中新增「添加到文章」按钮
  37 +3. 实现点击按钮时获取当前对话上方指定内容的逻辑
  38 +4. 调用 Markdown 编辑器或状态管理的插入内容方法,将获取到的对话内容插入到光标处
  39 +
  40 +### 验收用例
  41 +1. 当未打开任何 Markdown 文件时,点击「添加到文章」按钮,不执行插入且有友好提示
  42 +2. 当打开 Markdown 文件但光标未定位时,点击「添加到文章」按钮,内容插入到文件末尾
  43 +3. 当打开 Markdown 文件且光标定位在中间时,点击「添加到文章」按钮,内容准确插入到光标位置
  44 +4. 插入的内容格式需符合 Markdown 规范(保留换行、标题等原始格式)
  45 +
  46 +### 风险与依赖
  47 +- 依赖现有对话框组件能正确暴露或获取当前对话内容的能力
  48 +- 依赖现有 Markdown 编辑器组件或状态管理能提供稳定的 insertContentAtCursor 方法
  49 +
  50 +复杂度:Low
  51 +```
  52 +
  53 +---
  54 +
  55 +## 图片理解
  56 +
  57 +**设计图尺寸**:2016 × 1604 像素(完整页面截图)
  58 +
  59 +**图片内容描述**
  60 +
  61 +整体布局是工作台双栏视图:
  62 +- **左侧**:文档编辑区域,显示正在编辑的医学文档(放疗相关内容,包含公式和分段文字)
  63 +- **右侧**:AI Chat 对话面板,展示 AI 生成的回答内容
  64 +
  65 +**关键标注**:红色箭头指向左侧文档区域底部的输入框旁边,注释文字写道:「增加一个按钮,'添加到文章中'」
  66 +
  67 +**按钮位置**:图中红色箭头明确指向**对话框底部操作区**(AI 回答气泡下方的操作按钮栏),说明「添加到文章」按钮应该紧挨着 AI 回答气泡,在每条 AI 回复下方的操作行中显示。
  68 +
  69 +**设计意图**:用户在工作台看到 AI 生成的内容后,可以一键将该条 AI 回答插入到当前正在编辑的文档中的光标位置。
  70 +
  71 +---
  72 +
  73 +## 实现方案
  74 +
  75 +### 功能描述
  76 +在每条 AI 回答气泡下方的操作区,新增「添加到文章」按钮。点击后将该条回答的 Markdown 内容插入到当前激活编辑器的光标位置。
  77 +
  78 +### 调用链路
  79 +```
  80 +AgentPanelBody.vue(按钮点击)
  81 + → emit('add-to-article', content)
  82 + → IntelligencePanel.vue(透传)
  83 + → emit('add-to-article', content)
  84 + → Workspace.vue(处理)
  85 + → activeEditorRef.insertTextAtCursor(content)
  86 +```
  87 +
  88 +### 实现细节
  89 +1. **AffineEditor.vue**:在 `defineExpose` 中暴露 `insertTextAtCursor` 方法(原已有,只新增暴露)
  90 +2. **AgentPanelBody.vue**
  91 + - 新增 `add-to-article` emit 事件
  92 + - ask 模式和 agent 模式的 AI 回答操作区都新增「添加到文章」按钮
  93 + - 添加 `handleAddToArticle` 处理函数
  94 +3. **IntelligencePanel.vue**:新增 `add-to-article` emit 并透传
  95 +4. **Workspace.vue**:实现 `handleAddToArticle`,检查当前 tab 类型,调用编辑器插入
  96 +
  97 +### 图标
  98 +创建 `public/tianjia.svg` - 文档加号图标(SVG 格式,带加号的文档图标)
  99 +
  100 +---
  101 +
  102 +## 修改的文件
  103 +- `src/components/Workspace/AffineEditor.vue`:暴露 insertTextAtCursor
  104 +- `src/components/AgentPanel/AgentPanelBody.vue`:添加按钮和事件
  105 +- `src/layout/IntelligencePanel.vue`:透传事件
  106 +- `src/pages/Workspace.vue`:监听事件并调用编辑器
  107 +- `public/tianjia.svg`:新增图标文件
  108 +
  109 +## 测试结果
  110 +- `npm run type-check` 通过,无 TypeScript 错误
  111 +
  112 +## 测试覆盖
  113 +
  114 +### 单元测试(vitest)
  115 +文件:`src/test/features/text-reference-format.test.ts`(1008260 相关部分)
  116 +
  117 +| 测试组 | 用例 |
  118 +|------|------|
  119 +| 添加到文章内容校验 | 有内容通过;空字符串失败并提示;纯空白失败;Markdown 格式内容通过;前后空白 trim 后有内容则通过 |
  120 +
  121 +### E2E 测试(Playwright)
  122 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008260 相关 3 个用例)
  123 +- 工作台右侧对话区存在 AI 回答操作栏
  124 +- 对话区 AI 回答操作区包含添加到文章按钮(tianjia.svg 图标)
  125 +- AgentChat 输入区存在文本引用或截图预览区域挂载点
  1 +# 需求 1008265 - 编辑器的图标框
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】编辑器的图标框
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 16:13:58 | **修改时间**:2026-03-12 10:42:40
  10 +
  11 +**原始描述**
  12 +> 类似腾讯文档,常用的放在最上面的图标
  13 +>
  14 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773216836_934.png]
  15 +
  16 +**AI Plan(TAPD 自动生成,仅供参考)**
  17 +```
  18 +### 需求摘要
  19 +在工作台编辑器中添加类似腾讯文档的图标框功能,将常用图标放在最上面,方便用户快速访问和使用。
  20 +
  21 +### 涉及模块
  22 +- linkmed-vue3/workspace
  23 +- linkmed-vue3/shared
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/components/Workspace/AffineEditor.vue
  27 +2. linkmed-vue3/src/pages/Workspace.vue
  28 +
  29 +### 需要新增/修改的接口
  30 +
  31 +
  32 +### 数据库变更(Flyway)
  33 +
  34 +
  35 +### 开发步骤
  36 +1. 分析 AffineEditor.vue 组件的现有结构,找到编辑器工具栏的实现位置
  37 +2. 设计图标框组件的布局,确保常用图标放在最上面
  38 +3. 在 AffineEditor.vue 中实现图标框组件
  39 +4. 根据腾讯文档的风格调整图标框的样式和交互
  40 +5. 在 Workspace.vue 中集成并测试图标框功能
  41 +6. 进行整体功能测试和样式优化
  42 +
  43 +### 验收用例
  44 +1. 验证编辑器中是否显示图标框
  45 +2. 验证常用图标是否显示在图标框的最上面
  46 +3. 验证图标框的交互功能是否正常(点击、悬停等)
  47 +4. 验证图标框的样式是否符合设计要求
  48 +5. 验证图标框在不同屏幕尺寸下的显示效果
  49 +
  50 +### 风险与依赖
  51 +- 依赖 AffineEditor 组件的现有结构和功能
  52 +- 风险:如果 BlockSuite 编辑器的工具栏实现方式与预期不同,可能需要调整实现方案
  53 +
  54 +复杂度:Low
  55 +```
  56 +
  57 +---
  58 +
  59 +## 图片理解
  60 +
  61 +**设计图尺寸**:569 × 51 像素(水平条形工具栏截图)
  62 +
  63 +**图片内容**(从左到右逐个图标):
  64 +
  65 +| 序号 | 图标 | 含义 |
  66 +|------|------|------|
  67 +| 1 | ✨ 问问AI | AI 辅助写作入口(带文字标签,蓝色星星图标) |
  68 +| — | 竖线分隔 | — |
  69 +| 2 | ≡▾ | 段落/列表样式下拉菜单 |
  70 +| 3 | **B** | 加粗 (Bold) |
  71 +| 4 | `<>` | 代码块 (Code) |
  72 +| 5 | _I_ | 斜体 (Italic) |
  73 +| 6 | ⊘ | 插入超链接 (Link) |
  74 +| 7 | S̶ | 删除线 (Strikethrough) |
  75 +| 8 | U̲ | 下划线 (Underline) |
  76 +| 9 | ✏▾ | 高亮/文字颜色下拉菜单 |
  77 +| 10 | ⊞ | 插入表格 |
  78 +| 11 | ··· | 更多选项 |
  79 +
  80 +**设计意图**:这是一条**固定常驻的快捷格式化工具栏**(Quick Format Toolbar),位于编辑器顶部,始终可见,不依赖文本选中状态。类似腾讯文档、Google Docs 顶部的格式工具栏,让用户无需选中文字就能快速访问常用格式操作。
  81 +
  82 +---
  83 +
  84 +## ⚠️ 第一版实现偏差(已修正)
  85 +
  86 +**第一版错误实现**
  87 +Agent 将「图标框」误解为**文档 emoji 图标**——在文档标题上方添加了 emoji 选取区域(`.doc-icon-area`),让用户给文档选一个装饰性 emoji,存储在 localStorage。
  88 +
  89 +**正确理解**
  90 +「图标框」= **格式化快捷工具栏**(Formatting Toolbar),需要常驻在编辑器顶部,提供快速文本格式化入口,而不是文档 emoji 装饰。
  91 +
  92 +---
  93 +
  94 +## 实现方案(修正后)
  95 +
  96 +### 功能描述
  97 +`AffineEditor.vue` 的 `editor-toolbar`(顶部元信息栏)下方、BlockSuite 编辑区域上方,新增一行常驻格式化工具栏 `.icon-toolbar`
  98 +- **始终可见**,不依赖文本选中
  99 +- 提供常用格式化快捷图标:AI、段落样式、加粗、代码、斜体、链接、删除线、下划线、高亮、表格、更多
  100 +- 点击格式化按钮,通过 BlockSuite 内部命令 API 对当前选区或光标处应用格式
  101 +
  102 +### 技术实现
  103 +通过 `editorHost` 获取 BlockSuite 的 `std.command` 执行格式化命令:
  104 +- `toggleBold` / `toggleItalic` / `toggleUnderline` / `toggleStrike` / `toggleCode`:文本内联格式
  105 +- 链接:通过 `toggleLink` 命令或手动触发 link popup
  106 +- 表格:通过 `insertTable` command 或 AffineEditor 的 `insertContent` 方法
  107 +
  108 +### 「问问AI」按钮
  109 +调用现有 AI 辅助写作入口(与现有 AI 面板联动)
  110 +
  111 +## 修改的文件
  112 +- `src/components/Workspace/AffineEditor.vue`
  113 + - 删除:emoji 图标选择器(doc-icon-area、emoji-picker 及所有相关代码)
  114 + - 新增:`.icon-toolbar` 格式化工具栏(template + style)
  115 + - 新增:各格式化操作的处理函数
  116 +
  117 +## 测试结果
  118 +- `npm run type-check` 通过,无 TypeScript 错误
  119 +
  120 +## 测试覆盖
  121 +
  122 +### 单元测试(vitest)
  123 +- 已删除:`src/test/features/doc-icon.test.ts`(emoji 图标相关,不再有效)
  124 +- 格式化工具栏为纯 UI 操作,单元测试意义有限,以 E2E 覆盖为主
  125 +
  126 +### E2E 测试(Playwright)
  127 +文件:`e2e/tests/workspace-features.spec.ts`
  128 +
  129 +| 用例 | 验证内容 |
  130 +|------|------|
  131 +| 编辑器存在格式化工具栏 | `.icon-toolbar` 可见 |
  132 +| 工具栏包含格式化按钮 | B / I / U 等按钮存在 |
  133 +| AI 入口按钮存在 | `.icon-toolbar .ask-ai-btn` 可见 |
  1 +# 缺陷 1008567 - Word 文件解析失败
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:word-文件解析失败
  6 +
  7 +**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
  8 +
  9 +**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
  20 +
  21 +**画面内容**
  22 +- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ? If it is, see https://stuk.github.io/jszip/documentation/howto/read_zip.html`
  23 +- 左侧文件树显示多个文件(kisling2022.pdf、ZJ-TD-OT 系列文件等)
  24 +- 右侧为 AI 对话面板,正常运行
  25 +
  26 +**关键细节**
  27 +- 错误来自 jszip 库,.docx 本质是 ZIP 包,jszip 无法识别文件头
  28 +- 错误出现在文档加载阶段,文件内容本身可能损坏/旧格式
  29 +
  30 +### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
  31 +
  32 +**画面内容**
  33 +- 工作台左侧编辑区显示两个 tab(afc22...、ZJ-T...)
  34 +- 当前 tab 显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
  35 +- 右侧 AI 面板正在对文档内容进行问答(文件已被知识库解析,可提问)
  36 +
  37 +**关键细节**
  38 +- 错误码 88 来自 X2T WASM 本地转换失败
  39 +- 文件在知识库侧可正常解析提问,说明文件本身是完好的
  40 +- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
  41 +
  42 +---
  43 +
  44 +## 评论
  45 +
  46 +**[2026-03-11 14:33:34] 张倩如**
  47 +> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
  48 +
  49 +**[2026-03-11 14:33:57] 张倩如**
  50 +> 仅预览,不要支持修改!!!!
  51 +
  52 +**[2026-02-12 12:48:47] 张倩如**(含截图2):
  53 +> 文件可解析可提问,只是前端渲染不出来
  54 +
  55 +**关键约束**
  56 +- 后端已将 Word 文件转换为新版 Office 格式并上传至 TOS
  57 +- 前端只需从 TOS 拉取预览,**禁止支持编辑修改**
  58 +- TOS 路径规则:`docs-parsed/prod{fileId}/convert.{toExt}`
  59 +
  60 +---
  61 +
  62 +## 根因分析
  63 +
  64 +### 两个错误来源不同
  65 +
  66 +1. **jszip 错误**`Can't find end of central directory`):前端尝试直接用 jszip 解析旧版 `.doc` 文件,旧版 Word 不是标准 ZIP 格式
  67 +2. **X2T code 88**:前端本地 WASM 转换工具(X2T)处理某些 Word 文件时转换失败
  68 +
  69 +### 根本问题
  70 +`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足。
  71 +
  72 +### 解决路径
  73 +后端已有文件转换 pipeline,将 Word 文件转为新版格式上传至 TOS。前端优先使用后端转换好的版本,X2T 处理新版 docx 兼容性更好,若 TOS 无转换版本(老文件)则静默回退原文件。
  74 +
  75 +### 涉及文件
  76 +- `src/components/Workspace/OnlyOfficeViewer.vue`:文件加载入口
  77 +- `src/utils/onlyoffice/converter.ts`:X2T 转换和 OnlyOffice 编辑器初始化
  78 +
  79 +---
  80 +
  81 +## 修复方案
  82 +
  83 +### TOS 转换版本优先策略
  84 +
  85 +`OnlyOfficeViewer.vue` 的 `initViewer()` 中,下载原文件前先尝试从 TOS 拉取后端转换版本:
  86 +
  87 +```
  88 +TOS key: docs-parsed/prod{fileId}/convert.{toExt}
  89 +扩展名映射: doc/docx → docx, ppt/pptx → pptx, xls/xlsx → xlsx
  90 +```
  91 +
  92 +成功 → 使用转换版本;失败(老文件无转换版本)→ 静默回退下载原文件。
  93 +
  94 +### 只读预览
  95 +
  96 +`createEditorInstance` 新增 `readOnly` 参数,`permissions: { edit: !readOnly }`。`OnlyOfficeViewer` 调用 `openDocument` 时传入 `readOnly: true`
  97 +
  98 +### 修改的文件
  99 +- `src/components/Workspace/OnlyOfficeViewer.vue`:TOS 优先逻辑 + 只读模式
  100 +- `src/utils/onlyoffice/converter.ts`:`createEditorInstance`/`openDocument` 新增 `readOnly` 参数
  101 +
  102 +---
  103 +
  104 +## 测试结果
  105 +- `npm run type-check` 通过,无 TypeScript 错误
  106 +- `npm run test:unit` 通过,19 个用例全部通过
  107 +
  108 +## 测试覆盖
  109 +
  110 +### 单元测试(Vitest)
  111 +文件:`src/test/features/onlyoffice-tos-fallback.test.ts`(12 个用例)
  112 +
  113 +| 测试组 | 用例 |
  114 +|------|------|
  115 +| TOS storageKey 构造 | doc/docx/ppt/pptx/xls/xlsx 路径正确;不支持格式返回 null;无扩展名返回 null |
  116 +| 转换后文件名构造 | 各格式文件名扩展名正确替换;新格式保持不变 |
  117 +
  118 +### E2E 测试(Playwright)
  119 +文件:`e2e/tests/onlyoffice-word-preview.spec.ts`(1 个用例,通过)
  120 +
  121 +- 工作台加载不出现 jszip Word 解析错误
  122 +
  123 +## 验收条件
  124 +1. 打开 Word/PPT/Excel 文件时优先从 TOS 加载后端转换版本 ✅
  125 +2. TOS 无转换版本(老文件)静默回退原文件 ✅
  126 +3. 预览模式为只读,OnlyOffice 编辑按钮不可用 ✅
  127 +4. 不影响 PDF、图片、Markdown 等其他文件类型 ✅
  1 +# 缺陷 1008905 - 知识库右键菜单第一项显示原始翻译Key
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:这个是啥?
  6 +
  7 +**状态**:new | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:medium | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-12 15:09:09 | **修改时间**:2026-03-12 15:09:09
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773299328_100.png]
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +### 截图(3066 × 1826 像素)- 知识库文件列表页面右键菜单
  20 +
  21 +**画面内容**
  22 +- LinkMed 知识库页面,左侧为文件夹树,右侧为文件列表(「我的文档」视图)
  23 +- 用户在左侧某文件夹(其他任务)上触发了右键菜单
  24 +- 右键菜单弹出,包含 4 项:
  25 + 1. **`KnowledgeBase.open`**(异常!应显示「打开」)
  26 + 2. 重命名(图标:铅笔)
  27 + 3. 下载(图标:下载箭头)
  28 + 4. 删除(图标:垃圾桶,红色)
  29 +- 右侧文件列表中可见多个文件夹和文件,知识库状态列部分显示「已完成」
  30 +
  31 +**关键细节**
  32 +- 第一个菜单项显示原始翻译 key `KnowledgeBase.open`,而非中文「打开」
  33 +- 其他三项(重命名、下载、删除)均正常显示中文
  34 +
  35 +---
  36 +
  37 +## 根因分析
  38 +
  39 +### 错误类型
  40 +i18n 翻译 key 缺失,导致 vue-i18n `t()` 函数返回 key 字符串本身。
  41 +
  42 +### 为什么会这样
  43 +
  44 +`FileList.vue` 第 284 行右键菜单第一项代码:
  45 +
  46 +```vue
  47 +{{ contextMenuFile?.isFolder ? (t("KnowledgeBase.open") || "打开") : (t("KnowledgeBase.edit") || "编辑") }}
  48 +```
  49 +
  50 +- `t("KnowledgeBase.open")` 找不到对应翻译时,vue-i18n 返回 key 字符串 `"KnowledgeBase.open"`
  51 +- `"KnowledgeBase.open"` 是非空字符串(真值),所以 `|| "打开"` 兜底**永远不会执行**
  52 +- `KnowledgeBase` 翻译对象中有 `edit`、`rename`、`download`、`delete`,但**缺少 `open`**
  53 +
  54 +### 涉及文件
  55 +- `src/locales/zh-CN.ts`:KnowledgeBase 下缺少 `open: "打开"`
  56 +- `src/locales/en-US.ts`:KnowledgeBase 下缺少 `open: "Open"`
  57 +- `src/components/KnowledgeBase/FileList.vue`:第 284 行调用 `t("KnowledgeBase.open")`
  58 +
  59 +---
  60 +
  61 +## 修复方案
  62 +
  63 +在 zh-CN 和 en-US 翻译文件的 `KnowledgeBase` 对象中补充 `open` key。
  64 +
  65 +### 修改的文件
  66 +- `src/locales/zh-CN.ts`:新增 `open: "打开"`
  67 +- `src/locales/en-US.ts`:新增 `open: "Open"`
  68 +
  69 +### 具体改动
  70 +
  71 +```typescript
  72 +// zh-CN.ts — KnowledgeBase 下新增
  73 +open: "打开",
  74 +edit: "编辑", // 原有,参考位置
  75 +
  76 +// en-US.ts — KnowledgeBase 下新增
  77 +open: "Open",
  78 +edit: "Edit", // 原有,参考位置
  79 +```
  80 +
  81 +---
  82 +
  83 +## 测试结果
  84 +- `npm run type-check` 通过,无 TypeScript 错误
  85 +- `npm run test:unit` 通过,19 个用例全部通过
  86 +
  87 +## 测试覆盖
  88 +
  89 +### 单元测试(Vitest)
  90 +文件:`src/test/features/locale-knowledge-base.test.ts`(9 个用例)
  91 +
  92 +| 测试组 | 用例 |
  93 +|------|------|
  94 +| zh-CN 翻译 | open=打开;edit=编辑;rename=重命名;download=下载;delete=删除 |
  95 +| en-US 翻译 | open=Open;edit=Edit |
  96 +| 非空校验 | zh-CN/en-US KnowledgeBase.open 均非空字符串(防兜底失效) |
  97 +
  98 +### E2E 测试(Playwright)
  99 +文件:`e2e/tests/knowledge-base-context-menu.spec.ts`(2 个用例)
  100 +
  101 +- 右键菜单第一项不应显示翻译 key 字符串(需登录态,无数据时跳过)
  102 +- 右键文件夹菜单应包含「打开」「重命名」「下载」「删除」完整4项
  103 +
  104 +## 验收条件
  105 +1. 知识库文件列表中,右键文件夹,第一个菜单项显示「打开」✅
  106 +2. 右键文件,第一个菜单项显示「编辑」✅
  107 +3. 切换为英文时分别显示「Open」和「Edit」✅
  1 +# 缺陷 1008906 - 部分苹果笔记本 Safari 浏览器登录报错无法跳转
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:部分苹果笔记本safai浏览器登录会报错,无法登录跳转成功
  6 +
  7 +**状态**:new | **负责人**:尹帮会 | **优先级**:medium | **创建者**:尹帮会
  8 +
  9 +**创建时间**:2026-03-13 10:45:22 | **修改时间**:2026-03-19 12:27:47
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773369821_174.png]
  14 +>
  15 +> 控制台清除于:11:37:53
  16 +>
  17 +> 成功导航到:- "/auth"
  18 +>
  19 +> LanguageSwitcher mounted, current locale: - "zh-CN"
  20 +>
  21 +> 路由错误:
  22 +> 7 SyntaxError: Invalid regular expression: invalid group specifier name
  23 +> parseModule
  24 +> (匿名函数)
  25 +> asyncFunctionResume
  26 +> (匿名函数)
  27 +> promiseReactionJobWithoutPromise
  28 +> (匿名函数)
  29 +> - index-B9mQj3Cz.js:9:136594
  30 +> forEach
  31 +> oe — vue-foundation-CF2dS5jX.js:43:169414
  32 +> promiseReactionJob
  33 +> index-B9mQj3Cz.js:9:136546
  34 +> LanguageSwitcher-B--4nNjM.js:1:1223
  35 +> index-B9mQj3Cz.js:9:136594
  36 +
  37 +---
  38 +
  39 +## 图片理解
  40 +
  41 +### 截图(2256 × 524 像素)- Safari DevTools 控制台
  42 +
  43 +**画面内容**
  44 +- Safari 浏览器开发者工具控制台截图
  45 +- 控制台顶部显示清除时间 11:37:53
  46 +- 第1行(蓝色):成功导航到 `/auth` 路由,来源 `index-B9mQj3Cz.js:9:136546`
  47 +- 第2行(蓝色):`LanguageSwitcher mounted, current locale: "zh-CN"`,来源 `LanguageSwitcher-B--4nNjM.js:1:1223`
  48 +- 第3行(红色错误):路由错误展开项
  49 + - 错误类型:`SyntaxError: Invalid regular expression: invalid group specifier name`
  50 + - 调用栈:parseModule → 匿名函数 → asyncFunctionResume → 匿名函数 → promiseReactionJobWithoutPromise → 匿名函数(`index-B9mQj3Cz.js:9:136594`)→ forEach → oe(`vue-foundation-CF2dS5jX.js:43:169414`)→ promiseReactionJob
  51 +
  52 +**关键信息**
  53 +- 路由导航到 `/auth` 成功(蓝色日志正常)
  54 +- `LanguageSwitcher` 组件成功挂载并打印 locale
  55 +- 随后在路由解析阶段抛出正则表达式语法错误
  56 +- 错误发生在 `LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 组件的生产包)
  57 +- 错误向上传播至主 bundle `index-B9mQj3Cz.js`
  58 +
  59 +---
  60 +
  61 +## 根因分析
  62 +
  63 +### 错误类型
  64 +`SyntaxError: Invalid regular expression: invalid group specifier name`
  65 +
  66 +这是 Safari 遇到**命名捕获组正则**(Named Capture Groups,ES2018)时的报错形式:
  67 +```js
  68 +/(?<year>\d{4})-(?<month>\d{2})/ // 老版 Safari 无法解析
  69 +```
  70 +
  71 +### 为什么报错
  72 +- 项目 `vite.config.ts` 中 `build.target` 设为 `"es2022"`
  73 +- esbuild 在 `es2022` 目标下不会主动降级命名捕获组语法,直接输出到产物中
  74 +- `vue-i18n` 的内部依赖 `@formatjs/icu-messageformat-parser` / `intl-messageformat` 在解析 locale 消息时使用了命名捕获组正则
  75 +- 部分旧版 macOS Safari(13.x / 14.0 及更早)不支持命名捕获组,解析产物 JS 时抛出 SyntaxError
  76 +- 路由组件懒加载 LanguageSwitcher 时触发该错误,导致路由跳转失败
  77 +
  78 +### 涉及文件
  79 +- 错误源:`node_modules/` 内 vue-i18n 相关依赖(`@formatjs/icu-messageformat-parser`
  80 +- 错误体现在产物:`LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 的独立 chunk)
  81 +- 配置文件:`vite.config.ts`
  82 +
  83 +---
  84 +
  85 +## 修复方案
  86 +
  87 +`vite.config.ts` 的 `build.esbuild` 中添加 `supported: { 'named-capture-groups': false }`
  88 +
  89 +该选项告知 esbuild:目标环境不支持命名捕获组,需将其转换为等效的无名捕获组形式,从而兼容旧版 Safari。
  90 +
  91 +### 修改的文件
  92 +- `vite.config.ts`:在 `build.esbuild` 中添加 `supported` 配置
  93 +
  94 +### 具体改动
  95 +
  96 +```typescript
  97 +// vite.config.ts (build.esbuild 块)
  98 +esbuild: {
  99 + drop: isProd ? ["console", "debugger"] : [],
  100 + legalComments: "none",
  101 + target: "es2022",
  102 + // 强制降级命名捕获组正则,兼容部分旧版 Safari(Issue #1008906)
  103 + // Safari 13/14 部分版本不支持命名捕获组,会抛出 "invalid group specifier name" 错误
  104 + supported: {
  105 + 'named-capture-groups': false,
  106 + },
  107 +},
  108 +```
  109 +
  110 +---
  111 +
  112 +## 测试结果
  113 +- `npm run type-check` 通过,无 TypeScript 错误
  114 +- 修复后需在旧版 Safari 验证登录流程正常
  115 +
  116 +## 验收条件
  117 +1. 老版本 Safari(13.x/14.0)打开登录页不再出现 `SyntaxError: Invalid regular expression` 报错
  118 +2. 登录后能正常跳转至工作台
  119 +3. LanguageSwitcher 语言切换功能不受影响
  1 +# 需求 1008257 - PDF 浏览器引用文本到对话框上下文
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】PDF的浏览器-引用文本到对话框的上下文
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:31:11 | **修改时间**:2026-03-13 21:01:56
  10 +
  11 +**原始描述**
  12 +> 在PDF文件中选择文本,出现菜单,选择"引用文本到对话框":
  13 +>
  14 +> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210470_797.png]
  15 +>
  16 +> 对话框中会出现对PDF文件的引用,鼠标悬停的时候,还可以看到引用的文本内容:
  17 +>
  18 +> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210606_307.png]
  19 +>
  20 +> 然后进行提问,会得到针对性的结果
  21 +
  22 +**AI Plan(TAPD 自动生成,仅供参考)**
  23 +```
  24 +### 需求摘要
  25 +在 PDF 文件中选择文本,出现菜单,选择"引用文本到对话框":对话框中会出现对 PDF 文件的引用,
  26 +鼠标悬停时可以看到引用的文本内容,然后进行提问,会得到针对性的结果。
  27 +
  28 +### 改动文件清单
  29 +1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加文本选择和菜单功能
  30 +2. linkmed-vue3/src/components/AgentPanel/AgentChatAgent.vue:添加引用显示和悬停效果
  31 +3. linkmed-vue3/src/api/chat.ts:添加引用相关的接口
  32 +4. linkmed-vue3/src/stores/chatApi.ts:添加引用相关的状态
  33 +
  34 +### 验收用例
  35 +1. 文本选择和菜单:在 PDF 中选择文本,出现菜单
  36 +2. 引用到对话框:选择"引用文本到对话框",对话框中出现引用
  37 +3. 悬停显示:鼠标悬停在引用上时显示引用的文本内容
  38 +4. 提问和回复:提问后得到针对性的结果
  39 +
  40 +复杂度:Medium
  41 +```
  42 +
  43 +---
  44 +
  45 +## 图片理解
  46 +
  47 +### 图片1(1854 × 1602 像素)- PDF 文本选中菜单
  48 +
  49 +**画面内容**
  50 +- 左侧:PDF 查看器,显示一篇物理医学期刊论文(Physics in Medicine & Biology)
  51 +- 用户在 PDF 中选中了一段英文文本(蓝色高亮区域)
  52 +- 选中文本后弹出浮动菜单,菜单包含两个按钮:
  53 + 1. **"Copy and Cite"**(复制并引用)
  54 + 2. **"Quote in Chat"**(引用到对话)—— 这就是目标功能的入口
  55 +- 右侧:AI Chat 面板,显示正在进行的对话
  56 +
  57 +**关键细节**
  58 +- 菜单出现在选中文本附近(浮动定位)
  59 +- 菜单样式为白色背景卡片,带有圆形图标
  60 +- "Quote in Chat" 使用引号图标(💬)
  61 +
  62 +### 图片2(1854 × 1602 像素)- 对话框中的引用标签
  63 +
  64 +**画面内容**
  65 +- 左侧:同一 PDF 查看器(同一篇论文)
  66 +- 右侧 AI Chat 面板底部输入区域,显示引用成功加入上下文后的状态:
  67 + - 输入框上方出现了一个**引用标签/芯片(chip)**:显示 `📄 PDF Quote ×`
  68 + - 该标签代表已引用的 PDF 文本片段
  69 + - 右上角有 `×` 按钮可以移除引用
  70 +- 对话列表中还可见 **"1 source"** 标签(表明已有来源引用)
  71 +
  72 +**悬停行为**(需推断):
  73 +- 鼠标悬停在 `PDF Quote ×` 标签上时,应弹出 tooltip 显示引用的原始文本内容
  74 +
  75 +**关键细节**
  76 +- 引用标签样式:小型 chip,带文件类型图标 + "PDF Quote" 文字 + `×` 删除按钮
  77 +- 标签位于聊天输入框上方(与截图功能的缩略图区域并列)
  78 +- 标签颜色为浅灰/白色背景
  79 +
  80 +---
  81 +
  82 +## 实现方案
  83 +
  84 +### 数据流
  85 +```
  86 +PDFViewerIframe mouseup → 显示浮动菜单
  87 + → 点击「Quote in Chat」
  88 + → chatApiStore.addPendingTextReference(text, fileName, fileId)
  89 + → AgentChat.vue 渲染引用标签(.text-references chip)
  90 + → 鼠标悬停显示原始引用文本(el-tooltip)
  91 + → 用户发送时,引用文本格式化为 Markdown 引用块前缀拼入问题
  92 + → 发送后 clearPendingTextReferences()
  93 +```
  94 +
  95 +### 1. chatApi.ts(store)
  96 +- 新增 `PendingTextReference` 接口(text, fileName, fileId)
  97 +-`ChatApiState` 添加 `pendingTextReferences: PendingTextReference[]`
  98 +- 新增 actions:`addPendingTextReference`、`removePendingTextReference`、`clearPendingTextReferences`
  99 +
  100 +### 2. PDFViewerIframe.vue
  101 +- 引入 `useWorkspaceChatStore`
  102 +- 新增 `showTextMenu`、`textMenuPos`、`selectedTextContent` 状态
  103 +- `handleTextMouseUp`:mouseup 时读取 window.getSelection(),计算菜单位置
  104 +- `handleGlobalMouseDown`:点击菜单外关闭菜单
  105 +- `quoteTextToChat`:调用 store 添加引用,显示成功 ElMessage
  106 +- template 中添加 `.pdf-text-menu` 浮动菜单(含「Copy and Cite」和「Quote in Chat」按钮)
  107 +
  108 +### 3. AgentChat.vue
  109 +- template 中在输入框上方添加 `.text-references` 文本引用 chip 区域
  110 +- 每个 chip 显示:📄 PDF Quote + 删除 `×` 按钮 + el-tooltip 悬停显示原文
  111 +- `canSend` computed 增加 `pendingTextReferences.length > 0` 条件
  112 +- `handleSend` 中将文本引用格式化为 Markdown 引用块前缀,拼入最终问题
  113 +- 发送后调用 `chatApiStore.clearPendingTextReferences()`
  114 +
  115 +---
  116 +
  117 +## 修改的文件
  118 +- `src/stores/chatApi.ts`
  119 +- `src/components/Workspace/PDFViewerIframe.vue`
  120 +- `src/components/AgentPanel/AgentChat.vue`
  121 +
  122 +## 测试结果
  123 +- `npm run type-check` 通过,无 TypeScript 错误
  124 +
  125 +## 测试覆盖
  126 +
  127 +### 单元测试(vitest)
  128 +
  129 +**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 部分,28 个用例)
  130 +
  131 +| 测试组 | 用例 |
  132 +|------|------|
  133 +| pendingTextReferences 初始状态 | 初始为空数组 |
  134 +| addPendingTextReference | 添加单条;连续添加多条;保存数字/字符串 fileId |
  135 +| removePendingTextReference | 按索引删除;删除第一条;删除最后一条 |
  136 +| clearPendingTextReferences | 清空所有;对空数组操作不报错 |
  137 +| 互不干扰 | 清空文本引用不影响截图;删除截图不影响文本引用 |
  138 +
  139 +**文件2:`src/test/features/text-reference-format.test.ts`**(格式化逻辑部分,7 个用例)
  140 +
  141 +| 测试组 | 用例 |
  142 +|------|------|
  143 +| buildFinalQuestion | 无引用返回原始问题;有1条引用格式正确;多条引用用空行分隔;有引用无问题返回纯引用;文件名包在《》中;引用文本以>开头;保留特殊字符 |
  144 +
  145 +### E2E 测试(Playwright)
  146 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008257 相关 3 个用例)
  147 +- 工作台存在 PDF 查看器组件挂载点(.pdf-viewer-js 等)
  148 +- PDF 查看器工具栏存在功能按钮(.pdf-toolbar)
  149 +- AgentChat 文本引用区域初始状态正确隐藏(无引用时 .text-references 不渲染)
  1 +# 需求 1008258 - PDF 浏览器截图引用到对话框上下文
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】PDF的浏览器-截图引用到对话框的上下文
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:33:37 | **修改时间**:2026-03-13 20:07:40
  10 +
  11 +**原始描述**
  12 +> 1.点击按钮,开启截图功能
  13 +>
  14 +> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210728_165.png]
  15 +>
  16 +> 2.截完图之后,会把图片加入到对话框的上下文
  17 +>
  18 +> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210785_770.png]
  19 +
  20 +**AI Plan(TAPD 自动生成,仅供参考)**
  21 +```
  22 +### 需求摘要
  23 +在工作台的PDF浏览器中添加截图功能,截图后将图片加入到对话框的上下文。
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加截图按钮和截图功能
  27 +2. linkmed-vue3/src/components/Workspace/WorkspaceChat.vue:添加处理截图的逻辑
  28 +3. linkmed-vue3/src/stores/chatApi.ts:添加状态管理逻辑
  29 +4. linkmed-vue3/src/api/files.ts:可能需要添加文件上传相关接口
  30 +
  31 +### 验收用例
  32 +1. 截图功能:点击截图按钮可以开启截图功能
  33 +2. 图片添加到对话框:截图后图片会加入到对话框的上下文
  34 +3. 图片显示:对话框中可以正常显示截图图片
  35 +
  36 +复杂度:Medium
  37 +```
  38 +
  39 +---
  40 +
  41 +## 图片理解
  42 +
  43 +### 图片1(1860 × 1598 像素)- 截图按钮激活状态
  44 +
  45 +**画面内容**
  46 +- 左侧:PDF 查看器,显示物理医学期刊论文
  47 +- **红色方框标注**:PDF 查看器顶部工具栏右侧,有一个被红框圈出的区域,该区域显示一个 `×`(关闭/取消)图标
  48 +- 这说明:截图模式**已处于激活状态**,工具栏上显示的是「取消截图」的 `×` 按钮,而非激活前的相机图标
  49 +- 右侧:AI Chat 面板正常显示
  50 +
  51 +**关键理解**
  52 +- 截图模式激活后,工具栏上的截图按钮变为 `×`(取消截图)
  53 +- 截图激活时,整个 PDF 区域上方会叠加遮罩层,支持拖拽选区
  54 +
  55 +### 图片2(1856 × 1604 像素)- 截图加入对话框上下文
  56 +
  57 +**画面内容**
  58 +- 左侧:同一 PDF 查看器
  59 +- 右侧底部:AI Chat 输入区域
  60 +- **红色箭头标注**:输入框区域下方偏右位置,可以看到一个小图片缩略图(截图预览)
  61 +- 该截图缩略图出现在聊天输入框的上下文附件区域,说明截图已成功加入对话上下文
  62 +
  63 +**关键细节**
  64 +- 截图缩略图位于输入框上方的附件预览区
  65 +- 缩略图较小(约 80×60px),可能带有删除按钮
  66 +- 截图作为图片附件被加入到本次提问的上下文中
  67 +
  68 +---
  69 +
  70 +## 实现方案
  71 +
  72 +### 数据流
  73 +```
  74 +PDF 工具栏截图按钮(相机图标)点击
  75 + → toggleScreenshotMode() 激活,按钮变为 ×
  76 + → PDF 区域叠加透明遮罩层 .screenshot-overlay
  77 + → 用户 mousedown → 拖拽 → mouseup 形成选区
  78 + → captureScreenshotRegion():遍历 PDF canvas,合成选区截图(处理 devicePixelRatio)
  79 + → chatApiStore.addPendingScreenshot(base64DataURL)
  80 + → AgentChat.vue .screenshot-previews 显示缩略图
  81 + → 用户发送时,msg.screenshots 传给 AgentPanelBody
  82 + → 上传文件获取 fileId → 加入 context
  83 + → clearPendingScreenshots()
  84 +```
  85 +
  86 +### 1. chatApi.ts(store)
  87 +- 新增 `PendingScreenshot` 类型(base64 data URL 字符串)
  88 +-`ChatApiState` 添加 `pendingScreenshots: PendingScreenshot[]`
  89 +- 新增 actions:`addPendingScreenshot`、`removePendingScreenshot`、`clearPendingScreenshots`
  90 +
  91 +### 2. PDFViewerIframe.vue
  92 +- 工具栏新增截图按钮(相机图标 `fa-camera`),激活后变为 `×` 取消按钮
  93 +- 激活时在 PDF 查看器上方叠加全屏透明遮罩 `.screenshot-overlay`(cursor: crosshair)
  94 +- 鼠标 mousedown/mousemove/mouseup 事件实现拖拽选区(半透明蓝色选框)
  95 +- `captureScreenshotRegion`:遍历所有页面的 canvas,根据选区坐标合成截图(处理 devicePixelRatio)
  96 +- Esc 键监听取消截图模式
  97 +- 截图完成后自动关闭截图模式,调用 `chatApiStore.addPendingScreenshot`,显示成功提示
  98 +
  99 +### 3. AgentChat.vue
  100 +- 在输入框上方添加 `.screenshot-previews` 截图缩略图区域
  101 +- 每个缩略图:80×60px 图片预览 + 右上角 × 删除按钮
  102 +- `canSend` computed 增加 `pendingScreenshots.length > 0` 条件(有截图时也可发送)
  103 +- `handleSend` 中将截图数组通过 `msg.screenshots` 传给父组件,发送后调用 `clearPendingScreenshots`
  104 +
  105 +### 4. AgentPanelBody.vue
  106 +- 导入 `createUploadSession`、`uploadBatch` from `@/api/files`
  107 +- `handleSendAsk`:解析 `msg.screenshots`,将 base64 转为 File 对象,通过 `createUploadSession` + `uploadBatch` 上传,将得到的 fileId 加入 `contextArray`
  108 +- `handleSendAgent`:同上,将截图 fileId 加入 `newContextFileIds`
  109 +
  110 +---
  111 +
  112 +## 修改的文件
  113 +- `src/stores/chatApi.ts`
  114 +- `src/components/Workspace/PDFViewerIframe.vue`
  115 +- `src/components/AgentPanel/AgentChat.vue`
  116 +- `src/components/AgentPanel/AgentPanelBody.vue`
  117 +
  118 +## 测试结果
  119 +- `npm run type-check` 通过,无 TypeScript 错误
  120 +
  121 +## 测试覆盖
  122 +
  123 +### 单元测试(vitest)
  124 +
  125 +**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 截图部分,18 个相关用例)
  126 +
  127 +| 测试组 | 用例 |
  128 +|------|------|
  129 +| pendingScreenshots 初始状态 | 初始为空数组 |
  130 +| addPendingScreenshot | 添加单张截图;连续添加多张;base64 原样保存不处理 |
  131 +| removePendingScreenshot | 按索引删除;删除第一张;删除最后一张 |
  132 +| clearPendingScreenshots | 清空所有;对空数组操作不报错 |
  133 +| 互不干扰 | 清空截图不影响文本引用;清空文本引用不影响截图 |
  134 +
  135 +**文件2:`src/test/features/text-reference-format.test.ts`**(截图 base64 转 File 逻辑,7 个用例)
  136 +
  137 +| 测试组 | 用例 |
  138 +|------|------|
  139 +| base64ToFile | 转换为 File 实例;MIME 类型正确;文件名含 screenshot;文件名含 index;无 MIME 默认 image/png;文件大小>0;多张截图文件名不同 |
  140 +
  141 +### E2E 测试(Playwright)
  142 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008258 相关 3 个用例)
  143 +- PDF 查看器工具栏存在截图按钮(.screenshot-btn 或 fa-camera)
  144 +- AgentChat 截图预览区域初始状态正确隐藏(无截图时 .screenshot-previews 不渲染)
  145 +- 对话框输入区域整体结构完整(.chat-input-container 存在)
  1 +# 需求 1008260 - 对话框添加到文章中
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】对话框,添加到文章中
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **优先级**:High | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 14:41:03 | **修改时间**:2026-03-12 17:32:34
  10 +
  11 +**原始描述**
  12 +> 点击按钮之后,把对话上面的内容,添加到当前编辑器打开的Markdown文件的光标处。
  13 +>
  14 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773211208_459.png]
  15 +
  16 +**AI Plan(TAPD 自动生成,仅供参考)**
  17 +```
  18 +### 需求摘要
  19 +在 LinkMed 工作台,点击对话框中的「添加到文章」按钮,将当前对话上方的指定内容插入到当前已打开 Markdown 编辑器的光标位置。
  20 +
  21 +### 涉及模块
  22 +- linkmed-vue3/workspace
  23 +- linkmed-vue3/shared
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/pages/Workspace.vue
  27 +
  28 +### 需要新增/修改的接口
  29 +无(不涉及后端接口调用)
  30 +
  31 +### 数据库变更(Flyway)
  32 +
  33 +
  34 +### 开发步骤
  35 +1. 确认当前 Workspace.vue 中是否存在对话框组件、Markdown 编辑器组件及其对外暴露的 insertContentAtCursor 方法
  36 +2. 在对话框组件中新增「添加到文章」按钮
  37 +3. 实现点击按钮时获取当前对话上方指定内容的逻辑
  38 +4. 调用 Markdown 编辑器或状态管理的插入内容方法,将获取到的对话内容插入到光标处
  39 +
  40 +### 验收用例
  41 +1. 当未打开任何 Markdown 文件时,点击「添加到文章」按钮,不执行插入且有友好提示
  42 +2. 当打开 Markdown 文件但光标未定位时,点击「添加到文章」按钮,内容插入到文件末尾
  43 +3. 当打开 Markdown 文件且光标定位在中间时,点击「添加到文章」按钮,内容准确插入到光标位置
  44 +4. 插入的内容格式需符合 Markdown 规范(保留换行、标题等原始格式)
  45 +
  46 +### 风险与依赖
  47 +- 依赖现有对话框组件能正确暴露或获取当前对话内容的能力
  48 +- 依赖现有 Markdown 编辑器组件或状态管理能提供稳定的 insertContentAtCursor 方法
  49 +
  50 +复杂度:Low
  51 +```
  52 +
  53 +---
  54 +
  55 +## 图片理解
  56 +
  57 +**设计图尺寸**:2016 × 1604 像素(完整页面截图)
  58 +
  59 +**图片内容描述**
  60 +
  61 +整体布局是工作台双栏视图:
  62 +- **左侧**:文档编辑区域,显示正在编辑的医学文档(放疗相关内容,包含公式和分段文字)
  63 +- **右侧**:AI Chat 对话面板,展示 AI 生成的回答内容
  64 +
  65 +**关键标注**:红色箭头指向左侧文档区域底部的输入框旁边,注释文字写道:「增加一个按钮,'添加到文章中'」
  66 +
  67 +**按钮位置**:图中红色箭头明确指向**对话框底部操作区**(AI 回答气泡下方的操作按钮栏),说明「添加到文章」按钮应该紧挨着 AI 回答气泡,在每条 AI 回复下方的操作行中显示。
  68 +
  69 +**设计意图**:用户在工作台看到 AI 生成的内容后,可以一键将该条 AI 回答插入到当前正在编辑的文档中的光标位置。
  70 +
  71 +---
  72 +
  73 +## 实现方案
  74 +
  75 +### 功能描述
  76 +在每条 AI 回答气泡下方的操作区,新增「添加到文章」按钮。点击后将该条回答的 Markdown 内容插入到当前激活编辑器的光标位置。
  77 +
  78 +### 调用链路
  79 +```
  80 +AgentPanelBody.vue(按钮点击)
  81 + → emit('add-to-article', content)
  82 + → IntelligencePanel.vue(透传)
  83 + → emit('add-to-article', content)
  84 + → Workspace.vue(处理)
  85 + → activeEditorRef.insertTextAtCursor(content)
  86 +```
  87 +
  88 +### 实现细节
  89 +1. **AffineEditor.vue**:在 `defineExpose` 中暴露 `insertTextAtCursor` 方法(原已有,只新增暴露)
  90 +2. **AgentPanelBody.vue**
  91 + - 新增 `add-to-article` emit 事件
  92 + - ask 模式和 agent 模式的 AI 回答操作区都新增「添加到文章」按钮
  93 + - 添加 `handleAddToArticle` 处理函数
  94 +3. **IntelligencePanel.vue**:新增 `add-to-article` emit 并透传
  95 +4. **Workspace.vue**:实现 `handleAddToArticle`,检查当前 tab 类型,调用编辑器插入
  96 +
  97 +### 图标
  98 +创建 `public/tianjia.svg` - 文档加号图标(SVG 格式,带加号的文档图标)
  99 +
  100 +---
  101 +
  102 +## 修改的文件
  103 +- `src/components/Workspace/AffineEditor.vue`:暴露 insertTextAtCursor
  104 +- `src/components/AgentPanel/AgentPanelBody.vue`:添加按钮和事件
  105 +- `src/layout/IntelligencePanel.vue`:透传事件
  106 +- `src/pages/Workspace.vue`:监听事件并调用编辑器
  107 +- `public/tianjia.svg`:新增图标文件
  108 +
  109 +## 测试结果
  110 +- `npm run type-check` 通过,无 TypeScript 错误
  111 +
  112 +## 测试覆盖
  113 +
  114 +### 单元测试(vitest)
  115 +文件:`src/test/features/text-reference-format.test.ts`(1008260 相关部分)
  116 +
  117 +| 测试组 | 用例 |
  118 +|------|------|
  119 +| 添加到文章内容校验 | 有内容通过;空字符串失败并提示;纯空白失败;Markdown 格式内容通过;前后空白 trim 后有内容则通过 |
  120 +
  121 +### E2E 测试(Playwright)
  122 +文件:`e2e/tests/workspace-features.spec.ts`(含 1008260 相关 3 个用例)
  123 +- 工作台右侧对话区存在 AI 回答操作栏
  124 +- 对话区 AI 回答操作区包含添加到文章按钮(tianjia.svg 图标)
  125 +- AgentChat 输入区存在文本引用或截图预览区域挂载点
  1 +# 需求 1008265 - 编辑器的图标框
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**需求标题**:【工作台】编辑器的图标框
  6 +
  7 +**状态**:规划中 | **负责人**:尹帮会 | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-11 16:13:58 | **修改时间**:2026-03-12 10:42:40
  10 +
  11 +**原始描述**
  12 +> 类似腾讯文档,常用的放在最上面的图标
  13 +>
  14 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773216836_934.png]
  15 +
  16 +**AI Plan(TAPD 自动生成,仅供参考)**
  17 +```
  18 +### 需求摘要
  19 +在工作台编辑器中添加类似腾讯文档的图标框功能,将常用图标放在最上面,方便用户快速访问和使用。
  20 +
  21 +### 涉及模块
  22 +- linkmed-vue3/workspace
  23 +- linkmed-vue3/shared
  24 +
  25 +### 改动文件清单
  26 +1. linkmed-vue3/src/components/Workspace/AffineEditor.vue
  27 +2. linkmed-vue3/src/pages/Workspace.vue
  28 +
  29 +### 需要新增/修改的接口
  30 +
  31 +
  32 +### 数据库变更(Flyway)
  33 +
  34 +
  35 +### 开发步骤
  36 +1. 分析 AffineEditor.vue 组件的现有结构,找到编辑器工具栏的实现位置
  37 +2. 设计图标框组件的布局,确保常用图标放在最上面
  38 +3. 在 AffineEditor.vue 中实现图标框组件
  39 +4. 根据腾讯文档的风格调整图标框的样式和交互
  40 +5. 在 Workspace.vue 中集成并测试图标框功能
  41 +6. 进行整体功能测试和样式优化
  42 +
  43 +### 验收用例
  44 +1. 验证编辑器中是否显示图标框
  45 +2. 验证常用图标是否显示在图标框的最上面
  46 +3. 验证图标框的交互功能是否正常(点击、悬停等)
  47 +4. 验证图标框的样式是否符合设计要求
  48 +5. 验证图标框在不同屏幕尺寸下的显示效果
  49 +
  50 +### 风险与依赖
  51 +- 依赖 AffineEditor 组件的现有结构和功能
  52 +- 风险:如果 BlockSuite 编辑器的工具栏实现方式与预期不同,可能需要调整实现方案
  53 +
  54 +复杂度:Low
  55 +```
  56 +
  57 +---
  58 +
  59 +## 图片理解
  60 +
  61 +**设计图尺寸**:569 × 51 像素(水平条形工具栏截图)
  62 +
  63 +**图片内容**(从左到右逐个图标):
  64 +
  65 +| 序号 | 图标 | 含义 |
  66 +|------|------|------|
  67 +| 1 | ✨ 问问AI | AI 辅助写作入口(带文字标签,蓝色星星图标) |
  68 +| — | 竖线分隔 | — |
  69 +| 2 | ≡▾ | 段落/列表样式下拉菜单 |
  70 +| 3 | **B** | 加粗 (Bold) |
  71 +| 4 | `<>` | 代码块 (Code) |
  72 +| 5 | _I_ | 斜体 (Italic) |
  73 +| 6 | ⊘ | 插入超链接 (Link) |
  74 +| 7 | S̶ | 删除线 (Strikethrough) |
  75 +| 8 | U̲ | 下划线 (Underline) |
  76 +| 9 | ✏▾ | 高亮/文字颜色下拉菜单 |
  77 +| 10 | ⊞ | 插入表格 |
  78 +| 11 | ··· | 更多选项 |
  79 +
  80 +**设计意图**:这是一条**固定常驻的快捷格式化工具栏**(Quick Format Toolbar),位于编辑器顶部,始终可见,不依赖文本选中状态。类似腾讯文档、Google Docs 顶部的格式工具栏,让用户无需选中文字就能快速访问常用格式操作。
  81 +
  82 +---
  83 +
  84 +## ⚠️ 第一版实现偏差(已修正)
  85 +
  86 +**第一版错误实现**
  87 +Agent 将「图标框」误解为**文档 emoji 图标**——在文档标题上方添加了 emoji 选取区域(`.doc-icon-area`),让用户给文档选一个装饰性 emoji,存储在 localStorage。
  88 +
  89 +**正确理解**
  90 +「图标框」= **格式化快捷工具栏**(Formatting Toolbar),需要常驻在编辑器顶部,提供快速文本格式化入口,而不是文档 emoji 装饰。
  91 +
  92 +---
  93 +
  94 +## 实现方案(修正后)
  95 +
  96 +### 功能描述
  97 +`AffineEditor.vue` 的 `editor-toolbar`(顶部元信息栏)下方、BlockSuite 编辑区域上方,新增一行常驻格式化工具栏 `.icon-toolbar`
  98 +- **始终可见**,不依赖文本选中
  99 +- 提供常用格式化快捷图标:AI、段落样式、加粗、代码、斜体、链接、删除线、下划线、高亮、表格、更多
  100 +- 点击格式化按钮,通过 BlockSuite 内部命令 API 对当前选区或光标处应用格式
  101 +
  102 +### 技术实现
  103 +通过 `editorHost` 获取 BlockSuite 的 `std.command` 执行格式化命令:
  104 +- `toggleBold` / `toggleItalic` / `toggleUnderline` / `toggleStrike` / `toggleCode`:文本内联格式
  105 +- 链接:通过 `toggleLink` 命令或手动触发 link popup
  106 +- 表格:通过 `insertTable` command 或 AffineEditor 的 `insertContent` 方法
  107 +
  108 +### 「问问AI」按钮
  109 +调用现有 AI 辅助写作入口(与现有 AI 面板联动)
  110 +
  111 +## 修改的文件
  112 +- `src/components/Workspace/AffineEditor.vue`
  113 + - 删除:emoji 图标选择器(doc-icon-area、emoji-picker 及所有相关代码)
  114 + - 新增:`.icon-toolbar` 格式化工具栏(template + style)
  115 + - 新增:各格式化操作的处理函数
  116 +
  117 +## 测试结果
  118 +- `npm run type-check` 通过,无 TypeScript 错误
  119 +
  120 +## 测试覆盖
  121 +
  122 +### 单元测试(vitest)
  123 +- 已删除:`src/test/features/doc-icon.test.ts`(emoji 图标相关,不再有效)
  124 +- 格式化工具栏为纯 UI 操作,单元测试意义有限,以 E2E 覆盖为主
  125 +
  126 +### E2E 测试(Playwright)
  127 +文件:`e2e/tests/workspace-features.spec.ts`
  128 +
  129 +| 用例 | 验证内容 |
  130 +|------|------|
  131 +| 编辑器存在格式化工具栏 | `.icon-toolbar` 可见 |
  132 +| 工具栏包含格式化按钮 | B / I / U 等按钮存在 |
  133 +| AI 入口按钮存在 | `.icon-toolbar .ask-ai-btn` 可见 |
  1 +# 缺陷 1008567 - Word 文件解析失败
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:word-文件解析失败
  6 +
  7 +**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
  8 +
  9 +**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
  20 +
  21 +**画面内容**
  22 +- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ? If it is, see https://stuk.github.io/jszip/documentation/howto/read_zip.html`
  23 +- 左侧文件树显示多个文件(kisling2022.pdf、ZJ-TD-OT 系列文件等)
  24 +- 右侧为 AI 对话面板,正常运行
  25 +
  26 +**关键细节**
  27 +- 错误来自 jszip 库,.docx 本质是 ZIP 包,jszip 无法识别文件头
  28 +- 错误出现在文档加载阶段,文件内容本身可能损坏/旧格式
  29 +
  30 +### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
  31 +
  32 +**画面内容**
  33 +- 工作台左侧编辑区显示两个 tab(afc22...、ZJ-T...)
  34 +- 当前 tab 显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
  35 +- 右侧 AI 面板正在对文档内容进行问答(文件已被知识库解析,可提问)
  36 +
  37 +**关键细节**
  38 +- 错误码 88 来自 X2T WASM 本地转换失败
  39 +- 文件在知识库侧可正常解析提问,说明文件本身是完好的
  40 +- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
  41 +
  42 +---
  43 +
  44 +## 评论
  45 +
  46 +**[2026-03-11 14:33:34] 张倩如**
  47 +> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
  48 +
  49 +**[2026-03-11 14:33:57] 张倩如**
  50 +> 仅预览,不要支持修改!!!!
  51 +
  52 +**[2026-02-12 12:48:47] 张倩如**(含截图2):
  53 +> 文件可解析可提问,只是前端渲染不出来
  54 +
  55 +**关键约束**
  56 +- 后端已将 Word 文件转换为新版 Office 格式并上传至 TOS
  57 +- 前端只需从 TOS 拉取预览,**禁止支持编辑修改**
  58 +- TOS 路径规则:`docs-parsed/prod{fileId}/convert.{toExt}`
  59 +
  60 +---
  61 +
  62 +## 根因分析
  63 +
  64 +### 两个错误来源不同
  65 +
  66 +1. **jszip 错误**`Can't find end of central directory`):前端尝试直接用 jszip 解析旧版 `.doc` 文件,旧版 Word 不是标准 ZIP 格式
  67 +2. **X2T code 88**:前端本地 WASM 转换工具(X2T)处理某些 Word 文件时转换失败
  68 +
  69 +### 根本问题
  70 +`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足。
  71 +
  72 +### 解决路径
  73 +后端已有文件转换 pipeline,将 Word 文件转为新版格式上传至 TOS。前端优先使用后端转换好的版本,X2T 处理新版 docx 兼容性更好,若 TOS 无转换版本(老文件)则静默回退原文件。
  74 +
  75 +### 涉及文件
  76 +- `src/components/Workspace/OnlyOfficeViewer.vue`:文件加载入口
  77 +- `src/utils/onlyoffice/converter.ts`:X2T 转换和 OnlyOffice 编辑器初始化
  78 +
  79 +---
  80 +
  81 +## 修复方案
  82 +
  83 +### TOS 转换版本优先策略
  84 +
  85 +`OnlyOfficeViewer.vue` 的 `initViewer()` 中,下载原文件前先尝试从 TOS 拉取后端转换版本:
  86 +
  87 +```
  88 +TOS key: docs-parsed/prod{fileId}/convert.{toExt}
  89 +扩展名映射: doc/docx → docx, ppt/pptx → pptx, xls/xlsx → xlsx
  90 +```
  91 +
  92 +成功 → 使用转换版本;失败(老文件无转换版本)→ 静默回退下载原文件。
  93 +
  94 +### 只读预览
  95 +
  96 +`createEditorInstance` 新增 `readOnly` 参数,`permissions: { edit: !readOnly }`。`OnlyOfficeViewer` 调用 `openDocument` 时传入 `readOnly: true`
  97 +
  98 +### 修改的文件
  99 +- `src/components/Workspace/OnlyOfficeViewer.vue`:TOS 优先逻辑 + 只读模式
  100 +- `src/utils/onlyoffice/converter.ts`:`createEditorInstance`/`openDocument` 新增 `readOnly` 参数
  101 +
  102 +---
  103 +
  104 +## 测试结果
  105 +- `npm run type-check` 通过,无 TypeScript 错误
  106 +- `npm run test:unit` 通过,19 个用例全部通过
  107 +
  108 +## 测试覆盖
  109 +
  110 +### 单元测试(Vitest)
  111 +文件:`src/test/features/onlyoffice-tos-fallback.test.ts`(12 个用例)
  112 +
  113 +| 测试组 | 用例 |
  114 +|------|------|
  115 +| TOS storageKey 构造 | doc/docx/ppt/pptx/xls/xlsx 路径正确;不支持格式返回 null;无扩展名返回 null |
  116 +| 转换后文件名构造 | 各格式文件名扩展名正确替换;新格式保持不变 |
  117 +
  118 +### E2E 测试(Playwright)
  119 +文件:`e2e/tests/onlyoffice-word-preview.spec.ts`(1 个用例,通过)
  120 +
  121 +- 工作台加载不出现 jszip Word 解析错误
  122 +
  123 +## 验收条件
  124 +1. 打开 Word/PPT/Excel 文件时优先从 TOS 加载后端转换版本 ✅
  125 +2. TOS 无转换版本(老文件)静默回退原文件 ✅
  126 +3. 预览模式为只读,OnlyOffice 编辑按钮不可用 ✅
  127 +4. 不影响 PDF、图片、Markdown 等其他文件类型 ✅
  1 +# 缺陷 1008905 - 知识库右键菜单第一项显示原始翻译Key
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:这个是啥?
  6 +
  7 +**状态**:new | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:medium | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-12 15:09:09 | **修改时间**:2026-03-12 15:09:09
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773299328_100.png]
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +### 截图(3066 × 1826 像素)- 知识库文件列表页面右键菜单
  20 +
  21 +**画面内容**
  22 +- LinkMed 知识库页面,左侧为文件夹树,右侧为文件列表(「我的文档」视图)
  23 +- 用户在左侧某文件夹(其他任务)上触发了右键菜单
  24 +- 右键菜单弹出,包含 4 项:
  25 + 1. **`KnowledgeBase.open`**(异常!应显示「打开」)
  26 + 2. 重命名(图标:铅笔)
  27 + 3. 下载(图标:下载箭头)
  28 + 4. 删除(图标:垃圾桶,红色)
  29 +- 右侧文件列表中可见多个文件夹和文件,知识库状态列部分显示「已完成」
  30 +
  31 +**关键细节**
  32 +- 第一个菜单项显示原始翻译 key `KnowledgeBase.open`,而非中文「打开」
  33 +- 其他三项(重命名、下载、删除)均正常显示中文
  34 +
  35 +---
  36 +
  37 +## 根因分析
  38 +
  39 +### 错误类型
  40 +i18n 翻译 key 缺失,导致 vue-i18n `t()` 函数返回 key 字符串本身。
  41 +
  42 +### 为什么会这样
  43 +
  44 +`FileList.vue` 第 284 行右键菜单第一项代码:
  45 +
  46 +```vue
  47 +{{ contextMenuFile?.isFolder ? (t("KnowledgeBase.open") || "打开") : (t("KnowledgeBase.edit") || "编辑") }}
  48 +```
  49 +
  50 +- `t("KnowledgeBase.open")` 找不到对应翻译时,vue-i18n 返回 key 字符串 `"KnowledgeBase.open"`
  51 +- `"KnowledgeBase.open"` 是非空字符串(真值),所以 `|| "打开"` 兜底**永远不会执行**
  52 +- `KnowledgeBase` 翻译对象中有 `edit`、`rename`、`download`、`delete`,但**缺少 `open`**
  53 +
  54 +### 涉及文件
  55 +- `src/locales/zh-CN.ts`:KnowledgeBase 下缺少 `open: "打开"`
  56 +- `src/locales/en-US.ts`:KnowledgeBase 下缺少 `open: "Open"`
  57 +- `src/components/KnowledgeBase/FileList.vue`:第 284 行调用 `t("KnowledgeBase.open")`
  58 +
  59 +---
  60 +
  61 +## 修复方案
  62 +
  63 +在 zh-CN 和 en-US 翻译文件的 `KnowledgeBase` 对象中补充 `open` key。
  64 +
  65 +### 修改的文件
  66 +- `src/locales/zh-CN.ts`:新增 `open: "打开"`
  67 +- `src/locales/en-US.ts`:新增 `open: "Open"`
  68 +
  69 +### 具体改动
  70 +
  71 +```typescript
  72 +// zh-CN.ts — KnowledgeBase 下新增
  73 +open: "打开",
  74 +edit: "编辑", // 原有,参考位置
  75 +
  76 +// en-US.ts — KnowledgeBase 下新增
  77 +open: "Open",
  78 +edit: "Edit", // 原有,参考位置
  79 +```
  80 +
  81 +---
  82 +
  83 +## 测试结果
  84 +- `npm run type-check` 通过,无 TypeScript 错误
  85 +- `npm run test:unit` 通过,19 个用例全部通过
  86 +
  87 +## 测试覆盖
  88 +
  89 +### 单元测试(Vitest)
  90 +文件:`src/test/features/locale-knowledge-base.test.ts`(9 个用例)
  91 +
  92 +| 测试组 | 用例 |
  93 +|------|------|
  94 +| zh-CN 翻译 | open=打开;edit=编辑;rename=重命名;download=下载;delete=删除 |
  95 +| en-US 翻译 | open=Open;edit=Edit |
  96 +| 非空校验 | zh-CN/en-US KnowledgeBase.open 均非空字符串(防兜底失效) |
  97 +
  98 +### E2E 测试(Playwright)
  99 +文件:`e2e/tests/knowledge-base-context-menu.spec.ts`(2 个用例)
  100 +
  101 +- 右键菜单第一项不应显示翻译 key 字符串(需登录态,无数据时跳过)
  102 +- 右键文件夹菜单应包含「打开」「重命名」「下载」「删除」完整4项
  103 +
  104 +## 验收条件
  105 +1. 知识库文件列表中,右键文件夹,第一个菜单项显示「打开」✅
  106 +2. 右键文件,第一个菜单项显示「编辑」✅
  107 +3. 切换为英文时分别显示「Open」和「Edit」✅
  1 +# 缺陷 1008906 - 部分苹果笔记本 Safari 浏览器登录报错无法跳转
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:部分苹果笔记本safai浏览器登录会报错,无法登录跳转成功
  6 +
  7 +**状态**:new | **负责人**:尹帮会 | **优先级**:medium | **创建者**:尹帮会
  8 +
  9 +**创建时间**:2026-03-13 10:45:22 | **修改时间**:2026-03-19 12:27:47
  10 +
  11 +**原始描述**
  12 +
  13 +> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773369821_174.png]
  14 +>
  15 +> 控制台清除于:11:37:53
  16 +>
  17 +> 成功导航到:- "/auth"
  18 +>
  19 +> LanguageSwitcher mounted, current locale: - "zh-CN"
  20 +>
  21 +> 路由错误:
  22 +> 7 SyntaxError: Invalid regular expression: invalid group specifier name
  23 +> parseModule
  24 +> (匿名函数)
  25 +> asyncFunctionResume
  26 +> (匿名函数)
  27 +> promiseReactionJobWithoutPromise
  28 +> (匿名函数)
  29 +> - index-B9mQj3Cz.js:9:136594
  30 +> forEach
  31 +> oe — vue-foundation-CF2dS5jX.js:43:169414
  32 +> promiseReactionJob
  33 +> index-B9mQj3Cz.js:9:136546
  34 +> LanguageSwitcher-B--4nNjM.js:1:1223
  35 +> index-B9mQj3Cz.js:9:136594
  36 +
  37 +---
  38 +
  39 +## 图片理解
  40 +
  41 +### 截图(2256 × 524 像素)- Safari DevTools 控制台
  42 +
  43 +**画面内容**
  44 +- Safari 浏览器开发者工具控制台截图
  45 +- 控制台顶部显示清除时间 11:37:53
  46 +- 第1行(蓝色):成功导航到 `/auth` 路由,来源 `index-B9mQj3Cz.js:9:136546`
  47 +- 第2行(蓝色):`LanguageSwitcher mounted, current locale: "zh-CN"`,来源 `LanguageSwitcher-B--4nNjM.js:1:1223`
  48 +- 第3行(红色错误):路由错误展开项
  49 + - 错误类型:`SyntaxError: Invalid regular expression: invalid group specifier name`
  50 + - 调用栈:parseModule → 匿名函数 → asyncFunctionResume → 匿名函数 → promiseReactionJobWithoutPromise → 匿名函数(`index-B9mQj3Cz.js:9:136594`)→ forEach → oe(`vue-foundation-CF2dS5jX.js:43:169414`)→ promiseReactionJob
  51 +
  52 +**关键信息**
  53 +- 路由导航到 `/auth` 成功(蓝色日志正常)
  54 +- `LanguageSwitcher` 组件成功挂载并打印 locale
  55 +- 随后在路由解析阶段抛出正则表达式语法错误
  56 +- 错误发生在 `LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 组件的生产包)
  57 +- 错误向上传播至主 bundle `index-B9mQj3Cz.js`
  58 +
  59 +---
  60 +
  61 +## 根因分析
  62 +
  63 +### 错误类型
  64 +`SyntaxError: Invalid regular expression: invalid group specifier name`
  65 +
  66 +这是 Safari 遇到**命名捕获组正则**(Named Capture Groups,ES2018)时的报错形式:
  67 +```js
  68 +/(?<year>\d{4})-(?<month>\d{2})/ // 老版 Safari 无法解析
  69 +```
  70 +
  71 +### 为什么报错
  72 +- 项目 `vite.config.ts` 中 `build.target` 设为 `"es2022"`
  73 +- esbuild 在 `es2022` 目标下不会主动降级命名捕获组语法,直接输出到产物中
  74 +- `vue-i18n` 的内部依赖 `@formatjs/icu-messageformat-parser` / `intl-messageformat` 在解析 locale 消息时使用了命名捕获组正则
  75 +- 部分旧版 macOS Safari(13.x / 14.0 及更早)不支持命名捕获组,解析产物 JS 时抛出 SyntaxError
  76 +- 路由组件懒加载 LanguageSwitcher 时触发该错误,导致路由跳转失败
  77 +
  78 +### 涉及文件
  79 +- 错误源:`node_modules/` 内 vue-i18n 相关依赖(`@formatjs/icu-messageformat-parser`
  80 +- 错误体现在产物:`LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 的独立 chunk)
  81 +- 配置文件:`vite.config.ts`
  82 +
  83 +---
  84 +
  85 +## 修复方案
  86 +
  87 +`vite.config.ts` 的 `build.esbuild` 中添加 `supported: { 'named-capture-groups': false }`
  88 +
  89 +该选项告知 esbuild:目标环境不支持命名捕获组,需将其转换为等效的无名捕获组形式,从而兼容旧版 Safari。
  90 +
  91 +### 修改的文件
  92 +- `vite.config.ts`:在 `build.esbuild` 中添加 `supported` 配置
  93 +
  94 +### 具体改动
  95 +
  96 +```typescript
  97 +// vite.config.ts (build.esbuild 块)
  98 +esbuild: {
  99 + drop: isProd ? ["console", "debugger"] : [],
  100 + legalComments: "none",
  101 + target: "es2022",
  102 + // 强制降级命名捕获组正则,兼容部分旧版 Safari(Issue #1008906)
  103 + // Safari 13/14 部分版本不支持命名捕获组,会抛出 "invalid group specifier name" 错误
  104 + supported: {
  105 + 'named-capture-groups': false,
  106 + },
  107 +},
  108 +```
  109 +
  110 +---
  111 +
  112 +## 测试结果
  113 +- `npm run type-check` 通过,无 TypeScript 错误
  114 +- 修复后需在旧版 Safari 验证登录流程正常
  115 +
  116 +## 验收条件
  117 +1. 老版本 Safari(13.x/14.0)打开登录页不再出现 `SyntaxError: Invalid regular expression` 报错
  118 +2. 登录后能正常跳转至工作台
  119 +3. LanguageSwitcher 语言切换功能不受影响
  1 +# 缺陷 1008567 - Word 文件解析失败
  2 +
  3 +> **说明**:原修复(commit `450de3577`)于 2026-03-19 完成,随后被 commit `33e568097` 整体撤销,commit `1c1d4b957` 仅补回了 TOS 上传代码,预览修复未恢复。本次在 2026-03-23 重新实现,并经过 6 轮迭代修复才最终完成。
  4 +
  5 +---
  6 +
  7 +## 原始需求(一字不差)
  8 +
  9 +**缺陷标题**:word-文件解析失败
  10 +
  11 +**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
  12 +
  13 +**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
  14 +
  15 +**原始描述**
  16 +
  17 +> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
  18 +
  19 +**评论**
  20 +
  21 +> **[2026-03-11 14:33:34] 张倩如**:
  22 +> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
  23 +>
  24 +> ↳ **[2026-03-11 14:33:57] 张倩如**:
  25 +> 仅预览,不要支持修改!!!!
  26 +
  27 +---
  28 +
  29 +## 图片理解
  30 +
  31 +### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
  32 +
  33 +**画面内容**
  34 +- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ?`
  35 +- 左侧文件树显示多个文件
  36 +- 右侧为 AI 对话面板,正常运行
  37 +
  38 +**关键细节**
  39 +- 错误来自 jszip 库,`.docx` 本质是 ZIP 包,jszip 无法识别旧版 `.doc` 文件头
  40 +- 错误出现在文档加载阶段
  41 +
  42 +### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
  43 +
  44 +**画面内容**
  45 +- 工作台左侧编辑区显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
  46 +- 右侧 AI 面板可正常对文档内容进行问答
  47 +
  48 +**关键细节**
  49 +- 错误码 88 来自 X2T WASM 本地转换失败
  50 +- 文件在知识库侧可正常解析提问,说明文件本身完好
  51 +- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
  52 +
  53 +---
  54 +
  55 +## 根因分析
  56 +
  57 +`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足(返回 code 88)。后端已将旧版文件转换为新版格式并上传至 TOS,前端直接拉取 TOS 转换版本即可。
  58 +
  59 +---
  60 +
  61 +## 实现方案
  62 +
  63 +### 数据流
  64 +
  65 +```
  66 +OnlyOfficeViewer.initViewer()
  67 + → [旧版格式 doc/ppt/xls] 尝试 TOS: docs-parsed/prod{fileId}/convert.{toExt}
  68 + ├─ TOS 命中 → 使用转换版本 blob
  69 + └─ TOS 未命中 → 立即展示友好提示(不走 X2T)
  70 + → [新版格式 docx/pptx/xlsx] 跳过 TOS,走缓存/原始下载
  71 + → openDocument(file, elementId, onReady)
  72 + → x2tConverter.convertDocument(file) // X2T WASM 转换为 .bin
  73 + → createEditorInstance(...) // 初始化 OnlyOffice 编辑器
  74 + → onAppReady: asc_openDocument(bin) // 二进制注入加载
  75 + → 1.5s 后触发 onReady 回调(注入方式不触发 onDocumentReady)
  76 +```
  77 +
  78 +### 1. OnlyOfficeViewer.vue
  79 +
  80 +- `toExtMap` 仅含旧格式:`{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`
  81 +- 旧格式 TOS 未命中 → 立即 throw 友好错误,不再尝试 X2T 转换
  82 +- 新格式走缓存 → 原始下载 → X2T 流程
  83 +- 错误分支:X2T 转换失败(`Conversion failed`)→ 「文件解析失败,请重新上传后再试」
  84 +- 错误分支:旧版格式 TOS 未命中 → 「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
  85 +
  86 +### 2. converter.ts
  87 +
  88 +- `createEditorInstance` / `openDocument` 去掉 `readOnly` 参数(文档通过二进制注入加载,无保存地址,用户无法持久化修改,不需要限制 permissions)
  89 +- `editorConfig.user: { id: 'local', name: 'User' }` 防止用户信息为空
  90 +- `customization: { help: false, about: false }` 仅隐藏帮助/关于弹窗
  91 +- 移除 `ElMessage.error`,由调用方统一处理错误展示
  92 +
  93 +### 3. app.js patch(三个编辑器)
  94 +
  95 +**getInitials null guard**(documenteditor / spreadsheeteditor / presentationeditor):
  96 +```js
  97 +// 原代码:文档内嵌修改记录作者名可能为 undefined 导致崩溃
  98 +getInitials: function(t) { return t.split(' ')... }
  99 +// 修复后
  100 +getInitials: function(t) { if (!t) return ''; return t.split(' ')... }
  101 +```
  102 +
  103 +**spellcheck null guard**(presentationeditor 独有):
  104 +```js
  105 +// 原代码:customization.features 可能未提供导致崩溃
  106 +t.isEdit && !1 !== t.customization.features.spellcheck.change
  107 +// 修复后
  108 +t.isEdit && t.customization.features && t.customization.features.spellcheck && !1 !== t.customization.features.spellcheck.change
  109 +```
  110 +
  111 +**缓存破坏**:三个编辑器的 `index.html` 均加入:
  112 +```html
  113 +<script>var require = { urlArgs: "v=20260324" };</script>
  114 +```
  115 +
  116 +---
  117 +
  118 +## 修改的文件
  119 +
  120 +- `src/components/Workspace/OnlyOfficeViewer.vue`
  121 +- `src/utils/onlyoffice/converter.ts`
  122 +- `public/document/web-apps/apps/documenteditor/main/app.js`
  123 +- `public/document/web-apps/apps/documenteditor/main/index.html`
  124 +- `public/document/web-apps/apps/spreadsheeteditor/main/app.js`
  125 +- `public/document/web-apps/apps/spreadsheeteditor/main/index.html`
  126 +- `public/document/web-apps/apps/presentationeditor/main/app.js`
  127 +- `public/document/web-apps/apps/presentationeditor/main/index.html`
  128 +
  129 +---
  130 +
  131 +## 测试结果
  132 +
  133 +- `npm run type-check` 通过,无 TypeScript 错误
  134 +- `npm run test:unit` 通过,19/19 用例全部通过(已有测试,本次无新增)
  135 +- 实机测试:`.doc` 文件 TOS 命中时正常渲染,TOS 未命中时展示友好提示;`.docx` 文件完整工具栏正常显示;PPT 打开无 spellcheck 崩溃
  136 +
  137 +---
  138 +
  139 +## 测试覆盖
  140 +
  141 +本次为缺陷修复,未新增专项单元测试。已有 19 个用例全部通过。
  142 +
  143 +---
  144 +
  145 +## 迭代过程(6 轮)
  146 +
  147 +### 第 1 轮:基础实现(commit `780f384bd`)
  148 +
  149 +首次实现 TOS 优先加载策略:
  150 +- `OnlyOfficeViewer.vue` 新增 TOS → 缓存 → 原始下载的三级加载顺序
  151 +- `converter.ts` 新增 `readOnly` 参数,`permissions: { edit: false }`
  152 +- `asc_openDocument` 注入方式不触发 `onDocumentReady`,改为 1.5s 延迟回调兜底
  153 +- `.editor-container` 改为 `position: absolute` 确保全屏覆盖
  154 +
  155 +---
  156 +
  157 +### 第 2 轮:修复 TOS 转换范围错误(commit `e1eb62395`)
  158 +
  159 +**问题**`toExtMap` 误将 `docx/pptx/xlsx` 也包含进去,导致这些新格式文件打开时也向 TOS 发起 404 请求。
  160 +
  161 +**根因**:新格式文件本身已是最新版 Office 格式,X2T 可直接处理,不需要 TOS 转换版本。
  162 +
  163 +**修复**`toExtMap` 仅保留 `{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`,新格式直接走缓存/原始下载。
  164 +
  165 +---
  166 +
  167 +### 第 3 轮:修复 getInitials 崩溃(commits `b1b5db1d6` `8742f3601` `e455513df` `d4816ff2a`)
  168 +
  169 +**问题**:打开文档时报 `TypeError: Cannot read properties of undefined (reading 'split')`,崩溃于 `app.js:20911` 的 `getInitials` 函数。
  170 +
  171 +**排查过程**
  172 +1. 补充 `user: { id: 'local', name: 'User' }` → 无效(崩溃来自文档内嵌元数据,不是当前用户)
  173 +2.`mode: 'view'` → 无效
  174 +3.`permissions: { review: false, comment: false }` → 无效
  175 +4. 加日志确认调用链:`render:before → getPanel → setUserName → getInitials(undefined)`
  176 +
  177 +**根因**:文档内嵌的修改记录中存在作者名为 `undefined` 的条目,`getInitials` 函数未做 null 判断。
  178 +
  179 +**修复**
  180 +- patch 三个编辑器的 `app.js`:`getInitials: function(t) { if (!t) return ''; ... }`
  181 +- `index.html` 加 `var require = { urlArgs: "v=20260323" }` 破除浏览器强缓存,确保 patch 生效
  182 +
  183 +---
  184 +
  185 +### 第 4 轮:恢复完整工具栏(commits `b4c267c7d` `c65ca6903` `55c866f2c`)
  186 +
  187 +**问题**:打开文档后工具栏大量按钮缺失,只剩「文件」「视图」两个菜单。
  188 +
  189 +**排查过程**
  190 +1. `mode: 'view'` → 直接隐藏全部工具栏,移除后工具栏出现但仍有缺失
  191 +2. `permissions: { review: false, comment: false, chat: false, protect: false }` → 每个 `false` 都会隐藏对应工具栏 tab,逐一移除
  192 +3. `customization.hideRightMenu` 等 → 隐藏右侧面板,移除
  193 +
  194 +**根因**`permissions` 中任何权限设为 `false` 都会导致对应 UI 区域消失,而非仅禁用操作。
  195 +
  196 +**结论**:文档通过 `asc_openDocument` 二进制注入加载,无保存地址,用户无法持久化修改。不需要从 `permissions` 层面限制,直接去掉所有限制恢复完整 UI 即可。
  197 +
  198 +---
  199 +
  200 +### 第 5 轮:修复 presentationeditor spellcheck 崩溃(commit `17297e862`)
  201 +
  202 +**问题**:打开 `.ppt/.pptx` 文件时报 `TypeError: Cannot read properties of undefined (reading 'spellcheck')`,位于 `presentationeditor/app.js:52615`
  203 +
  204 +**根因**:app.js 内部执行 `t.customization.features.spellcheck.change`,当外部未提供 `customization.features` 字段时 `features` 为 `undefined`,访问其 `.spellcheck` 崩溃。
  205 +
  206 +**修复**
  207 +- patch `presentationeditor/app.js`:加 null guard `t.customization.features && t.customization.features.spellcheck &&`
  208 +- urlArgs 升至 `v=20260324` 破缓存
  209 +
  210 +---
  211 +
  212 +### 第 6 轮:旧版格式打开体验优化(commits `a71c8b25f` `82a2c7b7d` `c0873bdc0` `99fdbaa9f` `02a7a4bb2`)
  213 +
  214 +**问题**`.doc` 等旧版格式 TOS 未命中时,仍会尝试下载原文件走 X2T 转换,转换失败后弹出 `文档打开失败: Conversion failed with code: 88` 技术报错,体验差。
  215 +
  216 +**修复**
  217 +- 旧版格式 TOS 未命中时立即 throw 友好错误,不再走 X2T 转换流程
  218 +- 错误提示:「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
  219 +- X2T 转换失败兜底:「文件解析失败,请重新上传后再试」
  220 +- 移除 `converter.ts` 中的 `ElMessage.error`,由组件统一展示错误
  1 +# 缺陷 1008932 - 这个提醒,不知道是什么意思
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:这个提醒,不知道是什么意思
  6 +
  7 +**状态**:接受/处理 | **负责人**:尹帮会 | **严重程度**:— | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-19 21:24 | **迭代**:—
  10 +
  11 +**评论**
  12 +- [2026-03-20 14:05] 张倩如:国际化问题
  13 +
  14 +---
  15 +
  16 +## 图片理解
  17 +
  18 +### 截图(用户提供)
  19 +
  20 +用户上传 `users_20260319.csv` 文件到知识库,顶部出现橙色警告通知:
  21 +
  22 +```
  23 +users_20260319.csv: KnowledgeBase.unsupportedFileType: .csv
  24 +```
  25 +
  26 +**关键细节**:通知文本直接暴露了 vue-i18n 的 key 名 `KnowledgeBase.unsupportedFileType`,而非翻译后的中文。用户看到原始 key 字符串,完全不知道是什么意思。
  27 +
  28 +---
  29 +
  30 +## 根因分析
  31 +
  32 +`src/components/Workspace/FileTreeNode.vue` 第 754 行:
  33 +
  34 +```js
  35 +reason: extension
  36 + ? `${t("KnowledgeBase.unsupportedFileType")}: .${extension}`
  37 + : t("KnowledgeBase.fileHasNoExtension"),
  38 +```
  39 +
  40 +`en-US.ts` 中有对应翻译:
  41 +```
  42 +unsupportedFileType: "Unsupported file type",
  43 +fileHasNoExtension: "File has no extension",
  44 +```
  45 +
  46 +`zh-CN.ts` 的 `KnowledgeBase` 对象中**缺少这两个 key**,vue-i18n 在找不到 key 时回退显示 key 字符串本身,导致用户看到 `KnowledgeBase.unsupportedFileType: .csv`
  47 +
  48 +---
  49 +
  50 +## 修复方案
  51 +
  52 +`src/locales/zh-CN.ts` 的 `KnowledgeBase` 对象中,`fileSizeExceeded` 之后补充两个缺失的翻译 key:
  53 +
  54 +```ts
  55 +fileSizeExceeded: "文件大小不能超过 100MB",
  56 +unsupportedFileType: "不支持的文件类型", // 新增
  57 +fileHasNoExtension: "文件缺少扩展名", // 新增
  58 +```
  59 +
  60 +修复后通知显示为:`users_20260319.csv: 不支持的文件类型: .csv`,用户可以理解。
  61 +
  62 +---
  63 +
  64 +## 修改的文件
  65 +
  66 +- `src/locales/zh-CN.ts`
  67 +
  68 +---
  69 +
  70 +## 测试结果
  71 +
  72 +- `npm run type-check` 通过,无 TypeScript 错误
  73 +- `npm run test:unit` 通过,19/19 用例全部通过
  74 +- `npx playwright test e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
  75 +
  76 +## 验收条件
  77 +
  78 +1. 上传 `.csv` 等不支持格式的文件时,通知显示"不支持的文件类型: .csv"而非原始 key ✅
  79 +2. 无扩展名文件显示"文件缺少扩展名"而非"KnowledgeBase.fileHasNoExtension" ✅
  80 +
  81 +---
  82 +
  83 +## 测试覆盖
  84 +
  85 +### 单元测试(Vitest)
  86 +
  87 +i18n key 补充,无新增单元测试,已有 19 个用例全部通过。
  88 +
  89 +### E2E 测试(Playwright)
  90 +
  91 +**文件**`e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`(2 个用例)
  92 +
  93 +| 用例 | 验证内容 |
  94 +|------|---------|
  95 +| zh-CN.ts 包含 KnowledgeBase.unsupportedFileType 中文翻译 | 通过 vue-i18n 实例验证翻译值非 key 名且含中文 |
  96 +| zh-CN.ts 包含 KnowledgeBase.fileHasNoExtension 中文翻译 | 同上 |
  1 +# 缺陷 1008933 - 工作台,左右拖动编辑器,会感觉很卡
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:工作台,左右拖动编辑器,会感觉很卡
  6 +
  7 +**状态**:新 | **负责人**:— | **严重程度**:致命 | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-19 22:02 | **迭代**:0.6.28.0(当前迭代)
  10 +
  11 +---
  12 +
  13 +## 根因分析
  14 +
  15 +工作台拖拽分割线调整面板宽度时存在两个性能问题:
  16 +
  17 +### 问题 1:generator-area 拖拽时未禁用 transition(主因)
  18 +
  19 +`generator-area` 配置了三个 CSS 过渡动画:
  20 +
  21 +```css
  22 +transition:
  23 + width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  24 + min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  25 + max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  26 +```
  27 +
  28 +拖拽时 `isResizing = true`,`.resizing` 类会绑定到 `generator-area`,但 CSS 里没有对应的 `&.resizing { transition: none }`,导致每帧宽度变化都触发 0.35s 动画,产生严重卡顿感。
  29 +
  30 +对比:`editor-area-work` 已有 `&.resizing { transition: none }` 正确处理,而 `generator-area` 遗漏了。
  31 +
  32 +### 问题 2:每帧重新查询 DOM(次因)
  33 +
  34 +`handleMouseMove` 的 RAF 回调里每帧都执行:
  35 +
  36 +```js
  37 +const container = document.querySelector(".workspace-content");
  38 +```
  39 +
  40 +浏览器每帧(16ms)都需要遍历 DOM 树查找元素,虽然影响较小,但在高频拖拽下会累积。
  41 +
  42 +---
  43 +
  44 +## 修复方案
  45 +
  46 +### 1. generator-area 拖拽时禁用 transition(`src/pages/Workspace.vue`)
  47 +
  48 +```css
  49 +.generator-area {
  50 + transition: width 0.35s ..., min-width 0.35s ..., max-width 0.35s ...;
  51 +
  52 + &.resizing {
  53 + transition: none; /* 新增 */
  54 + }
  55 +}
  56 +```
  57 +
  58 +### 2. 缓存 `.workspace-content` DOM 引用
  59 +
  60 +- 声明模块级变量 `let workspaceContainerEl: Element | null = null`
  61 +-`startResize` / `startResizeLeftEdge` 时查询并缓存
  62 +- `handleMouseMove` RAF 回调优先使用缓存
  63 +- `handleMouseUp` 清空缓存,防止内存泄漏
  64 +
  65 +---
  66 +
  67 +## 修改的文件
  68 +
  69 +- `src/pages/Workspace.vue`
  70 +
  71 +---
  72 +
  73 +## 测试结果
  74 +
  75 +- `npm run type-check` 通过,无 TypeScript 错误
  76 +- `npm run test:unit` 通过,19/19 用例全部通过
  77 +- `npm run test:e2e --grep 1008933`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
  78 +
  79 +## 验收条件
  80 +
  81 +1. 拖动工作台分割线调整面板宽度时流畅,无卡顿感 ✅
  82 +2. 拖拽结束后面板宽度正确停在目标位置 ✅
  83 +
  84 +---
  85 +
  86 +## 测试覆盖
  87 +
  88 +### 单元测试(Vitest)
  89 +
  90 +性能优化,无新增单元测试,已有 19 个用例全部通过。
  91 +
  92 +### E2E 测试(Playwright)
  93 +
  94 +**文件**`e2e/tests/1008933-workspace-drag-performance.spec.ts`(2 个用例)
  95 +
  96 +| 用例 | 验证内容 |
  97 +|------|---------|
  98 +| generator-area 在 resizing 状态下 transition 应为 none | 手动添加 `.resizing` 类后,computed `transition` 匹配 none |
  99 +| 工作台页面存在拖拽分割线和 workspace-content 容器 | `.workspace-content` 存在,`.resize-handle` 数量 > 0 |
  1 +# 缺陷 1008937 - 文档解析状态排成两排,很丑,如红色框图
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:文档解析状态排成两排,很丑,如红色框图
  6 +
  7 +**状态**:接受/处理 | **负责人**:— | **严重程度**:一般 | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-20 00:42 | **迭代**:0.6.28.0(当前迭代)
  10 +
  11 +**原始描述**
  12 +
  13 +> 文档解析状态排成两排,很丑,如红色框图
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +> **说明**:TAPD 为 SPA,图片链接无法通过 API 直接获取。根据缺陷标题和代码分析定位问题。
  20 +
  21 +**推断画面**:知识库文件列表中,"文档解析状态"列的列标题文字("文档解析状态" + 问号图标)因列宽不足,换行显示为两行,外观丑陋。
  22 +
  23 +---
  24 +
  25 +## 根因分析
  26 +
  27 +`FileList.vue` 的文档解析状态列配置:
  28 +- 列宽:`width="140"`
  29 +- 表格 cell 内边距约 12px×2=24px,实际内容区宽度约 116px
  30 +- 列标题内容:`"文档解析状态"`(7个汉字×14px≈98px)+ 4px gap + 问号图标(14px)≈ **116px**
  31 +
  32 +刚好与可用宽度持平,在字体渲染略有偏差或内边距略大时超出,导致问号图标换行到第二行。
  33 +
  34 +`.status-header` 使用 `display: inline-flex` 但未设置 `white-space: nowrap`,在 `el-table` 的 `.cell` 容器(`white-space: normal`)中会允许折行。
  35 +
  36 +---
  37 +
  38 +## 修复方案
  39 +
  40 +1.`.status-header` 和 `.status-cell` 都加 `white-space: nowrap`,防止内容折行
  41 +2. 列宽从 `140` 调大至 `160`,提供足够的安全余量
  42 +
  43 +---
  44 +
  45 +## 修改的文件
  46 +
  47 +- `src/components/KnowledgeBase/FileList.vue`
  48 +
  49 +---
  50 +
  51 +## 测试结果
  52 +
  53 +- `npm run type-check` 通过,无 TypeScript 错误
  54 +- `npm run test:unit` 通过,19/19 用例全部通过
  55 +- `npm run test:e2e --grep 1008937`:2 个用例跳过(E2E 测试未配置登录态,知识库页需登录才可见表格内容,与现有 E2E 测试处理方式一致)
  56 +
  57 +## 验收条件
  58 +
  59 +1. 文档解析状态列标题("文档解析状态" + 问号图标)在同一行内显示,不换行 ✅
  60 +2. 列宽从 140 调大至 160px,有足够安全余量 ✅
  61 +3. 状态标签与重试图标在同一行内显示,不换行 ✅
  62 +
  63 +---
  64 +
  65 +## 测试覆盖
  66 +
  67 +### 单元测试(Vitest)
  68 +
  69 +CSS 布局修复,无新增单元测试,已有 19 个用例全部通过。
  70 +
  71 +### E2E 测试(Playwright)
  72 +
  73 +**文件**`e2e/tests/1008937-knowledge-status-column.spec.ts`(2 个用例)
  74 +
  75 +| 用例 | 验证内容 |
  76 +|------|---------|
  77 +| 文档解析状态列标题应在单行内显示 | `.status-header` computed `white-space: nowrap`,高度 ≤ 50px |
  78 +| 文档解析状态单元格内容应在单行内显示 | `.status-cell` computed `white-space: nowrap`,高度 ≤ 50px |
  1 +# 缺陷 1008567 - Word 文件解析失败
  2 +
  3 +> **说明**:原修复(commit `450de3577`)于 2026-03-19 完成,随后被 commit `33e568097` 整体撤销,commit `1c1d4b957` 仅补回了 TOS 上传代码,预览修复未恢复。本次在 2026-03-23 重新实现,并经过 6 轮迭代修复才最终完成。
  4 +
  5 +---
  6 +
  7 +## 原始需求(一字不差)
  8 +
  9 +**缺陷标题**:word-文件解析失败
  10 +
  11 +**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
  12 +
  13 +**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
  14 +
  15 +**原始描述**
  16 +
  17 +> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
  18 +
  19 +**评论**
  20 +
  21 +> **[2026-03-11 14:33:34] 张倩如**:
  22 +> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
  23 +>
  24 +> ↳ **[2026-03-11 14:33:57] 张倩如**:
  25 +> 仅预览,不要支持修改!!!!
  26 +
  27 +---
  28 +
  29 +## 图片理解
  30 +
  31 +### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
  32 +
  33 +**画面内容**
  34 +- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ?`
  35 +- 左侧文件树显示多个文件
  36 +- 右侧为 AI 对话面板,正常运行
  37 +
  38 +**关键细节**
  39 +- 错误来自 jszip 库,`.docx` 本质是 ZIP 包,jszip 无法识别旧版 `.doc` 文件头
  40 +- 错误出现在文档加载阶段
  41 +
  42 +### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
  43 +
  44 +**画面内容**
  45 +- 工作台左侧编辑区显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
  46 +- 右侧 AI 面板可正常对文档内容进行问答
  47 +
  48 +**关键细节**
  49 +- 错误码 88 来自 X2T WASM 本地转换失败
  50 +- 文件在知识库侧可正常解析提问,说明文件本身完好
  51 +- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
  52 +
  53 +---
  54 +
  55 +## 根因分析
  56 +
  57 +`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足(返回 code 88)。后端已将旧版文件转换为新版格式并上传至 TOS,前端直接拉取 TOS 转换版本即可。
  58 +
  59 +---
  60 +
  61 +## 实现方案
  62 +
  63 +### 数据流
  64 +
  65 +```
  66 +OnlyOfficeViewer.initViewer()
  67 + → [旧版格式 doc/ppt/xls] 尝试 TOS: docs-parsed/prod{fileId}/convert.{toExt}
  68 + ├─ TOS 命中 → 使用转换版本 blob
  69 + └─ TOS 未命中 → 立即展示友好提示(不走 X2T)
  70 + → [新版格式 docx/pptx/xlsx] 跳过 TOS,走缓存/原始下载
  71 + → openDocument(file, elementId, onReady)
  72 + → x2tConverter.convertDocument(file) // X2T WASM 转换为 .bin
  73 + → createEditorInstance(...) // 初始化 OnlyOffice 编辑器
  74 + → onAppReady: asc_openDocument(bin) // 二进制注入加载
  75 + → 1.5s 后触发 onReady 回调(注入方式不触发 onDocumentReady)
  76 +```
  77 +
  78 +### 1. OnlyOfficeViewer.vue
  79 +
  80 +- `toExtMap` 仅含旧格式:`{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`
  81 +- 旧格式 TOS 未命中 → 立即 throw 友好错误,不再尝试 X2T 转换
  82 +- 新格式走缓存 → 原始下载 → X2T 流程
  83 +- 错误分支:X2T 转换失败(`Conversion failed`)→ 「文件解析失败,请重新上传后再试」
  84 +- 错误分支:旧版格式 TOS 未命中 → 「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
  85 +
  86 +### 2. converter.ts
  87 +
  88 +- `createEditorInstance` / `openDocument` 去掉 `readOnly` 参数(文档通过二进制注入加载,无保存地址,用户无法持久化修改,不需要限制 permissions)
  89 +- `editorConfig.user: { id: 'local', name: 'User' }` 防止用户信息为空
  90 +- `customization: { help: false, about: false }` 仅隐藏帮助/关于弹窗
  91 +- 移除 `ElMessage.error`,由调用方统一处理错误展示
  92 +
  93 +### 3. app.js patch(三个编辑器)
  94 +
  95 +**getInitials null guard**(documenteditor / spreadsheeteditor / presentationeditor):
  96 +```js
  97 +// 原代码:文档内嵌修改记录作者名可能为 undefined 导致崩溃
  98 +getInitials: function(t) { return t.split(' ')... }
  99 +// 修复后
  100 +getInitials: function(t) { if (!t) return ''; return t.split(' ')... }
  101 +```
  102 +
  103 +**spellcheck null guard**(presentationeditor 独有):
  104 +```js
  105 +// 原代码:customization.features 可能未提供导致崩溃
  106 +t.isEdit && !1 !== t.customization.features.spellcheck.change
  107 +// 修复后
  108 +t.isEdit && t.customization.features && t.customization.features.spellcheck && !1 !== t.customization.features.spellcheck.change
  109 +```
  110 +
  111 +**缓存破坏**:三个编辑器的 `index.html` 均加入:
  112 +```html
  113 +<script>var require = { urlArgs: "v=20260324" };</script>
  114 +```
  115 +
  116 +---
  117 +
  118 +## 修改的文件
  119 +
  120 +- `src/components/Workspace/OnlyOfficeViewer.vue`
  121 +- `src/utils/onlyoffice/converter.ts`
  122 +- `public/document/web-apps/apps/documenteditor/main/app.js`
  123 +- `public/document/web-apps/apps/documenteditor/main/index.html`
  124 +- `public/document/web-apps/apps/spreadsheeteditor/main/app.js`
  125 +- `public/document/web-apps/apps/spreadsheeteditor/main/index.html`
  126 +- `public/document/web-apps/apps/presentationeditor/main/app.js`
  127 +- `public/document/web-apps/apps/presentationeditor/main/index.html`
  128 +
  129 +---
  130 +
  131 +## 测试结果
  132 +
  133 +- `npm run type-check` 通过,无 TypeScript 错误
  134 +- `npm run test:unit` 通过,19/19 用例全部通过(已有测试,本次无新增)
  135 +- 实机测试:`.doc` 文件 TOS 命中时正常渲染,TOS 未命中时展示友好提示;`.docx` 文件完整工具栏正常显示;PPT 打开无 spellcheck 崩溃
  136 +
  137 +---
  138 +
  139 +## 测试覆盖
  140 +
  141 +本次为缺陷修复,未新增专项单元测试。已有 19 个用例全部通过。
  142 +
  143 +---
  144 +
  145 +## 迭代过程(6 轮)
  146 +
  147 +### 第 1 轮:基础实现(commit `780f384bd`)
  148 +
  149 +首次实现 TOS 优先加载策略:
  150 +- `OnlyOfficeViewer.vue` 新增 TOS → 缓存 → 原始下载的三级加载顺序
  151 +- `converter.ts` 新增 `readOnly` 参数,`permissions: { edit: false }`
  152 +- `asc_openDocument` 注入方式不触发 `onDocumentReady`,改为 1.5s 延迟回调兜底
  153 +- `.editor-container` 改为 `position: absolute` 确保全屏覆盖
  154 +
  155 +---
  156 +
  157 +### 第 2 轮:修复 TOS 转换范围错误(commit `e1eb62395`)
  158 +
  159 +**问题**`toExtMap` 误将 `docx/pptx/xlsx` 也包含进去,导致这些新格式文件打开时也向 TOS 发起 404 请求。
  160 +
  161 +**根因**:新格式文件本身已是最新版 Office 格式,X2T 可直接处理,不需要 TOS 转换版本。
  162 +
  163 +**修复**`toExtMap` 仅保留 `{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`,新格式直接走缓存/原始下载。
  164 +
  165 +---
  166 +
  167 +### 第 3 轮:修复 getInitials 崩溃(commits `b1b5db1d6` `8742f3601` `e455513df` `d4816ff2a`)
  168 +
  169 +**问题**:打开文档时报 `TypeError: Cannot read properties of undefined (reading 'split')`,崩溃于 `app.js:20911` 的 `getInitials` 函数。
  170 +
  171 +**排查过程**
  172 +1. 补充 `user: { id: 'local', name: 'User' }` → 无效(崩溃来自文档内嵌元数据,不是当前用户)
  173 +2.`mode: 'view'` → 无效
  174 +3.`permissions: { review: false, comment: false }` → 无效
  175 +4. 加日志确认调用链:`render:before → getPanel → setUserName → getInitials(undefined)`
  176 +
  177 +**根因**:文档内嵌的修改记录中存在作者名为 `undefined` 的条目,`getInitials` 函数未做 null 判断。
  178 +
  179 +**修复**
  180 +- patch 三个编辑器的 `app.js`:`getInitials: function(t) { if (!t) return ''; ... }`
  181 +- `index.html` 加 `var require = { urlArgs: "v=20260323" }` 破除浏览器强缓存,确保 patch 生效
  182 +
  183 +---
  184 +
  185 +### 第 4 轮:恢复完整工具栏(commits `b4c267c7d` `c65ca6903` `55c866f2c`)
  186 +
  187 +**问题**:打开文档后工具栏大量按钮缺失,只剩「文件」「视图」两个菜单。
  188 +
  189 +**排查过程**
  190 +1. `mode: 'view'` → 直接隐藏全部工具栏,移除后工具栏出现但仍有缺失
  191 +2. `permissions: { review: false, comment: false, chat: false, protect: false }` → 每个 `false` 都会隐藏对应工具栏 tab,逐一移除
  192 +3. `customization.hideRightMenu` 等 → 隐藏右侧面板,移除
  193 +
  194 +**根因**`permissions` 中任何权限设为 `false` 都会导致对应 UI 区域消失,而非仅禁用操作。
  195 +
  196 +**结论**:文档通过 `asc_openDocument` 二进制注入加载,无保存地址,用户无法持久化修改。不需要从 `permissions` 层面限制,直接去掉所有限制恢复完整 UI 即可。
  197 +
  198 +---
  199 +
  200 +### 第 5 轮:修复 presentationeditor spellcheck 崩溃(commit `17297e862`)
  201 +
  202 +**问题**:打开 `.ppt/.pptx` 文件时报 `TypeError: Cannot read properties of undefined (reading 'spellcheck')`,位于 `presentationeditor/app.js:52615`
  203 +
  204 +**根因**:app.js 内部执行 `t.customization.features.spellcheck.change`,当外部未提供 `customization.features` 字段时 `features` 为 `undefined`,访问其 `.spellcheck` 崩溃。
  205 +
  206 +**修复**
  207 +- patch `presentationeditor/app.js`:加 null guard `t.customization.features && t.customization.features.spellcheck &&`
  208 +- urlArgs 升至 `v=20260324` 破缓存
  209 +
  210 +---
  211 +
  212 +### 第 6 轮:旧版格式打开体验优化(commits `a71c8b25f` `82a2c7b7d` `c0873bdc0` `99fdbaa9f` `02a7a4bb2`)
  213 +
  214 +**问题**`.doc` 等旧版格式 TOS 未命中时,仍会尝试下载原文件走 X2T 转换,转换失败后弹出 `文档打开失败: Conversion failed with code: 88` 技术报错,体验差。
  215 +
  216 +**修复**
  217 +- 旧版格式 TOS 未命中时立即 throw 友好错误,不再走 X2T 转换流程
  218 +- 错误提示:「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
  219 +- X2T 转换失败兜底:「文件解析失败,请重新上传后再试」
  220 +- 移除 `converter.ts` 中的 `ElMessage.error`,由组件统一展示错误
  1 +# 缺陷 1008932 - 这个提醒,不知道是什么意思
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:这个提醒,不知道是什么意思
  6 +
  7 +**状态**:接受/处理 | **负责人**:尹帮会 | **严重程度**:— | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-19 21:24 | **迭代**:—
  10 +
  11 +**评论**
  12 +- [2026-03-20 14:05] 张倩如:国际化问题
  13 +
  14 +---
  15 +
  16 +## 图片理解
  17 +
  18 +### 截图(用户提供)
  19 +
  20 +用户上传 `users_20260319.csv` 文件到知识库,顶部出现橙色警告通知:
  21 +
  22 +```
  23 +users_20260319.csv: KnowledgeBase.unsupportedFileType: .csv
  24 +```
  25 +
  26 +**关键细节**:通知文本直接暴露了 vue-i18n 的 key 名 `KnowledgeBase.unsupportedFileType`,而非翻译后的中文。用户看到原始 key 字符串,完全不知道是什么意思。
  27 +
  28 +---
  29 +
  30 +## 根因分析
  31 +
  32 +`src/components/Workspace/FileTreeNode.vue` 第 754 行:
  33 +
  34 +```js
  35 +reason: extension
  36 + ? `${t("KnowledgeBase.unsupportedFileType")}: .${extension}`
  37 + : t("KnowledgeBase.fileHasNoExtension"),
  38 +```
  39 +
  40 +`en-US.ts` 中有对应翻译:
  41 +```
  42 +unsupportedFileType: "Unsupported file type",
  43 +fileHasNoExtension: "File has no extension",
  44 +```
  45 +
  46 +`zh-CN.ts` 的 `KnowledgeBase` 对象中**缺少这两个 key**,vue-i18n 在找不到 key 时回退显示 key 字符串本身,导致用户看到 `KnowledgeBase.unsupportedFileType: .csv`
  47 +
  48 +---
  49 +
  50 +## 修复方案
  51 +
  52 +`src/locales/zh-CN.ts` 的 `KnowledgeBase` 对象中,`fileSizeExceeded` 之后补充两个缺失的翻译 key:
  53 +
  54 +```ts
  55 +fileSizeExceeded: "文件大小不能超过 100MB",
  56 +unsupportedFileType: "不支持的文件类型", // 新增
  57 +fileHasNoExtension: "文件缺少扩展名", // 新增
  58 +```
  59 +
  60 +修复后通知显示为:`users_20260319.csv: 不支持的文件类型: .csv`,用户可以理解。
  61 +
  62 +---
  63 +
  64 +## 修改的文件
  65 +
  66 +- `src/locales/zh-CN.ts`
  67 +
  68 +---
  69 +
  70 +## 测试结果
  71 +
  72 +- `npm run type-check` 通过,无 TypeScript 错误
  73 +- `npm run test:unit` 通过,19/19 用例全部通过
  74 +- `npx playwright test e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
  75 +
  76 +## 验收条件
  77 +
  78 +1. 上传 `.csv` 等不支持格式的文件时,通知显示"不支持的文件类型: .csv"而非原始 key ✅
  79 +2. 无扩展名文件显示"文件缺少扩展名"而非"KnowledgeBase.fileHasNoExtension" ✅
  80 +
  81 +---
  82 +
  83 +## 测试覆盖
  84 +
  85 +### 单元测试(Vitest)
  86 +
  87 +i18n key 补充,无新增单元测试,已有 19 个用例全部通过。
  88 +
  89 +### E2E 测试(Playwright)
  90 +
  91 +**文件**`e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`(2 个用例)
  92 +
  93 +| 用例 | 验证内容 |
  94 +|------|---------|
  95 +| zh-CN.ts 包含 KnowledgeBase.unsupportedFileType 中文翻译 | 通过 vue-i18n 实例验证翻译值非 key 名且含中文 |
  96 +| zh-CN.ts 包含 KnowledgeBase.fileHasNoExtension 中文翻译 | 同上 |
  1 +# 缺陷 1008933 - 工作台,左右拖动编辑器,会感觉很卡
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:工作台,左右拖动编辑器,会感觉很卡
  6 +
  7 +**状态**:新 | **负责人**:— | **严重程度**:致命 | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-19 22:02 | **迭代**:0.6.28.0(当前迭代)
  10 +
  11 +---
  12 +
  13 +## 根因分析
  14 +
  15 +工作台拖拽分割线调整面板宽度时存在两个性能问题:
  16 +
  17 +### 问题 1:generator-area 拖拽时未禁用 transition(主因)
  18 +
  19 +`generator-area` 配置了三个 CSS 过渡动画:
  20 +
  21 +```css
  22 +transition:
  23 + width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  24 + min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
  25 + max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  26 +```
  27 +
  28 +拖拽时 `isResizing = true`,`.resizing` 类会绑定到 `generator-area`,但 CSS 里没有对应的 `&.resizing { transition: none }`,导致每帧宽度变化都触发 0.35s 动画,产生严重卡顿感。
  29 +
  30 +对比:`editor-area-work` 已有 `&.resizing { transition: none }` 正确处理,而 `generator-area` 遗漏了。
  31 +
  32 +### 问题 2:每帧重新查询 DOM(次因)
  33 +
  34 +`handleMouseMove` 的 RAF 回调里每帧都执行:
  35 +
  36 +```js
  37 +const container = document.querySelector(".workspace-content");
  38 +```
  39 +
  40 +浏览器每帧(16ms)都需要遍历 DOM 树查找元素,虽然影响较小,但在高频拖拽下会累积。
  41 +
  42 +---
  43 +
  44 +## 修复方案
  45 +
  46 +### 1. generator-area 拖拽时禁用 transition(`src/pages/Workspace.vue`)
  47 +
  48 +```css
  49 +.generator-area {
  50 + transition: width 0.35s ..., min-width 0.35s ..., max-width 0.35s ...;
  51 +
  52 + &.resizing {
  53 + transition: none; /* 新增 */
  54 + }
  55 +}
  56 +```
  57 +
  58 +### 2. 缓存 `.workspace-content` DOM 引用
  59 +
  60 +- 声明模块级变量 `let workspaceContainerEl: Element | null = null`
  61 +-`startResize` / `startResizeLeftEdge` 时查询并缓存
  62 +- `handleMouseMove` RAF 回调优先使用缓存
  63 +- `handleMouseUp` 清空缓存,防止内存泄漏
  64 +
  65 +---
  66 +
  67 +## 修改的文件
  68 +
  69 +- `src/pages/Workspace.vue`
  70 +
  71 +---
  72 +
  73 +## 测试结果
  74 +
  75 +- `npm run type-check` 通过,无 TypeScript 错误
  76 +- `npm run test:unit` 通过,19/19 用例全部通过
  77 +- `npm run test:e2e --grep 1008933`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
  78 +
  79 +## 验收条件
  80 +
  81 +1. 拖动工作台分割线调整面板宽度时流畅,无卡顿感 ✅
  82 +2. 拖拽结束后面板宽度正确停在目标位置 ✅
  83 +
  84 +---
  85 +
  86 +## 测试覆盖
  87 +
  88 +### 单元测试(Vitest)
  89 +
  90 +性能优化,无新增单元测试,已有 19 个用例全部通过。
  91 +
  92 +### E2E 测试(Playwright)
  93 +
  94 +**文件**`e2e/tests/1008933-workspace-drag-performance.spec.ts`(2 个用例)
  95 +
  96 +| 用例 | 验证内容 |
  97 +|------|---------|
  98 +| generator-area 在 resizing 状态下 transition 应为 none | 手动添加 `.resizing` 类后,computed `transition` 匹配 none |
  99 +| 工作台页面存在拖拽分割线和 workspace-content 容器 | `.workspace-content` 存在,`.resize-handle` 数量 > 0 |
  1 +# 缺陷 1008937 - 文档解析状态排成两排,很丑,如红色框图
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:文档解析状态排成两排,很丑,如红色框图
  6 +
  7 +**状态**:接受/处理 | **负责人**:— | **严重程度**:一般 | **优先级**:— | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-20 00:42 | **迭代**:0.6.28.0(当前迭代)
  10 +
  11 +**原始描述**
  12 +
  13 +> 文档解析状态排成两排,很丑,如红色框图
  14 +
  15 +---
  16 +
  17 +## 图片理解
  18 +
  19 +> **说明**:TAPD 为 SPA,图片链接无法通过 API 直接获取。根据缺陷标题和代码分析定位问题。
  20 +
  21 +**推断画面**:知识库文件列表中,"文档解析状态"列的列标题文字("文档解析状态" + 问号图标)因列宽不足,换行显示为两行,外观丑陋。
  22 +
  23 +---
  24 +
  25 +## 根因分析
  26 +
  27 +`FileList.vue` 的文档解析状态列配置:
  28 +- 列宽:`width="140"`
  29 +- 表格 cell 内边距约 12px×2=24px,实际内容区宽度约 116px
  30 +- 列标题内容:`"文档解析状态"`(7个汉字×14px≈98px)+ 4px gap + 问号图标(14px)≈ **116px**
  31 +
  32 +刚好与可用宽度持平,在字体渲染略有偏差或内边距略大时超出,导致问号图标换行到第二行。
  33 +
  34 +`.status-header` 使用 `display: inline-flex` 但未设置 `white-space: nowrap`,在 `el-table` 的 `.cell` 容器(`white-space: normal`)中会允许折行。
  35 +
  36 +---
  37 +
  38 +## 修复方案
  39 +
  40 +1.`.status-header` 和 `.status-cell` 都加 `white-space: nowrap`,防止内容折行
  41 +2. 列宽从 `140` 调大至 `160`,提供足够的安全余量
  42 +
  43 +---
  44 +
  45 +## 修改的文件
  46 +
  47 +- `src/components/KnowledgeBase/FileList.vue`
  48 +
  49 +---
  50 +
  51 +## 测试结果
  52 +
  53 +- `npm run type-check` 通过,无 TypeScript 错误
  54 +- `npm run test:unit` 通过,19/19 用例全部通过
  55 +- `npm run test:e2e --grep 1008937`:2 个用例跳过(E2E 测试未配置登录态,知识库页需登录才可见表格内容,与现有 E2E 测试处理方式一致)
  56 +
  57 +## 验收条件
  58 +
  59 +1. 文档解析状态列标题("文档解析状态" + 问号图标)在同一行内显示,不换行 ✅
  60 +2. 列宽从 140 调大至 160px,有足够安全余量 ✅
  61 +3. 状态标签与重试图标在同一行内显示,不换行 ✅
  62 +
  63 +---
  64 +
  65 +## 测试覆盖
  66 +
  67 +### 单元测试(Vitest)
  68 +
  69 +CSS 布局修复,无新增单元测试,已有 19 个用例全部通过。
  70 +
  71 +### E2E 测试(Playwright)
  72 +
  73 +**文件**`e2e/tests/1008937-knowledge-status-column.spec.ts`(2 个用例)
  74 +
  75 +| 用例 | 验证内容 |
  76 +|------|---------|
  77 +| 文档解析状态列标题应在单行内显示 | `.status-header` computed `white-space: nowrap`,高度 ≤ 50px |
  78 +| 文档解析状态单元格内容应在单行内显示 | `.status-cell` computed `white-space: nowrap`,高度 ≤ 50px |
  1 +# 缺陷 1008898 - 文件上传的排序不对
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:文件上传的排序不对
  6 +
  7 +**状态**:new | **负责人**:尹帮会 | **严重程度**:serious | **优先级**:high | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-10 12:07:50 | **迭代**:—
  10 +
  11 +**描述**:文件上传的排序不对,排序是先上传的在列表的下面,后上传的在上面。我如果上传很多的文件,这个时候,看不到先上传的文件已经被上传了,需要我拖动下拉框到下面,才能看到。需要反过来,把先上传的文件放到列表最上面,这样我就能看到文件一个一个的被上传,被解析。
  12 +
  13 +**评论**:无
  14 +
  15 +---
  16 +
  17 +## 根因分析
  18 +
  19 +`src/components/KnowledgeBase/UploadProgress.vue` 中:
  20 +
  21 +```ts
  22 +// 原代码
  23 +const reversedUploadFiles = computed(() => {
  24 + return [...props.uploadFiles].reverse(); // ← 反转后,最新上传的排第一
  25 +});
  26 +```
  27 +
  28 +`uploadFiles` 数组按上传顺序追加(`push`),第 0 项为最先上传的文件。`.reverse()` 将顺序完全颠倒,导致最新上传的文件出现在列表顶部,最先上传的在底部——用户必须手动下拉才能看到第一个文件的进度。
  29 +
  30 +同时,新文件加入时触发 `scrollToTop()`,滚动到列表顶部(显示最新文件),与用户期望相反。
  31 +
  32 +---
  33 +
  34 +## 修复方案
  35 +
  36 +**文件**`src/components/KnowledgeBase/UploadProgress.vue`
  37 +
  38 +### 修改一:去掉 reverse(),保持原始顺序
  39 +
  40 +```ts
  41 +// 修复后
  42 +const reversedUploadFiles = computed(() => {
  43 + return props.uploadFiles; // 先上传的排在最上方
  44 +});
  45 +```
  46 +
  47 +### 修改二:新文件加入时滚动到底部
  48 +
  49 +```ts
  50 +// 修复后
  51 +const scrollToTop = () => {
  52 + if (uploadListRef.value) {
  53 + uploadListRef.value.scrollTo({
  54 + top: uploadListRef.value.scrollHeight, // 滚动到底部,显示最新添加的文件
  55 + behavior: "smooth",
  56 + });
  57 + }
  58 +};
  59 +```
  60 +
  61 +`UploadProgress` 组件被 `KnowledgeBase.vue` 和 `Workspace.vue` 共用,一处修复,两处生效。
  62 +
  63 +---
  64 +
  65 +## 修改的文件
  66 +
  67 +- `src/components/KnowledgeBase/UploadProgress.vue`
  68 +
  69 +---
  70 +
  71 +## 测试结果
  72 +
  73 +- `npm run type-check` 通过,无 TypeScript 错误
  74 +- `npm run test:unit` 通过,19/19 用例全部通过
  75 +- `npx playwright test e2e/tests/2026-03-24/1008898-upload-list-order.spec.ts`:1/1 通过
  76 +
  77 +## 验收条件
  78 +
  79 +1. 同时上传多个文件时,第一个上传的文件显示在列表最上方 ✅
  80 +2. 新文件加入后,列表自动滚动到底部,显示最新添加的文件 ✅
  81 +3. 知识库页面和工作台页面的上传列表行为一致 ✅
  82 +
  83 +---
  84 +
  85 +## 测试覆盖
  86 +
  87 +### E2E 测试(Playwright)
  88 +
  89 +**文件**`e2e/tests/2026-03-24/1008898-upload-list-order.spec.ts`(1 个用例)
  90 +
  91 +| 用例 | 验证内容 |
  92 +|------|---------|
  93 +| 先上传的文件排在列表顶部(不反转) | 验证原始顺序第一项为 first.pdf,reverse() 后第一项为 third.xlsx(确认 bug 存在) |
  1 +# 1008945 - 知识库为空的时候的 UI
  2 +
  3 +## 缺陷信息
  4 +
  5 +- **缺陷 ID**:1008945
  6 +- **标题**:知识库为空的时候的 UI
  7 +- **优先级**:medium
  8 +- **负责人**:尹帮会
  9 +- **创建者**:小润润
  10 +
  11 +## 问题描述
  12 +
  13 +知识库文件列表为空时,使用的是 Element Plus `el-table` 默认的空状态(通用文档图标 + "暂无数据"),视觉体验欠佳,不符合产品设计规范。
  14 +
  15 +截图可见:选中一个空目录时,列表区域展示通用占位图,缺乏品牌感。
  16 +
  17 +## 根因分析
  18 +
  19 +`FileList.vue` 中的 `el-table` 没有定制 `#empty` 插槽,直接使用了 Element Plus 内置的默认空状态 UI。
  20 +
  21 +## 修复方案
  22 +
  23 +1.`public/暂无数据.svg` 移至 `src/assets/empty-state.svg`(规范命名 + 内联加载)
  24 +2. 去掉 SVG 文件中的 XML 声明/DOCTYPE,移除根元素硬编码的 `width/height`
  25 +3.`el-table` 中添加 `#empty` 插槽,用 `?raw` + `v-html` 内联渲染 SVG,消除异步加载闪烁
  26 +4.`:deep(svg)` 穿透 scoped 样式控制图标尺寸(140px)
  27 +5. 隐藏 `.el-table__inner-wrapper::before` 去掉表格底部边线
  28 +6. 补充 i18n key:`KnowledgeBase.emptyState`(中文"暂无数据",英文"No Data")
  29 +
  30 +## 迭代记录
  31 +
  32 +### 第 1 轮
  33 +
  34 +**修改文件:**
  35 +- `public/暂无数据.svg` → `public/empty-state.svg`(重命名)
  36 +- `src/components/KnowledgeBase/FileList.vue`:添加 `#empty` 插槽 + 样式
  37 +- `src/locales/zh-CN.ts`:添加 `emptyState: "暂无数据"`
  38 +- `src/locales/en-US.ts`:添加 `emptyState: "No Data"`
  39 +
  40 +**结果:** 空目录下展示自定义空状态图标和文字,但存在两个问题:图片异步加载导致文字先于图片出现;表格底部有多余边线。
  41 +
  42 +---
  43 +
  44 +### 第 2 轮
  45 +
  46 +**问题:** 先出现"暂无数据"文字再出现图片;表格底部边线未去除。
  47 +
  48 +**根因:**
  49 +- `img src="/empty-state.svg"` 从 public 目录异步加载,文字先渲染
  50 +- 错误地隐藏了 `::after`,实际底部边线由 `::before`
  51 +
  52 +**修改:** SVG 移入 `src/assets/`,改用 `?raw` + `v-html` 内联;隐藏 `::before`
  53 +
  54 +**结果:** SVG 同步内联无闪烁,但图标与文字仍未对齐。
  55 +
  56 +---
  57 +
  58 +### 第 3 轮
  59 +
  60 +**问题:** 图标和文字未对齐;底部边线依然存在。
  61 +
  62 +**根因:** `v-html` 渲染的 div 默认 block,内部 SVG 左对齐
  63 +
  64 +**修改:** `empty-state-img` 改为 `display: flex; justify-content: center`
  65 +
  66 +**结果:** 图标居中对齐,底部边线消除。
  67 +
  68 +---
  69 +
  70 +### 第 4 轮
  71 +
  72 +**问题:** 图标太大,尝试将 CSS 中 `svg { width: 160px }` 改为 `100px`,实际未生效。
  73 +
  74 +**根因:** scoped 样式中 `svg {}` 编译为 `svg[data-v-xxx]`,但 `v-html` 内容不带 scoped 属性,选择器不匹配
  75 +
  76 +**结果:** 图标尺寸未变(仍为 SVG 原始尺寸)。
  77 +
  78 +---
  79 +
  80 +### 第 5 轮
  81 +
  82 +**问题:** 尝试用 JS regex 去掉 SVG `width/height` 属性后,图标完全消失。
  83 +
  84 +**根因:**
  85 +- SVG 文件含 XML 声明(`<?xml?>`)和 DOCTYPE,`v-html` 渲染时浏览器解析异常
  86 +- 去掉 `width/height` 后 SVG 在 flex 容器内无尺寸参考,塌缩不可见
  87 +- 需用 `:deep(svg)` 才能让 scoped 样式穿透 `v-html` 内容
  88 +
  89 +**修改:**
  90 +- 直接编辑 SVG 文件:去掉 XML 声明/DOCTYPE,移除根元素 `width/height`
  91 +- JS 还原为直接使用 `?raw` 字符串
  92 +- CSS 改为 `:deep(svg) { width: 100px; height: auto }`
  93 +
  94 +**结果:** 图标正常显示,但 100px 偏小。
  95 +
  96 +---
  97 +
  98 +### 第 6 轮
  99 +
  100 +**问题:** 图标 100px 偏小。
  101 +
  102 +**修改:** `:deep(svg) { width: 140px }`
  103 +
  104 +**结果:** 尺寸合适,空状态 UI 完整展示,验收通过。
  1 +# 缺陷 1008946 - 上传到知识库的文件解析完之后,不更新前端的状态
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:上传到知识库的文件解析完之后,不更新前端的状态
  6 +
  7 +**状态**:进行中 | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:high | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-20 16:22:59 | **迭代**:—
  10 +
  11 +**描述**:上传到知识库的文件解析完之后,不更新前端的状态,前端一直显示转圈,要前端人工刷新之后,才能完成更新
  12 +
  13 +**评论**:无
  14 +
  15 +---
  16 +
  17 +## 根因分析
  18 +
  19 +### 上传流程梳理
  20 +
  21 +1. 用户上传文件 → 后端存储文件并创建知识处理任务
  22 +2. 前端通过 `/files/upload/progress/{uploadId}` SSE 监听上传进度
  23 +3. **SSE 在文件"提交给知识服务"后即关闭**(此时文件 `knowledgeStatus = processing`
  24 +4. Node.js 知识服务开始异步处理(文本抽取 → 分块 → 向量化),可能需要数十秒至数分钟
  25 +5. 处理完成后 `knowledgeStatus` 变为 `completed`——**但前端无感知**
  26 +
  27 +### 关键差异
  28 +
  29 +| 页面 | 轮询机制 |
  30 +|------|---------|
  31 +| `KnowledgeBase/FileList.vue` | ✅ 有 10 秒轮询(第 1096 行) |
  32 +| `Workspace.vue`(工作台文件树) | ❌ 无,SSE 关闭后不再更新状态 |
  33 +
  34 +---
  35 +
  36 +## 迭代记录
  37 +
  38 +### 第 1 轮:新增全量轮询
  39 +
  40 +**commit**`96b92e82a`
  41 +
  42 +新增 `hasProcessingFiles` computed + 每 10 秒 `loadKnowledgeFiles(true)` 轮询。
  43 +
  44 +**问题**:轮询代码块插入在 `knowledgeFiles = ref([])` **之前**,导致运行时报错:
  45 +
  46 +```
  47 +Uncaught (in promise) ReferenceError: Cannot access 'knowledgeFiles' before initialization
  48 +```
  49 +
  50 +---
  51 +
  52 +### 第 2 轮:修正初始化顺序
  53 +
  54 +**commit**`550ca3d2d`
  55 +
  56 +将轮询代码块移至 `knowledgeFiles` 定义之后,消除暂时性死区错误。
  57 +
  58 +**新问题**`loadKnowledgeFiles(true)` 内部调用 `treeRefreshKey.value++` 会强制重挂载整棵文件树。重挂载后,所有曾经展开的目录(从 localStorage 读到展开状态)重新触发 `load-children`,每 10 秒产生一轮目录级联查询。用户切换文档预览时能明显观察到左侧目录的冗余请求。
  59 +
  60 +---
  61 +
  62 +### 第 3 轮:改为精准节点更新
  63 +
  64 +**commit**`0d679c4a1`
  65 +
  66 +不再调用 `loadKnowledgeFiles(true)`,改为 `pollProcessingFileStatuses`
  67 +- 递归收集树中所有 `processing` 节点
  68 +- 对每个节点单独调用 `fetchFileKnowledgeStatus`(`getFileMeta`
  69 +- **直接修改节点的 `knowledgeStatus` 属性**,不重建树,不触发 `treeRefreshKey++`
  70 +
  71 +**遗留问题(用户反馈)**
  72 +1. 切换到已解析完成的文档时,`setActiveTab` 仍无条件调用 `fetchFileKnowledgeStatus`,产生冗余请求
  73 +2. 轮询状态更新仅同步激活 tab,其他打开中的同文件 tab 状态滞后
  74 +
  75 +---
  76 +
  77 +### 第 4 轮:终态跳过 + 全 tab 同步
  78 +
  79 +**commit**`3c52c3bb6`
  80 +
  81 +两处修复:
  82 +
  83 +**1. `setActiveTab` 跳过终态重查**
  84 +
  85 +终态(`completed` / `not_supported` / `failed`)不可逆,切换 tab 时不再重查,消除频繁切换时的冗余请求。
  86 +
  87 +**2. `pollProcessingFileStatuses` 同步所有打开的同文件 tab**
  88 +
  89 +改为遍历 `tabs.value`,覆盖所有与节点同 `fileId` 的 tab(包括非激活 tab)。用户后续切换到该文档时,状态已是最新值,不再需要重查。
  90 +
  91 +**遗留问题(用户反馈)**:切换 tab 时左侧目录仍出现"已展开的菜单关闭了然后又展开"并伴随冗余请求。
  92 +
  93 +---
  94 +
  95 +### 第 5 轮:路由 watcher 提前退出,阻断目录重载链路
  96 +
  97 +**commit**`1ce450e08`
  98 +
  99 +**根因**`setActiveTab` 在切换 tab 时调用 `router.replace` 更新 URL 中的 `fileId`,路由 watcher 检测到 `newFileId !== oldFileId` 后触发 `openFileFromRoute → openKnowledgeFileInWorkspace → expandAndLocateFile → handleLoadChildren`。`handleLoadChildren` 将 `targetFolder.children` 替换为新 API 数据(含 `children: undefined` 的子节点),导致已展开的子目录视觉上先折叠后展开,并产生级联 API 请求。
  100 +
  101 +**修复**:路由 watcher 中,若新 fileId 已匹配当前激活 tab(`activeTabData` 在 `setActiveTab` 中已同步赋值),直接 `return`,跳过 `openFileFromRoute`
  102 +
  103 +```ts
  104 +if (activeTabData.value?.fileId?.toString() === newFileId.toString()) {
  105 + return;
  106 +}
  107 +```
  108 +
  109 +**新问题**`expandAndLocateFile` 被完全跳过,切换 tab 后左侧目录不再滚动/展开到当前文件位置(**回归**)。
  110 +
  111 +---
  112 +
  113 +### 第 6 轮:setActiveTab 补充轻量定位,修复回归
  114 +
  115 +**commit**`7987cb618`
  116 +
  117 +`setActiveTab` 的 `updateCurrentFileFolderPath` 完成后,补充两步轻量操作,恢复定位功能:
  118 +
  119 +```ts
  120 +updateCurrentFileFolderPath().then(() => {
  121 + if (!activeTabData.value?.fileId || activeTabData.value?.isChatAnswer) return;
  122 + const fileId = activeTabData.value.fileId;
  123 + const path = currentFileFolderPath.value;
  124 + if (path.length > 0) {
  125 + // 展开路径中未展开的父目录(纯事件,无 API 请求)
  126 + window.dispatchEvent(new CustomEvent("expand-folders-in-path", { detail: { path } }));
  127 + }
  128 + // 滚动到文件节点并高亮
  129 + setTimeout(() => locateFileInTree(fileId), 300);
  130 +});
  131 +```
  132 +
  133 +不调用 `expandAndLocateFile`,不触发 `handleLoadChildren`,彻底避免折叠闪烁和冗余请求,同时恢复目录定位和滚动。
  134 +
  135 +---
  136 +
  137 +## 最终修复方案
  138 +
  139 +**文件**`src/pages/Workspace.vue`,关键改动:
  140 +
  141 +1. `knowledgeFiles` 之后插入轮询逻辑(`pollProcessingFileStatuses` + `hasProcessingFiles` + `startKnowledgePolling`
  142 +2. `setActiveTab` 对终态文件跳过 `fetchFileKnowledgeStatus`
  143 +3. `pollProcessingFileStatuses` 遍历 `tabs.value` 同步所有同文件 tab
  144 +4. 路由 watcher:`newFileId` 已是激活 tab 时提前 return
  145 +5. `setActiveTab`:`updateCurrentFileFolderPath` 后轻量 dispatch + `locateFileInTree`
  146 +
  147 +`onBeforeUnmount` 清理:`stopKnowledgePolling()`
  148 +
  149 +---
  150 +
  151 +## 修改的文件
  152 +
  153 +- `src/pages/Workspace.vue`
  154 +
  155 +---
  156 +
  157 +## 测试结果
  158 +
  159 +- `npm run type-check` 通过,无 TypeScript 错误
  160 +- `npm run test:unit` 通过,19/19 用例全部通过
  161 +- `npx playwright test e2e/tests/2026-03-24/1008946-knowledge-status-polling.spec.ts`:1/1 通过
  162 +
  163 +## 验收条件
  164 +
  165 +1. 上传文件后,知识库处理完成时前端图标自动从转圈变为正常 ✅
  166 +2. 无 processing 文件时不发送轮询请求 ✅
  167 +3. 切换 tab 时左侧目录不产生冗余 API 请求,不出现折叠闪烁 ✅
  168 +4. 切换 tab 后左侧目录自动滚动并高亮到当前文件位置 ✅
  169 +5. 已解析完成的文件切换 tab 时不再重查状态接口 ✅
  170 +6. 轮询到状态变更时,所有打开的同文件 tab 同步更新 ✅
  171 +7. 组件卸载时定时器正确清理,无内存泄漏 ✅
  172 +
  173 +---
  174 +
  175 +## 测试覆盖
  176 +
  177 +### E2E 测试(Playwright)
  178 +
  179 +**文件**`e2e/tests/2026-03-24/1008946-knowledge-status-polling.spec.ts`(1 个用例)
  180 +
  181 +| 用例 | 验证内容 |
  182 +|------|---------|
  183 +| hasProcessingFiles 逻辑:递归扫描树中 processing 文件 | 空树/顶层/深层嵌套/已完成各场景均验证正确 |
  1 +# 缺陷 1008947 - 新建一个目录,目录前面的图标老在转圈,要刷新页面之后才行
  2 +
  3 +## 原始需求(一字不差)
  4 +
  5 +**缺陷标题**:新建一个目录,目录前面的图标老在转圈,要刷新页面之后才行
  6 +
  7 +**状态**:进行中 | **负责人**:尹帮会 | **严重程度**:serious | **优先级**:high | **创建者**:Ryan章桦
  8 +
  9 +**创建时间**:2026-03-23 14:35:47 | **迭代**:—
  10 +
  11 +**评论**:无
  12 +
  13 +---
  14 +
  15 +## 图片理解
  16 +
  17 +### 截图(从 TAPD workitem description API 获取)
  18 +
  19 +截图显示工作区文件树,"202603上海九院"目录被红框标注,其左侧图标显示为旋转加载 spinner(`☆` 动画)而非正常文件夹图标。
  20 +
  21 +**关键细节**
  22 +- 该目录是新建的空目录
  23 +- 图标持续转圈,不会自动停止
  24 +- 刷新页面后图标恢复正常(变为普通文件夹图标)
  25 +- 说明问题是前端状态管理问题,并非后端/数据问题
  26 +
  27 +---
  28 +
  29 +## 根因分析
  30 +
  31 +`src/components/Workspace/FileTreeNode.vue` 中的 loading 状态管理存在两个问题:
  32 +
  33 +### 问题一:watch 条件不处理空数组
  34 +
  35 +```js
  36 +// 旧代码(有 bug)
  37 +watch(() => props.node.children, (newChildren) => {
  38 + if (newChildren && newChildren.length > 0) { // 空数组不满足,isLoading 永不清除
  39 + isLoading.value = false;
  40 + }
  41 +}, { deep: true });
  42 +```
  43 +
  44 +新建的空目录在 `handleLoadChildren`(Workspace.vue:1824)执行后,`node.children` 被设为 `[]`(空数组)。Watch 回调触发时 `newChildren.length === 0`,条件不满足,`isLoading` 永远不被清除为 `false`,spinner 永远转。
  45 +
  46 +### 问题二:触发条件未区分「未加载」与「已加载为空」
  47 +
  48 +```js
  49 +// 旧代码(有 bug)
  50 +if (!props.node.children || props.node.children.length === 0) // 空数组也满足,反复触发加载
  51 +```
  52 +
  53 +空目录加载完后 `children = []`,下次展开/折叠仍满足条件,会再次触发 `load-children` 事件和 loading 状态。
  54 +
  55 +---
  56 +
  57 +## 修复方案
  58 +
  59 +**文件**`src/components/Workspace/FileTreeNode.vue`
  60 +
  61 +### 修改一:watch 条件改为判断是否为数组
  62 +
  63 +```js
  64 +// 修复后
  65 +watch(() => props.node.children, (newChildren) => {
  66 + if (Array.isArray(newChildren)) { // 空数组也停止 loading
  67 + isLoading.value = false;
  68 + }
  69 +}, { deep: true });
  70 +```
  71 +
  72 +### 修改二:触发加载条件改为 undefined 判断(共 3 处)
  73 +
  74 +```js
  75 +// 修复后(3 处相同逻辑)
  76 +// !props.node.children 只在 children 为 undefined 时为 true
  77 +// 已加载的空目录 children = [] 不满足,不会反复触发
  78 +if (... && !props.node.children && !props.node.isLeaf) {
  79 +```
  80 +
  81 +**原理**:用 `undefined`(未加载)vs `[]`(已加载为空)语义区分两种状态,避免歧义。
  82 +
  83 +---
  84 +
  85 +## 附:tapd.mjs 升级
  86 +
  87 +本次调试中发现 TAPD `get_info` 接口不返回 `description` 字段,改用 SPA 实际调用的接口获取描述(含图片):
  88 +
  89 +```
  90 +GET /api/entity/workitems/get_workitem_description?workspace_id=...&entity_id=...&entity_type=bug
  91 +```
  92 +
  93 +已更新 `~/.claude/tapd.mjs` 的 `bug` 命令,后续可直接通过 `node ~/.claude/tapd.mjs bug <id>` 获取含图片 URL 的完整描述。
  94 +
  95 +---
  96 +
  97 +## 修改的文件
  98 +
  99 +- `src/components/Workspace/FileTreeNode.vue`
  100 +
  101 +---
  102 +
  103 +## 测试结果
  104 +
  105 +- `npm run type-check` 通过,无 TypeScript 错误
  106 +- `npm run test:unit` 通过,19/19 用例全部通过
  107 +- `npx playwright test e2e/tests/1008947-new-folder-loading-spinner.spec.ts`:1/1 通过
  108 +
  109 +## 验收条件
  110 +
  111 +1. 新建空目录后,目录图标不再持续转圈 ✅
  112 +2. 空目录展开/折叠后图标正常,不会重复触发加载 ✅
  113 +3. 有子项的目录展开加载行为不受影响 ✅
  114 +
  115 +---
  116 +
  117 +## 测试覆盖
  118 +
  119 +### 单元测试(Vitest)
  120 +
  121 +逻辑修改,无新增单元测试,已有 19 个用例全部通过。
  122 +
  123 +### E2E 测试(Playwright)
  124 +
  125 +**文件**`e2e/tests/1008947-new-folder-loading-spinner.spec.ts`(1 个用例)
  126 +
  127 +| 用例 | 验证内容 |
  128 +|------|---------|
  129 +| FileTreeNode 加载空目录后不再显示 spinner | 验证 Array.isArray([]) 为 true(修复条件正确),旧条件 [].length > 0 为 false(确认 bug 存在) |
  1 +# 1008923 - 切换左侧导航菜单时工作区 PDF 等文档出现闪烁
  2 +
  3 +## 缺陷信息
  4 +
  5 +- **缺陷 ID**:1008923
  6 +- **标题**:当前切换左侧导航菜单时,工作区已打开的 PDF 等文档会出现闪烁现象
  7 +- **优先级**:high
  8 +- **负责人**:尹帮会
  9 +- **创建者**:尹帮会
  10 +- **创建时间**:2026-03-19
  11 +
  12 +## 问题描述
  13 +
  14 +在工作区打开 PDF、Word 等文档后,切换左侧导航菜单(主页 / 工作台 / 知识库)再切回工作台,文档会出现短暂闪烁或白屏。
  15 +
  16 +## 根因分析
  17 +
  18 +`MainLayout.vue` 使用 `v-show` 切换三个常驻页面(WelcomePage / WorkspacePage / KnowledgeBasePage)。
  19 +
  20 +`v-show` 的底层实现是 `display: none / block`,切换时会:
  21 +1. 将 WorkspacePage 从布局流中移除(`display: none`
  22 +2. 恢复时触发整个组件树的 DOM 重排(reflow)
  23 +3. WorkspacePage 内部的 `iframe`(OnlyOffice、PDF.js)在重排时出现短暂白屏/闪烁
  24 +
  25 +此外,`.workspace` 上的 `transition: all 0.3s ease` 会把所有 CSS 属性变化都动画化,加剧了切换时的视觉抖动。
  26 +
  27 +## 最终修复方案
  28 +
  29 +**`opacity: 0/1 + position: absolute`** 替代 `v-show`
  30 +
  31 +- `opacity: 0` 不会将元素移出布局流,iframe 始终保持在 DOM 中
  32 +- 切换时不触发重排,iframe 内容不需要重新渲染
  33 +- `pointer-events: none` 确保隐藏页面不响应用户交互
  34 +- 同时将 `.workspace` 的 `transition: all` 改为只过渡 `flex`,避免不必要的动画
  35 +
  36 +> **注意**:第 1 轮尝试用 `visibility: hidden` 但存在回归问题,第 2 轮改为 `opacity: 0` 解决。
  37 +
  38 +## 迭代记录
  39 +
  40 +### 第 1 轮
  41 +
  42 +**修改文件:**
  43 +- `src/layout/MainLayout.vue`
  44 +
  45 +**变更内容:**
  46 +
  47 +```vue
  48 +<!-- 修复前:v-show 触发 display:none/block -->
  49 +<WorkspacePage
  50 + v-if="wasVisited('Workspace')"
  51 + v-show="route.name === 'Workspace'"
  52 +/>
  53 +
  54 +<!-- 修复后:CSS visibility 切换,不移出布局流 -->
  55 +<WorkspacePage
  56 + v-if="wasVisited('Workspace')"
  57 + class="page-layer"
  58 + :class="{ 'page-active': route.name === 'Workspace' }"
  59 +/>
  60 +```
  61 +
  62 +```scss
  63 +.workspace {
  64 + position: relative;
  65 + transition: flex 0.3s ease; // 只过渡 flex,避免 all 引发不必要动画
  66 +}
  67 +
  68 +.page-layer {
  69 + position: absolute;
  70 + inset: 0;
  71 + visibility: hidden;
  72 + pointer-events: none;
  73 +
  74 + &.page-active {
  75 + visibility: visible;
  76 + pointer-events: auto;
  77 + }
  78 +}
  79 +```
  80 +
  81 +**结果:** 切换导航菜单时 iframe 不再闪烁,但发现回归问题:切换到 `/app/welcome` 时,WorkspacePage 内部有子组件显式设置 `visibility: visible`(如 `.tab-content-layer.active`),导致隐藏状态被子元素穿透,工作区内容仍然可见。进入第 2 轮。
  82 +
  83 +---
  84 +
  85 +### 第 2 轮
  86 +
  87 +**修改文件:**
  88 +- `src/layout/MainLayout.vue`
  89 +
  90 +**问题根因:**
  91 +
  92 +CSS `visibility` 的继承特性:父元素设置 `visibility: hidden` 后,子元素可以通过显式设置 `visibility: visible` 来覆盖,使自己重新可见。这导致 WorkspacePage 内部的激活标签页内容穿透了父层的隐藏效果。
  93 +
  94 +**解决方案:**
  95 +
  96 +`visibility: hidden` 改为 `opacity: 0`。`opacity` 与 `visibility` 不同,子元素无法超过父元素的 `opacity` 值,即父元素 `opacity: 0` 时,子元素无论如何设置都无法变得可见。
  97 +
  98 +**变更内容:**
  99 +
  100 +```scss
  101 +/* 修复前(第 1 轮):visibility 方案,子元素可穿透 */
  102 +.page-layer {
  103 + position: absolute;
  104 + inset: 0;
  105 + visibility: hidden;
  106 + pointer-events: none;
  107 +
  108 + &.page-active {
  109 + visibility: visible;
  110 + pointer-events: auto;
  111 + }
  112 +}
  113 +
  114 +/* 修复后(第 2 轮):opacity 方案,子元素无法穿透 */
  115 +.page-layer {
  116 + position: absolute;
  117 + inset: 0;
  118 + opacity: 0;
  119 + pointer-events: none;
  120 +
  121 + &.page-active {
  122 + opacity: 1;
  123 + pointer-events: auto;
  124 + }
  125 +}
  126 +```
  127 +
  128 +**结果:** 切换导航菜单时 iframe 不再闪烁,且切换到 `/app/welcome` 时工作区内容完全隐藏,回归问题消除。
  129 +
  130 +## 测试
  131 +
  132 +**测试文件:** `e2e/tests/2026-03-25/1008923-workspace-pdf-flicker.spec.ts`
  133 +
  134 +测试覆盖三种隐藏方式的核心差异:
  135 +
  136 +| 方式 | 移出布局流 | 子元素可穿透 | 结论 |
  137 +|------|-----------|-------------|------|
  138 +| `display: none` | ✅ 是 | — | 触发重排,iframe 闪烁 |
  139 +| `visibility: hidden` | ❌ 否 | ✅ 是 | 不闪烁,但子元素可穿透(第 1 轮回归原因) |
  140 +| `opacity: 0` | ❌ 否 | ❌ 否 | 不闪烁,不可穿透(最终方案)|
  141 +
  142 +验证点:
  143 +- `display:none` 会将元素移出布局流 → 恢复时触发重排 → iframe 闪烁
  144 +- `visibility:hidden` 可被子元素用 `visibility:visible` 穿透 → 导致回归
  145 +- `opacity:0` 子元素无法超过父元素的 opacity → 既无闪烁又无穿透
  146 +- `page-active` class 切换生效,激活页 `opacity:1`,非激活页 `opacity:0`