feat: add employee management, backend health check, and UI improvements

This commit is contained in:
2026-05-07 11:50:10 +08:00
parent a5db09f41e
commit c00db75c13
59 changed files with 3926 additions and 5796 deletions

View File

@@ -23,7 +23,9 @@ SERVER_PORT=8000
VITE_SERVER_HOST=127.0.0.1
VITE_SERVER_PORT=8000
SERVER_STARTUP_TIMEOUT=300
SERVER_BLOCKING_STARTUP_TIMEOUT=12
VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432

View File

@@ -1,117 +0,0 @@
# AI 报销预审中台 MVP — 总览
> **版本:** v1.0
> **周期:** 8 周W1 ~ W8
> **团队:** 3-5 人
> **目标:** 跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。
---
## 技术栈
| 层 | 技术 |
|---|---|
| 前端 | Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia |
| 后端 | Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 |
| 数据库 | PostgreSQL 15 + Redis 7 |
| 文件存储 | MinIOS3 兼容) |
| OCR | 百度云 OCR API + Mock Provider |
| 规则引擎 | 自研 JSON Rule Engine |
| Agent | 自研 Orchestrator 状态机 + 大模型 API |
| 部署 | Docker Compose |
---
## 团队分工建议
| 角色 | 人数 | 职责 |
|---|---|---|
| 后端工程师 A | 1 | 核心后端任务管理、影子账本、Agent 编排、规则引擎 |
| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 |
| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) |
| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 |
---
## 阶段总览
| 阶段 | 周数 | 任务数 | 文档 | 可并行度 |
|---|---|---|---|---|
| Phase 1: 项目基建 | W1 | 4 | [phase-1-project-infra/README.md](phase-1-project-infra/README.md) | 高(前端+后端+Docker并行 |
| Phase 2: 后端核心服务 | W2-W3 | 6 | [phase-2-backend-core/README.md](phase-2-backend-core/README.md) | 高任务API+文件上传+OCR并行 |
| Phase 3: Agent 编排 | W3-W4 | 4 | [phase-3-agent-orchestration/README.md](phase-3-agent-orchestration/README.md) | 中Orchestrator先行Agents并行 |
| Phase 4: 前端核心页面 | W4-W5 | 4 | [phase-4-frontend-pages/README.md](phase-4-frontend-pages/README.md) | 高(页面间独立并行) |
| Phase 5: 联调与集成 | W5-W6 | 2 | [phase-5-integration/README.md](phase-5-integration/README.md) | 中 |
| Phase 6: 测试与打磨 | W7-W8 | 4 | [phase-6-testing-polish/README.md](phase-6-testing-polish/README.md) | 中 |
| **总计** | **8 周** | **24 个任务** | | |
---
## 里程碑时间线
```
W1 W2 W3 W4 W5 W6 W7 W8
| | | | | | | |
├─Phase 1──┤ | | | | | |
| 基建 | | | | | | |
| ├────────Phase 2──────┤ | | | |
| | 后端核心 API | | | | |
| | ├────────Phase 3──────┤ | | |
| | | Agent 编排 | | | |
| | | ├────────Phase 4──────┤ | |
| | | | 前端页面 | | |
| | | | ├────Phase 5────┤ | |
| | | | | 联调集成 | | |
| | | | | | ├─────Phase 6─────┤
| | | | | | | 测试打磨 |
```
---
## 阶段依赖关系
```
Phase 1 (基建)
Phase 2 (后端核心) ←── 可与 Phase 3 部分重叠
Phase 3 (Agent 编排)
Phase 4 (前端页面) ←── 可与 Phase 3 后半段并行
Phase 5 (联调集成)
Phase 6 (测试打磨)
```
**关键路径:** Phase 1 → Phase 2 → Phase 3 → Phase 5 → Phase 6
**可并行路径:** Phase 4 可在 Phase 3 后半段提前开始
---
## 风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 |
| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback |
| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则JSON 配置化后续迭代 |
| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API |
| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页W8 补) |
---
## 验收标准
MVP 完成的标志:
- [ ] 用户能通过 Web 界面创建差旅报销任务
- [ ] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水)
- [ ] OCR 自动识别票据信息并生成报销草稿
- [ ] 规则引擎执行 6 条核心预审规则
- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议)
- [ ] 用户能补件并重新预审
- [ ] 用户确认后模拟同步成功
- [ ] 影子报销账本完整记录业务数据
- [ ] 审计日志记录所有关键操作
- [ ] 完整流程端到端测试通过

View File

@@ -1,802 +0,0 @@
# Phase 1: 项目基建W1
> **目标:** 搭建前后端项目骨架、定义数据库模型、配置开发环境,确保团队可以立即开始业务开发。
> **周期:** 第 1 周
> **任务数:** 4 个
> **可并行:** 后端骨架 / 前端骨架 / Docker Compose 可完全并行
> **前置依赖:** 无
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| 后端项目骨架 | FastAPI + SQLAlchemy + Alembic可运行的健康检查 |
| 数据库 Schema | 全部 12 张表的 ORM 模型 + Alembic 迁移 |
| 前端项目骨架 | Vue3 + TS + Ant Design Vue路由和 API 层配置 |
| 开发环境 | Docker ComposePostgreSQL + Redis + MinIO |
---
## 任务清单
### Task 1.1: 后端项目骨架搭建
**负责人:** 后端工程师 A
**预计工时:** 1 天
**Files:**
- Create: `backend/app/__init__.py`
- Create: `backend/app/main.py`
- Create: `backend/app/core/config.py`
- Create: `backend/app/core/database.py`
- Create: `backend/app/core/dependencies.py`
- Create: `backend/app/api/__init__.py`
- Create: `backend/app/api/v1/__init__.py`
- Create: `backend/app/api/v1/router.py`
- Create: `backend/app/models/__init__.py`
- Create: `backend/app/schemas/__init__.py`
- Create: `backend/app/services/__init__.py`
- Create: `backend/requirements.txt`
- Create: `backend/pyproject.toml`
- Create: `backend/Dockerfile`
- Create: `backend/alembic.ini`
- Create: `backend/alembic/env.py`
- Test: `backend/tests/__init__.py`
- Test: `backend/tests/conftest.py`
- Test: `backend/tests/test_health.py`
- [ ] **Step 1: 初始化后端项目结构**
创建 FastAPI 项目骨架,使用以下目录结构:
```
backend/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI 应用入口
│ ├── core/
│ │ ├── config.py # SettingsPydantic BaseSettings
│ │ ├── database.py # SQLAlchemy async engine + session
│ │ └── dependencies.py # 通用依赖注入db session, 当前用户等)
│ ├── api/
│ │ └── v1/
│ │ ├── __init__.py
│ │ ├── router.py # v1 路由聚合
│ │ ├── tasks.py # 报销任务 API
│ │ ├── documents.py # 票据附件 API
│ │ ├── precheck.py # 预审结果 API
│ │ └── supplements.py # 补件 API
│ ├── models/ # SQLAlchemy ORM models
│ │ ├── __init__.py
│ │ ├── task.py
│ │ ├── reimbursement.py
│ │ ├── document.py
│ │ ├── rule.py
│ │ └── audit.py
│ ├── schemas/ # Pydantic schemas
│ │ ├── __init__.py
│ │ ├── task.py
│ │ ├── reimbursement.py
│ │ ├── document.py
│ │ └── rule.py
│ ├── services/ # 业务逻辑层
│ │ ├── __init__.py
│ │ ├── task_service.py
│ │ ├── document_service.py
│ │ ├── ocr_service.py
│ │ ├── rule_engine.py
│ │ └── sync_service.py
│ └── agents/ # Agent 编排层
│ ├── __init__.py
│ ├── orchestrator.py
│ ├── intake_agent.py
│ ├── parse_agent.py
│ ├── rule_check_agent.py
│ ├── explain_agent.py
│ └── sync_agent.py
├── alembic/
│ ├── env.py
│ └── versions/
├── alembic.ini
├── requirements.txt
├── pyproject.toml
├── Dockerfile
└── tests/
├── __init__.py
├── conftest.py
└── test_health.py
```
- [ ] **Step 2: 编写核心配置文件**
`backend/app/core/config.py` 使用 Pydantic BaseSettings
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# App
APP_NAME: str = "AI Reimbursement Agent"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# MinIO / S3
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "reimbursement"
# OCR
OCR_PROVIDER: str = "baidu" # baidu | tencent | mock
BAIDU_OCR_API_KEY: str = ""
BAIDU_OCR_SECRET_KEY: str = ""
# LLM
LLM_PROVIDER: str = "openai"
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
LLM_BASE_URL: str = ""
class Config:
env_file = ".env"
settings = Settings()
```
- [ ] **Step 3: 编写数据库连接和 FastAPI 入口**
`backend/app/core/database.py`:
```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
```
`backend/app/main.py`:
```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1.router import api_router
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/health")
async def health():
return {"status": "ok", "version": settings.APP_VERSION}
```
- [ ] **Step 4: 编写 requirements.txt**
```
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy[asyncio]==2.0.35
asyncpg==0.30.0
alembic==1.13.0
pydantic==2.9.0
pydantic-settings==2.5.0
python-multipart==0.0.9
httpx==0.27.0
redis==5.1.0
minio==7.2.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pytest==8.3.0
pytest-asyncio==0.24.0
```
- [ ] **Step 5: 编写健康检查测试**
`backend/tests/conftest.py`:
```python
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
```
`backend/tests/test_health.py`:
```python
import pytest
@pytest.mark.asyncio
async def test_health(client):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
```
- [ ] **Step 6: 运行测试确认骨架可用**
Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add backend/
git commit -m "feat: 初始化后端项目骨架FastAPI + SQLAlchemy + Alembic"
```
---
### Task 1.2: 数据库 Schema + 迁移
**负责人:** 后端工程师 A
**预计工时:** 1.5 天
**前置依赖:** Task 1.1
**Files:**
- Create: `backend/app/models/base.py`
- Create: `backend/app/models/task.py`
- Create: `backend/app/models/reimbursement.py`
- Create: `backend/app/models/document.py`
- Create: `backend/app/models/rule.py`
- Create: `backend/app/models/audit.py`
- Create: `backend/app/models/enums.py`
- Modify: `backend/app/models/__init__.py`
- Test: `backend/tests/test_models.py`
- [ ] **Step 1: 定义枚举类型**
`backend/app/models/enums.py`:
```python
import enum
class TaskStatus(str, enum.Enum):
CREATED = "created"
MATERIAL_COLLECTING = "material_collecting"
PARSING = "parsing"
DRAFT_GENERATED = "draft_generated"
PRECHECKING = "prechecking"
NEED_SUPPLEMENT = "need_supplement"
PENDING_USER_CONFIRM = "pending_user_confirm"
SUBMITTING = "submitting"
SYNCED = "synced"
SYNC_FAILED = "sync_failed"
CLOSED = "closed"
class ExpenseType(str, enum.Enum):
TRAVEL_TRANSPORT = "travel_transport"
TRAVEL_HOTEL = "travel_hotel"
TRAVEL_MEAL = "travel_meal"
LOCAL_TRANSPORT = "local_transport"
BUSINESS_MEAL = "business_meal"
OFFICE_SUPPLY = "office_supply"
COMMUNICATION = "communication"
OTHER = "other"
class DocumentType(str, enum.Enum):
VAT_INVOICE = "vat_invoice"
TRAIN_TICKET = "train_ticket"
FLIGHT_ITINERARY = "flight_itinerary"
TAXI_RECEIPT = "taxi_receipt"
HOTEL_BILL = "hotel_bill"
PAYMENT_SCREENSHOT = "payment_screenshot"
TRAVEL_ORDER = "travel_order"
OTHER_ATTACHMENT = "other_attachment"
class RiskLevel(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
BLOCKED = "blocked"
class RuleAction(str, enum.Enum):
PASS = "pass"
WARN = "warn"
REQUIRE_EXPLANATION = "require_explanation"
REQUIRE_ATTACHMENT = "require_attachment"
REQUIRE_APPROVAL = "require_approval"
BLOCK = "block"
class SyncStatus(str, enum.Enum):
SUCCESS = "success"
FAILED = "failed"
RETRYING = "retrying"
PENDING = "pending"
```
- [ ] **Step 2: 定义 Base Mixin**
`backend/app/models/base.py`:
```python
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
def generate_id():
return str(uuid.uuid4())
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class IDMixin:
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id)
```
- [ ] **Step 3: 定义 ReimbursementTask 模型**
`backend/app/models/task.py`:
```python
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import IDMixin, TimestampMixin, Base
from app.models.enums import TaskStatus
class ReimbursementTask(IDMixin, TimestampMixin, Base):
__tablename__ = "reimbursement_task"
user_id: Mapped[str] = mapped_column(String(36), index=True)
company_id: Mapped[str] = mapped_column(String(36), index=True)
task_type: Mapped[str] = mapped_column(String(50), default="travel_expense")
status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED)
user_intent: Mapped[str | None] = mapped_column(Text, nullable=True)
current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True)
documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin")
reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin")
```
- [ ] **Step 4: 定义 ShadowReimbursement + ReimbursementItem + SupplementRequest + SyncRecord**
`backend/app/models/reimbursement.py` — 字段按开发文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节:
```python
from sqlalchemy import String, Text, Numeric, Date, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from decimal import Decimal
from datetime import date
from app.models.base import IDMixin, TimestampMixin, Base
from app.models.enums import RiskLevel, SyncStatus
class ShadowReimbursement(IDMixin, TimestampMixin, Base):
__tablename__ = "shadow_reimbursement"
task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"), unique=True)
applicant_id: Mapped[str] = mapped_column(String(36))
department_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
cost_center_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
reimbursement_type: Mapped[str] = mapped_column(String(50))
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0"))
currency: Mapped[str] = mapped_column(String(10), default="CNY")
risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
precheck_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
backend_system: Mapped[str | None] = mapped_column(String(50), nullable=True)
backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
task = relationship("ReimbursementTask", back_populates="reimbursement")
items = relationship("ReimbursementItem", back_populates="reimbursement", lazy="selectin")
class ReimbursementItem(IDMixin, TimestampMixin, Base):
__tablename__ = "reimbursement_item"
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
expense_type: Mapped[str] = mapped_column(String(50))
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
occurred_at: Mapped[date | None] = mapped_column(Date, nullable=True)
city: Mapped[str | None] = mapped_column(String(50), nullable=True)
vendor_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
invoice_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
policy_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
reimbursement = relationship("ShadowReimbursement", back_populates="items")
class SupplementRequest(IDMixin, TimestampMixin, Base):
__tablename__ = "supplement_request"
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
request_type: Mapped[str] = mapped_column(String(30)) # attachment / explanation / field_modify
target_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
message: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="pending") # pending / resolved / closed
user_response: Mapped[str | None] = mapped_column(Text, nullable=True)
resolved_at: Mapped[date | None] = mapped_column(Date, nullable=True)
class SyncRecord(IDMixin, TimestampMixin, Base):
__tablename__ = "sync_record"
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
target_system: Mapped[str] = mapped_column(String(50))
request_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
response_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
sync_status: Mapped[str] = mapped_column(String(20), default="pending")
backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
```
- [ ] **Step 5: 定义 ExpenseDocument 模型**
`backend/app/models/document.py` — 字段按开发文档 5.2.4 节:
```python
from sqlalchemy import String, Text, Numeric, Date, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from decimal import Decimal
from datetime import date
from app.models.base import IDMixin, TimestampMixin, Base
class ExpenseDocument(IDMixin, TimestampMixin, Base):
__tablename__ = "expense_document"
reimbursement_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"), nullable=True)
item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"))
document_type: Mapped[str] = mapped_column(String(30))
file_url: Mapped[str] = mapped_column(String(500))
ocr_status: Mapped[str] = mapped_column(String(20), default="pending") # pending / processing / done / failed
extracted_json: Mapped[dict | None] = mapped_column(Text, nullable=True)
invoice_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
invoice_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True)
amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
seller_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
buyer_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
verify_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
duplicate_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
task = relationship("ReimbursementTask", back_populates="documents")
```
- [ ] **Step 6: 定义 ExpenseRule + RuleHit 模型**
`backend/app/models/rule.py` — 字段按开发文档 5.2.5, 5.2.6 节:
```python
from sqlalchemy import String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import IDMixin, TimestampMixin, Base
class ExpenseRule(IDMixin, TimestampMixin, Base):
__tablename__ = "expense_rule"
rule_code: Mapped[str] = mapped_column(String(50), unique=True)
rule_name: Mapped[str] = mapped_column(String(100))
expense_type: Mapped[str] = mapped_column(String(50))
condition_json: Mapped[dict] = mapped_column(Text) # JSON string
action: Mapped[str] = mapped_column(String(30))
severity: Mapped[str] = mapped_column(String(20))
message_template: Mapped[str] = mapped_column(Text)
policy_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[str] = mapped_column(String(10), default="1.0")
class RuleHit(IDMixin, TimestampMixin, Base):
__tablename__ = "rule_hit"
reimbursement_id: Mapped[str] = mapped_column(String(36))
item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
rule_id: Mapped[str] = mapped_column(String(36))
severity: Mapped[str] = mapped_column(String(20))
hit_result: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
suggestion: Mapped[str | None] = mapped_column(Text, nullable=True)
```
- [ ] **Step 7: 定义 AuditLog 模型**
`backend/app/models/audit.py`:
```python
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import IDMixin, TimestampMixin, Base
class AuditLog(IDMixin, TimestampMixin, Base):
__tablename__ = "audit_log"
action: Mapped[str] = mapped_column(String(50), index=True) # upload / ocr / agent / rule_hit / supplement / confirm / sync
actor: Mapped[str] = mapped_column(String(36), index=True)
target_type: Mapped[str] = mapped_column(String(50)) # task / document / reimbursement / rule
target_id: Mapped[str] = mapped_column(String(36))
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string
```
- [ ] **Step 8: 更新 `models/__init__.py` 导出所有模型**
```python
from app.models.task import ReimbursementTask
from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord
from app.models.document import ExpenseDocument
from app.models.rule import ExpenseRule, RuleHit
from app.models.audit import AuditLog
from app.models.base import Base
__all__ = [
"ReimbursementTask", "ShadowReimbursement", "ReimbursementItem",
"ExpenseDocument", "ExpenseRule", "RuleHit",
"SupplementRequest", "SyncRecord", "AuditLog", "Base",
]
```
- [ ] **Step 9: 生成 Alembic 迁移**
Run: `cd backend && alembic revision --autogenerate -m "init schema"`
Run: `cd backend && alembic upgrade head`
- [ ] **Step 10: 编写模型测试**
`backend/tests/test_models.py` — 验证所有表能正确创建和插入数据。
- [ ] **Step 11: Commit**
```bash
git add backend/
git commit -m "feat: 完成所有数据模型定义和数据库迁移"
```
---
### Task 1.3: 前端项目骨架搭建
**负责人:** 前端工程师
**预计工时:** 1 天
**前置依赖:** 无(可与 Task 1.1 并行)
**Files:**
- Create: Vue3 + TypeScript 项目Vite 初始化)
- Create: `frontend/src/router/index.ts`
- Create: `frontend/src/stores/`
- Create: `frontend/src/api/`
- Create: `frontend/src/views/`
- Create: `frontend/src/components/`
- Create: `frontend/src/layouts/`
- [ ] **Step 1: 初始化 Vue3 项目**
```bash
npm create vite@latest frontend -- --template vue-ts
cd frontend
npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs
```
目录结构:
```
frontend/
├── src/
│ ├── api/ # API 调用
│ │ ├── index.ts # axios 实例
│ │ ├── task.ts # 报销任务 API
│ │ ├── document.ts # 票据 API
│ │ └── precheck.ts # 预审 API
│ ├── components/ # 通用组件
│ │ ├── FileUpload.vue
│ │ ├── ExpenseTable.vue
│ │ └── RuleHitCard.vue
│ ├── layouts/
│ │ └── MainLayout.vue
│ ├── router/
│ │ └── index.ts
│ ├── stores/
│ │ ├── task.ts
│ │ └── user.ts
│ ├── views/
│ │ ├── HomeView.vue # 报销入口
│ │ ├── UploadView.vue # 票据上传
│ │ ├── DraftView.vue # 报销草稿
│ │ ├── PrecheckView.vue # 预审结果
│ │ ├── SupplementView.vue # 补件交互
│ │ ├── ConfirmView.vue # 提交确认
│ │ └── AuditView.vue # 审计日志
│ ├── App.vue
│ └── main.ts
├── index.html
├── vite.config.ts
├── tsconfig.json
└── package.json
```
- [ ] **Step 2: 配置路由和布局**
`frontend/src/router/index.ts`:
```typescript
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
{ path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') },
{ path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') },
{ path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') },
{ path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') },
{ path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') },
{ path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') },
]
export default createRouter({ history: createWebHistory(), routes })
```
- [ ] **Step 3: 配置 API 封装**
`frontend/src/api/index.ts`:
```typescript
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
timeout: 30000,
})
export default api
```
- [ ] **Step 4: 确认前端能正常启动**
Run: `cd frontend && npm run dev`
Expected: 浏览器访问 http://localhost:5173 能看到页面
- [ ] **Step 5: Commit**
```bash
git add frontend/
git commit -m "feat: 初始化前端项目骨架Vue3 + TypeScript + Ant Design Vue"
```
---
### Task 1.4: Docker Compose 开发环境
**负责人:** 后端工程师 B
**预计工时:** 0.5 天
**前置依赖:** 无(可与 Task 1.1、1.3 并行)
**Files:**
- Create: `docker-compose.yml`
- Create: `backend/Dockerfile`
- Create: `frontend/Dockerfile`
- Create: `.env.example`
- [ ] **Step 1: 编写 docker-compose.yml**
```yaml
version: "3.8"
services:
postgres:
image: postgres:15
environment:
POSTGRES_DB: x_financial
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
volumes:
pgdata:
minio_data:
```
- [ ] **Step 2: 编写 .env.example**
```env
# 数据库
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial
# Redis
REDIS_URL=redis://localhost:6379/0
# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=reimbursement
# OCR必填正式环境开发环境可用 mock
OCR_PROVIDER=mock
BAIDU_OCR_API_KEY=
BAIDU_OCR_SECRET_KEY=
# LLM必填正式环境
LLM_PROVIDER=openai
LLM_API_KEY=
LLM_MODEL=gpt-4o-mini
LLM_BASE_URL=
# 前端
VITE_API_BASE_URL=http://localhost:8000/api/v1
```
- [ ] **Step 3: 编写后端 Dockerfile**
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
- [ ] **Step 4: 验证环境启动**
Run: `docker-compose up -d`
Run: `docker-compose ps`
Expected: postgres, redis, minio 均为 running
- [ ] **Step 5: Commit**
```bash
git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile
git commit -m "feat: 添加 Docker Compose 开发环境PostgreSQL + Redis + MinIO"
```
---
## 本阶段完成检查
- [ ] `cd backend && pytest tests/test_health.py -v` 通过
- [ ] `cd backend && alembic upgrade head` 无报错,所有表已创建
- [ ] `cd frontend && npm run dev` 能正常启动
- [ ] `docker-compose up -d` 三个服务均 running
- [ ] `.env.example` 已创建,配置项说明完整

View File

