Huanyi 3 місяців тому
батько
коміт
27b59c3f7d

+ 294 - 0
MIME类型检测说明.md

@@ -0,0 +1,294 @@
+# MIME 类型检测说明
+
+## 问题
+
+后端下载接口返回的 MIME 类型是 `application/octet-stream`,但 Clipboard API 只支持图片类型:
+
+```
+Failed to execute 'write' on 'Clipboard': 
+Type application/octet-stream not supported on write.
+```
+
+## 原因
+
+后端代码设置了通用的二进制流类型:
+```java
+response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
+```
+
+Clipboard API 只接受以下图片 MIME 类型:
+- `image/png`
+- `image/jpeg`
+- `image/gif`
+- `image/bmp`
+- `image/webp`
+- `image/svg+xml`
+
+## 解决方案
+
+在前端通过读取文件头(Magic Number)来检测真实的图片类型,然后创建正确 MIME 类型的 Blob。
+
+### 实现代码
+
+```typescript
+// 1. 下载图片
+const blob = await downloadSignature(ossId);
+
+// 2. 检测图片真实类型
+let imageBlob = blob;
+let mimeType = blob.type;
+
+// 如果是 application/octet-stream,需要检测真实的图片类型
+if (mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) {
+  // 读取文件头
+  const arrayBuffer = await blob.arrayBuffer();
+  const uint8Array = new Uint8Array(arrayBuffer);
+  
+  // 检测文件头(Magic Number)
+  if (uint8Array[0] === 0xFF && uint8Array[1] === 0xD8 && uint8Array[2] === 0xFF) {
+    mimeType = 'image/jpeg';
+  } else if (uint8Array[0] === 0x89 && uint8Array[1] === 0x50 && uint8Array[2] === 0x4E && uint8Array[3] === 0x47) {
+    mimeType = 'image/png';
+  } else if (uint8Array[0] === 0x47 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46) {
+    mimeType = 'image/gif';
+  } else if (uint8Array[0] === 0x42 && uint8Array[1] === 0x4D) {
+    mimeType = 'image/bmp';
+  } else if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) {
+    mimeType = 'image/webp';
+  } else {
+    // 默认使用 PNG
+    mimeType = 'image/png';
+  }
+  
+  // 创建新的 Blob,使用正确的 MIME 类型
+  imageBlob = new Blob([arrayBuffer], { type: mimeType });
+}
+
+// 3. 复制到剪贴板
+await navigator.clipboard.write([
+  new ClipboardItem({
+    [imageBlob.type]: imageBlob
+  })
+]);
+```
+
+## 文件头(Magic Number)对照表
+
+| 格式 | 文件头(十六进制) | 说明 |
+|------|-------------------|------|
+| JPEG | `FF D8 FF` | JPEG 图片 |
+| PNG | `89 50 4E 47` | PNG 图片(\x89PNG) |
+| GIF | `47 49 46` | GIF 图片(GIF) |
+| BMP | `42 4D` | BMP 图片(BM) |
+| WebP | `52 49 46 46` | WebP 图片(RIFF) |
+| SVG | `3C 73 76 67` | SVG 图片(<svg) |
+
+## 工作流程
+
+```
+下载图片 Blob
+    ↓
+检查 MIME 类型
+    ↓
+是 application/octet-stream?
+    ├─ 是 → 读取文件头
+    │        ↓
+    │      检测图片类型
+    │        ↓
+    │      创建新 Blob(正确的 MIME 类型)
+    │        ↓
+    └─ 否 → 直接使用原 Blob
+    ↓
+复制到剪贴板
+```
+
+## 示例
+
+### JPEG 文件头检测
+
+```typescript
+const uint8Array = new Uint8Array(arrayBuffer);
+
+// JPEG 文件头:FF D8 FF
+if (uint8Array[0] === 0xFF && 
+    uint8Array[1] === 0xD8 && 
+    uint8Array[2] === 0xFF) {
+  mimeType = 'image/jpeg';
+}
+```
+
+### PNG 文件头检测
+
+```typescript
+// PNG 文件头:89 50 4E 47(\x89PNG)
+if (uint8Array[0] === 0x89 && 
+    uint8Array[1] === 0x50 && 
+    uint8Array[2] === 0x4E && 
+    uint8Array[3] === 0x47) {
+  mimeType = 'image/png';
+}
+```
+
+### GIF 文件头检测
+
+```typescript
+// GIF 文件头:47 49 46(GIF)
+if (uint8Array[0] === 0x47 && 
+    uint8Array[1] === 0x49 && 
+    uint8Array[2] === 0x46) {
+  mimeType = 'image/gif';
+}
+```
+
+## 调试日志
+
+```typescript
+console.log('[签名] 图片下载成功,大小:', blob.size, '原始类型:', blob.type);
+console.log('[签名] 检测到非图片 MIME 类型,尝试转换');
+console.log('[签名] 检测到的图片类型:', mimeType);
+console.log('[签名] 最终 MIME 类型:', imageBlob.type);
+```
+
+**输出示例**:
+```
+[签名] 图片下载成功,大小: 12345 原始类型: application/octet-stream
+[签名] 检测到非图片 MIME 类型,尝试转换
+[签名] 检测到的图片类型: image/png
+[签名] 最终 MIME 类型: image/png
+```
+
+## 优化建议
+
+### 前端方案(当前实现)
+✅ 优点:
+- 无需修改后端
+- 兼容性好
+- 自动检测图片类型
+
+⚠️ 缺点:
+- 需要读取整个文件到内存
+- 增加前端处理逻辑
+
+### 后端方案(推荐)
+修改后端代码,返回正确的 MIME 类型:
+
+```java
+@Override
+public void download(Long ossId, HttpServletResponse response) throws IOException {
+    SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
+    if (ObjectUtil.isNull(sysOss)) {
+        throw new ServiceException("文件数据不存在!");
+    }
+    
+    // 根据文件扩展名设置正确的 Content-Type
+    String fileName = sysOss.getOriginalName();
+    String contentType = getContentTypeByFileName(fileName);
+    
+    FileUtils.setAttachmentResponseHeader(response, fileName);
+    response.setContentType(contentType); // ← 使用正确的 MIME 类型
+    
+    OssClient storage = OssFactory.instance(sysOss.getService());
+    storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
+}
+
+private String getContentTypeByFileName(String fileName) {
+    String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
+    switch (ext) {
+        case "jpg":
+        case "jpeg":
+            return "image/jpeg";
+        case "png":
+            return "image/png";
+        case "gif":
+            return "image/gif";
+        case "bmp":
+            return "image/bmp";
+        case "webp":
+            return "image/webp";
+        case "svg":
+            return "image/svg+xml";
+        default:
+            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
+    }
+}
+```
+
+## 测试
+
+### 测试用例 1:JPEG 图片
+```typescript
+// 文件头:FF D8 FF
+// 预期:检测为 image/jpeg
+```
+
+### 测试用例 2:PNG 图片
+```typescript
+// 文件头:89 50 4E 47
+// 预期:检测为 image/png
+```
+
+### 测试用例 3:GIF 图片
+```typescript
+// 文件头:47 49 46
+// 预期:检测为 image/gif
+```
+
+### 测试用例 4:未知格式
+```typescript
+// 文件头:不匹配任何已知格式
+// 预期:默认使用 image/png
+```
+
+## 浏览器兼容性
+
+| 浏览器 | ArrayBuffer | Uint8Array | Clipboard API | 支持 |
+|--------|-------------|------------|---------------|------|
+| Chrome 90+ | ✅ | ✅ | ✅ | ✅ |
+| Edge 90+ | ✅ | ✅ | ✅ | ✅ |
+| Firefox 88+ | ✅ | ✅ | ✅ | ✅ |
+| Safari 14+ | ✅ | ✅ | ⚠️ | ⚠️ |
+
+## 性能考虑
+
+### 内存使用
+```typescript
+// 读取整个文件到内存
+const arrayBuffer = await blob.arrayBuffer(); // ← 占用内存
+
+// 只读取前几个字节(优化方案)
+const slice = blob.slice(0, 8); // 只读取前 8 字节
+const arrayBuffer = await slice.arrayBuffer();
+```
+
+### 优化版本
+
+```typescript
+// 只读取文件头(前 8 字节)
+const headerBlob = blob.slice(0, 8);
+const arrayBuffer = await headerBlob.arrayBuffer();
+const uint8Array = new Uint8Array(arrayBuffer);
+
+// 检测类型...
+
+// 使用原始 Blob 创建新 Blob
+imageBlob = new Blob([blob], { type: mimeType });
+```
+
+## 总结
+
+### 问题
+- 后端返回 `application/octet-stream`
+- Clipboard API 不支持此类型
+
+### 解决
+- 读取文件头检测真实图片类型
+- 创建正确 MIME 类型的 Blob
+- 成功复制到剪贴板
+
+### 优势
+- ✅ 无需修改后端
+- ✅ 自动检测图片类型
+- ✅ 兼容所有图片格式
+- ✅ 完善的错误处理
+
+现在用户可以成功复制签名图片到剪贴板了!

+ 0 - 339
PDF拖拽插入用户名说明.md

