tony.cheng
2026-02-06 823cf3819f2f91adeada3707435d40b3dac8f7b4
feat: 实现证据材料审查弹窗功能

- 更新EvidenceAPIService API方法签名
- 在TabContainer中实现EvidenceBoard审核弹窗
- 支持材料基本信息展示(getPersonInfo API)
- 支持材料图片预览(getEvidenceListByPerson API)
- 实现审核通过和退回补充功能(auditEvidence API)
- 移除材料清单中的查看详情按钮
- 图片URL自动拼接platform_url前缀
- 添加完整的Loading状态和错误处理
- 样式100%遵循doc_audit.html原型
12 files added
8 files modified
4205 ■■■■■ changed files
API文档.md 565 ●●●●● patch | view | raw | blame | history
openspec/changes/evidence-audit-modal/proposal.md 117 ●●●●● patch | view | raw | blame | history
openspec/changes/evidence-audit-modal/specs/evidence-audit-dialog/spec.md 148 ●●●●● patch | view | raw | blame | history
openspec/changes/evidence-audit-modal/tasks.md 92 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-agreement-api/proposal.md 69 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-agreement-api/tasks.md 86 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-home-evidence-api/proposal.md 66 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-home-evidence-api/specs/home-evidence-display/spec.md 85 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-home-evidence-api/tasks.md 42 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-typical-case-api/proposal.md 202 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-typical-case-api/specs/typical-case-search/spec.md 281 ●●●●● patch | view | raw | blame | history
openspec/changes/integrate-typical-case-api/tasks.md 296 ●●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/TabContainer.jsx 1254 ●●●● patch | view | raw | blame | history
web-app/src/components/tools/CaseSearchContent.jsx 378 ●●●● patch | view | raw | blame | history
web-app/src/components/tools/TypicalCaseDetailContent.css 49 ●●●●● patch | view | raw | blame | history
web-app/src/components/tools/TypicalCaseDetailContent.jsx 312 ●●●● patch | view | raw | blame | history
web-app/src/components/tools/TypicalCaseSearch.css 35 ●●●●● patch | view | raw | blame | history
web-app/src/services/CaseAPIService.js 15 ●●●● patch | view | raw | blame | history
web-app/src/services/EvidenceAPIService.js 40 ●●●●● patch | view | raw | blame | history
web-app/src/services/MediationAgreementAPIService.js 73 ●●●●● patch | view | raw | blame | history
API文档.md
@@ -1026,6 +1026,87 @@
|»» name|integer|true|none|年份|none|
|»» count|integer|true|none|案件数量|none|
## GET 纠纷类型下拉列表数据源
GET /api/web/case/dispute-types
### 请求参数
|名称|位置|类型|必选|说明|
|---|---|---|---|---|
|caseSource|query|string| 否 |案例类型,包括judgment(判决文书)和mediation(调解案例),默认为judgment|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "成功",
  "data": [
    {
      "dispute_type": "劳动社保",
      "count": 10244
    },
    {
      "dispute_type": "家庭邻里",
      "count": 4885
    },
    {
      "dispute_type": "房屋规划",
      "count": 2143
    },
    {
      "dispute_type": "公共服务",
      "count": 1881
    },
    {
      "dispute_type": "交通运输",
      "count": 560
    },
    {
      "dispute_type": "市场监管",
      "count": 534
    },
    {
      "dispute_type": "民事经济纠纷",
      "count": 276
    },
    {
      "dispute_type": "城市管理",
      "count": 166
    },
    {
      "dispute_type": "教育医疗",
      "count": 156
    },
    {
      "dispute_type": "人身损害",
      "count": 41
    }
  ]
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|[object]|true|none||none|
|»» dispute_type|string|true|none|纠纷名称|none|
|»» count|integer|true|none|该纠纷类型的案例数量|none|
# AI云小调/法律条文查询
## GET 法律条文列表分页查询 [法律条文查询]
@@ -2555,7 +2636,59 @@
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "success",
  "data": [
    {
      "person_id": "2303191513081130",
      "per_type": "15_020008-1",
      "per_type_name": null,
      "per_class_name": "申请人",
      "true_name": "刘树杰",
      "file_count": "2",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": [
        {
          "summary_id": "202602051128280001",
          "case_id": "202601281644031088",
          "evidence_type": "1",
          "person_id": "2303191513081130",
          "name": "劳动合同",
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n#### 关键事实和信息\n1. **材料1**:南县公安局签发的证件,有效期限为2017年6月2日至2027年6月2日。\n2. **材料2**:班组组长胡龙光签署的承诺书,确认《工厂工作人员工资表》的真实性。承诺书中提到工人名单核对无误、人数无遗漏、工资金额无虚报,并且不涉及其他工厂的工资情况。承诺书有两个日期:2025年6月12日和2020年0月12日。\n3. **材料3**:刘树杰的个人信息,包括姓名、性别、民族、出生日期、住址和公民身份号码。\n\n#### 重要时间、金额、人员等关键要素\n- **有效期限**:2017年6月2日至2027年6月2日(材料1)\n- **承诺书日期**:2025年6月12日和2020年0月12日(材料2)\n- **个人基本信息**:刘树杰,女,汉族,1994年6月29日出生,住址为湖南省南县茅草街镇前哨街113号附22号,公民身份号码为412825199406294169(材料3)\n\n#### 证据材料的证明力和相关性\n- **材料1**:该证件由南县公安局签发,具有一定的权威性和可信度,但未明确其具体用途和与劳动争议的关系。\n- **材料2**:承诺书由班组组长胡龙光签署,确认了工资表的真实性和完整性。然而,承诺书中的两个日期存在矛盾,一个是2025年6月12日,另一个是2020年0月12日,这可能影响其证明力。\n- **材料3**:提供了刘树杰的详细个人信息,有助于确认其身份,但未直接涉及劳动合同或工资支付情况。\n\n#### 矛盾或问题\n- **材料2中的日期矛盾**:承诺书中有两个不同的日期,一个是2025年6月12日,另一个是2020年0月12日。这种矛盾可能导致对该承诺书真实性的质疑。\n- **材料1的具体用途不明确**:虽然证件由南县公安局签发,但未明确其在劳动争议中的具体作用。\n\n#### 结论\n材料1和材料3提供了背景信息和个人身份确认,但未直接涉及劳动合同或工资支付情况。材料2中的承诺书虽然确认了工资表的真实性,但由于日期矛盾,其证明力受到一定影响。建议进一步核实承诺书的日期,并明确材料1的具体用途。",
          "create_time": "2026-02-05T11:28:28",
          "update_time": "2026-02-05T11:28:28"
        },
        {
          "summary_id": "202602051129340001",
          "case_id": "202601281644031088",
          "evidence_type": "2",
          "person_id": "2303191513081130",
          "name": "工资支付记录",
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n**关键事实和信息:**\n1. **申请人信息**:\n   - 姓名:刘树杰\n   - 性别:女\n   - 年龄:31岁\n   - 职业:回线\n   - 单位地址:广州市白云区鹤边员村北街东一巷四号工楼心怡服饰\n\n2. **被申请人信息**:\n   - 姓名:胡龙光\n   - 地址:广州市白云区鹤边员村四巷7号\n   - 联系方式:13580429080\n\n3. **纠纷简要情况**:\n   - 申请人刘树杰在2024年3月20日至2025年4月22日期间在心怡服装厂工作,因经营不善,老板胡龙光拖欠其2025年4月的工资3742元。\n\n4. **调解结果**:\n   - 调解成功,双方达成分期支付协议。\n   - 分期支付期限为3个月,具体支付安排如下:\n     - 第一期:1500元,支付日期为2025年6月26日前\n     - 第二期:1500元,支付日期为2025年7月26日前\n     - 第三期:742元,支付日期为2025年8月26日前\n   - 支付方式:微信转账\n   - 乙方微信号:liu su jie 7879\n   - 乙方收款手机号码:130551008096\n\n**重要时间、金额、人员等关键要素:**\n- **雇佣关系期间**:2024年3月20日至2025年4月22日\n- **拖欠款项金额**:3742元\n- **分期支付期限**:3个月\n- **支付日期**:2025年6月26日、2025年7月26日、2025年8月26日\n- **支付方式**:微信转账\n- **调解时间**:2025年6月12日\n- **调解地点**:调解室\n- **调解员**:黄雯欣\n\n**证据材料的证明力和相关性:**\n- **证明力**:工资支付记录和调解协议详细列出了双方的身份信息、雇佣关系期间、拖欠工资的具体金额及分期支付安排,具有较高的证明力。\n- **相关性**:证据材料直接关联到申请人的工资拖欠问题,并且通过调解达成了具体的支付协议,与案件核心争议高度相关。\n\n**矛盾或问题:**\n- **联系方式错误**:材料1中刘树杰的联系电话为13055108096,而材料2中的收款手机号码为130551008096,存在一个数字的差异。建议核实并确认正确的联系方式,以确保支付顺利进行。\n\n综上所述,该证据材料清晰地展示了申请人刘树杰与被申请人胡龙光之间的劳务纠纷及其解决过程,具有较强的证明力和相关性。需要注意的是,应核实并确认正确的联系方式,以避免支付过程中出现不必要的问题。",
          "create_time": "2026-02-05T11:29:34",
          "update_time": "2026-02-05T11:29:34"
        }
      ]
    },
    {
      "person_id": "2303191513081131",
      "per_type": "15_020008-2",
      "per_type_name": null,
      "per_class_name": "被申请人",
      "true_name": "胡龙光",
      "file_count": "0",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": []
    }
  ]
}
```
```json
{
@@ -2563,36 +2696,97 @@
  "message": "success",
  "data": [
    {
      "person_id": "2303191513081130",
      "per_type": "15_020008-1",
      "per_type_name": null,
      "per_class_name": "申请人",
      "true_name": "刘树杰",
      "file_count": "2",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": [
        {
          "file_id": "FILE001",
          "summary_id": "202602051128280001",
          "name": "劳动合同",
          "audit_state": 1,
          "result": "2023年8月1日-2026年1月31日,约定月工资14,000元",
          "create_time": "2026-01-30 13:15:02",
          "show_url": "/api/web/fileInfo/show/202601281701461042"
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n#### 关键事实和信息\n1. **材料1**:南县公安局签发的证件,有效期限为2017年6月2日至2027年6月2日。\n2. **材料2**:班组组长胡龙光签署的承诺书,确认《工厂工作人员工资表》的真实性。承诺书中提到工人名单核对无误、人数无遗漏、工资金额无虚报,并且不涉及其他工厂的工资情况。承诺书有两个日期:2025年6月12日和2020年0月12日。\n3. **材料3**:刘树杰的个人信息,包括姓名、性别、民族、出生日期、住址和公民身份号码。\n\n#### 重要时间、金额、人员等关键要素\n- **有效期限**:2017年6月2日至2027年6月2日(材料1)\n- **承诺书日期**:2025年6月12日和2020年0月12日(材料2)\n- **个人基本信息**:刘树杰,女,汉族,1994年6月29日出生,住址为湖南省南县茅草街镇前哨街113号附22号,公民身份号码为412825199406294169(材料3)\n\n#### 证据材料的证明力和相关性\n- **材料1**:该证件由南县公安局签发,具有一定的权威性和可信度,但未明确其具体用途和与劳动争议的关系。\n- **材料2**:承诺书由班组组长胡龙光签署,确认了工资表的真实性和完整性。然而,承诺书中的两个日期存在矛盾,一个是2025年6月12日,另一个是2020年0月12日,这可能影响其证明力。\n- **材料3**:提供了刘树杰的详细个人信息,有助于确认其身份,但未直接涉及劳动合同或工资支付情况。\n\n#### 矛盾或问题\n- **材料2中的日期矛盾**:承诺书中有两个不同的日期,一个是2025年6月12日,另一个是2020年0月12日。这种矛盾可能导致对该承诺书真实性的质疑。\n- **材料1的具体用途不明确**:虽然证件由南县公安局签发,但未明确其在劳动争议中的具体作用。\n\n#### 结论\n材料1和材料3提供了背景信息和个人身份确认,但未直接涉及劳动合同或工资支付情况。材料2中的承诺书虽然确认了工资表的真实性,但由于日期矛盾,其证明力受到一定影响。建议进一步核实承诺书的日期,并明确材料1的具体用途。",
          "create_time": "2026-02-05T11:28:28",
          "update_time": "2026-02-05T11:28:28"
        },
        {
          "summary_id": "202602051129340001",
          "name": "工资支付记录",
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n**关键事实和信息:**\n1. **申请人信息**:\n   - 姓名:刘树杰\n   - 性别:女\n   - 年龄:31岁\n   - 职业:回线\n   - 单位地址:广州市白云区鹤边员村北街东一巷四号工楼心怡服饰\n\n2. **被申请人信息**:\n   - 姓名:胡龙光\n   - 地址:广州市白云区鹤边员村四巷7号\n   - 联系方式:13580429080\n\n3. **纠纷简要情况**:\n   - 申请人刘树杰在2024年3月20日至2025年4月22日期间在心怡服装厂工作,因经营不善,老板胡龙光拖欠其2025年4月的工资3742元。\n\n4. **调解结果**:\n   - 调解成功,双方达成分期支付协议。\n   - 分期支付期限为3个月,具体支付安排如下:\n     - 第一期:1500元,支付日期为2025年6月26日前\n     - 第二期:1500元,支付日期为2025年7月26日前\n     - 第三期:742元,支付日期为2025年8月26日前\n   - 支付方式:微信转账\n   - 乙方微信号:liu su jie 7879\n   - 乙方收款手机号码:130551008096\n\n**重要时间、金额、人员等关键要素:**\n- **雇佣关系期间**:2024年3月20日至2025年4月22日\n- **拖欠款项金额**:3742元\n- **分期支付期限**:3个月\n- **支付日期**:2025年6月26日、2025年7月26日、2025年8月26日\n- **支付方式**:微信转账\n- **调解时间**:2025年6月12日\n- **调解地点**:调解室\n- **调解员**:黄雯欣\n\n**证据材料的证明力和相关性:**\n- **证明力**:工资支付记录和调解协议详细列出了双方的身份信息、雇佣关系期间、拖欠工资的具体金额及分期支付安排,具有较高的证明力。\n- **相关性**:证据材料直接关联到申请人的工资拖欠问题,并且通过调解达成了具体的支付协议,与案件核心争议高度相关。\n\n**矛盾或问题:**\n- **联系方式错误**:材料1中刘树杰的联系电话为13055108096,而材料2中的收款手机号码为130551008096,存在一个数字的差异。建议核实并确认正确的联系方式,以确保支付顺利进行。\n\n综上所述,该证据材料清晰地展示了申请人刘树杰与被申请人胡龙光之间的劳务纠纷及其解决过程,具有较强的证明力和相关性。需要注意的是,应核实并确认正确的联系方式,以避免支付过程中出现不必要的问题。",
          "create_time": "2026-02-05T11:29:34",
          "update_time": "2026-02-05T11:29:34"
        }
      ]
    },
    {
      "person_id": "2303191513081131",
      "per_type": "15_020008-2",
      "per_type_name": null,
      "per_class_name": "被申请人",
      "true_name": "胡龙光",
      "file_count": "0",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": []
    }
  ]
}
```
```json
{
  "code": 200,
  "message": "success",
  "data": [
    {
      "person_id": "2303191513081130",
      "per_type": "15_020008-1",
      "per_type_name": null,
      "per_class_name": "申请人",
      "true_name": "刘树杰",
      "file_count": "2",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": [
        {
          "file_id": "FILE002",
          "name": "考勤记录",
          "audit_state": 1,
          "result": "2023年8月至2025年12月考勤正常",
          "create_time": "2026-01-30 13:15:02",
          "show_url": "/api/web/fileInfo/show/202601281701461043"
          "summary_id": "202602051128280001",
          "case_id": "202601281644031088",
          "evidence_type": "1",
          "person_id": "2303191513081130",
          "name": "劳动合同",
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n#### 关键事实和信息\n1. **材料1**:南县公安局签发的证件,有效期限为2017年6月2日至2027年6月2日。\n2. **材料2**:班组组长胡龙光签署的承诺书,确认《工厂工作人员工资表》的真实性。承诺书中提到工人名单核对无误、人数无遗漏、工资金额无虚报,并且不涉及其他工厂的工资情况。承诺书有两个日期:2025年6月12日和2020年0月12日。\n3. **材料3**:刘树杰的个人信息,包括姓名、性别、民族、出生日期、住址和公民身份号码。\n\n#### 重要时间、金额、人员等关键要素\n- **有效期限**:2017年6月2日至2027年6月2日(材料1)\n- **承诺书日期**:2025年6月12日和2020年0月12日(材料2)\n- **个人基本信息**:刘树杰,女,汉族,1994年6月29日出生,住址为湖南省南县茅草街镇前哨街113号附22号,公民身份号码为412825199406294169(材料3)\n\n#### 证据材料的证明力和相关性\n- **材料1**:该证件由南县公安局签发,具有一定的权威性和可信度,但未明确其具体用途和与劳动争议的关系。\n- **材料2**:承诺书由班组组长胡龙光签署,确认了工资表的真实性和完整性。然而,承诺书中的两个日期存在矛盾,一个是2025年6月12日,另一个是2020年0月12日,这可能影响其证明力。\n- **材料3**:提供了刘树杰的详细个人信息,有助于确认其身份,但未直接涉及劳动合同或工资支付情况。\n\n#### 矛盾或问题\n- **材料2中的日期矛盾**:承诺书中有两个不同的日期,一个是2025年6月12日,另一个是2020年0月12日。这种矛盾可能导致对该承诺书真实性的质疑。\n- **材料1的具体用途不明确**:虽然证件由南县公安局签发,但未明确其在劳动争议中的具体作用。\n\n#### 结论\n材料1和材料3提供了背景信息和个人身份确认,但未直接涉及劳动合同或工资支付情况。材料2中的承诺书虽然确认了工资表的真实性,但由于日期矛盾,其证明力受到一定影响。建议进一步核实承诺书的日期,并明确材料1的具体用途。",
          "create_time": "2026-02-05T11:28:28",
          "update_time": "2026-02-05T11:28:28"
        },
        {
          "summary_id": "202602051129340001",
          "case_id": "202601281644031088",
          "evidence_type": "2",
          "person_id": "2303191513081130",
          "name": "工资支付记录",
          "audit_state": 0,
          "result": "### 综合分析摘要\n\n**关键事实和信息:**\n1. **申请人信息**:\n   - 姓名:刘树杰\n   - 性别:女\n   - 年龄:31岁\n   - 职业:回线\n   - 单位地址:广州市白云区鹤边员村北街东一巷四号工楼心怡服饰\n\n2. **被申请人信息**:\n   - 姓名:胡龙光\n   - 地址:广州市白云区鹤边员村四巷7号\n   - 联系方式:13580429080\n\n3. **纠纷简要情况**:\n   - 申请人刘树杰在2024年3月20日至2025年4月22日期间在心怡服装厂工作,因经营不善,老板胡龙光拖欠其2025年4月的工资3742元。\n\n4. **调解结果**:\n   - 调解成功,双方达成分期支付协议。\n   - 分期支付期限为3个月,具体支付安排如下:\n     - 第一期:1500元,支付日期为2025年6月26日前\n     - 第二期:1500元,支付日期为2025年7月26日前\n     - 第三期:742元,支付日期为2025年8月26日前\n   - 支付方式:微信转账\n   - 乙方微信号:liu su jie 7879\n   - 乙方收款手机号码:130551008096\n\n**重要时间、金额、人员等关键要素:**\n- **雇佣关系期间**:2024年3月20日至2025年4月22日\n- **拖欠款项金额**:3742元\n- **分期支付期限**:3个月\n- **支付日期**:2025年6月26日、2025年7月26日、2025年8月26日\n- **支付方式**:微信转账\n- **调解时间**:2025年6月12日\n- **调解地点**:调解室\n- **调解员**:黄雯欣\n\n**证据材料的证明力和相关性:**\n- **证明力**:工资支付记录和调解协议详细列出了双方的身份信息、雇佣关系期间、拖欠工资的具体金额及分期支付安排,具有较高的证明力。\n- **相关性**:证据材料直接关联到申请人的工资拖欠问题,并且通过调解达成了具体的支付协议,与案件核心争议高度相关。\n\n**矛盾或问题:**\n- **联系方式错误**:材料1中刘树杰的联系电话为13055108096,而材料2中的收款手机号码为130551008096,存在一个数字的差异。建议核实并确认正确的联系方式,以确保支付顺利进行。\n\n综上所述,该证据材料清晰地展示了申请人刘树杰与被申请人胡龙光之间的劳务纠纷及其解决过程,具有较强的证明力和相关性。需要注意的是,应核实并确认正确的联系方式,以避免支付过程中出现不必要的问题。",
          "create_time": "2026-02-05T11:29:34",
          "update_time": "2026-02-05T11:29:34"
        }
      ]
    },
    {
      "person_id": "2303191513081131",
      "per_type": "15_020008-2",
      "per_type_name": null,
      "per_class_name": "被申请人",
      "true_name": "胡龙光",
      "file_count": "0",
      "audit_state": "0",
      "case_id": "202601281644031088",
      "file_list": []
    }
  ]
}
@@ -2605,6 +2799,32 @@
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|[object]|true|none||none|
|»» person_id|string|true|none|用户ID|none|
|»» per_type|string|true|none|分类编号(15_020008-1:申请人,15_020008-2:被申请人)|none|
|»» per_type_name|null|true|none||none|
|»» per_class_name|string|true|none|分类名称(申请人,被申请人)|none|
|»» true_name|string|true|none|申请人/被申请人名称|none|
|»» file_count|string|true|none|文件数量|none|
|»» audit_state|string|true|none|审核状态|none|
|»» case_id|string|true|none|案件ID|none|
|»» file_list|[object]|true|none||none|
|»»» summary_id|string|true|none||none|
|»»» name|string|true|none|材料类型名称|none|
|»»» audit_state|integer|true|none|审核状态|none|
|»»» result|string|true|none|材料说明|none|
|»»» create_time|string|true|none|创建时间|none|
|»»» update_time|string|true|none|修改时间|none|
|»»» case_id|string|true|none|案件ID|none|
|»»» evidence_type|string|true|none|材料类型标识|none|
|»»» person_id|string|true|none|用户ID|none|
## GET 获取证据材料当事人基本信息(审核页面用到)
@@ -3415,5 +3635,326 @@
|»» startTime|string|true|none||none|
|»» duration|string|true|none||none|
# AI云小调/调解协议
## POST 调解协议生成
POST /api/v1/medi-agreement/generate
> Body 请求参数
```json
{
  "caseId": "202601281644031088"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» caseId|body|string| 是 | 案件ID|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "调解协议生成成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "2",
    "agreeContent": "**调解协议书**\n\n甲方(申请人):刘树杰\n乙方(被申请人):胡龙光\n\n**一、纠纷背景**\n甲方刘树杰自2024年3月2日至2025年4月22日在乙方胡龙光经营的心怡服装厂从事服装日线工作。因心怡服装厂经营不善,乙方拖欠甲方2025年4月份工资共计人民币3742元。\n\n**二、调解结果**\n经调解机构“云小调”劳动争议AI调解智能体的调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:\n\n1. **具体解决方案**\n   乙方同意向甲方支付2025年4月份工资共计人民币叁仟柒佰肆拾贰元整(3742元)。\n\n2. **履行方式**\n   支付方式:一次性支付。\n   支付账户:甲方指定的银行账户(账户信息另行提供)。\n\n3. **履行期限**\n   乙方应在2025年10月31日前将上述款项支付至甲方指定的银行账户。\n\n**三、其他约定**\n1. **协议生效条件**\n   本协议自双方签字并经调解机构确认后生效。\n\n2. **协议份数及保存**\n   本协议一式四份,甲方、乙方、调解机构、“云小调”劳动争议AI调解智能体各执一份,具有同等法律效力。\n\n3. **无其他补充约定或补充约定为**\n   无其他补充约定。\n\n**四、违约责任**\n如乙方未按约定时间支付上述款项,甲方有权要求乙方一次性支付剩余全部款项,并按未付金额的日万分之五支付逾期付款违约金。\n\n**五、双方权利义务**\n1. 乙方应按约定时间足额支付上述款项。\n2. 甲方收到全部款项后,双方劳动关系正式解除。\n3. 双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结。\n\n**六、争议解决**\n如发生争议,双方同意提交本调解机构所在地人民法院诉讼解决。\n\n**七、支撑相关法条**\n1. 《中华人民共和国劳动合同法》第三十条第一款:“用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。”\n2. 《中华人民共和国民法典》第五百七十七条:“当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。”\n\n甲方(签字):_____________  \n日期:____年____月____日  \n\n乙方(签字):_____________  \n日期:____年____月____日  \n\n调解机构(盖章):_____________  \n调解员(签字):_____________  \n日期:____年____月____日  \n\n“云小调”劳动争议AI调解智能体(确认):_____________  \n日期:__2026__年__2__月__5__日  \n\n---\n\n请双方仔细阅读并确认上述协议内容,确保无误后签字。"
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|生成协议的纠纷编号|none|
|»» agreeId|string|true|none|协议ID|none|
|»» agreeContent|string|true|none|协议内容(Dify生成的完整调解协议书正文)|none|
## POST 调解协议确认
POST /api/v1/medi-agreement/confirm
> Body 请求参数
```json
{
  "caseId": "202601281644031088",
  "userType": "mediator"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» caseId|body|string| 是 | 案件ID|none|
|» userType|body|string| 是 | 用户类型:applicant(申请方) / respondent(被申请方) / mediator(调解员)|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "确认成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "2",
    "userType": "mediator",
    "mediatorConfirmed": 1,
    "applicantConfirmed": 0,
    "respondentConfirmed": 0
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|案件ID|none|
|»» agreeId|string|true|none|协议ID|none|
|»» userType|string|true|none|用户类型:applicant(申请方) / respondent(被申请方) / mediator(调解员)|none|
|»» mediatorConfirmed|integer|true|none|调解员确认状态(1为确认,默认为0)|none|
|»» applicantConfirmed|integer|true|none|申请方确认状态(1为确认,默认为0)|none|
|»» respondentConfirmed|integer|true|none|被申请方确认状态(1为确认,默认为0)|none|
## POST 调解协议下载
POST /api/v1/medi-agreement/download
> Body 请求参数
```json
{
  "caseId": "202601281644031088"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» caseId|body|string| 是 | 案件ID|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "获取成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "2",
    "agreeContent": "**调解协议书**\n\n甲方(申请人):刘树杰\n乙方(被申请人):胡龙光\n\n**一、纠纷背景**\n甲方刘树杰自2024年3月2日至2025年4月22日在乙方胡龙光经营的心怡服装厂从事服装日线工作。因心怡服装厂经营不善,乙方拖欠甲方2025年4月份工资共计人民币3742元。\n\n**二、调解结果**\n经调解机构“云小调”劳动争议AI调解智能体的调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:\n\n1. **具体解决方案**\n   乙方同意向甲方支付2025年4月份工资共计人民币叁仟柒佰肆拾贰元整(3742元)。\n\n2. **履行方式**\n   支付方式:一次性支付。\n   支付账户:甲方指定的银行账户(账户信息另行提供)。\n\n3. **履行期限**\n   乙方应在2025年10月31日前将上述款项支付至甲方指定的银行账户。\n\n**三、其他约定**\n1. **协议生效条件**\n   本协议自双方签字并经调解机构确认后生效。\n\n2. **协议份数及保存**\n   本协议一式四份,甲方、乙方、调解机构、“云小调”劳动争议AI调解智能体各执一份,具有同等法律效力。\n\n3. **无其他补充约定或补充约定为**\n   无其他补充约定。\n\n**四、违约责任**\n如乙方未按约定时间支付上述款项,甲方有权要求乙方一次性支付剩余全部款项,并按未付金额的日万分之五支付逾期付款违约金。\n\n**五、双方权利义务**\n1. 乙方应按约定时间足额支付上述款项。\n2. 甲方收到全部款项后,双方劳动关系正式解除。\n3. 双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结。\n\n**六、争议解决**\n如发生争议,双方同意提交本调解机构所在地人民法院诉讼解决。\n\n**七、支撑相关法条**\n1. 《中华人民共和国劳动合同法》第三十条第一款:“用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。”\n2. 《中华人民共和国民法典》第五百七十七条:“当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。”\n\n甲方(签字):_____________  \n日期:____年____月____日  \n\n乙方(签字):_____________  \n日期:____年____月____日  \n\n调解机构(盖章):_____________  \n调解员(签字):_____________  \n日期:____年____月____日  \n\n“云小调”劳动争议AI调解智能体(确认):_____________  \n日期:__2026__年__2__月__5__日  \n\n---\n\n请双方仔细阅读并确认上述协议内容,确保无误后签字。",
    "title": null
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|纠纷编号|none|
|»» agreeId|string|true|none|协议ID|none|
|»» agreeContent|string|true|none|调解员确认状态(1为确认,默认为0)|none|
|»» title|null|true|none|是否需要调解员确认|none|
## GET 获取调解协议内容
GET /api/v1/medi-agreement/detail/202601281644031088
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "获取成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "2",
    "agreeContent": "**调解协议书**\n\n甲方(申请人):刘树杰\n乙方(被申请人):胡龙光\n\n**一、纠纷背景**\n甲方刘树杰自2024年3月2日至2025年4月22日在乙方胡龙光经营的心怡服装厂从事服装日线工作。因心怡服装厂经营不善,乙方拖欠甲方2025年4月份工资共计人民币3742元。\n\n**二、调解结果**\n经调解机构“云小调”劳动争议AI调解智能体的调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:\n\n1. **具体解决方案**\n   乙方同意向甲方支付2025年4月份工资共计人民币叁仟柒佰肆拾贰元整(3742元)。\n\n2. **履行方式**\n   支付方式:一次性支付。\n   支付账户:甲方指定的银行账户(账户信息另行提供)。\n\n3. **履行期限**\n   乙方应在2025年10月31日前将上述款项支付至甲方指定的银行账户。\n\n**三、其他约定**\n1. **协议生效条件**\n   本协议自双方签字并经调解机构确认后生效。\n\n2. **协议份数及保存**\n   本协议一式四份,甲方、乙方、调解机构、“云小调”劳动争议AI调解智能体各执一份,具有同等法律效力。\n\n3. **无其他补充约定或补充约定为**\n   无其他补充约定。\n\n**四、违约责任**\n如乙方未按约定时间支付上述款项,甲方有权要求乙方一次性支付剩余全部款项,并按未付金额的日万分之五支付逾期付款违约金。\n\n**五、双方权利义务**\n1. 乙方应按约定时间足额支付上述款项。\n2. 甲方收到全部款项后,双方劳动关系正式解除。\n3. 双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结。\n\n**六、争议解决**\n如发生争议,双方同意提交本调解机构所在地人民法院诉讼解决。\n\n**七、支撑相关法条**\n1. 《中华人民共和国劳动合同法》第三十条第一款:“用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。”\n2. 《中华人民共和国民法典》第五百七十七条:“当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。”\n\n甲方(签字):_____________  \n日期:____年____月____日  \n\n乙方(签字):_____________  \n日期:____年____月____日  \n\n调解机构(盖章):_____________  \n调解员(签字):_____________  \n日期:____年____月____日  \n\n“云小调”劳动争议AI调解智能体(确认):_____________  \n日期:__2026__年__2__月__5__日  \n\n---\n\n请双方仔细阅读并确认上述协议内容,确保无误后签字。"
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|案件ID|none|
|»» agreeId|string|true|none|协议ID|none|
|»» agreeContent|string|true|none|协议内容|none|
## POST 调解协议内容修改
POST /api/v1/medi-agreement/update
> Body 请求参数
```json
{
  "caseId": "202601281644031088",
  "agreeContent": "**调解协议书**\n\n甲方(申请人):刘树杰\n乙方(被申请人):胡龙光\n\n**一、纠纷背景**\n甲方刘树杰自2024年3月2日至2025年4月22日在乙方胡龙光经营的心怡服装厂从事服装日线工作。因心怡服装厂经营不善,乙方拖欠甲方2025年4月份工资共计人民币3742元。\n\n**二、调解结果**\n经调解机构“云小调”劳动争议AI调解智能体的调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:\n\n1. **具体解决方案**\n   乙方同意向甲方支付2025年4月份工资共计人民币叁仟柒佰肆拾贰元整(3742元)。\n\n2. **履行方式**\n   支付方式:一次性支付。\n   支付账户:甲方指定的银行账户(账户信息另行提供)。\n\n3. **履行期限**\n   乙方应在2025年10月31日前将上述款项支付至甲方指定的银行账户。\n\n**三、其他约定**\n1. **协议生效条件**\n   本协议自双方签字并经调解机构确认后生效。\n\n2. **协议份数及保存**\n   本协议一式四份,甲方、乙方、调解机构、“云小调”劳动争议AI调解智能体各执一份,具有同等法律效力。\n\n3. **无其他补充约定或补充约定为**\n   无其他补充约定。\n\n**四、违约责任**\n如乙方未按约定时间支付上述款项,甲方有权要求乙方一次性支付剩余全部款项,并按未付金额的日万分之五支付逾期付款违约金。\n\n**五、双方权利义务**\n1. 乙方应按约定时间足额支付上述款项。\n2. 甲方收到全部款项后,双方劳动关系正式解除。\n3. 双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结。\n\n**六、争议解决**\n如发生争议,双方同意提交本调解机构所在地人民法院诉讼解决。\n\n**七、支撑相关法条**\n1. 《中华人民共和国劳动合同法》第三十条第一款:“用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。”\n2. 《中华人民共和国民法典》第五百七十七条:“当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。”\n\n甲方(签字):_____________  \n日期:____年____月____日  \n\n乙方(签字):_____________  \n日期:____年____月____日  \n\n调解机构(盖章):_____________  \n调解员(签字):_____________  \n日期:____年____月____日  \n\n“云小调”劳动争议AI调解智能体(确认):_____________  \n日期:__2026__年__2__月__5__日  \n\n---\n\n请双方仔细阅读并确认上述协议内容,确保无误后签字。"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» caseId|body|string| 是 | 案件ID|none|
|» agreeContent|body|string| 是 | 修改后的协议内容全文|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "修改成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "2"
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|案件ID|none|
|»» agreeId|string|true|none|修改后的协议内容全文|none|
## POST 调解协议重新生成
POST /api/v1/medi-agreement/regenerate
> Body 请求参数
```json
{
  "caseId": "202601281644031088"
}
```
### 请求参数
|名称|位置|类型|必选|中文名|说明|
|---|---|---|---|---|---|
|body|body|object| 否 ||none|
|» caseId|body|string| 是 | 案件ID|none|
> 返回示例
> 200 Response
```json
{
  "code": 200,
  "message": "重新生成成功",
  "data": {
    "caseId": "202601281644031088",
    "agreeId": "3",
    "agreeContent": "**调解协议书**\n\n甲方(申请人):刘树杰\n乙方(被申请人):胡龙光\n\n**一、纠纷背景**\n甲方刘树杰自2024年3月2日至2025年4月22日在乙方胡龙光经营的心怡服装厂从事服装日线工作。因心怡服装厂经营不善,乙方拖欠甲方2025年4月份工资共计人民币3742元。\n\n**二、调解结果**\n经调解机构“云小调”劳动争议AI调解智能体的调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:\n\n1. **具体解决方案**\n   乙方同意向甲方支付2025年4月份工资共计人民币叁仟柒佰肆拾贰元整(3742元)。\n\n2. **履行方式**\n   支付方式:一次性支付。\n   支付账户:甲方指定的银行账户(账户信息另行提供)。\n\n3. **履行期限**\n   乙方应在2025年10月31日前将上述款项支付至甲方指定的银行账户。\n\n**三、其他约定**\n1. **协议生效条件**\n   本协议自双方签字并经调解机构确认后生效。\n\n2. **协议份数及保存**\n   本协议一式四份,甲方、乙方、调解机构、“云小调”劳动争议AI调解智能体各执一份,具有同等法律效力。\n\n3. **无其他补充约定或补充约定为**\n   无其他补充约定。\n\n**四、违约责任**\n如乙方未按约定时间支付上述款项,甲方有权要求乙方一次性支付剩余全部款项,并按未付金额的日万分之五支付逾期付款违约金。\n\n**五、双方权利义务**\n1. 乙方应按约定时间足额支付上述款项。\n2. 甲方收到全部款项后,双方劳动关系正式解除。\n3. 双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结。\n\n**六、争议解决**\n如发生争议,双方同意提交本调解机构所在地人民法院诉讼解决。\n\n**七、支撑相关法条**\n1. 《中华人民共和国劳动合同法》第三十条第一款:“用人单位应当按照劳动合同约定和国家规定,向劳动者及时足额支付劳动报酬。”\n2. 《中华人民共和国民法典》第五百七十七条:“当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担继续履行、采取补救措施或者赔偿损失等违约责任。”\n\n甲方(签字):_____________  \n日期:____年____月____日  \n\n乙方(签字):_____________  \n日期:____年____月____日  \n\n调解机构(盖章):_____________  \n调解员(签字):_____________  \n日期:____年____月____日  \n\n“云小调”劳动争议AI调解智能体(确认):_____________  \n日期:__2026__年__2__月__5__日  \n\n---\n\n请双方仔细阅读并确认上述协议内容,确保无误后签字。"
  }
}
```
### 返回结果
|状态码|状态码含义|说明|数据模型|
|---|---|---|---|
|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|none|Inline|
### 返回数据结构
状态码 **200**
|名称|类型|必选|约束|中文名|说明|
|---|---|---|---|---|---|
|» code|integer|true|none||none|
|» message|string|true|none||none|
|» data|object|true|none||none|
|»» caseId|string|true|none|案件ID|none|
|»» agreeId|string|true|none|协议ID|none|
|»» agreeContent|string|true|none|协议内容|none|
# 数据模型
openspec/changes/evidence-audit-modal/proposal.md
New file
@@ -0,0 +1,117 @@
# 证据材料审查弹窗功能
## 概述
在"证据材料汇总"Tab的材料列表中,为未审核通过的材料项添加"审核"按钮,点击后弹出证据材料审查弹窗,支持查看材料详情、预览材料图片、执行审核操作。
## 背景与动机
当前证据材料汇总Tab仅展示材料列表,缺少审核操作入口。需要为调解员/审核人员提供便捷的材料审核功能,可在弹窗中查看材料基本信息、预览材料图片,并执行"审核通过"或"退回补充"操作。
## 功能范围
### 入口触发
- **位置**:TabContainer.jsx中EvidenceBoard组件的材料列表项
- **触发条件**:仅当`audit_state !== 1`(未审核通过)时显示"审核"按钮
- **按钮文案**:"审核"
### 弹窗参数
点击"审核"按钮时,携带以下5个参数传递给弹窗:
| 参数 | 来源字段 | 说明 |
|------|----------|------|
| case_id | file_list[].case_id | 案件ID |
| evidence_type | file_list[].evidence_type | 证据类型 |
| per_type | data[].per_type | 人员类型(15_020008-1/15_020008-2)|
| person_id | file_list[].person_id | 人员ID |
| name | file_list[].name | 材料名称 |
### 弹窗内容结构(遵循doc_audit.html原型)
#### 1. 材料基本信息区域
调用`EvidenceAPIService.getPersonInfo`获取,展示:
- 提交人:`per_class_name + "-" + true_name`
- 材料类型:根据`evidence_type`映射显示
- 材料数量:`total_count + "份"`
- 提交时间:`submit_time`
#### 2. 材料清单(表格形式)
展示当前材料的详细信息:
- 材料类型:根据`evidence_type`映射
- 文件格式:`suffix`
- 上传时间:从API返回的时间字段
- 文件大小:`total_file_size`
- **注意**:去掉原型中的"查看详情"按钮
#### 3. 材料预览区域
调用`EvidenceAPIService.getEvidenceListByPerson`获取图片列表:
- 图片URL拼接规则:`platform_url + "/" + show_url`
- `platform_url`从localStorage的`case_data_timeline.process_config.platform_url`获取
- 支持点击缩略图查看大图
#### 4. 审核操作区域
- **审核通过按钮**:调用`EvidenceAPIService.auditEvidence`,`audit_state=1`
- **退回补充按钮**:打开退回意见输入框,必填退回意见后调用API,`audit_state=2`
### API调用规范
#### getPersonInfo
```javascript
// 请求参数
{ case_id, evidence_type, per_type }
// 响应结构
{
  "code": 200,
  "data": {
    "id": "2303191513081130",
    "per_class_name": "申请人",
    "true_name": "刘树杰",
    "total_count": "3",
    "submit_time": "2026-01-28 17:01",
    "audit_state": 0,
    "total_file_size": "0.0003",
    "suffix": "png"
  }
}
```
#### getEvidenceListByPerson
```javascript
// 请求参数
{ case_id, evidence_type, per_type, person_id }
// 响应结构 - show_url需拼接platform_url前缀
```
#### auditEvidence
```javascript
// 请求参数
{
  "case_id": "202601281644031088",
  "evidence_type": 1,
  "person_id": "2303191513081130",
  "file_id_list": ["202601281701461043"],
  "audit_user": "当前用户",
  "audit_state": 1,  // 1-审核成功,2-退回补充
  "audit_remark": "审核意见"
}
```
## UI/UX要求
- 弹窗类型:模态对话框(Ant Design Modal)
- 弹窗宽度:根据内容自适应,参考原型约1000px
- 样式:100%遵循doc_audit.html原型
- 退回补充时退回意见必填
- 操作成功后显示友好提示消息,关闭弹窗并刷新材料列表
## 技术实现要点
1. 在TabContainer.jsx的EvidenceBoard组件中添加弹窗状态管理
2. 更新EvidenceAPIService.js中的API方法签名以匹配实际接口
3. 使用Ant Design Modal、Image、Spin、message等组件
4. 图片预览支持Image.PreviewGroup批量预览
## 原型参考
- 主要原型:`document/原型/doc_audit.html`
- 需移除原型中的"查看详情"按钮
## 关联变更
- 修改文件:`web-app/src/components/dashboard/TabContainer.jsx`
- 修改文件:`web-app/src/services/EvidenceAPIService.js`
openspec/changes/evidence-audit-modal/specs/evidence-audit-dialog/spec.md
New file
@@ -0,0 +1,148 @@
# 证据材料审查弹窗规范
## ADDED Requirements
### Requirement: 审核按钮入口
在证据材料汇总Tab的材料列表项中,系统**SHALL**在材料项的`audit_state !== 1`时显示"审核"按钮。
#### Scenario: 未审核材料显示审核按钮
**Given** 材料项的audit_state为0(待审核)
**When** 渲染材料列表项
**Then** 系统**SHALL**在材料项中显示"审核"按钮
#### Scenario: 已审核材料不显示审核按钮
**Given** 材料项的audit_state为1(已审核)
**When** 渲染材料列表项
**Then** 系统**SHALL NOT**显示"审核"按钮
#### Scenario: 驳回材料显示审核按钮
**Given** 材料项的audit_state为-2(驳回)
**When** 渲染材料列表项
**Then** 系统**SHALL**在材料项中显示"审核"按钮
---
### Requirement: 审核弹窗触发与参数传递
点击"审核"按钮时,系统**SHALL**打开证据材料审查弹窗,并传递必要的参数。
#### Scenario: 点击审核按钮打开弹窗
**Given** 用户在材料列表中看到"审核"按钮
**When** 用户点击"审核"按钮
**Then** 系统**SHALL**打开证据材料审查弹窗
**And** 系统**SHALL**传递以下参数:case_id, evidence_type, per_type, person_id, name
---
### Requirement: 材料基本信息展示
弹窗打开后,系统**SHALL**调用getPersonInfo API获取并展示材料基本信息。
#### Scenario: 加载并展示材料基本信息
**Given** 审核弹窗已打开
**When** 弹窗加载数据
**Then** 系统**SHALL**调用`EvidenceAPIService.getPersonInfo({ case_id, evidence_type, per_type })`
**And** 系统**SHALL**展示提交人信息(格式:per_class_name + "-" + true_name)
**And** 系统**SHALL**展示材料数量(格式:total_count + "份")
**And** 系统**SHALL**展示提交时间(submit_time)
---
### Requirement: 材料预览图片展示
弹窗**SHALL**调用getEvidenceListByPerson API获取材料图片列表,并正确拼接图片URL。
#### Scenario: 加载并展示材料预览图片
**Given** 审核弹窗已打开
**When** 弹窗加载图片数据
**Then** 系统**SHALL**调用`EvidenceAPIService.getEvidenceListByPerson({ case_id, evidence_type, per_type, person_id })`
**And** 系统**SHALL**将返回的show_url与platform_url拼接为完整图片URL
**And** platform_url**SHALL**从localStorage的`case_data_timeline.process_config.platform_url`获取
**And** 完整URL格式**SHALL**为:`{platform_url}/{show_url}`
#### Scenario: 点击缩略图查看大图
**Given** 材料预览区域显示图片缩略图
**When** 用户点击某个缩略图
**Then** 系统**SHALL**打开图片预览模态框显示大图
---
### Requirement: 审核通过操作
用户点击"审核通过"按钮时,系统**SHALL**提交审核结果。
#### Scenario: 执行审核通过
**Given** 用户在审核弹窗中
**When** 用户点击"审核通过"按钮
**Then** 系统**SHALL**调用`EvidenceAPIService.auditEvidence`
**And** 请求参数**SHALL**包含:case_id, evidence_type, person_id, file_id_list, audit_user, audit_state=1
**And** 成功后系统**SHALL**显示友好的成功消息
**And** 系统**SHALL**关闭弹窗
**And** 系统**SHALL**刷新材料列表
---
### Requirement: 退回补充操作
用户点击"退回补充"按钮时,系统**SHALL**要求填写退回意见后提交。
#### Scenario: 退回补充必填退回意见
**Given** 用户在审核弹窗中
**When** 用户点击"退回补充"按钮
**Then** 系统**SHALL**显示退回意见输入框
**And** 退回意见**SHALL**为必填项
#### Scenario: 提交退回补充
**Given** 用户已填写退回意见
**When** 用户确认提交退回
**Then** 系统**SHALL**调用`EvidenceAPIService.auditEvidence`
**And** 请求参数**SHALL**包含:case_id, evidence_type, person_id, file_id_list, audit_user, audit_state=2, audit_remark=退回意见
**And** 成功后系统**SHALL**显示友好的成功消息
**And** 系统**SHALL**关闭弹窗
**And** 系统**SHALL**刷新材料列表
#### Scenario: 退回意见为空时校验失败
**Given** 用户未填写退回意见
**When** 用户尝试提交退回
**Then** 系统**SHALL**显示必填校验错误提示
**And** 系统**SHALL NOT**调用API
---
### Requirement: UI样式规范
弹窗样式**SHALL**100%遵循doc_audit.html原型。
#### Scenario: 弹窗样式一致性
**Given** 审核弹窗已打开
**Then** 弹窗**SHALL**使用模态对话框形式
**And** 弹窗宽度**SHALL**约为1000px
**And** 材料基本信息区域**SHALL**使用info-grid布局
**And** section标题**SHALL**使用蓝色竖条样式(section-title)
**And** 审核通过按钮**SHALL**使用btn-pass样式(蓝色背景)
**And** 退回补充按钮**SHALL**使用btn-return样式(蓝色边框)
---
## REMOVED Requirements
### Requirement: 移除查看详情按钮
材料清单表格中**SHALL NOT**显示"查看详情"按钮。
#### Scenario: 材料清单不显示查看详情
**Given** 审核弹窗的材料清单表格
**When** 渲染材料信息列
**Then** 系统**SHALL NOT**显示"查看详情"按钮
---
## API规范
### getPersonInfo
- **方法**: GET
- **路径**: /api/v1/evidence/person-info
- **参数**: case_id, evidence_type, per_type
### getEvidenceListByPerson
- **方法**: GET
- **路径**: /api/v1/evidence/list-by-person
- **参数**: case_id, evidence_type, per_type, person_id
### auditEvidence
- **方法**: POST
- **路径**: /api/v1/evidence/audit
- **参数**: case_id, evidence_type, person_id, file_id_list, audit_user, audit_state, audit_remark
openspec/changes/evidence-audit-modal/tasks.md
New file
@@ -0,0 +1,92 @@
# 证据材料审查弹窗任务清单
## 阶段1:API层准备
- [x] 1.1 更新EvidenceAPIService.getPersonInfo方法签名
  - 修改参数为:{ case_id, evidence_type, per_type }
  - 更新JSDoc注释
- [x] 1.2 更新EvidenceAPIService.getEvidenceListByPerson方法签名
  - 修改参数为:{ case_id, evidence_type, per_type, person_id }
  - 更新JSDoc注释
- [x] 1.3 更新EvidenceAPIService.auditEvidence方法签名
  - 修改为POST请求(当前为PUT)
  - 参数:case_id, evidence_type, person_id, file_id_list, audit_user, audit_state, audit_remark
  - 更新JSDoc注释
## 阶段2:审核按钮入口
- [x] 2.1 在EvidenceBoard的renderEvidenceItem中添加"审核"按钮
  - 仅当`audit_state !== 1`时显示
  - 按钮样式参考原型
- [x] 2.2 添加弹窗状态管理
  - auditModalVisible: 控制弹窗显示/隐藏
  - currentAuditItem: 当前审核的材料项数据
  - 包含:case_id, evidence_type, per_type, person_id, name
## 阶段3:审核弹窗UI实现
- [x] 3.1 创建EvidenceAuditModal组件结构
  - 使用Ant Design Modal组件
  - 宽度约1000px,居中显示
- [x] 3.2 实现材料基本信息区域
  - 调用getPersonInfo API
  - 展示:提交人、材料类型、材料数量、提交时间
  - 使用info-grid布局,遵循原型样式
- [x] 3.3 实现材料清单表格
  - 表头:材料信息、材料预览
  - 展示:材料类型、文件格式、上传时间、文件大小
  - **注意**:不显示"查看详情"按钮 ✅ 已移除
- [x] 3.4 实现材料预览区域
  - 调用getEvidenceListByPerson API获取图片列表
  - 图片URL拼接:platform_url + "/" + show_url
  - platform_url从localStorage.case_data_timeline.process_config.platform_url获取
  - 使用Ant Design Image组件,支持点击放大预览
- [x] 3.5 实现审核操作按钮区域
  - 审核通过按钮(btn-pass样式)
  - 退回补充按钮(btn-return样式)
## 阶段4:审核逻辑实现
- [x] 4.1 实现"审核通过"功能
  - 调用auditEvidence API,audit_state=1
  - 成功后显示message.success提示
  - 关闭弹窗并刷新材料列表
- [x] 4.2 实现"退回补充"功能
  - 打开退回意见输入弹窗(嵌套Modal或内联表单)
  - 退回意见必填校验
  - 调用auditEvidence API,audit_state=2, audit_remark=退回意见
  - 成功后显示message.success提示
  - 关闭弹窗并刷新材料列表
- [x] 4.3 实现图片大图预览功能
  - 点击缩略图打开Image.PreviewGroup
  - 支持左右切换、缩放等操作
## 阶段5:样式与交互优化
- [x] 5.1 确保弹窗样式100%遵循doc_audit.html原型
  - 头部渐变背景
  - section-title样式(蓝色竖条)
  - 表格样式(list-table)
  - 按钮样式(btn-pass, btn-return)
- [x] 5.2 添加Loading状态
  - 数据加载时显示Spin
  - 提交审核时按钮显示loading
- [x] 5.3 添加错误处理
  - API调用失败时显示错误提示
  - 网络异常处理
## 阶段6:测试验证
- [x] 6.1 验证审核按钮显示逻辑
  - audit_state=0时显示
  - audit_state=1时不显示
  - audit_state=-2时显示
- [x] 6.2 验证弹窗数据加载
  - getPersonInfo API正确调用
  - getEvidenceListByPerson API正确调用
  - 图片URL正确拼接
- [x] 6.3 验证审核操作
  - 审核通过流程正常
  - 退回补充流程正常(含必填校验)
  - 操作后弹窗关闭、列表刷新
## 涉及文件
- `web-app/src/services/EvidenceAPIService.js` - API方法更新 ✅
- `web-app/src/components/dashboard/TabContainer.jsx` - EvidenceBoard组件修改 ✅
## 完成状态
所有任务已完成 ✅ (2026-01-26)
openspec/changes/integrate-agreement-api/proposal.md
New file
@@ -0,0 +1,69 @@
# Proposal: integrate-agreement-api
## Summary
实现首页调解协议 Tab 页面的 API 对接,包括协议内容展示、确认协议、下载协议、重新生成、修改协议等功能。
## Background
当前调解协议 Tab 页面(`AgreementSection` 组件)使用硬编码的静态内容展示。需要对接后端 API 实现动态数据加载和交互操作。
## Goals
1. 页面首次加载时自动生成并展示调解协议内容
2. 实现"确认协议"、"下载协议"、"重新生成"、"修改协议"四个操作按钮功能
3. 新增"修改调解协议书内容"弹窗,按原型 `doc_edit.html` 设计
## Non-Goals
- 不涉及协议的 PDF 生成逻辑(由后端处理)
- 不涉及用户权限校验
## Design
### API 调用流程
| 场景 | API | 说明 |
|------|-----|------|
| 首次加载 | `MediationAgreementAPIService.generateAgreement(caseId)` | 生成协议并获取内容展示 |
| 确认协议 | `MediationAgreementAPIService.confirmAgreement(caseId, userType)` | userType 固定为 'mediator' |
| 下载协议 | `MediationAgreementAPIService.downloadAgreement(caseId)` | 触发协议下载 |
| 重新生成 | `MediationAgreementAPIService.generateAgreement(caseId)` | 重新生成协议并刷新展示 |
| 修改弹窗-加载 | `MediationAgreementAPIService.getAgreementDetail(caseId)` | 获取当前协议内容 |
| 修改弹窗-保存 | `MediationAgreementAPIService.updateAgreement(caseId, agreeContent)` | 保存修改内容 |
| 修改弹窗-保存后刷新 | `MediationAgreementAPIService.generateAgreement(caseId)` | 刷新父页面协议内容 |
### 协议内容展示格式
- API 返回的 `agreeContent` 为 Markdown 格式文本
- 采用纯文本展示方式:处理 `\n` 为换行,Markdown 符号(如 `**`)简单过滤或原样显示
### UI 组件结构
```
AgreementSection (改造)
├── 协议内容展示区域 (动态渲染 agreeContent)
├── 操作按钮组
│   ├── 确认协议 (绿色)
│   ├── 修改协议 (黄色) → 打开修改弹窗
│   ├── 下载协议 (蓝色)
│   └── 重新生成 (青色)
└── 修改调解协议书弹窗 (Modal)
    ├── 编辑提示说明
    ├── 协议内容可编辑区域 (contenteditable)
    └── 保存修改按钮
```
### 修改协议弹窗设计
参照原型 `doc_edit.html`:
- 顶部提示:说明可编辑区域
- 编辑区域:使用 `contenteditable` 实现内容编辑
- 底部按钮:保存修改
## Risks & Mitigations
| 风险 | 缓解措施 |
|------|---------|
| API 调用失败 | 添加 loading 状态和错误提示 |
| 协议内容过长 | 保持现有 max-height + overflow-y: auto 滚动设计 |
## Dependencies
- `MediationAgreementAPIService` 已实现(web-app/src/services/MediationAgreementAPIService.js)
- 原型文件 `doc_edit.html` 作为 UI 参考
## Affected Components
- `web-app/src/components/dashboard/TabContainer.jsx` - AgreementSection 组件改造
openspec/changes/integrate-agreement-api/tasks.md
New file
@@ -0,0 +1,86 @@
# Tasks: integrate-agreement-api
## 实施任务清单
### Phase 1: 基础架构准备
- [x] **T1**: 在 `AgreementSection` 组件中添加状态管理
  - 添加 `agreementContent`、`loading`、`error` 状态
  - 添加 `editModalVisible`、`editContent` 弹窗状态
  - 从 `CaseDataContext` 获取 `caseId`
### Phase 2: 协议内容加载与展示
- [x] **T2**: 实现首次加载协议内容
  - 组件挂载时调用 `MediationAgreementAPIService.generateAgreement(caseId)`
  - 将返回的 `agreeContent` 存入状态并展示
  - 处理 loading 和 error 状态
- [x] **T3**: 实现协议内容渲染
  - 创建 `renderAgreementContent` 函数
  - 处理 `\n` 换行符转换为 `<br/>` 或段落
  - 简单过滤或保留 Markdown 符号(如 `**`)
### Phase 3: 操作按钮功能实现
- [x] **T4**: 添加四个操作按钮 UI
  - 按原型样式添加:确认协议(绿)、修改协议(黄)、下载协议(蓝)、重新生成(青)
  - 按钮样式参照原型 `index.html` 中 `.agreement-btn-*` 类
- [x] **T5**: 实现"确认协议"功能
  - 调用 `MediationAgreementAPIService.confirmAgreement(caseId, 'mediator')`
  - 成功后显示友好消息提示
- [x] **T6**: 实现"下载协议"功能
  - 调用 `MediationAgreementAPIService.downloadAgreement(caseId)`
  - 成功后显示友好消息提示
- [x] **T7**: 实现"重新生成"功能
  - 调用 `MediationAgreementAPIService.generateAgreement(caseId)`
  - 成功后刷新协议内容展示并显示友好消息提示
### Phase 4: 修改协议弹窗
- [x] **T8**: 创建修改协议弹窗 UI
  - 使用 Ant Design Modal 组件
  - 参照原型 `doc_edit.html` 设计弹窗样式
  - 包含:编辑提示、可编辑内容区域、保存按钮
- [x] **T9**: 实现弹窗数据加载
  - 点击"修改协议"按钮打开弹窗
  - 调用 `MediationAgreementAPIService.getAgreementDetail(caseId)` 获取内容
  - 将内容展示在可编辑区域
- [x] **T10**: 实现保存修改功能
  - 点击"保存修改"按钮
  - 调用 `MediationAgreementAPIService.updateAgreement(caseId, editedContent)`
  - 成功后显示消息提示并关闭弹窗
  - 调用 `MediationAgreementAPIService.generateAgreement(caseId)` 刷新父页面
### Phase 5: 验证与完善
- [x] **T11**: 功能验证
  - 验证首次加载协议内容正确展示
  - 验证四个操作按钮功能正常
  - 验证修改弹窗打开、编辑、保存流程
  - 验证各操作的消息提示
- [x] **T12**: 错误处理完善
  - 添加 API 调用失败的错误提示
  - 添加 loading 状态展示
## 依赖关系
```
T1 → T2 → T3
T1 → T4 → T5, T6, T7
T1 → T8 → T9 → T10
T3, T7, T10 → T11 → T12
```
## 验收标准
1. 页面加载后自动展示调解协议内容
2. 四个操作按钮功能正常,有友好消息提示
3. 修改协议弹窗样式与原型 `doc_edit.html` 一致
4. 所有 API 调用有 loading 状态和错误处理
## 实施状态: ✅ 已完成 (2026-02-06)
openspec/changes/integrate-home-evidence-api/proposal.md
New file
@@ -0,0 +1,66 @@
# 首页证据材料汇总API对接提案
## 概述
为云小调系统首页增加证据材料汇总展示功能,实现与后端API的数据对接,展示申请人和被申请人的材料信息及审核状态。
## 背景
当前首页缺少证据材料的可视化展示,用户无法直观了解案件材料的提交和审核情况。需要通过API对接实现材料数据的实时展示。
## 目标
- 实现首页证据材料汇总的数据加载和展示
- 提供清晰的Tab切换界面区分申请人和被申请人材料
- 展示材料审核状态和基本信息
- 提供良好的用户体验(loading状态、错误处理)
## 范围
### 包含
- 首页证据材料模块的API数据对接
- 申请人/被申请人Tab切换功能
- 材料列表展示(名称、状态、说明、时间)
- 审核状态计算逻辑
- Loading状态和错误处理
### 不包含
- 材料上传功能
- 材料详情查看功能
- 审核操作功能
## 设计决策
### 1. 数据获取策略
- 优先从URL参数获取case_id、case_type、platform_code
- URL参数为空时从localStorage的case_data_timeline中获取
- 使用EvidenceAPIService.getEvidenceList进行数据获取
### 2. Tab状态计算
- Tab标题显示格式:"申请人材料(状态)" / "被申请人材料(状态)"
- 状态判定逻辑:
  - 任一audit_state为0或-2 → "待审核"
  - 所有audit_state为1 → "已审核"
  - audit_state=-2 → "驳回"
### 3. 材料列表展示
- 按file_count字段实际数值展示材料数量
- 展示字段:name、audit_state、result、update_time/create_time
- 时间格式化为:YYYY-MM-DD HH:mm
### 4. 用户体验
- 数据加载时显示Loading状态
- API调用失败时显示错误信息
- 无数据时显示友好的空状态提示
## 验收标准
1. 首页能正确显示证据材料汇总模块
2. Tab切换功能正常,状态计算准确
3. 材料列表按要求格式展示
4. Loading状态和错误处理功能完善
5. 在不同数据状态下(有数据/无数据/错误)表现正常
## 风险与缓解
### 风险1:API响应慢影响用户体验
**缓解措施**:实现Loading状态,设置合理的超时时间
### 风险2:数据结构变化导致展示异常
**缓解措施**:增加数据校验,提供降级展示方案
### 风险3:URL参数缺失导致数据获取失败
**缓解措施**:提供localStorage备选方案,增加参数缺失提示
openspec/changes/integrate-home-evidence-api/specs/home-evidence-display/spec.md
New file
@@ -0,0 +1,85 @@
# 首页证据材料展示规范
## MODIFIED Requirements
### Requirement: 首页证据材料数据加载
首页 SHALL 从后端API加载证据材料数据并展示在首页指定区域。
#### Scenario: 首页初次加载
当用户访问首页时,系统应自动加载证据材料数据:
- 优先从URL参数获取case_id、case_type、platform_code
- 若URL参数为空,则从localStorage的case_data_timeline中获取
- 调用EvidenceAPIService.getEvidenceList接口获取数据
- 将返回的data封装到evidenceData对象中
#### Scenario: 参数缺失处理
当URL参数和localStorage都无有效参数时:
- 显示友好的错误提示:"无法获取案件信息,请从案件详情页进入"
- 不发起API调用
### Requirement: 申请人/被申请人数据分离
系统 SHALL 根据per_type字段将材料数据分为申请人和被申请人两类。
#### Scenario: 申请人材料识别
从evidenceData中筛选per_type = "15_020008-1"的数据作为申请人材料:
- 提取file_list数组
- 根据file_count字段确定展示数量
- 计算申请人整体审核状态
#### Scenario: 被申请人材料识别
从evidenceData中筛选per_type = "15_020008-2"的数据作为被申请人材料:
- 提取file_list数组
- 根据file_count字段确定展示数量
- 计算被申请人整体审核状态
### Requirement: Tab选项卡状态计算
Tab标题 SHALL 显示对应方的材料状态。
#### Scenario: 待审核状态显示
当申请人或被申请人中任意材料的audit_state为0或-2时:
- Tab标题显示:"申请人材料(待审核)" 或 "被申请人材料(待审核)"
#### Scenario: 已审核状态显示
当申请人或被申请人中所有材料的audit_state都为1时:
- Tab标题显示:"申请人材料(已审核)" 或 "被申请人材料(已审核)"
#### Scenario: 驳回状态显示
当申请人或被申请人中存在audit_state为-2的材料时:
- Tab标题显示:"申请人材料(驳回)" 或 "被申请人材料(驳回)"
### Requirement: 材料列表信息展示
每个材料 SHALL 展示核心信息字段。
#### Scenario: 材料基础信息展示
对于每份材料,展示以下信息:
- name:材料名称(如:劳动合同)
- audit_state:审核状态标签(0-待审核,1-审核成功,-2-驳回)
- result:材料说明文本
- 时间:update_time或create_time,格式化为YYYY-MM-DD HH:mm
#### Scenario: 材料数量控制
根据file_count字段控制展示的材料数量:
- file_count为3时展示3条材料记录
- file_count为5时展示5条材料记录
- 最多展示对应file_count数量的材料
### Requirement: 用户体验状态管理
系统 SHALL 提供完整的状态反馈。
#### Scenario: 加载状态展示
数据加载过程中:
- 显示Loading动画
- 禁用Tab切换操作
- 不展示空的材料列表
#### Scenario: 错误状态处理
API调用失败时:
- 显示错误图标和提示文字:"数据加载失败,请稍后重试"
- 提供刷新重试按钮
- 保持Tab结构但内容区域显示错误信息
#### Scenario: 空数据状态展示
API返回空数据时:
- 显示空状态图标
- 提示文字:"暂无材料数据"
- 保持Tab可切换但内容区域为空状态
openspec/changes/integrate-home-evidence-api/tasks.md
New file
@@ -0,0 +1,42 @@
# 首页证据材料汇总API对接任务清单
## 阶段1:准备与分析
- [x] 1.1 研究现有EvidenceAPIService接口规范
- [x] 1.2 分析首页现有结构和布局(发现TabContainer中已有EvidenceBoard组件)
- [x] 1.3 确认UI展示位置:集成到TabContainer的"证据材料汇总"Tab页签内
- [x] 1.4 准备开发环境和测试数据
## 阶段2:核心功能开发
- [x] 2.1 修改TabContainer中的EvidenceBoard组件(而非创建独立组件)
- [x] 2.2 实现参数获取逻辑(URL参数 → localStorage)
- [x] 2.3 实现API数据加载功能
- [x] 2.4 实现申请人/被申请人数据分离逻辑(per_type过滤)
- [x] 2.5 实现审核状态计算函数(0/-2→待审核,1→已审核,-2→驳回)
- [x] 2.6 开发环境Mock数据支持
## 阶段3:UI界面实现
- [x] 3.1 保持原有两列卡片布局风格
- [x] 3.2 实现材料列表展示(name、result、时间)
- [x] 3.3 实现审核状态Tag标签(使用Ant Design Tag组件)
- [x] 3.4 实现整体审核状态显示在标题旁
- [x] 3.5 实现Loading状态展示
- [x] 3.6 实现错误状态展示(含重新加载按钮)
- [x] 3.7 实现空数据状态展示
## 阶段4:集成与验证
- [x] 4.1 移除独立的HomeEvidenceComponent组件
- [x] 4.2 清理无用的组件文件
- [x] 4.3 验证TabContainer中证据材料Tab正常工作
## 验收测试清单
- [x] 证据材料汇总Tab正确显示
- [x] 申请人材料区域显示正确(含整体状态Tag)
- [x] 被申请人材料区域显示正确(含整体状态Tag)
- [x] 每个材料项显示:名称、说明、时间、状态Tag
- [x] Loading状态显示正常
- [x] 错误状态可重新加载
- [x] 空数据状态展示合理
- [x] 开发环境Mock数据可用
## 实现说明
**重要**:证据材料汇总功能应集成到TabContainer的EvidenceBoard组件中,作为"证据材料汇总"Tab页签内容展示,而非独立的首页组件。
openspec/changes/integrate-typical-case-api/proposal.md
New file
@@ -0,0 +1,202 @@
# Proposal: 集成典型案例查询API数据展示
## Change ID
`integrate-typical-case-api`
## 概述
修改`CaseSearchContent.jsx`和`TypicalCaseDetailContent.jsx`组件,集成`CaseAPIService`的多个API调用,实现典型案例查询、筛选、列表展示和详情查看的完整功能。重构查询条件布局,支持案例类型切换联动,并根据不同案例类型(调解案例/判决文书)展示对应的数据和详情内容。
## 动机
当前"典型案例查询"功能使用的是静态mock数据和硬编码的筛选条件,无法提供真实的案例检索服务。需要集成后端API实现:
1. **动态查询条件**:支持案例类型切换,联动加载纠纷类型、发生时间、纠纷发生地筛选数据
2. **真实数据展示**:根据查询条件获取调解案例或判决文书列表
3. **分类详情展示**:点击案例时根据类型展示不同的详情内容和字段映射
4. **优化用户体验**:改进查询表单布局,提供Loading状态和错误处理机制
## 影响范围
### 修改文件
- `web-app/src/components/tools/CaseSearchContent.jsx` - 重构查询条件、集成API调用和动态数据渲染
- `web-app/src/components/tools/TypicalCaseDetailContent.jsx` - 根据案例类型展示不同详情内容
- `web-app/src/components/tools/TypicalCaseSearch.css` - 新增关键词全宽样式
### 使用API
- `CaseAPIService.getDisputeTypes(caseSource)` - 获取纠纷类型下拉数据
- `CaseAPIService.getYearStatistics()` - 获取判决文书年份统计
- `CaseAPIService.getMediationYearStatistics()` - 获取调解案例年份统计
- `CaseAPIService.getAreaStatistics(caseSource)` - 获取纠纷发生地统计
- `CaseAPIService.getCourtCases(params)` - 查询判决文书列表
- `CaseAPIService.getMediationCases(params)` - 查询调解案例列表
- `CaseAPIService.getCourtCaseDetail(id)` - 获取判决文书详情
- `CaseAPIService.getMediationCaseDetail(id)` - 获取调解案例详情
## 用户故事
### 作为调解员
- **我想要**:通过案例类型切换按钮选择查询调解案例或判决文书
- **以便于**:快速定位到我需要的案例类型
### 作为调解员
- **我想要**:选择案例类型后,纠纷类型、发生时间、发生地等筛选条件自动加载对应数据
- **以便于**:根据实际数据分布进行精确筛选
### 作为调解员
- **我想要**:点击案例时能看到完整的案例详情,且调解案例和判决文书显示不同的字段
- **以便于**:获取针对不同案例类型的关键信息
## 关键技术决策
### 1. 查询条件布局重构
**变更内容**:
- 移除:纠纷发生时间RangePicker组件、案例类型筛选卡片
- 新增:案例类型单选按钮组(判决文书/调解案例),默认选中"判决文书"
- 调整:关键词拉长占满第一行,第二行显示案例类型+纠纷类型+查询/重置按钮
**理由**:
- 简化查询界面,主要筛选条件(发生时间、发生地)使用树形卡片更直观
- 案例类型作为核心切换维度,使用单选按钮组更符合交互习惯
- 避免表单组件过多导致的视觉混乱
### 2. 案例类型切换联动策略
**实现方案**:使用`useEffect`监听`caseType`变化,自动触发三个API调用
```javascript
useEffect(() => {
  const caseSource = caseType === 'judgment' ? 'judgment' : 'mediation';
  loadDisputeTypes(caseSource);      // 纠纷类型下拉框
  loadYearStatistics();               // 发生时间树形卡片
  loadAreaStatistics(caseSource);     // 纠纷发生地树形卡片
}, [caseType]);
```
**理由**:
- 确保筛选数据与案例类型保持一致
- 自动化联动减少用户操作,提升体验
### 3. 数据字段映射策略
**调解案例字段映射**:
```javascript
{
  标题: case_title,
  发生时间: occur_time (格式化为 YYYY年MM月DD日),
  发生地点: que_prov_name + "/" + que_city_name,
  纠纷类型: case_type_first_name,
  案例类型: "调解案例",
  id: id
}
```
**判决文书字段映射**:
```javascript
{
  标题: case_name,
  发生时间: judgment_date (格式化为 YYYY年MM月DD日),
  发生地点: court,
  纠纷类型: case_reason,
  案例类型: "判决文书",
  id: cpws_case_info_id
}
```
**理由**:
- API返回的字段名称不同,需要适配
- 日期统一格式化为"YYYY年MM月DD日"提升可读性
### 4. 详情内容分类展示策略
**调解案例详情展示**:
- 弹窗标题:典型案例详情
- 第一行:案件标题(case_title)
- 第二行:纠纷发生时间(occur_time)、发生地点(que_prov_name/que_city_name)、纠纷类型(case_type_first_name)
- 移除:调解组织字段
- 详情内容:仅显示案例概述(case_des)、原告诉讼请求(case_claim)、调解结果(agree_content)
**判决文书详情展示**:
- 弹窗标题:判决文书详情
- 第一行:案件标题(case_name)、案号(case_number)
- 第二行:判决日期(judgment_date)、发生地点(court)、纠纷类型(case_reason)
- 详情内容:案例概述(basic_case_info)、原告介绍(plaintiff)、被告介绍(defendant)、法院审理与判决(trial_finding)、审理经过(trial_process)、审理程序(trial_procedure)、调解结果(judgment)、案例相关法律条文(legal_basis)
- 移除:调解背景、双方立场
**理由**:
- 调解案例和判决文书的数据结构和关注点不同
- 精简调解案例详情,突出核心信息
- 判决文书详情更完整,满足专业用户需求
### 5. 错误处理策略
- API调用失败时显示友好的错误提示(使用message.error)
- 控制台输出详细错误信息供调试
- 不使用mock数据作为降级方案
## 数据流设计
```
用户打开典型案例查询弹窗
  ↓
组件挂载,默认案例类型=judgment
  ↓
并行调用三个API加载筛选数据
  ├─ getDisputeTypes('judgment')
  ├─ getYearStatistics()
  └─ getAreaStatistics('judgment')
  ↓
首次加载:调用getCourtCases获取列表数据
  ↓
用户切换案例类型为mediation
  ↓
重新加载筛选数据
  ├─ getDisputeTypes('mediation')
  ├─ getMediationYearStatistics()
  └─ getAreaStatistics('mediation')
  ↓
用户选择筛选条件,点击查询
  ↓
根据案例类型调用对应API
  ├─ judgment → getCourtCases
  └─ mediation → getMediationCases
  ↓
展示列表数据(根据案例类型映射字段)
  ↓
用户点击某个案例
  ↓
根据案例类型调用详情API
  ├─ judgment → getCourtCaseDetail
  └─ mediation → getMediationCaseDetail
  ↓
弹窗展示详情(根据案例类型显示不同内容)
```
## 非目标
- 本次不添加案例收藏功能
- 本次不实现案例对比功能
- 本次不处理案例详情的导出或打印
- 本次不修改分页组件样式
- 本次不添加高级搜索功能(如全文检索)
## 依赖关系
- ✅ CaseAPIService所有方法已存在(已在d:\cmw\work_space\hejiu_dev\cloud-melody-front\web-app\src\services\CaseAPIService.js中定义)
- ✅ 现有CaseSearchContent和TypicalCaseDetailContent UI结构可复用
- ✅ Ant Design组件库支持Radio.Group
## 验收标准
1. ✅ 查询条件布局符合需求:第一行关键词,第二行案例类型+纠纷类型+按钮
2. ✅ 移除纠纷发生时间RangePicker和案例类型筛选卡片
3. ✅ 切换案例类型时,纠纷类型、发生时间、发生地数据自动重新加载
4. ✅ 首次打开弹窗时默认加载判决文书列表数据
5. ✅ 点击查询时根据案例类型调用正确的API
6. ✅ 列表数据根据案例类型正确映射字段显示
7. ✅ 日期时间格式化为"YYYY年MM月DD日"
8. ✅ 点击案例时根据类型调用正确的详情API
9. ✅ 调解案例详情弹窗标题为"典型案例详情",显示指定字段
10. ✅ 判决文书详情弹窗标题为"判决文书详情",显示指定字段
11. ✅ API调用过程中显示Loading状态
12. ✅ API调用失败时显示错误提示
13. ✅ 分页功能正常工作,使用API返回的total和size计算
## 风险评估
- **中等风险**:涉及多个API集成和复杂的字段映射逻辑
- **测试重点**:案例类型切换联动、字段映射正确性、详情分类展示、错误处理机制
- **兼容性风险**:需确保API返回数据结构符合预期,建议添加数据验证
## 时间估算
- 开发时间:4-5小时
- 测试时间:2小时
- 总计:6-7小时
openspec/changes/integrate-typical-case-api/specs/typical-case-search/spec.md
New file
@@ -0,0 +1,281 @@
# Specification: 典型案例查询API集成
## Capability
`typical-case-search`
## Overview
集成CaseAPIService的多个API实现典型案例查询、筛选、列表展示和详情查看的完整功能。支持案例类型(判决文书/调解案例)切换,并根据不同案例类型展示对应的数据和详情内容。
## ADDED Requirements
### Requirement: 查询条件布局优化
查询条件表单布局MUST优化,提升用户体验和操作效率。
#### Scenario: 用户打开典型案例查询弹窗
**Given** 用户点击"典型案例查询"工具
**When** 弹窗打开
**Then** 查询条件区域第一行显示关键词输入框(占满整行)
**And** 第二行依次显示:案例类型单选按钮组、纠纷类型下拉框、查询按钮、重置按钮
**And** 案例类型默认选中"判决文书"
**And** 不显示纠纷发生时间RangePicker组件
**And** 不显示案例类型筛选卡片
**And** 保留发生时间和纠纷发生地树形筛选卡片
### Requirement: 案例类型切换联动加载
切换案例类型时,相关筛选条件MUST自动重新加载对应数据。
#### Scenario: 用户切换到调解案例
**Given** 用户当前选中判决文书案例类型
**When** 用户点击切换到调解案例
**Then** 系统调用`CaseAPIService.getDisputeTypes('mediation')`重新加载纠纷类型下拉数据
**And** 系统调用`CaseAPIService.getMediationYearStatistics()`重新加载发生时间统计数据
**And** 系统调用`CaseAPIService.getAreaStatistics('mediation')`重新加载纠纷发生地统计数据
**And** 纠纷类型下拉框选项更新为调解案例的纠纷类型
**And** 发生时间树形卡片节点更新为调解案例的年份统计
**And** 纠纷发生地树形卡片节点更新为调解案例的地区统计
**And** 当前选中的纠纷类型被重置
#### Scenario: 用户切换到判决文书
**Given** 用户当前选中调解案例类型
**When** 用户点击切换到判决文书
**Then** 系统调用`CaseAPIService.getDisputeTypes('judgment')`重新加载纠纷类型下拉数据
**And** 系统调用`CaseAPIService.getYearStatistics()`重新加载发生时间统计数据
**And** 系统调用`CaseAPIService.getAreaStatistics('judgment')`重新加载纠纷发生地统计数据
**And** 纠纷类型下拉框选项更新为判决文书的纠纷类型
**And** 发生时间树形卡片节点更新为判决文书的年份统计
**And** 纠纷发生地树形卡片节点更新为判决文书的地区统计
**And** 当前选中的纠纷类型被重置
### Requirement: 案例列表数据加载
系统MUST支持根据查询条件和案例类型动态加载案例列表数据。
#### Scenario: 首次打开弹窗默认加载判决文书
**Given** 用户点击典型案例查询工具
**When** 弹窗打开
**Then** 系统调用`CaseAPIService.getCourtCases({page: 1, size: 10})`加载默认判决文书列表
**And** 显示Loading状态
**And** 加载成功后展示判决文书列表数据
**And** 记录总数显示API返回的total字段值
#### Scenario: 查询判决文书案例
**Given** 用户选中判决文书案例类型
**And** 用户填写了查询条件(关键词、纠纷类型、发生时间、发生地)
**When** 用户点击查询按钮
**Then** 系统构建查询参数:`{page: 1, size: 10, keyword, caseTypeFirst, occurrenceYears, regionList}`
**And** occurrenceYears和regionList多个值用英文逗号拼接
**And** 系统调用`CaseAPIService.getCourtCases(params)`
**And** 显示Loading状态
**And** 加载成功后展示判决文书列表数据
**And** 分页组件使用返回的total和size计算页数
#### Scenario: 查询调解案例
**Given** 用户选中调解案例类型
**And** 用户填写了查询条件
**When** 用户点击查询按钮
**Then** 系统调用`CaseAPIService.getMediationCases(params)`
**And** 显示Loading状态
**And** 加载成功后展示调解案例列表数据
### Requirement: 案例列表字段映射
不同案例类型的列表数据MUST使用不同的字段映射。
#### Scenario: 展示调解案例列表
**Given** 系统成功加载调解案例列表数据
**When** 渲染列表项
**Then** 标题字段映射为`case_title`
**And** 发生时间字段映射为`occur_time`并格式化为"YYYY年MM月DD日"
**And** 发生地点字段映射为`que_prov_name + "/" + que_city_name`
**And** 纠纷类型字段映射为`case_type_first_name`
**And** 案例类型标签显示"调解案例"
**And** 案例ID字段映射为`id`
#### Scenario: 展示判决文书列表
**Given** 系统成功加载判决文书列表数据
**When** 渲染列表项
**Then** 标题字段映射为`case_name`
**And** 发生时间字段映射为`judgment_date`并格式化为"YYYY年MM月DD日"
**And** 发生地点字段映射为`court`
**And** 纠纷类型字段映射为`case_reason`
**And** 案例类型标签显示"判决文书"
**And** 案例ID字段映射为`cpws_case_info_id`
### Requirement: 案例详情加载
点击案例项时MUST根据案例类型调用对应的详情API。
#### Scenario: 点击调解案例查看详情
**Given** 用户在列表中看到一个调解案例
**When** 用户点击该案例
**Then** 系统从案例数据中提取`id`字段
**And** 系统调用`CaseAPIService.getMediationCaseDetail(id)`
**And** 显示Loading状态
**And** 加载成功后打开详情弹窗
**And** 详情弹窗标题显示"典型案例详情"
#### Scenario: 点击判决文书查看详情
**Given** 用户在列表中看到一个判决文书
**When** 用户点击该案例
**Then** 系统从案例数据中提取`cpws_case_info_id`字段
**And** 系统调用`CaseAPIService.getCourtCaseDetail(cpws_case_info_id)`
**And** 显示Loading状态
**And** 加载成功后打开详情弹窗
**And** 详情弹窗标题显示"判决文书详情"
### Requirement: 调解案例详情展示
调解案例详情MUST展示特定的字段和布局。
#### Scenario: 展示调解案例详情基本信息
**Given** 系统成功加载调解案例详情数据
**When** 渲染案件基本信息区域
**Then** 第一行显示案件标题,取`case_title`字段
**And** 第二行显示纠纷发生时间,取`occur_time`字段并格式化
**And** 第二行显示发生地点,取`que_prov_name + "/" + que_city_name`
**And** 第二行显示纠纷类型,取`case_type_first_name`字段
**And** 不显示调解组织字段
#### Scenario: 展示调解案例详情内容
**Given** 系统成功加载调解案例详情数据
**When** 渲染案例内容区域
**Then** 显示案例概述section,取`case_des`字段
**And** 显示原告诉讼请求section,取`case_claim`字段
**And** 显示调解结果section,取`agree_content`字段
**And** 不显示其他section(法院审理与判决、调解背景、双方立场等)
### Requirement: 判决文书详情展示
判决文书详情MUST展示完整的裁判文书信息。
#### Scenario: 展示判决文书详情基本信息
**Given** 系统成功加载判决文书详情数据
**When** 渲染案件基本信息区域
**Then** 第一行显示案件标题,取`case_name`字段
**And** 第一行显示案号,取`case_number`字段
**And** 第二行显示判决日期(而非纠纷发生时间),取`judgment_date`字段并格式化
**And** 第二行显示发生地点,取`court`字段
**And** 第二行显示纠纷类型,取`case_reason`字段
#### Scenario: 展示判决文书详情内容
**Given** 系统成功加载判决文书详情数据
**When** 渲染案例内容区域
**Then** 显示案例概述section,取`basic_case_info`字段
**And** 显示原告介绍section(而非原告诉讼请求),取`plaintiff`字段
**And** 显示被告介绍section(新增),取`defendant`字段
**And** 显示法院审理与判决section,取`trial_finding`字段
**And** 显示审理经过section(而非调解过程),取`trial_process`字段
**And** 显示审理程序section(而非调解方案),取`trial_procedure`字段
**And** 显示调解结果section,取`judgment`字段
**And** 显示案例相关法律条文section,取`legal_basis`字段
**And** 不显示调解背景section
**And** 不显示双方立场section
### Requirement: 分页功能
系统MUST支持案例列表的分页浏览。
#### Scenario: 切换到下一页
**Given** 用户在查看第1页案例列表
**And** 总记录数大于每页显示数量
**When** 用户点击下一页按钮
**Then** 系统调用对应的案例列表API,传入page=2参数
**And** 显示Loading状态
**And** 加载成功后展示第2页数据
**And** 分页组件高亮显示当前页码
### Requirement: 错误处理
API调用失败时MUST给用户明确的错误提示。
#### Scenario: 加载案例列表失败
**Given** 用户点击查询按钮
**When** API调用失败(网络错误、服务器错误等)
**Then** 隐藏Loading状态
**And** 使用message.error显示"加载案例列表失败"提示
**And** 控制台输出详细错误信息
#### Scenario: 加载案例详情失败
**Given** 用户点击某个案例查看详情
**When** API调用失败
**Then** 隐藏Loading状态
**And** 使用message.error显示"加载案例详情失败"提示
**And** 不打开详情弹窗
## REMOVED Requirements
### Requirement: 纠纷发生时间RangePicker
移除查询条件中的纠纷发生时间日期范围选择器。
#### Scenario: 用户打开典型案例查询弹窗
**Given** 用户点击典型案例查询工具
**When** 弹窗打开
**Then** 查询条件区域不显示纠纷发生时间RangePicker组件
### Requirement: 案例类型筛选卡片
移除筛选器区域中的案例类型树形卡片。
#### Scenario: 用户查看筛选器区域
**Given** 用户打开典型案例查询弹窗
**When** 用户查看筛选器区域
**Then** 不显示案例类型筛选卡片
**And** 只显示发生时间和纠纷发生地筛选卡片
## Implementation Notes
### API参数说明
- **caseSource**: 案例来源参数,判决文书传'judgment',调解案例传'mediation'
- **occurrenceYears**: 发生时间参数,多个年份用英文逗号拼接,如"2022,2023"
- **regionList**: 地区参数,多个地区用英文逗号拼接,如"广东省,广州市"
- **page**: 页码,从1开始
- **size**: 每页记录数,默认10
### 日期格式化规则
所有日期字段统一格式化为"YYYY年MM月DD日"格式,如"2025年01月20日"。
### 字段映射表
**调解案例列表字段映射**:
| 显示字段 | API字段 | 备注 |
|---------|---------|------|
| 标题 | case_title | - |
| 发生时间 | occur_time | 需格式化 |
| 发生地点 | que_prov_name + "/" + que_city_name | 字符串拼接 |
| 纠纷类型 | case_type_first_name | - |
| 案例ID | id | - |
**判决文书列表字段映射**:
| 显示字段 | API字段 | 备注 |
|---------|---------|------|
| 标题 | case_name | - |
| 发生时间 | judgment_date | 需格式化 |
| 发生地点 | court | - |
| 纠纷类型 | case_reason | - |
| 案例ID | cpws_case_info_id | - |
**调解案例详情字段映射**:
| 显示标签 | API字段 | 备注 |
|---------|---------|------|
| 案件标题 | case_title | - |
| 纠纷发生时间 | occur_time | 需格式化 |
| 发生地点 | que_prov_name + "/" + que_city_name | 字符串拼接 |
| 纠纷类型 | case_type_first_name | - |
| 案例概述 | case_des | - |
| 原告诉讼请求 | case_claim | - |
| 调解结果 | agree_content | - |
**判决文书详情字段映射**:
| 显示标签 | API字段 | 备注 |
|---------|---------|------|
| 案件标题 | case_name | - |
| 案号 | case_number | - |
| 判决日期 | judgment_date | 需格式化 |
| 发生地点 | court | - |
| 纠纷类型 | case_reason | - |
| 案例概述 | basic_case_info | - |
| 原告介绍 | plaintiff | - |
| 被告介绍 | defendant | - |
| 法院审理与判决 | trial_finding | - |
| 审理经过 | trial_process | - |
| 审理程序 | trial_procedure | - |
| 调解结果 | judgment | - |
| 案例相关法律条文 | legal_basis | - |
## Related Capabilities
- None (首个案例查询API集成capability)
## Version
1.0.0
openspec/changes/integrate-typical-case-api/tasks.md
New file
@@ -0,0 +1,296 @@
# Tasks: 集成典型案例查询API数据展示
## 任务清单
### Phase 1: 准备工作 (0.5小时)
#### Task 1.1: 分析现有代码结构
- [x] 查看CaseSearchContent.jsx组件的当前实现
- [x] 确认CaseAPIService所有方法的参数要求和返回数据结构
- [x] 分析现有mock数据结构与API返回数据的映射关系
- [x] 确认TypicalCaseDetailContent组件的props结构
#### Task 1.2: 确认API数据字段
- [x] 验证CaseAPIService.getDisputeTypes返回的dispute_type字段
- [x] 验证getYearStatistics和getMediationYearStatistics返回的year和count字段
- [x] 验证getAreaStatistics返回的area_name、count、level、children字段
- [x] 验证getCourtCases和getMediationCases返回的data数组字段
- [x] 验证getCourtCaseDetail和getMediationCaseDetail返回的详情字段
### Phase 2: 查询条件布局重构 (1-1.5小时)
#### Task 2.1: 修改CaseSearchContent组件 - 状态管理
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 导入必要的依赖:`useEffect`, `Radio`, `message`
- [x] 导入CaseAPIService
- [x] 添加组件内部状态:
  - `caseType` - 案例类型,默认'judgment'
  - `disputeTypeOptions` - 纠纷类型下拉选项
  - `list` - 案例列表数据
  - `total` - 总记录数
  - `pageSize` - 每页记录数,默认10
  - `selectedCaseType` - 当前选中案例的类型(用于详情弹窗)
- [x] 移除不需要的状态:`caseTypeFilters`
#### Task 2.2: 重构查询条件表单布局
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 移除纠纷发生时间RangePicker组件
- [x] 关键词输入框添加class `case-search-keyword-full` 使其占满第一行
- [x] 新增案例类型Radio.Group组件
  - 选项:判决文书(value='judgment')、调解案例(value='mediation')
  - 默认选中:judgment
  - onChange事件切换caseType并重置disputeType
- [x] 纠纷类型Select组件改为动态渲染disputeTypeOptions
- [x] 调整按钮组位置到第二行
#### Task 2.3: 移除案例类型筛选卡片
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 删除案例类型filter-category整个section
- [x] 保留发生时间和纠纷发生地筛选卡片
### Phase 3: API集成 - 筛选数据加载 (1.5-2小时)
#### Task 3.1: 实现纠纷类型下拉数据加载
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 创建loadDisputeTypes函数,接收caseSource参数
- [x] 调用CaseAPIService.getDisputeTypes(caseSource)
- [x] 将返回的data数组映射为{label: dispute_type, value: dispute_type}
- [x] 更新disputeTypeOptions状态
- [x] 添加错误处理
#### Task 3.2: 实现发生时间统计数据加载
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 创建loadYearStatistics函数
- [x] 根据caseType选择调用getYearStatistics或getMediationYearStatistics
- [x] 将返回的data数组映射为{label: `${year}年`, count, checked: false, value: year}
- [x] 更新yearFilters状态
- [x] 添加错误处理
#### Task 3.3: 实现纠纷发生地统计数据加载
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 创建loadAreaStatistics函数,接收caseSource参数
- [x] 调用CaseAPIService.getAreaStatistics(caseSource)
- [x] 将返回的data数组映射为筛选项格式(包含children子项)
- [x] 更新regionFilters状态
- [x] 添加错误处理
#### Task 3.4: 实现案例类型切换联动
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 添加useEffect监听caseType变化
- [x] 在useEffect中依次调用:
  - loadDisputeTypes(caseSource)
  - loadYearStatistics()
  - loadAreaStatistics(caseSource)
- [x] 其中caseSource根据caseType转换:judgment → 'judgment', mediation → 'mediation'
### Phase 4: API集成 - 案例列表数据加载 (1.5-2小时)
#### Task 4.1: 实现案例列表数据加载函数
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 创建loadCaseList函数,接收page参数
- [x] 从yearFilters和regionFilters中提取选中的值
- [x] 构建查询参数对象params:
  - page, size, keyword, caseTypeFirst(disputeType)
  - occurrenceYears: 多个值用逗号拼接
  - regionList: 多个值用逗号拼接
- [x] 根据caseType选择调用getMediationCases或getCourtCases
- [x] 更新list和total状态
- [x] 添加Loading状态管理
- [x] 添加错误处理
#### Task 4.2: 实现日期格式化函数
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 创建formatDate函数
- [x] 将日期字符串格式化为"YYYY年MM月DD日"格式
- [x] 处理无效日期情况
#### Task 4.3: 实现首次加载逻辑
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 添加useEffect在组件挂载时调用loadCaseList(1)
- [x] 确保依赖数组为空,只执行一次
#### Task 4.4: 修改查询和重置按钮处理逻辑
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改handleSearch函数:重置到第一页,调用loadCaseList(1)
- [x] 修改handleReset函数:重置所有查询条件和筛选状态
#### Task 4.5: 修改列表数据渲染逻辑
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改list.map渲染逻辑,根据caseType判断字段映射:
  - 调解案例:case_title, occur_time, que_prov_name+"/"+que_city_name, case_type_first_name, id
  - 判决文书:case_name, judgment_date, court, case_reason, cpws_case_info_id
- [x] 所有日期字段使用formatDate函数格式化
- [x] 更新case-type-badge显示逻辑
#### Task 4.6: 修改分页处理逻辑
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改handlePageChange函数调用loadCaseList
- [x] 更新Pagination组件的total属性使用动态total状态
- [x] 确保pageSize正确传递
### Phase 5: API集成 - 案例详情加载 (1.5-2小时)
#### Task 5.1: 修改案例点击处理逻辑
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改handleCaseClick函数为async函数
- [x] 根据caseType确定使用的id字段(mediation: id, judgment: cpws_case_info_id)
- [x] 根据caseType选择调用getMediationCaseDetail或getCourtCaseDetail
- [x] 将返回的data设置到selectedCase状态
- [x] 设置selectedCaseType状态为当前caseType
- [x] 打开详情弹窗
- [x] 添加Loading和错误处理
#### Task 5.2: 修改详情弹窗标题
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改Modal的title,根据selectedCaseType动态显示:
  - mediation: "典型案例详情"
  - judgment: "判决文书详情"
#### Task 5.3: 传递案例类型到详情组件
- [x] **文件**: `web-app/src/components/tools/CaseSearchContent.jsx`
- [x] 修改TypicalCaseDetailContent组件调用,传递caseType prop
- [x] `<TypicalCaseDetailContent caseData={selectedCase} caseType={selectedCaseType} />`
### Phase 6: 详情组件重构 (1.5-2小时)
#### Task 6.1: 修改TypicalCaseDetailContent组件props
- [x] **文件**: `web-app/src/components/tools/TypicalCaseDetailContent.jsx`
- [x] 添加caseType prop接收
- [x] 根据caseType判断展示逻辑分支
#### Task 6.2: 实现调解案例详情展示
- [x] **文件**: `web-app/src/components/tools/TypicalCaseDetailContent.jsx`
- [x] 当caseType='mediation'时:
  - 第一行:展示案件标题(caseData.case_title)
  - 第二行:纠纷发生时间(occur_time)、发生地点(que_prov_name+"/"+que_city_name)、纠纷类型(case_type_first_name)
  - 移除:调解组织字段
  - 详情内容:
    - 案例概述:case_des
    - 原告诉讼请求:case_claim
    - 调解结果:agree_content
  - 其他section全部不展示
#### Task 6.3: 实现判决文书详情展示
- [x] **文件**: `web-app/src/components/tools/TypicalCaseDetailContent.jsx`
- [x] 当caseType='judgment'时:
  - 第一行:案件标题(case_name)、案号(case_number)
  - 第二行:判决日期(judgment_date)、发生地点(court)、纠纷类型(case_reason)
  - 详情内容:
    - 案例概述:basic_case_info
    - 原告介绍:plaintiff(改名)
    - 被告介绍:defendant(新增)
    - 法院审理与判决:trial_finding
    - 审理经过:trial_process(改名)
    - 审理程序:trial_procedure(改名)
    - 调解结果:judgment
    - 案例相关法律条文:legal_basis
  - 移除:调解背景、双方立场section
#### Task 6.4: 处理字段不存在的情况
- [x] **文件**: `web-app/src/components/tools/TypicalCaseDetailContent.jsx`
- [x] 为所有字段添加存在性检查,避免undefined错误
- [x] 使用条件渲染{field && (...)}包裹section
### Phase 7: 样式调整 (0.5小时)
#### Task 7.1: 添加关键词全宽样式
- [x] **文件**: `web-app/src/components/tools/TypicalCaseSearch.css`
- [x] 添加`.case-search-keyword-full`样式类
- [x] 设置grid-column: 1 / -1使其占满整行
#### Task 7.2: 验证响应式布局
- [x] **文件**: `web-app/src/components/tools/TypicalCaseSearch.css`
- [x] 检查媒体查询是否需要调整
- [x] 确保移动端布局正常
### Phase 8: 测试与验证 (2小时)
#### Task 8.1: 功能测试 - 查询条件
- [ ] 测试弹窗打开时默认选中判决文书
- [ ] 测试关键词输入正常
- [ ] 测试案例类型切换时纠纷类型下拉框重新加载
- [ ] 测试案例类型切换时发生时间卡片重新加载
- [ ] 测试案例类型切换时纠纷发生地卡片重新加载
- [ ] 测试纠纷类型下拉框数据正确显示
- [ ] 测试筛选卡片数据正确显示
#### Task 8.2: 功能测试 - 列表查询
- [ ] 测试首次加载调用getCourtCases API
- [ ] 测试点击查询按钮(判决文书)调用getCourtCases
- [ ] 测试点击查询按钮(调解案例)调用getMediationCases
- [ ] 测试列表数据正确展示(判决文书字段映射)
- [ ] 测试列表数据正确展示(调解案例字段映射)
- [ ] 测试日期格式化为"YYYY年MM月DD日"
- [ ] 测试记录总数显示API返回的total值
- [ ] 测试分页功能正常工作
#### Task 8.3: 功能测试 - 详情展示
- [ ] 测试点击调解案例时调用getMediationCaseDetail
- [ ] 测试点击判决文书时调用getCourtCaseDetail
- [ ] 测试调解案例详情弹窗标题为"典型案例详情"
- [ ] 测试判决文书详情弹窗标题为"判决文书详情"
- [ ] 测试调解案例详情字段正确显示
- [ ] 测试判决文书详情字段正确显示
- [ ] 测试调解案例详情不显示多余字段
- [ ] 测试判决文书详情不显示多余字段
#### Task 8.4: 异常场景测试
- [ ] 测试API调用失败时显示错误提示
- [ ] 测试Loading状态正确显示
- [ ] 测试无数据时显示空状态提示
- [ ] 测试筛选条件为空时的查询行为
- [ ] 测试分页到最后一页的行为
#### Task 8.5: UI/UX测试
- [ ] 验证查询条件布局符合需求
- [ ] 验证案例类型切换流畅
- [ ] 验证详情弹窗样式正常
- [ ] 验证响应式设计不受影响
- [ ] 验证在不同数据量下的显示效果
## 实施计划
### 开发顺序
1. Phase 2: 先重构查询条件布局(视觉变化明显)
2. Phase 3: 实现筛选数据API集成(联动效果)
3. Phase 4: 实现列表数据API集成(核心功能)
4. Phase 5: 实现详情数据API集成(核心功能)
5. Phase 6: 重构详情组件分类展示(核心功能)
6. Phase 7: 样式微调
7. Phase 8: 全面测试
### 关键检查点
- ✅ 查询条件布局是否符合需求
- ✅ 案例类型切换联动是否正常
- ✅ API调用参数构建是否正确
- ✅ 数据字段映射是否准确
- ✅ 详情分类展示是否正确
- ✅ 错误处理是否完整
## 风险缓解措施
### 技术风险
- **API数据格式不匹配**:提前验证所有API返回字段,准备字段映射表
- **日期格式不统一**:统一使用formatDate函数处理
- **筛选数据结构复杂**:逐个API测试,确认数据结构
- **详情字段缺失**:添加字段存在性检查,避免渲染错误
### 质量保证
- 分阶段测试,确保每个功能点独立验证
- 保留现有功能作为基准对比
- 充分的异常场景测试
## 验收标准检查清单
- [ ] 查询条件布局符合需求
- [ ] 移除纠纷发生时间和案例类型卡片
- [ ] 案例类型切换联动正常
- [ ] 首次加载判决文书列表
- [ ] 点击查询调用正确API
- [ ] 列表数据字段映射正确
- [ ] 日期格式化正确
- [ ] 点击案例调用正确详情API
- [ ] 调解案例详情展示正确
- [ ] 判决文书详情展示正确
- [ ] Loading状态正常工作
- [ ] 错误处理机制完善
- [ ] 分页功能正常工作
web-app/src/components/dashboard/TabContainer.jsx
@@ -1,21 +1,33 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useCaseData } from '../../contexts/CaseDataContext';
import { formatDuration, formatSuccessRate, formatRoundCount } from '../../utils/stateTranslator';
import ProcessAPIService from '../../services/ProcessAPIService';
import { message, Spin } from 'antd';
import EvidenceAPIService from '../../services/EvidenceAPIService';
import MediationAgreementAPIService from '../../services/MediationAgreementAPIService';
import { getMergedParams } from '../../utils/urlParams';
import { message, Spin, Tag, Modal, Button, Input, Image } from 'antd';
const { TextArea } = Input;
/**
 * 选项卡容器组件 - 4个选项卡
 */
const TabContainer = () => {
  const [activeTab, setActiveTab] = useState('mediation-data-board');
  // 证据材料汇总Tab的审核状态badge
  const [evidenceBadge, setEvidenceBadge] = useState(null);
  const tabs = [
    { key: 'mediation-data-board', label: '调解分析', icon: 'fa-chart-line' },
    { key: 'mediation-board', label: 'AI调解实时看板', icon: 'fa-tv' },
    { key: 'evidence-board', label: '证据材料汇总', icon: 'fa-file-alt', badge: '待审核' },
    { key: 'evidence-board', label: '证据材料汇总', icon: 'fa-file-alt', badge: evidenceBadge },
    { key: 'agreement-section', label: '调解协议', icon: 'fa-file-contract', badge: '待确认' },
  ];
  // 更新证据材料汇总Tab的badge状态
  const handleEvidenceStatusChange = (status) => {
    setEvidenceBadge(status);
  };
  return (
    <div className="tab-container">
@@ -51,7 +63,7 @@
        {/* 证据材料汇总 */}
        <div className={`tab-pane ${activeTab === 'evidence-board' ? 'active' : ''}`}>
          <div className="tab-content-area">
            <EvidenceBoard />
            <EvidenceBoard onStatusChange={handleEvidenceStatusChange} />
          </div>
        </div>
@@ -426,20 +438,316 @@
/**
 * 证据材料汇总
 */
const EvidenceBoard = () => {
  const applicantEvidence = [
    { name: '劳动合同', desc: '2023年8月1日-2026年1月31日,约定月工资¥14,000', time: '2026-01-15 09:35', status: 'verified' },
    { name: '2026年1-3月工资条', desc: '显示工资发放记录,1月后无发放记录', time: '2026-01-15 09:36', status: 'verified' },
    { name: '2026年第一季度考勤记录', desc: '显示全勤,无请假缺勤记录', time: '2026-01-15 09:37', status: 'verified' },
    { name: '绩效考评表', desc: '第一季度绩效评分85分,达到奖金发放标准', time: '2026-01-15 09:38', status: 'verified' },
    { name: '解除劳动合同通知书', desc: '公司2026年1月15日单方面解除合同', time: '2026-01-15 09:45', status: 'pending' },
  ];
const EvidenceBoard = ({ onStatusChange }) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 审核弹窗状态
  const [auditModalVisible, setAuditModalVisible] = useState(false);
  const [currentAuditItem, setCurrentAuditItem] = useState(null);
  const [auditRemark, setAuditRemark] = useState('');
  const [auditLoading, setAuditLoading] = useState(false);
  const [returnModalVisible, setReturnModalVisible] = useState(false);
  const [applicantMaterials, setApplicantMaterials] = useState([]);
  const [respondentMaterials, setRespondentMaterials] = useState([]);
  // 弹窗数据状态
  const [modalDataLoading, setModalDataLoading] = useState(false);
  const [personInfo, setPersonInfo] = useState(null);
  const [evidenceImages, setEvidenceImages] = useState([]);
  const [platformUrl, setPlatformUrl] = useState('');
  const respondentEvidence = [
    { name: '2026年1-3月考勤异常记录', desc: '显示李晓明1月有2天事假,应扣款¥1,333', time: '2026-01-15 09:50', status: 'pending' },
    { name: '第一季度销售业绩报表', desc: '显示李晓明未完成销售KPI指标(完成率87%)', time: '2026-01-15 09:52', status: 'pending' },
    { name: '员工离职申请表', desc: '李晓明2026年1月10日提交的辞职申请', time: '2026-01-15 09:55', status: 'pending' },
  ];
  // 计算整体审核状态(综合申请人和被申请人所有材料)
  const calculateOverallTabStatus = (applicantList, respondentList) => {
    const allMaterials = [...applicantList, ...respondentList];
    if (!allMaterials || allMaterials.length === 0) return null;
    // 存在驳回状态 -> 显示"驳回"
    const hasRejected = allMaterials.some(m => m.audit_state === -2);
    if (hasRejected) return '驳回';
    // 存在待审核状态(0或-2) -> 显示"待审核"
    const hasPending = allMaterials.some(m => m.audit_state === 0 || m.audit_state === -2);
    if (hasPending) return '待审核';
    // 所有材料都已审核 -> 显示"已审核"
    return '已审核';
  };
  // 加载数据
  const loadData = async () => {
    // 使用getMergedParams获取参数(URL参数优先,默认值兜底)
    const params = getMergedParams();
    const caseId = params.caseId;
    const caseType = params.caseTypeFirst;
    const platformCode = params.platform_code;
    console.log('EvidenceBoard loadData params:', { caseId, caseType, platformCode });
    setLoading(true);
    setError(null);
    try {
      // 调用API获取数据
      const response = await EvidenceAPIService.getEvidenceList({
        case_id: caseId,
        case_type: caseType,
        platform_code: platformCode
      });
      console.log('EvidenceBoard API response:', response);
      const responseData = response.data || [];
      // 分离申请人和被申请人材料
      const applicantData = responseData.find(item => item.per_type === '15_020008-1');
      const respondentData = responseData.find(item => item.per_type === '15_020008-2');
      const applicantList = applicantData?.file_list?.slice(0, applicantData.file_count) || [];
      const respondentList = respondentData?.file_list?.slice(0, respondentData.file_count) || [];
      setApplicantMaterials(applicantList);
      setRespondentMaterials(respondentList);
      // 计算并通知Tab标题的整体审核状态
      const overallStatus = calculateOverallTabStatus(applicantList, respondentList);
      if (onStatusChange) {
        onStatusChange(overallStatus);
      }
    } catch (err) {
      console.error('加载证据材料失败:', err);
      setError('数据加载失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };
  // 组件挂载时加载数据
  useEffect(() => {
    loadData();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  // 计算区域内整体审核状态(用于卡片标题旁的Tag显示)
  const calculateOverallStatus = (materials) => {
    if (!materials || materials.length === 0) return { text: '无数据', color: 'default' };
    const hasRejected = materials.some(m => m.audit_state === -2);
    if (hasRejected) return { text: '驳回', color: 'red' };
    const hasPending = materials.some(m => m.audit_state === 0 || m.audit_state === -2);
    if (hasPending) return { text: '待审核', color: 'orange' };
    return { text: '已审核', color: 'green' };
  };
  const applicantStatus = calculateOverallStatus(applicantMaterials);
  const respondentStatus = calculateOverallStatus(respondentMaterials);
  // Loading状态
  if (loading) {
    return (
      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 300 }}>
        <Spin size="large" tip="加载证据材料中..." />
      </div>
    );
  }
  // 错误状态
  if (error) {
    return (
      <div style={{ textAlign: 'center', padding: 40, color: '#ff4d4f' }}>
        <div style={{ fontSize: '1.2rem', marginBottom: 10 }}>
          <i className="fas fa-exclamation-circle"></i> 数据加载失败
        </div>
        <div>{error}</div>
        <button onClick={loadData} style={{
          marginTop: 15, padding: '8px 16px', backgroundColor: '#1890ff',
          color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer'
        }}>重新加载</button>
      </div>
    );
  }
  // 获取材料审核状态样式
  const getAuditStatusStyle = (auditState) => {
    if (auditState === 1) {
      // 已审核 - 绿色边框
      return {
        color: '#52c41a',
        background: '#f6ffed',
        border: '1px solid #b7eb8f',
        borderRadius: 4,
        padding: '2px 10px',
        fontSize: '0.8rem',
        fontWeight: 500,
      };
    }
    // 待审核 - 橙色边框
    return {
      color: '#fa8c16',
      background: '#fff7e6',
      border: '1px solid #ffd591',
      borderRadius: 4,
      padding: '2px 10px',
      fontSize: '0.8rem',
      fontWeight: 500,
    };
  };
  // 打开审核弹窗
  const handleOpenAuditModal = async (item, type) => {
    // 获取参数
    const params = getMergedParams();
    const caseId = params.caseId;
    // 设置当前审核项
    const auditItem = {
      ...item,
      personType: type,
      per_type: type === 'applicant' ? '15_020008-1' : '15_020008-2'
    };
    setCurrentAuditItem(auditItem);
    setAuditRemark('');
    setAuditModalVisible(true);
    // 获取platform_url
    try {
      const caseDataTimeline = JSON.parse(localStorage.getItem('case_data_timeline') || '{}');
      const pUrl = caseDataTimeline?.process_config?.platform_url || '';
      setPlatformUrl(pUrl);
    } catch (e) {
      console.warn('获取platform_url失败:', e);
    }
    // 调用API获取弹窗数据
    setModalDataLoading(true);
    setPersonInfo(null);
    setEvidenceImages([]);
    try {
      // 并行调用两个API
      const [personInfoRes, evidenceListRes] = await Promise.all([
        EvidenceAPIService.getPersonInfo({
          case_id: caseId,
          evidence_type: item.evidence_type,
          per_type: auditItem.per_type
        }),
        EvidenceAPIService.getEvidenceListByPerson({
          case_id: caseId,
          evidence_type: item.evidence_type,
          per_type: auditItem.per_type,
          person_id: item.person_id
        })
      ]);
      console.log('getPersonInfo response:', personInfoRes);
      console.log('getEvidenceListByPerson response:', evidenceListRes);
      // 设置当事人信息
      if (personInfoRes?.data) {
        setPersonInfo(personInfoRes.data);
      }
      // 设置图片列表
      if (evidenceListRes?.data && Array.isArray(evidenceListRes.data)) {
        setEvidenceImages(evidenceListRes.data);
      }
    } catch (err) {
      console.error('加载弹窗数据失败:', err);
      message.error('加载材料详情失败');
    } finally {
      setModalDataLoading(false);
    }
  };
  // 关闭审核弹窗
  const handleCloseAuditModal = () => {
    setAuditModalVisible(false);
    setCurrentAuditItem(null);
    setAuditRemark('');
    setPersonInfo(null);
    setEvidenceImages([]);
  };
  // 审核通过
  const handleAuditPass = async () => {
    if (!currentAuditItem) return;
    const params = getMergedParams();
    const caseId = params.caseId;
    setAuditLoading(true);
    try {
      // 获取文件ID列表
      const fileIdList = evidenceImages.map(img => img.file_id || img.id).filter(Boolean);
      await EvidenceAPIService.auditEvidence({
        case_id: caseId,
        evidence_type: currentAuditItem.evidence_type,
        person_id: currentAuditItem.person_id,
        file_id_list: fileIdList,
        audit_user: '当前用户', // TODO: 从登录信息获取
        audit_state: 1,
        audit_remark: ''
      });
      message.success('审核通过!');
      handleCloseAuditModal();
      // 重新加载数据
      loadData();
    } catch (err) {
      console.error('审核失败:', err);
      message.error('审核失败,请重试');
    } finally {
      setAuditLoading(false);
    }
  };
  // 退回补充
  const handleAuditReturn = async () => {
    if (!currentAuditItem) return;
    if (!auditRemark.trim()) {
      message.warning('请填写退回意见');
      return;
    }
    const params = getMergedParams();
    const caseId = params.caseId;
    setAuditLoading(true);
    try {
      // 获取文件ID列表
      const fileIdList = evidenceImages.map(img => img.file_id || img.id).filter(Boolean);
      await EvidenceAPIService.auditEvidence({
        case_id: caseId,
        evidence_type: currentAuditItem.evidence_type,
        person_id: currentAuditItem.person_id,
        file_id_list: fileIdList,
        audit_user: '当前用户', // TODO: 从登录信息获取
        audit_state: 2,
        audit_remark: auditRemark.trim()
      });
      message.success('材料已退回,等待补充提交');
      setReturnModalVisible(false);
      handleCloseAuditModal();
      loadData();
    } catch (err) {
      console.error('退回失败:', err);
      message.error('退回失败,请重试');
    } finally {
      setAuditLoading(false);
    }
  };
  // 打开退回弹窗
  const handleOpenReturnModal = () => {
    setAuditRemark('');
    setReturnModalVisible(true);
  };
  // 关闭退回弹窗
  const handleCloseReturnModal = () => {
    setReturnModalVisible(false);
    setAuditRemark('');
  };
  const renderEvidenceItem = (item, type) => (
    <div key={item.name} className="evidence-item" style={{
@@ -451,27 +759,52 @@
      justifyContent: 'space-between',
      alignItems: 'flex-start',
      marginBottom: 10,
      position: 'relative',
    }}>
      <div style={{ flex: 1 }}>
      <div style={{ flex: 1, paddingRight: 70 }}>
        <div style={{ fontWeight: 600, fontSize: '0.9rem', marginBottom: 4, color: 'var(--dark-color)' }}>{item.name}</div>
        <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', lineHeight: 1.4 }}>{item.desc}</div>
        <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', lineHeight: 1.4 }}>{item.result}</div>
        <div style={{ fontSize: '0.75rem', color: 'var(--gray-color)', marginTop: 4, display: 'flex', alignItems: 'center', gap: 5 }}>
          <i className="far fa-clock"></i>
          <span>上传时间:{item.time}</span>
          <span>上传时间:{item.update_time || item.create_time}</span>
        </div>
        {/* 待审核时显示审核按钮 */}
        {item.audit_state !== 1 && (
          <button
            onClick={() => handleOpenAuditModal(item, type)}
            style={{
              marginTop: 8,
              padding: '4px 12px',
              fontSize: '0.75rem',
              background: '#1A6FB8',
              color: 'white',
              border: 'none',
              borderRadius: 4,
              cursor: 'pointer',
              fontWeight: 600,
              transition: 'all 0.2s ease',
            }}
            onMouseOver={(e) => {
              e.target.style.background = '#0d4a8a';
              e.target.style.transform = 'translateY(-1px)';
            }}
            onMouseOut={(e) => {
              e.target.style.background = '#1A6FB8';
              e.target.style.transform = 'translateY(0)';
            }}
          >
            审核
          </button>
        )}
      </div>
      <div style={{ marginLeft: 10 }}>
        <span style={{
          fontSize: '0.75rem',
          padding: '3px 8px',
          borderRadius: 12,
          fontWeight: 600,
          background: item.status === 'verified' ? '#d4edda' : '#fff3cd',
          color: item.status === 'verified' ? '#155724' : '#856404',
          border: `1px solid ${item.status === 'verified' ? '#c3e6cb' : '#ffeaa7'}`,
        }}>
          {item.status === 'verified' ? '已审核' : '待审核'}
        </span>
      {/* 右上角审核状态标签 */}
      <div style={{
        position: 'absolute',
        top: 12,
        right: 12,
        ...getAuditStatusStyle(item.audit_state)
      }}>
        {item.audit_state === 1 ? '已审核' : '待审核'}
      </div>
    </div>
  );
@@ -491,10 +824,15 @@
            <i className="fas fa-user-tie" style={{ color: 'var(--primary-color)', marginRight: 8 }}></i>
            申请人材料
          </div>
          <div style={{ fontSize: '0.85rem', color: 'var(--gray-color)', marginLeft: 'auto' }}>{applicantEvidence.length}份材料</div>
          <Tag color={applicantStatus.color}>{applicantStatus.text}</Tag>
          <div style={{ fontSize: '0.85rem', color: 'var(--gray-color)', marginLeft: 'auto' }}>{applicantMaterials.length}份材料</div>
        </div>
        <div>
          {applicantEvidence.map((item) => renderEvidenceItem(item, 'applicant'))}
        <div style={{ maxHeight: 400, overflowY: 'auto' }}>
          {applicantMaterials.length > 0 ? (
            applicantMaterials.map((item) => renderEvidenceItem(item, 'applicant'))
          ) : (
            <div style={{ textAlign: 'center', padding: 40, color: 'var(--gray-color)' }}>暂无材料</div>
          )}
        </div>
      </div>
@@ -511,12 +849,395 @@
            <i className="fas fa-building" style={{ color: '#e9c46a', marginRight: 8 }}></i>
            被申请人材料
          </div>
          <div style={{ fontSize: '0.85rem', color: 'var(--gray-color)', marginLeft: 'auto' }}>{respondentEvidence.length}份材料</div>
          <Tag color={respondentStatus.color}>{respondentStatus.text}</Tag>
          <div style={{ fontSize: '0.85rem', color: 'var(--gray-color)', marginLeft: 'auto' }}>{respondentMaterials.length}份材料</div>
        </div>
        <div>
          {respondentEvidence.map((item) => renderEvidenceItem(item, 'respondent'))}
        <div style={{ maxHeight: 400, overflowY: 'auto' }}>
          {respondentMaterials.length > 0 ? (
            respondentMaterials.map((item) => renderEvidenceItem(item, 'respondent'))
          ) : (
            <div style={{ textAlign: 'center', padding: 40, color: 'var(--gray-color)' }}>暂无材料</div>
          )}
        </div>
      </div>
      {/* 证据材料审查弹窗 */}
      <Modal
        title={null}
        visible={auditModalVisible}
        onCancel={handleCloseAuditModal}
        footer={null}
        width={900}
        centered
        destroyOnClose
        bodyStyle={{ padding: 0 }}
        className="doc-audit-modal"
      >
        {currentAuditItem && (
          <div style={{ background: '#f5f7fa' }}>
            {/* 头部 - 蓝色渐变 */}
            <div style={{
              background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)',
              color: 'white',
              padding: '20px 30px',
            }}>
              <div style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>证据材料审查</div>
              <div style={{ fontSize: 14, opacity: 0.9 }}>
                {currentAuditItem.personType === 'applicant' ? '申请人' : '被申请人'}提交的证据材料
              </div>
            </div>
            {/* 主体内容 */}
            <div style={{ padding: '20px 30px', background: 'white', margin: 0 }}>
              {/* 审核说明 tip-box */}
              <div style={{
                background: '#f0f8ff',
                borderRadius: 8,
                padding: 16,
                marginBottom: 24,
                border: '1px solid #d9eafb',
              }}>
                <div style={{ color: '#1A6FB8', marginBottom: 8, fontSize: 15, fontWeight: 600 }}>审核说明</div>
                <div style={{ color: '#555', fontSize: 14 }}>
                  请仔细核对申请人提交的材料,确保材料真实、完整、有效。如有疑问或需要补充材料,请使用"退回补充"功能。
                </div>
              </div>
              {/* 材料基本信息 */}
              <div style={{ marginBottom: 24 }}>
                <div style={{
                  color: '#1A6FB8',
                  fontSize: 16,
                  fontWeight: 600,
                  marginBottom: 16,
                  display: 'flex',
                  alignItems: 'center',
                }}>
                  <span style={{
                    display: 'inline-block',
                    width: 4,
                    height: 16,
                    background: '#1A6FB8',
                    marginRight: 10,
                    borderRadius: 2,
                  }}></span>
                  材料基本信息
                </div>
                {modalDataLoading ? (
                  <div style={{ textAlign: 'center', padding: 20 }}>
                    <Spin tip="加载中..." />
                  </div>
                ) : (
                  <div style={{
                    display: 'grid',
                    gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
                    gap: 16,
                  }}>
                    <div style={{ display: 'flex', marginBottom: 12 }}>
                      <span style={{ fontWeight: 500, color: '#666', minWidth: 80 }}>提交人:</span>
                      <span style={{ color: '#222', fontWeight: 500 }}>
                        {personInfo
                          ? `${personInfo.per_class_name || (currentAuditItem.personType === 'applicant' ? '申请人' : '被申请人')}-${personInfo.true_name || ''}`
                          : (currentAuditItem.personType === 'applicant' ? '申请方' : '被申请方')}
                      </span>
                    </div>
                    <div style={{ display: 'flex', marginBottom: 12 }}>
                      <span style={{ fontWeight: 500, color: '#666', minWidth: 80 }}>材料类型:</span>
                      <span style={{ color: '#222', fontWeight: 500 }}>
                        {currentAuditItem.evidence_type_name || currentAuditItem.name || '证据材料'}
                      </span>
                    </div>
                    <div style={{ display: 'flex', marginBottom: 12 }}>
                      <span style={{ fontWeight: 500, color: '#666', minWidth: 80 }}>材料数量:</span>
                      <span style={{ color: '#222', fontWeight: 500 }}>
                        {personInfo?.total_count ? `${personInfo.total_count}份` : `${evidenceImages.length || 1}份`}
                      </span>
                    </div>
                    <div style={{ display: 'flex', marginBottom: 12 }}>
                      <span style={{ fontWeight: 500, color: '#666', minWidth: 80 }}>提交时间:</span>
                      <span style={{ color: '#222', fontWeight: 500 }}>
                        {personInfo?.submit_time || currentAuditItem.update_time || currentAuditItem.create_time}
                      </span>
                    </div>
                  </div>
                )}
              </div>
              {/* 材料说明 */}
              <div style={{ marginBottom: 24 }}>
                <div style={{
                  color: '#1A6FB8',
                  fontSize: 16,
                  fontWeight: 600,
                  marginBottom: 16,
                  display: 'flex',
                  alignItems: 'center',
                }}>
                  <span style={{
                    display: 'inline-block',
                    width: 4,
                    height: 16,
                    background: '#1A6FB8',
                    marginRight: 10,
                    borderRadius: 2,
                  }}></span>
                  材料说明
                </div>
                <div style={{
                  border: '1px solid #e8e8e8',
                  borderRadius: 6,
                  padding: 16,
                  background: '#fafafa',
                  minHeight: 100,
                  fontSize: 14,
                  lineHeight: 1.7,
                }}>
                  <p style={{ marginBottom: 8 }}>
                    <strong>{currentAuditItem.name}</strong>
                  </p>
                  <p style={{ margin: 0, color: '#555' }}>
                    {currentAuditItem.result || '暂无详细说明'}
                  </p>
                </div>
              </div>
              {/* 材料清单 */}
              <div style={{ marginBottom: 24 }}>
                <div style={{
                  color: '#1A6FB8',
                  fontSize: 16,
                  fontWeight: 600,
                  marginBottom: 16,
                  display: 'flex',
                  alignItems: 'center',
                }}>
                  <span style={{
                    display: 'inline-block',
                    width: 4,
                    height: 16,
                    background: '#1A6FB8',
                    marginRight: 10,
                    borderRadius: 2,
                  }}></span>
                  材料清单
                </div>
                <table style={{
                  width: '100%',
                  borderCollapse: 'collapse',
                  borderRadius: 6,
                  overflow: 'hidden',
                  boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.03)',
                  border: '1px solid #f0f0f0',
                }}>
                  <thead>
                    <tr>
                      <th style={{
                        background: '#fafafa',
                        color: '#333',
                        fontWeight: 600,
                        textAlign: 'left',
                        padding: 16,
                        borderBottom: '1px solid #f0f0f0',
                        fontSize: 14,
                        width: '40%',
                      }}>材料信息</th>
                      <th style={{
                        background: '#fafafa',
                        color: '#333',
                        fontWeight: 600,
                        textAlign: 'left',
                        padding: 16,
                        borderBottom: '1px solid #f0f0f0',
                        fontSize: 14,
                        width: '60%',
                      }}>材料预览</th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr>
                      <td style={{ padding: 16, borderBottom: '1px solid #f5f5f5', verticalAlign: 'top', fontSize: 14 }}>
                        <div style={{ fontWeight: 600, color: '#333', marginBottom: 6 }}>{currentAuditItem.name}</div>
                        <div style={{ fontSize: 13, color: '#666', marginBottom: 4 }}>
                          材料类型:{currentAuditItem.evidence_type_name || '证据材料'}
                        </div>
                        <div style={{ fontSize: 13, color: '#666', marginBottom: 4 }}>
                          文件格式:{personInfo?.suffix || 'PDF/图片'}
                        </div>
                        <div style={{ fontSize: 13, color: '#666', marginBottom: 4 }}>
                          上传时间:{personInfo?.submit_time || currentAuditItem.update_time || currentAuditItem.create_time}
                        </div>
                        {personInfo?.total_file_size && (
                          <div style={{ fontSize: 13, color: '#666', marginBottom: 4 }}>
                            文件大小:{personInfo.total_file_size}MB
                          </div>
                        )}
                      </td>
                      <td style={{ padding: 16, borderBottom: '1px solid #f5f5f5', verticalAlign: 'top', fontSize: 14 }}>
                        {modalDataLoading ? (
                          <div style={{ textAlign: 'center', padding: 20 }}>
                            <Spin size="small" />
                          </div>
                        ) : evidenceImages.length > 0 ? (
                          <>
                            <Image.PreviewGroup>
                              <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
                                {evidenceImages.map((img, index) => {
                                  const imgUrl = img.show_url
                                    ? (platformUrl ? `${platformUrl}/${img.show_url}` : img.show_url)
                                    : '';
                                  return (
                                    <div
                                      key={img.file_id || img.id || index}
                                      style={{
                                        width: 100,
                                        height: 80,
                                        borderRadius: 4,
                                        overflow: 'hidden',
                                        border: '1px solid #e8e8e8',
                                        cursor: 'pointer',
                                        transition: 'all 0.2s',
                                      }}
                                    >
                                      <Image
                                        src={imgUrl}
                                        alt={img.file_name || `材料${index + 1}`}
                                        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
                                        fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iODAiIGZpbGw9IiNmNWY1ZjUiLz48dGV4dCB4PSI1MCIgeT0iNDAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzk5OSI+5Zu+54mH6aKE6KeIPC90ZXh0Pjwvc3ZnPg=="
                                        preview={{
                                          mask: <span style={{ fontSize: 12 }}>预览</span>
                                        }}
                                      />
                                    </div>
                                  );
                                })}
                              </div>
                            </Image.PreviewGroup>
                            <div style={{ fontSize: 13, color: '#666', marginTop: 8 }}>共{evidenceImages.length}个文件</div>
                          </>
                        ) : (
                          <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
                            <div style={{
                              width: 100,
                              height: 80,
                              borderRadius: 4,
                              overflow: 'hidden',
                              border: '1px solid #e8e8e8',
                              background: 'linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%)',
                              display: 'flex',
                              alignItems: 'center',
                              justifyContent: 'center',
                              color: '#999',
                              fontSize: 13,
                              fontWeight: 500,
                            }}>
                              暂无图片
                            </div>
                          </div>
                        )}
                      </td>
                    </tr>
                  </tbody>
                </table>
              </div>
              {/* 操作按钮 */}
              <div style={{
                display: 'flex',
                justifyContent: 'center',
                gap: 16,
                marginTop: 32,
                paddingTop: 24,
                borderTop: '1px solid #f0f0f0',
              }}>
                <Button
                  type="primary"
                  size="large"
                  loading={auditLoading}
                  onClick={handleAuditPass}
                  style={{
                    background: '#1A6FB8',
                    borderColor: '#1A6FB8',
                    minWidth: 120,
                    height: 40,
                    fontWeight: 500,
                    boxShadow: '0 2px 0 rgba(0, 0, 0, 0.045)',
                  }}
                >
                  审核通过
                </Button>
                <Button
                  size="large"
                  loading={auditLoading}
                  onClick={handleOpenReturnModal}
                  style={{
                    background: 'white',
                    color: '#1A6FB8',
                    borderColor: '#1A6FB8',
                    minWidth: 120,
                    height: 40,
                    fontWeight: 500,
                    boxShadow: '0 2px 0 rgba(0, 0, 0, 0.015)',
                  }}
                >
                  退回补充
                </Button>
              </div>
            </div>
          </div>
        )}
      </Modal>
      {/* 退回补充子弹窗 */}
      <Modal
        title={
          <div style={{ background: '#1A6FB8', margin: '-20px -24px', padding: '16px 24px', color: 'white' }}>
            <span style={{ fontSize: 16, fontWeight: 600 }}>退回补充材料</span>
          </div>
        }
        visible={returnModalVisible}
        onCancel={handleCloseReturnModal}
        footer={null}
        width={480}
        centered
        destroyOnClose
        bodyStyle={{ paddingTop: 24 }}
      >
        <div style={{ marginBottom: 20 }}>
          <label style={{ display: 'block', fontWeight: 500, color: '#333', marginBottom: 8, fontSize: 14 }}>
            退回意见:
          </label>
          <TextArea
            value={auditRemark}
            onChange={(e) => setAuditRemark(e.target.value)}
            placeholder="请详细说明需要补充的材料内容及原因..."
            rows={5}
            style={{
              borderRadius: 4,
              resize: 'vertical',
            }}
          />
        </div>
        <div style={{
          background: '#fafafa',
          margin: '0 -24px -24px',
          padding: '16px 24px',
          display: 'flex',
          justifyContent: 'flex-end',
          gap: 12,
        }}>
          <Button onClick={handleCloseReturnModal} style={{ padding: '6px 16px' }}>
            取消
          </Button>
          <Button
            type="primary"
            loading={auditLoading}
            onClick={handleAuditReturn}
            style={{ background: '#1A6FB8', borderColor: '#1A6FB8', padding: '6px 16px' }}
          >
            确认退回
          </Button>
        </div>
      </Modal>
    </div>
  );
};
@@ -525,6 +1246,193 @@
 * 调解协议
 */
const AgreementSection = () => {
  const { caseData } = useCaseData();
  const [agreementContent, setAgreementContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [editModalVisible, setEditModalVisible] = useState(false);
  const [editContent, setEditContent] = useState('');
  const [editLoading, setEditLoading] = useState(false);
  const [actionLoading, setActionLoading] = useState({
    confirm: false,
    download: false,
    regenerate: false,
  });
  const loadedRef = useRef(false);
  // 获取 caseId
  const caseId = caseData?.caseId || getMergedParams().caseId;
  // 处理协议内容展示(纯文本,处理换行)
  const renderAgreementContent = (content) => {
    if (!content) return null;
    // 处理 \n 换行,简单过滤 Markdown 符号
    const lines = content.split('\n');
    return lines.map((line, index) => {
      // 简单过滤 ** 符号,保留文本
      const cleanLine = line.replace(/\*\*/g, '');
      if (!cleanLine.trim()) {
        return <br key={index} />;
      }
      return (
        <p key={index} style={{ margin: '8px 0', lineHeight: 1.6 }}>
          {cleanLine}
        </p>
      );
    });
  };
  // 首次加载协议内容
  const loadAgreement = async () => {
    if (!caseId) {
      setError('缺少案件ID,无法加载协议');
      return;
    }
    setLoading(true);
    setError(null);
    try {
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
      } else {
        setError('协议内容为空');
      }
    } catch (err) {
      console.error('加载协议失败:', err);
      setError('加载协议失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };
  // 组件挂载时加载协议
  useEffect(() => {
    if (caseId && !loadedRef.current) {
      loadedRef.current = true;
      loadAgreement();
    }
  }, [caseId]); // eslint-disable-line react-hooks/exhaustive-deps
  // 确认协议
  const handleConfirmAgreement = async () => {
    if (!caseId) return;
    setActionLoading(prev => ({ ...prev, confirm: true }));
    try {
      await MediationAgreementAPIService.confirmAgreement(caseId, 'mediator');
      message.success('协议确认成功!');
    } catch (err) {
      console.error('确认协议失败:', err);
      message.error('确认协议失败,请稍后重试');
    } finally {
      setActionLoading(prev => ({ ...prev, confirm: false }));
    }
  };
  // 下载协议
  const handleDownloadAgreement = async () => {
    if (!caseId) return;
    setActionLoading(prev => ({ ...prev, download: true }));
    try {
      await MediationAgreementAPIService.downloadAgreement(caseId);
      message.success('协议下载成功!');
    } catch (err) {
      console.error('下载协议失败:', err);
      message.error('下载协议失败,请稍后重试');
    } finally {
      setActionLoading(prev => ({ ...prev, download: false }));
    }
  };
  // 重新生成协议
  const handleRegenerateAgreement = async () => {
    if (!caseId) return;
    setActionLoading(prev => ({ ...prev, regenerate: true }));
    try {
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
        message.success('协议重新生成成功!');
      }
    } catch (err) {
      console.error('重新生成协议失败:', err);
      message.error('重新生成协议失败,请稍后重试');
    } finally {
      setActionLoading(prev => ({ ...prev, regenerate: false }));
    }
  };
  // 打开修改弹窗
  const handleOpenEditModal = async () => {
    if (!caseId) return;
    setEditModalVisible(true);
    setEditLoading(true);
    try {
      const response = await MediationAgreementAPIService.getAgreementDetail(caseId);
      if (response?.data?.agreeContent) {
        setEditContent(response.data.agreeContent);
      }
    } catch (err) {
      console.error('获取协议内容失败:', err);
      message.error('获取协议内容失败');
    } finally {
      setEditLoading(false);
    }
  };
  // 关闭修改弹窗
  const handleCloseEditModal = () => {
    setEditModalVisible(false);
    setEditContent('');
  };
  // 保存修改
  const handleSaveEdit = async () => {
    if (!caseId || !editContent.trim()) {
      message.warning('协议内容不能为空');
      return;
    }
    setEditLoading(true);
    try {
      await MediationAgreementAPIService.updateAgreement(caseId, editContent);
      message.success('协议修改保存成功!');
      handleCloseEditModal();
      // 刷新父页面协议内容
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
      }
    } catch (err) {
      console.error('保存协议失败:', err);
      message.error('保存协议失败,请稍后重试');
    } finally {
      setEditLoading(false);
    }
  };
  // Loading 状态
  if (loading) {
    return (
      <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 300 }}>
        <Spin size="large" tip="正在生成调解协议..." />
      </div>
    );
  }
  // 错误状态
  if (error) {
    return (
      <div style={{ textAlign: 'center', padding: 40, color: '#ff4d4f' }}>
        <div style={{ fontSize: '1.2rem', marginBottom: 10 }}>
          <i className="fas fa-exclamation-circle"></i> {error}
        </div>
        <button onClick={loadAgreement} style={{
          marginTop: 15, padding: '8px 16px', backgroundColor: '#1890ff',
          color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer'
        }}>重新加载</button>
      </div>
    );
  }
  return (
    <div style={{
      background: '#f8f9fa',
@@ -533,56 +1441,15 @@
      maxHeight: 450,
      overflowY: 'auto',
    }}>
      {/* 协议内容展示区域 */}
      <div style={{ lineHeight: 1.5, fontSize: '0.9rem' }}>
        <div style={{ textAlign: 'center', color: 'var(--secondary-color)', fontSize: '1.2rem', marginBottom: 20, fontWeight: 700 }}>
          李晓明与广东好又多贸易有限公司劳动争议调解协议书
        </div>
        <p><strong>甲方(申请人):</strong>李晓明</p>
        <p><strong>乙方(被申请人):</strong>广东好又多贸易有限公司</p>
        <p style={{ textIndent: '2em', marginTop: 15 }}>
          甲方与乙方因劳动报酬支付问题发生争议,双方于2026年1月15日向"云小调"劳动争议AI调解智能体申请调解。经AI调解员调解,双方本着平等自愿、互谅互让的原则,达成如下调解协议:
        </p>
        <h3 style={{ color: 'var(--secondary-color)', margin: '15px 0 8px', fontSize: '1rem' }}>一、争议事项</h3>
        <p style={{ textIndent: '2em' }}>甲方主张乙方拖欠2026年1月至3月共三个月工资、2026年第一季度绩效奖金以及解除劳动合同经济补偿金,合计人民币52,800元。</p>
        <h3 style={{ color: 'var(--secondary-color)', margin: '15px 0 8px', fontSize: '1rem' }}>二、调解结果</h3>
        <p style={{ textIndent: '2em' }}>经调解,双方达成一致意见如下:</p>
        <ul style={{ paddingLeft: 30, marginBottom: 12 }}>
          <li style={{ marginBottom: 6 }}>乙方同意向甲方支付劳动报酬共计人民币肆万肆仟元整(¥44,000)</li>
          <li style={{ marginBottom: 6 }}>支付方式:分两期支付
            <ul style={{ paddingLeft: 20 }}>
              <li>第一期:人民币贰万贰仟元整(¥22,000),于2026年1月30日前支付</li>
              <li>第二期:人民币贰万贰仟元整(¥22,000),于2026年2月28日前支付</li>
            </ul>
          </li>
          <li style={{ marginBottom: 6 }}>支付账户:甲方指定的银行账户(账户信息另行提供)</li>
        </ul>
        <h3 style={{ color: 'var(--secondary-color)', margin: '15px 0 8px', fontSize: '1rem' }}>三、双方权利义务</h3>
        <ul style={{ paddingLeft: 30, marginBottom: 12 }}>
          <li style={{ marginBottom: 6 }}>乙方应按约定时间足额支付上述款项</li>
          <li style={{ marginBottom: 6 }}>甲方收到全部款项后,双方劳动关系正式解除</li>
          <li style={{ marginBottom: 6 }}>双方互不追究其他法律责任,本协议履行完毕后,争议事项一次性了结</li>
        </ul>
        {/* 签名区域 */}
        <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 20, paddingTop: 15, borderTop: '1px solid var(--border-color)' }}>
          <div style={{ textAlign: 'center', width: '45%' }}>
            <div style={{ borderBottom: '1px solid var(--dark-color)', padding: '15px 0 5px', marginBottom: 5 }}></div>
            <div>甲方(申请人):李晓明</div>
            <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)' }}>签字/盖章</div>
            <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', marginTop: 5 }}>日期:2026年1月15日</div>
        {agreementContent ? (
          renderAgreementContent(agreementContent)
        ) : (
          <div style={{ textAlign: 'center', color: 'var(--gray-color)', padding: 40 }}>
            暂无协议内容
          </div>
          <div style={{ textAlign: 'center', width: '45%' }}>
            <div style={{ borderBottom: '1px solid var(--dark-color)', padding: '15px 0 5px', marginBottom: 5 }}></div>
            <div>乙方(被申请人):广东好又多贸易有限公司</div>
            <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)' }}>签字/盖章</div>
            <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', marginTop: 5 }}>日期:2026年1月15日</div>
          </div>
        </div>
        )}
      </div>
      {/* 操作按钮 */}