@@ -1,834 +0,0 @@
# Phase 2: 后端核心服务W2-W3
> **目标:** 实现所有后端业务 API包括任务管理、文件上传、OCR 集成、规则引擎、影子账本、补件与提交。
> **周期:** 第 2 ~ 3 周
> **任务数:** 6 个
> **可并行:** Task 2.1 / 2.2 / 2.3 可并行Task 2.4 依赖 2.1Task 2.5 依赖 2.2 + 2.4
> **前置依赖:** Phase 1 完成
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| 报销任务 API | 创建/查询/列表 |
| 文件上传 API | MinIO 存储 + 票据管理 |
| OCR 服务 | 百度云 + Mock Provider |
| 规则引擎 | 6 条核心规则 + 管理 API |
| 影子账本 API | 草稿/预审结果查询 |
| 补件 + 提交 API | 补件交互 + 模拟同步 |
---
## 任务清单
### Task 2.1: 报销任务管理 API
**负责人:** 后端工程师 A
**预计工时:** 1.5 天
**前置依赖:** Phase 1
**Files:**
- Create: `backend/app/schemas/task.py`
- Create: `backend/app/services/task_service.py`
- Create: `backend/app/api/v1/tasks.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_task_api.py`
- [ ] **Step 1: 定义 Pydantic schemas**
`backend/app/schemas/task.py`:
```python
from pydantic import BaseModel
from datetime import datetime
class TaskCreateRequest(BaseModel):
user_id: str
company_id: str
user_intent: str
entry_channel: str = "web"
class TaskResponse(BaseModel):
task_id: str
status: str
class Config:
from_attributes = True
class TaskDetailResponse(BaseModel):
id: str
user_id: str
company_id: str
task_type: str
status: str
user_intent: str | None
current_agent: str | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TaskListResponse(BaseModel):
total: int
items: list[TaskDetailResponse]
```
- [ ] **Step 2: 实现 TaskService 业务逻辑**
`backend/app/services/task_service.py`:
```python
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.models.task import ReimbursementTask
from app.models.enums import TaskStatus
from app.schemas.task import TaskCreateRequest
class TaskService:
def __init__(self, db: AsyncSession):
self.db = db
async def create_task(self, req: TaskCreateRequest) -> ReimbursementTask:
task = ReimbursementTask(
user_id=req.user_id,
company_id=req.company_id,
user_intent=req.user_intent,
status=TaskStatus.MATERIAL_COLLECTING,
)
self.db.add(task)
await self.db.commit()
await self.db.refresh(task)
return task
async def get_task(self, task_id: str) -> ReimbursementTask | None:
result = await self.db.execute(
select(ReimbursementTask).where(ReimbursementTask.id == task_id)
)
return result.scalar_one_or_none()
async def list_tasks(self, user_id: str | None = None, status: str | None = None,
page: int = 1, size: int = 20) -> tuple[list[ReimbursementTask], int]:
query = select(ReimbursementTask)
count_query = select(func.count()).select_from(ReimbursementTask)
if user_id:
query = query.where(ReimbursementTask.user_id == user_id)
count_query = count_query.where(ReimbursementTask.user_id == user_id)
if status:
query = query.where(ReimbursementTask.status == status)
count_query = count_query.where(ReimbursementTask.status == status)
total_result = await self.db.execute(count_query)
total = total_result.scalar() or 0
query = query.offset((page - 1) * size).limit(size).order_by(ReimbursementTask.created_at.desc())
result = await self.db.execute(query)
return result.scalars().all(), total
async def update_status(self, task_id: str, status: TaskStatus, current_agent: str | None = None) -> ReimbursementTask | None:
task = await self.get_task(task_id)
if not task:
return None
task.status = status
task.current_agent = current_agent
await self.db.commit()
await self.db.refresh(task)
return task
```
- [ ] **Step 3: 实现 API 路由**
`backend/app/api/v1/tasks.py`:
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.task import TaskCreateRequest, TaskResponse, TaskDetailResponse, TaskListResponse
from app.services.task_service import TaskService
router = APIRouter(prefix="/reimbursement/tasks", tags=["tasks"])
@router.post("", response_model=TaskResponse, status_code=201)
async def create_task(req: TaskCreateRequest, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
task = await svc.create_task(req)
return TaskResponse(task_id=task.id, status=task.status.value)
@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
task = await svc.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.get("", response_model=TaskListResponse)
async def list_tasks(user_id: str | None = None, status: str | None = None,
page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
items, total = await svc.list_tasks(user_id, status, page, size)
return TaskListResponse(total=total, items=items)
```
更新 `backend/app/api/v1/router.py`:
```python
from fastapi import APIRouter
from app.api.v1.tasks import router as tasks_router
api_router = APIRouter()
api_router.include_router(tasks_router)
```
- [ ] **Step 4: 编写测试**
```python
# backend/tests/test_task_api.py
import pytest
@pytest.mark.asyncio
async def test_create_task(client):
response = await client.post("/api/v1/reimbursement/tasks", json={
"user_id": "U001",
"company_id": "C001",
"user_intent": "我要报这次北京出差的费用",
"entry_channel": "web"
})
assert response.status_code == 201
data = response.json()
assert "task_id" in data
assert data["status"] == "material_collecting"
@pytest.mark.asyncio
async def test_get_task(client):
# 先创建
create_resp = await client.post("/api/v1/reimbursement/tasks", json={
"user_id": "U001", "company_id": "C001", "user_intent": "test"
})
task_id = create_resp.json()["task_id"]
# 再查询
get_resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}")
assert get_resp.status_code == 200
assert get_resp.json()["user_id"] == "U001"
@pytest.mark.asyncio
async def test_list_tasks(client):
response = await client.get("/api/v1/reimbursement/tasks")
assert response.status_code == 200
assert "total" in response.json()
assert "items" in response.json()
```
- [ ] **Step 5: 运行测试**
Run: `cd backend && pytest tests/test_task_api.py -v`
Expected: All PASS
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现报销任务管理 API创建/查询/列表)"
```
---
### Task 2.2: 文件上传与票据管理 API
**负责人:** 后端工程师 B
**预计工时:** 1.5 天
**前置依赖:** Phase 1
**Files:**
- Create: `backend/app/schemas/document.py`
- Create: `backend/app/services/document_service.py`
- Create: `backend/app/services/storage_service.py`
- Create: `backend/app/api/v1/documents.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_document_api.py`
- [ ] **Step 1: 实现 MinIO 存储服务**
`backend/app/services/storage_service.py` — 封装 MinIO 操作:
- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件到 MinIO
- `get_file_url(bucket, file_name)` → 获取文件访问 URL
- `delete_file(bucket, file_name)` → 删除文件
- `ensure_bucket(bucket)` → 确保 bucket 存在
开发阶段可使用 mock 实现(本地文件系统存储)。
- [ ] **Step 2: 实现文档服务**
`backend/app/services/document_service.py`:
- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO创建 DB 记录
- `get_documents(task_id)` → 查询任务下所有票据
- `get_document(document_id)` → 查询单个票据
- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果
- [ ] **Step 3: 定义 Pydantic schemas**
`backend/app/schemas/document.py`:
```python
from pydantic import BaseModel
from datetime import date
from decimal import Decimal
class DocumentUploadResponse(BaseModel):
document_id: str
ocr_status: str
class DocumentResponse(BaseModel):
id: str
task_id: str
document_type: str
file_url: str
ocr_status: str
invoice_code: str | None
invoice_number: str | None
invoice_date: date | None
amount: Decimal | None
seller_name: str | None
class Config:
from_attributes = True
```
- [ ] **Step 4: 实现 API 路由**
`backend/app/api/v1/documents.py`:
```python
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.document import DocumentUploadResponse, DocumentResponse
from app.services.document_service import DocumentService
router = APIRouter(prefix="/reimbursement/tasks/{task_id}/documents", tags=["documents"])
@router.post("", response_model=DocumentUploadResponse, status_code=201)
async def upload_document(
task_id: str,
file: UploadFile = File(...),
document_type: str = Form(...),
db: AsyncSession = Depends(get_db)
):
svc = DocumentService(db)
doc = await svc.upload_document(task_id, file, document_type)
return DocumentUploadResponse(document_id=doc.id, ocr_status=doc.ocr_status)
@router.get("", response_model=list[DocumentResponse])
async def list_documents(task_id: str, db: AsyncSession = Depends(get_db)):
svc = DocumentService(db)
docs = await svc.get_documents(task_id)
return docs
```
`router.py` 中注册 documents_router。
- [ ] **Step 5: 编写测试(使用 mock MinIO**
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现文件上传与票据管理 APIMinIO 存储)"
```
---
### Task 2.3: OCR 服务集成
**负责人:** 后端工程师 B
**预计工时:** 2 天
**前置依赖:** Task 2.2(需要 document_service
**可并行于:** Task 2.1、Task 2.4
**Files:**
- Create: `backend/app/services/ocr_service.py`
- Create: `backend/app/services/ocr_providers/__init__.py`
- Create: `backend/app/services/ocr_providers/base.py`
- Create: `backend/app/services/ocr_providers/baidu.py`
- Create: `backend/app/services/ocr_providers/mock.py`
- Test: `backend/tests/test_ocr_service.py`
- [ ] **Step 1: 定义 OCR Provider 抽象接口**
`backend/app/services/ocr_providers/base.py`:
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class OCRResult:
document_type: str # 识别出的票据类型
raw_text: str # 原始文字
fields: dict = field(default_factory=dict) # 结构化字段
confidence: float = 0.0 # 整体置信度 0-1
provider: str = "" # 提供商名称
class OCRProvider(ABC):
@abstractmethod
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
...
@abstractmethod
async def recognize_vat_invoice(self, file_url: str) -> OCRResult:
...
@abstractmethod
async def recognize_train_ticket(self, file_url: str) -> OCRResult:
...
```
- [ ] **Step 2: 实现 Mock OCR Provider**
`backend/app/services/ocr_providers/mock.py` — 根据文件名/类型返回预定义的结构化数据:
```python
from app.services.ocr_providers.base import OCRProvider, OCRResult
class MockOCRProvider(OCRProvider):
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
if document_type == "vat_invoice" or "invoice" in file_url:
return await self.recognize_vat_invoice(file_url)
elif document_type == "train_ticket" or "train" in file_url:
return await self.recognize_train_ticket(file_url)
return OCRResult(document_type="unknown", raw_text="", confidence=0.0, provider="mock")
async def recognize_vat_invoice(self, file_url: str) -> OCRResult:
return OCRResult(
document_type="vat_invoice",
raw_text="增值税电子普通发票",
fields={
"invoice_code": "050002100311",
"invoice_number": "23912077",
"invoice_date": "2026-04-20",
"amount": "1061.95",
"tax_amount": "61.95",
"total_amount": "1123.90",
"seller_name": "北京XX酒店管理有限公司",
"buyer_name": "XX科技有限公司",
"check_code": "1234567890",
},
confidence=0.95,
provider="mock"
)
async def recognize_train_ticket(self, file_url: str) -> OCRResult:
return OCRResult(
document_type="train_ticket",
raw_text="火车票",
fields={
"train_number": "G101",
"departure_station": "北京南",
"arrival_station": "上海虹桥",
"departure_date": "2026-04-18",
"departure_time": "07:00",
"seat_type": "二等座",
"amount": "553.00",
"passenger_name": "张三",
"id_number": "****1234",
},
confidence=0.90,
provider="mock"
)
```
- [ ] **Step 3: 实现百度 OCR Provider**
`backend/app/services/ocr_providers/baidu.py` — 调用百度云 OCR API
- `recognize_vat_invoice()` → 调用增值税发票识别接口
- `recognize_train_ticket()` → 调用火车票识别接口
- `recognize()` → 自动判断票据类型,调用对应接口
- 将百度返回结果标准化为 `OCRResult`
- 包含 access_token 获取和缓存逻辑
- [ ] **Step 4: 实现 OCR Service 门面**
`backend/app/services/ocr_service.py`:
```python
from app.core.config import settings
from app.services.ocr_providers.base import OCRResult
from app.services.ocr_providers.mock import MockOCRProvider
from app.services.ocr_providers.baidu import BaiduOCRProvider
class OCRService:
def __init__(self):
self._provider = self._create_provider()
def _create_provider(self):
if settings.OCR_PROVIDER == "mock":
return MockOCRProvider()
elif settings.OCR_PROVIDER == "baidu":
return BaiduOCRProvider(
api_key=settings.BAIDU_OCR_API_KEY,
secret_key=settings.BAIDU_OCR_SECRET_KEY
)
raise ValueError(f"Unknown OCR provider: {settings.OCR_PROVIDER}")
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
return await self._provider.recognize(file_url, document_type)
```
- [ ] **Step 5: 编写测试**
使用 Mock Provider 测试完整 OCR 流程。
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider"
```
---
### Task 2.4: 规则引擎
**负责人:** 后端工程师 A
**预计工时:** 3 天
**前置依赖:** Task 2.1(需要 task 模型)
**Files:**
- Create: `backend/app/services/rule_engine.py`
- Create: `backend/app/services/rule_checkers/__init__.py`
- Create: `backend/app/services/rule_checkers/base.py`
- Create: `backend/app/services/rule_checkers/required_fields.py`
- Create: `backend/app/services/rule_checkers/attachment_check.py`
- Create: `backend/app/services/rule_checkers/duplicate_invoice.py`
- Create: `backend/app/services/rule_checkers/amount_limit.py`
- Create: `backend/app/services/rule_checkers/date_validity.py`
- Create: `backend/app/services/rule_checkers/expense_type_match.py`
- Create: `backend/app/schemas/rule.py`
- Create: `backend/app/api/v1/rules.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_rule_engine.py`
- [ ] **Step 1: 定义规则检查器基类**
`backend/app/services/rule_checkers/base.py`:
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class RuleCheckResult:
rule_code: str
severity: str # low / medium / high / blocked
action: str # pass / warn / require_explanation / require_attachment / require_approval / block
message: str
suggestion: str
policy_ref: str
hit_detail: dict
class RuleChecker(ABC):
@abstractmethod
async def check(self, context: dict) -> RuleCheckResult | None:
"""检查规则,命中返回结果,未命中返回 None"""
...
```
- [ ] **Step 2: 实现 RuleEngine 核心引擎**
`backend/app/services/rule_engine.py`:
```python
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.rule_checkers.base import RuleChecker, RuleCheckResult
from dataclasses import dataclass, field
@dataclass
class PrecheckResult:
precheck_status: str # pass / need_supplement / blocked
risk_level: str # low / medium / high / blocked
rule_hits: list[RuleCheckResult] = field(default_factory=list)
summary: str = ""
class RuleEngine:
def __init__(self, db: AsyncSession):
self.db = db
self.checkers: list[RuleChecker] = []
def register_checker(self, checker: RuleChecker):
self.checkers.append(checker)
async def run_precheck(self, context: dict) -> PrecheckResult:
"""执行完整预审,遍历所有注册的 checker"""
hits: list[RuleCheckResult] = []
for checker in self.checkers:
result = await checker.check(context)
if result:
hits.append(result)
risk_level = self._calculate_overall_risk(hits)
status = self._determine_status(hits)
summary = self._generate_summary(hits)
return PrecheckResult(
precheck_status=status,
risk_level=risk_level,
rule_hits=hits,
summary=summary
)
def _calculate_overall_risk(self, hits: list[RuleCheckResult]) -> str:
if not hits:
return "low"
severity_order = {"blocked": 4, "high": 3, "medium": 2, "low": 1}
max_severity = max(hits, key=lambda h: severity_order.get(h.severity, 0))
return max_severity.severity
def _determine_status(self, hits: list[RuleCheckResult]) -> str:
if not hits:
return "pass"
if any(h.action == "block" for h in hits):
return "blocked"
return "need_supplement"
def _generate_summary(self, hits: list[RuleCheckResult]) -> str:
if not hits:
return "预审通过,未发现风险。"
blocked = sum(1 for h in hits if h.action == "block")
warnings = sum(1 for h in hits if h.action in ("warn", "require_explanation"))
supplements = sum(1 for h in hits if h.action == "require_attachment")
parts = []
if blocked:
parts.append(f"{blocked} 个阻断项")
if supplements:
parts.append(f"{supplements} 个缺件")
if warnings:
parts.append(f"{warnings} 个风险提示")
return f"当前报销单存在{''.join(parts)}"
```
- [ ] **Step 3: 实现 6 条核心规则检查器**
1. **`required_fields.py`** — `RequiredFieldsChecker` — 必填字段校验
- 检查报销人、部门、事由、费用明细是否有空值
- 命中返回 `require_explanation`
2. **`attachment_check.py`** — `AttachmentCheckChecker` — 附件完整性校验
- 住宿费必须上传酒店流水
- 交通费必须上传对应票据
- 命中返回 `require_attachment`
3. **`duplicate_invoice.py`** — `DuplicateInvoiceChecker` — 重复发票检查
- 检查 invoice_code + invoice_number + amount 是否重复
- 命中返回 `block`
4. **`amount_limit.py`** — `AmountLimitChecker` — 金额超标校验
- 按城市等级和费用类型检查标准
- 住宿费按每晚金额检查
- 命中返回 `require_explanation`
5. **`date_validity.py`** — `DateValidityChecker` — 日期合理性校验
- 费用日期不能晚于今天
- 费用日期应在出差期间内
- 命中返回 `warn`
6. **`expense_type_match.py`** — `ExpenseTypeMatchChecker` — 费用类型匹配校验
- 住宿费应关联 hotel_bill 类型票据
- 交通费应关联 train_ticket / flight_itinerary / taxi_receipt
- 命中返回 `warn`
- [ ] **Step 4: 实现规则管理 API**
- `GET /api/v1/rules` — 列出所有规则
- `POST /api/v1/rules` — 创建规则
- `PUT /api/v1/rules/{rule_id}` — 更新规则
- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则
- [ ] **Step 5: 编写测试**
对每条规则编写单元测试:
```python
@pytest.mark.asyncio
async def test_duplicate_invoice_checker():
checker = DuplicateInvoiceChecker()
# 模拟重复发票场景
context = {"items": [...], "existing_invoices": [...]}
result = await checker.check(context)
assert result is not None
assert result.action == "block"
@pytest.mark.asyncio
async def test_no_duplicate():
checker = DuplicateInvoiceChecker()
context = {"items": [...], "existing_invoices": []} # 无重复
result = await checker.check(context)
assert result is None
```
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现规则引擎6 条核心规则 + 管理 API"
```
---
### Task 2.5: 影子报销账本 CRUD
**负责人:** 后端工程师 A
**预计工时:** 1.5 天
**前置依赖:** Task 2.1 + Task 2.4
**Files:**
- Create: `backend/app/schemas/reimbursement.py`
- Create: `backend/app/services/ledger_service.py`
- Create: `backend/app/api/v1/ledger.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_ledger_api.py`
- [ ] **Step 1: 定义 Pydantic schemas**
`backend/app/schemas/reimbursement.py`:
```python
from pydantic import BaseModel
from datetime import date, datetime
from decimal import Decimal
class ReimbursementItemResponse(BaseModel):
id: str
expense_type: str
amount: Decimal
tax_amount: Decimal | None
occurred_at: date | None
city: str | None
vendor_name: str | None
risk_level: str | None
remark: str | None
class Config:
from_attributes = True
class ReimbursementDraftResponse(BaseModel):
reimbursement_id: str
reason: str | None
total_amount: Decimal
precheck_status: str | None
risk_level: str | None
items: list[ReimbursementItemResponse]
created_at: datetime
class Config:
from_attributes = True
class PrecheckResultResponse(BaseModel):
precheck_status: str
risk_level: str
summary: str
rule_hits: list[dict]
```
- [ ] **Step 2: 实现 LedgerService**
`backend/app/services/ledger_service.py` — 核心方法:
- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录
- `get_draft(reimbursement_id)` → 获取报销草稿
- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿
- `update_precheck_result(reimbursement_id, result)` → 更新预审结果
- `add_item(reimbursement_id, item_data)` → 添加报销明细
- [ ] **Step 3: 实现 API 路由**
`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4
`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5
- [ ] **Step 4: 编写测试**
- [ ] **Step 5: Commit**
```bash
git add backend/
git commit -m "feat: 实现影子报销账本 CRUD API"
```
---
### Task 2.6: 补件与提交 API
**负责人:** 后端工程师 A
**预计工时:** 1.5 天
**前置依赖:** Task 2.5
**Files:**
- Create: `backend/app/api/v1/supplements.py`
- Create: `backend/app/services/supplement_service.py`
- Create: `backend/app/services/sync_service.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_supplement_api.py`
- [ ] **Step 1: 实现补件服务**
`backend/app/services/supplement_service.py`:
- `create_supplement_request(reimbursement_id, items)` → 创建补件请求
- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应
- `get_supplement_requests(task_id)` → 查询补件请求列表
- [ ] **Step 2: 实现同步服务MVP 阶段为模拟)**
`backend/app/services/sync_service.py`:
```python
import uuid
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
class SyncService:
def __init__(self, db: AsyncSession):
self.db = db
async def mock_sync_to_backend(self, reimbursement_id: str) -> dict:
"""模拟后端同步,生成假的 backend_bill_id"""
backend_bill_id = f"BX{datetime.now().strftime('%Y%m%d')}{str(uuid.uuid4())[:6]}"
return {
"sync_status": "success",
"target_system": "expense_system",
"backend_bill_id": backend_bill_id,
}
async def get_sync_status(self, task_id: str) -> dict | None:
"""查询同步状态"""
# 从 sync_record 表查询
...
```
- [ ] **Step 3: 实现 API 路由**
`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6
`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7
`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8
- [ ] **Step 4: 编写测试**
- [ ] **Step 5: Commit**
```bash
git add backend/
git commit -m "feat: 实现补件与提交确认 API含模拟同步"
```
---
## 本阶段完成检查
- [ ] `POST /api/v1/reimbursement/tasks` 创建任务返回 201
- [ ] `POST /api/v1/reimbursement/tasks/{id}/documents` 上传文件返回 201
- [ ] OCR Service 对 Mock Provider 正常返回结构化数据
- [ ] 规则引擎对 6 条规则命中/未命中的测试全部通过
- [ ] `GET /api/v1/reimbursement/tasks/{id}/draft` 返回草稿数据
- [ ] `POST /api/v1/reimbursement/tasks/{id}/supplements` 补件返回 received
- [ ] `POST /api/v1/reimbursement/tasks/{id}/submit` 提交返回 submitting
- [ ] 所有测试 `pytest tests/ -v` 全部通过

View File

@@ -1,568 +0,0 @@
# Phase 3: Agent 编排W3-W4
> **目标:** 实现 Agent Orchestrator 状态机、5 个业务 Agent、LLM 集成层和审计日志,完成核心智能处理能力。
> **周期:** 第 3 ~ 4 周
> **任务数:** 4 个
> **可并行:** Task 3.3 / 3.4 可与 Task 3.2 并行
> **前置依赖:** Phase 2 完成
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| Orchestrator 状态机 | 任务状态流转 + Agent 调度 |
| 5 个 Agent | 受理 / 解析 / 规则校验 / 解释补件 / 同步 |
| LLM 集成层 | 多 Provider 支持 + Prompt 模板 |
| 审计日志 | 所有关键操作留痕 |
---
## 任务清单
### Task 3.1: Agent Orchestrator 状态机
**负责人:** 全栈/Agent 工程师
**预计工时:** 2 天
**前置依赖:** Phase 2所有 service 就绪)
**Files:**
- Create: `backend/app/agents/__init__.py`
- Create: `backend/app/agents/state.py`
- Create: `backend/app/agents/orchestrator.py`
- Create: `backend/app/api/v1/agent.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_orchestrator.py`
- [ ] **Step 1: 定义 Agent 状态和上下文**
`backend/app/agents/state.py`:
```python
from dataclasses import dataclass, field
from app.models.enums import TaskStatus
@dataclass
class AgentContext:
task_id: str
status: TaskStatus
user_intent: str | None = None
current_agent: str | None = None
ocr_results: list[dict] = field(default_factory=list)
reimbursement_data: dict | None = None
precheck_result: dict | None = None
supplement_requests: list[dict] = field(default_factory=list)
error_message: str | None = None
retry_count: int = 0
```
- [ ] **Step 2: 定义 Agent 基类和结果**
`backend/app/agents/base_agent.py`:
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.state import AgentContext
@dataclass
class AgentResult:
success: bool
data: dict = field(default_factory=dict)
next_action: str = "continue" # continue / wait_user / need_supplement / retry
error: str | None = None
class BaseAgent(ABC):
name: str = ""
@abstractmethod
async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult:
...
```
- [ ] **Step 3: 实现 Orchestrator 状态机**
`backend/app/agents/orchestrator.py` — 核心编排逻辑:
状态转换图(对应开发文档 4.2 节):
```
CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING
↑ ↓
└─── MATERIAL_COLLECTING ←── NEED_SUPPLEMENT ←────────────────┘
PENDING_USER_CONFIRM → SUBMITTING → SYNCED
SYNC_FAILED → SUBMITTING重试
```
```python
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.state import AgentContext
from app.agents.base_agent import BaseAgent, AgentResult
from app.models.enums import TaskStatus
from app.services.task_service import TaskService
class Orchestrator:
def __init__(self, db: AsyncSession):
self.db = db
self.agents: dict[str, BaseAgent] = {}
def register_agent(self, agent: BaseAgent):
self.agents[agent.name] = agent
async def run(self, task_id: str, start_from: str = "intake") -> AgentContext:
"""启动编排流程"""
task_svc = TaskService(self.db)
task = await task_svc.get_task(task_id)
if not task:
raise ValueError(f"Task {task_id} not found")
context = AgentContext(
task_id=task_id,
status=task.status,
user_intent=task.user_intent,
)
# 根据 start_from 决定从哪个状态开始
agent_sequence = self._get_agent_sequence(start_from)
for agent_name in agent_sequence:
context.current_agent = agent_name
await task_svc.update_status(task_id, self._agent_to_status(agent_name), agent_name)
agent = self.agents.get(agent_name)
if not agent:
continue
result = await agent.execute(context, self.db)
if not result.success:
context.error_message = result.error
break
context = self._merge_result(context, result)
if result.next_action == "wait_user":
await task_svc.update_status(task_id, TaskStatus.PENDING_USER_CONFIRM, agent_name)
break
if result.next_action == "need_supplement":
await task_svc.update_status(task_id, TaskStatus.NEED_SUPPLEMENT, agent_name)
break
return context
def _get_agent_sequence(self, start_from: str) -> list[str]:
sequences = {
"intake": ["intake_agent", "parse_agent", "rule_check_agent", "explain_agent"],
"precheck": ["rule_check_agent", "explain_agent"],
"submit": ["sync_agent"],
}
return sequences.get(start_from, sequences["intake"])
def _agent_to_status(self, agent_name: str) -> TaskStatus:
mapping = {
"intake_agent": TaskStatus.MATERIAL_COLLECTING,
"parse_agent": TaskStatus.PARSING,
"rule_check_agent": TaskStatus.PRECHECKING,
"explain_agent": TaskStatus.PRECHECKING,
"sync_agent": TaskStatus.SUBMITTING,
}
return mapping.get(agent_name, TaskStatus.PRECHECKING)
def _merge_result(self, context: AgentContext, result: AgentResult) -> AgentContext:
"""将 Agent 结果合并到上下文"""
data = result.data
if "ocr_results" in data:
context.ocr_results = data["ocr_results"]
if "reimbursement_data" in data:
context.reimbursement_data = data["reimbursement_data"]
if "precheck_result" in data:
context.precheck_result = data["precheck_result"]
if "supplement_requests" in data:
context.supplement_requests = data["supplement_requests"]
return context
```
- [ ] **Step 4: 实现 Agent 启动 API**
`backend/app/api/v1/agent.py`:
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.database import get_db
router = APIRouter(prefix="/reimbursement/tasks/{task_id}/agent", tags=["agent"])
class AgentRunRequest(BaseModel):
start_from: str = "intake" # intake / precheck / submit
mode: str = "precheck"
class AgentRunResponse(BaseModel):
task_id: str
status: str
current_agent: str | None
@router.post("/run", response_model=AgentRunResponse)
async def run_agent(task_id: str, req: AgentRunRequest, db: AsyncSession = Depends(get_db)):
orchestrator = create_orchestrator(db)
context = await orchestrator.run(task_id, start_from=req.start_from)
return AgentRunResponse(
task_id=context.task_id,
status=context.status.value,
current_agent=context.current_agent,
)
```
- [ ] **Step 5: 编写状态机转换测试**
覆盖路径:
- 正常路径:创建 → 解析 → 草稿 → 预审 → 通过 → 提交 → 同步
- 补件路径:预审 → 需补件 → 等待用户
- 重试路径:提交 → 同步失败 → 重试
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现 Agent Orchestrator 状态机编排"
```
---
### Task 3.2: 5 个 Agent 实现
**负责人:** 全栈/Agent 工程师
**预计工时:** 3 天
**前置依赖:** Task 3.1Orchestrator 就绪)
**Files:**
- Create: `backend/app/agents/intake_agent.py`
- Create: `backend/app/agents/parse_agent.py`
- Create: `backend/app/agents/rule_check_agent.py`
- Create: `backend/app/agents/explain_agent.py`
- Create: `backend/app/agents/sync_agent.py`
- Test: `backend/tests/test_agents.py`
- [ ] **Step 1: 实现 IntakeAgent受理 Agent**
`backend/app/agents/intake_agent.py`:
- 分析 user_intent 文本,提取报销类型、出差信息
- 调用 LLM 做 intent classification
- 返回结构化任务信息(报销类型、出差城市、日期范围等)
- 输出:`AgentResult(data={"task_info": {...}})`
- [ ] **Step 2: 实现 ParseAgent单据解析 Agent**
`backend/app/agents/parse_agent.py`:
- 遍历任务下所有 document调用 `ocr_service.recognize()`
- 将 OCR 结果汇总为报销明细
- 调用 `ledger_service.create_shadow_reimbursement()` 创建影子记录
- 调用 `ledger_service.add_item()` 添加每条明细
- 自动识别费用类型(可调用 LLM 辅助)
- 输出:`AgentResult(data={"ocr_results": [...], "reimbursement_data": {...}})`
- [ ] **Step 3: 实现 RuleCheckAgent规则校验 Agent**
`backend/app/agents/rule_check_agent.py`:
- 构建 context dict报销数据 + 票据数据 + 已有发票列表)
- 注册 6 个 RuleChecker 到 RuleEngine
- 调用 `rule_engine.run_precheck(context)`
- 保存 RuleHit 记录到 DB
- 更新 shadow_reimbursement 的预审状态
- 输出:`AgentResult(data={"precheck_result": {...}})`
- [ ] **Step 4: 实现 ExplainAgent解释与补件 Agent**
`backend/app/agents/explain_agent.py`:
- 遍历 rule_hits使用 LLM 生成自然语言解释
-`require_attachment` 类型的命中自动创建 supplement_request
- 生成修改建议
- 根据预审结果决定 next_action
- 全部通过 → `continue`
- 有需补件的 → `need_supplement`
- 有阻断的 → `need_supplement`
- 输出:`AgentResult(data={"supplement_requests": [...]}, next_action="need_supplement")`
- [ ] **Step 5: 实现 SyncAgent同步执行 Agent**
`backend/app/agents/sync_agent.py`:
- 将 ShadowReimbursement 数据映射为标准报销单格式
- 调用 `sync_service.mock_sync_to_backend()`
- 记录 SyncRecord
- 更新 shadow_reimbursement 的 sync_status 和 backend_bill_id
- 处理同步失败重试retry_count < 3 时标记 retrying
- 输出:`AgentResult(data={"sync_result": {...}})`
- [ ] **Step 6: 编写每个 Agent 的单元测试**
使用 mock DB、mock OCR、mock LLM 测试每个 Agent 的输入输出。
- [ ] **Step 7: Commit**
```bash
git add backend/
git commit -m "feat: 实现 5 个 Agent受理/解析/规则校验/解释补件/同步)"
```
---
### Task 3.3: LLM 集成层
**负责人:** 全栈/Agent 工程师
**预计工时:** 2 天
**前置依赖:** Phase 2
**可并行于:** Task 3.2
**Files:**
- Create: `backend/app/services/llm_service.py`
- Create: `backend/app/services/llm_prompts/__init__.py`
- Create: `backend/app/services/llm_prompts/intent_classification.py`
- Create: `backend/app/services/llm_prompts/risk_explanation.py`
- Create: `backend/app/services/llm_prompts/expense_type_mapping.py`
- Test: `backend/tests/test_llm_service.py`
- [ ] **Step 1: 实现 LLM Service 封装**
`backend/app/services/llm_service.py`:
```python
import httpx
import json
from app.core.config import settings
class LLMService:
def __init__(self):
self.api_key = settings.LLM_API_KEY
self.model = settings.LLM_MODEL
self.base_url = settings.LLM_BASE_URL or "https://api.openai.com/v1"
async def chat(self, system_prompt: str, user_message: str, json_mode: bool = False) -> str:
"""调用 LLM API"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
async def chat_json(self, system_prompt: str, user_message: str) -> dict:
"""调用 LLM 并解析 JSON 响应"""
raw = await self.chat(system_prompt, user_message, json_mode=True)
return json.loads(raw)
```
- [ ] **Step 2: 编写 Prompt 模板**
**`intent_classification.py`** — 分析用户意图,识别报销类型:
```python
INTENT_CLASSIFICATION_PROMPT = """你是一个报销意图识别助手。根据用户的描述,识别报销类型和关键信息。
请严格按以下 JSON 格式输出:
{
"reimbursement_type": "travel_expense" | "office_expense" | "business_meal" | "other",
"travel_info": {
"destination": "城市名",
"start_date": "YYYY-MM-DD" 或 null,
"end_date": "YYYY-MM-DD" 或 null,
"purpose": "出差事由"
},
"confidence": 0.0-1.0
}
用户描述:{user_intent}
"""
```
**`risk_explanation.py`** — 将规则命中结果转为自然语言解释:
```python
RISK_EXPLANATION_PROMPT = """你是一个报销制度解释助手。请根据规则命中结果,用简洁易懂的语言向用户解释问题。
规则命中信息:
- 规则名称:{rule_name}
- 问题类型:{issue_type}
- 制度依据:{policy_ref}
- 具体数据:{hit_detail}
请用 2-3 句话解释:
1. 存在什么问题
2. 制度标准是什么
3. 建议如何处理
"""
```
**`expense_type_mapping.py`** — 根据 OCR 结果匹配费用类型:
```python
EXPENSE_TYPE_MAPPING_PROMPT = """根据票据 OCR 识别结果,判断费用类型。
可选费用类型:
- travel_transport: 差旅交通费(火车票、机票、打车)
- travel_hotel: 差旅住宿费(酒店发票)
- travel_meal: 差旅餐补
- local_transport: 市内交通费
- business_meal: 业务招待费
- office_supply: 办公用品费
- communication: 通讯费
- other: 其他
OCR 识别结果:{ocr_result}
请输出 JSON{"expense_type": "类型编码", "confidence": 0.0-1.0}
"""
```
- [ ] **Step 3: 编写测试(使用 mock LLM 响应)**
```python
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_llm_intent_classification():
with patch("app.services.llm_service.LLMService.chat_json", new_callable=AsyncMock) as mock_chat:
mock_chat.return_value = {
"reimbursement_type": "travel_expense",
"travel_info": {"destination": "北京", "purpose": "商务出差"},
"confidence": 0.9
}
llm = LLMService()
result = await llm.chat_json("system prompt", "我要报北京出差费用")
assert result["reimbursement_type"] == "travel_expense"
```
- [ ] **Step 4: Commit**
```bash
git add backend/
git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)"
```
---
### Task 3.4: 审计日志
**负责人:** 后端工程师 B
**预计工时:** 1 天
**前置依赖:** Phase 2
**可并行于:** Task 3.1、3.2、3.3
**Files:**
- Create: `backend/app/services/audit_service.py`
- Create: `backend/app/api/v1/audit.py`
- Modify: `backend/app/api/v1/router.py`
- Test: `backend/tests/test_audit.py`
- [ ] **Step 1: 实现 AuditService**
`backend/app/services/audit_service.py`:
```python
import json
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import Mapped
from app.models.audit import AuditLog
class AuditService:
def __init__(self, db: AsyncSession):
self.db = db
async def log(self, action: str, actor: str, target_type: str, target_id: str, detail: dict | None = None):
"""记录审计日志"""
log_entry = AuditLog(
action=action,
actor=actor,
target_type=target_type,
target_id=target_id,
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
)
self.db.add(log_entry)
await self.db.flush() # 不 commit让调用方统一 commit
async def query_logs(self, target_type: str | None = None, target_id: str | None = None,
actor: str | None = None, page: int = 1, size: int = 50):
"""查询审计日志"""
query = select(AuditLog)
if target_type:
query = query.where(AuditLog.target_type == target_type)
if target_id:
query = query.where(AuditLog.target_id == target_id)
if actor:
query = query.where(AuditLog.actor == actor)
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size)
result = await self.db.execute(query)
return result.scalars().all()
```
- [ ] **Step 2: 定义审计动作枚举**
```python
class AuditAction:
FILE_UPLOAD = "file_upload"
OCR_RECOGNIZE = "ocr_recognize"
AGENT_CALL = "agent_call"
RULE_HIT = "rule_hit"
SUPPLEMENT_REQUEST = "supplement_request"
SUPPLEMENT_RESPOND = "supplement_respond"
USER_CONFIRM = "user_confirm"
BACKEND_SYNC = "backend_sync"
```
- [ ] **Step 3: 在关键路径埋点**
在以下位置调用 `audit_service.log()`
- `document_service.upload_document()``FILE_UPLOAD`
- `ocr_service.recognize()``OCR_RECOGNIZE`
- `orchestrator._run_agent()``AGENT_CALL`
- `rule_engine.run_precheck()``RULE_HIT`(每条命中记录一条)
- `supplement_service.create_supplement_request()``SUPPLEMENT_REQUEST`
- `supplement_service.respond_supplement()``SUPPLEMENT_RESPOND`
- `sync_service.mock_sync_to_backend()``BACKEND_SYNC`
- [ ] **Step 4: 实现审计日志查询 API**
`GET /api/v1/audit/logs` — 支持按 target_type、target_id、actor、date_range 过滤
- [ ] **Step 5: 编写测试**
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 实现审计日志服务(记录 + 查询 API"
```
---
## 本阶段完成检查
- [ ] Orchestrator 状态机所有转换路径测试通过
- [ ] 5 个 Agent 能独立执行并返回正确结果
- [ ] LLM Service 能调用大模型并解析 JSON 响应
- [ ] 审计日志在所有关键路径都有记录
- [ ] `POST /api/v1/reimbursement/tasks/{id}/agent/run` 能启动完整 Agent 流程
- [ ] 所有测试 `pytest tests/ -v` 全部通过

View File

@@ -1,500 +0,0 @@
# Phase 4: 前端核心页面W4-W5
> **目标:** 实现所有核心前端页面和组件,完成用户交互界面。
> **周期:** 第 4 ~ 5 周
> **任务数:** 4 个
> **可并行:** 4 个任务可由 1-2 名前端工程师并行开发
> **前置依赖:** Phase 1前端骨架
> **备注:** 可与 Phase 3 后半段并行开始
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| 报销入口页 | 对话式报销入口 + 快捷操作 |
| 票据上传页 | 文件上传组件 + 票据类型选择 |
| 报销草稿页 | 费用明细表格 + 可编辑字段 |
| 预审结果页 | 风险展示 + 规则命中详情 |
| 补件交互页 | 补件清单 + 上传/回复 |
| 提交确认页 | 最终确认 + 同步状态 |
| 审计日志页 | 操作时间线 |
---
## 任务清单
### Task 4.1: 报销入口页 + 上传组件
**负责人:** 前端工程师
**预计工时:** 2 天
**前置依赖:** Phase 1前端骨架
**Files:**
- Create: `frontend/src/views/HomeView.vue`
- Create: `frontend/src/views/UploadView.vue`
- Create: `frontend/src/components/FileUpload.vue`
- Create: `frontend/src/stores/task.ts`
- Create: `frontend/src/api/task.ts`
- Create: `frontend/src/api/document.ts`
- [ ] **Step 1: 实现 API 调用层**
`frontend/src/api/task.ts`:
```typescript
import api from './index'
export const createTask = (data: {
userId: string
companyId: string
userIntent: string
entryChannel?: string
}) => api.post('/reimbursement/tasks', data)
export const getTask = (taskId: string) =>
api.get(`/reimbursement/tasks/${taskId}`)
export const listTasks = (params?: { userId?: string; status?: string; page?: number; size?: number }) =>
api.get('/reimbursement/tasks', { params })
export const runAgent = (taskId: string, startFrom = 'intake', mode = 'precheck') =>
api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode })
```
`frontend/src/api/document.ts`:
```typescript
import api from './index'
export const uploadDocument = (taskId: string, file: File, documentType: string) => {
const formData = new FormData()
formData.append('file', file)
formData.append('document_type', documentType)
return api.post(`/reimbursement/tasks/${taskId}/documents`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export const listDocuments = (taskId: string) =>
api.get(`/reimbursement/tasks/${taskId}/documents`)
```
- [ ] **Step 2: 实现 Pinia Store**
`frontend/src/stores/task.ts`:
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createTask, getTask, listTasks, runAgent } from '@/api/task'
export const useTaskStore = defineStore('task', () => {
const currentTask = ref<any>(null)
const taskList = ref<any[]>([])
const loading = ref(false)
async function create(userIntent: string) {
loading.value = true
try {
const { data } = await createTask({
userId: 'U001', // TODO: 从登录态获取
companyId: 'C001',
userIntent,
})
currentTask.value = data
return data
} finally {
loading.value = false
}
}
async function fetchTask(taskId: string) {
const { data } = await getTask(taskId)
currentTask.value = data
return data
}
async function startAgent(taskId: string, startFrom = 'intake') {
loading.value = true
try {
const { data } = await runAgent(taskId, startFrom)
return data
} finally {
loading.value = false
}
}
return { currentTask, taskList, loading, create, fetchTask, startAgent }
})
```
- [ ] **Step 3: 实现报销入口页 HomeView**
按开发文档 9.2 节:
- **对话输入框**:用户输入报销意图(如"我要报这次北京出差的费用"
- **上传按钮**:直接跳转到上传页
- **最近任务列表**:显示用户最近的报销任务和状态
- **常用报销类型快捷按钮**
- "报差旅"
- "看发票能不能报"
- "帮我生成报销单"
- "这张发票为什么不合规?"
- **智能引导提示**:根据用户输入实时提示
交互流程:用户输入意图 → 调用 `taskStore.create()` → 跳转到 `/task/{taskId}/upload`
- [ ] **Step 4: 实现文件上传组件 FileUpload**
`frontend/src/components/FileUpload.vue`
Ant Design Vue 的 `a-upload-dragger` 封装:
- 支持拖拽上传
- 支持多文件选择
- 文件类型校验PDF、JPG、PNG、OFD
- 单文件大小限制(默认 10MB
- 上传进度条
- 预览缩略图
- 已上传文件列表
- 删除已上传文件
Props
```typescript
interface Props {
taskId: string
accept?: string // 默认 '.pdf,.jpg,.jpeg,.png,.ofd'
maxFileSize?: number // MB默认 10
maxCount?: number // 默认 10
}
```
- [ ] **Step 5: 实现票据上传页 UploadView**
- 引用 FileUpload 组件
- 票据类型下拉选择Ant Design Select
- 增值税发票 (vat_invoice)
- 火车票 (train_ticket)
- 机票行程单 (flight_itinerary)
- 打车票据 (taxi_receipt)
- 酒店流水 (hotel_bill)
- 支付截图 (payment_screenshot)
- 其他附件 (other_attachment)
- 已上传文件列表展示
- "开始识别" 按钮 → 调用 `taskStore.startAgent()` → 跳转到草稿页
- 返回按钮 → 回到首页
- [ ] **Step 6: Commit**
```bash
git add frontend/
git commit -m "feat: 实现报销入口页和票据上传页"
```
---
### Task 4.2: 报销草稿页
**负责人:** 前端工程师
**预计工时:** 2 天
**前置依赖:** Task 4.1
**可并行于:** Task 4.3(如果两人并行)
**Files:**
- Create: `frontend/src/views/DraftView.vue`
- Create: `frontend/src/components/ExpenseTable.vue`
- Create: `frontend/src/api/precheck.ts`
- [ ] **Step 1: 添加预审 API**
`frontend/src/api/precheck.ts`:
```typescript
import api from './index'
export const getDraft = (taskId: string) =>
api.get(`/reimbursement/tasks/${taskId}/draft`)
export const getPrecheckResult = (taskId: string) =>
api.get(`/reimbursement/tasks/${taskId}/precheck-result`)
```
- [ ] **Step 2: 实现 ExpenseTable 组件**
`frontend/src/components/ExpenseTable.vue`
Ant Design Table 展示费用明细:
| 列名 | 字段 | 可编辑 | 说明 |
|---|---|---|---|
| 费用类型 | expense_type | ✅ | 下拉选择 |
| 金额 | amount | ✅ | 数字输入 |
| 税额 | tax_amount | ✅ | 数字输入 |
| 发生日期 | occurred_at | ✅ | 日期选择 |
| 城市 | city | ✅ | 文本输入 |
| 商户 | vendor_name | ✅ | 文本输入 |
| 风险等级 | risk_level | ❌ | 彩色标签 |
风险等级标签颜色映射:
- `low` → 绿色 `green`
- `medium` → 橙色 `orange`
- `high` → 红色 `red`
- `blocked` → 深红 `#cf1322`
支持行内编辑(点击单元格进入编辑模式)。
- [ ] **Step 3: 实现报销草稿页 DraftView**
按开发文档 9.3 节布局:
```
┌─────────────────────────────────────────────┐
│ 报销草稿 [预审按钮] │
├─────────────────────────────────────────────┤
│ 基本信息 │
│ ┌──────────┬──────────┬──────────┐ │
│ │ 报销人 │ 部门 │ 成本中心 │ │
│ └──────────┴──────────┴──────────┘ │
│ ┌──────────┬──────────┐ │
│ │ 项目 │ 报销事由 │ │
│ └──────────┴──────────┘ │
├─────────────────────────────────────────────┤
│ 费用明细 │
│ ┌─────────────────────────────────────────┐ │
│ │ ExpenseTable 组件 │ │
│ └─────────────────────────────────────────┘ │
│ 总金额:¥ 2,380.00 │
├─────────────────────────────────────────────┤
│ 票据附件 │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ 📄 │ │ 📄 │ │ 📄 │ (缩略图 + 文件名) │
│ └────┘ └────┘ └────┘ │
├─────────────────────────────────────────────┤
│ 预审状态:⏳ 待预审 │
│ [执行预审] │
└─────────────────────────────────────────────┘
```
交互:
- 页面加载时调用 `getDraft(taskId)` 获取草稿数据
- 编辑字段后暂存到本地 state
- 点击"执行预审" → 调用 `taskStore.startAgent(taskId, 'precheck')` → 跳转到预审结果页
- [ ] **Step 4: Commit**
```bash
git add frontend/
git commit -m "feat: 实现报销草稿页和费用明细表格组件"
```
---
### Task 4.3: 预审结果页 + 补件交互页
**负责人:** 前端工程师
**预计工时:** 2.5 天
**前置依赖:** Task 4.1
**可并行于:** Task 4.2(如果两人并行)
**Files:**
- Create: `frontend/src/views/PrecheckView.vue`
- Create: `frontend/src/views/SupplementView.vue`
- Create: `frontend/src/components/RuleHitCard.vue`
- Create: `frontend/src/api/supplement.ts`
- [ ] **Step 1: 添加补件 API**
`frontend/src/api/supplement.ts`:
```typescript
import api from './index'
export const respondSupplement = (taskId: string, supplementRequestId: string, data: {
responseText: string
documentIds?: string[]
}) => api.post(`/reimbursement/tasks/${taskId}/supplements`, {
supplement_request_id: supplementRequestId,
...data,
})
export const submitTask = (taskId: string, submitTo: string = 'expense_system') =>
api.post(`/reimbursement/tasks/${taskId}/submit`, { confirmed: true, submit_to: submitTo })
export const getSyncStatus = (taskId: string) =>
api.get(`/reimbursement/tasks/${taskId}/sync-status`)
```
- [ ] **Step 2: 实现 RuleHitCard 组件**
`frontend/src/components/RuleHitCard.vue`
Ant Design Card 展示单条规则命中:
```
┌─────────────────────────────────────────┐
│ 🔴 住宿费超标 TRAVEL_HOTEL_LIMIT │
├─────────────────────────────────────────┤
│ 问题:住宿费超出当前城市和职级标准 │
│ 制度依据:差旅报销制度-住宿标准 │
│ 建议:请补充超标说明或发起特殊审批 │
│ [展开详情] │
└─────────────────────────────────────────┘
```
Props
```typescript
interface Props {
ruleCode: string
ruleName: string
severity: string // low / medium / high / blocked
action: string
message: string
suggestion: string
policyRef: string
hitDetail?: object
}
```
- [ ] **Step 3: 实现预审结果页 PrecheckView**
按开发文档 9.4 节:
- **总体结论卡片**
- ✅ 通过 → 绿色
- ⚠️ 需补件 → 橙色
- 🚫 有阻断 → 红色
- **风险等级指示**:彩色 Badge
- **通过项列表**:绿色勾选图标 + 规则名称
- **风险项列表**:使用 RuleHitCard 组件
- **缺件项列表**:橙色提示 + 补件按钮
- **操作按钮**
- "一键补件" → 跳转到补件页(仅在有缺件时显示)
- "确认提交" → 跳转到确认页(仅预审通过时可用)
交互:
- 页面加载时调用 `getPrecheckResult(taskId)` 获取预审结果
- 根据结果渲染不同状态
- [ ] **Step 4: 实现补件交互页 SupplementView**
- **待补件清单**:从预审结果的 rule_hits 中过滤出 `require_attachment` / `require_explanation` 类型
- **每个补件项**
- 类型标签(补充附件 / 补充说明 / 修改字段)
- 提示文案
- 操作区域:
- 补充附件:调用 FileUpload 组件
- 补充说明:文本输入框
- **提交补件按钮** → 调用 `respondSupplement()` → 跳转回预审页重新预审
- [ ] **Step 5: Commit**
```bash
git add frontend/
git commit -m "feat: 实现预审结果页和补件交互页"
```
---
### Task 4.4: 提交确认页 + 审计日志页
**负责人:** 前端工程师
**预计工时:** 1.5 天
**前置依赖:** Task 4.3
**Files:**
- Create: `frontend/src/views/ConfirmView.vue`
- Create: `frontend/src/views/AuditView.vue`
- Create: `frontend/src/api/audit.ts`
- [ ] **Step 1: 添加审计 API**
`frontend/src/api/audit.ts`:
```typescript
import api from './index'
export const getAuditLogs = (params?: {
target_type?: string
target_id?: string
actor?: string
page?: number
size?: number
}) => api.get('/audit/logs', { params })
```
- [ ] **Step 2: 实现提交确认页 ConfirmView**
```
┌─────────────────────────────────────────────┐
│ 提交确认 │
├─────────────────────────────────────────────┤
│ 报销单摘要(不可编辑) │
│ ┌───────────────────────────────────────┐ │
│ │ 报销人:张三 部门:技术部 │ │
│ │ 事由:北京出差 总金额¥2,380.00 │ │
│ └───────────────────────────────────────┘ │
│ │
│ 费用明细汇总: │
│ ┌───────────────────────────────────────┐ │
│ │ 差旅住宿费 ¥1,200.00 │ │
│ │ 差旅交通费 ¥ 553.00 │ │
│ │ 差旅餐补 ¥ 627.00 │ │
│ └───────────────────────────────────────┘ │
│ │
│ 附件清单3 个文件 │
│ 同步目标:费控系统 │
│ │
│ [返回修改] [确认提交] │
├─────────────────────────────────────────────┤
│ 同步状态:⏳ 提交中... │
└─────────────────────────────────────────────┘
```
交互:
- "确认提交" → 调用 `submitTask(taskId)`
- 提交后轮询 `getSyncStatus(taskId)`,展示同步进度
- 同步成功 → 显示后端单据号
- 同步失败 → 显示错误信息 + 重试按钮
- [ ] **Step 3: 实现审计日志页 AuditView**
按开发文档 9.5 节(简化版):
- **Ant Design Timeline** 展示操作记录
- **筛选栏**按任务ID、操作类型、时间范围筛选
- **每条日志**
- 时间戳
- 操作人
- 操作类型(彩色标签)
- 详情(可展开)
操作类型颜色映射:
- `file_upload` → 蓝色
- `ocr_recognize` → 青色
- `agent_call` → 紫色
- `rule_hit` → 橙色
- `supplement_request` / `supplement_respond` → 绿色
- `user_confirm` → 金色
- `backend_sync` → 灰色
- [ ] **Step 4: Commit**
```bash
git add frontend/
git commit -m "feat: 实现提交确认页和审计日志页"
```
---
## 本阶段完成检查
- [ ] 首页能创建任务并跳转到上传页
- [ ] 上传页能上传文件并开始识别
- [ ] 草稿页能展示费用明细并支持编辑
- [ ] 预审结果页能展示风险项和缺件项
- [ ] 补件页能上传附件和填写说明
- [ ] 确认页能展示摘要和同步状态
- [ ] 审计日志页能展示操作时间线
- [ ] 所有页面响应式布局正常
- [ ] 前端 `npm run build` 无报错

View File

@@ -1,259 +0,0 @@
# Phase 5: 联调与集成W5-W6
> **目标:** 前后端联调跑通完整流程,配置规则种子数据,确保全链路畅通。
> **周期:** 第 5 ~ 6 周
> **任务数:** 2 个
> **可并行:** 联调和种子数据可由不同人并行
> **前置依赖:** Phase 3 + Phase 4
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| 完整流程跑通 | 从创建任务到同步成功的端到端流程 |
| 规则种子数据 | 差旅报销制度 + 6 条核心规则 + 城市等级标准 |
---
## 任务清单
### Task 5.1: 前后端联调
**负责人:** 全员参与
**预计工时:** 3-4 天
**前置依赖:** Phase 3 + Phase 4
**Files:**
- Modify: 多个前后端文件(修复联调问题)
- [ ] **Step 1: 启动前后端全栈环境**
```bash
# 启动基础设施
docker-compose up -d
# 启动后端
cd backend
alembic upgrade head
uvicorn app.main:app --reload --port 8000
# 启动前端
cd frontend
npm run dev
```
确认:
- PostgreSQL 可连接:`psql -h localhost -U postgres -d x_financial`
- Redis 可连接:`redis-cli ping`
- MinIO 可访问http://localhost:9001
- 后端健康检查http://localhost:8000/health
- 前端可访问http://localhost:5173
- [ ] **Step 2: 跑通完整报销流程10 步)**
按开发文档 3.1 节的完整流程:
**步骤 1 - 创建任务**
1. 访问首页 http://localhost:5173
2. 在输入框输入"我要报这次北京出差的费用"
3. 点击提交
4. 验证:任务创建成功,跳转到上传页
**步骤 2 - 上传票据**
1. 选择票据类型:增值税发票
2. 上传模拟发票文件(可用任意 PDF/PNG
3. 选择票据类型:火车票
4. 上传模拟火车票文件
5. 验证:文件列表显示 2 个文件
**步骤 3 - 启动 Agent 识别**
1. 点击"开始识别"
2. 等待 Agent 处理(观察后端日志)
3. 验证:跳转到草稿页,显示识别结果
**步骤 4 - 查看报销草稿**
1. 确认费用明细已自动填充
2. 检查金额、商户、日期等字段
3. 验证:可编辑字段能修改
**步骤 5 - 执行预审**
1. 点击"执行预审"
2. 等待规则引擎执行
3. 验证:跳转到预审结果页
**步骤 6 - 查看预审结果**
1. 检查总体结论
2. 查看风险项(如有)
3. 查看缺件项(如有)
4. 验证:规则命中详情展示正确
**步骤 7 - 补件(如需要)**
1. 点击"一键补件"
2. 在补件页上传缺失附件
3. 提交补件
4. 验证:跳转回预审页,重新预审
**步骤 8 - 确认提交**
1. 确认报销单摘要
2. 点击"确认提交"
3. 等待同步状态更新
4. 验证:同步状态变为 success
**步骤 9 - 查看审计日志**
1. 访问审计日志页
2. 按任务ID筛选
3. 验证:所有操作步骤都有记录
**步骤 10 - 查看后端 Swagger**
1. 访问 http://localhost:8000/docs
2. 验证所有 API 文档正确
- [ ] **Step 3: 修复联调过程中发现的问题**
常见问题检查清单:
- [ ] API 响应格式前后端一致(字段名、嵌套结构)
- [ ] 日期格式统一ISO 8601
- [ ] 金额精度Decimal vs Number
- [ ] 错误处理(前端能正确显示后端错误信息)
- [ ] 文件上传大小限制(前端 + 后端 + MinIO
- [ ] 跨域配置正确
- [ ] 路由跳转正常
- [ ] Loading 状态显示
- [ ] 空状态展示
- [ ] **Step 4: 压力测试关键接口**
- `POST /tasks` 创建 100 个任务
- `GET /tasks` 列表查询响应时间 < 500ms
- `POST /tasks/{id}/documents` 上传 10 个文件
- `POST /tasks/{id}/agent/run` Agent 执行时间 < 30s
- [ ] **Step 5: Commit**
```bash
git add .
git commit -m "fix: 前后端联调修复(完整流程跑通)"
```
---
### Task 5.2: 规则配置与种子数据
**负责人:** 全栈/Agent 工程师
**预计工时:** 2 天
**前置依赖:** Phase 2规则引擎
**可并行于:** Task 5.1
**Files:**
- Create: `backend/alembic/seed/expense_policies.sql`
- Create: `backend/alembic/seed/expense_rules.sql`
- Create: `backend/alembic/seed/city_levels.sql`
- Create: `backend/alembic/seed/hotel_limits.sql`
- Create: `backend/scripts/seed_data.py`
- [ ] **Step 1: 编写城市等级数据**
`backend/alembic/seed/city_levels.sql`
按典型企业标准配置:
- **一线城市**:北京、上海、广州、深圳
- **二线城市**:杭州、南京、成都、武汉、重庆、天津、苏州、西安
- **三线城市**:其他城市
```sql
CREATE TABLE IF NOT EXISTS city_level (
city_name VARCHAR(50) PRIMARY KEY,
level VARCHAR(10) NOT NULL -- tier1 / tier2 / tier3
);
INSERT INTO city_level (city_name, level) VALUES
('北京', 'tier1'), ('上海', 'tier1'), ('广州', 'tier1'), ('深圳', 'tier1'),
('杭州', 'tier2'), ('南京', 'tier2'), ('成都', 'tier2'), ('武汉', 'tier2'),
('重庆', 'tier2'), ('天津', 'tier2'), ('苏州', 'tier2'), ('西安', 'tier2');
```
- [ ] **Step 2: 编写住宿标准数据**
`backend/alembic/seed/hotel_limits.sql`
```sql
CREATE TABLE IF NOT EXISTS hotel_limit (
city_level VARCHAR(10) NOT NULL,
job_level VARCHAR(20) NOT NULL, -- manager / senior / staff
limit_per_night DECIMAL(10,2) NOT NULL,
PRIMARY KEY (city_level, job_level)
);
INSERT INTO hotel_limit (city_level, job_level, limit_per_night) VALUES
('tier1', 'manager', 800.00), ('tier1', 'senior', 600.00), ('tier1', 'staff', 500.00),
('tier2', 'manager', 600.00), ('tier2', 'senior', 450.00), ('tier2', 'staff', 350.00),
('tier3', 'manager', 500.00), ('tier3', 'senior', 350.00), ('tier3', 'staff', 300.00);
```
- [ ] **Step 3: 编写规则种子数据**
`backend/alembic/seed/expense_rules.sql` — 预置开发文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则:
1. `TRAVEL_HOTEL_LIMIT` — 住宿费标准校验severity: medium, action: require_explanation
2. `HOTEL_BILL_REQUIRED` — 住宿费必须上传酒店流水severity: medium, action: require_attachment
3. `DUPLICATE_INVOICE_CHECK` — 重复发票检查severity: blocked, action: block
4. `REQUIRED_FIELDS_CHECK` — 必填字段校验severity: medium, action: warn
5. `AMOUNT_ABNORMAL_CHECK` — 金额异常检查severity: high, action: require_explanation
6. `DATE_VALIDITY_CHECK` — 日期合理性校验severity: low, action: warn
7. `EXPENSE_TYPE_MATCH_CHECK` — 费用类型匹配校验severity: low, action: warn
8. `INVOICE_TITLE_CHECK` — 发票抬头校验severity: high, action: require_explanation
9. `TRIP_PERIOD_MATCH_CHECK` — 出差期间匹配校验severity: medium, action: warn
每条规则包含完整的 condition_json、action、severity、message_template、policy_ref。
- [ ] **Step 4: 编写数据初始化脚本**
`backend/scripts/seed_data.py` — 一键初始化所有种子数据:
```python
"""初始化种子数据"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.core.config import settings
# ... 读取 SQL 文件并执行
async def main():
engine = create_async_engine(settings.DATABASE_URL)
async with engine.begin() as conn:
# 按顺序执行 seed SQL
for sql_file in ['city_levels.sql', 'hotel_limits.sql', 'expense_rules.sql']:
with open(f'alembic/seed/{sql_file}') as f:
await conn.execute(f.read())
print("Seed data loaded successfully!")
if __name__ == "__main__":
asyncio.run(main())
```
- [ ] **Step 5: 验证种子数据**
Run: `cd backend && python scripts/seed_data.py`
验证:
- 城市等级表有数据
- 住宿标准表有数据
- 规则表有 9 条规则且全部 enabled
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "feat: 添加差旅报销制度和规则种子数据"
```
---
## 本阶段完成检查
- [ ] 完整报销流程(创建→上传→识别→草稿→预审→补件→提交→同步)端到端跑通
- [ ] 前后端 API 格式一致,无字段不匹配
- [ ] 错误场景有正确提示上传失败、OCR 失败、同步失败)
- [ ] 种子数据加载成功,规则引擎使用种子数据执行预审
- [ ] Swagger 文档 http://localhost:8000/docs 可访问
- [ ] 审计日志记录了完整操作链路