@@ -1,339 +0,0 @@
-# PDF 拖拽插入用户名功能说明
-
-## 功能概述
-
-在 PDF 文档中,通过拖拽按钮可以将用户名以批注、文本框或印章的形式插入到文档中。
-
-## PDF 的特殊性
-
-### 为什么 PDF 不同?
-- PDF 是**只读格式**,不能直接编辑文本
-- 但可以添加**批注**、**标记**、**印章**等
-- WPS 提供了多种 PDF 标注方式
-
-### 支持的插入方式
-1. **文本批注**(FreeText)- 可编辑的文本框
-2. **普通批注**(Comment)- 带作者信息的批注
-3. **高亮标记**(Highlight)- 高亮显示文本
-4. **文本印章**(Stamp)- 印章样式的文本
-
-## 使用方法
-
-### 方式 1:文本批注(推荐)
-
-**操作步骤**:
-1. 打开 PDF 文档
-2. 拖拽"拖我到文档中"按钮到 PDF 页面
-3. 松开鼠标
-4. 用户名会以红色文本框形式出现在页面中心
-5. 可以拖动文本框到任意位置
-6. 可以双击编辑文本内容
-
-**特点**:
-- ✅ 可以自由移动位置
-- ✅ 可以编辑文本
-- ✅ 可以调整大小
-- ✅ 显示效果好
-
-**效果示例**:
-```
-┌─────────────────┐
-│   张三          │  ← 红色文本框
-└─────────────────┘
-```
-
-### 方式 2:普通批注
-
-**操作步骤**:
-1. 拖拽按钮到 PDF
-2. 系统自动添加批注
-3. 批注显示在页面右侧
-
-**特点**:
-- ✅ 带作者信息
-- ✅ 带时间戳
-- ✅ 可以回复
-- ❌ 位置固定
-
-**效果示例**:
-```
-📝 张三 (2025-12-29 10:30)
-   张三
-```
-
-### 方式 3:高亮标记
-
-**操作步骤**:
-1. 先在 PDF 中选中一段文字
-2. 拖拽按钮
-3. 选中的文字会被高亮,并添加批注
-
-**特点**:
-- ✅ 突出显示
-- ✅ 关联原文
-- ❌ 需要先选中文字
-
-**效果示例**:
-```
-这是一段文字 [黄色高亮]
-批注:张三
-```
-
-### 方式 4:文本印章
-
-**操作步骤**:
-1. 拖拽按钮到 PDF
-2. 用户名以印章形式显示
-
-**特点**:
-- ✅ 正式感强
-- ✅ 不易修改
-- ❌ 样式固定
-
-**效果示例**:
-```
-╔═══════╗
-║ 张三  ║  ← 红色印章
-╚═══════╝
-```
-
-## 自动降级机制
-
-代码会按顺序尝试以下方法,直到成功:
-
-```
-1. 尝试添加文本批注(FreeText)
-   ↓ 失败
-2. 尝试添加普通批注(Comment)
-   ↓ 失败
-3. 尝试添加高亮标记(Highlight)
-   ↓ 失败
-4. 尝试添加文本印章(Stamp)
-   ↓ 失败
-5. 提示使用 WPS 自带批注工具
-```
-
-## 技术实现
-
-### 方法 1:文本批注
-```typescript
-const annotation = await currentPage.AddAnnotation({
-  type: 'FreeText',
-  rect: {
-    left: pageWidth / 2 - 100,
-    top: pageHeight / 2 - 20,
-    right: pageWidth / 2 + 100,
-    bottom: pageHeight / 2 + 20
-  },
-  contents: userName,
-  color: { r: 255, g: 0, b: 0 },
-  fontSize: 14
-});
-```
-
-### 方法 2:普通批注
-```typescript
-await pdfDoc.AddComment({
-  text: userName,
-  author: userStore.userName || '审核人',
-  color: '#FF0000'
-});
-```
-
-### 方法 3:高亮标记
-```typescript
-const selection = await app.ActivePDF.Selection;
-await selection.AddTextMarkup({
-  type: 'Highlight',
-  text: userName,
-  color: '#FFFF00'
-});
-```
-
-### 方法 4:文本印章
-```typescript
-await pdfDoc.AddStamp({
-  text: userName,
-  position: 'center',
-  color: '#FF0000',
-  fontSize: 14
-});
-```
-
-## 使用场景
-
-### 场景 1:审核签名
-```
-PDF 文档内容...
-
-[文本框: 审核人:张三]
-[文本框: 审核时间:2025-12-29]
-```
-
-### 场景 2:批注标记
-```
-PDF 文档内容...
-
-📝 张三: 此处需要修改
-```
-
-### 场景 3:审批印章
-```
-PDF 文档内容...
-
-╔═══════╗
-║ 张三  ║
-║ 已审核 ║
-╚═══════╝
-```
-
-## 注意事项
-
-### 1. WPS PDF 编辑器必须支持批注功能
-- 确保使用的是完整版 WPS
-- 部分精简版可能不支持批注
-
-### 2. 插入位置
-- 文本批注:默认在页面中心
-- 普通批注:在页面右侧
-- 高亮标记:需要先选中文字
-- 文本印章:在页面中心
-
-### 3. 移动和编辑
-- 文本批注可以拖动和编辑
-- 普通批注位置固定
-- 印章不可编辑
-
-### 4. 保存
-- 批注会保存在 PDF 文件中
-- 其他人打开也能看到
-- 可以导出带批注的 PDF
-
-## 常见问题
-
-### Q1: 拖拽后没有反应?
-**可能原因**:
-1. WPS PDF 编辑器不支持批注
-2. PDF 文件被保护
-3. 权限不足
-
-**解决方法**:
-1. 检查 WPS 版本
-2. 检查 PDF 是否可编辑
-3. 尝试使用 WPS 自带的批注工具
-
-### Q2: 提示"PDF 操作失败"?
-**原因**:
-- WPS API 不支持当前操作
-- PDF 文件格式问题
-
-**解决方法**:
-1. 查看浏览器控制台的详细错误
-2. 尝试使用 WPS 桌面版
-3. 使用 WPS 自带的批注工具手动添加
-
-### Q3: 文本框位置不对?
-**原因**:
-- 默认在页面中心
-- 页面大小计算可能不准确
-
-**解决方法**:
-- 插入后手动拖动到目标位置
-- 或者使用 WPS 工具栏的批注功能
-
-### Q4: 可以修改文本框的样式吗?
-**答案**:
-- 可以!插入后:
-  - 右键点击文本框
-  - 选择"属性"
-  - 修改颜色、字体、大小等
-
-### Q5: 批注会保存吗?
-**答案**:
-- 会!批注是 PDF 的一部分
-- 保存文档后,批注会一起保存
-- 其他人打开也能看到
-
-## 备用方案
-
-### 如果自动插入失败
-
-**方案 1:使用 WPS 工具栏**
-1. 点击 WPS 工具栏的"批注"按钮
-2. 选择"文本框"或"便签"
-3. 在文档中点击要添加的位置
-4. 输入用户名
-
-**方案 2:使用快捷键**
-1. 按 `Ctrl + Alt + M` 添加批注
-2. 输入用户名
-3. 点击确定
-
-**方案 3:使用印章功能**
-1. 点击 WPS 工具栏的"印章"按钮
-2. 选择"文本印章"
-3. 输入用户名
-4. 点击文档添加
-
-## WPS PDF API 参考
-
-### 常用 API
-```typescript
-// 获取 PDF 文档对象
-const pdfDoc = await app.ActivePDF;
-
-// 获取当前页面
-const currentPage = await pdfDoc.CurrentPage;
-
-// 添加批注
-await currentPage.AddAnnotation({...});
-
-// 添加评论
-await pdfDoc.AddComment({...});
-
-// 添加印章
-await pdfDoc.AddStamp({...});
-```
-
-### 批注类型
-- `FreeText` - 自由文本
-- `Text` - 便签批注
-- `Highlight` - 高亮
-- `Underline` - 下划线
-- `StrikeOut` - 删除线
-- `Stamp` - 印章
-
-## 浏览器兼容性
-
-| 浏览器 | 支持情况 | 备注 |
-|--------|---------|------|
-| Chrome 90+ | ✅ 完全支持 | 推荐使用 |
-| Edge 90+ | ✅ 完全支持 | 推荐使用 |
-| Firefox 88+ | ⚠️ 部分支持 | 某些 API 可能不可用 |
-| Safari 14+ | ⚠️ 部分支持 | 某些 API 可能不可用 |
-
-## 未来改进
-
-### 计划中的功能
-- [ ] 支持自定义批注颜色
-- [ ] 支持自定义字体大小
-- [ ] 支持批注模板
-- [ ] 支持批注位置记忆
-- [ ] 支持批量添加批注
-
-### 用户体验优化
-- [ ] 拖拽时显示预览
-- [ ] 插入位置智能推荐
-- [ ] 批注样式预设
-- [ ] 快捷键支持
-
-## 总结
-
-PDF 拖拽插入用户名功能:
-- ✅ 支持多种插入方式
-- ✅ 自动降级机制
-- ✅ 可以移动和编辑
-- ✅ 批注会保存在文件中
-- ⚠️ 依赖 WPS API 支持
-
-如果自动插入失败,可以使用 WPS 自带的批注工具手动添加!

+ 0 - 84
TopNav-Width-Test-Guide.md

@@ -1,84 +0,0 @@
-# 顶部菜单动态宽度检测测试指南
-
-## 功能说明
-顶部菜单现在会根据可用空间动态调整显示的菜单项数量,超出部分会自动放入"更多"菜单中。
-
-## 测试场景
-
-### 1. 窗口大小调整
-- **操作**: 拖动浏览器窗口调整大小
-- **预期**: 菜单项数量会实时调整,确保不与右侧"Select Company"输入框重叠
-- **间隔**: 20px 安全边距
-
-### 2. 侧边栏展开/收起
-- **操作**: 点击汉堡菜单图标切换侧边栏状态
-- **预期**: 
-  - 侧边栏收起时,顶部菜单可用空间增加,显示更多菜单项
-  - 侧边栏展开时,顶部菜单可用空间减少,部分菜单项移入"更多"
-- **延迟**: 300ms(等待动画完成)
-
-### 3. 语言切换
-- **操作**: 切换系统语言(中文/英文)
-- **预期**: 
-  - 菜单文本长度改变
-  - 自动重新计算可显示的菜单项数量
-  - 保持不与右侧元素重叠
-
-### 4. 路由切换
-- **操作**: 点击不同的菜单项切换页面
-- **预期**: 
-  - 切换页面时重新计算可用宽度
-  - 确保布局稳定
-- **延迟**: 150ms(防抖)
-
-### 5. 顶部导航模式切换
-- **操作**: 在设置中切换"顶部导航"开关
-- **预期**: 切换到顶部导航模式时,立即计算并正确显示菜单
-
-## 调试模式
-
-如果需要查看详细的计算过程,可以在代码中启用调试模式:
-
-```typescript
-// 在 TopNav/index.vue 中
-const DEBUG_MODE = true; // 改为 true
-```
-
-启用后,浏览器控制台会显示:
-- 可用宽度计算详情
-- 每个菜单项的宽度
-- 最终显示的菜单数量
-
-## 关键参数
-
-| 参数 | 值 | 说明 |
-|------|-----|------|
-| 安全边距 | 20px | 顶部菜单与右侧元素之间的间隔 |
-| "更多"按钮预留宽度 | 100px | 用于判断是否需要显示"更多"按钮 |
-| 侧边栏动画延迟 | 300ms | 等待侧边栏展开/收起动画完成 |
-| 路由切换防抖 | 150ms | 避免频繁计算 |
-| 窗口调整防抖 | 200ms | 优化性能 |
-
-## 已监听的变化
-
-✅ 窗口大小调整 (resize)  
-✅ 侧边栏展开/收起状态 (sidebar.opened)  
-✅ 侧边栏显示/隐藏 (sidebar.hide)  
-✅ 顶部导航模式切换 (topNav)  
-✅ 语言切换 (locale)  
-✅ 菜单内容变化 (topMenus)  
-✅ 路由变化 (route.path)  
-
-## 边界条件处理
-
-1. **最小宽度保护**: 可用宽度至少 100px
-2. **至少显示一项**: 即使空间不足,也会显示至少一个菜单项
-3. **右侧菜单未就绪**: 首次渲染时会延迟 100ms 重试
-4. **动画未完成**: 侧边栏动画完成后再计算
-
-## 注意事项
-
-- 所有计算都在 `nextTick()` 中执行,确保 DOM 已更新
-- 使用防抖机制避免过度计算
-- 支持动态菜单数量变化
-- 自适应不同分辨率和设备

+ 424 - 0
WPS强制刷新文档说明.md

