Ver Fonte

在线文档编辑初步成型

Huanyi há 3 meses atrás
pai
commit
eb0d93eaee

+ 382 - 0
.kiro/specs/backend-api-requirements.md

@@ -0,0 +1,382 @@
+# 后端 WPS 回调接口需求文档
+
+## 问题分析
+
+### 当前错误
+```
+init error: AppInfoNotExists won't start session
+GET https://o.wpsgo.com/api/v3/office/file/28/multiwatermark 403 (Forbidden)
+```
+
+### 根本原因
+WPS SDK 在初始化时会:
+1. 向 WPS 服务器验证 appId
+2. WPS 服务器会回调我们的后端接口获取文件信息
+3. 如果回调失败,返回 AppInfoNotExists 错误
+
+**结论**:必须实现后端回调接口,WPS SDK 才能正常工作。
+
+## 必需的后端接口
+
+### 1. 文件信息接口(最重要)
+
+**接口地址**:
+```
+GET /v1/3rd/file/info
+```
+
+**请求参数**(Query):
+- `_w_appid`: WPS 应用ID(SX20251229FLIAPDAPP)
+- `_w_fileid`: 文件ID(前端传递的 fileId)
+
+**请求头**:
+```
+X-WebOffice-Token: {前端传递的 token}
+X-User-Query: {前端传递的 customArgs,JSON 格式}
+```
+
+**响应格式**:
+```json
+{
+  "code": 0,
+  "msg": "success",
+  "data": {
+    "file": {
+      "id": "28",
+      "name": "万颗星临床营养系统接口文档.pdf",
+      "version": 1,
+      "size": 1234567,
+      "download_url": "http://img.tpidea.cn/2025/12/29/d94280895eac4a499da425a3564c69b7.pdf",
+      "creator": {
+        "id": "user_1",
+        "name": "张三"
+      },
+      "create_time": 1735459200,
+      "modify_time": 1735459200,
+      "user_acl": {
+        "rename": 1,
+        "history": 1,
+        "copy": 1,
+        "export": 1,
+        "print": 1,
+        "read": 1,
+        "update": 1,
+        "comment": 1
+      }
+    }
+  }
+}
+```
+
+**字段说明**:
+- `id`: 文件ID,必须与请求的 `_w_fileid` 一致
+- `name`: 文件名
+- `version`: 文件版本号,从 1 开始
+- `size`: 文件大小(字节)
+- `download_url`: 文件下载地址,必须是可访问的 HTTP/HTTPS URL
+- `creator`: 创建者信息
+- `create_time`: 创建时间(Unix 时间戳,秒)
+- `modify_time`: 修改时间(Unix 时间戳,秒)
+- `user_acl`: 用户权限配置
+  - `rename`: 是否允许重命名(1=允许,0=不允许)
+  - `history`: 是否允许查看历史版本
+  - `copy`: 是否允许复制
+  - `export`: 是否允许导出
+  - `print`: 是否允许打印
+  - `read`: 是否允许查看(必须为 1)
+  - `update`: 是否允许编辑(1=允许,0=只读)
+  - `comment`: 是否允许批注
+
+### 2. WPS 应用配置
+
+**在 WPS 开放平台控制台配置**:
+1. 登录:https://open.wps.cn/
+2. 进入应用管理
+3. 找到应用:SX20251229FLIAPDAPP
+4. 配置回调地址:
+   - 文件信息接口:`https://你的域名/v1/3rd/file/info`
+   - 保存接口:`https://你的域名/v3/3rd/files`
+
+### 3. 三阶段保存接口(可选,用于编辑保存)
+
+#### 阶段 1:准备上传
+```
+GET /v3/3rd/files/:file_id/upload/prepare
+```
+
+**响应**:
+```json
+{
+  "code": 0,
+  "data": {
+    "digest_types": ["sha1", "md5"]
+  }
+}
+```
+
+#### 阶段 2:获取上传地址
+```
+POST /v3/3rd/files/:file_id/upload/address
+```
+
+**请求体**:
+```json
+{
+  "name": "文档.pdf",
+  "size": 1234567,
+  "digest": {
+    "sha1": "abc123..."
+  },
+  "is_manual": true
+}
+```
+
+**响应**:
+```json
+{
+  "code": 0,
+  "data": {
+    "method": "PUT",
+    "url": "https://your-oss.com/upload/file123",
+    "headers": {},
+    "send_back_params": {}
+  }
+}
+```
+
+#### 阶段 3:上传完成通知
+```
+POST /v3/3rd/files/:file_id/upload/complete
+```
+
+**请求体**:
+```json
+{
+  "request": {
+    "name": "文档.pdf",
+    "size": 1234567,
+    "digest": { "sha1": "abc123..." },
+    "is_manual": true
+  },
+  "response": {
+    "status_code": 200,
+    "headers": {}
+  }
+}
+```
+
+**响应**:
+```json
+{
+  "code": 0,
+  "data": {
+    "id": "28",
+    "name": "文档.pdf",
+    "version": 2,
+    "size": 1234567,
+    "create_time": 1735459200,
+    "modify_time": 1735459300,
+    "creator_id": "user_1",
+    "modifier_id": "user_1"
+  }
+}
+```
+
+## 实现优先级
+
+### P0(必须实现,否则无法使用)
+- ✅ 文件信息接口:`GET /v1/3rd/file/info`
+- ✅ WPS 控制台配置回调地址
+
+### P1(编辑功能需要)
+- 三阶段保存接口(如果只需要查看,可以暂不实现)
+
+## 快速实现示例(Node.js/Express)
+
+```javascript
+// 文件信息接口
+app.get('/v1/3rd/file/info', async (req, res) => {
+  const { _w_appid, _w_fileid } = req.query;
+  
+  // 验证 appId
+  if (_w_appid !== 'SX20251229FLIAPDAPP') {
+    return res.status(403).json({ code: 403, msg: 'Invalid appId' });
+  }
+  
+  // 从数据库获取文件信息
+  const document = await getDocumentById(_w_fileid);
+  
+  if (!document) {
+    return res.status(404).json({ code: 404, msg: 'File not found' });
+  }
+  
+  // 返回文件信息
+  res.json({
+    code: 0,
+    msg: 'success',
+    data: {
+      file: {
+        id: document.id.toString(),
+        name: document.fileName,
+        version: document.version || 1,
+        size: document.fileSize || 0,
+        download_url: document.url,
+        creator: {
+          id: document.creatorId?.toString() || 'user_1',
+          name: document.creatorName || '未知用户'
+        },
+        create_time: Math.floor(new Date(document.createTime).getTime() / 1000),
+        modify_time: Math.floor(new Date(document.updateTime).getTime() / 1000),
+        user_acl: {
+          rename: 1,
+          history: 1,
+          copy: 1,
+          export: 1,
+          print: 1,
+          read: 1,
+          update: 1,  // 1=可编辑,0=只读
+          comment: 1
+        }
+      }
+    }
+  });
+});
+```
+
+## 快速实现示例(Java/Spring Boot)
+
+```java
+@RestController
+@RequestMapping("/v1/3rd/file")
+public class WpsFileController {
+    
+    @Autowired
+    private DocumentService documentService;
+    
+    @GetMapping("/info")
+    public ResponseEntity<?> getFileInfo(
+        @RequestParam("_w_appid") String appId,
+        @RequestParam("_w_fileid") String fileId
+    ) {
+        // 验证 appId
+        if (!"SX20251229FLIAPDAPP".equals(appId)) {
+            return ResponseEntity.status(403)
+                .body(Map.of("code", 403, "msg", "Invalid appId"));
+        }
+        
+        // 获取文件信息
+        Document document = documentService.getById(Long.parseLong(fileId));
+        
+        if (document == null) {
+            return ResponseEntity.status(404)
+                .body(Map.of("code", 404, "msg", "File not found"));
+        }
+        
+        // 构建响应
+        Map<String, Object> response = new HashMap<>();
+        response.put("code", 0);
+        response.put("msg", "success");
+        
+        Map<String, Object> fileInfo = new HashMap<>();
+        fileInfo.put("id", document.getId().toString());
+        fileInfo.put("name", document.getFileName());
+        fileInfo.put("version", document.getVersion() != null ? document.getVersion() : 1);
+        fileInfo.put("size", document.getFileSize() != null ? document.getFileSize() : 0);
+        fileInfo.put("download_url", document.getUrl());
+        
+        Map<String, Object> creator = new HashMap<>();
+        creator.put("id", document.getCreatorId() != null ? document.getCreatorId().toString() : "user_1");
+        creator.put("name", document.getCreatorName() != null ? document.getCreatorName() : "未知用户");
+        fileInfo.put("creator", creator);
+        
+        fileInfo.put("create_time", document.getCreateTime().getTime() / 1000);
+        fileInfo.put("modify_time", document.getUpdateTime().getTime() / 1000);
+        
+        Map<String, Integer> userAcl = new HashMap<>();
+        userAcl.put("rename", 1);
+        userAcl.put("history", 1);
+        userAcl.put("copy", 1);
+        userAcl.put("export", 1);
+        userAcl.put("print", 1);
+        userAcl.put("read", 1);
+        userAcl.put("update", 1);  // 1=可编辑,0=只读
+        userAcl.put("comment", 1);
+        fileInfo.put("user_acl", userAcl);
+        
+        response.put("data", Map.of("file", fileInfo));
+        
+        return ResponseEntity.ok(response);
+    }
+}
+```
+
+## 测试方法
+
+### 1. 使用 Postman 测试
+```
+GET http://localhost:8080/v1/3rd/file/info?_w_appid=SX20251229FLIAPDAPP&_w_fileid=28
+```
+
+### 2. 使用 curl 测试
+```bash
+curl "http://localhost:8080/v1/3rd/file/info?_w_appid=SX20251229FLIAPDAPP&_w_fileid=28"
+```
+
+### 3. 检查响应
+确保响应格式完全符合上面的 JSON 格式。
+
+## 常见问题
+
+### Q1: 仍然报 AppInfoNotExists
+**原因**:
+1. 后端接口未实现
+2. 接口地址配置错误
+3. 接口返回格式不正确
+4. WPS 控制台未配置回调地址
+
+**解决**:
+1. 确认接口已实现并可访问
+2. 检查 WPS 控制台的回调地址配置
+3. 使用 Postman 测试接口返回格式
+
+### Q2: 403 Forbidden
+**原因**:
+1. appId 验证失败
+2. 文件不存在
+3. 权限不足
+
+**解决**:
+1. 检查 appId 是否正确
+2. 检查 fileId 对应的文件是否存在
+3. 检查用户权限
+
+### Q3: 文件无法显示
+**原因**:
+1. download_url 无法访问
+2. 文件格式不支持
+3. 文件损坏
+
+**解决**:
+1. 确保 download_url 可以直接访问
+2. 检查文件格式是否正确
+3. 尝试直接下载文件验证
+
+## 下一步行动
+
+### 立即执行
+1. 实现 `GET /v1/3rd/file/info` 接口
+2. 使用 Postman 测试接口
+3. 在 WPS 控制台配置回调地址
+
+### 验证步骤
+1. 重启后端服务
+2. 刷新前端页面
+3. 打开审核对话框
+4. 检查是否还有 AppInfoNotExists 错误
+5. 验证文档是否正常显示
+
+## 参考资料
+
+- [WPS 开放平台](https://open.wps.cn/)
+- [文件信息接口文档](https://solution.wps.cn/docs/callback/file.html)
+- [三阶段保存文档](https://solution.wps.cn/docs/callback/save.html)

+ 553 - 0
.kiro/specs/frontend-implementation-plan.md

@@ -0,0 +1,553 @@
+# 前端实现方案 - WPS SDK 集成(无回调处理)
+
+## 核心原则
+
+**前端职责**:
+- ✅ 初始化 WPS SDK
+- ✅ 配置编辑器参数
+- ✅ 处理用户交互
+- ✅ 显示加载和错误状态
+
+**后端职责**:
+- ✅ 实现所有 WPS 回调接口
+- ✅ 处理文件上传和存储
+- ✅ 管理文件版本
+- ✅ 处理三阶段保存
+
+## 前端实现清单
+
+### 1. 移除前端回调处理器
+
+**删除文件**:
+- `src/utils/wpsCallback.ts` - 不需要前端拦截请求
+
+**原因**:
+- WPS SDK 会直接调用后端接口
+- 前端不应该拦截和模拟这些请求
+- 所有回调逻辑由后端处理
+
+### 2. 简化 WPS 初始化配置
+
+**标准初始化方式**:
+```typescript
+// src/components/DocumentAuditDialog/index.vue
+
+const initWpsEditor = async () => {
+  if (!wpsContainerRef.value || !props.document?.ossId) {
+    return;
+  }
+
+  try {
+    wpsLoading.value = true;
+    wpsError.value = '';
+
+    // 检查 SDK
+    if (!(window as any).WebOfficeSDK) {
+      wpsError.value = 'WPS SDK 未加载';
+      wpsLoading.value = false;
+      return;
+    }
+
+    const WebOfficeSDK = (window as any).WebOfficeSDK;
+    
+    // 获取文件类型
+    const officeType = getFileType(props.document.fileName || '');
+    
+    // 生成文件 ID
+    const fileId = `${props.document.id}`;
+    
+    // 标准初始化配置(按官方文档)
+    const config = {
+      // 必需参数
+      appId: 'SX20251229FLIAPDAPP',
+      officeType: officeType,
+      fileId: fileId,
+      
+      // 可选参数
+      mount: wpsContainerRef.value,
+      
+      // 自定义参数(会传递到后端回调接口)
+      customArgs: {
+        documentId: props.document.id,
+        fileName: props.document.fileName,
+        fileUrl: props.document.url,
+        userId: userStore.userId,
+        userName: userStore.userName
+      }
+    };
+
+    console.log('[WPS] 初始化配置:', config);
+
+    // 初始化编辑器
+    wpsInstance = WebOfficeSDK.init(config);
+    
+    wpsLoading.value = false;
+    console.log('[WPS] 编辑器初始化成功');
+    
+  } catch (err: any) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+  }
+};
+```
+
+### 3. 文件类型映射
+
+```typescript
+// 获取 WPS 文件类型
+const getFileType = (fileName: string): string => {
+  const name = fileName.toLowerCase();
+  
+  // Word 文档
+  if (name.endsWith('.docx') || name.endsWith('.doc')) {
+    return 'w';
+  }
+  
+  // Excel 表格
+  if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
+    return 's';
+  }
+  
+  // PowerPoint 演示
+  if (name.endsWith('.pptx') || name.endsWith('.ppt')) {
+    return 'p';
+  }
+  
+  // PDF 文档
+  if (name.endsWith('.pdf')) {
+    return 'f';
+  }
+  
+  // 默认为 Word
+  return 'w';
+};
+```
+
+### 4. 保存文档(可选)
+
+```typescript
+// 手动保存文档
+const saveWpsDocument = async () => {
+  if (!wpsInstance) {
+    ElMessage.warning('编辑器未初始化');
+    return null;
+  }
+
+  try {
+    console.log('[WPS] 开始保存文档');
+    
+    // 调用 SDK 的 save 方法
+    // WPS SDK 会自动调用后端的三阶段保存接口
+    const result = await wpsInstance.save();
+    
+    console.log('[WPS] 保存成功:', result);
+    ElMessage.success('文档已保存');
+    
+    return result;
+  } catch (err: any) {
+    console.error('[WPS] 保存失败:', err);
+    ElMessage.error('保存失败: ' + err.message);
+    throw err;
+  }
+};
+```
+
+### 5. 销毁编辑器
+
+```typescript
+// 销毁 WPS 编辑器
+const destroyWpsEditor = () => {
+  if (wpsInstance) {
+    try {
+      if (wpsInstance.destroy) {
+        wpsInstance.destroy();
+      }
+      wpsInstance = null;
+      console.log('[WPS] 编辑器已销毁');
+    } catch (err) {
+      console.error('[WPS] 销毁编辑器失败:', err);
+    }
+  }
+};
+
+// 组件卸载时清理
+onBeforeUnmount(() => {
+  destroyWpsEditor();
+});
+```
+
+### 6. 错误处理和降级
+
+```typescript
+// 降级到 iframe 预览
+const fallbackToIframe = () => {
+  wpsError.value = 'WPS 编辑器加载失败,已切换到预览模式';
+  // 模板中已有 iframe 降级方案
+};
+
+// 初始化时的错误处理
+const initWpsEditor = async () => {
+  try {
+    // ... 初始化代码
+  } catch (err: any) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+    
+    // 如果有 URL,降级到 iframe
+    if (props.document?.url) {
+      fallbackToIframe();
+    }
+  }
+};
+```
+
+## 完整的组件代码结构
+
+```vue
+<template>
+  <el-dialog v-model="dialogVisible" title="文档审核" width="1200px">
+    <div class="audit-container">
+      <!-- 左侧:WPS 编辑器 -->
+      <div class="preview-section">
+        <div class="preview-header">
+          <div class="file-info">
+            <el-icon class="file-icon"><Document /></el-icon>
+            <span class="file-name">{{ document?.fileName }}</span>
+          </div>
+        </div>
+        
+        <div class="preview-container">
+          <!-- WPS 编辑器容器 -->
+          <div v-if="document?.ossId" ref="wpsContainerRef" class="wps-container">
+            <!-- 降级方案:iframe 预览 -->
+            <iframe 
+              v-if="wpsError && document?.url"
+              :src="document.url"
+              class="document-iframe"
+              frameborder="0"
+            ></iframe>
+          </div>
+          
+          <!-- 加载状态 -->
+          <div v-if="wpsLoading" class="loading-state">
+            <el-icon class="is-loading"><Loading /></el-icon>
+            <p>正在加载编辑器...</p>
+          </div>
+          
+          <!-- 错误状态 -->
+          <div v-if="wpsError && !document?.url" class="error-state">
+            <el-result icon="error" title="加载失败" :sub-title="wpsError">
+              <template #extra>
+                <el-button type="primary" @click="initWpsEditor">重新加载</el-button>
+              </template>
+            </el-result>
+          </div>
+          
+          <!-- 空状态 -->
+          <el-empty v-if="!document?.ossId" description="暂无文档" />
+        </div>
+      </div>
+      
+      <!-- 右侧:审核表单 -->
+      <div class="audit-section">
+        <!-- 审核表单内容 -->
+      </div>
+    </div>
+    
+    <template #footer>
+      <el-button @click="handleCancel">取消</el-button>
+      <el-button type="primary" @click="submitForm" :loading="loading">
+        提交审核
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
+import { ElMessage } from 'element-plus';
+import { useUserStore } from '@/store/modules/user';
+
+// Props 和 Emits
+interface Props {
+  modelValue: boolean;
+  document?: Document | null;
+  auditApi: (data: AuditData) => Promise<any>;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+// 状态
+const userStore = useUserStore();
+const dialogVisible = ref(false);
+const wpsContainerRef = ref<HTMLDivElement>();
+const wpsLoading = ref(false);
+const wpsError = ref('');
+let wpsInstance: any = null;
+
+// WPS 配置
+const WPS_APP_ID = 'SX20251229FLIAPDAPP';
+
+// 获取文件类型
+const getFileType = (fileName: string): string => {
+  const name = fileName.toLowerCase();
+  if (name.endsWith('.docx') || name.endsWith('.doc')) return 'w';
+  if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 's';
+  if (name.endsWith('.pptx') || name.endsWith('.ppt')) return 'p';
+  if (name.endsWith('.pdf')) return 'f';
+  return 'w';
+};
+
+// 初始化 WPS 编辑器
+const initWpsEditor = async () => {
+  if (!wpsContainerRef.value || !props.document?.ossId) return;
+
+  try {
+    wpsLoading.value = true;
+    wpsError.value = '';
+
+    if (!(window as any).WebOfficeSDK) {
+      wpsError.value = 'WPS SDK 未加载';
+      wpsLoading.value = false;
+      return;
+    }
+
+    const WebOfficeSDK = (window as any).WebOfficeSDK;
+    const officeType = getFileType(props.document.fileName || '');
+    const fileId = `${props.document.id}`;
+    
+    // 标准配置
+    const config = {
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+      mount: wpsContainerRef.value,
+      customArgs: {
+        documentId: props.document.id,
+        fileName: props.document.fileName,
+        fileUrl: props.document.url,
+        userId: userStore.userId,
+        userName: userStore.userName
+      }
+    };
+
+    console.log('[WPS] 初始化配置:', config);
+    wpsInstance = WebOfficeSDK.init(config);
+    wpsLoading.value = false;
+    console.log('[WPS] 编辑器初始化成功');
+    
+  } catch (err: any) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+  }
+};
+
+// 保存文档
+const saveWpsDocument = async () => {
+  if (!wpsInstance) return null;
+  
+  try {
+    console.log('[WPS] 开始保存文档');
+    const result = await wpsInstance.save();
+    console.log('[WPS] 保存成功:', result);
+    ElMessage.success('文档已保存');
+    return result;
+  } catch (err: any) {
+    console.error('[WPS] 保存失败:', err);
+    ElMessage.error('保存失败');
+    throw err;
+  }
+};
+
+// 销毁编辑器
+const destroyWpsEditor = () => {
+  if (wpsInstance) {
+    try {
+      if (wpsInstance.destroy) {
+        wpsInstance.destroy();
+      }
+      wpsInstance = null;
+      console.log('[WPS] 编辑器已销毁');
+    } catch (err) {
+      console.error('[WPS] 销毁编辑器失败:', err);
+    }
+  }
+};
+
+// 监听对话框打开
+watch(() => props.modelValue, (val) => {
+  dialogVisible.value = val;
+  if (val && props.document?.ossId) {
+    nextTick(() => {
+      initWpsEditor();
+    });
+  }
+});
+
+// 监听对话框关闭
+watch(dialogVisible, (val) => {
+  emit('update:modelValue', val);
+  if (!val) {
+    destroyWpsEditor();
+  }
+});
+
+// 提交审核
+const submitForm = async () => {
+  // 可选:提交前保存文档
+  if (wpsInstance) {
+    try {
+      await saveWpsDocument();
+    } catch (err) {
+      console.error('保存文档失败:', err);
+      // 继续提交审核
+    }
+  }
+  
+  // 提交审核逻辑...
+};
+
+// 组件卸载
+onBeforeUnmount(() => {
+  destroyWpsEditor();
+});
+</script>
+```
+
+## 需要删除的代码
+
+### 1. 删除回调处理器文件
+```bash
+# 删除这个文件
+src/utils/wpsCallback.ts
+```
+
+### 2. 删除组件中的回调相关代码
+```typescript
+// 删除这些导入
+import { setWpsFileInfo, clearWpsFileInfo } from '@/utils/wpsCallback';
+
+// 删除这些调用
+setWpsFileInfo(fileId, { ... });
+clearWpsFileInfo(fileId);
+```
+
+## 后端需要实现的接口
+
+### 1. 文件信息接口
+```
+GET /v1/3rd/file/info?_w_appid={appId}&_w_fileid={fileId}
+```
+
+### 2. 三阶段保存接口
+```
+GET  /v3/3rd/files/:file_id/upload/prepare
+POST /v3/3rd/files/:file_id/upload/address
+POST /v3/3rd/files/:file_id/upload/complete
+```
+
+### 3. 其他可能需要的接口
+```
+GET  /v1/3rd/file/version    # 文件版本列表
+POST /v1/3rd/file/rename     # 文件重命名
+POST /v1/3rd/file/copy       # 文件复制
+```
+
+## 前端配置要点
+
+### 1. customArgs 参数说明
+
+```typescript
+customArgs: {
+  // 这些参数会通过 X-User-Query 请求头传递到后端
+  documentId: props.document.id,      // 文档ID
+  fileName: props.document.fileName,  // 文件名
+  fileUrl: props.document.url,        // 文件URL
+  userId: userStore.userId,           // 用户ID
+  userName: userStore.userName        // 用户名
+}
+```
+
+**注意**:
+- 不要使用保留字段:`type`, `version`, `mode`, `history_id`, `share_id`
+- 建议使用业务前缀,如:`doc_id`, `doc_name`
+
+### 2. fileId 生成规则
+
+```typescript
+// 简单方式:使用文档ID
+const fileId = `${props.document.id}`;
+
+// 或者:使用文档ID + 时间戳(确保唯一性)
+const fileId = `${props.document.id}_${Date.now()}`;
+
+// 或者:使用 UUID
+import { v4 as uuidv4 } from 'uuid';
+const fileId = uuidv4();
+```
+
+**建议**:使用文档ID,便于后端关联
+
+### 3. officeType 映射
+
+```typescript
+const FILE_TYPE_MAP: Record<string, string> = {
+  'doc': 'w',   'docx': 'w',   // Word
+  'xls': 's',   'xlsx': 's',   // Excel  
+  'ppt': 'p',   'pptx': 'p',   // PowerPoint
+  'pdf': 'f'                   // PDF
+};
+```
+
+## 测试清单
+
+### 前端测试
+- [ ] SDK 加载成功
+- [ ] 编辑器初始化成功
+- [ ] 文档正确显示
+- [ ] 可以编辑文档
+- [ ] 保存按钮可用
+- [ ] 错误提示正确
+- [ ] 降级方案生效
+
+### 集成测试(需要后端配合)
+- [ ] 文件信息接口返回正确
+- [ ] 保存功能正常
+- [ ] 版本号正确递增
+- [ ] 文件上传成功
+- [ ] 审核流程完整
+
+## 常见问题
+
+### Q1: AppInfoNotExists 错误
+**原因**:后端接口未实现或配置错误  
+**解决**:等待后端实现回调接口
+
+### Q2: 编辑器加载失败
+**原因**:SDK 文件未加载或网络问题  
+**解决**:检查 `public/web-office-sdk.js` 是否存在
+
+### Q3: 保存失败
+**原因**:后端三阶段保存接口未实现  
+**解决**:等待后端实现保存接口
+
+### Q4: 文档不显示
+**原因**:fileId 或 officeType 不正确  
+**解决**:检查参数配置和文件类型映射
+
+## 总结
+
+前端实现非常简单:
+1. ✅ 引入 WPS SDK
+2. ✅ 调用 `WebOfficeSDK.init()` 初始化
+3. ✅ 传递正确的参数
+4. ✅ 处理加载和错误状态
+5. ❌ **不需要**处理任何回调接口
+6. ❌ **不需要**拦截请求
+7. ❌ **不需要**实现三阶段保存
+
+所有复杂的逻辑都由后端处理!

+ 0 - 0
.kiro/specs/quick-start-guide.md


+ 0 - 0
.kiro/specs/wps-three-phase-save/requirements.md


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

@@ -0,0 +1,339 @@
+# 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 自带的批注工具手动添加!

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

@@ -0,0 +1,258 @@
+# 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 预览** → 简单快速,只能查看
+
+告诉我你的选择,我会帮你完成相应的实现!

+ 212 - 208
index.html

@@ -1,215 +1,219 @@
 <!doctype html>
 <html>
-  <head>
-    <meta charset="utf-8" />
-    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
-    <meta name="renderer" content="webkit" />
-    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
-<!--    <link rel="icon" href="/favicon.ico" />-->
-    <link rel="icon" href="/?" />
-    <title>%VITE_APP_TITLE%</title>
-    <!--[if lt IE 11
+
+<head>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+  <meta name="renderer" content="webkit" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+  <!--    <link rel="icon" href="/favicon.ico" />-->
+  <link rel="icon" href="/?" />
+  <title>%VITE_APP_TITLE%</title>
+  <!-- WPS WebOffice SDK (本地版本 v2.0.7) -->
+  <script src="/web-office-sdk.js"></script>
+  <!--[if lt IE 11
       ]><script>
         window.location.href = '/html/ie.html';
       </script><!
     [endif]-->
-    <style>
-      html,
-      body,
-      #app {
-        height: 100%;
-        margin: 0px;
-        padding: 0px;
-      }
-
-      .chromeframe {
-        margin: 0.2em 0;
-        background: #ccc;
-        color: #000;
-        padding: 0.2em 0;
-      }
-
-      #loader-wrapper {
-        position: fixed;
-        top: 0;
-        left: 0;
-        width: 100%;
-        height: 100%;
-        z-index: 999999;
-      }
-
-      #loader {
-        display: block;
-        position: relative;
-        left: 50%;
-        top: 50%;
-        width: 150px;
-        height: 150px;
-        margin: -75px 0 0 -75px;
-        border-radius: 50%;
-        border: 3px solid transparent;
-        border-top-color: #fff;
-        -webkit-animation: spin 2s linear infinite;
-        -ms-animation: spin 2s linear infinite;
-        -moz-animation: spin 2s linear infinite;
-        -o-animation: spin 2s linear infinite;
-        animation: spin 2s linear infinite;
-        z-index: 1001;
-      }
-
-      #loader:before {
-        content: '';
-        position: absolute;
-        top: 5px;
-        left: 5px;
-        right: 5px;
-        bottom: 5px;
-        border-radius: 50%;
-        border: 3px solid transparent;
-        border-top-color: #fff;
-        -webkit-animation: spin 3s linear infinite;
-        -moz-animation: spin 3s linear infinite;
-        -o-animation: spin 3s linear infinite;
-        -ms-animation: spin 3s linear infinite;
-        animation: spin 3s linear infinite;
-      }
-
-      #loader:after {
-        content: '';
-        position: absolute;
-        top: 15px;
-        left: 15px;
-        right: 15px;
-        bottom: 15px;
-        border-radius: 50%;
-        border: 3px solid transparent;
-        border-top-color: #fff;
-        -moz-animation: spin 1.5s linear infinite;
-        -o-animation: spin 1.5s linear infinite;
-        -ms-animation: spin 1.5s linear infinite;
-        -webkit-animation: spin 1.5s linear infinite;
-        animation: spin 1.5s linear infinite;
-      }
-
-      @-webkit-keyframes spin {
-        0% {
-          -webkit-transform: rotate(0deg);
-          -ms-transform: rotate(0deg);
-          transform: rotate(0deg);
-        }
-
-        100% {
-          -webkit-transform: rotate(360deg);
-          -ms-transform: rotate(360deg);
-          transform: rotate(360deg);
-        }
-      }
-
-      @keyframes spin {
-        0% {
-          -webkit-transform: rotate(0deg);
-          -ms-transform: rotate(0deg);
-          transform: rotate(0deg);
-        }
-
-        100% {
-          -webkit-transform: rotate(360deg);
-          -ms-transform: rotate(360deg);
-          transform: rotate(360deg);
-        }
-      }
-
-      #loader-wrapper .loader-section {
-        position: fixed;
-        top: 0;
-        width: 51%;
-        height: 100%;
-        background: #7171c6;
-        z-index: 1000;
-        -webkit-transform: translateX(0);
-        -ms-transform: translateX(0);
-        transform: translateX(0);
-      }
-
-      #loader-wrapper .loader-section.section-left {
-        left: 0;
-      }
-
-      #loader-wrapper .loader-section.section-right {
-        right: 0;
-      }
-
-      .loaded #loader-wrapper .loader-section.section-left {
-        -webkit-transform: translateX(-100%);
-        -ms-transform: translateX(-100%);
-        transform: translateX(-100%);
-        -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-        transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-      }
-
-      .loaded #loader-wrapper .loader-section.section-right {
-        -webkit-transform: translateX(100%);
-        -ms-transform: translateX(100%);
-        transform: translateX(100%);
-        -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-        transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
-      }
-
-      .loaded #loader {
-        opacity: 0;
-        -webkit-transition: all 0.3s ease-out;
-        transition: all 0.3s ease-out;
-      }
-
-      .loaded #loader-wrapper {
-        visibility: hidden;
-        -webkit-transform: translateY(-100%);
-        -ms-transform: translateY(-100%);
-        transform: translateY(-100%);
-        -webkit-transition: all 0.3s 1s ease-out;
-        transition: all 0.3s 1s ease-out;
-      }
-
-      .no-js #loader-wrapper {
-        display: none;
-      }
-
-      .no-js h1 {
-        color: #222222;
-      }
-
-      #loader-wrapper .load_title {
-        font-family: 'Open Sans';
-        color: #fff;
-        font-size: 19px;
-        width: 100%;
-        text-align: center;
-        z-index: 9999999999999;
-        position: absolute;
-        top: 60%;
-        opacity: 1;
-        line-height: 30px;
-      }
-
-      #loader-wrapper .load_title span {
-        font-weight: normal;
-        font-style: italic;
-        font-size: 13px;
-        color: #fff;
-        opacity: 0.5;
-      }
-    </style>
-  </head>
-
-  <body>
-    <div id="app">
-      <div id="loader-wrapper">
-        <div id="loader"></div>
-        <div class="loader-section section-left"></div>
-        <div class="loader-section section-right"></div>
-        <div class="load_title">正在加载系统资源,请耐心等待</div>
-      </div>
+  <style>
+    html,
+    body,
+    #app {
+      height: 100%;
+      margin: 0px;
+      padding: 0px;
+    }
+
+    .chromeframe {
+      margin: 0.2em 0;
+      background: #ccc;
+      color: #000;
+      padding: 0.2em 0;
+    }
+
+    #loader-wrapper {
+      position: fixed;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      z-index: 999999;
+    }
+
+    #loader {
+      display: block;
+      position: relative;
+      left: 50%;
+      top: 50%;
+      width: 150px;
+      height: 150px;
+      margin: -75px 0 0 -75px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #fff;
+      -webkit-animation: spin 2s linear infinite;
+      -ms-animation: spin 2s linear infinite;
+      -moz-animation: spin 2s linear infinite;
+      -o-animation: spin 2s linear infinite;
+      animation: spin 2s linear infinite;
+      z-index: 1001;
+    }
+
+    #loader:before {
+      content: '';
+      position: absolute;
+      top: 5px;
+      left: 5px;
+      right: 5px;
+      bottom: 5px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #fff;
+      -webkit-animation: spin 3s linear infinite;
+      -moz-animation: spin 3s linear infinite;
+      -o-animation: spin 3s linear infinite;
+      -ms-animation: spin 3s linear infinite;
+      animation: spin 3s linear infinite;
+    }
+
+    #loader:after {
+      content: '';
+      position: absolute;
+      top: 15px;
+      left: 15px;
+      right: 15px;
+      bottom: 15px;
+      border-radius: 50%;
+      border: 3px solid transparent;
+      border-top-color: #fff;
+      -moz-animation: spin 1.5s linear infinite;
+      -o-animation: spin 1.5s linear infinite;
+      -ms-animation: spin 1.5s linear infinite;
+      -webkit-animation: spin 1.5s linear infinite;
+      animation: spin 1.5s linear infinite;
+    }
+
+    @-webkit-keyframes spin {
+      0% {
+        -webkit-transform: rotate(0deg);
+        -ms-transform: rotate(0deg);
+        transform: rotate(0deg);
+      }
+
+      100% {
+        -webkit-transform: rotate(360deg);
+        -ms-transform: rotate(360deg);
+        transform: rotate(360deg);
+      }
+    }
+
+    @keyframes spin {
+      0% {
+        -webkit-transform: rotate(0deg);
+        -ms-transform: rotate(0deg);
+        transform: rotate(0deg);
+      }
+
+      100% {
+        -webkit-transform: rotate(360deg);
+        -ms-transform: rotate(360deg);
+        transform: rotate(360deg);
+      }
+    }
+
+    #loader-wrapper .loader-section {
+      position: fixed;
+      top: 0;
+      width: 51%;
+      height: 100%;
+      background: #7171c6;
+      z-index: 1000;
+      -webkit-transform: translateX(0);
+      -ms-transform: translateX(0);
+      transform: translateX(0);
+    }
+
+    #loader-wrapper .loader-section.section-left {
+      left: 0;
+    }
+
+    #loader-wrapper .loader-section.section-right {
+      right: 0;
+    }
+
+    .loaded #loader-wrapper .loader-section.section-left {
+      -webkit-transform: translateX(-100%);
+      -ms-transform: translateX(-100%);
+      transform: translateX(-100%);
+      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+    }
+
+    .loaded #loader-wrapper .loader-section.section-right {
+      -webkit-transform: translateX(100%);
+      -ms-transform: translateX(100%);
+      transform: translateX(100%);
+      -webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+      transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
+    }
+
+    .loaded #loader {
+      opacity: 0;
+      -webkit-transition: all 0.3s ease-out;
+      transition: all 0.3s ease-out;
+    }
+
+    .loaded #loader-wrapper {
+      visibility: hidden;
+      -webkit-transform: translateY(-100%);
+      -ms-transform: translateY(-100%);
+      transform: translateY(-100%);
+      -webkit-transition: all 0.3s 1s ease-out;
+      transition: all 0.3s 1s ease-out;
+    }
+
+    .no-js #loader-wrapper {
+      display: none;
+    }
+
+    .no-js h1 {
+      color: #222222;
+    }
+
+    #loader-wrapper .load_title {
+      font-family: 'Open Sans';
+      color: #fff;
+      font-size: 19px;
+      width: 100%;
+      text-align: center;
+      z-index: 9999999999999;
+      position: absolute;
+      top: 60%;
+      opacity: 1;
+      line-height: 30px;
+    }
+
+    #loader-wrapper .load_title span {
+      font-weight: normal;
+      font-style: italic;
+      font-size: 13px;
+      color: #fff;
+      opacity: 0.5;
+    }
+  </style>
+</head>
+
+<body>
+  <div id="app">
+    <div id="loader-wrapper">
+      <div id="loader"></div>
+      <div class="loader-section section-left"></div>
+      <div class="loader-section section-right"></div>
+      <div class="load_title">正在加载系统资源,请耐心等待</div>
     </div>
