fix: stabilize file downloads and file workspace
This commit is contained in:
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
? "通过后合同会进入已审批状态,可继续签署或归档。"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+739
-114
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 /> },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -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` 存业务分类,避免语义混用。
|
||||
Reference in New Issue
Block a user