fix: stabilize file downloads and file workspace

This commit is contained in:
2026-06-21 13:54:35 +08:00
parent a89f4c7050
commit 76b9198b64
13 changed files with 2020 additions and 401 deletions
+101 -28
View File
@@ -1,6 +1,8 @@
from pathlib import Path
from typing import Any, Dict, Optional
from uuid import uuid4
import base64
import binascii
import hashlib
import io
import mimetypes
@@ -13,12 +15,14 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.file import File
from app.repositories.file_repository import FileRepository
from app.repositories.generated_contract_repository import GeneratedContractRepository
class FileService:
def __init__(self, db: Session):
self.db = db
self.repository = FileRepository(db)
self.generated_contract_repository = GeneratedContractRepository(db)
self.storage_backend = (settings.file_storage_backend or "minio").strip().lower()
if self.storage_backend not in {"minio", "local"}:
self.storage_backend = "minio"
@@ -85,7 +89,8 @@ class FileService:
file_id = f"file-{uuid4().hex[:12]}"
safe_file_name = Path(file.filename or "uploaded-file").name
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
storage_bucket = self._resolve_storage_bucket(bucket)
record_bucket = self._resolve_record_bucket(bucket)
storage_bucket = self._resolve_storage_bucket(record_bucket)
self._ensure_bucket(storage_bucket)
self._put_object(storage_bucket, object_key, content, file.content_type or "application/octet-stream")
@@ -93,7 +98,7 @@ class FileService:
id=file_id,
tenant_id=tenant_id,
uploaded_by=uploader_id,
bucket=storage_bucket,
bucket=record_bucket,
object_key=object_key,
file_name=safe_file_name,
mime_type=file.content_type or "application/octet-stream",
@@ -128,7 +133,8 @@ class FileService:
file_id = f"file-{uuid4().hex[:12]}"
safe_file_name = Path(file_name).name or "generated-file"
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
storage_bucket = self._resolve_storage_bucket(bucket)
record_bucket = self._resolve_record_bucket(bucket)
storage_bucket = self._resolve_storage_bucket(record_bucket)
self._ensure_bucket(storage_bucket)
self._put_object(storage_bucket, object_key, content, mime_type)
@@ -136,7 +142,7 @@ class FileService:
id=file_id,
tenant_id=tenant_id,
uploaded_by=uploader_id,
bucket=storage_bucket,
bucket=record_bucket,
object_key=object_key,
file_name=safe_file_name,
mime_type=mime_type,
@@ -220,7 +226,13 @@ class FileService:
def get_download_payload(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> dict:
file_record = self._get_active_file(current_user, file_id, allow_owner_access=allow_owner_access)
content = self._read_object(file_record.bucket, file_record.object_key)
try:
content = self._read_object(file_record.bucket, file_record.object_key)
except HTTPException:
recovered = self._recover_known_file_payload(file_record)
if recovered is not None:
return recovered
raise
return {
"id": file_record.id,
"file_name": file_record.file_name,
@@ -234,13 +246,16 @@ class FileService:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(content)
return
self.minio_client.put_object(
bucket_name=bucket,
object_name=object_key,
data=io.BytesIO(content),
length=len(content),
content_type=content_type,
)
try:
self.minio_client.put_object(
bucket_name=bucket,
object_name=object_key,
data=io.BytesIO(content),
length=len(content),
content_type=content_type,
)
except S3Error as exc:
raise self._storage_unavailable_exception(exc)
def _read_object(self, bucket: str, object_key: str) -> bytes:
if self.storage_backend == "local":
@@ -253,16 +268,25 @@ class FileService:
return file_path.read_bytes()
response = None
not_found_error: Optional[S3Error] = None
unavailable_error: Optional[S3Error] = None
try:
response = self.minio_client.get_object(bucket, object_key)
return response.read()
except S3Error as exc:
if exc.code == "NoSuchKey":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="file content not found",
)
raise
for storage_bucket in self._storage_bucket_candidates(bucket):
try:
response = self.minio_client.get_object(storage_bucket, object_key)
return response.read()
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchBucket"}:
not_found_error = exc
continue
unavailable_error = exc
continue
if unavailable_error is not None:
raise self._storage_unavailable_exception(unavailable_error)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="file content not found",
) from not_found_error
finally:
if response is not None:
response.close()
@@ -275,18 +299,40 @@ class FileService:
file_path.unlink()
self._prune_empty_local_dirs(file_path.parent, self._resolve_local_bucket_path(bucket))
return
try:
self.minio_client.remove_object(bucket, object_key)
except S3Error as exc:
if exc.code != "NoSuchKey":
raise
last_error: Optional[S3Error] = None
for storage_bucket in self._storage_bucket_candidates(bucket):
try:
self.minio_client.remove_object(storage_bucket, object_key)
return
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchBucket"}:
continue
last_error = exc
if last_error is not None:
raise self._storage_unavailable_exception(last_error)
def _resolve_storage_bucket(self, bucket: str) -> str:
def _resolve_record_bucket(self, bucket: str) -> str:
normalized_bucket = (bucket or "").strip().lower()
if normalized_bucket:
return normalized_bucket
return settings.minio_bucket.strip().lower()
def _resolve_storage_bucket(self, bucket: str) -> str:
normalized_bucket = self._resolve_record_bucket(bucket)
if self.storage_backend == "minio":
configured_bucket = (settings.minio_bucket or "").strip().lower()
if configured_bucket:
return configured_bucket
return normalized_bucket
def _storage_bucket_candidates(self, bucket: str) -> list[str]:
candidates = [self._resolve_storage_bucket(bucket), self._resolve_record_bucket(bucket)]
unique_candidates: list[str] = []
for candidate in candidates:
if candidate and candidate not in unique_candidates:
unique_candidates.append(candidate)
return unique_candidates
def _ensure_bucket(self, bucket: str):
if self.storage_backend == "local":
self._resolve_local_bucket_path(bucket).mkdir(parents=True, exist_ok=True)
@@ -296,7 +342,34 @@ class FileService:
self.minio_client.make_bucket(bucket)
except S3Error as exc:
if exc.code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
raise
raise self._storage_unavailable_exception(exc)
def _storage_unavailable_exception(self, exc: S3Error) -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"file storage unavailable: {exc.code}",
)
def _recover_known_file_payload(self, file_record: File) -> Optional[dict]:
if getattr(file_record, "file_type", None) != "contract":
return None
generated_contract = self.generated_contract_repository.get_by_file_id(
file_record.id,
file_record.tenant_id,
)
if generated_contract is None or not getattr(generated_contract, "file_content_base64", None):
return None
try:
content = base64.b64decode(generated_contract.file_content_base64)
except (binascii.Error, ValueError):
return None
return {
"id": file_record.id,
"file_name": file_record.file_name or generated_contract.file_name,
"mime_type": file_record.mime_type or generated_contract.mime_type,
"content": content,
"recovered_from": "generated_contract",
}
def _resolve_local_bucket_path(self, bucket: str) -> Path:
return (self.local_storage_dir / bucket).resolve()
+188 -20
View File
@@ -1,4 +1,5 @@
import io
import base64
import tempfile
import unittest
from pathlib import Path
@@ -214,6 +215,7 @@ class FilesApiTestCase(unittest.TestCase):
class FileServiceStorageTestCase(unittest.TestCase):
def test_upload_download_delete_manage_minio_storage(self):
from app.services.file_service import FileService
from app.core.config import settings
class FakeObjectResponse:
def __init__(self, content: bytes):
@@ -304,31 +306,131 @@ class FileServiceStorageTestCase(unittest.TestCase):
def count(self, tenant_id, file_type=None):
return 0
original_minio_bucket = settings.minio_bucket
settings.minio_bucket = "contracts"
service = FileService(db=None)
repository = InMemoryRepository()
fake_minio = FakeMinioClient()
service.repository = repository
service.minio_client = fake_minio
current_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
upload = UploadFile(filename="nested/contract.txt", file=io.BytesIO(b"payload"), headers={"content-type": "text/plain"})
try:
repository = InMemoryRepository()
fake_minio = FakeMinioClient()
service.repository = repository
service.minio_client = fake_minio
current_user = {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
upload = UploadFile(filename="nested/contract.txt", file=io.BytesIO(b"payload"), headers={"content-type": "text/plain"})
created = service.upload_file(current_user, upload, "document", "contracts")
object_key = repository.created.object_key
created = service.upload_file(current_user, upload, "document", "contracts")
object_key = repository.created.object_key
self.assertEqual(created["file_name"], "contract.txt")
self.assertIn("contracts", fake_minio.buckets)
self.assertEqual(fake_minio.objects[("contracts", object_key)], b"payload")
self.assertEqual(created["file_name"], "contract.txt")
self.assertIn("contracts", fake_minio.buckets)
self.assertEqual(fake_minio.objects[("contracts", object_key)], b"payload")
download = service.get_download_payload(current_user, repository.created.id)
self.assertEqual(download["content"], b"payload")
download = service.get_download_payload(current_user, repository.created.id)
self.assertEqual(download["content"], b"payload")
service.delete_file(current_user, repository.created.id)
self.assertNotIn(("contracts", object_key), fake_minio.objects)
self.assertEqual(repository.deleted.status, "deleted")
service.delete_file(current_user, repository.created.id)
self.assertNotIn(("contracts", object_key), fake_minio.objects)
self.assertEqual(repository.deleted.status, "deleted")
finally:
settings.minio_bucket = original_minio_bucket
def test_business_bucket_uses_configured_minio_bucket_for_storage(self):
from app.services.file_service import FileService
from app.core.config import settings
class FakeObjectResponse:
def __init__(self, content: bytes):
self._content = content
def read(self):
return self._content
def close(self):
return None
def release_conn(self):
return None
class FakeMinioClient:
def __init__(self):
self.buckets = {"contracts"}
self.objects = {}
def bucket_exists(self, bucket):
return bucket in self.buckets
def make_bucket(self, bucket):
self.buckets.add(bucket)
def put_object(self, bucket_name, object_name, data, length, content_type):
self.objects[(bucket_name, object_name)] = data.read(length)
def get_object(self, bucket_name, object_name):
key = (bucket_name, object_name)
if key not in self.objects:
raise S3Error(
code="NoSuchKey",
message="Not Found",
resource=object_name,
request_id="req",
host_id="host",
response=None,
bucket_name=bucket_name,
object_name=object_name,
)
return FakeObjectResponse(self.objects[key])
def remove_object(self, bucket_name, object_name):
self.objects.pop((bucket_name, object_name), None)
class InMemoryRepository:
def __init__(self):
self.created = None
def create(self, file_record):
if file_record.created_at is None:
file_record.created_at = datetime.now(timezone.utc)
self.created = file_record
return file_record
def get_by_id(self, file_id, tenant_id):
if self.created and self.created.id == file_id and self.created.tenant_id == tenant_id:
return self.created
return None
original_minio_bucket = settings.minio_bucket
settings.minio_bucket = "contracts"
service = FileService(db=None)
try:
service.repository = InMemoryRepository()
service.minio_client = FakeMinioClient()
created = service.create_file_record(
{"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"},
file_name="review-report.pdf",
content=b"report-payload",
file_type="report",
mime_type="application/pdf",
bucket="generated",
require_admin=False,
)
object_key = service.repository.created.object_key
self.assertEqual(created["bucket"], "generated")
self.assertEqual(service.repository.created.bucket, "generated")
self.assertIn(("contracts", object_key), service.minio_client.objects)
self.assertNotIn(("generated", object_key), service.minio_client.objects)
download = service.get_download_payload(
{"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"},
service.repository.created.id,
)
self.assertEqual(download["content"], b"report-payload")
finally:
settings.minio_bucket = original_minio_bucket
def test_download_missing_object_returns_404(self):
from app.services.file_service import FileService
@@ -386,6 +488,72 @@ class FileServiceStorageTestCase(unittest.TestCase):
self.assertEqual(ctx.exception.status_code, status.HTTP_404_NOT_FOUND)
self.assertEqual(ctx.exception.detail, "file content not found")
def test_contract_file_recovers_from_generated_contract_when_object_unavailable(self):
from app.services.file_service import FileService
class FakeMinioClient:
def get_object(self, bucket_name, object_name):
raise S3Error(
code="AccessDenied",
message="Access Denied",
resource=object_name,
request_id="req",
host_id="host",
response=None,
bucket_name=bucket_name,
object_name=object_name,
)
class InMemoryRepository:
def __init__(self):
self.created = type(
"FileRecord",
(),
{
"id": "file-contract-123",
"tenant_id": "tenant-demo-001",
"uploaded_by": "user-admin",
"status": "active",
"bucket": "generated",
"object_key": "tenant-demo-001/contract/object.docx",
"file_name": "contract.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"file_type": "contract",
},
)()
def get_by_id(self, file_id, tenant_id):
if self.created.id == file_id and self.created.tenant_id == tenant_id:
return self.created
return None
class InMemoryGeneratedContractRepository:
def get_by_file_id(self, file_id, tenant_id=None):
if file_id != "file-contract-123":
return None
return type(
"GeneratedContractRecord",
(),
{
"file_name": "contract.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"file_content_base64": base64.b64encode(b"recovered-contract-bytes").decode("ascii"),
},
)()
service = FileService(db=None)
service.repository = InMemoryRepository()
service.generated_contract_repository = InMemoryGeneratedContractRepository()
service.minio_client = FakeMinioClient()
payload = service.get_download_payload(
{"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"},
"file-contract-123",
)
self.assertEqual(payload["content"], b"recovered-contract-bytes")
self.assertEqual(payload["recovered_from"], "generated_contract")
def test_owner_can_download_without_admin_role_when_enabled(self):
from app.services.file_service import FileService
@@ -0,0 +1,52 @@
import { type NextRequest } from "next/server";
function resolveBackendBaseUrl() {
const configuredBackend = process.env.BACKEND_BASE_URL;
if (configuredBackend) return configuredBackend;
const publicApiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
if (publicApiBase?.startsWith("http")) {
return publicApiBase.replace(/\/api\/v1\/?$/, "");
}
return process.env.NODE_ENV === "production" ? "http://backend:8000" : "http://localhost:8000";
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> },
) {
const { id } = await context.params;
const backendBaseUrl = resolveBackendBaseUrl();
const targetUrl = new URL(`/api/v1/files/${encodeURIComponent(id)}/download`, backendBaseUrl);
const response = await fetch(targetUrl, {
headers: {
authorization: request.headers.get("authorization") || "",
accept: request.headers.get("accept") || "*/*",
},
cache: "no-store",
});
if (!response.ok) {
const message = await response.text().catch(() => "文件下载失败");
return new Response(message || "文件下载失败", {
status: response.status,
headers: { "content-type": response.headers.get("content-type") || "text/plain; charset=utf-8" },
});
}
const headers = new Headers();
const contentType = response.headers.get("content-type");
const contentDisposition = response.headers.get("content-disposition");
const contentLength = response.headers.get("content-length");
if (contentType) headers.set("content-type", contentType);
if (contentDisposition) headers.set("content-disposition", contentDisposition);
if (contentLength) headers.set("content-length", contentLength);
headers.set("cache-control", "no-store");
return new Response(response.body, {
status: response.status,
headers,
});
}
+160 -14
View File
@@ -8,7 +8,7 @@ import {
FileSearchOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { Button, Empty, Form, Input, Modal, Select, Space, Table, Tag, Typography, message } from "antd";
import { Button, Empty, Form, Input, Modal, Select, Space, Table, Tag, Timeline, Typography, message } from "antd";
import type { ColumnsType } from "antd/es/table";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -32,6 +32,13 @@ import {
const { Text } = Typography;
const { TextArea } = Input;
function getApprovalTimelineColor(event: string, status: string) {
if (event === "approved" || status === "approved") return "green";
if (event === "rejected" || status === "rejected") return "red";
if (event === "pending" || status === "pending") return "blue";
return "gray";
}
type ApprovalDecisionFormValues = {
comment?: string;
};
@@ -294,14 +301,58 @@ export default function ContractApprovalsPage() {
typeof record.can_decide === "boolean" ? record.can_decide : record.status === "pending"
), []);
const pendingMineItems = useMemo(
() => items.filter((record) => record.status === "pending" && canDecideApproval(record)).slice(0, 4),
[canDecideApproval, items],
);
const rejectedItems = useMemo(
() => items.filter((record) => record.status === "rejected").slice(0, 4),
[items],
);
const renderApprovalExpandedRow = useCallback((record: ContractApprovalStep) => (
<div className="approval-workbench-expanded-row">
<div className="approval-workbench-expanded-head">
<Space wrap>
<Text strong>线</Text>
<Tag color={getContractApprovalStatusColor(record.status)}>{formatContractApprovalStatus(record.status)}</Tag>
</Space>
<Link href={`/contracts/${record.contract_id}#contract-approval`}></Link>
</div>
{record.timeline?.length ? (
<Timeline
className="approval-workbench-timeline"
items={record.timeline.map((item) => ({
key: item.key,
color: getApprovalTimelineColor(item.event, item.status),
children: (
<div className="approval-workbench-timeline-item">
<div>
<Text strong>{item.title}</Text>
<Text type="secondary">{formatDateTime(item.occurred_at)}</Text>
</div>
<Text type="secondary">
{item.actor_name ? `操作人:${item.actor_name}` : "操作人未记录"}
</Text>
{item.description ? <Text>{item.description}</Text> : null}
</div>
),
}))}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无时间线记录。" />
)}
</div>
), []);
const columns = useMemo<ColumnsType<ContractApprovalStep>>(() => [
{
title: "合同",
key: "contract",
fixed: "left",
width: 280,
width: 260,
render: (_, record) => (
<Space orientation="vertical" size={2}>
<Space direction="vertical" size={2}>
<Link href={`/contracts/${record.contract_id}`}>
<Text strong>{record.contract?.template_name ?? record.title}</Text>
</Link>
@@ -312,13 +363,13 @@ export default function ContractApprovalsPage() {
{
title: "类型",
key: "type",
width: 150,
width: 130,
render: (_, record) => formatContractType(record.contract?.document_type_code ?? record.contract?.contract_type),
},
{
title: "审批",
key: "approval",
width: 180,
width: 160,
render: (_, record) => (
<Space wrap>
<Tag color={getContractApprovalStepTypeColor(record.step_type)}>
@@ -343,9 +394,9 @@ export default function ContractApprovalsPage() {
{
title: "提交/处理人",
key: "people",
width: 210,
width: 190,
render: (_, record) => (
<Space orientation="vertical" size={2}>
<Space direction="vertical" size={2}>
<Text>{record.submitter_name || "提交人未记录"}</Text>
<Text type="secondary">{record.assignee_name || "-"}</Text>
</Space>
@@ -354,9 +405,9 @@ export default function ContractApprovalsPage() {
{
title: "审批意见",
key: "comment",
width: 260,
width: 220,
render: (_, record) => (
<Space orientation="vertical" size={2}>
<Space direction="vertical" size={2}>
<Text>{record.comment || "暂无意见"}</Text>
{record.decided_by_name ? <Text type="secondary">{record.decided_by_name}</Text> : null}
</Space>
@@ -365,7 +416,7 @@ export default function ContractApprovalsPage() {
{
title: "提交时间",
dataIndex: "created_at",
width: 170,
width: 160,
render: (value: string | null) => formatDateTime(value),
sorter: (a, b) => new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime(),
},
@@ -387,7 +438,7 @@ export default function ContractApprovalsPage() {
{
title: "操作",
key: "actions",
width: 210,
width: 220,
render: (_, record) => (
<Space wrap>
<Link href={`/contracts/${record.contract_id}`}></Link>
@@ -412,6 +463,11 @@ export default function ContractApprovalsPage() {
</Button>
</>
) : record.status === "pending" ? <Text type="secondary"></Text> : null}
{record.status === "rejected" ? (
<Link href={`/contracts/${record.contract_id}#contract-approval`}>
<Button size="small"></Button>
</Link>
) : null}
</Space>
),
},
@@ -449,6 +505,69 @@ export default function ContractApprovalsPage() {
</div>
</section>
{(pendingMineItems.length > 0 || rejectedItems.length > 0) ? (
<section className="landing-section approval-workbench-focus">
{pendingMineItems.length > 0 ? (
<div className="approval-workbench-focus-panel">
<div className="approval-workbench-focus-head">
<div>
<Text strong></Text>
<Text type="secondary"></Text>
</div>
<Tag color="gold">{pendingMineItems.length}</Tag>
</div>
<div className="approval-workbench-focus-list">
{pendingMineItems.map((record) => (
<article className="approval-workbench-focus-item" key={record.id}>
<div>
<Link href={`/contracts/${record.contract_id}`}>
<Text strong>{record.contract?.template_name ?? record.title}</Text>
</Link>
<Text type="secondary">{record.assignee_name || "当前审批人"} · {formatDateTime(record.created_at)}</Text>
</div>
<Space wrap>
<Button size="small" icon={<CheckCircleOutlined />} onClick={() => handleOpenDecisionModal(record, "approve")}>
</Button>
<Button danger size="small" icon={<CloseCircleOutlined />} onClick={() => handleOpenDecisionModal(record, "reject")}>
退
</Button>
</Space>
</article>
))}
</div>
</div>
) : null}
{rejectedItems.length > 0 ? (
<div className="approval-workbench-focus-panel approval-workbench-focus-panel-warning">
<div className="approval-workbench-focus-head">
<div>
<Text strong>退</Text>
<Text type="secondary"></Text>
</div>
<Tag color="red">{rejectedItems.length}</Tag>
</div>
<div className="approval-workbench-focus-list">
{rejectedItems.map((record) => (
<article className="approval-workbench-focus-item" key={record.id}>
<div>
<Link href={`/contracts/${record.contract_id}#contract-approval`}>
<Text strong>{record.contract?.template_name ?? record.title}</Text>
</Link>
<Text type="secondary">{record.comment || "审批人未填写退回原因"}</Text>
</div>
<Link href={`/contracts/${record.contract_id}#contract-approval`}>
<Button size="small"></Button>
</Link>
</article>
))}
</div>
</div>
) : null}
</section>
) : null}
<section className="landing-section">
<div className="landing-section-header contract-ledger-list-header">
<div>
@@ -549,7 +668,11 @@ export default function ContractApprovalsPage() {
<div className="landing-section-header contract-ledger-list-header">
<div>
<h2></h2>
<p> {filteredItems.length} {items.length} </p>
<p>
{filteredItems.length
? `???? ${filteredItems.length} ????????? ${items.length} ????????????`
: "??????????????????????????????"}
</p>
</div>
<Space wrap>
<Select options={scopeOptions} value={scope} onChange={setScope} style={{ width: 140 }} />
@@ -562,10 +685,33 @@ export default function ContractApprovalsPage() {
columns={columns}
dataSource={filteredItems}
loading={loading}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无审批任务。" /> }}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Space direction="vertical" size={8}>
<Text></Text>
<Space wrap>
<Link href="/contracts">
<Button size="small"></Button>
</Link>
<Link href="/contract-ledger">
<Button size="small"></Button>
</Link>
</Space>
</Space>
}
/>
),
}}
pagination={{ pageSize: 10 }}
rowKey="id"
scroll={{ x: 1520 }}
expandable={{
expandedRowRender: renderApprovalExpandedRow,
rowExpandable: (record) => Boolean(record.timeline?.length || record.comment),
}}
/>
</div>
</section>
@@ -582,7 +728,7 @@ export default function ContractApprovalsPage() {
decisionForm.resetFields();
}}
>
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Text type="secondary">
{decisionModal?.decision === "approve"
? "通过后合同会进入已审批状态,可继续签署或归档。"
+85 -5
View File
@@ -128,6 +128,8 @@ type ContractVersionRow = {
is_base?: boolean;
};
type ContractDetailPanelKey = "lifecycle" | "signoff" | "audit" | "versions" | "tasks";
const contractDetailAnchors = [
{ href: "#contract-summary", label: "概览" },
{ href: "#contract-content", label: "正文预览" },
@@ -392,6 +394,13 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
const [showTechnicalDetails, setShowTechnicalDetails] = useState(false);
const [showVariableDetails, setShowVariableDetails] = useState(false);
const [showAdvancedDraftDetails, setShowAdvancedDraftDetails] = useState(false);
const [expandedDetailPanels, setExpandedDetailPanels] = useState<Record<ContractDetailPanelKey, boolean>>({
lifecycle: false,
signoff: false,
audit: false,
versions: false,
tasks: false,
});
useEffect(() => {
const loadDetail = async () => {
@@ -606,6 +615,14 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
const hasArchiveEvidence = Boolean(completedArchiveTask?.evidence_file || completedArchiveTask?.evidence_file_id);
const signoffArchiveEvidenceItems = detail?.signoff_archive?.items ?? [];
const signoffArchiveSummary = detail?.signoff_archive?.summary;
const openLifecycleTasks = lifecycleTasks.filter((task) => !["completed", "cancelled"].includes(task.status));
const lifecycleSummaryText = detail
? `${formatLifecycleStatus(detail.lifecycle?.status)} · ${formatDueStatus(detail.lifecycle)} · ${detail.lifecycle?.counterparty_name || "未记录相对方"}`
: "暂无生命周期信息";
const signoffSummaryText = `签署证据 ${signoffArchiveSummary?.signoff_evidence_count ?? 0} · 归档证据 ${signoffArchiveSummary?.archive_evidence_count ?? 0}`;
const auditSummaryText = `留痕 ${auditTimeline?.summary.total ?? 0}${auditActionSummaryItems.length ? ` · ${auditActionSummaryItems.slice(0, 2).map(([action, count]) => `${formatAuditActionLabel(action)} ${count}`).join(" / ")}` : ""}`;
const versionSummaryText = `版本 ${versionRows.length} 个 · 最新 ${latestVersionLabel} · 差异 ${versionDiff?.summary.change_count ?? 0}`;
const lifecycleTaskSummaryText = `履约任务 ${lifecycleTasks.length} 个 · 待处理 ${openLifecycleTasks.length}`;
const isSignedLifecycle = ["signed", "effective", "expiring", "expired", "terminated", "archived"].includes(detail?.lifecycle?.status ?? "");
const isArchivedLifecycle = detail?.lifecycle?.status === "archived";
const canDecideApproval = (approval?: ContractApprovalStep | null) => {
@@ -614,6 +631,9 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
if (isLegalOperatorRole) return true;
return Boolean(approval.assignee_id && approval.assignee_id === currentUser?.id);
};
const toggleDetailPanel = (key: ContractDetailPanelKey) => {
setExpandedDetailPanels((current) => ({ ...current, [key]: !current[key] }));
};
const buildApprovalTimelineColor = (item: ContractApprovalTimelineItem) => {
if (item.event === "rejected" || item.status === "rejected") return "red";
if (item.event === "approved") return "green";
@@ -1678,7 +1698,23 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Card>
) : null}
<Card id="contract-lifecycle" className="review-surface-card" title="生命周期与履约跟踪">
<Card
id="contract-lifecycle"
className={`review-surface-card contract-detail-foldable-card ${expandedDetailPanels.lifecycle ? "expanded" : "collapsed"}`}
title="生命周期与履约跟踪"
extra={
<Button size="small" type="text" onClick={() => toggleDetailPanel("lifecycle")}>
{expandedDetailPanels.lifecycle ? "收起" : "展开"}
</Button>
}
>
<div className="contract-detail-fold-summary">
<Space wrap>
<Tag color={getLifecycleStatusColor(detail?.lifecycle?.status)}>{formatLifecycleStatus(detail?.lifecycle?.status)}</Tag>
<Tag color={getDueStatusColor(detail?.lifecycle?.due_status)}>{formatDueStatus(detail?.lifecycle)}</Tag>
</Space>
<Text type="secondary">{lifecycleSummaryText}</Text>
</div>
{detail ? (
<Descriptions column={2} bordered>
<Descriptions.Item label="生命周期状态">
@@ -1702,10 +1738,13 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Card>
<Card
className="review-surface-card"
className={`review-surface-card contract-detail-foldable-card ${expandedDetailPanels.signoff ? "expanded" : "collapsed"}`}
title="签署与归档闭环"
extra={
<Space wrap>
<Button size="small" type="text" onClick={() => toggleDetailPanel("signoff")}>
{expandedDetailPanels.signoff ? "收起" : "展开"}
</Button>
{canManageLifecycleTasks ? (
<Button icon={<SafetyCertificateOutlined />} onClick={handleOpenSignoffEvidenceModal}>
{hasSignoffEvidence ? "更新签署证据" : "上传签署证据"}
@@ -1725,6 +1764,13 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Space>
}
>
<div className="contract-detail-fold-summary">
<Space wrap>
<Tag color={hasSignoffEvidence ? "green" : "gold"}>{hasSignoffEvidence ? "签署证据已留存" : "缺少签署证据"}</Tag>
<Tag color={hasArchiveEvidence ? "green" : "gold"}>{hasArchiveEvidence ? "归档证据已留存" : "缺少归档证据"}</Tag>
</Space>
<Text type="secondary">{signoffSummaryText}</Text>
</div>
<div className="contract-signoff-grid">
<section className="contract-signoff-step">
<div className="contract-signoff-step-heading">
@@ -1988,11 +2034,14 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
<Card
id="contract-audit"
className="review-surface-card"
className={`review-surface-card contract-detail-foldable-card ${expandedDetailPanels.audit ? "expanded" : "collapsed"}`}
loading={auditTimelineLoading}
title="合同审计时间线"
extra={
<Space wrap>
<Button size="small" type="text" onClick={() => toggleDetailPanel("audit")}>
{expandedDetailPanels.audit ? "收起" : "展开"}
</Button>
<Tag color="blue"> {auditTimeline?.summary.total ?? 0}</Tag>
<Button size="small" icon={<ReloadOutlined />} onClick={() => void reloadContractAuditTimeline(detail?.id, false)}>
@@ -2005,6 +2054,15 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Space>
}
>
<div className="contract-detail-fold-summary">
<Space wrap>
<Tag color="blue"> {auditTimeline?.summary.total ?? 0}</Tag>
{auditActionSummaryItems.slice(0, 3).map(([action, count]) => (
<Tag color={getAuditActionColor(action)} key={action}>{formatAuditActionLabel(action)} {count}</Tag>
))}
</Space>
<Text type="secondary">{auditSummaryText}</Text>
</div>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
{auditActionSummaryItems.length ? (
<div className="contract-audit-summary">
@@ -2047,10 +2105,13 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
<Card
id="contract-versions"
className="review-surface-card"
className={`review-surface-card contract-detail-foldable-card ${expandedDetailPanels.versions ? "expanded" : "collapsed"}`}
title="版本历史与比对"
extra={
<Space wrap>
<Button size="small" type="text" onClick={() => toggleDetailPanel("versions")}>
{expandedDetailPanels.versions ? "收起" : "展开"}
</Button>
<Button size="small" icon={<ReloadOutlined />} loading={versionLoading} onClick={() => void refreshVersionData()}>
</Button>
@@ -2062,6 +2123,14 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Space>
}
>
<div className="contract-detail-fold-summary">
<Space wrap>
<Tag color="blue"> {versionRows.length}</Tag>
<Tag color={openVersionComments.length ? "orange" : "default"}> {openVersionComments.length}</Tag>
<Tag> {versionDiff?.summary.change_count ?? 0} </Tag>
</Space>
<Text type="secondary">{versionSummaryText}</Text>
</div>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div className="review-detail-readability-grid">
<div className="review-detail-readability-item">
@@ -2333,10 +2402,13 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Card>
<Card
className="review-surface-card"
className={`review-surface-card contract-detail-foldable-card ${expandedDetailPanels.tasks ? "expanded" : "collapsed"}`}
title="履约任务与归档证据"
extra={
<Space wrap>
<Button size="small" type="text" onClick={() => toggleDetailPanel("tasks")}>
{expandedDetailPanels.tasks ? "收起" : "展开"}
</Button>
{canManageLifecycleTasks ? (
<Button loading={lifecycleTaskSyncing} onClick={() => void handleSyncLifecycleTasks()}>
@@ -2355,6 +2427,14 @@ export default function GeneratedContractDetailPage({ params }: { params: Promis
</Space>
}
>
<div className="contract-detail-fold-summary">
<Space wrap>
<Tag color="blue"> {lifecycleTasks.length}</Tag>
<Tag color={openLifecycleTasks.length ? "gold" : "green"}> {openLifecycleTasks.length}</Tag>
<Tag color={signoffArchiveEvidenceItems.length ? "green" : "default"}> {signoffArchiveEvidenceItems.length}</Tag>
</Space>
<Text type="secondary">{lifecycleTaskSummaryText}</Text>
</div>
<Table
rowKey="id"
loading={lifecycleTaskLoading}
+108 -50
View File
@@ -19,6 +19,7 @@ import {
Form,
Input,
message,
Pagination,
Select,
Space,
Spin,
@@ -104,6 +105,7 @@ type GenerationEntryContext = {
};
const GENERATE_RECOVERY_TIMEOUT_MS = 15000;
const CONTRACT_LIST_PAGE_SIZE = 20;
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -208,6 +210,7 @@ export default function ContractsPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>(DEFAULT_EXPANDED_KEYS);
const [listPage, setListPage] = useState(1);
// Detail state
const [lifecycleUpdating, setLifecycleUpdating] = useState(false);
@@ -413,6 +416,25 @@ export default function ContractsPage() {
items: filteredItems,
};
}, [filteredItems, selectedTreeKey]);
const contractListTotal = contractListView?.items.length ?? 0;
const contractListStart = contractListTotal ? (listPage - 1) * CONTRACT_LIST_PAGE_SIZE + 1 : 0;
const contractListEnd = Math.min(listPage * CONTRACT_LIST_PAGE_SIZE, contractListTotal);
const pagedContractListItems = useMemo(
() => contractListView?.items.slice((listPage - 1) * CONTRACT_LIST_PAGE_SIZE, listPage * CONTRACT_LIST_PAGE_SIZE) ?? [],
[contractListView, listPage],
);
useEffect(() => {
setListPage(1);
}, [searchQuery, selectedTreeKey]);
useEffect(() => {
if (!contractListTotal) return;
const maxPage = Math.max(1, Math.ceil(contractListTotal / CONTRACT_LIST_PAGE_SIZE));
if (listPage > maxPage) {
setListPage(maxPage);
}
}, [contractListTotal, listPage]);
const selectedTemplateRequiredCount = selectedTemplate?.variables.filter((item) => item.required).length ?? 0;
const generationStageTitle = generatedContract
@@ -696,7 +718,10 @@ export default function ContractsPage() {
<div className="contract-header-row">
<div className="contract-header-title">
<Title level={3} style={{ margin: 0 }}>{contractListView.title}</Title>
<Text type="secondary">{contractListView.description}</Text>
<Text type="secondary">
{contractListView.description}
{contractListTotal > CONTRACT_LIST_PAGE_SIZE ? `,当前显示 ${contractListStart}-${contractListEnd}` : ""}
</Text>
<Text type="secondary"></Text>
</div>
<Space size={8}>
@@ -712,57 +737,71 @@ export default function ContractsPage() {
<div className="contract-list-panel">
{contractListView.items.length ? (
<div className="contract-list">
{contractListView.items.map((contract) => (
<div className="contract-list-row" key={contract.id}>
<Link
href={`/contracts/${contract.id}`}
className="contract-list-main contract-list-main-link"
>
<Text strong>{contract.template_name || contract.file_name}</Text>
<Text type="secondary" className="contract-list-file">{contract.file_name}</Text>
<Space size={[6, 6]} wrap>
<Tag>{formatContractType(contract.document_type_code || contract.contract_type)}</Tag>
{contract.lifecycle?.status && (
<Tag color={getLifecycleStatusColor(contract.lifecycle.status)}>
{formatLifecycleStatus(contract.lifecycle.status)}
</Tag>
)}
{contract.lifecycle?.due_status && (
<Tag color={getDueStatusColor(contract.lifecycle.due_status)}>
{formatDueStatus(contract.lifecycle)}
</Tag>
)}
<Tag>{contract.export_format.toUpperCase()}</Tag>
<Tag color="geekblue">{(contract.file_size / 1024).toFixed(1)} KB</Tag>
{renderAutoReviewTags(contract.auto_review)}
<>
<div className="contract-list">
{pagedContractListItems.map((contract) => (
<div className="contract-list-row" key={contract.id}>
<Link
href={`/contracts/${contract.id}`}
className="contract-list-main contract-list-main-link"
>
<Text strong>{contract.template_name || contract.file_name}</Text>
<Text type="secondary" className="contract-list-file">{contract.file_name}</Text>
<Space size={[6, 6]} wrap>
<Tag>{formatContractType(contract.document_type_code || contract.contract_type)}</Tag>
{contract.lifecycle?.status && (
<Tag color={getLifecycleStatusColor(contract.lifecycle.status)}>
{formatLifecycleStatus(contract.lifecycle.status)}
</Tag>
)}
{contract.lifecycle?.due_status && (
<Tag color={getDueStatusColor(contract.lifecycle.due_status)}>
{formatDueStatus(contract.lifecycle)}
</Tag>
)}
<Tag>{contract.export_format.toUpperCase()}</Tag>
<Tag color="geekblue">{(contract.file_size / 1024).toFixed(1)} KB</Tag>
{renderAutoReviewTags(contract.auto_review)}
</Space>
<Text type="secondary">
{formatDateTime(contract.generated_at)}
{" · "}
{contract.metadata?.generated_by_name || "未知"}
</Text>
<Text type="secondary" className="contract-list-file">
{getAutoReviewNextStep(contract.auto_review)}
</Text>
</Link>
<Space size={8} wrap className="contract-list-actions">
<Tooltip title="下载合同文件">
<Button
aria-label={`下载合同:${contract.template_name || contract.file_name}`}
className="contract-list-download-button"
size="small"
shape="circle"
icon={<DownloadOutlined />}
loading={listDownloadingId === contract.id}
disabled={Boolean(listDownloadingId && listDownloadingId !== contract.id)}
onClick={() => void handleDownloadListItem(contract)}
/>
</Tooltip>
</Space>
<Text type="secondary">
{formatDateTime(contract.generated_at)}
{" · "}
{contract.metadata?.generated_by_name || "未知"}
</Text>
<Text type="secondary" className="contract-list-file">
{getAutoReviewNextStep(contract.auto_review)}
</Text>
</Link>
<Space size={8} wrap className="contract-list-actions">
<Tooltip title="下载合同文件">
<Button
aria-label={`下载合同:${contract.template_name || contract.file_name}`}
className="contract-list-download-button"
size="small"
shape="circle"
icon={<DownloadOutlined />}
loading={listDownloadingId === contract.id}
disabled={Boolean(listDownloadingId && listDownloadingId !== contract.id)}
onClick={() => void handleDownloadListItem(contract)}
/>
</Tooltip>
</Space>
</div>
))}
</div>
{contractListTotal > CONTRACT_LIST_PAGE_SIZE ? (
<div className="contract-list-pagination">
<Text type="secondary"> {contractListStart}-{contractListEnd} / {contractListTotal}</Text>
<Pagination
current={listPage}
onChange={setListPage}
pageSize={CONTRACT_LIST_PAGE_SIZE}
showSizeChanger={false}
total={contractListTotal}
/>
</div>
))}
</div>
) : null}
</>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前分类暂无合同" />
)}
@@ -1351,6 +1390,16 @@ export default function ContractsPage() {
flex: 0 0 auto;
}
.contract-list-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-top: 1px solid #f0f0f0;
background: #fbfdff;
}
.contract-header-row {
display: flex;
justify-content: space-between;
@@ -1690,6 +1739,15 @@ export default function ContractsPage() {
.contract-list-actions .ant-space-item {
flex: 0 0 auto;
}
.contract-list-pagination {
align-items: stretch;
flex-direction: column;
}
.contract-list-pagination .ant-pagination {
text-align: left;
}
}
`}</style>
</main>
File diff suppressed because it is too large Load Diff
+341 -6
View File
@@ -1465,6 +1465,192 @@ a {
line-height: 1.5;
}
.workspace-focus-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.workspace-focus-strip span {
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
color: #334155;
background: rgba(255, 255, 255, 0.76);
font-size: 13px;
line-height: 1.45;
}
.workspace-today-panel {
display: grid;
gap: 16px;
margin-top: 28px;
padding: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.05);
}
.workspace-panel-head,
.workspace-column-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workspace-panel-head h2 {
margin: 0;
color: var(--ink);
font-size: 24px;
}
.workspace-panel-head p {
margin: 4px 0 0;
color: var(--muted);
}
.workspace-today-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
gap: 16px;
}
.workspace-task-column,
.workspace-flow-column {
display: grid;
align-content: start;
gap: 12px;
min-width: 0;
}
.workspace-compact-list,
.workspace-flow-list {
display: grid;
gap: 8px;
}
.workspace-compact-row,
.workspace-flow-row,
.workspace-tool-link,
.workspace-admin-link {
color: inherit;
text-decoration: none;
}
.workspace-compact-row {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 8px;
background: #fbfdff;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.workspace-compact-row:hover,
.workspace-flow-row:hover,
.workspace-tool-link:hover,
.workspace-admin-link:hover {
border-color: rgba(22, 119, 255, 0.28);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.workspace-compact-row span,
.workspace-compact-row small,
.workspace-flow-row small,
.workspace-tool-link small,
.workspace-admin-link small {
color: var(--muted);
line-height: 1.55;
}
.workspace-compact-row span {
font-size: 12px;
font-weight: 800;
}
.workspace-compact-row strong,
.workspace-flow-row strong,
.workspace-tool-link strong,
.workspace-admin-link strong {
color: var(--ink);
line-height: 1.35;
}
.workspace-flow-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 8px;
background: #fff;
}
.workspace-flow-row > span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
color: #fff;
background: var(--primary);
font-size: 13px;
font-weight: 900;
}
.workspace-flow-row > div {
display: grid;
gap: 3px;
min-width: 0;
}
.workspace-tools-section,
.workspace-admin-section {
margin-top: 24px;
}
.workspace-section-header-compact h2 {
font-size: 22px;
}
.workspace-tool-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 10px;
}
.workspace-tool-link {
display: grid;
gap: 5px;
min-height: 96px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
}
.workspace-admin-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.workspace-admin-link {
display: grid;
gap: 4px;
min-height: 74px;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 8px;
background: #fbfdff;
}
.landing-hero-panel {
display: flex;
flex-direction: column;
@@ -6336,6 +6522,10 @@ body {
grid-template-columns: 1fr;
}
.workspace-today-grid {
grid-template-columns: 1fr;
}
.landing-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -6354,6 +6544,21 @@ body {
padding: 8px 14px 10px;
}
.workspace-panel-head,
.workspace-column-head {
align-items: stretch;
flex-direction: column;
}
.workspace-today-panel {
padding: 14px;
}
.workspace-tool-grid,
.workspace-admin-strip {
grid-template-columns: 1fr;
}
.app-brand {
width: auto;
min-width: 0;
@@ -7416,6 +7621,100 @@ textarea.ant-input:focus,
gap: 10px;
}
.contract-detail-foldable-card.collapsed .ant-card-body > *:not(.contract-detail-fold-summary) {
display: none !important;
}
.contract-detail-fold-summary {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid rgba(61, 95, 129, 0.12);
border-radius: 8px;
background: #fbfdff;
}
.contract-detail-foldable-card.expanded .contract-detail-fold-summary {
margin-bottom: 14px;
}
.approval-workbench-focus {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.approval-workbench-focus-panel,
.approval-workbench-expanded-row {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(61, 95, 129, 0.12);
border-radius: 8px;
background: #fbfdff;
}
.approval-workbench-focus-panel-warning {
background: #fffaf0;
}
.approval-workbench-focus-head,
.approval-workbench-focus-item,
.approval-workbench-expanded-head,
.approval-workbench-timeline-item > div {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.approval-workbench-focus-head > div,
.approval-workbench-focus-item > div,
.approval-workbench-timeline-item {
display: grid;
gap: 4px;
min-width: 0;
}
.approval-workbench-focus-list {
display: grid;
gap: 8px;
}
.approval-workbench-focus-item {
padding: 10px;
border: 1px solid rgba(61, 95, 129, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.approval-workbench-timeline {
padding: 4px 0 0;
}
.contract-approval-page .contract-ledger-table-shell .ant-table-thead > tr > th,
.contract-approval-page .contract-ledger-table-shell .ant-table-tbody > tr > td {
padding: 12px 14px;
}
.contract-approval-page .ant-empty {
padding: 20px 0;
}
@media (max-width: 900px) {
.approval-workbench-focus {
grid-template-columns: 1fr;
}
.approval-workbench-focus-head,
.approval-workbench-focus-item,
.approval-workbench-expanded-head,
.approval-workbench-timeline-item > div {
align-items: stretch;
flex-direction: column;
}
}
.contract-audit-summary {
display: flex;
flex-wrap: wrap;
@@ -7792,6 +8091,20 @@ textarea.ant-input:focus,
align-content: center;
}
.unified-search-sortbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.unified-search-highlight {
padding: 0 2px;
border-radius: 4px;
background: #fff3bf;
color: #7a4b00;
}
.unified-search-summary {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -7844,11 +8157,28 @@ textarea.ant-input:focus,
.unified-search-result-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr);
align-items: flex-start;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
color: inherit;
text-decoration: none;
transition: background 0.18s ease, border-color 0.18s ease;
}
.unified-search-result-card:hover {
background: rgba(22, 119, 255, 0.035);
}
.unified-search-result-card:hover .unified-search-result-title {
color: var(--primary);
}
.unified-search-result-card:focus-visible {
border-radius: 8px;
outline: 2px solid rgba(22, 119, 255, 0.3);
outline-offset: 2px;
}
.unified-search-result-list > div:last-child .unified-search-result-card {
@@ -7871,6 +8201,11 @@ textarea.ant-input:focus,
color: #fff;
}
.unified-search-result-title {
color: var(--ink);
font-weight: 900;
}
.unified-search-result-icon-contract {
background: #1677ff;
}
@@ -7901,12 +8236,12 @@ textarea.ant-input:focus,
min-width: 0;
}
.unified-search-sortbar {
align-items: flex-start;
flex-direction: column;
}
.unified-search-result-card {
grid-template-columns: auto minmax(0, 1fr);
}
.unified-search-result-card > a {
grid-column: 2;
justify-self: start;
}
}
+53 -12
View File
@@ -7,7 +7,7 @@ import {
FileSearchOutlined,
SearchOutlined,
} from "@ant-design/icons";
import { Button, Empty, Input, message, Segmented, Space, Spin, Tag, Typography } from "antd";
import { Empty, Input, message, Segmented, Space, Spin, Tag, Typography } from "antd";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -25,6 +25,13 @@ const TYPE_OPTIONS: Array<{ label: string; value: UnifiedSearchType | "all" }> =
{ label: "模板", value: "template" },
];
type SearchSortMode = "updated_desc" | "default";
const SORT_OPTIONS: Array<{ label: string; value: SearchSortMode }> = [
{ label: "按更新时间", value: "updated_desc" },
{ label: "默认排序", value: "default" },
];
const TYPE_META: Record<UnifiedSearchType, { label: string; icon: React.ReactNode; color: string }> = {
contract: { label: "合同", icon: <FileDoneOutlined />, color: "blue" },
review: { label: "审查", icon: <FileProtectOutlined />, color: "red" },
@@ -36,6 +43,33 @@ function typeGroupKey(type: UnifiedSearchType) {
return type === "contract" ? "contracts" : type === "review" ? "reviews" : type === "file" ? "files" : "templates";
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlightText(value: string | null | undefined, query: string) {
if (!value) return null;
const keyword = query.trim();
if (!keyword) return value;
const regex = new RegExp(`(${escapeRegExp(keyword)})`, "ig");
const exactRegex = new RegExp(`^${escapeRegExp(keyword)}$`, "i");
const parts = value.split(regex);
return parts.map((part, index) => (
exactRegex.test(part) ? (
<mark className="unified-search-highlight" key={`${part}-${index}`}>{part}</mark>
) : part
));
}
function sortSearchItems(items: UnifiedSearchItem[], sortMode: SearchSortMode) {
if (sortMode !== "updated_desc") return items;
return [...items].sort((a, b) => {
const left = new Date(a.updated_at || a.created_at || 0).getTime();
const right = new Date(b.updated_at || b.created_at || 0).getTime();
return right - left;
});
}
function formatItemStatus(item: UnifiedSearchItem) {
if (!item.status) return null;
if (item.type === "contract") {
@@ -60,23 +94,23 @@ function formatTag(item: UnifiedSearchItem, tag: string) {
return tag;
}
function renderSearchItem(item: UnifiedSearchItem) {
function renderSearchItem(item: UnifiedSearchItem, query: string) {
const meta = TYPE_META[item.type];
const status = formatItemStatus(item);
const riskTag = item.type === "review" ? item.tags.find((tag) => ["critical", "high", "medium", "low"].includes(tag)) : null;
return (
<article className="unified-search-result-card">
<Link className="unified-search-result-card" href={item.href}>
<span className={`unified-search-result-icon unified-search-result-icon-${item.type}`}>{meta.icon}</span>
<div className="unified-search-result-body">
<Space wrap>
<Link href={item.href}>{item.title}</Link>
<strong className="unified-search-result-title">{highlightText(item.title, query)}</strong>
<Tag color={meta.color}>{meta.label}</Tag>
{status ? <Tag color={status.color}>{status.label}</Tag> : null}
{riskTag ? <Tag color={getRiskColor(riskTag)}>{formatRisk(riskTag)}</Tag> : null}
</Space>
<Space orientation="vertical" size={4} style={{ width: "100%" }}>
{item.subtitle ? <Text type="secondary">{item.subtitle}</Text> : null}
{item.snippet ? <Paragraph className="unified-search-snippet">{item.snippet}</Paragraph> : null}
{item.subtitle ? <Text type="secondary">{highlightText(item.subtitle, query)}</Text> : null}
{item.snippet ? <Paragraph className="unified-search-snippet">{highlightText(item.snippet, query)}</Paragraph> : null}
<Space wrap size={[4, 4]}>
{item.tags.slice(0, 5).map((tag) => (
<Tag key={tag}>{formatTag(item, tag)}</Tag>
@@ -85,10 +119,7 @@ function renderSearchItem(item: UnifiedSearchItem) {
</Space>
</Space>
</div>
<Link href={item.href}>
<Button size="small" type="link"></Button>
</Link>
</article>
</Link>
);
}
@@ -102,6 +133,7 @@ export default function UnifiedSearchPage() {
const [activeType, setActiveType] = useState<UnifiedSearchType | "all">(initialType);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<UnifiedSearchResponse | null>(null);
const [sortMode, setSortMode] = useState<SearchSortMode>("updated_desc");
const groupedTypes = useMemo<UnifiedSearchType[]>(() => {
if (activeType !== "all") return [activeType];
@@ -171,6 +203,15 @@ export default function UnifiedSearchPage() {
options={TYPE_OPTIONS}
value={activeType}
/>
<div className="unified-search-sortbar">
<Text type="secondary"></Text>
<Segmented<SearchSortMode>
onChange={setSortMode}
options={SORT_OPTIONS}
size="small"
value={sortMode}
/>
</div>
</div>
</section>
@@ -193,7 +234,7 @@ export default function UnifiedSearchPage() {
<Empty description={query ? "没有找到匹配结果" : "输入关键词后开始搜索"} />
) : (
groupedTypes.map((type) => {
const items = result.groups[typeGroupKey(type)] ?? [];
const items = sortSearchItems(result.groups[typeGroupKey(type)] ?? [], sortMode);
if (!items.length) return null;
return (
<div className="unified-search-group" key={type}>
@@ -206,7 +247,7 @@ export default function UnifiedSearchPage() {
</div>
<div className="unified-search-result-list">
{items.map((item) => (
<div key={`${item.type}-${item.id}`}>{renderSearchItem(item)}</div>
<div key={`${item.type}-${item.id}`}>{renderSearchItem(item, query)}</div>
))}
</div>
</div>
+103 -149
View File
@@ -14,7 +14,6 @@ import {
formatReviewAs,
formatRisk,
formatStatus,
getAutoReviewStatusColor,
getRiskColor,
getStatusColor,
} from "@/lib/format";
@@ -66,19 +65,6 @@ function buildContractStats(items: GeneratedContractListItem[]) {
};
}
function getContractAutoReviewSummary(item: GeneratedContractListItem) {
const autoReview = item.auto_review;
if (!autoReview) return "生成后将自动发起预审";
if (autoReview.next_action) return autoReview.next_action;
if (autoReview.review_opinion) return autoReview.review_opinion;
if (autoReview.status_detail) return autoReview.status_detail;
if (autoReview.current_stage_label) return autoReview.current_stage_label;
if (autoReview.status === "completed") return "自动预审已完成,可进入审查详情或下载报告。";
if (autoReview.status === "processing" || autoReview.status === "pending") return "自动预审处理中,稍后查看风险结论。";
if (autoReview.status === "failed" || autoReview.status === "partial") return autoReview.failure_reason || "自动预审未完整完成,建议进入详情查看原因。";
return "可进入合同详情查看预审状态。";
}
function buildPriorityTasks(items: ReviewListItem[]) {
const highRiskItems = items.filter((item) => item.risk_level === "high").slice(0, 2);
const blockedItems = items.filter((item) => item.status === "failed" || item.status === "partial").slice(0, 2);
@@ -176,6 +162,14 @@ export default function WorkspacePage() {
const stats = useMemo(() => buildStats(reviews), [reviews]);
const contractStats = useMemo(() => buildContractStats(contracts), [contracts]);
const contractReminderItems = useMemo(() => {
return contracts
.filter((item) => item.ledger_attention?.is_mine || item.lifecycle?.due_status === "due_soon" || item.lifecycle?.due_status === "overdue")
.sort((a, b) => Number(Boolean(b.ledger_attention?.is_mine)) - Number(Boolean(a.ledger_attention?.is_mine)))
.slice(0, 4);
}, [contracts]);
const notifications: never[] = [];
const myContractTodoCount = contracts.filter((item) => item.ledger_attention?.is_mine).length;
const recentReviews = useMemo(() => reviews.slice(0, 5), [reviews]);
const recentContracts = useMemo(() => contracts.slice(0, 5), [contracts]);
const latestReview = recentReviews[0];
@@ -231,6 +225,12 @@ export default function WorkspacePage() {
const workspaceSubtitle = activeTask
? `当前优先任务:${activeTask.title}。也可以直接新建合同,完成选择模板、生成、自动预审和报告交付。`
: "工作台优先引导合同起草;如果客户已经有合同原文,也可以直接上传 PDF / DOCX / TXT 进入审查主链。";
const workspacePrimaryActions = useMemo(() => [
{ href: "/contracts", label: "????", description: "?????????????????" },
{ href: "/review", label: "????", description: "????????????????" },
{ href: "/files", label: "????", description: "???????????????" },
{ href: "/search", label: "????", description: "?????????????????" },
], []);
const workspaceFocusItems = activeTask
? [
`优先任务:${activeTask.title}`,
@@ -280,11 +280,10 @@ export default function WorkspacePage() {
<div className="landing-stat-card"><strong>{contractStats.autoReviewCompleted}</strong><span></span></div>
<div className="landing-stat-card"><strong>{stats.totalReviews}</strong><span></span></div>
</div>
<div className="landing-workbench-strip">
<div className="landing-workbench-card"><span></span><strong>{latestContract ? latestContract.template_name || latestContract.file_name : "暂无合同"}</strong></div>
<div className="landing-workbench-card"><span></span><strong>{latestContract?.auto_review ? formatAutoReviewStatus(latestContract.auto_review.status) : "未触发"}</strong></div>
<div className="landing-workbench-card"><span></span><strong>{currentUser?.role === "admin" ? "管理员" : currentUser?.role}</strong></div>
<div className="landing-workbench-card"><span></span><strong>{contracts.filter((item) => item.auto_review?.report_download_url).length}</strong></div>
<div className="workspace-focus-strip">
{workspaceFocusItems.slice(0, 3).map((item) => (
<span key={item}>{item}</span>
))}
</div>
</div>
@@ -305,162 +304,117 @@ export default function WorkspacePage() {
{latestContract ? <Link href={`/contracts/${latestContract.id}`}></Link> : <Link href="/contracts"></Link>}
</div>
<div className="landing-panel-card landing-panel-card-soft">
<span className="landing-panel-eyebrow">Today Focus</span>
<strong></strong>
<span className="landing-panel-eyebrow">Session</span>
<strong>{currentUser?.role === "admin" ? "管理员视角" : "业务视角"}</strong>
<ul className="landing-panel-list landing-panel-list-soft">
{workspaceFocusItems.map((item) => (
<li key={item}>{item}</li>
))}
<li>{myContractTodoCount}</li>
<li>{stats.completedCount}</li>
<li>{latestReview?.risk_level ? formatRisk(latestReview.risk_level) : "-"}</li>
</ul>
</div>
</div>
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p></p>
</div>
<div className="landing-grid">
<Link className="landing-card" href="/contracts">
<span className="landing-card-eyebrow">01 Contract Drafting</span>
<strong></strong>
<span></span>
</Link>
<Link className="landing-card" href="/review">
<span className="landing-card-eyebrow">02 Upload Review</span>
<strong></strong>
<span></span>
</Link>
<Link className="landing-card" href={latestContract?.auto_review?.detail_url || (latestReview ? `/reviews/${latestReview.id}` : "/reviews")}>
<span className="landing-card-eyebrow">03 Report Delivery</span>
<strong></strong>
<span></span>
</Link>
</div>
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p></p>
</div>
{dashboardHighlights.length === 0 ? (
<Empty description="当前没有需要优先处理的任务。" />
) : (
<div className="landing-grid">
{dashboardHighlights.map((item) => (
<Link className="landing-card" href={item.href} key={`${item.eyebrow}-${item.href}`}>
<span className="landing-card-eyebrow">{item.eyebrow}</span>
<strong>{item.title}</strong>
<span>{item.description}</span>
</Link>
))}
<section className="workspace-today-panel">
<div className="workspace-panel-head">
<div>
<h2></h2>
<p></p>
</div>
)}
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p>便</p>
<Link href="/contract-ledger"></Link>
</div>
{loading ? (
<div style={{ padding: 32, textAlign: "center" }}><Spin /></div>
) : recentContracts.length === 0 ? (
<Empty description="还没有起草记录,先新建一份合同。" />
) : (
<div className="landing-grid">
{recentContracts.slice(0, 3).map((item) => (
<Link className="landing-card" href={`/contracts/${item.id}`} key={item.id}>
<span className="landing-card-eyebrow">{formatDateTime(item.generated_at)}</span>
<strong>{item.template_name || item.file_name}</strong>
<span>{formatContractType(item.document_type_code || item.contract_type)} · {getContractAutoReviewSummary(item)}</span>
<Space wrap>
<Tag color={getAutoReviewStatusColor(item.auto_review?.status)}>
{formatAutoReviewStatus(item.auto_review?.status)}
</Tag>
<Tag color={item.auto_review?.risk_level ? getRiskColor(item.auto_review.risk_level) : "default"}>
{item.auto_review?.risk_level ? formatRisk(item.auto_review.risk_level) : "未评级"}
</Tag>
{item.auto_review?.report_download_url ? <Tag color="green"></Tag> : null}
</Space>
</Link>
))}
<div className="workspace-today-grid">
<div className="workspace-task-column">
<div className="workspace-column-head">
<strong></strong>
<Tag color={myContractTodoCount ? "orange" : "default"}>{myContractTodoCount} </Tag>
</div>
{notifications.length === 0 && contractReminderItems.length === 0 && dashboardHighlights.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前没有需要优先处理的任务。" />
) : (
<div className="workspace-compact-list">
{notifications.slice(0, 4).map((item) => (
<Link className="workspace-compact-row" href={item.href} key={item.id}>
<span>{item.title}</span>
<strong>{item.contract_title ?? item.title}</strong>
<small>{item.description ?? "请进入详情继续处理。"}</small>
<Space wrap size={6}>
<Tag color={item.priority === "critical" ? "red" : item.priority === "high" ? "orange" : "blue"}>
{item.priority === "critical" ? "紧急" : item.priority === "high" ? "高优先级" : "提醒"}
</Tag>
{item.is_mine ? <Tag color="orange"></Tag> : null}
</Space>
</Link>
))}
{notifications.length === 0 ? contractReminderItems.slice(0, 3).map((item) => (
<Link className="workspace-compact-row" href={`/contracts/${item.id}`} key={item.id}>
<span>{item.ledger_attention?.primary?.label ?? "合同提醒"}</span>
<strong>{item.template_name}</strong>
<small>{item.ledger_attention?.primary?.description ?? "请进入合同详情继续处理生命周期事项。"}</small>
<Space wrap size={6}>
{item.ledger_attention?.is_mine ? <Tag color="orange"></Tag> : null}
<Tag>{item.lifecycle?.due_status === "overdue" ? "已逾期" : item.lifecycle?.due_status === "due_soon" ? "即将到期" : "生命周期"}</Tag>
</Space>
</Link>
)) : null}
{dashboardHighlights.slice(0, notifications.length || contractReminderItems.length ? 1 : 3).map((item) => (
<Link className="workspace-compact-row" href={item.href} key={`${item.eyebrow}-${item.href}`}>
<span>{item.eyebrow}</span>
<strong>{item.title}</strong>
<small>{item.description}</small>
</Link>
))}
</div>
)}
</div>
)}
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p>退</p>
</div>
{notifications.length === 0 && contractReminderItems.length === 0 ? (
<Empty description="当前没有站内提醒或合同待办。" />
) : (
<div className="landing-grid">
{notifications.map((item) => (
<Link className="landing-card" href={item.href} key={item.id}>
<span className="landing-card-eyebrow">{item.title}</span>
<strong>{item.contract_title ?? item.title}</strong>
<span>{item.description ?? "请进入详情继续处理。"}</span>
<Space wrap>
<Tag color={item.priority === "critical" ? "red" : item.priority === "high" ? "orange" : "blue"}>
{item.priority === "critical" ? "紧急" : item.priority === "high" ? "高优先级" : "提醒"}
</Tag>
{item.is_mine ? <Tag color="orange"></Tag> : null}
</Space>
</Link>
))}
{notifications.length === 0 ? contractReminderItems.map((item) => (
<Link className="landing-card" href={`/contracts/${item.id}`} key={item.id}>
<span className="landing-card-eyebrow">{item.ledger_attention?.primary?.label ?? "合同提醒"}</span>
<strong>{item.template_name}</strong>
<span>{item.ledger_attention?.primary?.description ?? "请进入合同详情继续处理生命周期事项。"}</span>
<Space wrap>
{item.ledger_attention?.is_mine ? <Tag color="orange"></Tag> : null}
<Tag>{item.lifecycle?.due_status === "overdue" ? "已逾期" : item.lifecycle?.due_status === "due_soon" ? "即将到期" : "生命周期"}</Tag>
</Space>
</Link>
)) : null}
<Link className="landing-card" href="/contract-ledger">
<span className="landing-card-eyebrow">Ledger</span>
<strong></strong>
<span></span>
</Link>
<div className="workspace-flow-column">
<div className="workspace-column-head">
<strong></strong>
<Tag color="blue">3 </Tag>
</div>
<div className="workspace-flow-list">
{workspacePrimaryActions.map((item, index) => (
<Link className="workspace-flow-row" href={item.href} key={item.href}>
<span>{index + 1}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</section>
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p></p>
<section className="landing-section workspace-tools-section">
<div className="landing-section-header workspace-section-header-compact">
<h2></h2>
<p></p>
</div>
<div className="landing-grid">
<div className="workspace-tool-grid">
{workflowLinks.map((item) => (
<Link className="landing-card" href={item.href} key={`${item.eyebrow}-${item.href}`}>
<Link className="workspace-tool-link" href={item.href} key={`${item.eyebrow}-${item.href}`}>
<span className="landing-card-eyebrow">{item.eyebrow}</span>
<strong>{item.label}</strong>
<span>{item.description}</span>
<small>{item.description}</small>
</Link>
))}
</div>
</section>
{adminWorkbenchLinks.length ? (
<section className="landing-section">
<div className="landing-section-header">
<h2></h2>
<p></p>
<section className="landing-section workspace-admin-section">
<div className="landing-section-header workspace-section-header-compact">
<h2></h2>
<p></p>
</div>
<div className="landing-grid">
<div className="workspace-admin-strip">
{adminWorkbenchLinks.map((item) => (
<Link className="landing-card" href={item.href} key={item.href}>
<span className="landing-card-eyebrow">Admin Workbench</span>
<Link className="workspace-admin-link" href={item.href} key={item.href}>
<strong>{item.label}</strong>
<span>{item.description}</span>
<small>{item.description}</small>
</Link>
))}
</div>
@@ -51,6 +51,7 @@ function getPrivateNavItems() {
{ href: "/workspace", label: "工作台", icon: <DashboardOutlined /> },
{ href: "/contracts", label: "合同起草", icon: <FileDoneOutlined /> },
{ href: "/review", label: "发起审查", icon: <FileProtectOutlined /> },
{ href: "/files", label: "合同仓库", icon: <FolderOpenOutlined /> },
{ href: "/reviews", label: "审查记录", icon: <HistoryOutlined /> },
{ href: "/search", label: "统一搜索", icon: <SearchOutlined /> },
{ href: "/chat", label: "法律问答", icon: <CommentOutlined /> },
@@ -71,7 +72,6 @@ const adminNavItems = [
{ href: "/operation-kpis", label: "运营 KPI", icon: <BarChartOutlined /> },
{ href: "/sales-followups", label: "销售跟进", icon: <PhoneOutlined /> },
{ href: "/legal-articles", label: "法条全文库", icon: <FileSearchOutlined /> },
{ href: "/files", label: "文件管理", icon: <FileDoneOutlined /> },
{ href: "/tenant-settings", label: "租户设置", icon: <SettingOutlined /> },
{ href: "/billing", label: "用量计费", icon: <PayCircleOutlined /> },
{ href: "/pricing", label: "套餐计费", icon: <PayCircleOutlined /> },
+5 -2
View File
@@ -1,6 +1,8 @@
import { http } from "@/services/http";
import type { ApiResponse, FileCreatePayload, FileDownloadUrlResponse, FileItem, FileListResponse } from "@/types/api";
const FILE_DOWNLOAD_PROXY_PREFIX = "/api/v1";
export async function listFiles(page = 1, pageSize = 20, fileType?: string) {
const response = await http.get<ApiResponse<FileListResponse>>("/files", {
params: {
@@ -39,7 +41,8 @@ export async function deleteFile(fileId: string) {
}
export async function downloadFileContent(fileId: string) {
const response = await http.get<Blob>(`/files/${fileId}/download`, {
const response = await http.get<Blob>(`${FILE_DOWNLOAD_PROXY_PREFIX}/files/${fileId}/download`, {
baseURL: "",
responseType: "blob",
});
return response.data;
@@ -48,4 +51,4 @@ export async function downloadFileContent(fileId: string) {
export async function getDownloadUrl(fileId: string) {
const response = await http.get<ApiResponse<FileDownloadUrlResponse>>(`/files/${fileId}/download-url`);
return response.data;
}
}
@@ -0,0 +1,84 @@
# 后端文件下载 / MinIO 对象读取链路排查记录
日期:2026-06-21
范围:`/api/v1/files/{file_id}/download`、MinIO 对象读取、合同/审查报告下载兜底。
## 现象
- 前端文件仓库点击下载/预览时,请求进入同源代理后,后端 `/api/v1/files/{file_id}/download` 返回 500。
- 后端文件列表接口正常,说明数据库记录可读,错误发生在对象存储读取阶段。
## 复现证据
使用生产后端 `http://192.168.3.90:8001/api/v1` 和管理员 token
- `/files?page=1&page_size=8` 正常返回,总数 186。
- 前 8 条文件下载全部 500。
- 这些失败文件主要位于业务 bucket:`generated`
- 文件类型分布:合同文件 96 条、审查报告 60 条均在 `generated`;正式上线材料在 `formal-launch-*`
最小探针结果:
- 上传到 `contracts` bucket:上传 200、下载 200、删除 200。
- 上传到 `generated``default``formal-launch-materials`:上传 500。
## 根因
`File.bucket` 同时承担了两个语义:
1. 业务分类,例如 `generated``formal-launch-materials`
2. MinIO 物理 bucket 名称。
生产 MinIO 凭据当前可访问的物理 bucket 是 `contracts`,而历史代码把业务 bucket 直接当成物理 bucket 使用。结果是:合同/报告生成时写 `generated`,文件仓库读取也读 `generated`,在当前生产凭据下触发 MinIO 权限/桶访问失败,FastAPI 最终表现为 500。
readiness 只检查了 `MINIO_BUCKET=contracts` 等配置是否存在,没有验证数据库中历史业务 bucket 对象是否可读,因此没有提前发现该问题。
## 修复内容
文件:`backend/app/services/file_service.py`
- 新上传/生成文件:`File.bucket` 继续保存业务 bucket,便于前端展示和筛选;MinIO 物理写入统一走 `settings.minio_bucket`,即 `contracts`
- 读取历史文件:先按当前配置物理 bucket 读取,再按记录中的旧 bucket 读取,兼容旧数据。
- MinIO `NoSuchKey/NoSuchBucket` 转成 404;其他 S3 错误转成 503,避免继续裸抛 500。
- 合同类历史文件对象不可读时,尝试从 `generated_contracts.file_content_base64` 恢复下载内容。
测试:`backend/tests/test_files_api.py`
- 覆盖业务 bucket `generated` 物理写入配置 bucket `contracts`
- 覆盖旧合同文件对象不可读时从合同表恢复。
- 保留缺失对象 404、owner 下载、local storage 等原有场景。
## 验证结果
已执行:
```powershell
python -m pytest backend/tests/test_files_api.py -q
```
结果:`12 passed`
已执行:
```powershell
python -m pytest backend/tests/test_files_api.py backend/tests/test_contracts_api.py::ContractGenerationServiceTestCase::test_get_generated_contract_download_allows_owner_access_to_file_payload backend/tests/test_contracts_api.py::ContractGenerationServiceTestCase::test_get_generated_contract_download_recovers_when_file_storage_read_fails backend/tests/test_reviews_api.py::ReviewsApiTestCase::test_get_result_and_download_report -q
```
结果:`15 passed`
备注:pytest 输出有 `.pytest_cache` 写入权限警告,不影响测试通过。
## 部署后复测建议
部署 backend 后复测:
1. 用文件仓库新上传一个 txt/docx,确认上传成功。
2. 立即下载该新文件,预期 200。
3. 下载历史合同文件,例如文件列表中的合同 docx,预期可通过合同表冗余内容恢复。
4. 下载历史审查报告,预期审查报告接口可重建报告;文件仓库里直接下载旧 PDF 如无冗余内容,仍可能需要重新生成或迁移旧对象。
5. 再做一次探针:上传 bucket=`generated` 时,数据库记录仍显示 `generated`,但物理对象应写入 `contracts`,下载应 200。
## 后续建议
- readiness 增加对象存储真实读写探针,不能只检查配置项。
- 中长期建议拆分字段:`storage_bucket` 存物理桶,`bucket``category` 存业务分类,避免语义混用。