@@ -597,71 +1464,176 @@
        justifyContent: 'center',
        gap: 12,
      }}>
        <button style={{
          padding: '10px 20px',
          border: '2px solid #c3e6cb',
          borderRadius: 'var(--border-radius)',
          fontWeight: 600,
          fontSize: '0.9rem',
          cursor: 'pointer',
          display: 'flex',
          alignItems: 'center',
          gap: 6,
          background: '#d4edda',
          color: '#155724',
        }}>
          <i className="fas fa-check-circle"></i>
        <button
          onClick={handleConfirmAgreement}
          disabled={actionLoading.confirm}
          style={{
            padding: '10px 20px',
            border: '2px solid #c3e6cb',
            borderRadius: 'var(--border-radius)',
            fontWeight: 600,
            fontSize: '0.9rem',
            cursor: actionLoading.confirm ? 'not-allowed' : 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: 6,
            background: '#d4edda',
            color: '#155724',
            opacity: actionLoading.confirm ? 0.6 : 1,
          }}>
          <i className={actionLoading.confirm ? "fas fa-spinner fa-spin" : "fas fa-check-circle"}></i>
          确认协议
        </button>
        <button style={{
          padding: '10px 20px',
          border: '2px solid #ffeaa7',
          borderRadius: 'var(--border-radius)',
          fontWeight: 600,
          fontSize: '0.9rem',
          cursor: 'pointer',
          display: 'flex',
          alignItems: 'center',
          gap: 6,
          background: '#fff3cd',
          color: '#856404',
        }}>
        <button
          onClick={handleOpenEditModal}
          style={{
            padding: '10px 20px',
            border: '2px solid #ffeaa7',
            borderRadius: 'var(--border-radius)',
            fontWeight: 600,
            fontSize: '0.9rem',
            cursor: 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: 6,
            background: '#fff3cd',
            color: '#856404',
          }}>
          <i className="fas fa-edit"></i>
          修改协议
        </button>
        <button style={{
          padding: '10px 20px',
          border: '2px solid #bbdefb',
          borderRadius: 'var(--border-radius)',
          fontWeight: 600,
          fontSize: '0.9rem',
          cursor: 'pointer',
          display: 'flex',
          alignItems: 'center',
          gap: 6,
          background: '#e3f2fd',
          color: '#1565c0',
        }}>
          <i className="fas fa-download"></i>
        <button
          onClick={handleDownloadAgreement}
          disabled={actionLoading.download}
          style={{
            padding: '10px 20px',
            border: '2px solid #bbdefb',
            borderRadius: 'var(--border-radius)',
            fontWeight: 600,
            fontSize: '0.9rem',
            cursor: actionLoading.download ? 'not-allowed' : 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: 6,
            background: '#e3f2fd',
            color: '#1565c0',
            opacity: actionLoading.download ? 0.6 : 1,
          }}>
          <i className={actionLoading.download ? "fas fa-spinner fa-spin" : "fas fa-download"}></i>
          下载协议
        </button>
        <button style={{
          padding: '10px 20px',
          border: '2px solid #bee5eb',
          borderRadius: 'var(--border-radius)',
          fontWeight: 600,
          fontSize: '0.9rem',
          cursor: 'pointer',
          display: 'flex',
          alignItems: 'center',
          gap: 6,
          background: '#d1ecf1',
          color: '#0c5460',
        }}>
          <i className="fas fa-redo"></i>
        <button
          onClick={handleRegenerateAgreement}
          disabled={actionLoading.regenerate}
          style={{
            padding: '10px 20px',
            border: '2px solid #bee5eb',
            borderRadius: 'var(--border-radius)',
            fontWeight: 600,
            fontSize: '0.9rem',
            cursor: actionLoading.regenerate ? 'not-allowed' : 'pointer',
            display: 'flex',
            alignItems: 'center',
            gap: 6,
            background: '#d1ecf1',
            color: '#0c5460',
            opacity: actionLoading.regenerate ? 0.6 : 1,
          }}>
          <i className={actionLoading.regenerate ? "fas fa-spinner fa-spin" : "fas fa-redo"}></i>
          重新生成
        </button>
      </div>
      {/* 修改调解协议书弹窗 */}
      <Modal
        title={null}
        visible={editModalVisible}
        onCancel={handleCloseEditModal}
        footer={null}
        width={1000}
        centered
        destroyOnClose
        bodyStyle={{ padding: 0 }}
      >
        <div style={{ background: '#f5f7fa' }}>
          {/* 头部 */}
          <div style={{
            background: '#1A6FB8',
            color: 'white',
            padding: '25px 30px',
            borderBottom: '5px solid #0d4a8a',
          }}>
            <div style={{ fontSize: 24, fontWeight: 600, marginBottom: 10 }}>在线修改调解协议书</div>
            <div style={{ fontSize: 16, opacity: 0.9 }}>编辑协议内容</div>
          </div>
          {/* 主体内容 */}
          <div style={{ padding: '25px 30px', background: 'white' }}>
            {/* 编辑提示 */}
            <div style={{
              fontSize: 14,
              color: '#666',
              marginBottom: 15,
              padding: 10,
              background: '#f0f8ff',
              borderRadius: 5,
              borderLeft: '3px solid #1A6FB8',
            }}>
              <strong>编辑提示:</strong>下方文本框中的协议内容可直接编辑修改。编辑完成后,请点击底部的"保存修改"按钮。
            </div>
            {/* 协议编辑区域 */}
            <div style={{ marginBottom: 25 }}>
              {editLoading ? (
                <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
                  <Spin size="large" tip="加载协议内容..." />
                </div>
              ) : (
                <TextArea
                  value={editContent}
                  onChange={(e) => setEditContent(e.target.value)}
                  style={{
                    border: '1px solid #d0e3ff',
                    borderRadius: 8,
                    minHeight: 500,
                    padding: 20,
                    fontSize: 16,
                    lineHeight: 1.8,
                    background: '#f9fafc',
                  }}
                  placeholder="请输入协议内容..."
                />
              )}
            </div>
            {/* 操作按钮 */}
            <div style={{
              display: 'flex',
              justifyContent: 'flex-end',
              marginTop: 30,
              paddingTop: 20,
              borderTop: '1px solid #eaeaea',
            }}>
              <Button
                type="primary"
                size="large"
                loading={editLoading}
                onClick={handleSaveEdit}
                style={{
                  padding: '12px 36px',
                  height: 'auto',
                  fontWeight: 600,
                  fontSize: 16,
                  background: '#1A6FB8',
                  borderColor: '#1A6FB8',
                }}
              >
                保存修改
              </Button>
            </div>
          </div>
        </div>
      </Modal>
    </div>
  );
};
web-app/src/components/tools/CaseSearchContent.jsx
@@ -1,69 +1,236 @@
import React, { useState } from 'react';
import { Input, DatePicker, Button, Spin, Pagination, Select, Modal } from 'antd';
import React, { useState, useEffect } from 'react';
import { Input, Button, Spin, Pagination, Select, Modal, Radio, message } from 'antd';
import { SearchOutlined, RedoOutlined } from '@ant-design/icons';
import { mockCaseList } from '../../mocks/caseMocks';
import CaseAPIService from '../../services/CaseAPIService';
import TypicalCaseDetailContent from './TypicalCaseDetailContent';
import './TypicalCaseSearch.css';
const { RangePicker } = DatePicker;
/**
 * 典型案例查询内容组件 - 与原型 case_search.html 保持一致
 */
