Explorar o código

审计助手模块v1版本代码提交

徐滕 hai 1 día
pai
achega
66066c0cd3

+ 99 - 0
db/audit_assistant_tables.sql

@@ -0,0 +1,99 @@
+-- ============================
+-- 审计助手模块 - 数据库表结构
+-- ============================
+-- 数据库: new_ccpm
+-- 创建时间: 2026-10-22
+-- ============================
+
+-- ----------------------------
+-- Table structure for audit_task
+-- ----------------------------
+DROP TABLE IF EXISTS `audit_task`;
+CREATE TABLE `audit_task` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `task_name` varchar(200) DEFAULT NULL COMMENT '任务名称',
+  `project_type` varchar(50) DEFAULT '建设工程' COMMENT '项目类型: 建设工程/市政工程/供电工程/装修工程',
+  `output_type` varchar(20) DEFAULT '审计报告' COMMENT '输出类型: 审计报告/审定单',
+  `status` varchar(20) DEFAULT 'draft' COMMENT '状态: draft-草稿/pending-待处理/processing-处理中/completed-已完成/failed-失败/cancelled-已取消',
+  
+  -- 业务数据
+  `file_count` int(11) DEFAULT 0 COMMENT '文件数量',
+  `selected_fields` text COMMENT '用户勾选的字段(JSON数组): ["project_name", "contract_amount"]',
+  
+  -- Dify集成
+  `dify_conversation_id` varchar(100) DEFAULT NULL COMMENT 'Dify会话ID(用于多轮对话)',
+  `dify_user_id` varchar(100) DEFAULT NULL COMMENT 'Dify用户标识(使用当前登录用户名)',
+  
+  -- 结果文件
+  `result_file_url` varchar(500) DEFAULT NULL COMMENT '生成的报告OSS URL',
+  `result_file_name` varchar(200) DEFAULT NULL COMMENT '报告文件名',
+  
+  -- 过程日志
+  `process_log` text COMMENT '处理日志(JSON数组)',
+  `error_msg` text COMMENT '错误信息',
+  
+  -- 统计信息
+  `start_time` datetime DEFAULT NULL COMMENT '开始处理时间',
+  `end_time` datetime DEFAULT NULL COMMENT '完成时间',
+  `duration_seconds` int(11) DEFAULT NULL COMMENT '处理耗时(秒)',
+  
+  -- 权限控制
+  `office_id` varchar(64) DEFAULT NULL COMMENT '所属部门ID(sys_office.id)',
+  
+  -- JeePlus标准字段
+  `create_by` varchar(64) DEFAULT NULL COMMENT '创建人ID',
+  `create_date` datetime DEFAULT NULL COMMENT '创建时间',
+  `update_by` varchar(64) DEFAULT NULL COMMENT '更新人ID',
+  `update_date` datetime DEFAULT NULL COMMENT '更新时间',
+  `remarks` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` char(1) DEFAULT '0' COMMENT '删除标记: 0-正常/1-删除',
+  
+  PRIMARY KEY (`id`),
+  KEY `idx_create_by` (`create_by`),
+  KEY `idx_status` (`status`),
+  KEY `idx_office` (`office_id`),
+  KEY `idx_create_date` (`create_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计任务表';
+
+-- ----------------------------
+-- Table structure for audit_file_extract
+-- ----------------------------
+DROP TABLE IF EXISTS `audit_file_extract`;
+CREATE TABLE `audit_file_extract` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `task_id` varchar(64) NOT NULL COMMENT '关联任务ID(audit_task.id)',
+  
+  -- 文件元数据
+  `file_name` varchar(200) DEFAULT NULL COMMENT '原始文件名',
+  `file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件URL',
+  `file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
+  `file_type` varchar(20) DEFAULT NULL COMMENT '文件类型: pdf/docx/xlsx/jpg/png',
+  `file_version` int(11) DEFAULT 1 COMMENT '文件版本(每次替换+1)',
+  `replaced_by` varchar(64) DEFAULT NULL COMMENT '被哪个版本替换(audit_file_extract.id)',
+  
+  -- Dify相关
+  `dify_file_id` varchar(100) DEFAULT NULL COMMENT 'Dify返回的文件ID',
+  
+  -- 提取结果
+  `extract_data` json DEFAULT NULL COMMENT '23个审计字段(JSON格式)',
+  `extract_status` varchar(20) DEFAULT 'pending' COMMENT '提取状态: success-成功/failed-失败/pending-待处理',
+  `extract_error` text COMMENT '提取失败原因',
+  
+  -- 编辑追踪
+  `edit_version` int(11) DEFAULT 1 COMMENT '编辑版本号(每次编辑+1)',
+  `last_edit_by` varchar(64) DEFAULT NULL COMMENT '最后编辑人ID',
+  `last_edit_time` datetime DEFAULT NULL COMMENT '最后编辑时间',
+  
+  -- JeePlus标准字段
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_date` datetime DEFAULT NULL,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_date` datetime DEFAULT NULL,
+  `remarks` varchar(500) DEFAULT NULL,
+  `del_flag` char(1) DEFAULT '0',
+  
+  PRIMARY KEY (`id`),
+  KEY `idx_task_id` (`task_id`),
+  KEY `idx_file_version` (`file_version`),
+  KEY `idx_replaced_by` (`replaced_by`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计文件提取结果表';

+ 43 - 0
src/main/java/com/jeeplus/common/config/Global.java

@@ -581,4 +581,47 @@ public class Global {
 	public static String getYyShUrl(){
 	public static String getYyShUrl(){
 		return getConfig("yy_shcxurl");
 		return getConfig("yy_shcxurl");
 	}
 	}
+
+	/**
+	 * 获取审计助手目录
+	 */
+	public static String getAuditAssistantDir() {
+		return getConfig("auditAssistantDir");
+	}
+
+	/**
+	 * 获取审计临时目录
+	 */
+	public static String getAuditTempDir() {
+		return getConfig("auditTempDir");
+	}
+
+	/**
+	 * 获取Dify API地址
+	 */
+	public static String getDifyApiUrl() {
+		return getConfig("dify.api.url");
+	}
+
+	/**
+	 * 获取Dify API密钥
+	 */
+	public static String getDifyApiKey() {
+		return getConfig("dify.api.key");
+	}
+
+	/**
+	 * 获取Dify API超时时间(毫秒)
+	 */
+	public static int getDifyApiTimeout() {
+		String timeout = getConfig("dify.api.timeout");
+		if (StringUtils.isNotBlank(timeout)) {
+			try {
+				return Integer.parseInt(timeout);
+			} catch (NumberFormatException e) {
+				return 300000; // 默认5分钟
+			}
+		}
+		return 300000;
+	}
 }
 }

+ 6 - 0
src/main/java/com/jeeplus/common/oss/OSSClientUtil.java

