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