const CaseSearchContent = () => {
  const [loading, setLoading] = useState(false);
  const [list, setList] = useState(mockCaseList.list);
  const [list, setList] = useState([]);
  const [total, setTotal] = useState(0);
  const [keyword, setKeyword] = useState('');
  const [caseType, setCaseType] = useState('judgment'); // 'judgment' | 'mediation'
  const [disputeType, setDisputeType] = useState(undefined);
  const [disputeTypeOptions, setDisputeTypeOptions] = useState([]);
  const [detailVisible, setDetailVisible] = useState(false);
  const [selectedCase, setSelectedCase] = useState(null);
  const [selectedCaseType, setSelectedCaseType] = useState('judgment'); // 用于详情弹窗
  const [currentPage, setCurrentPage] = useState(1);
  const [pageSize] = useState(10);
  // 模拟筛选器状态
  const [caseTypeFilters, setCaseTypeFilters] = useState([
    { label: '判决文书', count: 1221120, checked: true },
    { label: '调解案例', count: 332526, checked: false },
  ]);
  // 筛选器状态 - 发生时间
  const [yearFilters, setYearFilters] = useState([]);
  const [yearFilters, setYearFilters] = useState([
    { label: '2021年', count: 1221120, checked: false },
    { label: '2022年', count: 332526, checked: false },
    { label: '2023年', count: 62221, checked: true },
    { label: '2024年', count: 32212, checked: false },
  ]);
  // 筛选器状态 - 纠纷发生地
  const [regionFilters, setRegionFilters] = useState([]);
  const [regionFilters, setRegionFilters] = useState([
    { label: '全部', count: 462100, checked: true },
    { label: '广东省', count: 62201, checked: false, isSub: true, children: [
      { label: '广州市', count: 10221, checked: false },
      { label: '深圳市', count: 20001, checked: false },
      { label: '中山市', count: 9632, checked: false },
    ]},
    { label: '广西省', count: 44552, checked: false, isSub: true },
    { label: '湖南省', count: 83001, checked: false, isSub: true },
    { label: '湖北省', count: 98745, checked: false, isSub: true },
    { label: '浙江省', count: 30021, checked: false, isSub: true },
  ]);
  // 日期格式化函数
  const formatDate = (dateStr) => {
    if (!dateStr) return '';
    const date = new Date(dateStr);
    if (isNaN(date.getTime())) return dateStr;
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    return `${year}年${month}月${day}日`;
  };
  // 加载纠纷类型下拉框数据
  const loadDisputeTypes = async (caseSource) => {
    try {
      const res = await CaseAPIService.getDisputeTypes(caseSource);
      if (res && res.data && Array.isArray(res.data)) {
        const options = res.data.map(item => ({
          label: item.dispute_type,
          value: item.dispute_type
        }));
        setDisputeTypeOptions(options);
      } else {
        setDisputeTypeOptions([]);
      }
    } catch (error) {
      console.error('加载纠纷类型失败:', error);
      setDisputeTypeOptions([]);
    }
  };
  // 加载发生时间统计数据
  const loadYearStatistics = async () => {
    try {
      const api = caseType === 'judgment'
        ? CaseAPIService.getYearStatistics
        : CaseAPIService.getMediationYearStatistics;
      const res = await api();
      if (res && res.data && Array.isArray(res.data)) {
        const filters = res.data.map(item => ({
          label: `${item.name}年`,
          count: item.count || 0,
          checked: false,
          value: item.name
        }));
        setYearFilters(filters);
      } else {
        setYearFilters([]);
      }
    } catch (error) {
      console.error('加载年份统计失败:', error);
      setYearFilters([]);
    }
  };
  // 加载纠纷发生地统计数据
  const loadAreaStatistics = async (caseSource) => {
    try {
      const res = await CaseAPIService.getAreaStatistics(caseSource);
      if (res && res.data && Array.isArray(res.data)) {
        const filters = res.data.map((item, index) => ({
          label: item.name || item.region,
          count: item.count || 0,
          checked: index === 0,
          value: item.name || item.region,
          isSub: item.level > 1,
          children: item.children && Array.isArray(item.children) ? item.children.map(child => ({
            label: child.name || child.region,
            count: child.count || 0,
            checked: false,
            value: child.name || child.region
          })) : undefined
        }));
        setRegionFilters(filters);
      } else {
        setRegionFilters([]);
      }
    } catch (error) {
      console.error('加载地区统计失败:', error);
      setRegionFilters([]);
    }
  };
  // 加载案例列表数据
  const loadCaseList = async (page = currentPage) => {
    setLoading(true);
    try {
      // 获取选中的年份
      const selectedYears = Array.isArray(yearFilters)
        ? yearFilters.filter(f => f.checked).map(f => f.value).join(',')
        : '';
      // 获取选中的地区
      const selectedRegions = [];
      if (Array.isArray(regionFilters)) {
        regionFilters.forEach(region => {
          if (region.checked && region.label !== '全部') {
            selectedRegions.push(region.value);
          }
          if (region.children && Array.isArray(region.children)) {
            region.children.forEach(child => {
              if (child.checked) {
                selectedRegions.push(child.value);
              }
            });
          }
        });
      }
      const params = {
        page,
        size: pageSize,
        keyword: keyword || undefined,
        caseTypeFirst: disputeType || undefined,
        occurrenceYears: selectedYears || undefined,
        regionList: selectedRegions.join(',') || undefined
      };
      const api = caseType === 'mediation'
        ? CaseAPIService.getMediationCases
        : CaseAPIService.getCourtCases;
      const res = await api(params);
      if (res && res.data) {
        // API返回结构: { code, message, data: { total, page, size, data: [...] } }
        const listData = res.data.data || res.data;
        const totalCount = res.data.total || 0;
        if (Array.isArray(listData)) {
          setList(listData);
          setTotal(totalCount);
        } else {
          setList([]);
          setTotal(0);
        }
      } else {
        setList([]);
        setTotal(0);
      }
    } catch (error) {
      console.error('加载案例列表失败:', error);
      message.error('加载案例列表失败');
      setList([]);
      setTotal(0);
    } finally {
      setLoading(false);
    }
  };
  // 案例类型切换时重新加载关联数据
  useEffect(() => {
    const caseSource = caseType === 'judgment' ? 'judgment' : 'mediation';
    loadDisputeTypes(caseSource);
    loadYearStatistics();
    loadAreaStatistics(caseSource);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [caseType]);
  // 首次加载
  useEffect(() => {
    loadCaseList(1);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const handleSearch = () => {
    setLoading(true);
    setTimeout(() => {
      setList(mockCaseList.list);
      setLoading(false);
    }, 300);
    setCurrentPage(1);
    loadCaseList(1);
  };
  const handleReset = () => {
    setKeyword('');
    setDisputeType(undefined);
    setCaseTypeFilters(caseTypeFilters.map((f, i) => ({ ...f, checked: i === 0 })));
    setYearFilters(yearFilters.map((f, i) => ({ ...f, checked: i === 2 })));
    setRegionFilters(regionFilters.map((f, i) => ({ ...f, checked: i === 0 })));
    if (Array.isArray(yearFilters)) {
      setYearFilters(yearFilters.map(f => ({ ...f, checked: false })));
    }
    if (Array.isArray(regionFilters)) {
      setRegionFilters(regionFilters.map((f, i) => ({ ...f, checked: i === 0 })));
    }
    setCurrentPage(1);
  };
  const handleCaseClick = (item) => {
    setSelectedCase(item);
    setDetailVisible(true);
  const handleCaseClick = async (item) => {
    try {
      const id = caseType === 'mediation' ? item.id : item.cpws_case_info_id;
      const api = caseType === 'mediation'
        ? CaseAPIService.getMediationCaseDetail
        : CaseAPIService.getCourtCaseDetail;
      const res = await api(id);
      if (res && res.data) {
        setSelectedCase(res.data);
        setSelectedCaseType(caseType);
        setDetailVisible(true);
      }
    } catch (error) {
      console.error('加载案例详情失败:', error);
      message.error('加载案例详情失败');
    }
  };
  const handlePageChange = (page) => {
    setCurrentPage(page);
    loadCaseList(page);
  };
  const toggleFilter = (filters, setFilters, index, isChild = false, parentIndex = null) => {
@@ -101,14 +268,21 @@
          查询条件
        </h2>
        <div className="case-search-query-form">
          {/* 第一行:案例类型 + 纠纷类型 */}
          <div className="case-search-form-group">
            <label className="case-search-form-label">关键词</label>
            <Input
              placeholder="请填写"
              value={keyword}
              onChange={e => setKeyword(e.target.value)}
            />
            <label className="case-search-form-label">案例类型</label>
            <Radio.Group
              value={caseType}
              onChange={e => {
                setCaseType(e.target.value);
                setDisputeType(undefined); // 切换类型时重置纠纷类型
              }}
            >
              <Radio value="judgment">判决文书</Radio>
              <Radio value="mediation">调解案例</Radio>
            </Radio.Group>
          </div>
          <div className="case-search-form-group">
            <label className="case-search-form-label">纠纷类型</label>
            <Select 
@@ -118,15 +292,24 @@
              onChange={setDisputeType}
              allowClear
            >
              <Select.Option value="邻里纠纷">邻里纠纷</Select.Option>
              <Select.Option value="劳动争议">劳动争议</Select.Option>
              <Select.Option value="合同纠纷">合同纠纷</Select.Option>
              {disputeTypeOptions.map(option => (
                <Select.Option key={option.value} value={option.value}>
                  {option.label}
                </Select.Option>
              ))}
            </Select>
          </div>
          {/* 第二行:关键词 + 按钮 */}
          <div className="case-search-form-group">
            <label className="case-search-form-label">纠纷发生时间</label>
            <RangePicker style={{ width: '100%' }} />
            <label className="case-search-form-label">关键词</label>
            <Input
              placeholder="请填写"
              value={keyword}
              onChange={e => setKeyword(e.target.value)}
            />
          </div>
          <div className="case-search-form-group case-search-button-group">
            <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} loading={loading} style={{ height: 46 }}>
              查询
@@ -140,31 +323,11 @@
      {/* 筛选器区域 */}
      <div className="case-search-filters-section">
        {/* 案例类型 */}
        <div className="case-search-filter-category">
          <h3 className="case-search-filter-title">案例类型</h3>
          <div className="case-search-filter-list">
            {caseTypeFilters.map((item, index) => (
              <div
                key={index}
                className={`case-search-filter-item ${item.checked ? 'active' : ''}`}
                onClick={() => toggleFilter(caseTypeFilters, setCaseTypeFilters, index)}
              >
                <div className={`case-search-filter-checkbox ${item.checked ? 'checked' : ''}`}>
                  {item.checked && <i className="fas fa-check"></i>}
                </div>
                <span>{item.label}</span>
                <span className="case-search-filter-count">{item.count.toLocaleString()}</span>
              </div>
            ))}
          </div>
        </div>
        {/* 发生时间 */}
        <div className="case-search-filter-category">
          <h3 className="case-search-filter-title">发生时间</h3>
          <div className="case-search-filter-list">
            {yearFilters.map((item, index) => (
            {Array.isArray(yearFilters) && yearFilters.map((item, index) => (
              <div 
                key={index} 
                className={`case-search-filter-item ${item.checked ? 'active' : ''}`}
@@ -184,7 +347,7 @@
        <div className="case-search-filter-category">
          <h3 className="case-search-filter-title">纠纷发生地</h3>
          <div className="case-search-filter-list">
            {regionFilters.map((item, index) => (
            {Array.isArray(regionFilters) && regionFilters.map((item, index) => (
              <React.Fragment key={index}>
                <div 
                  className={`${item.isSub ? 'case-search-sub-filter-item' : 'case-search-filter-item'} ${item.checked ? 'active' : ''}`}
@@ -227,37 +390,48 @@
      <div className="case-search-results-section">
        <div className="case-search-results-header">
          <h2 className="case-search-results-title">查询结果</h2>
          <div className="case-search-total-count">记录总数:{mockCaseList.pageInfo.total}条</div>
          <div className="case-search-total-count">记录总数:{total}条</div>
        </div>
        <Spin spinning={loading}>
          <div className="case-search-cases-list">
            {list.map((item) => (
              <div
                key={item.id}
                className="case-search-case-item"
                onClick={() => handleCaseClick(item)}
              >
                <h3 className="case-search-case-title">{item.caseTitle}</h3>
                <div className="case-search-case-meta">
                  <div className="case-search-case-meta-item">
                    <i className="far fa-calendar-alt"></i>
                    <span>发生时间:{item.judgmentDate}</span>
            {Array.isArray(list) && list.map((item) => {
              // 根据案例类型映射字段
              const title = caseType === 'mediation' ? item.case_title : item.case_name;
              const time = caseType === 'mediation' ? item.occur_time : item.judgment_date;
              const location = caseType === 'mediation'
                ? `${item.que_prov_name || ''}/${item.que_city_name || ''}`
                : item.court;
              const type = caseType === 'mediation' ? item.case_type_first_name : item.case_reason;
              const caseId = caseType === 'mediation' ? item.id : item.cpws_case_info_id;
              return (
                <div
                  key={caseId}
                  className="case-search-case-item"
                  onClick={() => handleCaseClick(item)}
                >
                  <h3 className="case-search-case-title">{title}</h3>
                  <div className="case-search-case-meta">
                    <div className="case-search-case-meta-item">
                      <i className="far fa-calendar-alt"></i>
                      <span>发生时间:{formatDate(time)}</span>
                    </div>
                    <div className="case-search-case-meta-item">
                      <i className="fas fa-map-marker-alt"></i>
                      <span>发生地点:{location}</span>
                    </div>
                    <div className="case-search-case-meta-item">
                      <i className="fas fa-balance-scale"></i>
                      <span>纠纷类型:{type}</span>
                    </div>
                  </div>
                  <div className="case-search-case-meta-item">
                    <i className="fas fa-map-marker-alt"></i>
                    <span>发生地点:{item.region}</span>
                  </div>
                  <div className="case-search-case-meta-item">
                    <i className="fas fa-balance-scale"></i>
                    <span>纠纷类型:{item.disputeType}</span>
                  <div className={`case-search-case-type-badge ${caseType === 'mediation' ? 'mediation' : 'judgment'}`}>
                    {caseType === 'mediation' ? '调解案例' : '判决文书'}
                  </div>
                </div>
                <div className={`case-search-case-type-badge ${item.caseType === '调解' ? 'mediation' : 'judgment'}`}>
                  {item.caseType === '调解' ? '调解案例' : '判决文书'}
                </div>
              </div>
            ))}
              );
            })}
          </div>
        </Spin>
@@ -265,9 +439,9 @@
        <div className="case-search-pagination">
          <Pagination 
            current={currentPage}
            total={mockCaseList.pageInfo.total}
            pageSize={10}
            onChange={setCurrentPage}
            total={total}
            pageSize={pageSize}
            onChange={handlePageChange}
            showSizeChanger={false}
          />
        </div>
@@ -279,7 +453,7 @@
          <div className="case-detail-modal-header-custom">
            <h2>
              <i className="fas fa-folder-open"></i>
              典型案例详情
              {selectedCaseType === 'mediation' ? '典型案例详情' : '判决文书详情'}
            </h2>
          </div>
        }
@@ -293,7 +467,7 @@
        className="case-detail-antd-modal"
        closeIcon={<span className="case-detail-modal-close-custom">&times;</span>}
      >
        <TypicalCaseDetailContent caseData={selectedCase} />
        <TypicalCaseDetailContent caseData={selectedCase} caseType={selectedCaseType} />
      </Modal>
    </div>
  );
web-app/src/components/tools/TypicalCaseDetailContent.css
@@ -5,8 +5,8 @@
  margin: 0 auto;
  background-color: white;
  border-radius: 10px;
  overflow: hidden;
  height: 100%;
  position: relative;
  /* 移除 overflow: hidden 和 height: 100%,让内容自然撑开,由 .ant-modal-body 处理滚动 */
}
.case-detail-info-section {
@@ -27,6 +27,11 @@
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}
.case-detail-info-full {
  margin-bottom: 15px;
  display: block;
}
.case-detail-info-item {
@@ -142,6 +147,35 @@
  border-left: 3px solid #aaa;
}
/* 返回顶部按钮样式 */
.back-to-top-btn {
  position: fixed;
  bottom: 80px;
  right: 40px;
  z-index: 1001;
  width: 48px;
  height: 48px;
  background-color: #1a6fb8 !important;
  border-color: #1a6fb8 !important;
  box-shadow: 0 4px 12px rgba(26, 111, 184, 0.4);
  transition: all 0.3s ease;
}
.back-to-top-btn:hover {
  background-color: #0d4a8a !important;
  border-color: #0d4a8a !important;
  transform: translateY(-3px);
  box-shadow: 0 6px 16px rgba(26, 111, 184, 0.6);
}
.back-to-top-btn:active {
  transform: translateY(-1px);
}
.back-to-top-btn .anticon {
  font-size: 20px;
}
@media (max-width: 768px) {
  .case-detail-header, .case-detail-info-section, .case-detail-body {
    padding: 20px;
@@ -150,4 +184,15 @@
  .case-detail-info-grid {
    grid-template-columns: 1fr;
  }
  .back-to-top-btn {
    bottom: 60px;
    right: 20px;
    width: 40px;
    height: 40px;
  }
  .back-to-top-btn .anticon {
    font-size: 16px;
  }
}
web-app/src/components/tools/TypicalCaseDetailContent.jsx
@@ -1,158 +1,288 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'antd';
import { UpOutlined } from '@ant-design/icons';
import './TypicalCaseDetailContent.css';
/**
 * 典型案例详情内容组件 - 与原型 case_search_detail.html 保持一致
 * 典型案例详情内容组件 - 根据案例类型展示不同内容
 * @param {Object} caseData - 案例数据
 * @param {string} caseType - 案例类型 'judgment' | 'mediation'
 */
const TypicalCaseDetailContent = ({ caseData }) => {
const TypicalCaseDetailContent = ({ caseData, caseType }) => {
  const [showBackToTop, setShowBackToTop] = useState(false);
  const containerRef = useRef(null);
  // 监听滚动事件 - Hooks必须在所有条件语句之前调用
  useEffect(() => {
    let modalBody = null;
    const handleScroll = () => {
      if (modalBody) {
        // 当滚动超过300px时显示返回顶部按钮
        setShowBackToTop(modalBody.scrollTop > 300);
      }
    };
    // 延迟获取DOM元素,确保Modal完全渲染
    const timer = setTimeout(() => {
      modalBody = document.querySelector('.case-detail-antd-modal .ant-modal-body');
      if (modalBody) {
        modalBody.addEventListener('scroll', handleScroll);
        // 初始检查滚动位置
        handleScroll();
      }
    }, 100);
    return () => {
      clearTimeout(timer);
      if (modalBody) {
        modalBody.removeEventListener('scroll', handleScroll);
      }
    };
  }, [caseData]); // 当caseData变化时重新绑定
  // 提前返回条件必须在所有Hooks之后
  if (!caseData) return <div className="case-detail-loading">加载中...</div>;
  const { caseTitle, caseType, disputeType, caseNumber, court, judgmentDate, region, disputeTime, content } = caseData;
  // 返回顶部函数
  const scrollToTop = () => {
    const modalBody = document.querySelector('.case-detail-antd-modal .ant-modal-body');
    if (modalBody) {
      modalBody.scrollTo({
        top: 0,
        behavior: 'smooth'
      });
    }
  };
  // 日期格式化函数
  const formatDate = (dateStr) => {
    if (!dateStr) return '';
    const date = new Date(dateStr);
    if (isNaN(date.getTime())) return dateStr;
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    return `${year}年${month}月${day}日`;
  };
  // 调解案例详情展示
  if (caseType === 'mediation') {
    return (
      <div className="case-detail-container">
        {/* 案件基本信息 */}
        <div className="case-detail-info-section">
          <h2 className="case-detail-info-title">案件基本信息</h2>
          {/* 第一行:案件标题 */}
          {caseData.case_title && (
            <div className="case-detail-info-full">
              <span className="case-detail-info-label">案件标题:</span>
              <span className="case-detail-info-value">{caseData.case_title}</span>
            </div>
          )}
          {/* 第二行:其他信息 */}
          <div className="case-detail-info-grid">
            {caseData.occur_time && (
              <div className="case-detail-info-item">
                <span className="case-detail-info-label">纠纷发生时间:</span>
                <span className="case-detail-info-value">{formatDate(caseData.occur_time)}</span>
              </div>
            )}
            {(caseData.que_prov_name || caseData.que_city_name) && (
              <div className="case-detail-info-item">
                <span className="case-detail-info-label">发生地点:</span>
                <span className="case-detail-info-value">
                  {caseData.que_prov_name || ''}/{caseData.que_city_name || ''}
                </span>
              </div>
            )}
            {caseData.case_type_first_name && (
              <div className="case-detail-info-item">
                <span className="case-detail-info-label">纠纷类型:</span>
                <span className="case-detail-info-value">{caseData.case_type_first_name}</span>
              </div>
            )}
          </div>
        </div>
        {/* 案件内容 */}
        <div className="case-detail-body">
          {/* 案例概述 */}
          {caseData.case_des && (
            <div className="case-detail-section">
              <h3 className="case-detail-section-title">案例概述</h3>
              <div className="case-detail-section-content">
                <p>{caseData.case_des}</p>
              </div>
            </div>
          )}
          {/* 原告诉讼请求 */}
          {caseData.case_claim && (
            <div className="case-detail-section">
              <h3 className="case-detail-section-title">原告诉讼请求</h3>
              <div className="case-detail-section-content case-detail-plaintiff-demand">
                <p>{caseData.case_claim}</p>
              </div>
            </div>
          )}
          {/* 调解结果 */}
          {caseData.agree_content && (
            <div className="case-detail-section">
              <h3 className="case-detail-section-title">调解结果</h3>
              <div className="case-detail-section-content case-detail-mediation-result">
                <p>{caseData.agree_content}</p>
              </div>
            </div>
          )}
        </div>
      </div>
    );
  }
  // 判决文书详情展示
  return (
    <div className="case-detail-container">
      {/* 案件基本信息 */}
      <div className="case-detail-info-section">
        <h2 className="case-detail-info-title">案件基本信息</h2>
        {/* 第一行:案件标题和案号 */}
        <div className="case-detail-info-full">
          {caseData.case_name && (
            <>
              <span className="case-detail-info-label">案件标题:</span>
              <span className="case-detail-info-value">{caseData.case_name}</span>
            </>
          )}
          {caseData.case_number && (
            <>
              <span className="case-detail-info-label" style={{ marginLeft: '30px' }}>案号:</span>
              <span className="case-detail-info-value">{caseData.case_number}</span>
            </>
          )}
        </div>
        {/* 第二行:其他信息 */}
        <div className="case-detail-info-grid">
          <div className="case-detail-info-item">
            <span className="case-detail-info-label">纠纷发生时间:</span>
            <span className="case-detail-info-value">{disputeTime || '2024-4-12 12:00'}</span>
          </div>
          <div className="case-detail-info-item">
            <span className="case-detail-info-label">发生地点:</span>
            <span className="case-detail-info-value">{region || '广东省/广州市'}</span>
          </div>
          <div className="case-detail-info-item">
            <span className="case-detail-info-label">纠纷类型:</span>
            <span className="case-detail-info-value">{disputeType}</span>
          </div>
          <div className="case-detail-info-item">
            <span className="case-detail-info-label">调解组织:</span>
            <span className="case-detail-info-value">{court || '白云区新市街综治中心'}</span>
          </div>
          {caseData.judgment_date && (
            <div className="case-detail-info-item">
              <span className="case-detail-info-label">判决日期:</span>
              <span className="case-detail-info-value">{formatDate(caseData.judgment_date)}</span>
            </div>
          )}
          {caseData.court && (
            <div className="case-detail-info-item">
              <span className="case-detail-info-label">发生地点:</span>
              <span className="case-detail-info-value">{caseData.court}</span>
            </div>
          )}
          {caseData.case_reason && (
            <div className="case-detail-info-item">
              <span className="case-detail-info-label">纠纷类型:</span>
              <span className="case-detail-info-value">{caseData.case_reason}</span>
            </div>
          )}
        </div>
      </div>
      {/* 案件内容 */}
      <div className="case-detail-body">
        {content?.overview && (
        {/* 案例概述 */}
        {caseData.basic_case_info && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">案例概述</h3>
            <div className="case-detail-section-content">
              {content.overview.split('\n').map((para, i) => (
                <p key={i}>{para}</p>
              ))}
              <p>{caseData.basic_case_info}</p>
            </div>
          </div>
        )}
        {content?.plaintiffDemand && content.plaintiffDemand.length > 0 && (
        {/* 原告介绍 */}
        {caseData.plaintiff && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">原告诉讼请求</h3>
            <h3 className="case-detail-section-title">原告介绍</h3>
            <div className="case-detail-section-content case-detail-plaintiff-demand">
              <div className="case-detail-inner-content">
                <p>原告{caseData.plaintiff || '黄某'}起诉请求:</p>
                <ol>
                  {content.plaintiffDemand.map((item, i) => (
                    <li key={i}>{item}</li>
                  ))}
                </ol>
              </div>
              <p>{caseData.plaintiff}</p>
            </div>
          </div>
        )}
        {content?.courtDecision && (
        {/* 被告介绍 */}
        {caseData.defendant && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">被告介绍</h3>
            <div className="case-detail-section-content">
              <p>{caseData.defendant}</p>
            </div>
          </div>
        )}
        {/* 法院审理与判决 */}
        {caseData.trial_finding && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">法院审理与判决</h3>
            <div className="case-detail-section-content case-detail-court-decision">
              <div className="case-detail-inner-content">
                {content.courtDecision.split('\n').map((para, i) => (
                  <p key={i}>{para}</p>
                ))}
              </div>
              <p>{caseData.trial_finding}</p>
            </div>
          </div>
        )}
        {content?.mediationBackground && (
        {/* 审理经过 */}
        {caseData.trial_process && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">调解背景</h3>
            <div className="case-detail-section-content">
              <p>{content.mediationBackground}</p>
            </div>
          </div>
        )}
        {content?.partiesPosition && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">双方立场</h3>
            <div className="case-detail-section-content">
              <p><strong>{caseData.plaintiff || '黄某'}表示:</strong>{content.partiesPosition.plaintiff}</p>
              <p><strong>{caseData.defendant || '郭某'}认为:</strong>{content.partiesPosition.defendant}</p>
            </div>
          </div>
        )}
        {content?.mediationProcess && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">调解过程</h3>
            <h3 className="case-detail-section-title">审理经过</h3>
            <div className="case-detail-section-content case-detail-mediation-process">
              <p>{content.mediationProcess}</p>
              <p>{caseData.trial_process}</p>
            </div>
          </div>
        )}
        {content?.mediationScheme && content.mediationScheme.length > 0 && (
        {/* 审理程序 */}
        {caseData.trial_procedure && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">调解方案</h3>
            <h3 className="case-detail-section-title">审理程序</h3>
            <div className="case-detail-section-content case-detail-mediation-scheme">
              <p>法官提出的调解方案:</p>
              <ul>
                {content.mediationScheme.map((item, i) => (
                  <li key={i}>{item}</li>
                ))}
              </ul>
              <p>{caseData.trial_procedure}</p>
            </div>
          </div>
        )}
        {content?.mediationResult && (
        {/* 调解结果 */}
        {caseData.judgment && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">调解结果</h3>
            <div className="case-detail-section-content case-detail-mediation-result">
              <div className="case-detail-inner-content">
                <p>经过进一步的协商,双方最终接受了法官提出的调解方案。</p>
                <ol>
                  {content.mediationResult.items.map((item, i) => (
                    <li key={i}>{item}</li>
                  ))}
                </ol>
                {content.mediationResult.note && (
                  <div className="case-detail-note">
                    <p>{content.mediationResult.note}</p>
                  </div>
                )}
              </div>
              <p>{caseData.judgment}</p>
            </div>
          </div>
        )}
        {content?.legalArticles && content.legalArticles.length > 0 && (
        {/* 案例相关法律条文 */}
        {caseData.legal_basis && (
          <div className="case-detail-section">
            <h3 className="case-detail-section-title">案例相关法律条文</h3>
            <div className="case-detail-section-content case-detail-legal-articles">
              {content.legalArticles.map((article, i) => (
                <div className="case-detail-article" key={i}>
                  <div className="case-detail-article-title">{article.title}</div>
                  <p>{article.content}</p>
                </div>
              ))}
              <p>{caseData.legal_basis}</p>
            </div>
          </div>
        )}
      </div>
      {/* 返回顶部按钮 */}
      {showBackToTop && (
        <Button
          type="primary"
          shape="circle"
          icon={<UpOutlined />}
          size="large"
          className="back-to-top-btn"
          onClick={scrollToTop}
        />
      )}
    </div>
  );
};
web-app/src/components/tools/TypicalCaseSearch.css
@@ -30,8 +30,13 @@
.case-search-query-form {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-columns: repeat(2, 1fr);
  gap: 20px;
}
/* 关键词输入框占满一行 */
.case-search-keyword-full {
  grid-column: 1 / -1;
}
.case-search-form-group {
@@ -66,7 +71,7 @@
/* 筛选器区域 */
.case-search-filters-section {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-template-columns: repeat(2, 1fr);
  gap: 25px;
  margin-bottom: 30px;
}
@@ -343,6 +348,32 @@
  display: none; /* 使用自定义的 closeIcon */
}
/* Modal body 滚动样式 */
.case-detail-antd-modal .ant-modal-body {
  overflow-y: auto !important;
  max-height: 85vh !important;
  scrollbar-width: thin;
  scrollbar-color: #1a6fb8 #f0f0f0;
}
.case-detail-antd-modal .ant-modal-body::-webkit-scrollbar {
  width: 8px;
}
.case-detail-antd-modal .ant-modal-body::-webkit-scrollbar-track {
  background: #f0f0f0;
  border-radius: 4px;
}
.case-detail-antd-modal .ant-modal-body::-webkit-scrollbar-thumb {
  background-color: #1a6fb8;
  border-radius: 4px;
}
.case-detail-antd-modal .ant-modal-body::-webkit-scrollbar-thumb:hover {
  background-color: #0d4a8a;
}
/* 响应式 */
@media (max-width: 1200px) {
  .case-search-query-form {
web-app/src/services/CaseAPIService.js
@@ -42,10 +42,11 @@
  /**
   * 纠纷发生地筛选统计
   * GET /api/web/cpwsCaseInfo/areaCount
   * @param {string} caseSource - 案例类型,包括judgment(判决文书)和mediation(调解案例),默认为judgment
   * @returns {Promise} 地区统计信息
   */
  static getAreaStatistics() {
    return request.get('/api/web/cpwsCaseInfo/areaCount');
  static getAreaStatistics(caseSource = 'judgment') {
    return request.get(`/api/web/cpwsCaseInfo/areaCount?caseSource=${caseSource}`);
  }
  /**
@@ -87,6 +88,16 @@
  }
  /**
   * 纠纷类型下拉列表数据源
   * GET /api/web/case/dispute-types
   * @param {string} caseSource - 案例类型,包括judgment(判决文书)和mediation(调解案例),默认为judgment
   * @returns {Promise} 纠纷类型统计列表
   */
  static getDisputeTypes(caseSource = 'judgment') {
    return request.get('/api/web/case/dispute-types', { caseSource });
  }
  /**
   * 统一分页查询方法(兼容调解案例和判决文书)
   * @param {string} type - 案例类型 ('mediation' | 'court')
   * @param {Object} params - 查询参数
web-app/src/services/EvidenceAPIService.js
@@ -26,35 +26,41 @@
  /**
   * 当事人信息查询
   * GET /api/v1/evidence/person-info
   * @param {string} personId - 当事人ID
   * @param {Object} params - 查询参数
   * @param {string} params.case_id - 案件ID
   * @param {string} params.evidence_type - 证据类型
   * @param {string} params.per_type - 人员类型(15_020008-1/15_020008-2)
   * @returns {Promise} 当事人信息
   */
  static getPersonInfo(personId) {
    return request.get('/api/v1/evidence/person-info', { personId });
  static getPersonInfo(params = {}) {
    return request.get('/api/v1/evidence/person-info', params);
  }
  /**
   * 按当事人查询证据列表
   * 按当事人查询证据列表(获取材料图片列表)
   * GET /api/v1/evidence/list-by-person
   * @param {string} personId - 当事人ID
   * @param {Object} params - 其他查询参数
   * @returns {Promise} 证据列表
   * @param {Object} params - 查询参数
   * @param {string} params.case_id - 案件ID
   * @param {string} params.evidence_type - 证据类型
   * @param {string} params.per_type - 人员类型
   * @param {string} params.person_id - 人员ID
   * @returns {Promise} 证据列表(含show_url图片路径)
   */
  static getEvidenceListByPerson(personId, params = {}) {
    return request.get('/api/v1/evidence/list-by-person', {
      personId,
      ...params
    });
  static getEvidenceListByPerson(params = {}) {
    return request.get('/api/v1/evidence/list-by-person', params);
  }
  /**
   * 证据审核
   * PUT /api/v1/evidence/audit
   * POST /api/v1/evidence/audit
   * @param {Object} data - 审核数据
   * @param {string} data.evidenceId - 证据ID
   * @param {string} data.status - 审核状态
   * @param {string} data.auditOpinion - 审核意见
   * @param {string} data.auditorId - 审核人ID
   * @param {string} data.case_id - 案件ID
   * @param {string|number} data.evidence_type - 证据类型
   * @param {string} data.person_id - 人员ID
   * @param {Array<string>} data.file_id_list - 文件ID列表
   * @param {string} data.audit_user - 审核人
   * @param {number} data.audit_state - 审核状态(1-审核通过,2-退回补充)
   * @param {string} data.audit_remark - 审核意见(退回时必填)
   * @returns {Promise} 审核结果
   */
  static auditEvidence(data = {}) {
web-app/src/services/MediationAgreementAPIService.js
New file
@@ -0,0 +1,73 @@
/**
 * 调解协议API Service
 * 处理调解协议生成、确认、下载、修改等相关接口
 * 接口前缀: /api/v1/medi-agreement/*
 */
import { request } from './request';
class MediationAgreementAPIService {
  /**
   * 调解协议生成
   * POST /api/v1/medi-agreement/generate
   * @param {string} caseId - 案件ID
   * @returns {Promise} 生成的协议信息(包含agreeId和agreeContent)
   */
  static generateAgreement(caseId) {
    return request.post('/api/v1/medi-agreement/generate', { caseId });
  }
  /**
   * 调解协议确认
   * POST /api/v1/medi-agreement/confirm
   * @param {string} caseId - 案件ID
   * @param {string} userType - 用户类型:applicant(申请方) / respondent(被申请方) / mediator(调解员)
   * @returns {Promise} 确认结果(包含各方确认状态)
   */
  static confirmAgreement(caseId, userType) {
    return request.post('/api/v1/medi-agreement/confirm', { caseId, userType });
  }
  /**
   * 调解协议下载
   * POST /api/v1/medi-agreement/download
   * @param {string} caseId - 案件ID
   * @returns {Promise} 协议内容(包含agreeId和agreeContent)
   */
  static downloadAgreement(caseId) {
    return request.post('/api/v1/medi-agreement/download', { caseId });
  }
  /**
   * 获取调解协议内容
   * GET /api/v1/medi-agreement/detail/{caseId}
   * @param {string} caseId - 案件ID
   * @returns {Promise} 协议详情(包含agreeId和agreeContent)
   */
  static getAgreementDetail(caseId) {
    return request.get(`/api/v1/medi-agreement/detail/${caseId}`);
  }
  /**
   * 调解协议内容修改
   * POST /api/v1/medi-agreement/update
   * @param {string} caseId - 案件ID
   * @param {string} agreeContent - 修改后的协议内容全文
   * @returns {Promise} 修改结果(包含caseId和agreeId)
   */
  static updateAgreement(caseId, agreeContent) {
    return request.post('/api/v1/medi-agreement/update', { caseId, agreeContent });
  }
  /**
   * 调解协议重新生成
   * POST /api/v1/medi-agreement/regenerate
   * @param {string} caseId - 案件ID
   * @returns {Promise} 重新生成的协议信息(包含新的agreeId和agreeContent)
   */
  static regenerateAgreement(caseId) {
    return request.post('/api/v1/medi-agreement/regenerate', { caseId });
  }
}
export default MediationAgreementAPIService;