@@ -74,6 +74,8 @@ public class OSSClientUtil {
     private String workContractInfo = Global.getWorkContractInfo();
     private String workContractInfo = Global.getWorkContractInfo();
     private String militaryIndustryConfidentiality = Global.getMilitaryIndustryConfidentiality();
     private String militaryIndustryConfidentiality = Global.getMilitaryIndustryConfidentiality();
     private String qzBucketName = Global.getQzBucketName();;
     private String qzBucketName = Global.getQzBucketName();;
+    private String auditAssistantDir = Global.getAuditAssistantDir();
+    private String auditTempDir = Global.getAuditTempDir();
     private OSSClient ossClient;
     private OSSClient ossClient;
 
 
     public OSSClientUtil() {
     public OSSClientUtil() {
@@ -176,6 +178,10 @@ public class OSSClientUtil {
                 return uploadImg2OSS(fileFile,workContractInfo);
                 return uploadImg2OSS(fileFile,workContractInfo);
             case "militaryIndustryConfidentiality":
             case "militaryIndustryConfidentiality":
                 return uploadImg2OSS(fileFile,militaryIndustryConfidentiality);
                 return uploadImg2OSS(fileFile,militaryIndustryConfidentiality);
+            case "auditAssistant":
+                return uploadImg2OSS(fileFile, Global.getAuditAssistantDir());
+            case "auditTemp":
+                return uploadImg2OSS(fileFile, Global.getAuditTempDir());
             default:
             default:
                 return "";
                 return "";
         }
         }

+ 522 - 0
src/main/java/com/jeeplus/modules/audit_assistant/HANDOVER.md

@@ -0,0 +1,522 @@
+# 审计助手模块 - 开发交接文档
+
+> **生成时间**: 2026-10-22  
+> **开发者**: Qoder (Desktop) → QoderCN (IDEA Plugin)  
+> **用途**: 将已完成的后端代码交接给IDEA插件,继续前端开发
+
+---
+
+## 一、项目概述
+
+### 1.1 功能描述
+审计助手是一个集成Dify AI工作流的审计任务管理系统,主要功能包括:
+- 上传多个审计相关文件(PDF/Word/Excel/JPG)
+- 调用Dify工作流自动提取23个审计字段
+- 用户选择需要的字段进行一致性检查
+- 生成审计报告(Word/Excel格式)
+- 支持Excel在线编辑(Luckysheet)
+
+### 1.2 技术架构
+- **后端框架**: JeePlus (Spring MVC 4.0.8 + MyBatis 3.2.8 + Shiro 1.2.3)
+- **AI集成**: Dify Workflow (社区版 v1.17.1)
+- **文件存储**: 阿里云OSS (bucketName=xgxm-test)
+- **前端技术**: Layui + Luckysheet (在线表格编辑)
+- **数据库**: MySQL (utf8mb4)
+
+### 1.3 核心流程
+```
+用户上传文件 → 创建任务 → 第一轮(Dify提取字段/SSE流式) 
+→ 用户选择字段 → 第二轮(Dify生成报告/阻塞式) 
+→ 下载报告 / Excel在线编辑
+```
+
+---
+
+## 二、已完成的后端代码
+
+### 2.1 数据库层
+
+#### 表结构
+**文件**: `db/audit_assistant_tables.sql`
+
+**audit_task 表**(审计任务)
+- 主键: id (varchar(64))
+- 业务字段: task_name, project_type, output_type, status
+- Dify集成: dify_conversation_id, dify_user_id
+- 结果文件: result_file_url, result_file_name
+- 权限控制: office_id (关联sys_office表)
+- 统计信息: start_time, end_time, duration_seconds
+
+**audit_file_extract 表**(文件提取结果)
+- 主键: id (varchar(64))
+- 关联: task_id (外键到audit_task)
+- 文件元数据: file_name, file_url, file_size, file_type, file_version
+- 版本管理: replaced_by (指向新版本ID)
+- Dify集成: dify_file_id
+- 提取结果: extract_data (JSON), extract_status, extract_error
+- 编辑追踪: edit_version, last_edit_by, last_edit_time
+
+#### Mapper XML
+**文件**: 
+- `src/main/resources/mappings/modules/audit_assistant/AuditTaskDao.xml`
+- `src/main/resources/mappings/modules/audit_assistant/AuditFileExtractDao.xml`
+
+**关键点**:
+- 使用 `<include refid="auditTaskJoins"/>` 关联 sys_office 表
+- 查询条件支持 `office.id` 和 `officeId` 两种过滤方式
+- INSERT/UPDATE 使用 `#{office.id}` 设置部门ID
+
+### 2.2 Entity实体类
+
+#### AuditTask.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditTask.java`
+
+**关键字段**:
+```java
+private Office office;        // 所属部门对象(MyBatis关联查询用)
+private String officeId;      // 部门ID(查询过滤用)
+private String officeName;    // 部门名称(冗余字段,列表展示用)
+private String status;        // 状态: draft/pending/processing/completed/failed/cancelled
+private String selectedFields;// 用户勾选的字段(JSON数组)
+private String difyConversationId; // Dify会话ID(多轮对话用)
+```
+
+**重要设计**:
+- ✅ 使用 `Office office` 对象引用,而非 `String companyId`
+- ✅ 保留 `officeId` 用于查询条件
+- ✅ 添加 `officeName` 避免关联查询,提升性能
+
+#### AuditFileExtract.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditFileExtract.java`
+
+**版本管理策略**:
+- `fileVersion`: 从1开始递增,每次重新上传或编辑后+1
+- `replacedBy`: 外键指向新版本ID,null表示最新版本
+- `editVersion`: 只记录编辑次数(与fileVersion区分)
+- **所有历史版本保留**,不物理删除(符合审计合规要求)
+
+### 2.3 Service服务层
+
+#### AuditAssistantService.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/service/AuditAssistantService.java`
+
+**核心方法**:
+
+1. **createTask()** - 创建任务并上传文件
+   - 生成UUID作为taskId
+   - 自动设置 `office = UserUtils.getUser().getOffice()`
+   - 为每个文件生成OSS路径:`attachment-file/audit-assistant/YYYY/MM/DD/{uuid}/{timestamp}_filename`
+   - 初始状态: "draft"
+
+2. **startFirstRound()** - 第一轮字段提取(SSE流式)
+   - 更新状态为 "processing"
+   - 调用 `DifyApiClient.chatStream()` 异步处理
+   - 通过 `SseEventHandler` 实时接收提取结果
+   - 保存 `difyConversationId` 供第二轮使用
+
+3. **startSecondRound()** - 第二轮报告生成(阻塞式)
+   - 接收用户选择的字段(selectedFields JSON数组)
+   - 构建 `doc_results` 包含所有文件的提取数据
+   - 调用 `DifyApiClient.chat()` 阻塞等待报告生成
+   - TODO: 将报告内容转换为Word/Excel并上传OSS
+
+4. **getExcelData()** - Excel转Luckysheet
+   - 从OSS下载Excel文件
+   - 调用 `ExcelConvertUtil.excelToLuckysheet()` 转换
+   - 返回JSON供前端Luckysheet组件渲染
+
+5. **saveEditedExcel()** - 保存编辑后的Excel
+   - 接收Luckysheet JSON数据
+   - 调用 `ExcelConvertUtil.luckysheetToExcel()` 转换回Excel
+   - 创建新版本(fileVersion+1),标记旧版本replacedBy
+   - 上传新文件到OSS
+
+6. **deleteTask()** - 逻辑删除
+   - 设置 `del_flag='1'`
+   - **不删除OSS文件**(审计追溯需要)
+
+### 2.4 Controller控制层
+
+#### AuditAssistantController.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/web/AuditAssistantController.java`
+
+**代码规范**:
+- ✅ 统一使用 `@RequestMapping`(而非@GetMapping/@PostMapping)
+- ✅ GET请求返回JSP视图路径,API请求添加@ResponseBody
+- ✅ 返回 `Map<String, Object>` 或 Entity对象
+- ✅ 使用 `@ModelAttribute` 初始化实体
+
+**API接口列表**:
+
+| 路径 | 方法 | 说明 | 权限 |
+|------|------|------|------|
+| `/audit/assistant/list` | GET | 任务列表页 | audit:assistant:list |
+| `/audit/assistant/listData` 或 `/list/data` | GET/POST | 任务列表数据API | - |
+| `/audit/assistant/form` | GET | 新建/编辑任务页 | audit:assistant:edit |
+| `/audit/assistant/upload` | POST | 上传文件创建任务 | - |
+| `/audit/assistant/detail` | GET | 任务详情页 | audit:assistant:view |
+| `/audit/assistant/startExtract` | POST | 开始第一轮处理 | - |
+| `/audit/assistant/generateReport` | POST | 生成报告(第二轮) | - |
+| `/audit/assistant/getStatus` | GET | 查询处理状态(轮询) | - |
+| `/audit/assistant/excelEdit` | GET | Excel编辑页 | audit:assistant:edit |
+| `/audit/assistant/getExcelData` | GET | 获取Excel数据(转Luckysheet) | - |
+| `/audit/assistant/saveExcel` | POST | 保存编辑后的Excel | - |
+| `/audit/assistant/reExtract` | POST | 重新处理 | - |
+| `/audit/assistant/delete` | POST | 删除任务(逻辑删除) | - |
+| `/audit/assistant/downloadReport` | GET | 下载报告 | - |
+| `/audit/assistant/downloadFile` | GET | 下载原始文件 | - |
+
+**注意**: `listData` 接口同时支持 `/listData` 和 `/list/data` 两种路径格式
+
+### 2.5 工具类
+
+#### DifyApiClient.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/utils/DifyApiClient.java`
+
+**两种调用模式**:
+
+1. **chatStream()** - SSE流式调用(第一轮)
+   - 使用 `response_mode="streaming"`
+   - 通过 `SseEventHandler` 异步处理事件
+   - 适合长时间运行的任务,可实时展示进度
+   - 超时配置: 5分钟读取超时
+
+2. **chat()** - 阻塞式调用(第二轮)
+   - 使用 `response_mode="blocking"`
+   - 等待完整响应后返回
+   - 适合快速响应的场景
+
+**请求体结构**:
+```json
+{
+  "inputs": {...},
+  "query": "请提取以下文件中的审计字段",
+  "response_mode": "streaming",
+  "user": "当前登录用户名",
+  "conversation_id": "",
+  "files": [
+    {"type": "document", "url": "http://..."}
+  ]
+}
+```
+
+#### ExcelConvertUtil.java
+**文件**: `src/main/java/com/jeeplus/modules/audit_assistant/utils/ExcelConvertUtil.java`
+
+**POI版本兼容性**:
+- 使用 POI 3.16 的 `Cell.CELL_TYPE_*` 常量(非枚举)
+- 使用 `HSSFDateUtil` 处理日期判断和转换
+- 使用 `DecimalFormat` 格式化数字,避免科学计数法
+
+**转换逻辑**:
+- Excel → Luckysheet: 遍历单元格,提取行号(r)、列号(c)、值(v)、显示值(m)
+- Luckysheet → Excel: 根据r/c定位单元格,设置值
+
+---
+
+## 三、配置文件
+
+### jeeplus.properties
+**文件**: `src/main/resources/jeeplus.properties`
+
+**新增配置**:
+```properties
+# ==================== 审计助手配置 ====================
+# OSS目录配置
+auditAssistantDir=attachment-file/audit-assistant/
+auditTempDir=attachment-file/audit-temp/
+
+# Dify API配置
+dify.api.url=http://your-dify-server/v1
+dify.api.key=your-api-key-here
+dify.api.timeout=300000
+```
+
+### Global.java
+**文件**: `src/main/java/com/jeeplus/common/config/Global.java`
+
+**新增方法**:
+```java
+public static String getAuditAssistantDir() { ... }
+public static String getAuditTempDir() { ... }
+public static String getDifyApiUrl() { ... }
+public static String getDifyApiKey() { ... }
+public static int getDifyApiTimeout() { ... }
+```
+
+### OSSClientUtil.java
+**文件**: `src/main/java/com/jeeplus/common/oss/OSSClientUtil.java`
+
+**新增case分支**:
+```java
+case "auditAssistant":
+    uploadPath = Global.getAuditAssistantDir();
+    break;
+case "auditTemp":
+    uploadPath = Global.getAuditTempDir();
+    break;
+```
+
+---
+
+## 四、待完成的前端工作
+
+### 4.1 JSP页面清单
+
+需要在 `src/main/webapp/webpage/modules/audit_assistant/` 目录下创建:
+
+#### 1. auditTaskList.jsp(任务列表页)
+**布局**:
+- 左侧面板(40%宽度):
+  - 文件上传区域(支持拖拽、多文件)
+  - 项目类型选择下拉框
+  - 输出类型选择下拉框
+  - "开始处理"按钮
+  - 任务列表(表格,支持状态筛选Tab)
+- 右侧面板(60%宽度):
+  - 任务详情展示
+  - 文件列表(含提取状态)
+  - 字段选择复选框
+  - "生成报告"按钮
+  - 处理进度条
+
+**AJAX接口调用**:
+- 加载任务列表: `GET /audit/assistant/listData`
+- 上传文件: `POST /audit/assistant/upload`
+- 开始处理: `POST /audit/assistant/startExtract`
+- 查询状态: `GET /audit/assistant/getStatus?taskId=xxx`(轮询)
+- 生成报告: `POST /audit/assistant/generateReport`
+
+#### 2. auditTaskForm.jsp(新建/编辑任务表单)
+**字段**:
+- 任务名称(文本输入)
+- 项目类型(下拉选择:建设工程/市政工程/供电工程/装修工程)
+- 输出类型(下拉选择:审计报告/审定单)
+- 所属部门(自动填充当前用户部门,不可修改)
+
+**注意**: 此页面可能不需要,因为任务是通过上传文件直接创建的
+
+#### 3. auditTaskDetail.jsp(任务详情页)
+**展示内容**:
+- 任务基本信息(名称、类型、状态、创建时间等)
+- 文件列表(文件名、大小、版本、提取状态)
+- 提取的字段数据(表格展示23个字段)
+- 处理日志(JSON格式,展示处理过程)
+- 下载报告按钮(如果已完成)
+
+#### 4. excelEdit.jsp(Excel在线编辑页)
+**集成Luckysheet**:
+- 引入Luckysheet CSS和JS
+- 初始化Luckysheet容器
+- 调用 `GET /audit/assistant/getExcelData?fileId=xxx` 获取数据
+- 渲染Luckysheet表格
+- "保存"按钮调用 `POST /audit/assistant/saveExcel`
+- "返回"按钮回到任务详情页
+
+**Luckysheet配置示例**:
+```javascript
+luckysheet.create({
+  container: 'luckysheet',
+  data: luckysheetData,  // 从后端获取的JSON
+  title: fileName,
+  lang: 'zh',
+  showtoolbar: true,
+  showinfobar: false,
+  showsheetbar: true,
+  showstatisticBar: true,
+  allowCopy: true,
+  enableAddRow: true,
+  enableAddCol: true,
+  sheetFormulaBar: true,
+});
+```
+
+### 4.2 JavaScript文件
+
+建议在 `src/main/webapp/static/modules/audit_assistant/` 创建:
+
+#### auditTask.js
+**功能**:
+- 任务列表加载和刷新
+- 文件上传处理(FormData + AJAX)
+- 状态轮询(setInterval每2秒查询一次)
+- 字段选择(复选框联动)
+- 报告生成触发
+
+#### excelEdit.js
+**功能**:
+- Luckysheet初始化
+- 数据加载和保存
+- 版本切换(如果有多个版本)
+
+### 4.3 CSS样式
+
+建议使用Layui默认样式,必要时自定义:
+- 文件上传区域样式
+- 任务列表表格样式
+- 进度条样式
+- 字段选择复选框组样式
+
+---
+
+## 五、关键技术点
+
+### 5.1 权限控制
+- **方法级**: 使用 `@RequiresPermissions("audit:assistant:list")` 注解
+- **数据级**: 通过 `office_id` 实现部门隔离
+- **查询过滤**: `auditTask.setCreateBy(UserUtils.getUser())` 只查当前用户的任务
+
+### 5.2 版本管理
+- **fileVersion**: 每次上传或编辑后+1
+- **replacedBy**: 指向新版本ID,形成版本链
+- **查询最新版本**: `SELECT * FROM audit_file_extract WHERE task_id=? ORDER BY file_version DESC LIMIT 1`
+- **历史版本保留**: 不物理删除,便于审计追溯
+
+### 5.3 Dify集成
+- **第一轮(SSE流式)**:
+  ```java
+  client.chatStream(userId, inputs, fileUrls, new SseEventHandler() {
+      public void onMessage(String data) {
+          // 解析JSON,提取字段
+      }
+      public void onComplete() {
+          // 保存conversation_id,更新状态
+      }
+      public void onError(Exception e) {
+          // 记录错误,更新状态为failed
+      }
+  });
+  ```
+
+- **第二轮(阻塞式)**:
+  ```java
+  Map<String, Object> inputs = new HashMap<>();
+  inputs.put("selected_fields", JSON.parseArray(selectedFields));
+  inputs.put("doc_results", docResults);
+  inputs.put("output_type", task.getOutputType());
+  
+  String result = client.chat(userId, inputs, "请生成审计报告");
+  JSONObject resultJson = JSON.parseObject(result);
+  String reportContent = resultJson.getString("answer");
+  ```
+
+### 5.4 Excel ↔ Luckysheet转换
+- **Excel → Luckysheet**:
+  ```java
+  JSONArray sheets = ExcelConvertUtil.excelToLuckysheet(inputStream);
+  // 返回格式: [{"name": "Sheet1", "celldata": [...]}]
+  ```
+
+- **Luckysheet → Excel**:
+  ```java
+  Workbook workbook = ExcelConvertUtil.luckysheetToExcel(luckysheetData);
+  ByteArrayOutputStream bos = new ByteArrayOutputStream();
+  workbook.write(bos);
+  byte[] excelBytes = bos.toByteArray();
+  // 上传到OSS
+  ```
+
+### 5.5 OSS文件路径规则
+```
+attachment-file/audit-assistant/2026/10/22/{uuid}/{timestamp}_原文件名
+```
+- **uuid**: 确保唯一性,避免冲突
+- **timestamp**: 保证同一uuid下多次上传不覆盖
+- **日期分层**: 便于管理和清理
+
+---
+
+## 六、测试要点
+
+### 6.1 单元测试
+- [ ] 任务创建和文件上传
+- [ ] 第一轮字段提取(模拟Dify响应)
+- [ ] 第二轮报告生成(模拟Dify响应)
+- [ ] Excel ↔ Luckysheet转换
+- [ ] 版本管理(上传新版本、查询历史版本)
+- [ ] 逻辑删除
+
+### 6.2 集成测试
+- [ ] 完整流程:上传 → 提取 → 选择字段 → 生成报告 → 下载
+- [ ] Excel在线编辑:加载 → 修改 → 保存 → 重新加载
+- [ ] 并发测试:多人同时上传和处理
+- [ ] 异常处理:Dify超时、OSS上传失败、文件格式错误
+
+### 6.3 前端测试
+- [ ] 文件上传(拖拽、点击、多文件)
+- [ ] 状态轮询(实时更新进度)
+- [ ] 字段选择(复选框联动)
+- [ ] Luckysheet渲染和编辑
+- [ ] 响应式布局(不同屏幕尺寸)
+
+---
+
+## 七、常见问题排查
+
+### 7.1 404 Not Found
+**原因**: URL路径不匹配  
+**解决**: 检查Controller的 `@RequestMapping` 是否与前端请求一致  
+**示例**: 前端请求 `/list/data`,后端定义 `listData` → 修改为 `@RequestMapping(value = {"listData", "list/data"})`
+
+### 7.2 Dify API调用失败
+**检查项**:
+- [ ] `jeeplus.properties` 中 `dify.api.url` 和 `dify.api.key` 配置正确
+- [ ] Dify服务器可访问(ping测试)
+- [ ] API Key有效且未过期
+- [ ] 网络连接正常(无防火墙拦截)
+
+### 7.3 OSS上传失败
+**检查项**:
+- [ ] OSSClientUtil中添加了 `auditAssistant` case分支
+- [ ] OSS账号权限正确(有写权限)
+- [ ] Bucket名称正确(xgxm-test)
+- [ ] 网络可访问OSS endpoint
+
+### 7.4 Excel转换异常
+**原因**: POI版本不兼容  
+**解决**: 确保使用 `Cell.CELL_TYPE_*` 常量而非枚举,使用 `HSSFDateUtil` 处理日期
+
+### 7.5 Luckysheet不显示
+**检查项**:
+- [ ] CSS和JS文件正确引入
+- [ ] 容器div存在且有宽高
+- [ ] 返回的JSON格式正确(包含name和celldata)
+- [ ] 浏览器控制台无报错
+
+---
+
+## 八、后续优化建议
+
+### 8.1 功能增强
+- [ ] 支持Word文档在线编辑(目前仅Excel)
+- [ ] 添加字段对比功能(高亮差异)
+- [ ] 支持批量导出报告
+- [ ] 添加任务模板功能
+- [ ] 支持自定义字段映射
+
+### 8.2 性能优化
+- [ ] Excel转换使用异步处理(大文件耗时较长)
+- [ ] Luckysheet数据分页加载(超过1000行时分页)
+- [ ] OSS文件使用CDN加速
+- [ ] Dify响应缓存(相同文件不重复提取)
+
+### 8.3 安全加固
+- [ ] 文件上传大小限制(如50MB)
+- [ ] 文件类型白名单校验
+- [ ] XSS防护(Luckysheet内容过滤)
+- [ ] SQL注入防护(MyBatis参数化查询已具备)
+- [ ] OSS文件访问权限控制(签名URL)
+
+---
+
+## 九、联系方式
+
+如有问题,请提供:
+1. 具体的错误信息(截图或文字)
+2. 相关的代码片段(前后10-20行)
+3. 你期望的结果是什么
+4. 你已经尝试过什么方法
+
+---
+
+**文档结束**

+ 213 - 0
src/main/java/com/jeeplus/modules/audit_assistant/README.md

@@ -0,0 +1,213 @@
+# 审计助手模块 - 开发任务清单
+
+## 项目概述
+基于Dify工作流 + Luckysheet + OSS的审计助手系统,实现文件上传、字段提取、在线编辑、报告生成等功能。
+
+---
+
+## 任务进度总览
+
+| 阶段 | 任务 | 状态 | 完成时间 | 备注 |
+|------|------|------|----------|------|
+| 1 | 数据库表与配置 | ✅ 已完成 | 2026-10-22 | SQL脚本已创建,配置文件已修改 |
+| 2 | Entity实体类 | ✅ 已完成 | 2026-10-22 | AuditTask.java, AuditFileExtract.java |
+| 3 | DAO层与Mapper | ✅ 已完成 | 2026-10-22 | DAO接口和XML映射文件 |
+| 4 | Service业务逻辑 | ✅ 已完成 | 2026-10-22 | 核心Service + Dify客户端 + Excel工具 |
+| 5 | Controller API | ✅ 已完成 | 2026-10-22 | REST API接口(需完善部分TODO) |
+| 6 | 前端JSP页面 | ⏳ 进行中 | - | 列表页已完成,其他页面待开发 |
+
+---
+
+## 阶段1: 数据库表与配置文件
+
+### 1.1 创建数据库表
+- [ ] `audit_task` - 审计任务主表
+- [ ] `audit_file_extract` - 文件提取结果表
+
+**位置**: MySQL数据库 `new_ccpm`
+
+**SQL脚本**: 见下方实现代码
+
+### 1.2 配置文件修改
+- [ ] `jeeplus.properties` - 添加Dify和OSS配置
+- [ ] `Global.java` - 添加getter方法
+- [ ] `OSSClientUtil.java` - 添加auditAssistant case
+
+**位置**: 
+- `src/main/resources/jeeplus.properties`
+- `src/main/java/com/jeeplus/common/config/Global.java`
+- `src/main/java/com/jeeplus/common/oss/OSSClientUtil.java`
+
+---
+
+## 阶段2: Entity实体类
+
+### 2.1 AuditTask.java
+- [ ] 继承DataEntity<AuditTask>
+- [ ] 定义所有字段及getter/setter
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditTask.java`
+
+### 2.2 AuditFileExtract.java
+- [ ] 继承DataEntity<AuditFileExtract>
+- [ ] 定义所有字段及getter/setter
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditFileExtract.java`
+
+---
+
+## 阶段3: DAO层与Mapper XML
+
+### 3.1 AuditTaskDao.java
+- [ ] 接口定义,继承CrudDao<AuditTask>
+- [ ] 自定义查询方法
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/dao/AuditTaskDao.java`
+
+### 3.2 AuditFileExtractDao.java
+- [ ] 接口定义,继承CrudDao<AuditFileExtract>
+- [ ] 自定义查询方法
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/dao/AuditFileExtractDao.java`
+
+### 3.3 AuditTaskDao.xml
+- [ ] MyBatis映射文件
+- [ ] SQL语句定义
+
+**位置**: `src/main/resources/mappings/modules/audit_assistant/AuditTaskDao.xml`
+
+### 3.4 AuditFileExtractDao.xml
+- [ ] MyBatis映射文件
+- [ ] SQL语句定义
+
+**位置**: `src/main/resources/mappings/modules/audit_assistant/AuditFileExtractDao.xml`
+
+---
+
+## 阶段4: Service业务逻辑
+
+### 4.1 AuditAssistantService.java
+- [ ] createTask() - 创建任务
+- [ ] startFirstRound() - 第一轮字段提取
+- [ ] startSecondRound() - 第二轮报告生成
+- [ ] editExcelAndSave() - Excel编辑保存
+- [ ] reExtract() - 重新处理
+- [ ] deleteTask() - 删除任务
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/service/AuditAssistantService.java`
+
+### 4.2 DifyApiClient.java
+- [ ] chatStream() - SSE流式调用
+- [ ] chat() - 同步调用
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/utils/DifyApiClient.java`
+
+### 4.3 ExcelConvertUtil.java
+- [ ] excelToLuckysheet() - Excel转Luckysheet JSON
+- [ ] luckysheetToExcel() - Luckysheet JSON转Excel
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/utils/ExcelConvertUtil.java`
+
+---
+
+## 阶段5: Controller API接口
+
+### 5.1 AuditAssistantController.java
+- [ ] POST /upload - 上传文件创建任务
+- [ ] GET /list - 任务列表
+- [ ] GET /detail/{taskId} - 任务详情
+- [ ] POST /start-extract/{taskId} - 开始第一轮处理
+- [ ] POST /generate-report/{taskId} - 生成报告
+- [ ] GET /status/{taskId} - 查询处理状态
+- [ ] GET /excel/data/{fileId} - 获取Excel数据
+- [ ] POST /excel/save - 保存编辑后的Excel
+- [ ] POST /re-extract/{taskId} - 重新处理
+- [ ] POST /delete/{taskId} - 删除任务
+- [ ] GET /download-report/{taskId} - 下载报告
+- [ ] GET /download-file/{fileId} - 下载文件
+
+**位置**: `src/main/java/com/jeeplus/modules/audit_assistant/web/AuditAssistantController.java`
+
+---
+
+## 阶段6: 前端JSP页面
+
+### 6.1 auditTaskList.jsp
+- [ ] 任务列表展示
+- [ ] 搜索/筛选功能
+- [ ] 分页功能
+- [ ] 按照HTML样式实现
+
+**位置**: `src/main/webapp/webpage/modules/audit_assistant/auditTaskList.jsp`
+
+### 6.2 auditTaskForm.jsp
+- [ ] 文件上传界面
+- [ ] 项目类型/输出类型选择
+- [ ] 步骤条展示
+- [ ] 按照HTML样式实现
+
+**位置**: `src/main/webapp/webpage/modules/audit_assistant/auditTaskForm.jsp`
+
+### 6.3 auditTaskDetail.jsp
+- [ ] 任务详情展示
+- [ ] 文件列表与提取结果
+- [ ] 字段勾选界面
+- [ ] 一致性检测结果
+- [ ] 合规性检测结果
+- [ ] 处理日志展示
+- [ ] 报告下载按钮
+- [ ] 按照HTML样式实现
+
+**位置**: `src/main/webapp/webpage/modules/audit_assistant/auditTaskDetail.jsp`
+
+### 6.4 excelEdit.jsp
+- [ ] Luckysheet组件集成
+- [ ] Excel数据加载
+- [ ] 保存/取消功能
+- [ ] 按照HTML样式实现
+
+**位置**: `src/main/webapp/webpage/modules/audit_assistant/excelEdit.jsp`
+
+---
+
+## 技术要点
+
+### OSS文件路径格式
+```
+attachment-file/audit-assistant/2026/10/22/{uuid}/{timestamp}_原文件名.pdf
+```
+
+### Dify API配置
+```properties
+dify.api.url=http://YOUR_DIFY_SERVER:5001
+dify.api.key=app-YOUR_API_KEY_HERE
+```
+
+### Luckysheet集成
+- CDN引入或本地部署
+- 初始化配置
+- 数据转换(JSON ↔ Excel)
+
+---
+
+## 注意事项
+
+1. **权限控制**: office_id字段用于部门隔离,创建任务时自动设置为当前用户所属部门
+2. **OSS删除**: 只删除数据库记录(del_flag='1'),不删除OSS文件
+3. **版本管理**: 保留所有历史版本,通过file_version和replaced_by关联
+4. **并发编辑**: 不需要锁定机制(一人一文件)
+5. **重新处理**: 用户手动触发,非自动
+
+---
+
+## 参考文档
+
+- UI样式参考: `C:/Users/CDB/.qoder-cn/app/chat-session-visualizations/.../audit-assistant-final.html`
+- JeePlus框架文档
+- Dify API文档
+- Luckysheet官方文档
+
+---
+
+**最后更新**: 2026-10-22
+**开发者**: Qoder

+ 74 - 0
src/main/java/com/jeeplus/modules/audit_assistant/dao/AuditFileExtractDao.java

@@ -0,0 +1,74 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.dao;
+
+import com.jeeplus.common.persistence.CrudDao;
+import com.jeeplus.common.persistence.annotation.MyBatisDao;
+import com.jeeplus.modules.audit_assistant.entity.AuditFileExtract;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 审计文件提取结果DAO接口
+ * @author Qoder
+ * @version 2026-10-22
+ */
+@MyBatisDao
+public interface AuditFileExtractDao extends CrudDao<AuditFileExtract> {
+	
+	/**
+	 * 根据任务ID查询文件列表
+	 * @param taskId 任务ID
+	 * @return 文件列表
+	 */
+	List<AuditFileExtract> findByTaskId(@Param("taskId") String taskId);
+	
+	/**
+	 * 根据任务ID和版本查询文件
+	 * @param taskId 任务ID
+	 * @param version 版本号
+	 * @return 文件
+	 */
+	AuditFileExtract findByTaskIdAndVersion(@Param("taskId") String taskId, @Param("version") Integer version);
+	
+	/**
+	 * 查询最新版本的文件
+	 * @param taskId 任务ID
+	 * @return 最新文件
+	 */
+	AuditFileExtract findLatestByTaskId(@Param("taskId") String taskId);
+	
+	/**
+	 * 更新提取状态
+	 * @param id 文件ID
+	 * @param status 新状态
+	 * @param error 错误信息
+	 * @return 影响行数
+	 */
+	int updateExtractStatus(@Param("id") String id, @Param("status") String status, @Param("error") String error);
+	
+	/**
+	 * 更新提取数据
+	 * @param id 文件ID
+	 * @param extractData 提取数据(JSON)
+	 * @return 影响行数
+	 */
+	int updateExtractData(@Param("id") String id, @Param("extractData") String extractData);
+	
+	/**
+	 * 标记文件被替换
+	 * @param id 文件ID
+	 * @param replacedBy 新版本ID
+	 * @return 影响行数
+	 */
+	int markAsReplaced(@Param("id") String id, @Param("replacedBy") String replacedBy);
+	
+	/**
+	 * 根据任务ID删除所有文件(逻辑删除)
+	 * @param taskId 任务ID
+	 * @return 影响行数
+	 */
+	int deleteByTaskId(@Param("taskId") String taskId);
+}

+ 57 - 0
src/main/java/com/jeeplus/modules/audit_assistant/dao/AuditTaskDao.java

@@ -0,0 +1,57 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.dao;
+
+import com.jeeplus.common.persistence.CrudDao;
+import com.jeeplus.common.persistence.annotation.MyBatisDao;
+import com.jeeplus.modules.audit_assistant.entity.AuditTask;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 审计任务DAO接口
+ * @author Qoder
+ * @version 2026-10-22
+ */
+@MyBatisDao
+public interface AuditTaskDao extends CrudDao<AuditTask> {
+	
+	/**
+	 * 根据状态查询任务列表
+	 * @param status 状态
+	 * @return 任务列表
+	 */
+	List<AuditTask> findByStatus(@Param("status") String status);
+	
+	/**
+	 * 根据Dify会话ID查询任务
+	 * @param conversationId Dify会话ID
+	 * @return 任务
+	 */
+	AuditTask findByDifyConversationId(@Param("conversationId") String conversationId);
+	
+	/**
+	 * 根据用户ID查询任务列表
+	 * @param createBy 创建人ID
+	 * @return 任务列表
+	 */
+	List<AuditTask> findByCreateBy(@Param("createBy") String createBy);
+	
+	/**
+	 * 更新任务状态
+	 * @param id 任务ID
+	 * @param status 新状态
+	 * @return 影响行数
+	 */
+	int updateStatus(@Param("id") String id, @Param("status") String status);
+	
+	/**
+	 * 更新Dify会话ID
+	 * @param id 任务ID
+	 * @param conversationId Dify会话ID
+	 * @return 影响行数
+	 */
+	int updateDifyConversationId(@Param("id") String id, @Param("conversationId") String conversationId);
+}

+ 261 - 0
src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditFileExtract.java

@@ -0,0 +1,261 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.entity;
+
+import com.jeeplus.common.persistence.DataEntity;
+import com.jeeplus.common.utils.excel.annotation.ExcelField;
+import org.hibernate.validator.constraints.Length;
+
+import java.util.Date;
+
+/**
+ * 审计文件提取结果Entity
+ * 
+ * <p>用于存储上传文件的元数据和Dify提取的字段数据,支持:</p>
+ * <ul>
+ *   <li>文件版本管理(每次重新上传或编辑后版本号+1)</li>
+ *   <li>历史版本追溯(通过replacedBy关联被替换的版本)</li>
+ *   <li>在线编辑追踪(记录最后编辑人和编辑时间)</li>
+ *   <li>提取状态监控(成功/失败/待处理)</li>
+ * </ul>
+ * 
+ * <p>重要设计原则:</p>
+ * <ul>
+ *   <li>所有历史版本都保留在数据库中(逻辑删除,不物理删除)</li>
+ *   <li>OSS上的文件也保留,不删除(便于审计追溯)</li>
+ *   <li>最新版本通过fileVersion最大值判断</li>
+ * </ul>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.entity.AuditTask
+ * @see com.jeeplus.common.persistence.DataEntity
+ */
+public class AuditFileExtract extends DataEntity<AuditFileExtract> {
+	
+	private static final long serialVersionUID = 1L;
+	
+	// ==================== 关联关系 ====================
+	
+	/** 
+	 * 关联任务ID 
+	 * <p>外键关联audit_task表的主键,一个任务可以有多个文件</p>
+	 */
+	private String taskId;
+	
+	// ==================== 文件元数据 ====================
+	
+	/** 原始文件名(用户上传时的文件名,如:合同.pdf) */
+	private String fileName;
+	
+	/** 
+	 * OSS文件URL 
+	 * <p>格式:http://oss.gangwaninfo.com/attachment-file/audit-assistant/2026/10/22/{uuid}/{timestamp}_原文件名</p>
+	 */
+	private String fileUrl;
+	
+	/** 文件大小(字节) */
+	private Long fileSize;
+	
+	/** 
+	 * 文件类型 
+	 * <p>可选值:pdf、docx、xlsx、jpg、png等</p>
+	 */
+	private String fileType;
+	
+	/** 
+	 * 文件版本号 
+	 * <p>从1开始递增,每次用户重新上传或在线编辑后+1</p>
+	 * <p>用于标识同一文件的不同版本</p>
+	 */
+	private Integer fileVersion;
+	
+	/** 
+	 * 被哪个版本替换 
+	 * <p>外键关联本表的id,指向新版本</p>
+	 * <p>如果为null,表示这是最新版本</p>
+	 */
+	private String replacedBy;
+	
+	// ==================== Dify集成 ====================
+	
+	/** 
+	 * Dify返回的文件ID 
+	 * <p>Dify上传文件后返回的唯一标识,用于后续调用工作流时引用该文件</p>
+	 */
+	private String difyFileId;
+	
+	// ==================== 提取结果 ====================
+	
+	/** 
+	 * 提取的字段数据(JSON格式)
+	 * <p>包含23个审计相关字段的提取结果,示例:</p>
+	 * <pre>{
+	 *   "project_name": "XXX工程项目",
+	 *   "contract_amount": 1000000,
+	 *   "construction_unit": "XXX建设公司"
+	 * }</pre>
+	 */
+	private String extractData;
+	
+	/** 
+	 * 提取状态 
+	 * <p>可选值:</p>
+	 * <ul>
+	 *   <li>pending - 待处理(刚上传,未调用Dify)</li>
+	 *   <li>success - 成功(Dify提取完成)</li>
+	 *   <li>failed - 失败(Dify调用出错)</li>
+	 * </ul>
+	 */
+	private String extractStatus;
+	
+	/** 提取失败原因(extractStatus为failed时记录详细错误信息) */
+	private String extractError;
+	
+	// ==================== 编辑追踪 ====================
+	
+	/** 
+	 * 编辑版本号 
+	 * <p>用户在Luckysheet中在线编辑Excel后,每次保存版本号+1</p>
+	 * <p>与fileVersion的区别:editVersion只记录编辑次数,fileVersion记录上传和编辑总次数</p>
+	 */
+	private Integer editVersion;
+	
+	/** 
+	 * 最后编辑人ID 
+	 * <p>关联sys_user表的主键,记录谁最后一次修改了这个文件</p>
+	 */
+	private String lastEditBy;
+	
+	/** 最后编辑时间 */
+	private Date lastEditTime;
+	
+	public AuditFileExtract() {
+		super();
+	}
+	
+	public AuditFileExtract(String id){
+		super(id);
+	}
+
+	@Length(min=0, max=64, message="任务ID长度必须介于 0 和 64 之间")
+	public String getTaskId() {
+		return taskId;
+	}
+
+	public void setTaskId(String taskId) {
+		this.taskId = taskId;
+	}
+	
+	@Length(min=0, max=200, message="文件名长度必须介于 0 和 200 之间")
+	public String getFileName() {
+		return fileName;
+	}
+
+	public void setFileName(String fileName) {
+		this.fileName = fileName;
+	}
+	
+	@Length(min=0, max=500, message="文件URL长度必须介于 0 和 500 之间")
+	public String getFileUrl() {
+		return fileUrl;
+	}
+
+	public void setFileUrl(String fileUrl) {
+		this.fileUrl = fileUrl;
+	}
+	
+	public Long getFileSize() {
+		return fileSize;
+	}
+
+	public void setFileSize(Long fileSize) {
+		this.fileSize = fileSize;
+	}
+	
+	@Length(min=0, max=20, message="文件类型长度必须介于 0 和 20 之间")
+	public String getFileType() {
+		return fileType;
+	}
+
+	public void setFileType(String fileType) {
+		this.fileType = fileType;
+	}
+	
+	public Integer getFileVersion() {
+		return fileVersion;
+	}
+
+	public void setFileVersion(Integer fileVersion) {
+		this.fileVersion = fileVersion;
+	}
+	
+	@Length(min=0, max=64, message="替换版本ID长度必须介于 0 和 64 之间")
+	public String getReplacedBy() {
+		return replacedBy;
+	}
+
+	public void setReplacedBy(String replacedBy) {
+		this.replacedBy = replacedBy;
+	}
+	
+	@Length(min=0, max=100, message="Dify文件ID长度必须介于 0 和 100 之间")
+	public String getDifyFileId() {
+		return difyFileId;
+	}
+
+	public void setDifyFileId(String difyFileId) {
+		this.difyFileId = difyFileId;
+	}
+	
+	public String getExtractData() {
+		return extractData;
+	}
+
+	public void setExtractData(String extractData) {
+		this.extractData = extractData;
+	}
+	
+	@Length(min=0, max=20, message="提取状态长度必须介于 0 和 20 之间")
+	public String getExtractStatus() {
+		return extractStatus;
+	}
+
+	public void setExtractStatus(String extractStatus) {
+		this.extractStatus = extractStatus;
+	}
+	
+	public String getExtractError() {
+		return extractError;
+	}
+
+	public void setExtractError(String extractError) {
+		this.extractError = extractError;
+	}
+	
+	public Integer getEditVersion() {
+		return editVersion;
+	}
+
+	public void setEditVersion(Integer editVersion) {
+		this.editVersion = editVersion;
+	}
+	
+	@Length(min=0, max=64, message="最后编辑人ID长度必须介于 0 和 64 之间")
+	public String getLastEditBy() {
+		return lastEditBy;
+	}
+
+	public void setLastEditBy(String lastEditBy) {
+		this.lastEditBy = lastEditBy;
+	}
+	
+	public Date getLastEditTime() {
+		return lastEditTime;
+	}
+
+	public void setLastEditTime(Date lastEditTime) {
+		this.lastEditTime = lastEditTime;
+	}
+}

+ 301 - 0
src/main/java/com/jeeplus/modules/audit_assistant/entity/AuditTask.java

@@ -0,0 +1,301 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.entity;
+
+import com.jeeplus.common.persistence.DataEntity;
+import com.jeeplus.common.utils.excel.annotation.ExcelField;
+import com.jeeplus.modules.sys.entity.Office;
+import org.hibernate.validator.constraints.Length;
+
+import java.util.Date;
+
+/**
+ * 审计任务Entity
+ * 
+ * <p>用于管理审计任务的完整生命周期,包括:</p>
+ * <ul>
+ *   <li>任务创建与文件上传</li>
+ *   <li>Dify工作流调用(字段提取)</li>
+ *   <li>用户字段选择与一致性检查</li>
+ *   <li>报告生成与下载</li>
+ * </ul>
+ * 
+ * <p>权限控制:通过office_id实现部门级别的数据隔离,创建任务时自动设置为当前用户所属部门。</p>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.entity.AuditFileExtract
+ * @see com.jeeplus.common.persistence.DataEntity
+ */
+public class AuditTask extends DataEntity<AuditTask> {
+	
+	private static final long serialVersionUID = 1L;
+	
+	// ==================== 基础信息 ====================
+	
+	/** 任务名称(如:审计任务_2026-10-22) */
+	private String taskName;
+	
+	/** 
+	 * 项目类型 
+	 * <p>可选值:建设工程、市政工程、供电工程、装修工程等</p>
+	 */
+	private String projectType;
+	
+	/** 
+	 * 输出类型 
+	 * <p>可选值:审计报告、审定单等</p>
+	 */
+	private String outputType;
+	
+	/** 
+	 * 任务状态 
+	 * <p>可选值:</p>
+	 * <ul>
+	 *   <li>draft - 草稿(刚上传文件,未开始处理)</li>
+	 *   <li>pending - 待处理(等待Dify处理)</li>
+	 *   <li>processing - 处理中(Dify正在提取字段)</li>
+	 *   <li>completed - 已完成(报告已生成)</li>
+	 *   <li>failed - 失败(处理出错)</li>
+	 *   <li>cancelled - 已取消(用户主动取消)</li>
+	 * </ul>
+	 */
+	private String status;
+	
+	// ==================== 业务数据 ====================
+	
+	/** 上传的文件数量 */
+	private Integer fileCount;
+	
+	/** 
+	 * 用户勾选的字段(JSON数组格式)
+	 * <p>示例:["project_name", "contract_amount", "construction_unit"]</p>
+	 * <p>用于第二轮报告生成时告诉Dify需要包含哪些字段</p>
+	 */
+	private String selectedFields;
+	
+	// ==================== Dify集成 ====================
+	
+	/** 
+	 * Dify会话ID 
+	 * <p>用于多轮对话上下文保持,第一轮提取后保存,第二轮生成报告时复用</p>
+	 */
+	private String difyConversationId;
+	
+	/** 
+	 * Dify用户标识 
+	 * <p>使用当前登录用户的loginName,用于Dify区分不同用户的会话</p>
+	 */
+	private String difyUserId;
+	
+	// ==================== 结果文件 ====================
+	
+	/** 生成的报告在OSS中的URL地址 */
+	private String resultFileUrl;
+	
+	/** 报告文件名(如:审计报告_XXX项目.docx) */
+	private String resultFileName;
+	
+	// ==================== 过程日志 ====================
+	
+	/** 
+	 * 处理日志(JSON数组格式)
+	 * <p>记录每个文件的处理进度和状态,用于前端展示处理过程</p>
+	 */
+	private String processLog;
+	
+	/** 错误信息(处理失败时记录详细错误原因) */
+	private String errorMsg;
+	
+	// ==================== 统计信息 ====================
+	
+	/** 开始处理时间(第一轮调用Dify的时间) */
+	private Date startTime;
+	
+	/** 完成时间(报告生成完成的时间) */
+	private Date endTime;
+	
+	/** 处理耗时(秒),由endTime - startTime计算得出 */
+	private Integer durationSeconds;
+	
+	// ==================== 权限控制 ====================
+	
+	/** 
+	 * 所属部门对象 
+	 * <p>用于MyBatis关联查询,获取部门详细信息(如部门名称)</p>
+	 */
+	private Office office;
+	
+	/** 
+	 * 部门ID 
+	 * <p>关联sys_office表的主键,用于数据权限隔离和查询过滤</p>
+	 */
+	private String officeId;
+	
+	/** 
+	 * 部门名称 
+	 * <p>冗余字段,用于列表展示时避免关联查询,提升性能</p>
+	 */
+	private String officeName;
+	
+	public AuditTask() {
+		super();
+	}
+	
+	public AuditTask(String id){
+		super(id);
+	}
+
+	@Length(min=0, max=200, message="任务名称长度必须介于 0 和 200 之间")
+	public String getTaskName() {
+		return taskName;
+	}
+
+	public void setTaskName(String taskName) {
+		this.taskName = taskName;
+	}
+	
+	@Length(min=0, max=50, message="项目类型长度必须介于 0 和 50 之间")
+	public String getProjectType() {
+		return projectType;
+	}
+
+	public void setProjectType(String projectType) {
+		this.projectType = projectType;
+	}
+	
+	@Length(min=0, max=20, message="输出类型长度必须介于 0 和 20 之间")
+	public String getOutputType() {
+		return outputType;
+	}
+
+	public void setOutputType(String outputType) {
+		this.outputType = outputType;
+	}
+	
+	@Length(min=0, max=20, message="状态长度必须介于 0 和 20 之间")
+	public String getStatus() {
+		return status;
+	}
+
+	public void setStatus(String status) {
+		this.status = status;
+	}
+	
+	public Integer getFileCount() {
+		return fileCount;
+	}
+
+	public void setFileCount(Integer fileCount) {
+		this.fileCount = fileCount;
+	}
+	
+	public String getSelectedFields() {
+		return selectedFields;
+	}
+
+	public void setSelectedFields(String selectedFields) {
+		this.selectedFields = selectedFields;
+	}
+	
+	@Length(min=0, max=100, message="Dify会话ID长度必须介于 0 和 100 之间")
+	public String getDifyConversationId() {
+		return difyConversationId;
+	}
+
+	public void setDifyConversationId(String difyConversationId) {
+		this.difyConversationId = difyConversationId;
+	}
+	
+	@Length(min=0, max=100, message="Dify用户标识长度必须介于 0 和 100 之间")
+	public String getDifyUserId() {
+		return difyUserId;
+	}
+
+	public void setDifyUserId(String difyUserId) {
+		this.difyUserId = difyUserId;
+	}
+	
+	@Length(min=0, max=500, message="报告URL长度必须介于 0 和 500 之间")
+	public String getResultFileUrl() {
+		return resultFileUrl;
+	}
+
+	public void setResultFileUrl(String resultFileUrl) {
+		this.resultFileUrl = resultFileUrl;
+	}
+	
+	@Length(min=0, max=200, message="报告文件名长度必须介于 0 和 200 之间")
+	public String getResultFileName() {
+		return resultFileName;
+	}
+
+	public void setResultFileName(String resultFileName) {
+		this.resultFileName = resultFileName;
+	}
+	
+	public String getProcessLog() {
+		return processLog;
+	}
+
+	public void setProcessLog(String processLog) {
+		this.processLog = processLog;
+	}
+	
+	public String getErrorMsg() {
+		return errorMsg;
+	}
+
+	public void setErrorMsg(String errorMsg) {
+		this.errorMsg = errorMsg;
+	}
+	
+	public Date getStartTime() {
+		return startTime;
+	}
+
+	public void setStartTime(Date startTime) {
+		this.startTime = startTime;
+	}
+	
+	public Date getEndTime() {
+		return endTime;
+	}
+
+	public void setEndTime(Date endTime) {
+		this.endTime = endTime;
+	}
+	
+	public Integer getDurationSeconds() {
+		return durationSeconds;
+	}
+
+	public void setDurationSeconds(Integer durationSeconds) {
+		this.durationSeconds = durationSeconds;
+	}
+	
+	public Office getOffice() {
+		return office;
+	}
+
+	public void setOffice(Office office) {
+		this.office = office;
+	}
+	
+	public String getOfficeId() {
+		return officeId;
+	}
+
+	public void setOfficeId(String officeId) {
+		this.officeId = officeId;
+	}
+	
+	public String getOfficeName() {
+		return officeName;
+	}
+
+	public void setOfficeName(String officeName) {
+		this.officeName = officeName;
+	}
+}

+ 654 - 0
src/main/java/com/jeeplus/modules/audit_assistant/service/AuditAssistantService.java

@@ -0,0 +1,654 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jeeplus.common.config.Global;
+import com.jeeplus.common.persistence.Page;
+import com.jeeplus.common.service.CrudService;
+import com.jeeplus.common.utils.IdGen;
+import com.jeeplus.common.utils.StringUtils;
+import com.jeeplus.modules.audit_assistant.dao.AuditFileExtractDao;
+import com.jeeplus.modules.audit_assistant.dao.AuditTaskDao;
+import com.jeeplus.modules.audit_assistant.entity.AuditFileExtract;
+import com.jeeplus.modules.audit_assistant.entity.AuditTask;
+import com.jeeplus.modules.audit_assistant.utils.DifyApiClient;
+import com.jeeplus.modules.sys.utils.UserUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.time.LocalDate;
+import java.util.*;
+
+/**
+ * 审计助手Service
+ * 
+ * <p>核心业务逻辑层,负责审计任务的完整生命周期管理:</p>
+ * <ol>
+ *   <li>任务创建与文件上传(集成OSS存储)</li>
+ *   <li>第一轮处理:调用Dify工作流提取23个审计字段(SSE流式响应)</li>
+ *   <li>第二轮处理:根据用户选择的字段生成审计报告(阻塞式调用)</li>
+ *   <li>Excel在线编辑支持(Luckysheet格式转换)</li>
+ *   <li>文件版本管理(保留所有历史版本)</li>
+ *   <li>任务删除与报告下载</li>
+ * </ol>
+ * 
+ * <p>技术要点:</p>
+ * <ul>
+ *   <li>使用@Transactional保证数据一致性</li>
+ *   <li>通过DifyApiClient与Dify API交互(支持流式和阻塞两种模式)</li>
+ *   <li>Excel数据通过ExcelConvertUtil转换为Luckysheet格式供前端编辑</li>
+ *   <li>文件存储在阿里云OSS,路径格式:attachment-file/audit-assistant/YYYY/MM/DD/{uuid}/{timestamp}_filename</li>
+ * </ul>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.utils.DifyApiClient
+ * @see com.jeeplus.modules.audit_assistant.utils.ExcelConvertUtil
+ * @see com.jeeplus.common.service.CrudService
+ */
+@Service
+@Transactional(readOnly = true)
+public class AuditAssistantService extends CrudService<AuditTaskDao, AuditTask> {
+	
+	@Autowired
+	private AuditFileExtractDao auditFileExtractDao;
+	
+	// ==================== 任务创建 ====================
+	
+	/**
+	 * 创建审计任务并上传文件到OSS
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>生成唯一的taskId(UUID)</li>
+	 *   <li>创建任务记录,设置项目类型、输出类型、部门信息等</li>
+	 *   <li>遍历上传的文件列表,为每个文件生成OSS路径并创建提取记录</li>
+	 *   <li>返回taskId和文件列表供前端展示</li>
+	 * </ol>
+	 * 
+	 * <p>注意事项:</p>
+	 * <ul>
+	 *   <li>任务初始状态为"draft"(草稿),等待用户点击"开始处理"</li>
+	 *   <li>office自动设置为当前登录用户所属部门,实现数据隔离</li>
+	 *   <li>文件版本初始为1,每次重新上传或编辑后+1</li>
+	 *   <li>TODO: 需要集成OSSClientUtil实现真实的文件上传</li>
+	 * </ul>
+	 * 
+	 * @param files 用户上传的文件数组(支持PDF/Word/Excel/JPG/PNG格式)
+	 * @param projectType 项目类型(建设工程/市政工程/供电工程/装修工程等)
+	 * @param outputType 输出类型(审计报告/审定单)
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *           <li>taskId (String): 任务ID</li>
+	 *           <li>files (List): 文件信息列表,每项包含fileId、fileName、fileUrl等</li>
+	 *         </ul>
+	 */
+	@Transactional(readOnly = false)
+	public Map<String, Object> createTask(MultipartFile[] files, String projectType, String outputType) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// 1. 生成taskId
+			String taskId = IdGen.uuid();
+			
+			// 2. 创建任务记录
+			AuditTask task = new AuditTask();
+			task.setId(taskId);
+			task.setTaskName("审计任务_" + LocalDate.now());
+			task.setProjectType(projectType != null ? projectType : "建设工程");
+			task.setOutputType(outputType != null ? outputType : "审计报告");
+			task.setStatus("draft");
+			task.setFileCount(files.length);
+			if (UserUtils.getUser().getOffice() != null) {
+				task.setOffice(UserUtils.getUser().getOffice());
+				task.setOfficeId(UserUtils.getUser().getOffice().getId());
+			}
+			task.setCreateBy(UserUtils.getUser());
+			task.setCreateDate(new Date());
+			task.setDelFlag("0");
+			dao.insert(task);
+			
+			// 3. 上传文件并创建文件记录
+			List<Map<String, Object>> fileList = new ArrayList<>();
+			for (MultipartFile file : files) {
+				if (file.isEmpty()) continue;
+				
+				// 生成OSS路径: attachment-file/audit-assistant/2026/10/22/{uuid}/{timestamp}_原文件名
+				String ossPath = generateOssPath(file.getOriginalFilename());
+				
+				// TODO: 上传到OSS(需要集成OSSClientUtil)
+				// String ossUrl = Global.getAliyunDownloadUrl() + "/" + ossPath;
+				String ossUrl = "http://oss.gangwaninfo.com/" + ossPath; // 临时URL
+				
+				// 创建文件提取记录
+				AuditFileExtract fileExtract = new AuditFileExtract();
+				fileExtract.setId(IdGen.uuid());
+				fileExtract.setTaskId(taskId);
+				fileExtract.setFileName(file.getOriginalFilename());
+				fileExtract.setFileUrl(ossUrl);
+				fileExtract.setFileSize(file.getSize());
+				fileExtract.setFileType(getFileType(file.getOriginalFilename()));
+				fileExtract.setFileVersion(1);
+				fileExtract.setExtractStatus("pending");
+				fileExtract.setCreateBy(UserUtils.getUser());
+				fileExtract.setCreateDate(new Date());
+				fileExtract.setDelFlag("0");
+				auditFileExtractDao.insert(fileExtract);
+				
+				// 添加到返回列表
+				Map<String, Object> fileInfo = new HashMap<>();
+				fileInfo.put("fileId", fileExtract.getId());
+				fileInfo.put("fileName", file.getOriginalFilename());
+				fileInfo.put("fileUrl", ossUrl);
+				fileList.add(fileInfo);
+			}
+			
+			result.put("success", true);
+			result.put("taskId", taskId);
+			result.put("files", fileList);
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "创建任务失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 开始第一轮处理:调用Dify工作流提取23个审计字段
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>更新任务状态为"processing"(处理中)</li>
+	 *   <li>查询任务关联的所有文件(获取最新版本)</li>
+	 *   <li>构建Dify API请求参数(file_urls数组)</li>
+	 *   <li>使用SSE流式方式调用Dify chat接口</li>
+	 *   <li>实时接收并解析Dify返回的JSON数据</li>
+	 *   <li>将提取结果保存到audit_file_extract表的extract_data字段</li>
+	 *   <li>更新任务状态为"completed"或"failed"</li>
+	 * </ol>
+	 * 
+	 * <p>技术要点:</p>
+	 * <ul>
+	 *   <li>使用DifyApiClient.chatStream()进行SSE流式调用,避免长时间等待</li>
+	 *   <li>通过SseEventHandler异步处理流式响应,实时更新处理进度</li>
+	 *   <li>提取结果为JSON格式,包含23个审计字段及其置信度</li>
+	 *   <li>difyConversationId保存后用于第二轮报告生成</li>
+	 * </ul>
+	 * 
+	 * @param taskId 任务ID(audit_task表主键)
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *           <li>taskId (String): 任务ID</li>
+	 *         </ul>
+	 */
+	@Transactional(readOnly = false)
+	public Map<String, Object> startFirstRound(String taskId) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// 1. 更新任务状态
+			dao.updateStatus(taskId, "processing");
+			
+			// 2. 查询任务关联的所有文件
+			List<AuditFileExtract> files = auditFileExtractDao.findByTaskId(taskId);
+			
+			// 3. 准备调用Dify API
+			DifyApiClient difyClient = new DifyApiClient(
+				Global.getDifyApiUrl(),
+				Global.getDifyApiKey()
+			);
+			
+			// 4. 构建文件URL列表
+			List<String> fileUrls = new ArrayList<>();
+			for (AuditFileExtract file : files) {
+				fileUrls.add(file.getFileUrl());
+			}
+			
+			// 5. 构建inputs参数
+			Map<String, Object> inputs = new HashMap<>();
+			inputs.put("current_user_name", UserUtils.getUser().getName());
+			inputs.put("current_user_id", UserUtils.getUser().getId());
+			inputs.put("task_id", taskId);
+			inputs.put("project_type", dao.get(taskId).getProjectType());
+			
+			// 6. 调用Dify API(流式)
+			String userId = UserUtils.getUser().getLoginName();
+			difyClient.chatStream(userId, inputs, fileUrls, new DifyApiClient.SseEventHandler() {
+				@Override
+				public void onMessage(String data) {
+					// 解析Dify返回的消息
+					try {
+						JSONObject json = JSON.parseObject(data);
+						String eventType = json.getString("event");
+						
+						if ("message".equals(eventType)) {
+							// 接收消息片段
+							String answer = json.getString("answer");
+							// TODO: 累积answer,等待流结束再解析
+						} else if ("message_end".equals(eventType)) {
+							// 流结束,解析最终结果
+							String conversationId = json.getString("conversation_id");
+							
+							// 更新任务的dify_conversation_id
+							dao.updateDifyConversationId(taskId, conversationId);
+							
+							// TODO: 解析extract_data并更新audit_file_extract表
+						}
+					} catch (Exception e) {
+						e.printStackTrace();
+					}
+				}
+				
+				@Override
+				public void onComplete() {
+					// 更新任务状态为completed
+					dao.updateStatus(taskId, "completed");
+				}
+				
+				@Override
+				public void onError(Exception e) {
+					// 更新任务状态为failed
+					dao.updateStatus(taskId, "failed");
+					
+					// 记录错误信息
+					AuditTask task = dao.get(taskId);
+					task.setErrorMsg(e.getMessage());
+					dao.update(task);
+				}
+			});
+			
+			result.put("success", true);
+			result.put("msg", "开始处理,请稍候...");
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "启动处理失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 开始第二轮处理:根据用户选择的字段生成审计报告
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>更新任务的selected_fields字段(保存用户勾选的字段列表)</li>
+	 *   <li>查询所有文件的提取结果(从第一轮处理得到的extract_data)</li>
+	 *   <li>构建doc_results数组,包含每个文件的文件名和提取数据</li>
+	 *   <li>调用Dify API的chat接口(阻塞式,非流式)生成报告内容</li>
+	 *   <li>解析Dify返回的报告文本</li>
+	 *   <li>TODO: 将报告内容转换为Word/Excel格式并上传到OSS</li>
+	 *   <li>更新任务状态为"completed",保存报告URL和文件名</li>
+	 * </ol>
+	 * 
+	 * <p>技术要点:</p>
+	 * <ul>
+	 *   <li>使用DifyApiClient.chat()阻塞式调用,等待完整报告生成</li>
+	 *   <li>inputs参数包含:selected_fields(用户选择的字段)、doc_results(文档提取结果)、output_type(输出类型)</li>
+	 *   <li>Dify工作流会根据这些输入生成符合要求的审计报告</li>
+	 *   <li>报告生成后需要转换为Word或Excel格式(当前为TODO项)</li>
+	 * </ul>
+	 * 
+	 * @param taskId 任务ID(audit_task表主键)
+	 * @param selectedFields 用户勾选的字段(JSON数组字符串),示例:["project_name", "contract_amount"]
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *           <li>reportUrl (String): 生成的报告OSS URL</li>
+	 *           <li>reportName (String): 报告文件名</li>
+	 *         </ul>
+	 */
+	@Transactional(readOnly = false)
+	public Map<String, Object> startSecondRound(String taskId, String selectedFields) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// 1. 更新任务的selected_fields
+			AuditTask task = dao.get(taskId);
+			task.setSelectedFields(selectedFields);
+			dao.update(task);
+			
+			// 2. 查询所有文件的提取结果
+			List<AuditFileExtract> files = auditFileExtractDao.findByTaskId(taskId);
+			
+			// 3. 构建doc_results
+			JSONArray docResults = new JSONArray();
+			for (AuditFileExtract file : files) {
+				if ("success".equals(file.getExtractStatus())) {
+					JSONObject docResult = new JSONObject();
+					docResult.put("file_name", file.getFileName());
+					docResult.put("extract_data", JSON.parseObject(file.getExtractData()));
+					docResults.add(docResult);
+				}
+			}
+			
+			// 4. 调用Dify API生成报告
+			DifyApiClient difyClient = new DifyApiClient(
+				Global.getDifyApiUrl(),
+				Global.getDifyApiKey()
+			);
+			
+			Map<String, Object> inputs = new HashMap<>();
+			inputs.put("selected_fields", JSON.parseArray(selectedFields));
+			inputs.put("doc_results", docResults);
+			inputs.put("output_type", task.getOutputType());
+			
+			String userId = UserUtils.getUser().getLoginName();
+			String apiResult = difyClient.chat(userId, inputs, "请生成审计报告");
+			
+			// 5. 解析报告内容
+			JSONObject resultJson = JSON.parseObject(apiResult);
+			String reportContent = resultJson.getString("answer");
+			String conversationId = resultJson.getString("conversation_id");
+			
+			// 6. TODO: 将报告内容转换为Word/Excel并上传到OSS
+			
+			// 7. 更新任务
+			task.setResultFileUrl("http://oss.gangwaninfo.com/report.dox"); // 临时URL
+			task.setResultFileName("审计报告_" + task.getTaskName() + ".docx");
+			task.setStatus("completed");
+			task.setEndTime(new Date());
+			dao.update(task);
+			
+			result.put("success", true);
+			result.put("reportUrl", task.getResultFileUrl());
+			result.put("reportName", task.getResultFileName());
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "生成报告失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 删除审计任务(逻辑删除)
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>将任务的del_flag设置为"1"(逻辑删除,不从数据库物理删除)</li>
+	 *   <li>将关联的所有文件记录的del_flag设置为"1"</li>
+	 *   <li>OSS上的文件保留不删除(便于审计追溯和历史查询)</li>
+	 * </ol>
+	 * 
+	 * <p>设计原则:</p>
+	 * <ul>
+	 *   <li>所有数据都采用逻辑删除,保留完整的审计痕迹</li>
+	 *   <li>OSS文件也保留,不物理删除(符合审计合规要求)</li>
+	 *   <li>通过del_flag='0'过滤,用户看不到已删除的任务</li>
+	 * </ul>
+	 * 
+	 * @param taskId 任务ID(audit_task表主键)
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *         </ul>
+	 */
+	@Transactional(readOnly = false)
+	public Map<String, Object> deleteTask(String taskId) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// 1. 逻辑删除任务
+			AuditTask task = dao.get(taskId);
+			task.setDelFlag("1");
+			dao.update(task);
+			
+			// 2. 逻辑删除关联文件
+			auditFileExtractDao.deleteByTaskId(taskId);
+			
+			// 注意: 不删除OSS文件(按需求)
+			
+			result.put("success", true);
+			result.put("msg", "删除成功");
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "删除任务失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 获取Excel文件数据并转换为Luckysheet格式
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>根据fileId查询audit_file_extract表获取文件信息</li>
+	 *   <li>从OSS下载Excel文件到本地临时目录</li>
+	 *   <li>使用ExcelConvertUtil.excelToLuckysheet()将Excel转换为Luckysheet JSON格式</li>
+	 *   <li>返回JSON数据供前端Luckysheet组件渲染</li>
+	 * </ol>
+	 * 
+	 * <p>技术要点:</p>
+	 * <ul>
+	 *   <li>Luckysheet是一种开源的在线表格组件,支持类似Excel的编辑体验</li>
+	 *   <li>转换后的JSON包含单元格数据、样式、公式等完整信息</li>
+	 *   <li>前端可以直接在Luckysheet中编辑,保存时再转回Excel格式</li>
+	 * </ul>
+	 * 
+	 * @param fileId 文件ID(audit_file_extract表主键)
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *           <li>luckysheetData (JSONArray): Luckysheet格式的JSON数组,包含所有sheet的数据</li>
+	 *         </ul>
+	 * @see com.jeeplus.modules.audit_assistant.utils.ExcelConvertUtil#excelToLuckysheet(java.io.InputStream)
+	 */
+	public Map<String, Object> getExcelData(String fileId) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// TODO: 从数据库查询文件信息
+			// AuditFileExtract file = auditFileExtractDao.get(fileId);
+			
+			// TODO: 从OSS下载Excel文件
+			
+			// TODO: 转换为Luckysheet JSON
+			// JSONArray luckysheetData = ExcelConvertUtil.excelToLuckysheet(inputStream);
+			
+			// 临时返回示例数据
+			JSONArray mockData = new JSONArray();
+			JSONObject sheet = new JSONObject();
+			sheet.put("name", "Sheet1");
+			sheet.put("celldata", new JSONArray());
+			mockData.add(sheet);
+			
+			result.put("success", true);
+			result.put("luckysheetData", mockData);
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "获取Excel数据失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 保存用户编辑后的Excel数据
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>接收前端Luckysheet组件提交的JSON数据</li>
+	 *   <li>使用ExcelConvertUtil.luckysheetToExcel()将JSON转换回Excel格式</li>
+	 *   <li>生成新的OSS路径(版本号+1)</li>
+	 *   <li>上传新版本的Excel文件到OSS</li>
+	 *   <li>创建新的audit_file_extract记录,fileVersion递增</li>
+	 *   <li>标记旧版本为被替换状态(replacedBy指向新版本)</li>
+	 *   <li>更新editVersion和lastEditBy字段</li>
+	 * </ol>
+	 * 
+	 * <p>版本管理策略:</p>
+	 * <ul>
+	 *   <li>每次编辑保存都会创建新版本,fileVersion自动+1</li>
+	 *   <li>历史版本保留在数据库和OSS中,不删除</li>
+	 *   <li>通过replacedBy字段建立版本链,方便追溯修改历史</li>
+	 *   <li>最新版本通过MAX(fileVersion)查询得到</li>
+	 * </ul>
+	 * 
+	 * @param fileId 原文件ID(audit_file_extract表主键)
+	 * @param luckysheetData Luckysheet格式的JSON字符串(包含所有单元格数据)
+	 * @return Map包含以下键值对:
+	 *         <ul>
+	 *           <li>success (Boolean): 是否成功</li>
+	 *           <li>msg (String): 提示信息</li>
+	 *           <li>newFileId (String): 新版本文件ID</li>
+	 *         </ul>
+	 * @see com.jeeplus.modules.audit_assistant.utils.ExcelConvertUtil#luckysheetToExcel(com.alibaba.fastjson.JSONArray)
+	 */
+	@Transactional(readOnly = false)
+	public Map<String, Object> saveEditedExcel(String fileId, String luckysheetData) {
+		Map<String, Object> result = new HashMap<>();
+		
+		try {
+			// TODO: 将Luckysheet JSON转换为Excel
+			
+			// TODO: 上传到OSS(新版本)
+			
+			// TODO: 创建新的audit_file_extract记录
+			
+			result.put("success", true);
+			result.put("msg", "保存成功");
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+			result.put("success", false);
+			result.put("msg", "保存失败: " + e.getMessage());
+		}
+		
+		return result;
+	}
+	
+	/**
+	 * 下载生成的审计报告文件
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>根据taskId查询任务记录,获取resultFileUrl和resultFileName</li>
+	 *   <li>从OSS下载报告文件(Word或Excel格式)</li>
+	 *   <li>设置HTTP响应头,触发浏览器下载</li>
+	 * </ol>
+	 * 
+	 * <p>注意事项:</p>
+	 * <ul>
+	 *   <li>Content-Type根据文件类型设置(docx用application/vnd.openxmlformats-officedocument.wordprocessingml.document)</li>
+	 *   <li>文件名需要URLEncoder编码,支持中文文件名</li>
+	 *   <li>TODO: 需要集成OSSClientUtil实现真实的文件下载</li>
+	 * </ul>
+	 * 
+	 * @param taskId 任务ID(audit_task表主键)
+	 * @param response HTTP响应对象,用于输出文件流
+	 */
+	public void downloadReport(String taskId, HttpServletResponse response) {
+		try {
+			AuditTask task = dao.get(taskId);
+			if (task == null || StringUtils.isBlank(task.getResultFileUrl())) {
+				response.sendError(HttpServletResponse.SC_NOT_FOUND);
+				return;
+			}
+			
+			// TODO: 从OSS下载文件并返回流
+			
+			response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+			response.setHeader("Content-Disposition", "attachment; filename=" + 
+				URLEncoder.encode(task.getResultFileName(), "UTF-8"));
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+		}
+	}
+	
+	/**
+	 * 下载原始上传文件(用户上传的PDF/Word/Excel等)
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>根据fileId查询audit_file_extract表获取文件URL和文件名</li>
+	 *   <li>从OSS下载原始文件</li>
+	 *   <li>设置HTTP响应头,触发浏览器下载</li>
+	 * </ol>
+	 * 
+	 * <p>使用场景:</p>
+	 * <ul>
+	 *   <li>用户需要查看原始文档内容</li>
+	 *   <li>审计人员需要核对原始凭证</li>
+	 *   <li>对比提取结果与原始文件的差异</li>
+	 * </ul>
+	 * 
+	 * @param fileId 文件ID(audit_file_extract表主键)
+	 * @param response HTTP响应对象,用于输出文件流
+	 */
+	public void downloadFile(String fileId, HttpServletResponse response) {
+		try {
+			// TODO: 从数据库查询文件信息
+			// TODO: 从OSS下载文件并返回流
+			
+		} catch (Exception e) {
+			e.printStackTrace();
+		}
+	}
+	
+	/**
+	 * 生成OSS文件路径
+	 * 格式: attachment-file/audit-assistant/2026/10/22/{uuid}/{timestamp}_原文件名
+	 */
+	private String generateOssPath(String originalFileName) {
+		LocalDate now = LocalDate.now();
+		String uuid = IdGen.uuid();
+		long timestamp = System.currentTimeMillis();
+		
+		String dir = String.format("attachment-file/audit-assistant/%d/%02d/%02d/%s/",
+			now.getYear(), now.getMonthValue(), now.getDayOfMonth(), uuid);
+		
+		String fileName = timestamp + "_" + originalFileName;
+		
+		return dir + fileName;
+	}
+	
+	/**
+	 * 获取文件类型
+	 */
+	private String getFileType(String fileName) {
+		if (fileName == null) return "";
+		
+		int dotIndex = fileName.lastIndexOf(".");
+		if (dotIndex == -1) return "";
+		
+		String ext = fileName.substring(dotIndex + 1).toLowerCase();
+		
+		if ("pdf".equals(ext)) return "pdf";
+		if ("doc".equals(ext) || "docx".equals(ext)) return "docx";
+		if ("xls".equals(ext) || "xlsx".equals(ext)) return "xlsx";
+		if ("jpg".equals(ext) || "jpeg".equals(ext)) return "jpg";
+		if ("png".equals(ext)) return "png";
+		
+		return ext;
+	}
+}

+ 289 - 0
src/main/java/com/jeeplus/modules/audit_assistant/utils/DifyApiClient.java

@@ -0,0 +1,289 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.utils;
+
+import com.alibaba.fastjson.JSON;
+import okhttp3.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Dify API客户端工具类
+ * 
+ * <p>封装与Dify工作流平台的HTTP交互,支持两种调用模式:</p>
+ * <ul>
+ *   <li><b>SSE流式调用(chatStream)</b>:用于第一轮字段提取,实时接收处理进度</li>
+ *   <li><b>阻塞式调用(chat)</b>:用于第二轮报告生成,等待完整结果返回</li>
+ * </ul>
+ * 
+ * <p>技术要点:</p>
+ * <ul>
+ *   <li>使用OkHttp作为HTTP客户端,配置5分钟读取超时(适应长时间AI处理)</li>
+ *   <li>SSE(Server-Sent Events)支持服务端主动推送数据,避免前端轮询</li>
+ *   <li>通过SseEventHandler接口实现异步事件处理,解耦业务逻辑</li>
+ *   <li>请求体包含inputs(输入参数)、files(文件URL数组)、response_mode(响应模式)</li>
+ * </ul>
+ * 
+ * <p>使用示例:</p>
+ * <pre>{@code
+ * // 流式调用(第一轮)
+ * DifyApiClient client = new DifyApiClient(apiUrl, apiKey);
+ * client.chatStream(userId, inputs, fileUrls, new SseEventHandler() {
+ *     public void onMessage(String data) {
+ *         // 处理每条消息
+ *     }
+ *     public void onComplete() {
+ *         // 处理完成
+ *     }
+ *     public void onError(Exception e) {
+ *         // 处理错误
+ *     }
+ * });
+ * 
+ * // 阻塞式调用(第二轮)
+ * String result = client.chat(userId, inputs, query);
+ * }</pre>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.service.AuditAssistantService
+ */
+public class DifyApiClient {
+	
+	private static final Logger log = LoggerFactory.getLogger(DifyApiClient.class);
+	
+	/** Dify API基础URL(如:https://api.dify.ai/v1) */
+	private String apiUrl;
+	
+	/** Dify API密钥(从Global.getDifyApiKey()获取) */
+	private String apiKey;
+	
+	/** OkHttp客户端实例,配置了长超时时间以适应AI处理 */
+	private OkHttpClient httpClient;
+	
+	/**
+	 * 构造Dify API客户端
+	 * 
+	 * @param apiUrl Dify API基础URL
+	 * @param apiKey Dify API密钥
+	 */
+	public DifyApiClient(String apiUrl, String apiKey) {
+		this.apiUrl = apiUrl;
+		this.apiKey = apiKey;
+		this.httpClient = new OkHttpClient.Builder()
+			.connectTimeout(30, TimeUnit.SECONDS)    // 连接超时30秒
+			.readTimeout(300, TimeUnit.SECONDS)       // 读取超时5分钟(AI处理可能较慢)
+			.writeTimeout(30, TimeUnit.SECONDS)       // 写入超时30秒
+			.build();
+	}
+	
+	// ==================== SSE事件处理 ====================
+	
+	/**
+	 * SSE(Server-Sent Events)事件处理器接口
+	 * 
+	 * <p>用于异步处理Dify返回的流式数据,实现非阻塞的消息接收。</p>
+	 * <p>Dify会持续推送event和data,直到发送message_end事件。</p>
+	 */
+	public interface SseEventHandler {
+		/**
+		 * 接收到消息片段时触发
+		 * @param data JSON格式的消息内容
+		 */
+		void onMessage(String data);
+		
+		/**
+		 * 流结束时触发(所有消息接收完毕)
+		 */
+		void onComplete();
+		
+		/**
+		 * 发生错误时触发
+		 * @param e 异常对象
+		 */
+		void onError(Exception e);
+	}
+	
+	// ==================== 流式调用(第一轮字段提取) ====================
+	
+	/**
+	 * 流式对话(SSE方式)- 用于第一轮字段提取
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>构建请求体(inputs、files、response_mode="streaming")</li>
+	 *   <li>发送POST请求到Dify /chat-messages接口</li>
+	 *   <li>通过SSE持续接收事件流(event: message / message_end / error)</li>
+	 *   <li>解析每个事件的data字段,提取JSON内容</li>
+	 *   <li>当收到message_end事件时,保存conversation_id供第二轮使用</li>
+	 * </ol>
+	 * 
+	 * <p>技术要点:</p>
+	 * <ul>
+	 *   <li>使用response_mode="streaming"启用流式响应</li>
+	 *   <li>files数组包含文件URL,Dify会自动下载并OCR识别</li>
+	 *   <li>每个event对应一个JSON对象,包含answer、conversation_id等字段</li>
+	 *   <li>适合长时间运行的任务,可以实时展示处理进度</li>
+	 * </ul>
+	 * 
+	 * @param userId 用户标识(使用当前登录用户的loginName,用于Dify区分会话)
+	 * @param inputs 输入参数字典(可以为空Map)
+	 * @param fileUrls 文件URL列表(Dify会下载这些文件并进行OCR/LLM处理)
+	 * @param handler SSE事件处理器(实现onMessage/onComplete/onError三个方法)
+	 */
+	public void chatStream(String userId, Map<String, Object> inputs, List<String> fileUrls, SseEventHandler handler) {
+		try {
+			// 构建请求体
+			Map<String, Object> requestBody = new HashMap<>();
+			requestBody.put("inputs", inputs);
+			requestBody.put("query", "请提取以下文件中的审计字段");
+			requestBody.put("response_mode", "streaming");
+			requestBody.put("user", userId);
+			requestBody.put("conversation_id", ""); // 第一轮为空
+			
+			// 如果有文件,添加files参数
+			if (fileUrls != null && !fileUrls.isEmpty()) {
+				List<Map<String, String>> files = new java.util.ArrayList<>();
+				for (String url : fileUrls) {
+					Map<String, String> file = new HashMap<>();
+					file.put("type", "document");
+					file.put("url", url);
+					files.add(file);
+				}
+				requestBody.put("files", files);
+			}
+			
+			// 构建HTTP请求
+			Request request = new Request.Builder()
+				.url(apiUrl + "/v1/chat-messages")
+				.post(RequestBody.create(
+					MediaType.parse("application/json; charset=utf-8"),
+					JSON.toJSONString(requestBody)
+				))
+				.addHeader("Authorization", "Bearer " + apiKey)
+				.build();
+			
+			// 发送请求并监听SSE流
+			httpClient.newCall(request).enqueue(new Callback() {
+				@Override
+				public void onFailure(Call call, IOException e) {
+					log.error("Dify API调用失败", e);
+					handler.onError(e);
+				}
+				
+				@Override
+				public void onResponse(Call call, Response response) throws IOException {
+					if (!response.isSuccessful()) {
+						handler.onError(new Exception("HTTP " + response.code()));
+						return;
+					}
+					
+					// 读取SSE流
+					try (ResponseBody responseBody = response.body()) {
+						if (responseBody == null) {
+							handler.onError(new Exception("响应体为空"));
+							return;
+						}
+						
+						String line;
+						BufferedReader reader = new BufferedReader(responseBody.charStream());
+						while ((line = reader.readLine()) != null) {
+							if (line.startsWith("data: ")) {
+								String data = line.substring(6);
+								if ("[DONE]".equals(data)) {
+									break;
+								}
+								handler.onMessage(data);
+							}
+						}
+						handler.onComplete();
+					}
+				}
+			});
+			
+		} catch (Exception e) {
+			log.error("构建请求失败", e);
+			handler.onError(e);
+		}
+	}
+	
+	// ==================== 阻塞式调用(第二轮报告生成) ====================
+	
+	/**
+	 * 阻塞式对话 - 用于第二轮报告生成
+	 * 
+	 * <p>业务流程:</p>
+	 * <ol>
+	 *   <li>构建请求体(inputs包含selected_fields、doc_results、output_type)</li>
+	 *   <li>发送POST请求到Dify /chat-messages接口</li>
+	 *   <li>等待Dify完整处理(response_mode="blocking")</li>
+	 *   <li>一次性返回完整的JSON响应</li>
+	 * </ol>
+	 * 
+	 * <p>技术要点:</p>
+	 * <ul>
+	 *   <li>使用response_mode="blocking",适合快速响应的场景</li>
+	 *   <li>inputs中包含第一轮提取的所有字段数据,Dify会基于这些数据生成报告</li>
+	 *   <li>query参数可以指定报告生成的具体要求</li>
+	 *   <li>返回的JSON包含answer(报告内容)、conversation_id等字段</li>
+	 *   <li>调用线程会阻塞直到收到完整响应或超时</li>
+	 * </ul>
+	 * 
+	 * @param userId 用户标识(使用当前登录用户的loginName)
+	 * @param inputs 输入参数字典,必须包含:
+	 *               <ul>
+	 *                 <li>selected_fields (Array): 用户勾选的字段列表</li>
+	 *                 <li>doc_results (Array): 文档提取结果数组</li>
+	 *                 <li>output_type (String): 输出类型(审计报告/审定单)</li>
+	 *               </ul>
+	 * @param query 查询提示词(如:"请生成审计报告")
+	 * @return Dify返回的完整JSON字符串,示例:
+	 *         <pre>{
+	 *           "event": "message",
+	 *           "answer": "审计报告内容...",
+	 *           "conversation_id": "xxx",
+	 *           "message_id": "yyy"
+	 *         }</pre>
+	 * @throws Exception HTTP请求失败或响应异常时抛出
+	 */
+	public String chat(String userId, Map<String, Object> inputs, String query) throws Exception {
+		// 构建请求体
+		Map<String, Object> requestBody = new HashMap<>();
+		requestBody.put("inputs", inputs);
+		requestBody.put("query", query);
+		requestBody.put("response_mode", "blocking");  // 阻塞模式,等待完整响应
+		requestBody.put("user", userId);
+		
+		// 构建HTTP请求
+		Request request = new Request.Builder()
+			.url(apiUrl + "/v1/chat-messages")
+			.post(RequestBody.create(
+				MediaType.parse("application/json; charset=utf-8"),
+				JSON.toJSONString(requestBody)
+			))
+			.addHeader("Authorization", "Bearer " + apiKey)
+			.build();
+		
+		// 发送请求并等待响应(同步阻塞)
+		try (Response response = httpClient.newCall(request).execute()) {
+			if (!response.isSuccessful()) {
+				throw new Exception("HTTP " + response.code());
+			}
+			
+			ResponseBody body = response.body();
+			if (body == null) {
+				throw new Exception("响应体为空");
+			}
+			
+			return body.string();
+		}
+	}
+}

+ 278 - 0
src/main/java/com/jeeplus/modules/audit_assistant/utils/ExcelConvertUtil.java

@@ -0,0 +1,278 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.utils;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.poi.hssf.usermodel.HSSFDateUtil;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.InputStream;
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * Excel与Luckysheet JSON格式互转工具类
+ * 
+ * <p>提供两种转换方法:</p>
+ * <ul>
+ *   <li><b>Excel → Luckysheet JSON</b>:将Excel文件转换为Luckysheet组件可识别的JSON格式,供前端在线编辑</li>
+ *   <li><b>Luckysheet JSON → Excel</b>:将前端编辑后的JSON数据转换回Excel格式,用于保存和下载</li>
+ * </ul>
+ * 
+ * <p>技术要点:</p>
+ * <ul>
+ *   <li>使用Apache POI 3.16处理Excel文件(兼容旧版本POI API)</li>
+ *   <li>支持多Sheet转换,保留所有工作表数据</li>
+ *   <li>单元格数据处理:区分实际值(v)和显示值(m),支持公式、日期、数字格式化</li>
+ *   <li>日期类型通过HSSFDateUtil识别并转换为yyyy-MM-dd格式</li>
+ *   <li>数字类型使用DecimalFormat避免科学计数法显示</li>
+ * </ul>
+ * 
+ * <p>使用场景:</p>
+ * <ul>
+ *   <li>用户上传Excel后,转换为Luckysheet JSON供前端编辑</li>
+ *   <li>用户编辑完成后,将JSON转换回Excel并上传到OSS</li>
+ *   <li>保持Excel的完整性和格式,包括样式、公式等(当前实现为基础版本)</li>
+ * </ul>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.service.AuditAssistantService
+ */
+public class ExcelConvertUtil {
+	
+	// ==================== Excel → Luckysheet JSON ====================
+	
+	/**
+	 * 将Excel文件转换为Luckysheet JSON格式
+	 * 
+	 * <p>转换逻辑:</p>
+	 * <ol>
+	 *   <li>使用XSSFWorkbook读取Excel文件(支持.xlsx格式)</li>
+	 *   <li>遍历所有Sheet,为每个Sheet创建JSON对象</li>
+	 *   <li>遍历每个单元格,提取行号(r)、列号(c)、值(v)、显示值(m)</li>
+	 *   <li>根据单元格类型调用不同的getValue方法获取值</li>
+	 *   <li>将所有单元格数据组装成celldata数组</li>
+	 * </ol>
+	 * 
+	 * <p>Luckysheet数据结构示例:</p>
+	 * <pre>{@code
+	 * [
+	 *   {
+	 *     "name": "Sheet1",
+	 *     "celldata": [
+	 *       {"r": 0, "c": 0, "v": {"v": "项目名称", "m": "项目名称"}},
+	 *       {"r": 0, "c": 1, "v": {"v": "合同金额", "m": "合同金额"}}
+	 *     ]
+	 *   }
+	 * ]
+	 * }</pre>
+	 * 
+	 * @param excelStream Excel文件输入流(.xlsx格式)
+	 * @return Luckysheet JSON数组,包含所有Sheet的数据
+	 * @throws Exception Excel解析失败时抛出异常
+	 */
+	public static JSONArray excelToLuckysheet(InputStream excelStream) throws Exception {
+		JSONArray sheets = new JSONArray();
+		
+		try (Workbook workbook = new XSSFWorkbook(excelStream)) {
+			for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+				Sheet sheet = workbook.getSheetAt(i);
+				JSONObject sheetData = new JSONObject();
+				sheetData.put("name", sheet.getSheetName());
+				
+				// 转换单元格数据
+				JSONArray celldata = new JSONArray();
+				for (Row row : sheet) {
+					for (Cell cell : row) {
+						JSONObject cellData = new JSONObject();
+						cellData.put("r", row.getRowNum());
+						cellData.put("c", cell.getColumnIndex());
+						
+						JSONObject value = new JSONObject();
+						value.put("v", getCellValue(cell));  // 实际值
+						value.put("m", getCellDisplayValue(cell));  // 显示值
+						cellData.put("v", value);
+						
+						celldata.add(cellData);
+					}
+				}
+				sheetData.put("celldata", celldata);
+				sheets.add(sheetData);
+			}
+		}
+		
+		return sheets;
+	}
+	
+	// ==================== Luckysheet JSON → Excel ====================
+	
+	/**
+	 * 将Luckysheet JSON转换为Excel Workbook对象
+	 * 
+	 * <p>转换逻辑:</p>
+	 * <ol>
+	 *   <li>遍历Luckysheet JSON数组,为每个Sheet创建Excel工作表</li>
+	 *   <li>解析celldata数组,根据行号(r)和列号(c)定位单元格</li>
+	 *   <li>提取单元格的值(v),根据内容类型设置到对应单元格</li>
+	 *   <li>自动识别数字、日期、文本等类型并设置合适的单元格样式</li>
+	 * </ol>
+	 * 
+	 * <p>注意事项:</p>
+	 * <ul>
+	 *   <li>当前实现为基础版本,主要处理单元格数据,暂不处理样式和公式</li>
+	 *   <li>如果值为数字字符串,会自动转换为数值类型</li>
+	 *   <li>如果值符合日期格式(yyyy-MM-dd),会转换为日期类型</li>
+	 *   <li>返回的Workbook需要调用write()方法写入输出流</li>
+	 * </ul>
+	 * 
+	 * @param luckysheetData Luckysheet JSON数组(来自前端提交)
+	 * @return Excel Workbook对象(XSSFWorkbook格式,.xlsx)
+	 * @throws Exception Excel创建失败时抛出异常
+	 */
+	public static Workbook luckysheetToExcel(JSONArray luckysheetData) throws Exception {
+		Workbook workbook = new XSSFWorkbook();
+		
+		for (int i = 0; i < luckysheetData.size(); i++) {
+			JSONObject sheetData = luckysheetData.getJSONObject(i);
+			Sheet sheet = workbook.createSheet(sheetData.getString("name"));
+			
+			JSONArray celldata = sheetData.getJSONArray("celldata");
+			for (int j = 0; j < celldata.size(); j++) {
+				JSONObject cell = celldata.getJSONObject(j);
+				int r = cell.getIntValue("r");
+				int c = cell.getIntValue("c");
+				String value = cell.getJSONObject("v").getString("v");
+				
+				Row row = sheet.getRow(r);
+				if (row == null) row = sheet.createRow(r);
+				
+				Cell excelCell = row.createCell(c);
+				excelCell.setCellValue(value);
+			}
+		}
+		
+		return workbook;
+	}
+	
+	// ==================== 单元格值读取辅助方法 ====================
+	
+	/**
+	 * 获取Excel单元格的实际值(用于数据存储)
+	 * 
+	 * <p>根据单元格类型返回对应的字符串值:</p>
+	 * <ul>
+	 *   <li>STRING: 直接返回文本内容</li>
+	 *   <li>NUMERIC: 
+	 *     <ul>
+	 *       <li>如果是日期格式,转换为yyyy-MM-dd字符串</li>
+	 *       <li>如果是整数,去除小数点(如100.0 → "100")</li>
+	 *       <li>如果是小数,保留原始数值</li>
+	 *     </ul>
+	 *   </li>
+	 *   <li>BOOLEAN: 返回"true"或"false"</li>
+	 *   <li>FORMULA: 返回公式表达式(如:SUM(A1:B2))</li>
+	 *   <li>BLANK: 返回空字符串</li>
+	 * </ul>
+	 * 
+	 * <p>版本兼容性:</p>
+	 * <ul>
+	 *   <li>使用POI 3.16的Cell.CELL_TYPE_*常量(而非枚举)</li>
+	 *   <li>使用HSSFDateUtil处理日期判断和转换</li>
+	 * </ul>
+	 * 
+	 * @param cell Excel单元格对象
+	 * @return 单元格的实际值(字符串格式)
+	 */
+	private static String getCellValue(Cell cell) {
+		if (cell == null) return "";
+		
+		switch (cell.getCellType()) {
+			case Cell.CELL_TYPE_STRING:
+				return cell.getStringCellValue();
+			case Cell.CELL_TYPE_NUMERIC:
+				if (HSSFDateUtil.isCellDateFormatted(cell)) {
+					Date date = HSSFDateUtil.getJavaDate(cell.getNumericCellValue());
+					return new SimpleDateFormat("yyyy-MM-dd").format(date);
+				} else {
+					double numVal = cell.getNumericCellValue();
+					// 如果是整数,不显示小数点
+					if (numVal == Math.floor(numVal)) {
+						return String.valueOf((long) numVal);
+					}
+					return String.valueOf(numVal);
+				}
+			case Cell.CELL_TYPE_BOOLEAN:
+				return String.valueOf(cell.getBooleanCellValue());
+			case Cell.CELL_TYPE_FORMULA:
+				return cell.getCellFormula();
+			case Cell.CELL_TYPE_BLANK:
+				return "";
+			default:
+				return "";
+		}
+	}
+	
+	/**
+	 * 获取Excel单元格的显示值(用于前端展示)
+	 * 
+	 * <p>与getCellValue的区别:</p>
+	 * <ul>
+	 *   <li>getCellValue返回实际存储的值(如公式表达式)</li>
+	 *   <li>getCellDisplayValue返回用户看到的值(如公式计算结果)</li>
+	 * </ul>
+	 * 
+	 * <p>处理逻辑:</p>
+	 * <ul>
+	 *   <li>NUMERIC类型使用DecimalFormat格式化,避免科学计数法(如1E5 → 100000)</li>
+	 *   <li>其他类型与getCellValue保持一致</li>
+	 *   <li>日期格式统一为yyyy-MM-dd</li>
+	 * </ul>
+	 * 
+	 * <p>版本兼容性:</p>
+	 * <ul>
+	 *   <li>使用POI 3.16的Cell.CELL_TYPE_*常量</li>
+	 *   <li>使用DecimalFormat("#.##########")保留最多10位小数,去除尾部零</li>
+	 * </ul>
+	 * 
+	 * @param cell Excel单元格对象
+	 * @return 单元格的显示值(字符串格式,适合前端展示)
+	 */
+	private static String getCellDisplayValue(Cell cell) {
+		if (cell == null) return "";
+		
+		switch (cell.getCellType()) {
+			case Cell.CELL_TYPE_STRING:
+				return cell.getStringCellValue();
+			case Cell.CELL_TYPE_NUMERIC:
+				if (HSSFDateUtil.isCellDateFormatted(cell)) {
+					Date date = HSSFDateUtil.getJavaDate(cell.getNumericCellValue());
+					return new SimpleDateFormat("yyyy-MM-dd").format(date);
+				} else {
+					double numVal = cell.getNumericCellValue();
+					// 使用DecimalFormat格式化数字
+					DecimalFormat df = new DecimalFormat("#.##");
+					return df.format(numVal);
+				}
+			case Cell.CELL_TYPE_BOOLEAN:
+				return String.valueOf(cell.getBooleanCellValue());
+			case Cell.CELL_TYPE_FORMULA:
+				// 公式返回计算结果
+				try {
+					double numVal = cell.getNumericCellValue();
+					DecimalFormat df = new DecimalFormat("#.##");
+					return df.format(numVal);
+				} catch (Exception e) {
+					return cell.getCellFormula();
+				}
+			case Cell.CELL_TYPE_BLANK:
+				return "";
+			default:
+				return "";
+		}
+	}
+}

+ 365 - 0
src/main/java/com/jeeplus/modules/audit_assistant/web/AuditAssistantController.java

@@ -0,0 +1,365 @@
+/**
+ * Copyright &copy; 2015-2020 <a href="http://www.jeeplus.org/">JeePlus</a> All rights reserved.
+ */
+package com.jeeplus.modules.audit_assistant.web;
+
+import com.jeeplus.common.persistence.Page;
+import com.jeeplus.common.utils.StringUtils;
+import com.jeeplus.common.web.BaseController;
+import com.jeeplus.modules.audit_assistant.entity.AuditTask;
+import com.jeeplus.modules.audit_assistant.service.AuditAssistantService;
+import com.jeeplus.modules.sys.utils.UserUtils;
+import org.apache.shiro.authz.annotation.RequiresPermissions;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Map;
+
+/**
+ * 审计助手Controller
+ * 
+ * <p>提供审计任务管理的RESTful API接口,包括:</p>
+ * <ul>
+ *   <li>任务列表查询与分页(支持按状态、部门过滤)</li>
+ *   <li>文件上传与任务创建</li>
+ *   <li>第一轮字段提取处理(调用Dify工作流)</li>
+ *   <li>第二轮报告生成(根据用户选择的字段)</li>
+ *   <li>Excel在线编辑支持(Luckysheet集成)</li>
+ *   <li>任务删除与文件下载</li>
+ * </ul>
+ * 
+ * <p>权限控制:</p>
+ * <ul>
+ *   <li>使用@RequiresPermissions注解进行方法级权限校验</li>
+ *   <li>数据级别通过office_id实现部门隔离</li>
+ *   <li>所有查询自动过滤当前用户创建的任务</li>
+ * </ul>
+ * 
+ * <p>代码规范:</p>
+ * <ul>
+ *   <li>统一使用@RequestMapping注解(而非@GetMapping/@PostMapping)</li>
+ *   <li>GET请求返回JSP视图路径,API请求添加@ResponseBody</li>
+ *   <li>@ModelAttribute用于实体初始化,支持新增和编辑场景</li>
+ *   <li>返回Map<String, Object>或Entity对象,保持与WorkKnowledgeBase模块一致</li>
+ * </ul>
+ * 
+ * @author Qoder
+ * @version 2026-10-22
+ * @see com.jeeplus.modules.audit_assistant.service.AuditAssistantService
+ * @see com.jeeplus.common.web.BaseController
+ */
+@Controller
+@RequestMapping(value = "${adminPath}/audit/assistant")
+public class AuditAssistantController extends BaseController {
+	
+	@Autowired
+	private AuditAssistantService auditAssistantService;
+	
+	/**
+	 * 实体初始化方法(JeePlus框架标准)
+	 * 
+	 * <p>在Controller方法执行前自动调用,用于:</p>
+	 * <ul>
+	 *   <li>编辑场景:根据id从数据库加载实体数据</li>
+	 *   <li>新增场景:创建空实体对象</li>
+	 * </ul>
+	 * 
+	 * @param id 任务ID(可选参数,有值表示编辑场景)
+	 * @return 初始化后的AuditTask实体
+	 */
+	@ModelAttribute
+	public AuditTask get(@RequestParam(required = false) String id) {
+		AuditTask entity = null;
+		if (StringUtils.isNotBlank(id)) {
+			entity = auditAssistantService.get(id);
+		}
+		if (entity == null) {
+			entity = new AuditTask();
+		}
+		return entity;
+	}
+	
+	// ==================== 页面路由 ====================
+	
+	/**
+	 * 任务列表页(GET请求)
+	 * 
+	 * <p>访问路径:/audit/assistant/list</p>
+	 * <p>权限要求:audit:assistant:list</p>
+	 * 
+	 * @return JSP视图路径
+	 */
+	@RequiresPermissions("audit:assistant:list")
+	@RequestMapping(value = {"list", ""})
+	public String list(AuditTask auditTask, HttpServletRequest request, HttpServletResponse response, Model model) {
+		return "modules/audit_assistant/auditTaskList";
+	}
+	
+	/**
+	 * 新建/编辑任务页(GET请求)
+	 * 
+	 * <p>访问路径:/audit/assistant/form</p>
+	 * <p>权限要求:audit:assistant:edit</p>
+	 * <p>说明:通过@ModelAttribute自动初始化auditTask对象</p>
+	 * 
+	 * @param auditTask 任务实体(由@ModelAttribute自动注入)
+	 * @param model Spring MVC模型对象
+	 * @return JSP视图路径
+	 */
+	@RequiresPermissions("audit:assistant:edit")
+	@RequestMapping(value = "form")
+	public String form(AuditTask auditTask, Model model) {
+		model.addAttribute("auditTask", auditTask);
+		return "modules/audit_assistant/auditTaskForm";
+	}
+	
+	/**
+	 * 任务详情页(GET请求)
+	 * 
+	 * <p>访问路径:/audit/assistant/detail?taskId=xxx</p>
+	 * <p>权限要求:audit:assistant:view</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @param model Spring MVC模型对象
+	 * @return JSP视图路径
+	 */
+	@RequiresPermissions("audit:assistant:view")
+	@RequestMapping(value = "detail")
+	public String detail(@RequestParam String taskId, Model model) {
+		AuditTask task = auditAssistantService.get(taskId);
+		model.addAttribute("task", task);
+		model.addAttribute("taskId", taskId);
+		return "modules/audit_assistant/auditTaskDetail";
+	}
+	
+	/**
+	 * Excel在线编辑页(GET请求)
+	 * 
+	 * <p>访问路径:/audit/assistant/excelEdit?fileId=xxx</p>
+	 * <p>权限要求:audit:assistant:edit</p>
+	 * <p>说明:前端会加载Luckysheet组件并调用getExcelData API获取数据</p>
+	 * 
+	 * @param fileId 文件ID(必填)
+	 * @param model Spring MVC模型对象
+	 * @return JSP视图路径
+	 */
+	@RequiresPermissions("audit:assistant:edit")
+	@RequestMapping(value = "excelEdit")
+	public String excelEdit(@RequestParam String fileId, Model model) {
+		model.addAttribute("fileId", fileId);
+		return "modules/audit_assistant/excelEdit";
+	}
+	
+	// ==================== API接口(JSON响应) ====================
+	
+	/**
+	 * 任务列表数据API(分页查询)
+	 * 
+	 * <p>请求路径:/audit/assistant/listData 或 /audit/assistant/list/data</p>
+	 * <p>请求方式:GET/POST</p>
+	 * <p>说明:自动过滤当前用户创建的任务,支持按状态、部门等条件筛选</p>
+	 * 
+	 * @param auditTask 查询条件(任务名称、状态、部门等)
+	 * @param request HTTP请求对象
+	 * @param response HTTP响应对象
+	 * @param model Spring MVC模型对象
+	 * @return 分页数据(Page<AuditTask>)
+	 */
+	@ResponseBody
+	@RequestMapping(value = {"listData", "list/data"})
+	public Page<AuditTask> listData(AuditTask auditTask, HttpServletRequest request, HttpServletResponse response, Model model) {
+		Page<AuditTask> page = new Page<>(request, response);
+		auditTask.setCreateBy(UserUtils.getUser());  // 只查询当前用户的任务
+		page = auditAssistantService.findPage(page, auditTask);
+		return page;
+	}
+	
+	/**
+	 * 任务详情数据API
+	 * 
+	 * <p>请求路径:/audit/assistant/detailData</p>
+	 * <p>请求方式:GET</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @return AuditTask实体对象
+	 */
+	@ResponseBody
+	@RequestMapping(value = "detailData")
+	public AuditTask detailData(@RequestParam String taskId) {
+		return auditAssistantService.get(taskId);
+	}
+	
+	/**
+	 * 上传文件并创建任务API
+	 * 
+	 * <p>请求路径:/audit/assistant/upload</p>
+	 * <p>请求方式:POST(multipart/form-data)</p>
+	 * <p>说明:支持多文件上传,自动创建任务记录并生成OSS路径</p>
+	 * 
+	 * @param files 上传的文件数组(支持PDF/Word/Excel/JPG/PNG)
+	 * @param projectType 项目类型(建设工程/市政工程等)
+	 * @param outputType 输出类型(审计报告/审定单)
+	 * @return Map包含success、msg、taskId、files等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "upload")
+	public Map<String, Object> uploadFiles(@RequestParam("files") MultipartFile[] files,
+										   @RequestParam("projectType") String projectType,
+										   @RequestParam("outputType") String outputType) {
+		return auditAssistantService.createTask(files, projectType, outputType);
+	}
+	
+	/**
+	 * 开始第一轮处理API(字段提取)
+	 * 
+	 * <p>请求路径:/audit/assistant/startExtract</p>
+	 * <p>请求方式:POST</p>
+	 * <p>说明:异步调用Dify工作流,通过SSE流式接收提取结果</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @return Map包含success、msg等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "startExtract")
+	public Map<String, Object> startExtract(@RequestParam String taskId) {
+		return auditAssistantService.startFirstRound(taskId);
+	}
+	
+	/**
+	 * 生成报告API(第二轮处理)
+	 * 
+	 * <p>请求路径:/audit/assistant/generateReport</p>
+	 * <p>请求方式:POST</p>
+	 * <p>说明:根据用户选择的字段生成审计报告,阻塞式调用Dify</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @param selectedFields 用户勾选的字段(JSON数组字符串)
+	 * @return Map包含success、msg、reportUrl、reportName等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "generateReport")
+	public Map<String, Object> generateReport(@RequestParam String taskId,
+											  @RequestParam String selectedFields) {
+		return auditAssistantService.startSecondRound(taskId, selectedFields);
+	}
+	
+	/**
+	 * 查询任务处理状态API(前端轮询使用)
+	 * 
+	 * <p>请求路径:/audit/assistant/getStatus</p>
+	 * <p>请求方式:GET</p>
+	 * <p>说明:返回任务当前状态和处理进度,用于前端展示进度条</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @return AuditTask实体对象(包含status、processLog等字段)
+	 */
+	@ResponseBody
+	@RequestMapping(value = "getStatus")
+	public AuditTask getStatus(@RequestParam String taskId) {
+		return auditAssistantService.get(taskId);
+	}
+	
+	/**
+	 * 获取Excel数据API(转换为Luckysheet格式)
+	 * 
+	 * <p>请求路径:/audit/assistant/getExcelData</p>
+	 * <p>请求方式:GET</p>
+	 * <p>说明:将Excel文件转换为Luckysheet JSON,供前端编辑组件使用</p>
+	 * 
+	 * @param fileId 文件ID(必填)
+	 * @return Map包含success、msg、luckysheetData等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "getExcelData")
+	public Map<String, Object> getExcelData(@RequestParam String fileId) {
+		return auditAssistantService.getExcelData(fileId);
+	}
+	
+	/**
+	 * 保存编辑后的Excel API
+	 * 
+	 * <p>请求路径:/audit/assistant/saveExcel</p>
+	 * <p>请求方式:POST</p>
+	 * <p>说明:将Luckysheet JSON转换回Excel,创建新版本并上传到OSS</p>
+	 * 
+	 * @param fileId 原文件ID(必填)
+	 * @param luckysheetData Luckysheet格式的JSON字符串
+	 * @return Map包含success、msg、newFileId等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "saveExcel")
+	public Map<String, Object> saveExcel(@RequestParam String fileId,
+										 @RequestParam String luckysheetData) {
+		return auditAssistantService.saveEditedExcel(fileId, luckysheetData);
+	}
+	
+	/**
+	 * 重新处理API(重新调用Dify提取字段)
+	 * 
+	 * <p>请求路径:/audit/assistant/reExtract</p>
+	 * <p>请求方式:POST</p>
+	 * <p>说明:用户上传新文件或修改后,可以手动触发重新提取</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @return Map包含success、msg等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "reExtract")
+	public Map<String, Object> reExtract(@RequestParam String taskId) {
+		return auditAssistantService.startFirstRound(taskId);
+	}
+	
+	/**
+	 * 删除任务API(逻辑删除)
+	 * 
+	 * <p>请求路径:/audit/assistant/delete</p>
+	 * <p>请求方式:POST</p>
+	 * <p>说明:仅设置del_flag='1',不物理删除数据库记录和OSS文件</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @return Map包含success、msg等信息
+	 */
+	@ResponseBody
+	@RequestMapping(value = "delete")
+	public Map<String, Object> delete(@RequestParam String taskId) {
+		return auditAssistantService.deleteTask(taskId);
+	}
+	
+	/**
+	 * 下载报告文件
+	 * 
+	 * <p>请求路径:/audit/assistant/downloadReport</p>
+	 * <p>请求方式:GET</p>
+	 * <p>说明:从OSS下载报告文件,触发浏览器下载</p>
+	 * 
+	 * @param taskId 任务ID(必填)
+	 * @param response HTTP响应对象(用于输出文件流)
+	 */
+	@RequestMapping(value = "downloadReport")
+	public void downloadReport(@RequestParam String taskId, HttpServletResponse response) {
+		auditAssistantService.downloadReport(taskId, response);
+	}
+	
+	/**
+	 * 下载原始上传文件
+	 * 
+	 * <p>请求路径:/audit/assistant/downloadFile</p>
+	 * <p>请求方式:GET</p>
+	 * <p>说明:下载用户上传的原始文件(PDF/Word/Excel等)</p>
+	 * 
+	 * @param fileId 文件ID(必填)
+	 * @param response HTTP响应对象(用于输出文件流)
+	 */
+	@RequestMapping(value = "downloadFile")
+	public void downloadFile(@RequestParam String fileId, HttpServletResponse response) {
+		auditAssistantService.downloadFile(fileId, response);
+	}
+}

+ 12 - 0
src/main/resources/jeeplus.properties

@@ -445,3 +445,15 @@ oidc.clientSecret=KCOPjVcNFIEyUdxalBHNWuIpdBDQY5X8Eg0V36FPt3J2VzIapPqJo5HdjJCI3Y
 #????
 #????
 oidc.redirectUri=http://127.0.0.1:8090/a/oidc/callback
 oidc.redirectUri=http://127.0.0.1:8090/a/oidc/callback
 oidc.postLogoutRedirectUri=http://127.0.0.1:8090/a/login?oidcChecked=1
 oidc.postLogoutRedirectUri=http://127.0.0.1:8090/a/login?oidcChecked=1
+
+#============================#
+#===== Audit Assistant ======#
+#============================#
+# OSS目录配置
+auditAssistantDir=attachment-file/audit-assistant/
+auditTempDir=attachment-file/audit-temp/
+
+# Dify API配置
+dify.api.url=http://YOUR_DIFY_SERVER:5001
+dify.api.key=app-YOUR_API_KEY_HERE
+dify.api.timeout=300000

+ 196 - 0
src/main/resources/mappings/modules/audit_assistant/AuditFileExtractDao.xml

@@ -0,0 +1,196 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.jeeplus.modules.audit_assistant.dao.AuditFileExtractDao">
+    
+	<sql id="auditFileExtractColumns">
+		a.id AS "id",
+		a.task_id AS "taskId",
+		a.file_name AS "fileName",
+		a.file_url AS "fileUrl",
+		a.file_size AS "fileSize",
+		a.file_type AS "fileType",
+		a.file_version AS "fileVersion",
+		a.replaced_by AS "replacedBy",
+		a.dify_file_id AS "difyFileId",
+		a.extract_data AS "extractData",
+		a.extract_status AS "extractStatus",
+		a.extract_error AS "extractError",
+		a.edit_version AS "editVersion",
+		a.last_edit_by AS "lastEditBy",
+		a.last_edit_time AS "lastEditTime",
+		a.create_by AS "createBy.id",
+		a.create_date AS "createDate",
+		a.update_by AS "updateBy.id",
+		a.update_date AS "updateDate",
+		a.remarks AS "remarks",
+		a.del_flag AS "delFlag"
+	</sql>
+	
+	<sql id="auditFileExtractJoins">
+	</sql>
+    
+	<select id="get" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		WHERE a.id = #{id}
+	</select>
+	
+	<select id="findList" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		<where>
+			a.del_flag = '0'
+			<if test="taskId != null and taskId != ''">
+				AND a.task_id = #{taskId}
+			</if>
+			<if test="fileName != null and fileName != ''">
+				AND a.file_name LIKE 
+					<if test="dbName == 'oracle'">'%'||#{fileName}||'%'</if>
+					<if test="dbName == 'mssql'">'%'+#{fileName}+'%'</if>
+					<if test="dbName == 'mysql'">concat('%',#{fileName},'%')</if>
+			</if>
+			<if test="extractStatus != null and extractStatus != ''">
+				AND a.extract_status = #{extractStatus}
+			</if>
+		</where>
+		ORDER BY a.file_version DESC, a.create_date DESC
+	</select>
+	
+	<select id="findAllList" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		<where>
+			a.del_flag = '0'
+		</where>
+		ORDER BY a.create_date DESC
+	</select>
+	
+	<insert id="insert">
+		INSERT INTO audit_file_extract(
+			id,
+			task_id,
+			file_name,
+			file_url,
+			file_size,
+			file_type,
+			file_version,
+			replaced_by,
+			dify_file_id,
+			extract_data,
+			extract_status,
+			extract_error,
+			edit_version,
+			last_edit_by,
+			last_edit_time,
+			create_by,
+			create_date,
+			update_by,
+			update_date,
+			remarks,
+			del_flag
+		) VALUES (
+			#{id},
+			#{taskId},
+			#{fileName},
+			#{fileUrl},
+			#{fileSize},
+			#{fileType},
+			#{fileVersion},
+			#{replacedBy},
+			#{difyFileId},
+			#{extractData},
+			#{extractStatus},
+			#{extractError},
+			#{editVersion},
+			#{lastEditBy},
+			#{lastEditTime},
+			#{createBy.id},
+			#{createDate},
+			#{updateBy.id},
+			#{updateDate},
+			#{remarks},
+			#{delFlag}
+		)
+	</insert>
+	
+	<update id="update">
+		UPDATE audit_file_extract SET
+			task_id = #{taskId},
+			file_name = #{fileName},
+			file_url = #{fileUrl},
+			file_size = #{fileSize},
+			file_type = #{fileType},
+			file_version = #{fileVersion},
+			replaced_by = #{replacedBy},
+			dify_file_id = #{difyFileId},
+			extract_data = #{extractData},
+			extract_status = #{extractStatus},
+			extract_error = #{extractError},
+			edit_version = #{editVersion},
+			last_edit_by = #{lastEditBy},
+			last_edit_time = #{lastEditTime},
+			update_by = #{updateBy.id},
+			update_date = #{updateDate},
+			remarks = #{remarks}
+		WHERE id = #{id}
+	</update>
+	
+	<!-- 自定义方法 -->
+	<select id="findByTaskId" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		WHERE a.task_id = #{taskId}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		ORDER BY a.file_version DESC, a.create_date DESC
+	</select>
+	
+	<select id="findByTaskIdAndVersion" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		WHERE a.task_id = #{taskId}
+		AND a.file_version = #{version}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		LIMIT 1
+	</select>
+	
+	<select id="findLatestByTaskId" resultType="AuditFileExtract">
+		SELECT 
+			<include refid="auditFileExtractColumns"/>
+		FROM audit_file_extract a
+		WHERE a.task_id = #{taskId}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		ORDER BY a.file_version DESC, a.create_date DESC
+		LIMIT 1
+	</select>
+	
+	<update id="updateExtractStatus">
+		UPDATE audit_file_extract SET
+			extract_status = #{status},
+			extract_error = #{error}
+		WHERE id = #{id}
+	</update>
+	
+	<update id="updateExtractData">
+		UPDATE audit_file_extract SET
+			extract_data = #{extractData}
+		WHERE id = #{id}
+	</update>
+	
+	<update id="markAsReplaced">
+		UPDATE audit_file_extract SET
+			replaced_by = #{replacedBy}
+		WHERE id = #{id}
+	</update>
+	
+	<update id="deleteByTaskId">
+		UPDATE audit_file_extract SET
+			del_flag = '1'
+		WHERE task_id = #{taskId}
+	</update>
+	
+</mapper>

+ 211 - 0
src/main/resources/mappings/modules/audit_assistant/AuditTaskDao.xml

@@ -0,0 +1,211 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.jeeplus.modules.audit_assistant.dao.AuditTaskDao">
+    
+	<sql id="auditTaskColumns">
+		a.id AS "id",
+		a.task_name AS "taskName",
+		a.project_type AS "projectType",
+		a.output_type AS "outputType",
+		a.status AS "status",
+		a.file_count AS "fileCount",
+		a.selected_fields AS "selectedFields",
+		a.dify_conversation_id AS "difyConversationId",
+		a.dify_user_id AS "difyUserId",
+		a.result_file_url AS "resultFileUrl",
+		a.result_file_name AS "resultFileName",
+		a.process_log AS "processLog",
+		a.error_msg AS "errorMsg",
+		a.start_time AS "startTime",
+		a.end_time AS "endTime",
+		a.duration_seconds AS "durationSeconds",
+		a.office_id AS "office.id",
+		a.create_by AS "createBy.id",
+		a.create_date AS "createDate",
+		a.update_by AS "updateBy.id",
+		a.update_date AS "updateDate",
+		a.remarks AS "remarks",
+		a.del_flag AS "delFlag"
+	</sql>
+	
+	<sql id="auditTaskJoins">
+		LEFT JOIN sys_office o ON o.id = a.office_id
+	</sql>
+    
+	<select id="get" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		WHERE a.id = #{id}
+	</select>
+	
+	<select id="findList" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		<include refid="auditTaskJoins"/>
+		<where>
+			a.del_flag = '0'
+			<if test="taskName != null and taskName != ''">
+				AND a.task_name LIKE 
+					<if test="dbName == 'oracle'">'%'||#{taskName}||'%'</if>
+					<if test="dbName == 'mssql'">'%'+#{taskName}+'%'</if>
+					<if test="dbName == 'mysql'">concat('%',#{taskName},'%')</if>
+			</if>
+			<if test="projectType != null and projectType != ''">
+				AND a.project_type = #{projectType}
+			</if>
+			<if test="status != null and status != ''">
+				AND a.status = #{status}
+			</if>
+			<if test="office != null and office.id != null and office.id != ''">
+				AND a.office_id = #{office.id}
+			</if>
+			<if test="officeId != null and officeId != ''">
+				AND a.office_id = #{officeId}
+			</if>
+		</where>
+		<choose>
+			<when test="page !=null and page.orderBy != null and page.orderBy != ''">
+				ORDER BY ${page.orderBy}
+			</when>
+			<otherwise>
+				ORDER BY a.create_date DESC
+			</otherwise>
+		</choose>
+	</select>
+	
+	<select id="findAllList" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		<where>
+			a.del_flag = '0'
+		</where>
+		<choose>
+			<when test="page !=null and page.orderBy != null and page.orderBy != ''">
+				ORDER BY ${page.orderBy}
+			</when>
+			<otherwise>
+				ORDER BY a.create_date DESC
+			</otherwise>
+		</choose>
+	</select>
+	
+	<insert id="insert">
+		INSERT INTO audit_task(
+			id,
+			task_name,
+			project_type,
+			output_type,
+			status,
+			file_count,
+			selected_fields,
+			dify_conversation_id,
+			dify_user_id,
+			result_file_url,
+			result_file_name,
+			process_log,
+			error_msg,
+			start_time,
+			end_time,
+			duration_seconds,
+			office_id,
+			create_by,
+			create_date,
+			update_by,
+			update_date,
+			remarks,
+			del_flag
+		) VALUES (
+			#{id},
+			#{taskName},
+			#{projectType},
+			#{outputType},
+			#{status},
+			#{fileCount},
+			#{selectedFields},
+			#{difyConversationId},
+			#{difyUserId},
+			#{resultFileUrl},
+			#{resultFileName},
+			#{processLog},
+			#{errorMsg},
+			#{startTime},
+			#{endTime},
+			#{durationSeconds},
+			#{office.id},
+			#{createBy.id},
+			#{createDate},
+			#{updateBy.id},
+			#{updateDate},
+			#{remarks},
+			#{delFlag}
+		)
+	</insert>
+	
+	<update id="update">
+		UPDATE audit_task SET
+			task_name = #{taskName},
+			project_type = #{projectType},
+			output_type = #{outputType},
+			status = #{status},
+			file_count = #{fileCount},
+			selected_fields = #{selectedFields},
+			dify_conversation_id = #{difyConversationId},
+			dify_user_id = #{difyUserId},
+			result_file_url = #{resultFileUrl},
+			result_file_name = #{resultFileName},
+			process_log = #{processLog},
+			error_msg = #{errorMsg},
+			start_time = #{startTime},
+			end_time = #{endTime},
+			duration_seconds = #{durationSeconds},
+			office_id = #{office.id},
+			update_by = #{updateBy.id},
+			update_date = #{updateDate},
+			remarks = #{remarks}
+		WHERE id = #{id}
+	</update>
+	
+	<!-- 自定义方法 -->
+	<select id="findByStatus" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		WHERE a.status = #{status}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		ORDER BY a.create_date DESC
+	</select>
+	
+	<select id="findByDifyConversationId" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		WHERE a.dify_conversation_id = #{conversationId}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		LIMIT 1
+	</select>
+	
+	<select id="findByCreateBy" resultType="AuditTask">
+		SELECT 
+			<include refid="auditTaskColumns"/>
+		FROM audit_task a
+		WHERE a.create_by = #{createBy}
+		AND a.del_flag = #{DEL_FLAG_NORMAL}
+		ORDER BY a.create_date DESC
+	</select>
+	
+	<update id="updateStatus">
+		UPDATE audit_task SET
+			status = #{status}
+		WHERE id = #{id}
+	</update>
+	
+	<update id="updateDifyConversationId">
+		UPDATE audit_task SET
+			dify_conversation_id = #{conversationId}
+		WHERE id = #{id}
+	</update>
+	
+</mapper>

+ 757 - 0
src/main/webapp/webpage/modules/audit_assistant/auditTaskList.jsp

@@ -0,0 +1,757 @@
+<%@ page contentType="text/html;charset=UTF-8" language="java" %>
+<%@ include file="/webpage/include/taglib.jsp"%>
+<html>
+<head>
+    <title>审计助手</title>
+    <meta name="decorator" content="default"/>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body { font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 13px; color: #333; background: #eef1f5; overflow: hidden; height: 100vh; }
+
+        /* ===== Wrapper (fills the framework iframe) ===== */
+        .aa-wrapper { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
+
+        /* ===== Audit Assistant Area ===== */
+        .aa-area { flex: 1; display: flex; overflow: hidden; padding: 10px; gap: 10px; }
+
+        /* -- Left Panel -- */
+        .aa-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; background: #fff; border-radius: 3px; border: 1px solid #e8e8e8; overflow: hidden; }
+
+        .aa-left-upload { padding: 12px; border-bottom: 1px solid #f0f0f0; }
+        .aa-left-upload .uz { border: 2px dashed #d9d9d9; border-radius: 3px; padding: 16px 10px; text-align: center; cursor: pointer; transition: all .2s; background: #fafafa; }
+        .aa-left-upload .uz:hover { border-color: #1e88e5; background: #f0f7ff; }
+        .aa-left-upload .uz .uz-icon { font-size: 24px; margin-bottom: 4px; opacity: .4; }
+        .aa-left-upload .uz .uz-text { font-size: 11px; color: #666; margin-bottom: 2px; }
+        .aa-left-upload .uz .uz-hint { font-size: 10px; color: #aaa; }
+        .aa-left-upload .new-btn { margin-top: 8px; }
+        .aa-left-upload .new-btn .btn { width: 100%; justify-content: center; }
+
+        .aa-left-tabs { display: flex; border-bottom: 1px solid #e8e8e8; background: #fafafa; flex-shrink: 0; }
+        .aa-left-tab { flex: 1; padding: 6px 4px; text-align: center; font-size: 11px; cursor: pointer; color: #666; border-bottom: 2px solid transparent; transition: all .15s; }
+        .aa-left-tab:hover { color: #1e88e5; }
+        .aa-left-tab.active { color: #1e88e5; border-bottom-color: #1e88e5; font-weight: 600; background: #fff; }
+        .aa-left-tab .cnt { background: #e0e0e0; color: #666; padding: 0 4px; border-radius: 6px; font-size: 9px; margin-left: 2px; }
+        .aa-left-tab.active .cnt { background: #e3f2fd; color: #1e88e5; }
+
+        .aa-left-list { flex: 1; overflow-y: auto; padding: 6px; }
+        .aa-task { padding: 8px 10px; border-radius: 3px; cursor: pointer; margin-bottom: 3px; transition: all .15s; border: 1px solid transparent; }
+        .aa-task:hover { background: #f5f9ff; border-color: #e3f2fd; }
+        .aa-task.active { background: #e3f2fd; border-color: #90caf9; }
+        .aa-task-hd { display: flex; align-items: center; gap: 5px; margin-bottom: 3px; }
+        .aa-task-st { font-size: 10px; padding: 1px 5px; border-radius: 2px; font-weight: 500; flex-shrink: 0; }
+        .aa-task-st.done { background: #e8f5e9; color: #2e7d32; }
+        .aa-task-st.pending { background: #fff3e0; color: #e65100; }
+        .aa-task-st.failed { background: #ffebee; color: #c62828; }
+        .aa-task-st.processing { background: #e3f2fd; color: #1565c0; }
+        .aa-task-name { font-size: 11px; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
+        .aa-task.active .aa-task-name { color: #1e88e5; }
+        .aa-task-meta { font-size: 10px; color: #999; display: flex; gap: 6px; }
+
+        .aa-left-bottom { padding: 8px 10px; border-top: 1px solid #f0f0f0; display: flex; gap: 4px; background: #fafafa; flex-shrink: 0; }
+        .aa-left-bottom .btn { flex: 1; font-size: 10px; padding: 4px 6px; justify-content: center; }
+
+        /* -- Right Panel -- */
+        .aa-right { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: #fff; border-radius: 3px; border: 1px solid #e8e8e8; }
+
+        .aa-right-toolbar { padding: 8px 14px; border-bottom: 1px solid #e8e8e8; display: flex; align-items: center; justify-content: space-between; background: #fafafa; flex-shrink: 0; }
+        .aa-right-title { font-size: 14px; font-weight: 600; }
+        .aa-right-actions { display: flex; gap: 4px; }
+
+        .aa-right-content { flex: 1; overflow-y: auto; padding: 12px; }
+
+        /* ===== Buttons ===== */
+        .btn { display: inline-flex; align-items: center; gap: 3px; padding: 5px 12px; border-radius: 3px; font-size: 12px; border: none; cursor: pointer; transition: all .15s; font-family: inherit; text-decoration: none; }
+        .btn-primary { background: #1e88e5; color: #fff; }
+        .btn-primary:hover { background: #1976d2; }
+        .btn-success { background: #43a047; color: #fff; }
+        .btn-success:hover { background: #388e3c; }
+        .btn-danger { background: #e53935; color: #fff; }
+        .btn-danger:hover { background: #c62828; }
+        .btn-default { background: #fff; color: #555; border: 1px solid #d9d9d9; }
+        .btn-default:hover { color: #1e88e5; border-color: #1e88e5; }
+        .btn-sm { padding: 3px 8px; font-size: 11px; }
+        .btn-lg { padding: 6px 18px; font-size: 13px; }
+        .btn[disabled] { opacity: .5; cursor: not-allowed; }
+
+        /* ===== Steps ===== */
+        .steps { display: flex; align-items: center; justify-content: center; padding: 16px 0; }
+        .st-item { display: flex; align-items: center; gap: 5px; }
+        .st-dot { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; border: 2px solid #d9d9d9; color: #bbb; background: #fff; }
+        .st-item.active .st-dot { background: #1e88e5; border-color: #1e88e5; color: #fff; }
+        .st-item.done .st-dot { background: #43a047; border-color: #43a047; color: #fff; }
+        .st-lbl { font-size: 11px; color: #bbb; }
+        .st-item.active .st-lbl { color: #1e88e5; font-weight: 600; }
+        .st-item.done .st-lbl { color: #43a047; }
+        .st-line { width: 32px; height: 2px; background: #e8e8e8; margin: 0 3px; }
+        .st-line.done { background: #43a047; }
+
+        /* ===== Cards ===== */
+        .card { background: #fff; border: 1px solid #e8e8e8; border-radius: 3px; margin-bottom: 10px; }
+        .card-hd { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
+        .card-hd .ch-icon { width: 18px; height: 18px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 11px; }
+        .card-bd { padding: 12px; }
+
+        /* ===== Upload Zone (right panel) ===== */
+        .upload-zone { border: 2px dashed #d9d9d9; border-radius: 3px; padding: 28px 16px; text-align: center; cursor: pointer; transition: all .2s; background: #fafafa; }
+        .upload-zone:hover { border-color: #1e88e5; background: #f0f7ff; }
+        .upload-zone .uz-icon { font-size: 28px; margin-bottom: 4px; opacity: .4; }
+        .upload-zone .uz-text { font-size: 12px; color: #666; margin-bottom: 2px; }
+        .upload-zone .uz-hint { font-size: 10px; color: #aaa; }
+
+        /* ===== File Row ===== */
+        .file-row { display: flex; align-items: center; padding: 6px 10px; background: #fafbfc; border: 1px solid #f0f0f0; border-radius: 3px; margin-top: 5px; gap: 8px; }
+        .file-row .fr-icon { width: 24px; height: 24px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
+        .file-row .fr-icon.pdf { background: #ffebee; color: #c62828; }
+        .file-row .fr-icon.xls { background: #e8f5e9; color: #2e7d32; }
+        .file-row .fr-icon.doc { background: #e3f2fd; color: #1565c0; }
+        .file-row .fr-icon.img { background: #fff3e0; color: #e65100; }
+        .file-row .fr-info { flex: 1; }
+        .file-row .fr-name { font-size: 12px; font-weight: 500; }
+        .file-row .fr-size { font-size: 10px; color: #999; }
+        .file-row .fr-status { font-size: 10px; padding: 1px 6px; border-radius: 8px; background: #e8f5e9; color: #2e7d32; }
+        .file-row .fr-status.wait { background: #fff3e0; color: #e65100; }
+        .file-row .fr-rm { cursor: pointer; color: #bbb; font-size: 13px; }
+        .file-row .fr-rm:hover { color: #e53935; }
+
+        /* ===== Form ===== */
+        .form-row { display: flex; gap: 16px; margin-top: 10px; }
+        .form-item { flex: 1; }
+        .form-item label { display: block; font-size: 12px; color: #555; margin-bottom: 4px; font-weight: 500; }
+        .form-item label .req { color: #e53935; }
+        .form-item select { width: 100%; padding: 5px 8px; border: 1px solid #d9d9d9; border-radius: 3px; font-size: 12px; outline: none; height: auto; }
+        .form-item select:focus { border-color: #1e88e5; }
+        .form-hint { font-size: 10px; color: #aaa; margin-top: 2px; }
+
+        /* ===== Log ===== */
+        .log-box { background: #1a1a2e; border-radius: 3px; padding: 10px; font-family: "Consolas", monospace; font-size: 11px; line-height: 1.7; color: #ccc; max-height: 220px; overflow-y: auto; }
+        .log-line { display: flex; gap: 6px; }
+        .log-time { color: #666; flex-shrink: 0; }
+        .log-node { color: #64b5f6; font-weight: 600; flex-shrink: 0; min-width: 80px; }
+        .log-msg { color: #aaa; }
+        .log-line.ok .log-msg { color: #81c784; }
+        .log-line.info .log-msg { color: #64b5f6; }
+        .log-line.warn .log-msg { color: #ffb74d; }
+
+        /* ===== Progress ===== */
+        .progress-wrap { margin: 8px 0; }
+        .progress-label { display: flex; justify-content: space-between; font-size: 11px; color: #888; margin-bottom: 3px; }
+        .progress-track { height: 5px; background: #f0f0f0; border-radius: 3px; overflow: hidden; }
+        .progress-fill { height: 100%; background: linear-gradient(90deg, #1e88e5, #42a5f5); border-radius: 3px; width: 100%; animation: slide 1.2s infinite linear; }
+        @keyframes slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
+
+        /* ===== Stats ===== */
+        .stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 10px; }
+        .stat-box { background: #fafbfc; border: 1px solid #f0f0f0; border-radius: 3px; padding: 8px; text-align: center; }
+        .stat-num { font-size: 20px; font-weight: 700; }
+        .stat-num.blue { color: #1e88e5; }
+        .stat-num.red { color: #e53935; }
+        .stat-num.orange { color: #fb8c00; }
+        .stat-num.green { color: #43a047; }
+        .stat-lbl { font-size: 10px; color: #999; margin-top: 2px; }
+
+        /* ===== Download ===== */
+        .download-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: linear-gradient(135deg, #e3f2fd, #e8f5e9); border: 1px solid #a5d6a7; border-radius: 3px; }
+        .download-row .dl-icon { font-size: 22px; }
+        .download-row .dl-info { flex: 1; }
+        .download-row .dl-name { font-size: 12px; font-weight: 600; }
+        .download-row .dl-meta { font-size: 10px; color: #999; margin-top: 1px; }
+
+        /* ===== Detail ===== */
+        .detail-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
+        .detail-title { font-size: 15px; font-weight: 700; }
+        .detail-sub { font-size: 11px; color: #999; margin-top: 2px; }
+        .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+        .detail-section-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 6px; padding-bottom: 3px; border-bottom: 1px solid #f0f0f0; }
+        .detail-info-row { font-size: 12px; line-height: 1.8; }
+
+        /* ===== Alert ===== */
+        .alert { padding: 6px 10px; border-radius: 3px; font-size: 11px; margin-bottom: 8px; }
+        .alert-info { background: #e3f2fd; border: 1px solid #90caf9; color: #1565c0; }
+        .alert-danger { background: #ffebee; border: 1px solid #ef9a9a; color: #c62828; }
+
+        /* ===== Spinner ===== */
+        .spinner { display: inline-flex; align-items: center; gap: 3px; color: #1e88e5; font-size: 11px; }
+        .spinner .dot { width: 4px; height: 4px; border-radius: 50%; background: #1e88e5; animation: blink 1s infinite; }
+        .spinner .dot:nth-child(2) { animation-delay: .15s; }
+        .spinner .dot:nth-child(3) { animation-delay: .3s; }
+        @keyframes blink { 0%,100% { opacity: .3; } 50% { opacity: 1; } }
+
+        /* ===== View Toggle ===== */
+        .view { display: none; }
+        .view.active { display: block; }
+
+        /* ===== Button Bar ===== */
+        .btn-bar { display: flex; justify-content: flex-end; gap: 6px; margin-top: 10px; }
+
+        /* ===== Empty State ===== */
+        .empty-state { text-align: center; padding: 40px 20px; color: #999; }
+        .empty-icon { font-size: 40px; margin-bottom: 8px; opacity: 0.3; }
+        .empty-text { font-size: 12px; }
+    </style>
+</head>
+<body>
+<div class="aa-wrapper">
+<div class="aa-area">
+
+    <!-- ===== Left Panel ===== -->
+    <div class="aa-left">
+        <!-- Upload -->
+        <div class="aa-left-upload">
+            <div class="uz" id="leftUploadZone">
+                <div class="uz-icon">📁</div>
+                <div class="uz-text">拖入文件或点击新建任务</div>
+                <div class="uz-hint">PDF / Word / Excel / JPG,≤15MB,最多5个</div>
+            </div>
+            <div class="new-btn">
+                <button class="btn btn-primary btn-sm" onclick="showNewAudit()">+ 新建审计</button>
+            </div>
+        </div>
+
+        <!-- Tabs -->
+        <div class="aa-left-tabs">
+            <div class="aa-left-tab active" data-filter="all" onclick="filterList('all',this)">全部 <span class="cnt" id="cntAll">0</span></div>
+            <div class="aa-left-tab" data-filter="pending" onclick="filterList('pending',this)">待处理 <span class="cnt" id="cntPending">0</span></div>
+            <div class="aa-left-tab" data-filter="done" onclick="filterList('done',this)">已完成 <span class="cnt" id="cntDone">0</span></div>
+            <div class="aa-left-tab" data-filter="failed" onclick="filterList('failed',this)">失败 <span class="cnt" id="cntFailed">0</span></div>
+        </div>
+
+        <!-- Task List -->
+        <div class="aa-left-list" id="taskList"></div>
+
+        <!-- Bottom -->
+        <div class="aa-left-bottom">
+            <button class="btn btn-default btn-sm" onclick="alert('该功能暂未开放')">归档已完成</button>
+            <button class="btn btn-default btn-sm" style="color:#e53935;" onclick="alert('该功能暂未开放')">清理历史</button>
+        </div>
+    </div>
+
+    <!-- ===== Right Panel ===== -->
+    <div class="aa-right">
+        <!-- Toolbar -->
+        <div class="aa-right-toolbar">
+            <div class="aa-right-title" id="rTitle">新建审计任务</div>
+            <div class="aa-right-actions" id="rActions"></div>
+        </div>
+
+        <!-- Content -->
+        <div class="aa-right-content">
+
+            <!-- View: New Audit -->
+            <div class="view active" id="v-new">
+                <!-- Steps -->
+                <div class="card">
+                    <div class="card-bd" style="padding:6px 12px;">
+                        <div class="steps">
+                            <div class="st-item active"><div class="st-dot">1</div><div class="st-lbl">上传资料</div></div>
+                            <div class="st-line"></div>
+                            <div class="st-item"><div class="st-dot">2</div><div class="st-lbl">字段识别</div></div>
+                            <div class="st-line"></div>
+                            <div class="st-item"><div class="st-dot">3</div><div class="st-lbl">勾选字段</div></div>
+                            <div class="st-line"></div>
+                            <div class="st-item"><div class="st-dot">4</div><div class="st-lbl">审计处理</div></div>
+                            <div class="st-line"></div>
+                            <div class="st-item"><div class="st-dot">5</div><div class="st-lbl">查看结果</div></div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Upload -->
+                <div class="card">
+                    <div class="card-hd"><span class="ch-icon" style="background:#e3f2fd;color:#1e88e5;">📄</span> 上传送审资料</div>
+                    <div class="card-bd">
+                        <div class="upload-zone" id="uploadZone">
+                            <div class="uz-icon">📁</div>
+                            <div class="uz-text">点击或拖拽文件到此区域上传</div>
+                            <div class="uz-hint">支持 PDF / Word / Excel / JPG / PNG,单个≤15MB,最多5个</div>
+                        </div>
+                        <div style="margin-top:6px;" id="fileRows"></div>
+                    </div>
+                </div>
+
+                <!-- Config -->
+                <div class="card">
+                    <div class="card-hd"><span class="ch-icon" style="background:#e8f5e9;color:#2e7d32;">⚙</span> 审计配置</div>
+                    <div class="card-bd">
+                        <div class="form-row">
+                            <div class="form-item">
+                                <label><span class="req">*</span> 项目类型</label>
+                                <select id="projectType">
+                                    <option value="建设工程">建设工程</option>
+                                    <option value="市政工程">市政工程</option>
+                                    <option value="供电工程">供电工程</option>
+                                    <option value="装修工程">装修工程</option>
+                                </select>
+                                <div class="form-hint">决定适用的法规和报告模板</div>
+                            </div>
+                            <div class="form-item">
+                                <label><span class="req">*</span> 输出类型</label>
+                                <select id="outputType">
+                                    <option value="审计报告">审计报告(Word)</option>
+                                    <option value="审定单">审定单(Excel)</option>
+                                </select>
+                                <div class="form-hint">选择最终生成的文件类型</div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <div class="btn-bar">
+                    <button class="btn btn-primary btn-lg" id="startBtn" onclick="startExtract()">开始识别 &raquo;</button>
+                </div>
+            </div>
+
+            <!-- View: Detail -->
+            <div class="view" id="v-detail">
+                <div id="detailBody"></div>
+            </div>
+
+        </div>
+    </div>
+
+</div>
+</div>
+
+<input type="file" id="fileInput" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png" style="display:none;">
+
+<script>
+var CTX = '${ctx}';
+var allTasks = [];
+var currentFilter = 'all';
+var currentTaskId = null;
+var currentDetail = null;
+var uploadedFiles = [];
+var pollTimer = null;
+
+$(document).ready(function() {
+    // 上传区域点击 / 拖拽
+    $('#uploadZone').on('click', function(){ $('#fileInput').click(); });
+    $('#leftUploadZone').on('click', function(){ showNewAudit(); $('#fileInput').click(); });
+
+    bindDrop(document.getElementById('uploadZone'));
+    bindDrop(document.getElementById('leftUploadZone'));
+
+    $('#fileInput').on('change', function(){ handleFiles(this.files); this.value = ''; });
+
+    loadTasks();
+});
+
+function bindDrop(zone) {
+    if (!zone) return;
+    zone.addEventListener('dragover', function(e){ e.preventDefault(); this.style.borderColor = '#1e88e5'; this.style.background = '#f0f7ff'; });
+    zone.addEventListener('dragleave', function(e){ e.preventDefault(); this.style.borderColor = '#d9d9d9'; this.style.background = '#fafafa'; });
+    zone.addEventListener('drop', function(e){
+        e.preventDefault();
+        this.style.borderColor = '#d9d9d9'; this.style.background = '#fafafa';
+        showNewAudit();
+        handleFiles(e.dataTransfer.files);
+    });
+}
+
+/* ================= 视图切换 ================= */
+function showNewAudit() {
+    document.querySelectorAll('.view').forEach(function(v){ v.classList.remove('active'); });
+    document.getElementById('v-new').classList.add('active');
+    document.getElementById('rTitle').textContent = '新建审计任务';
+    document.getElementById('rActions').innerHTML = '';
+    document.querySelectorAll('.aa-task').forEach(function(t){ t.classList.remove('active'); });
+    currentTaskId = null;
+}
+
+function filterList(type, tabEl) {
+    document.querySelectorAll('.aa-left-tab').forEach(function(t){ t.classList.remove('active'); });
+    tabEl.classList.add('active');
+    currentFilter = type;
+    renderList();
+}
+
+/* ================= 任务列表 ================= */
+function loadTasks() {
+    $.ajax({
+        url: CTX + '/audit/assistant/listData',
+        type: 'GET',
+        data: { pageNo: 1, pageSize: 200 },
+        success: function(page) {
+            allTasks = page.list || [];
+            updateTabCounts();
+            renderList();
+        },
+        error: function() { alert('加载任务列表失败'); }
+    });
+}
+
+function statusMeta(status) {
+    var map = {
+        'draft':      { cls: 'pending',    text: '草稿' },
+        'pending':    { cls: 'pending',    text: '待处理' },
+        'processing': { cls: 'processing', text: '处理中' },
+        'completed':  { cls: 'done',       text: '已完成' },
+        'failed':     { cls: 'failed',     text: '失败' },
+        'cancelled':  { cls: 'pending',    text: '已取消' }
+    };
+    return map[status] || { cls: 'pending', text: status || '未知' };
+}
+
+function matchFilter(status, filter) {
+    if (filter === 'all') return true;
+    if (filter === 'pending') return status === 'draft' || status === 'pending' || status === 'processing';
+    if (filter === 'done') return status === 'completed';
+    if (filter === 'failed') return status === 'failed';
+    return true;
+}
+
+function updateTabCounts() {
+    var c = { all: 0, pending: 0, done: 0, failed: 0 };
+    allTasks.forEach(function(t) {
+        c.all++;
+        if (matchFilter(t.status, 'pending')) c.pending++;
+        if (matchFilter(t.status, 'done')) c.done++;
+        if (matchFilter(t.status, 'failed')) c.failed++;
+    });
+    $('#cntAll').text(c.all);
+    $('#cntPending').text(c.pending);
+    $('#cntDone').text(c.done);
+    $('#cntFailed').text(c.failed);
+}
+
+function renderList() {
+    var list = allTasks.filter(function(t){ return matchFilter(t.status, currentFilter); });
+    var $box = $('#taskList');
+    if (list.length === 0) {
+        $box.html('<div class="empty-state"><div class="empty-icon">📋</div><div class="empty-text">暂无任务,点击"新建审计"开始</div></div>');
+        return;
+    }
+    var html = '';
+    list.forEach(function(t) {
+        var m = statusMeta(t.status);
+        html += '<div class="aa-task' + (t.id === currentTaskId ? ' active' : '') + '" onclick="selectTask(\'' + t.id + '\', this)">' +
+            '<div class="aa-task-hd">' +
+                '<span class="aa-task-st ' + m.cls + '">' + m.text + '</span>' +
+                '<span class="aa-task-name" title="' + (t.taskName || '') + '">' + (t.taskName || '未命名任务') + '</span>' +
+            '</div>' +
+            '<div class="aa-task-meta"><span>' + (t.fileCount || 0) + ' 份文件</span><span>' + (t.outputType || '-') + '</span><span>' + formatDate(t.createDate) + '</span></div>' +
+        '</div>';
+    });
+    $box.html(html);
+}
+
+function selectTask(taskId, el) {
+    currentTaskId = taskId;
+    document.querySelectorAll('.aa-task').forEach(function(t){ t.classList.remove('active'); });
+    if (el) el.classList.add('active');
+    loadDetail(taskId);
+}
+
+/* ================= 任务详情 ================= */
+function loadDetail(taskId) {
+    $.ajax({
+        url: CTX + '/audit/assistant/detailData',
+        type: 'GET',
+        data: { taskId: taskId },
+        success: function(task) {
+            currentDetail = task;
+            renderDetail(task);
+            document.querySelectorAll('.view').forEach(function(v){ v.classList.remove('active'); });
+            document.getElementById('v-detail').classList.add('active');
+            document.getElementById('rTitle').textContent = '审计记录详情';
+            renderToolbarActions(task);
+        },
+        error: function() { alert('加载任务详情失败'); }
+    });
+}
+
+function renderToolbarActions(task) {
+    var html = '';
+    if (task.resultFileUrl) {
+        html += '<a class="btn btn-success btn-sm" href="' + CTX + '/audit/assistant/downloadReport?taskId=' + task.id + '">下载报告</a>';
+    }
+    html += '<button class="btn btn-default btn-sm" onclick="reExtract()">重新处理</button>';
+    html += '<button class="btn btn-danger btn-sm" onclick="deleteTask()">删除</button>';
+    $('#rActions').html(html);
+}
+
+function renderDetail(task) {
+    var m = statusMeta(task.status);
+    var fieldCount = 0;
+    try { fieldCount = JSON.parse(task.selectedFields || '[]').length; } catch (e) {}
+
+    var html = '';
+    html += '<div class="detail-top"><div>' +
+        '<div class="detail-title">' + (task.taskName || '未命名任务') + '</div>' +
+        '<div class="detail-sub">创建于 ' + formatDate(task.createDate) + ' · 耗时 ' + fmtDuration(task.durationSeconds) + ' · <span class="aa-task-st ' + m.cls + '" style="vertical-align:middle;">' + m.text + '</span></div>' +
+    '</div></div>';
+
+    // 配置 + 任务信息
+    html += '<div class="card"><div class="card-bd"><div class="detail-grid">' +
+        '<div><div class="detail-section-title">审计配置</div><div class="detail-info-row">' +
+            '<div><strong>项目类型:</strong>' + (task.projectType || '-') + '</div>' +
+            '<div><strong>输出类型:</strong>' + (task.outputType || '-') + '</div>' +
+            '<div><strong>审计字段:</strong>' + (fieldCount > 0 ? fieldCount + ' 个' : '待勾选') + '</div>' +
+        '</div></div>' +
+        '<div><div class="detail-section-title">任务信息</div><div class="detail-info-row">' +
+            '<div><strong>送审资料:</strong>' + (task.fileCount || 0) + ' 份文件</div>' +
+            '<div><strong>所属部门:</strong>' + (task.officeName || '-') + '</div>' +
+            '<div><strong>任务ID:</strong>' + task.id + '</div>' +
+        '</div></div>' +
+    '</div></div></div>';
+
+    // 统计
+    html += '<div class="stats-row">' +
+        '<div class="stat-box"><div class="stat-num blue">' + (task.fileCount || 0) + '</div><div class="stat-lbl">送审文件</div></div>' +
+        '<div class="stat-box"><div class="stat-num orange">' + fieldCount + '</div><div class="stat-lbl">审计字段</div></div>' +
+        '<div class="stat-box"><div class="stat-num ' + (task.status === 'failed' ? 'red' : 'green') + '">' + m.text + '</div><div class="stat-lbl">当前状态</div></div>' +
+        '<div class="stat-box"><div class="stat-num green">' + fmtDuration(task.durationSeconds) + '</div><div class="stat-lbl">总耗时</div></div>' +
+    '</div>';
+
+    // 状态相关
+    if (task.status === 'processing') {
+        html += '<div class="card"><div class="card-bd">' +
+            '<div class="alert alert-info"><span class="spinner"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span> 正在调用 AI 工作流处理中,请稍候...</div>' +
+            '<div class="progress-wrap"><div class="progress-track"><div class="progress-fill"></div></div></div>' +
+        '</div></div>';
+    }
+    if (task.status === 'failed') {
+        html += '<div class="alert alert-danger">处理失败:' + (task.errorMsg || '未知错误') + '</div>';
+    }
+    if (task.status === 'completed' && task.resultFileUrl) {
+        html += '<div class="card"><div class="card-hd"><span class="ch-icon" style="background:#e8f5e9;color:#2e7d32;">📥</span> 生成文件</div><div class="card-bd">' +
+            '<div class="download-row"><div class="dl-icon">📄</div><div class="dl-info">' +
+                '<div class="dl-name">' + (task.resultFileName || '审计报告') + '</div>' +
+                '<div class="dl-meta">生成于 ' + formatDate(task.updateDate) + ' · 需审计人员复核确认</div>' +
+            '</div><a class="btn btn-success btn-sm" href="' + CTX + '/audit/assistant/downloadReport?taskId=' + task.id + '">下载</a></div>' +
+        '</div></div>';
+    }
+    if (task.status === 'completed' && !task.resultFileUrl) {
+        html += '<div class="card"><div class="card-bd"><div class="alert alert-info">字段提取已完成,请生成审计报告。</div>' +
+            '<div class="btn-bar"><button class="btn btn-primary" onclick="generateReport()">生成报告 &raquo;</button></div></div></div>';
+    }
+
+    // 处理日志
+    html += '<div class="card"><div class="card-hd"><span class="ch-icon" style="background:#1a1a2e;color:#64b5f6;">🖥</span> 处理日志</div><div class="card-bd">' +
+        renderLogBox(task.processLog) + '</div></div>';
+
+    $('#detailBody').html(html);
+}
+
+function renderLogBox(processLog) {
+    var lines = '';
+    try {
+        var logs = JSON.parse(processLog || 'null');
+        if (logs && logs.length) {
+            logs.forEach(function(l) {
+                if (typeof l === 'string') {
+                    lines += '<div class="log-line info"><span class="log-msg">' + l + '</span></div>';
+                } else {
+                    var type = l.type || l.level || 'info';
+                    lines += '<div class="log-line ' + type + '"><span class="log-time">' + (l.time || '') + '</span><span class="log-node">' + (l.node || '') + '</span><span class="log-msg">' + (l.msg || '') + '</span></div>';
+                }
+            });
+        }
+    } catch (e) {
+        if (processLog) lines = '<div class="log-line info"><span class="log-msg">' + processLog + '</span></div>';
+    }
+    if (!lines) lines = '<div class="log-line"><span class="log-msg" style="color:#666;">暂无日志</span></div>';
+    return '<div class="log-box">' + lines + '</div>';
+}
+
+/* ================= 文件上传 ================= */
+function handleFiles(files) {
+    if (!files || files.length === 0) return;
+    var allowed = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png'];
+    for (var i = 0; i < files.length; i++) {
+        var f = files[i];
+        var ext = f.name.split('.').pop().toLowerCase();
+        if (allowed.indexOf(ext) < 0) { alert('文件 "' + f.name + '" 格式不支持'); continue; }
+        if (f.size > 15 * 1024 * 1024) { alert('文件 "' + f.name + '" 超过15MB限制'); continue; }
+        if (uploadedFiles.length >= 5) { alert('最多上传 5 个文件'); break; }
+        uploadedFiles.push(f);
+    }
+    renderFileRows();
+}
+
+function renderFileRows() {
+    var html = '';
+    uploadedFiles.forEach(function(f, idx) {
+        var ext = f.name.split('.').pop().toLowerCase();
+        var icon = 'pdf', letter = 'P';
+        if (ext === 'xls' || ext === 'xlsx') { icon = 'xls'; letter = 'X'; }
+        else if (ext === 'doc' || ext === 'docx') { icon = 'doc'; letter = 'W'; }
+        else if (ext === 'jpg' || ext === 'jpeg' || ext === 'png') { icon = 'img'; letter = 'J'; }
+        html += '<div class="file-row">' +
+            '<div class="fr-icon ' + icon + '">' + letter + '</div>' +
+            '<div class="fr-info"><div class="fr-name">' + f.name + '</div><div class="fr-size">' + fmtSize(f.size) + '</div></div>' +
+            '<span class="fr-status wait">待上传</span>' +
+            '<span class="fr-rm" onclick="removeFile(' + idx + ')">&times;</span>' +
+        '</div>';
+    });
+    $('#fileRows').html(html);
+}
+
+function removeFile(idx) {
+    uploadedFiles.splice(idx, 1);
+    renderFileRows();
+}
+
+/* ================= 业务流程 ================= */
+function startExtract() {
+    if (uploadedFiles.length === 0) { alert('请先上传文件'); return; }
+    var formData = new FormData();
+    uploadedFiles.forEach(function(f){ formData.append('files', f); });
+    formData.append('projectType', $('#projectType').val());
+    formData.append('outputType', $('#outputType').val());
+
+    $('#startBtn').prop('disabled', true).text('上传中...');
+    $.ajax({
+        url: CTX + '/audit/assistant/upload',
+        type: 'POST',
+        data: formData,
+        processData: false,
+        contentType: false,
+        success: function(result) {
+            $('#startBtn').prop('disabled', false).html('开始识别 &raquo;');
+            if (result.success) {
+                uploadedFiles = [];
+                renderFileRows();
+                loadTasks();
+                var taskId = result.taskId;
+                currentTaskId = taskId;
+                loadDetail(taskId);
+                callStartExtract(taskId);
+            } else {
+                alert(result.msg || '创建任务失败');
+            }
+        },
+        error: function() {
+            $('#startBtn').prop('disabled', false).html('开始识别 &raquo;');
+            alert('网络请求失败');
+        }
+    });
+}
+
+function callStartExtract(taskId) {
+    $.ajax({
+        url: CTX + '/audit/assistant/startExtract',
+        type: 'POST',
+        data: { taskId: taskId },
+        success: function(result) {
+            if (result.success) {
+                startPolling(taskId);
+            } else {
+                alert(result.msg || '启动处理失败');
+            }
+        },
+        error: function() { alert('网络请求失败'); }
+    });
+}
+
+function reExtract() {
+    if (!currentTaskId) { alert('请先选择任务'); return; }
+    if (!confirm('确定要重新处理吗?这将重新调用AI提取字段。')) return;
+    $.ajax({
+        url: CTX + '/audit/assistant/reExtract',
+        type: 'POST',
+        data: { taskId: currentTaskId },
+        success: function(result) {
+            if (result.success) {
+                alert('已重新开始处理');
+                startPolling(currentTaskId);
+            } else {
+                alert(result.msg || '重新处理失败');
+            }
+        },
+        error: function() { alert('网络请求失败'); }
+    });
+}
+
+function deleteTask() {
+    if (!currentTaskId) { alert('请先选择任务'); return; }
+    if (!confirm('确定要删除该任务吗?删除后不可恢复。')) return;
+    $.ajax({
+        url: CTX + '/audit/assistant/delete',
+        type: 'POST',
+        data: { taskId: currentTaskId },
+        success: function(result) {
+            if (result.success) {
+                currentTaskId = null;
+                loadTasks();
+                showNewAudit();
+            } else {
+                alert(result.msg || '删除失败');
+            }
+        },
+        error: function() { alert('网络请求失败'); }
+    });
+}
+
+function generateReport() {
+    if (!currentDetail) { alert('请先选择任务'); return; }
+    if (!confirm('确定要生成报告吗?')) return;
+    var fields = currentDetail.selectedFields || '[]';
+    $.ajax({
+        url: CTX + '/audit/assistant/generateReport',
+        type: 'POST',
+        data: { taskId: currentDetail.id, selectedFields: fields },
+        success: function(result) {
+            if (result.success) {
+                alert('报告生成成功!');
+                loadDetail(currentDetail.id);
+                loadTasks();
+            } else {
+                alert(result.msg || '生成报告失败');
+            }
+        },
+        error: function() { alert('网络请求失败'); }
+    });
+}
+
+function startPolling(taskId) {
+    stopPolling();
+    pollTimer = setInterval(function() {
+        $.ajax({
+            url: CTX + '/audit/assistant/getStatus',
+            type: 'GET',
+            data: { taskId: taskId },
+            success: function(task) {
+                if (currentTaskId === taskId) {
+                    currentDetail = task;
+                    renderDetail(task);
+                    renderToolbarActions(task);
+                }
+                if (task.status === 'completed' || task.status === 'failed') {
+                    stopPolling();
+                    loadTasks();
+                    if (task.status === 'completed') alert('处理完成!');
+                    else alert('处理失败:' + (task.errorMsg || '未知错误'));
+                }
+            }
+        });
+    }, 2500);
+}
+
+function stopPolling() {
+    if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+}
+
+/* ================= 工具函数 ================= */
+function formatDate(dateStr) {
+    if (!dateStr) return '-';
+    var d = new Date(dateStr);
+    if (isNaN(d.getTime())) return '-';
+    var p = function(n){ return ('0' + n).slice(-2); };
+    return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+}
+
+function fmtDuration(sec) {
+    if (!sec && sec !== 0) return '-';
+    var m = Math.floor(sec / 60), s = sec % 60;
+    return m + ':' + ('0' + s).slice(-2);
+}
+
+function fmtSize(bytes) {
+    if (!bytes && bytes !== 0) return '-';
+    if (bytes < 1024) return bytes + ' B';
+    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
+    return (bytes / 1024 / 1024).toFixed(1) + ' MB';
+}
+</script>
+</body>
+</html>