-    <script type="module" src="/src/main.ts"></script>
-  </body>
-</html>
+  </div>
+  <script type="module" src="/src/main.ts"></script>
+</body>
+
+</html>

+ 5 - 0
package.json

@@ -22,6 +22,10 @@
   "dependencies": {
     "@element-plus/icons-vue": "2.3.1",
     "@highlightjs/vue-plugin": "2.1.0",
+    "@vue-office/docx": "^1.6.3",
+    "@vue-office/excel": "^1.7.14",
+    "@vue-office/pdf": "^2.0.10",
+    "@vue-office/pptx": "^1.0.1",
     "@vueup/vue-quill": "1.2.0",
     "@vueuse/core": "13.1.0",
     "animate.css": "4.1.1",
@@ -40,6 +44,7 @@
     "screenfull": "6.0.2",
     "vue": "3.5.13",
     "vue-cropper": "1.1.1",
+    "vue-demi": "^0.14.10",
     "vue-i18n": "11.1.3",
     "vue-json-pretty": "2.4.0",
     "vue-router": "4.5.0",

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
public/web-office-sdk.js


+ 63 - 0
src/api/setting/carousel/index.ts

@@ -0,0 +1,63 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import { CarouselVO, CarouselForm, CarouselQuery } from '@/api/setting/carousel/types';
+
+/**
+ * 查询轮播图设置列表
+ * @param query
+ * @returns {*}
+ */
+
+export const listCarousel = (query?: CarouselQuery): AxiosPromise<CarouselVO[]> => {
+  return request({
+    url: '/setting/carousel/list',
+    method: 'get',
+    params: query
+  });
+};
+
+/**
+ * 查询轮播图设置详细
+ * @param id
+ */
+export const getCarousel = (id: string | number): AxiosPromise<CarouselVO> => {
+  return request({
+    url: '/setting/carousel/' + id,
+    method: 'get'
+  });
+};
+
+/**
+ * 新增轮播图设置
+ * @param data
+ */
+export const addCarousel = (data: CarouselForm) => {
+  return request({
+    url: '/setting/carousel',
+    method: 'post',
+    data: data
+  });
+};
+
+/**
+ * 修改轮播图设置
+ * @param data
+ */
+export const updateCarousel = (data: CarouselForm) => {
+  return request({
+    url: '/setting/carousel',
+    method: 'put',
+    data: data
+  });
+};
+
+/**
+ * 删除轮播图设置
+ * @param id
+ */
+export const delCarousel = (id: string | number | Array<string | number>) => {
+  return request({
+    url: '/setting/carousel/' + id,
+    method: 'delete'
+  });
+};

+ 57 - 0
src/api/setting/carousel/types.ts

@@ -0,0 +1,57 @@
+export interface CarouselVO {
+  /**
+   * 序号
+   */
+  id: string | number;
+
+  /**
+   * 图片
+   */
+  ossid: string | number;
+
+  /**
+   * 图片Url
+   */
+  ossidUrl: string;
+  /**
+   * 排序
+   */
+  sort: number;
+
+  /**
+   * 备注
+   */
+  note: string;
+
+}
+
+export interface CarouselForm extends BaseEntity {
+  /**
+   * 序号
+   */
+  id?: string | number;
+
+  /**
+   * 图片
+   */
+  ossid?: string | number;
+
+  /**
+   * 排序
+   */
+  sort?: number;
+
+  /**
+   * 备注
+   */
+  note?: string;
+
+}
+
+export interface CarouselQuery extends PageQuery {
+
+  /**
+   * 日期范围参数
+   */
+  params?: any;
+}

+ 0 - 0
src/api/wps/index.ts


+ 444 - 0
src/api/wps/save.ts

@@ -0,0 +1,444 @@
+import request from '@/utils/request';
+import CryptoJS from 'crypto-js';
+
+/**
+ * WPS 三阶段保存接口实现
+ * 
+ * 注意:当前实现包含前端模拟版本,用于开发和测试
+ * 生产环境应该使用真实的后端接口
+ */
+
+// 是否使用前端模拟(开发模式)
+const USE_MOCK = true; // 设置为 false 使用真实后端接口
+
+// 文档信息接口
+export interface FileInfo {
+    id: string;
+    name: string;
+    version: number;
+    size: number;
+    create_time: number;
+    modify_time: number;
+    creator_id: string;
+    modifier_id: string;
+}
+
+// 准备上传参数
+export interface PrepareUploadParams {
+    file_id: string;
+}
+
+// 准备上传返回
+export interface PrepareUploadResponse {
+    digest_types: string[];
+}
+
+// 获取上传地址参数
+export interface GetUploadAddressParams {
+    file_id: string;
+    name: string;
+    size: number;
+    digest: Record<string, string>;
+    is_manual: boolean;
+    attachment_size?: number;
+    content_type?: string;
+}
+
+// 获取上传地址返回
+export interface GetUploadAddressResponse {
+    url: string;
+    method: string;
+    headers?: Record<string, string>;
+    params?: Record<string, string>;
+    send_back_params?: Record<string, string>;
+}
+
+// 上传完成参数
+export interface UploadCompleteParams {
+    file_id: string;
+    request: GetUploadAddressParams;
+    response: {
+        status_code: number;
+        headers?: Record<string, string>;
+        body?: string; // base64 编码
+    };
+    send_back_params?: Record<string, string>;
+}
+
+/**
+ * 第一阶段:准备上传
+ * 协商摘要算法
+ */
+export const prepareUpload = (params: PrepareUploadParams): Promise<PrepareUploadResponse> => {
+    if (USE_MOCK) {
+        // 前端模拟实现
+        return Promise.resolve({
+            digest_types: ['sha1', 'md5', 'sha256']
+        });
+    }
+
+    return request({
+        url: `/v3/3rd/files/${params.file_id}/upload/prepare`,
+        method: 'get'
+    });
+};
+
+/**
+ * 第二阶段:获取上传地址
+ * 获取文件上传的目标地址
+ */
+export const getUploadAddress = (params: GetUploadAddressParams): Promise<GetUploadAddressResponse> => {
+    if (USE_MOCK) {
+        // 前端模拟实现 - 使用 Blob URL
+        return Promise.resolve({
+            url: 'mock://upload', // 模拟上传地址
+            method: 'PUT',
+            headers: {},
+            params: {},
+            send_back_params: {
+                file_id: params.file_id,
+                timestamp: Date.now().toString()
+            }
+        });
+    }
+
+    return request({
+        url: `/v3/3rd/files/${params.file_id}/upload/address`,
+        method: 'post',
+        data: {
+            name: params.name,
+            size: params.size,
+            digest: params.digest,
+            is_manual: params.is_manual,
+            attachment_size: params.attachment_size,
+            content_type: params.content_type
+        }
+    });
+};
+
+/**
+ * 第三阶段:上传完成通知
+ * 通知接入方上传已完成
+ */
+export const uploadComplete = (params: UploadCompleteParams): Promise<FileInfo> => {
+    if (USE_MOCK) {
+        // 前端模拟实现 - 使用 localStorage 存储文件信息
+        const fileId = params.file_id;
+        const currentTime = Math.floor(Date.now() / 1000);
+
+        // 从 localStorage 获取或创建文件记录
+        const storageKey = `wps_file_${fileId}`;
+        let fileRecord: any = null;
+
+        try {
+            const stored = localStorage.getItem(storageKey);
+            if (stored) {
+                fileRecord = JSON.parse(stored);
+            }
+        } catch (err) {
+            console.warn('读取文件记录失败:', err);
+        }
+
+        // 创建或更新文件信息
+        const version = fileRecord ? fileRecord.version + 1 : 1;
+        const fileInfo: FileInfo = {
+            id: fileId,
+            name: params.request.name,
+            version: version,
+            size: params.request.size,
+            create_time: fileRecord ? fileRecord.create_time : currentTime,
+            modify_time: currentTime,
+            creator_id: fileRecord ? fileRecord.creator_id : 'user_' + Date.now(),
+            modifier_id: 'user_' + Date.now()
+        };
+
+        // 保存到 localStorage
+        try {
+            localStorage.setItem(storageKey, JSON.stringify(fileInfo));
+            console.log('[前端模拟] 文件信息已保存到 localStorage:', fileInfo);
+        } catch (err) {
+            console.error('[前端模拟] 保存文件信息失败:', err);
+        }
+
+        return Promise.resolve(fileInfo);
+    }
+
+    return request({
+        url: `/v3/3rd/files/${params.file_id}/upload/complete`,
+        method: 'post',
+        data: {
+            request: params.request,
+            response: params.response,
+            send_back_params: params.send_back_params
+        }
+    });
+};
+
+/**
+ * 计算文件摘要
+ * @param file 文件 Blob 或 ArrayBuffer
+ * @param algorithm 算法类型 md5/sha1/sha256
+ */
+export const calculateDigest = async (
+    file: Blob | ArrayBuffer,
+    algorithm: 'md5' | 'sha1' | 'sha256'
+): Promise<string> => {
+    let arrayBuffer: ArrayBuffer;
+
+    if (file instanceof Blob) {
+        arrayBuffer = await file.arrayBuffer();
+    } else {
+        arrayBuffer = file;
+    }
+
+    // 转换为 WordArray
+    const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer as any);
+
+    // 计算摘要
+    let hash: any;
+    switch (algorithm) {
+        case 'md5':
+            hash = CryptoJS.MD5(wordArray);
+            break;
+        case 'sha1':
+            hash = CryptoJS.SHA1(wordArray);
+            break;
+        case 'sha256':
+            hash = CryptoJS.SHA256(wordArray);
+            break;
+        default:
+            throw new Error(`不支持的算法: ${algorithm}`);
+    }
+
+    return hash.toString();
+};
+
+/**
+ * 完整的三阶段保存流程
+ * @param fileId 文件ID
+ * @param fileName 文件名
+ * @param fileBlob 文件内容
+ * @param isManual 是否手动保存
+ */
+export const saveFileThreePhase = async (
+    fileId: string,
+    fileName: string,
+    fileBlob: Blob,
+    isManual: boolean = false
+): Promise<FileInfo> => {
+    try {
+        // 第一阶段:准备上传,协商摘要算法
+        console.log('[WPS保存] 第一阶段:准备上传');
+        const prepareResult = await prepareUpload({ file_id: fileId });
+        const digestTypes = prepareResult.digest_types || ['sha1'];
+        console.log('[WPS保存] 支持的摘要算法:', digestTypes);
+
+        // 计算文件摘要
+        console.log('[WPS保存] 计算文件摘要...');
+        const digest: Record<string, string> = {};
+        for (const type of digestTypes) {
+            if (['md5', 'sha1', 'sha256'].includes(type)) {
+                digest[type] = await calculateDigest(fileBlob, type as any);
+            }
+        }
+        console.log('[WPS保存] 文件摘要:', digest);
+
+        // 第二阶段:获取上传地址
+        console.log('[WPS保存] 第二阶段:获取上传地址');
+        const uploadAddressParams: GetUploadAddressParams = {
+            file_id: fileId,
+            name: fileName,
+            size: fileBlob.size,
+            digest: digest,
+            is_manual: isManual,
+            content_type: fileBlob.type
+        };
+
+        const addressResult = await getUploadAddress(uploadAddressParams);
+        console.log('[WPS保存] 上传地址:', addressResult.url);
+
+        // 上传文件到指定地址
+        console.log('[WPS保存] 上传文件...');
+
+        let uploadResponse: Response;
+        if (USE_MOCK && addressResult.url === 'mock://upload') {
+            // 前端模拟 - 不实际上传,直接模拟响应
+            console.log('[前端模拟] 跳过实际上传,使用模拟响应');
+            uploadResponse = new Response(null, {
+                status: 200,
+                statusText: 'OK',
+                headers: new Headers({
+                    'content-type': 'application/json',
+                    'x-mock': 'true'
+                })
+            });
+
+            // 可选:将文件保存到 IndexedDB(用于更持久的存储)
+            try {
+                await saveFileToIndexedDB(fileId, fileBlob);
+                console.log('[前端模拟] 文件已保存到 IndexedDB');
+            } catch (err) {
+                console.warn('[前端模拟] 保存到 IndexedDB 失败:', err);
+            }
+        } else {
+            // 真实上传
+            uploadResponse = await fetch(addressResult.url, {
+                method: addressResult.method || 'PUT',
+                headers: addressResult.headers || {},
+                body: fileBlob
+            });
+        }
+
+        console.log('[WPS保存] 上传响应状态:', uploadResponse.status);
+
+        // 获取响应头
+        const responseHeaders: Record<string, string> = {};
+        uploadResponse.headers.forEach((value, key) => {
+            responseHeaders[key] = value;
+        });
+
+        // 获取响应体(如果有)
+        let responseBody: string | undefined;
+        try {
+            const bodyText = await uploadResponse.text();
+            if (bodyText) {
+                responseBody = btoa(bodyText); // base64 编码
+            }
+        } catch (err) {
+            console.warn('[WPS保存] 无法读取响应体:', err);
+        }
+
+        // 第三阶段:上传完成通知
+        console.log('[WPS保存] 第三阶段:上传完成通知');
+        const completeParams: UploadCompleteParams = {
+            file_id: fileId,
+            request: uploadAddressParams,
+            response: {
+                status_code: uploadResponse.status,
+                headers: responseHeaders,
+                body: responseBody
+            },
+            send_back_params: addressResult.send_back_params
+        };
+
+        const fileInfo = await uploadComplete(completeParams);
+        console.log('[WPS保存] 保存完成,文件信息:', fileInfo);
+
+        return fileInfo;
+    } catch (error) {
+        console.error('[WPS保存] 保存失败:', error);
+        throw error;
+    }
+};
+
+
+/**
+ * 前端模拟:将文件保存到 IndexedDB
+ * 用于更持久的本地存储
+ */
+const saveFileToIndexedDB = (fileId: string, fileBlob: Blob): Promise<void> => {
+    return new Promise((resolve, reject) => {
+        const dbName = 'WPS_Files';
+        const storeName = 'files';
+        const request = indexedDB.open(dbName, 1);
+
+        request.onerror = () => {
+            reject(new Error('无法打开 IndexedDB'));
+        };
+
+        request.onsuccess = (event) => {
+            const db = (event.target as IDBOpenDBRequest).result;
+            const transaction = db.transaction([storeName], 'readwrite');
+            const store = transaction.objectStore(storeName);
+
+            const fileRecord = {
+                id: fileId,
+                blob: fileBlob,
+                timestamp: Date.now()
+            };
+
+            const putRequest = store.put(fileRecord);
+
+            putRequest.onsuccess = () => {
+                resolve();
+            };
+
+            putRequest.onerror = () => {
+                reject(new Error('保存文件到 IndexedDB 失败'));
+            };
+        };
+
+        request.onupgradeneeded = (event) => {
+            const db = (event.target as IDBOpenDBRequest).result;
+            if (!db.objectStoreNames.contains(storeName)) {
+                db.createObjectStore(storeName, { keyPath: 'id' });
+            }
+        };
+    });
+};
+
+/**
+ * 前端模拟:从 IndexedDB 读取文件
+ */
+export const getFileFromIndexedDB = (fileId: string): Promise<Blob | null> => {
+    return new Promise((resolve, reject) => {
+        const dbName = 'WPS_Files';
+        const storeName = 'files';
+        const request = indexedDB.open(dbName, 1);
+
+        request.onerror = () => {
+            reject(new Error('无法打开 IndexedDB'));
+        };
+
+        request.onsuccess = (event) => {
+            const db = (event.target as IDBOpenDBRequest).result;
+            const transaction = db.transaction([storeName], 'readonly');
+            const store = transaction.objectStore(storeName);
+            const getRequest = store.get(fileId);
+
+            getRequest.onsuccess = () => {
+                const result = getRequest.result;
+                resolve(result ? result.blob : null);
+            };
+
+            getRequest.onerror = () => {
+                reject(new Error('读取文件失败'));
+            };
+        };
+
+        request.onupgradeneeded = (event) => {
+            const db = (event.target as IDBOpenDBRequest).result;
+            if (!db.objectStoreNames.contains(storeName)) {
+                db.createObjectStore(storeName, { keyPath: 'id' });
+            }
+        };
+    });
+};
+
+/**
+ * 前端模拟:清除所有保存的文件
+ */
+export const clearAllFiles = (): Promise<void> => {
+    return new Promise((resolve, reject) => {
+        // 清除 localStorage
+        const keys = Object.keys(localStorage);
+        keys.forEach(key => {
+            if (key.startsWith('wps_file_')) {
+                localStorage.removeItem(key);
+            }
+        });
+
+        // 清除 IndexedDB
+        const dbName = 'WPS_Files';
+        const request = indexedDB.deleteDatabase(dbName);
+
+        request.onsuccess = () => {
+            console.log('[前端模拟] 所有文件已清除');
+            resolve();
+        };
+
+        request.onerror = () => {
+            reject(new Error('清除 IndexedDB 失败'));
+        };
+    });
+};

