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.

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
framework_code
dist
dist-ssr
*.local
# Playwright
e2e/.auth/
playwright-report/
test-results/
e2e/.env.test
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Large temp files
stream1.txt
stream2.txt
... ...
{
"recommendations": ["Vue.volar"]
}
... ...
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
This project uses **cnpm** as the package manager (not npm or yarn).
**Development:**
```bash
cnpm run dev # Start dev server on port 5173
```
**Building:**
```bash
cnpm run build # Production build (uses 8GB memory allocation)
```
**Type Checking:**
```bash
cnpm run type-check # Run TypeScript type checking without emitting files
```
**Preview:**
```bash
cnpm run preview # Preview production build locally
```
## Tech Stack
- **Frontend Framework**: Vue 3 with Composition API
- **Language**: TypeScript
- **Build Tool**: Vite 6
- **State Management**: Pinia (Composition API style with `defineStore`)
- **Routing**: Vue Router 4 with hash history mode
- **UI Components**: Element Plus (with auto-import)
- **Internationalization**: vue-i18n
- **Rich Text Editors**: Multiple options (BlockSuite, Univer.js, Cherry Markdown, Vditor)
- **Auto-imports**: Vue, Vue Router, Pinia, vue-i18n APIs are auto-imported (see `auto-imports.d.ts`)
## Project Structure
```
src/
├── api/ # API service modules (user, chat, files, etc.)
├── assets/ # Static assets (styles, images)
├── components/ # Reusable Vue components organized by feature
│ ├── auth/ # Authentication components
│ ├── Chat/ # Chat-related components
│ ├── KnowledgeBase/
│ ├── Settings/
│ ├── Workspace/
│ └── common/ # Shared components
├── layout/ # Layout components (MainLayout, etc.)
├── locales/ # i18n translation files
├── pages/ # Top-level page components (Auth, Welcome, Workspace, etc.)
├── router/ # Vue Router configuration
├── stores/ # Pinia stores (auth, user, chat, settings, etc.)
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── App.vue # Root component
└── main.ts # Application entry point
```
## Architecture Overview
### State Management (Pinia)
All stores use the Composition API style with `defineStore`:
- State is defined using `ref()` or `reactive()`
- Getters use `computed()`
- Actions are plain functions
- Store files are in `src/stores/`
Example store pattern:
```typescript
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>('')
const isAuthenticated = computed(() => !!token.value)
async function login(credentials) {
// action logic
}
return { token, isAuthenticated, login }
})
```
### API Layer
API services are organized by domain in `src/api/`:
- Each service exports functions that make HTTP requests
- Uses axios via `src/utils/request.ts` (configured as `service`)
- Mock API interceptors available in `src/api/mock.ts` for development
### Routing
- Uses hash history mode (`createWebHashHistory()`)
- Main layout at `/app` with nested routes
- Route meta includes:
- `requiresAuth`: boolean for auth guard
- `hideIntelligencePanel`: control panel visibility
- `title`, `icon`, `hidden`: UI metadata
- Global navigation guards in `src/router/index.ts`:
- Token-based authentication on `/auth?token=...`
- Redirect unauthenticated users to login
### Components Organization
Components are organized by feature:
- **Feature folders** (Chat, KnowledgeBase, Settings, Workspace, auth): Domain-specific components
- **common/**: Shared components used across features
- **layout/**: Application layout components
### Auto-imports
The project uses `unplugin-auto-import` and `unplugin-vue-components`:
- Vue APIs (ref, computed, watch, etc.) are auto-imported
- Vue Router APIs (useRouter, useRoute) are auto-imported
- Pinia APIs (storeToRefs, etc.) are auto-imported
- vue-i18n APIs are auto-imported
- Element Plus components are auto-imported
- Type definitions regenerated in dev mode: `src/auto-import.d.ts` and `src/components.d.ts`
### Environment Configuration
Environment variables are defined in `vite.config.ts` (not in `.env` files):
- `import.meta.env.VITE_APP_BASE_URL`: API base URL (defaults to https://ai.linkmed.cc)
- Dev server proxies `/api` and `/deepresearch` to the backend
## Build System Notes
The Vite configuration includes several **custom plugins** to handle module compatibility issues:
1. **fixModuleNotDefined**: Handles CJS modules in development mode
2. **fixProblematicCjsModules**: Fixes specific problematic CJS modules (lodash, file-saver, etc.)
3. **fixBlockSuiteAccessor**: Transforms modern accessor syntax in BlockSuite packages during build
4. **fixHighlightJs**: Fixes highlight.js import issues
These plugins exist because the project uses many complex dependencies (BlockSuite, Univer.js, React-based libraries) that have CJS/ESM compatibility issues.
**Important build configuration:**
- Build target is `es2022` (for better class/decorator handling)
- Memory allocation increased to 8GB for production builds
- Manual chunk splitting for optimal bundle size
- React dependencies are included despite this being a Vue project (required by BlockSuite)
## Development Guidelines
### Adding New Features
1. Create a new store in `src/stores/` if state management is needed
2. Add API functions in appropriate `src/api/` module
3. Create feature-specific components in `src/components/[feature]/`
4. Add page components in `src/pages/` if needed
5. Update routes in `src/router/index.ts`
### Working with API
The backend base URL is proxied in development:
- Local API calls use `/api` prefix (proxied to backend)
- Backend base URL: https://ai.linkmed.cc (can be changed in vite.config.ts line 206)
### Type Safety
- TypeScript strict mode is enabled
- Use the auto-generated type definitions in `src/auto-import.d.ts` and `src/components.d.ts`
- Type definitions for custom modules in `src/types/`
### Authentication Flow
1. User logs in via `/auth` route
2. Token stored in localStorage (key: `auth_token`)
3. Auth state managed by `useAuthStore` (src/stores/auth.ts)
4. Token refresh logic included
5. Router guard checks `isAuthenticated` computed property
## Common Issues
### Module Resolution Errors
If you encounter "module is not defined" or CJS/ESM errors:
- Check if the module needs to be added to `fixProblematicCjsModules` in vite.config.ts
- Consider adding to `resolve.dedupe` array if there are version conflicts
### Build Memory Issues
If build fails with JavaScript heap out of memory:
- Memory is already allocated to 8GB in package.json build script
- If needed, increase further in the `NODE_OPTIONS=--max-old-space-size=XXXX` flag
### BlockSuite/Univer Integration
These are complex dependencies with special requirements:
- Do not modify their import patterns
- They require React as a peer dependency
- Custom esbuild configuration is needed for decorators
... ...
# Vue 3 + TypeScript + Vite
## 启动项目:
`cnpm run dev`
## 构建项目:
`cnpm run build`
... ...
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es').ElMessage
const ElNotification: typeof import('element-plus/es').ElNotification
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useI18n: typeof import('vue-i18n')['useI18n']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
... ...
# LinkMed Claude Code 自动研发使用指南
借助 Claude Code 实现从需求描述到代码实现、自动验证的完整研发流程,减少人工手动验证环节。
---
## 目录
1. [快速开始](#1-快速开始)
2. [整体工作流程](#2-整体工作流程)
3. [多端协作架构](#3-多端协作架构)
4. [使用方式](#4-使用方式)
5. [从 TAPD 获取任务详情](#5-从-tapd-获取任务详情)
6. [TAPD 脚本安装配置](#6-tapd-脚本安装配置)
7. [测试体系](#7-测试体系)
8. [单元测试规范(Vitest)](#8-单元测试规范vitest)
9. [E2E 测试规范(Playwright)](#9-e2e-测试规范playwright)
10. [提交规范](#10-提交规范)
11. [dev-sessions 文档规范](#11-dev-sessions-文档规范)
12. [后续规划](#12-后续规划)
---
## 1. 快速开始
### 启动方式
```bash
# 普通模式(每步操作需手动确认)
claude
# 全自动模式(跳过所有权限确认,适合批量任务)
claude --dangerously-skip-permissions
# 全自动模式 + 直接传入任务
claude --dangerously-skip-permissions -p "帮我打包并提交代码"
```
> ⚠️ `--dangerously-skip-permissions` 为**高风险模式**,Claude 可直接执行文件读写、Git 操作、终端命令,**不会弹出确认框**,包括删除文件、强制推送、覆盖数据等破坏性操作。**仅在本地受信任环境中使用**,不要在生产服务器或共享环境中开启。建议配合 `CLAUDE.md` 中的约束说明,明确告知 Claude 哪些操作是禁止的。
---
## 2. 整体工作流程
```
┌─────────────────────────────────────────────────────┐
│ 1. 获取任务(需求 / 缺陷,含图片和评论) │
│ · 直接描述需求/缺陷内容 │
│ · 或提供 TAPD ID,脚本自动拉取详情+评论 │
│ 需求:node ~/.claude/tapd.mjs story <ID> │
│ 缺陷:node ~/.claude/tapd.mjs bug <ID> │
│ · 从描述/评论中提取图片 URL,下载并阅读 │
│ COOKIE=$(cat ~/.claude/tapd-cookie.txt) │
│ curl -s -L -H "Cookie: $COOKIE" \ │
│ -H "Referer: https://www.tapd.cn/" <URL> │
│ -o /tmp/req_img.png │
│ · 评论中若有关键信息(设计说明、补充要求等) │
│ 必须纳入分析,写入 dev-sessions 文档 │
└─────────────────────┬───────────────────────────────┘
│ 逐个取出
┌─────────────────────▼───────────────────────────────┐
│ 2. 分析与规划 │
│ · 理解需求目标和验收标准 │
│ · 阅读相关代码,理解现有架构和模块 │
│ · 制定实现方案,必要时与开发者确认 │
└─────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 3. 实现代码 │
│ · 每修改完一个文件,立刻运行 lint 和 type-check │
│ npm run lint && npm run type-check │
│ · 有错误自动修复,无需人工干预,修完再继续 │
│ · 遵循项目现有代码规范和架构模式 │
└─────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 4. 单元测试(Vitest) │
│ · 针对 Store 逻辑、工具函数编写/更新测试 │
│ · npm run test:unit │
│ · 测试文件位于 src/test/**/*.test.ts │
└──────────┬──────────────────────────┬───────────────┘
↓ 全部通过 ↓ 有失败
│ ┌───────────────────────┐
│ │ 定位失败原因 │
│ │ → 修复代码或测试用例 │
│ │ → 重新运行测试 │
│ └──────────┬────────────┘
│ ↑
│ └── 循环直到全部通过
┌─────────────────────────────────────────────────────┐
│ 5. E2E 测试(Playwright) │
│ · 针对需要真实浏览器的交互场景编写测试 │
│ · 存放至 e2e/tests/{模块}/{需求ID}-描述.spec.ts │
│ · npm run test:e2e 自动运行验证 │
└──────────┬──────────────────────────┬───────────────┘
↓ 全部通过 ↓ 有失败
┌──────────────────────┐ ┌──────────────────────────┐
│ 6. 逐条核查验收标准 │ │ 定位失败原因 │
│ · 对照需求验收标准 │ │ → 修复代码或测试用例 │
│ 逐条确认代码实现 │ │ → 重新运行测试 │
│ · 发现遗漏则补充实现 │ └────────────┬─────────────┘
└──────────┬───────────┘ ↑
↓ └── 循环直到全部通过
┌──────────────────────────────────────────────────────┐
│ 7. 提交并推送代码(每次必做,无需等待用户提醒) │
│ · git commit(含功能代码、测试、文档) │
│ · git push │
│ · 更新 dev-sessions 过程记录文档 │
└──────────┬───────────────────────────────────────────┘
│ ↑
│ └── 循环直到全部通过
↓ 还有下一个需求
┌──────────────────────┐
│ 返回步骤 1,处理 │
│ 下一个需求 │
└──────────────────────┘
↓ 所有需求完成
全部完成 ✅
```
> 多个需求**串行执行**,每个需求测试通过并提交后才开始下一个,保证每次提交都是可验证的最小单元。
> **全自动执行,任何情况不打断用户,不询问确认,自行决策完成任务。**
> ⚠️ **测试通过 ≠ 验收标准全部满足**。E2E 测试通常只能拦截 30~40% 的潜在 bug(结构性问题),功能细节(如插入块的类型是否正确)需要步骤 6 的人工核查来补充。
---
## 3. 多端协作架构
每个端由独立的 Claude Code 机器人负责,各自在自己的工作目录中完成实现。
```
┌──────────────────────────────────────────────────────┐
│ 需求 / 缺陷 │
│ (TAPD 需求描述 + 验收标准) │
└──────────┬──────────────────┬────────────────┬───────┘
│ │ │ 按涉及端分配
▼ ▼ ▼
┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 🤖 后端机器人 │ │ 🤖 前端机器人 │ │ 🤖 管理后台机器人 │
│ │ │ │ │ │
│ backend/ │ │ linkmed-vue3/ │ │ linkmed-admin/ │
│ Java 21 │ │ Vue 3 + Vite │ │ Java 21 + Vue 3 │
│ Spring Boot 8181 │ │ TypeScript 5173 │ │ Spring Boot / 5174 │
└──────────┬──────────┘ └──────────┬───────────┘ └──────────┬───────────┘
│ │ ↑ │ ↑
│ 涉及多端时, │ │ 仅在后端就绪后 │ │ 仅在后端就绪后
├──── 接口就绪,通知 ───────┘ │ 才接入联调 │ │ 才接入联调
└──── 接口就绪,通知 ────────────────────────────────┘
│ │ │
▼ ▼ ▼
提交推送 提交推送 提交推送
```
**核心原则:涉及前后端联调的需求,后端先行。** 后端机器人完成接口开发并推送后,前端/管理后台机器人再接入联调,避免接口未就绪时空转。
---
## 4. 使用方式
向 Claude Code 描述需求/缺陷时附带验证意图,Claude Code 会自动完成实现 + 测试:
```
示例:"实现文件上传大小限制为 100MB,完成后帮我验证"
示例:"修复切换深度检索历史触发 cancel 请求的 bug,验证修复正确"
示例:"关闭 Dig Paper 知识库入口,确认页面上不再显示"
```
也可以直接提供 TAPD 任务 ID,Claude Code 会通过脚本自动拉取详情(需求或缺陷均可):
```
示例:"处理需求 1008229"
示例:"处理缺陷 1008906"
示例:"按顺序完成需求 1008251、1008252、1008253"
```
---
## 5. 从 TAPD 获取任务详情
### 查看需求列表
```bash
# LinkMed 项目最新需求(默认)
node ~/.claude/tapd.mjs stories
# 关键词搜索
node ~/.claude/tapd.mjs stories 67139335 知识库
```
输出示例:
```
Total: 614 | Showing: 20
[1008229] 知识库-对解析失败的文件新增重试按钮和功能
status=developing owner=张倩如;尹帮会; iteration=0.6.26.0(当前迭代)
[1008263] 【工作台】对话记录的优化
status=planning owner=尹帮会; iteration=0.6.27.0
```
### 查看单个需求详情
通过需求的 short_id(TAPD 列表中括号内的编号)获取完整信息:
```bash
node ~/.claude/tapd.mjs story 1008229
```
输出包含需求标题、描述、验收标准、状态、负责人等字段。
### 查看缺陷(Bug)详情
```bash
node ~/.claude/tapd.mjs bug 1008906
```
输出包含缺陷标题、描述、重现步骤、严重程度、优先级、状态,以及**所有评论和回复**
### 评论的重要性
评论中常包含以下关键信息,**必须阅读**
- 产品/后端对实现方案的补充说明(如 TOS 路径、接口格式)
- 对原始描述的修正或追加要求
- 评论中的截图(格式同描述图片,需下载阅读)
- 关键约束(如「仅预览,不要支持修改!!!!」)
脚本已自动输出评论内容,格式:
```
--- 评论(N 条)---
[时间] 作者:内容
↳ [时间] 作者:回复内容(缩进表示回复)
[图片: URL](评论中的截图)
```
### 在工作流中的用法
告知 Claude Code 任务 ID 后,Claude Code 会自动判断类型并调用对应命令:
```bash
# Claude Code 内部自动调用,无需手动执行
node ~/.claude/tapd.mjs story <需求ID> # 需求
node ~/.claude/tapd.mjs bug <缺陷ID> # 缺陷
```
### 下载需求中的图片
TAPD 需求描述中常含有设计图,格式为 `[图片: https://file.tapd.cn/...]`**必须下载并阅读**,不能仅凭文字描述实现。
```bash
COOKIE=$(cat ~/.claude/tapd-cookie.txt)
curl -s -L \
-H "Cookie: $COOKIE" \
-H "Referer: https://www.tapd.cn/" \
-H "User-Agent: Mozilla/5.0" \
"https://file.tapd.cn//tfl/captures/..." \
-o /tmp/req_<需求ID>_1.png
# 然后用 Read 工具读取图片内容
```
### Cookie 过期处理
登录态通常可持续数周。若脚本报错或返回空数据,重新登录即可:
```bash
node ~/.claude/tapd.mjs login your@linkingmed.com yourpassword
```
### 常用项目 ID
| 项目名 | workspace_id |
|--------|-------------|
| LinkMed | `67139335` |
| AI公共平台(AiPlan) | `21580481` |
| 科研项目管理 | `38189866` |
| 专病数据库 | `59607085` |
| RAIC.OIS信息管理系统 | `58951789` |
完整列表通过 `node ~/.claude/tapd.mjs projects` 获取。
---
## 6. TAPD 脚本安装配置
### 前置要求
Node.js 18+(使用内置 `fetch` 和 `crypto`,无需安装任何 npm 包):
```bash
node -v # 需要 v18.0.0 以上
```
如果版本过低,可通过 [fnm](https://github.com/Schniz/fnm)[nvm](https://github.com/nvm-sh/nvm) 升级:
```bash
# fnm
fnm install 20 && fnm use 20
# nvm
nvm install 20 && nvm use 20
```
### 安装脚本
```bash
mkdir -p ~/.claude
nano ~/.claude/tapd.mjs # 或用 VS Code: code ~/.claude/tapd.mjs
```
将以下内容保存为 `~/.claude/tapd.mjs`
```js
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { randomBytes, createCipheriv } from 'crypto';
const COOKIE_FILE = join(homedir(), '.claude', 'tapd-cookie.txt');
const DEFAULT_WS = '67139335'; // LinkMed 项目
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';
const jar = {};
function loadCookie() {
if (existsSync(COOKIE_FILE)) {
for (const kv of readFileSync(COOKIE_FILE, 'utf8').trim().split('; ')) {
const [k, ...v] = kv.split('=');
if (k) jar[k.trim()] = v.join('=').trim();
}
}
}
function saveCookie() {
writeFileSync(COOKIE_FILE, Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '));
}
function getCookieStr() { return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; '); }
function parseCookies(resp) {
for (const c of (resp.headers.getSetCookie?.() || [])) {
const eqIdx = c.indexOf('=');
const key = c.slice(0, eqIdx).trim();
const val = c.slice(eqIdx + 1).split(';')[0].trim();
if (val && val !== 'deleted') jar[key] = val;
else delete jar[key];
}
}
async function req(url, opts = {}) {
const resp = await fetch(url, {
...opts,
headers: { 'User-Agent': UA, 'Cookie': getCookieStr(), ...(opts.headers || {}) },
redirect: 'manual',
});
parseCookies(resp);
return resp;
}
async function get(path) {
const r = await req(`https://www.tapd.cn${path}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*' }
});
return r.json();
}
async function post(path, body, wsId = DEFAULT_WS) {
const r = await req(`https://www.tapd.cn${path}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json,*/*',
'Content-Type': 'application/json',
Referer: `https://www.tapd.cn/tapd_fe/${wsId}/prong/stories`,
Origin: 'https://www.tapd.cn',
},
body: JSON.stringify(body),
});
return r.json();
}
function aesEncrypt(password) {
const key = randomBytes(32), iv = randomBytes(16);
const buf = Buffer.from(password, 'utf8');
const pad = 16 - (buf.length % 16);
const padded = pad === 16 ? buf : Buffer.concat([buf, Buffer.alloc(pad, 0)]);
const cipher = createCipheriv('aes-256-cbc', key, iv);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
return { ciphertext: encrypted.toString('base64'), key: key.toString('base64'), iv: iv.toString('base64') };
}
async function login(email, password) {
await req('https://www.tapd.cn/cloud_logins/login?site=TAPD&ref=https%3A%2F%2Fwww.tapd.cn%2F');
const enc = aesEncrypt(password);
const form = new URLSearchParams({
'data[Login][email]': email, 'data[Login][password]': enc.ciphertext,
'data[Login][encrypt_key]': enc.key, 'data[Login][encrypt_iv]': enc.iv,
'data[Login][via]': 'encrypt_password', 'data[Login][type]': '2',
'data[Login][ref]': 'https://www.tapd.cn/', 'data[Login][site]': 'TAPD',
'data[Login][login]': 'login', 'data[protocol]': '1',
});
const p2 = await req('https://www.tapd.cn/cloud_logins/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://www.tapd.cn', Referer: 'https://www.tapd.cn/cloud_logins/login' },
body: form.toString(),
});
let location = p2.headers.get('location');
while (location) {
const r = await req(location);
location = r.headers.get('location');
}
saveCookie();
return !!jar['_wt'];
}
const [,, cmd, arg1, arg2] = process.argv;
loadCookie();
if (cmd === 'login') {
const ok = await login(arg1, arg2);
console.log(ok ? 'Login successful' : 'Login failed');
} else if (cmd === 'projects') {
const data = await get('/api/workspace/workspaces/get_all_my_projects');
const projects = data.data?.all_my_projects || [];
projects.forEach(p => console.log(`${p.id}\t${p.project_name}\t${p.status}`));
} else if (cmd === 'stories') {
const wsId = arg1 || DEFAULT_WS;
const keyword = arg2 || '';
const body = { workspace_ids: [wsId], page: 1, page_count: 20 };
if (keyword) body.search_data = { keyword };
const data = await post('/api/entity/stories/story_list_by_condition', body, wsId);
const list = data.data?.list || [];
console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
list.forEach(s => console.log(`[${s.short_id}] ${s.name}\n status=${s.status} owner=${s.owner} iteration=${s.iteration_name}`));
} else if (cmd === 'story') {
if (!arg1) { console.error('Usage: tapd.mjs story <story_id>'); process.exit(1); }
const data = await get(`/api/entity/stories/stories/get_info?workspace_id=${arg2 || DEFAULT_WS}&story_id=${arg1}`);
console.log(JSON.stringify(data, null, 2));
} else if (cmd === 'bugs') {
const data = await post('/api/entity/bugs/bug_list_by_condition', { workspace_ids: [arg1 || DEFAULT_WS], page: 1, page_count: 20 }, arg1 || DEFAULT_WS);
const list = data.data?.list || [];
console.log(`Total: ${data.data?.total} | Showing: ${list.length}`);
list.forEach(b => console.log(`[${b.id}] ${b.title}\n status=${b.status} owner=${b.owner}`));
} else {
console.log('Usage:');
console.log(' node ~/.claude/tapd.mjs login <email> <password>');
console.log(' node ~/.claude/tapd.mjs projects');
console.log(` node ~/.claude/tapd.mjs stories [workspace_id=${DEFAULT_WS}] [keyword]`);
console.log(' node ~/.claude/tapd.mjs story <story_id> [workspace_id]');
console.log(' node ~/.claude/tapd.mjs bugs [workspace_id]');
}
```
### 首次登录
```bash
node ~/.claude/tapd.mjs login your@linkingmed.com yourpassword
# 输出 "Login successful" 表示成功
# 验证是否正常工作
node ~/.claude/tapd.mjs projects
```
### 可选:设置别名
```bash
echo "alias tapd='node ~/.claude/tapd.mjs'" >> ~/.zshrc
source ~/.zshrc
# 之后可直接使用
tapd stories
tapd story 1008229
```
### 技术实现说明
TAPD 没有开放的公共 API,脚本通过逆向前端内部 API 实现:
**登录流程:** TAPD 登录页用 **AES-256-CBC + ZeroPadding** 加密密码,key 和 iv 连同密文一起发给服务器。脚本用 Node.js 内置 `crypto` 复现,无需 CAPTCHA 即可完成登录。
关键请求字段:
```
data[Login][email] 用户邮箱
data[Login][password] AES 加密后的密码(base64)
data[Login][encrypt_key] AES key(base64)
data[Login][encrypt_iv] AES IV(base64)
data[Login][via] encrypt_password
```
**主要 API 接口:**
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/workspace/workspaces/get_all_my_projects` | GET | 获取我的项目列表 |
| `/api/entity/stories/story_list_by_condition` | POST | 查询需求列表 |
| `/api/entity/stories/stories/get_info` | GET | 获取需求详情 |
| `/api/entity/bugs/bug_list_by_condition` | POST | 查询 Bug 列表 |
所有接口均需携带登录 cookie 并设置 `X-Requested-With: XMLHttpRequest`
**内部 ID 构造规则:**
`story` 命令不依赖关键词搜索,而是直接构造内部 ID:
```
内部 ID = '11' + workspaceId + shortId.padStart(9, '0')
示例:'11' + '67139335' + '001008260' = '1167139335001008260'
```
> 这些接口通过分析 TAPD 前端 JS bundle 发现,不在官方文档中,未来版本可能变更。
---
## 7. 测试体系
本项目采用两层测试策略,分工明确:
| 层级 | 工具 | 测什么 | 运行速度 | 命令 |
|------|------|--------|--------|------|
| 单元测试 | **Vitest** | Store 逻辑、工具函数、数据处理 | 毫秒级 | `npm run test:unit` |
| E2E 测试 | **Playwright** | 需要真实浏览器的交互流程 | 分钟级 | `npm run test:e2e` |
**Vitest** — 把 API 替换为 mock,直接测 Store 内部的业务逻辑,不依赖浏览器和测试账号数据。适合测响应格式适配、状态变更、错误处理等纯逻辑。测试文件位于 `src/test/**/*.test.ts`
**Playwright(E2E)** — 驱动真实浏览器执行完整用户操作,适合测纯 DOM 交互(如截图拖拽、编辑器焦点)。测试文件位于 `e2e/tests/`
---
## 8. 单元测试规范(Vitest)
### 文件位置
```
src/test/stores/ # Store 测试
src/test/utils/ # 工具函数测试
```
### 适合写单元测试的场景
- Store action 的成功/失败分支逻辑
- 响应数据的格式适配(多种格式兼容)
- 复杂的纯函数(如数学公式预处理)
- 状态变更后 computed 是否正确
### 不适合写单元测试的场景
- 需要真实浏览器渲染的 UI 交互
- 依赖第三方富文本编辑器(BlockSuite)的行为
---
## 9. E2E 测试规范(Playwright)
### 文件命名
```
e2e/tests/{模块}/{需求ID}-{简短描述}.spec.ts
```
示例:
```
e2e/tests/welcome/1008878-cancel-api.spec.ts
e2e/tests/agent/1008229-file-upload-limit.spec.ts
```
### 文件头部模板
```typescript
/**
* 需求 ID:1008878
* 需求描述:切换深度检索历史不应触发 cancel 请求
*
* 验收标准:
* 1. 在 Welcome 页面点击历史记录,POST /cancel 不被调用
* 2. 主动点击终止按钮时,POST /cancel 正常调用
*/
```
### 断言强度要求
E2E 测试断言应尽量验证**功能正确性**,而不只是**结构存在性**
| 场景 | 弱断言(不推荐) | 强断言(推荐) |
|------|----------------|--------------|
| 插入块 | `afterCount > beforeCount` | 验证新块的 `data-block-flavour` / `data-level` 等属性 |
| 按钮点击 | `expect(btn).toBeVisible()` | 验证点击后产生的实际效果 |
| 表单提交 | 无错误即通过 | 验证提交后页面状态/数据变化 |
### 运行前检查清单
```bash
# 1. 确认 dev server 指向当前项目(pc1 用 5175,避免与 pc/ 的 5173 冲突)
lsof -i :5175 | grep node
# 若没有,启动:node node_modules/.bin/vite --port 5175 &
# 2. 确认 playwright.config.ts 中 baseURL 与上面端口一致
grep baseURL playwright.config.ts
```
### 工作台(Workspace)测试注意事项
工作台使用**堆叠 tab 模式**,所有已打开文件的组件同时存在于 DOM,只用 CSS 控制显示:
```typescript
// ❌ 错误:会匹配所有 tab 中的元素
page.locator(".icon-toolbar .icon-btn")
// ✅ 正确:限定到当前激活的 tab
page.locator(".tab-content-layer.active .icon-toolbar .icon-btn")
```
打开编辑器时应点击**已有文件**,不要用"新建文件"按钮(新建会触发 AI 文档生成面板,AffineEditor 不渲染):
```typescript
const mdFile = page.locator(".node-content.is-file").filter({
has: page.locator(".node-name").filter({ hasText: /\.md$/i }),
}).first();
await mdFile.click();
await page.waitForSelector(".tab-content-layer.active .editor-toolbar", { timeout: 20000 });
```
需要在编辑器中建立光标位置时,要点击 `rich-text` 元素并按 `End` 键(仅点击容器不能建立 BlockSuite TextSelection):
```typescript
const richText = page.locator(".tab-content-layer.active rich-text, .tab-content-layer.active affine-paragraph").first();
await richText.click();
await page.keyboard.press("End");
```
### 测试完成后的处理
| 情况 | 处理方式 |
|------|---------|
| 核心逻辑,后续可能被改动 | 保留,防止回归 |
| 简单 UI 变更,一次性验证 | 验证后可删除 |
| 不稳定、依赖特定数据 | 加 `.skip` 或删除 |
---
## 10. 提交规范
每次完成任务后,**不等用户提醒**,自动执行:
```bash
# 1. 提交所有相关改动(功能代码 + E2E 测试 + 文档)
git add <相关文件>
git commit -m "feat/fix/test/docs: <需求ID>【模块】描述"
# 2. 推送到远端
git push origin <当前分支>
# 3. 更新 dev-sessions 过程记录(按具体日期分目录)
# claude-code/dev-sessions/{YYYY-MM-DD}/{需求ID}-{描述}.md
# 示例:claude-code/dev-sessions/2026-03-19/1008906-Safari登录报错.md
```
**提交信息格式:**
| 类型 | 前缀 | 示例 |
|------|------|------|
| 需求 | `feat:` | `feat: 1008265【工作台】编辑器新增格式化工具栏` |
| 缺陷修复 | `fix:` | `fix: 1008906【Safari兼容】修复旧版Safari命名捕获组报错` |
| 测试 | `test:` | `test: 1008265 E2E测试全部通过(15/15)` |
| 文档 | `docs:` | `docs: 新增 1008906 缺陷执行记录` |
---
## 11. dev-sessions 文档规范
每个任务(需求或缺陷)执行完成后,需在 `claude-code/dev-sessions/{YYYY-MM-DD}/{ID}-{简短描述}.md` 创建或更新过程记录。
**具体日期**分目录管理(如 `2026-03-19/`),避免单目录文件过多。
### 需求文档结构
```
# 需求 {ID} - {标题}
## 原始需求(一字不差)
- 需求标题、状态、负责人、优先级、创建者、时间
- 原始描述:直接引用 TAPD 中的原文,包含图片 URL
## 图片理解
- 每张图片单独一节,注明尺寸
- 画面内容:客观描述看到了什么
- 关键细节:对实现有指导意义的 UI 细节
- 悬停/交互行为(需推断):图片未直接展示但可推断的行为
## 评论(若有)
- 每条评论的作者、时间、内容
- 评论中的图片需下载阅读,记录关键信息
- 对实现有约束或补充的评论要重点标注
## 实现方案
- 数据流图(用代码块 ASCII 表示)
- 各修改文件的具体改动点
## 修改的文件
- 文件路径列表
## 测试结果
- type-check / lint 结果
## 测试覆盖
- 单元测试(Vitest):测试文件、用例数、覆盖的测试组
- E2E 测试(Playwright):测试文件、主要用例
```
### 缺陷(Bug)文档结构
缺陷记录侧重根因分析和修复,而非功能实现:
```
# 缺陷 {ID} - {标题}
## 原始需求(一字不差)
- 缺陷标题、状态、负责人、严重程度、优先级、创建者、时间
- 原始描述:直接引用 TAPD 中的原文,包含图片 URL(控制台截图等)
- 重现步骤:原文引用
## 图片理解
- 每张截图单独一节,注明尺寸
- 画面内容:控制台报错信息、调用栈、错误来源等关键信息
- 关键细节:对定位问题有指导意义的信息(文件名、行号、错误类型)
## 评论(若有)
- 每条评论的作者、时间、内容
- 评论中的图片需下载阅读,记录关键信息
- 对修复方案有约束的评论要重点标注(如「仅预览,不要支持修改」)
## 根因分析
- 错误类型及含义
- 为什么会产生这个错误(配置/依赖/版本问题等)
- 涉及文件(配置文件、出错 chunk、依赖包等)
## 修复方案
- 修复思路(修改什么、为什么这样修)
- 修改的文件列表及具体改动说明
## 测试结果
- type-check / lint 结果
- 验收条件(修复后如何验证)
```
### 图片理解要求
**必须**
- 下载 TAPD 图片并用 Read 工具阅读(不能只凭图片 URL 推测)
- 记录每张图的像素尺寸
- 区分「图片明确展示」和「需推断」的内容
**图片下载方式**
```bash
COOKIE=$(cat ~/.claude/tapd-cookie.txt)
curl -s -L \
-H "Cookie: $COOKIE" \
-H "Referer: https://www.tapd.cn/" \
-H "User-Agent: Mozilla/5.0" \
"<TAPD图片URL>" -o /tmp/req_<需求ID>_<序号>.png
# 然后用 Read 工具读取图片
```
### 范例文档
参考 `claude-code/workflow/dev-sessions-范例-1008257.md`——该需求包含两张设计图,分别展示不同交互阶段,是目前图片理解最完整的范例:
- **图片1**:PDF 文本选中后弹出菜单(展示「Quote in Chat」入口,菜单样式、图标、定位方式)
- **图片2**:引用标签出现在对话输入框上方(展示「PDF Quote ×」chip 样式 + tooltip 行为)
- 每张图单独分析,明确标注「需推断」的部分(如 tooltip 的触发行为)
- 图片细节直接指导实现(chip 的颜色、按钮标签文字、删除方式)
---
## 12. 后续规划
- [x] 接入 TAPD,自动读取需求描述
- [ ] 接入 GitLab CI,push 时自动触发测试
- [ ] 测试通过后自动提交并创建 MR
... ...
# 需求 1008257 - PDF 浏览器引用文本到对话框上下文
## 原始需求(一字不差)
**需求标题**:【工作台】PDF的浏览器-引用文本到对话框的上下文
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:31:11 | **修改时间**:2026-03-13 21:01:56
**原始描述**
> 在PDF文件中选择文本,出现菜单,选择"引用文本到对话框":
>
> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210470_797.png]
>
> 对话框中会出现对PDF文件的引用,鼠标悬停的时候,还可以看到引用的文本内容:
>
> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210606_307.png]
>
> 然后进行提问,会得到针对性的结果
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在 PDF 文件中选择文本,出现菜单,选择"引用文本到对话框":对话框中会出现对 PDF 文件的引用,
鼠标悬停时可以看到引用的文本内容,然后进行提问,会得到针对性的结果。
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加文本选择和菜单功能
2. linkmed-vue3/src/components/AgentPanel/AgentChatAgent.vue:添加引用显示和悬停效果
3. linkmed-vue3/src/api/chat.ts:添加引用相关的接口
4. linkmed-vue3/src/stores/chatApi.ts:添加引用相关的状态
### 验收用例
1. 文本选择和菜单:在 PDF 中选择文本,出现菜单
2. 引用到对话框:选择"引用文本到对话框",对话框中出现引用
3. 悬停显示:鼠标悬停在引用上时显示引用的文本内容
4. 提问和回复:提问后得到针对性的结果
复杂度:Medium
```
---
## 图片理解
### 图片1(1854 × 1602 像素)- PDF 文本选中菜单
**画面内容**
- 左侧:PDF 查看器,显示一篇物理医学期刊论文(Physics in Medicine & Biology)
- 用户在 PDF 中选中了一段英文文本(蓝色高亮区域)
- 选中文本后弹出浮动菜单,菜单包含两个按钮:
1. **"Copy and Cite"**(复制并引用)
2. **"Quote in Chat"**(引用到对话)—— 这就是目标功能的入口
- 右侧:AI Chat 面板,显示正在进行的对话
**关键细节**
- 菜单出现在选中文本附近(浮动定位)
- 菜单样式为白色背景卡片,带有圆形图标
- "Quote in Chat" 使用引号图标(💬)
### 图片2(1854 × 1602 像素)- 对话框中的引用标签
**画面内容**
- 左侧:同一 PDF 查看器(同一篇论文)
- 右侧 AI Chat 面板底部输入区域,显示引用成功加入上下文后的状态:
- 输入框上方出现了一个**引用标签/芯片(chip)**:显示 `📄 PDF Quote ×`
- 该标签代表已引用的 PDF 文本片段
- 右上角有 `×` 按钮可以移除引用
- 对话列表中还可见 **"1 source"** 标签(表明已有来源引用)
**悬停行为**(需推断):
- 鼠标悬停在 `PDF Quote ×` 标签上时,应弹出 tooltip 显示引用的原始文本内容
**关键细节**
- 引用标签样式:小型 chip,带文件类型图标 + "PDF Quote" 文字 + `×` 删除按钮
- 标签位于聊天输入框上方(与截图功能的缩略图区域并列)
- 标签颜色为浅灰/白色背景
---
## 实现方案
### 数据流
```
PDFViewerIframe mouseup → 显示浮动菜单
→ 点击「Quote in Chat」
→ chatApiStore.addPendingTextReference(text, fileName, fileId)
→ AgentChat.vue 渲染引用标签(.text-references chip)
→ 鼠标悬停显示原始引用文本(el-tooltip)
→ 用户发送时,引用文本格式化为 Markdown 引用块前缀拼入问题
→ 发送后 clearPendingTextReferences()
```
### 1. chatApi.ts(store)
- 新增 `PendingTextReference` 接口(text, fileName, fileId)
-`ChatApiState` 添加 `pendingTextReferences: PendingTextReference[]`
- 新增 actions:`addPendingTextReference`、`removePendingTextReference`、`clearPendingTextReferences`
### 2. PDFViewerIframe.vue
- 引入 `useWorkspaceChatStore`
- 新增 `showTextMenu`、`textMenuPos`、`selectedTextContent` 状态
- `handleTextMouseUp`:mouseup 时读取 window.getSelection(),计算菜单位置
- `handleGlobalMouseDown`:点击菜单外关闭菜单
- `quoteTextToChat`:调用 store 添加引用,显示成功 ElMessage
- template 中添加 `.pdf-text-menu` 浮动菜单(含「Copy and Cite」和「Quote in Chat」按钮)
### 3. AgentChat.vue
- template 中在输入框上方添加 `.text-references` 文本引用 chip 区域
- 每个 chip 显示:📄 PDF Quote + 删除 `×` 按钮 + el-tooltip 悬停显示原文
- `canSend` computed 增加 `pendingTextReferences.length > 0` 条件
- `handleSend` 中将文本引用格式化为 Markdown 引用块前缀,拼入最终问题
- 发送后调用 `chatApiStore.clearPendingTextReferences()`
---
## 修改的文件
- `src/stores/chatApi.ts`
- `src/components/Workspace/PDFViewerIframe.vue`
- `src/components/AgentPanel/AgentChat.vue`
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 部分,28 个用例)
| 测试组 | 用例 |
|------|------|
| pendingTextReferences 初始状态 | 初始为空数组 |
| addPendingTextReference | 添加单条;连续添加多条;保存数字/字符串 fileId |
| removePendingTextReference | 按索引删除;删除第一条;删除最后一条 |
| clearPendingTextReferences | 清空所有;对空数组操作不报错 |
| 互不干扰 | 清空文本引用不影响截图;删除截图不影响文本引用 |
**文件2:`src/test/features/text-reference-format.test.ts`**(格式化逻辑部分,7 个用例)
| 测试组 | 用例 |
|------|------|
| buildFinalQuestion | 无引用返回原始问题;有1条引用格式正确;多条引用用空行分隔;有引用无问题返回纯引用;文件名包在《》中;引用文本以>开头;保留特殊字符 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008257 相关 3 个用例)
- 工作台存在 PDF 查看器组件挂载点(.pdf-viewer-js 等)
- PDF 查看器工具栏存在功能按钮(.pdf-toolbar)
- AgentChat 文本引用区域初始状态正确隐藏(无引用时 .text-references 不渲染)
... ...
# 需求 1008258 - PDF 浏览器截图引用到对话框上下文
## 原始需求(一字不差)
**需求标题**:【工作台】PDF的浏览器-截图引用到对话框的上下文
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:33:37 | **修改时间**:2026-03-13 20:07:40
**原始描述**
> 1.点击按钮,开启截图功能
>
> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210728_165.png]
>
> 2.截完图之后,会把图片加入到对话框的上下文
>
> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210785_770.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在工作台的PDF浏览器中添加截图功能,截图后将图片加入到对话框的上下文。
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加截图按钮和截图功能
2. linkmed-vue3/src/components/Workspace/WorkspaceChat.vue:添加处理截图的逻辑
3. linkmed-vue3/src/stores/chatApi.ts:添加状态管理逻辑
4. linkmed-vue3/src/api/files.ts:可能需要添加文件上传相关接口
### 验收用例
1. 截图功能:点击截图按钮可以开启截图功能
2. 图片添加到对话框:截图后图片会加入到对话框的上下文
3. 图片显示:对话框中可以正常显示截图图片
复杂度:Medium
```
---
## 图片理解
### 图片1(1860 × 1598 像素)- 截图按钮激活状态
**画面内容**
- 左侧:PDF 查看器,显示物理医学期刊论文
- **红色方框标注**:PDF 查看器顶部工具栏右侧,有一个被红框圈出的区域,该区域显示一个 `×`(关闭/取消)图标
- 这说明:截图模式**已处于激活状态**,工具栏上显示的是「取消截图」的 `×` 按钮,而非激活前的相机图标
- 右侧:AI Chat 面板正常显示
**关键理解**
- 截图模式激活后,工具栏上的截图按钮变为 `×`(取消截图)
- 截图激活时,整个 PDF 区域上方会叠加遮罩层,支持拖拽选区
### 图片2(1856 × 1604 像素)- 截图加入对话框上下文
**画面内容**
- 左侧:同一 PDF 查看器
- 右侧底部:AI Chat 输入区域
- **红色箭头标注**:输入框区域下方偏右位置,可以看到一个小图片缩略图(截图预览)
- 该截图缩略图出现在聊天输入框的上下文附件区域,说明截图已成功加入对话上下文
**关键细节**
- 截图缩略图位于输入框上方的附件预览区
- 缩略图较小(约 80×60px),可能带有删除按钮
- 截图作为图片附件被加入到本次提问的上下文中
---
## 实现方案
### 数据流
```
PDF 工具栏截图按钮(相机图标)点击
→ toggleScreenshotMode() 激活,按钮变为 ×
→ PDF 区域叠加透明遮罩层 .screenshot-overlay
→ 用户 mousedown → 拖拽 → mouseup 形成选区
→ captureScreenshotRegion():遍历 PDF canvas,合成选区截图(处理 devicePixelRatio)
→ chatApiStore.addPendingScreenshot(base64DataURL)
→ AgentChat.vue .screenshot-previews 显示缩略图
→ 用户发送时,msg.screenshots 传给 AgentPanelBody
→ 上传文件获取 fileId → 加入 context
→ clearPendingScreenshots()
```
### 1. chatApi.ts(store)
- 新增 `PendingScreenshot` 类型(base64 data URL 字符串)
-`ChatApiState` 添加 `pendingScreenshots: PendingScreenshot[]`
- 新增 actions:`addPendingScreenshot`、`removePendingScreenshot`、`clearPendingScreenshots`
### 2. PDFViewerIframe.vue
- 工具栏新增截图按钮(相机图标 `fa-camera`),激活后变为 `×` 取消按钮
- 激活时在 PDF 查看器上方叠加全屏透明遮罩 `.screenshot-overlay`(cursor: crosshair)
- 鼠标 mousedown/mousemove/mouseup 事件实现拖拽选区(半透明蓝色选框)
- `captureScreenshotRegion`:遍历所有页面的 canvas,根据选区坐标合成截图(处理 devicePixelRatio)
- Esc 键监听取消截图模式
- 截图完成后自动关闭截图模式,调用 `chatApiStore.addPendingScreenshot`,显示成功提示
### 3. AgentChat.vue
- 在输入框上方添加 `.screenshot-previews` 截图缩略图区域
- 每个缩略图:80×60px 图片预览 + 右上角 × 删除按钮
- `canSend` computed 增加 `pendingScreenshots.length > 0` 条件(有截图时也可发送)
- `handleSend` 中将截图数组通过 `msg.screenshots` 传给父组件,发送后调用 `clearPendingScreenshots`
### 4. AgentPanelBody.vue
- 导入 `createUploadSession`、`uploadBatch` from `@/api/files`
- `handleSendAsk`:解析 `msg.screenshots`,将 base64 转为 File 对象,通过 `createUploadSession` + `uploadBatch` 上传,将得到的 fileId 加入 `contextArray`
- `handleSendAgent`:同上,将截图 fileId 加入 `newContextFileIds`
---
## 修改的文件
- `src/stores/chatApi.ts`
- `src/components/Workspace/PDFViewerIframe.vue`
- `src/components/AgentPanel/AgentChat.vue`
- `src/components/AgentPanel/AgentPanelBody.vue`
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 截图部分,18 个相关用例)
| 测试组 | 用例 |
|------|------|
| pendingScreenshots 初始状态 | 初始为空数组 |
| addPendingScreenshot | 添加单张截图;连续添加多张;base64 原样保存不处理 |
| removePendingScreenshot | 按索引删除;删除第一张;删除最后一张 |
| clearPendingScreenshots | 清空所有;对空数组操作不报错 |
| 互不干扰 | 清空截图不影响文本引用;清空文本引用不影响截图 |
**文件2:`src/test/features/text-reference-format.test.ts`**(截图 base64 转 File 逻辑,7 个用例)
| 测试组 | 用例 |
|------|------|
| base64ToFile | 转换为 File 实例;MIME 类型正确;文件名含 screenshot;文件名含 index;无 MIME 默认 image/png;文件大小>0;多张截图文件名不同 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008258 相关 3 个用例)
- PDF 查看器工具栏存在截图按钮(.screenshot-btn 或 fa-camera)
- AgentChat 截图预览区域初始状态正确隐藏(无截图时 .screenshot-previews 不渲染)
- 对话框输入区域整体结构完整(.chat-input-container 存在)
... ...
# 需求 1008260 - 对话框添加到文章中
## 原始需求(一字不差)
**需求标题**:【工作台】对话框,添加到文章中
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:High | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:41:03 | **修改时间**:2026-03-12 17:32:34
**原始描述**
> 点击按钮之后,把对话上面的内容,添加到当前编辑器打开的Markdown文件的光标处。
>
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773211208_459.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在 LinkMed 工作台,点击对话框中的「添加到文章」按钮,将当前对话上方的指定内容插入到当前已打开 Markdown 编辑器的光标位置。
### 涉及模块
- linkmed-vue3/workspace
- linkmed-vue3/shared
### 改动文件清单
1. linkmed-vue3/src/pages/Workspace.vue
### 需要新增/修改的接口
无(不涉及后端接口调用)
### 数据库变更(Flyway)
### 开发步骤
1. 确认当前 Workspace.vue 中是否存在对话框组件、Markdown 编辑器组件及其对外暴露的 insertContentAtCursor 方法
2. 在对话框组件中新增「添加到文章」按钮
3. 实现点击按钮时获取当前对话上方指定内容的逻辑
4. 调用 Markdown 编辑器或状态管理的插入内容方法,将获取到的对话内容插入到光标处
### 验收用例
1. 当未打开任何 Markdown 文件时,点击「添加到文章」按钮,不执行插入且有友好提示
2. 当打开 Markdown 文件但光标未定位时,点击「添加到文章」按钮,内容插入到文件末尾
3. 当打开 Markdown 文件且光标定位在中间时,点击「添加到文章」按钮,内容准确插入到光标位置
4. 插入的内容格式需符合 Markdown 规范(保留换行、标题等原始格式)
### 风险与依赖
- 依赖现有对话框组件能正确暴露或获取当前对话内容的能力
- 依赖现有 Markdown 编辑器组件或状态管理能提供稳定的 insertContentAtCursor 方法
复杂度:Low
```
---
## 图片理解
**设计图尺寸**:2016 × 1604 像素(完整页面截图)
**图片内容描述**
整体布局是工作台双栏视图:
- **左侧**:文档编辑区域,显示正在编辑的医学文档(放疗相关内容,包含公式和分段文字)
- **右侧**:AI Chat 对话面板,展示 AI 生成的回答内容
**关键标注**:红色箭头指向左侧文档区域底部的输入框旁边,注释文字写道:「增加一个按钮,'添加到文章中'」
**按钮位置**:图中红色箭头明确指向**对话框底部操作区**(AI 回答气泡下方的操作按钮栏),说明「添加到文章」按钮应该紧挨着 AI 回答气泡,在每条 AI 回复下方的操作行中显示。
**设计意图**:用户在工作台看到 AI 生成的内容后,可以一键将该条 AI 回答插入到当前正在编辑的文档中的光标位置。
---
## 实现方案
### 功能描述
在每条 AI 回答气泡下方的操作区,新增「添加到文章」按钮。点击后将该条回答的 Markdown 内容插入到当前激活编辑器的光标位置。
### 调用链路
```
AgentPanelBody.vue(按钮点击)
→ emit('add-to-article', content)
→ IntelligencePanel.vue(透传)
→ emit('add-to-article', content)
→ Workspace.vue(处理)
→ activeEditorRef.insertTextAtCursor(content)
```
### 实现细节
1. **AffineEditor.vue**:在 `defineExpose` 中暴露 `insertTextAtCursor` 方法(原已有,只新增暴露)
2. **AgentPanelBody.vue**
- 新增 `add-to-article` emit 事件
- ask 模式和 agent 模式的 AI 回答操作区都新增「添加到文章」按钮
- 添加 `handleAddToArticle` 处理函数
3. **IntelligencePanel.vue**:新增 `add-to-article` emit 并透传
4. **Workspace.vue**:实现 `handleAddToArticle`,检查当前 tab 类型,调用编辑器插入
### 图标
创建 `public/tianjia.svg` - 文档加号图标(SVG 格式,带加号的文档图标)
---
## 修改的文件
- `src/components/Workspace/AffineEditor.vue`:暴露 insertTextAtCursor
- `src/components/AgentPanel/AgentPanelBody.vue`:添加按钮和事件
- `src/layout/IntelligencePanel.vue`:透传事件
- `src/pages/Workspace.vue`:监听事件并调用编辑器
- `public/tianjia.svg`:新增图标文件
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
文件:`src/test/features/text-reference-format.test.ts`(1008260 相关部分)
| 测试组 | 用例 |
|------|------|
| 添加到文章内容校验 | 有内容通过;空字符串失败并提示;纯空白失败;Markdown 格式内容通过;前后空白 trim 后有内容则通过 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008260 相关 3 个用例)
- 工作台右侧对话区存在 AI 回答操作栏
- 对话区 AI 回答操作区包含添加到文章按钮(tianjia.svg 图标)
- AgentChat 输入区存在文本引用或截图预览区域挂载点
... ...
# 需求 1008265 - 编辑器的图标框
## 原始需求(一字不差)
**需求标题**:【工作台】编辑器的图标框
**状态**:规划中 | **负责人**:尹帮会 | **创建者**:Ryan章桦
**创建时间**:2026-03-11 16:13:58 | **修改时间**:2026-03-12 10:42:40
**原始描述**
> 类似腾讯文档,常用的放在最上面的图标
>
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773216836_934.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在工作台编辑器中添加类似腾讯文档的图标框功能,将常用图标放在最上面,方便用户快速访问和使用。
### 涉及模块
- linkmed-vue3/workspace
- linkmed-vue3/shared
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/AffineEditor.vue
2. linkmed-vue3/src/pages/Workspace.vue
### 需要新增/修改的接口
### 数据库变更(Flyway)
### 开发步骤
1. 分析 AffineEditor.vue 组件的现有结构,找到编辑器工具栏的实现位置
2. 设计图标框组件的布局,确保常用图标放在最上面
3. 在 AffineEditor.vue 中实现图标框组件
4. 根据腾讯文档的风格调整图标框的样式和交互
5. 在 Workspace.vue 中集成并测试图标框功能
6. 进行整体功能测试和样式优化
### 验收用例
1. 验证编辑器中是否显示图标框
2. 验证常用图标是否显示在图标框的最上面
3. 验证图标框的交互功能是否正常(点击、悬停等)
4. 验证图标框的样式是否符合设计要求
5. 验证图标框在不同屏幕尺寸下的显示效果
### 风险与依赖
- 依赖 AffineEditor 组件的现有结构和功能
- 风险:如果 BlockSuite 编辑器的工具栏实现方式与预期不同,可能需要调整实现方案
复杂度:Low
```
---
## 图片理解
**设计图尺寸**:569 × 51 像素(水平条形工具栏截图)
**图片内容**(从左到右逐个图标):
| 序号 | 图标 | 含义 |
|------|------|------|
| 1 | ✨ 问问AI | AI 辅助写作入口(带文字标签,蓝色星星图标) |
| — | 竖线分隔 | — |
| 2 | ≡▾ | 段落/列表样式下拉菜单 |
| 3 | **B** | 加粗 (Bold) |
| 4 | `<>` | 代码块 (Code) |
| 5 | _I_ | 斜体 (Italic) |
| 6 | ⊘ | 插入超链接 (Link) |
| 7 | S̶ | 删除线 (Strikethrough) |
| 8 | U̲ | 下划线 (Underline) |
| 9 | ✏▾ | 高亮/文字颜色下拉菜单 |
| 10 | ⊞ | 插入表格 |
| 11 | ··· | 更多选项 |
**设计意图**:这是一条**固定常驻的快捷格式化工具栏**(Quick Format Toolbar),位于编辑器顶部,始终可见,不依赖文本选中状态。类似腾讯文档、Google Docs 顶部的格式工具栏,让用户无需选中文字就能快速访问常用格式操作。
---
## ⚠️ 第一版实现偏差(已修正)
**第一版错误实现**
Agent 将「图标框」误解为**文档 emoji 图标**——在文档标题上方添加了 emoji 选取区域(`.doc-icon-area`),让用户给文档选一个装饰性 emoji,存储在 localStorage。
**正确理解**
「图标框」= **格式化快捷工具栏**(Formatting Toolbar),需要常驻在编辑器顶部,提供快速文本格式化入口,而不是文档 emoji 装饰。
---
## 实现方案(修正后)
### 功能描述
`AffineEditor.vue` 的 `editor-toolbar`(顶部元信息栏)下方、BlockSuite 编辑区域上方,新增一行常驻格式化工具栏 `.icon-toolbar`
- **始终可见**,不依赖文本选中
- 提供常用格式化快捷图标:AI、段落样式、加粗、代码、斜体、链接、删除线、下划线、高亮、表格、更多
- 点击格式化按钮,通过 BlockSuite 内部命令 API 对当前选区或光标处应用格式
### 技术实现
通过 `editorHost` 获取 BlockSuite 的 `std.command` 执行格式化命令:
- `toggleBold` / `toggleItalic` / `toggleUnderline` / `toggleStrike` / `toggleCode`:文本内联格式
- 链接:通过 `toggleLink` 命令或手动触发 link popup
- 表格:通过 `insertTable` command 或 AffineEditor 的 `insertContent` 方法
### 「问问AI」按钮
调用现有 AI 辅助写作入口(与现有 AI 面板联动)
## 修改的文件
- `src/components/Workspace/AffineEditor.vue`
- 删除:emoji 图标选择器(doc-icon-area、emoji-picker 及所有相关代码)
- 新增:`.icon-toolbar` 格式化工具栏(template + style)
- 新增:各格式化操作的处理函数
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
- 已删除:`src/test/features/doc-icon.test.ts`(emoji 图标相关,不再有效)
- 格式化工具栏为纯 UI 操作,单元测试意义有限,以 E2E 覆盖为主
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`
| 用例 | 验证内容 |
|------|------|
| 编辑器存在格式化工具栏 | `.icon-toolbar` 可见 |
| 工具栏包含格式化按钮 | B / I / U 等按钮存在 |
| AI 入口按钮存在 | `.icon-toolbar .ask-ai-btn` 可见 |
... ...
# 缺陷 1008567 - Word 文件解析失败
## 原始需求(一字不差)
**缺陷标题**:word-文件解析失败
**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
---
## 图片理解
### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
**画面内容**
- 工作台页面顶部红色错误横幅:`文档加载失败: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`
- 左侧文件树显示多个文件(kisling2022.pdf、ZJ-TD-OT 系列文件等)
- 右侧为 AI 对话面板,正常运行
**关键细节**
- 错误来自 jszip 库,.docx 本质是 ZIP 包,jszip 无法识别文件头
- 错误出现在文档加载阶段,文件内容本身可能损坏/旧格式
### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
**画面内容**
- 工作台左侧编辑区显示两个 tab(afc22...、ZJ-T...)
- 当前 tab 显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
- 右侧 AI 面板正在对文档内容进行问答(文件已被知识库解析,可提问)
**关键细节**
- 错误码 88 来自 X2T WASM 本地转换失败
- 文件在知识库侧可正常解析提问,说明文件本身是完好的
- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
---
## 评论
**[2026-03-11 14:33:34] 张倩如**
> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
**[2026-03-11 14:33:57] 张倩如**
> 仅预览,不要支持修改!!!!
**[2026-02-12 12:48:47] 张倩如**(含截图2):
> 文件可解析可提问,只是前端渲染不出来
**关键约束**
- 后端已将 Word 文件转换为新版 Office 格式并上传至 TOS
- 前端只需从 TOS 拉取预览,**禁止支持编辑修改**
- TOS 路径规则:`docs-parsed/prod{fileId}/convert.{toExt}`
---
## 根因分析
### 两个错误来源不同
1. **jszip 错误**`Can't find end of central directory`):前端尝试直接用 jszip 解析旧版 `.doc` 文件,旧版 Word 不是标准 ZIP 格式
2. **X2T code 88**:前端本地 WASM 转换工具(X2T)处理某些 Word 文件时转换失败
### 根本问题
`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足。
### 解决路径
后端已有文件转换 pipeline,将 Word 文件转为新版格式上传至 TOS。前端优先使用后端转换好的版本,X2T 处理新版 docx 兼容性更好,若 TOS 无转换版本(老文件)则静默回退原文件。
### 涉及文件
- `src/components/Workspace/OnlyOfficeViewer.vue`:文件加载入口
- `src/utils/onlyoffice/converter.ts`:X2T 转换和 OnlyOffice 编辑器初始化
---
## 修复方案
### TOS 转换版本优先策略
`OnlyOfficeViewer.vue` 的 `initViewer()` 中,下载原文件前先尝试从 TOS 拉取后端转换版本:
```
TOS key: docs-parsed/prod{fileId}/convert.{toExt}
扩展名映射: doc/docx → docx, ppt/pptx → pptx, xls/xlsx → xlsx
```
成功 → 使用转换版本;失败(老文件无转换版本)→ 静默回退下载原文件。
### 只读预览
`createEditorInstance` 新增 `readOnly` 参数,`permissions: { edit: !readOnly }`。`OnlyOfficeViewer` 调用 `openDocument` 时传入 `readOnly: true`
### 修改的文件
- `src/components/Workspace/OnlyOfficeViewer.vue`:TOS 优先逻辑 + 只读模式
- `src/utils/onlyoffice/converter.ts`:`createEditorInstance`/`openDocument` 新增 `readOnly` 参数
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19 个用例全部通过
## 测试覆盖
### 单元测试(Vitest)
文件:`src/test/features/onlyoffice-tos-fallback.test.ts`(12 个用例)
| 测试组 | 用例 |
|------|------|
| TOS storageKey 构造 | doc/docx/ppt/pptx/xls/xlsx 路径正确;不支持格式返回 null;无扩展名返回 null |
| 转换后文件名构造 | 各格式文件名扩展名正确替换;新格式保持不变 |
### E2E 测试(Playwright)
文件:`e2e/tests/onlyoffice-word-preview.spec.ts`(1 个用例,通过)
- 工作台加载不出现 jszip Word 解析错误
## 验收条件
1. 打开 Word/PPT/Excel 文件时优先从 TOS 加载后端转换版本 ✅
2. TOS 无转换版本(老文件)静默回退原文件 ✅
3. 预览模式为只读,OnlyOffice 编辑按钮不可用 ✅
4. 不影响 PDF、图片、Markdown 等其他文件类型 ✅
... ...
# 缺陷 1008905 - 知识库右键菜单第一项显示原始翻译Key
## 原始需求(一字不差)
**缺陷标题**:这个是啥?
**状态**:new | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:medium | **创建者**:Ryan章桦
**创建时间**:2026-03-12 15:09:09 | **修改时间**:2026-03-12 15:09:09
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773299328_100.png]
---
## 图片理解
### 截图(3066 × 1826 像素)- 知识库文件列表页面右键菜单
**画面内容**
- LinkMed 知识库页面,左侧为文件夹树,右侧为文件列表(「我的文档」视图)
- 用户在左侧某文件夹(其他任务)上触发了右键菜单
- 右键菜单弹出,包含 4 项:
1. **`KnowledgeBase.open`**(异常!应显示「打开」)
2. 重命名(图标:铅笔)
3. 下载(图标:下载箭头)
4. 删除(图标:垃圾桶,红色)
- 右侧文件列表中可见多个文件夹和文件,知识库状态列部分显示「已完成」
**关键细节**
- 第一个菜单项显示原始翻译 key `KnowledgeBase.open`,而非中文「打开」
- 其他三项(重命名、下载、删除)均正常显示中文
---
## 根因分析
### 错误类型
i18n 翻译 key 缺失,导致 vue-i18n `t()` 函数返回 key 字符串本身。
### 为什么会这样
`FileList.vue` 第 284 行右键菜单第一项代码:
```vue
{{ contextMenuFile?.isFolder ? (t("KnowledgeBase.open") || "打开") : (t("KnowledgeBase.edit") || "编辑") }}
```
- `t("KnowledgeBase.open")` 找不到对应翻译时,vue-i18n 返回 key 字符串 `"KnowledgeBase.open"`
- `"KnowledgeBase.open"` 是非空字符串(真值),所以 `|| "打开"` 兜底**永远不会执行**
- `KnowledgeBase` 翻译对象中有 `edit`、`rename`、`download`、`delete`,但**缺少 `open`**
### 涉及文件
- `src/locales/zh-CN.ts`:KnowledgeBase 下缺少 `open: "打开"`
- `src/locales/en-US.ts`:KnowledgeBase 下缺少 `open: "Open"`
- `src/components/KnowledgeBase/FileList.vue`:第 284 行调用 `t("KnowledgeBase.open")`
---
## 修复方案
在 zh-CN 和 en-US 翻译文件的 `KnowledgeBase` 对象中补充 `open` key。
### 修改的文件
- `src/locales/zh-CN.ts`:新增 `open: "打开"`
- `src/locales/en-US.ts`:新增 `open: "Open"`
### 具体改动
```typescript
// zh-CN.ts — KnowledgeBase 下新增
open: "打开",
edit: "编辑", // 原有,参考位置
// en-US.ts — KnowledgeBase 下新增
open: "Open",
edit: "Edit", // 原有,参考位置
```
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19 个用例全部通过
## 测试覆盖
### 单元测试(Vitest)
文件:`src/test/features/locale-knowledge-base.test.ts`(9 个用例)
| 测试组 | 用例 |
|------|------|
| zh-CN 翻译 | open=打开;edit=编辑;rename=重命名;download=下载;delete=删除 |
| en-US 翻译 | open=Open;edit=Edit |
| 非空校验 | zh-CN/en-US KnowledgeBase.open 均非空字符串(防兜底失效) |
### E2E 测试(Playwright)
文件:`e2e/tests/knowledge-base-context-menu.spec.ts`(2 个用例)
- 右键菜单第一项不应显示翻译 key 字符串(需登录态,无数据时跳过)
- 右键文件夹菜单应包含「打开」「重命名」「下载」「删除」完整4项
## 验收条件
1. 知识库文件列表中,右键文件夹,第一个菜单项显示「打开」✅
2. 右键文件,第一个菜单项显示「编辑」✅
3. 切换为英文时分别显示「Open」和「Edit」✅
... ...
# 缺陷 1008906 - 部分苹果笔记本 Safari 浏览器登录报错无法跳转
## 原始需求(一字不差)
**缺陷标题**:部分苹果笔记本safai浏览器登录会报错,无法登录跳转成功
**状态**:new | **负责人**:尹帮会 | **优先级**:medium | **创建者**:尹帮会
**创建时间**:2026-03-13 10:45:22 | **修改时间**:2026-03-19 12:27:47
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773369821_174.png]
>
> 控制台清除于:11:37:53
>
> 成功导航到:- "/auth"
>
> LanguageSwitcher mounted, current locale: - "zh-CN"
>
> 路由错误:
> 7 SyntaxError: Invalid regular expression: invalid group specifier name
> parseModule
> (匿名函数)
> asyncFunctionResume
> (匿名函数)
> promiseReactionJobWithoutPromise
> (匿名函数)
> - index-B9mQj3Cz.js:9:136594
> forEach
> oe — vue-foundation-CF2dS5jX.js:43:169414
> promiseReactionJob
> index-B9mQj3Cz.js:9:136546
> LanguageSwitcher-B--4nNjM.js:1:1223
> index-B9mQj3Cz.js:9:136594
---
## 图片理解
### 截图(2256 × 524 像素)- Safari DevTools 控制台
**画面内容**
- Safari 浏览器开发者工具控制台截图
- 控制台顶部显示清除时间 11:37:53
- 第1行(蓝色):成功导航到 `/auth` 路由,来源 `index-B9mQj3Cz.js:9:136546`
- 第2行(蓝色):`LanguageSwitcher mounted, current locale: "zh-CN"`,来源 `LanguageSwitcher-B--4nNjM.js:1:1223`
- 第3行(红色错误):路由错误展开项
- 错误类型:`SyntaxError: Invalid regular expression: invalid group specifier name`
- 调用栈:parseModule → 匿名函数 → asyncFunctionResume → 匿名函数 → promiseReactionJobWithoutPromise → 匿名函数(`index-B9mQj3Cz.js:9:136594`)→ forEach → oe(`vue-foundation-CF2dS5jX.js:43:169414`)→ promiseReactionJob
**关键信息**
- 路由导航到 `/auth` 成功(蓝色日志正常)
- `LanguageSwitcher` 组件成功挂载并打印 locale
- 随后在路由解析阶段抛出正则表达式语法错误
- 错误发生在 `LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 组件的生产包)
- 错误向上传播至主 bundle `index-B9mQj3Cz.js`
---
## 根因分析
### 错误类型
`SyntaxError: Invalid regular expression: invalid group specifier name`
这是 Safari 遇到**命名捕获组正则**(Named Capture Groups,ES2018)时的报错形式:
```js
/(?<year>\d{4})-(?<month>\d{2})/ // 老版 Safari 无法解析
```
### 为什么报错
- 项目 `vite.config.ts` 中 `build.target` 设为 `"es2022"`
- esbuild 在 `es2022` 目标下不会主动降级命名捕获组语法,直接输出到产物中
- `vue-i18n` 的内部依赖 `@formatjs/icu-messageformat-parser` / `intl-messageformat` 在解析 locale 消息时使用了命名捕获组正则
- 部分旧版 macOS Safari(13.x / 14.0 及更早)不支持命名捕获组,解析产物 JS 时抛出 SyntaxError
- 路由组件懒加载 LanguageSwitcher 时触发该错误,导致路由跳转失败
### 涉及文件
- 错误源:`node_modules/` 内 vue-i18n 相关依赖(`@formatjs/icu-messageformat-parser`
- 错误体现在产物:`LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 的独立 chunk)
- 配置文件:`vite.config.ts`
---
## 修复方案
`vite.config.ts` 的 `build.esbuild` 中添加 `supported: { 'named-capture-groups': false }`
该选项告知 esbuild:目标环境不支持命名捕获组,需将其转换为等效的无名捕获组形式,从而兼容旧版 Safari。
### 修改的文件
- `vite.config.ts`:在 `build.esbuild` 中添加 `supported` 配置
### 具体改动
```typescript
// vite.config.ts (build.esbuild 块)
esbuild: {
drop: isProd ? ["console", "debugger"] : [],
legalComments: "none",
target: "es2022",
// 强制降级命名捕获组正则,兼容部分旧版 Safari(Issue #1008906)
// Safari 13/14 部分版本不支持命名捕获组,会抛出 "invalid group specifier name" 错误
supported: {
'named-capture-groups': false,
},
},
```
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- 修复后需在旧版 Safari 验证登录流程正常
## 验收条件
1. 老版本 Safari(13.x/14.0)打开登录页不再出现 `SyntaxError: Invalid regular expression` 报错
2. 登录后能正常跳转至工作台
3. LanguageSwitcher 语言切换功能不受影响
... ...
# 需求 1008257 - PDF 浏览器引用文本到对话框上下文
## 原始需求(一字不差)
**需求标题**:【工作台】PDF的浏览器-引用文本到对话框的上下文
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:31:11 | **修改时间**:2026-03-13 21:01:56
**原始描述**
> 在PDF文件中选择文本,出现菜单,选择"引用文本到对话框":
>
> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210470_797.png]
>
> 对话框中会出现对PDF文件的引用,鼠标悬停的时候,还可以看到引用的文本内容:
>
> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210606_307.png]
>
> 然后进行提问,会得到针对性的结果
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在 PDF 文件中选择文本,出现菜单,选择"引用文本到对话框":对话框中会出现对 PDF 文件的引用,
鼠标悬停时可以看到引用的文本内容,然后进行提问,会得到针对性的结果。
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加文本选择和菜单功能
2. linkmed-vue3/src/components/AgentPanel/AgentChatAgent.vue:添加引用显示和悬停效果
3. linkmed-vue3/src/api/chat.ts:添加引用相关的接口
4. linkmed-vue3/src/stores/chatApi.ts:添加引用相关的状态
### 验收用例
1. 文本选择和菜单:在 PDF 中选择文本,出现菜单
2. 引用到对话框:选择"引用文本到对话框",对话框中出现引用
3. 悬停显示:鼠标悬停在引用上时显示引用的文本内容
4. 提问和回复:提问后得到针对性的结果
复杂度:Medium
```
---
## 图片理解
### 图片1(1854 × 1602 像素)- PDF 文本选中菜单
**画面内容**
- 左侧:PDF 查看器,显示一篇物理医学期刊论文(Physics in Medicine & Biology)
- 用户在 PDF 中选中了一段英文文本(蓝色高亮区域)
- 选中文本后弹出浮动菜单,菜单包含两个按钮:
1. **"Copy and Cite"**(复制并引用)
2. **"Quote in Chat"**(引用到对话)—— 这就是目标功能的入口
- 右侧:AI Chat 面板,显示正在进行的对话
**关键细节**
- 菜单出现在选中文本附近(浮动定位)
- 菜单样式为白色背景卡片,带有圆形图标
- "Quote in Chat" 使用引号图标(💬)
### 图片2(1854 × 1602 像素)- 对话框中的引用标签
**画面内容**
- 左侧:同一 PDF 查看器(同一篇论文)
- 右侧 AI Chat 面板底部输入区域,显示引用成功加入上下文后的状态:
- 输入框上方出现了一个**引用标签/芯片(chip)**:显示 `📄 PDF Quote ×`
- 该标签代表已引用的 PDF 文本片段
- 右上角有 `×` 按钮可以移除引用
- 对话列表中还可见 **"1 source"** 标签(表明已有来源引用)
**悬停行为**(需推断):
- 鼠标悬停在 `PDF Quote ×` 标签上时,应弹出 tooltip 显示引用的原始文本内容
**关键细节**
- 引用标签样式:小型 chip,带文件类型图标 + "PDF Quote" 文字 + `×` 删除按钮
- 标签位于聊天输入框上方(与截图功能的缩略图区域并列)
- 标签颜色为浅灰/白色背景
---
## 实现方案
### 数据流
```
PDFViewerIframe mouseup → 显示浮动菜单
→ 点击「Quote in Chat」
→ chatApiStore.addPendingTextReference(text, fileName, fileId)
→ AgentChat.vue 渲染引用标签(.text-references chip)
→ 鼠标悬停显示原始引用文本(el-tooltip)
→ 用户发送时,引用文本格式化为 Markdown 引用块前缀拼入问题
→ 发送后 clearPendingTextReferences()
```
### 1. chatApi.ts(store)
- 新增 `PendingTextReference` 接口(text, fileName, fileId)
-`ChatApiState` 添加 `pendingTextReferences: PendingTextReference[]`
- 新增 actions:`addPendingTextReference`、`removePendingTextReference`、`clearPendingTextReferences`
### 2. PDFViewerIframe.vue
- 引入 `useWorkspaceChatStore`
- 新增 `showTextMenu`、`textMenuPos`、`selectedTextContent` 状态
- `handleTextMouseUp`:mouseup 时读取 window.getSelection(),计算菜单位置
- `handleGlobalMouseDown`:点击菜单外关闭菜单
- `quoteTextToChat`:调用 store 添加引用,显示成功 ElMessage
- template 中添加 `.pdf-text-menu` 浮动菜单(含「Copy and Cite」和「Quote in Chat」按钮)
### 3. AgentChat.vue
- template 中在输入框上方添加 `.text-references` 文本引用 chip 区域
- 每个 chip 显示:📄 PDF Quote + 删除 `×` 按钮 + el-tooltip 悬停显示原文
- `canSend` computed 增加 `pendingTextReferences.length > 0` 条件
- `handleSend` 中将文本引用格式化为 Markdown 引用块前缀,拼入最终问题
- 发送后调用 `chatApiStore.clearPendingTextReferences()`
---
## 修改的文件
- `src/stores/chatApi.ts`
- `src/components/Workspace/PDFViewerIframe.vue`
- `src/components/AgentPanel/AgentChat.vue`
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 部分,28 个用例)
| 测试组 | 用例 |
|------|------|
| pendingTextReferences 初始状态 | 初始为空数组 |
| addPendingTextReference | 添加单条;连续添加多条;保存数字/字符串 fileId |
| removePendingTextReference | 按索引删除;删除第一条;删除最后一条 |
| clearPendingTextReferences | 清空所有;对空数组操作不报错 |
| 互不干扰 | 清空文本引用不影响截图;删除截图不影响文本引用 |
**文件2:`src/test/features/text-reference-format.test.ts`**(格式化逻辑部分,7 个用例)
| 测试组 | 用例 |
|------|------|
| buildFinalQuestion | 无引用返回原始问题;有1条引用格式正确;多条引用用空行分隔;有引用无问题返回纯引用;文件名包在《》中;引用文本以>开头;保留特殊字符 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008257 相关 3 个用例)
- 工作台存在 PDF 查看器组件挂载点(.pdf-viewer-js 等)
- PDF 查看器工具栏存在功能按钮(.pdf-toolbar)
- AgentChat 文本引用区域初始状态正确隐藏(无引用时 .text-references 不渲染)
... ...
# 需求 1008258 - PDF 浏览器截图引用到对话框上下文
## 原始需求(一字不差)
**需求标题**:【工作台】PDF的浏览器-截图引用到对话框的上下文
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:Middle | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:33:37 | **修改时间**:2026-03-13 20:07:40
**原始描述**
> 1.点击按钮,开启截图功能
>
> [图片1: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210728_165.png]
>
> 2.截完图之后,会把图片加入到对话框的上下文
>
> [图片2: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773210785_770.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在工作台的PDF浏览器中添加截图功能,截图后将图片加入到对话框的上下文。
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/PDFViewerIframe.vue:添加截图按钮和截图功能
2. linkmed-vue3/src/components/Workspace/WorkspaceChat.vue:添加处理截图的逻辑
3. linkmed-vue3/src/stores/chatApi.ts:添加状态管理逻辑
4. linkmed-vue3/src/api/files.ts:可能需要添加文件上传相关接口
### 验收用例
1. 截图功能:点击截图按钮可以开启截图功能
2. 图片添加到对话框:截图后图片会加入到对话框的上下文
3. 图片显示:对话框中可以正常显示截图图片
复杂度:Medium
```
---
## 图片理解
### 图片1(1860 × 1598 像素)- 截图按钮激活状态
**画面内容**
- 左侧:PDF 查看器,显示物理医学期刊论文
- **红色方框标注**:PDF 查看器顶部工具栏右侧,有一个被红框圈出的区域,该区域显示一个 `×`(关闭/取消)图标
- 这说明:截图模式**已处于激活状态**,工具栏上显示的是「取消截图」的 `×` 按钮,而非激活前的相机图标
- 右侧:AI Chat 面板正常显示
**关键理解**
- 截图模式激活后,工具栏上的截图按钮变为 `×`(取消截图)
- 截图激活时,整个 PDF 区域上方会叠加遮罩层,支持拖拽选区
### 图片2(1856 × 1604 像素)- 截图加入对话框上下文
**画面内容**
- 左侧:同一 PDF 查看器
- 右侧底部:AI Chat 输入区域
- **红色箭头标注**:输入框区域下方偏右位置,可以看到一个小图片缩略图(截图预览)
- 该截图缩略图出现在聊天输入框的上下文附件区域,说明截图已成功加入对话上下文
**关键细节**
- 截图缩略图位于输入框上方的附件预览区
- 缩略图较小(约 80×60px),可能带有删除按钮
- 截图作为图片附件被加入到本次提问的上下文中
---
## 实现方案
### 数据流
```
PDF 工具栏截图按钮(相机图标)点击
→ toggleScreenshotMode() 激活,按钮变为 ×
→ PDF 区域叠加透明遮罩层 .screenshot-overlay
→ 用户 mousedown → 拖拽 → mouseup 形成选区
→ captureScreenshotRegion():遍历 PDF canvas,合成选区截图(处理 devicePixelRatio)
→ chatApiStore.addPendingScreenshot(base64DataURL)
→ AgentChat.vue .screenshot-previews 显示缩略图
→ 用户发送时,msg.screenshots 传给 AgentPanelBody
→ 上传文件获取 fileId → 加入 context
→ clearPendingScreenshots()
```
### 1. chatApi.ts(store)
- 新增 `PendingScreenshot` 类型(base64 data URL 字符串)
-`ChatApiState` 添加 `pendingScreenshots: PendingScreenshot[]`
- 新增 actions:`addPendingScreenshot`、`removePendingScreenshot`、`clearPendingScreenshots`
### 2. PDFViewerIframe.vue
- 工具栏新增截图按钮(相机图标 `fa-camera`),激活后变为 `×` 取消按钮
- 激活时在 PDF 查看器上方叠加全屏透明遮罩 `.screenshot-overlay`(cursor: crosshair)
- 鼠标 mousedown/mousemove/mouseup 事件实现拖拽选区(半透明蓝色选框)
- `captureScreenshotRegion`:遍历所有页面的 canvas,根据选区坐标合成截图(处理 devicePixelRatio)
- Esc 键监听取消截图模式
- 截图完成后自动关闭截图模式,调用 `chatApiStore.addPendingScreenshot`,显示成功提示
### 3. AgentChat.vue
- 在输入框上方添加 `.screenshot-previews` 截图缩略图区域
- 每个缩略图:80×60px 图片预览 + 右上角 × 删除按钮
- `canSend` computed 增加 `pendingScreenshots.length > 0` 条件(有截图时也可发送)
- `handleSend` 中将截图数组通过 `msg.screenshots` 传给父组件,发送后调用 `clearPendingScreenshots`
### 4. AgentPanelBody.vue
- 导入 `createUploadSession`、`uploadBatch` from `@/api/files`
- `handleSendAsk`:解析 `msg.screenshots`,将 base64 转为 File 对象,通过 `createUploadSession` + `uploadBatch` 上传,将得到的 fileId 加入 `contextArray`
- `handleSendAgent`:同上,将截图 fileId 加入 `newContextFileIds`
---
## 修改的文件
- `src/stores/chatApi.ts`
- `src/components/Workspace/PDFViewerIframe.vue`
- `src/components/AgentPanel/AgentChat.vue`
- `src/components/AgentPanel/AgentPanelBody.vue`
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
**文件1:`src/test/stores/chatApi-pdf-context.test.ts`**(chatApi store 截图部分,18 个相关用例)
| 测试组 | 用例 |
|------|------|
| pendingScreenshots 初始状态 | 初始为空数组 |
| addPendingScreenshot | 添加单张截图;连续添加多张;base64 原样保存不处理 |
| removePendingScreenshot | 按索引删除;删除第一张;删除最后一张 |
| clearPendingScreenshots | 清空所有;对空数组操作不报错 |
| 互不干扰 | 清空截图不影响文本引用;清空文本引用不影响截图 |
**文件2:`src/test/features/text-reference-format.test.ts`**(截图 base64 转 File 逻辑,7 个用例)
| 测试组 | 用例 |
|------|------|
| base64ToFile | 转换为 File 实例;MIME 类型正确;文件名含 screenshot;文件名含 index;无 MIME 默认 image/png;文件大小>0;多张截图文件名不同 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008258 相关 3 个用例)
- PDF 查看器工具栏存在截图按钮(.screenshot-btn 或 fa-camera)
- AgentChat 截图预览区域初始状态正确隐藏(无截图时 .screenshot-previews 不渲染)
- 对话框输入区域整体结构完整(.chat-input-container 存在)
... ...
# 需求 1008260 - 对话框添加到文章中
## 原始需求(一字不差)
**需求标题**:【工作台】对话框,添加到文章中
**状态**:规划中 | **负责人**:尹帮会 | **优先级**:High | **创建者**:Ryan章桦
**创建时间**:2026-03-11 14:41:03 | **修改时间**:2026-03-12 17:32:34
**原始描述**
> 点击按钮之后,把对话上面的内容,添加到当前编辑器打开的Markdown文件的光标处。
>
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773211208_459.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在 LinkMed 工作台,点击对话框中的「添加到文章」按钮,将当前对话上方的指定内容插入到当前已打开 Markdown 编辑器的光标位置。
### 涉及模块
- linkmed-vue3/workspace
- linkmed-vue3/shared
### 改动文件清单
1. linkmed-vue3/src/pages/Workspace.vue
### 需要新增/修改的接口
无(不涉及后端接口调用)
### 数据库变更(Flyway)
### 开发步骤
1. 确认当前 Workspace.vue 中是否存在对话框组件、Markdown 编辑器组件及其对外暴露的 insertContentAtCursor 方法
2. 在对话框组件中新增「添加到文章」按钮
3. 实现点击按钮时获取当前对话上方指定内容的逻辑
4. 调用 Markdown 编辑器或状态管理的插入内容方法,将获取到的对话内容插入到光标处
### 验收用例
1. 当未打开任何 Markdown 文件时,点击「添加到文章」按钮,不执行插入且有友好提示
2. 当打开 Markdown 文件但光标未定位时,点击「添加到文章」按钮,内容插入到文件末尾
3. 当打开 Markdown 文件且光标定位在中间时,点击「添加到文章」按钮,内容准确插入到光标位置
4. 插入的内容格式需符合 Markdown 规范(保留换行、标题等原始格式)
### 风险与依赖
- 依赖现有对话框组件能正确暴露或获取当前对话内容的能力
- 依赖现有 Markdown 编辑器组件或状态管理能提供稳定的 insertContentAtCursor 方法
复杂度:Low
```
---
## 图片理解
**设计图尺寸**:2016 × 1604 像素(完整页面截图)
**图片内容描述**
整体布局是工作台双栏视图:
- **左侧**:文档编辑区域,显示正在编辑的医学文档(放疗相关内容,包含公式和分段文字)
- **右侧**:AI Chat 对话面板,展示 AI 生成的回答内容
**关键标注**:红色箭头指向左侧文档区域底部的输入框旁边,注释文字写道:「增加一个按钮,'添加到文章中'」
**按钮位置**:图中红色箭头明确指向**对话框底部操作区**(AI 回答气泡下方的操作按钮栏),说明「添加到文章」按钮应该紧挨着 AI 回答气泡,在每条 AI 回复下方的操作行中显示。
**设计意图**:用户在工作台看到 AI 生成的内容后,可以一键将该条 AI 回答插入到当前正在编辑的文档中的光标位置。
---
## 实现方案
### 功能描述
在每条 AI 回答气泡下方的操作区,新增「添加到文章」按钮。点击后将该条回答的 Markdown 内容插入到当前激活编辑器的光标位置。
### 调用链路
```
AgentPanelBody.vue(按钮点击)
→ emit('add-to-article', content)
→ IntelligencePanel.vue(透传)
→ emit('add-to-article', content)
→ Workspace.vue(处理)
→ activeEditorRef.insertTextAtCursor(content)
```
### 实现细节
1. **AffineEditor.vue**:在 `defineExpose` 中暴露 `insertTextAtCursor` 方法(原已有,只新增暴露)
2. **AgentPanelBody.vue**
- 新增 `add-to-article` emit 事件
- ask 模式和 agent 模式的 AI 回答操作区都新增「添加到文章」按钮
- 添加 `handleAddToArticle` 处理函数
3. **IntelligencePanel.vue**:新增 `add-to-article` emit 并透传
4. **Workspace.vue**:实现 `handleAddToArticle`,检查当前 tab 类型,调用编辑器插入
### 图标
创建 `public/tianjia.svg` - 文档加号图标(SVG 格式,带加号的文档图标)
---
## 修改的文件
- `src/components/Workspace/AffineEditor.vue`:暴露 insertTextAtCursor
- `src/components/AgentPanel/AgentPanelBody.vue`:添加按钮和事件
- `src/layout/IntelligencePanel.vue`:透传事件
- `src/pages/Workspace.vue`:监听事件并调用编辑器
- `public/tianjia.svg`:新增图标文件
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
文件:`src/test/features/text-reference-format.test.ts`(1008260 相关部分)
| 测试组 | 用例 |
|------|------|
| 添加到文章内容校验 | 有内容通过;空字符串失败并提示;纯空白失败;Markdown 格式内容通过;前后空白 trim 后有内容则通过 |
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`(含 1008260 相关 3 个用例)
- 工作台右侧对话区存在 AI 回答操作栏
- 对话区 AI 回答操作区包含添加到文章按钮(tianjia.svg 图标)
- AgentChat 输入区存在文本引用或截图预览区域挂载点
... ...
# 需求 1008265 - 编辑器的图标框
## 原始需求(一字不差)
**需求标题**:【工作台】编辑器的图标框
**状态**:规划中 | **负责人**:尹帮会 | **创建者**:Ryan章桦
**创建时间**:2026-03-11 16:13:58 | **修改时间**:2026-03-12 10:42:40
**原始描述**
> 类似腾讯文档,常用的放在最上面的图标
>
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773216836_934.png]
**AI Plan(TAPD 自动生成,仅供参考)**
```
### 需求摘要
在工作台编辑器中添加类似腾讯文档的图标框功能,将常用图标放在最上面,方便用户快速访问和使用。
### 涉及模块
- linkmed-vue3/workspace
- linkmed-vue3/shared
### 改动文件清单
1. linkmed-vue3/src/components/Workspace/AffineEditor.vue
2. linkmed-vue3/src/pages/Workspace.vue
### 需要新增/修改的接口
### 数据库变更(Flyway)
### 开发步骤
1. 分析 AffineEditor.vue 组件的现有结构,找到编辑器工具栏的实现位置
2. 设计图标框组件的布局,确保常用图标放在最上面
3. 在 AffineEditor.vue 中实现图标框组件
4. 根据腾讯文档的风格调整图标框的样式和交互
5. 在 Workspace.vue 中集成并测试图标框功能
6. 进行整体功能测试和样式优化
### 验收用例
1. 验证编辑器中是否显示图标框
2. 验证常用图标是否显示在图标框的最上面
3. 验证图标框的交互功能是否正常(点击、悬停等)
4. 验证图标框的样式是否符合设计要求
5. 验证图标框在不同屏幕尺寸下的显示效果
### 风险与依赖
- 依赖 AffineEditor 组件的现有结构和功能
- 风险:如果 BlockSuite 编辑器的工具栏实现方式与预期不同,可能需要调整实现方案
复杂度:Low
```
---
## 图片理解
**设计图尺寸**:569 × 51 像素(水平条形工具栏截图)
**图片内容**(从左到右逐个图标):
| 序号 | 图标 | 含义 |
|------|------|------|
| 1 | ✨ 问问AI | AI 辅助写作入口(带文字标签,蓝色星星图标) |
| — | 竖线分隔 | — |
| 2 | ≡▾ | 段落/列表样式下拉菜单 |
| 3 | **B** | 加粗 (Bold) |
| 4 | `<>` | 代码块 (Code) |
| 5 | _I_ | 斜体 (Italic) |
| 6 | ⊘ | 插入超链接 (Link) |
| 7 | S̶ | 删除线 (Strikethrough) |
| 8 | U̲ | 下划线 (Underline) |
| 9 | ✏▾ | 高亮/文字颜色下拉菜单 |
| 10 | ⊞ | 插入表格 |
| 11 | ··· | 更多选项 |
**设计意图**:这是一条**固定常驻的快捷格式化工具栏**(Quick Format Toolbar),位于编辑器顶部,始终可见,不依赖文本选中状态。类似腾讯文档、Google Docs 顶部的格式工具栏,让用户无需选中文字就能快速访问常用格式操作。
---
## ⚠️ 第一版实现偏差(已修正)
**第一版错误实现**
Agent 将「图标框」误解为**文档 emoji 图标**——在文档标题上方添加了 emoji 选取区域(`.doc-icon-area`),让用户给文档选一个装饰性 emoji,存储在 localStorage。
**正确理解**
「图标框」= **格式化快捷工具栏**(Formatting Toolbar),需要常驻在编辑器顶部,提供快速文本格式化入口,而不是文档 emoji 装饰。
---
## 实现方案(修正后)
### 功能描述
`AffineEditor.vue` 的 `editor-toolbar`(顶部元信息栏)下方、BlockSuite 编辑区域上方,新增一行常驻格式化工具栏 `.icon-toolbar`
- **始终可见**,不依赖文本选中
- 提供常用格式化快捷图标:AI、段落样式、加粗、代码、斜体、链接、删除线、下划线、高亮、表格、更多
- 点击格式化按钮,通过 BlockSuite 内部命令 API 对当前选区或光标处应用格式
### 技术实现
通过 `editorHost` 获取 BlockSuite 的 `std.command` 执行格式化命令:
- `toggleBold` / `toggleItalic` / `toggleUnderline` / `toggleStrike` / `toggleCode`:文本内联格式
- 链接:通过 `toggleLink` 命令或手动触发 link popup
- 表格:通过 `insertTable` command 或 AffineEditor 的 `insertContent` 方法
### 「问问AI」按钮
调用现有 AI 辅助写作入口(与现有 AI 面板联动)
## 修改的文件
- `src/components/Workspace/AffineEditor.vue`
- 删除:emoji 图标选择器(doc-icon-area、emoji-picker 及所有相关代码)
- 新增:`.icon-toolbar` 格式化工具栏(template + style)
- 新增:各格式化操作的处理函数
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
## 测试覆盖
### 单元测试(vitest)
- 已删除:`src/test/features/doc-icon.test.ts`(emoji 图标相关,不再有效)
- 格式化工具栏为纯 UI 操作,单元测试意义有限,以 E2E 覆盖为主
### E2E 测试(Playwright)
文件:`e2e/tests/workspace-features.spec.ts`
| 用例 | 验证内容 |
|------|------|
| 编辑器存在格式化工具栏 | `.icon-toolbar` 可见 |
| 工具栏包含格式化按钮 | B / I / U 等按钮存在 |
| AI 入口按钮存在 | `.icon-toolbar .ask-ai-btn` 可见 |
... ...
# 缺陷 1008567 - Word 文件解析失败
## 原始需求(一字不差)
**缺陷标题**:word-文件解析失败
**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
---
## 图片理解
### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
**画面内容**
- 工作台页面顶部红色错误横幅:`文档加载失败: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`
- 左侧文件树显示多个文件(kisling2022.pdf、ZJ-TD-OT 系列文件等)
- 右侧为 AI 对话面板,正常运行
**关键细节**
- 错误来自 jszip 库,.docx 本质是 ZIP 包,jszip 无法识别文件头
- 错误出现在文档加载阶段,文件内容本身可能损坏/旧格式
### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
**画面内容**
- 工作台左侧编辑区显示两个 tab(afc22...、ZJ-T...)
- 当前 tab 显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
- 右侧 AI 面板正在对文档内容进行问答(文件已被知识库解析,可提问)
**关键细节**
- 错误码 88 来自 X2T WASM 本地转换失败
- 文件在知识库侧可正常解析提问,说明文件本身是完好的
- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
---
## 评论
**[2026-03-11 14:33:34] 张倩如**
> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
**[2026-03-11 14:33:57] 张倩如**
> 仅预览,不要支持修改!!!!
**[2026-02-12 12:48:47] 张倩如**(含截图2):
> 文件可解析可提问,只是前端渲染不出来
**关键约束**
- 后端已将 Word 文件转换为新版 Office 格式并上传至 TOS
- 前端只需从 TOS 拉取预览,**禁止支持编辑修改**
- TOS 路径规则:`docs-parsed/prod{fileId}/convert.{toExt}`
---
## 根因分析
### 两个错误来源不同
1. **jszip 错误**`Can't find end of central directory`):前端尝试直接用 jszip 解析旧版 `.doc` 文件,旧版 Word 不是标准 ZIP 格式
2. **X2T code 88**:前端本地 WASM 转换工具(X2T)处理某些 Word 文件时转换失败
### 根本问题
`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足。
### 解决路径
后端已有文件转换 pipeline,将 Word 文件转为新版格式上传至 TOS。前端优先使用后端转换好的版本,X2T 处理新版 docx 兼容性更好,若 TOS 无转换版本(老文件)则静默回退原文件。
### 涉及文件
- `src/components/Workspace/OnlyOfficeViewer.vue`:文件加载入口
- `src/utils/onlyoffice/converter.ts`:X2T 转换和 OnlyOffice 编辑器初始化
---
## 修复方案
### TOS 转换版本优先策略
`OnlyOfficeViewer.vue` 的 `initViewer()` 中,下载原文件前先尝试从 TOS 拉取后端转换版本:
```
TOS key: docs-parsed/prod{fileId}/convert.{toExt}
扩展名映射: doc/docx → docx, ppt/pptx → pptx, xls/xlsx → xlsx
```
成功 → 使用转换版本;失败(老文件无转换版本)→ 静默回退下载原文件。
### 只读预览
`createEditorInstance` 新增 `readOnly` 参数,`permissions: { edit: !readOnly }`。`OnlyOfficeViewer` 调用 `openDocument` 时传入 `readOnly: true`
### 修改的文件
- `src/components/Workspace/OnlyOfficeViewer.vue`:TOS 优先逻辑 + 只读模式
- `src/utils/onlyoffice/converter.ts`:`createEditorInstance`/`openDocument` 新增 `readOnly` 参数
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19 个用例全部通过
## 测试覆盖
### 单元测试(Vitest)
文件:`src/test/features/onlyoffice-tos-fallback.test.ts`(12 个用例)
| 测试组 | 用例 |
|------|------|
| TOS storageKey 构造 | doc/docx/ppt/pptx/xls/xlsx 路径正确;不支持格式返回 null;无扩展名返回 null |
| 转换后文件名构造 | 各格式文件名扩展名正确替换;新格式保持不变 |
### E2E 测试(Playwright)
文件:`e2e/tests/onlyoffice-word-preview.spec.ts`(1 个用例,通过)
- 工作台加载不出现 jszip Word 解析错误
## 验收条件
1. 打开 Word/PPT/Excel 文件时优先从 TOS 加载后端转换版本 ✅
2. TOS 无转换版本(老文件)静默回退原文件 ✅
3. 预览模式为只读,OnlyOffice 编辑按钮不可用 ✅
4. 不影响 PDF、图片、Markdown 等其他文件类型 ✅
... ...
# 缺陷 1008905 - 知识库右键菜单第一项显示原始翻译Key
## 原始需求(一字不差)
**缺陷标题**:这个是啥?
**状态**:new | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:medium | **创建者**:Ryan章桦
**创建时间**:2026-03-12 15:09:09 | **修改时间**:2026-03-12 15:09:09
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773299328_100.png]
---
## 图片理解
### 截图(3066 × 1826 像素)- 知识库文件列表页面右键菜单
**画面内容**
- LinkMed 知识库页面,左侧为文件夹树,右侧为文件列表(「我的文档」视图)
- 用户在左侧某文件夹(其他任务)上触发了右键菜单
- 右键菜单弹出,包含 4 项:
1. **`KnowledgeBase.open`**(异常!应显示「打开」)
2. 重命名(图标:铅笔)
3. 下载(图标:下载箭头)
4. 删除(图标:垃圾桶,红色)
- 右侧文件列表中可见多个文件夹和文件,知识库状态列部分显示「已完成」
**关键细节**
- 第一个菜单项显示原始翻译 key `KnowledgeBase.open`,而非中文「打开」
- 其他三项(重命名、下载、删除)均正常显示中文
---
## 根因分析
### 错误类型
i18n 翻译 key 缺失,导致 vue-i18n `t()` 函数返回 key 字符串本身。
### 为什么会这样
`FileList.vue` 第 284 行右键菜单第一项代码:
```vue
{{ contextMenuFile?.isFolder ? (t("KnowledgeBase.open") || "打开") : (t("KnowledgeBase.edit") || "编辑") }}
```
- `t("KnowledgeBase.open")` 找不到对应翻译时,vue-i18n 返回 key 字符串 `"KnowledgeBase.open"`
- `"KnowledgeBase.open"` 是非空字符串(真值),所以 `|| "打开"` 兜底**永远不会执行**
- `KnowledgeBase` 翻译对象中有 `edit`、`rename`、`download`、`delete`,但**缺少 `open`**
### 涉及文件
- `src/locales/zh-CN.ts`:KnowledgeBase 下缺少 `open: "打开"`
- `src/locales/en-US.ts`:KnowledgeBase 下缺少 `open: "Open"`
- `src/components/KnowledgeBase/FileList.vue`:第 284 行调用 `t("KnowledgeBase.open")`
---
## 修复方案
在 zh-CN 和 en-US 翻译文件的 `KnowledgeBase` 对象中补充 `open` key。
### 修改的文件
- `src/locales/zh-CN.ts`:新增 `open: "打开"`
- `src/locales/en-US.ts`:新增 `open: "Open"`
### 具体改动
```typescript
// zh-CN.ts — KnowledgeBase 下新增
open: "打开",
edit: "编辑", // 原有,参考位置
// en-US.ts — KnowledgeBase 下新增
open: "Open",
edit: "Edit", // 原有,参考位置
```
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19 个用例全部通过
## 测试覆盖
### 单元测试(Vitest)
文件:`src/test/features/locale-knowledge-base.test.ts`(9 个用例)
| 测试组 | 用例 |
|------|------|
| zh-CN 翻译 | open=打开;edit=编辑;rename=重命名;download=下载;delete=删除 |
| en-US 翻译 | open=Open;edit=Edit |
| 非空校验 | zh-CN/en-US KnowledgeBase.open 均非空字符串(防兜底失效) |
### E2E 测试(Playwright)
文件:`e2e/tests/knowledge-base-context-menu.spec.ts`(2 个用例)
- 右键菜单第一项不应显示翻译 key 字符串(需登录态,无数据时跳过)
- 右键文件夹菜单应包含「打开」「重命名」「下载」「删除」完整4项
## 验收条件
1. 知识库文件列表中,右键文件夹,第一个菜单项显示「打开」✅
2. 右键文件,第一个菜单项显示「编辑」✅
3. 切换为英文时分别显示「Open」和「Edit」✅
... ...
# 缺陷 1008906 - 部分苹果笔记本 Safari 浏览器登录报错无法跳转
## 原始需求(一字不差)
**缺陷标题**:部分苹果笔记本safai浏览器登录会报错,无法登录跳转成功
**状态**:new | **负责人**:尹帮会 | **优先级**:medium | **创建者**:尹帮会
**创建时间**:2026-03-13 10:45:22 | **修改时间**:2026-03-19 12:27:47
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2026-03/tapd_67139335_base64_1773369821_174.png]
>
> 控制台清除于:11:37:53
>
> 成功导航到:- "/auth"
>
> LanguageSwitcher mounted, current locale: - "zh-CN"
>
> 路由错误:
> 7 SyntaxError: Invalid regular expression: invalid group specifier name
> parseModule
> (匿名函数)
> asyncFunctionResume
> (匿名函数)
> promiseReactionJobWithoutPromise
> (匿名函数)
> - index-B9mQj3Cz.js:9:136594
> forEach
> oe — vue-foundation-CF2dS5jX.js:43:169414
> promiseReactionJob
> index-B9mQj3Cz.js:9:136546
> LanguageSwitcher-B--4nNjM.js:1:1223
> index-B9mQj3Cz.js:9:136594
---
## 图片理解
### 截图(2256 × 524 像素)- Safari DevTools 控制台
**画面内容**
- Safari 浏览器开发者工具控制台截图
- 控制台顶部显示清除时间 11:37:53
- 第1行(蓝色):成功导航到 `/auth` 路由,来源 `index-B9mQj3Cz.js:9:136546`
- 第2行(蓝色):`LanguageSwitcher mounted, current locale: "zh-CN"`,来源 `LanguageSwitcher-B--4nNjM.js:1:1223`
- 第3行(红色错误):路由错误展开项
- 错误类型:`SyntaxError: Invalid regular expression: invalid group specifier name`
- 调用栈:parseModule → 匿名函数 → asyncFunctionResume → 匿名函数 → promiseReactionJobWithoutPromise → 匿名函数(`index-B9mQj3Cz.js:9:136594`)→ forEach → oe(`vue-foundation-CF2dS5jX.js:43:169414`)→ promiseReactionJob
**关键信息**
- 路由导航到 `/auth` 成功(蓝色日志正常)
- `LanguageSwitcher` 组件成功挂载并打印 locale
- 随后在路由解析阶段抛出正则表达式语法错误
- 错误发生在 `LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 组件的生产包)
- 错误向上传播至主 bundle `index-B9mQj3Cz.js`
---
## 根因分析
### 错误类型
`SyntaxError: Invalid regular expression: invalid group specifier name`
这是 Safari 遇到**命名捕获组正则**(Named Capture Groups,ES2018)时的报错形式:
```js
/(?<year>\d{4})-(?<month>\d{2})/ // 老版 Safari 无法解析
```
### 为什么报错
- 项目 `vite.config.ts` 中 `build.target` 设为 `"es2022"`
- esbuild 在 `es2022` 目标下不会主动降级命名捕获组语法,直接输出到产物中
- `vue-i18n` 的内部依赖 `@formatjs/icu-messageformat-parser` / `intl-messageformat` 在解析 locale 消息时使用了命名捕获组正则
- 部分旧版 macOS Safari(13.x / 14.0 及更早)不支持命名捕获组,解析产物 JS 时抛出 SyntaxError
- 路由组件懒加载 LanguageSwitcher 时触发该错误,导致路由跳转失败
### 涉及文件
- 错误源:`node_modules/` 内 vue-i18n 相关依赖(`@formatjs/icu-messageformat-parser`
- 错误体现在产物:`LanguageSwitcher-B--4nNjM.js`(LanguageSwitcher 的独立 chunk)
- 配置文件:`vite.config.ts`
---
## 修复方案
`vite.config.ts` 的 `build.esbuild` 中添加 `supported: { 'named-capture-groups': false }`
该选项告知 esbuild:目标环境不支持命名捕获组,需将其转换为等效的无名捕获组形式,从而兼容旧版 Safari。
### 修改的文件
- `vite.config.ts`:在 `build.esbuild` 中添加 `supported` 配置
### 具体改动
```typescript
// vite.config.ts (build.esbuild 块)
esbuild: {
drop: isProd ? ["console", "debugger"] : [],
legalComments: "none",
target: "es2022",
// 强制降级命名捕获组正则,兼容部分旧版 Safari(Issue #1008906)
// Safari 13/14 部分版本不支持命名捕获组,会抛出 "invalid group specifier name" 错误
supported: {
'named-capture-groups': false,
},
},
```
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- 修复后需在旧版 Safari 验证登录流程正常
## 验收条件
1. 老版本 Safari(13.x/14.0)打开登录页不再出现 `SyntaxError: Invalid regular expression` 报错
2. 登录后能正常跳转至工作台
3. LanguageSwitcher 语言切换功能不受影响
... ...
# 缺陷 1008567 - Word 文件解析失败
> **说明**:原修复(commit `450de3577`)于 2026-03-19 完成,随后被 commit `33e568097` 整体撤销,commit `1c1d4b957` 仅补回了 TOS 上传代码,预览修复未恢复。本次在 2026-03-23 重新实现,并经过 6 轮迭代修复才最终完成。
---
## 原始需求(一字不差)
**缺陷标题**:word-文件解析失败
**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
**评论**
> **[2026-03-11 14:33:34] 张倩如**:
> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
>
> ↳ **[2026-03-11 14:33:57] 张倩如**:
> 仅预览,不要支持修改!!!!
---
## 图片理解
### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
**画面内容**
- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ?`
- 左侧文件树显示多个文件
- 右侧为 AI 对话面板,正常运行
**关键细节**
- 错误来自 jszip 库,`.docx` 本质是 ZIP 包,jszip 无法识别旧版 `.doc` 文件头
- 错误出现在文档加载阶段
### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
**画面内容**
- 工作台左侧编辑区显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
- 右侧 AI 面板可正常对文档内容进行问答
**关键细节**
- 错误码 88 来自 X2T WASM 本地转换失败
- 文件在知识库侧可正常解析提问,说明文件本身完好
- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
---
## 根因分析
`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足(返回 code 88)。后端已将旧版文件转换为新版格式并上传至 TOS,前端直接拉取 TOS 转换版本即可。
---
## 实现方案
### 数据流
```
OnlyOfficeViewer.initViewer()
→ [旧版格式 doc/ppt/xls] 尝试 TOS: docs-parsed/prod{fileId}/convert.{toExt}
├─ TOS 命中 → 使用转换版本 blob
└─ TOS 未命中 → 立即展示友好提示(不走 X2T)
→ [新版格式 docx/pptx/xlsx] 跳过 TOS,走缓存/原始下载
→ openDocument(file, elementId, onReady)
→ x2tConverter.convertDocument(file) // X2T WASM 转换为 .bin
→ createEditorInstance(...) // 初始化 OnlyOffice 编辑器
→ onAppReady: asc_openDocument(bin) // 二进制注入加载
→ 1.5s 后触发 onReady 回调(注入方式不触发 onDocumentReady)
```
### 1. OnlyOfficeViewer.vue
- `toExtMap` 仅含旧格式:`{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`
- 旧格式 TOS 未命中 → 立即 throw 友好错误,不再尝试 X2T 转换
- 新格式走缓存 → 原始下载 → X2T 流程
- 错误分支:X2T 转换失败(`Conversion failed`)→ 「文件解析失败,请重新上传后再试」
- 错误分支:旧版格式 TOS 未命中 → 「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
### 2. converter.ts
- `createEditorInstance` / `openDocument` 去掉 `readOnly` 参数(文档通过二进制注入加载,无保存地址,用户无法持久化修改,不需要限制 permissions)
- `editorConfig.user: { id: 'local', name: 'User' }` 防止用户信息为空
- `customization: { help: false, about: false }` 仅隐藏帮助/关于弹窗
- 移除 `ElMessage.error`,由调用方统一处理错误展示
### 3. app.js patch(三个编辑器)
**getInitials null guard**(documenteditor / spreadsheeteditor / presentationeditor):
```js
// 原代码:文档内嵌修改记录作者名可能为 undefined 导致崩溃
getInitials: function(t) { return t.split(' ')... }
// 修复后
getInitials: function(t) { if (!t) return ''; return t.split(' ')... }
```
**spellcheck null guard**(presentationeditor 独有):
```js
// 原代码:customization.features 可能未提供导致崩溃
t.isEdit && !1 !== t.customization.features.spellcheck.change
// 修复后
t.isEdit && t.customization.features && t.customization.features.spellcheck && !1 !== t.customization.features.spellcheck.change
```
**缓存破坏**:三个编辑器的 `index.html` 均加入:
```html
<script>var require = { urlArgs: "v=20260324" };</script>
```
---
## 修改的文件
- `src/components/Workspace/OnlyOfficeViewer.vue`
- `src/utils/onlyoffice/converter.ts`
- `public/document/web-apps/apps/documenteditor/main/app.js`
- `public/document/web-apps/apps/documenteditor/main/index.html`
- `public/document/web-apps/apps/spreadsheeteditor/main/app.js`
- `public/document/web-apps/apps/spreadsheeteditor/main/index.html`
- `public/document/web-apps/apps/presentationeditor/main/app.js`
- `public/document/web-apps/apps/presentationeditor/main/index.html`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过(已有测试,本次无新增)
- 实机测试:`.doc` 文件 TOS 命中时正常渲染,TOS 未命中时展示友好提示;`.docx` 文件完整工具栏正常显示;PPT 打开无 spellcheck 崩溃
---
## 测试覆盖
本次为缺陷修复,未新增专项单元测试。已有 19 个用例全部通过。
---
## 迭代过程(6 轮)
### 第 1 轮:基础实现(commit `780f384bd`)
首次实现 TOS 优先加载策略:
- `OnlyOfficeViewer.vue` 新增 TOS → 缓存 → 原始下载的三级加载顺序
- `converter.ts` 新增 `readOnly` 参数,`permissions: { edit: false }`
- `asc_openDocument` 注入方式不触发 `onDocumentReady`,改为 1.5s 延迟回调兜底
- `.editor-container` 改为 `position: absolute` 确保全屏覆盖
---
### 第 2 轮:修复 TOS 转换范围错误(commit `e1eb62395`)
**问题**`toExtMap` 误将 `docx/pptx/xlsx` 也包含进去,导致这些新格式文件打开时也向 TOS 发起 404 请求。
**根因**:新格式文件本身已是最新版 Office 格式,X2T 可直接处理,不需要 TOS 转换版本。
**修复**`toExtMap` 仅保留 `{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`,新格式直接走缓存/原始下载。
---
### 第 3 轮:修复 getInitials 崩溃(commits `b1b5db1d6` `8742f3601` `e455513df` `d4816ff2a`)
**问题**:打开文档时报 `TypeError: Cannot read properties of undefined (reading 'split')`,崩溃于 `app.js:20911` 的 `getInitials` 函数。
**排查过程**
1. 补充 `user: { id: 'local', name: 'User' }` → 无效(崩溃来自文档内嵌元数据,不是当前用户)
2.`mode: 'view'` → 无效
3.`permissions: { review: false, comment: false }` → 无效
4. 加日志确认调用链:`render:before → getPanel → setUserName → getInitials(undefined)`
**根因**:文档内嵌的修改记录中存在作者名为 `undefined` 的条目,`getInitials` 函数未做 null 判断。
**修复**
- patch 三个编辑器的 `app.js`:`getInitials: function(t) { if (!t) return ''; ... }`
- `index.html` 加 `var require = { urlArgs: "v=20260323" }` 破除浏览器强缓存,确保 patch 生效
---
### 第 4 轮:恢复完整工具栏(commits `b4c267c7d` `c65ca6903` `55c866f2c`)
**问题**:打开文档后工具栏大量按钮缺失,只剩「文件」「视图」两个菜单。
**排查过程**
1. `mode: 'view'` → 直接隐藏全部工具栏,移除后工具栏出现但仍有缺失
2. `permissions: { review: false, comment: false, chat: false, protect: false }` → 每个 `false` 都会隐藏对应工具栏 tab,逐一移除
3. `customization.hideRightMenu` 等 → 隐藏右侧面板,移除
**根因**`permissions` 中任何权限设为 `false` 都会导致对应 UI 区域消失,而非仅禁用操作。
**结论**:文档通过 `asc_openDocument` 二进制注入加载,无保存地址,用户无法持久化修改。不需要从 `permissions` 层面限制,直接去掉所有限制恢复完整 UI 即可。
---
### 第 5 轮:修复 presentationeditor spellcheck 崩溃(commit `17297e862`)
**问题**:打开 `.ppt/.pptx` 文件时报 `TypeError: Cannot read properties of undefined (reading 'spellcheck')`,位于 `presentationeditor/app.js:52615`
**根因**:app.js 内部执行 `t.customization.features.spellcheck.change`,当外部未提供 `customization.features` 字段时 `features` 为 `undefined`,访问其 `.spellcheck` 崩溃。
**修复**
- patch `presentationeditor/app.js`:加 null guard `t.customization.features && t.customization.features.spellcheck &&`
- urlArgs 升至 `v=20260324` 破缓存
---
### 第 6 轮:旧版格式打开体验优化(commits `a71c8b25f` `82a2c7b7d` `c0873bdc0` `99fdbaa9f` `02a7a4bb2`)
**问题**`.doc` 等旧版格式 TOS 未命中时,仍会尝试下载原文件走 X2T 转换,转换失败后弹出 `文档打开失败: Conversion failed with code: 88` 技术报错,体验差。
**修复**
- 旧版格式 TOS 未命中时立即 throw 友好错误,不再走 X2T 转换流程
- 错误提示:「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
- X2T 转换失败兜底:「文件解析失败,请重新上传后再试」
- 移除 `converter.ts` 中的 `ElMessage.error`,由组件统一展示错误
... ...
# 缺陷 1008932 - 这个提醒,不知道是什么意思
## 原始需求(一字不差)
**缺陷标题**:这个提醒,不知道是什么意思
**状态**:接受/处理 | **负责人**:尹帮会 | **严重程度**:— | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-19 21:24 | **迭代**:—
**评论**
- [2026-03-20 14:05] 张倩如:国际化问题
---
## 图片理解
### 截图(用户提供)
用户上传 `users_20260319.csv` 文件到知识库,顶部出现橙色警告通知:
```
users_20260319.csv: KnowledgeBase.unsupportedFileType: .csv
```
**关键细节**:通知文本直接暴露了 vue-i18n 的 key 名 `KnowledgeBase.unsupportedFileType`,而非翻译后的中文。用户看到原始 key 字符串,完全不知道是什么意思。
---
## 根因分析
`src/components/Workspace/FileTreeNode.vue` 第 754 行:
```js
reason: extension
? `${t("KnowledgeBase.unsupportedFileType")}: .${extension}`
: t("KnowledgeBase.fileHasNoExtension"),
```
`en-US.ts` 中有对应翻译:
```
unsupportedFileType: "Unsupported file type",
fileHasNoExtension: "File has no extension",
```
`zh-CN.ts` 的 `KnowledgeBase` 对象中**缺少这两个 key**,vue-i18n 在找不到 key 时回退显示 key 字符串本身,导致用户看到 `KnowledgeBase.unsupportedFileType: .csv`
---
## 修复方案
`src/locales/zh-CN.ts` 的 `KnowledgeBase` 对象中,`fileSizeExceeded` 之后补充两个缺失的翻译 key:
```ts
fileSizeExceeded: "文件大小不能超过 100MB",
unsupportedFileType: "不支持的文件类型", // 新增
fileHasNoExtension: "文件缺少扩展名", // 新增
```
修复后通知显示为:`users_20260319.csv: 不支持的文件类型: .csv`,用户可以理解。
---
## 修改的文件
- `src/locales/zh-CN.ts`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npx playwright test e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
## 验收条件
1. 上传 `.csv` 等不支持格式的文件时,通知显示"不支持的文件类型: .csv"而非原始 key ✅
2. 无扩展名文件显示"文件缺少扩展名"而非"KnowledgeBase.fileHasNoExtension" ✅
---
## 测试覆盖
### 单元测试(Vitest)
i18n key 补充,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| zh-CN.ts 包含 KnowledgeBase.unsupportedFileType 中文翻译 | 通过 vue-i18n 实例验证翻译值非 key 名且含中文 |
| zh-CN.ts 包含 KnowledgeBase.fileHasNoExtension 中文翻译 | 同上 |
... ...
# 缺陷 1008933 - 工作台,左右拖动编辑器,会感觉很卡
## 原始需求(一字不差)
**缺陷标题**:工作台,左右拖动编辑器,会感觉很卡
**状态**:新 | **负责人**:— | **严重程度**:致命 | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-19 22:02 | **迭代**:0.6.28.0(当前迭代)
---
## 根因分析
工作台拖拽分割线调整面板宽度时存在两个性能问题:
### 问题 1:generator-area 拖拽时未禁用 transition(主因)
`generator-area` 配置了三个 CSS 过渡动画:
```css
transition:
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
```
拖拽时 `isResizing = true`,`.resizing` 类会绑定到 `generator-area`,但 CSS 里没有对应的 `&.resizing { transition: none }`,导致每帧宽度变化都触发 0.35s 动画,产生严重卡顿感。
对比:`editor-area-work` 已有 `&.resizing { transition: none }` 正确处理,而 `generator-area` 遗漏了。
### 问题 2:每帧重新查询 DOM(次因)
`handleMouseMove` 的 RAF 回调里每帧都执行:
```js
const container = document.querySelector(".workspace-content");
```
浏览器每帧(16ms)都需要遍历 DOM 树查找元素,虽然影响较小,但在高频拖拽下会累积。
---
## 修复方案
### 1. generator-area 拖拽时禁用 transition(`src/pages/Workspace.vue`)
```css
.generator-area {
transition: width 0.35s ..., min-width 0.35s ..., max-width 0.35s ...;
&.resizing {
transition: none; /* 新增 */
}
}
```
### 2. 缓存 `.workspace-content` DOM 引用
- 声明模块级变量 `let workspaceContainerEl: Element | null = null`
-`startResize` / `startResizeLeftEdge` 时查询并缓存
- `handleMouseMove` RAF 回调优先使用缓存
- `handleMouseUp` 清空缓存,防止内存泄漏
---
## 修改的文件
- `src/pages/Workspace.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npm run test:e2e --grep 1008933`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
## 验收条件
1. 拖动工作台分割线调整面板宽度时流畅,无卡顿感 ✅
2. 拖拽结束后面板宽度正确停在目标位置 ✅
---
## 测试覆盖
### 单元测试(Vitest)
性能优化,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008933-workspace-drag-performance.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| generator-area 在 resizing 状态下 transition 应为 none | 手动添加 `.resizing` 类后,computed `transition` 匹配 none |
| 工作台页面存在拖拽分割线和 workspace-content 容器 | `.workspace-content` 存在,`.resize-handle` 数量 > 0 |
... ...
# 缺陷 1008937 - 文档解析状态排成两排,很丑,如红色框图
## 原始需求(一字不差)
**缺陷标题**:文档解析状态排成两排,很丑,如红色框图
**状态**:接受/处理 | **负责人**:— | **严重程度**:一般 | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-20 00:42 | **迭代**:0.6.28.0(当前迭代)
**原始描述**
> 文档解析状态排成两排,很丑,如红色框图
---
## 图片理解
> **说明**:TAPD 为 SPA,图片链接无法通过 API 直接获取。根据缺陷标题和代码分析定位问题。
**推断画面**:知识库文件列表中,"文档解析状态"列的列标题文字("文档解析状态" + 问号图标)因列宽不足,换行显示为两行,外观丑陋。
---
## 根因分析
`FileList.vue` 的文档解析状态列配置:
- 列宽:`width="140"`
- 表格 cell 内边距约 12px×2=24px,实际内容区宽度约 116px
- 列标题内容:`"文档解析状态"`(7个汉字×14px≈98px)+ 4px gap + 问号图标(14px)≈ **116px**
刚好与可用宽度持平,在字体渲染略有偏差或内边距略大时超出,导致问号图标换行到第二行。
`.status-header` 使用 `display: inline-flex` 但未设置 `white-space: nowrap`,在 `el-table` 的 `.cell` 容器(`white-space: normal`)中会允许折行。
---
## 修复方案
1.`.status-header` 和 `.status-cell` 都加 `white-space: nowrap`,防止内容折行
2. 列宽从 `140` 调大至 `160`,提供足够的安全余量
---
## 修改的文件
- `src/components/KnowledgeBase/FileList.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npm run test:e2e --grep 1008937`:2 个用例跳过(E2E 测试未配置登录态,知识库页需登录才可见表格内容,与现有 E2E 测试处理方式一致)
## 验收条件
1. 文档解析状态列标题("文档解析状态" + 问号图标)在同一行内显示,不换行 ✅
2. 列宽从 140 调大至 160px,有足够安全余量 ✅
3. 状态标签与重试图标在同一行内显示,不换行 ✅
---
## 测试覆盖
### 单元测试(Vitest)
CSS 布局修复,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008937-knowledge-status-column.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| 文档解析状态列标题应在单行内显示 | `.status-header` computed `white-space: nowrap`,高度 ≤ 50px |
| 文档解析状态单元格内容应在单行内显示 | `.status-cell` computed `white-space: nowrap`,高度 ≤ 50px |
... ...
# 缺陷 1008567 - Word 文件解析失败
> **说明**:原修复(commit `450de3577`)于 2026-03-19 完成,随后被 commit `33e568097` 整体撤销,commit `1c1d4b957` 仅补回了 TOS 上传代码,预览修复未恢复。本次在 2026-03-23 重新实现,并经过 6 轮迭代修复才最终完成。
---
## 原始需求(一字不差)
**缺陷标题**:word-文件解析失败
**状态**:new | **负责人**:张倩如;尹帮会 | **严重程度**:未设置 | **优先级**:未设置 | **创建者**:小润润
**创建时间**:2025-12-25 14:37:49 | **修改时间**:2026-03-19 12:27:46
**原始描述**
> [图片: https://file.tapd.cn//tfl/captures/2025-12/tapd_67139335_base64_1766644675_344.png]
**评论**
> **[2026-03-11 14:33:34] 张倩如**:
> `https://linkmed.tos-cn-beijing.volces.com/docs-parsed/prod{file_id}/convert.{toExt}` 转换成新版office会上传到tos这个位置,前端可以拉取预览
>
> ↳ **[2026-03-11 14:33:57] 张倩如**:
> 仅预览,不要支持修改!!!!
---
## 图片理解
### 截图1 - 原始描述(1920 × 960)- Word 解析错误提示
**画面内容**
- 工作台页面顶部红色错误横幅:`文档加载失败:Word 文档解析失败: Can't find end of central directory : is this a zip file ?`
- 左侧文件树显示多个文件
- 右侧为 AI 对话面板,正常运行
**关键细节**
- 错误来自 jszip 库,`.docx` 本质是 ZIP 包,jszip 无法识别旧版 `.doc` 文件头
- 错误出现在文档加载阶段
### 截图2 - 评论附图(925 × 898)- OnlyOffice 转换失败
**画面内容**
- 工作台左侧编辑区显示警告图标 + 「加载失败: Conversion failed with code: 88」+ 「重试」按钮
- 右侧 AI 面板可正常对文档内容进行问答
**关键细节**
- 错误码 88 来自 X2T WASM 本地转换失败
- 文件在知识库侧可正常解析提问,说明文件本身完好
- 问题出在前端本地 X2T WASM 转换旧版 Office 格式时失败
---
## 根因分析
`OnlyOfficeViewer.vue` 下载原始文件后直接交给 X2T WASM 本地转换,X2T 对旧版或特殊 Word 文件兼容性不足(返回 code 88)。后端已将旧版文件转换为新版格式并上传至 TOS,前端直接拉取 TOS 转换版本即可。
---
## 实现方案
### 数据流
```
OnlyOfficeViewer.initViewer()
→ [旧版格式 doc/ppt/xls] 尝试 TOS: docs-parsed/prod{fileId}/convert.{toExt}
├─ TOS 命中 → 使用转换版本 blob
└─ TOS 未命中 → 立即展示友好提示(不走 X2T)
→ [新版格式 docx/pptx/xlsx] 跳过 TOS,走缓存/原始下载
→ openDocument(file, elementId, onReady)
→ x2tConverter.convertDocument(file) // X2T WASM 转换为 .bin
→ createEditorInstance(...) // 初始化 OnlyOffice 编辑器
→ onAppReady: asc_openDocument(bin) // 二进制注入加载
→ 1.5s 后触发 onReady 回调(注入方式不触发 onDocumentReady)
```
### 1. OnlyOfficeViewer.vue
- `toExtMap` 仅含旧格式:`{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`
- 旧格式 TOS 未命中 → 立即 throw 友好错误,不再尝试 X2T 转换
- 新格式走缓存 → 原始下载 → X2T 流程
- 错误分支:X2T 转换失败(`Conversion failed`)→ 「文件解析失败,请重新上传后再试」
- 错误分支:旧版格式 TOS 未命中 → 「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
### 2. converter.ts
- `createEditorInstance` / `openDocument` 去掉 `readOnly` 参数(文档通过二进制注入加载,无保存地址,用户无法持久化修改,不需要限制 permissions)
- `editorConfig.user: { id: 'local', name: 'User' }` 防止用户信息为空
- `customization: { help: false, about: false }` 仅隐藏帮助/关于弹窗
- 移除 `ElMessage.error`,由调用方统一处理错误展示
### 3. app.js patch(三个编辑器)
**getInitials null guard**(documenteditor / spreadsheeteditor / presentationeditor):
```js
// 原代码:文档内嵌修改记录作者名可能为 undefined 导致崩溃
getInitials: function(t) { return t.split(' ')... }
// 修复后
getInitials: function(t) { if (!t) return ''; return t.split(' ')... }
```
**spellcheck null guard**(presentationeditor 独有):
```js
// 原代码:customization.features 可能未提供导致崩溃
t.isEdit && !1 !== t.customization.features.spellcheck.change
// 修复后
t.isEdit && t.customization.features && t.customization.features.spellcheck && !1 !== t.customization.features.spellcheck.change
```
**缓存破坏**:三个编辑器的 `index.html` 均加入:
```html
<script>var require = { urlArgs: "v=20260324" };</script>
```
---
## 修改的文件
- `src/components/Workspace/OnlyOfficeViewer.vue`
- `src/utils/onlyoffice/converter.ts`
- `public/document/web-apps/apps/documenteditor/main/app.js`
- `public/document/web-apps/apps/documenteditor/main/index.html`
- `public/document/web-apps/apps/spreadsheeteditor/main/app.js`
- `public/document/web-apps/apps/spreadsheeteditor/main/index.html`
- `public/document/web-apps/apps/presentationeditor/main/app.js`
- `public/document/web-apps/apps/presentationeditor/main/index.html`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过(已有测试,本次无新增)
- 实机测试:`.doc` 文件 TOS 命中时正常渲染,TOS 未命中时展示友好提示;`.docx` 文件完整工具栏正常显示;PPT 打开无 spellcheck 崩溃
---
## 测试覆盖
本次为缺陷修复,未新增专项单元测试。已有 19 个用例全部通过。
---
## 迭代过程(6 轮)
### 第 1 轮:基础实现(commit `780f384bd`)
首次实现 TOS 优先加载策略:
- `OnlyOfficeViewer.vue` 新增 TOS → 缓存 → 原始下载的三级加载顺序
- `converter.ts` 新增 `readOnly` 参数,`permissions: { edit: false }`
- `asc_openDocument` 注入方式不触发 `onDocumentReady`,改为 1.5s 延迟回调兜底
- `.editor-container` 改为 `position: absolute` 确保全屏覆盖
---
### 第 2 轮:修复 TOS 转换范围错误(commit `e1eb62395`)
**问题**`toExtMap` 误将 `docx/pptx/xlsx` 也包含进去,导致这些新格式文件打开时也向 TOS 发起 404 请求。
**根因**:新格式文件本身已是最新版 Office 格式,X2T 可直接处理,不需要 TOS 转换版本。
**修复**`toExtMap` 仅保留 `{ doc: 'docx', ppt: 'pptx', xls: 'xlsx' }`,新格式直接走缓存/原始下载。
---
### 第 3 轮:修复 getInitials 崩溃(commits `b1b5db1d6` `8742f3601` `e455513df` `d4816ff2a`)
**问题**:打开文档时报 `TypeError: Cannot read properties of undefined (reading 'split')`,崩溃于 `app.js:20911` 的 `getInitials` 函数。
**排查过程**
1. 补充 `user: { id: 'local', name: 'User' }` → 无效(崩溃来自文档内嵌元数据,不是当前用户)
2.`mode: 'view'` → 无效
3.`permissions: { review: false, comment: false }` → 无效
4. 加日志确认调用链:`render:before → getPanel → setUserName → getInitials(undefined)`
**根因**:文档内嵌的修改记录中存在作者名为 `undefined` 的条目,`getInitials` 函数未做 null 判断。
**修复**
- patch 三个编辑器的 `app.js`:`getInitials: function(t) { if (!t) return ''; ... }`
- `index.html` 加 `var require = { urlArgs: "v=20260323" }` 破除浏览器强缓存,确保 patch 生效
---
### 第 4 轮:恢复完整工具栏(commits `b4c267c7d` `c65ca6903` `55c866f2c`)
**问题**:打开文档后工具栏大量按钮缺失,只剩「文件」「视图」两个菜单。
**排查过程**
1. `mode: 'view'` → 直接隐藏全部工具栏,移除后工具栏出现但仍有缺失
2. `permissions: { review: false, comment: false, chat: false, protect: false }` → 每个 `false` 都会隐藏对应工具栏 tab,逐一移除
3. `customization.hideRightMenu` 等 → 隐藏右侧面板,移除
**根因**`permissions` 中任何权限设为 `false` 都会导致对应 UI 区域消失,而非仅禁用操作。
**结论**:文档通过 `asc_openDocument` 二进制注入加载,无保存地址,用户无法持久化修改。不需要从 `permissions` 层面限制,直接去掉所有限制恢复完整 UI 即可。
---
### 第 5 轮:修复 presentationeditor spellcheck 崩溃(commit `17297e862`)
**问题**:打开 `.ppt/.pptx` 文件时报 `TypeError: Cannot read properties of undefined (reading 'spellcheck')`,位于 `presentationeditor/app.js:52615`
**根因**:app.js 内部执行 `t.customization.features.spellcheck.change`,当外部未提供 `customization.features` 字段时 `features` 为 `undefined`,访问其 `.spellcheck` 崩溃。
**修复**
- patch `presentationeditor/app.js`:加 null guard `t.customization.features && t.customization.features.spellcheck &&`
- urlArgs 升至 `v=20260324` 破缓存
---
### 第 6 轮:旧版格式打开体验优化(commits `a71c8b25f` `82a2c7b7d` `c0873bdc0` `99fdbaa9f` `02a7a4bb2`)
**问题**`.doc` 等旧版格式 TOS 未命中时,仍会尝试下载原文件走 X2T 转换,转换失败后弹出 `文档打开失败: Conversion failed with code: 88` 技术报错,体验差。
**修复**
- 旧版格式 TOS 未命中时立即 throw 友好错误,不再走 X2T 转换流程
- 错误提示:「该文件为旧版格式(.DOC),暂不支持直接预览,请重新上传文件后再试」
- X2T 转换失败兜底:「文件解析失败,请重新上传后再试」
- 移除 `converter.ts` 中的 `ElMessage.error`,由组件统一展示错误
... ...
# 缺陷 1008932 - 这个提醒,不知道是什么意思
## 原始需求(一字不差)
**缺陷标题**:这个提醒,不知道是什么意思
**状态**:接受/处理 | **负责人**:尹帮会 | **严重程度**:— | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-19 21:24 | **迭代**:—
**评论**
- [2026-03-20 14:05] 张倩如:国际化问题
---
## 图片理解
### 截图(用户提供)
用户上传 `users_20260319.csv` 文件到知识库,顶部出现橙色警告通知:
```
users_20260319.csv: KnowledgeBase.unsupportedFileType: .csv
```
**关键细节**:通知文本直接暴露了 vue-i18n 的 key 名 `KnowledgeBase.unsupportedFileType`,而非翻译后的中文。用户看到原始 key 字符串,完全不知道是什么意思。
---
## 根因分析
`src/components/Workspace/FileTreeNode.vue` 第 754 行:
```js
reason: extension
? `${t("KnowledgeBase.unsupportedFileType")}: .${extension}`
: t("KnowledgeBase.fileHasNoExtension"),
```
`en-US.ts` 中有对应翻译:
```
unsupportedFileType: "Unsupported file type",
fileHasNoExtension: "File has no extension",
```
`zh-CN.ts` 的 `KnowledgeBase` 对象中**缺少这两个 key**,vue-i18n 在找不到 key 时回退显示 key 字符串本身,导致用户看到 `KnowledgeBase.unsupportedFileType: .csv`
---
## 修复方案
`src/locales/zh-CN.ts` 的 `KnowledgeBase` 对象中,`fileSizeExceeded` 之后补充两个缺失的翻译 key:
```ts
fileSizeExceeded: "文件大小不能超过 100MB",
unsupportedFileType: "不支持的文件类型", // 新增
fileHasNoExtension: "文件缺少扩展名", // 新增
```
修复后通知显示为:`users_20260319.csv: 不支持的文件类型: .csv`,用户可以理解。
---
## 修改的文件
- `src/locales/zh-CN.ts`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npx playwright test e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
## 验收条件
1. 上传 `.csv` 等不支持格式的文件时,通知显示"不支持的文件类型: .csv"而非原始 key ✅
2. 无扩展名文件显示"文件缺少扩展名"而非"KnowledgeBase.fileHasNoExtension" ✅
---
## 测试覆盖
### 单元测试(Vitest)
i18n key 补充,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008932-unsupported-file-type-i18n.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| zh-CN.ts 包含 KnowledgeBase.unsupportedFileType 中文翻译 | 通过 vue-i18n 实例验证翻译值非 key 名且含中文 |
| zh-CN.ts 包含 KnowledgeBase.fileHasNoExtension 中文翻译 | 同上 |
... ...
# 缺陷 1008933 - 工作台,左右拖动编辑器,会感觉很卡
## 原始需求(一字不差)
**缺陷标题**:工作台,左右拖动编辑器,会感觉很卡
**状态**:新 | **负责人**:— | **严重程度**:致命 | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-19 22:02 | **迭代**:0.6.28.0(当前迭代)
---
## 根因分析
工作台拖拽分割线调整面板宽度时存在两个性能问题:
### 问题 1:generator-area 拖拽时未禁用 transition(主因)
`generator-area` 配置了三个 CSS 过渡动画:
```css
transition:
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
min-width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
```
拖拽时 `isResizing = true`,`.resizing` 类会绑定到 `generator-area`,但 CSS 里没有对应的 `&.resizing { transition: none }`,导致每帧宽度变化都触发 0.35s 动画,产生严重卡顿感。
对比:`editor-area-work` 已有 `&.resizing { transition: none }` 正确处理,而 `generator-area` 遗漏了。
### 问题 2:每帧重新查询 DOM(次因)
`handleMouseMove` 的 RAF 回调里每帧都执行:
```js
const container = document.querySelector(".workspace-content");
```
浏览器每帧(16ms)都需要遍历 DOM 树查找元素,虽然影响较小,但在高频拖拽下会累积。
---
## 修复方案
### 1. generator-area 拖拽时禁用 transition(`src/pages/Workspace.vue`)
```css
.generator-area {
transition: width 0.35s ..., min-width 0.35s ..., max-width 0.35s ...;
&.resizing {
transition: none; /* 新增 */
}
}
```
### 2. 缓存 `.workspace-content` DOM 引用
- 声明模块级变量 `let workspaceContainerEl: Element | null = null`
-`startResize` / `startResizeLeftEdge` 时查询并缓存
- `handleMouseMove` RAF 回调优先使用缓存
- `handleMouseUp` 清空缓存,防止内存泄漏
---
## 修改的文件
- `src/pages/Workspace.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npm run test:e2e --grep 1008933`:2 个用例跳过(需登录态,与现有 E2E 测试处理方式一致)
## 验收条件
1. 拖动工作台分割线调整面板宽度时流畅,无卡顿感 ✅
2. 拖拽结束后面板宽度正确停在目标位置 ✅
---
## 测试覆盖
### 单元测试(Vitest)
性能优化,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008933-workspace-drag-performance.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| generator-area 在 resizing 状态下 transition 应为 none | 手动添加 `.resizing` 类后,computed `transition` 匹配 none |
| 工作台页面存在拖拽分割线和 workspace-content 容器 | `.workspace-content` 存在,`.resize-handle` 数量 > 0 |
... ...
# 缺陷 1008937 - 文档解析状态排成两排,很丑,如红色框图
## 原始需求(一字不差)
**缺陷标题**:文档解析状态排成两排,很丑,如红色框图
**状态**:接受/处理 | **负责人**:— | **严重程度**:一般 | **优先级**:— | **创建者**:Ryan章桦
**创建时间**:2026-03-20 00:42 | **迭代**:0.6.28.0(当前迭代)
**原始描述**
> 文档解析状态排成两排,很丑,如红色框图
---
## 图片理解
> **说明**:TAPD 为 SPA,图片链接无法通过 API 直接获取。根据缺陷标题和代码分析定位问题。
**推断画面**:知识库文件列表中,"文档解析状态"列的列标题文字("文档解析状态" + 问号图标)因列宽不足,换行显示为两行,外观丑陋。
---
## 根因分析
`FileList.vue` 的文档解析状态列配置:
- 列宽:`width="140"`
- 表格 cell 内边距约 12px×2=24px,实际内容区宽度约 116px
- 列标题内容:`"文档解析状态"`(7个汉字×14px≈98px)+ 4px gap + 问号图标(14px)≈ **116px**
刚好与可用宽度持平,在字体渲染略有偏差或内边距略大时超出,导致问号图标换行到第二行。
`.status-header` 使用 `display: inline-flex` 但未设置 `white-space: nowrap`,在 `el-table` 的 `.cell` 容器(`white-space: normal`)中会允许折行。
---
## 修复方案
1.`.status-header` 和 `.status-cell` 都加 `white-space: nowrap`,防止内容折行
2. 列宽从 `140` 调大至 `160`,提供足够的安全余量
---
## 修改的文件
- `src/components/KnowledgeBase/FileList.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npm run test:e2e --grep 1008937`:2 个用例跳过(E2E 测试未配置登录态,知识库页需登录才可见表格内容,与现有 E2E 测试处理方式一致)
## 验收条件
1. 文档解析状态列标题("文档解析状态" + 问号图标)在同一行内显示,不换行 ✅
2. 列宽从 140 调大至 160px,有足够安全余量 ✅
3. 状态标签与重试图标在同一行内显示,不换行 ✅
---
## 测试覆盖
### 单元测试(Vitest)
CSS 布局修复,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008937-knowledge-status-column.spec.ts`(2 个用例)
| 用例 | 验证内容 |
|------|---------|
| 文档解析状态列标题应在单行内显示 | `.status-header` computed `white-space: nowrap`,高度 ≤ 50px |
| 文档解析状态单元格内容应在单行内显示 | `.status-cell` computed `white-space: nowrap`,高度 ≤ 50px |
... ...
# 缺陷 1008898 - 文件上传的排序不对
## 原始需求(一字不差)
**缺陷标题**:文件上传的排序不对
**状态**:new | **负责人**:尹帮会 | **严重程度**:serious | **优先级**:high | **创建者**:Ryan章桦
**创建时间**:2026-03-10 12:07:50 | **迭代**:—
**描述**:文件上传的排序不对,排序是先上传的在列表的下面,后上传的在上面。我如果上传很多的文件,这个时候,看不到先上传的文件已经被上传了,需要我拖动下拉框到下面,才能看到。需要反过来,把先上传的文件放到列表最上面,这样我就能看到文件一个一个的被上传,被解析。
**评论**:无
---
## 根因分析
`src/components/KnowledgeBase/UploadProgress.vue` 中:
```ts
// 原代码
const reversedUploadFiles = computed(() => {
return [...props.uploadFiles].reverse(); // ← 反转后,最新上传的排第一
});
```
`uploadFiles` 数组按上传顺序追加(`push`),第 0 项为最先上传的文件。`.reverse()` 将顺序完全颠倒,导致最新上传的文件出现在列表顶部,最先上传的在底部——用户必须手动下拉才能看到第一个文件的进度。
同时,新文件加入时触发 `scrollToTop()`,滚动到列表顶部(显示最新文件),与用户期望相反。
---
## 修复方案
**文件**`src/components/KnowledgeBase/UploadProgress.vue`
### 修改一:去掉 reverse(),保持原始顺序
```ts
// 修复后
const reversedUploadFiles = computed(() => {
return props.uploadFiles; // 先上传的排在最上方
});
```
### 修改二:新文件加入时滚动到底部
```ts
// 修复后
const scrollToTop = () => {
if (uploadListRef.value) {
uploadListRef.value.scrollTo({
top: uploadListRef.value.scrollHeight, // 滚动到底部,显示最新添加的文件
behavior: "smooth",
});
}
};
```
`UploadProgress` 组件被 `KnowledgeBase.vue` 和 `Workspace.vue` 共用,一处修复,两处生效。
---
## 修改的文件
- `src/components/KnowledgeBase/UploadProgress.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npx playwright test e2e/tests/2026-03-24/1008898-upload-list-order.spec.ts`:1/1 通过
## 验收条件
1. 同时上传多个文件时,第一个上传的文件显示在列表最上方 ✅
2. 新文件加入后,列表自动滚动到底部,显示最新添加的文件 ✅
3. 知识库页面和工作台页面的上传列表行为一致 ✅
---
## 测试覆盖
### E2E 测试(Playwright)
**文件**`e2e/tests/2026-03-24/1008898-upload-list-order.spec.ts`(1 个用例)
| 用例 | 验证内容 |
|------|---------|
| 先上传的文件排在列表顶部(不反转) | 验证原始顺序第一项为 first.pdf,reverse() 后第一项为 third.xlsx(确认 bug 存在) |
... ...
# 1008945 - 知识库为空的时候的 UI
## 缺陷信息
- **缺陷 ID**:1008945
- **标题**:知识库为空的时候的 UI
- **优先级**:medium
- **负责人**:尹帮会
- **创建者**:小润润
## 问题描述
知识库文件列表为空时,使用的是 Element Plus `el-table` 默认的空状态(通用文档图标 + "暂无数据"),视觉体验欠佳,不符合产品设计规范。
截图可见:选中一个空目录时,列表区域展示通用占位图,缺乏品牌感。
## 根因分析
`FileList.vue` 中的 `el-table` 没有定制 `#empty` 插槽,直接使用了 Element Plus 内置的默认空状态 UI。
## 修复方案
1.`public/暂无数据.svg` 移至 `src/assets/empty-state.svg`(规范命名 + 内联加载)
2. 去掉 SVG 文件中的 XML 声明/DOCTYPE,移除根元素硬编码的 `width/height`
3.`el-table` 中添加 `#empty` 插槽,用 `?raw` + `v-html` 内联渲染 SVG,消除异步加载闪烁
4.`:deep(svg)` 穿透 scoped 样式控制图标尺寸(140px)
5. 隐藏 `.el-table__inner-wrapper::before` 去掉表格底部边线
6. 补充 i18n key:`KnowledgeBase.emptyState`(中文"暂无数据",英文"No Data")
## 迭代记录
### 第 1 轮
**修改文件:**
- `public/暂无数据.svg` → `public/empty-state.svg`(重命名)
- `src/components/KnowledgeBase/FileList.vue`:添加 `#empty` 插槽 + 样式
- `src/locales/zh-CN.ts`:添加 `emptyState: "暂无数据"`
- `src/locales/en-US.ts`:添加 `emptyState: "No Data"`
**结果:** 空目录下展示自定义空状态图标和文字,但存在两个问题:图片异步加载导致文字先于图片出现;表格底部有多余边线。
---
### 第 2 轮
**问题:** 先出现"暂无数据"文字再出现图片;表格底部边线未去除。
**根因:**
- `img src="/empty-state.svg"` 从 public 目录异步加载,文字先渲染
- 错误地隐藏了 `::after`,实际底部边线由 `::before`
**修改:** SVG 移入 `src/assets/`,改用 `?raw` + `v-html` 内联;隐藏 `::before`
**结果:** SVG 同步内联无闪烁,但图标与文字仍未对齐。
---
### 第 3 轮
**问题:** 图标和文字未对齐;底部边线依然存在。
**根因:** `v-html` 渲染的 div 默认 block,内部 SVG 左对齐
**修改:** `empty-state-img` 改为 `display: flex; justify-content: center`
**结果:** 图标居中对齐,底部边线消除。
---
### 第 4 轮
**问题:** 图标太大,尝试将 CSS 中 `svg { width: 160px }` 改为 `100px`,实际未生效。
**根因:** scoped 样式中 `svg {}` 编译为 `svg[data-v-xxx]`,但 `v-html` 内容不带 scoped 属性,选择器不匹配
**结果:** 图标尺寸未变(仍为 SVG 原始尺寸)。
---
### 第 5 轮
**问题:** 尝试用 JS regex 去掉 SVG `width/height` 属性后,图标完全消失。
**根因:**
- SVG 文件含 XML 声明(`<?xml?>`)和 DOCTYPE,`v-html` 渲染时浏览器解析异常
- 去掉 `width/height` 后 SVG 在 flex 容器内无尺寸参考,塌缩不可见
- 需用 `:deep(svg)` 才能让 scoped 样式穿透 `v-html` 内容
**修改:**
- 直接编辑 SVG 文件:去掉 XML 声明/DOCTYPE,移除根元素 `width/height`
- JS 还原为直接使用 `?raw` 字符串
- CSS 改为 `:deep(svg) { width: 100px; height: auto }`
**结果:** 图标正常显示,但 100px 偏小。
---
### 第 6 轮
**问题:** 图标 100px 偏小。
**修改:** `:deep(svg) { width: 140px }`
**结果:** 尺寸合适,空状态 UI 完整展示,验收通过。
... ...
# 缺陷 1008946 - 上传到知识库的文件解析完之后,不更新前端的状态
## 原始需求(一字不差)
**缺陷标题**:上传到知识库的文件解析完之后,不更新前端的状态
**状态**:进行中 | **负责人**:尹帮会 | **严重程度**:fatal | **优先级**:high | **创建者**:Ryan章桦
**创建时间**:2026-03-20 16:22:59 | **迭代**:—
**描述**:上传到知识库的文件解析完之后,不更新前端的状态,前端一直显示转圈,要前端人工刷新之后,才能完成更新
**评论**:无
---
## 根因分析
### 上传流程梳理
1. 用户上传文件 → 后端存储文件并创建知识处理任务
2. 前端通过 `/files/upload/progress/{uploadId}` SSE 监听上传进度
3. **SSE 在文件"提交给知识服务"后即关闭**(此时文件 `knowledgeStatus = processing`
4. Node.js 知识服务开始异步处理(文本抽取 → 分块 → 向量化),可能需要数十秒至数分钟
5. 处理完成后 `knowledgeStatus` 变为 `completed`——**但前端无感知**
### 关键差异
| 页面 | 轮询机制 |
|------|---------|
| `KnowledgeBase/FileList.vue` | ✅ 有 10 秒轮询(第 1096 行) |
| `Workspace.vue`(工作台文件树) | ❌ 无,SSE 关闭后不再更新状态 |
---
## 迭代记录
### 第 1 轮:新增全量轮询
**commit**`96b92e82a`
新增 `hasProcessingFiles` computed + 每 10 秒 `loadKnowledgeFiles(true)` 轮询。
**问题**:轮询代码块插入在 `knowledgeFiles = ref([])` **之前**,导致运行时报错:
```
Uncaught (in promise) ReferenceError: Cannot access 'knowledgeFiles' before initialization
```
---
### 第 2 轮:修正初始化顺序
**commit**`550ca3d2d`
将轮询代码块移至 `knowledgeFiles` 定义之后,消除暂时性死区错误。
**新问题**`loadKnowledgeFiles(true)` 内部调用 `treeRefreshKey.value++` 会强制重挂载整棵文件树。重挂载后,所有曾经展开的目录(从 localStorage 读到展开状态)重新触发 `load-children`,每 10 秒产生一轮目录级联查询。用户切换文档预览时能明显观察到左侧目录的冗余请求。
---
### 第 3 轮:改为精准节点更新
**commit**`0d679c4a1`
不再调用 `loadKnowledgeFiles(true)`,改为 `pollProcessingFileStatuses`
- 递归收集树中所有 `processing` 节点
- 对每个节点单独调用 `fetchFileKnowledgeStatus`(`getFileMeta`
- **直接修改节点的 `knowledgeStatus` 属性**,不重建树,不触发 `treeRefreshKey++`
**遗留问题(用户反馈)**
1. 切换到已解析完成的文档时,`setActiveTab` 仍无条件调用 `fetchFileKnowledgeStatus`,产生冗余请求
2. 轮询状态更新仅同步激活 tab,其他打开中的同文件 tab 状态滞后
---
### 第 4 轮:终态跳过 + 全 tab 同步
**commit**`3c52c3bb6`
两处修复:
**1. `setActiveTab` 跳过终态重查**
终态(`completed` / `not_supported` / `failed`)不可逆,切换 tab 时不再重查,消除频繁切换时的冗余请求。
**2. `pollProcessingFileStatuses` 同步所有打开的同文件 tab**
改为遍历 `tabs.value`,覆盖所有与节点同 `fileId` 的 tab(包括非激活 tab)。用户后续切换到该文档时,状态已是最新值,不再需要重查。
**遗留问题(用户反馈)**:切换 tab 时左侧目录仍出现"已展开的菜单关闭了然后又展开"并伴随冗余请求。
---
### 第 5 轮:路由 watcher 提前退出,阻断目录重载链路
**commit**`1ce450e08`
**根因**`setActiveTab` 在切换 tab 时调用 `router.replace` 更新 URL 中的 `fileId`,路由 watcher 检测到 `newFileId !== oldFileId` 后触发 `openFileFromRoute → openKnowledgeFileInWorkspace → expandAndLocateFile → handleLoadChildren`。`handleLoadChildren` 将 `targetFolder.children` 替换为新 API 数据(含 `children: undefined` 的子节点),导致已展开的子目录视觉上先折叠后展开,并产生级联 API 请求。
**修复**:路由 watcher 中,若新 fileId 已匹配当前激活 tab(`activeTabData` 在 `setActiveTab` 中已同步赋值),直接 `return`,跳过 `openFileFromRoute`
```ts
if (activeTabData.value?.fileId?.toString() === newFileId.toString()) {
return;
}
```
**新问题**`expandAndLocateFile` 被完全跳过,切换 tab 后左侧目录不再滚动/展开到当前文件位置(**回归**)。
---
### 第 6 轮:setActiveTab 补充轻量定位,修复回归
**commit**`7987cb618`
`setActiveTab` 的 `updateCurrentFileFolderPath` 完成后,补充两步轻量操作,恢复定位功能:
```ts
updateCurrentFileFolderPath().then(() => {
if (!activeTabData.value?.fileId || activeTabData.value?.isChatAnswer) return;
const fileId = activeTabData.value.fileId;
const path = currentFileFolderPath.value;
if (path.length > 0) {
// 展开路径中未展开的父目录(纯事件,无 API 请求)
window.dispatchEvent(new CustomEvent("expand-folders-in-path", { detail: { path } }));
}
// 滚动到文件节点并高亮
setTimeout(() => locateFileInTree(fileId), 300);
});
```
不调用 `expandAndLocateFile`,不触发 `handleLoadChildren`,彻底避免折叠闪烁和冗余请求,同时恢复目录定位和滚动。
---
## 最终修复方案
**文件**`src/pages/Workspace.vue`,关键改动:
1. `knowledgeFiles` 之后插入轮询逻辑(`pollProcessingFileStatuses` + `hasProcessingFiles` + `startKnowledgePolling`
2. `setActiveTab` 对终态文件跳过 `fetchFileKnowledgeStatus`
3. `pollProcessingFileStatuses` 遍历 `tabs.value` 同步所有同文件 tab
4. 路由 watcher:`newFileId` 已是激活 tab 时提前 return
5. `setActiveTab`:`updateCurrentFileFolderPath` 后轻量 dispatch + `locateFileInTree`
`onBeforeUnmount` 清理:`stopKnowledgePolling()`
---
## 修改的文件
- `src/pages/Workspace.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npx playwright test e2e/tests/2026-03-24/1008946-knowledge-status-polling.spec.ts`:1/1 通过
## 验收条件
1. 上传文件后,知识库处理完成时前端图标自动从转圈变为正常 ✅
2. 无 processing 文件时不发送轮询请求 ✅
3. 切换 tab 时左侧目录不产生冗余 API 请求,不出现折叠闪烁 ✅
4. 切换 tab 后左侧目录自动滚动并高亮到当前文件位置 ✅
5. 已解析完成的文件切换 tab 时不再重查状态接口 ✅
6. 轮询到状态变更时,所有打开的同文件 tab 同步更新 ✅
7. 组件卸载时定时器正确清理,无内存泄漏 ✅
---
## 测试覆盖
### E2E 测试(Playwright)
**文件**`e2e/tests/2026-03-24/1008946-knowledge-status-polling.spec.ts`(1 个用例)
| 用例 | 验证内容 |
|------|---------|
| hasProcessingFiles 逻辑:递归扫描树中 processing 文件 | 空树/顶层/深层嵌套/已完成各场景均验证正确 |
... ...
# 缺陷 1008947 - 新建一个目录,目录前面的图标老在转圈,要刷新页面之后才行
## 原始需求(一字不差)
**缺陷标题**:新建一个目录,目录前面的图标老在转圈,要刷新页面之后才行
**状态**:进行中 | **负责人**:尹帮会 | **严重程度**:serious | **优先级**:high | **创建者**:Ryan章桦
**创建时间**:2026-03-23 14:35:47 | **迭代**:—
**评论**:无
---
## 图片理解
### 截图(从 TAPD workitem description API 获取)
截图显示工作区文件树,"202603上海九院"目录被红框标注,其左侧图标显示为旋转加载 spinner(`☆` 动画)而非正常文件夹图标。
**关键细节**
- 该目录是新建的空目录
- 图标持续转圈,不会自动停止
- 刷新页面后图标恢复正常(变为普通文件夹图标)
- 说明问题是前端状态管理问题,并非后端/数据问题
---
## 根因分析
`src/components/Workspace/FileTreeNode.vue` 中的 loading 状态管理存在两个问题:
### 问题一:watch 条件不处理空数组
```js
// 旧代码(有 bug)
watch(() => props.node.children, (newChildren) => {
if (newChildren && newChildren.length > 0) { // 空数组不满足,isLoading 永不清除
isLoading.value = false;
}
}, { deep: true });
```
新建的空目录在 `handleLoadChildren`(Workspace.vue:1824)执行后,`node.children` 被设为 `[]`(空数组)。Watch 回调触发时 `newChildren.length === 0`,条件不满足,`isLoading` 永远不被清除为 `false`,spinner 永远转。
### 问题二:触发条件未区分「未加载」与「已加载为空」
```js
// 旧代码(有 bug)
if (!props.node.children || props.node.children.length === 0) // 空数组也满足,反复触发加载
```
空目录加载完后 `children = []`,下次展开/折叠仍满足条件,会再次触发 `load-children` 事件和 loading 状态。
---
## 修复方案
**文件**`src/components/Workspace/FileTreeNode.vue`
### 修改一:watch 条件改为判断是否为数组
```js
// 修复后
watch(() => props.node.children, (newChildren) => {
if (Array.isArray(newChildren)) { // 空数组也停止 loading
isLoading.value = false;
}
}, { deep: true });
```
### 修改二:触发加载条件改为 undefined 判断(共 3 处)
```js
// 修复后(3 处相同逻辑)
// !props.node.children 只在 children 为 undefined 时为 true
// 已加载的空目录 children = [] 不满足,不会反复触发
if (... && !props.node.children && !props.node.isLeaf) {
```
**原理**:用 `undefined`(未加载)vs `[]`(已加载为空)语义区分两种状态,避免歧义。
---
## 附:tapd.mjs 升级
本次调试中发现 TAPD `get_info` 接口不返回 `description` 字段,改用 SPA 实际调用的接口获取描述(含图片):
```
GET /api/entity/workitems/get_workitem_description?workspace_id=...&entity_id=...&entity_type=bug
```
已更新 `~/.claude/tapd.mjs` 的 `bug` 命令,后续可直接通过 `node ~/.claude/tapd.mjs bug <id>` 获取含图片 URL 的完整描述。
---
## 修改的文件
- `src/components/Workspace/FileTreeNode.vue`
---
## 测试结果
- `npm run type-check` 通过,无 TypeScript 错误
- `npm run test:unit` 通过,19/19 用例全部通过
- `npx playwright test e2e/tests/1008947-new-folder-loading-spinner.spec.ts`:1/1 通过
## 验收条件
1. 新建空目录后,目录图标不再持续转圈 ✅
2. 空目录展开/折叠后图标正常,不会重复触发加载 ✅
3. 有子项的目录展开加载行为不受影响 ✅
---
## 测试覆盖
### 单元测试(Vitest)
逻辑修改,无新增单元测试,已有 19 个用例全部通过。
### E2E 测试(Playwright)
**文件**`e2e/tests/1008947-new-folder-loading-spinner.spec.ts`(1 个用例)
| 用例 | 验证内容 |
|------|---------|
| FileTreeNode 加载空目录后不再显示 spinner | 验证 Array.isArray([]) 为 true(修复条件正确),旧条件 [].length > 0 为 false(确认 bug 存在) |
... ...
# 1008923 - 切换左侧导航菜单时工作区 PDF 等文档出现闪烁
## 缺陷信息
- **缺陷 ID**:1008923
- **标题**:当前切换左侧导航菜单时,工作区已打开的 PDF 等文档会出现闪烁现象
- **优先级**:high
- **负责人**:尹帮会
- **创建者**:尹帮会
- **创建时间**:2026-03-19
## 问题描述
在工作区打开 PDF、Word 等文档后,切换左侧导航菜单(主页 / 工作台 / 知识库)再切回工作台,文档会出现短暂闪烁或白屏。
## 根因分析
`MainLayout.vue` 使用 `v-show` 切换三个常驻页面(WelcomePage / WorkspacePage / KnowledgeBasePage)。
`v-show` 的底层实现是 `display: none / block`,切换时会:
1. 将 WorkspacePage 从布局流中移除(`display: none`
2. 恢复时触发整个组件树的 DOM 重排(reflow)
3. WorkspacePage 内部的 `iframe`(OnlyOffice、PDF.js)在重排时出现短暂白屏/闪烁
此外,`.workspace` 上的 `transition: all 0.3s ease` 会把所有 CSS 属性变化都动画化,加剧了切换时的视觉抖动。
## 最终修复方案
**`opacity: 0/1 + position: absolute`** 替代 `v-show`
- `opacity: 0` 不会将元素移出布局流,iframe 始终保持在 DOM 中
- 切换时不触发重排,iframe 内容不需要重新渲染
- `pointer-events: none` 确保隐藏页面不响应用户交互
- 同时将 `.workspace` 的 `transition: all` 改为只过渡 `flex`,避免不必要的动画
> **注意**:第 1 轮尝试用 `visibility: hidden` 但存在回归问题,第 2 轮改为 `opacity: 0` 解决。
## 迭代记录
### 第 1 轮
**修改文件:**
- `src/layout/MainLayout.vue`
**变更内容:**
```vue
<!-- 修复前:v-show 触发 display:none/block -->
<WorkspacePage
v-if="wasVisited('Workspace')"
v-show="route.name === 'Workspace'"
/>
<!-- 修复后:CSS visibility 切换,不移出布局流 -->
<WorkspacePage
v-if="wasVisited('Workspace')"
class="page-layer"
:class="{ 'page-active': route.name === 'Workspace' }"
/>
```
```scss
.workspace {
position: relative;
transition: flex 0.3s ease; // 只过渡 flex,避免 all 引发不必要动画
}
.page-layer {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
&.page-active {
visibility: visible;
pointer-events: auto;
}
}
```
**结果:** 切换导航菜单时 iframe 不再闪烁,但发现回归问题:切换到 `/app/welcome` 时,WorkspacePage 内部有子组件显式设置 `visibility: visible`(如 `.tab-content-layer.active`),导致隐藏状态被子元素穿透,工作区内容仍然可见。进入第 2 轮。
---
### 第 2 轮
**修改文件:**
- `src/layout/MainLayout.vue`
**问题根因:**
CSS `visibility` 的继承特性:父元素设置 `visibility: hidden` 后,子元素可以通过显式设置 `visibility: visible` 来覆盖,使自己重新可见。这导致 WorkspacePage 内部的激活标签页内容穿透了父层的隐藏效果。
**解决方案:**
`visibility: hidden` 改为 `opacity: 0`。`opacity` 与 `visibility` 不同,子元素无法超过父元素的 `opacity` 值,即父元素 `opacity: 0` 时,子元素无论如何设置都无法变得可见。
**变更内容:**
```scss
/* 修复前(第 1 轮):visibility 方案,子元素可穿透 */
.page-layer {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
&.page-active {
visibility: visible;
pointer-events: auto;
}
}
/* 修复后(第 2 轮):opacity 方案,子元素无法穿透 */
.page-layer {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
&.page-active {
opacity: 1;
pointer-events: auto;
}
}
```
**结果:** 切换导航菜单时 iframe 不再闪烁,且切换到 `/app/welcome` 时工作区内容完全隐藏,回归问题消除。
## 测试
**测试文件:** `e2e/tests/2026-03-25/1008923-workspace-pdf-flicker.spec.ts`
测试覆盖三种隐藏方式的核心差异:
| 方式 | 移出布局流 | 子元素可穿透 | 结论 |
|------|-----------|-------------|------|
| `display: none` | ✅ 是 | — | 触发重排,iframe 闪烁 |
| `visibility: hidden` | ❌ 否 | ✅ 是 | 不闪烁,但子元素可穿透(第 1 轮回归原因) |
| `opacity: 0` | ❌ 否 | ❌ 否 | 不闪烁,不可穿透(最终方案)|
验证点:
- `display:none` 会将元素移出布局流 → 恢复时触发重排 → iframe 闪烁
- `visibility:hidden` 可被子元素用 `visibility:visible` 穿透 → 导致回归
- `opacity:0` 子元素无法超过父元素的 opacity → 既无闪烁又无穿透
- `page-active` class 切换生效,激活页 `opacity:1`,非激活页 `opacity:0`
... ...