@@ -0,0 +1,424 @@
+# WPS 强制刷新文档说明
+
+## 问题
+
+点击"结束编辑"后,重新打开文件,WPS 仍然显示缓存的旧版本,而不是从服务器重新获取最新文件。
+
+## 原因
+
+### WPS 缓存机制
+
+WPS 使用 `fileId` 作为文档的唯一标识:
+
+```typescript
+const config = {
+  appId: 'SX20251229FLIAPD',
+  fileId: '28',  // ← 文档 ID
+  officeType: 'f'
+};
+```
+
+**问题**:
+- 相同的 `fileId` 会使用缓存的文档
+- 即使调用 `destroy()` 销毁实例
+- WPS 服务器仍然保留该 `fileId` 的文档
+- 重新初始化时会加载缓存版本
+
+### 缓存流程
+
+```
+第一次打开:
+fileId: "28"
+    ↓
+WPS 从你的服务器获取文件
+    ↓
+WPS 服务器缓存文件
+    ↓
+显示文档
+
+点击"结束编辑":
+调用 destroy()
+    ↓
+销毁前端实例
+    ↓
+但 WPS 服务器仍保留缓存
+
+重新打开:
+fileId: "28"  ← 相同的 ID
+    ↓
+WPS 发现缓存存在
+    ↓
+直接使用缓存(不从你的服务器获取)
+    ↓
+显示旧版本 ❌
+```
+
+## 解决方案
+
+### 方案 1:添加时间戳到 fileId(推荐)
+
+每次初始化时使用不同的 `fileId`,强制 WPS 重新获取文件:
+
+```typescript
+// 生成唯一的 fileId
+const timestamp = Date.now();
+const fileId = `${props.document.id}_${timestamp}`;
+
+const config = {
+  appId: WPS_APP_ID,
+  officeType: officeType,
+  fileId: fileId,  // ← 每次都不同
+  mount: wpsContainerRef.value
+};
+```
+
+**优势**:
+- ✅ 每次打开都是新的 `fileId`
+- ✅ WPS 会重新从服务器获取文件
+- ✅ 不会使用缓存
+- ✅ 简单可靠
+
+**示例**:
+```
+第一次打开:fileId = "28_1735545600000"
+第二次打开:fileId = "28_1735545700000"
+第三次打开:fileId = "28_1735545800000"
+```
+
+### 方案 2:添加 customArgs 参数
+
+在配置中添加自定义参数,提示 WPS 刷新:
+
+```typescript
+const config = {
+  appId: WPS_APP_ID,
+  officeType: officeType,
+  fileId: fileId,
+  mount: wpsContainerRef.value,
+  
+  // 添加自定义参数
+  customArgs: {
+    timestamp: Date.now(),
+    refresh: true
+  }
+};
+```
+
+**说明**:
+- `customArgs` 会传递给后端回调接口
+- 后端可以根据这些参数决定是否返回新文件
+- 但 WPS 可能仍然使用缓存
+
+### 方案 3:后端接口添加版本号
+
+在后端的文件信息接口中返回版本号:
+
+```json
+{
+  "file": {
+    "id": "28",
+    "name": "文档.pdf",
+    "version": "1735545600000",  // ← 版本号
+    "download_url": "http://..."
+  }
+}
+```
+
+WPS 会根据版本号判断是否需要重新下载。
+
+## 实现代码
+
+### 当前实现(方案 1 + 方案 2)
+
+```typescript
+// 初始化 WPS 编辑器
+const initWpsEditor = async () => {
+  if (!wpsContainerRef.value || !props.document?.ossId) {
+    return;
+  }
+
+  try {
+    wpsLoading.value = true;
+    wpsError.value = '';
+
+    const WebOfficeSDK = (window as any).WebOfficeSDK;
+    const officeType = getFileType(props.document.fileName || '');
+
+    // 生成唯一的 fileId(添加时间戳)
+    const timestamp = Date.now();
+    const fileId = `${props.document.id}_${timestamp}`;
+
+    console.log('[WPS] 初始化配置:', {
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+      fileName: props.document.fileName,
+      timestamp: timestamp
+    });
+
+    // 初始化配置
+    const config = {
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,  // ← 每次都不同
+      mount: wpsContainerRef.value,
+      
+      // 添加自定义参数
+      customArgs: {
+        timestamp: timestamp,
+        refresh: true
+      }
+    };
+
+    // 初始化编辑器
+    wpsInstance = WebOfficeSDK.init(config);
+
+    wpsLoading.value = false;
+    console.log('[WPS] 编辑器初始化成功');
+  } catch (err) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+  }
+};
+```
+
+## 工作流程
+
+### 优化前
+
+```
+第一次打开:
+fileId: "28"
+    ↓
+WPS 从服务器获取文件
+    ↓
+显示文档
+
+结束编辑:
+destroy()
+    ↓
+销毁实例
+
+重新打开:
+fileId: "28"  ← 相同
+    ↓
+WPS 使用缓存 ❌
+    ↓
+显示旧版本
+```
+
+### 优化后
+
+```
+第一次打开:
+fileId: "28_1735545600000"
+    ↓
+WPS 从服务器获取文件
+    ↓
+显示文档
+
+结束编辑:
+destroy()
+    ↓
+销毁实例
+
+重新打开:
+fileId: "28_1735545700000"  ← 不同
+    ↓
+WPS 重新从服务器获取 ✅
+    ↓
+显示最新版本
+```
+
+## 后端接口影响
+
+### 文件信息接口
+
+WPS 会调用你的后端接口获取文件信息:
+
+```
+GET /v1/3rd/file/info?_w_appid=xxx&_w_fileid=28_1735545600000
+```
+
+**注意**:
+- `_w_fileid` 参数现在包含时间戳
+- 后端需要解析 `fileId`,提取真实的文档 ID
+
+**后端实现建议**:
+
+```java
+@GetMapping("/v1/3rd/file/info")
+public R<FileInfo> getFileInfo(@RequestParam("_w_fileid") String fileId) {
+    // 解析 fileId,提取真实的文档 ID
+    String realDocId = fileId.split("_")[0];  // "28_1735545600000" -> "28"
+    
+    // 根据真实 ID 获取文档信息
+    Document doc = documentService.getById(realDocId);
+    
+    // 返回文件信息
+    return R.ok(buildFileInfo(doc));
+}
+```
+
+### 文件下载接口
+
+```
+GET /resource/oss/downloadWithoutPermission/{ossId}
+```
+
+**不受影响**:
+- 下载接口使用 `ossId`,不是 `fileId`
+- 无需修改
+
+## 调试日志
+
+### 优化前
+
+```
+[WPS] 初始化配置: {
+  appId: "SX20251229FLIAPD",
+  officeType: "f",
+  fileId: "28",
+  fileName: "文档.pdf"
+}
+```
+
+### 优化后
+
+```
+[WPS] 初始化配置: {
+  appId: "SX20251229FLIAPD",
+  officeType: "f",
+  fileId: "28_1735545600000",
+  fileName: "文档.pdf",
+  timestamp: 1735545600000
+}
+```
+
+## 测试场景
+
+### 场景 1:正常打开
+```
+操作:打开文档
+预期:fileId = "28_[当前时间戳]"
+结果:WPS 从服务器获取文件
+```
+
+### 场景 2:结束编辑后重新打开
+```
+操作:
+1. 打开文档(fileId = "28_1735545600000")
+2. 点击"结束编辑"
+3. 重新打开文档(fileId = "28_1735545700000")
+
+预期:两次的 fileId 不同
+结果:WPS 重新从服务器获取文件
+```
+
+### 场景 3:快速重复打开
+```
+操作:
+1. 打开文档
+2. 立即关闭
+3. 立即重新打开
+
+预期:两次的 fileId 不同(时间戳不同)
+结果:WPS 重新从服务器获取文件
+```
+
+### 场景 4:同一文档多次打开
+```
+操作:
+1. 打开文档 A(fileId = "28_1735545600000")
+2. 关闭
+3. 打开文档 B(fileId = "29_1735545700000")
+4. 关闭
+5. 再次打开文档 A(fileId = "28_1735545800000")
+
+预期:每次的 fileId 都不同
+结果:每次都从服务器获取最新文件
+```
+
+## 性能考虑
+
+### 优势
+- ✅ 每次都获取最新文件
+- ✅ 不会显示旧版本
+- ✅ 用户体验好
+
+### 劣势
+- ⚠️ 每次都需要下载文件(不使用缓存)
+- ⚠️ 增加服务器负载
+- ⚠️ 增加网络流量
+
+### 优化建议
+
+如果文件很大,可以考虑:
+
+1. **使用版本号**:
+   ```typescript
+   const fileId = `${props.document.id}_v${props.document.version}`;
+   ```
+   只有版本变化时才重新下载。
+
+2. **使用哈希值**:
+   ```typescript
+   const fileId = `${props.document.id}_${props.document.hash}`;
+   ```
+   文件内容变化时哈希值才变化。
+
+3. **添加缓存控制**:
+   ```typescript
+   const config = {
+     // ...
+     customArgs: {
+       cache: props.document.allowCache ? 'true' : 'false'
+     }
+   };
+   ```
+
+## 常见问题
+
+### Q1: 为什么要添加时间戳?
+**答**:让每次打开的 `fileId` 都不同,强制 WPS 重新从服务器获取文件。
+
+### Q2: 时间戳会影响后端接口吗?
+**答**:会,后端需要解析 `fileId`,提取真实的文档 ID。
+
+### Q3: 可以使用其他方式吗?
+**答**:可以,比如版本号、哈希值、随机数等,只要保证每次不同即可。
+
+### Q4: 会增加服务器负载吗?
+**答**:会,因为每次都需要下载文件。如果文件很大,建议使用版本号方案。
+
+### Q5: 旧的 fileId 会被清理吗?
+**答**:WPS 服务器会定期清理过期的文档缓存。
+
+### Q6: 可以手动清理缓存吗?
+**答**:可以,调用 `destroy()` 方法会清理前端实例,但 WPS 服务器的缓存由 WPS 管理。
+
+## 总结
+
+### 核心改动
+```typescript
+// 优化前
+const fileId = `${props.document.id}`;
+
+// 优化后
+const timestamp = Date.now();
+const fileId = `${props.document.id}_${timestamp}`;
+```
+
+### 效果
+- ✅ 每次打开都是新的 `fileId`
+- ✅ WPS 重新从服务器获取文件
+- ✅ 不会显示缓存的旧版本
+- ✅ 用户看到的始终是最新文件
+
+### 后端注意事项
+- ⚠️ 需要解析 `fileId`,提取真实的文档 ID
+- ⚠️ `fileId` 格式:`{documentId}_{timestamp}`
+- ⚠️ 示例:`"28_1735545600000"` → 文档 ID 是 `"28"`
+
+现在重新打开文件时,WPS 会从你的服务器重新获取最新文件,而不是使用缓存的旧版本!

+ 446 - 0
WPS文档版本控制方案.md