+ 788 - 0
src/components/AnnotationEditor/index.vue

@@ -0,0 +1,788 @@
+<template>
+    <div class="annotation-editor">
+        <!-- 文档预览区域 -->
+        <div v-if="ossId && canPreview" class="editor-wrapper">
+            <!-- 顶部工具栏 -->
+            <div class="editor-header">
+                <!-- 文件信息 -->
+                <div class="file-info">
+                    <el-icon class="file-icon"><Document /></el-icon>
+                    <span class="file-name">{{ fileName || '未命名文档' }}</span>
+                    <el-tag v-if="fileType" size="small" effect="plain">{{ fileType.toUpperCase() }}</el-tag>
+                </div>
+                
+                <!-- 操作按钮 -->
+                <div class="editor-actions">
+                    <!-- 编辑模式切换(所有文档类型) -->
+                    <el-switch
+                        v-model="useWpsEditor"
+                        active-text="在线编辑"
+                        inactive-text="预览模式"
+                        inline-prompt
+                        size="large"
+                    />
+                    
+                    <!-- WPS 设置按钮 -->
+                    <el-button 
+                        v-if="useWpsEditor"
+                        @click="showSettings"
+                        circle
+                    >
+                        <el-icon><Setting /></el-icon>
+                    </el-button>
+                    
+                    <!-- 保存按钮 -->
+                    <el-button 
+                        v-if="useWpsEditor && wpsEditorRef" 
+                        type="primary"
+                        @click="handleSaveEdit"
+                        :loading="saving"
+                    >
+                        <el-icon><Download /></el-icon>
+                        保存
+                    </el-button>
+                </div>
+            </div>
+            
+            <!-- 文档内容区域 -->
+            <div class="editor-content">
+                <!-- WPS 编辑器(支持所有格式:Word/Excel/PPT/PDF) -->
+                <div v-if="useWpsEditor && fileUrl" class="wps-wrapper">
+                    <WpsEditor
+                        :file-url="fileUrl"
+                        :file-name="fileName"
+                        :file-type="fileType"
+                        :mode="wpsMode"
+                        :user-id="userStore.userId?.toString()"
+                        :user-name="userStore.userName"
+                        :enable-comment="wpsOptions.enableComment"
+                        :enable-revision="wpsOptions.enableRevision"
+                        :enable-watermark="wpsOptions.enableWatermark"
+                        :watermark-text="wpsOptions.watermarkText"
+                        :enable-download="wpsOptions.enableDownload"
+                        :enable-print="wpsOptions.enablePrint"
+                        :enable-copy="wpsOptions.enableCopy"
+                        :enable-save="wpsOptions.enableSave"
+                        :enable-share="wpsOptions.enableShare"
+                        :enable-history="wpsOptions.enableHistory"
+                        :read-only="wpsOptions.readOnly"
+                        @ready="handleWpsReady"
+                        @error="handleWpsError"
+                        @save="handleWpsSave"
+                        @file-change="handleWpsFileChange"
+                        ref="wpsEditorRef"
+                    />
+                </div>
+
+                <!-- Word 预览 -->
+                <div v-else-if="fileType === 'docx' && fileBlob && !useWpsEditor" class="preview-wrapper">
+                    <VueOfficeDocx 
+                        :src="fileBlob" 
+                        @rendered="handleRendered"
+                        @error="handlePreviewError"
+                    />
+                </div>
+                
+                <!-- Excel 预览 -->
+                <div v-else-if="fileType === 'xlsx' && fileBlob && !useWpsEditor" class="preview-wrapper">
+                    <VueOfficeExcel 
+                        :src="fileBlob"
+                        @rendered="handleRendered"
+                        @error="handlePreviewError"
+                    />
+                </div>
+                
+                <!-- PDF 预览 -->
+                <div v-else-if="fileType === 'pdf' && fileBlob && !useWpsEditor" class="pdf-wrapper">
+                    <div class="pdf-viewer">
+                        <VueOfficePdf 
+                            :src="fileBlob"
+                            @rendered="handleRendered"
+                            @error="handlePreviewError"
+                        />
+                    </div>
+                </div>
+                
+                <!-- 加载状态 -->
+                <div v-if="previewLoading" class="loading-state">
+                    <el-icon class="is-loading"><Loading /></el-icon>
+                    <p>文档加载中...</p>
+                </div>
+                
+                <!-- 错误状态 -->
+                <el-result 
+                    v-if="previewError"
+                    icon="error"
+                    title="加载失败"
+                    :sub-title="previewError"
+                >
+                    <template #extra>
+                        <el-button type="primary" @click="downloadFileForPreview">重新加载</el-button>
+                    </template>
+                </el-result>
+            </div>
+        </div>
+        
+        <!-- 空状态 -->
+        <el-empty 
+            v-else
+            description="暂无文档"
+            :image-size="120"
+        />
+        
+        <!-- WPS 设置抽屉 -->
+        <el-drawer
+            v-model="settingsVisible"
+            title="编辑器设置"
+            direction="rtl"
+            size="400px"
+        >
+            <el-tabs v-model="activeTab" class="settings-tabs">
+                <!-- 编辑模式 -->
+                <el-tab-pane label="编辑模式" name="mode">
+                    <div class="settings-section">
+                        <el-radio-group v-model="wpsMode" class="mode-radio-group">
+                            <el-radio value="normal" border>
+                                <div class="radio-label">
+                                    <div class="label-title">完整模式</div>
+                                    <div class="label-desc">显示所有编辑工具</div>
+                                </div>
+                            </el-radio>
+                            <el-radio value="simple" border>
+                                <div class="radio-label">
+                                    <div class="label-title">简洁模式</div>
+                                    <div class="label-desc">只显示基本工具</div>
+                                </div>
+                            </el-radio>
+                        </el-radio-group>
+                    </div>
+                </el-tab-pane>
+                
+                <!-- 功能开关 -->
+                <el-tab-pane label="功能开关" name="features">
+                    <div class="settings-section">
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>批注功能</span>
+                                <span class="item-desc">允许添加和查看批注</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enableComment" />
+                        </div>
+                        <el-divider />
+                        
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>修订功能</span>
+                                <span class="item-desc">跟踪文档修改记录</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enableRevision" />
+                        </div>
+                        <el-divider />
+                        
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>水印功能</span>
+                                <span class="item-desc">在文档中显示水印</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enableWatermark" />
+                        </div>
+                        
+                        <div v-if="wpsOptions.enableWatermark" class="watermark-input">
+                            <el-input 
+                                v-model="wpsOptions.watermarkText" 
+                                placeholder="输入水印文字"
+                                clearable
+                            />
+                        </div>
+                    </div>
+                </el-tab-pane>
+                
+                <!-- 权限控制 -->
+                <el-tab-pane label="权限控制" name="permissions">
+                    <div class="settings-section">
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>下载权限</span>
+                                <span class="item-desc">允许下载文档</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enableDownload" />
+                        </div>
+                        <el-divider />
+                        
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>打印权限</span>
+                                <span class="item-desc">允许打印文档</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enablePrint" />
+                        </div>
+                        <el-divider />
+                        
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>复制权限</span>
+                                <span class="item-desc">允许复制内容</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.enableCopy" />
+                        </div>
+                        <el-divider />
+                        
+                        <div class="setting-item">
+                            <div class="item-label">
+                                <span>只读模式</span>
+                                <span class="item-desc">文档只能查看</span>
+                            </div>
+                            <el-switch v-model="wpsOptions.readOnly" />
+                        </div>
+                    </div>
+                </el-tab-pane>
+            </el-tabs>
+            
+            <template #footer>
+                <div class="drawer-footer">
+                    <el-button @click="settingsVisible = false">取消</el-button>
+                    <el-button type="primary" @click="applySettings">应用设置</el-button>
+                </div>
+            </template>
+        </el-drawer>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, nextTick } from 'vue';
+import { 
+    Document, Loading, Close, Edit, ChatLineSquare, 
+    Setting, Download, Delete 
+} from '@element-plus/icons-vue';
+import { ElMessage, ElMessageBox } from 'element-plus';
+import VueOfficeDocx from '@vue-office/docx';
+import VueOfficeExcel from '@vue-office/excel';
+import VueOfficePdf from '@vue-office/pdf';
+import '@vue-office/docx/lib/index.css';
+import '@vue-office/excel/lib/index.css';
+import request from '@/utils/request';
+import WpsEditor from '@/components/WpsEditor/index.vue';
+import { useUserStore } from '@/store/modules/user';
+
+interface Props {
+    ossId?: number | string;
+    fileName?: string;
+}
+
+interface Emits {
+    (e: 'ready'): void;
+    (e: 'save', data: any): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const userStore = useUserStore();
+
+const previewLoading = ref(false);
+const previewError = ref('');
+const fileBlob = ref<ArrayBuffer | null>(null);
+const fileUrl = ref('');
+const useWpsEditor = ref(false);
+const wpsMode = ref<'simple' | 'normal'>('normal');
+const wpsEditorRef = ref<InstanceType<typeof WpsEditor>>();
+const saving = ref(false);
+const settingsVisible = ref(false);
+const activeTab = ref('mode');
+
+// WPS 高级选项
+const wpsOptions = ref({
+    enableComment: true,
+    enableRevision: true,
+    enableWatermark: false,
+    watermarkText: '',
+    enableDownload: true,
+    enablePrint: true,
+    enableCopy: true,
+    enableSave: true,
+    enableShare: false,
+    enableHistory: false,
+    readOnly: false
+});
+
+// 获取文件类型
+const fileType = computed(() => {
+    if (!props.fileName) return '';
+    const fileName = props.fileName.toLowerCase();
+    if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) return 'docx';
+    if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) return 'xlsx';
+    if (fileName.endsWith('.pdf')) return 'pdf';
+    return '';
+});
+
+// 判断是否可以预览
+const canPreview = computed(() => {
+    return ['docx', 'xlsx', 'pdf'].includes(fileType.value);
+});
+
+// 下载文件用于预览
+const downloadFileForPreview = async () => {
+    if (!props.ossId || !canPreview.value) {
+        previewLoading.value = false;
+        return;
+    }
+
+    try {
+        previewLoading.value = true;
+        previewError.value = '';
+        fileBlob.value = null;
+        fileUrl.value = '';
+
+        const baseURL = import.meta.env.VITE_APP_BASE_API;
+        fileUrl.value = `${baseURL}/resource/oss/downloadWithoutPermission/${props.ossId}`;
+
+        if (!useWpsEditor.value) {
+            const response = await request({
+                url: `/resource/oss/downloadWithoutPermission/${props.ossId}`,
+                method: 'get',
+                responseType: 'arraybuffer'
+            });
+            fileBlob.value = response;
+        }
+    } catch (error) {
+        console.error('下载文件失败:', error);
+        previewError.value = '文件下载失败,请重试';
+        previewLoading.value = false;
+    }
+};
+
+// WPS 编辑器就绪
+const handleWpsReady = () => {
+    previewLoading.value = false;
+    emit('ready');
+};
+
+// WPS 编辑器错误
+const handleWpsError = (error: string) => {
+    previewError.value = error;
+    previewLoading.value = false;
+};
+
+// WPS 编辑器保存
+const handleWpsSave = (data: any) => {
+    ElMessage.success('文档已保存');
+    emit('save', data);
+};
+
+// WPS 文件变化
+const handleWpsFileChange = (data: any) => {
+    console.log('WPS 文件变化:', data);
+};
+
+// 文档渲染完成
+const handleRendered = () => {
+    previewLoading.value = false;
+};
+
+// 文档预览错误
+const handlePreviewError = (error: any) => {
+    previewLoading.value = false;
+    previewError.value = '文档预览失败,请尝试重新加载';
+    console.error('文档预览错误:', error);
+};
+
+// 显示设置
+const showSettings = () => {
+    settingsVisible.value = true;
+};
+
+// 应用设置
+const applySettings = () => {
+    settingsVisible.value = false;
+    ElMessage.success('设置已应用');
+    // 重新加载编辑器
+    if (useWpsEditor.value) {
+        downloadFileForPreview();
+    }
+};
+
+// 保存编辑
+const handleSaveEdit = async () => {
+    if (!wpsEditorRef.value) {
+        ElMessage.warning('编辑器未初始化');
+        return;
+    }
+    
+    saving.value = true;
+    try {
+        const result = await wpsEditorRef.value.saveDocument();
+        ElMessage.success('文档已保存');
+        emit('save', result);
+    } catch (error) {
+        console.error('保存编辑失败:', error);
+        ElMessage.error('保存编辑失败');
+    } finally {
+        saving.value = false;
+    }
+};
+
+// 获取所有批注数据(WPS 自带的批注)
+const getAnnotations = () => {
+    // WPS 的批注数据由 WPS SDK 管理,不需要我们自己维护
+    return {
+        textAnnotations: [],
+        signatures: []
+    };
+};
+
+// 保存文档
+const saveDocument = async () => {
+    if (useWpsEditor.value && wpsEditorRef.value) {
+        return await wpsEditorRef.value.saveDocument();
+    }
+    return null;
+};
+
+// 监听 ossId 变化
+watch(() => props.ossId, (newId) => {
+    if (newId) {
+        downloadFileForPreview();
+    }
+}, { immediate: true });
+
+// 监听 useWpsEditor 变化
+watch(useWpsEditor, () => {
+    downloadFileForPreview();
+});
+
+// 暴露方法
+defineExpose({
+    getAnnotations,
+    saveDocument
+});
+</script>
+
+<style scoped lang="scss">
+.annotation-editor {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    background: #f5f7fa;
+    position: relative;
+}
+
+.editor-wrapper {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    flex-direction: column;
+}
+
+.editor-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 16px 20px;
+    background: #fff;
+    border-bottom: 1px solid #e4e7ed;
+    flex-shrink: 0;
+    z-index: 10;
+    
+    .file-info {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        
+        .file-icon {
+            font-size: 24px;
+            color: #409eff;
+        }
+        
+        .file-name {
+            font-size: 16px;
+            font-weight: 500;
+            color: #303133;
+            max-width: 300px;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+        }
+    }
+    
+    .editor-actions {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+    }
+}
+
+.editor-content {
+    position: absolute;
+    top: 68px;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: #fff;
+}
+
+.wps-wrapper,
+.preview-wrapper,
+.pdf-wrapper {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    overflow: auto;
+    
+    // 自定义滚动条样式
+    &::-webkit-scrollbar {
+        width: 8px;
+        height: 8px;
+    }
+    
+    &::-webkit-scrollbar-track {
+        background: #f1f1f1;
+        border-radius: 4px;
+    }
+    
+    &::-webkit-scrollbar-thumb {
+        background: #c1c1c1;
+        border-radius: 4px;
+        
+        &:hover {
+            background: #a8a8a8;
+        }
+    }
+}
+
+.wps-wrapper {
+    :deep(.wps-editor-container) {
+        height: 100%;
+        
+        .wps-container {
+            height: 100%;
+        }
+    }
+}
+
+.preview-wrapper {
+    padding: 20px;
+    
+    :deep(.docx-wrapper),
+    :deep(.excel-wrapper) {
+        height: auto !important;
+        max-height: none !important;
+    }
+}
+
+.pdf-wrapper {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    
+    .pdf-viewer {
+        width: 100%;
+        height: 100%;
+        overflow: auto;
+        
+        // 自定义滚动条样式
+        &::-webkit-scrollbar {
+            width: 8px;
+            height: 8px;
+        }
+        
+        &::-webkit-scrollbar-track {
+            background: #f1f1f1;
+            border-radius: 4px;
+        }
+        
+        &::-webkit-scrollbar-thumb {
+            background: #c1c1c1;
+            border-radius: 4px;
+            
+            &:hover {
+                background: #a8a8a8;
+            }
+        }
+        
+        :deep(canvas) {
+            display: block;
+            margin: 20px auto;
+            box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+        }
+    }
+}
+
+.signature-item,
+.annotation-item {
+    position: absolute;
+    z-index: 10;
+    
+    .remove-btn {
+        position: absolute;
+        top: -10px;
+        right: -10px;
+        opacity: 0;
+        transition: opacity 0.3s;
+    }
+    
+    &:hover .remove-btn {
+        opacity: 1;
+    }
+}
+
+.signature-item {
+    width: 100px;
+    height: 50px;
+    
+    img {
+        width: 100%;
+        height: 100%;
+        border: 2px solid #409eff;
+        border-radius: 4px;
+        background: rgba(64, 158, 255, 0.05);
+        transition: transform 0.3s;
+    }
+    
+    &:hover img {
+        transform: scale(1.05);
+    }
+}
+
+.annotation-item {
+    min-width: 200px;
+    max-width: 300px;
+    background: #fff;
+    border: 2px solid #ffc107;
+    border-radius: 8px;
+    padding: 12px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+    
+    .annotation-display {
+        cursor: pointer;
+        
+        .annotation-text {
+            color: #303133;
+            font-size: 14px;
+            line-height: 1.6;
+            word-break: break-all;
+            min-height: 40px;
+        }
+    }
+    
+    .annotation-actions {
+        display: flex;
+        gap: 8px;
+        margin-top: 8px;
+        justify-content: flex-end;
+    }
+}
+
+.loading-state {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+    z-index: 100;
+    
+    .el-icon {
+        font-size: 48px;
+        color: #409eff;
+        margin-bottom: 16px;
+    }
+    
+    p {
+        font-size: 14px;
+        color: #909399;
+    }
+}
+
+:deep(.el-result),
+:deep(.el-empty) {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 100%;
+}
+
+.settings-tabs {
+    :deep(.el-tabs__content) {
+        padding: 0;
+    }
+}
+
+.settings-section {
+    padding: 20px 0;
+}
+
+.mode-radio-group {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    
+    :deep(.el-radio) {
+        margin: 0;
+        padding: 16px;
+        
+        &.is-bordered {
+            border-radius: 8px;
+        }
+    }
+    
+    .radio-label {
+        .label-title {
+            font-size: 14px;
+            font-weight: 500;
+            color: #303133;
+            margin-bottom: 4px;
+        }
+        
+        .label-desc {
+            font-size: 12px;
+            color: #909399;
+        }
+    }
+}
+
+.setting-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 0;
+    
+    .item-label {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+        
+        > span:first-child {
+            font-size: 14px;
+            color: #303133;
+        }
+        
+        .item-desc {
+            font-size: 12px;
+            color: #909399;
+        }
+    }
+}
+
+.watermark-input {
+    margin-top: 12px;
+    padding-left: 20px;
+}
+
+.drawer-footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 12px;
+}
+
+:deep(.el-divider) {
+    margin: 12px 0;
+}
+</style>

