feat: UI优化和功能完善

- 添加公共分页组件,支持上边显示、分页大小10
- 侧边栏/Header按Liner风格优化,添加动画效果
- 异常处理列表支持分页和明细弹窗
- 任务结果页面支持已匹配列表分页
- 修复Dialog弹窗背景透明问题
- 新增dropdown-menu组件
- 添加parsed_file_record模型和回填脚本
- 前端枚举中文化
This commit is contained in:
freedakgmail
2026-07-07 12:57:15 +08:00
parent 78bd27a59a
commit b93929eb1a
24 changed files with 3155 additions and 608 deletions
+451 -17
View File
@@ -4,26 +4,49 @@
执行对账任务
"""
from typing import Any, Dict, List, Optional
from pathlib import Path
from typing import Annotated, Any
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.tenant import get_current_company_id
from app.models.exception_item import ExceptionItem
from app.models.field_mapping import FieldMapping
from app.models.parsed_file_record import ParsedFileRecord
from app.models.reconciliation_task import ReconciliationTask
from app.models.uploaded_file import UploadedFile
from app.services.file_storage import FileStorageService
from app.services.reconciliation.engine import run_reconciliation
router = APIRouter(prefix="/api/reconciliation", tags=["对账执行"])
DBSession = Annotated[AsyncSession, Depends(get_db)]
CompanyId = Annotated[int, Depends(get_current_company_id)]
EMPLOYEE_ID_KEYS = ("employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号")
EMPLOYEE_NAME_KEYS = ("employee_name", "员工姓名", "姓名", "name")
SALARY_AMOUNT_KEYS = ("salary_amount", "gross_salary", "应发工资", "工资", "税前工资")
SOCIAL_SECURITY_AMOUNT_KEYS = (
"social_security_amount",
"social_security",
"社保",
"社保金额",
"社保个人部分",
)
TAX_AMOUNT_KEYS = ("tax_amount", "personal_income_tax", "tax", "个税", "个人所得税", "税额")
NET_SALARY_KEYS = ("net_salary", "实发工资", "实发")
BANK_AMOUNT_KEYS = ("net_salary", "bank_amount", "银行金额", "实发工资", "实发")
@router.post("/execute/{task_id}")
async def execute_reconciliation(
task_id: int,
db: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
执行对账
@@ -41,11 +64,43 @@ async def execute_reconciliation(
raise HTTPException(status_code=400, detail="任务状态不允许执行对账")
try:
# 获取上传的数据
salary_data = task.salary_data or []
social_security_data = task.social_security_data or []
tax_data = task.tax_data or []
bank_data = task.bank_data if hasattr(task, 'bank_data') else None
# 优先从 ParsedFileRecord 读取解析后的数据
records_result = await db.execute(
select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
)
)
parsed_records = list(records_result.scalars().all())
salary_data: list[dict[str, Any]] = []
social_security_data: list[dict[str, Any]] = []
tax_data: list[dict[str, Any]] = []
bank_data: list[dict[str, Any]] = []
for rec in parsed_records:
normalized = rec.normalized_data or {}
row = {"employee_id": rec.employee_id, "employee_name": rec.employee_name, **normalized}
bucket = rec.file_bucket
if bucket == "salary":
salary_data.append(row)
elif bucket == "social_security":
social_security_data.append(row)
elif bucket == "tax":
tax_data.append(row)
elif bucket == "bank":
bank_data.append(row)
# 若没有 ParsedFileRecord 数据,降级到直接从上传文件解析
if not parsed_records:
salary_data = await _raw_records_from_files(
db, task_id, company_id, "salary"
)
social_security_data = await _raw_records_from_files(
db, task_id, company_id, "social_security"
)
tax_data = await _raw_records_from_files(db, task_id, company_id, "tax")
bank_data = await _raw_records_from_files(db, task_id, company_id, "bank")
# 执行对账
result = await run_reconciliation(
@@ -74,15 +129,15 @@ async def execute_reconciliation(
},
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"对账执行失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"对账执行失败: {str(e)}") from e
@router.get("/result/{task_id}")
async def get_reconciliation_result(
task_id: int,
db: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
获取对账结果
@@ -113,12 +168,391 @@ async def get_reconciliation_result(
}
def _to_number(value: Any) -> float | None:
if value in (None, ""):
return None
try:
if pd.isna(value):
return None
except TypeError:
pass
try:
return float(value)
except (TypeError, ValueError):
return None
def _pick(record: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = record.get(key)
if value not in (None, ""):
return value
return None
def _employee_id(record: dict[str, Any]) -> str | None:
value = _pick(record, *EMPLOYEE_ID_KEYS)
return str(value).strip() if value not in (None, "") else None
def _employee_name(record: dict[str, Any]) -> str | None:
value = _pick(record, *EMPLOYEE_NAME_KEYS)
return str(value).strip() if value not in (None, "") else None
def _normalize_matched_record(record: dict[str, Any]) -> dict[str, Any] | None:
employee_id = _employee_id(record)
if not employee_id:
return None
return {
"employee_id": employee_id,
"employee_name": _employee_name(record) or employee_id,
"salary_amount": _to_number(_pick(record, *SALARY_AMOUNT_KEYS)),
"social_security_amount": _to_number(_pick(record, *SOCIAL_SECURITY_AMOUNT_KEYS)),
"tax_amount": _to_number(_pick(record, *TAX_AMOUNT_KEYS)),
"net_salary": _to_number(_pick(record, *NET_SALARY_KEYS, *BANK_AMOUNT_KEYS)),
}
def _records_from_reconciliation_result(result: dict | None) -> list[dict[str, Any]]:
if not isinstance(result, dict):
return []
candidates = [
result.get("matched_records"),
result.get("matched_employees_list"),
result.get("matched_items"),
result.get("matched"),
]
for candidate in candidates:
if not isinstance(candidate, list):
continue
records = []
for item in candidate:
if isinstance(item, dict):
normalized = _normalize_matched_record(item)
if normalized:
records.append(normalized)
if records:
return records
return []
def _standardize_row(row: dict[str, Any], mappings: list[FieldMapping]) -> dict[str, Any]:
standardized = dict(row)
for mapping in mappings:
if mapping.is_skipped or not mapping.standard_field:
continue
if mapping.source_field in row and row[mapping.source_field] not in (None, ""):
standardized[mapping.standard_field] = row[mapping.source_field]
return standardized
def _read_uploaded_file_rows(
file: UploadedFile,
mappings: list[FieldMapping],
) -> list[dict[str, Any]]:
file_path = FileStorageService.get_file_path(file.stored_filename)
if not file_path.exists() or not file_path.is_file():
return []
suffix = Path(file.original_filename or file.stored_filename).suffix.lower()
try:
if suffix in {".xlsx", ".xls"}:
df = pd.read_excel(file_path)
else:
df = None
for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312"):
try:
df = pd.read_csv(file_path, encoding=encoding)
break
except UnicodeDecodeError:
continue
if df is None:
return []
except Exception:
return []
df = df.where(pd.notna(df), None)
return [_standardize_row(row, mappings) for row in df.to_dict(orient="records")]
def _file_bucket(file_type: str) -> str:
lowered = file_type.lower()
if "工资" in file_type or "salary" in lowered:
return "salary"
if "社保" in file_type or "social" in lowered:
return "social_security"
if "个税" in file_type or "tax" in lowered:
return "tax"
if "银行" in file_type or "bank" in lowered:
return "bank"
return "other"
async def _raw_records_from_files(
db: AsyncSession,
task_id: int,
company_id: int,
bucket: str,
) -> list[dict[str, Any]]:
"""从上传文件按 bucket 直接读取原始记录(未走 ParsedFileRecord 时的降级路径)"""
type_map = {
"salary": ("salary", "SALARY", "工资", "工资表"),
"social_security": ("social_security", "SOCIAL_SECURITY", "社保", "社保表"),
"tax": ("tax", "TAX", "个税", "个人所得税"),
"bank": ("bank", "BANK", "银行", "银行数据"),
}
type_keywords = type_map.get(bucket, (bucket,))
del type_keywords # 仅用于文档,逻辑中用 bucket
files_result = await db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = [f for f in files_result.scalars().all() if _file_bucket(f.file_type) == bucket]
if not files:
return []
file_ids = [f.id for f in files]
mappings_result = await db.execute(
select(FieldMapping).where(
FieldMapping.company_id == company_id,
FieldMapping.file_id.in_(file_ids),
)
)
mappings_by_file: dict[int, list[FieldMapping]] = {}
for m in mappings_result.scalars().all():
mappings_by_file.setdefault(m.file_id, []).append(m)
records: list[dict[str, Any]] = []
for file in files:
rows = _read_uploaded_file_rows(file, mappings_by_file.get(file.id, []))
records.extend(rows)
return records
async def _records_from_uploaded_files(
db: AsyncSession,
task_id: int,
company_id: int,
exception_employee_ids: set[str],
) -> list[dict[str, Any]]:
files_result = await db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = list(files_result.scalars().all())
if not files:
return []
file_ids = [file.id for file in files]
mappings_result = await db.execute(
select(FieldMapping).where(
FieldMapping.company_id == company_id,
FieldMapping.file_id.in_(file_ids),
)
)
mappings_by_file: dict[int, list[FieldMapping]] = {}
for mapping in mappings_result.scalars().all():
mappings_by_file.setdefault(mapping.file_id, []).append(mapping)
records_by_bucket: dict[str, dict[str, dict[str, Any]]] = {
"salary": {},
"social_security": {},
"tax": {},
"bank": {},
}
for file in files:
bucket = _file_bucket(file.file_type)
if bucket not in records_by_bucket:
continue
rows = _read_uploaded_file_rows(file, mappings_by_file.get(file.id, []))
for row in rows:
employee_id = _employee_id(row)
if employee_id:
records_by_bucket[bucket][employee_id] = row
employee_ids = set(records_by_bucket["salary"].keys())
if not employee_ids:
employee_ids.update(records_by_bucket["social_security"].keys())
employee_ids.update(records_by_bucket["tax"].keys())
employee_ids.update(records_by_bucket["bank"].keys())
matched_records = []
for employee_id in sorted(employee_ids):
if employee_id in exception_employee_ids:
continue
salary_record = records_by_bucket["salary"].get(employee_id, {})
social_record = records_by_bucket["social_security"].get(employee_id, {})
tax_record = records_by_bucket["tax"].get(employee_id, {})
bank_record = records_by_bucket["bank"].get(employee_id, {})
salary_amount = _to_number(_pick(salary_record, *SALARY_AMOUNT_KEYS))
social_security_amount = _to_number(
_pick(social_record, *SOCIAL_SECURITY_AMOUNT_KEYS, "total")
)
if social_security_amount is None:
social_security_amount = _to_number(_pick(salary_record, *SOCIAL_SECURITY_AMOUNT_KEYS))
tax_amount = _to_number(_pick(tax_record, *TAX_AMOUNT_KEYS))
if tax_amount is None:
tax_amount = _to_number(_pick(salary_record, *TAX_AMOUNT_KEYS))
net_salary = _to_number(_pick(salary_record, *NET_SALARY_KEYS))
if net_salary is None:
net_salary = _to_number(_pick(bank_record, *BANK_AMOUNT_KEYS))
matched_records.append({
"employee_id": employee_id,
"employee_name": (
_employee_name(salary_record)
or _employee_name(social_record)
or _employee_name(tax_record)
or _employee_name(bank_record)
or employee_id
),
"salary_amount": salary_amount,
"social_security_amount": social_security_amount,
"tax_amount": tax_amount,
"net_salary": net_salary,
})
return matched_records
@router.get("/matched/{task_id}")
async def get_matched_records(
task_id: int,
db: DBSession,
company_id: CompanyId,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
"""
获取已匹配记录明细
返回对账中匹配成功的员工记录列表
"""
task = await db.get(ReconciliationTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.company_id != company_id:
raise HTTPException(status_code=403, detail="无权访问该任务")
page = max(page, 1)
page_size = max(page_size, 1)
exception_result = await db.execute(
select(ExceptionItem.employee_id).where(
ExceptionItem.task_id == task_id,
ExceptionItem.company_id == company_id,
ExceptionItem.employee_id.is_not(None),
)
)
exception_employee_ids = {
str(employee_id) for employee_id in exception_result.scalars().all() if employee_id
}
# 优先从 ParsedFileRecord 读取
matched_records: list[dict[str, Any]] = []
parsed_result = await db.execute(
select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
ParsedFileRecord.employee_id.is_not(None),
)
)
parsed_records = list(parsed_result.scalars().all())
if parsed_records:
# 按 bucket 聚合
by_emp: dict[str, dict[str, Any]] = {}
for rec in parsed_records:
emp_id = rec.employee_id
if not emp_id or emp_id in exception_employee_ids:
continue
if emp_id not in by_emp:
by_emp[emp_id] = {
"employee_id": emp_id,
"employee_name": rec.employee_name or emp_id,
"salary_amount": None,
"social_security_amount": None,
"tax_amount": None,
"net_salary": None,
}
nd = rec.normalized_data or {}
# 尝试填充各金额字段
for key in SALARY_AMOUNT_KEYS:
if by_emp[emp_id]["salary_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["salary_amount"] = _to_number(v)
for key in SOCIAL_SECURITY_AMOUNT_KEYS:
if by_emp[emp_id]["social_security_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["social_security_amount"] = _to_number(v)
for key in TAX_AMOUNT_KEYS:
if by_emp[emp_id]["tax_amount"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["tax_amount"] = _to_number(v)
for key in (*NET_SALARY_KEYS, *BANK_AMOUNT_KEYS):
if by_emp[emp_id]["net_salary"] is None:
v = nd.get(key)
if v is not None:
by_emp[emp_id]["net_salary"] = _to_number(v)
matched_records = [by_emp[k] for k in sorted(by_emp)]
if not matched_records:
matched_records = _records_from_reconciliation_result(task.reconciliation_result)
if not matched_records:
matched_records = await _records_from_uploaded_files(
db,
task_id,
company_id,
exception_employee_ids,
)
total = len(matched_records)
start = (page - 1) * page_size
end = start + page_size
items = matched_records[start:end]
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"message": None if total > 0 else "暂无可展示的已匹配明细",
}
@router.post("/retry/{task_id}")
async def retry_reconciliation(
task_id: int,
db: AsyncSession = Depends(get_db),
company_id: int = Depends(get_current_company_id),
) -> Dict[str, Any]:
db: DBSession,
company_id: CompanyId,
) -> dict[str, Any]:
"""
重试对账
+62
View File
@@ -0,0 +1,62 @@
"""
解析后文件记录模型
存储上传文件解析后的每行原始数据,供已匹配列表等场景消费
"""
from typing import Any, Optional
from sqlalchemy import ForeignKey, Integer, String, JSON, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BaseModel
class ParsedFileRecord(BaseModel):
"""
解析后文件记录
每个上传文件解析后的一行原始数据对应一条记录
"""
__tablename__ = "parsed_file_records"
# 归属
company_id: Mapped[int] = mapped_column(
Integer, ForeignKey("companies.id"), nullable=False, index=True
)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("reconciliation_tasks.id"), nullable=False, index=True
)
uploaded_file_id: Mapped[int] = mapped_column(
Integer, ForeignKey("uploaded_files.id"), nullable=False, index=True
)
# 数据桶/文件类型
file_bucket: Mapped[str] = mapped_column(
String(30), nullable=False, default="salary",
comment="数据桶: salary / social_security / tax / bank / other"
)
# 行号
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
# 原始行数据(JSON
raw_data: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
# 标准化后数据(JSON
normalized_data: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=True)
# 员工标识(冗余存储,加速已匹配列表查询)
employee_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
employee_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
__table_args__ = (
Index("ix_parsed_file_records_task_bucket", "task_id", "file_bucket"),
)
def __repr__(self) -> str:
return (
f"<ParsedFileRecord(id={self.id}, task_id={self.task_id}, "
f"file_bucket='{self.file_bucket}', row_index={self.row_index})>"
)
@@ -0,0 +1,216 @@
"""
解析后文件记录服务
提供文件解析→标准→入库的完整链路
"""
from pathlib import Path
from typing import Any
import pandas as pd
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.field_mapping import FieldMapping
from app.models.parsed_file_record import ParsedFileRecord
from app.models.uploaded_file import UploadedFile
from app.services.file_storage import FileStorageService
EMPLOYEE_ID_KEYS = (
"employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号",
)
EMPLOYEE_NAME_KEYS = ("employee_name", "员工姓名", "姓名", "name")
def _normalize_row(
row: dict[str, Any],
mappings: list[FieldMapping],
) -> dict[str, Any]:
"""按字段映射将行标准化"""
standardized = dict(row)
for mapping in mappings:
if mapping.is_skipped or not mapping.standard_field:
continue
if mapping.source_field in row and row[mapping.source_field] not in (None, ""):
standardized[mapping.standard_field] = row[mapping.source_field]
return standardized
def _extract_employee_id(row: dict[str, Any]) -> str | None:
for key in EMPLOYEE_ID_KEYS:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _extract_employee_name(row: dict[str, Any]) -> str | None:
for key in EMPLOYEE_NAME_KEYS:
value = row.get(key)
if value not in (None, ""):
return str(value).strip()
return None
def _bucket_of(file_type: str) -> str:
lowered = file_type.lower()
if "工资" in file_type or "salary" in lowered:
return "salary"
if "社保" in file_type or "social" in lowered:
return "social_security"
if "个税" in file_type or "tax" in lowered:
return "tax"
if "银行" in file_type or "bank" in lowered:
return "bank"
return "other"
def _read_file_rows(
file: UploadedFile,
mappings: list[FieldMapping],
) -> list[dict[str, Any]]:
"""读取文件并返回行列表"""
file_path = FileStorageService.get_file_path(file.stored_filename)
if not file_path.exists() or not file_path.is_file():
return []
suffix = Path(file.original_filename or file.stored_filename).suffix.lower()
try:
if suffix in {".xlsx", ".xls"}:
df = pd.read_excel(file_path)
else:
df = None
for encoding in ("utf-8", "utf-8-sig", "gbk", "gb2312"):
try:
df = pd.read_csv(file_path, encoding=encoding)
break
except UnicodeDecodeError:
continue
if df is None:
return []
except Exception:
return []
df = df.where(pd.notna(df), None)
records = []
for idx, row in enumerate(df.to_dict(orient="records")):
normalized = _normalize_row(row, mappings)
records.append({
"row_index": idx,
"raw_data": {k: v for k, v in row.items() if k is not None},
"normalized_data": normalized,
"employee_id": _extract_employee_id(normalized),
"employee_name": _extract_employee_name(normalized),
})
return records
class ParsedFileRecordService:
"""解析后文件记录服务"""
def __init__(self, db: AsyncSession):
self.db = db
async def parse_and_save_file(self, file: UploadedFile) -> int:
"""
解析单个上传文件并写入 parsed_file_records
Returns:
写入的记录数
"""
# 已有记录则跳过(幂等)
existing = await self.db.execute(
select(ParsedFileRecord)
.where(ParsedFileRecord.uploaded_file_id == file.id)
.limit(1)
)
if existing.scalars().first() is not None:
return 0
# 加载字段映射
mappings_result = await self.db.execute(
select(FieldMapping).where(
FieldMapping.file_id == file.id,
FieldMapping.company_id == file.company_id,
)
)
mappings = list(mappings_result.scalars().all())
# 解析文件行
rows = _read_file_rows(file, mappings)
bucket = _bucket_of(file.file_type)
# 批量写入
records = [
ParsedFileRecord(
company_id=file.company_id,
task_id=file.task_id,
uploaded_file_id=file.id,
file_bucket=bucket,
row_index=r["row_index"],
raw_data=r["raw_data"],
normalized_data=r["normalized_data"],
employee_id=r["employee_id"],
employee_name=r["employee_name"],
)
for r in rows
]
self.db.add_all(records)
await self.db.commit()
return len(records)
async def parse_and_save_task_files(self, task_id: int, company_id: int) -> dict[str, int]:
"""
解析并保存任务关联的所有上传文件
Returns:
{bucket: count} 各桶写入的记录数
"""
files_result = await self.db.execute(
select(UploadedFile).where(
UploadedFile.task_id == task_id,
UploadedFile.company_id == company_id,
)
)
files = list(files_result.scalars().all())
counts: dict[str, int] = {}
for file in files:
cnt = await self.parse_and_save_file(file)
if cnt > 0:
bucket = _bucket_of(file.file_type)
counts[bucket] = counts.get(bucket, 0) + cnt
return counts
async def get_records_by_task(
self,
task_id: int,
company_id: int,
buckets: list[str] | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[ParsedFileRecord], int]:
"""
按任务读取解析记录
Returns:
(records, total)
"""
query = select(ParsedFileRecord).where(
ParsedFileRecord.task_id == task_id,
ParsedFileRecord.company_id == company_id,
)
if buckets:
query = query.where(ParsedFileRecord.file_bucket.in_(buckets))
count_result = await self.db.execute(
select(ParsedFileRecord.id).where(*query.whereclause.compile().compile() if query.whereclause is not None else [])
)
# 简化计数
all_result = await self.db.execute(query)
all_records = list(all_result.scalars().all())
total = len(all_records)
start = (page - 1) * page_size
return all_records[start:start + page_size], total
@@ -0,0 +1,53 @@
"""添加解析后文件记录模型
Revision ID: e4f8a1b2c56d
Revises: 52067074731d
Create Date: 2026-07-07 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "e4f8a1b2c56d"
down_revision: Union[str, None] = "52067074731d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"parsed_file_records",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("task_id", sa.Integer(), nullable=False),
sa.Column("uploaded_file_id", sa.Integer(), nullable=False),
sa.Column("file_bucket", sa.String(length=30), nullable=False),
sa.Column("row_index", sa.Integer(), nullable=False),
sa.Column("raw_data", sa.JSON(), nullable=False),
sa.Column("normalized_data", sa.JSON(), nullable=True),
sa.Column("employee_id", sa.String(length=100), nullable=True),
sa.Column("employee_name", sa.String(length=200), nullable=True),
sa.ForeignKeyConstraint(["company_id"], ["companies.id"]),
sa.ForeignKeyConstraint(["task_id"], ["reconciliation_tasks.id"]),
sa.ForeignKeyConstraint(["uploaded_file_id"], ["uploaded_files.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_parsed_file_records_company_id", "parsed_file_records", ["company_id"])
op.create_index("ix_parsed_file_records_task_id", "parsed_file_records", ["task_id"])
op.create_index("ix_parsed_file_records_employee_id", "parsed_file_records", ["employee_id"])
op.create_index(
"ix_parsed_file_records_task_bucket", "parsed_file_records", ["task_id", "file_bucket"]
)
def downgrade() -> None:
op.drop_index("ix_parsed_file_records_task_bucket")
op.drop_index("ix_parsed_file_records_employee_id")
op.drop_index("ix_parsed_file_records_task_id")
op.drop_index("ix_parsed_file_records_company_id")
op.drop_table("parsed_file_records")
+340
View File
@@ -0,0 +1,340 @@
"""
回填 ParsedFileRecord 数据
从现有 UploadedFile 记录中:
1. 生成真实 CSV/Excel 文件(模拟原始上传文件)
2. 解析并写入 ParsedFileRecord 表
用法:
cd backend
python scripts/backfill_parsed_records.py
"""
import sys
import os
import csv
import io
import random
from pathlib import Path
from sqlalchemy import select
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.database import async_session_maker as AsyncSessionLocal
from app.models.uploaded_file import UploadedFile, FileType
from app.models.parsed_file_record import ParsedFileRecord
from app.models.field_mapping import FieldMapping # noqa: F401 — SQLAlchemy relationship 需要
# 以下为解决 relationship 字符串引用的传递依赖
from app.models.company import Company # noqa: F401
from app.models.user import User # noqa: F401
from app.models.reconciliation_task import ReconciliationTask # noqa: F401
from app.models.exception_item import ExceptionItem # noqa: F401
from app.models.company_rule import CompanyRule # noqa: F401
from app.models.standard_field import StandardField # noqa: F401
from app.services.file_storage import file_storage_service
# ── 从 reconciliation.py 复制的工具函数 ──────────────────────────────────────
EMPLOYEE_ID_KEYS = (
"employee_id", "员工编号", "员工ID", "员工id", "工号", "员工工号"
)
EMPLOYEE_NAME_KEYS = (
"employee_name", "员工姓名", "姓名", "name", "Name"
)
SALARY_COLUMNS = [
"员工编号", "姓名", "部门", "基本工资", "岗位工资", "绩效工资",
"奖金", "加班费", "应发工资", "社保", "公积金", "个税", "实发工资"
]
SOCIAL_COLUMNS = [
"工号", "姓名", "部门", "养老保险", "医疗保险", "失业保险",
"工伤保险", "生育保险", "公积金", "社保合计"
]
TAX_COLUMNS = [
"工号", "姓名", "部门", "应发工资", "养老保险", "医疗保险",
"失业保险", "公积金", "专项附加扣除", "应税所得", "税率", "个税"
]
EMPLOYEES = [
("EMP001", "张明华", "管理层", 35000),
("EMP002", "李晓燕", "财务部", 28000),
("EMP003", "王建国", "技术部", 30000),
("EMP004", "刘伟强", "技术部", 22000),
("EMP005", "陈志明", "技术部", 18000),
("EMP006", "周建军", "技术部", 17000),
("EMP007", "吴海涛", "技术部", 12000),
("EMP008", "郑晓峰", "技术部", 15000),
("EMP009", "孙丽娜", "产品部", 22000),
("EMP010", "马俊杰", "产品部", 14000),
("EMP011", "朱婷婷", "产品部", 16000),
("EMP012", "胡文静", "运营部", 25000),
("EMP013", "林浩然", "运营部", 13000),
("EMP014", "何雨晴", "运营部", 12000),
("EMP015", "高建峰", "运营部", 9000),
("EMP016", "罗晓东", "市场部", 26000),
("EMP017", "宋志远", "市场部", 11000),
("EMP018", "唐思远", "市场部", 15000),
("EMP019", "韩冰冰", "市场部", 8000),
("EMP020", "冯婉君", "行政部", 14000),
("EMP021", "许志刚", "行政部", 8000),
("EMP022", "邓小丽", "行政部", 6000),
("EMP023", "曹丽华", "人事部", 15000),
("EMP024", "彭海燕", "人事部", 9000),
("EMP025", "曾敏仪", "人事部", 8500),
]
def _file_bucket(file_type: str) -> str:
lowered = file_type.lower()
if "工资" in file_type or "salary" in lowered:
return "salary"
if "社保" in file_type or "social" in lowered:
return "social_security"
if "个税" in file_type or "tax" in lowered:
return "tax"
if "银行" in file_type or "bank" in lowered:
return "bank"
return "other"
def _to_number(value) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip().replace(",", "").replace("¥", "").replace("", "")
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _normalize_field(key: str) -> str:
key = key.strip().lower()
mapping = {
"employee_id": "employee_id",
"员工编号": "employee_id", "员工id": "employee_id",
"工号": "employee_id", "员工工号": "employee_id",
"employee_name": "employee_name",
"姓名": "employee_name", "员工姓名": "employee_name",
"name": "employee_name",
"base_salary": "base_salary", "基本工资": "base_salary", "岗位工资": "base_salary",
"gross_salary": "gross_salary", "应发工资": "gross_salary",
"net_salary": "net_salary", "实发工资": "net_salary",
"social_security": "social_security", "社保": "social_security",
"pension": "pension", "养老保险": "pension",
"medical": "medical", "医疗保险": "medical",
"housing_fund": "housing_fund", "公积金": "housing_fund",
"personal_income_tax": "personal_income_tax",
"tax": "tax", "个税": "tax", "个人所得税": "personal_income_tax",
}
return mapping.get(key, key)
def _generate_salary_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
performance = round(base * random.uniform(0.1, 0.3), 2)
bonus = round(base * random.uniform(0.5, 1.5), 2) if random.random() > 0.3 else 0
overtime = random.choice([0, 500, 1000, 1500])
gross = base + performance + bonus + overtime
social = round(base * 0.105, 2)
housing = round(base * 0.12, 2)
taxable = gross - social - housing - 2000
if taxable <= 3000:
tax = round(taxable * 0.03, 2)
elif taxable <= 12000:
tax = round(taxable * 0.10 - 210, 2)
elif taxable <= 25000:
tax = round(taxable * 0.20 - 1410, 2)
else:
tax = round(taxable * 0.25 - 2660, 2)
net = round(gross - social - housing - tax, 2)
rows.append({
"员工编号": emp_id,
"姓名": name,
"部门": dept,
"基本工资": base,
"岗位工资": base,
"绩效工资": performance,
"奖金": bonus,
"加班费": overtime,
"应发工资": round(gross, 2),
"社保": social,
"公积金": housing,
"个税": tax,
"实发工资": net,
})
return rows
def _generate_social_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
pension = round(base * 0.08, 2)
medical = round(base * 0.02, 2)
unemployment = round(base * 0.005, 2)
total = round(pension + medical + unemployment + base * 0.12, 2)
rows.append({
"工号": emp_id,
"姓名": name,
"部门": dept,
"养老保险": pension,
"医疗保险": medical,
"失业保险": unemployment,
"工伤保险": 0,
"生育保险": 0,
"公积金": round(base * 0.12, 2),
"社保合计": total,
})
return rows
def _generate_tax_rows() -> list[dict]:
rows = []
for emp_id, name, dept, base in EMPLOYEES:
gross = base * random.uniform(1.1, 1.3)
social = base * 0.105
housing = base * 0.12
taxable = gross - social - housing - 2000 - 5000
if taxable <= 0:
tax = 0
rate = 0
elif taxable <= 3000:
tax = round(taxable * 0.03, 2)
rate = 0.03
elif taxable <= 12000:
tax = round(taxable * 0.10 - 210, 2)
rate = 0.10
else:
tax = round(taxable * 0.20 - 1410, 2)
rate = 0.20
rows.append({
"工号": emp_id,
"姓名": name,
"部门": dept,
"应发工资": round(gross, 2),
"养老保险": round(social, 2),
"医疗保险": round(medical if (medical := base * 0.02) else 0, 2),
"失业保险": round(unemployment if (unemployment := base * 0.005) else 0, 2),
"公积金": round(housing, 2),
"专项附加扣除": 2000,
"应税所得": round(max(taxable, 0), 2),
"税率": rate,
"个税": tax,
})
return rows
def _parse_csv(content: str, bucket: str) -> list[dict]:
"""解析 CSV 内容并标准化字段"""
reader = csv.DictReader(io.StringIO(content))
records = []
for row in reader:
normalized = {_normalize_field(k): v for k, v in row.items()}
emp_id = None
for key in EMPLOYEE_ID_KEYS:
if key in normalized and normalized[key]:
emp_id = str(normalized[key]).strip()
break
emp_name = None
for key in EMPLOYEE_NAME_KEYS:
if key in normalized and normalized[key]:
emp_name = str(normalized[key]).strip()
break
records.append({
"raw_data": dict(row),
"normalized_data": normalized,
"employee_id": emp_id,
"employee_name": emp_name,
})
return records
async def backfill():
upload_dir = file_storage_service.get_upload_dir()
upload_dir.mkdir(parents=True, exist_ok=True)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(UploadedFile).where(
UploadedFile.parse_status == "解析成功",
UploadedFile.stored_filename.isnot(None),
)
)
files = list(result.scalars().all())
print(f"找到 {len(files)} 个已解析的上传文件")
if not files:
print("没有需要回填的文件")
return
created = 0
for file in files:
# 确保目录存在
company_dir = upload_dir / str(file.company_id)
company_dir.mkdir(parents=True, exist_ok=True)
bucket = _file_bucket(file.file_type)
# 生成对应 bucket 的行数据
if bucket == "salary":
rows = _generate_salary_rows()
elif bucket == "social_security":
rows = _generate_social_rows()
elif bucket == "tax":
rows = _generate_tax_rows()
else:
rows = []
if not rows:
print(f" 跳过文件 {file.id} (bucket={bucket}, 无对应生成器)")
continue
# 生成 CSV 内容
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_content = buffer.getvalue()
# 保存文件(覆盖 stub
file_ext = ".csv"
stored_path = company_dir / f"{file.id}{file_ext}"
with open(stored_path, "w", encoding="utf-8-sig") as f:
f.write(csv_content)
# 更新 stored_filename(移除 company_id 前缀,因为 FileStorageService 会拼接)
actual_stored = f"{file.company_id}/{file.id}{file_ext}"
file.stored_filename = actual_stored
await db.flush()
# 解析并写入 ParsedFileRecord
parsed = _parse_csv(csv_content, bucket)
for idx, rec in enumerate(parsed):
pfr = ParsedFileRecord(
company_id=file.company_id,
task_id=file.task_id,
uploaded_file_id=file.id,
file_bucket=bucket,
row_index=idx,
raw_data=rec["raw_data"],
normalized_data=rec["normalized_data"],
employee_id=rec["employee_id"],
employee_name=rec["employee_name"],
)
db.add(pfr)
created += 1
print(f" 文件 {file.id}: bucket={bucket}, {len(parsed)}")
await db.commit()
print(f"\n完成:创建 {created} 条 ParsedFileRecord")
if __name__ == "__main__":
import asyncio
asyncio.run(backfill())
+27
View File
@@ -0,0 +1,27 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import asyncio
from app.core.database import async_session_maker
from sqlalchemy import text
async def check():
async with async_session_maker() as db:
r1 = await db.execute(text("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"))
print('Tables:', [row[0] for row in r1.fetchall()])
r2 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'companies' AND table_schema = 'public'"))
print('companies cols:', [row[0] for row in r2.fetchall()])
r3 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'reconciliation_tasks' AND table_schema = 'public'"))
print('reconciliation_tasks cols:', [row[0] for row in r3.fetchall()])
r4 = await db.execute(text("SELECT column_name FROM information_schema.columns WHERE table_name = 'uploaded_files' AND table_schema = 'public'"))
print('uploaded_files cols:', [row[0] for row in r4.fetchall()])
r5 = await db.execute(text("SELECT id, name FROM companies LIMIT 5"))
print('Companies:', r5.fetchall())
r6 = await db.execute(text("SELECT id, company_id, name, status FROM reconciliation_tasks LIMIT 10"))
print('Tasks:', r6.fetchall())
r7 = await db.execute(text("SELECT id, task_id, company_id, file_type, parse_status, stored_filename FROM uploaded_files LIMIT 10"))
print('UploadedFiles:', r7.fetchall())
asyncio.run(check())
+13 -1
View File
@@ -23,6 +23,18 @@ const loginSchema = z.object({
type LoginFormData = z.infer<typeof loginSchema>;
interface LoginResponse {
access_token: string;
user: {
id: number;
email: string;
full_name: string;
role: string;
permissions: string[];
company_id: number;
};
}
// 测试用户数据
const TEST_USERS = [
{ name: "管理员", email: "admin@xingchen.com", password: "admin123", role: "管理员" },
@@ -43,7 +55,7 @@ export default function LoginPage() {
setError("");
try {
const response = await api.post(ENDPOINTS.AUTH.LOGIN, data);
const response = await api.post<LoginResponse>(ENDPOINTS.AUTH.LOGIN, data);
const { access_token, user } = response.data;
// 保存到 localStorage
+57 -36
View File
@@ -33,8 +33,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertTriangle,
import { AlertTriangle,
CheckCircle,
Clock,
Eye,
@@ -42,6 +41,7 @@ import {
XCircle,
} from "lucide-react";
import { api } from "@/lib/api/client";
import { Pagination } from "@/components/ui/pagination";
interface ExceptionItem {
id: number;
@@ -93,6 +93,35 @@ const typeLabels: Record<string, string> = {
duplicate_record: "重复记录",
format_error: "格式错误",
logic_error: "逻辑错误",
rule_violation: "规则违规",
threshold_exceeded: "阈值超限",
missing_employee: "员工缺失",
extra_employee: "多余员工",
amount_difference: "金额差异",
zero_amount: "金额为零",
negative_amount: "负数金额",
unusual_amount: "金额异常",
duplicate_employee: "重复员工",
bank_mismatch: "银行不匹配",
bank_missing: "银行记录缺失",
bank_extra: "银行多余记录",
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
RULE_VIOLATION: "规则违规",
THRESHOLD_EXCEEDED: "阈值超限",
MISSING_EMPLOYEE: "员工缺失",
EXTRA_EMPLOYEE: "多余员工",
AMOUNT_DIFFERENCE: "金额差异",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
const container = {
@@ -121,8 +150,8 @@ function ExceptionsContent() {
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
const [pageSize] = useState(20);
const [pageSize, setPageSize] = useState(10);
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -130,7 +159,16 @@ function ExceptionsContent() {
fetchExceptions();
fetchSummary();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId, status, severity, exceptionType, page]);
}, [taskId, status, severity, exceptionType]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
fetchExceptions();
fetchSummary();
}
async function fetchExceptions() {
setLoading(true);
@@ -344,6 +382,10 @@ function ExceptionsContent() {
<SelectItem value="duplicate_record"></SelectItem>
<SelectItem value="format_error"></SelectItem>
<SelectItem value="logic_error"></SelectItem>
<SelectItem value="missing_employee"></SelectItem>
<SelectItem value="duplicate_employee"></SelectItem>
<SelectItem value="zero_amount"></SelectItem>
<SelectItem value="unusual_amount"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -371,6 +413,14 @@ function ExceptionsContent() {
</div>
</CardHeader>
<CardContent className="p-0">
{total > 0 && (
<Pagination
page={page}
pageSize={pageSize}
total={total}
onChange={onPageChange}
/>
)}
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
@@ -428,7 +478,7 @@ function ExceptionsContent() {
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{typeLabels[exception.exception_type] || exception.exception_type}
{typeLabels[exception.exception_type] || typeLabels[exception.exception_type.toLowerCase()] || typeLabels[exception.exception_type.toUpperCase()] || exception.exception_type}
</Badge>
</TableCell>
<TableCell>
@@ -473,35 +523,6 @@ function ExceptionsContent() {
)}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t">
<p className="text-caption text-muted-foreground">
{page} {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="btn-press"
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
className="btn-press"
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
@@ -525,7 +546,7 @@ function ExceptionsContent() {
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
<Badge variant="outline">{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}</Badge>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
+282 -172
View File
@@ -2,8 +2,26 @@
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Trash2, ToggleLeft, ToggleRight, Search } from "lucide-react";
import { motion } from "motion/react";
import {
ArrowLeft,
Trash2,
ToggleLeft,
ToggleRight,
Search,
FileText
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { api } from "@/lib/api/client";
import { ENDPOINTS } from "@/lib/api/endpoints";
import { useToast } from "@/lib/hooks/useToast";
@@ -61,6 +79,19 @@ const RULE_TYPE_LABELS: Record<string, string> = {
DEPARTMENT_MAPPING: "部门映射",
};
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.06 },
},
};
const item = {
hidden: { opacity: 0, y: 8 },
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
};
export default function RulesPage() {
const router = useRouter();
const toast = useToast();
@@ -129,196 +160,275 @@ export default function RulesPage() {
const inactiveCount = rules.filter((r) => r.status === "INACTIVE").length;
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto">
{/* 页面头部 */}
<div className="flex items-center justify-between mb-6">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center gap-4">
<button
<Button
variant="ghost"
size="icon"
onClick={() => router.push("/dashboard")}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<ArrowLeft className="w-5 h-5" />
</button>
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
<h1 className="text-heading-1 text-foreground"></h1>
<p className="text-muted-foreground mt-1">
</p>
</div>
</div>
</div>
</motion.div>
{/* 统计卡片 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{rules.length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-green-600">{activeCount}</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-2xl font-bold text-gray-400">{inactiveCount}</div>
<div className="text-sm text-gray-500 dark:text-gray-400"></div>
</div>
</div>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"
>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1">{rules.length}</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1 text-emerald-600">{activeCount}</p>
</CardContent>
</Card>
</motion.div>
<motion.div variants={item}>
<Card className="hover-lift">
<CardContent className="p-5">
<p className="text-caption text-muted-foreground uppercase tracking-wide"></p>
<p className="text-3xl font-semibold mt-1 text-muted-foreground">{inactiveCount}</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
{/* 筛选栏 */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div className="flex items-center gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索规则..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索规则..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
<option value="FIELD_MAPPING"></option>
<option value="ACCOUNT_MAPPING"></option>
<option value="DEPARTMENT_MAPPING"></option>
</select>
<div className="w-[150px]">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="FIELD_MAPPING"></SelectItem>
<SelectItem value="ACCOUNT_MAPPING"></SelectItem>
<SelectItem value="DEPARTMENT_MAPPING"></SelectItem>
</SelectContent>
</Select>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="INACTIVE"></option>
</select>
</div>
</div>
<div className="w-[150px]">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INACTIVE"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* 规则列表 */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
</th>
<th className="px-4 py-3 text-center font-medium text-gray-600 dark:text-gray-400">
使
</th>
<th className="px-4 py-3 text-right font-medium text-gray-600 dark:text-gray-400">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{filteredRules.map((rule) => (
<tr
key={rule.id}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-4 py-3">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-xs">
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
</span>
</td>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{rule.target_value}
</td>
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
{rule.match_count}
</td>
<td className="px-4 py-3 text-center">
<span
className={`px-2 py-1 rounded text-xs ${
rule.status === "ACTIVE"
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
: "bg-gray-100 dark:bg-gray-700 text-gray-500"
}`}
>
{rule.status === "ACTIVE" ? "启用" : "停用"}
</span>
</td>
<td className="px-4 py-3 text-center text-gray-500 dark:text-gray-400">
{rule.last_used_at
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
: "-"}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleToggleStatus(rule.id, rule.status)}
className={`p-1 rounded transition-colors ${
rule.status === "ACTIVE"
? "text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30"
: "text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
title={rule.status === "ACTIVE" ? "停用" : "启用"}
>
{rule.status === "ACTIVE" ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
<button
onClick={() => handleDeleteRule(rule.id)}
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredRules.length === 0 && (
<div className="px-4 py-12 text-center text-gray-500 dark:text-gray-400">
</div>
)}
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="mt-6"
>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground uppercase tracking-wide text-xs">
使
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground uppercase tracking-wide text-xs">
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredRules.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
<FileText className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-foreground font-medium"></p>
<p className="text-caption text-muted-foreground mt-1">
</p>
</td>
</tr>
) : (
filteredRules.map((rule, index) => (
<motion.tr
key={rule.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.02 }}
className="hover:bg-accent/50 transition-colors"
>
<td className="px-4 py-3">
<Badge variant="outline">
{RULE_TYPE_LABELS[rule.rule_type] || rule.rule_type}
</Badge>
</td>
<td className="px-4 py-3 font-medium text-foreground">
{rule.match_condition.source_field || JSON.stringify(rule.match_condition)}
</td>
<td className="px-4 py-3 text-muted-foreground">
{rule.target_value}
</td>
<td className="px-4 py-3 text-center text-muted-foreground">
{rule.match_count}
</td>
<td className="px-4 py-3 text-center">
<Badge className={rule.status === "ACTIVE"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500"
}>
{rule.status === "ACTIVE" ? "启用" : "停用"}
</Badge>
</td>
<td className="px-4 py-3 text-center text-muted-foreground text-caption">
{rule.last_used_at
? new Date(rule.last_used_at).toLocaleDateString("zh-CN")
: "-"}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleStatus(rule.id, rule.status)}
className={`h-8 w-8 ${rule.status === "ACTIVE"
? "text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "text-muted-foreground hover:text-foreground"
}`}
title={rule.status === "ACTIVE" ? "停用" : "启用"}
>
{rule.status === "ACTIVE" ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteRule(rule.id)}
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</motion.tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</motion.div>
{/* 说明 */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h3 className="font-medium text-blue-800 dark:text-blue-300 mb-2">
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4, ease: [0.16, 1, 0.3, 1] }}
className="mt-6"
>
<Card className="border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="p-5">
<h3 className="font-medium text-foreground mb-3">
</h3>
<ul className="text-body-small text-muted-foreground space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
</li>
</ul>
</CardContent>
</Card>
</motion.div>
</div>
);
}
@@ -4,6 +4,7 @@ import { useState, useEffect, use, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { motion } from "motion/react";
import { api } from "@/lib/api/client";
import { Pagination } from "@/components/ui/pagination";
import {
Card,
CardContent,
@@ -26,6 +27,12 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
FileText,
@@ -35,6 +42,7 @@ import {
TrendingUp,
Users,
AlertOctagon,
Eye,
} from "lucide-react";
interface TaskDetail {
@@ -76,6 +84,15 @@ interface ExceptionItem {
difference_amount: number | null;
}
interface MatchedItem {
employee_id: string;
employee_name: string;
salary_amount: number | null;
social_security_amount: number | null;
tax_amount: number | null;
net_salary: number | null;
}
const severityConfig = {
low: { label: "低", bg: "bg-blue-100 dark:bg-blue-900/40", text: "text-blue-700 dark:text-blue-300" },
medium: { label: "中", bg: "bg-yellow-100 dark:bg-yellow-900/40", text: "text-yellow-700 dark:text-yellow-300" },
@@ -91,6 +108,33 @@ const typeLabels: Record<string, string> = {
logic_error: "逻辑错误",
rule_violation: "规则违规",
threshold_exceeded: "阈值超限",
missing_employee: "员工缺失",
extra_employee: "多余员工",
amount_difference: "金额差异",
zero_amount: "金额为零",
negative_amount: "负数金额",
unusual_amount: "金额异常",
duplicate_employee: "重复员工",
bank_mismatch: "银行不匹配",
bank_missing: "银行记录缺失",
bank_extra: "银行多余记录",
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
RULE_VIOLATION: "规则违规",
THRESHOLD_EXCEEDED: "阈值超限",
MISSING_EMPLOYEE: "员工缺失",
EXTRA_EMPLOYEE: "多余员工",
AMOUNT_DIFFERENCE: "金额差异",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
export default function TaskResultPage({ params }: { params: Promise<{ id: string }> }) {
@@ -100,9 +144,18 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
const [task, setTask] = useState<TaskDetail | null>(null);
const [result, setResult] = useState<ReconciliationResult | null>(null);
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
const [matchedTotal, setMatchedTotal] = useState(0);
const [matchedPage, setMatchedPage] = useState(1);
const [matchedPageSize, setMatchedPageSize] = useState(10);
const [loading, setLoading] = useState(true);
const [matchedLoading, setMatchedLoading] = useState(false);
const [exceptionDetailOpen, setExceptionDetailOpen] = useState(false);
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
const [matchedDetailOpen, setMatchedDetailOpen] = useState(false);
const [selectedMatched, setSelectedMatched] = useState<MatchedItem | null>(null);
const fetchResultRef = useRef<() => Promise<void>>();
const fetchResultRef = useRef<(() => Promise<void>) | undefined>(undefined);
const fetchTaskDetail = useCallback(async () => {
try {
@@ -144,12 +197,36 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
}
}, [taskId]);
const fetchMatchedRecords = useCallback(async () => {
setMatchedLoading(true);
try {
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
params: { page: matchedPage, page_size: matchedPageSize },
});
setMatchedRecords(res.data.items || []);
setMatchedTotal(res.data.total || 0);
} catch (error) {
console.error("获取已匹配列表失败", error);
} finally {
setMatchedLoading(false);
}
}, [taskId, matchedPage, matchedPageSize]);
function onMatchedPageChange(newPage: number, newPageSize: number) {
setMatchedPage(newPage);
if (newPageSize !== matchedPageSize) {
setMatchedPageSize(newPageSize);
}
fetchMatchedRecords();
}
useEffect(() => {
if (taskId) {
fetchTaskDetail();
fetchExceptions();
fetchMatchedRecords();
}
}, [taskId, fetchTaskDetail, fetchExceptions]);
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
async function handleExport() {
try {
@@ -377,7 +454,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
<div key={type} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{typeLabels[type] || type}
{typeLabels[type] || typeLabels[type.toLowerCase()] || typeLabels[type.toUpperCase()] || type}
</span>
<Badge variant="secondary">{count}</Badge>
</div>
@@ -484,7 +561,14 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
</TableHeader>
<TableBody>
{exceptions.map((exc) => (
<TableRow key={exc.id}>
<TableRow
key={exc.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => {
setSelectedException(exc);
setExceptionDetailOpen(true);
}}
>
<TableCell>
<div>
<div className="font-medium">{exc.employee_name}</div>
@@ -493,7 +577,7 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{typeLabels[exc.exception_type] || exc.exception_type}
{typeLabels[exc.exception_type] || typeLabels[exc.exception_type.toLowerCase()] || typeLabels[exc.exception_type.toUpperCase()] || exc.exception_type}
</Badge>
</TableCell>
<TableCell>
@@ -527,22 +611,217 @@ export default function TaskResultPage({ params }: { params: Promise<{ id: strin
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"></CardTitle>
<Badge variant="secondary">{task.matched_count} </Badge>
<Badge variant="secondary">{matchedTotal} </Badge>
</div>
</CardHeader>
<CardContent className="py-16 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-50 dark:bg-emerald-950/30 mx-auto mb-4 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-emerald-500" />
</div>
<p className="text-lg font-medium text-foreground">!</p>
<p className="text-sm text-muted-foreground mt-1">
{task.matched_count}
</p>
<CardContent>
{matchedTotal > 0 && (
<div className="mb-4">
<Pagination
total={matchedTotal}
page={matchedPage}
pageSize={matchedPageSize}
onChange={onMatchedPageChange}
/>
</div>
)}
{matchedLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : matchedRecords.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchedRecords.map((record, index) => (
<TableRow
key={`${record.employee_id}-${index}`}
className="cursor-pointer hover:bg-muted/50"
onClick={() => {
setSelectedMatched(record);
setMatchedDetailOpen(true);
}}
>
<TableCell>
<div>
<div className="font-medium">{record.employee_name}</div>
<div className="text-xs text-muted-foreground">{record.employee_id}</div>
</div>
</TableCell>
<TableCell className="text-right">
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right font-medium">
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
</TableCell>
<TableCell>
<Eye className="h-4 w-4 text-muted-foreground" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
{/* 异常详情弹窗 */}
<Dialog open={exceptionDetailOpen} onOpenChange={setExceptionDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedException && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="font-medium">{selectedException.employee_name}</p>
</div>
<div>
<p className="text-caption text-muted-foreground">ID</p>
<p className="font-medium">{selectedException.employee_id}</p>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<Badge variant="outline">
{typeLabels[selectedException.exception_type] || typeLabels[selectedException.exception_type.toLowerCase()] || typeLabels[selectedException.exception_type.toUpperCase()] || selectedException.exception_type}
</Badge>
</div>
<div>
<p className="text-caption text-muted-foreground"></p>
<Badge className={`${severityConfig[selectedException.severity].bg} ${severityConfig[selectedException.severity].text}`}>
{severityConfig[selectedException.severity].label}
</Badge>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-medium mb-3"></h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{selectedException.salary_amount != null && (
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground"></p>
<p className="font-mono font-medium mt-1">{selectedException.salary_amount.toFixed(2)}</p>
</div>
)}
{selectedException.social_security_amount != null && (
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground"></p>
<p className="font-mono font-medium mt-1">{selectedException.social_security_amount.toFixed(2)}</p>
</div>
)}
{selectedException.tax_amount != null && (
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground"></p>
<p className="font-mono font-medium mt-1">{selectedException.tax_amount.toFixed(2)}</p>
</div>
)}
{selectedException.bank_amount != null && (
<div className="p-3 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground"></p>
<p className="font-mono font-medium mt-1">{selectedException.bank_amount.toFixed(2)}</p>
</div>
)}
</div>
{selectedException.difference_amount != null && (
<div className="mt-4 p-4 rounded-lg bg-muted/50">
<p className="text-caption text-muted-foreground"></p>
<p className={`text-2xl font-mono font-semibold mt-1 ${selectedException.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}`}>
{selectedException.difference_amount > 0 ? "+" : ""}
{selectedException.difference_amount.toFixed(2)}
</p>
</div>
)}
</div>
<div className="border-t pt-4">
<div className="space-y-3">
<div>
<p className="text-caption text-muted-foreground mb-1"></p>
<p>{selectedException.description}</p>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* 已匹配记录明细弹窗 */}
<Dialog open={matchedDetailOpen} onOpenChange={setMatchedDetailOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedMatched && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-caption text-muted-foreground"></p>
<p className="font-medium">{selectedMatched.employee_name}</p>
</div>
<div>
<p className="text-caption text-muted-foreground">ID</p>
<p className="font-medium">{selectedMatched.employee_id}</p>
</div>
</div>
<div className="border-t pt-4">
<h4 className="font-medium mb-3"></h4>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.salary_amount != null ? selectedMatched.salary_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.social_security_amount != null ? selectedMatched.social_security_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-muted/50">
<span className="text-muted-foreground"></span>
<span className="font-mono font-medium">
{selectedMatched.tax_amount != null ? selectedMatched.tax_amount.toFixed(2) : "-"}
</span>
</div>
<div className="flex justify-between items-center p-3 rounded-lg bg-primary/10 border border-primary/20">
<span className="font-medium"></span>
<span className="font-mono font-semibold text-lg">
{selectedMatched.net_salary != null ? selectedMatched.net_salary.toFixed(2) : "-"}
</span>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
+33 -17
View File
@@ -7,11 +7,10 @@ import {
Filter,
ArrowRight,
Loader2,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/ui/pagination";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
@@ -59,24 +58,40 @@ export default function TasksPage() {
const [loading, setLoading] = useState(true);
const [selectedPeriod, setSelectedPeriod] = useState<string>("");
const [availablePeriods] = useState(getAvailablePeriods);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const loadTasks = useCallback(async () => {
try {
setLoading(true);
const params = selectedPeriod ? { period: selectedPeriod } : {};
const res = await api.get<Task[]>(ENDPOINTS.TASK.LIST, { params });
setTasks(res.data);
const params = {
page,
page_size: pageSize,
...(selectedPeriod ? { period: selectedPeriod } : {})
};
const res = await api.get<{ items: Task[]; total: number }>(ENDPOINTS.TASK.LIST, { params });
setTasks(res.data.items || []);
setTotal(res.data.total || 0);
} catch (error) {
console.error("加载任务列表失败:", error);
} finally {
setLoading(false);
}
}, [selectedPeriod]);
}, [page, pageSize, selectedPeriod]);
useEffect(() => {
loadTasks();
}, [loadTasks]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
loadTasks();
}
const getMatchRate = (task: Task) => {
if (task.total_employees === 0) return "0%";
const rate = Math.round((task.matched_count / task.total_employees) * 100);
@@ -176,7 +191,16 @@ export default function TasksPage() {
</Card>
</motion.div>
) : (
<div className="space-y-4">
<>
<div className="mb-4">
<Pagination
total={total}
page={page}
pageSize={pageSize}
onChange={onPageChange}
/>
</div>
<div className="space-y-4">
{tasks.map((task, index) => (
<motion.div
key={task.id}
@@ -241,16 +265,8 @@ export default function TasksPage() {
</Card>
</motion.div>
))}
</div>
)}
{/* Pagination hint */}
{tasks.length > 0 && (
<div className="mt-6 flex items-center justify-center gap-2 text-caption text-muted-foreground">
<ChevronLeft className="w-4 h-4" />
<span> 50 </span>
<ChevronRight className="w-4 h-4" />
</div>
</div>
</>
)}
</div>
);
+72 -15
View File
@@ -21,10 +21,22 @@ export async function GET(
try {
const response = await fetch(targetUrl, { headers });
const data = await response.json();
return NextResponse.json(data, { status: response.status });
// 先检查 Content-Type,确保是 JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} else {
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: { 'content-type': contentType || 'text/plain' }
});
}
} catch (error) {
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
console.error('GET proxy error:', error);
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
}
}
@@ -51,10 +63,22 @@ export async function POST(
headers,
body,
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
// 先检查 Content-Type,确保是 JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} else {
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: { 'content-type': contentType || 'text/plain' }
});
}
} catch (error) {
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
console.error('POST proxy error:', error);
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
}
}
@@ -81,10 +105,21 @@ export async function PUT(
headers,
body,
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} else {
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: { 'content-type': contentType || 'text/plain' }
});
}
} catch (error) {
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
console.error('PUT proxy error:', error);
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
}
}
@@ -111,10 +146,21 @@ export async function PATCH(
headers,
body,
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} else {
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: { 'content-type': contentType || 'text/plain' }
});
}
} catch (error) {
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
console.error('PATCH proxy error:', error);
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
}
}
@@ -139,9 +185,20 @@ export async function DELETE(
method: 'DELETE',
headers,
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} else {
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: { 'content-type': contentType || 'text/plain' }
});
}
} catch (error) {
return NextResponse.json({ detail: 'Proxy error' }, { status: 502 });
console.error('DELETE proxy error:', error);
return NextResponse.json({ detail: 'Proxy error', error: String(error) }, { status: 502 });
}
}
+185 -110
View File
@@ -3,84 +3,86 @@
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist/700.css";
@import "@fontsource/ibm-plex-sans/400.css";
@import "@fontsource/ibm-plex-sans/500.css";
@import "@fontsource/ibm-plex-sans/600.css";
@custom-variant dark (&:is(.dark *));
/* Design System Tokens */
/* Liner Style Design System - Minimal, Clean, Refined */
:root {
/* Colors - Professional Blue-Gray Palette */
--background: 0 0% 100%;
--foreground: 220 14% 18%;
/* Colors - Light Mode - Soft & Clean */
--background: 0 0% 99%;
--foreground: 220 10% 15%;
--card: 0 0% 100%;
--card-foreground: 220 14% 18%;
--card-foreground: 220 10% 15%;
--popover: 0 0% 100%;
--popover-foreground: 220 14% 18%;
--popover-foreground: 220 10% 15%;
/* Primary - Professional Blue */
--primary: 221 83% 53%;
/* Primary - Subtle Blue */
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
/* Secondary - Cool Gray */
--secondary: 220 13% 95%;
--secondary-foreground: 220 14% 18%;
/* Secondary - Ultra Light Gray */
--secondary: 220 14% 96%;
--secondary-foreground: 220 10% 25%;
/* Muted */
--muted: 220 13% 95%;
--muted-foreground: 220 9% 46%;
/* Muted - Very Light */
--muted: 220 14% 96%;
--muted-foreground: 220 8% 46%;
/* Accent */
--accent: 220 13% 95%;
--accent-foreground: 220 14% 18%;
/* Accent - Soft Highlight */
--accent: 217 91% 97%;
--accent-foreground: 217 91% 40%;
/* Destructive */
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
/* Borders & Inputs */
/* Borders - Ultra Fine */
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 221 83% 53%;
--ring: 217 91% 60%;
/* Radius */
--radius: 0.5rem;
/* Radius - Larger, Softer */
--radius: 0.625rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.06), 0 2px 4px -2px rgb(0 0 0 / 0.04);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.06), 0 4px 6px -4px rgb(0 0 0 / 0.04);
/* Shadows - Minimal */
--shadow: 0 1px 2px 0 hsl(220 14% 96%);
--shadow-sm: 0 0px 0px 1px hsl(220 13% 91%);
--shadow-md: 0 2px 4px 0 hsl(220 14% 96%);
--shadow-lg: 0 4px 8px 0 hsl(220 14% 96%);
}
/* Dark Mode - Professional Dark */
/* Dark Mode - Soft Dark */
.dark {
--background: 220 17% 10%;
--foreground: 210 17% 95%;
--card: 220 17% 12%;
--card-foreground: 210 17% 95%;
--popover: 220 17% 12%;
--popover-foreground: 210 17% 95%;
--background: 220 20% 7%;
--foreground: 220 14% 96%;
--card: 220 18% 9%;
--card-foreground: 220 14% 96%;
--popover: 220 18% 9%;
--popover-foreground: 220 14% 96%;
--primary: 217 91% 60%;
--primary-foreground: 220 17% 10%;
--primary: 217 91% 65%;
--primary-foreground: 220 20% 7%;
--secondary: 217 33% 17%;
--secondary-foreground: 210 17% 95%;
--secondary: 220 18% 14%;
--secondary-foreground: 220 14% 96%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--muted: 220 18% 14%;
--muted-foreground: 220 8% 64%;
--accent: 217 33% 17%;
--accent-foreground: 210 17% 95%;
--accent: 217 33% 20%;
--accent-foreground: 217 91% 75%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 17% 95%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 224 76% 48%;
--border: 220 18% 16%;
--input: 220 18% 16%;
--ring: 217 91% 65%;
--shadow: 0 1px 2px 0 hsl(220 20% 4%);
--shadow-sm: 0 0px 0px 1px hsl(220 18% 16%);
--shadow-md: 0 2px 4px 0 hsl(220 20% 4%);
--shadow-lg: 0 4px 8px 0 hsl(220 20% 4%);
}
/* Base Styles */
@@ -89,12 +91,13 @@
}
body {
font-family: "Geist", "IBM Plex Sans", system-ui, sans-serif;
font-family: "Geist", system-ui, -apple-system, sans-serif;
background: hsl(var(--background));
color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* Smooth Scrolling */
@@ -104,37 +107,37 @@ html {
/* Selection */
::selection {
background: hsl(var(--primary) / 0.2);
background: hsl(var(--primary) / 0.15);
color: hsl(var(--foreground));
}
/* Focus Visible */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline: 2px solid hsl(var(--ring) / 0.5);
outline-offset: 2px;
border-radius: calc(var(--radius) - 2px);
}
/* Scrollbar Styling */
/* Scrollbar - Minimal */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
background: hsl(var(--border));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
background: hsl(var(--muted-foreground) / 0.3);
}
/* Animation Utilities */
/* Animations - Subtle */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
@@ -143,7 +146,7 @@ html {
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(6px);
}
to {
opacity: 1;
@@ -154,7 +157,7 @@ html {
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
transform: translateY(-6px);
}
to {
opacity: 1;
@@ -165,7 +168,7 @@ html {
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
transform: scale(0.97);
}
to {
opacity: 1;
@@ -173,11 +176,6 @@ html {
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -185,78 +183,70 @@ html {
/* Animation Classes */
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
animation: fadeIn 0.2s ease-out forwards;
}
.animate-slide-up {
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation: slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-slide-down {
animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-scale-in {
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-spin {
animation: spin 1s linear infinite;
animation: spin 0.8s linear infinite;
}
/* Staggered Animation Delays */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
/* Staggered Animation */
.stagger-1 { animation-delay: 30ms; }
.stagger-2 { animation-delay: 60ms; }
.stagger-3 { animation-delay: 90ms; }
.stagger-4 { animation-delay: 120ms; }
.stagger-5 { animation-delay: 150ms; }
/* Transition Utilities */
/* Transitions - Smooth */
.transition-base {
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.transition-smooth {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.transition-bounce {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Hover Effects */
/* Hover Effects - Subtle Lift */
.hover-lift {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.hover-lift:hover {
transform: translateY(-2px);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.hover-scale {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.hover-scale:hover {
transform: scale(1.02);
transform: scale(1.01);
}
/* Button Active Effect */
/* Button Press Effect */
.btn-press:active {
transform: scale(0.97);
transform: scale(0.98);
}
/* Glass Effect */
/* Glass Effect - Subtle */
.glass {
background: hsl(var(--background) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: hsl(var(--background) / 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* Reduced Motion */
@@ -275,38 +265,38 @@ html {
.text-display {
font-size: 3rem;
line-height: 1.1;
letter-spacing: -0.025em;
letter-spacing: -0.03em;
font-weight: 700;
}
.text-heading-1 {
font-size: 2.25rem;
font-size: 2rem;
line-height: 1.2;
letter-spacing: -0.02em;
letter-spacing: -0.025em;
font-weight: 600;
}
.text-heading-2 {
font-size: 1.5rem;
line-height: 1.3;
letter-spacing: -0.015em;
letter-spacing: -0.02em;
font-weight: 600;
}
.text-heading-3 {
font-size: 1.25rem;
font-size: 1.125rem;
line-height: 1.4;
letter-spacing: -0.01em;
font-weight: 600;
}
.text-body {
font-size: 1rem;
font-size: 0.9375rem;
line-height: 1.6;
}
.text-body-small {
font-size: 0.875rem;
font-size: 0.8125rem;
line-height: 1.5;
}
@@ -314,4 +304,89 @@ html {
font-size: 0.75rem;
line-height: 1.4;
letter-spacing: 0.01em;
}
/* Liner-Style Card - Border only, no shadow */
.liner-card {
border: 1px solid hsl(var(--border));
background: hsl(var(--card));
border-radius: var(--radius);
}
/* Liner-Style Button - Clean, minimal */
.liner-btn {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.liner-btn:hover {
background: hsl(var(--accent));
border-color: hsl(var(--accent-foreground) / 0.2);
}
.liner-btn:active {
transform: scale(0.98);
}
/* Liner-Style Input */
.liner-input {
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.liner-input:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.1);
}
/* Liner-Style Badge */
.liner-badge {
background: hsl(var(--accent));
color: hsl(var(--accent-foreground));
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
font-weight: 500;
letter-spacing: -0.01em;
}
/* Liner-Style Table */
.liner-table {
border-collapse: separate;
border-spacing: 0;
}
.liner-table th {
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
}
.liner-table td {
border-bottom: 1px solid hsl(var(--border));
}
.liner-table tr:hover td {
background: hsl(var(--accent));
}
/* Gradient Text */
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary) / 0.8));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Subtle Divider */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, hsl(var(--border)), transparent);
}
+76 -43
View File
@@ -1,11 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { LogOut, User } from "lucide-react";
import {
LogOut,
User,
Bell,
Moon,
Sun,
Workflow
} from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/lib/api/client";
import ENDPOINTS from "@/lib/api/endpoints";
import { cn } from "@/lib/utils";
const ROLE_LABELS: Record<string, string> = {
admin: "管理员",
accountant: "会计",
viewer: "查看者",
};
export function Header() {
const router = useRouter();
@@ -13,68 +36,78 @@ export function Header() {
const handleLogout = async () => {
try {
// 调用后端登出接口
await api.post(ENDPOINTS.AUTH.LOGOUT);
} catch {
// 即使后端失败也清除本地状态
}
// 清除本地状态
logout();
// 跳转到登录页
router.push("/login");
};
if (!user) return null;
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 w-full border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/60">
<div className="flex h-14 items-center justify-between px-6">
{/* 左侧 - Logo 和系统名称 */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-shield w-4 h-4 text-primary"
aria-hidden="true"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path>
</svg>
<Workflow className="w-4 h-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground leading-tight">AI助手</span>
<span className="text-[10px] text-muted-foreground">S2F </span>
</div>
<span className="font-semibold text-foreground">AI助手</span>
</div>
{/* 右侧 - 用户信息和登出 */}
<div className="flex items-center gap-4">
{/* 用户信息 */}
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted/50">
<User className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-foreground">{user.full_name}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{user.role}</span>
</div>
</div>
{/* 登出按钮 */}
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<LogOut className="w-4 h-4" />
<span></span>
{/* 右侧 - 用户信息和操作 */}
<div className="flex items-center gap-2">
{/* 通知按钮 */}
<Button variant="ghost" size="icon" className="relative">
<Bell className="w-4 h-4" />
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" />
</Button>
{/* 用户下拉菜单 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 h-9 px-2">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-3.5 h-3.5 text-primary" />
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-foreground leading-tight">
{user.full_name}
</span>
<span className="text-[10px] text-muted-foreground">
{ROLE_LABELS[user.role] || user.role}
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{user.full_name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2">
<User className="w-4 h-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="gap-2 text-destructive focus:text-destructive"
>
<LogOut className="w-4 h-4" />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
+42 -19
View File
@@ -44,7 +44,7 @@ export function Sidebar() {
return (
<aside
className={cn(
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-background transition-all duration-300 flex flex-col",
"sticky top-14 h-[calc(100vh-3.5rem)] border-r border-border bg-card transition-all duration-300 flex flex-col",
collapsed ? "w-16" : "w-56"
)}
>
@@ -62,25 +62,40 @@ export function Sidebar() {
"flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all relative group",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
: "text-muted-foreground hover:bg-accent hover:text-foreground",
collapsed && "justify-center px-0"
)}
>
{/* Active indicator bar */}
{isActive && (
<motion.div
layoutId="activeNav"
className="absolute inset-0 bg-primary/10 rounded-lg -z-10"
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
/>
<>
<motion.div
layoutId="activeIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-primary rounded-r-full"
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
/>
<motion.div
layoutId="activeBg"
className="absolute inset-0 bg-primary/10 rounded-lg -z-10"
transition={{ type: "spring", bounce: 0.2, duration: 0.4 }}
/>
</>
)}
<Icon className={cn("w-5 h-5 shrink-0", isActive && "text-primary")} />
{!collapsed && (
<span className="text-body font-medium">{item.label}</span>
<span className={cn(
"text-sm font-medium",
isActive && "text-primary"
)}>
{item.label}
</span>
)}
{/* Tooltip for collapsed state */}
{collapsed && (
<div className="absolute left-full ml-2 px-2 py-1 bg-foreground text-background text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50">
<div className="absolute left-full ml-2 px-2 py-1 bg-foreground text-background text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-md">
{item.label}
</div>
)}
@@ -90,16 +105,24 @@ export function Sidebar() {
</nav>
{/* Collapse toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-center h-10 border-t border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
<div className="border-t border-border">
<button
onClick={() => setCollapsed(!collapsed)}
className={cn(
"flex items-center h-10 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
collapsed ? "justify-center w-full" : "px-4 gap-2"
)}
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<>
<ChevronLeft className="w-4 h-4" />
<span className="text-xs"></span>
</>
)}
</button>
</div>
</aside>
);
}
+1 -1
View File
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:bg-slate-900 bg-white",
className
)}
{...props}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-card p-1 shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
>
<path
d="M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.5 3.25C5.01575 3.25 3 5.26575 3 7.75C3 10.2342 5.01575 12.25 7.5 12.25C9.98425 12.25 12 10.2342 12 7.75C12 5.26575 9.98425 3.25 7.5 3.25ZM7.5 10.25C5.88575 10.25 4.5935 8.95775 4.5935 7.3435C4.5935 5.72925 5.88575 4.4375 7.5 4.4375C9.11425 4.4375 10.4065 5.72925 10.4065 7.3435C10.4065 8.95775 9.11425 10.25 7.5 10.25Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface PaginationProps {
/** 当前页码(从 1 开始) */
page: number;
/** 每页条数 */
pageSize: number;
/** 总条数 */
total: number;
/** 页码或每页条数变化时的回调 */
onChange: (page: number, pageSize: number) => void;
/** 可选的每页条数选项,默认 [10, 20, 50] */
pageSizeOptions?: number[];
/** 是否显示每页条数选择器,默认 true */
showPageSizeSelector?: boolean;
}
export function Pagination({
page,
pageSize,
total,
onChange,
pageSizeOptions = [10, 20, 50],
showPageSizeSelector = true,
}: PaginationProps) {
const totalPages = Math.ceil(total / pageSize);
const start = total > 0 ? (page - 1) * pageSize + 1 : 0;
const end = Math.min(page * pageSize, total);
return (
<div className="flex items-center justify-between px-4 py-3 border-t">
<div className="flex items-center gap-4">
{showPageSizeSelector && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Select
value={String(pageSize)}
onValueChange={(val) => onChange(1, Number(val))}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground"></span>
</div>
)}
{total > 0 && (
<span className="text-sm text-muted-foreground">
{start}-{end} {total}
</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onChange(1, pageSize)}
disabled={page <= 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onChange(page - 1, pageSize)}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 mx-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<Button
key={pageNum}
variant={pageNum === page ? "default" : "outline"}
size="sm"
className="h-8 w-9"
onClick={() => onChange(pageNum, pageSize)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onChange(page + 1, pageSize)}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onChange(totalPages, pageSize)}
disabled={page >= totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}
+4 -1
View File
@@ -83,7 +83,10 @@ export interface CompanyRuleResponse {
id: number;
company_id: number;
rule_type: string;
match_condition: Record<string, unknown>;
match_condition: {
source_field?: string;
[key: string]: unknown;
};
target_value: string;
priority: number;
status: string;
+293
View File
@@ -12,6 +12,7 @@
"@fontsource/ibm-plex-sans": "^5.2.8",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.16",
@@ -1563,6 +1564,56 @@
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.20",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.20.tgz",
"integrity": "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-menu": "2.1.20",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.5.tgz",
"integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.0.tgz",
"integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
@@ -1621,6 +1672,233 @@
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.20",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.20.tgz",
"integrity": "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.15",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.12",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.3",
"@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.7",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-roving-focus": "1.1.15",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.5.tgz",
"integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
"version": "1.1.12",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
"integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.2.0.tgz",
"integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.15",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz",
"integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-effect-event": "0.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.12",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz",
"integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.3.tgz",
"integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.7.tgz",
"integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.15",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.15.tgz",
"integrity": "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-is-hydrated": "0.1.1",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
@@ -1898,6 +2176,21 @@
}
}
},
"node_modules/@radix-ui/react-use-is-hydrated": {
"version": "0.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz",
"integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
"@fontsource/ibm-plex-sans": "^5.2.8",
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.20",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.16",
@@ -43,12 +43,13 @@ import {
XCircle,
} from "lucide-react";
import { api } from "@/lib/api/client";
import { Pagination } from "@/components/ui/pagination";
interface ExceptionItem {
id: number;
task_id: number;
exception_type: string;
severity: "low" | "medium" | "high" | "critical";
severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
status: "pending" | "processing" | "resolved" | "ignored";
employee_id: string;
employee_name: string;
@@ -73,10 +74,33 @@ interface Summary {
}
const severityColors = {
low: "bg-blue-100 text-blue-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-orange-100 text-orange-800",
critical: "bg-red-100 text-red-800",
LOW: "bg-blue-100 text-blue-800",
MEDIUM: "bg-yellow-100 text-yellow-800",
HIGH: "bg-orange-100 text-orange-800",
CRITICAL: "bg-red-100 text-red-800",
};
const severityLabels: Record<string, string> = {
LOW: "低",
MEDIUM: "中",
HIGH: "高",
CRITICAL: "严重",
};
const typeLabels: Record<string, string> = {
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
MISSING_EMPLOYEE: "员工缺失",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
const statusIcons = {
@@ -106,8 +130,8 @@ export default function ExceptionsPage() {
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
const [pageSize] = useState(20);
const [pageSize, setPageSize] = useState(10);
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -133,6 +157,15 @@ export default function ExceptionsPage() {
}
}, [taskId, status, severity, exceptionType, page, pageSize]);
function onPageChange(newPage: number, newPageSize: number) {
setPage(newPage);
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
}
fetchExceptions();
fetchSummary();
}
const fetchSummary = useCallback(async () => {
try {
const res = await api.get<Summary>("/api/exceptions/summary", {
@@ -259,10 +292,10 @@ export default function ExceptionsPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="low"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="high"></SelectItem>
<SelectItem value="critical"></SelectItem>
<SelectItem value="LOW"></SelectItem>
<SelectItem value="MEDIUM"></SelectItem>
<SelectItem value="HIGH"></SelectItem>
<SelectItem value="CRITICAL"></SelectItem>
</SelectContent>
</Select>
<Select value={exceptionType} onValueChange={setExceptionType}>
@@ -271,11 +304,14 @@ export default function ExceptionsPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
<SelectItem value="amount_mismatch"></SelectItem>
<SelectItem value="missing_record"></SelectItem>
<SelectItem value="duplicate_record"></SelectItem>
<SelectItem value="format_error"></SelectItem>
<SelectItem value="logic_error"></SelectItem>
<SelectItem value="AMOUNT_MISMATCH"></SelectItem>
<SelectItem value="MISSING_EMPLOYEE"></SelectItem>
<SelectItem value="ZERO_AMOUNT"></SelectItem>
<SelectItem value="NEGATIVE_AMOUNT"></SelectItem>
<SelectItem value="UNUSUAL_AMOUNT"></SelectItem>
<SelectItem value="DUPLICATE_EMPLOYEE"></SelectItem>
<SelectItem value="BANK_MISMATCH"></SelectItem>
<SelectItem value="BANK_MISSING"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch}>
@@ -295,6 +331,14 @@ export default function ExceptionsPage() {
</CardDescription>
</CardHeader>
<CardContent>
{total > 0 && (
<Pagination
page={page}
pageSize={pageSize}
total={total}
onChange={onPageChange}
/>
)}
<Table>
<TableHeader>
<TableRow>
@@ -345,19 +389,12 @@ export default function ExceptionsPage() {
</TableCell>
<TableCell>
<Badge variant="outline">
{exception.exception_type === "amount_mismatch" && "金额不一致"}
{exception.exception_type === "missing_record" && "记录缺失"}
{exception.exception_type === "duplicate_record" && "重复记录"}
{exception.exception_type === "format_error" && "格式错误"}
{exception.exception_type === "logic_error" && "逻辑错误"}
{typeLabels[exception.exception_type] || exception.exception_type}
</Badge>
</TableCell>
<TableCell>
<Badge className={severityColors[exception.severity]}>
{exception.severity === "low" && "低"}
{exception.severity === "medium" && "中"}
{exception.severity === "high" && "高"}
{exception.severity === "critical" && "严重"}
{severityLabels[exception.severity] || exception.severity}
</Badge>
</TableCell>
<TableCell className="max-w-[300px] truncate">
@@ -414,33 +451,6 @@ export default function ExceptionsPage() {
)}
</TableBody>
</Table>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
{page} {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
@@ -467,12 +477,12 @@ export default function ExceptionsPage() {
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<Badge variant="outline">{selectedException.exception_type}</Badge>
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<Badge className={severityColors[selectedException.severity]}>
{selectedException.severity}
{severityLabels[selectedException.severity] || selectedException.severity}
</Badge>
</div>
</div>
@@ -2,6 +2,8 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useRouter } from "next/navigation";
import { api } from "@/lib/api/client";
import { Pagination } from "@/components/ui/pagination";
import {
Card,
CardContent,
@@ -61,7 +63,7 @@ interface ReconciliationResult {
interface ExceptionItem {
id: number;
exception_type: string;
severity: "low" | "medium" | "high" | "critical";
severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
status: string;
employee_id: string;
employee_name: string;
@@ -73,6 +75,31 @@ interface ExceptionItem {
difference_amount: number | null;
}
interface MatchedItem {
employee_id: string;
employee_name: string;
salary_amount: number | null;
social_security_amount: number | null;
tax_amount: number | null;
net_salary: number | null;
}
const typeLabels: Record<string, string> = {
AMOUNT_MISMATCH: "金额不一致",
MISSING_RECORD: "记录缺失",
DUPLICATE_RECORD: "重复记录",
FORMAT_ERROR: "格式错误",
LOGIC_ERROR: "逻辑错误",
MISSING_EMPLOYEE: "员工缺失",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
export default function TaskResultPage() {
const params = useParams();
const router = useRouter();
@@ -81,7 +108,12 @@ export default function TaskResultPage() {
const [task, setTask] = useState<TaskDetail | null>(null);
const [result, setResult] = useState<ReconciliationResult | null>(null);
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
const [matchedRecords, setMatchedRecords] = useState<MatchedItem[]>([]);
const [loading, setLoading] = useState(true);
const [matchedLoading, setMatchedLoading] = useState(false);
const [matchedPage, setMatchedPage] = useState(1);
const [matchedPageSize, setMatchedPageSize] = useState(10);
const [matchedTotal, setMatchedTotal] = useState(0);
const fetchResultRef = useRef<() => Promise<void>>();
@@ -118,6 +150,29 @@ export default function TaskResultPage() {
}
}, [taskId]);
const fetchMatchedRecords = useCallback(async () => {
setMatchedLoading(true);
try {
const res = await api.get<{ items: MatchedItem[]; total: number }>(`/api/reconciliation/matched/${taskId}`, {
params: { page: matchedPage, page_size: matchedPageSize },
});
setMatchedRecords(res.data.items || []);
setMatchedTotal(res.data.total || 0);
} catch (error) {
console.error("获取已匹配列表失败", error);
} finally {
setMatchedLoading(false);
}
}, [taskId, matchedPage, matchedPageSize]);
function onMatchedPageChange(newPage: number, newPageSize: number) {
setMatchedPage(newPage);
if (newPageSize !== matchedPageSize) {
setMatchedPageSize(newPageSize);
}
fetchMatchedRecords();
}
useEffect(() => {
fetchResultRef.current = fetchResult;
}, [fetchResult]);
@@ -126,8 +181,9 @@ export default function TaskResultPage() {
if (taskId) {
fetchTaskDetail();
fetchExceptions();
fetchMatchedRecords();
}
}, [taskId, fetchTaskDetail, fetchExceptions]);
}, [taskId, fetchTaskDetail, fetchExceptions, fetchMatchedRecords]);
async function handleExport() {
try {
@@ -292,20 +348,30 @@ export default function TaskResultPage() {
<CardContent>
{result?.exceptions_by_type && Object.keys(result.exceptions_by_type).length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(result.exceptions_by_type).map(([type, count]) => (
<div key={type} className="border rounded-lg p-4">
<div className="text-sm text-muted-foreground">
{type === "amount_mismatch" && "金额不一致"}
{type === "missing_record" && "记录缺失"}
{type === "duplicate_record" && "重复记录"}
{type === "format_error" && "格式错误"}
{type === "logic_error" && "逻辑错误"}
{type === "rule_violation" && "规则违规"}
{type === "threshold_exceeded" && "阈值超限"}
{Object.entries(result.exceptions_by_type).map(([type, count]) => {
const getTypeLabel = (t: string) => {
const labels: Record<string, string> = {
AMOUNT_MISMATCH: "金额不一致",
MISSING_EMPLOYEE: "员工缺失",
ZERO_AMOUNT: "金额为零",
NEGATIVE_AMOUNT: "负数金额",
UNUSUAL_AMOUNT: "金额异常",
DUPLICATE_EMPLOYEE: "重复员工",
BANK_MISMATCH: "银行不匹配",
BANK_MISSING: "银行记录缺失",
BANK_EXTRA: "银行多余记录",
};
return labels[t] || t;
};
return (
<div key={type} className="border rounded-lg p-4">
<div className="text-sm text-muted-foreground">
{getTypeLabel(type)}
</div>
<div className="text-2xl font-bold mt-2">{count}</div>
</div>
<div className="text-2xl font-bold mt-2">{count}</div>
</div>
))}
);
})}
</div>
) : (
<p className="text-muted-foreground"></p>
@@ -321,19 +387,19 @@ export default function TaskResultPage() {
<CardContent>
{result?.exceptions_by_severity && Object.keys(result.exceptions_by_severity).length > 0 ? (
<div className="flex gap-4">
{["critical", "high", "medium", "low"].map((severity) => {
{["CRITICAL", "HIGH", "MEDIUM", "LOW"].map((severity) => {
const count = result.exceptions_by_severity[severity] || 0;
const colors = {
critical: "bg-red-500",
high: "bg-orange-500",
medium: "bg-yellow-500",
low: "bg-blue-500",
CRITICAL: "bg-red-500",
HIGH: "bg-orange-500",
MEDIUM: "bg-yellow-500",
LOW: "bg-blue-500",
};
const labels = {
critical: "严重",
high: "高",
medium: "中",
low: "低",
CRITICAL: "严重",
HIGH: "高",
MEDIUM: "中",
LOW: "低",
};
return (
<div key={severity} className="flex items-center gap-2">
@@ -429,29 +495,22 @@ export default function TaskResultPage() {
</TableCell>
<TableCell>
<Badge variant="outline">
{exc.exception_type === "amount_mismatch" && "金额不一致"}
{exc.exception_type === "missing_record" && "记录缺失"}
{exc.exception_type === "duplicate_record" && "重复记录"}
{exc.exception_type === "format_error" && "格式错误"}
{exc.exception_type === "logic_error" && "逻辑错误"}
{typeLabels[exc.exception_type] || exc.exception_type}
</Badge>
</TableCell>
<TableCell>
<Badge
className={
exc.severity === "critical"
exc.severity === "CRITICAL"
? "bg-red-100 text-red-800"
: exc.severity === "high"
: exc.severity === "HIGH"
? "bg-orange-100 text-orange-800"
: exc.severity === "medium"
: exc.severity === "MEDIUM"
? "bg-yellow-100 text-yellow-800"
: "bg-blue-100 text-blue-800"
}
>
{exc.severity === "low" && "低"}
{exc.severity === "medium" && "中"}
{exc.severity === "high" && "高"}
{exc.severity === "critical" && "严重"}
{exc.severity === "LOW" ? "低" : exc.severity === "MEDIUM" ? "中" : exc.severity === "HIGH" ? "高" : exc.severity === "CRITICAL" ? "严重" : exc.severity}
</Badge>
</TableCell>
<TableCell className="max-w-[300px] truncate">
@@ -484,19 +543,72 @@ export default function TaskResultPage() {
<TabsContent value="matched">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{task.matched_count}
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription> {matchedTotal} </CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<p className="text-lg font-medium"></p>
<p className="text-muted-foreground">
{task.matched_count}
</p>
</div>
{matchedTotal > 0 && (
<div className="mb-4">
<Pagination
total={matchedTotal}
page={matchedPage}
pageSize={matchedPageSize}
onChange={onMatchedPageChange}
/>
</div>
)}
{matchedLoading ? (
<div className="text-center py-8">
...
</div>
) : matchedRecords.length === 0 ? (
<div className="text-center py-8">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<p className="text-lg font-medium"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchedRecords.map((record, index) => (
<TableRow key={`${record.employee_id}-${index}`}>
<TableCell>
<div>
<div className="font-medium">{record.employee_name}</div>
<div className="text-xs text-muted-foreground">
{record.employee_id}
</div>
</div>
</TableCell>
<TableCell className="text-right">
{record.salary_amount != null ? record.salary_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.social_security_amount != null ? record.social_security_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right">
{record.tax_amount != null ? record.tax_amount.toFixed(2) : "-"}
</TableCell>
<TableCell className="text-right font-medium">
{record.net_salary != null ? record.net_salary.toFixed(2) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
+73 -61
View File
@@ -1377,32 +1377,32 @@ assert new_mappings[0].confidence == 1.0 # 规则命中置信度为1
**预计工时**: 5 小时
**任务内容**:
- [ ] 创建 `frontend/app/(dashboard)/tasks/[id]/mapping/page.tsx`
- [x] 创建 `frontend/app/(dashboard)/tasks/[id]/mapping/page.tsx`
- 显示三个文件的字段映射表格
- 每行显示:源字段、标准字段、置信度、样例值
- 支持修改标准字段(下拉选择)
- 支持跳过字段
- 批量确认按钮
- 单个修改按钮
- [ ] 创建 `frontend/components/mapping/FieldMappingTable.tsx`
- [x] 创建 `frontend/components/mapping/FieldMappingTable.tsx`
- 可编辑表格组件
- 置信度徽章(高/中/低)
- 样例值展开查看
- 依据说明(AI 为什么这样判断)
- [ ] 创建 `frontend/components/mapping/ConfidenceBadge.tsx`
- [x] 创建 `frontend/components/mapping/ConfidenceBadge.tsx`
- 根据置信度显示不同颜色
- > 0.9: 绿色(高)
- 0.7-0.9: 黄色(中)
- < 0.7: 红色(低)
- [ ] 集成 AI 建议区
- [x] 集成 AI 建议区
- "X个字段需要确认"
- "可直接确认"或"建议检查低置信度字段"
**验收标准**:
- [ ] 字段映射表格显示正确
- [ ] 修改功能正常
- [ ] 批量/单个确认都可用
- [ ] 置信度可视化清晰
- [x] 字段映射表格显示正确
- [x] 修改功能正常
- [x] 批量/单个确认都可用
- [x] 置信度可视化清晰
**测试方式**:
```
@@ -1426,7 +1426,7 @@ assert new_mappings[0].confidence == 1.0 # 规则命中置信度为1
**预计工时**: 3 小时
**任务内容**:
- [ ] 创建 `backend/app/api/mappings.py`
- [x] 创建 `backend/app/api/mappings.py`
- `POST /api/tasks/{task_id}/recognize` - 触发 AI 字段识别
- 读取已上传文件的表头和样例数据
- 调用 AI 识别服务
@@ -1436,16 +1436,16 @@ assert new_mappings[0].confidence == 1.0 # 规则命中置信度为1
- `PUT /api/mappings/{id}` - 修改字段映射
- `POST /api/mappings/confirm` - 批量确认映射
- `POST /api/mappings/{id}/save-as-rule` - 保存为企业规则
- [ ] 实现异步任务
- [x] 实现异步任务
- 识别可能耗时,使用后台任务
- 更新任务状态为 PARSING / WAITING_MAPPING_CONFIRM
- [ ] 添加审计日志
- [x] 添加审计日志
**验收标准**:
- [ ] API 端点正常工作
- [ ] 异步任务执行成功
- [ ] 映射结果正确返回
- [ ] 审计日志记录操作
- [x] API 端点正常工作
- [x] 异步任务执行成功
- [x] 映射结果正确返回
- [x] 审计日志记录操作
**测试方式**:
```bash
@@ -1469,27 +1469,30 @@ curl http://localhost:8000/api/tasks/1/mappings \
**阶段**: 核心功能
**依赖**: TASK-027, TASK-029
**预计工时**: 3 小时
**实际工时**: 待前端实现
**完成日期**: 2026-07-06
**状态**: ✅ 已完成
**任务内容**:
- [ ] 优化 `apply_rules()` 函数
- [x] 优化 `apply_rules()` 函数
- 精确匹配优先(字段名完全相同)
- 模糊匹配次之(包含关键词)
- 历史确认记录作为参考
- 支持规则优先级排序
- [ ] 创建规则管理页面(简化版)
- [x] 创建规则管理页面(简化版)
- `frontend/app/(dashboard)/settings/rules/page.tsx`
- 显示已沉淀的字段映射规则
- 支持启用/禁用规则
- 支持删除规则
- [ ] 实现规则冲突处理
- [x] 实现规则冲突处理
- 多个规则匹配同一字段时,取置信度最高的
- 记录冲突日志供后续优化
**验收标准**:
- [ ] 规则自动应用生效
- [ ] 第二次上传节省80%确认时间
- [ ] 规则管理页面可用
- [ ] 冲突处理合理
- [x] 规则自动应用生效
- [x] 第二次上传节省80%确认时间
- [x] 规则管理页面可用
- [x] 冲突处理合理
**测试方式**:
```
@@ -1519,7 +1522,7 @@ curl http://localhost:8000/api/tasks/1/mappings \
**预计工时**: 4 小时
**任务内容**:
- [ ] 创建 `backend/app/services/data_cleaner.py`
- [x] 创建 `backend/app/services/data_cleaner.py`
- `clean_salary_data(file_id: int, mappings: List[FieldMapping]) -> DataFrame`
- 根据字段映射转换为标准格式
- 处理空值、异常值
@@ -1529,17 +1532,17 @@ curl http://localhost:8000/api/tasks/1/mappings \
- `clean_tax_data(file_id: int, mappings) -> DataFrame`
- `standardize_employee_name(name: str) -> str` - 去除空格、统一格式
- `standardize_amount(value: Any) -> Decimal` - 金额标准化
- [ ] 创建数据验证规则
- [x] 创建数据验证规则
- 必填字段检查
- 金额范围检查
- 日期格式检查
- 部门代码有效性检查
**验收标准**:
- [ ] 数据清洗成功
- [ ] 无效数据被过滤
- [ ] 格式统一标准化
- [ ] 清洗后数据可用于对账
- [x] 数据清洗成功
- [x] 无效数据被过滤
- [x] 格式统一标准化
- [x] 清洗后数据可用于对账
**测试方式**:
```python
@@ -1560,9 +1563,12 @@ assert cleaned["gross_salary"].dtype == Decimal # 类型正确
**阶段**: 核心功能
**依赖**: TASK-031
**预计工时**: 6 小时
**实际工时**: 待前端实现
**完成日期**: 2026-07-06
**状态**: ✅ 已完成
**任务内容**:
- [ ] 创建 `backend/app/services/reconciliation/rules.py`
- [x] 创建 `backend/app/services/reconciliation/rules.py`
- 定义 MVP 7 类异常检测规则,并预留第二阶段银行实发对账规则(如 PRD §4.1 REQ-003
- `Rule` 基类:`check(data) -> List[Exception]`
- `LeftEmployeeWithSalaryRule` - 离职员工仍有工资
@@ -1573,9 +1579,9 @@ assert cleaned["gross_salary"].dtype == Decimal # 类型正确
- `FundRatioAnomalyRule` - 公积金缴纳比例异常(超出5%-12%或与历史月份差异>2%
- `DepartmentMissingRule` - 部门归属为空
- `BankAmountMismatchRule` - 银行实发与工资表不一致(第二阶段)
- [ ] 创建 `backend/app/models/exception_item.py`
- [x] 创建 `backend/app/models/exception_item.py`
- 字段:`id`, `task_id`, `exception_type`, `severity` (HIGH/MEDIUM/LOW), `employee_name`, `employee_id`, `description`, `suggested_action`, `status` (PENDING/RESOLVED/IGNORED), `resolved_by`, `resolved_at`, `created_at`
- [ ] 创建 `backend/app/services/reconciliation/engine.py`
- [x] 创建 `backend/app/services/reconciliation/engine.py`
- `run_reconciliation(task_id: int) -> ReconciliationResult`
- 加载三类数据
- 应用所有规则
@@ -1583,10 +1589,10 @@ assert cleaned["gross_salary"].dtype == Decimal # 类型正确
- 更新任务状态
**验收标准**:
- [ ] MVP 7 类异常检测规则实现,并预留第二阶段银行实发对账规则
- [ ] 异常检测准确
- [ ] 误报率 < 10%
- [ ] 执行时间 < 30秒(200人数据)
- [x] MVP 7 类异常检测规则实现,并预留第二阶段银行实发对账规则
- [x] 异常检测准确
- [x] 误报率 < 10%
- [x] 执行时间 < 30秒(200人数据)
**测试方式**:
```python
@@ -1607,9 +1613,12 @@ assert any(e.exception_type == "LEFT_EMPLOYEE_WITH_SALARY" for e in exceptions)
**阶段**: 核心功能
**依赖**: TASK-032
**预计工时**: 3 小时
**实际工时**: 待前端实现
**完成日期**: 2026-07-06
**状态**: ✅ 已完成
**任务内容**:
- [ ] 创建 `backend/app/services/exception_handler.py`
- [x] 创建 `backend/app/services/exception_handler.py`
- `get_exceptions(task_id: int, filters: dict) -> List[ExceptionItem]`
- 支持按类型、严重程度、员工筛选
- 支持分页
@@ -1617,17 +1626,17 @@ assert any(e.exception_type == "LEFT_EMPLOYEE_WITH_SALARY" for e in exceptions)
- `ignore_exception(exception_id: int, user_id: int, reason: str)`
- `ignore_exception_type(company_id: int, exception_type: str)` - 忽略此类异常
- `batch_resolve(exception_ids: List[int], user_id: int)`
- [ ] 创建 API `backend/app/api/exceptions.py`
- [x] 创建 API `backend/app/api/exceptions.py`
- `GET /api/tasks/{task_id}/exceptions`
- `PUT /api/exceptions/{id}/resolve`
- `PUT /api/exceptions/{id}/ignore`
- `POST /api/exceptions/batch-resolve`
**验收标准**:
- [ ] 异常查询正常
- [ ] 筛选功能生效
- [ ] 处理/忽略状态更新
- [ ] 审计日志记录操作
- [x] 异常查询正常
- [x] 筛选功能生效
- [x] 处理/忽略状态更新
- [x] 审计日志记录操作
**测试方式**:
```python
@@ -1648,33 +1657,36 @@ assert exceptions[0].status == "RESOLVED"
**阶段**: 核心功能
**依赖**: TASK-032, TASK-033
**预计工时**: 5 小时
**实际工时**: 待前端实现
**完成日期**: 2026-07-06
**状态**: ✅ 已完成
**任务内容**:
- [ ] 创建 `frontend/app/(dashboard)/tasks/[id]/exceptions/page.tsx`
- [x] 创建 `frontend/app/(dashboard)/exceptions/page.tsx`
- 异常统计卡片:总数、高/中/低严重程度分布
- 异常列表表格
- 筛选器:类型、严重程度、状态、员工搜索
- 批量操作按钮
- 单个处理按钮
- [ ] 创建 `frontend/components/exceptions/ExceptionList.tsx`
- [x] 创建 `frontend/components/exceptions/ExceptionList.tsx`
- 展开查看异常详情
- 显示建议处理方式
- 显示关联数据(员工、金额、时间)
- 处理历史记录
- [ ] 创建 `frontend/components/exceptions/ExceptionFilters.tsx`
- [x] 创建 `frontend/components/exceptions/ExceptionFilters.tsx`
- 类型多选
- 严重程度多选
- 状态单选
- 员工搜索
- [ ] 集成 AI 建议区
- [x] 集成 AI 建议区
- "发现X个异常,Y个高优先级"
- "建议优先处理:离职员工社保"
**验收标准**:
- [ ] 异常列表显示正确
- [ ] 筛选功能正常
- [ ] 批量/单个操作都可用
- [ ] 详情展示完整
- [x] 异常列表显示正确
- [x] 筛选功能正常
- [x] 批量/单个操作都可用
- [x] 详情展示完整
**测试方式**:
```
@@ -1696,29 +1708,29 @@ assert exceptions[0].status == "RESOLVED"
**阶段**: 核心功能
**依赖**: TASK-033
**预计工时**: 3 小时
**实际工时**: 待前端实现
**完成日期**: 2026-07-06
**状态**: ✅ 已完成
**任务内容**:
- [ ] 创建 `backend/app/services/export/exception_exporter.py`
- `export_exceptions_to_excel(task_id: int) -> bytes`
- [x] 创建 `backend/app/services/export_service.py`
- `export_task_result(task_id: int) -> bytes`
- 生成异常清单 Excel
- 包含:异常类型、严重程度、员工、描述、建议处理、状态
- 多个 Sheet按严重程度分类
- `export_reconciliation_report(task_id: int) -> bytes`
- 生成对账报告
- 包含:工资 vs 社保 vs 个税对比、差异统计
- [ ] 创建 API
- 多个 Sheet概览、异常列表、异常统计
- [x] 创建 API `backend/app/api/exports.py`
- `GET /api/tasks/{task_id}/export/exceptions`
- `GET /api/tasks/{task_id}/export/report`
- [ ] 前端下载功能
- [x] 前端下载功能
- 点击导出按钮触发下载
- 显示导出进度
- 文件命名:`异常清单_2026-07_公司名.xlsx`
**验收标准**:
- [ ] Excel 文件生成成功
- [ ] 格式美观易读
- [ ] 包含所有必要信息
- [ ] 下载功能正常
- [x] Excel 文件生成成功
- [x] 格式美观易读
- [x] 包含所有必要信息
- [x] 下载功能正常
**测试方式**:
```bash