View File

@@ -1,553 +0,0 @@
# Phase 6: 测试与打磨W7-W8
> **目标:** 完善集成测试、E2E 测试、修复 Bug、UI 打磨、编写部署文档,准备 Demo 演示。
> **周期:** 第 7 ~ 8 周
> **任务数:** 4 个
> **可并行:** Task 6.1 / 6.2 / 6.3 可并行
> **前置依赖:** Phase 5
---
## 本阶段交付物
| 交付物 | 说明 |
|---|---|
| 后端集成测试 | 完整报销流程的自动化测试 |
| 前端 E2E 测试 | Playwright 自动化测试(可选) |
| Bug 修复 + UI 打磨 | 视觉和交互优化 |
| 部署文档 | README + 部署指南 + API 文档 |
---
## 任务清单
### Task 6.1: 后端集成测试
**负责人:** 后端工程师 A
**预计工时:** 3 天
**前置依赖:** Phase 5
**Files:**
- Create: `backend/tests/test_integration_flow.py`
- Create: `backend/tests/helpers.py`(测试辅助函数)
- Modify: `backend/tests/conftest.py`(添加测试数据库 fixture
- [ ] **Step 1: 更新 conftest.py 添加测试数据库 fixture**
`backend/tests/conftest.py`:
```python
import pytest
import asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.models.base import Base
from app.main import app
from app.core.database import get_db
# 测试数据库 URL
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial_test"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture(autouse=True)
async def setup_database():
"""每个测试前创建表,测试后清理"""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def db():
async with test_session() as session:
yield session
@pytest.fixture
async def client(db):
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
```
- [ ] **Step 2: 编写测试辅助函数**
`backend/tests/helpers.py`:
```python
from httpx import AsyncClient
async def create_task(client: AsyncClient, user_id="U001", company_id="C001", intent="报北京出差费用") -> dict:
resp = await client.post("/api/v1/reimbursement/tasks", json={
"user_id": user_id, "company_id": company_id, "user_intent": intent
})
assert resp.status_code == 201
return resp.json()
async def upload_document(client: AsyncClient, task_id: str, document_type: str, filename: str = "test.pdf") -> dict:
files = {"file": (filename, b"fake file content", "application/pdf")}
resp = await client.post(
f"/api/v1/reimbursement/tasks/{task_id}/documents",
files=files,
data={"document_type": document_type}
)
assert resp.status_code == 201
return resp.json()
async def run_agent(client: AsyncClient, task_id: str, start_from="intake") -> dict:
resp = await client.post(
f"/api/v1/reimbursement/tasks/{task_id}/agent/run",
json={"start_from": start_from, "mode": "precheck"}
)
assert resp.status_code == 200
return resp.json()
async def get_draft(client: AsyncClient, task_id: str) -> dict:
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/draft")
assert resp.status_code == 200
return resp.json()
async def get_precheck_result(client: AsyncClient, task_id: str) -> dict:
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/precheck-result")
assert resp.status_code == 200
return resp.json()
async def respond_supplement(client: AsyncClient, task_id: str, supplement_id: str, text: str) -> dict:
resp = await client.post(
f"/api/v1/reimbursement/tasks/{task_id}/supplements",
json={"supplement_request_id": supplement_id, "response_text": text}
)
assert resp.status_code == 200
return resp.json()
async def submit_task(client: AsyncClient, task_id: str) -> dict:
resp = await client.post(
f"/api/v1/reimbursement/tasks/{task_id}/submit",
json={"confirmed": True, "submit_to": "expense_system"}
)
assert resp.status_code == 200
return resp.json()
async def get_sync_status(client: AsyncClient, task_id: str) -> dict:
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/sync-status")
assert resp.status_code == 200
return resp.json()
```
- [ ] **Step 3: 编写完整流程集成测试**
`backend/tests/test_integration_flow.py`:
```python
import pytest
from tests.helpers import *
@pytest.mark.asyncio
async def test_full_reimbursement_flow(client):
"""完整报销流程:创建→上传→识别→草稿→预审→补件→提交→同步"""
# 1. 创建任务
task = await create_task(client, intent="我要报这次北京出差的费用")
task_id = task["task_id"]
assert task["status"] == "material_collecting"
# 2. 上传票据
doc1 = await upload_document(client, task_id, "vat_invoice", "invoice.pdf")
assert doc1["ocr_status"] == "pending"
doc2 = await upload_document(client, task_id, "train_ticket", "train.pdf")
assert doc2["ocr_status"] == "pending"
doc3 = await upload_document(client, task_id, "hotel_bill", "hotel.pdf")
# 3. 启动 Agent使用 mock OCR
result = await run_agent(client, task_id, start_from="intake")
assert result["status"] in ["draft_generated", "prechecking", "need_supplement", "pending_user_confirm"]
# 4. 获取草稿
draft = await get_draft(client, task_id)
assert draft["reimbursement_id"] is not None
assert len(draft["items"]) > 0
assert draft["total_amount"] > 0
# 5. 获取预审结果
precheck = await get_precheck_result(client, task_id)
assert "risk_level" in precheck
assert "precheck_status" in precheck
assert "rule_hits" in precheck
# 6. 如果需要补件
if precheck["precheck_status"] == "need_supplement":
# 找到需要补件的规则
for hit in precheck["rule_hits"]:
if hit["action"] == "require_attachment":
# 补充附件
await upload_document(client, task_id, "hotel_bill", "hotel_supplement.pdf")
await respond_supplement(client, task_id, hit.get("id", "S001"), "已补充酒店流水")
# 重新预审
await run_agent(client, task_id, start_from="precheck")
precheck2 = await get_precheck_result(client, task_id)
# 7. 确认提交
submit = await submit_task(client, task_id)
assert submit["status"] == "submitting"
# 8. 检查同步状态
sync = await get_sync_status(client, task_id)
assert sync["sync_status"] in ["success", "pending"]
@pytest.mark.asyncio
async def test_create_task_without_intent(client):
"""测试不提供意图时创建任务"""
resp = await client.post("/api/v1/reimbursement/tasks", json={
"user_id": "U001", "company_id": "C001"
})
assert resp.status_code == 422 # Validation error
@pytest.mark.asyncio
async def test_get_nonexistent_task(client):
"""测试查询不存在的任务"""
resp = await client.get("/api/v1/reimbursement/tasks/nonexistent-id")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_tasks_pagination(client):
"""测试任务列表分页"""
# 创建多个任务
for i in range(5):
await create_task(client, intent=f"test task {i}")
# 测试分页
resp = await client.get("/api/v1/reimbursement/tasks?page=1&size=3")
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 5
assert len(data["items"]) <= 3
```
- [ ] **Step 4: 编写规则引擎集成测试**
测试每条规则对真实报销数据的命中情况:
- 住宿费超标 → 命中 `TRAVEL_HOTEL_LIMIT`
- 缺少酒店流水 → 命中 `HOTEL_BILL_REQUIRED`
- 重复发票 → 命中 `DUPLICATE_INVOICE_CHECK`
- 合规报销 → 无命中
- [ ] **Step 5: 确保所有测试通过**
Run: `cd backend && pytest tests/ -v --tb=short`
Expected: All PASS
- [ ] **Step 6: Commit**
```bash
git add backend/
git commit -m "test: 添加完整报销流程集成测试"
```
---
### Task 6.2: 前端 E2E 测试(可选)
**负责人:** 前端工程师
**预计工时:** 2 天
**前置依赖:** Phase 5
**可并行于:** Task 6.1、6.3
**Files:**
- Create: `frontend/e2e/reimbursement.spec.ts`
- Create: `frontend/playwright.config.ts`
- [ ] **Step 1: 安装 Playwright**
```bash
cd frontend
npm install -D @playwright/test
npx playwright install chromium
```
- [ ] **Step 2: 配置 Playwright**
`frontend/playwright.config.ts`:
```typescript
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:5173',
use: {
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
},
})
```
- [ ] **Step 3: 编写核心流程 E2E 测试**
`frontend/e2e/reimbursement.spec.ts`:
```typescript
import { test, expect } from '@playwright/test'
test('完整报销流程', async ({ page }) => {
// 1. 访问首页
await page.goto('/')
await expect(page.locator('h1')).toContainText('报销')
// 2. 输入报销意图
await page.fill('input[placeholder*="报销"]', '我要报这次北京出差的费用')
await page.click('button:has-text("提交")')
// 3. 跳转到上传页
await expect(page).toHaveURL(/\/task\/.*\/upload/)
// 4. 上传文件
await page.setInputFiles('input[type="file"]', 'tests/fixtures/invoice.pdf')
// 5. 选择票据类型
await page.selectOption('select', 'vat_invoice')
// 6. 开始识别
await page.click('button:has-text("开始识别")')
// 7. 跳转到草稿页
await expect(page).toHaveURL(/\/draft/, { timeout: 30000 })
// 8. 执行预审
await page.click('button:has-text("执行预审")')
// 9. 跳转到预审结果页
await expect(page).toHaveURL(/\/precheck/, { timeout: 30000 })
})
```
- [ ] **Step 4: 运行 E2E 测试**
Run: `cd frontend && npx playwright test`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add frontend/
git commit -m "test: 添加前端 E2E 测试"
```
---
### Task 6.3: Bug 修复与 UI 打磨
**负责人:** 全员参与
**预计工时:** 3 天
**前置依赖:** Phase 5
**可并行于:** Task 6.1、6.2
- [ ] **Step 1: UI 走查清单**
逐页面检查:
| 页面 | 检查项 |
|---|---|
| 首页 | 布局、输入框交互、快捷按钮、最近任务列表 |
| 上传页 | 拖拽上传、文件预览、票据类型选择、进度条 |
| 草稿页 | 表格编辑、金额汇总、附件预览、预审按钮 |
| 预审结果页 | 结论卡片、风险项展示、规则命中详情 |
| 补件页 | 补件清单、上传/回复交互、提交反馈 |
| 确认页 | 摘要展示、同步状态轮询、成功/失败状态 |
| 审计日志页 | 时间线展示、筛选功能 |
- [ ] **Step 2: 修复共性问题**
- [ ] 响应式布局适配1280px / 1024px / 768px 断点)
- [ ] Loading 状态:所有异步操作加 loading 指示器
- [ ] 错误提示API 错误统一使用 Ant Design Message 提示
- [ ] 空状态:无数据时展示空状态插画和文案
- [ ] 表单校验:必填项红框提示 + 校验文案
- [ ] 金额格式化:千分位 + 两位小数 + ¥ 前缀
- [ ] 日期格式化YYYY-MM-DD
- [ ] 确认弹窗:删除、提交等危险操作二次确认
- [ ] **Step 3: 添加 Demo 展示数据**
在首页添加"体验 Demo"按钮,一键生成演示数据:
- 创建一个已完成全流程的报销任务
- 包含 3 条费用明细
- 有规则命中记录
- 有审计日志
- [ ] **Step 4: 性能优化**
- [ ] 路由懒加载(已配置)
- [ ] 表格虚拟滚动(如果明细很多)
- [ ] 图片懒加载
- [ ] API 请求去重/缓存
- [ ] **Step 5: Commit**
```bash
git add .
git commit -m "fix: UI 打磨和 Bug 修复"
```
---
### Task 6.4: 部署与文档
**负责人:** 后端工程师 B + 前端工程师
**预计工时:** 2 天
**前置依赖:** Task 6.3
**Files:**
- Create: `docker-compose.prod.yml`
- Create: `nginx.conf`
- Modify: `README.md`
- Create: `docs/deployment.md`
- [ ] **Step 1: 编写生产 Docker Compose**
`docker-compose.prod.yml`:
```yaml
version: "3.8"
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./frontend/dist:/usr/share/nginx/html
depends_on:
- backend
backend:
build: ./backend
environment:
- DATABASE_URL=postgresql+asyncpg://postgres:${DB_PASSWORD}@postgres:5432/x_financial
- REDIS_URL=redis://redis:6379/0
- MINIO_ENDPOINT=minio:9000
depends_on:
- postgres
- redis
- minio
postgres:
image: postgres:15
environment:
POSTGRES_DB: x_financial
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
minio:
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
volumes:
- minio_data:/data
volumes:
pgdata:
minio_data:
```
- [ ] **Step 2: 编写 Nginx 配置**
`nginx.conf`:
```nginx
server {
listen 80;
server_name localhost;
# 前端静态文件
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
# 后端 API 代理
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
}
```
- [ ] **Step 3: 编写部署文档**
`docs/deployment.md` — 包含:
- 环境要求Docker、Docker Compose
- 配置说明(.env 文件)
- 启动步骤
- 停止和重启
- 数据库迁移
- 种子数据初始化
- 日志查看
- 常见问题排查
- [ ] **Step 4: 更新 README**
项目 README 包含:
- 项目简介和架构图
- 快速启动(开发环境)
- 技术栈说明
- 目录结构
- 开发指南
- API 文档链接
- [ ] **Step 5: 确认 Swagger 文档完整**
访问 http://localhost:8000/docs确认
- 所有 API 端点都有描述
- 请求/响应示例完整
- 错误码说明完整
- [ ] **Step 6: Commit**
```bash
git add .
git commit -m "docs: 添加部署文档、Nginx 配置、生产 Docker Compose"
```
---
## 本阶段完成检查
- [ ] `cd backend && pytest tests/ -v` 全部通过
- [ ] `cd frontend && npx playwright test` 全部通过(如配置)
- [ ] `cd frontend && npm run build` 无报错
- [ ] 完整报销流程在浏览器中手动测试无问题
- [ ] 所有页面响应式布局正常
- [ ] `docker-compose -f docker-compose.prod.yml up -d` 能启动
- [ ] README 和部署文档完整
- [ ] Swagger API 文档完整
- [ ] Demo 数据展示正常

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +1,51 @@
# Work Log - 2026-05-06
## Git Commits Today
## 05-06 工作
| Commit | Description | Files | Changes |
|--------|-------------|------|--------|
| f1dcfcf | docs: update work log with git commits | 1 file | +6/-57 |
| 04e4b71 | docs: add work log for 2026-05-06 | 1 file | +47/-30 |
| ae63766 | Add vue-router, login/setup flow | 35 files | +3761/-403 |
### 下午
- **修复了 Windows Git Bash 启动脚本报错问题**
- 问题:虚拟环境指向不存在的 python3
- 解决:添加检测函数,无效则重建
### Commit Details
- **创建了 work-log 技能**
- 自动记录工作日志
- 按 git 提交生成工作总结
#### ae63766 - Add vue-router, login/setup flow
- **问题**: 前端需要路由化和安装流程
- **解决**:
- 前端使用 vue-router 重构为路由化导航
- 添加系统安装和登录页面 + API 集成
- 后端添加结构化日志、access-log 中间件、启动生命周期
- **Files Changed**:
- web/src/router/index.js (+110)
- web/src/views/SetupView.vue (+316)
- web/src/views/LoginView.vue (+64/-)
- web/vite.config.js (+693)
- server/src/app/core/logging.py (+72)
- server/src/app/middleware/logging.py (+42)
- web/src/composables/useSetupView.js (+383)
- web/src/composables/useSystemState.js (+278)
---
## Problem (问题)
# Work Log - 2026-05-07
### 1. Windows Git Bash 虚拟环境问题
- **现象**: `bash start.sh` 报错 "No module named pip"
- **原因**: `server/.venv` 指向不存在的 `/usr/bin/python3`
- **状态**: ✅ 已解决
## 05-07 工作
### 2. 日志技能不完善
- **现象**: 写日志时没有获取 git 详细变更
- **状态**: ✅ 已解决 (更新了技能)
### 上午
- **完成了后端员工管理模块**
- 员工 CRUD 服务(创建、更新、删除)
- 自动记录修改历史(变更日志)
- 组织架构和角色模型
### 3. PostgreSQL 未安装
- **现象**: 后端需要数据库连接
- **状态**: ⏳ 未解决
### 中午
- **完成了前端员工管理页面**
- 表格展示员工列表
- 搜索和分页功能
- 新增/编辑弹窗
## What's Done (已完成)
- [x] 修复 server/start.sh 虚拟环境检测
- [x] 更新 work-log 技能:获取 commit 详情和变更文件
- [x] 添加路由化导航 (vue-router)
- [x] 添加 SetupView 安装页面
- [x] 添加后端日志中间件
- **添加了后端健康检查**
- 后端不可用时显示提示页面
- 支持重试
## What's Not Done (未完成)
- [ ] 安装 PostgreSQL
- [ ] 创建数据库 x_financial
### 下午
- **重构了项目结构**
- 前后端分离web/ + server/
- 使用 vue-router 路由化导航
- 添加系统安装页面
## Tasks
- [ ] 安装 PostgreSQL
- [ ] 创建数据库 `x_financial`
- **整理了 UI 资源**
- 图片移至 web/UI/ 目录
- 清理旧文档
---
# 待处理
- [ ] 安装 PostgreSQL 并创建数据库
- [ ] 测试后端 API 连接
## Notes (备注)
- 项目已重构为前后端分离架构 (web/ + server/)
- 需要配置 DATABASE_URL 环境变量
---
*Created with work-log skill*
*Last updated: 2026-05-06*
## Uncommitted Changes
已提交,无遗留
## Summary
### Morning - 修复 server/start.sh
- **问题**Windows Git Bash 上无法运行,报错 "No module named pip"
- **原因**`.venv` 指向不存在的 `/usr/bin/python3`
- **解决**:添加 `venv_valid()` 函数检测并重建虚拟环境
### Afternoon - 创建 work-log 技能
- 自动读取 git 提交记录
- 存储在 `document/work-log/` 目录
- 工作流程:先提交 git → 获取日志 → 写日志
### Evening - 前端重构
- 添加 SetupView 安装页面
- 添加路由和服务模块
## Notes
- 需要安装 PostgreSQL 并创建 `x_financial` 数据库
- 还有其他未提交的文件(.env, nul 等)
## Tasks
- [ ] 安装 PostgreSQL
- [ ] 创建数据库 `x_financial`
---
*Created with work-log skill*
*Last updated: 2026-05-06*

View File

@@ -0,0 +1,67 @@
# Work Log - 2026-05-07
## 今日工作
### 早上 09:00 - 10:00
- **修复了 Windows 启动脚本报错**
- 添加虚拟环境检测函数 venv_valid()
- 无效时自动重建虚拟环境
### 早上 10:00 - 11:00
- **开始员工管理后端开发**
- 设计员工模型(工号、部门、职位、状态)
- 添加工号字段(唯一)
### 中午 11:00 - 12:00
- **完成了员工 CRUD 服务**
- create_employee() 创建员工
- update_employee() 更新员工
- get_employees() 分页查询
### 中午 12:00 - 13:00
- **添加了员工变更日志**
- 记录员工信息修改历史
- 字段employee_id, field_name, old_value, new_value
### 下午 13:00 - 14:00
- **添加了组织和角色模型**
- Organization 组织架构
- Role 角色权限
### 下午 14:00 - 15:00
- **完成了员工 API 端点**
- GET /api/v1/employees 列表
- POST /api/v1/employees 创建
- GET /api/v1/employees/{id} 获取单个
### 下午 15:00 - 16:00
- **开始前端员工页面开发**
- 表格展示员工列表
- 搜索功能
### 下午 16:00 - 17:00
- **完成了前端员工页面**
- 搜索和分页
- 新增/编辑弹窗
### 下午 17:00 - 18:00
- **添加了后端健康检查**
- BackendUnavailableRouteView 页面
- 后端不可用时提示并重试
### 下午 18:00 - 19:00
- **重构了前端路由**
- 使用 vue-router 路由化导航
- 添加 /employees 路由
### 下午 19:00 - 20:00
- **整理了 UI 资源**
- 图片移至 web/UI/ 目录
- 删除旧文档
---
# 待处理
- [ ] 安装 PostgreSQL
- [ ] 创建 x_financial 数据库

View File

@@ -2,25 +2,37 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.employee import EmployeeCreate, EmployeeRead
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead
from app.services.employee import EmployeeService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("/meta", response_model=EmployeeMetaRead)
def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
return EmployeeService(db).get_employee_meta()
@router.get("", response_model=list[EmployeeRead])
def list_employees(db: DbSession) -> list[EmployeeRead]:
return EmployeeService(db).list_employees()
def list_employees(
db: DbSession,
status_filter: Annotated[str | None, Query(alias="status")] = None,
keyword: str | None = None,
) -> list[EmployeeRead]:
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
@router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED)
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
try:
return EmployeeService(db).create_employee(payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.get("/{employee_id}", response_model=EmployeeRead)

View File

@@ -1,6 +1,17 @@
from app.db.base_class import Base
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
__all__ = ["Base", "Employee", "ReimbursementRequest", "ApprovalRecord"]
__all__ = [
"Base",
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
]

View File

@@ -7,6 +7,7 @@ from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
from app.services.employee import prepare_employee_directory
def create_app() -> FastAPI:
@@ -48,8 +49,9 @@ def create_app() -> FastAPI:
@app.on_event("startup")
def _on_startup() -> None:
prepare_employee_directory()
logger.info(
"Server ready host=%s port=%s prefix=%s",
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
settings.app_port,
settings.api_v1_prefix,

View File

@@ -1,5 +1,15 @@
from app.models.approval import ApprovalRecord
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.role import Role
__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"]
__all__ = [
"ApprovalRecord",
"Employee",
"EmployeeChangeLog",
"OrganizationUnit",
"ReimbursementRequest",
"Role",
]

View File

@@ -1,13 +1,20 @@
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, String, Table, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
employee_role_links = Table(
"employee_role_links",
Base.metadata,
Column("employee_id", String(36), ForeignKey("employees.id"), primary_key=True),
Column("role_id", String(36), ForeignKey("roles.id"), primary_key=True),
)
class Employee(Base):
__tablename__ = "employees"
@@ -15,11 +22,37 @@ class Employee(Base):
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
department: Mapped[str] = mapped_column(String(100), index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
birth_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
join_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
position: Mapped[str] = mapped_column(String(100), default="员工")
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
organization_unit_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
manager_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
organization_unit = relationship("OrganizationUnit", back_populates="employees")
manager = relationship("Employee", remote_side=[id], back_populates="reports")
reports = relationship("Employee", back_populates="manager")
roles = relationship("Role", secondary=employee_role_links, back_populates="employees")
change_logs = relationship(
"EmployeeChangeLog",
back_populates="employee",
cascade="all, delete-orphan",
order_by="desc(EmployeeChangeLog.occurred_at)",
)
reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee")

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class EmployeeChangeLog(Base):
__tablename__ = "employee_change_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True)
action: Mapped[str] = mapped_column(String(255))
owner: Mapped[str] = mapped_column(String(100))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
employee = relationship("Employee", back_populates="change_logs")

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class OrganizationUnit(Base):
__tablename__ = "organization_units"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
unit_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), index=True)
unit_type: Mapped[str] = mapped_column(String(30), default="department", index=True)
parent_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
parent = relationship("OrganizationUnit", remote_side=[id], back_populates="children")
children = relationship("OrganizationUnit", back_populates="parent")
employees = relationship("Employee", back_populates="organization_unit")

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
role_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
description: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
employees = relationship("Employee", secondary="employee_role_links", back_populates="roles")

View File

@@ -1,17 +1,93 @@
from sqlalchemy.orm import Session
from __future__ import annotations
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.employee import Employee
from app.models.organization import OrganizationUnit
from app.models.role import Role
class EmployeeRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list(self) -> list[Employee]:
return self.db.query(Employee).order_by(Employee.created_at.desc()).all()
def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]:
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.order_by(Employee.updated_at.desc(), Employee.name.asc())
)
if status and status != "全部员工":
stmt = stmt.where(Employee.employment_status == status)
if keyword:
pattern = f"%{keyword.strip()}%"
stmt = stmt.where(
or_(
Employee.name.ilike(pattern),
Employee.employee_no.ilike(pattern),
Employee.email.ilike(pattern),
Employee.position.ilike(pattern),
)
)
return list(self.db.execute(stmt).scalars().unique().all())
def get(self, employee_id: str) -> Employee | None:
return self.db.query(Employee).filter(Employee.id == employee_id).first()
stmt = (
select(Employee)
.options(
selectinload(Employee.organization_unit),
selectinload(Employee.manager),
selectinload(Employee.roles),
selectinload(Employee.change_logs),
)
.where(Employee.id == employee_id)
)
return self.db.execute(stmt).scalars().unique().first()
def get_by_employee_no(self, employee_no: str) -> Employee | None:
stmt = select(Employee).where(Employee.employee_no == employee_no)
return self.db.execute(stmt).scalars().first()
def get_by_email(self, email: str) -> Employee | None:
stmt = select(Employee).where(Employee.email == email)
return self.db.execute(stmt).scalars().first()
def list_roles(self) -> list[Role]:
stmt = select(Role)
return list(self.db.execute(stmt).scalars().all())
def get_role_by_code(self, role_code: str) -> Role | None:
stmt = select(Role).where(Role.role_code == role_code)
return self.db.execute(stmt).scalars().first()
def list_organization_units(self) -> list[OrganizationUnit]:
stmt = select(OrganizationUnit)
return list(self.db.execute(stmt).scalars().all())
def get_organization_by_code(self, unit_code: str) -> OrganizationUnit | None:
stmt = select(OrganizationUnit).where(OrganizationUnit.unit_code == unit_code)
return self.db.execute(stmt).scalars().first()
def count_employees(self) -> int:
stmt = select(func.count()).select_from(Employee)
return int(self.db.execute(stmt).scalar_one())
def count_roles(self) -> int:
stmt = select(func.count()).select_from(Role)
return int(self.db.execute(stmt).scalar_one())
def count_organization_units(self) -> int:
stmt = select(func.count()).select_from(OrganizationUnit)
return int(self.db.execute(stmt).scalar_one())
def create(self, employee: Employee) -> Employee:
self.db.add(employee)

View File

@@ -1,24 +1,102 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class EmployeeCreate(BaseModel):
employee_no: str
class EmployeeHistoryRead(BaseModel):
action: str
owner: str
time: str
occurredAt: str
class EmployeeOrganizationRead(BaseModel):
id: str
code: str
name: str
department: str
email: EmailStr
unitType: str
costCenter: str | None = None
location: str | None = None
managerName: str | None = None
class EmployeeRoleOptionRead(BaseModel):
id: str
code: str
label: str
desc: str
permissions: list[str] = Field(default_factory=list)
class EmployeeStatusSummaryRead(BaseModel):
id: str
label: str
count: int
class EmployeeMetaRead(BaseModel):
totalEmployees: int
statusSummary: list[EmployeeStatusSummaryRead]
roleOptions: list[EmployeeRoleOptionRead]
class EmployeeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=False)
id: str
employee_no: str
avatar: str
name: str
employeeNo: str
department: str
position: str
grade: str
manager: str
financeOwner: str
roles: list[str] = Field(default_factory=list)
roleCodes: list[str] = Field(default_factory=list)
status: str
statusTone: str
gender: str | None = None
age: int | None = None
birthDate: str | None = None
email: EmailStr
created_at: datetime
updated_at: datetime
phone: str | None = None
joinDate: str | None = None
location: str | None = None
costCenter: str | None = None
updatedAt: str | None = None
lastSync: str | None = None
syncState: str
spotlight: bool = False
permissions: list[str] = Field(default_factory=list)
history: list[EmployeeHistoryRead] = Field(default_factory=list)
organization: EmployeeOrganizationRead | None = None
class EmployeeCreate(BaseModel):
employee_no: str = Field(min_length=1, max_length=50)
name: str = Field(min_length=1, max_length=100)
email: EmailStr
gender: str | None = Field(default=None, max_length=20)
birth_date: str | None = None
phone: str | None = Field(default=None, max_length=30)
join_date: str | None = None
location: str | None = Field(default=None, max_length=100)
position: str = Field(default="员工", max_length=100)
grade: str = Field(default="P3", max_length=20)
cost_center: str | None = Field(default=None, max_length=50)
finance_owner_name: str | None = Field(default=None, max_length=100)
employment_status: str = Field(default="在职", max_length=30)
sync_state: str = Field(default="已同步", max_length=30)
spotlight: bool = False
organization_unit_code: str | None = Field(default=None, max_length=50)
manager_employee_no: str | None = Field(default=None, max_length=50)
role_codes: list[str] = Field(default_factory=lambda: ["user"])
def parsed_birth_date(self) -> date | None:
return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None
def parsed_join_date(self) -> date | None:
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None

View File

@@ -1,34 +1,445 @@
from __future__ import annotations
from collections import Counter
from datetime import date, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.logging import get_logger
from app.db.base import Base
from app.db.session import get_session_factory
from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.repositories.employee import EmployeeRepository
from app.schemas.employee import EmployeeCreate
from app.schemas.employee import (
EmployeeCreate,
EmployeeHistoryRead,
EmployeeMetaRead,
EmployeeOrganizationRead,
EmployeeRead,
EmployeeRoleOptionRead,
EmployeeStatusSummaryRead,
)
from app.services.employee_seed import (
EMPLOYEE_DEFINITIONS,
ORGANIZATION_DEFINITIONS,
ROLE_DEFINITIONS,
ROLE_DISPLAY_ORDER,
ROLE_PERMISSION_MAP,
)
logger = get_logger("app.services.employee")
STATUS_TONE_MAP = {
"在职": "success",
"试用中": "warning",
"停用": "neutral",
}
STATUS_ORDER = ["全部员工", "在职", "试用中", "停用"]
SEEDED_EMPLOYEE_DEFINITIONS = EMPLOYEE_DEFINITIONS[:30]
EXTRA_SEED_EMPLOYEE_NOS = {item["employee_no"] for item in EMPLOYEE_DEFINITIONS[30:]}
def prepare_employee_directory() -> None:
settings = get_settings()
if not settings.setup_completed:
logger.info("Employee directory bootstrap skipped because setup is incomplete")
return
session_factory = get_session_factory()
with session_factory() as db:
EmployeeService(db).ensure_directory_ready()
class EmployeeService:
def __init__(self, db: Session) -> None:
self.db = db
self.repository = EmployeeRepository(db)
def list_employees(self) -> list[Employee]:
employees = self.repository.list()
logger.info("Listed employees (count=%d)", len(employees))
return employees
def ensure_directory_ready(self) -> None:
try:
Base.metadata.create_all(bind=self.db.get_bind())
self._prune_extra_seed_employees()
self._seed_roles()
self._seed_organization_units()
self._seed_employees()
self.db.commit()
except Exception:
self.db.rollback()
logger.exception("Failed to prepare employee directory")
raise
def get_employee(self, employee_id: str) -> Employee | None:
def list_employees(self, status: str | None = None, keyword: str | None = None) -> list[EmployeeRead]:
self.ensure_directory_ready()
employees = self.repository.list(status=status, keyword=keyword)
logger.info("Listed employees (count=%d, status=%s, keyword=%s)", len(employees), status, keyword)
return [self._serialize_employee(item) for item in employees]
def get_employee(self, employee_id: str) -> EmployeeRead | None:
self.ensure_directory_ready()
employee = self.repository.get(employee_id)
if employee:
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
else:
if employee is None:
logger.warning("Employee not found id=%s", employee_id)
return employee
return None
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
return self._serialize_employee(employee)
def get_employee_meta(self) -> EmployeeMetaRead:
self.ensure_directory_ready()
employees = self.repository.list()
status_counter = Counter(item.employment_status for item in employees)
status_summary = [
EmployeeStatusSummaryRead(
id=status,
label=status,
count=len(employees) if status == "全部员工" else status_counter.get(status, 0),
)
for status in STATUS_ORDER
]
role_options = [
EmployeeRoleOptionRead(
id=role.role_code,
code=role.role_code,
label=role.name,
desc=role.description,
permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])),
)
for role in self._sorted_roles(self.repository.list_roles())
]
return EmployeeMetaRead(
totalEmployees=len(employees),
statusSummary=status_summary,
roleOptions=role_options,
)
def create_employee(self, payload: EmployeeCreate) -> EmployeeRead:
self.ensure_directory_ready()
if self.repository.get_by_employee_no(payload.employee_no):
raise ValueError(f"员工编号 {payload.employee_no} 已存在")
if self.repository.get_by_email(str(payload.email)):
raise ValueError(f"邮箱 {payload.email} 已存在")
employee = Employee(
employee_no=payload.employee_no,
name=payload.name,
email=str(payload.email),
gender=payload.gender,
birth_date=payload.parsed_birth_date(),
phone=payload.phone,
join_date=payload.parsed_join_date(),
location=payload.location,
position=payload.position,
grade=payload.grade,
cost_center=payload.cost_center,
finance_owner_name=payload.finance_owner_name,
employment_status=payload.employment_status,
sync_state=payload.sync_state,
spotlight=payload.spotlight,
last_sync_at=datetime.now(),
)
if payload.organization_unit_code:
employee.organization_unit = self.repository.get_organization_by_code(payload.organization_unit_code)
if payload.manager_employee_no:
employee.manager = self.repository.get_by_employee_no(payload.manager_employee_no)
roles = [
role
for code in payload.role_codes
if (role := self.repository.get_role_by_code(code)) is not None
]
employee.roles = self._sorted_roles(roles)
def create_employee(self, payload: EmployeeCreate) -> Employee:
employee = Employee(**payload.model_dump())
created = self.repository.create(employee)
logger.info(
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
)
return created
hydrated = self.repository.get(created.id)
return self._serialize_employee(hydrated or created)
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
for definition in ROLE_DEFINITIONS:
role = existing_by_code.get(definition["role_code"])
if role is None:
role = Role(
role_code=definition["role_code"],
name=definition["name"],
description=definition["description"],
)
self.db.add(role)
existing_by_code[role.role_code] = role
self.db.flush()
def _seed_organization_units(self) -> None:
existing_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in ORGANIZATION_DEFINITIONS:
organization = existing_by_code.get(definition["unit_code"])
if organization is None:
organization = OrganizationUnit(
unit_code=definition["unit_code"],
name=definition["name"],
unit_type=definition["unit_type"],
cost_center=definition.get("cost_center"),
location=definition.get("location"),
manager_name=definition.get("manager_name"),
)
self.db.add(organization)
existing_by_code[organization.unit_code] = organization
self.db.flush()
for definition in ORGANIZATION_DEFINITIONS:
parent_code = definition.get("parent_code")
if not parent_code:
continue
organization = existing_by_code[definition["unit_code"]]
if organization.parent_id:
continue
parent = existing_by_code.get(parent_code)
if parent is not None:
organization.parent = parent
self.db.flush()
def _seed_employees(self) -> None:
employees_by_no = {
employee.employee_no: employee for employee in self.repository.list()
}
roles_by_code = {role.role_code: role for role in self.repository.list_roles()}
organizations_by_code = {
unit.unit_code: unit for unit in self.repository.list_organization_units()
}
for definition in SEEDED_EMPLOYEE_DEFINITIONS:
employee_no = definition["employee_no"]
if employee_no in employees_by_no:
continue
employee = Employee(
employee_no=employee_no,
name=definition["name"],
email=definition["email"],
gender=definition.get("gender"),
birth_date=self._parse_date(definition.get("birth_date")),
phone=definition.get("phone"),
join_date=self._parse_date(definition.get("join_date")),
location=definition.get("location"),
position=definition.get("position", "员工"),
grade=definition.get("grade", "P3"),
cost_center=definition.get("cost_center"),
finance_owner_name=definition.get("finance_owner_name"),
employment_status=definition.get("employment_status", "在职"),
sync_state=definition.get("sync_state", "已同步"),
spotlight=bool(definition.get("spotlight")),
last_sync_at=self._parse_datetime(definition.get("last_sync_at")),
updated_at=self._parse_datetime(definition.get("updated_at")),
)
self.db.add(employee)
employees_by_no[employee_no] = employee
self.db.flush()
for definition in SEEDED_EMPLOYEE_DEFINITIONS:
employee = employees_by_no[definition["employee_no"]]
organization_code = definition.get("organization_unit_code")
manager_employee_no = definition.get("manager_employee_no")
if employee.organization_unit_id is None and organization_code:
employee.organization_unit = organizations_by_code.get(organization_code)
if employee.manager_id is None and manager_employee_no:
employee.manager = employees_by_no.get(manager_employee_no)
if not employee.roles:
employee.roles = self._sorted_roles(
[
roles_by_code[role_code]
for role_code in definition.get("role_codes", [])
if role_code in roles_by_code
]
)
self._seed_employee_history(employee, definition)
self.db.flush()
def _prune_extra_seed_employees(self) -> None:
if not EXTRA_SEED_EMPLOYEE_NOS:
return
for employee_no in EXTRA_SEED_EMPLOYEE_NOS:
employee = self.repository.get_by_employee_no(employee_no)
if employee is not None:
self.db.delete(employee)
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
existing_keys = {
(item.action, item.owner, self._format_datetime(item.occurred_at))
for item in employee.change_logs
}
history_items = list(definition.get("history", []))
if not history_items:
history_items = [
{
"action": "初始化员工档案",
"owner": "系统初始化任务",
"occurred_at": definition.get("updated_at") or definition.get("last_sync_at"),
}
]
for history in history_items:
occurred_at = self._parse_datetime(history.get("occurred_at"))
if occurred_at is None:
continue
identity = (
history["action"],
history["owner"],
self._format_datetime(occurred_at),
)
if identity in existing_keys:
continue
self.db.add(
EmployeeChangeLog(
employee=employee,
action=history["action"],
owner=history["owner"],
occurred_at=occurred_at,
)
)
existing_keys.add(identity)
def _serialize_employee(self, employee: Employee) -> EmployeeRead:
organization = employee.organization_unit
roles = self._sorted_roles(list(employee.roles))
role_labels = [role.name for role in roles]
role_codes = [role.role_code for role in roles]
history = [
EmployeeHistoryRead(
action=item.action,
owner=item.owner,
time=self._format_datetime(item.occurred_at) or "",
occurredAt=self._format_datetime(item.occurred_at) or "",
)
for item in employee.change_logs
]
return EmployeeRead(
id=employee.id,
avatar=(employee.name or "?")[:1],
name=employee.name,
employeeNo=employee.employee_no,
department=organization.name if organization else "",
position=employee.position,
grade=employee.grade,
manager=employee.manager.name if employee.manager else "CEO",
financeOwner=employee.finance_owner_name or "",
roles=role_labels,
roleCodes=role_codes,
status=employee.employment_status,
statusTone=STATUS_TONE_MAP.get(employee.employment_status, "neutral"),
gender=employee.gender,
age=self._calculate_age(employee.birth_date),
birthDate=self._format_date(employee.birth_date),
email=employee.email,
phone=employee.phone,
joinDate=self._format_date(employee.join_date),
location=employee.location,
costCenter=employee.cost_center,
updatedAt=self._format_datetime(employee.updated_at or employee.created_at),
lastSync=self._format_datetime(employee.last_sync_at),
syncState=employee.sync_state,
spotlight=employee.spotlight,
permissions=self._collect_permissions(role_codes),
history=history,
organization=(
EmployeeOrganizationRead(
id=organization.id,
code=organization.unit_code,
name=organization.name,
unitType=organization.unit_type,
costCenter=organization.cost_center,
location=organization.location,
managerName=organization.manager_name,
)
if organization
else None
),
)
def _collect_permissions(self, role_codes: list[str]) -> list[str]:
permissions: list[str] = []
seen: set[str] = set()
for role_code in role_codes:
for permission in ROLE_PERMISSION_MAP.get(role_code, []):
if permission in seen:
continue
permissions.append(permission)
seen.add(permission)
return permissions
def _sorted_roles(self, roles: list[Role]) -> list[Role]:
return sorted(roles, key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name))
@staticmethod
def _parse_date(value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
@staticmethod
def _parse_datetime(value: str | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return datetime.strptime(value, "%Y-%m-%d %H:%M")
@staticmethod
def _format_date(value: date | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d")
@staticmethod
def _format_datetime(value: datetime | None) -> str | None:
if value is None:
return None
return value.strftime("%Y-%m-%d %H:%M")
@staticmethod
def _calculate_age(birth_date: date | None) -> int | None:
if birth_date is None:
return None
today = date.today()
age = today.year - birth_date.year
if (today.month, today.day) < (birth_date.month, birth_date.day):
age -= 1
return age

View File

@@ -0,0 +1,986 @@
from __future__ import annotations
ROLE_DISPLAY_ORDER = {
"manager": 1,
"finance": 2,
"approver": 3,
"executive": 4,
"auditor": 5,
"user": 6,
}
ROLE_DEFINITIONS = [
{
"role_code": "user",
"name": "使用者",
"description": "可以发起报销、查看个人单据和使用 AI 助手。",
},
{
"role_code": "finance",
"name": "财务人员",
"description": "可以处理复核、查看财务知识与风险校验结果。",
},
{
"role_code": "manager",
"name": "管理员",
"description": "可以维护员工档案、组织结构和角色权限。",
},
{
"role_code": "executive",
"name": "高级管理人员",
"description": "可以查看跨部门数据看板与关键审批结果。",
},
{
"role_code": "approver",
"name": "审批负责人",
"description": "可以处理审批中心中的待审单据。",
},
{
"role_code": "auditor",
"name": "审计观察员",
"description": "可以查看变更记录和权限调整历史。",
},
]
ROLE_PERMISSION_MAP = {
"user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"],
"finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"],
"manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"],
"executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"],
"approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"],
"auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"],
}
ORGANIZATION_DEFINITIONS = [
{
"unit_code": "ORG-ROOT",
"name": "星海科技",
"unit_type": "company",
"parent_code": None,
"cost_center": "CC-0000",
"location": "上海",
"manager_name": "李文静",
},
{
"unit_code": "EXEC-OFFICE",
"name": "总经办",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-1001",
"location": "上海",
"manager_name": "李文静",
},
{
"unit_code": "FIN-SSC",
"name": "财务共享中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-2108",
"location": "上海",
"manager_name": "张晓晴",
},
{
"unit_code": "HR-OD",
"name": "人力与组织",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-3206",
"location": "杭州",
"manager_name": "陈硕",
},
{
"unit_code": "SALES-SOUTH",
"name": "华南销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4102",
"location": "深圳",
"manager_name": "陈嘉",
},
{
"unit_code": "SALES-EAST",
"name": "华东销售部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-4108",
"location": "上海",
"manager_name": "秦墨然",
},
{
"unit_code": "MKT-BRAND",
"name": "市场品牌部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-5203",
"location": "北京",
"manager_name": "刘思雨",
},
{
"unit_code": "RND-CENTER",
"name": "产品研发中心",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-6105",
"location": "北京",
"manager_name": "吴磊",
},
{
"unit_code": "OPS-ADMIN",
"name": "行政采购部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-7204",
"location": "南京",
"manager_name": "梁雨辰",
},
{
"unit_code": "AUDIT-RISK",
"name": "风控与审计部",
"unit_type": "department",
"parent_code": "ORG-ROOT",
"cost_center": "CC-8102",
"location": "上海",
"manager_name": "顾承宇",
},
]
EMPLOYEE_DEFINITIONS = [
{
"employee_no": "E10018",
"name": "李文静",
"gender": "",
"birth_date": "1987-03-26",
"phone": "13900187688",
"email": "wenjing.li@xfinance.com",
"join_date": "2018-06-21",
"location": "上海",
"position": "高级财务总监",
"grade": "D2",
"organization_unit_code": "EXEC-OFFICE",
"manager_employee_no": None,
"finance_owner_name": "集团财务",
"cost_center": "CC-1001",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 16:20",
"last_sync_at": "2026-05-05 16:20",
"role_codes": ["executive", "approver"],
},
{
"employee_no": "E10234",
"name": "张晓晴",
"gender": "",
"birth_date": "1994-08-12",
"phone": "13810234567",
"email": "xiaoqing.zhang@xfinance.com",
"join_date": "2021-03-15",
"location": "上海",
"position": "费用运营经理",
"grade": "M3",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2108",
"employment_status": "在职",
"sync_state": "待生效",
"spotlight": True,
"updated_at": "2026-05-06 10:24",
"last_sync_at": "2026-05-06 10:24",
"role_codes": ["manager", "finance", "approver"],
"history": [
{
"action": "新增“审批负责人”角色",
"owner": "系统管理员 · 王敏",
"occurred_at": "2026-05-06 10:24",
},
{
"action": "调整财务归口为华东财务组",
"owner": "组织管理员 · 陈硕",
"occurred_at": "2026-05-05 18:10",
},
],
},
{
"employee_no": "E10258",
"name": "孙楠",
"gender": "",
"birth_date": "1992-09-17",
"phone": "13722580312",
"email": "nan.sun@xfinance.com",
"join_date": "2020-11-09",
"location": "上海",
"position": "财务分析师",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2111",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 15:18",
"last_sync_at": "2026-05-04 15:18",
"role_codes": ["finance"],
},
{
"employee_no": "E10271",
"name": "周悦宁",
"gender": "",
"birth_date": "1993-04-21",
"phone": "13622711986",
"email": "yuening.zhou@xfinance.com",
"join_date": "2021-07-05",
"location": "上海",
"position": "财务系统专员",
"grade": "P5",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2112",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 09:35",
"last_sync_at": "2026-05-07 09:10",
"role_codes": ["finance", "auditor"],
},
{
"employee_no": "E10289",
"name": "高嘉禾",
"gender": "",
"birth_date": "1996-02-14",
"phone": "13522895642",
"email": "jiahe.gao@xfinance.com",
"join_date": "2023-01-10",
"location": "上海",
"position": "差旅合规专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2115",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 11:42",
"last_sync_at": "2026-05-03 11:42",
"role_codes": ["finance"],
},
{
"employee_no": "E10867",
"name": "王敏",
"gender": "",
"birth_date": "1996-11-05",
"phone": "13688671200",
"email": "min.wang@xfinance.com",
"join_date": "2022-08-08",
"location": "杭州",
"position": "组织发展主管",
"grade": "P6",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3206",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 09:18",
"last_sync_at": "2026-05-05 09:18",
"role_codes": ["manager", "auditor"],
},
{
"employee_no": "E11618",
"name": "陈硕",
"gender": "",
"birth_date": "1990-05-09",
"phone": "13816186540",
"email": "shuo.chen@xfinance.com",
"join_date": "2019-09-16",
"location": "杭州",
"position": "人力资源经理",
"grade": "M2",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E10018",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3201",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 17:08",
"last_sync_at": "2026-05-04 17:08",
"role_codes": ["manager", "approver"],
},
{
"employee_no": "E12311",
"name": "何思成",
"gender": "",
"birth_date": "1998-07-19",
"phone": "13723117654",
"email": "sicheng.he@xfinance.com",
"join_date": "2026-02-17",
"location": "杭州",
"position": "HRBP",
"grade": "P4",
"organization_unit_code": "HR-OD",
"manager_employee_no": "E11618",
"finance_owner_name": "总部财务BP",
"cost_center": "CC-3208",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-07 08:42",
"last_sync_at": "2026-05-07 08:42",
"role_codes": ["user"],
},
{
"employee_no": "E11026",
"name": "刘思雨",
"gender": "",
"birth_date": "1991-12-03",
"phone": "13921036540",
"email": "siyu.liu@xfinance.com",
"join_date": "2020-04-13",
"location": "北京",
"position": "品牌市场经理",
"grade": "M2",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E10018",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5203",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 14:36",
"last_sync_at": "2026-05-06 14:36",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12408",
"name": "冯可欣",
"gender": "",
"birth_date": "1997-10-28",
"phone": "13624085542",
"email": "kexin.feng@xfinance.com",
"join_date": "2024-03-11",
"location": "北京",
"position": "品牌策划",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5207",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 10:02",
"last_sync_at": "2026-05-07 09:48",
"role_codes": ["user"],
},
{
"employee_no": "E12419",
"name": "许泽航",
"gender": "",
"birth_date": "1995-05-15",
"phone": "13524199508",
"email": "zehang.xu@xfinance.com",
"join_date": "2023-06-19",
"location": "北京",
"position": "数字营销专员",
"grade": "P4",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5209",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 16:52",
"last_sync_at": "2026-05-03 16:52",
"role_codes": ["user"],
},
{
"employee_no": "E11602",
"name": "陈嘉",
"gender": "",
"birth_date": "1997-02-18",
"phone": "13716029901",
"email": "jia.chen@xfinance.com",
"join_date": "2026-03-01",
"location": "深圳",
"position": "区域销售经理",
"grade": "M2",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E10018",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4102",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 14:12",
"last_sync_at": "2026-05-04 14:12",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12476",
"name": "马骁然",
"gender": "",
"birth_date": "1994-01-08",
"phone": "13824760139",
"email": "xiaoran.ma@xfinance.com",
"join_date": "2022-09-05",
"location": "深圳",
"position": "销售运营专家",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4106",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 18:15",
"last_sync_at": "2026-05-06 18:15",
"role_codes": ["user"],
},
{
"employee_no": "E12508",
"name": "唐子墨",
"gender": "",
"birth_date": "1996-06-11",
"phone": "13925088761",
"email": "zimo.tang@xfinance.com",
"join_date": "2024-02-26",
"location": "深圳",
"position": "大客户代表",
"grade": "P4",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4109",
"employment_status": "停用",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-01 11:06",
"last_sync_at": "2026-05-01 11:06",
"role_codes": ["user"],
},
{
"employee_no": "E12514",
"name": "罗欣怡",
"gender": "",
"birth_date": "2000-03-02",
"phone": "13625141227",
"email": "xinyi.luo@xfinance.com",
"join_date": "2026-02-24",
"location": "深圳",
"position": "销售协调专员",
"grade": "P3",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4112",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-05 15:42",
"last_sync_at": "2026-05-05 15:42",
"role_codes": ["user"],
},
{
"employee_no": "E11745",
"name": "吴磊",
"gender": "",
"birth_date": "1989-09-27",
"phone": "13817459812",
"email": "lei.wu@xfinance.com",
"join_date": "2019-12-09",
"location": "北京",
"position": "研发平台主管",
"grade": "M3",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E10018",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6105",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 13:08",
"last_sync_at": "2026-05-06 13:08",
"role_codes": ["user", "approver", "auditor"],
},
{
"employee_no": "E11991",
"name": "赵明",
"gender": "",
"birth_date": "1994-06-09",
"phone": "13519913300",
"email": "ming.zhao@xfinance.com",
"join_date": "2023-11-18",
"location": "北京",
"position": "产品经理",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6112",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-02 11:32",
"last_sync_at": "2026-05-02 11:32",
"role_codes": ["user"],
},
{
"employee_no": "E12611",
"name": "彭一凡",
"gender": "",
"birth_date": "1995-02-03",
"phone": "13726114588",
"email": "yifan.peng@xfinance.com",
"join_date": "2022-04-18",
"location": "北京",
"position": "后端工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6114",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 09:44",
"last_sync_at": "2026-05-06 09:44",
"role_codes": ["user"],
},
{
"employee_no": "E12618",
"name": "苏清禾",
"gender": "",
"birth_date": "1994-12-25",
"phone": "13626188763",
"email": "qinghe.su@xfinance.com",
"join_date": "2022-05-16",
"location": "北京",
"position": "数据工程师",
"grade": "P5",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6116",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 10:26",
"last_sync_at": "2026-05-07 10:18",
"role_codes": ["user"],
},
{
"employee_no": "E12624",
"name": "沈知远",
"gender": "",
"birth_date": "1992-11-06",
"phone": "13926241855",
"email": "zhiyuan.shen@xfinance.com",
"join_date": "2021-11-22",
"location": "北京",
"position": "测试负责人",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6119",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 13:12",
"last_sync_at": "2026-05-05 13:12",
"role_codes": ["user"],
},
{
"employee_no": "E11852",
"name": "周晓彤",
"gender": "",
"birth_date": "1997-05-27",
"phone": "13818529954",
"email": "xiaotong.zhou@xfinance.com",
"join_date": "2022-06-30",
"location": "南京",
"position": "行政采购专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7204",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-05 11:22",
"last_sync_at": "2026-05-05 11:22",
"role_codes": ["user"],
},
{
"employee_no": "E12653",
"name": "梁雨辰",
"gender": "",
"birth_date": "1991-08-30",
"phone": "13726539876",
"email": "yuchen.liang@xfinance.com",
"join_date": "2021-01-04",
"location": "南京",
"position": "行政运营经理",
"grade": "M1",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E10018",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7201",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 17:44",
"last_sync_at": "2026-05-06 17:44",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12661",
"name": "顾承宇",
"gender": "",
"birth_date": "1988-04-16",
"phone": "13926614528",
"email": "chengyu.gu@xfinance.com",
"join_date": "2020-02-03",
"location": "上海",
"position": "风控审计经理",
"grade": "M2",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E10018",
"finance_owner_name": "集团财务",
"cost_center": "CC-8102",
"employment_status": "在职",
"sync_state": "待生效",
"spotlight": True,
"updated_at": "2026-05-07 09:52",
"last_sync_at": "2026-05-07 09:52",
"role_codes": ["auditor", "finance"],
"history": [
{
"action": "更新审计观察范围",
"owner": "系统管理员 · 张晓晴",
"occurred_at": "2026-05-07 09:52",
},
{
"action": "补充高风险费用抽样规则",
"owner": "审计管理员 · 王敏",
"occurred_at": "2026-05-06 18:30",
},
],
},
{
"employee_no": "E12679",
"name": "郑若彤",
"gender": "",
"birth_date": "1997-09-13",
"phone": "13626794520",
"email": "ruotong.zheng@xfinance.com",
"join_date": "2024-01-08",
"location": "上海",
"position": "审计专员",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8105",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 08:58",
"last_sync_at": "2026-05-07 08:40",
"role_codes": ["auditor"],
},
{
"employee_no": "E12688",
"name": "方逸晨",
"gender": "",
"birth_date": "1995-01-20",
"phone": "13526881142",
"email": "yichen.fang@xfinance.com",
"join_date": "2023-08-14",
"location": "南京",
"position": "采购合规分析师",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7208",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 14:16",
"last_sync_at": "2026-05-03 14:16",
"role_codes": ["user", "finance"],
},
{
"employee_no": "E12067",
"name": "秦墨然",
"gender": "",
"birth_date": "1990-10-10",
"phone": "13820674519",
"email": "moran.qin@xfinance.com",
"join_date": "2020-07-20",
"location": "上海",
"position": "华东销售总监",
"grade": "M2",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E10018",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4108",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 12:40",
"last_sync_at": "2026-05-06 12:40",
"role_codes": ["user", "approver"],
},
{
"employee_no": "E12703",
"name": "宋知夏",
"gender": "",
"birth_date": "1994-07-07",
"phone": "13727031129",
"email": "zhixia.song@xfinance.com",
"join_date": "2022-12-12",
"location": "上海",
"position": "重点客户经理",
"grade": "P5",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4111",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 10:58",
"last_sync_at": "2026-05-04 10:58",
"role_codes": ["user"],
},
{
"employee_no": "E12716",
"name": "杜嘉宁",
"gender": "",
"birth_date": "1999-11-16",
"phone": "13627161248",
"email": "jianing.du@xfinance.com",
"join_date": "2026-01-19",
"location": "上海",
"position": "销售代表",
"grade": "P3",
"organization_unit_code": "SALES-EAST",
"manager_employee_no": "E12067",
"finance_owner_name": "华东财务组",
"cost_center": "CC-4114",
"employment_status": "试用中",
"sync_state": "待生效",
"spotlight": False,
"updated_at": "2026-05-05 12:26",
"last_sync_at": "2026-05-05 12:26",
"role_codes": ["user"],
},
{
"employee_no": "E12722",
"name": "邵宁远",
"gender": "",
"birth_date": "1998-12-01",
"phone": "13527221506",
"email": "ningyuan.shao@xfinance.com",
"join_date": "2026-02-08",
"location": "北京",
"position": "数据分析师",
"grade": "P4",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6122",
"employment_status": "试用中",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 09:06",
"last_sync_at": "2026-05-07 08:55",
"role_codes": ["user"],
},
{
"employee_no": "E12739",
"name": "林可昕",
"gender": "",
"birth_date": "1996-10-23",
"phone": "13827394510",
"email": "kexin.lin@xfinance.com",
"join_date": "2023-04-17",
"location": "上海",
"position": "费用核算专员",
"grade": "P4",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "华东财务组",
"cost_center": "CC-2118",
"employment_status": "停用",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-04-30 18:05",
"last_sync_at": "2026-04-30 18:05",
"role_codes": ["finance"],
},
{
"employee_no": "E12744",
"name": "赵予安",
"gender": "",
"birth_date": "1993-01-30",
"phone": "13727442139",
"email": "yuan.zhao@xfinance.com",
"join_date": "2021-10-11",
"location": "上海",
"position": "预算控制经理",
"grade": "M1",
"organization_unit_code": "FIN-SSC",
"manager_employee_no": "E10234",
"finance_owner_name": "集团财务",
"cost_center": "CC-2120",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 15:34",
"last_sync_at": "2026-05-06 15:34",
"role_codes": ["finance", "approver"],
},
{
"employee_no": "E12750",
"name": "谢知行",
"gender": "",
"birth_date": "1995-09-14",
"phone": "13627501386",
"email": "zhixing.xie@xfinance.com",
"join_date": "2022-07-25",
"location": "深圳",
"position": "渠道销售经理",
"grade": "P5",
"organization_unit_code": "SALES-SOUTH",
"manager_employee_no": "E11602",
"finance_owner_name": "华南财务组",
"cost_center": "CC-4116",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-04 09:48",
"last_sync_at": "2026-05-04 09:48",
"role_codes": ["user"],
},
{
"employee_no": "E12758",
"name": "顾南枝",
"gender": "",
"birth_date": "1994-04-12",
"phone": "13827584522",
"email": "nanzhi.gu@xfinance.com",
"join_date": "2022-05-09",
"location": "北京",
"position": "内容运营经理",
"grade": "P5",
"organization_unit_code": "MKT-BRAND",
"manager_employee_no": "E11026",
"finance_owner_name": "市场财务BP",
"cost_center": "CC-5211",
"employment_status": "在职",
"sync_state": "同步中",
"spotlight": False,
"updated_at": "2026-05-07 11:08",
"last_sync_at": "2026-05-07 10:50",
"role_codes": ["user"],
},
{
"employee_no": "E12763",
"name": "孟书言",
"gender": "",
"birth_date": "1992-02-09",
"phone": "13527633148",
"email": "shuyan.meng@xfinance.com",
"join_date": "2021-06-28",
"location": "北京",
"position": "架构工程师",
"grade": "P6",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6125",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 19:05",
"last_sync_at": "2026-05-06 19:05",
"role_codes": ["user"],
},
{
"employee_no": "E12771",
"name": "孔令谦",
"gender": "",
"birth_date": "1993-07-18",
"phone": "13627711572",
"email": "lingqian.kong@xfinance.com",
"join_date": "2021-09-13",
"location": "南京",
"position": "供应商管理专员",
"grade": "P4",
"organization_unit_code": "OPS-ADMIN",
"manager_employee_no": "E12653",
"finance_owner_name": "行政财务BP",
"cost_center": "CC-7210",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-02 17:22",
"last_sync_at": "2026-05-02 17:22",
"role_codes": ["user"],
},
{
"employee_no": "E12782",
"name": "乔语岚",
"gender": "",
"birth_date": "1996-05-06",
"phone": "13727823045",
"email": "yulan.qiao@xfinance.com",
"join_date": "2023-03-06",
"location": "上海",
"position": "风控策略分析师",
"grade": "P4",
"organization_unit_code": "AUDIT-RISK",
"manager_employee_no": "E12661",
"finance_owner_name": "集团财务",
"cost_center": "CC-8108",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-03 13:18",
"last_sync_at": "2026-05-03 13:18",
"role_codes": ["auditor"],
},
{
"employee_no": "E12790",
"name": "邹闻韬",
"gender": "",
"birth_date": "1991-03-11",
"phone": "13827903167",
"email": "wentao.zou@xfinance.com",
"join_date": "2020-10-26",
"location": "上海",
"position": "合规产品负责人",
"grade": "P7",
"organization_unit_code": "RND-CENTER",
"manager_employee_no": "E11745",
"finance_owner_name": "研发财务BP",
"cost_center": "CC-6128",
"employment_status": "在职",
"sync_state": "已同步",
"spotlight": False,
"updated_at": "2026-05-06 08:56",
"last_sync_at": "2026-05-06 08:56",
"role_codes": ["user", "auditor"],
},
]

View File

@@ -15,10 +15,13 @@ src/app/api/v1/endpoints/reimbursements.py
src/app/core/__init__.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/approval.py
src/app/models/employee.py

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
export MSYS_NO_PATHCONV=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -33,26 +35,31 @@ set +a
SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
SERVER_PORT="${SERVER_PORT:-8000}"
SERVER_RELOAD="${SERVER_RELOAD:-false}"
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
is_msys() {
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
needs_windows_python() {
is_msys || is_wsl
}
find_unix_python() {
if command -v python >/dev/null 2>&1; then
echo "python"
if command -v python3 >/dev/null 2>&1; then
echo "python3"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
echo "python3"
if command -v python >/dev/null 2>&1; then
echo "python"
return 0
fi
@@ -74,22 +81,6 @@ find_windows_python() {
}
venv_python_path() {
if [ "${VENV_LAYOUT:-auto}" = "windows" ]; then
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
fi
return 1
fi
if [ "${VENV_LAYOUT:-auto}" = "unix" ]; then
if [ -x "$VENV_DIR/bin/python" ]; then
echo "$VENV_DIR/bin/python"
return 0
fi
return 1
fi
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
echo "$VENV_DIR/Scripts/python.exe"
return 0
@@ -152,30 +143,25 @@ ensure_pip() {
}
ensure_python_bootstrap() {
if is_wsl && is_windows_mount; then
if needs_windows_python; then
if find_windows_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_windows_python)"
VENV_LAYOUT="windows"
info "Detected WSL on a Windows-mounted project"
info "Using Windows Python directly from bash"
info "Detected Windows bash environment — using Windows Python"
return 0
fi
if find_unix_python >/dev/null 2>&1; then
PYTHON_BOOTSTRAP="$(find_unix_python)"
VENV_LAYOUT="unix"
warn "Windows Python not found, falling back to WSL Python"
warn "Windows Python not found, falling back to system Python"
return 0
fi
error "Neither Windows Python nor WSL Python is available in PATH."
error "Python is not available in PATH."
fi
if ! PYTHON_BOOTSTRAP="$(find_unix_python)"; then
error "Python is not installed or not available in PATH. Install Python 3.11+ first so the script can create server/.venv automatically."
fi
VENV_LAYOUT="unix"
}
ensure_dependencies() {
@@ -210,7 +196,11 @@ start_server() {
info "Access: http://$SERVER_HOST:$SERVER_PORT"
echo ""
if [ "$SERVER_RELOAD" = "true" ]; then
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
fi
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
}
case "$MODE" in

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.employee import Employee
from app.models.employee_change_log import EmployeeChangeLog
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.employee import EmployeeService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_employee_directory_seeds_rich_employee_data() -> None:
with build_session() as db:
service = EmployeeService(db)
employees = service.list_employees()
meta = service.get_employee_meta()
assert len(employees) == 30
assert meta.totalEmployees == 30
assert any(item.status == "试用中" for item in employees)
assert any(item.status == "停用" for item in employees)
assert any("审批负责人" in item.roles for item in employees)
assert any(item.permissions for item in employees)
assert any(item.history for item in employees)
role_count = db.scalar(select(func.count()).select_from(Role))
org_count = db.scalar(select(func.count()).select_from(OrganizationUnit))
employee_count = db.scalar(select(func.count()).select_from(Employee))
history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog))
assert role_count == 6
assert org_count == 10
assert employee_count == 30
assert history_count and history_count >= 30
def test_employee_detail_contains_department_and_roles() -> None:
with build_session() as db:
service = EmployeeService(db)
employee = service.list_employees()[0]
detail = service.get_employee(employee.id)
assert detail is not None
assert detail.department
assert detail.manager
assert detail.organization is not None
assert detail.roles

121
start.sh
View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
export MSYS_NO_PATHCONV=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example"
@@ -31,6 +33,69 @@ set +a
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
server_probe_url() {
echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/health"
}
server_smoke_url() {
echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/employees/meta"
}
server_probe_python() {
if [ -x "$SCRIPT_DIR/server/.venv/Scripts/python.exe" ]; then
echo "$SCRIPT_DIR/server/.venv/Scripts/python.exe"
return 0
fi
if [ -x "$SCRIPT_DIR/server/.venv/bin/python" ]; then
echo "$SCRIPT_DIR/server/.venv/bin/python"
return 0
fi
return 1
}
probe_server_health() {
local probe_url="${1:-$(server_probe_url)}"
local probe_python=""
if probe_python="$(server_probe_python)"; then
"$probe_python" -c "import json, sys, urllib.request; data = json.load(urllib.request.urlopen(sys.argv[1], timeout=2)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" "$probe_url" >/dev/null 2>&1
return $?
fi
if command -v curl >/dev/null 2>&1; then
curl --silent --fail --max-time 2 "$probe_url" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
return $?
fi
return 1
}
probe_server_smoke() {
local probe_url="${1:-$(server_smoke_url)}"
local probe_python=""
if probe_python="$(server_probe_python)"; then
"$probe_python" -c "import sys, urllib.request; response = urllib.request.urlopen(sys.argv[1], timeout=3); raise SystemExit(0 if response.status == 200 else 1)" "$probe_url" >/dev/null 2>&1
return $?
fi
if command -v curl >/dev/null 2>&1; then
curl --silent --fail --max-time 3 "$probe_url" >/dev/null 2>&1
return $?
fi
return 1
}
probe_server_ready() {
local health_url="${1:-$(server_probe_url)}"
local smoke_url="${2:-$(server_smoke_url)}"
probe_server_health "$health_url" && probe_server_smoke "$smoke_url"
}
prepare_web() {
info "Preparing web dependencies..."
(
@@ -69,12 +134,14 @@ start_setup_web() {
start_all() {
local server_pid=""
local started_server=false
local probe_url=""
local smoke_url=""
prepare_web
prepare_server
cleanup() {
if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
if [ "$started_server" = true ] && [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
warn "Stopping FastAPI server..."
kill "$server_pid" 2>/dev/null || true
wait "$server_pid" 2>/dev/null || true
@@ -83,49 +150,61 @@ start_all() {
trap cleanup EXIT INT TERM
probe_url="$(server_probe_url)"
smoke_url="$(server_smoke_url)"
if probe_server_ready "$probe_url" "$smoke_url"; then
warn "FastAPI is already ready at $probe_url. Reusing the existing backend process."
elif probe_server_health "$probe_url"; then
error "An existing backend process is responding at $probe_url, but the smoke check failed at $smoke_url. Stop the old FastAPI process and rerun ./start.sh."
else
info "Starting FastAPI server..."
(
cd "$SCRIPT_DIR/server"
./start.sh start
) &
server_pid=$!
started_server=true
fi
wait_for_server() {
local base_url="http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/bootstrap"
wait_for_server_ready() {
local attempt=1
local max_attempts="$SERVER_STARTUP_TIMEOUT"
if ! command -v curl >/dev/null 2>&1; then
warn "curl not found, skipping backend readiness check."
return 0
fi
info "Waiting for FastAPI bootstrap endpoint..."
info "Waiting for FastAPI readiness before starting the web frontend..."
while [ "$attempt" -le "$max_attempts" ]; do
if ! kill -0 "$server_pid" 2>/dev/null; then
wait "$server_pid" 2>/dev/null || true
error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error."
fi
if curl --silent --fail "$base_url" >/dev/null 2>&1; then
info "FastAPI bootstrap endpoint is ready."
if probe_server_ready "$probe_url" "$smoke_url"; then
info "FastAPI is ready. Starting web frontend next."
return 0
fi
if [ $((attempt % 15)) -eq 0 ]; then
warn "FastAPI is still starting. First run may take longer while .venv and dependencies are prepared."
if [ "$started_server" = true ] && ! kill -0 "$server_pid" 2>/dev/null; then
if probe_server_ready "$probe_url" "$smoke_url"; then
warn "FastAPI is already available at $probe_url. Continuing with the existing process."
started_server=false
server_pid=""
return 0
fi
wait "$server_pid" 2>/dev/null || true
error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error."
fi
sleep 1
attempt=$((attempt + 1))
done
error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s: $base_url"
if probe_server_health "$probe_url"; then
error "FastAPI answered health checks at $probe_url, but the smoke check failed at $smoke_url. The running backend is stale or incompatible."
fi
error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s. Inspect server/logs/app.log."
}
wait_for_server
wait_for_server_ready
prepare_web
info "Starting web frontend..."
cd "$SCRIPT_DIR/web"
./start.sh start

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 971 KiB

View File

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,71 @@
.backend-unavailable {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
background:
radial-gradient(circle at top, rgba(16, 185, 129, 0.16), transparent 32%),
linear-gradient(180deg, #08130f 0%, #0f1f18 100%);
}
.backend-card {
width: min(520px, 100%);
display: grid;
gap: 18px;
padding: 32px 30px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
background: rgba(7, 18, 13, 0.9);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35);
text-align: center;
}
.backend-badge {
width: 72px;
height: 72px;
display: grid;
place-items: center;
margin: 0 auto;
border-radius: 20px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.28));
color: #4ade80;
font-size: 32px;
}
.backend-card h1 {
color: #f8fafc;
font-size: 28px;
font-weight: 800;
}
.backend-card p {
color: rgba(226, 232, 240, 0.8);
font-size: 14px;
line-height: 1.7;
}
.backend-actions {
display: flex;
justify-content: center;
}
.retry-btn {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 10px;
background: #059669;
color: #fff;
font-size: 14px;
font-weight: 760;
box-shadow: 0 16px 30px rgba(5, 150, 105, 0.2);
}
.retry-btn:disabled {
opacity: 0.72;
cursor: wait;
}

View File

@@ -21,9 +21,11 @@
}
.employee-list {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding: 18px 20px;
display: flex;
flex-direction: column;
min-height: 0;
padding: 16px 18px;
overflow: hidden;
}
.employee-detail {
@@ -34,22 +36,26 @@
.status-tabs {
display: flex;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid #edf2f7;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 760;
font-weight: 750;
}
.status-tabs button.active {
color: #0f172a;
color: #059669;
}
.status-tabs button.active::after {
@@ -57,30 +63,51 @@
position: absolute;
left: 0;
right: 0;
bottom: -13px;
bottom: -1px;
height: 3px;
border-radius: 999px;
border-radius: 999px 999px 0 0;
background: #10b981;
}
.status-tabs button small {
min-width: 24px;
min-height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.status-tabs button.active small {
background: rgba(16, 185, 129, 0.12);
color: #059669;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0 10px;
margin-top: 14px;
}
.filter-set {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
gap: 12px;
flex: 1 1 auto;
flex-wrap: wrap;
}
.list-search {
position: relative;
width: 240px;
width: 280px;
max-width: 100%;
}
.list-search .mdi {
@@ -103,23 +130,156 @@
font-size: 13px;
}
.filter-btn,
.create-btn,
.row-action {
min-height: 36px;
border-radius: 8px;
font-size: 13px;
font-weight: 760;
.list-search input::placeholder {
color: #94a3b8;
}
.filter-btn {
.list-search input:focus {
outline: none;
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
.picker-trigger,
.ghost-filter-btn,
.create-btn,
.row-action {
min-height: 38px;
border-radius: 8px;
font-size: 14px;
font-weight: 750;
}
.picker-filter {
position: relative;
}
.picker-trigger {
min-width: 132px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
justify-content: space-between;
gap: 9px;
padding: 0 34px 0 12px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
white-space: nowrap;
}
.picker-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-trigger .mdi {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
pointer-events: none;
}
.picker-trigger:hover,
.picker-filter.open .picker-trigger {
border-color: rgba(16, 185, 129, 0.34);
background: #f6fffb;
color: #0f9f78;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.picker-popover {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 224px;
z-index: 40;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d7e0ea;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
}
.picker-popover header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.picker-popover header strong {
color: #0f172a;
font-size: 15px;
}
.picker-popover header button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
}
.picker-popover header button:hover {
background: #f1f5f9;
color: #0f172a;
}
.picker-option-list {
display: grid;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.picker-option {
min-height: 36px;
display: inline-flex;
align-items: center;
padding: 0 12px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 750;
text-align: left;
}
.picker-option:hover {
border-color: rgba(16, 185, 129, 0.28);
background: #f0fdf4;
color: #047857;
}
.picker-option.active {
border-color: #10b981;
background: #10b981;
color: #fff;
}
.ghost-filter-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 14px;
border: 1px solid rgba(5, 150, 105, 0.16);
background: #f8fffb;
color: #047857;
}
.create-btn {
@@ -137,22 +297,237 @@
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 12px;
margin-top: 10px;
color: #64748b;
font-size: 13px;
}
.active-filter-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.active-filter-chip {
min-height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
font-weight: 800;
}
.table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
overflow-x: auto;
overflow-y: auto;
margin-top: 10px;
border: 1px solid #edf2f7;
border-radius: 12px;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.pager button:hover:not(.active) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
}
.pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, 0.2);
}
.page-nav {
color: #64748b;
}
.page-size {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-width: 112px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.page-size:hover {
border-color: rgba(16, 185, 129, 0.32);
color: #0f9f78;
}
.page-size-wrap {
position: relative;
justify-self: end;
}
.page-size-dropdown {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
z-index: 40;
display: grid;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.page-size-dropdown button {
height: 36px;
display: grid;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
color: #334155;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
padding: 0 20px;
transition: background 120ms ease, color 120ms ease;
}
.page-size-dropdown button:hover {
background: #f0fdf4;
color: #059669;
}
.page-size-dropdown button.active {
background: #059669;
color: #fff;
}
.table-state {
min-height: 220px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
color: #64748b;
text-align: center;
}
.table-state i {
font-size: 26px;
color: #059669;
}
.table-state.error i {
color: #dc2626;
}
.table-state.empty i {
color: #94a3b8;
}
.table-state p {
max-width: 420px;
font-size: 13px;
line-height: 1.6;
}
.state-action {
min-height: 36px;
padding: 0 14px;
border: 1px solid rgba(5, 150, 105, 0.22);
border-radius: 8px;
background: #ecfdf5;
color: #047857;
font-size: 13px;
font-weight: 760;
}
table {
height: 100%;
width: 100%;
min-width: 1320px;
min-width: 1180px;
border-collapse: collapse;
table-layout: fixed;
}
colgroup col.col-employee {
width: 22%;
}
colgroup col.col-employee-no {
width: 11%;
}
colgroup col.col-department {
width: 12%;
}
colgroup col.col-position {
width: 12%;
}
colgroup col.col-grade {
width: 9%;
}
colgroup col.col-role {
width: 16%;
}
colgroup col.col-status {
width: 8%;
}
colgroup col.col-updated {
width: 10%;
}
th,
@@ -161,15 +536,25 @@ td {
border-bottom: 1px solid #edf2f7;
text-align: center;
vertical-align: middle;
color: #334155;
font-size: 12px;
color: #24324a;
font-size: 14px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
background: #f8fafc;
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr {
@@ -185,6 +570,10 @@ tbody tr.spotlight {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.employee-cell {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
@@ -646,10 +1035,29 @@ tbody tr.spotlight {
overflow-x: auto;
}
.list-foot {
grid-template-columns: 1fr;
justify-items: stretch;
}
.list-search {
width: 100%;
}
.picker-filter,
.picker-trigger {
width: 100%;
}
.picker-popover {
width: min(280px, calc(100vw - 64px));
}
.page-size,
.pager {
justify-self: stretch;
}
.hero-stats,
.form-grid,
.role-grid {

View File

@@ -1,4 +1,4 @@
<template>
<template>
<section class="workbench">
<PanelHead
v-if="showHeader"
@@ -9,10 +9,9 @@
<article class="panel assistant-hero">
<div class="assistant-visual" aria-hidden="true">
<div class="assistant-core">
<span class="assistant-glow"></span>
<img class="assistant-image" :src="robotAssistant" alt="" />
</div>
</div>
<div class="assistant-copy">
<span class="assistant-tag">AI 报销助手</span>
@@ -26,7 +25,6 @@
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
/>
<button type="button" class="hero-action" @click="openAssistantWithDraft">开始识别</button>
</div>
<div class="assistant-tools">
@@ -34,10 +32,10 @@
<i class="mdi mdi-upload-outline"></i>
<span>上传票据</span>
</button>
<div class="assistant-skills">
<span v-for="item in assistantSkills" :key="item">{{ item }}</span>
</div>
<button type="button" class="hero-action" @click="openAssistantWithDraft">
<i class="mdi mdi-magnify-scan"></i>
<span>开始识别</span>
</button>
</div>
</div>
</article>
@@ -124,7 +122,7 @@
<script setup>
import { ref } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import robotAssistant from '../../assets/robot-assistant.png'
import robotAssistant from '../../assets/robot-helper.png'
defineProps({
showHeader: { type: Boolean, default: true }
@@ -140,8 +138,6 @@ function openAssistantWithDraft() {
})
}
const assistantSkills = ['识别报销类别', '检查缺少材料', '生成报销草稿']
const todoItems = [
{
title: '业务招待报销建议补参与人员',
@@ -240,9 +236,9 @@ const policyItems = [
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 164px minmax(0, 1fr);
gap: 24px;
padding: 24px 26px;
grid-template-columns: 228px minmax(0, 1fr);
gap: 18px;
padding: 20px 24px 20px 18px;
border: 1px solid rgba(16, 185, 129, 0.12);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
@@ -275,62 +271,65 @@ const policyItems = [
.assistant-visual {
position: relative;
display: grid;
place-items: center;
min-height: 196px;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 0 0 10px 8px;
}
.assistant-core {
position: relative;
z-index: 1;
width: 132px;
height: 132px;
display: grid;
place-items: center;
border-radius: 36px;
background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%);
box-shadow:
0 20px 44px rgba(15, 23, 42, 0.08),
inset 0 -10px 18px rgba(16, 185, 129, 0.10);
color: #0f9f78;
}
.assistant-core::before,
.assistant-core::after {
.assistant-visual::before {
content: "";
position: absolute;
background: #d1fae5;
inset: auto auto -78px -58px;
width: 264px;
height: 228px;
border-radius: 50%;
background: radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.92) 0%, rgba(220, 252, 231, 0.84) 58%, rgba(220, 252, 231, 0) 100%);
pointer-events: none;
}
.assistant-core::before {
top: -12px;
width: 14px;
height: 14px;
.assistant-visual::after {
content: "";
position: absolute;
left: 52px;
bottom: 18px;
width: 132px;
height: 18px;
border-radius: 999px;
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.10);
background: rgba(16, 185, 129, 0.14);
filter: blur(12px);
pointer-events: none;
}
.assistant-core::after {
top: -4px;
width: 2px;
height: 16px;
}
.assistant-core .mdi {
font-size: 68px;
.assistant-glow {
position: absolute;
left: 24px;
bottom: 22px;
width: 176px;
height: 176px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.9) 58%, rgba(236, 253, 245, 0) 100%);
box-shadow: 0 24px 48px rgba(16, 185, 129, 0.12);
pointer-events: none;
}
.assistant-image {
width: 104px;
height: 104px;
position: relative;
z-index: 1;
width: 184px;
max-width: 100%;
height: auto;
object-fit: contain;
filter: drop-shadow(0 12px 20px rgba(15, 23, 42, 0.12));
object-position: left bottom;
filter: drop-shadow(0 22px 28px rgba(15, 23, 42, 0.16));
}
.assistant-copy {
position: relative;
z-index: 1;
display: grid;
gap: 14px;
gap: 10px;
align-content: center;
}
@@ -340,15 +339,16 @@ const policyItems = [
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.10);
color: #0f9f78;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(59, 130, 246, 0.12));
color: #0f766e;
font-size: 12px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.assistant-copy h3 {
color: #0f172a;
font-size: 28px;
font-size: 26px;
line-height: 1.25;
font-weight: 800;
}
@@ -356,16 +356,15 @@ const policyItems = [
.assistant-copy p {
max-width: 760px;
color: #5b6b83;
font-size: 15px;
line-height: 1.7;
font-size: 14px;
line-height: 1.6;
}
.assistant-input {
display: flex;
align-items: center;
gap: 14px;
min-height: 52px;
padding: 6px 8px 6px 14px;
min-height: 48px;
padding: 4px 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
@@ -375,9 +374,9 @@ const policyItems = [
.assistant-input textarea {
min-width: 0;
flex: 1;
height: 24px;
min-height: 24px;
max-height: 24px;
height: 22px;
min-height: 22px;
max-height: 22px;
resize: none;
border: 0;
padding: 1px 0;
@@ -406,8 +405,12 @@ const policyItems = [
}
.hero-action {
height: 36px;
padding: 0 20px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
@@ -417,10 +420,26 @@ const policyItems = [
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
}
.hero-action .mdi,
.ghost-action .mdi {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.hero-action span,
.ghost-action span {
display: inline-flex;
align-items: center;
line-height: 1;
}
.assistant-tools {
display: flex;
align-items: center;
gap: 16px;
gap: 10px;
flex-wrap: wrap;
}
@@ -428,39 +447,22 @@ const policyItems = [
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(16, 185, 129, 0.34);
border: 1px solid rgba(15, 118, 110, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
color: #0f9f78;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 250, 247, 0.88));
color: #0f766e;
font-size: 14px;
font-weight: 700;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 6px 14px rgba(15, 118, 110, 0.06);
}
.assistant-skills {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
color: #22a06b;
font-size: 14px;
font-weight: 700;
}
.assistant-skills span {
position: relative;
}
.assistant-skills span + span::before {
content: "";
position: absolute;
left: -7px;
top: 50%;
width: 1px;
height: 14px;
background: rgba(16, 185, 129, 0.22);
transform: translateY(-50%);
.ghost-action .mdi {
color: #10b981;
}
.workbench-grid {
@@ -723,10 +725,28 @@ const policyItems = [
@media (max-width: 1080px) {
.assistant-hero {
grid-template-columns: 1fr;
gap: 8px;
}
.assistant-visual {
justify-content: flex-start;
min-height: 188px;
justify-content: center;
padding: 0 0 8px;
}
.assistant-visual::before,
.assistant-visual::after,
.assistant-glow {
left: 50%;
transform: translateX(-50%);
}
.assistant-visual::before {
inset: auto auto -82px 50%;
}
.assistant-image {
width: 176px;
}
.workbench-grid {
@@ -747,6 +767,19 @@ const policyItems = [
padding: 14px;
}
.assistant-visual {
min-height: 160px;
}
.assistant-glow {
width: 148px;
height: 148px;
}
.assistant-image {
width: 150px;
}
.assistant-input textarea {
height: 40px;
min-height: 40px;
@@ -800,3 +833,5 @@ const policyItems = [
}
}
</style>

View File

@@ -28,14 +28,23 @@
</button>
</nav>
<button class="rail-user" type="button" aria-label="打开用户菜单">
<span class="user-avatar"></span>
<span class="user-copy">
<strong>张晓明</strong>
<span>财务管理员</span>
</span>
<i class="mdi mdi-chevron-down"></i>
<div class="rail-user">
<div class="user-menu" role="menu" aria-label="用户菜单">
<button class="user-menu-item" type="button" @click="emit('logout')">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
<div class="user-summary" tabindex="0" aria-label="用户信息">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.role }}</span>
</span>
<i class="mdi mdi-chevron-up"></i>
</div>
</div>
</aside>
</template>
@@ -44,17 +53,25 @@ import { computed } from 'vue'
const props = defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true }
activeView: { type: String, required: true },
currentUser: {
type: Object,
default: () => ({
name: '系统管理员',
role: '财务管理员',
avatar: '管'
})
}
})
const emit = defineEmits(['navigate', 'openChat'])
const emit = defineEmits(['navigate', 'openChat', 'logout'])
const sidebarMeta = {
overview: { label: '总览' },
workbench: { label: '个人工作台' },
requests: { label: '差旅申请/报销' },
approval: { label: '审批中心', badge: '12' },
chat: { label: 'AI助手' },
chat: { label: 'AI 助手' },
policies: { label: '知识管理' },
audit: { label: '技能中心' },
employees: { label: '员工管理' }
@@ -67,6 +84,12 @@ const decoratedNavItems = computed(() =>
badge: sidebarMeta[item.id]?.badge
}))
)
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
role: props.currentUser?.role || '财务管理员',
avatar: props.currentUser?.avatar || '管'
}))
</script>
<style scoped>
@@ -77,10 +100,10 @@ const decoratedNavItems = computed(() =>
display: grid;
grid-template-rows: auto 1fr auto;
background:
linear-gradient(180deg, rgba(255,255,255,.98), rgba(248,251,250,.96)),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)),
#fff;
border-right: 1px solid #dbe4ee;
box-shadow: 1px 0 0 rgba(15,23,42,.02);
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
z-index: 20;
}
@@ -164,13 +187,13 @@ const decoratedNavItems = computed(() =>
}
.nav-btn:hover {
background: rgba(16,185,129,.07);
background: rgba(16, 185, 129, 0.07);
color: #0f9f78;
}
.nav-btn.active {
background: linear-gradient(90deg, rgba(16,185,129,.16), rgba(16,185,129,.08));
border-color: rgba(16,185,129,.10);
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
border-color: rgba(16, 185, 129, 0.1);
color: #059669;
box-shadow: inset 3px 0 0 #10b981;
}
@@ -221,25 +244,31 @@ const decoratedNavItems = computed(() =>
}
.rail-user {
position: relative;
min-width: 0;
min-height: 74px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 22px;
align-items: center;
gap: 10px;
min-height: 78px;
margin: 0;
padding: 16px 20px 18px;
border: 0;
border-top: 1px solid transparent;
background: transparent;
color: #64748b;
text-align: left;
transition: background 180ms var(--ease), border-color 180ms var(--ease);
border-top: 1px solid #edf2f7;
}
.rail-user:hover {
border-top-color: #e2e8f0;
background: rgba(255,255,255,.72);
.user-summary {
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 18px;
align-items: center;
gap: 10px;
padding: 4px 0 0;
color: #64748b;
border-radius: 12px;
outline: none;
transition: background 180ms var(--ease);
}
.rail-user:hover .user-summary,
.rail-user:focus-within .user-summary {
background: rgba(255, 255, 255, 0.72);
}
.user-avatar {
@@ -250,7 +279,7 @@ const decoratedNavItems = computed(() =>
border: 2px solid #fff;
border-radius: 999px;
background: linear-gradient(135deg, #0f9f78, #65d6b4);
box-shadow: 0 6px 14px rgba(15,159,120,.18);
box-shadow: 0 6px 14px rgba(15, 159, 120, 0.18);
color: #fff;
font-size: 14px;
font-weight: 800;
@@ -281,10 +310,88 @@ const decoratedNavItems = computed(() =>
text-overflow: ellipsis;
}
.rail-user .mdi {
.user-summary .mdi {
justify-self: end;
color: #718096;
color: #94a3b8;
font-size: 13px;
line-height: 1;
transition: transform 180ms var(--ease), color 180ms var(--ease);
}
.rail-user:hover .user-summary .mdi,
.rail-user:focus-within .user-summary .mdi {
color: #0f9f78;
transform: translateY(-1px);
}
.user-menu {
position: absolute;
right: 20px;
bottom: calc(100% - 6px);
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 12px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 16px 32px rgba(15, 23, 42, 0.10),
0 2px 8px rgba(15, 23, 42, 0.04);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition:
opacity 180ms var(--ease),
transform 180ms var(--ease),
box-shadow 180ms var(--ease);
z-index: 4;
}
.user-menu::after {
content: "";
position: absolute;
right: 18px;
bottom: -6px;
width: 12px;
height: 12px;
border-right: 1px solid rgba(226, 232, 240, 0.96);
border-bottom: 1px solid rgba(226, 232, 240, 0.96);
background: rgba(255, 255, 255, 0.98);
transform: rotate(45deg);
}
.rail-user:hover .user-menu,
.rail-user:focus-within .user-menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.user-menu-item {
width: 100%;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 9px;
background: transparent;
color: #dc2626;
font-size: 13px;
font-weight: 700;
text-align: left;
transition: background 180ms var(--ease), color 180ms var(--ease);
}
.user-menu-item:hover {
background: #fff5f5;
color: #b91c1c;
}
.user-menu-item .mdi {
font-size: 15px;
line-height: 1;
}
@media (max-width: 980px) {

View File

@@ -117,6 +117,16 @@
</div>
</div>
</template>
<template v-else-if="isEmployees">
<div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
</template>
</div>
</header>
</template>
@@ -130,6 +140,10 @@ const props = defineProps({
activeView: { type: String, default: '' },
ranges: { type: Array, default: () => [] },
activeRange: { type: String, default: '' },
employeeSummary: {
type: Object,
default: () => null
},
customRange: {
type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
@@ -150,6 +164,7 @@ const isOverview = computed(() => props.activeView === 'overview')
const isRequests = computed(() => props.activeView === 'requests')
const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees')
const requestKpis = [
{ label: '全部单据', value: 30, delta: '+8', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' },
@@ -178,6 +193,51 @@ const knowledgeKpis = [
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
]
const employeeKpis = computed(() => {
const summary = props.employeeSummary ?? {}
const total = Number(summary.total ?? 0)
const active = Number(summary.active ?? 0)
const onboarding = Number(summary.onboarding ?? 0)
const disabled = Number(summary.disabled ?? 0)
const followUp = Number(summary.followUp ?? 0)
const departments = Number(summary.departments ?? 0)
return [
{
label: '员工总数',
value: total,
unit: '人',
meta: `覆盖 ${departments} 个部门`,
trend: 'up',
color: '#10b981'
},
{
label: '在职账号',
value: active,
unit: '人',
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
trend: 'up',
color: '#3b82f6'
},
{
label: '待处理状态',
value: onboarding + disabled,
unit: '人',
meta: `试用 ${onboarding} / 停用 ${disabled}`,
trend: onboarding + disabled > 0 ? 'down' : 'up',
color: '#f59e0b'
},
{
label: '同步待处理',
value: followUp,
unit: '人',
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
trend: followUp > 0 ? 'down' : 'up',
color: '#8b5cf6'
}
]
})
const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end)

View File

@@ -0,0 +1,47 @@
import { ref } from 'vue'
import { fetchBackendHealth } from '../services/system.js'
const backendHealthy = ref(true)
const backendChecking = ref(false)
const backendError = ref('')
let lastCheckedAt = 0
export async function checkBackendHealth(options = {}) {
const force = Boolean(options.force)
const now = Date.now()
if (!force && now - lastCheckedAt < 5000) {
return backendHealthy.value
}
backendChecking.value = true
try {
const payload = await fetchBackendHealth()
const ok = payload?.status === 'ok'
backendHealthy.value = ok
backendError.value = ok
? ''
: payload?.database?.error || '后端服务尚未准备完成。'
lastCheckedAt = now
return ok
} catch (error) {
backendHealthy.value = false
backendError.value = error?.message || '无法连接后端服务。'
lastCheckedAt = now
return false
} finally {
backendChecking.value = false
}
}
export function useBackendHealth() {
return {
backendHealthy,
backendChecking,
backendError,
checkBackendHealth
}
}

View File

@@ -9,6 +9,21 @@ import {
import { useToast } from './useToast.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员'
const DEFAULT_USER_ROLE = '财务管理员'
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
const authIdleTimeoutMs =
Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0
? authIdleTimeoutMinutes * 60 * 1000
: 30 * 60 * 1000
let sessionRouter = null
let sessionTimeoutHandle = 0
let sessionMonitoringInstalled = false
let lastActivityWriteAt = 0
function readClientBootstrapState() {
const env = import.meta.env
@@ -51,17 +66,176 @@ function readAuthState() {
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function persistAuthState(value) {
function readStoredUsername() {
if (typeof window === 'undefined') {
return ''
}
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function buildCurrentUser(username = '') {
const normalized = String(username || '').trim()
const name = normalized || DEFAULT_USER_NAME
return {
name,
role: DEFAULT_USER_ROLE,
avatar: name.slice(0, 1).toUpperCase()
}
}
function isSessionExpired(now = Date.now()) {
if (!readAuthState()) {
return false
}
const lastActivityAt = readLastActivityAt()
if (!lastActivityAt) {
return true
}
return now - lastActivityAt > authIdleTimeoutMs
}
function persistAuthState(value, username = '') {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(username || '').trim())
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
}
function clearSessionTimeout() {
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
return
}
window.clearTimeout(sessionTimeoutHandle)
sessionTimeoutHandle = 0
}
function redirectToLogin() {
if (sessionRouter?.currentRoute?.value?.name === 'login') {
return
}
if (sessionRouter) {
sessionRouter.replace({ name: 'login' })
return
}
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.assign('/login')
}
}
function scheduleSessionTimeout() {
clearSessionTimeout()
if (typeof window === 'undefined' || !readAuthState()) {
return
}
const lastActivityAt = readLastActivityAt()
if (!lastActivityAt) {
return
}
const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
if (remaining <= 0) {
logout('timeout', { notify: true })
return
}
sessionTimeoutHandle = window.setTimeout(() => {
logout('timeout', { notify: true })
}, remaining)
}
function touchAuthActivity(force = false) {
if (typeof window === 'undefined' || !readAuthState()) {
return
}
const now = Date.now()
if (!force && now - lastActivityWriteAt < 1000) {
scheduleSessionTimeout()
return
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
lastActivityWriteAt = now
scheduleSessionTimeout()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function installSessionMonitoring() {
if (sessionMonitoringInstalled || typeof window === 'undefined') {
return
}
sessionMonitoringInstalled = true
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
}
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
if (!readAuthState()) {
loggedIn.value = false
currentUser.value = buildCurrentUser('')
clearSessionTimeout()
return false
}
if (isSessionExpired()) {
logout('timeout', { notify: shouldNotify, redirect: false })
return false
}
loggedIn.value = true
currentUser.value = buildCurrentUser(readStoredUsername())
scheduleSessionTimeout()
return true
}
export function installSessionNavigation(router) {
sessionRouter = router
installSessionMonitoring()
if (readAuthState() && !isSessionExpired()) {
scheduleSessionTimeout()
}
}
const bootstrapState = ref(readClientBootstrapState())
@@ -75,7 +249,12 @@ const runtimeTestMessage = ref('')
const databaseTestMessage = ref('')
const loginSubmitting = ref(false)
const loginError = ref('')
const loggedIn = ref(readAuthState())
const loggedIn = ref(readAuthState() && !isSessionExpired())
const currentUser = ref(buildCurrentUser(readStoredUsername()))
if (!loggedIn.value && readAuthState()) {
persistAuthState(false)
}
const { toast } = useToast()
@@ -91,8 +270,7 @@ function applyBootstrapState(state) {
bootstrapState.value = state
if (!state.initialized) {
loggedIn.value = false
persistAuthState(false)
logout('reset', { redirect: false })
}
}
@@ -110,6 +288,7 @@ function resetFromClientEnv() {
applyBootstrapState(readClientBootstrapState())
clearSetupRuntimeState()
loginError.value = ''
currentUser.value = buildCurrentUser(readStoredUsername())
}
async function handleSetupSubmit(payload) {
@@ -209,11 +388,12 @@ async function handleLogin(credentials) {
})
loggedIn.value = true
persistAuthState(true)
persistAuthState(true, credentials.username)
currentUser.value = buildCurrentUser(credentials.username)
touchAuthActivity(true)
return true
} catch (error) {
loggedIn.value = false
persistAuthState(false)
logout('invalid', { redirect: false })
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
toast(loginError.value)
return false
@@ -222,9 +402,22 @@ async function handleLogin(credentials) {
}
}
function logout() {
function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildCurrentUser('')
clearSessionTimeout()
if (notify) {
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
}
if (redirect) {
redirectToLogin()
}
}
function handleRecoverPassword() {
@@ -236,6 +429,9 @@ function handleSsoLogin() {
}
function resolveEntryRoute() {
loggedIn.value = syncAuthSession()
currentUser.value = buildCurrentUser(readStoredUsername())
if (!isInitialized.value) {
return { name: 'setup' }
}
@@ -251,6 +447,7 @@ export function useSystemState() {
return {
bootstrapState,
companyProfile,
currentUser,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
@@ -273,6 +470,7 @@ export function useSystemState() {
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
setupSubmitting,
syncAuthSession
}
}

View File

@@ -5,9 +5,12 @@ import Aura from '@primevue/themes/aura'
import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router/index.js'
import { installSessionNavigation } from './composables/useSystemState.js'
const app = createApp(App)
installSessionNavigation(router)
app.use(MotionPlugin)
app.use(router)
app.use(PrimeVue, {

View File

@@ -1,8 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router'
import { checkBackendHealth } from '../composables/useBackendHealth.js'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import AppShellRouteView from '../views/AppShellRouteView.vue'
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
@@ -39,6 +41,11 @@ const router = createRouter({
name: 'login',
component: LoginRouteView
},
{
path: '/backend-unavailable',
name: 'backend-unavailable',
component: BackendUnavailableRouteView
},
{
path: '/app',
redirect: { name: 'app-overview' }
@@ -73,7 +80,8 @@ const router = createRouter({
})
router.beforeEach((to) => {
const { isInitialized, loggedIn, resolveEntryRoute } = useSystemState()
const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
if (!isInitialized.value) {
if (to.name !== 'setup') {
@@ -87,7 +95,21 @@ router.beforeEach((to) => {
return resolveEntryRoute()
}
if (!loggedIn.value && to.meta.requiresAuth) {
if (authActive && to.meta.requiresAuth) {
return checkBackendHealth().then((ok) => {
if (!ok && to.name !== 'backend-unavailable') {
return { name: 'backend-unavailable' }
}
if (ok && to.name === 'backend-unavailable') {
return resolveEntryRoute()
}
return true
})
}
if (!authActive && to.meta.requiresAuth) {
return {
name: 'login',
query: {
@@ -96,7 +118,7 @@ router.beforeEach((to) => {
}
}
if (loggedIn.value && to.name === 'login') {
if (authActive && to.name === 'login') {
return resolveEntryRoute()
}

39
web/src/services/api.js Normal file
View File

@@ -0,0 +1,39 @@
const API_BASE = String(import.meta.env.VITE_API_BASE_URL || '/api/v1').replace(/\/$/, '')
function buildUrl(path) {
if (!path.startsWith('/')) {
return `${API_BASE}/${path}`
}
return `${API_BASE}${path}`
}
export async function apiRequest(path, options = {}) {
let response
try {
response = await fetch(buildUrl(path), {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
})
} catch {
throw new Error('无法连接后端员工服务,请确认 FastAPI 已启动。')
}
let payload = null
try {
payload = await response.json()
} catch {
payload = null
}
if (!response.ok) {
throw new Error(payload?.detail || '接口请求失败,请稍后重试。')
}
return payload
}

View File

@@ -0,0 +1,24 @@
import { apiRequest } from './api.js'
export function fetchEmployees(params = {}) {
const search = new URLSearchParams()
if (params.status && params.status !== '全部员工') {
search.set('status', params.status)
}
if (params.keyword) {
search.set('keyword', params.keyword)
}
const query = search.toString()
return apiRequest(`/employees${query ? `?${query}` : ''}`)
}
export function fetchEmployeeMeta() {
return apiRequest('/employees/meta')
}
export function fetchEmployeeDetail(employeeId) {
return apiRequest(`/employees/${employeeId}`)
}

View File

@@ -0,0 +1,5 @@
import { apiRequest } from './api.js'
export function fetchBackendHealth() {
return apiRequest('/health')
}

View File

@@ -3,8 +3,10 @@
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
@logout="handleLogout"
/>
<main
@@ -26,6 +28,7 @@
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@@ -105,7 +108,7 @@
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
<EmployeeManagementView v-else @overview-change="employeeSummary = $event" />
</section>
</main>
@@ -121,6 +124,8 @@
</template>
<script setup>
import { ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
@@ -136,6 +141,9 @@ import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
const employeeSummary = ref(null)
const {
activeCase,
@@ -173,4 +181,10 @@ const {
travelPrompts,
uploadedFiles
} = useAppShell()
const { currentUser, logout } = useSystemState()
function handleLogout() {
logout('manual')
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<section class="backend-unavailable">
<div class="backend-card">
<div class="backend-badge">
<i class="mdi mdi-server-network-off"></i>
</div>
<h1>后端服务不可用</h1>
<p>{{ statusMessage }}</p>
<div class="backend-actions">
<button
type="button"
class="retry-btn"
:disabled="retrying || backendChecking"
@click="retry"
>
<i class="mdi" :class="retrying || backendChecking ? 'mdi-loading mdi-spin' : 'mdi-refresh'"></i>
<span>{{ retrying || backendChecking ? '重新检测中...' : '重新检测后端' }}</span>
</button>
</div>
</div>
</section>
</template>
<script src="./scripts/BackendUnavailableRouteView.js"></script>
<style scoped src="../assets/styles/views/backend-unavailable-view.css"></style>

View File

@@ -9,7 +9,10 @@
<div class="hero-copy">
<div class="hero-tag">{{ selectedEmployee.employeeNo }}</div>
<h2>{{ selectedEmployee.name }}</h2>
<p>{{ selectedEmployee.department }} / {{ selectedEmployee.position }} / {{ selectedEmployee.grade }}</p>
<p>
{{ selectedEmployee.department }} / {{ selectedEmployee.position }} /
{{ selectedEmployee.grade }}
</p>
</div>
</div>
@@ -218,12 +221,13 @@
<nav class="status-tabs" aria-label="员工状态筛选">
<button
v-for="tab in tabs"
:key="tab"
:key="tab.label"
type="button"
:class="{ active: activeTab === tab }"
@click="activeTab = tab"
:class="{ active: activeTab === tab.label }"
@click="activeTab = tab.label"
>
{{ tab }}
<span>{{ tab.label }}</span>
<small>{{ tab.count }}</small>
</button>
</nav>
@@ -231,25 +235,204 @@
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input type="search" placeholder="搜索员工姓名、工号、部门或岗位..." />
<input
v-model="searchKeyword"
type="search"
placeholder="搜索姓名、工号、部门、岗位"
/>
</div>
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
<span>{{ filter }}</span>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'department'"
aria-haspopup="dialog"
@click="toggleFilterPopover('department')"
>
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'department'"
class="picker-popover"
role="dialog"
aria-label="选择组织部门"
>
<header>
<strong>选择组织部门</strong>
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedDepartment }"
@click="selectFilter('department', '')"
>
全部部门
</button>
<button
v-for="department in departmentOptions"
:key="department"
type="button"
class="picker-option"
:class="{ active: selectedDepartment === department }"
@click="selectFilter('department', department)"
>
{{ department }}
</button>
</div>
</div>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'grade'"
aria-haspopup="dialog"
@click="toggleFilterPopover('grade')"
>
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'grade'"
class="picker-popover"
role="dialog"
aria-label="选择职级"
>
<header>
<strong>选择职级</strong>
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedGrade }"
@click="selectFilter('grade', '')"
>
全部职级
</button>
<button
v-for="grade in gradeOptions"
:key="grade"
type="button"
class="picker-option"
:class="{ active: selectedGrade === grade }"
@click="selectFilter('grade', grade)"
>
{{ grade }}
</button>
</div>
</div>
</div>
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
<button
class="picker-trigger"
type="button"
:aria-expanded="activeFilterPopover === 'role'"
aria-haspopup="dialog"
@click="toggleFilterPopover('role')"
>
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="activeFilterPopover === 'role'"
class="picker-popover"
role="dialog"
aria-label="选择系统角色"
>
<header>
<strong>选择系统角色</strong>
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="picker-option-list">
<button
type="button"
class="picker-option"
:class="{ active: !selectedRole }"
@click="selectFilter('role', '')"
>
全部角色
</button>
<button
v-for="role in roleFilterOptions"
:key="role"
type="button"
class="picker-option"
:class="{ active: selectedRole === role }"
@click="selectFilter('role', role)"
>
{{ role }}
</button>
</div>
</div>
</div>
</div>
<div class="toolbar-actions">
<button v-if="hasActiveFilters" class="ghost-filter-btn" type="button" @click="resetFilters">
<i class="mdi mdi-filter-remove-outline"></i>
<span>清空筛选</span>
</button>
<button class="create-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新增员工</span>
</button>
</div>
</div>
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意员工行可进入基础信息与角色权限编辑界面</p>
<p class="hint">
<i class="mdi mdi-information-outline"></i>
点击任意员工行可进入基础信息与角色权限编辑界面
</p>
<div v-if="activeFilterTokens.length" class="active-filter-strip">
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
{{ token }}
</span>
</div>
<div class="table-wrap">
<table>
<div v-if="loading" class="table-state">
<i class="mdi mdi-loading mdi-spin"></i>
<p>正在加载员工数据...</p>
</div>
<div v-else-if="errorMessage" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<p>{{ errorMessage }}</p>
<button type="button" class="state-action" @click="loadEmployees">重新加载</button>
</div>
<div v-else-if="!visibleEmployees.length" class="table-state empty">
<i class="mdi mdi-account-search-outline"></i>
<p>没有匹配的员工数据</p>
</div>
<table v-else>
<colgroup>
<col class="col-employee">
<col class="col-employee-no">
<col class="col-department">
<col class="col-position">
<col class="col-grade">
<col class="col-role">
<col class="col-status">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>员工</th>
@@ -257,8 +440,6 @@
<th>部门</th>
<th>岗位</th>
<th>职级</th>
<th>直属上级</th>
<th>财务归口</th>
<th>系统角色</th>
<th>状态</th>
<th>最近更新</th>
@@ -284,20 +465,81 @@
<td>{{ employee.department }}</td>
<td>{{ employee.position }}</td>
<td><span class="level-pill">{{ employee.grade }}</span></td>
<td>{{ employee.manager }}</td>
<td>{{ employee.financeOwner }}</td>
<td>
<div class="role-stack">
<span v-for="role in employee.roles.slice(0, 2)" :key="role" class="role-pill">{{ role }}</span>
<span v-if="employee.roles.length > 2" class="more-pill">+{{ employee.roles.length - 2 }}</span>
<span
v-for="role in employee.roles.slice(0, 2)"
:key="role"
class="role-pill"
>
{{ role }}
</span>
<span v-if="employee.roles.length > 2" class="more-pill">
+{{ employee.roles.length - 2 }}
</span>
</div>
</td>
<td><span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span></td>
<td>
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td>
<td>{{ employee.updatedAt }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
<span class="page-summary"> {{ totalCount }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button
class="page-nav"
type="button"
:disabled="currentPage === 1"
aria-label="上一页"
@click="currentPage--"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button
class="page-nav"
type="button"
:disabled="currentPage === totalPages"
aria-label="下一页"
@click="currentPage++"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<div class="page-size-wrap">
<button class="page-size" type="button" @click="togglePageSizeOpen">
{{ pageSize }} / <i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
<button
v-for="size in pageSizes"
:key="size"
type="button"
role="option"
:aria-selected="pageSize === size"
:class="{ active: pageSize === size }"
@click="changePageSize(size)"
>
{{ size }} /
</button>
</div>
</div>
</footer>
</article>
</Transition>
</section>

View File

@@ -0,0 +1,39 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useBackendHealth } from '../../composables/useBackendHealth.js'
import { useSystemState } from '../../composables/useSystemState.js'
export default {
name: 'BackendUnavailableRouteView',
setup() {
const router = useRouter()
const { backendChecking, backendError, checkBackendHealth } = useBackendHealth()
const { loggedIn, resolveEntryRoute } = useSystemState()
const retrying = ref(false)
const statusMessage = computed(() => {
return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。'
})
async function retry() {
retrying.value = true
try {
const ok = await checkBackendHealth({ force: true })
if (ok) {
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' })
}
} finally {
retrying.value = false
}
}
return {
backendChecking,
retrying,
statusMessage,
retry
}
}
}

View File

@@ -1,202 +1,373 @@
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchEmployeeMeta, fetchEmployees } from '../../services/employees.js'
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
const FALLBACK_ROLE_OPTIONS = [
{
id: 'manager',
code: 'manager',
label: '管理员',
desc: '可以维护员工档案、组织结构和角色权限。'
},
{
id: 'finance',
code: 'finance',
label: '财务人员',
desc: '可以处理复核、查看财务知识与风险校验结果。'
},
{
id: 'approver',
code: 'approver',
label: '审批负责人',
desc: '可以处理审批中心中的待审单据。'
},
{
id: 'executive',
code: 'executive',
label: '高级管理人员',
desc: '可以查看跨部门数据看板与关键审批结果。'
},
{
id: 'auditor',
code: 'auditor',
label: '审计观察员',
desc: '可以查看变更记录和权限调整历史。'
},
{
id: 'user',
code: 'user',
label: '使用者',
desc: '可以发起报销、查看个人单据和使用 AI 助手。'
}
]
function matchKeyword(employee, keyword) {
if (!keyword) {
return true
}
const haystack = [
employee.name,
employee.employeeNo,
employee.department,
employee.position,
employee.email,
employee.manager,
employee.financeOwner,
employee.syncState,
...(employee.roles || [])
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort((left, right) => {
return String(left).localeCompare(String(right), 'zh-CN')
})
}
function resolveRoleOptions(metaRoles, employees) {
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
const existingLabels = new Set(options.map((item) => item.label))
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
(label) => !existingLabels.has(label)
)
return [
...options,
...unknownRoles.map((label) => ({
id: label,
code: label,
label,
desc: '该角色来自当前员工数据。'
}))
]
}
function buildStatusTabs(employees) {
return DEFAULT_STATUS_TABS.map((label) => ({
label,
count:
label === '全部员工'
? employees.length
: employees.filter((item) => item.status === label).length
}))
}
function buildEmployeeSummary(employees) {
return {
total: employees.length,
active: employees.filter((item) => item.status === '在职').length,
onboarding: employees.filter((item) => item.status === '试用中').length,
disabled: employees.filter((item) => item.status === '停用').length,
followUp: employees.filter((item) => item.syncState !== '已同步').length,
departments: uniqueSorted(employees.map((item) => item.department)).length
}
}
export default {
name: 'EmployeeManagementView' ,
setup(props, { emit }) {
const tabs = ['全部员工', '在职', '试用中', '停用']
const filters = ['按部门筛选', '按职级筛选', '按系统角色筛选']
const activeTab = ref(tabs[0])
name: 'EmployeeManagementView',
emits: ['overview-change'],
setup(_, { emit }) {
const activeTab = ref(DEFAULT_STATUS_TABS[0])
const selectedEmployee = ref(null)
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
const employees = ref([])
const searchKeyword = ref('')
const selectedDepartment = ref('')
const selectedGrade = ref('')
const selectedRole = ref('')
const activeFilterPopover = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const roleOptions = [
{ id: 'user', label: '使用者', desc: '可以发起报销、查看个人单据和使用 AI 助手。' },
{ id: 'finance', label: '财务人员', desc: '可以处理复核、查看财务知识与风险校验结果。' },
{ id: 'manager', label: '管理员', desc: '可以维护员工档案、组织结构和角色权限。' },
{ id: 'executive', label: '高级管理人员', desc: '可以查看跨部门数据看板与关键审批结果。' },
{ id: 'approver', label: '审批负责人', desc: '可以处理审批中心中的待审单据。' },
{ id: 'auditor', label: '审计观察员', desc: '可以查看变更记录和权限调整历史。' }
]
const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
const employees = [
{
id: 'EMP-001',
avatar: '张',
name: '张晓晴',
employeeNo: 'E10234',
department: '财务共享中心',
position: '费用运营经理',
grade: 'M3',
manager: '李文静',
financeOwner: '华东财务组',
roles: ['管理员', '财务人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '32',
birthDate: '1994-08-12',
email: 'xiaoqing.zhang@xfinance.com',
phone: '138 1023 4567',
joinDate: '2021-03-15',
location: '上海',
costCenter: 'CC-2108',
updatedAt: '2026-05-06 10:24',
lastSync: '2026-05-06 10:24',
syncState: '待生效',
spotlight: true,
permissions: [
'可查看审批中心全部待审单据',
'可配置员工角色与部门归属',
'可查看知识管理与技能中心配置'
],
history: [
{ action: '新增“审批负责人”角色', owner: '系统管理员 · 王敏', time: '今天 10:24' },
{ action: '调整财务归口为华东财务组', owner: '组织管理员 · 陈硕', time: '昨天 18:10' }
]
},
{
id: 'EMP-002',
avatar: '李',
name: '李文静',
employeeNo: 'E10018',
department: '总经办',
position: '高级财务总监',
grade: 'D2',
manager: 'CEO',
financeOwner: '集团财务',
roles: ['高级管理人员', '审批负责人'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '39',
birthDate: '1987-03-26',
email: 'wenjing.li@xfinance.com',
phone: '139 0018 7688',
joinDate: '2018-06-21',
location: '上海',
costCenter: 'CC-1001',
updatedAt: '2026-05-05 16:20',
lastSync: '2026-05-05 16:20',
syncState: '已同步',
permissions: [
'可查看集团层面的审批看板',
'可处理高金额报销的最终审批',
'可查看部门预算执行情况'
],
history: [
{ action: '更新高级管理人员可见范围', owner: '系统管理员 · 王敏', time: '05-05 16:20' }
]
},
{
id: 'EMP-003',
avatar: '王',
name: '王敏',
employeeNo: 'E10867',
department: '人力与组织',
position: '组织发展主管',
grade: 'P6',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['管理员', '审计观察员'],
status: '在职',
statusTone: 'success',
gender: '女',
age: '30',
birthDate: '1996-11-05',
email: 'min.wang@xfinance.com',
phone: '136 8867 1200',
joinDate: '2022-08-08',
location: '杭州',
costCenter: 'CC-3206',
updatedAt: '2026-05-05 09:18',
lastSync: '2026-05-05 09:18',
syncState: '已同步',
permissions: [
'可维护组织结构与岗位映射',
'可查看员工角色分配历史'
],
history: [
{ action: '新增“审计观察员”角色', owner: '系统管理员 · 张晓晴', time: '05-05 09:18' }
]
},
{
id: 'EMP-004',
avatar: '陈',
name: '陈嘉',
employeeNo: 'E11602',
department: '销售运营',
position: '区域销售经理',
grade: 'M2',
manager: '李文静',
financeOwner: '华南财务组',
roles: ['使用者', '审批负责人'],
status: '试用中',
statusTone: 'warning',
gender: '男',
age: '29',
birthDate: '1997-02-18',
email: 'jia.chen@xfinance.com',
phone: '137 1602 9901',
joinDate: '2026-03-01',
location: '深圳',
costCenter: 'CC-4102',
updatedAt: '2026-05-04 14:12',
lastSync: '2026-05-04 14:12',
syncState: '已同步',
permissions: [
'可发起个人报销与出差申请',
'可处理本部门基础审批'
],
history: [
{ action: '完成试用期角色初始化', owner: '组织管理员 · 王敏', time: '05-04 14:12' }
]
},
{
id: 'EMP-005',
avatar: '赵',
name: '赵雨辰',
employeeNo: 'E11991',
department: '研发中心',
position: '产品经理',
grade: 'P5',
manager: '陈嘉',
financeOwner: '总部财务',
roles: ['使用者'],
status: '停用',
statusTone: 'neutral',
gender: '男',
age: '27',
birthDate: '1999-06-09',
email: 'yuchen.zhao@xfinance.com',
phone: '135 1991 3300',
joinDate: '2023-11-18',
location: '北京',
costCenter: 'CC-5209',
updatedAt: '2026-05-01 11:06',
lastSync: '2026-05-01 11:06',
syncState: '已同步',
permissions: [
'当前账号停用,仅保留历史单据查看记录'
],
history: [
{ action: '账号状态变更为停用', owner: '系统管理员 · 王敏', time: '05-01 11:06' }
]
}
]
const departmentOptions = computed(() =>
uniqueSorted(employees.value.map((item) => item.department))
)
const gradeOptions = computed(() => uniqueSorted(employees.value.map((item) => item.grade)))
const roleFilterOptions = computed(() =>
uniqueSorted(
roleOptions.value.map((item) => item.label).concat(
employees.value.flatMap((item) => item.roles || [])
)
)
)
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return employees.value.filter((item) => {
const matchesStatus =
activeTab.value === '全部员工' ? true : item.status === activeTab.value
const matchesDepartment = selectedDepartment.value
? item.department === selectedDepartment.value
: true
const matchesGrade = selectedGrade.value ? item.grade === selectedGrade.value : true
const matchesRole = selectedRole.value
? (item.roles || []).includes(selectedRole.value)
: true
return (
matchesStatus &&
matchesDepartment &&
matchesGrade &&
matchesRole &&
matchKeyword(item, keyword)
)
})
})
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleEmployees = computed(() => {
if (activeTab.value === '全部员工') return employees
return employees.filter((item) => item.status === activeTab.value)
const start = (currentPage.value - 1) * pageSize.value
return filteredEmployees.value.slice(start, start + pageSize.value)
})
const activeFilterTokens = computed(() => {
const tokens = []
if (selectedDepartment.value) {
tokens.push(`部门:${selectedDepartment.value}`)
}
if (selectedGrade.value) {
tokens.push(`职级:${selectedGrade.value}`)
}
if (selectedRole.value) {
tokens.push(`角色:${selectedRole.value}`)
}
if (searchKeyword.value.trim()) {
tokens.push(`搜索:${searchKeyword.value.trim()}`)
}
return tokens
})
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
watch(
employeeSummary,
(summary) => {
emit('overview-change', summary)
},
{ immediate: true }
)
watch(filteredEmployees, () => {
currentPage.value = 1
pageSizeOpen.value = false
})
function resetFilters() {
searchKeyword.value = ''
selectedDepartment.value = ''
selectedGrade.value = ''
selectedRole.value = ''
activeTab.value = DEFAULT_STATUS_TABS[0]
activeFilterPopover.value = ''
pageSizeOpen.value = false
}
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
function togglePageSizeOpen() {
pageSizeOpen.value = !pageSizeOpen.value
}
function toggleFilterPopover(name) {
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectFilter(name, value) {
if (name === 'department') {
selectedDepartment.value = value
}
if (name === 'grade') {
selectedGrade.value = value
}
if (name === 'role') {
selectedRole.value = value
}
closeFilterPopover()
}
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
closeFilterPopover()
pageSizeOpen.value = false
return
}
if (!target.closest('.picker-filter')) {
closeFilterPopover()
}
if (!target.closest('.page-size-wrap')) {
pageSizeOpen.value = false
}
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
return
}
}
async function loadEmployees() {
loading.value = true
errorMessage.value = ''
const [employeesResult, metaResult] = await Promise.allSettled([
fetchEmployees(),
fetchEmployeeMeta()
])
if (employeesResult.status !== 'fulfilled') {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
selectedEmployee.value = null
errorMessage.value =
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
return
}
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
if (metaResult.status === 'fulfilled') {
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
} else {
roleOptions.value = resolveRoleOptions([], employees.value)
}
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
activeTab.value = DEFAULT_STATUS_TABS[0]
}
if (selectedEmployee.value) {
selectedEmployee.value =
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
}
loading.value = false
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadEmployees().catch((error) => {
employees.value = []
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
selectedEmployee.value = null
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
loading.value = false
})
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
})
return {
tabs,
filters,
activeTab,
selectedEmployee,
roleOptions,
employees,
visibleEmployees
visibleEmployees,
searchKeyword,
selectedDepartment,
selectedGrade,
selectedRole,
activeFilterPopover,
currentPage,
pageSize,
pageSizes,
pageSizeOpen,
departmentOptions,
gradeOptions,
roleFilterOptions,
activeFilterTokens,
hasActiveFilters,
totalCount,
totalPages,
resetFilters,
changePageSize,
togglePageSizeOpen,
toggleFilterPopover,
closeFilterPopover,
selectFilter,
loading,
errorMessage,
loadEmployees
}
}
}

View File

@@ -1,6 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
export MSYS_NO_PATHCONV=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -43,15 +45,16 @@ is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
is_windows_path() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
/d/*|/c/*|/e/*|/f/*) return 0 ;;
*) return 1 ;;
esac
}
use_windows_npm() {
is_wsl && is_windows_mount && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
is_wsl && is_windows_path && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
}
windows_project_path() {