+ 1020 - 94
src/components/DocumentAuditDialog/index.vue

@@ -1,157 +1,1083 @@
 <template>
-    <el-dialog v-model="dialogVisible" :title="title" width="500px" append-to-body>
-        <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="120px">
-            <el-form-item :label="t('document.document.auditForm.result')" prop="result">
-                <el-radio-group v-model="auditForm.result">
-                    <el-radio label="3">{{ t('document.document.auditForm.pass') }}</el-radio>
-                    <el-radio label="2">{{ t('document.document.auditForm.reject') }}</el-radio>
-                </el-radio-group>
-            </el-form-item>
-            <el-form-item :label="t('document.document.auditForm.reason')" prop="reason"
-                v-if="auditForm.result === '2'">
-                <el-input v-model="auditForm.reason" type="textarea" :rows="3"
-                    :placeholder="t('document.document.auditForm.reasonPlaceholder')" />
-            </el-form-item>
-        </el-form>
-        <template #footer>
-            <div class="dialog-footer">
-                <el-button :loading="loading" type="primary" @click="submitForm">{{
-                    t('document.document.button.submit') }}</el-button>
-                <el-button @click="handleCancel">{{ t('document.document.button.cancel') }}</el-button>
+  <el-dialog v-model="dialogVisible" :title="title" width="1200px" top="5vh" append-to-body destroy-on-close class="audit-dialog">
+    <div class="audit-container">
+      <!-- 左侧:文档编辑区域 -->
+      <div class="preview-section">
+        <div class="preview-header">
+          <div class="file-info">
+            <el-icon class="file-icon"><Document /></el-icon>
+            <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>
+          </div>
+        </div>
+        <div class="preview-container">
+          <!-- WPS 编辑器容器 -->
+          <div 
+            v-if="document?.ossId" 
+            ref="wpsContainerRef" 
+            class="wps-container"
+            @dragenter="handleDragEnter"
+            @dragleave="handleDragLeave"
+            @dragover.prevent="handleDragOver"
+            @drop.prevent="handleDrop"
+          >
+            <!-- 拖拽提示层 -->
+            <div v-if="isDragging" class="drag-overlay">
+              <div class="drag-hint">
+                <el-icon class="drag-icon"><Upload /></el-icon>
+                <p>松开鼠标插入图片</p>
+              </div>
             </div>
