feat: refactor monolithic App.vue into modular Vue component architecture

- Extract 711-line App.vue into 15+ focused files across 5 directories
- Add data layer (icons, metrics, policies, auditTrail, requests)
- Add composables (useNavigation, useRequests, useChat, useToast)
- Add layout components (SidebarRail, TopBar, FilterBar)
- Add shared components (PanelHead, InfoRow, ToastNotification)
- Add business component (RequestTable) and 5 view components
- Extract global CSS to assets/styles/global.css
- Add start.sh with WSL/Windows cross-platform support
- Add .gitignore for node_modules, dist, and IDE dirs
This commit is contained in:
2026-04-28 17:20:52 +08:00
commit 7141e1d11a
40 changed files with 10133 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
.omc/
.omx/
.claude/
.codex/
*.log
.DS_Store
Thumbs.db

File diff suppressed because it is too large Load Diff

117
docs/plans/00-overview.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ReimburseOps - 企业报销智能运营台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "x-financial-reimbursement-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "vite --host 127.0.0.1",
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"vite": "^5.4.19",
"vue": "^3.5.13"
}
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ReimburseOps - 企业报销智能运营台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

122
src/App.vue Normal file
View File

@@ -0,0 +1,122 @@
<template>
<div class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="setView"
@open-chat="openChat"
/>
<main class="main">
<TopBar
:current-view="currentView"
:search="search"
@update:search="search = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="openChat"
/>
<FilterBar
v-if="activeView !== 'chat'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section class="workarea">
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="openChat"
@approve="handleApprove"
@reject="handleReject"
/>
<ChatView
v-else-if="activeView === 'chat'"
ref="chatViewRef"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="prompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else />
</section>
</main>
<ToastNotification :toast-text="toastText" />
</div>
</template>
<script setup>
import './assets/styles/global.css'
import SidebarRail from './components/layout/SidebarRail.vue'
import TopBar from './components/layout/TopBar.vue'
import FilterBar from './components/layout/FilterBar.vue'
import ToastNotification from './components/shared/ToastNotification.vue'
import OverviewView from './views/OverviewView.vue'
import ChatView from './views/ChatView.vue'
import RequestsView from './views/RequestsView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue'
import { useNavigation, navItems } from './composables/useNavigation.js'
import { useRequests } from './composables/useRequests.js'
import { useChat } from './composables/useChat.js'
import { useToast } from './composables/useToast.js'
const { activeView, currentView, setView } = useNavigation()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } = useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat } = useChat(activeView)
const { toastText, toast } = useToast()
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
}
</script>
<style scoped>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
background:
radial-gradient(circle at 22% -12%, rgba(51,92,255,.10), transparent 34%),
linear-gradient(180deg, #fff 0, var(--bg) 260px);
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.workarea { overflow: auto; padding: 22px 28px 34px; }
@media (max-width: 1180px) {
.app { grid-template-columns: 72px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.app { display: block; }
}
</style>

View File

@@ -0,0 +1,94 @@
:root {
--bg: #f6f8fb;
--surface: #fff;
--surface-soft: #f9fbff;
--ink: #101828;
--text: #344054;
--muted: #667085;
--line: #e4e7ec;
--line-strong: #d0d5dd;
--primary: #335cff;
--primary-soft: #eef3ff;
--success: #0e9384;
--success-soft: #e7f8f5;
--warning: #b54708;
--warning-soft: #fff4e5;
--danger: #b42318;
--danger-soft: #ffebe9;
--nav: #0b1220;
--nav-muted: #7d89a5;
--radius: 8px;
--ease: cubic-bezier(.2, .8, .2, 1);
font-family: Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100dvh; color: var(--text); background: var(--bg); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(51,92,255,.24); outline-offset: 2px; }
.eyebrow { color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
h1, h2, h3, p { margin: 0; }
h1 { margin-top: 4px; color: var(--ink); font-size: clamp(25px, 3vw, 36px); line-height: 1.1; }
.btn {
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
color: var(--text);
font-weight: 700;
transition: transform 180ms var(--ease), box-shadow 180ms var(--ease), background 180ms var(--ease), border-color 180ms var(--ease);
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,24,40,.08); }
.btn:active, .mini-btn:active, .chip:active, .nav-btn:active { transform: scale(.97); }
.btn.primary { border-color: transparent; background: var(--primary); color: #fff; box-shadow: 0 12px 24px rgba(51,92,255,.22); }
.btn.success { border-color: transparent; background: var(--success); color: #fff; }
.btn.danger { border-color: rgba(180,35,24,.18); background: var(--danger-soft); color: var(--danger); }
.btn.ghost { background: transparent; }
.badge { display: inline-flex; min-height: 26px; align-items: center; padding: 4px 9px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 780; white-space: nowrap; }
.badge.success { background: var(--success-soft); color: var(--success); }
.badge.warning { background: var(--warning-soft); color: var(--warning); }
.badge.danger { background: var(--danger-soft); color: var(--danger); }
.panel {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
}
.panel:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
.mini-btn { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; color: var(--text); font-size: 12px; font-weight: 750; }
@keyframes grow { from { transform: scaleX(0); transform-origin: left; } to { transform: scaleX(1); transform-origin: left; } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.message-list-enter-active, .message-list-leave-active { transition: opacity 220ms var(--ease), transform 220ms var(--ease); }
.message-list-enter-from { opacity: 0; transform: translateY(8px) scale(.98); }
.message-list-leave-to { opacity: 0; transform: translateY(-6px) scale(.98); }
@media (max-width: 1180px) {
.metric-strip, .overview-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 760px) {
.topbar { flex-direction: column; padding: 18px 16px; }
.top-actions, .search, .btn { width: 100%; }
.filters, .metric-strip, .overview-grid, .donut-layout, .dialog-body, .dialog-foot, .review-summary, .chat-hero { grid-template-columns: 1fr; }
.filters, .workarea { padding-inline: 16px; }
.bar-row { grid-template-columns: 1fr; gap: 6px; }
.bar-row strong { text-align: left; }
.case-panel { border-left: 0; border-top: 1px solid var(--line); }
.review-summary { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; scroll-behavior: auto !important; }
}

View File

@@ -0,0 +1,63 @@
<template>
<article class="panel queue-panel" :class="{ expanded }">
<PanelHead eyebrow="Approval queue" title="待处理报销申请" note="可直接通过、退回,或把当前单据带入合规对话继续追问。" />
<div class="table-wrap">
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="request in requests" :key="request.id">
<td>
<strong>{{ request.person }}</strong>
<p>{{ request.dept }}</p>
</td>
<td>
<strong>{{ request.category }} · {{ request.amount }}</strong>
<p>{{ request.id }}</p>
</td>
<td>
<span class="badge" :class="request.status">{{ request.verdict }}</span>
</td>
<td>
<span class="badge" :class="request.sla.includes('51') ? 'danger' : ''">{{ request.sla }}</span>
</td>
<td>{{ request.risk }}</td>
<td>
<div class="row-actions">
<button class="mini-btn" @click="emit('approve', request)">通过</button>
<button class="mini-btn" @click="emit('ask', request)">询问 AI</button>
<button class="mini-btn" @click="emit('reject', request)">退回</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</template>
<script setup>
import PanelHead from '../shared/PanelHead.vue'
defineProps({
requests: { type: Array, required: true },
expanded: Boolean
})
const emit = defineEmits(['ask', 'approve', 'reject'])
const columns = ['申请人', '费用与金额', 'AI 结论', 'SLA', '关键风险', '操作']
</script>
<style scoped>
.queue-panel { padding: 20px; }
.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; min-width: 860px; border-collapse: collapse; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { background: var(--surface-soft); color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
td strong { color: var(--ink); }
td p { margin-top: 4px; color: var(--muted); font-size: 12px; }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>

View File

@@ -0,0 +1,69 @@
<template>
<section class="filters" aria-label="筛选条件">
<label>
<span>法人主体</span>
<select v-model="filters.entity">
<option>全部主体</option>
<option>Northstar China Ltd.</option>
<option>Northstar Singapore Pte.</option>
<option>Northstar US Inc.</option>
</select>
</label>
<label>
<span>费用类型</span>
<select v-model="filters.category">
<option>全部费用</option>
<option>差旅交通</option>
<option>住宿</option>
<option>业务招待</option>
<option>办公采购</option>
</select>
</label>
<label>
<span>风险等级</span>
<select v-model="filters.risk">
<option>全部风险</option>
<option>高风险</option>
<option>需解释</option>
<option>低风险</option>
</select>
</label>
<div class="segmented" role="tablist" aria-label="处理视图">
<button
v-for="range in ranges"
:key="range"
:class="{ active: activeRange === range }"
type="button"
@click="emit('update:activeRange', range)"
>
{{ range }}
</button>
</div>
</section>
</template>
<script setup>
defineProps({
filters: { type: Object, required: true },
ranges: { type: Array, required: true },
activeRange: { type: String, required: true }
})
const emit = defineEmits(['update:activeRange'])
</script>
<style scoped>
.filters {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr)) auto;
gap: 14px;
padding: 16px 28px;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.filters label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
.filters select { height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; color: var(--ink); }
.segmented { align-self: end; display: inline-grid; grid-auto-flow: column; gap: 4px; min-height: 42px; padding: 4px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.segmented button { min-height: 32px; padding: 0 13px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-weight: 700; }
.segmented button.active { background: #fff; color: var(--ink); box-shadow: 0 1px 2px rgba(16,24,40,.08); }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<aside class="rail" aria-label="主导航">
<div class="mark">RO</div>
<nav class="rail-nav">
<button
v-for="item in navItems"
:key="item.id"
class="nav-btn"
:class="{ active: activeView === item.id }"
type="button"
:aria-label="item.label"
:title="item.label"
@click="emit('navigate', item.id)"
>
<span v-html="item.icon"></span>
</button>
</nav>
<button class="nav-btn muted" type="button" aria-label="打开合规对话" title="打开合规对话" @click="emit('openChat')">
<span v-html="messageIcon"></span>
</button>
</aside>
</template>
<script setup>
import { icons } from '../../data/icons.js'
defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true }
})
const emit = defineEmits(['navigate', 'openChat'])
const messageIcon = icons.message
</script>
<style scoped>
.rail {
position: sticky;
top: 0;
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 18px;
padding: 18px 12px;
background: var(--nav);
color: #fff;
z-index: 20;
}
.mark { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 14px; background: linear-gradient(135deg,#fff,#9db2ff); color: #10215c; font-weight: 850; }
.rail-nav { display: grid; gap: 10px; align-content: start; }
.nav-btn {
width: 48px;
min-height: 48px;
display: grid;
place-items: center;
border: 0;
border-radius: 14px;
background: transparent;
color: var(--nav-muted);
transition: background 160ms var(--ease), color 160ms var(--ease), transform 160ms var(--ease);
}
.nav-btn:hover, .nav-btn.active { background: rgba(255,255,255,.1); color: #fff; transform: translateY(-1px); }
.nav-btn svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
@media (max-width: 760px) {
.rail { position: sticky; height: auto; grid-template-columns: auto 1fr auto; grid-template-rows: none; padding: 10px 12px; }
.rail-nav { display: flex; overflow-x: auto; }
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<header class="topbar">
<div>
<div class="eyebrow">Global finance operations</div>
<h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p>
</div>
<div class="top-actions">
<label class="search">
<span v-html="searchIcon"></span>
<input :value="search" type="search" placeholder="搜索申请人、单号、费用类型" @input="emit('update:search', $event.target.value)" />
</label>
<button class="btn" type="button" @click="emit('batchApprove')">
<span v-html="checkIcon"></span>
批量通过
</button>
<button class="btn primary" type="button" @click="emit('openChat')">
<span v-html="messageIcon"></span>
合规对话
</button>
</div>
</header>
</template>
<script setup>
import { icons } from '../../data/icons.js'
defineProps({
currentView: { type: Object, required: true },
search: { type: String, default: '' }
})
const emit = defineEmits(['update:search', 'batchApprove', 'openChat'])
const searchIcon = icons.search
const checkIcon = icons.check
const messageIcon = icons.message
</script>
<style scoped>
.topbar {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
background: rgba(255,255,255,.9);
backdrop-filter: blur(14px);
}
.topbar p { margin-top: 6px; color: var(--muted); font-size: 13px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: wrap; }
.search { position: relative; min-width: 300px; }
.search span { position: absolute; left: 13px; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); color: var(--muted); }
.search span svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.search input { width: 100%; height: 42px; padding: 0 14px 0 40px; border: 1px solid var(--line); border-radius: var(--radius); }
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="info-row">
<div class="rank">{{ rank }}</div>
<div>
<strong>{{ title }}</strong>
<p>{{ note }}</p>
</div>
<span class="badge" :class="tone">{{ badge }}</span>
</div>
</template>
<script setup>
defineProps({
rank: String,
title: String,
note: String,
badge: String,
tone: String
})
</script>
<style scoped>
.info-row { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: start; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
.rank { min-width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; background: #fff; color: var(--primary); font-size: 12px; font-weight: 850; box-shadow: inset 0 0 0 1px var(--line); }
.info-row strong { color: var(--ink); }
.info-row p { margin-top: 4px; color: var(--muted); }
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="panel-head">
<div class="eyebrow">{{ eyebrow }}</div>
<h2>{{ title }}</h2>
<p>{{ note }}</p>
</div>
</template>
<script setup>
defineProps({
eyebrow: String,
title: String,
note: String
})
</script>
<style scoped>
.panel-head { margin-bottom: 18px; }
.panel-head h2 { margin-top: 4px; color: var(--ink); font-size: 20px; }
.panel-head p { margin-top: 6px; color: var(--muted); font-size: 13px; }
</style>

View File

@@ -0,0 +1,31 @@
<template>
<Transition name="toast">
<div v-if="toastText" class="toast" role="status" aria-live="polite">{{ toastText }}</div>
</Transition>
</template>
<script setup>
defineProps({
toastText: String
})
</script>
<style scoped>
.toast {
position: fixed;
right: 22px;
bottom: 22px;
max-width: min(380px, calc(100vw - 44px));
padding: 14px 16px;
border-radius: 12px;
background: #0b1220;
color: #fff;
box-shadow: 0 18px 48px rgba(16,24,40,.16);
z-index: 120;
animation: fadeUp 180ms var(--ease) both;
}
.toast-enter-active { transition: opacity 180ms var(--ease), transform 180ms var(--ease); }
.toast-leave-active { transition: opacity 160ms var(--ease), transform 160ms var(--ease); }
.toast-enter-from { opacity: 0; transform: translateY(10px); }
.toast-leave-to { opacity: 0; transform: translateY(-6px); }
</style>

View File

@@ -0,0 +1,63 @@
import { nextTick, ref } from 'vue'
import { initialMessages, prompts } from '../data/requests.js'
export function useChat(activeView) {
const messages = ref([...initialMessages])
const draft = ref('')
const uploadedFiles = ref([])
const messageList = ref(null)
const activeCase = ref(null)
function agentReply(text) {
const c = activeCase.value
if (!c) return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。'
if (text.includes('审批')) return `${c.id} 建议审批意见:发票验真通过,费用归属与预算中心匹配;${c.risk} 已触发规则提示,建议保留业务说明后通过。`
if (text.includes('补件')) return '补件优先级:业务目的说明、行程或客户名单、直属经理确认记录。'
if (text.includes('拦截')) return `拦截原因是 ${c.risk},该风险需要财务复核并留下制度依据。`
if (text.includes('审计')) return `审计摘要:${c.person} 提交 ${c.amount} 报销,命中 ${c.risk},系统已保留 AI 判断。`
return '建议先核对政策阈值和附件完整性,再决定通过、退回补件或转人工复核。'
}
function scrollToBottom() {
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight, behavior: 'smooth' }))
}
function sendMessage() {
const text = draft.value.trim()
if (!text) return false
messages.value.push({ id: Date.now(), role: 'user', text })
draft.value = ''
setTimeout(() => {
messages.value.push({ id: Date.now() + 1, role: 'agent', text: agentReply(text) })
scrollToBottom()
}, 260)
return true
}
function handleUpload(event) {
uploadedFiles.value = Array.from(event.target.files ?? []).map((file) => ({
name: file.name,
size: file.size
}))
if (uploadedFiles.value.length) {
const names = uploadedFiles.value.map((file) => file.name).join('、')
messages.value.push({
id: Date.now(),
role: 'agent',
text: `已接收 ${uploadedFiles.value.length} 个附件:${names}。我会优先核对发票验真、费用标准、预算归属和必备审批材料。`
})
scrollToBottom()
}
}
function openChat(request) {
activeCase.value = request
activeView.value = 'chat'
nextTick(() => messageList.value?.scrollTo({ top: messageList.value.scrollHeight }))
}
return {
messages, draft, uploadedFiles, messageList, activeCase, prompts,
sendMessage, handleUpload, openChat, scrollToBottom
}
}

View File

@@ -0,0 +1,24 @@
import { computed, ref } from 'vue'
import { icons } from '../data/icons.js'
export const navItems = [
{ id: 'overview', label: '运营总览', icon: icons.dashboard, title: '企业报销智能运营台', desc: '面向财务共享中心的审批、风控、SLA 与智能体协同工作台。' },
{ id: 'chat', label: '合规对话', icon: icons.message, title: 'AI 合规对话', desc: '上传单据、追问制度依据,并生成可留痕的审核建议。' },
{ id: 'requests', label: '报销队列', icon: icons.list, title: '报销申请队列', desc: '按风险、补件状态和 AI 建议处理待审单据。' },
{ id: 'policies', label: '政策规则', icon: icons.file, title: '政策规则中心', desc: '维护差旅、招待、采购和发票校验规则。' },
{ id: 'audit', label: '审计追踪', icon: icons.audit, title: '审计追踪', desc: '查看关键审批动作、AI 建议和制度命中记录。' }
]
export function useNavigation() {
const activeView = ref('overview')
const currentView = computed(
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
)
function setView(view) {
activeView.value = view
}
return { activeView, currentView, setView, navItems }
}

View File

@@ -0,0 +1,37 @@
import { computed, reactive, ref } from 'vue'
import { initialRequests } from '../data/requests.js'
export function useRequests() {
const requests = ref(initialRequests)
const search = ref('')
const filters = reactive({ entity: '全部主体', category: '全部费用', risk: '全部风险' })
const ranges = ['今日', '本周', '本月']
const activeRange = ref('今日')
const filteredRequests = computed(() => {
const key = search.value.trim().toLowerCase()
return requests.value.filter((item) => {
const matchesSearch = !key || `${item.id}${item.person}${item.category}${item.risk}`.toLowerCase().includes(key)
const matchesCategory = filters.category === '全部费用' || item.category.includes(filters.category.replace('交通', ''))
const matchesRisk = filters.risk === '全部风险' || (filters.risk === '高风险' ? item.status === 'danger' : item.verdict.includes(filters.risk.replace('低风险', '通过')))
return matchesSearch && matchesCategory && matchesRisk
})
})
function approveRequest(request) {
request.verdict = '已通过'
request.status = 'success'
return `${request.id} 已标记为通过,审计日志已更新。`
}
function rejectRequest(request) {
request.verdict = '已退回补件'
request.status = 'danger'
return `${request.id} 已退回,系统将通知申请人补充材料。`
}
return {
requests, search, filters, ranges, activeRange,
filteredRequests, approveRequest, rejectRequest
}
}

View File

@@ -0,0 +1,13 @@
import { ref } from 'vue'
export function useToast() {
const toastText = ref('')
function toast(text) {
toastText.value = text
clearTimeout(toast.timer)
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
}
return { toastText, toast }
}

5
src/data/auditTrail.js Normal file
View File

@@ -0,0 +1,5 @@
export const auditTrail = [
{ time: '09:40', title: '规则 A1 被财务复核放行', note: '保留会议说明并写入审批意见。', badge: '完成', tone: 'success' },
{ time: '09:18', title: '重复发票拦截', note: 'REQ-2026-0416 已转人工核查。', badge: '阻断', tone: 'danger' },
{ time: '08:52', title: '自动补件提醒发送', note: '11 位员工收到业务招待纪要提醒。', badge: '执行中', tone: 'primary' }
]

11
src/data/icons.js Normal file
View File

@@ -0,0 +1,11 @@
const iconPath = (content) => `<svg viewBox="0 0 24 24" aria-hidden="true">${content}</svg>`
export const icons = {
dashboard: iconPath('<path d="M3 13h8V3H3z"/><path d="M13 21h8V11h-8z"/><path d="M13 3h8v6h-8z"/><path d="M3 21h8v-6H3z"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
audit: iconPath('<path d="M12 8v4l3 3"/><path d="M3.05 11a9 9 0 1 1 .5 4"/><path d="M3 4v7h7"/>'),
search: iconPath('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
check: iconPath('<path d="M20 6 9 17l-5-5"/>'),
message: iconPath('<path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"/>')
}

20
src/data/metrics.js Normal file
View File

@@ -0,0 +1,20 @@
export const metrics = [
{ label: '本月报销额', value: '¥1,286,400', delta: '+8.4%', note: '待审批 ¥361,600', color: '#335cff', tone: '' },
{ label: '平均处理周期', value: '18.6h', delta: '-2.1h', note: '业务补件等待下降', color: '#0e9384', tone: '' },
{ label: '超 SLA 单据', value: '37', delta: '需处理', note: '12 单超过 48 小时', color: '#f79009', tone: 'warn' },
{ label: '高风险拦截', value: '16', delta: '+5', note: '重复发票、异常供应商', color: '#d92d20', tone: 'bad' }
]
export const spendByCategory = [
{ name: '差旅交通', value: '¥390k', width: '92%', color: 'linear-gradient(90deg,#335cff,#6f8cff)' },
{ name: '住宿', value: '¥310k', width: '73%', color: 'linear-gradient(90deg,#0e9384,#56b8aa)' },
{ name: '业务招待', value: '¥240k', width: '57%', color: 'linear-gradient(90deg,#f79009,#ffb64d)' },
{ name: '办公采购', value: '¥180k', width: '42%', color: 'linear-gradient(90deg,#6941c6,#8d68de)' }
]
export const auditMix = [
{ name: '建议通过', value: '42%', color: '#335cff' },
{ name: '自动通过', value: '26%', color: '#0e9384' },
{ name: '需补件', value: '18%', color: '#f79009' },
{ name: '高风险', value: '14%', color: '#d92d20' }
]

5
src/data/policies.js Normal file
View File

@@ -0,0 +1,5 @@
export const policies = [
{ code: 'A1', title: '差旅住宿标准', note: '按城市、职级、会议峰值期动态判断。', badge: '启用', tone: 'success' },
{ code: 'A2', title: '发票查重与验真', note: '票号、税号、金额、抬头四重校验。', badge: '启用', tone: 'success' },
{ code: 'A3', title: '业务招待材料前置', note: '客户名单、拜访纪要、审批单缺一不可。', badge: '建议强化', tone: 'warning' }
]

14
src/data/requests.js Normal file
View File

@@ -0,0 +1,14 @@
export const initialRequests = [
{ id: 'REQ-2026-0418', person: '刘倩', dept: '销售 · 华东区域', category: '差旅报销', amount: '¥8,460', verdict: '可通过但需备注', status: 'warning', sla: '51h', risk: '住宿超标 17.4%' },
{ id: 'REQ-2026-0422', person: '韩阳', dept: '解决方案 · 北区', category: '业务招待', amount: '¥1,980', verdict: '等待补件', status: 'warning', sla: '22h', risk: '缺少客户拜访纪要' },
{ id: 'REQ-2026-0431', person: '王鑫', dept: '运营管理 · 总部', category: '通勤交通', amount: '¥1,224', verdict: '规则全通过', status: 'success', sla: '4h', risk: '无明显风险' },
{ id: 'REQ-2026-0436', person: '陈嘉', dept: '市场 · 品牌活动', category: '活动采购', amount: '¥12,680', verdict: '建议人工复核', status: 'danger', sla: '36h', risk: '供应商与历史黑名单相似' }
]
export const prompts = ['生成审批意见', '列出补件清单', '解释为什么拦截', '生成审计摘要']
export const initialMessages = [
{ id: 1, role: 'agent', text: '我已读取单据、发票、行程和公司差旅制度。当前建议:可通过,但需要保留会议说明。' },
{ id: 2, role: 'user', text: '请列出这张单据的主要风险。' },
{ id: 3, role: 'agent', text: '主要风险:住宿单晚均价超标准 17.4%,并且需要人工确认会议附件。' }
]

5
src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import { MotionPlugin } from '@vueuse/motion'
import App from './App.vue'
createApp(App).use(MotionPlugin).mount('#app')

31
src/views/AuditView.vue Normal file
View File

@@ -0,0 +1,31 @@
<template>
<section class="view single">
<article class="panel">
<PanelHead eyebrow="Audit trail" title="近期关键动作" note="保留审批判断、AI 建议和人工处理动作。" />
<div class="list">
<InfoRow
v-for="event in auditTrail"
:key="event.title"
:rank="event.time"
:title="event.title"
:note="event.note"
:badge="event.badge"
:tone="event.tone"
/>
</div>
</article>
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import InfoRow from '../components/shared/InfoRow.vue'
import { auditTrail } from '../data/auditTrail.js'
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
.panel { padding: 20px; }
.list { display: grid; gap: 12px; }
</style>

167
src/views/ChatView.vue Normal file
View File

@@ -0,0 +1,167 @@
<template>
<section class="view chat-view">
<article class="panel chat-shell">
<header class="chat-hero">
<div>
<div class="eyebrow">Compliance conversation</div>
<h2>上传单据询问 AI 是否合规</h2>
<p>把发票行程单合同附件或审批说明放到同一个上下文里AI 会按制度预算和审计留痕给出建议</p>
</div>
<label class="upload-card">
<span class="upload-icon" v-html="fileIcon"></span>
<strong>上传单据</strong>
<small>PDF图片Excel 或压缩包</small>
<input type="file" multiple @change="emit('upload', $event)" />
</label>
</header>
<div class="upload-list" aria-live="polite">
<span v-for="file in uploadedFiles" :key="file.name" class="file-pill">{{ file.name }}</span>
<span v-if="!uploadedFiles.length" class="file-pill muted">尚未上传文件可直接选择现有报销单追问</span>
</div>
<div class="dialog-body chat-body">
<section
v-motion
class="review-summary"
aria-label="审核摘要"
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 0.08, duration: 0.3 } }"
>
<div class="risk-ring"><strong>82</strong><span>可信分</span></div>
<div>
<h3>建议有条件通过</h3>
<p>发票验真与预算归属通过当前风险集中在 {{ activeCase?.risk }}</p>
<div class="summary-pills">
<span>制度命中 3</span>
<span>附件完整度 86%</span>
<span>SLA {{ activeCase?.sla }}</span>
</div>
</div>
</section>
<div class="messages" ref="messageList" aria-live="polite">
<TransitionGroup name="message-list">
<div v-for="message in messages" :key="message.id" class="message" :class="message.role">
<span>{{ message.role === 'user' ? 'Reviewer' : 'Finance AI' }}</span>
<p>{{ message.text }}</p>
</div>
</TransitionGroup>
</div>
<aside class="case-panel">
<h3>单据上下文</h3>
<dl>
<div><dt>单据编号</dt><dd>{{ activeCase?.id }}</dd></div>
<div><dt>申请人</dt><dd>{{ activeCase?.person }}</dd></div>
<div><dt>金额</dt><dd>{{ activeCase?.amount }}</dd></div>
<div><dt>风险点</dt><dd>{{ activeCase?.risk }}</dd></div>
</dl>
<h3>快捷追问</h3>
<div class="quick-prompts">
<button v-for="prompt in quickPrompts" :key="prompt" class="chip" type="button" @click="emit('draft', prompt)">{{ prompt }}</button>
</div>
</aside>
</div>
<footer class="dialog-foot chat-foot">
<textarea v-model="localDraft" placeholder="询问合规问题,例如:这张住宿发票是否超标?需要补哪些材料?" @keydown.ctrl.enter.prevent="emit('send')"></textarea>
<button class="btn success" type="button" @click="emit('approveCase')">通过</button>
<button class="btn danger" type="button" @click="emit('rejectCase')">转人工</button>
<button class="btn primary" type="button" @click="emit('send')">发送</button>
</footer>
</article>
</section>
</template>
<script setup>
import { icons } from '../data/icons.js'
const props = defineProps({
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
messageList: { type: Object, default: null }
})
const emit = defineEmits(['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'update:draft'])
const fileIcon = icons.file
import { computed } from 'vue'
const localDraft = computed({
get: () => props.draft,
set: (val) => emit('update:draft', val)
})
</script>
<style scoped>
.chat-view { max-width: 1280px; }
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.chat-shell { min-height: calc(100dvh - 210px); display: grid; grid-template-rows: auto auto minmax(420px, 1fr) auto; overflow: hidden; padding: 0; }
.chat-hero {
display: grid;
grid-template-columns: minmax(0,1fr) 260px;
gap: 18px;
align-items: stretch;
padding: 24px;
border-bottom: 1px solid var(--line);
background:
radial-gradient(circle at 12% 10%, rgba(51,92,255,.14), transparent 28%),
linear-gradient(135deg, #fff, #f7fbff);
}
.chat-hero h2 { margin-top: 4px; color: var(--ink); font-size: 28px; }
.chat-hero p { max-width: 780px; margin-top: 8px; color: var(--muted); line-height: 1.6; }
.upload-card {
position: relative;
min-height: 148px;
display: grid;
place-items: center;
align-content: center;
gap: 6px;
padding: 18px;
border: 1px dashed rgba(51,92,255,.36);
border-radius: var(--radius);
background: rgba(255,255,255,.72);
color: var(--text);
cursor: pointer;
transition: transform 180ms var(--ease), border-color 180ms var(--ease), box-shadow 180ms var(--ease);
}
.upload-card:hover { transform: translateY(-2px); border-color: var(--primary); box-shadow: 0 18px 42px rgba(51,92,255,.12); }
.upload-card input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.upload-icon { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; background: var(--primary-soft); color: var(--primary); }
.upload-icon svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 2; fill: none; }
.upload-card strong { color: var(--ink); }
.upload-card small { color: var(--muted); }
.upload-list { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 24px; border-bottom: 1px solid var(--line); background: #fff; }
.file-pill { min-height: 30px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; font-weight: 750; }
.file-pill.muted { background: var(--surface-soft); color: var(--muted); }
.dialog-body { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: auto minmax(0,1fr); }
.chat-body { border-bottom: 1px solid var(--line); }
.review-summary { grid-column: 1 / -1; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--line); background: #fff; }
.risk-ring { width: 82px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 55%,transparent 56%), conic-gradient(var(--success) 0 82%, #e4e7ec 82% 100%); }
.risk-ring strong { color: var(--ink); font-size: 24px; line-height: 1; }
.risk-ring span { color: var(--muted); font-size: 11px; }
.review-summary h3 { color: var(--ink); }
.review-summary p { margin-top: 5px; color: var(--muted); }
.summary-pills { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.summary-pills span { min-height: 28px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: 12px; font-weight: 750; }
.messages { min-height: 0; overflow: auto; display: grid; align-content: start; gap: 12px; padding: 22px 24px; background: linear-gradient(180deg,#fbfcff,#f6f8fb); }
.message { max-width: 82%; display: grid; gap: 6px; will-change: transform, opacity; }
.message.user { justify-self: end; }
.message span { color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.message p { padding: 13px 15px; border: 1px solid var(--line); border-radius: 16px 16px 16px 6px; background: #fff; box-shadow: 0 8px 24px rgba(16,24,40,.05); }
.message.user p { border-color: transparent; border-radius: 16px 16px 6px 16px; background: linear-gradient(135deg,var(--primary),#2446d8); color: #fff; box-shadow: 0 14px 30px rgba(51,92,255,.20); }
.case-panel { overflow: auto; padding: 22px; border-left: 1px solid var(--line); background: rgba(255,255,255,.72); }
.case-panel h3 { margin: 0 0 12px; color: var(--ink); }
dl { margin: 0 0 18px; display: grid; gap: 10px; }
dl div { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
dt { color: var(--muted); font-size: 12px; }
dd { margin: 0; color: var(--ink); font-weight: 750; text-align: right; }
.quick-prompts { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 999px; background: #fff; color: var(--text); font-size: 12px; font-weight: 700; transition: transform 160ms var(--ease), border-color 160ms var(--ease), background 160ms var(--ease); }
.chip:hover { border-color: rgba(51,92,255,.28); background: var(--primary-soft); color: var(--primary); }
.dialog-foot { display: grid; grid-template-columns: minmax(0,1fr) auto auto auto; gap: 10px; padding: 16px 20px; border-top: 1px solid var(--line); background: #fff; }
.chat-foot { border-top: 0; }
textarea { min-height: 48px; max-height: 116px; resize: vertical; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius); }
</style>

View File

@@ -0,0 +1,94 @@
<template>
<section class="view">
<div class="metric-strip">
<article
v-for="(metric, index) in metrics"
:key="metric.label"
v-motion
class="metric"
:initial="{ opacity: 0, y: 16 }"
:enter="{ opacity: 1, y: 0, transition: { delay: index * 0.07, duration: 0.36 } }"
:style="{ '--accent': metric.color }"
>
<div class="metric-top">
<span>{{ metric.label }}</span>
<b :class="metric.tone">{{ metric.delta }}</b>
</div>
<strong>{{ metric.value }}</strong>
<small>{{ metric.note }}</small>
</article>
</div>
<div class="overview-grid">
<article class="panel spend-panel">
<PanelHead eyebrow="Category spend" title="费用类型月度支出" note="用企业报销常见科目展示本月费用压力。" />
<div class="bar-chart" role="img" aria-label="费用类型月度支出柱状图">
<div v-for="item in spendByCategory" :key="item.name" class="bar-row">
<span>{{ item.name }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: item.width, background: item.color }"></div></div>
<strong>{{ item.value }}</strong>
</div>
</div>
</article>
<article class="panel">
<PanelHead eyebrow="Compliance mix" title="审核结论占比" note="把自动通过、需补件和高风险分层展示。" />
<div class="donut-layout">
<div class="donut" aria-label="审核结论环形图"><strong>68%</strong><span>可自动处理</span></div>
<div class="legend">
<div v-for="item in auditMix" :key="item.name" class="legend-row">
<i :style="{ background: item.color }"></i><span>{{ item.name }}</span><strong>{{ item.value }}</strong>
</div>
</div>
</div>
</article>
</div>
<RequestTable :requests="filteredRequests" @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import RequestTable from '../components/business/RequestTable.vue'
import { metrics, spendByCategory, auditMix } from '../data/metrics.js'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject'])
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 16px; }
.metric {
min-height: 128px; padding: 18px; border-top: 3px solid var(--accent);
border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
}
.metric:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
.metric-top { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
.metric-top b { padding: 4px 8px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; }
.metric-top b.warn { background: var(--warning-soft); color: var(--warning); }
.metric-top b.bad { background: var(--danger-soft); color: var(--danger); }
.metric strong { display: block; margin-top: 16px; color: var(--ink); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
.metric small { display: block; margin-top: 10px; color: var(--muted); }
.overview-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 22px; }
.spend-panel { padding: 20px; }
.bar-chart { display: grid; gap: 14px; }
.bar-row { display: grid; grid-template-columns: 94px minmax(0, 1fr) 70px; align-items: center; gap: 14px; font-weight: 700; }
.bar-track { height: 36px; overflow: hidden; border-radius: 7px; background: #f2f4f7; }
.bar-fill { height: 100%; border-radius: inherit; animation: grow 520ms var(--ease) both; }
.bar-row strong { color: var(--ink); text-align: right; font-variant-numeric: tabular-nums; }
.donut-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); align-items: center; gap: 20px; }
.donut { width: 174px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 52%,transparent 53%), conic-gradient(#335cff 0 42%,#0e9384 42% 68%,#f79009 68% 86%,#d92d20 86% 100%); }
.donut strong { display: block; color: var(--ink); font-size: 28px; text-align: center; }
.donut span { color: var(--muted); font-size: 12px; }
.legend { display: grid; gap: 10px; }
.legend-row { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.legend-row i { width: 12px; height: 12px; border-radius: 999px; }
.legend-row strong { color: var(--ink); }
</style>

View File

@@ -0,0 +1,31 @@
<template>
<section class="view single">
<article class="panel">
<PanelHead eyebrow="Policy automation" title="规则运行状态" note="把关键政策、阈值和命中表现集中维护。" />
<div class="list">
<InfoRow
v-for="policy in policies"
:key="policy.code"
:rank="policy.code"
:title="policy.title"
:note="policy.note"
:badge="policy.badge"
:tone="policy.tone"
/>
</div>
</article>
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import InfoRow from '../components/shared/InfoRow.vue'
import { policies } from '../data/policies.js'
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
.panel { padding: 20px; }
.list { display: grid; gap: 12px; }
</style>

View File

@@ -0,0 +1,20 @@
<template>
<section class="view single">
<RequestTable :requests="filteredRequests" expanded @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
</section>
</template>
<script setup>
import RequestTable from '../components/business/RequestTable.vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject'])
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.view.single { max-width: 1120px; }
</style>

97
start.sh Normal file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# X-Financial Reimbursement Admin - Start Script
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
# ----------------------------------------------------------
# Check Node.js
# ----------------------------------------------------------
if ! command -v node &>/dev/null; then
error "Node.js is not installed. Install it first: https://nodejs.org"
fi
if ! command -v npm &>/dev/null; then
error "npm is not installed. It should come with Node.js."
fi
info "Node.js $(node -v) | npm $(npm -v)"
# ----------------------------------------------------------
# Detect WSL + Windows node_modules platform mismatch
# ----------------------------------------------------------
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
check_platform_mismatch() {
local rollup_dir="node_modules/@rollup"
if [ ! -d "$rollup_dir" ]; then
return 1
fi
# List installed rollup platform packages
local platforms
platforms="$(ls -1 "$rollup_dir" 2>/dev/null | grep -E '^rollup-(win|linux)')"
if [ -z "$platforms" ]; then
return 1
fi
# Running on WSL/Linux but has Windows rollup bindings
if echo "$platforms" | grep -q "win32"; then
return 0
fi
return 1
}
# ----------------------------------------------------------
# Install dependencies if node_modules is missing or mismatched
# ----------------------------------------------------------
NEED_INSTALL=false
if [ ! -d "node_modules" ]; then
warn "node_modules not found"
NEED_INSTALL=true
elif is_wsl && check_platform_mismatch; then
warn "Detected WSL with Windows node_modules (rollup platform mismatch)"
warn "Removing node_modules to reinstall with correct platform bindings..."
# WSL can't delete locked Windows .exe/.node files, use PowerShell instead
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
if command -v powershell.exe &>/dev/null; then
powershell.exe -NoProfile -Command "Remove-Item -Recurse -Force '${WIN_PATH}\\node_modules','${WIN_PATH}\\package-lock.json'" 2>/dev/null || true
elif command -v cmd.exe &>/dev/null; then
cmd.exe /c "rd /s /q \"${WIN_PATH}\\node_modules\"" 2>/dev/null || true
cmd.exe /c "del /f /q \"${WIN_PATH}\\package-lock.json\"" 2>/dev/null || true
else
rm -rf node_modules package-lock.json 2>/dev/null || true
fi
# Fallback: clean up anything remaining via WSL
rm -rf node_modules package-lock.json 2>/dev/null || true
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
info "Running npm install..."
npm install
fi
# ----------------------------------------------------------
# Start dev server
# ----------------------------------------------------------
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://127.0.0.1:5173"
echo ""
exec npm start

6
vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()]
})