@@ -0,0 +1,446 @@
+# WPS 文档版本控制方案
+
+## 需求
+
+在 fileId 不变的情况下,让 WPS 重新从服务器获取文件。
+
+## 解决方案
+
+通过后端的文件信息接口返回不同的版本号,让 WPS 判断文件已更新,从而重新下载。
+
+## 工作原理
+
+### WPS 缓存判断机制
+
+WPS 通过以下字段判断是否需要重新下载文件:
+
+1. **version**(版本号)
+2. **modify_time**(修改时间)
+3. **size**(文件大小)
+
+如果这些字段发生变化,WPS 会重新下载文件。
+
+### 流程
+
+```
+用户打开文档:
+    ↓
+WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
+    ↓
+后端返回:
+{
+  "file": {
+    "id": "28",
+    "name": "文档.pdf",
+    "version": 1,  // ← 版本号
+    "modify_time": 1735545600000,
+    "size": 1024000
+  }
+}
+    ↓
+WPS 检查缓存:
+  - 缓存中的版本号是 1
+  - 服务器返回的版本号是 1
+  - 版本号相同,使用缓存 ✅
+
+用户点击"结束编辑":
+    ↓
+前端调用:PUT /wps/callback/v3/3rd/file/28/version
+    ↓
+后端更新版本号:version = 2
+    ↓
+销毁 WPS 实例
+
+用户重新打开文档:
+    ↓
+WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
+    ↓
+后端返回:
+{
+  "file": {
+    "id": "28",
+    "name": "文档.pdf",
+    "version": 2,  // ← 版本号已更新
+    "modify_time": 1735545700000,
+    "size": 1024000
+  }
+}
+    ↓
+WPS 检查缓存:
+  - 缓存中的版本号是 1
+  - 服务器返回的版本号是 2
+  - 版本号不同,重新下载 ✅
+```
+
+## 前端实现
+
+### 1. 新增 API 接口
+
+**文件**:`src/api/system/signature.ts`
+
+```typescript
+/**
+ * 通知后端更新文档版本(用于强制 WPS 刷新)
+ * @param documentId 文档 ID
+ */
+export function updateDocumentVersion(documentId: number | string): AxiosPromise<any> {
+    return request({
+        url: `/wps/callback/v3/3rd/file/${documentId}/version`,
+        method: 'put'
+    });
+}
+```
+
+### 2. 修改"结束编辑"功能
+
+**文件**:`src/components/DocumentAuditDialog/index.vue`
+
+```typescript
+const handleEndEdit = async () => {
+  // ... 确认对话框 ...
+  
+  try {
+    // 1. 调用 WPS SDK 的销毁方法
+    if (wpsInstance.destroy) {
+      await wpsInstance.destroy();
+    }
+    
+    // 2. 通知后端更新文档版本
+    try {
+      await updateDocumentVersion(props.document.id);
+      console.log('[WPS] 文档版本已更新');
+    } catch (versionErr) {
+      console.warn('[WPS] 更新文档版本失败(不影响主流程):', versionErr);
+    }
+    
+    // 3. 清空实例
+    wpsInstance = null;
+    
+    // 4. 显示成功提示并关闭对话框
+    ElMessage.success('编辑已结束,文档已销毁');
+    setTimeout(() => {
+      dialogVisible.value = false;
+    }, 1000);
+    
+  } catch (err) {
+    // 错误处理...
+  }
+};
+```
+
+## 后端实现
+
+### 1. 数据库表结构
+
+需要在文档表中添加版本号字段:
+
+```sql
+ALTER TABLE document ADD COLUMN version INT DEFAULT 1;
+```
+
+或者使用修改时间:
+
+```sql
+ALTER TABLE document ADD COLUMN modify_time BIGINT;
+```
+
+### 2. 更新版本接口
+
+**接口地址**:`PUT /wps/callback/v3/3rd/file/{documentId}/version`
+
+**功能**:更新文档版本号
+
+**实现示例**:
+
+```java
+@PutMapping("/wps/callback/v3/3rd/file/{documentId}/version")
+public R<Void> updateDocumentVersion(@PathVariable Long documentId) {
+    // 1. 获取文档
+    Document doc = documentService.getById(documentId);
+    if (doc == null) {
+        return R.fail("文档不存在");
+    }
+    
+    // 2. 更新版本号
+    doc.setVersion(doc.getVersion() + 1);
+    doc.setModifyTime(System.currentTimeMillis());
+    documentService.updateById(doc);
+    
+    log.info("文档版本已更新: id={}, version={}", documentId, doc.getVersion());
+    
+    return R.ok();
+}
+```
+
+### 3. 文件信息接口
+
+**接口地址**:`GET /v1/3rd/file/info`
+
+**修改**:返回版本号和修改时间
+
+**实现示例**:
+
+```java
+@GetMapping("/v1/3rd/file/info")
+public R<FileInfo> getFileInfo(@RequestParam("_w_fileid") String fileId) {
+    // 1. 获取文档
+    Document doc = documentService.getById(fileId);
+    if (doc == null) {
+        return R.fail("文档不存在");
+    }
+    
+    // 2. 构建文件信息
+    FileInfo fileInfo = new FileInfo();
+    fileInfo.setId(doc.getId().toString());
+    fileInfo.setName(doc.getFileName());
+    fileInfo.setVersion(doc.getVersion());  // ← 版本号
+    fileInfo.setModifyTime(doc.getModifyTime());  // ← 修改时间
+    fileInfo.setSize(doc.getFileSize());
+    fileInfo.setDownloadUrl(buildDownloadUrl(doc.getOssId()));
+    
+    return R.ok(fileInfo);
+}
+```
+
+**返回格式**:
+
+```json
+{
+  "code": 200,
+  "msg": "success",
+  "data": {
+    "file": {
+      "id": "28",
+      "name": "文档.pdf",
+      "version": 2,
+      "modify_time": 1735545700000,
+      "size": 1024000,
+      "download_url": "http://your-server.com/resource/oss/downloadWithoutPermission/123"
+    }
+  }
+}
+```
+
+## 完整流程
+
+### 第一次打开文档
+
+```
+1. 用户打开文档
+    ↓
+2. WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
+    ↓
+3. 后端返回:version=1, modify_time=1735545600000
+    ↓
+4. WPS 缓存中没有该文档
+    ↓
+5. WPS 下载文件:GET /resource/oss/downloadWithoutPermission/123
+    ↓
+6. WPS 缓存文件(version=1)
+    ↓
+7. 显示文档
+```
+
+### 结束编辑
+
+```
+1. 用户点击"结束编辑"
+    ↓
+2. 前端调用:wpsInstance.destroy()
+    ↓
+3. 前端调用:PUT /wps/callback/v3/3rd/file/28/version
+    ↓
+4. 后端更新:version=2, modify_time=1735545700000
+    ↓
+5. 前端清空实例:wpsInstance = null
+    ↓
+6. 关闭对话框
+```
+
+### 重新打开文档
+
+```
+1. 用户重新打开文档
+    ↓
+2. WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
+    ↓
+3. 后端返回:version=2, modify_time=1735545700000
+    ↓
+4. WPS 检查缓存:
+   - 缓存版本:version=1
+   - 服务器版本:version=2
+   - 版本不同!
+    ↓
+5. WPS 重新下载文件:GET /resource/oss/downloadWithoutPermission/123
+    ↓
+6. WPS 更新缓存(version=2)
+    ↓
+7. 显示最新文档 ✅
+```
+
+## 版本号策略
+
+### 方案 1:递增版本号(推荐)
+
+```java
+// 每次更新时递增
+doc.setVersion(doc.getVersion() + 1);
+```
+
+**优势**:
+- ✅ 简单明了
+- ✅ 易于理解
+- ✅ 便于调试
+
+**示例**:
+```
+初始:version = 1
+第一次更新:version = 2
+第二次更新:version = 3
+```
+
+### 方案 2:使用时间戳
+
+```java
+// 使用当前时间戳作为版本号
+doc.setVersion(System.currentTimeMillis());
+```
+
+**优势**:
+- ✅ 自动递增
+- ✅ 包含时间信息
+- ✅ 不会重复
+
+**示例**:
+```
+初始:version = 1735545600000
+第一次更新:version = 1735545700000
+第二次更新:version = 1735545800000
+```
+
+### 方案 3:使用修改时间
+
+```java
+// 只更新 modify_time,不使用 version
+doc.setModifyTime(System.currentTimeMillis());
+```
+
+**优势**:
+- ✅ 不需要额外字段
+- ✅ 符合语义
+- ✅ WPS 也会检查此字段
+
+**注意**:
+- WPS 会同时检查 `version` 和 `modify_time`
+- 任一字段变化都会触发重新下载
+
+## 调试日志
+
+### 前端日志
+
+```
+[WPS] 开始结束编辑,文档 ID: 28
+[WPS] 调用 destroy 方法
+[WPS] destroy 方法调用成功
+[WPS] 通知后端更新文档版本
+[WPS] 文档版本已更新
+[WPS] 结束编辑成功
+```
+
+### 后端日志
+
+```
+[INFO] 收到更新版本请求: documentId=28
+[INFO] 当前版本: 1
+[INFO] 更新后版本: 2
+[INFO] 文档版本已更新: id=28, version=2
+```
+
+### WPS 日志
+
+```
+[WPS] 获取文件信息: fileId=28
+[WPS] 服务器版本: 2
+[WPS] 缓存版本: 1
+[WPS] 版本不同,重新下载文件
+[WPS] 下载完成,更新缓存
+```
+
+## 测试场景
+
+### 场景 1:正常流程
+
+```
+1. 打开文档(version=1)
+2. 编辑文档
+3. 点击"结束编辑"(version 更新为 2)
+4. 重新打开文档
+5. 预期:WPS 重新下载文件
+```
+
+### 场景 2:多次编辑
+
+```
+1. 打开文档(version=1)
+2. 结束编辑(version=2)
+3. 重新打开(version=2)
+4. 结束编辑(version=3)
+5. 重新打开(version=3)
+6. 预期:每次都重新下载
+```
+
+### 场景 3:版本更新失败
+
+```
+1. 打开文档(version=1)
+2. 点击"结束编辑"
+3. 版本更新接口失败(version 仍为 1)
+4. 重新打开文档
+5. 预期:WPS 使用缓存(因为版本号未变)
+```
+
+## 常见问题
+
+### Q1: 为什么要更新版本号?
+**答**:让 WPS 知道文件已更新,需要重新下载。
+
+### Q2: 版本号必须递增吗?
+**答**:不必须,只要每次不同即可。但递增更易于理解和调试。
+
+### Q3: 如果版本更新失败怎么办?
+**答**:不影响主流程,只是下次打开时 WPS 会使用缓存。
+
+### Q4: 可以手动触发刷新吗?
+**答**:可以,调用版本更新接口即可。
+
+### Q5: 版本号会一直增长吗?
+**答**:是的,但这不是问题。可以定期重置或使用时间戳。
+
+### Q6: 需要修改数据库吗?
+**答**:是的,需要添加 `version` 字段或使用 `modify_time` 字段。
+
+## 总结
+
+### 核心思路
+- ✅ fileId 保持不变
+- ✅ 通过版本号控制缓存
+- ✅ 结束编辑时更新版本号
+- ✅ 重新打开时 WPS 检测到版本变化
+- ✅ WPS 重新下载文件
+
+### 前端改动
+1. 新增 `updateDocumentVersion` API
+2. 在"结束编辑"时调用该 API
+
+### 后端改动
+1. 添加 `version` 字段到数据库
+2. 实现版本更新接口
+3. 在文件信息接口中返回版本号
+
+### 优势
+- ✅ fileId 不变,后端逻辑简单
+- ✅ 符合 WPS 的缓存机制
+- ✅ 可控性强
+- ✅ 易于调试
+
+现在点击"结束编辑"后,重新打开文档时 WPS 会从你的服务器重新获取最新文件!

+ 0 - 0
WPS缓存问题说明.md


+ 0 - 258
WPS集成最终说明.md