-        </template>
-    </el-dialog>
+            
+            <!-- 降级方案:iframe 预览 -->
+            <iframe v-if="wpsError && document?.url" :src="document.url" class="document-iframe" frameborder="0"></iframe>
+          </div>
+
+          <!-- 加载状态 -->
+          <div v-if="wpsLoading" class="loading-state">
+            <el-icon class="is-loading"><Loading /></el-icon>
+            <p>正在加载编辑器...</p>
+          </div>
+
+          <!-- 错误状态 -->
+          <div v-if="wpsError && !document?.url" class="error-state">
+            <el-result icon="error" title="加载失败" :sub-title="wpsError">
+              <template #extra>
+                <el-button type="primary" @click="initWpsEditor">重新加载</el-button>
+              </template>
+            </el-result>
+          </div>
+
+          <!-- 空状态 -->
+          <el-empty v-if="!document?.ossId" description="暂无文档" :image-size="100" />
+        </div>
+      </div>
+
+      <!-- 右侧:审核表单区域 -->
+      <div class="audit-section">
+        <div class="section-header">
+          <el-icon><Edit /></el-icon>
+          <span>审核信息</span>
+        </div>
+
+        <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-position="top" class="audit-form">
+          <!-- 文档信息卡片 -->
+          <el-card shadow="never" class="info-card">
+            <template #header>
+              <div class="card-header">
+                <el-icon><InfoFilled /></el-icon>
+                <span>文档信息</span>
+              </div>
+            </template>
+            <div class="info-item">
+              <span class="info-label">文档名称:</span>
+              <span class="info-value">{{ document?.fileName || '-' }}</span>
+            </div>
+            <div class="info-item">
+              <span class="info-label">文档ID:</span>
+              <span class="info-value">{{ document?.id || '-' }}</span>
+            </div>
+          </el-card>
+
+          <!-- 审核结果 -->
+          <el-form-item :label="t('document.document.auditForm.result')" prop="result">
+            <el-radio-group v-model="auditForm.result" class="result-radio-group">
+              <el-radio label="3" border>
+                <div class="radio-content">
+                  <el-icon class="radio-icon success"><CircleCheck /></el-icon>
+                  <div class="radio-text">
+                    <div class="radio-title">{{ t('document.document.auditForm.pass') }}</div>
+                    <div class="radio-desc">文档审核通过</div>
+                  </div>
+                </div>
+              </el-radio>
+              <el-radio label="2" border>
+                <div class="radio-content">
+                  <el-icon class="radio-icon danger"><CircleClose /></el-icon>
+                  <div class="radio-text">
+                    <div class="radio-title">{{ t('document.document.auditForm.reject') }}</div>
+                    <div class="radio-desc">文档需要修改</div>
+                  </div>
+                </div>
+              </el-radio>
+            </el-radio-group>
+          </el-form-item>
+
+          <!-- 驳回原因 -->
+          <el-form-item v-if="auditForm.result === '2'" :label="t('document.document.auditForm.reason')" prop="reason">
+            <el-input
+              v-model="auditForm.reason"
+              type="textarea"
+              :rows="6"
+              :placeholder="t('document.document.auditForm.reasonPlaceholder')"
+              maxlength="500"
+              show-word-limit
+            />
+          </el-form-item>
+
+          <!-- 审核提示 -->
+          <el-alert v-if="auditForm.result === '3'" title="审核通过后,文档将进入下一流程" type="success" :closable="false" show-icon />
+          <el-alert v-if="auditForm.result === '2'" title="驳回后,文档将退回给提交人修改" type="warning" :closable="false" show-icon />
+        </el-form>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <el-button @click="handleCancel" size="large">
+          <el-icon><Close /></el-icon>
+          {{ t('document.document.button.cancel') }}
+        </el-button>
+        <el-button type="primary" @click="submitForm" :loading="loading" size="large">
+          <el-icon><Check /></el-icon>
+          {{ t('document.document.button.submit') }}
+        </el-button>
+      </div>
+    </template>
+  </el-dialog>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, watch, nextTick } from 'vue';
+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 { useUserStore } from '@/store/modules/user';
 
 interface Document {
-    id: number | string;
-    name?: string;
+  id: number | string;
+  name?: string;
+  ossId?: number | string;
+  fileName?: string;
+  url?: string;
 }
 
 interface AuditData {
-    documentId: number | string;
-    result: number;
-    rejectReason?: string;
+  documentId: number | string;
+  result: number;
+  rejectReason?: string;
 }
 
 interface Props {
-    modelValue: boolean;
-    document?: Document | null;
-    title?: string;
-    auditApi: (data: AuditData) => Promise<any>;
+  modelValue: boolean;
+  document?: Document | null;
+  title?: string;
+  auditApi: (data: AuditData) => Promise<any>;
 }
 
 interface Emits {
-    (e: 'update:modelValue', value: boolean): void;
-    (e: 'success'): void;
+  (e: 'update:modelValue', value: boolean): void;
+  (e: 'success'): void;
 }
 
 const props = defineProps<Props>();
 const emit = defineEmits<Emits>();
 
 const { t } = useI18n();
+const userStore = useUserStore();
 
 const dialogVisible = ref(false);
 const loading = ref(false);
 const auditFormRef = ref<FormInstance>();
 
+// WPS 编辑器相关
+const wpsContainerRef = ref<HTMLDivElement>();
+const wpsLoading = ref(false);
+const wpsError = ref('');
+const isDragging = ref(false);
+const isDraggingUserName = ref(false);
+let wpsInstance: any = null;
+let dragCounter = 0; // 用于跟踪拖拽进入/离开次数
+
+// WPS 配置
+const WPS_APP_ID = 'SX20251229FLIAPD';
+
 // 审核表单数据
 const auditForm = ref({
-    id: 0 as number | string,
-    result: '3', // 默认通过
-    reason: ''
+  id: 0 as number | string,
+  result: '3',
+  reason: ''
 });
 
 // 审核表单验证规则
 const auditRules = reactive({
-    result: [
-        {
-            required: true,
-            message: t('document.document.auditRule.resultRequired'),
-            trigger: 'change'
+  result: [
+    {
+      required: true,
+      message: t('document.document.auditRule.resultRequired'),
+      trigger: 'change'
+    }
+  ],
+  reason: [
+    {
+      required: true,
+      message: t('document.document.auditRule.reasonRequired'),
+      trigger: 'blur'
+    }
+  ]
+});
+
+// 获取文件类型
+const getFileType = (fileName: string) => {
+  const name = fileName.toLowerCase();
+  if (name.endsWith('.docx') || name.endsWith('.doc')) return 'w';
+  if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 's';
+  if (name.endsWith('.pptx') || name.endsWith('.ppt')) return 'p';
+  if (name.endsWith('.pdf')) return 'f';
+  return 'w';
+};
+
+// 初始化 WPS 编辑器
+const initWpsEditor = async () => {
+  if (!wpsContainerRef.value || !props.document?.ossId) {
+    return;
+  }
+
+  try {
+    wpsLoading.value = true;
+    wpsError.value = '';
+
+    // 检查 WPS SDK
+    if (!(window as any).WebOfficeSDK) {
+      wpsError.value = 'WPS SDK 未加载';
+      wpsLoading.value = false;
+      return;
+    }
+
+    const WebOfficeSDK = (window as any).WebOfficeSDK;
+
+    // 获取文件类型
+    const officeType = getFileType(props.document.fileName || '');
+
+    // 生成文件 ID
+    const fileId = `${props.document.id}`;
+
+    console.log('[WPS] 初始化配置:', {
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+      fileName: props.document.fileName,
+      fileUrl: props.document.url
+    });
+
+    // 标准初始化配置(按官方文档)
+    const config = {
+      // 必需参数
+      appId: WPS_APP_ID,
+      officeType: officeType,
+      fileId: fileId,
+
+      // 可选参数
+      mount: wpsContainerRef.value
+    };
+
+    // 初始化 WPS 编辑器
+    wpsInstance = WebOfficeSDK.init(config);
+
+    wpsLoading.value = false;
+    console.log('[WPS] 编辑器初始化成功');
+  } catch (err: any) {
+    console.error('[WPS] 初始化失败:', err);
+    wpsError.value = err.message || '初始化失败';
+    wpsLoading.value = false;
+  }
+};
+
+// 销毁 WPS 编辑器
+const destroyWpsEditor = () => {
+  if (wpsInstance) {
+    try {
+      if (wpsInstance.destroy) {
+        wpsInstance.destroy();
+      }
+      wpsInstance = null;
+      console.log('[WPS] 编辑器已销毁');
+    } catch (err) {
+      console.error('[WPS] 销毁编辑器失败:', err);
+    }
+  }
+};
+
+// 保存文档
+const saveWpsDocument = async () => {
+  if (!wpsInstance) {
+    return null;
+  }
+
+  try {
+    console.log('[WPS] 开始保存文档');
+    const result = await wpsInstance.save();
+    console.log('[WPS] 保存成功:', result);
+    return result;
+  } catch (err: any) {
+    console.error('[WPS] 保存失败:', err);
+    throw err;
+  }
+};
+
+// 拖拽进入
+const handleDragEnter = (e: DragEvent) => {
+  dragCounter++;
+  if (dragCounter === 1) {
+    isDragging.value = true;
+  }
+};
+
+// 拖拽离开
+const handleDragLeave = (e: DragEvent) => {
+  dragCounter--;
+  if (dragCounter === 0) {
+    isDragging.value = false;
+  }
+};
+
+// 拖拽悬停
+const handleDragOver = (e: DragEvent) => {
+  e.preventDefault();
+  e.stopPropagation();
+};
+
+// 处理拖放
+const handleDrop = async (e: DragEvent) => {
+  e.preventDefault();
+  e.stopPropagation();
+  
+  isDragging.value = false;
+  dragCounter = 0;
+  
+  if (!wpsInstance) {
+    ElMessage.warning('WPS 编辑器未初始化');
+    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) {
+    return;
+  }
+  
+  // 只处理第一个文件
+  const file = files[0];
+  
+  // 检查是否是图片
+  if (!file.type.startsWith('image/')) {
+    ElMessage.warning('只支持插入图片文件');
+    return;
+  }
+  
+  try {
+    console.log('[WPS] 开始插入图片:', file.name);
+    
+    // 读取图片为 base64
+    const reader = new FileReader();
+    reader.onload = async (event) => {
+      const base64 = event.target?.result as string;
+      
+      try {
+        // 获取 WPS Application 对象
+        const app = await wpsInstance.Application;
+        
+        if (!app) {
+          ElMessage.error('无法获取 WPS Application 对象');
+          return;
         }
-    ],
-    reason: [
-        {
-            required: true,
-            message: t('document.document.auditRule.reasonRequired'),
-            trigger: 'blur'
+        
+        // 根据文件类型插入图片
+        const officeType = getFileType(props.document?.fileName || '');
+        
+        if (officeType === 'w') {
+          // Word 文档:插入图片到光标位置
+          const selection = await app.ActiveDocument.Application.Selection;
+          await selection.InlineShapes.AddPicture(base64);
+          ElMessage.success('图片已插入');
+        } else if (officeType === 's') {
+          // Excel 表格:插入图片到当前单元格
+          const activeSheet = await app.ActiveSheet;
+          const activeCell = await app.ActiveCell;
+          const row = await activeCell.Row;
+          const col = await activeCell.Column;
+          await activeSheet.Shapes.AddPicture(base64, false, true, col * 64, row * 20, 200, 150);
+          ElMessage.success('图片已插入');
+        } else if (officeType === 'p') {
+          // PowerPoint:插入图片到当前幻灯片
+          const activeSlide = await app.ActivePresentation.Slides.Item(await app.ActiveWindow.Selection.SlideRange.SlideIndex);
+          await activeSlide.Shapes.AddPicture(base64, false, true, 100, 100, 200, 150);
+          ElMessage.success('图片已插入');
+        } else {
+          ElMessage.warning('当前文档类型不支持插入图片');
         }
-    ]
-});
+        
+        console.log('[WPS] 图片插入成功');
+      } catch (err: any) {
+        console.error('[WPS] 插入图片失败:', err);
+        ElMessage.error('插入图片失败: ' + err.message);
+      }
+    };
+    
+    reader.onerror = () => {
+      ElMessage.error('读取图片文件失败');
+    };
+    
+    reader.readAsDataURL(file);
+    
+  } catch (err: any) {
+    console.error('[WPS] 处理拖放失败:', err);
+    ElMessage.error('处理拖放失败');
+  }
+};
 
-// 监听modelValue变化
-watch(
-    () => props.modelValue,
-    (val) => {
-        dialogVisible.value = val;
-        if (val && props.document) {
-            auditForm.value = {
-                id: props.document.id,
-                result: '3', // 默认通过
-                reason: ''
-            };
-            // 重置表单验证
-            nextTick(() => {
-                auditFormRef.value?.clearValidate();
+// 开始拖拽用户名
+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;
+  }
+  
+  try {
+    console.log('[WPS] 开始插入用户名:', userName);
+    
+    // 获取 WPS Application 对象
+    const app = await wpsInstance.Application;
+    
+    if (!app) {
+      ElMessage.error('无法获取 WPS Application 对象');
+      return;
+    }
+    
+    // 根据文件类型插入文本
+    const officeType = getFileType(props.document?.fileName || '');
+    
+    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);
+      }
+    } else {
+      ElMessage.warning('当前文档类型不支持插入文本');
+    }
+    
+    console.log('[WPS] 用户名插入成功');
+  } catch (err: any) {
+    console.error('[WPS] 插入用户名失败:', err);
+    ElMessage.error('插入用户名失败: ' + err.message);
+  }
+};
+
+// 监听 modelValue 变化
+watch(
+  () => props.modelValue,
+  (val) => {
+    dialogVisible.value = val;
+    if (val && props.document) {
+      auditForm.value = {
+        id: props.document.id,
+        result: '3',
+        reason: ''
+      };
+      nextTick(() => {
+        auditFormRef.value?.clearValidate();
+        // 自动初始化 WPS 编辑器
+        if (props.document?.ossId) {
+          initWpsEditor();
         }
+      });
     }
+  }
 );
 
 // 监听dialogVisible变化
 watch(dialogVisible, (val) => {
-    emit('update:modelValue', val);
-    if (!val) {
-        auditForm.value = {
-            id: 0,
-            result: '3',
-            reason: ''
-        };
-    }
+  emit('update:modelValue', val);
+  if (!val) {
+    auditForm.value = {
+      id: 0,
+      result: '3',
+      reason: ''
+    };
+    destroyWpsEditor();
+  }
 });
 
 // 取消操作
 const handleCancel = () => {
-    dialogVisible.value = false;
+  dialogVisible.value = false;
 };
 
 // 提交表单
 const submitForm = () => {
-    auditFormRef.value?.validate(async (valid: boolean) => {
-        if (valid) {
-            loading.value = true;
-            try {
-                const auditData: AuditData = {
-                    documentId: auditForm.value.id,
-                    result: parseInt(auditForm.value.result),
-                    rejectReason: auditForm.value.reason
-                };
-                await props.auditApi(auditData);
-                ElMessage.success(t('document.document.message.auditSuccess'));
-                dialogVisible.value = false;
-                emit('success');
-            } catch (error) {
-                console.error(t('document.document.message.auditFailed'), error);
-                ElMessage.error(t('document.document.message.auditFailed'));
-            } finally {
-                loading.value = false;
+  auditFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      loading.value = true;
+      try {
+        // 如果使用了 WPS 编辑器,先保存文档
+        let savedFileInfo = null;
+        if (wpsInstance) {
+          try {
+            savedFileInfo = await saveWpsDocument();
+            if (savedFileInfo) {
+              console.log('文档保存成功:', savedFileInfo);
+              ElMessage.success('文档已保存');
             }
+          } catch (err) {
+            console.error('保存文档失败:', err);
+            ElMessage.warning('文档保存失败,将继续提交审核');
+          }
         }
-    });
+
+        // 构建审核数据
+        const auditData: any = {
+          documentId: auditForm.value.id,
+          result: parseInt(auditForm.value.result),
+          rejectReason: auditForm.value.reason
+        };
+
+        // 添加保存的文件信息
+        if (savedFileInfo) {
+          auditData.fileInfo = savedFileInfo;
+        }
+
+        await props.auditApi(auditData);
+        ElMessage.success(t('document.document.message.auditSuccess'));
+        dialogVisible.value = false;
+        emit('success');
+      } catch (error) {
+        console.error(t('document.document.message.auditFailed'), error);
+        ElMessage.error(t('document.document.message.auditFailed'));
+      } finally {
+        loading.value = false;
+      }
+    }
+  });
 };
+
+// 组件卸载前清理
+onBeforeUnmount(() => {
+  destroyWpsEditor();
+});
 </script>
 
 <style scoped lang="scss">
-.dialog-footer {
+.audit-dialog {
+  :deep(.el-dialog__header) {
+    padding: 20px 24px;
+    border-bottom: 1px solid #e4e7ed;
+    margin: 0;
+
+    .el-dialog__title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  :deep(.el-dialog__body) {
+    padding: 0;
+    height: calc(80vh - 140px);
+    overflow: hidden;
+  }
+
+  :deep(.el-dialog__footer) {
+    padding: 16px 24px;
+    border-top: 1px solid #e4e7ed;
+  }
+}
+
+.audit-container {
+  display: flex;
+  height: 100%;
+  gap: 1px;
+  background: #e4e7ed;
+}
+
+.preview-section {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  min-width: 0;
+  position: relative;
+}
+
+.preview-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 20px;
+  background: #fff;
+  border-bottom: 1px solid #e4e7ed;
+  flex-shrink: 0;
+
+  .file-info {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+
+    .file-icon {
+      font-size: 24px;
+      color: #409eff;
+    }
+
+    .file-name {
+      font-size: 16px;
+      font-weight: 500;
+      color: #303133;
+      max-width: 600px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+  
+  .header-actions {
+    display: flex;
+    gap: 8px;
+    
+    .drag-username-btn {
+      cursor: move;
+      user-select: none;
+      
+      &:active {
+        cursor: grabbing;
+      }
+      
+      .el-icon {
+        margin-right: 4px;
+      }
+    }
+  }
+}
+
+.preview-container {
+  position: absolute;
+  top: 68px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  overflow: hidden;
+
+  .wps-container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+  }
+
+  .document-iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+  
+  .drag-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background: rgba(64, 158, 255, 0.1);
+    border: 2px dashed #409eff;
     display: flex;
-    justify-content: flex-end;
-    gap: 10px;
+    align-items: center;
+    justify-content: center;
+    z-index: 9999;
+    pointer-events: none;
+    
+    .drag-hint {
+      text-align: center;
+      
+      .drag-icon {
+        font-size: 64px;
+        color: #409eff;
+        margin-bottom: 16px;
+      }
+      
+      p {
+        font-size: 18px;
+        font-weight: 500;
+        color: #409eff;
+      }
+    }
+  }
+
+  .loading-state {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+
+    .el-icon {
+      font-size: 48px;
+      color: #409eff;
+      margin-bottom: 16px;
+    }
+
+    p {
+      font-size: 14px;
+      color: #909399;
+    }
+  }
+
+  :deep(.el-result),
+  :deep(.el-empty) {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    width: 100%;
+  }
+}
+
+.audit-section {
+  width: 400px;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 16px 20px;
+  background: #f5f7fa;
+  border-bottom: 1px solid #e4e7ed;
+  font-size: 15px;
+  font-weight: 500;
+  color: #303133;
+
+  .el-icon {
+    font-size: 18px;
+    color: #409eff;
+  }
+}
+
+.audit-form {
+  flex: 1;
+  padding: 20px;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #dcdfe6;
+    border-radius: 3px;
+
+    &:hover {
+      background: #c0c4cc;
+    }
+  }
+}
+
+.info-card {
+  margin-bottom: 20px;
+
+  :deep(.el-card__header) {
+    padding: 12px 16px;
+    background: #f5f7fa;
+  }
+
+  .card-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-size: 14px;
+    font-weight: 500;
+    color: #303133;
+
+    .el-icon {
+      font-size: 16px;
+      color: #409eff;
+    }
+  }
+
+  :deep(.el-card__body) {
+    padding: 16px;
+  }
+}
+
+.info-item {
+  display: flex;
+  align-items: center;
+  padding: 8px 0;
+  font-size: 14px;
+
+  &:not(:last-child) {
+    border-bottom: 1px solid #f0f2f5;
+  }
+
+  .info-label {
+    color: #909399;
+    min-width: 80px;
+  }
+
+  .info-value {
+    color: #303133;
+    flex: 1;
+    word-break: break-all;
+  }
+}
+
+.result-radio-group {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+
+  :deep(.el-radio) {
+    margin: 0;
+    padding: 0;
+    height: auto;
+
+    &.is-bordered {
+      padding: 16px;
+      border-radius: 8px;
+      border: 2px solid #dcdfe6;
+      transition: all 0.3s;
+
+      &:hover {
+        border-color: #409eff;
+      }
+
+      &.is-checked {
+        border-color: #409eff;
+        background: #ecf5ff;
+      }
+    }
+
+    .el-radio__input {
+      display: none;
+    }
+
+    .el-radio__label {
+      padding: 0;
+    }
+  }
+}
+
+.radio-content {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+
+  .radio-icon {
+    font-size: 32px;
+    flex-shrink: 0;
+
+    &.success {
+      color: #67c23a;
+    }
+
+    &.danger {
+      color: #f56c6c;
+    }
+  }
+
+  .radio-text {
+    flex: 1;
+
+    .radio-title {
+      font-size: 15px;
+      font-weight: 500;
+      color: #303133;
+      margin-bottom: 4px;
+    }
+
+    .radio-desc {
+      font-size: 13px;
+      color: #909399;
+    }
+  }
+}
+
+:deep(.el-form-item) {
+  margin-bottom: 20px;
+
+  .el-form-item__label {
+    font-weight: 500;
+    color: #303133;
+    margin-bottom: 8px;
+  }
+}
+
+:deep(.el-alert) {
+  margin-top: 12px;
+  border-radius: 6px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+
+  .el-button {
+    min-width: 100px;
+
+    .el-icon {
+      margin-right: 4px;
+    }
+  }
+}
+
+// 响应式设计
+@media (max-width: 1400px) {
+  .audit-dialog {
+    :deep(.el-dialog) {
+      width: 95% !important;
+    }
+  }
+
+  .audit-section {
+    width: 350px;
+  }
+}
+
+@media (max-width: 1200px) {
+  .audit-container {
+    flex-direction: column;
+  }
+
+  .preview-section {
+    height: 50%;
+  }
+
+  .audit-section {
+    width: 100%;
+    height: 50%;
+  }
 }
 </style>

