feat: add employee management, backend health check, and UI improvements
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| 文件存储 | MinIO(S3 兼容) |
|
||||
| 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 条核心预审规则
|
||||
- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议)
|
||||
- [ ] 用户能补件并重新预审
|
||||
- [ ] 用户确认后模拟同步成功
|
||||
- [ ] 影子报销账本完整记录业务数据
|
||||
- [ ] 审计日志记录所有关键操作
|
||||
- [ ] 完整流程端到端测试通过
|
||||
@@ -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 Compose(PostgreSQL + 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 # Settings(Pydantic 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` 已创建,配置项说明完整
|
||||
@@ -1,834 +0,0 @@
|
||||
# Phase 2: 后端核心服务(W2-W3)
|
||||
|
||||
> **目标:** 实现所有后端业务 API,包括任务管理、文件上传、OCR 集成、规则引擎、影子账本、补件与提交。
|
||||
> **周期:** 第 2 ~ 3 周
|
||||
> **任务数:** 6 个
|
||||
> **可并行:** Task 2.1 / 2.2 / 2.3 可并行;Task 2.4 依赖 2.1;Task 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: 实现文件上传与票据管理 API(MinIO 存储)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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` 全部通过
|
||||
@@ -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.1(Orchestrator 就绪)
|
||||
|
||||
**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` 全部通过
|
||||
@@ -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` 无报错
|
||||
@@ -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 可访问
|
||||
- [ ] 审计日志记录了完整操作链路
|
||||
@@ -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 数据展示正常
|
||||
@@ -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*
|
||||
67
document/work-log/2026-05-07.md
Normal 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 数据库
|
||||
@@ -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:
|
||||
return EmployeeService(db).create_employee(payload)
|
||||
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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
21
server/src/app/models/employee_change_log.py
Normal 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")
|
||||
32
server/src/app/models/organization.py
Normal 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")
|
||||
24
server/src/app/models/role.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
986
server/src/app/services/employee_seed.py
Normal 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"],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
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
|
||||
|
||||
62
server/tests/test_employee_service.py
Normal 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
|
||||
133
start.sh
@@ -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
|
||||
|
||||
info "Starting FastAPI server..."
|
||||
(
|
||||
cd "$SCRIPT_DIR/server"
|
||||
./start.sh start
|
||||
) &
|
||||
server_pid=$!
|
||||
probe_url="$(server_probe_url)"
|
||||
smoke_url="$(server_smoke_url)"
|
||||
|
||||
wait_for_server() {
|
||||
local base_url="http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/bootstrap"
|
||||
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_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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
BIN
web/src/assets/robot-helper.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
71
web/src/assets/styles/views/backend-unavailable-view.css
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<section class="workbench">
|
||||
<PanelHead
|
||||
v-if="showHeader"
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
<article class="panel assistant-hero">
|
||||
<div class="assistant-visual" aria-hidden="true">
|
||||
<div class="assistant-core">
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
<span class="assistant-glow"></span>
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="assistant-copy">
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
</button>
|
||||
<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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
47
web/src/composables/useBackendHealth.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
24
web/src/services/employees.js
Normal 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}`)
|
||||
}
|
||||
5
web/src/services/system.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchBackendHealth() {
|
||||
return apiRequest('/health')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
27
web/src/views/BackendUnavailableRouteView.vue
Normal 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>
|
||||
@@ -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>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
</button>
|
||||
<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>
|
||||
|
||||
39
web/src/views/scripts/BackendUnavailableRouteView.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||