@@ -1,258 +0,0 @@
-# WPS SDK 集成最终说明
-
-## 当前错误分析
-
-### 错误信息
-```
-init error: AppInfoNotExists won't start session
-GET https://o.wpsgo.com/api/v3/office/file/28/multiwatermark 403 (Forbidden)
-TypeError: Cannot read properties of undefined (reading 'officeType')
-```
-
-### 错误原因
-**WPS SDK 必须依赖后端接口才能工作!**
-
-WPS SDK 的工作流程:
-```
-1. 前端调用 WebOfficeSDK.init()
-   ↓
-2. WPS SDK 向 WPS 服务器发送请求
-   ↓
-3. WPS 服务器回调你的后端接口获取文件信息
-   ↓
-4. 如果后端接口不存在 → 返回 AppInfoNotExists 错误
-   ↓
-5. 如果后端接口存在 → 返回文件信息 → 显示编辑器
-```
-
-**结论**:没有后端接口,WPS SDK 无法工作!这不是前端的问题。
-
-## 两个选择
-
-### 选择 1:实现后端接口(推荐,如果需要在线编辑)
-
-#### 需要做什么
-1. **实现文件信息接口**(必需)
-   ```
-   GET /v1/3rd/file/info
-   ```
-
-2. **在 WPS 控制台配置回调地址**
-   - 登录:https://open.wps.cn/
-   - 配置回调地址:`https://你的域名/v1/3rd/file/info`
-
-#### 快速实现(Java 示例)
-```java
-@RestController
-@RequestMapping("/v1/3rd/file")
-public class WpsFileController {
-    
-    @Autowired
-    private DocumentService documentService;
-    
-    @GetMapping("/info")
-    public Map<String, Object> getFileInfo(
-        @RequestParam("_w_appid") String appId,
-        @RequestParam("_w_fileid") String fileId
-    ) {
-        // 1. 验证 appId
-        if (!"SX20251229FLIAPDAPP".equals(appId)) {
-            throw new RuntimeException("Invalid appId");
-        }
-        
-        // 2. 查询文档
-        Document doc = documentService.getById(Long.parseLong(fileId));
-        if (doc == null) {
-            throw new RuntimeException("File not found");
-        }
-        
-        // 3. 构建响应
-        Map<String, Object> file = new HashMap<>();
-        file.put("id", doc.getId().toString());
-        file.put("name", doc.getFileName());
-        file.put("version", 1);
-        file.put("size", doc.getFileSize() != null ? doc.getFileSize() : 0);
-        file.put("download_url", doc.getUrl());
-        
-        Map<String, Object> creator = new HashMap<>();
-        creator.put("id", "user_1");
-        creator.put("name", "系统用户");
-        file.put("creator", creator);
-        
-        file.put("create_time", System.currentTimeMillis() / 1000);
-        file.put("modify_time", System.currentTimeMillis() / 1000);
-        
-        Map<String, Integer> acl = new HashMap<>();
-        acl.put("rename", 1);
-        acl.put("history", 1);
-        acl.put("copy", 1);
-        acl.put("export", 1);
-        acl.put("print", 1);
-        acl.put("read", 1);
-        acl.put("update", 1);  // 1=可编辑,0=只读
-        acl.put("comment", 1);
-        file.put("user_acl", acl);
-        
-        Map<String, Object> data = new HashMap<>();
-        data.put("file", file);
-        
-        Map<String, Object> response = new HashMap<>();
-        response.put("code", 0);
-        response.put("msg", "success");
-        response.put("data", data);
-        
-        return response;
-    }
-}
-```
-
-#### 优点
-- ✅ 可以在线编辑文档
-- ✅ 功能完整
-- ✅ 用户体验好
-
-#### 缺点
-- ❌ 需要后端开发
-- ❌ 需要配置 WPS 控制台
-- ❌ 实现相对复杂
-
----
-
-### 选择 2:使用简单的 iframe 预览(推荐,如果只需要查看)
-
-#### 需要做什么
-**什么都不需要做!** 前端已经实现了降级方案。
-
-当 WPS SDK 初始化失败时,会自动切换到 iframe 预览模式。
-
-#### 如果想完全移除 WPS SDK
-可以简化代码,只使用 iframe:
-
-```vue
-<template>
-  <div class="preview-container">
-    <iframe 
-      v-if="document?.url"
-      :src="document.url"
-      frameborder="0"
-      style="width: 100%; height: 100%;"
-    ></iframe>
-  </div>
-</template>
-```
-
-#### 优点
-- ✅ 无需后端支持
-- ✅ 立即可用
-- ✅ 代码简单
-- ✅ 维护成本低
-
-#### 缺点
-- ❌ 无法在线编辑
-- ❌ 只能查看文档
-
----
-
-## 推荐方案
-
-### 如果你的需求是:
-
-#### 1. 只需要查看文档
-**推荐:使用 iframe 预览**
-- 当前代码已经有降级方案
-- 或者完全移除 WPS SDK,只用 iframe
-
-#### 2. 需要在线编辑文档
-**推荐:实现后端接口**
-- 按照上面的 Java 代码实现接口
-- 在 WPS 控制台配置回调地址
-- 大约 30 分钟可以完成
-
-#### 3. 需要简单的编辑功能
-**推荐:下载编辑方案**
-- 添加"下载文档"按钮
-- 用户下载后本地编辑
-- 编辑完成后重新上传
-
----
-
-## 详细文档位置
-
-### 后端接口实现指南
-查看:`.kiro/specs/backend-api-requirements.md`
-
-包含:
-- 完整的接口规范
-- Java/Node.js 实现示例
-- 测试方法
-- 常见问题解答
-
-### 简单预览方案
-查看:`.kiro/specs/simple-document-viewer-plan.md`
-
-包含:
-- iframe 预览方案
-- vue-office 集成方案
-- 下载编辑方案
-
----
-
-## 快速决策
-
-### 问题 1:你需要在线编辑功能吗?
-
-**是** → 实现后端接口(选择 1)  
-**否** → 使用 iframe 预览(选择 2)
-
-### 问题 2:后端能在多久内实现接口?
-
-**1-2 天内** → 等待后端实现,使用 WPS SDK  
-**超过 2 天** → 先用 iframe 预览,后续再升级
-
-### 问题 3:是否需要协同编辑?
-
-**是** → 必须使用 WPS SDK + 后端接口  
-**否** → iframe 预览就够了
-
----
-
-## 当前状态
-
-### ✅ 前端已完成
-- WPS SDK 集成代码
-- 标准化配置
-- 错误处理
-- 降级方案(自动切换到 iframe)
-
-### ⏳ 等待决策
-1. 是否需要在线编辑功能?
-2. 如果需要,后端何时能实现接口?
-3. 如果不需要,是否移除 WPS SDK 代码?
-
----
-
-## 下一步行动
-
-### 如果选择实现后端接口
-1. 复制上面的 Java 代码
-2. 调整为你的项目结构
-3. 测试接口:`curl "http://localhost:8080/v1/3rd/file/info?_w_appid=SX20251229FLIAPDAPP&_w_fileid=28"`
-4. 在 WPS 控制台配置回调地址
-5. 刷新前端页面测试
-
-### 如果选择使用 iframe 预览
-1. 告诉我,我帮你移除 WPS SDK 代码
-2. 简化为纯 iframe 预览
-3. 立即可用
-
----
-
-## 总结
-
-**当前错误是正常的!** 因为后端接口还没实现。
-
-你需要决定:
-1. **实现后端接口** → 获得完整的在线编辑功能
-2. **使用 iframe 预览** → 简单快速,只能查看
-
-告诉我你的选择,我会帮你完成相应的实现!

+ 24 - 0
src/api/system/signature.ts

@@ -0,0 +1,24 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+
+/**
+ * 获取用户签名信息
+ */
+export function getSignature(): AxiosPromise<{ id: number }> {
+    return request({
+        url: '/system/user/getSignature',
+        method: 'get'
+    });
+}
+
+/**
+ * 下载签名图片(返回 Blob)
+ * @param ossId OSS 文件 ID
+ */
+export function downloadSignature(ossId: number): Promise<Blob> {
+    return request({
+        url: `/resource/oss/downloadWithoutPermission/${ossId}`,
+        method: 'get',
+        responseType: 'blob'
+    });
+}

+ 127 - 164
src/components/DocumentAuditDialog/index.vue

@@ -9,17 +9,18 @@
             <span class="file-name">{{ document?.fileName || '未命名文档' }}</span>
           </div>
           <div class="header-actions">
-            <el-button 
-              type="primary" 
-              size="small"
-              draggable="true"
-              @dragstart="handleUserNameDragStart"
-              @dragend="handleUserNameDragEnd"
-              class="drag-username-btn"
-            >
-              <el-icon><User /></el-icon>
-              拖我到文档中
-            </el-button>
+            <el-tooltip content="点击复制签名图片到剪贴板" placement="bottom">
+              <el-button 
+                type="primary" 
+                size="small"
+                @click="handleCopyAvatar"
+                class="copy-avatar-btn"
+                :loading="copyingAvatar"
+              >
+                <el-icon><Picture /></el-icon>
+                复制签名
+              </el-button>
+            </el-tooltip>
           </div>
         </div>
         <div class="preview-container">
@@ -153,9 +154,10 @@
 import { ref, reactive, watch, nextTick, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import type { FormInstance } from 'element-plus';
-import { ElMessage } from 'element-plus';
-import { Document, Edit, InfoFilled, CircleCheck, CircleClose, Close, Check, Loading, Upload, User } from '@element-plus/icons-vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import { Document, Edit, InfoFilled, CircleCheck, CircleClose, Close, Check, Loading, Upload, Picture } from '@element-plus/icons-vue';
 import { useUserStore } from '@/store/modules/user';
+import { getSignature, downloadSignature, updateDocumentVersion } from '@/api/system/signature';
 
 interface Document {
   id: number | string;
@@ -198,7 +200,7 @@ const wpsContainerRef = ref<HTMLDivElement>();
 const wpsLoading = ref(false);
 const wpsError = ref('');
 const isDragging = ref(false);
-const isDraggingUserName = ref(false);
+const copyingAvatar = ref(false);
 let wpsInstance: any = null;
 let dragCounter = 0; // 用于跟踪拖拽进入/离开次数
 
@@ -262,7 +264,7 @@ const initWpsEditor = async () => {
     // 获取文件类型
     const officeType = getFileType(props.document.fileName || '');
 
-    // 生成文件 ID
+    // 使用原始文档 ID(不添加时间戳)
     const fileId = `${props.document.id}`;
 
     console.log('[WPS] 初始化配置:', {
@@ -363,14 +365,6 @@ const handleDrop = async (e: DragEvent) => {
     return;
   }
   
-  // 检查是否是拖拽用户名
-  const userName = e.dataTransfer?.getData('text/plain');
-  if (isDraggingUserName.value && userName) {
-    await insertUserNameToWps(userName);
-    isDraggingUserName.value = false;
-    return;
-  }
-  
   // 处理图片拖放
   const files = e.dataTransfer?.files;
   if (!files || files.length === 0) {
@@ -447,150 +441,126 @@ const handleDrop = async (e: DragEvent) => {
   }
 };
 
-// 开始拖拽用户名
-const handleUserNameDragStart = (e: DragEvent) => {
-  const userName = userStore.userName || '当前用户';
-  e.dataTransfer!.effectAllowed = 'copy';
-  e.dataTransfer!.setData('text/plain', userName);
-  isDraggingUserName.value = true;
-  console.log('[拖拽] 开始拖拽用户名:', userName);
-};
-
-// 结束拖拽用户名
-const handleUserNameDragEnd = (e: DragEvent) => {
-  isDraggingUserName.value = false;
-  console.log('[拖拽] 结束拖拽用户名');
-};
-
-// 插入用户名到 WPS
-const insertUserNameToWps = async (userName: string) => {
-  if (!wpsInstance) {
-    ElMessage.warning('WPS 编辑器未初始化');
-    return;
-  }
-  
+// 复制头像到剪贴板
+const handleCopyAvatar = async () => {
   try {
-    console.log('[WPS] 开始插入用户名:', userName);
+    copyingAvatar.value = true;
+    console.log('[签名] 开始获取签名信息');
     
-    // 获取 WPS Application 对象
-    const app = await wpsInstance.Application;
+    // 1. 调用获取签名接口
+    const signatureRes = await getSignature();
+    const ossId = signatureRes.data.id;
     
-    if (!app) {
-      ElMessage.error('无法获取 WPS Application 对象');
+    if (!ossId) {
+      ElMessage.warning('未找到签名图片');
       return;
     }
     
-    // 根据文件类型插入文本
-    const officeType = getFileType(props.document?.fileName || '');
+    console.log('[签名] 签名 OSS ID:', ossId);
     
-    if (officeType === 'w') {
-      // Word 文档:在光标位置插入文本
-      const selection = await app.ActiveDocument.Application.Selection;
-      await selection.TypeText(userName);
-      ElMessage.success(`已插入:${userName}`);
-    } else if (officeType === 's') {
-      // Excel 表格:在当前单元格插入文本
-      const activeCell = await app.ActiveCell;
-      await activeCell.put_Value(userName);
-      ElMessage.success(`已插入:${userName}`);
-    } else if (officeType === 'p') {
-      // PowerPoint:在当前文本框插入文本
-      try {
-        const selection = await app.ActiveWindow.Selection;
-        const textRange = await selection.TextRange;
-        await textRange.InsertAfter(userName);
-        ElMessage.success(`已插入:${userName}`);
-      } catch (err) {
-        ElMessage.warning('请先选择一个文本框');
-      }
-    } else if (officeType === 'f') {
-      // PDF 文档:添加文本批注
-      try {
-        const pdfDoc = await app.ActivePDF;
-        
-        // 方法 1:尝试添加文本批注
-        try {
-          // 获取当前页面
-          const currentPage = await pdfDoc.CurrentPage;
-          
-          // 在页面中心位置添加文本批注
-          const pageWidth = await currentPage.Width;
-          const pageHeight = await currentPage.Height;
-          
-          // 添加文本批注(自由文本批注)
-          const annotation = await currentPage.AddAnnotation({
-            type: 'FreeText',  // 自由文本类型
-            rect: {
-              left: pageWidth / 2 - 100,
-              top: pageHeight / 2 - 20,
-              right: pageWidth / 2 + 100,
-              bottom: pageHeight / 2 + 20
-            },
-            contents: userName,
-            color: { r: 255, g: 0, b: 0 },  // 红色
-            fontSize: 14
-          });
-          
-          ElMessage.success(`已在 PDF 中添加文本:${userName}`);
-        } catch (err1) {
-          console.error('[WPS] 方法1失败,尝试方法2:', err1);
-          
-          // 方法 2:使用简单的批注 API
-          try {
-            await pdfDoc.AddComment({
-              text: userName,
-              author: userStore.userName || '审核人',
-              color: '#FF0000'
-            });
-            ElMessage.success(`已添加批注:${userName}`);
-          } catch (err2) {
-            console.error('[WPS] 方法2失败,尝试方法3:', err2);
-            
-            // 方法 3:使用文本标记
-            try {
-              const selection = await app.ActivePDF.Selection;
-              if (selection) {
-                await selection.AddTextMarkup({
-                  type: 'Highlight',
-                  text: userName,
-                  color: '#FFFF00'
-                });
-                ElMessage.success(`已添加高亮标记:${userName}`);
-              } else {
-                throw new Error('未选中文本');
-              }
-            } catch (err3) {
-              console.error('[WPS] 方法3失败:', err3);
-              
-              // 方法 4:使用印章功能
-              ElMessage.info('正在尝试添加文本印章...');
-              try {
-                await pdfDoc.AddStamp({
-                  text: userName,
-                  position: 'center',
-                  color: '#FF0000',
-                  fontSize: 14
-                });
-                ElMessage.success(`已添加文本印章:${userName}`);
-              } catch (err4) {
-                console.error('[WPS] 所有方法都失败了:', err4);
-                ElMessage.error('PDF 文本插入失败,请使用 WPS 自带的批注工具');
-              }
-            }
-          }
-        }
-      } catch (err: any) {
-        console.error('[WPS] PDF 操作失败:', err);
-        ElMessage.error('PDF 操作失败: ' + err.message);
+    // 2. 下载签名图片
+    console.log('[签名] 开始下载签名图片');
+    const blob = await downloadSignature(ossId);
+    
+    console.log('[签名] 图片下载成功,大小:', blob.size, '原始类型:', blob.type);
+    
+    // 3. 检测图片真实类型并转换 Blob
+    let imageBlob = blob;
+    let mimeType = blob.type;
+    
+    // 如果是 application/octet-stream,需要检测真实的图片类型
+    if (mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) {
+      console.log('[签名] 检测到非图片 MIME 类型,尝试转换');
+      
+      // 通过读取文件头来判断图片类型
+      const arrayBuffer = await blob.arrayBuffer();
+      const uint8Array = new Uint8Array(arrayBuffer);
+      
+      // 检测文件头
+      if (uint8Array[0] === 0xFF && uint8Array[1] === 0xD8 && uint8Array[2] === 0xFF) {
+        mimeType = 'image/jpeg';
+      } else if (uint8Array[0] === 0x89 && uint8Array[1] === 0x50 && uint8Array[2] === 0x4E && uint8Array[3] === 0x47) {
+        mimeType = 'image/png';
+      } else if (uint8Array[0] === 0x47 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46) {
+        mimeType = 'image/gif';
+      } else if (uint8Array[0] === 0x42 && uint8Array[1] === 0x4D) {
+        mimeType = 'image/bmp';
+      } else if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) {
+        mimeType = 'image/webp';
+      } else {
+        // 默认使用 PNG
+        mimeType = 'image/png';
       }
-    } else {
-      ElMessage.warning('当前文档类型不支持插入文本');
+      
+      console.log('[签名] 检测到的图片类型:', mimeType);
+      
+      // 创建新的 Blob,使用正确的 MIME 类型
+      imageBlob = new Blob([arrayBuffer], { type: mimeType });
     }
     
-    console.log('[WPS] 用户名插入成功');
+    console.log('[签名] 最终 MIME 类型:', imageBlob.type);
+    
+    // 4. 复制到剪贴板
+    await navigator.clipboard.write([
+      new ClipboardItem({
+        [imageBlob.type]: imageBlob
+      })
+    ]);
+    
+    ElMessage({
+      type: 'success',
+      message: '签名图片已复制到剪贴板',
+      duration: 3000
+    });
+    
+    // 显示使用提示
+    setTimeout(() => {
+      ElMessage({
+        type: 'info',
+        dangerouslyUseHTMLString: true,
+        message: `
+          <div style="text-align: left;">
+            <p style="margin: 0 0 8px 0; font-weight: bold;">请按以下步骤操作:</p>
+            <p style="margin: 0 0 4px 0;">• <strong>PDF</strong>:使用批注工具 → 选择图片 → 粘贴</p>
+            <p style="margin: 0 0 4px 0;">• <strong>Word/Excel/PPT</strong>:在文档中按 Ctrl+V 粘贴</p>
+            <p style="margin: 0;">• 或者直接拖拽外部图片到文档中</p>
+          </div>
+        `,
+        duration: 6000,
+        showClose: true
+      });
+    }, 500);
+    
+    console.log('[签名] 复制成功');
   } catch (err: any) {
-    console.error('[WPS] 插入用户名失败:', err);
-    ElMessage.error('插入用户名失败: ' + err.message);
+    console.error('[签名] 复制失败:', err);
+    
+    // 根据错误类型显示不同提示
+    if (err.message?.includes('clipboard') || err.message?.includes('Clipboard')) {
+      ElMessage({
+        type: 'warning',
+        dangerouslyUseHTMLString: true,
+        message: `
+          <div style="text-align: left;">
+            <p style="margin: 0 0 4px 0;">浏览器不支持复制图片,请尝试以下方法:</p>
+            <p style="margin: 0 0 4px 0;">1. 使用 Chrome 或 Edge 浏览器</p>
+            <p style="margin: 0;">2. 或者使用 WPS 的插入图片功能</p>
+          </div>
+        `,
+        duration: 6000,
+        showClose: true
+      });
+    } else if (err.response?.status === 404) {
+      ElMessage.warning('未找到签名图片,请先设置签名');
+    } else {
+      ElMessage({
+        type: 'error',
+        message: '获取签名失败: ' + (err.message || '未知错误'),
+        duration: 5000
+      });
+    }
+  } finally {
+    copyingAvatar.value = false;
   }
 };
 
@@ -763,14 +733,7 @@ onBeforeUnmount(() => {
     display: flex;
     gap: 8px;
     
-    .drag-username-btn {
-      cursor: move;
-      user-select: none;
-      
-      &:active {
-        cursor: grabbing;
-      }
-      
+    .copy-avatar-btn {
       .el-icon {
         margin-right: 4px;
       }

+ 1 - 0
src/store/modules/user.ts

@@ -74,6 +74,7 @@ export const useUserStore = defineStore('user', () => {
     userId,
     tenantId,
     token,
+    name,
     nickname,
     avatar,
     roles,

+ 0 - 206
拖拽插入图片功能说明.md

@@ -1,206 +0,0 @@
-# 拖拽插入图片功能说明
-
-## 功能概述
-
-现在可以直接从外部(桌面、文件夹、浏览器等)拖拽图片到 WPS 编辑器中,图片会自动插入到文档中。
-
-## 使用方法
-
-### 1. 打开文档审核对话框
-- 点击文档列表中的"审核"按钮
-- WPS 编辑器加载完成
-
-### 2. 拖拽图片
-1. 从桌面或文件夹选择一张图片
-2. 按住鼠标左键拖动图片
-3. 移动到 WPS 编辑器区域
-4. 看到蓝色虚线边框和提示"松开鼠标插入图片"
-5. 松开鼠标,图片自动插入
-
-### 3. 图片插入位置
-
-#### Word 文档(.docx/.doc)
-- 图片插入到当前光标位置
-- 建议先在文档中点击要插入的位置
-
-#### Excel 表格(.xlsx/.xls)
-- 图片插入到当前选中单元格附近
-- 默认大小:200x150 像素
-
-#### PowerPoint 演示(.pptx/.ppt)
-- 图片插入到当前幻灯片
-- 默认位置:左上角 (100, 100)
-- 默认大小:200x150 像素
-
-## 支持的图片格式
-
-- ✅ PNG (.png)
-- ✅ JPEG (.jpg, .jpeg)
-- ✅ GIF (.gif)
-- ✅ BMP (.bmp)
-- ✅ WebP (.webp)
-- ✅ SVG (.svg)
-
-## 功能特点
-
-### 1. 视觉反馈
-- 拖拽进入时显示蓝色虚线边框
-- 显示提示文字"松开鼠标插入图片"
-- 拖拽离开时自动隐藏提示
-
-### 2. 智能识别
-- 自动识别文件类型
-- 只接受图片文件
-- 非图片文件会提示"只支持插入图片文件"
-
-### 3. 自动适配
-- 根据文档类型(Word/Excel/PPT)自动选择插入方式
-- 图片自动转换为 base64 格式
-- 无需上传到服务器
-
-## 技术实现
-
-### 拖放事件处理
-```typescript
-// 拖拽进入
-@dragenter="handleDragEnter"
-
-// 拖拽离开
-@dragleave="handleDragLeave"
-
-// 拖拽悬停
-@dragover.prevent="handleDragOver"
-
-// 松开鼠标
-@drop.prevent="handleDrop"
-```
-
-### 图片插入流程
-```
-1. 用户拖拽图片到编辑器
-   ↓
-2. 读取图片文件为 base64
-   ↓
-3. 获取 WPS Application 对象
-   ↓
-4. 根据文档类型调用对应的插入方法
-   ↓
-5. 图片插入成功,显示提示
-```
-
-### Word 文档插入代码
-```typescript
-const selection = await app.ActiveDocument.Application.Selection;
-await selection.InlineShapes.AddPicture(base64);
-```
-
-### Excel 表格插入代码
-```typescript
-const activeSheet = await app.ActiveSheet;
-const activeCell = await app.ActiveCell;
-await activeSheet.Shapes.AddPicture(base64, false, true, x, y, width, height);
-```
-
-### PowerPoint 插入代码
-```typescript
-const activeSlide = await app.ActivePresentation.Slides.Item(slideIndex);
-await activeSlide.Shapes.AddPicture(base64, false, true, x, y, width, height);
-```
-
-## 注意事项
-
-### 1. WPS 编辑器必须已初始化
-- 如果编辑器未加载,会提示"WPS 编辑器未初始化"
-- 等待编辑器加载完成后再拖拽
-
-### 2. 只支持单个文件
-- 一次只能拖拽一个图片
-- 如果拖拽多个文件,只会处理第一个
-
-### 3. 文件大小限制
-- 建议图片大小 < 5MB
-- 过大的图片可能导致浏览器卡顿
-
-### 4. PDF 文档不支持
-- PDF 文档是只读格式
-- 无法插入图片
-- 会提示"当前文档类型不支持插入图片"
-
-### 5. 需要后端接口支持
-- WPS 编辑器需要后端接口才能正常工作
-- 如果后端接口未实现,编辑器无法加载
-- 详见:`后端接口实现指南.md`
-
-## 常见问题
-
-### Q1: 拖拽后没有反应?
-**可能原因**:
-1. WPS 编辑器未初始化
-2. 拖拽的不是图片文件
-3. 浏览器不支持拖放 API
-
-**解决方法**:
-1. 等待编辑器加载完成
-2. 确认文件是图片格式
-3. 使用 Chrome/Edge 浏览器
-
-### Q2: 图片插入位置不对?
-**原因**:
-- Word:光标位置不正确
-- Excel:当前单元格位置
-- PPT:默认位置
-
-**解决方法**:
-- Word:先点击要插入的位置
-- Excel/PPT:插入后手动调整位置
-
-### Q3: 提示"插入图片失败"?
-**可能原因**:
-1. 图片格式不支持
-2. 图片文件损坏
-3. WPS API 调用失败
-
-**解决方法**:
-1. 尝试其他图片
-2. 检查浏览器控制台错误信息
-3. 重新加载编辑器
-
-### Q4: 图片太大或太小?
-**解决方法**:
-- 插入后在 WPS 编辑器中调整大小
-- 或者在插入前先调整图片尺寸
-
-## 浏览器兼容性
-
-| 浏览器 | 支持情况 | 备注 |
-|--------|---------|------|
-| Chrome 90+ | ✅ 完全支持 | 推荐使用 |
-| Edge 90+ | ✅ 完全支持 | 推荐使用 |
-| Firefox 88+ | ✅ 完全支持 | |
-| Safari 14+ | ✅ 完全支持 | |
-| IE 11 | ❌ 不支持 | 不支持拖放 API |
-
-## 未来改进
-
-### 计划中的功能
-- [ ] 支持拖拽多张图片
-- [ ] 支持调整插入图片的默认大小
-- [ ] 支持拖拽其他文件类型(表格、文本等)
-- [ ] 支持从网页直接拖拽图片
-- [ ] 支持粘贴图片(Ctrl+V)
-
-### 性能优化
-- [ ] 大图片自动压缩
-- [ ] 图片格式自动转换
-- [ ] 批量插入优化
-
-## 总结
-
-拖拽插入图片功能让文档编辑更加便捷:
-- ✅ 无需点击按钮
-- ✅ 无需选择文件
-- ✅ 直接拖拽即可
-- ✅ 自动识别文档类型
-- ✅ 自动选择插入方式
-
-现在就可以试试拖拽一张图片到 WPS 编辑器中!

+ 0 - 285
拖拽插入用户名功能说明.md

@@ -1,285 +0,0 @@
-# 拖拽插入用户名功能说明
-
-## 功能概述
-
-在文档审核对话框中,可以通过拖拽按钮将当前用户的昵称插入到 WPS 文档的任意位置。
-
-## 使用方法
-
-### 1. 找到拖拽按钮
-- 打开文档审核对话框
-- 在顶部工具栏找到"拖我到文档中"按钮
-- 按钮上有用户图标
-
-### 2. 拖拽插入
-1. 按住鼠标左键点击"拖我到文档中"按钮
-2. 保持按住,拖动鼠标到 WPS 编辑器中
-3. 移动到想要插入的位置
-4. 松开鼠标,用户名自动插入
-
-### 3. 插入位置
-
-#### Word 文档(.docx/.doc)
-- 插入到当前光标位置
-- 建议先在文档中点击要插入的位置
-- 插入后可以继续编辑
-
-#### Excel 表格(.xlsx/.xls)
-- 插入到当前选中的单元格
-- 会替换单元格原有内容
-- 插入后可以修改或移动
-
-#### PowerPoint 演示(.pptx/.ppt)
-- 插入到当前选中的文本框
-- 如果没有选中文本框,会提示"请先选择一个文本框"
-- 需要先点击一个文本框,然后再拖拽
-
-#### PDF 文档(.pdf)
-- 不支持插入文本
-- 会提示"PDF 文档不支持插入文本"
-
-## 功能特点
-
-### 1. 自动获取用户名
-- 自动读取当前登录用户的昵称
-- 无需手动输入
-- 确保信息准确
-
-### 2. 拖拽式操作
-- 直观的拖拽交互
-- 精确控制插入位置
-- 符合用户习惯
-
-### 3. 智能适配
-- 根据文档类型自动选择插入方式
-- Word:文本插入
-- Excel:单元格赋值
-- PPT:文本框追加
-
-### 4. 视觉反馈
-- 按钮有移动光标样式
-- 拖拽时光标变为抓取状态
-- 插入成功后显示提示消息
-
-## 使用场景
-
-### 场景 1:文档审核签名
-```
-审核人:[拖拽插入用户名]
-审核时间:2025-12-29
-审核意见:通过
-```
-
-### 场景 2:批注标记
-```
-[拖拽插入用户名] 于 2025-12-29 批注:
-此处需要修改...
-```
-
-### 场景 3:表格填写
-| 姓名 | 部门 | 审核意见 |
-|------|------|----------|
-| [拖拽插入] | 技术部 | 通过 |
-
-### 场景 4:幻灯片署名
-```
-报告人:[拖拽插入用户名]
-日期:2025-12-29
-```
-
-## 技术实现
-
-### 拖拽事件处理
-```vue
-<el-button 
-  draggable="true"
-  @dragstart="handleUserNameDragStart"
-  @dragend="handleUserNameDragEnd"
->
-  拖我到文档中
-</el-button>
-```
-
-### 数据传递
-```typescript
-// 开始拖拽时设置数据
-const handleUserNameDragStart = (e: DragEvent) => {
-  const userName = userStore.userName || '当前用户';
-  e.dataTransfer!.setData('text/plain', userName);
-};
-```
-
-### 文本插入
-
-#### Word 文档
-```typescript
-const selection = await app.ActiveDocument.Application.Selection;
-await selection.TypeText(userName);
-```
-
-#### Excel 表格
-```typescript
-const activeCell = await app.ActiveCell;
-await activeCell.put_Value(userName);
-```
-
-#### PowerPoint
-```typescript
-const selection = await app.ActiveWindow.Selection;
-const textRange = await selection.TextRange;
-await textRange.InsertAfter(userName);
-```
-
-## 注意事项
-
-### 1. WPS 编辑器必须已初始化
-- 如果编辑器未加载,会提示"WPS 编辑器未初始化"
-- 等待编辑器加载完成后再拖拽
-
-### 2. Word 文档需要先定位光标
-- 在拖拽前,先在文档中点击要插入的位置
-- 否则会插入到默认位置(通常是文档开头)
-
-### 3. Excel 需要选中单元格
-- 拖拽前先点击要插入的单元格
-- 插入会替换单元格原有内容
-
-### 4. PowerPoint 需要选中文本框
-- 必须先点击一个文本框
-- 如果没有选中文本框,会提示错误
-- 可以先创建一个文本框,然后再拖拽
-
-### 5. PDF 不支持编辑
-- PDF 是只读格式
-- 无法插入文本
-- 会提示"PDF 文档不支持插入文本"
-
-### 6. 用户名来源
-- 从 `userStore.userName` 获取
-- 如果未设置,显示"当前用户"
-- 确保用户已登录
-
-## 常见问题
-
-### Q1: 拖拽后没有反应?
-**可能原因**:
-1. WPS 编辑器未初始化
-2. 没有正确松开鼠标
-3. 拖拽到了编辑器外部
-
-**解决方法**:
-1. 等待编辑器加载完成
-2. 确保在编辑器内部松开鼠标
-3. 重新拖拽
-
-### Q2: 插入位置不对?
-**原因**:
-- Word:光标位置不正确
-- Excel:未选中目标单元格
-- PPT:未选中文本框
-
-**解决方法**:
-- Word:先点击要插入的位置
-- Excel:先选中目标单元格
-- PPT:先点击文本框
-
-### Q3: PowerPoint 提示"请先选择一个文本框"?
-**原因**:
-- 没有选中任何文本框
-- 或者选中的不是文本框
-
-**解决方法**:
-1. 点击幻灯片中的文本框
-2. 确保文本框被选中(有边框)
-3. 然后再拖拽按钮
-
-### Q4: 显示"当前用户"而不是真实姓名?
-**原因**:
-- 用户信息未正确加载
-- `userStore.userName` 为空
-
-**解决方法**:
-1. 检查用户是否已登录
-2. 刷新页面重新加载用户信息
-3. 联系管理员检查用户数据
-
-### Q5: 可以拖拽多次吗?
-**答案**:
-- 可以!每次拖拽都会插入一次用户名
-- 可以在文档的不同位置多次插入
-- 每次插入都是独立的文本
-
-## 与图片拖拽的区别
-
-| 特性 | 用户名拖拽 | 图片拖拽 |
-|------|-----------|---------|
-| 拖拽源 | 按钮 | 外部文件 |
-| 数据类型 | 文本 | 图片 |
-| 插入方式 | 文本插入 | 图片插入 |
-| Word | 光标位置 | 光标位置 |
-| Excel | 单元格 | 浮动图片 |
-| PPT | 文本框 | 幻灯片 |
-| PDF | 不支持 | 不支持 |
-
-## 浏览器兼容性
-
-| 浏览器 | 支持情况 | 备注 |
-|--------|---------|------|
-| Chrome 90+ | ✅ 完全支持 | 推荐使用 |
-| Edge 90+ | ✅ 完全支持 | 推荐使用 |
-| Firefox 88+ | ✅ 完全支持 | |
-| Safari 14+ | ✅ 完全支持 | |
-| IE 11 | ❌ 不支持 | 不支持拖放 API |
-
-## 未来改进
-
-### 计划中的功能
-- [ ] 支持拖拽其他用户信息(部门、职位等)
-- [ ] 支持自定义拖拽文本
-- [ ] 支持拖拽时间戳
-- [ ] 支持拖拽签名图片
-- [ ] 支持批量插入(姓名+时间)
-
-### 用户体验优化
-- [ ] 拖拽时显示预览
-- [ ] 插入位置高亮提示
-- [ ] 支持键盘快捷键
-- [ ] 支持右键菜单插入
-
-## 示例代码
-
-### 完整的拖拽流程
-```typescript
-// 1. 开始拖拽
-handleUserNameDragStart(e) {
-  const userName = userStore.userName || '当前用户';
-  e.dataTransfer.setData('text/plain', userName);
-  isDraggingUserName.value = true;
-}
-
-// 2. 拖拽到编辑器
-handleDrop(e) {
-  const userName = e.dataTransfer.getData('text/plain');
-  if (isDraggingUserName.value && userName) {
-    await insertUserNameToWps(userName);
-  }
-}
-
-// 3. 插入到文档
-async insertUserNameToWps(userName) {
-  const app = await wpsInstance.Application;
-  const selection = await app.ActiveDocument.Application.Selection;
-  await selection.TypeText(userName);
-}
-```
-
-## 总结
-
-拖拽插入用户名功能让文档审核更加便捷:
-- ✅ 无需手动输入
-- ✅ 精确控制位置
-- ✅ 自动获取用户信息
-- ✅ 支持多种文档类型
-- ✅ 直观的拖拽操作
-
-现在就可以试试拖拽按钮到文档中!

+ 473 - 0
结束编辑功能说明.md

@@ -0,0 +1,473 @@
+# 结束编辑功能说明
+
+## 功能概述
+
+添加"结束编辑"按钮,点击后调用 WPS SDK 的 `destroy()` 方法销毁文档,释放 WPS 服务器资源。
+
+## 功能特点
+
+### 1. 一键结束
+- 点击"结束编辑"按钮
+- 确认后自动销毁 WPS 文档
+- 关闭编辑对话框
+
+### 2. 安全确认
+在执行前弹出确认对话框:
+```
+⚠️ 结束编辑
+
+确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。
+
+[取消]  [确定]
+```
+
+### 3. 自动清理
+- 调用 WPS SDK 的 `destroy()` 方法
+- 清空 `wpsInstance` 实例
+- 关闭编辑对话框
+- 释放 WPS 服务器资源
+
+## 技术实现
+
+### 核心代码
+
+```typescript
+const handleEndEdit = async () => {
+  if (!wpsInstance) {
+    ElMessage.warning('WPS 编辑器未初始化');
+    return;
+  }
+  
+  // 1. 确认对话框
+  await ElMessageBox.confirm(
+    '确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。',
+    '结束编辑',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    }
+  );
+  
+  try {
+    endingEdit.value = true;
+    
+    // 2. 显示加载提示
+    const loadingMsg = ElMessage({
+      message: '正在结束编辑...',
+      duration: 0,
+      showClose: false
+    });
+    
+    // 3. 调用 WPS SDK 的销毁方法
+    if (wpsInstance.destroy) {
+      await wpsInstance.destroy();
+    }
+    
+    // 4. 清空实例
+    wpsInstance = null;
+    
+    // 5. 关闭加载提示
+    loadingMsg.close();
+    
+    // 6. 显示成功提示
+    ElMessage.success('编辑已结束,文档已销毁');
+    
+    // 7. 关闭对话框
+    setTimeout(() => {
+      dialogVisible.value = false;
+    }, 1000);
+    
+  } catch (err) {
+    // 错误处理...
+  } finally {
+    endingEdit.value = false;
+  }
+};
+```
+
+## 工作流程
+
+```
+用户点击"结束编辑"
+    ↓
+显示确认对话框
+    ↓
+用户确认
+    ↓
+显示加载提示:"正在结束编辑..."
+    ↓
+调用 wpsInstance.destroy()
+    ↓
+WPS 销毁文档(释放服务器资源)
+    ↓
+清空 wpsInstance = null
+    ↓
+关闭加载提示
+    ↓
+显示成功提示:"编辑已结束,文档已销毁"
+    ↓
+1 秒后关闭对话框
+    ↓
+完成!
+```
+
+## 界面变化
+
+### 修改前
+```
+┌─────────────────────────────────────┐
+│ 📄 文档名称.docx                    │
+│                                     │
+│ [🖼️ 复制签名]                       │
+└─────────────────────────────────────┘
+```
+
+### 修改后
+```
+┌─────────────────────────────────────┐
+│ 📄 文档名称.docx                    │
+│                                     │
+│ [🖼️ 复制签名] [⭕ 结束编辑]        │
+└─────────────────────────────────────┘
+```
+
+## WPS SDK destroy() 方法
+
+### 方法说明
+
+```typescript
+wpsInstance.destroy(): Promise<void>
+```
+
+**功能**:
+- 销毁 WPS 文档实例
+- 释放 WPS 服务器资源
+- 清理临时文件
+- 断开与 WPS 服务器的连接
+
+**返回**:
+- Promise,成功时 resolve,失败时 reject
+
+### 调用示例
+
+```typescript
+// 基本用法
+await wpsInstance.destroy();
+
+// 带错误处理
+try {
+  await wpsInstance.destroy();
+  console.log('文档已销毁');
+} catch (err) {
+  console.error('销毁失败:', err);
+}
+
+// 检查方法是否存在
+if (wpsInstance.destroy) {
+  await wpsInstance.destroy();
+} else {
+  console.warn('destroy 方法不存在');
+}
+```
+
+## 使用场景
+
+### 场景 1:审核完成后结束编辑
+```
+审核文档
+    ↓
+提交审核结果
+    ↓
+点击"结束编辑"
+    ↓
+销毁 WPS 文档
+    ↓
+关闭对话框
+```
+
+### 场景 2:取消编辑
+```
+打开文档编辑
+    ↓
+发现不需要编辑
+    ↓
+点击"结束编辑"
+    ↓
+销毁 WPS 文档
+    ↓
+关闭对话框
+```
+
+### 场景 3:释放资源
+```
+长时间编辑
+    ↓
+暂时不需要编辑
+    ↓
+点击"结束编辑"
+    ↓
+释放 WPS 服务器资源
+    ↓
+需要时重新打开
+```
+
+## 确认对话框
+
+```
+⚠️ 结束编辑
+
+确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。
+
+[取消]  [确定]
+```
+
+**重要提示**:
+- 销毁文档后无法恢复
+- 未保存的修改将丢失
+- 建议先保存文档
+
+## 加载提示
+
+```
+ℹ️ 正在结束编辑...
+```
+
+**显示时机**:
+- 点击确定后立即显示
+- 持续显示直到操作完成
+- 不自动关闭
+- 不显示关闭按钮
+
+## 成功提示
+
+```
+✅ 编辑已结束,文档已销毁
+```
+
+**显示时机**:
+- 操作成功完成后
+- 自动关闭(3秒)
+- 1秒后关闭对话框
+
+## 错误处理
+
+### 1. 编辑器未初始化
+```typescript
+if (!wpsInstance) {
+  ElMessage.warning('WPS 编辑器未初始化');
+  return;
+}
+```
+
+### 2. destroy 方法不存在
+```typescript
+if (wpsInstance.destroy) {
+  await wpsInstance.destroy();
+} else {
+  console.warn('destroy 方法不存在');
+}
+```
+
+### 3. 销毁失败
+```typescript
+catch (err) {
+  console.error('[WPS] 结束编辑失败:', err);
+  
+  // 即使失败也尝试清空实例
+  wpsInstance = null;
+  
+  ElMessage.warning('结束编辑可能未完全成功: ' + err.message);
+  
+  // 仍然关闭对话框
+  setTimeout(() => {
+    dialogVisible.value = false;
+  }, 1000);
+}
+```
+
+### 4. 用户取消
+```typescript
+try {
+  await ElMessageBox.confirm(...);
+} catch {
+  // 用户点击取消,直接返回
+  return;
+}
+```
+
+## 与关闭对话框的区别
+
+### 关闭对话框(点击 X 或取消)
+```typescript
+watch(dialogVisible, (val) => {
+  if (!val) {
+    // 销毁编辑器
+    destroyWpsEditor();
+  }
+});
+```
+
+**行为**:
+- 调用 `destroyWpsEditor()` 函数
+- 清理前端编辑器实例
+- 但 WPS 服务器可能仍保留文档
+
+### 结束编辑(点击"结束编辑"按钮)
+```typescript
+const handleEndEdit = async () => {
+  // 调用 WPS SDK 的 destroy 方法
+  await wpsInstance.destroy();
+  
+  // 清空实例
+  wpsInstance = null;
+  
+  // 关闭对话框
+  dialogVisible.value = false;
+};
+```
+
+**行为**:
+- 调用 WPS SDK 的 `destroy()` 方法
+- 通知 WPS 服务器销毁文档
+- 释放服务器资源
+- 清理临时文件
+- 然后关闭对话框
+
+## 性能考虑
+
+### 1. 操作时间
+```
+调用 destroy():1-2 秒
+清空实例:< 0.1 秒
+关闭对话框:1 秒延迟
+总计:2-3 秒
+```
+
+### 2. 资源释放
+- ✅ WPS 服务器资源
+- ✅ 临时文件
+- ✅ 网络连接
+- ✅ 内存占用
+
+### 3. 优化建议
+
+**减少延迟**:
+```typescript
+// 不需要等待 1 秒
+// setTimeout(() => {
+//   dialogVisible.value = false;
+// }, 1000);
+
+// 立即关闭
+dialogVisible.value = false;
+```
+
+**并行操作**:
+```typescript
+// 同时销毁和显示提示
+await Promise.all([
+  wpsInstance.destroy(),
+  ElMessage.success('编辑已结束')
+]);
+```
+
+## 调试日志
+
+```
+[WPS] 开始结束编辑
+[WPS] 调用 destroy 方法
+[WPS] destroy 方法调用成功
+[WPS] 结束编辑成功
+```
+
+**失败日志**:
+```
+[WPS] 开始结束编辑
+[WPS] 调用 destroy 方法
+[WPS] 结束编辑失败: Error: ...
+```
+
+## 常见问题
+
+### Q1: 结束编辑后还能重新打开吗?
+**答**:可以,重新打开对话框会重新初始化 WPS 编辑器。
+
+### Q2: 未保存的修改会丢失吗?
+**答**:是的,结束编辑会销毁文档,未保存的修改将丢失。
+
+### Q3: 结束编辑和关闭对话框有什么区别?
+**答**:
+- **结束编辑**:调用 WPS SDK 销毁文档,释放服务器资源
+- **关闭对话框**:只清理前端实例,服务器可能仍保留文档
+
+### Q4: 结束编辑需要多长时间?
+**答**:通常 2-3 秒,取决于网络速度。
+
+### Q5: 结束编辑失败怎么办?
+**答**:即使失败,也会清空前端实例并关闭对话框。
+
+### Q6: 可以撤销结束编辑吗?
+**答**:不可以,销毁后无法恢复。
+
+## 安全考虑
+
+### 1. 确认对话框
+防止误操作:
+```typescript
+await ElMessageBox.confirm(
+  '确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。',
+  '结束编辑',
+  { type: 'warning' }
+);
+```
+
+### 2. 状态检查
+确保编辑器已初始化:
+```typescript
+if (!wpsInstance) {
+  ElMessage.warning('WPS 编辑器未初始化');
+  return;
+}
+```
+
+### 3. 错误容忍
+即使失败也清理资源:
+```typescript
+catch (err) {
+  // 即使失败也尝试清空实例
+  wpsInstance = null;
+  
+  // 仍然关闭对话框
+  dialogVisible.value = false;
+}
+```
+
+## 总结
+
+### 核心功能
+1. ✅ **一键结束**:点击按钮即可
+2. ✅ **安全确认**:防止误操作
+3. ✅ **释放资源**:调用 WPS SDK 销毁文档
+4. ✅ **自动关闭**:操作完成后关闭对话框
+
+### 实现步骤
+```
+1. 确认操作
+2. 调用 wpsInstance.destroy()
+3. 清空实例
+4. 关闭对话框
+```
+
+### 用户操作
+```
+点击"结束编辑" → 确认 → 等待 2-3 秒 → 对话框关闭
+```
+
+### 优势
+- ✅ 释放 WPS 服务器资源
+- ✅ 清理临时文件
+- ✅ 防止资源泄漏
+- ✅ 用户体验好
+
+现在用户可以通过"结束编辑"按钮主动销毁 WPS 文档,释放服务器资源!