+ 763 - 0
src/components/DocumentAuditDialog/index.vue.bak

@@ -0,0 +1,763 @@
+<template>
+    <el-dialog v-model="dialogVisible" :title="title" width="900px" append-to-body>
+        <el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="120px">
+            <!-- 文件预览区域 -->
+            <el-form-item v-if="document?.ossId && canPreview" label="文档预览">
+                <div class="document-preview">
+                    <!-- 切换编辑器按钮和高级选项 -->
+                    <div v-if="fileType !== 'pdf'" class="editor-switch">
+                        <el-switch
+                            v-model="useWpsEditor"
+                            active-text="WPS 在线编辑"
+                            inactive-text="预览模式"
+                        />
+                        
+                        <!-- WPS 高级选项 -->
+                        <el-popover
+                            v-if="useWpsEditor"
+                            placement="bottom"
+                            :width="300"
+                            trigger="click"
+                        >
+                            <template #reference>
+                                <el-button size="small" type="primary" link>
+                                    <el-icon><Setting /></el-icon>
+                                    高级选项
+                                </el-button>
+                            </template>
+                            <div class="wps-options">
+                                <el-form label-width="100px" size="small">
+                                    <el-form-item label="编辑模式">
+                                        <el-radio-group v-model="wpsMode">
+                                            <el-radio label="normal">完整模式</el-radio>
+                                            <el-radio label="simple">简洁模式</el-radio>
+                                        </el-radio-group>
+                                    </el-form-item>
+                                    <el-form-item label="功能开关">
+                                        <el-checkbox v-model="wpsOptions.enableComment">批注</el-checkbox>
+                                        <el-checkbox v-model="wpsOptions.enableRevision">修订</el-checkbox>
+                                        <el-checkbox v-model="wpsOptions.enableWatermark">水印</el-checkbox>
+                                    </el-form-item>
+                                    <el-form-item label="权限控制">
+                                        <el-checkbox v-model="wpsOptions.enableDownload">下载</el-checkbox>
+                                        <el-checkbox v-model="wpsOptions.enablePrint">打印</el-checkbox>
+                                        <el-checkbox v-model="wpsOptions.enableCopy">复制</el-checkbox>
+                                    </el-form-item>
+                                    <el-form-item label="水印文字" v-if="wpsOptions.enableWatermark">
+                                        <el-input v-model="wpsOptions.watermarkText" placeholder="输入水印文字" />
+                                    </el-form-item>
+                                    <el-form-item label="只读模式">
+                                        <el-switch v-model="wpsOptions.readOnly" />
+                                    </el-form-item>
+                                </el-form>
+                            </div>
+                        </el-popover>
+                    </div>
+
+                    <!-- WPS 编辑器(Word/Excel/PPT) -->
+                    <WpsEditor
+                        v-if="useWpsEditor && fileUrl && fileType !== 'pdf'"
+                        :file-url="fileUrl"
+                        :file-name="document?.fileName"
+                        :file-type="fileType"
+                        :mode="wpsMode"
+                        :user-id="userStore.userId?.toString()"
+                        :user-name="userStore.userName"
+                        :enable-comment="wpsOptions.enableComment"
+                        :enable-revision="wpsOptions.enableRevision"
+                        :enable-watermark="wpsOptions.enableWatermark"
+                        :watermark-text="wpsOptions.watermarkText"
+                        :enable-download="wpsOptions.enableDownload"
+                        :enable-print="wpsOptions.enablePrint"
+                        :enable-copy="wpsOptions.enableCopy"
+                        :enable-save="wpsOptions.enableSave"
+                        :enable-share="wpsOptions.enableShare"
+                        :enable-history="wpsOptions.enableHistory"
+                        :read-only="wpsOptions.readOnly"
+                        @ready="handleWpsReady"
+                        @error="handleWpsError"
+                        @save="handleWpsSave"
+                        @file-change="handleWpsFileChange"
+                        style="height: 600px;"
+                        ref="wpsEditorRef"
+                    />
+
+                    <!-- Word 文档预览 -->
+                    <VueOfficeDocx 
+                        v-else-if="fileType === 'docx' && fileBlob && !useWpsEditor" 
+                        :src="fileBlob" 
+                        @rendered="handleRendered"
+                        @error="handlePreviewError"
+                        style="height: 500px; overflow: auto;"
+                    />
+                    
+                    <!-- Excel 文档预览 -->
+                    <VueOfficeExcel 
+                        v-else-if="fileType === 'xlsx' && fileBlob && !useWpsEditor" 
+                        :src="fileBlob"
+                        @rendered="handleRendered"
+                        @error="handlePreviewError"
+                        style="height: 500px; overflow: auto;"
+                    />
+                    
+                    <!-- PDF 文档预览(支持签名和批注) -->
+                    <div v-else-if="fileType === 'pdf' && fileBlob" class="pdf-container" ref="pdfContainerRef">
+                        <!-- 工具栏 -->
+                        <div class="pdf-toolbar">
+                            <el-radio-group v-model="annotationMode" size="small">
+                                <el-radio-button label="signature">
+                                    <el-icon><Edit /></el-icon>
+                                    签名
+                                </el-radio-button>
+                                <el-radio-button label="text">
+                                    <el-icon><ChatLineSquare /></el-icon>
+                                    批注
+                                </el-radio-button>
+                            </el-radio-group>
+                            <span class="toolbar-tip">
+                                {{ annotationMode === 'signature' ? '点击 PDF 添加签名' : '点击 PDF 添加文字批注' }}
+                            </span>
+                        </div>
+                        
+                        <div class="pdf-wrapper" @click="handlePdfClick">
+                            <VueOfficePdf 
+                                :src="fileBlob"
+                                @rendered="handleRendered"
+                                @error="handlePreviewError"
+                            />
+                        </div>
+                        
+                        <!-- 签名预览层 -->
+                        <div 
+                            v-for="(signature, index) in signatures" 
+                            :key="'sig-' + index"
+                            class="signature-preview"
+                            :style="{
+                                left: signature.x + 'px',
+                                top: signature.y + 'px'
+                            }"
+                            @click.stop="removeSignature(index)"
+                        >
+                            <img :src="signatureImage" alt="签名" />
+                            <el-icon class="remove-icon"><Close /></el-icon>
+                        </div>
+                        
+                        <!-- 批注预览层 -->
+                        <div 
+                            v-for="(annotation, index) in annotations" 
+                            :key="'ann-' + index"
+                            class="annotation-preview"
+                            :style="{
+                                left: annotation.x + 'px',
+                                top: annotation.y + 'px'
+                            }"
+                            @click.stop
+                        >
+                            <input
+                                v-if="annotation.editing"
+                                v-model="annotation.text"
+                                class="annotation-input"
+                                placeholder="输入批注..."
+                                @blur="finishAnnotationEdit(index)"
+                                @keyup.enter="finishAnnotationEdit(index)"
+                            />
+                            <div 
+                                v-else
+                                class="annotation-text"
+                                @dblclick="startAnnotationEdit(index)"
+                            >
+                                {{ annotation.text || '空批注' }}
+                            </div>
+                            <el-icon class="remove-icon" @click="removeAnnotation(index)"><Close /></el-icon>
+                        </div>
+                    </div>
+                    
+                    <!-- 加载中 -->
+                    <div v-if="previewLoading" class="preview-loading">
+                        <el-icon class="is-loading"><Loading /></el-icon>
+                        <span>文档加载中...</span>
+                    </div>
+                    
+                    <!-- 预览失败提示 -->
+                    <el-alert 
+                        v-if="previewError" 
+                        type="error" 
+                        :title="previewError" 
+                        :closable="false"
+                        style="margin-top: 10px;"
+                    />
+                </div>
+            </el-form-item>
+            
+            <el-form-item :label="t('document.document.auditForm.result')" prop="result">
+                <el-radio-group v-model="auditForm.result">
+                    <el-radio label="3">{{ t('document.document.auditForm.pass') }}</el-radio>
+                    <el-radio label="2">{{ t('document.document.auditForm.reject') }}</el-radio>
+                </el-radio-group>
+            </el-form-item>
+            <el-form-item :label="t('document.document.auditForm.reason')" prop="reason"
+                v-if="auditForm.result === '2'">
+                <el-input v-model="auditForm.reason" type="textarea" :rows="3"
+                    :placeholder="t('document.document.auditForm.reasonPlaceholder')" />
+            </el-form-item>
+        </el-form>
+        <template #footer>
+            <div class="dialog-footer">
+                <el-button :loading="loading" type="primary" @click="submitForm">{{
+                    t('document.document.button.submit') }}</el-button>
+                <el-button @click="handleCancel">{{ t('document.document.button.cancel') }}</el-button>
+            </div>
+        </template>
+    </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, nextTick, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import type { FormInstance } from 'element-plus';
+import { ElMessage } from 'element-plus';
+import { Loading, Close, Edit, ChatLineSquare, Setting } from '@element-plus/icons-vue';
+import VueOfficeDocx from '@vue-office/docx';
+import VueOfficeExcel from '@vue-office/excel';
+import VueOfficePdf from '@vue-office/pdf';
+import '@vue-office/docx/lib/index.css';
+import '@vue-office/excel/lib/index.css';
+import request from '@/utils/request';
+import WpsEditor from '@/components/WpsEditor/index.vue';
+import { useUserStore } from '@/store/modules/user';
+
+interface TextAnnotation {
+    x: number;
+    y: number;
+    text: string;
+    editing: boolean;
+    timestamp: number;
+}
+
+interface Signature {
+    x: number;
+    y: number;
+    pageX: number;
+    pageY: number;
+    page: number;
+}
+
+interface Document {
+    id: number | string;
+    name?: string;
+    ossId?: number | string;
+    fileName?: string;
+}
+
+interface AuditData {
+    documentId: number | string;
+    result: number;
+    rejectReason?: string;
+}
+
+interface Props {
+    modelValue: boolean;
+    document?: Document | null;
+    title?: string;
+    auditApi: (data: AuditData) => Promise<any>;
+}
+
+interface Emits {
+    (e: 'update:modelValue', value: boolean): void;
+    (e: 'success'): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const { t } = useI18n();
+const userStore = useUserStore();
+
+const dialogVisible = ref(false);
+const loading = ref(false);
+const auditFormRef = ref<FormInstance>();
+const previewLoading = ref(false);
+const previewError = ref('');
+const fileBlob = ref<ArrayBuffer | null>(null);
+const fileUrl = ref(''); // 文件 URL,用于 WPS 编辑器
+const useWpsEditor = ref(false); // 是否使用 WPS 编辑器
+const wpsMode = ref<'simple' | 'normal'>('normal'); // WPS 编辑模式
+const wpsEditorRef = ref<InstanceType<typeof WpsEditor>>();
+const pdfContainerRef = ref<HTMLDivElement>();
+const signatures = ref<Array<{ x: number; y: number; pageX: number; pageY: number; page: number }>>([]);
+const annotations = ref<Array<{ x: number; y: number; text: string; page: number; editing: boolean }>>([]);
+const annotationMode = ref<'signature' | 'text'>('signature'); // 当前模式:签名或批注
+
+// WPS 高级选项
+const wpsOptions = ref({
+    enableComment: true,
+    enableRevision: true,
+    enableWatermark: false,
+    watermarkText: '',
+    enableDownload: true,
+    enablePrint: true,
+    enableCopy: true,
+    enableSave: true,
+    enableShare: false,
+    enableHistory: false,
+    readOnly: false
+});
+
+// 签名 SVG 图片(Base64 编码)
+const signatureImage = ref('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNmZmYiIHN0cm9rZT0iIzQwOWVmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzQwOWVmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+562+5ZCNPC90ZXh0Pjwvc3ZnPg==');
+
+const dialogVisible = ref(false);
+const loading = ref(false);
+const auditFormRef = ref<FormInstance>();
+const previewLoading = ref(false);
+const previewError = ref('');
+const fileBlob = ref<ArrayBuffer | null>(null);
+const fileUrl = ref(''); // 文件 URL,用于 WPS 编辑器
+const useWpsEditor = ref(false); // 是否使用 WPS 编辑器
+const pdfContainerRef = ref<HTMLDivElement>();
+const signatures = ref<Array<{ x: number; y: number; pageX: number; pageY: number; page: number }>>([]);
+const annotations = ref<Array<{ x: number; y: number; text: string; page: number; editing: boolean }>>([]);
+const annotationMode = ref<'signature' | 'text'>('signature'); // 当前模式:签名或批注
+// 签名 SVG 图片(Base64 编码)
+const signatureImage = ref('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9IiNmZmYiIHN0cm9rZT0iIzQwOWVmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzQwOWVmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+562+5ZCNPC90ZXh0Pjwvc3ZnPg==');
+
+// 审核表单数据
+const auditForm = ref({
+    id: 0 as number | string,
+    result: '3', // 默认通过
+    reason: ''
+});
+
+// 获取文件类型
+const fileType = computed(() => {
+    if (!props.document?.fileName) return '';
+    const fileName = props.document.fileName.toLowerCase();
+    if (fileName.endsWith('.docx') || fileName.endsWith('.doc')) return 'docx';
+    if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) return 'xlsx';
+    if (fileName.endsWith('.pdf')) return 'pdf';
+    return '';
+});
+
+// 判断是否可以预览
+const canPreview = computed(() => {
+    return ['docx', 'xlsx', 'pdf'].includes(fileType.value);
+});
+
+// 审核表单验证规则
+const auditRules = reactive({
+    result: [
+        {
+            required: true,
+            message: t('document.document.auditRule.resultRequired'),
+            trigger: 'change'
+        }
+    ],
+    reason: [
+        {
+            required: true,
+            message: t('document.document.auditRule.reasonRequired'),
+            trigger: 'blur'
+        }
+    ]
+});
+
+// 下载文件用于预览
+const downloadFileForPreview = async () => {
+    if (!props.document?.ossId || !canPreview.value) {
+        previewLoading.value = false;
+        return;
+    }
+
+    try {
+        previewLoading.value = true;
+        previewError.value = '';
+        fileBlob.value = null;
+        fileUrl.value = '';
+        signatures.value = [];
+        annotations.value = [];
+
+        // 构建文件 URL(用于 WPS 编辑器)
+        const baseURL = import.meta.env.VITE_APP_BASE_API;
+        fileUrl.value = `${baseURL}/resource/oss/downloadWithoutPermission/${props.document.ossId}`;
+
+        // 如果不使用 WPS 编辑器,下载文件用于 vue-office 预览
+        if (!useWpsEditor.value) {
+            const response = await request({
+                url: `/resource/oss/downloadWithoutPermission/${props.document.ossId}`,
+                method: 'get',
+                responseType: 'arraybuffer'
+            });
+
+            fileBlob.value = response;
+        }
+    } catch (error) {
+        console.error('下载文件失败:', error);
+        previewError.value = '文件下载失败,无法预览';
+        previewLoading.value = false;
+    }
+};
+
+// WPS 编辑器就绪
+const handleWpsReady = () => {
+    previewLoading.value = false;
+    console.log('WPS 编辑器就绪');
+};
+
+// WPS 编辑器保存
+const handleWpsSave = (data: any) => {
+    console.log('WPS 保存文档:', data);
+    ElMessage.success('文档已保存');
+};
+
+// WPS 文件变化
+const handleWpsFileChange = (data: any) => {
+    console.log('WPS 文件变化:', data);
+};
+
+// 处理 PDF 点击事件
+const handlePdfClick = (event: MouseEvent) => {
+    if (!pdfContainerRef.value) return;
+
+    const container = pdfContainerRef.value;
+    const rect = container.getBoundingClientRect();
+    
+    // 计算点击位置相对于容器的坐标
+    const x = event.clientX - rect.left + container.scrollLeft;
+    const y = event.clientY - rect.top + container.scrollTop;
+
+    if (annotationMode.value === 'signature') {
+        // 添加签名
+        signatures.value.push({
+            x: x - 50, // 签名图片宽度的一半,使其居中
+            y: y - 25, // 签名图片高度的一半,使其居中
+            pageX: x,
+            pageY: y,
+            page: 1
+        });
+        console.log('添加签名:', { x, y });
+    } else {
+        // 添加批注
+        annotations.value.push({
+            x: x - 75, // 批注框宽度的一半
+            y: y - 20, // 批注框高度的一半
+            text: '',
+            page: 1,
+            editing: true
+        });
+        // 自动聚焦到新添加的批注输入框
+        nextTick(() => {
+            const inputs = document.querySelectorAll('.annotation-input');
+            const lastInput = inputs[inputs.length - 1] as HTMLInputElement;
+            if (lastInput) {
+                lastInput.focus();
+            }
+        });
+        console.log('添加批注:', { x, y });
+    }
+};
+
+// 移除签名
+const removeSignature = (index: number) => {
+    signatures.value.splice(index, 1);
+};
+
+// 移除批注
+const removeAnnotation = (index: number) => {
+    annotations.value.splice(index, 1);
+};
+
+// 完成批注编辑
+const finishAnnotationEdit = (index: number) => {
+    if (annotations.value[index]) {
+        annotations.value[index].editing = false;
+    }
+};
+
+// 开始编辑批注
+const startAnnotationEdit = (index: number) => {
+    if (annotations.value[index]) {
+        annotations.value[index].editing = true;
+        nextTick(() => {
+            const inputs = document.querySelectorAll('.annotation-input');
+            const input = inputs[index] as HTMLInputElement;
+            if (input) {
+                input.focus();
+            }
+        });
+    }
+};
+
+// 监听modelValue变化
+watch(
+    () => props.modelValue,
+    (val) => {
+        dialogVisible.value = val;
+        if (val && props.document) {
+            auditForm.value = {
+                id: props.document.id,
+                result: '3', // 默认通过
+                reason: ''
+            };
+            // 重置预览状态
+            previewLoading.value = true;
+            previewError.value = '';
+            fileBlob.value = null;
+            // 下载文件用于预览
+            downloadFileForPreview();
+            // 重置表单验证
+            nextTick(() => {
+                auditFormRef.value?.clearValidate();
+            });
+        }
+    }
+);
+
+// 监听dialogVisible变化
+watch(dialogVisible, (val) => {
+    emit('update:modelValue', val);
+    if (!val) {
+        auditForm.value = {
+            id: 0,
+            result: '3',
+            reason: ''
+        };
+        previewLoading.value = false;
+        previewError.value = '';
+        fileBlob.value = null;
+        fileUrl.value = '';
+        signatures.value = [];
+        annotations.value = [];
+        annotationMode.value = 'signature';
+        useWpsEditor.value = false;
+    }
+});
+
+// 监听 useWpsEditor 变化
+watch(useWpsEditor, () => {
+    downloadFileForPreview();
+});
+
+// 文档渲染完成
+const handleRendered = () => {
+    previewLoading.value = false;
+    console.log('文档渲染完成');
+};
+
+// 文档预览错误
+const handlePreviewError = (error: any) => {
+    previewLoading.value = false;
+    previewError.value = '文档预览失败,请尝试下载后查看';
+    console.error('文档预览错误:', error);
+};
+
+// 取消操作
+const handleCancel = () => {
+    dialogVisible.value = false;
+};
+
+// 提交表单
+const submitForm = () => {
+    auditFormRef.value?.validate(async (valid: boolean) => {
+        if (valid) {
+            loading.value = true;
+            try {
+                const auditData: AuditData = {
+                    documentId: auditForm.value.id,
+                    result: parseInt(auditForm.value.result),
+                    rejectReason: auditForm.value.reason
+                };
+                await props.auditApi(auditData);
+                ElMessage.success(t('document.document.message.auditSuccess'));
+                dialogVisible.value = false;
+                emit('success');
+            } catch (error) {
+                console.error(t('document.document.message.auditFailed'), error);
+                ElMessage.error(t('document.document.message.auditFailed'));
+            } finally {
+                loading.value = false;
+            }
+        }
+    });
+};
+</script>
+
+<style scoped lang="scss">
+.dialog-footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 10px;
+}
+
+.document-preview {
+    width: 100%;
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    position: relative;
+    
+    .editor-switch {
+        padding: 10px;
+        background: #f5f7fa;
+        border-bottom: 1px solid #dcdfe6;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 15px;
+    }
+    
+    .wps-options {
+        .el-checkbox {
+            display: block;
+            margin-bottom: 8px;
+        }
+    }
+    
+    .preview-loading {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        height: 500px;
+        color: #909399;
+        
+        .el-icon {
+            font-size: 32px;
+            margin-bottom: 10px;
+        }
+    }
+}
+
+.pdf-container {
+    position: relative;
+    width: 100%;
+    height: 500px;
+    display: flex;
+    flex-direction: column;
+    background: #f5f5f5;
+    
+    .pdf-toolbar {
+        padding: 10px;
+        background: #fff;
+        border-bottom: 1px solid #dcdfe6;
+        display: flex;
+        gap: 15px;
+        align-items: center;
+        flex-shrink: 0;
+        
+        .toolbar-tip {
+            color: #909399;
+            font-size: 13px;
+        }
+    }
+    
+    .pdf-wrapper {
+        position: relative;
+        flex: 1;
+        overflow: auto;
+        cursor: crosshair;
+        
+        :deep(canvas) {
+            display: block;
+            margin: 0 auto;
+            background: white;
+        }
+    }
+    
+    .annotation-preview {
+        position: absolute;
+        min-width: 150px;
+        background: #fff3cd;
+        border: 2px solid #ffc107;
+        border-radius: 4px;
+        padding: 8px;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+        cursor: move;
+        z-index: 10;
+        
+        .annotation-input {
+            width: 100%;
+            padding: 4px 8px;
+            border: 1px solid #dcdfe6;
+            border-radius: 4px;
+            font-size: 13px;
+            outline: none;
+            
+            &:focus {
+                border-color: #409eff;
+            }
+        }
+        
+        .annotation-text {
+            min-height: 30px;
+            padding: 4px 8px;
+            cursor: text;
+            word-break: break-all;
+            white-space: pre-wrap;
+            color: #333;
+            font-size: 13px;
+        }
+        
+        .remove-icon {
+            position: absolute;
+            top: -8px;
+            right: -8px;
+            width: 20px;
+            height: 20px;
+            background: #f56c6c;
+            color: white;
+            border-radius: 50%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: pointer;
+            font-size: 12px;
+            
+            &:hover {
+                background: #f78989;
+            }
+        }
+    }
+    
+    .signature-preview {
+        position: absolute;
+        width: 100px;
+        height: 50px;
+        cursor: pointer;
+        transition: all 0.3s;
+        z-index: 10;
+        
+        &:hover {
+            transform: scale(1.1);
+            
+            .remove-icon {
+                opacity: 1;
+            }
+        }
+        
+        img {
+            width: 100%;
+            height: 100%;
+            object-fit: contain;
+            border: 2px solid #409eff;
+            border-radius: 4px;
+            background: rgba(64, 158, 255, 0.1);
+        }
+        
+        .remove-icon {
+            position: absolute;
+            top: -8px;
+            right: -8px;
+            width: 20px;
+            height: 20px;
+            background: #f56c6c;
+            color: white;
+            border-radius: 50%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            opacity: 0;
+            transition: opacity 0.3s;
+            font-size: 12px;
+        }
+    }
+}
+</style>

+ 261 - 0
src/components/WpsEditor/index.vue

@@ -0,0 +1,261 @@
+<template>
+    <div class="wps-editor-container">
+        <div v-if="loading" class="loading-container">
+            <el-icon class="is-loading"><Loading /></el-icon>
+            <span>正在加载 WPS 编辑器...</span>
+        </div>
+        <div v-if="error" class="error-container">
+            <el-alert type="error" title="加载失败" :closable="false">
+                <div style="white-space: pre-line; line-height: 1.6;">{{ error }}</div>
+            </el-alert>
+        </div>
+        <div ref="wpsContainerRef" class="wps-container"></div>
+    </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
+import { Loading } from '@element-plus/icons-vue';
+import { ElMessage } from 'element-plus';
+
+interface Props {
+    fileUrl: string;
+    fileName?: string;
+    fileType?: 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' | 'pdf';
+    mode?: 'simple' | 'normal';
+    userId?: string;
+    userName?: string;
+    enableComment?: boolean;
+    enableRevision?: boolean;
+    enableWatermark?: boolean;
+    watermarkText?: string;
+    enableDownload?: boolean;
+    enablePrint?: boolean;
+    enableCopy?: boolean;
+    enableSave?: boolean;
+    enableShare?: boolean;
+    enableHistory?: boolean;
+    readOnly?: boolean;
+}
+
+interface Emits {
+    (e: 'ready'): void;
+    (e: 'error', error: string): void;
+    (e: 'save', data: any): void;
+    (e: 'fileChange', data: any): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    mode: 'normal',
+    fileName: '未命名文档',
+    userId: 'user_' + Date.now(),
+    userName: '当前用户',
+    enableComment: true,
+    enableRevision: true,
+    enableWatermark: false,
+    watermarkText: '',
+    enableDownload: true,
+    enablePrint: true,
+    enableCopy: true,
+    enableSave: true,
+    enableShare: false,
+    enableHistory: false,
+    readOnly: false
+});
+
+const emit = defineEmits<Emits>();
+
+const wpsContainerRef = ref<HTMLDivElement>();
+const loading = ref(true);
+const error = ref('');
+let wpsInstance: any = null;
+
+// WPS 配置
+const WPS_APP_ID = 'SX20251229FLIAPDAPP';
+
+// 初始化 WPS 编辑器(符合官方文档)
+const initWpsEditor = async () => {
+    if (!wpsContainerRef.value || !props.fileUrl) {
+        error.value = '缺少必要参数';
+        loading.value = false;
+        emit('error', error.value);
+        return;
+    }
+
+    try {
+        loading.value = true;
+        error.value = '';
+
+        // 检查 WPS SDK 是否已加载
+        if (!(window as any).WebOfficeSDK) {
+            const errorMsg = 'WPS SDK 未加载。请按以下步骤操作:\n\n1. 从 WPS 官网下载 JSSDK\n2. 将 SDK 文件放到 public 目录\n3. 在 index.html 中引入 SDK 脚本\n\n或使用 CDN(需要网络连接)';
+            error.value = errorMsg;
+            loading.value = false;
+            emit('error', errorMsg);
+            return;
+        }
+
+        const WebOfficeSDK = (window as any).WebOfficeSDK;
+        
+        // 根据文件类型确定 officeType
+        let officeType = WebOfficeSDK.OfficeType.Writer;
+        if (props.fileType === 'xlsx' || props.fileType === 'xls') {
+            officeType = WebOfficeSDK.OfficeType.Spreadsheet;
+        } else if (props.fileType === 'pptx' || props.fileType === 'ppt') {
+            officeType = WebOfficeSDK.OfficeType.Presentation;
+        } else if (props.fileType === 'pdf') {
+            officeType = WebOfficeSDK.OfficeType.Pdf;
+        }
+
+        // 生成唯一的文件ID
+        const fileId = `${props.userId}_${Date.now()}`;
+
+        // 构建初始化配置(符合官方文档)
+        const config: any = {
+            // 必需参数
+            appId: WPS_APP_ID,
+            officeType: officeType,
+            fileId: fileId,
+            
+            // 挂载节点
+            mount: wpsContainerRef.value,
+            
+            // 自定义参数(会传递到回调接口)
+            customArgs: {
+                fileName: props.fileName,
+                fileUrl: props.fileUrl,
+                userId: props.userId,
+                userName: props.userName,
+                readOnly: props.readOnly,
+                enableComment: props.enableComment,
+                enableRevision: props.enableRevision
+            }
+        };
+
+        console.log('[WPS] 初始化配置:', config);
+
+        // 初始化 WebOffice
+        wpsInstance = await WebOfficeSDK.init(config);
+        
+        loading.value = false;
+        emit('ready');
+        console.log('[WPS] 编辑器初始化成功');
+        
+    } catch (err: any) {
+        console.error('[WPS] 初始化失败:', err);
+        error.value = err.message || '初始化失败';
+        loading.value = false;
+        emit('error', error.value);
+    }
+};
+
+// 保存文档
+const saveDocument = async () => {
+    if (!wpsInstance) {
+        ElMessage.warning('编辑器未初始化');
+        return null;
+    }
+
+    try {
+        console.log('[WPS] 开始保存文档');
+        // 调用 WPS 的保存方法
+        const result = await wpsInstance.save();
+        console.log('[WPS] 保存成功:', result);
+        ElMessage.success('保存成功');
+        emit('save', result);
+        return result;
+    } catch (err: any) {
+        console.error('[WPS] 保存失败:', err);
+        ElMessage.error('保存失败');
+        throw err;
+    }
+};
+
+// 销毁编辑器
+const destroyEditor = () => {
+    if (wpsInstance) {
+        try {
+            if (wpsInstance.destroy) {
+                wpsInstance.destroy();
+            }
+            wpsInstance = null;
+            console.log('[WPS] 编辑器已销毁');
+        } catch (err) {
+            console.error('[WPS] 销毁编辑器失败:', err);
+        }
+    }
+};
+
+// 监听文件 URL 变化
+watch(() => props.fileUrl, (newUrl) => {
+    if (newUrl) {
+        destroyEditor();
+        initWpsEditor();
+    }
+});
+
+onMounted(() => {
+    // 等待 WPS SDK 加载完成
+    let checkCount = 0;
+    const maxChecks = 100; // 10秒超时(100 * 100ms)
+    
+    const checkWpsSDK = setInterval(() => {
+        checkCount++;
+        
+        if ((window as any).WebOfficeSDK) {
+            clearInterval(checkWpsSDK);
+            console.log('[WPS] SDK 加载成功');
+            if (props.fileUrl) {
+                initWpsEditor();
+            }
+        } else if (checkCount >= maxChecks) {
+            clearInterval(checkWpsSDK);
+            console.error('[WPS] SDK 加载超时');
+            error.value = 'WPS SDK 加载超时。请检查:\n1. 网络连接是否正常\n2. index.html 中的 SDK 脚本是否正确引入\n3. SDK CDN 地址是否可访问';
+            loading.value = false;
+            emit('error', error.value);
+        }
+    }, 100);
+});
+
+onBeforeUnmount(() => {
+    destroyEditor();
+});
+
+// 暴露方法
+defineExpose({
+    saveDocument,
+    destroyEditor
+});
+</script>
+
+<style scoped lang="scss">
+.wps-editor-container {
+    width: 100%;
+    height: 100%;
+    position: relative;
+    
+    .loading-container,
+    .error-container {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        z-index: 10;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 10px;
+        
+        .el-icon {
+            font-size: 32px;
+            color: #409eff;
+        }
+    }
+    
+    .wps-container {
+        width: 100%;
+        height: 100%;
+    }
+}
+</style>

+ 415 - 0
src/utils/web-office-sdk-solution-v2.0.7/index.d.ts

@@ -0,0 +1,415 @@
+/**
+ * 定义用户头部其它按钮菜单
+ */
+interface IUserHeaderSubItemsConf {
+  /**
+   * 类型
+   */
+  type: 'export_img' | 'split_line' | 'custom';
+  /**
+   * 文本
+   */
+  text?: string;
+  /**
+   * 事件订阅
+   */
+  subscribe?: ((arg0?: any) => any) | string;
+}
+
+/**
+ * 定义用户头部按钮配置
+ */
+interface IUserHeaderButtonConf {
+  /**
+   * 提示
+   */
+  tooltip?: string;
+  /**
+   * 事件订阅
+   */
+  subscribe?: ((arg0?: any) => any) | string;
+
+  /**
+   * 菜单项
+   */
+  items?: IUserHeaderSubItemsConf[];
+}
+
+/**
+ * 用于保存iframe原始尺寸
+ */
+interface IIframeWH {
+  width: string;
+  height: string;
+}
+
+/**
+ * 定义用户头部配置
+ */
+interface IUserHeadersConf {
+  /**
+   * 返回按钮
+   */
+  backBtn?: IUserHeaderButtonConf;
+  /**
+   * 分享按钮
+   */
+  shareBtn?: IUserHeaderButtonConf;
+  /**
+   * 其他按钮
+   */
+  otherMenuBtn?: IUserHeaderButtonConf;
+}
+interface ICommonOptions {
+  /**
+   * 是否显示顶部区域,头部和工具栏
+   */
+  isShowTopArea: boolean;
+  /**
+   * 是否显示头部
+   */
+  isShowHeader: boolean;
+  /**
+   * 是否需要父级全屏
+   */
+  isParentFullscreen: boolean;
+  /**
+   * 是否在iframe区域内全屏
+   */
+  isIframeViewFullscreen: boolean;
+  /**
+   * 是否在浏览器区域内全屏
+   */
+  isBrowserViewFullscreen: boolean;
+}
+/**
+ * 文字自定义配置
+ */
+interface IWpsOptions {
+  /**
+   * 是否显示目录
+   */
+  isShowDocMap?: boolean;
+  /**
+   * 默认以最佳显示比例打开
+   */
+  isBestScale?: boolean;
+  /**
+   * pc-是否展示底部状态栏
+   */
+  isShowBottomStatusBar?: boolean;
+}
+
+/**
+ * 表格自定义配置
+ */
+interface IEtOptions {
+  /**
+   * pc-是否展示底部状态栏
+   */
+  isShowBottomStatusBar?: boolean;
+}
+
+/**
+ * pdf自定义配置
+ */
+interface IPDFOptions {
+  isShowComment?: boolean;
+  isInSafeMode?: boolean;
+  /**
+   * pc-是否展示底部状态栏
+   */
+  isShowBottomStatusBar?: boolean;
+}
+
+/**
+ * 演示自定义配置
+ */
+interface IWppOptions {
+  /**
+   * pc-是否展示底部状态栏
+   */
+  isShowBottomStatusBar?: boolean;
+}
+
+/**
+ * 数据表自定义配置
+ */
+ interface IDBOptions {
+  /**
+   * 是否显示使用反馈按钮
+   */
+   isShowFeedback?: boolean
+}
+
+/**
+ * 定义用户通用事件订阅
+ */
+interface ISubscriptionsConf {
+  [key: string]: any;
+  /**
+   * 导航事件
+   */
+  navigate: (arg0?: any) => any;
+  /**
+   * WPSWEB ready 事件
+   */
+  ready: (arg0?: any) => any;
+  /**
+   * 打印事件
+   */
+  print?: {
+    custom?: boolean,
+    subscribe: (arg0?: any) => any,
+  };
+  /**
+   * 导出 PDF 事件
+   */
+  exportPdf?: (arg0?: any) => any;
+}
+
+interface ITokenData {
+  token: string;
+  timeout: number;
+}
+
+interface IClipboardData {
+  text: string;
+  html: string;
+}
+
+/**
+ * 用户配置
+ */
+interface IConfig {
+  /**
+   * WPSWEB iframe 挂载点
+   */
+  mount?: HTMLElement;
+  /**
+   * url参数
+   */
+  url?: string;
+  wpsUrl?: string; // 即将废弃
+  /**
+   * 头部
+   */
+  headers?: IUserHeadersConf;
+   /**
+   * 头部
+   */
+  mode?: 'nomal' | 'simple';
+  /**
+   * 通用配置
+   */
+  commonOptions?: ICommonOptions;
+  /**
+   * 文字自定义配置
+   */
+  wpsOptions?: IWpsOptions;
+  wordOptions?: IWppOptions;
+  /**
+   * 表格自定义配置
+   */
+  etOptions?: IEtOptions;
+  excelOptions?: IEtOptions;
+  /**
+   * 演示自定义配置
+   */
+  wppOptions?: IWppOptions;
+  pptOptions?: IWppOptions;
+  /**
+   * pdf自定义配置
+   */
+  pdfOptions?: IPDFOptions;
+  /**
+   * db自定义配置
+   */
+  dbOptions?: IDBOptions;
+  /**
+   * 事件订阅
+   */
+  subscriptions?: ISubscriptionsConf;
+  // 调试模式
+  debug?: boolean;
+  commandBars?: IWpsCommandBars[];
+  print?: {
+    custom?: boolean,
+    callback?: string,
+  };
+
+  exportPdf?: {
+    callback?: string,
+  };
+
+  // 获取token
+  refreshToken?: TRefreshToken;
+
+  cooperUserAttribute?: {
+    isCooperUsersAvatarVisible?: boolean,
+    cooperUsersColor?: [{
+      userId: string | number,
+      color:  string,
+    }],
+  };
+}
+
+// type eventConfig = {
+//   eventName: cbEventNames,
+// }
+
+/** ============================= */
+interface IMessage {
+  eventName: string;
+  msgId?: string;
+  callbackId?: number;
+  data?: any;
+  url?: any;
+  result?: any;
+  error?: any;
+  _self?: boolean;
+  sdkInstanceId?: number
+}
+
+/**
+ *  WPSWEBAPI
+ */
+interface IWpsWebApi {
+  WpsApplication?: () => any;
+}
+
+/**
+ *  工具栏
+ */
+interface IWpsCommandBars {
+  cmbId: string;
+  attributes: IWpsCommandBarAttr[] | IWpsCommandBarObjectAttr;
+}
+
+/**
+ *  工具栏属性
+ */
+interface IWpsCommandBarAttr {
+  name: string;
+  value: any;
+}
+/**
+ *  工具栏属性
+ */
+interface IWpsCommandBarObjectAttr {
+  [propName: string]: any;
+}
+
+/**
+ * D.IWPS 定义
+ */
+
+interface IWps extends IWpsCompatible {
+  version: string;
+  url: string;
+  iframe: any;
+  Enum? : any; // 即将废弃
+  Events?: any; // 即将废弃
+  Props?: string;
+  advancedApiReady: () => Promise<any>;
+  /**
+   * 兼容1.x用法
+   */
+  ready?:() => Promise<any>;
+  destroy: () => Promise<any>;
+  WpsApplication?: () => any;
+  WordApplication?: () => any;
+  EtApplication?: () => any;
+  ExcelApplication?: () => any;
+  WppApplication?: () => any;
+  PPTApplication?: () => any;
+  PDFApplication?: () => any;
+  Application?: any;
+  CommonApi?: any;
+  commonApiReady: () => Promise<any>;
+  setToken: (tokenData: {
+    token: string, timeout?: number, hasRefreshTokenConfig: boolean,
+  }) => Promise<any>;
+  tokenData?: { token: string } | null;
+  commandBars?: IWpsCommandBars[] | null;
+  iframeReady?: boolean;
+  on: (eventName: string, handle: (event?: any) => void) => void;
+  off: (eventName: string, handle: (event?: any) => void) => void;
+  Stack?: any;
+  Free?: (objId: any) => Promise<any>;
+}
+
+/**
+ * 兼容1.x用法
+ */
+interface IWpsCompatible { 
+  tabs?: {
+    getTabs: () => Promise<Array<{tabKey: number, text: string}>>
+    switchTab: (tabKey: number) => Promise<any>,
+  }
+  setCommandBars?: (args: Array<IWpsCommandBars>) => Promise<void>;
+  save?: () => Promise<any>;
+  ApiEvent?: {
+    AddApiEventListener: (eventName: string, handle: (event?: any) => void) => void
+    RemoveApiEventListener: (eventName: string, handle: (event?: any) => void) => void
+  }
+  executeCommandBar?: (id: string) => void
+}
+
+interface IFlag {
+  advancedApiReadySended: boolean;
+  advancedApiReadySendedJust: boolean;
+  commonApiReadySended: boolean;
+  commonApiReadySendedJust: boolean;
+}
+
+interface ICbEvent {
+  refreshToken?: TRefreshToken;
+}
+
+interface IWebOfficeSDK {
+  config: (conf: IConfig) => IWps | undefined;
+  init: (conf: IAppConfig) => IWps | undefined;
+  OfficeType: OfficeType;
+}
+
+interface IReadyEvent {
+  event: string;
+  callback?: (...args: any) => void;
+  after?: boolean;
+}
+
+type TRefreshToken = () => ITokenData | Promise<ITokenData>;
+
+type sendMsgToWps = (msg: IMessage) => void;
+type getId = () => string;
+
+interface IAppConfig extends IConfig { 
+  appId: string;
+  fileId: string | number;
+  officeType: string;
+  /**
+   * @deprecated use token instead
+   */
+  fileToken?: string | ITokenData;
+  token?: string | ITokenData;
+  endpoint?: string;
+  customArgs?: Record<string, string | number>;
+  /**
+   * @deprecated A config item for WebOfficeSDK.config
+   */
+  url?: string;
+  mount?: any;
+  attrAllow: string | string[];
+  isListenResize?: boolean; // sdk内部是否监听resize变化,默认监听
+}
+
+type OfficeType = {
+  Spreadsheet: string,
+  Writer: string,
+  Presentation: string,
+  Pdf: string,
+  Otl: string
+}
+
+export { IAppConfig, ICbEvent, IClipboardData, ICommonOptions, IConfig, IDBOptions, IEtOptions, IFlag, IIframeWH, IMessage, IPDFOptions, IReadyEvent, ISubscriptionsConf, ITokenData, IUserHeaderButtonConf, IUserHeaderSubItemsConf, IUserHeadersConf, IWebOfficeSDK, IWppOptions, IWps, IWpsCommandBarAttr, IWpsCommandBarObjectAttr, IWpsCommandBars, IWpsCompatible, IWpsOptions, IWpsWebApi, OfficeType, TRefreshToken, getId, sendMsgToWps };

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
src/utils/web-office-sdk-solution-v2.0.7/web-office-sdk-solution-v2.0.7.cjs.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
src/utils/web-office-sdk-solution-v2.0.7/web-office-sdk-solution-v2.0.7.es.js


Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
src/utils/web-office-sdk-solution-v2.0.7/web-office-sdk-solution-v2.0.7.umd.js


+ 227 - 0
src/views/setting/carousel/index.vue

@@ -0,0 +1,227 @@
+<template>
+  <div class="p-2">
+    <transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
+      <div v-show="showSearch" class="mb-[10px]">
+        <el-card shadow="hover">
+          <el-form ref="queryFormRef" :model="queryParams" :inline="true">
+            <el-form-item>
+              <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
+              <el-button icon="Refresh" @click="resetQuery">重置</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+      </div>
+    </transition>
+
+    <el-card shadow="never">
+      <template #header>
+        <el-row :gutter="10" class="mb8">
+          <el-col :span="1.5">
+            <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['setting:carousel:add']">新增</el-button>
+          </el-col>
+          <el-col :span="1.5">
+            <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate()" v-hasPermi="['setting:carousel:edit']">修改</el-button>
+          </el-col>
+          <el-col :span="1.5">
+            <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete()" v-hasPermi="['setting:carousel:remove']">删除</el-button>
+          </el-col>
+          <el-col :span="1.5">
+            <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['setting:carousel:export']">导出</el-button>
+          </el-col>
+          <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
+        </el-row>
+      </template>
+
+      <el-table v-loading="loading" border :data="carouselList" @selection-change="handleSelectionChange">
+        <el-table-column type="selection" width="55" align="center" />
+        <el-table-column label="序号" align="center" prop="id" v-if="true" />
+        <el-table-column label="图片" align="center" prop="ossidUrl" width="100">
+          <template #default="scope">
+            <image-preview :src="scope.row.ossidUrl" :width="50" :height="50"/>
+          </template>
+        </el-table-column>
+        <el-table-column label="排序" align="center" prop="sort" />
+        <el-table-column label="备注" align="center" prop="note" />
+        <el-table-column label="操作" align="center" fixed="right"  class-name="small-padding fixed-width">
+          <template #default="scope">
+            <el-tooltip content="修改" placement="top">
+              <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['setting:carousel:edit']"></el-button>
+            </el-tooltip>
+            <el-tooltip content="删除" placement="top">
+              <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['setting:carousel:remove']"></el-button>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
+    </el-card>
+    <!-- 添加或修改轮播图设置对话框 -->
+    <el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" append-to-body>
+      <el-form ref="carouselFormRef" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="图片" prop="ossid">
+          <image-upload v-model="form.ossid"/>
+        </el-form-item>
+        <el-form-item label="排序" prop="sort">
+          <el-input v-model="form.sort" placeholder="请输入排序" />
+        </el-form-item>
+        <el-form-item label="备注" prop="note">
+          <el-input v-model="form.note" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div class="dialog-footer">
+          <el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
+          <el-button @click="cancel">取 消</el-button>
+        </div>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="Carousel" lang="ts">
+import { listCarousel, getCarousel, delCarousel, addCarousel, updateCarousel } from '@/api/setting/carousel';
+import { CarouselVO, CarouselQuery, CarouselForm } from '@/api/setting/carousel/types';
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
+
+const carouselList = ref<CarouselVO[]>([]);
+const buttonLoading = ref(false);
+const loading = ref(true);
+const showSearch = ref(true);
+const ids = ref<Array<string | number>>([]);
+const single = ref(true);
+const multiple = ref(true);
+const total = ref(0);
+
+const queryFormRef = ref<ElFormInstance>();
+const carouselFormRef = ref<ElFormInstance>();
+
+const dialog = reactive<DialogOption>({
+  visible: false,
+  title: ''
+});
+
+const initFormData: CarouselForm = {
+  id: undefined,
+  ossid: undefined,
+  sort: undefined,
+  note: undefined,
+}
+const data = reactive<PageData<CarouselForm, CarouselQuery>>({
+  form: {...initFormData},
+  queryParams: {
+    pageNum: 1,
+    pageSize: 10,
+    params: {
+    }
+  },
+  rules: {
+    id: [
+      { required: true, message: "序号不能为空", trigger: "blur" }
+    ],
+    ossid: [
+      { required: true, message: "图片不能为空", trigger: "blur" }
+    ],
+    sort: [
+      { required: true, message: "排序不能为空", trigger: "blur" }
+    ],
+  }
+});
+
+const { queryParams, form, rules } = toRefs(data);
+
+/** 查询轮播图设置列表 */
+const getList = async () => {
+  loading.value = true;
+  const res = await listCarousel(queryParams.value);
+  carouselList.value = res.rows;
+  total.value = res.total;
+  loading.value = false;
+}
+
+/** 取消按钮 */
+const cancel = () => {
+  reset();
+  dialog.visible = false;
+}
+
+/** 表单重置 */
+const reset = () => {
+  form.value = {...initFormData};
+  carouselFormRef.value?.resetFields();
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNum = 1;
+  getList();
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value?.resetFields();
+  handleQuery();
+}
+
+/** 多选框选中数据 */
+const handleSelectionChange = (selection: CarouselVO[]) => {
+  ids.value = selection.map(item => item.id);
+  single.value = selection.length != 1;
+  multiple.value = !selection.length;
+}
+
+/** 新增按钮操作 */
+const handleAdd = () => {
+  reset();
+  dialog.visible = true;
+  dialog.title = "添加轮播图设置";
+}
+
+/** 修改按钮操作 */
+const handleUpdate = async (row?: CarouselVO) => {
+  reset();
+  const _id = row?.id || ids.value[0]
+  const res = await getCarousel(_id);
+  Object.assign(form.value, res.data);
+  dialog.visible = true;
+  dialog.title = "修改轮播图设置";
+}
+
+/** 提交按钮 */
+const submitForm = () => {
+  carouselFormRef.value?.validate(async (valid: boolean) => {
+    if (valid) {
+      buttonLoading.value = true;
+      if (form.value.id) {
+        await updateCarousel(form.value).finally(() =>  buttonLoading.value = false);
+      } else {
+        await addCarousel(form.value).finally(() =>  buttonLoading.value = false);
+      }
+      proxy?.$modal.msgSuccess("操作成功");
+      dialog.visible = false;
+      await getList();
+    }
+  });
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (row?: CarouselVO) => {
+  const _ids = row?.id || ids.value;
+  await proxy?.$modal.confirm('是否确认删除轮播图设置编号为"' + _ids + '"的数据项?').finally(() => loading.value = false);
+  await delCarousel(_ids);
+  proxy?.$modal.msgSuccess("删除成功");
+  await getList();
+}
+
+/** 导出按钮操作 */
+const handleExport = () => {
+  proxy?.download('setting/carousel/export', {
+    ...queryParams.value
+  }, `carousel_${new Date().getTime()}.xlsx`)
+}
+
+onMounted(() => {
+  getList();
+});
+</script>

+ 1 - 0
vite.config.ts

@@ -19,6 +19,7 @@ export default defineConfig(({ mode, command }) => {
     // https://cn.vitejs.dev/config/#resolve-extensions
     plugins: createPlugins(env, command === 'build'),
     server: {
+      allowedHosts: ['yp1.yingpaipay.com'],
       host: '0.0.0.0',
       port: Number(env.VITE_APP_PORT),
       open: false,

+ 0 - 150
国际化处理说明-项目管理.md

@@ -1,150 +0,0 @@
-# 项目管理模块国际化说明
-
-## 已完成内容
-
-### 1. 静态页面国际化
-
-已完成以下部分的国际化:
-
-#### 搜索表单
-- 项目编号、名称、项目语言、项目类型
-- PD/GPD、PM/GPM、CTA/GCTA
-- 申办方、CRO
-- 开始时间、结束时间、创建时间、更新时间
-- 搜索、重置按钮
-
-#### 操作按钮
-- 新增、修改、删除、导出按钮
-
-#### 表格列
-- 序号、项目编号、名称、图标
-- 项目语言、项目类型、状态
-- PD/GPD、PM/GPM、CTA/GCTA
-- 申办方、CRO、备注
-- 创建者、创建时间、更新时间
-- 操作列(修改、删除tooltip)
-
-#### 表单对话框
-- 对话框标题(添加/修改项目)
-- 所有表单字段标签和占位符
-- 确定、取消按钮
-
-#### 提示信息
-- 删除确认提示
-- 操作成功、删除成功提示
-
-#### 验证规则
-- 序号、项目编号、名称、项目语言、项目类型的必填验证提示
-
-### 2. 国际化文件位置
-
-- **中文**: `src/lang/modules/project/management/zh_CN.ts`
-- **英文**: `src/lang/modules/project/management/en_US.ts`
-
-### 3. 使用方式
-
-在Vue组件中:
-
-```typescript
-import { useI18n } from 'vue-i18n';
-import { parseI18nName } from '@/utils/i18n';
-
-const { t } = useI18n();
-
-// 使用国际化函数
-t('project.management.search.code')
-t('project.management.button.add')
-t('project.management.table.name')
-
-// 解析字典的国际化名称
-parseI18nName(dict.label)
-```
-
-## 字典国际化
-
-### 需要国际化的字典
-
-1. **project_type** (项目类型)
-2. **project_language** (项目语言)
-
-### 字典国际化方法
-
-字典数据存储在数据库中,其`label`字段应该是JSON格式,包含多语言数据:
-
-```json
-{
-  "zh_CN": "中文名称",
-  "en_US": "English Name"
-}
-```
-
-### 数据库字典配置示例
-
-#### project_type (项目类型)
-
-| 字典值 | 字典标签(JSON格式) |
-|--------|---------------------|
-| 1 | `{"zh_CN":"临床试验","en_US":"Clinical Trial"}` |
-| 2 | `{"zh_CN":"真实世界研究","en_US":"Real World Study"}` |
-| 3 | `{"zh_CN":"上市后研究","en_US":"Post-Marketing Study"}` |
-
-#### project_language (项目语言)
-
-| 字典值 | 字典标签(JSON格式) |
-|--------|---------------------|
-| zh_CN | `{"zh_CN":"中文","en_US":"Chinese"}` |
-| en_US | `{"zh_CN":"英文","en_US":"English"}` |
-| zh_TW | `{"zh_CN":"繁体中文","en_US":"Traditional Chinese"}` |
-
-### 在管理后台配置字典
-
-1. 进入系统管理 -> 字典管理
-2. 找到对应的字典类型(`project_type`、`project_language`)
-3. 编辑字典数据的标签字段,使用JSON格式:
-   ```json
-   {"zh_CN":"中文标签","en_US":"English Label"}
-   ```
-
-### 前端处理
-
-前端代码已使用`parseI18nName(dict.label)`函数处理字典显示:
-
-```vue
-<!-- 下拉选择 -->
-<el-option 
-  v-for="dict in project_language" 
-  :key="dict.value" 
-  :label="parseI18nName(dict.label)" 
-  :value="dict.value"
-/>
-
-<!-- 表格显示 -->
-<dict-tag :options="project_language" :value="scope.row.language"/>
-```
-
-`parseI18nName`函数会:
-1. 解析JSON格式的label
-2. 根据当前语言环境返回对应的翻译
-3. 如果解析失败或找不到对应语言,返回原始值
-
-## 注意事项
-
-1. **字典标签格式**:必须是有效的JSON格式
-2. **语言代码**:使用`zh_CN`和`en_US`作为键名
-3. **回退机制**:如果当前语言没有翻译,会依次尝试zh_CN、en_US,最后使用原值
-4. **DictTag组件**:已自动支持国际化,无需修改
-
-## 类型错误说明
-
-当前存在一个与国际化无关的TypeScript类型错误:
-- 位置:第175行 `<image-upload v-model="form.icon"/>`
-- 原因:`form.icon`的类型定义与`image-upload`组件预期类型不匹配
-- 影响:不影响运行,仅是类型检查警告
-- 建议:需要更新`ManagementForm`类型定义或`image-upload`组件的props类型
-
-## 测试建议
-
-1. 切换语言测试所有文本是否正确显示
-2. 验证字典数据的国际化显示
-3. 测试表单验证提示信息
-4. 测试操作提示消息(成功、删除确认等)

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

@@ -0,0 +1,206 @@
+# 拖拽插入图片功能说明
+
+## 功能概述
+
+现在可以直接从外部(桌面、文件夹、浏览器等)拖拽图片到 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 编辑器中!

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

@@ -0,0 +1,285 @@
+# 拖拽插入用户名功能说明
+
+## 功能概述
+
+在文档审核对话框中,可以通过拖拽按钮将当前用户的昵称插入到 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);
+}
+```
+
+## 总结
+
+拖拽插入用户名功能让文档审核更加便捷:
+- ✅ 无需手动输入
+- ✅ 精确控制位置
+- ✅ 自动获取用户信息
+- ✅ 支持多种文档类型
+- ✅ 直观的拖拽操作
+
+现在就可以试试拖拽按钮到文档中!

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff