6 Commits

Author SHA1 Message Date
c00db75c13 feat: add employee management, backend health check, and UI improvements 2026-05-07 11:50:10 +08:00
a5db09f41e docs: update work log with commit details and problem/solution
- Add commit file changes statistics
- Add Problem/Solution sections
- Add What's Done/Not Done sections

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:29:02 +08:00
62f7810bd0 docs: restore full work log with commit history
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:26:59 +08:00
f1dcfcfebf docs: update work log with git commits
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:25:48 +08:00
04e4b7148c docs: add work log for 2026-05-06
- Fix server/start.sh venv issue on Windows/Git Bash
- Create work-log skill for project documentation
- Add SetupView and routing refactoring

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 22:25:01 +08:00
ae63766c91 Add vue-router, login/setup flow and backend logging
Refactor frontend to route-based navigation with vue-router, add
system setup and login pages with API integration. Add structured
logging, access-log middleware and startup lifecycle to FastAPI
backend.
2026-05-06 22:23:42 +08:00
85 changed files with 7608 additions and 6091 deletions

46
.env.example Normal file
View File

@@ -0,0 +1,46 @@
APP_NAME=X-Financial
APP_ENV=local
APP_DEBUG=true
API_V1_PREFIX=/api/v1
SETUP_COMPLETED=false
VITE_SETUP_COMPLETED=false
COMPANY_NAME=
COMPANY_CODE=
ADMIN_EMAIL=
VITE_COMPANY_NAME=
VITE_COMPANY_CODE=
VITE_ADMIN_EMAIL=
# Admin login credentials are stored separately under server/.secrets/
WEB_HOST=127.0.0.1
WEB_PORT=5173
VITE_WEB_HOST=127.0.0.1
VITE_WEB_PORT=5173
SERVER_HOST=127.0.0.1
SERVER_PORT=8000
VITE_SERVER_HOST=127.0.0.1
VITE_SERVER_PORT=8000
SERVER_STARTUP_TIMEOUT=300
SERVER_BLOCKING_STARTUP_TIMEOUT=12
VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=x_financial
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
VITE_POSTGRES_HOST=127.0.0.1
VITE_POSTGRES_PORT=5432
VITE_POSTGRES_DB=x_financial
VITE_POSTGRES_USER=postgres
DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:5432/x_financial
SQLALCHEMY_ECHO=false
REDIS_URL=
VITE_REDIS_URL=
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173"]'

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ web/.vite/
*.log
.DS_Store
Thumbs.db
__pycache__/
*.pyc
server/.venv/
server/.secrets/

View File

@@ -8,13 +8,34 @@
- `UI/`:界面参考稿
- `document/`:业务文档
根目录启动前端
根目录统一环境变量
- `.env`
- `.env.example`
这里集中维护:
- 前端启动端口
- 后端启动端口
- PostgreSQL 连接参数
- `DATABASE_URL`
- `REDIS_URL`
从根目录统一启动:
```bash
./start.sh
```
或手动进入前端目录
可选模式
```bash
./start.sh web
./start.sh server
./start.sh all
```
手动进入前端目录:
```bash
cd web

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
# Work Log - 2026-05-06
## 05-06 工作
### 下午
- **修复了 Windows Git Bash 启动脚本报错问题**
- 问题:虚拟环境指向不存在的 python3
- 解决:添加检测函数,无效则重建
- **创建了 work-log 技能**
- 自动记录工作日志
- 按 git 提交生成工作总结
---
# Work Log - 2026-05-07
## 05-07 工作
### 上午
- **完成了后端员工管理模块**
- 员工 CRUD 服务(创建、更新、删除)
- 自动记录修改历史(变更日志)
- 组织架构和角色模型
### 中午
- **完成了前端员工管理页面**
- 表格展示员工列表
- 搜索和分页功能
- 新增/编辑弹窗
- **添加了后端健康检查**
- 后端不可用时显示提示页面
- 支持重试
### 下午
- **重构了项目结构**
- 前后端分离web/ + server/
- 使用 vue-router 路由化导航
- 添加系统安装页面
- **整理了 UI 资源**
- 图片移至 web/UI/ 目录
- 清理旧文档
---
# 待处理
- [ ] 安装 PostgreSQL 并创建数据库
- [ ] 测试后端 API 连接

View File

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

View File

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

View File

@@ -44,7 +44,13 @@ class Settings(BaseSettings):
redis_url: str | None = Field(default=None, alias="REDIS_URL")
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
vite_api_base_url: str = Field(
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
)
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="logs", alias="LOG_DIR")
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
@property
def resolved_database_url(self) -> str:

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_LEVEL_COLORS: dict[int, str] = {
logging.DEBUG: "\033[36m",
logging.INFO: "\033[32m",
logging.WARNING: "\033[33m",
logging.ERROR: "\033[31m",
logging.CRITICAL: "\033[1;31m",
}
_RESET = "\033[0m"
class _ColorFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
color = _LEVEL_COLORS.get(record.levelno, "")
record.levelname = f"{color}{record.levelname:<8}{_RESET}"
return super().format(record)
def _build_console_handler(level: int) -> logging.StreamHandler:
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(level)
handler.setFormatter(_ColorFormatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def _build_file_handler(log_dir: Path, level: int) -> RotatingFileHandler:
log_dir.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
log_dir / "app.log",
maxBytes=10 * 1024 * 1024,
backupCount=10,
encoding="utf-8",
)
handler.setLevel(level)
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
return handler
def setup_logging(
*,
level: str = "INFO",
log_dir: str = "logs",
enable_file: bool = True,
) -> None:
numeric_level = getattr(logging, level.upper(), logging.INFO)
root = logging.getLogger()
root.setLevel(numeric_level)
root.handlers.clear()
root.addHandler(_build_console_handler(numeric_level))
if enable_file:
from app.core.config import SERVER_DIR
file_path = SERVER_DIR / log_dir
root.addHandler(_build_file_handler(file_path, numeric_level))
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

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

View File

@@ -5,17 +5,33 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging
from app.middleware.logging import AccessLogMiddleware
from app.services.employee import prepare_employee_directory
def create_app() -> FastAPI:
settings = get_settings()
setup_logging(
level=settings.log_level,
log_dir=settings.log_dir,
enable_file=settings.log_file_enabled,
)
logger = get_logger("app.main")
logger.info(
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
)
app = FastAPI(
title=settings.app_name,
debug=settings.app_debug,
version="0.1.0",
)
app.add_middleware(AccessLogMiddleware)
if settings.cors_origins:
app.add_middleware(
CORSMiddleware,
@@ -31,6 +47,16 @@ def create_app() -> FastAPI:
def root() -> dict[str, str]:
return {"message": f"{settings.app_name} is running"}
@app.on_event("startup")
def _on_startup() -> None:
prepare_employee_directory()
logger.info(
"Server ready - host=%s port=%s prefix=%s",
settings.app_host,
settings.app_port,
settings.api_v1_prefix,
)
return app

View File

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.core.logging import get_logger
logger = get_logger("app.middleware.access")
_SKIP_PATHS: frozenset[str] = frozenset({"/", "/docs", "/openapi.json", "/redoc"})
class AccessLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in _SKIP_PATHS:
return await call_next(request)
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
level = logging.WARNING if response.status_code >= 500 else logging.INFO
logger.log(
level,
"%s %s %s %.1fms request_id=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
request_id,
)
response.headers["X-Request-ID"] = request_id
return response

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,37 @@
from sqlalchemy.orm import Session
from app.core.logging import get_logger
from app.models.reimbursement import ReimbursementRequest
from app.repositories.reimbursement import ReimbursementRepository
from app.schemas.reimbursement import ReimbursementCreate
logger = get_logger("app.services.reimbursement")
class ReimbursementService:
def __init__(self, db: Session) -> None:
self.repository = ReimbursementRepository(db)
def list_reimbursements(self) -> list[ReimbursementRequest]:
return self.repository.list()
items = self.repository.list()
logger.info("Listed reimbursements (count=%d)", len(items))
return items
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
return self.repository.get(request_id)
request = self.repository.get(request_id)
if request:
logger.info("Fetched reimbursement id=%s no=%s", request_id, request.request_no)
else:
logger.warning("Reimbursement not found id=%s", request_id)
return request
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
request = ReimbursementRequest(**payload.model_dump(), status="draft")
return self.repository.create(request)
created = self.repository.create(request)
logger.info(
"Created reimbursement id=%s no=%s amount=%s",
created.id,
created.request_no,
created.amount,
)
return created

View File

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

View File

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

View File

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

121
start.sh
View File

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

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 971 KiB

After

Width:  |  Height:  |  Size: 971 KiB

View File

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 163 KiB

187
web/package-lock.json generated
View File

@@ -12,11 +12,13 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"pg": "^8.13.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vite": "^5.4.19",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3"
"vue-chartjs": "^5.3.3",
"vue-router": "^4.5.1"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -1015,6 +1017,12 @@
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
@@ -1170,7 +1178,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -1501,12 +1508,114 @@
"license": "MIT",
"optional": true
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
@@ -1559,6 +1668,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/primeicons": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
@@ -1679,6 +1827,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/style-value-types": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-5.1.2.tgz",
@@ -1770,7 +1927,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1830,7 +1986,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
@@ -1857,12 +2012,36 @@
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT",
"optional": true
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

View File

@@ -14,10 +14,12 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"pg": "^8.13.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vite": "^5.4.19",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3"
"vue-chartjs": "^5.3.3",
"vue-router": "^4.5.1"
}
}

View File

@@ -1,201 +1,17 @@
<template>
<!-- Login Page -->
<LoginView
v-if="!loggedIn"
@login="handleLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
<!-- Main App -->
<div v-else class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已筛出 23 个低风险单据可进入批量通过确认')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id} 已生成通过意见`)"
@reject-case="toast(`${activeCase?.id} 已转人工复核`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
<RouterView />
<ToastNotification :toast-text="toastText" />
</template>
<script setup>
import { RouterView } from 'vue-router'
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 LoginView from './views/LoginView.vue'
import OverviewView from './views/OverviewView.vue'
import PersonalWorkbenchView from './views/PersonalWorkbenchView.vue'
import ChatView from './views/ChatView.vue'
import TravelReimbursementCreateView from './views/TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './views/TravelRequestDetailView.vue'
import RequestsView from './views/RequestsView.vue'
import ApprovalCenterView from './views/ApprovalCenterView.vue'
import PoliciesView from './views/PoliciesView.vue'
import AuditView from './views/AuditView.vue'
import EmployeeManagementView from './views/EmployeeManagementView.vue'
import { useToast } from './composables/useToast.js'
import { useAppShell } from './composables/useAppShell.js'
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { toastText } = useToast()
</script>
<style scoped src="./assets/styles/app.css"></style>
<style src="./assets/styles/app.css"></style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -4,6 +4,68 @@
grid-template-columns: 220px minmax(0, 1fr);
background: var(--bg);
}
.boot-state {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 24rem),
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.14), transparent 28rem),
#f8fafc;
}
.boot-card {
width: min(560px, 100%);
padding: 36px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
display: grid;
gap: 14px;
}
.boot-card h1 {
font-size: 28px;
}
.boot-card p {
color: var(--muted);
line-height: 1.7;
}
.boot-badge {
display: inline-flex;
width: fit-content;
min-height: 28px;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.12);
color: #059669;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
}
.boot-badge-error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.boot-action {
width: fit-content;
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: 8px;
background: #0f172a;
color: #fff;
font-weight: 700;
}
.main { min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr); }
.main.overview-main {
grid-template-rows: auto minmax(0, 1fr);

View File

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

View File

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

View File

@@ -482,6 +482,16 @@
margin-top: 2px;
}
.login-error {
padding: 12px 14px;
border: 1px solid rgba(239, 68, 68, .18);
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
font-size: 13px;
line-height: 1.55;
}
.remember {
display: inline-flex;
align-items: center;
@@ -528,6 +538,13 @@
background: linear-gradient(135deg, #13c990, #047857);
}
.submit-btn:disabled,
.sso-btn:disabled {
opacity: .6;
cursor: not-allowed;
box-shadow: none;
}
.divider {
position: relative;
display: grid;

View File

@@ -0,0 +1,607 @@
.setup-page {
min-height: 100dvh;
display: grid;
grid-template-columns: minmax(320px, 392px) minmax(0, 1fr);
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.24), transparent 24rem),
radial-gradient(circle at 36% 14%, rgba(16, 185, 129, 0.16), transparent 18rem),
linear-gradient(135deg, #04110d 0%, #0b1f18 26%, #10281f 26%, #eef5f1 26%, #f6fbf8 100%);
}
.setup-context {
padding: 42px 28px 32px;
color: rgba(255, 255, 255, 0.92);
display: grid;
align-content: start;
gap: 22px;
border-right: 1px solid rgba(110, 231, 183, 0.08);
background: linear-gradient(180deg, rgba(4, 17, 13, 0.92), rgba(16, 40, 31, 0.9));
}
.setup-brand {
display: flex;
gap: 18px;
align-items: flex-start;
}
.setup-brand-mark {
position: relative;
flex: 0 0 64px;
width: 64px;
height: 64px;
display: grid;
place-items: center;
}
.setup-brand-ring {
position: absolute;
inset: 0;
border-radius: 18px;
background:
linear-gradient(145deg, rgba(209, 250, 229, 0.96), rgba(52, 211, 153, 0.88)),
linear-gradient(145deg, rgba(16, 185, 129, 0.4), rgba(5, 150, 105, 0.6));
box-shadow:
0 18px 36px rgba(16, 185, 129, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.46);
transform: rotate(-8deg);
}
.setup-brand-ring::before {
content: '';
position: absolute;
inset: 7px;
border-radius: 14px;
border: 1px solid rgba(4, 120, 87, 0.22);
}
.setup-brand-core {
position: relative;
z-index: 1;
width: 42px;
height: 42px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(3, 32, 24, 0.92);
color: #d1fae5;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.14em;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.setup-kicker {
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.86);
}
.setup-kicker-light {
color: rgba(209, 250, 229, 0.82);
}
.setup-context h1 {
color: #f4fff8;
font-size: clamp(1.9rem, 2.4vw, 2.5rem);
line-height: 1.08;
text-shadow: 0 12px 36px rgba(2, 12, 8, 0.34);
}
.setup-lead {
color: rgba(220, 252, 231, 0.84);
font-size: 14px;
line-height: 1.8;
}
.setup-nav {
display: grid;
gap: 10px;
}
.setup-nav-item {
width: 100%;
padding: 14px 14px 14px 12px;
border: 1px solid rgba(110, 231, 183, 0.12);
border-radius: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 12px;
background: linear-gradient(160deg, rgba(10, 23, 18, 0.82), rgba(15, 39, 31, 0.72));
color: inherit;
text-align: left;
transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.setup-nav-item:hover {
transform: translateY(-1px);
border-color: rgba(110, 231, 183, 0.22);
}
.setup-nav-item.is-active {
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 14px 28px rgba(3, 10, 7, 0.22);
}
.setup-nav-item.is-complete {
background: linear-gradient(160deg, rgba(8, 31, 23, 0.96), rgba(12, 58, 44, 0.86));
}
.setup-nav-index {
width: 40px;
height: 40px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.16);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.setup-nav-copy {
display: grid;
gap: 4px;
}
.setup-nav-copy strong {
color: #f0fdf4;
font-size: 14px;
}
.setup-nav-copy small {
color: rgba(209, 250, 229, 0.72);
font-size: 12px;
line-height: 1.55;
}
.setup-nav-check {
color: #6ee7b7;
font-size: 14px;
}
.setup-progress {
margin-top: 8px;
padding: 16px 18px;
border: 1px solid rgba(110, 231, 183, 0.14);
border-radius: 8px;
background: rgba(7, 33, 25, 0.76);
}
.setup-progress strong {
color: #f0fdf4;
font-size: 15px;
}
.setup-progress p {
margin-top: 8px;
color: rgba(209, 250, 229, 0.72);
font-size: 13px;
line-height: 1.65;
}
.setup-complete {
margin-top: auto;
padding: 16px 18px 0;
border-top: 1px solid rgba(110, 231, 183, 0.12);
display: grid;
gap: 12px;
}
.setup-complete p {
color: rgba(209, 250, 229, 0.76);
font-size: 13px;
line-height: 1.6;
}
.setup-complete-btn {
width: 100%;
}
.setup-panel {
padding: 36px;
display: grid;
align-content: start;
gap: 24px;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.08), transparent 16rem),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0));
}
.setup-panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 22px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
background: linear-gradient(135deg, #063b2e, #0f5f49);
box-shadow: 0 16px 34px rgba(6, 59, 46, 0.16);
}
.setup-panel-head h2 {
color: #ffffff;
font-size: 28px;
}
.setup-panel-desc {
margin-top: 10px;
color: rgba(236, 253, 245, 0.82);
font-size: 14px;
line-height: 1.65;
}
.setup-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: rgba(240, 253, 244, 0.14);
color: #d1fae5;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(209, 250, 229, 0.18);
}
.setup-chip.is-success {
background: rgba(16, 185, 129, 0.22);
}
.setup-form {
padding: 30px 32px;
border: 1px solid rgba(16, 185, 129, 0.18);
border-radius: 8px;
background: linear-gradient(180deg, rgba(244, 255, 248, 0.98), rgba(255, 255, 255, 0.94));
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
backdrop-filter: blur(14px);
}
.setup-stage {
display: grid;
gap: 22px;
}
.section-head {
display: grid;
gap: 6px;
}
.section-head h3 {
color: #065f46;
font-size: 18px;
}
.section-head p {
color: #5b6f67;
font-size: 13px;
line-height: 1.7;
}
.field-grid {
display: grid;
gap: 16px;
}
.field-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 8px;
}
.field span {
color: #244239;
font-size: 13px;
font-weight: 600;
}
.field-note {
color: #5f7c72;
font-size: 12px;
line-height: 1.5;
}
.field-group-note {
margin-top: -6px;
color: #5f7c72;
font-size: 12px;
line-height: 1.6;
}
.optional-block {
padding: 18px 18px 0;
border: 1px dashed rgba(16, 185, 129, 0.22);
border-radius: 8px;
background: rgba(240, 253, 244, 0.52);
}
.optional-block-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.optional-block-head strong {
color: #065f46;
font-size: 14px;
}
.optional-block-head span {
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
display: inline-flex;
align-items: center;
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 12px;
font-weight: 700;
}
.field input {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid rgba(148, 163, 184, 0.78);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #0f172a;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.field input:hover {
transform: translateY(-1px);
}
.field input:focus {
border-color: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.field-span-2 {
grid-column: span 2;
}
.setup-runtime {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.setup-runtime article {
position: relative;
overflow: hidden;
padding: 16px 18px;
border-radius: 8px;
border: 1px solid rgba(110, 231, 183, 0.14);
display: grid;
gap: 10px;
box-shadow: 0 14px 32px rgba(3, 10, 7, 0.2);
}
.setup-runtime article::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, rgba(209, 250, 229, 0.92), rgba(16, 185, 129, 0.48));
}
.setup-runtime article:nth-child(1) {
background: linear-gradient(150deg, rgba(7, 33, 25, 0.96), rgba(16, 67, 52, 0.9));
}
.setup-runtime article:nth-child(2) {
background: linear-gradient(150deg, rgba(7, 28, 26, 0.96), rgba(11, 59, 61, 0.9));
}
.setup-runtime article:nth-child(3) {
background: linear-gradient(150deg, rgba(16, 28, 19, 0.96), rgba(48, 74, 36, 0.9));
}
.setup-runtime span {
font-size: 12px;
text-transform: uppercase;
color: rgba(167, 243, 208, 0.7);
}
.setup-runtime strong {
color: #f8fffb;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-shadow: 0 2px 16px rgba(4, 9, 7, 0.22);
}
.setup-summary-grid {
display: grid;
gap: 12px;
}
.setup-summary-item {
padding: 16px 18px;
border: 1px solid rgba(16, 185, 129, 0.14);
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
background: rgba(248, 250, 252, 0.9);
}
.setup-summary-item strong {
display: block;
color: #0f172a;
font-size: 14px;
}
.setup-summary-item span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.setup-summary-item .pi-check-circle {
color: #10b981;
font-size: 18px;
}
.setup-summary-item .pi-clock {
color: #f59e0b;
font-size: 18px;
}
.setup-error {
margin-top: 22px;
padding: 14px 16px;
border: 1px solid rgba(239, 68, 68, 0.18);
border-radius: 8px;
background: #fef2f2;
color: #b91c1c;
white-space: pre-line;
}
.setup-status {
margin-top: 22px;
padding: 14px 16px;
border-radius: 8px;
white-space: pre-line;
}
.setup-status.is-success {
border: 1px solid rgba(16, 185, 129, 0.18);
background: #ecfdf5;
color: #047857;
}
.setup-status.is-danger {
border: 1px solid rgba(239, 68, 68, 0.18);
background: #fef2f2;
color: #b91c1c;
}
.setup-gate {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 8px;
background: #fffbeb;
color: #b45309;
}
.setup-actions {
margin-top: 28px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
}
.setup-actions-right {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.primary-btn,
.secondary-btn {
min-height: 46px;
padding: 0 18px;
border-radius: 8px;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 700;
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.primary-btn {
background: linear-gradient(135deg, #10b981, #0f766e);
color: #fff;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.secondary-btn {
background: rgba(240, 253, 244, 0.94);
color: #1f4f41;
border-color: rgba(16, 185, 129, 0.18);
}
.secondary-btn-strong {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(5, 150, 105, 0.12));
color: #065f46;
}
.primary-btn:hover,
.secondary-btn:hover {
transform: translateY(-1px);
}
.primary-btn:disabled,
.secondary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
@media (max-width: 1180px) {
.setup-page {
grid-template-columns: 1fr;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.2), transparent 22rem),
linear-gradient(180deg, #04110d 0%, #10281f 36%, #eef5f1 36%, #f6fbf8 100%);
}
.setup-context,
.setup-panel {
padding: 28px 24px;
}
.setup-complete {
padding-inline: 0;
}
}
@media (max-width: 820px) {
.field-grid-2,
.setup-runtime {
grid-template-columns: 1fr;
}
.field-span-2 {
grid-column: auto;
}
.setup-actions {
flex-direction: column;
align-items: stretch;
}
.setup-actions-right {
width: 100%;
flex-direction: column;
}
.primary-btn,
.secondary-btn {
width: 100%;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,75 +1,74 @@
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useChat } from './useChat.js'
import { useToast } from './useToast.js'
import { documents } from '../data/requests.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
export function useAppShell() {
const loggedIn = ref(false)
const route = useRoute()
const router = useRouter()
const travelCreateMode = ref(false)
const detailMode = ref(false)
const selectedTravelRequest = ref(null)
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null })
const smartEntrySessionId = ref(0)
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, openNewChat } = useChat(activeView)
const { toastText, toast } = useToast()
const { requests, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest } =
useRequests()
const { messages, draft, uploadedFiles, messageList, activeCase, prompts, sendMessage, handleUpload, openChat, openNewChat } =
useChat(activeView)
const { toast } = useToast()
const docSearch = ref('')
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
const travelPrompts = ['帮我提交出差申请', '预订机票', '预订酒店', '预订火车票', '查询差旅政策']
const travelPrompts = ['生成差旅摘要', '识别报销风险', '核对审批链', '提取随附票据', '生成沟通建议']
const selectedTravelRequest = computed(() => {
const requestId = String(route.params.requestId || '')
if (!requestId) {
return null
}
const rawRequest = requests.value.find((item) => String(item.id) === requestId)
return normalizeRequestForUi(rawRequest)
})
const detailMode = computed(() => route.name === 'app-request-detail')
const topBarView = computed(() => {
if (detailMode.value) {
return {
title: '差旅报销详情',
desc: '查看报销单据详情、票据识别与审批进度'
title: '差旅申请详情',
desc: '查看申请单、票据、审批意见与风控提示。'
}
}
return currentView.value
})
const filteredDocuments = computed(() => {
const key = docSearch.value.trim().toLowerCase()
return documents.filter((doc) => {
const matchesSearch = !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key)
return matchesSearch
return documents.filter((doc) => !key || `${doc.id}${doc.applicant}${doc.destination}${doc.type}`.toLowerCase().includes(key))
})
})
function handleLogin(credentials) {
if (credentials.username && credentials.password) {
loggedIn.value = true
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。')
}
function handleSsoLogin() {
toast('SSO 登录通道建设中。')
}
function handleApprove(request) {
const msg = approveRequest(request)
toast(msg)
const message = approveRequest(request)
toast(message)
}
function handleReject(request) {
const msg = rejectRequest(request)
toast(msg)
const message = rejectRequest(request)
toast(message)
}
function handleNavigate(view) {
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryOpen.value = false
setView(view)
}
@@ -82,8 +81,6 @@ export function useAppShell() {
function openTravelCreate() {
smartEntryOpen.value = true
travelCreateMode.value = false
detailMode.value = false
selectedTravelRequest.value = null
smartEntryContext.value = { prompt: '', source: 'topbar', request: null }
smartEntrySessionId.value += 1
}
@@ -91,10 +88,7 @@ export function useAppShell() {
function openSmartEntry(payload = {}) {
smartEntryOpen.value = true
travelCreateMode.value = false
if (payload.source !== 'detail') {
detailMode.value = false
selectedTravelRequest.value = null
}
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
@@ -108,14 +102,14 @@ export function useAppShell() {
}
function openRequestDetail(request) {
selectedTravelRequest.value = request
detailMode.value = true
activeView.value = 'requests'
router.push({
name: 'app-request-detail',
params: { requestId: request.id }
})
}
function closeRequestDetail() {
detailMode.value = false
selectedTravelRequest.value = null
router.push({ name: 'app-requests' })
}
return {
@@ -133,14 +127,10 @@ export function useAppShell() {
filteredRequests,
filters,
handleApprove,
handleLogin,
handleNavigate,
handleOpenChat,
handleRecoverPassword,
handleReject,
handleSsoLogin,
handleUpload,
loggedIn,
messageList,
messages,
navItems,
@@ -160,7 +150,6 @@ export function useAppShell() {
smartEntryOpen,
smartEntrySessionId,
toast,
toastText,
topBarView,
travelCreateMode,
travelPrompts,

View File

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

View File

@@ -8,9 +8,24 @@ export function useLoginView() {
const showPassword = ref(false)
const features = [
{ title: '智能审单', desc: 'AI 自动识别票据与规则,提升准确率与效率', icon: 'mdi mdi-file-document-outline', tone: 'green' },
{ title: '异常预警', desc: '多维风险识别与预警,主动防控风险', icon: 'mdi mdi-bell-outline', tone: 'red' },
{ title: 'SLA 监控', desc: '实时监控服务水平协议,保障审批及时性', icon: 'mdi mdi-sync', tone: 'blue' }
{
title: '智能审单',
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
icon: 'mdi mdi-file-document-outline',
tone: 'green'
},
{
title: '异常预警',
desc: '多维风险识别与预警,主动防控报销风险',
icon: 'mdi mdi-bell-outline',
tone: 'red'
},
{
title: 'SLA 监控',
desc: '实时监控服务水位,保障审批和处理时效',
icon: 'mdi mdi-sync',
tone: 'blue'
}
]
const LogoMark = {

View File

@@ -1,82 +1,113 @@
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'chat', 'policies', 'audit', 'employees']
export const navItems = [
{
id: 'overview',
label: '总览',
navHint: '运营指标与趋势',
navHint: '查看系统总览与关键指标',
icon: icons.dashboard,
title: '企业报销智能运营台',
desc: '面向财务共享中心的审批、风控、SLA与自动化运营看板'
title: '财务运营总览',
desc: '聚合差旅申请、审批效率、风险信号与 SLA 表现。'
},
{
id: 'workbench',
label: '个人工作台',
navHint: '今日待办与报销进度',
navHint: '集中处理个人待办',
icon: icons.workspace,
title: '个人工作台',
desc: '集中处理今日待办、查看报销进度,并快速进入 AI 报销助手'
desc: '聚焦当前待办、快捷操作与助手入口。'
},
{
id: 'requests',
label: '差旅申请/报销',
navHint: '差旅单据与发起申请',
label: '申请单',
navHint: '查看和管理申请',
icon: icons.list,
title: '差旅申请/报销',
desc: '查看员工差旅报销单据、跟踪进度、发起新申请'
title: '差旅申请与单据',
desc: '集中查看申请单状态、处理进度和风险提示。'
},
{
id: 'approval',
label: '审批中心',
navHint: '待审批单据与批量处理',
navHint: '处理审批任务',
icon: icons.approval,
title: '审批中心',
desc: '统一处理待审批单据,聚焦效率、风险与 SLA'
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'chat',
label: 'AI助手',
navHint: '财务知识问答与制度解释',
label: 'AI 助手',
navHint: '进入智能问答',
icon: icons.message,
title: '财务AI助手',
desc: '面向员工与财务场景的智能问答助手,提供制度解读、报销指引与常见问题解答'
title: 'AI 财务助手',
desc: '围绕制度、票据、审批和差旅场景进行快速问答。'
},
{
id: 'policies',
label: '知识管理',
navHint: '制度、文档与知识库',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '财务知识管理中心',
desc: '上传制度文档、沉淀财务知识、构建面向员工问答与知识管理的统一知识库'
title: '制度与知识库',
desc: '统一管理制度文档、知识问答和搜索入口。'
},
{
id: 'audit',
label: '技能中心',
navHint: 'Skill 设计与版本配置',
label: '审计追踪',
navHint: '查看日志与追踪记录',
icon: icons.skill,
title: '技能中心',
desc: '统一管理技能的触发规则、提示词结构、输出约束与上线版本'
title: '审计追踪',
desc: '记录关键操作、追踪审批链和系统行为。'
},
{
id: 'employees',
label: '员工管理',
navHint: '员工档案、岗位与角色权限',
navHint: '维护员工与组织信息',
icon: icons.users,
title: '员工管理',
desc: '集中维护员工基础信息、职级部门岗位,以及管理员、财务人员、使用者和高级管理人员等系统角色'
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
}
]
const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
requests: 'app-requests',
approval: 'app-approval',
chat: 'app-chat',
policies: 'app-policies',
audit: 'app-audit',
employees: 'app-employees'
}
export function useNavigation() {
const activeView = ref('overview')
const route = useRoute()
const router = useRouter()
const activeView = computed({
get() {
return route.meta.appView || 'overview'
},
set(view) {
setView(view)
}
})
const currentView = computed(
() => navItems.find((item) => item.id === activeView.value) ?? navItems[0]
)
function setView(view) {
activeView.value = view
const targetName = viewRouteNames[view] || viewRouteNames.overview
if (route.name === targetName) {
return
}
router.push({ name: targetName })
}
return { activeView, currentView, setView, navItems }

View File

@@ -0,0 +1,383 @@
import { computed, reactive, ref, watch } from 'vue'
function createForm(initialState) {
return {
company_name: initialState?.company?.name || '',
company_code: initialState?.company?.code || '',
admin_email: initialState?.company?.admin_email || '',
admin_username: '',
admin_password: '',
admin_password_confirm: '',
web_host: initialState?.web?.host || '127.0.0.1',
web_port: initialState?.web?.port || 5173,
server_host: initialState?.server?.host || '127.0.0.1',
server_port: initialState?.server?.port || 8000,
postgres_host: initialState?.database?.host || '127.0.0.1',
postgres_port: initialState?.database?.port || 5432,
postgres_db: initialState?.database?.name || 'x_financial',
postgres_user: initialState?.database?.username || 'postgres',
postgres_password: '',
redis_url: initialState?.redis?.url || ''
}
}
function buildPayload(form) {
return {
company_name: form.company_name.trim(),
company_code: form.company_code.trim(),
admin_email: form.admin_email.trim(),
admin_username: form.admin_username.trim(),
admin_password: String(form.admin_password || ''),
admin_password_confirm: String(form.admin_password_confirm || ''),
web_host: form.web_host.trim(),
web_port: Number(form.web_port),
server_host: form.server_host.trim(),
server_port: Number(form.server_port),
postgres_host: form.postgres_host.trim(),
postgres_port: Number(form.postgres_port),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
}
}
function buildRuntimeFingerprint(form) {
return JSON.stringify({
web_host: form.web_host.trim(),
web_port: String(form.web_port).trim(),
server_host: form.server_host.trim(),
server_port: String(form.server_port).trim()
})
}
function buildDatabaseFingerprint(form) {
return JSON.stringify({
postgres_host: form.postgres_host.trim(),
postgres_port: String(form.postgres_port).trim(),
postgres_db: form.postgres_db.trim(),
postgres_user: form.postgres_user.trim(),
postgres_password: String(form.postgres_password || ''),
redis_url: form.redis_url.trim()
})
}
function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(String(value || '').trim())
}
export function useSetupView(props, emit) {
const form = reactive(createForm(props.initialState))
const activeSection = ref('company')
let syncingFromProps = false
watch(
() => props.initialState,
(state) => {
syncingFromProps = true
Object.assign(form, createForm(state))
queueMicrotask(() => {
syncingFromProps = false
})
},
{ deep: true }
)
watch(
() => buildRuntimeFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('runtime-dirty')
}
}
)
watch(
() => buildDatabaseFingerprint(form),
(_value, oldValue) => {
if (oldValue !== undefined && !syncingFromProps) {
emit('database-dirty')
}
}
)
const companyReady = computed(() => form.company_name.trim().length >= 2)
const adminReady = computed(() => {
return Boolean(
isEmail(form.admin_email) &&
form.admin_username.trim().length >= 4 &&
String(form.admin_password || '').length >= 5 &&
form.admin_password === form.admin_password_confirm
)
})
const runtimeInputsReady = computed(() => {
return Boolean(
form.web_host.trim() &&
String(form.web_port).trim() &&
form.server_host.trim() &&
String(form.server_port).trim()
)
})
const databaseInputsReady = computed(() => {
return Boolean(
form.postgres_host.trim() &&
String(form.postgres_port).trim() &&
form.postgres_db.trim() &&
form.postgres_user.trim() &&
String(form.postgres_password || '').length > 0
)
})
const runtimeReady = computed(() => runtimeInputsReady.value && props.runtimeTestPassed)
const databaseReady = computed(() => databaseInputsReady.value && props.databaseTestPassed)
const finalReady = computed(() => companyReady.value && adminReady.value && runtimeReady.value && databaseReady.value)
const sections = computed(() => [
{
id: 'company',
index: '01',
title: '企业信息',
desc: '填写企业名称与识别编码。',
complete: companyReady.value
},
{
id: 'admin',
index: '02',
title: '管理员安全',
desc: '配置管理员邮箱、账号与密码。',
complete: adminReady.value
},
{
id: 'runtime',
index: '03',
title: '运行端口',
desc: '单独检测 Web 与后端端口占用。',
complete: runtimeReady.value
},
{
id: 'database',
index: '04',
title: '数据库',
desc: '检测 PostgreSQL 连接Redis 暂时可选。',
complete: databaseReady.value
}
])
const activeStep = computed(() => sections.value.find((section) => section.id === activeSection.value) || sections.value[0])
const completionCount = computed(() => sections.value.filter((section) => section.complete).length)
const runtimeEndpoints = computed(() => [
{
label: 'Web',
value: `${form.web_host}:${form.web_port}`
},
{
label: 'Server',
value: `${form.server_host}:${form.server_port}`
}
])
const summaryItems = computed(() => [
{
label: '企业信息',
detail: form.company_name.trim() || '未完成',
complete: companyReady.value
},
{
label: '管理员安全',
detail: form.admin_username.trim() || form.admin_email.trim() || '未完成',
complete: adminReady.value
},
{
label: '运行端口',
detail: `${form.web_host}:${form.web_port} / ${form.server_host}:${form.server_port}`,
complete: runtimeReady.value
},
{
label: '数据库',
detail: `${form.postgres_host}:${form.postgres_port}/${form.postgres_db}`,
complete: databaseReady.value
}
])
const currentTestMessage = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestMessage
}
if (activeSection.value === 'database') {
return props.databaseTestMessage
}
return ''
})
const currentTestPassed = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTestPassed
}
if (activeSection.value === 'database') {
return props.databaseTestPassed
}
return false
})
const showTestAction = computed(() => ['runtime', 'database'].includes(activeSection.value))
const testButtonLabel = computed(() => {
if (activeSection.value === 'runtime') {
return props.runtimeTesting ? '检测中...' : '检测端口占用'
}
if (activeSection.value === 'database') {
return props.databaseTesting ? '检测中...' : '检测数据库连接'
}
return ''
})
const testButtonIcon = computed(() => {
if ((activeSection.value === 'runtime' && props.runtimeTesting) || (activeSection.value === 'database' && props.databaseTesting)) {
return 'pi pi-spin pi-spinner'
}
return activeSection.value === 'runtime' ? 'pi pi-server' : 'pi pi-database'
})
const canRuntimeTest = computed(() => Boolean(runtimeInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canDatabaseTest = computed(() => Boolean(databaseInputsReady.value && !props.submitting && !props.runtimeTesting && !props.databaseTesting))
const canTest = computed(() => {
if (activeSection.value === 'runtime') {
return canRuntimeTest.value
}
if (activeSection.value === 'database') {
return canDatabaseTest.value
}
return false
})
const submitHint = computed(() => {
if (activeSection.value === 'admin') {
if (!form.admin_email.trim() && !form.admin_username.trim() && !String(form.admin_password || '').length) {
return ''
}
if (!form.admin_email.trim()) {
return '请填写管理员邮箱。'
}
if (!isEmail(form.admin_email)) {
return '管理员邮箱格式不正确。'
}
if (form.admin_username.trim() && form.admin_username.trim().length < 4) {
return '管理员账号至少 4 位。'
}
if (String(form.admin_password || '').length > 0 && String(form.admin_password || '').length < 5) {
return '管理员密码当前至少 5 位。'
}
if (
String(form.admin_password_confirm || '').length > 0 &&
form.admin_password !== form.admin_password_confirm
) {
return '两次输入的管理员密码不一致。'
}
}
if (activeSection.value === 'runtime') {
if (!runtimeInputsReady.value) {
return '请先填写 Web 与 Server 的主机和端口。'
}
if (!props.runtimeTestPassed) {
return '请先完成端口占用检测。'
}
}
if (activeSection.value === 'database') {
if (!databaseInputsReady.value) {
return '请先填写 PostgreSQL 连接信息。'
}
if (!props.databaseTestPassed) {
return '请先完成数据库连接检测。'
}
}
if (activeSection.value === 'company') {
return ''
}
if (!companyReady.value) {
return '请先完成企业信息。'
}
if (!adminReady.value) {
return '请先完成管理员安全配置。'
}
if (!runtimeReady.value) {
return '请先完成运行端口检测。'
}
if (!databaseReady.value) {
return '请先完成数据库连接检测。'
}
return ''
})
function goToSection(id) {
activeSection.value = id
}
function submitForm() {
if (!finalReady.value || props.submitting) {
return
}
emit('submit', buildPayload(form))
}
function testSetup() {
if (!canTest.value) {
return
}
const payload = buildPayload(form)
if (activeSection.value === 'runtime') {
emit('runtime-test', payload)
return
}
if (activeSection.value === 'database') {
emit('database-test', payload)
}
}
return {
activeSection,
activeStep,
canSubmit: finalReady,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
}
}

View File

@@ -0,0 +1,476 @@
import { computed, ref } from 'vue'
import {
loginBootstrapAdmin,
saveBootstrapConfig,
testBootstrapDatabase,
testBootstrapRuntime
} from '../services/bootstrap.js'
import { useToast } from './useToast.js'
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
const DEFAULT_USER_NAME = '系统管理员'
const DEFAULT_USER_ROLE = '财务管理员'
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
const authIdleTimeoutMs =
Number.isFinite(authIdleTimeoutMinutes) && authIdleTimeoutMinutes > 0
? authIdleTimeoutMinutes * 60 * 1000
: 30 * 60 * 1000
let sessionRouter = null
let sessionTimeoutHandle = 0
let sessionMonitoringInstalled = false
let lastActivityWriteAt = 0
function readClientBootstrapState() {
const env = import.meta.env
return {
initialized: String(env.VITE_SETUP_COMPLETED || '').toLowerCase() === 'true',
company: {
name: env.VITE_COMPANY_NAME || '',
code: env.VITE_COMPANY_CODE || '',
admin_email: env.VITE_ADMIN_EMAIL || ''
},
web: {
host: env.VITE_WEB_HOST || '127.0.0.1',
port: Number(env.VITE_WEB_PORT || 5173)
},
server: {
host: env.VITE_SERVER_HOST || '127.0.0.1',
port: Number(env.VITE_SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.VITE_POSTGRES_HOST || '127.0.0.1',
port: Number(env.VITE_POSTGRES_PORT || 5432),
name: env.VITE_POSTGRES_DB || 'x_financial',
username: env.VITE_POSTGRES_USER || 'postgres',
password_configured: false
},
redis: {
enabled: Boolean(env.VITE_REDIS_URL),
url: env.VITE_REDIS_URL || ''
}
}
}
function readAuthState() {
if (typeof window === 'undefined') {
return false
}
return window.sessionStorage.getItem(AUTH_STORAGE_KEY) === 'true'
}
function readStoredUsername() {
if (typeof window === 'undefined') {
return ''
}
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
}
function readLastActivityAt() {
if (typeof window === 'undefined') {
return 0
}
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
}
function buildCurrentUser(username = '') {
const normalized = String(username || '').trim()
const name = normalized || DEFAULT_USER_NAME
return {
name,
role: DEFAULT_USER_ROLE,
avatar: name.slice(0, 1).toUpperCase()
}
}
function isSessionExpired(now = Date.now()) {
if (!readAuthState()) {
return false
}
const lastActivityAt = readLastActivityAt()
if (!lastActivityAt) {
return true
}
return now - lastActivityAt > authIdleTimeoutMs
}
function persistAuthState(value, username = '') {
if (typeof window === 'undefined') {
return
}
if (value) {
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(username || '').trim())
return
}
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
}
function clearSessionTimeout() {
if (typeof window === 'undefined' || !sessionTimeoutHandle) {
return
}
window.clearTimeout(sessionTimeoutHandle)
sessionTimeoutHandle = 0
}
function redirectToLogin() {
if (sessionRouter?.currentRoute?.value?.name === 'login') {
return
}
if (sessionRouter) {
sessionRouter.replace({ name: 'login' })
return
}
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.assign('/login')
}
}
function scheduleSessionTimeout() {
clearSessionTimeout()
if (typeof window === 'undefined' || !readAuthState()) {
return
}
const lastActivityAt = readLastActivityAt()
if (!lastActivityAt) {
return
}
const remaining = authIdleTimeoutMs - (Date.now() - lastActivityAt)
if (remaining <= 0) {
logout('timeout', { notify: true })
return
}
sessionTimeoutHandle = window.setTimeout(() => {
logout('timeout', { notify: true })
}, remaining)
}
function touchAuthActivity(force = false) {
if (typeof window === 'undefined' || !readAuthState()) {
return
}
const now = Date.now()
if (!force && now - lastActivityWriteAt < 1000) {
scheduleSessionTimeout()
return
}
window.sessionStorage.setItem(AUTH_LAST_ACTIVITY_KEY, String(now))
lastActivityWriteAt = now
scheduleSessionTimeout()
}
function handleSessionActivity(event) {
if (typeof document !== 'undefined' && event?.type === 'visibilitychange' && document.visibilityState !== 'visible') {
return
}
touchAuthActivity()
}
function installSessionMonitoring() {
if (sessionMonitoringInstalled || typeof window === 'undefined') {
return
}
sessionMonitoringInstalled = true
SESSION_ACTIVITY_EVENTS.forEach((eventName) => {
window.addEventListener(eventName, handleSessionActivity, { passive: true })
})
}
function syncAuthSession(options = {}) {
const shouldNotify = Boolean(options.notify)
if (!readAuthState()) {
loggedIn.value = false
currentUser.value = buildCurrentUser('')
clearSessionTimeout()
return false
}
if (isSessionExpired()) {
logout('timeout', { notify: shouldNotify, redirect: false })
return false
}
loggedIn.value = true
currentUser.value = buildCurrentUser(readStoredUsername())
scheduleSessionTimeout()
return true
}
export function installSessionNavigation(router) {
sessionRouter = router
installSessionMonitoring()
if (readAuthState() && !isSessionExpired()) {
scheduleSessionTimeout()
}
}
const bootstrapState = ref(readClientBootstrapState())
const setupSubmitting = ref(false)
const setupError = ref('')
const runtimeTesting = ref(false)
const databaseTesting = ref(false)
const runtimeTestPassed = ref(false)
const databaseTestPassed = ref(false)
const runtimeTestMessage = ref('')
const databaseTestMessage = ref('')
const loginSubmitting = ref(false)
const loginError = ref('')
const loggedIn = ref(readAuthState() && !isSessionExpired())
const currentUser = ref(buildCurrentUser(readStoredUsername()))
if (!loggedIn.value && readAuthState()) {
persistAuthState(false)
}
const { toast } = useToast()
const companyProfile = computed(() => ({
name: bootstrapState.value.company?.name || '',
code: bootstrapState.value.company?.code || '',
adminEmail: bootstrapState.value.company?.admin_email || ''
}))
const isInitialized = computed(() => Boolean(bootstrapState.value.initialized))
function applyBootstrapState(state) {
bootstrapState.value = state
if (!state.initialized) {
logout('reset', { redirect: false })
}
}
function clearSetupRuntimeState() {
runtimeTesting.value = false
databaseTesting.value = false
runtimeTestPassed.value = false
databaseTestPassed.value = false
runtimeTestMessage.value = ''
databaseTestMessage.value = ''
setupError.value = ''
}
function resetFromClientEnv() {
applyBootstrapState(readClientBootstrapState())
clearSetupRuntimeState()
loginError.value = ''
currentUser.value = buildCurrentUser(readStoredUsername())
}
async function handleSetupSubmit(payload) {
if (!runtimeTestPassed.value) {
setupError.value = '请先完成运行端口检测。'
toast(setupError.value)
return false
}
if (!databaseTestPassed.value) {
setupError.value = '请先完成数据库连接检测。'
toast(setupError.value)
return false
}
setupSubmitting.value = true
setupError.value = ''
try {
const state = await saveBootstrapConfig(payload)
applyBootstrapState(state)
toast('初始化配置已写入。现在可以进入登录页。')
return true
} catch (error) {
setupError.value = error.message || '初始化配置写入失败,请稍后重试。'
toast(setupError.value)
return false
} finally {
setupSubmitting.value = false
}
}
async function handleRuntimeTest(payload) {
runtimeTesting.value = true
runtimeTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapRuntime(payload)
runtimeTestPassed.value = true
runtimeTestMessage.value = result.detail || '端口占用检测通过。'
toast(runtimeTestMessage.value)
} catch (error) {
runtimeTestPassed.value = false
runtimeTestMessage.value = error.message || '端口占用检测失败。'
toast(runtimeTestMessage.value)
} finally {
runtimeTesting.value = false
}
}
async function handleDatabaseTest(payload) {
databaseTesting.value = true
databaseTestMessage.value = ''
setupError.value = ''
try {
const result = await testBootstrapDatabase(payload)
databaseTestPassed.value = true
databaseTestMessage.value = result.detail || '数据库连接检测通过。'
toast(databaseTestMessage.value)
} catch (error) {
databaseTestPassed.value = false
databaseTestMessage.value = error.message || '数据库连接检测失败。'
toast(databaseTestMessage.value)
} finally {
databaseTesting.value = false
}
}
function handleRuntimeDirty() {
runtimeTestPassed.value = false
runtimeTestMessage.value = ''
if (setupError.value === '请先完成运行端口检测。') {
setupError.value = ''
}
}
function handleDatabaseDirty() {
databaseTestPassed.value = false
databaseTestMessage.value = ''
if (setupError.value === '请先完成数据库连接检测。') {
setupError.value = ''
}
}
async function handleLogin(credentials) {
loginSubmitting.value = true
loginError.value = ''
try {
await loginBootstrapAdmin({
username: credentials.username,
password: credentials.password
})
loggedIn.value = true
persistAuthState(true, credentials.username)
currentUser.value = buildCurrentUser(credentials.username)
touchAuthActivity(true)
return true
} catch (error) {
logout('invalid', { redirect: false })
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
toast(loginError.value)
return false
} finally {
loginSubmitting.value = false
}
}
function logout(reason = 'manual', options = {}) {
const notify = options.notify ?? reason === 'timeout'
const redirect = options.redirect ?? reason !== 'invalid'
loggedIn.value = false
persistAuthState(false)
currentUser.value = buildCurrentUser('')
clearSessionTimeout()
if (notify) {
toast(reason === 'timeout' ? '登录已超时,请重新登录。' : '已退出登录。')
}
if (redirect) {
redirectToLogin()
}
}
function handleRecoverPassword() {
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
}
function handleSsoLogin() {
toast('SSO 登录暂未启用。')
}
function resolveEntryRoute() {
loggedIn.value = syncAuthSession()
currentUser.value = buildCurrentUser(readStoredUsername())
if (!isInitialized.value) {
return { name: 'setup' }
}
if (!loggedIn.value) {
return { name: 'login' }
}
return { name: 'app-overview' }
}
export function useSystemState() {
return {
bootstrapState,
companyProfile,
currentUser,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleLogin,
handleRecoverPassword,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
handleSsoLogin,
isInitialized,
loggedIn,
loginError,
loginSubmitting,
logout,
resetFromClientEnv,
resolveEntryRoute,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting,
syncAuthSession
}
}

View File

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

View File

@@ -4,10 +4,15 @@ import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import 'primeicons/primeicons.css'
import App from './App.vue'
import router from './router/index.js'
import { installSessionNavigation } from './composables/useSystemState.js'
const app = createApp(App)
installSessionNavigation(router)
app.use(MotionPlugin)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,

132
web/src/router/index.js Normal file
View File

@@ -0,0 +1,132 @@
import { createRouter, createWebHistory } from 'vue-router'
import { checkBackendHealth } from '../composables/useBackendHealth.js'
import { appViews } from '../composables/useNavigation.js'
import { useSystemState } from '../composables/useSystemState.js'
import AppShellRouteView from '../views/AppShellRouteView.vue'
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
import LoginRouteView from '../views/LoginRouteView.vue'
import SetupRouteView from '../views/SetupRouteView.vue'
const appChildRoutes = appViews
.filter((view) => view !== 'requests')
.map((view) => ({
path: view,
name: `app-${view}`,
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: view
}
}))
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'root',
redirect: () => {
const { resolveEntryRoute } = useSystemState()
return resolveEntryRoute()
}
},
{
path: '/setup',
name: 'setup',
component: SetupRouteView
},
{
path: '/login',
name: 'login',
component: LoginRouteView
},
{
path: '/backend-unavailable',
name: 'backend-unavailable',
component: BackendUnavailableRouteView
},
{
path: '/app',
redirect: { name: 'app-overview' }
},
{
path: '/app/requests',
name: 'app-requests',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
{
path: '/app/requests/:requestId',
name: 'app-request-detail',
component: AppShellRouteView,
meta: {
requiresAuth: true,
appView: 'requests'
}
},
...appChildRoutes.map((route) => ({
...route,
path: `/app/${route.path}`
})),
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
})
router.beforeEach((to) => {
const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
if (!isInitialized.value) {
if (to.name !== 'setup') {
return { name: 'setup' }
}
return true
}
if (to.name === 'setup') {
return resolveEntryRoute()
}
if (authActive && to.meta.requiresAuth) {
return checkBackendHealth().then((ok) => {
if (!ok && to.name !== 'backend-unavailable') {
return { name: 'backend-unavailable' }
}
if (ok && to.name === 'backend-unavailable') {
return resolveEntryRoute()
}
return true
})
}
if (!authActive && to.meta.requiresAuth) {
return {
name: 'login',
query: {
redirect: to.fullPath
}
}
}
if (authActive && to.name === 'login') {
return resolveEntryRoute()
}
if (to.name === 'root') {
return resolveEntryRoute()
}
return true
})
export default router

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

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

78
web/src/services/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,78 @@
const SETUP_API_BASE = '/__setup'
function formatValidationErrors(detail) {
if (!Array.isArray(detail)) {
return ''
}
return detail
.map((item) => {
const field = Array.isArray(item.loc) ? item.loc[item.loc.length - 1] : 'field'
return `${field}: ${item.msg}`
})
.join('\n')
}
async function request(path, options = {}) {
let response
try {
response = await fetch(`${SETUP_API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
})
} catch {
throw new Error('无法连接初始化服务,请确认本地配置桥已启动。')
}
let data = null
try {
data = await response.json()
} catch {
data = null
}
if (!response.ok) {
const validationMessage = formatValidationErrors(data?.detail)
const message = validationMessage || data?.detail || '初始化请求失败,请稍后重试。'
throw new Error(message)
}
return data
}
export function fetchBootstrapState() {
return request('/bootstrap')
}
export function saveBootstrapConfig(payload) {
return request('/bootstrap', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function testBootstrapRuntime(payload) {
return request('/bootstrap/runtime', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function testBootstrapDatabase(payload) {
return request('/bootstrap/database', {
method: 'PUT',
body: JSON.stringify(payload)
})
}
export function loginBootstrapAdmin(payload) {
return request('/auth/login', {
method: 'POST',
body: JSON.stringify(payload)
})
}

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
function parseRequestDateFromId(id) {
const match = String(id || '').match(/^REQ-(\d{4})-(\d{2})(\d{2})$/)
if (!match) {
return ''
}
const [, year, month, day] = match
return `${year}-${month}-${day}`
}
function formatTripWindow(range) {
const normalized = String(range || '')
if (!normalized) {
return '待补充'
}
if (normalized.includes('本月')) {
return '本月申请'
}
if (normalized.includes('本周')) {
return '本周申请'
}
if (normalized.includes('今天')) {
return '今日申请'
}
return normalized
}
function mapApproval(status) {
if (status === 'success') {
return {
node: '已完成归档',
approval: '已完成',
approvalTone: 'success',
travel: '已完成行程',
travelTone: 'success'
}
}
if (status === 'danger') {
return {
node: '异常待复核',
approval: '待处理',
approvalTone: 'danger',
travel: '存在异常',
travelTone: 'danger'
}
}
return {
node: '财务审核中',
approval: '审批中',
approvalTone: 'info',
travel: '待安排行程',
travelTone: 'warning'
}
}
export function normalizeRequestForUi(request) {
if (!request) {
return null
}
const applyTime = parseRequestDateFromId(request.id) || '2026-04-18'
const reason = `${request.category || '差旅'}申请`
const city = request.entity || '待补充'
const period = formatTripWindow(request.range)
const approvalState = mapApproval(request.status)
return {
...request,
reason,
city,
period,
applyTime,
node: approvalState.node,
approval: approvalState.approval,
approvalTone: approvalState.approvalTone,
travel: approvalState.travel,
travelTone: approvalState.travelTone
}
}

View File

@@ -0,0 +1,190 @@
<template>
<div class="app">
<SidebarRail
:nav-items="navItems"
:active-view="activeView"
:current-user="currentUser"
@navigate="handleNavigate"
@open-chat="handleOpenChat"
@logout="handleLogout"
/>
<main
class="main"
:class="{
'chat-main': activeView === 'chat',
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'requests-main': activeView === 'requests',
'approval-main': activeView === 'approval',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
'employees-main': activeView === 'employees'
}"
>
<TopBar
:current-view="topBarView"
:search="search"
:active-view="activeView"
:ranges="ranges"
:active-range="activeRange"
:employee-summary="employeeSummary"
:custom-range="customRange"
@update:search="search = $event"
@update:active-range="activeRange = $event"
@update:custom-range="customRange = $event"
@batch-approve="toast('已批量通过 23 条审批任务')"
@open-chat="handleOpenChat"
@new-application="openTravelCreate"
/>
<FilterBar
v-if="activeView !== 'chat' && activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'requests' && activeView !== 'approval' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'employees'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
:active-range="activeRange"
@update:active-range="activeRange = $event"
/>
<section
class="workarea"
:class="{
'chat-workarea': activeView === 'chat',
'requests-workarea': activeView === 'requests',
'approval-workarea': activeView === 'approval',
'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit',
'employees-workarea': activeView === 'employees'
}"
>
<OverviewView
v-if="activeView === 'overview'"
:filtered-requests="filteredRequests"
@ask="handleOpenChat"
@approve="handleApprove"
@reject="handleReject"
/>
<PersonalWorkbenchView
v-else-if="activeView === 'workbench'"
@open-assistant="openSmartEntry"
/>
<ChatView
v-else-if="activeView === 'chat'"
:documents="filteredDocuments"
:doc-search="docSearch"
:messages="messages"
:uploaded-files="uploadedFiles"
:active-case="activeCase"
:quick-prompts="travelPrompts"
:draft="draft"
:message-list="messageList"
@send="sendMessage"
@upload="handleUpload"
@draft="draft = $event"
@select-case="handleOpenChat"
@approve-case="toast(`${activeCase?.id || '当前单据'} 已标记为通过`)"
@reject-case="toast(`${activeCase?.id || '当前单据'} 已标记为驳回`)"
/>
<TravelRequestDetailView
v-else-if="activeView === 'requests' && detailMode && selectedTravelRequest"
:request="selectedTravelRequest"
@back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry"
/>
<RequestsView
v-else-if="activeView === 'requests'"
:filtered-requests="filteredRequests"
@ask="openRequestDetail"
@approve="handleApprove"
@reject="handleReject"
@create-request="openTravelCreate"
/>
<ApprovalCenterView v-else-if="activeView === 'approval'" />
<PoliciesView v-else-if="activeView === 'policies'" />
<AuditView v-else-if="activeView === 'audit'" />
<EmployeeManagementView v-else @overview-change="employeeSummary = $event" />
</section>
</main>
<TravelReimbursementCreateView
v-if="smartEntryOpen"
:key="smartEntrySessionId"
:initial-prompt="smartEntryContext.prompt"
:entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request"
@close="closeSmartEntry"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import ChatView from './ChatView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import RequestsView from './RequestsView.vue'
import ApprovalCenterView from './ApprovalCenterView.vue'
import PoliciesView from './PoliciesView.vue'
import AuditView from './AuditView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
const employeeSummary = ref(null)
const {
activeCase,
activeRange,
activeView,
closeRequestDetail,
closeSmartEntry,
customRange,
detailMode,
docSearch,
draft,
filteredDocuments,
filteredRequests,
filters,
handleApprove,
handleNavigate,
handleOpenChat,
handleReject,
handleUpload,
messageList,
messages,
navItems,
openRequestDetail,
openSmartEntry,
openTravelCreate,
ranges,
search,
selectedTravelRequest,
sendMessage,
smartEntryContext,
smartEntryOpen,
smartEntrySessionId,
toast,
topBarView,
travelPrompts,
uploadedFiles
} = useAppShell()
const { currentUser, logout } = useSystemState()
function handleLogout() {
logout('manual')
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<template>
<LoginView
:company-name="companyProfile.name"
:submitting="loginSubmitting"
:error-message="loginError"
@login="submitLogin"
@recover-password="handleRecoverPassword"
@sso-login="handleSsoLogin"
/>
</template>
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import LoginView from './LoginView.vue'
const route = useRoute()
const router = useRouter()
const {
companyProfile,
handleLogin,
handleRecoverPassword,
handleSsoLogin,
loginError,
loginSubmitting,
resolveEntryRoute
} = useSystemState()
async function submitLogin(credentials) {
const passed = await handleLogin(credentials)
if (!passed) {
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : ''
if (redirect.startsWith('/app/')) {
router.replace(redirect)
return
}
router.replace(resolveEntryRoute())
}
</script>

View File

@@ -2,7 +2,7 @@
<main class="login-page">
<header class="page-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</header>
<section class="hero">
@@ -18,8 +18,8 @@
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>361,600</strong>
<small>较昨日 <b class="up"> 8.3%</b></small>
<strong>¥ 61,600</strong>
<small>较昨日 <b class="up">+8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
@@ -36,19 +36,19 @@
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger"> 16.7%</b></small>
<small>较昨日 <b class="danger">+16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up"> 6.2%</b></small>
<small>较昨日 <b class="up">+6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up"> 3.1%</b></small>
<small>较昨日 <b class="up">+3.1%</b></small>
</div>
</div>
@@ -66,18 +66,19 @@
<section class="login-card" aria-label="登录表单">
<div class="card-brand">
<LogoMark />
<strong>星海科技</strong>
<strong>{{ displayCompanyName }}</strong>
</div>
<header class="card-head">
<h2>欢迎登录</h2>
<p>登录企业报销智能运营台</p>
<p>使用初始化时创建的管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入账号 / 邮箱 / 手机号" autocomplete="username" required />
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
</label>
<label class="field">
@@ -86,7 +87,7 @@
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
placeholder="请输入管理员密码"
autocomplete="current-password"
required
/>
@@ -96,7 +97,7 @@
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-slash'"></i>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
</button>
</label>
@@ -112,16 +113,20 @@
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住</span>
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div>
<button class="submit-btn" type="submit">登录</button>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<button class="submit-btn" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
<div class="divider"><span></span></div>
<button class="sso-btn" type="button" @click="emit('sso-login')">
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
<i class="mdi mdi-shield-outline"></i>
<span>SSO 单点登录</span>
</button>
@@ -129,26 +134,37 @@
<footer class="security-note">
<i class="mdi mdi-lock-outline"></i>
<span>安全登录 · 数据加密传输 · 如需帮助请联系管理员</span>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
</footer>
</section>
</main>
</template>
<script setup>
import { computed } from 'vue'
import { useLoginView } from '../composables/useLoginView.js'
const props = defineProps({
companyName: {
type: String,
default: ''
},
submitting: {
type: Boolean,
default: false
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const {
features,
LogoMark,
password,
remember,
showPassword,
tenant,
username
} = useLoginView()
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
</script>
<style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -0,0 +1,51 @@
<template>
<SetupView
:initial-state="bootstrapState || {}"
:submitting="setupSubmitting"
:runtime-testing="runtimeTesting"
:database-testing="databaseTesting"
:runtime-test-passed="runtimeTestPassed"
:database-test-passed="databaseTestPassed"
:runtime-test-message="runtimeTestMessage"
:database-test-message="databaseTestMessage"
:error-message="setupError"
@submit="submitSetup"
@runtime-test="handleRuntimeTest"
@database-test="handleDatabaseTest"
@runtime-dirty="handleRuntimeDirty"
@database-dirty="handleDatabaseDirty"
/>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useSystemState } from '../composables/useSystemState.js'
import SetupView from './SetupView.vue'
const router = useRouter()
const {
bootstrapState,
databaseTestMessage,
databaseTestPassed,
databaseTesting,
handleDatabaseDirty,
handleDatabaseTest,
handleRuntimeDirty,
handleRuntimeTest,
handleSetupSubmit,
runtimeTestMessage,
runtimeTestPassed,
runtimeTesting,
setupError,
setupSubmitting
} = useSystemState()
async function submitSetup(payload) {
const completed = await handleSetupSubmit(payload)
if (completed) {
router.replace({ name: 'login' })
}
}
</script>

316
web/src/views/SetupView.vue Normal file
View File

@@ -0,0 +1,316 @@
<template>
<main class="setup-page">
<aside class="setup-context">
<div class="setup-brand">
<div class="setup-brand-mark" aria-hidden="true">
<span class="setup-brand-ring"></span>
<span class="setup-brand-core">XF</span>
</div>
<div>
<p class="setup-kicker">INITIAL SETUP</p>
<h1>初始化配置</h1>
</div>
</div>
<p class="setup-lead">
先完成 4 个必要步骤再进入主登录界面扩展服务当前不参与初始化完成条件
</p>
<nav class="setup-nav" aria-label="初始化步骤">
<button
v-for="section in sections"
:key="section.id"
class="setup-nav-item"
:class="{ 'is-active': activeSection === section.id, 'is-complete': section.complete }"
type="button"
@click="goToSection(section.id)"
>
<span class="setup-nav-index">{{ section.index }}</span>
<span class="setup-nav-copy">
<strong>{{ section.title }}</strong>
<small>{{ section.desc }}</small>
</span>
<i v-if="section.complete" class="pi pi-check setup-nav-check"></i>
</button>
</nav>
<div class="setup-progress">
<strong>{{ completionCount }} / {{ sections.length }} 已完成</strong>
<p>企业信息管理员安全运行端口数据库连接都通过后左下角会自动出现完成初始化按钮</p>
</div>
<div v-if="canSubmit" class="setup-complete">
<p>所有必要步骤已通过检测可以写入配置并进入登录界面</p>
<button class="primary-btn setup-complete-btn" type="button" :disabled="submitting" @click="submitForm">
<i class="pi pi-check"></i>
<span>{{ submitting ? '写入配置中...' : '完成初始化并进入登录' }}</span>
</button>
</div>
</aside>
<section class="setup-panel">
<header class="setup-panel-head">
<div>
<p class="setup-kicker setup-kicker-light">{{ activeStep.index }}</p>
<h2>{{ activeStep.title }}</h2>
<p class="setup-panel-desc">{{ activeStep.desc }}</p>
</div>
<span class="setup-chip" :class="{ 'is-success': activeStep.complete }">
{{ activeStep.complete ? '已完成' : '待配置' }}
</span>
</header>
<div class="setup-form">
<section v-if="activeSection === 'company'" class="setup-stage">
<div class="section-head">
<h3>企业基础信息</h3>
<p>这里仅保留企业名称与企业编码不放管理员邮箱</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>企业名称</span>
<input v-model.trim="form.company_name" type="text" placeholder="请输入企业名称" required />
</label>
<label class="field">
<span>企业编码</span>
<input v-model.trim="form.company_code" type="text" placeholder="例如 FIN" />
</label>
</div>
</section>
<section v-else-if="activeSection === 'admin'" class="setup-stage">
<div class="section-head">
<h3>管理员安全</h3>
<p>管理员邮箱账号和密码在这里配置密码不会写入 `.env`只会保存哈希后的密文</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>管理员邮箱</span>
<input v-model.trim="form.admin_email" type="email" placeholder="admin@company.com" />
</label>
<label class="field">
<span>管理员账号</span>
<input v-model.trim="form.admin_username" type="text" placeholder="例如 superadmin" required />
</label>
<label class="field">
<span>管理员密码</span>
<input
v-model="form.admin_password"
type="password"
placeholder="请输入管理员密码"
autocomplete="new-password"
required
/>
</label>
<label class="field">
<span>确认密码</span>
<input
v-model="form.admin_password_confirm"
type="password"
placeholder="请再次输入管理员密码"
autocomplete="new-password"
required
/>
</label>
</div>
<p class="field-group-note">管理员密码当前暂定至少 5 </p>
</section>
<section v-else-if="activeSection === 'runtime'" class="setup-stage">
<div class="section-head">
<h3>运行端口配置</h3>
<p>这一步只检测 Web Server 端口占用情况不检测数据库</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>Web Host</span>
<input v-model.trim="form.web_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Web Port</span>
<input v-model.number="form.web_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>Server Host</span>
<input v-model.trim="form.server_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>Server Port</span>
<input v-model.number="form.server_port" type="number" min="1" max="65535" required />
</label>
</div>
<div class="setup-runtime">
<article v-for="item in runtimeEndpoints" :key="item.label">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</article>
</div>
</section>
<section v-else class="setup-stage">
<div class="section-head">
<h3>数据库连接</h3>
<p>这里检测 PostgreSQL 连接Redis 作为扩展服务暂时可选不影响完成初始化</p>
</div>
<div class="field-grid field-grid-2">
<label class="field">
<span>PostgreSQL Host</span>
<input v-model.trim="form.postgres_host" type="text" placeholder="127.0.0.1" required />
</label>
<label class="field">
<span>PostgreSQL Port</span>
<input v-model.number="form.postgres_port" type="number" min="1" max="65535" required />
</label>
<label class="field">
<span>数据库名称</span>
<input v-model.trim="form.postgres_db" type="text" placeholder="x_financial" required />
</label>
<label class="field">
<span>数据库用户</span>
<input v-model.trim="form.postgres_user" type="text" placeholder="postgres" required />
</label>
<label class="field field-span-2">
<span>数据库密码</span>
<input
v-model="form.postgres_password"
type="password"
placeholder="请输入数据库密码"
autocomplete="new-password"
required
/>
</label>
</div>
<div class="optional-block">
<div class="optional-block-head">
<strong>扩展服务</strong>
<span>可选</span>
</div>
<label class="field">
<span>Redis URL</span>
<input v-model.trim="form.redis_url" type="text" placeholder="redis://127.0.0.1:6379/0" />
</label>
</div>
<div class="setup-summary-grid">
<article v-for="item in summaryItems" :key="item.label" class="setup-summary-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.detail }}</span>
</div>
<i :class="['pi', item.complete ? 'pi-check-circle' : 'pi-clock']"></i>
</article>
</div>
</section>
<p v-if="currentTestMessage" :class="['setup-status', currentTestPassed ? 'is-success' : 'is-danger']">
{{ currentTestMessage }}
</p>
<p v-if="errorMessage" class="setup-error">{{ errorMessage }}</p>
<p v-if="submitHint" class="setup-gate">{{ submitHint }}</p>
<footer class="setup-actions">
<div class="setup-actions-right">
<button
v-if="showTestAction"
class="secondary-btn secondary-btn-strong"
type="button"
:disabled="!canTest"
@click="testSetup"
>
<i :class="testButtonIcon"></i>
<span>{{ testButtonLabel }}</span>
</button>
</div>
</footer>
</div>
</section>
</main>
</template>
<script setup>
import { useSetupView } from '../composables/useSetupView.js'
const props = defineProps({
initialState: {
type: Object,
default: () => ({})
},
submitting: {
type: Boolean,
default: false
},
runtimeTesting: {
type: Boolean,
default: false
},
databaseTesting: {
type: Boolean,
default: false
},
runtimeTestPassed: {
type: Boolean,
default: false
},
databaseTestPassed: {
type: Boolean,
default: false
},
runtimeTestMessage: {
type: String,
default: ''
},
databaseTestMessage: {
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
}
})
const emit = defineEmits(['submit', 'runtime-test', 'database-test', 'runtime-dirty', 'database-dirty'])
const {
activeSection,
activeStep,
canSubmit,
canTest,
completionCount,
currentTestMessage,
currentTestPassed,
form,
goToSection,
runtimeEndpoints,
sections,
showTestAction,
submitForm,
submitHint,
summaryItems,
testButtonIcon,
testButtonLabel,
testSetup
} = useSetupView(props, emit)
</script>
<style scoped src="../assets/styles/views/setup-view.css"></style>

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
export default {
name: 'RequestsView',
props: {
filteredRequests: { type: Array, required: true }
},
emits: ['ask', 'approve', 'reject', 'create-request'] ,
},
emits: ['ask', 'approve', 'reject', 'create-request'],
setup(props, { emit }) {
const activeTab = ref('全部')
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
@@ -18,49 +20,28 @@ export default {
const appliedEnd = ref('')
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) return `${appliedStart.value} ~ ${appliedEnd.value}`
if (appliedStart.value && appliedEnd.value) {
return `${appliedStart.value} ~ ${appliedEnd.value}`
}
return '选择时间段'
})
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) return
if (!rangeStart.value || !rangeEnd.value) {
return
}
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
const rows = [
{ id: 'BR240715001', reason: '华东区域客户拜访', city: '上海、苏州、杭州', period: '07-14~07-17 (4天)', applyTime: '2024-07-13', amount: '¥4,280.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240714010', reason: '年度战略合作伙伴会议', city: '北京', period: '07-15~07-16 (2天)', applyTime: '2024-07-12', amount: '¥1,860.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240713008', reason: '产品培训与交流', city: '深圳', period: '07-10~07-12 (3天)', applyTime: '2024-07-09', amount: '¥2,150.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240712001', reason: '客户方案汇报', city: '上海', period: '07-08~07-11 (4天)', applyTime: '2024-07-07', amount: '¥3,680.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240711005', reason: '华南区域市场调研', city: '广州、佛山', period: '07-09~07-11 (3天)', applyTime: '2024-07-06', amount: '¥1,920.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240710003', reason: '供应商现场考察', city: '东莞', period: '07-06~07-07 (2天)', applyTime: '2024-07-05', amount: '¥680.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240709005', reason: '客户方案汇报', city: '北京', period: '07-06~07-08 (3天)', applyTime: '2024-07-05', amount: '¥1,980.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240708012', reason: '供应商现场考察', city: '广州', period: '07-04~07-05 (2天)', applyTime: '2024-07-03', amount: '¥860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240707003', reason: '项目启动会', city: '成都', period: '07-01~07-03 (3天)', applyTime: '2024-06-29', amount: '¥2,420.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240706009', reason: '客户拜访与市场调研', city: '南京、合肥', period: '06-28~06-30 (3天)', applyTime: '2024-06-26', amount: '¥1,750.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240705007', reason: '技术交流会', city: '武汉', period: '06-25~06-26 (2天)', applyTime: '2024-06-23', amount: '¥1,120.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240704004', reason: '渠道合作洽谈', city: '西安', period: '06-20~06-21 (2天)', applyTime: '2024-06-18', amount: '¥780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240703011', reason: '新员工入职培训', city: '长沙', period: '06-18~06-19 (2天)', applyTime: '2024-06-16', amount: '¥920.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240702006', reason: '季度业绩复盘会', city: '杭州', period: '06-15~06-16 (2天)', applyTime: '2024-06-13', amount: '¥1,350.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240701002', reason: '智慧金融峰会参展', city: '上海', period: '06-12~06-14 (3天)', applyTime: '2024-06-10', amount: '¥5,680.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240630009', reason: '西南区域渠道拓展', city: '重庆、贵阳', period: '06-10~06-13 (4天)', applyTime: '2024-06-08', amount: '¥3,450.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240629003', reason: '信息安全合规审计', city: '深圳', period: '06-08~06-09 (2天)', applyTime: '2024-06-06', amount: '¥1,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240628007', reason: '产学研合作对接', city: '南京', period: '06-05~06-07 (3天)', applyTime: '2024-06-03', amount: '¥2,260.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240627001', reason: 'ERP系统上线支持', city: '青岛', period: '06-03~06-05 (3天)', applyTime: '2024-06-01', amount: '¥1,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240626004', reason: '大客户续约洽谈', city: '天津', period: '06-01~06-02 (2天)', applyTime: '2024-05-29', amount: '¥890.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240625010', reason: '区域销售团队建设', city: '厦门', period: '05-28~05-30 (3天)', applyTime: '2024-05-26', amount: '¥2,780.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240624002', reason: '供应链管理系统演示', city: '苏州', period: '05-25~05-26 (2天)', applyTime: '2024-05-23', amount: '¥650.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240623008', reason: '行业白皮书发布会', city: '北京', period: '05-22~05-23 (2天)', applyTime: '2024-05-20', amount: '¥1,560.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240622005', reason: '跨部门协同工作坊', city: '大连', period: '05-20~05-22 (3天)', applyTime: '2024-05-18', amount: '¥2,340.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '无需预订', travelTone: 'neutral' },
{ id: 'BR240621003', reason: '数字化转型的客户交流', city: '深圳、珠海', period: '05-16~05-18 (3天)', applyTime: '2024-05-14', amount: '¥3,120.00', node: '待提交', approval: '待提交', approvalTone: 'info', travel: '待预订酒店', travelTone: 'warning' },
{ id: 'BR240620006', reason: '年中预算评审会', city: '上海', period: '05-13~05-14 (2天)', applyTime: '2024-05-11', amount: '¥1,480.00', node: '财务审核', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240619001', reason: '医疗行业解决方案展', city: '成都', period: '05-10~05-12 (3天)', applyTime: '2024-05-08', amount: '¥3,860.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240618009', reason: '东北区域客户回访', city: '沈阳、长春', period: '05-06~05-09 (4天)', applyTime: '2024-05-04', amount: '¥4,520.00', node: '部门负责人审批', approval: '审批中', approvalTone: 'info', travel: '待订机票', travelTone: 'warning' },
{ id: 'BR240617007', reason: '大数据平台技术对接', city: '杭州', period: '05-03~05-05 (3天)', applyTime: '2024-05-01', amount: '¥2,180.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' },
{ id: 'BR240616004', reason: '国际业务合规培训', city: '北京', period: '04-28~04-30 (3天)', applyTime: '2024-04-26', amount: '¥2,960.00', node: '已完成', approval: '已完成', approvalTone: 'success', travel: '已订酒店/机票', travelTone: 'success' }
]
const rows = computed(() =>
props.filteredRequests
.map((item) => normalizeRequestForUi(item))
.filter(Boolean)
)
const currentPage = ref(1)
const pageSize = ref(10)
@@ -74,8 +55,27 @@ export default {
}
const filteredRows = computed(() => {
if (activeTab.value === '全部') return rows
return rows.filter((row) => row.approval === activeTab.value || row.travel.includes(activeTab.value.replace('待出行', '待订')))
if (activeTab.value === '全部') {
return rows.value
}
if (activeTab.value === '待提交') {
return rows.value.filter((row) => row.approval === '待提交')
}
if (activeTab.value === '审批中') {
return rows.value.filter((row) => row.approval === '审批中')
}
if (activeTab.value === '待出行') {
return rows.value.filter((row) => row.travel.includes('待'))
}
if (activeTab.value === '已完成') {
return rows.value.filter((row) => row.approval === '已完成')
}
return rows.value
})
const totalCount = computed(() => filteredRows.value.length)
@@ -86,7 +86,9 @@ export default {
return filteredRows.value.slice(start, start + pageSize.value)
})
watch(activeTab, () => { currentPage.value = 1 })
watch([activeTab, rows], () => {
currentPage.value = 1
})
return {
emit,
@@ -113,4 +115,3 @@ export default {
}
}
}

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# ============================================================
# X-Financial Reimbursement Admin - Start Script
# ============================================================
export MSYS_NO_PATHCONV=1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ROOT_ENV_FILE="$ROOT_DIR/.env"
MODE="${1:-start}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@@ -18,69 +18,160 @@ 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"
if [ -f "$ROOT_ENV_FILE" ]; then
set -a
. "$ROOT_ENV_FILE"
set +a
fi
if ! command -v npm &>/dev/null; then
error "npm is not installed. It should come with Node.js."
fi
WEB_HOST="${WEB_HOST:-127.0.0.1}"
WEB_PORT="${WEB_PORT:-5173}"
info "Node.js $(node -v) | npm $(npm -v)"
export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"
export VITE_COMPANY_CODE="${COMPANY_CODE:-}"
export VITE_ADMIN_EMAIL="${ADMIN_EMAIL:-}"
export VITE_WEB_HOST="${WEB_HOST}"
export VITE_WEB_PORT="${WEB_PORT}"
export VITE_SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
export VITE_SERVER_PORT="${SERVER_PORT:-8000}"
export VITE_POSTGRES_HOST="${POSTGRES_HOST:-127.0.0.1}"
export VITE_POSTGRES_PORT="${POSTGRES_PORT:-5432}"
export VITE_POSTGRES_DB="${POSTGRES_DB:-x_financial}"
export VITE_POSTGRES_USER="${POSTGRES_USER:-postgres}"
export VITE_REDIS_URL="${REDIS_URL:-}"
# ----------------------------------------------------------
# WSL on a Windows-mounted repo should reuse Windows Node
# ----------------------------------------------------------
is_wsl() {
grep -qi microsoft /proc/version 2>/dev/null
}
is_windows_mount() {
is_windows_path() {
case "$SCRIPT_DIR" in
/mnt/*) return 0 ;;
/d/*|/c/*|/e/*|/f/*) return 0 ;;
*) return 1 ;;
esac
}
if is_wsl && is_windows_mount && command -v powershell.exe &>/dev/null && command -v wslpath &>/dev/null; then
WIN_PATH="$(wslpath -w "$SCRIPT_DIR")"
WIN_PATH_PS="${WIN_PATH//\'/\'\'}"
info "Detected WSL on a Windows-mounted project"
info "Using Windows npm to avoid cross-platform node_modules installs"
info "Access: http://127.0.0.1:5173"
echo ""
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$WIN_PATH_PS'; npm start"
fi
use_windows_npm() {
is_wsl && is_windows_path && command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1
}
windows_project_path() {
wslpath -w "$SCRIPT_DIR"
}
run_windows_powershell() {
local command="$1"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$command"
}
run_windows_npm_install() {
local win_path
local win_path_ps
win_path="$(windows_project_path)"
win_path_ps="${win_path//\'/\'\'}"
run_windows_powershell "Set-Location -LiteralPath '$win_path_ps'; npm install"
}
run_windows_npm_start() {
local win_path
local win_path_ps
win_path="$(windows_project_path)"
win_path_ps="${win_path//\'/\'\'}"
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$win_path_ps'; npm start -- --host $WEB_HOST --port $WEB_PORT"
}
# ----------------------------------------------------------
# Install dependencies only when they are missing or unusable
# ----------------------------------------------------------
dependencies_ready() {
[ -d "node_modules" ] || return 1
[ -f "node_modules/vite/bin/vite.js" ] || return 1
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
[ -f "node_modules/pg/package.json" ] || return 1
[ -f "node_modules/vue-router/package.json" ] || return 1
node -e "require('rollup')" >/dev/null 2>&1
if use_windows_npm; then
local win_path
local win_path_ps
win_path="$(windows_project_path)"
win_path_ps="${win_path//\'/\'\'}"
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Set-Location -LiteralPath '$win_path_ps'; node -e \"require('rollup'); require('pg'); require('vue-router')\"" >/dev/null 2>&1
return $?
fi
[ -e "node_modules/.bin/vite" ] || [ -e "node_modules/.bin/vite.cmd" ] || return 1
node -e "require('rollup'); require('pg'); require('vue-router')" >/dev/null 2>&1
}
if ! dependencies_ready; then
warn "Dependencies are missing or incomplete"
info "Running npm install..."
ensure_runtime_tools() {
if use_windows_npm; then
info "Detected WSL on a Windows-mounted project"
info "Using Windows npm to manage web dependencies"
if ! run_windows_powershell "node -v > \$null; npm -v > \$null" >/dev/null 2>&1; then
error "Windows Node.js/npm is not available in PATH. Install Node.js on Windows first."
fi
return 0
fi
if ! command -v node >/dev/null 2>&1; then
error "Node.js is not installed. Install it first: https://nodejs.org"
fi
if ! command -v npm >/dev/null 2>&1; then
error "npm is not installed. It should come with Node.js."
fi
info "Node.js $(node -v) | npm $(npm -v)"
}
ensure_dependencies() {
ensure_runtime_tools
if dependencies_ready; then
info "Web dependencies are ready."
return 0
fi
warn "Web dependencies are missing or incomplete"
info "Installing web dependencies..."
if use_windows_npm; then
run_windows_npm_install
else
npm install
fi
if ! dependencies_ready; then
error "Dependencies are still incomplete after npm install. Try deleting node_modules and running npm install manually."
error "Web dependencies are still incomplete after installation. Try deleting web/node_modules and running npm install manually."
fi
fi
# ----------------------------------------------------------
# Start dev server
# ----------------------------------------------------------
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://127.0.0.1:5173"
echo ""
info "Web dependencies are ready."
}
exec npm start
start_dev_server() {
info "Starting X-Financial Reimbursement Admin..."
info "Access: http://$WEB_HOST:$WEB_PORT"
echo ""
if use_windows_npm; then
run_windows_npm_start
fi
exec npm start -- --host "$WEB_HOST" --port "$WEB_PORT"
}
case "$MODE" in
deps)
ensure_dependencies
;;
start)
ensure_dependencies
start_dev_server
;;
*)
error "Unknown mode: $MODE. Use one of: deps, start"
;;
esac

View File

@@ -1,6 +1,697 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const envFile = path.join(rootDir, '.env')
const envExampleFile = path.join(rootDir, '.env.example')
const adminSecretDir = path.join(rootDir, 'server', '.secrets')
const adminSecretFile = path.join(adminSecretDir, 'admin.json')
const adminScryptOptions = { N: 16384, r: 8, p: 1 }
const adminScryptKeyLength = 64
function ensureEnvFile() {
if (fs.existsSync(envFile)) {
return
}
if (fs.existsSync(envExampleFile)) {
fs.copyFileSync(envExampleFile, envFile)
return
}
fs.writeFileSync(envFile, '', 'utf8')
}
function ensureAdminSecretDir() {
fs.mkdirSync(adminSecretDir, { recursive: true })
}
function parseEnv(text) {
const result = {}
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex === -1) {
continue
}
const key = trimmed.slice(0, separatorIndex).trim()
let value = trimmed.slice(separatorIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
function readEnvState() {
ensureEnvFile()
return parseEnv(fs.readFileSync(envFile, 'utf8'))
}
function readAdminSecret() {
if (!fs.existsSync(adminSecretFile)) {
return null
}
try {
const payload = JSON.parse(fs.readFileSync(adminSecretFile, 'utf8'))
if (
payload &&
payload.algorithm === 'scrypt' &&
typeof payload.username === 'string' &&
typeof payload.salt === 'string' &&
typeof payload.derived_key === 'string'
) {
return payload
}
} catch {
return null
}
return null
}
function hashAdminPassword(password, salt, keyLength = adminScryptKeyLength, options = adminScryptOptions) {
return scryptSync(password, Buffer.from(salt, 'hex'), keyLength, options)
}
function persistAdminCredentials(payload) {
ensureAdminSecretDir()
const existing = readAdminSecret()
const salt = randomBytes(16).toString('hex')
const now = new Date().toISOString()
const derivedKey = hashAdminPassword(String(payload.admin_password || ''), salt)
const record = {
version: 1,
algorithm: 'scrypt',
username: String(payload.admin_username || '').trim(),
salt,
derived_key: derivedKey.toString('hex'),
key_length: adminScryptKeyLength,
...adminScryptOptions,
created_at: existing?.created_at || now,
updated_at: now
}
fs.writeFileSync(adminSecretFile, `${JSON.stringify(record, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600
})
}
function verifyAdminCredentials(username, password) {
const record = readAdminSecret()
if (!record) {
throw new Error('管理员账号尚未初始化,请先完成初始化配置。')
}
if (record.username !== String(username || '').trim()) {
return false
}
const derivedKey = hashAdminPassword(
String(password || ''),
record.salt,
Number(record.key_length || adminScryptKeyLength),
{
N: Number(record.N || adminScryptOptions.N),
r: Number(record.r || adminScryptOptions.r),
p: Number(record.p || adminScryptOptions.p)
}
)
const storedKey = Buffer.from(record.derived_key, 'hex')
if (storedKey.length !== derivedKey.length) {
return false
}
return timingSafeEqual(storedKey, derivedKey)
}
function normalizeLoopbackHost(host) {
const normalized = String(host || '').trim().toLowerCase()
if (normalized === 'localhost' || normalized === '::1') {
return '127.0.0.1'
}
if (normalized === '::') {
return '0.0.0.0'
}
return normalized
}
function resolveClientHost(host) {
const normalizedHost = normalizeLoopbackHost(host)
if (!normalizedHost || normalizedHost === '0.0.0.0') {
return '127.0.0.1'
}
return String(host || '').trim()
}
function hostsConflict(left, right) {
const normalizedLeft = normalizeLoopbackHost(left)
const normalizedRight = normalizeLoopbackHost(right)
if (!normalizedLeft || !normalizedRight) {
return false
}
if (normalizedLeft === normalizedRight) {
return true
}
return normalizedLeft === '0.0.0.0' || normalizedRight === '0.0.0.0'
}
function serializeEnvValue(value) {
const stringValue = value == null ? '' : String(value)
if (stringValue === '') {
return ''
}
if (/^[A-Za-z0-9_./:-]+$/u.test(stringValue)) {
return stringValue
}
return `'${stringValue.replace(/'/gu, `'\\''`)}'`
}
function updateEnvFile(updates) {
ensureEnvFile()
let content = fs.readFileSync(envFile, 'utf8')
const existingLines = content ? content.split(/\r?\n/u) : []
const remainingKeys = new Set(Object.keys(updates))
const nextLines = existingLines.map((line) => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
return line
}
const separatorIndex = line.indexOf('=')
if (separatorIndex === -1) {
return line
}
const key = line.slice(0, separatorIndex).trim()
if (!remainingKeys.has(key)) {
return line
}
remainingKeys.delete(key)
return `${key}=${serializeEnvValue(updates[key])}`
})
for (const key of remainingKeys) {
nextLines.push(`${key}=${serializeEnvValue(updates[key])}`)
}
content = `${nextLines.join('\n').replace(/\n+$/u, '')}\n`
fs.writeFileSync(envFile, content, 'utf8')
}
function buildDatabaseUrl(payload) {
const username = encodeURIComponent(payload.postgres_user)
const password = encodeURIComponent(payload.postgres_password)
return `postgresql+psycopg://${username}:${password}@${payload.postgres_host}:${payload.postgres_port}/${payload.postgres_db}`
}
function buildCorsOrigins(payload) {
const webHost = String(payload.web_host || '').trim()
const webPort = String(payload.web_port || '').trim()
const origins = new Set()
const normalizedHost = normalizeLoopbackHost(webHost)
if (normalizedHost === '0.0.0.0') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
origins.add(`http://0.0.0.0:${webPort}`)
} else {
origins.add(`http://${webHost}:${webPort}`)
if (normalizedHost === '127.0.0.1') {
origins.add(`http://127.0.0.1:${webPort}`)
origins.add(`http://localhost:${webPort}`)
}
}
return JSON.stringify([...origins])
}
function buildApiBaseUrl(payload, currentEnv) {
const apiPrefix = currentEnv.API_V1_PREFIX || '/api/v1'
const host = resolveClientHost(payload.server_host)
const port = String(payload.server_port || '').trim()
return `http://${host}:${port}${apiPrefix}`
}
function buildClientEnvUpdates(payload, apiBaseUrl) {
return {
VITE_SETUP_COMPLETED: 'true',
VITE_COMPANY_NAME: String(payload.company_name || '').trim(),
VITE_COMPANY_CODE: String(payload.company_code || '').trim(),
VITE_ADMIN_EMAIL: String(payload.admin_email || '').trim(),
VITE_WEB_HOST: String(payload.web_host || '').trim(),
VITE_WEB_PORT: String(payload.web_port || '').trim(),
VITE_SERVER_HOST: String(payload.server_host || '').trim(),
VITE_SERVER_PORT: String(payload.server_port || '').trim(),
VITE_POSTGRES_HOST: String(payload.postgres_host || '').trim(),
VITE_POSTGRES_PORT: String(payload.postgres_port || '').trim(),
VITE_POSTGRES_DB: String(payload.postgres_db || '').trim(),
VITE_POSTGRES_USER: String(payload.postgres_user || '').trim(),
VITE_REDIS_URL: String(payload.redis_url || '').trim(),
VITE_API_BASE_URL: apiBaseUrl
}
}
function normalizeState(env) {
return {
initialized: String(env.SETUP_COMPLETED || '').toLowerCase() === 'true',
company: {
name: env.COMPANY_NAME || '',
code: env.COMPANY_CODE || '',
admin_email: env.ADMIN_EMAIL || ''
},
admin: {
configured: Boolean(readAdminSecret())
},
web: {
host: env.WEB_HOST || '127.0.0.1',
port: Number(env.WEB_PORT || 5173)
},
server: {
host: env.SERVER_HOST || '127.0.0.1',
port: Number(env.SERVER_PORT || 8000)
},
database: {
driver: 'postgresql',
host: env.POSTGRES_HOST || '127.0.0.1',
port: Number(env.POSTGRES_PORT || 5432),
name: env.POSTGRES_DB || 'x_financial',
username: env.POSTGRES_USER || 'postgres',
password_configured: Boolean(env.POSTGRES_PASSWORD)
},
redis: {
enabled: Boolean(env.REDIS_URL),
url: env.REDIS_URL || ''
}
}
}
async function readJsonBody(req) {
const chunks = []
for await (const chunk of req) {
chunks.push(chunk)
}
const raw = Buffer.concat(chunks).toString('utf8')
return raw ? JSON.parse(raw) : {}
}
function sendJson(res, statusCode, payload) {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.end(JSON.stringify(payload))
}
function validateRuntimePayload(payload) {
const fields = [
['web_host', 'Web Host'],
['server_host', 'Server Host']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const portFields = [
['web_port', 'Web Port'],
['server_port', 'Server Port']
]
for (const [field, label] of portFields) {
const value = Number(payload[field])
if (!Number.isInteger(value) || value < 1 || value > 65535) {
return `${label} 必须在 1 到 65535 之间。`
}
}
return ''
}
function validateDatabasePayload(payload) {
const fields = [
['postgres_host', 'PostgreSQL Host'],
['postgres_db', '数据库名称'],
['postgres_user', '数据库用户']
]
for (const [field, label] of fields) {
if (!String(payload[field] ?? '').trim()) {
return `请填写 ${label}`
}
}
const port = Number(payload.postgres_port)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return 'PostgreSQL Port 必须在 1 到 65535 之间。'
}
if (!String(payload.postgres_password || '').length) {
return '请填写数据库密码。'
}
return ''
}
function validateIdentityPayload(payload) {
const companyName = String(payload.company_name || '').trim()
const adminEmail = String(payload.admin_email || '').trim()
const adminUsername = String(payload.admin_username || '').trim()
const adminPassword = String(payload.admin_password || '')
const adminPasswordConfirm = String(payload.admin_password_confirm || '')
if (companyName.length < 2) {
return '企业名称至少 2 个字符。'
}
if (!adminEmail) {
return '请填写管理员邮箱。'
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(adminEmail)) {
return '管理员邮箱格式不正确。'
}
if (adminUsername.length < 4) {
return '管理员账号至少 4 位。'
}
if (!/^[A-Za-z0-9._@-]+$/u.test(adminUsername)) {
return '管理员账号仅允许字母、数字、点、下划线、中划线和 @。'
}
if (adminPassword.length < 5) {
return '管理员密码当前至少 5 位。'
}
if (adminPassword !== adminPasswordConfirm) {
return '两次输入的管理员密码不一致。'
}
return ''
}
function validateSetupPayload(payload) {
return validateIdentityPayload(payload) || validateRuntimePayload(payload) || validateDatabasePayload(payload)
}
function canReuseCurrentWebPort(payload, currentEnv) {
return (
Number(payload.web_port) === Number(currentEnv.WEB_PORT || 5173) &&
hostsConflict(String(payload.web_host || '').trim(), currentEnv.WEB_HOST || '127.0.0.1')
)
}
async function assertPortAvailable(host, port) {
await new Promise((resolve, reject) => {
const tester = net.createServer()
tester.once('error', (error) => {
tester.close()
reject(error)
})
tester.once('listening', () => {
tester.close(() => resolve())
})
tester.listen(port, host)
})
}
async function testRuntimePorts(payload, currentEnv) {
const webPort = Number(payload.web_port)
const serverPort = Number(payload.server_port)
const webHost = String(payload.web_host || '').trim()
const serverHost = String(payload.server_host || '').trim()
if (webPort === serverPort && hostsConflict(webHost, serverHost)) {
throw new Error('Web 与 Server 不能使用同一个主机与端口组合。')
}
if (!canReuseCurrentWebPort(payload, currentEnv)) {
try {
await assertPortAvailable(webHost, webPort)
} catch {
throw new Error(`Web 端口 ${webHost}:${webPort} 已被占用。`)
}
}
try {
await assertPortAvailable(serverHost, serverPort)
} catch {
throw new Error(`Server 端口 ${serverHost}:${serverPort} 已被占用。`)
}
}
async function loadPgClient() {
try {
const module = await import('pg')
return module.Client
} catch {
throw new Error('缺少 Node 侧 PostgreSQL 驱动 pgweb/node_modules/pg。请先执行 bash start.sh或进入 web 目录执行 npm install。')
}
}
async function testDatabaseConnection(payload) {
const Client = await loadPgClient()
const client = new Client({
host: String(payload.postgres_host || '').trim(),
port: Number(payload.postgres_port),
database: String(payload.postgres_db || '').trim(),
user: String(payload.postgres_user || '').trim(),
password: String(payload.postgres_password || ''),
connectionTimeoutMillis: 5000
})
try {
await client.connect()
await client.query('SELECT 1')
} finally {
await client.end().catch(() => {})
}
}
function localSetupPlugin() {
return {
name: 'local-setup-api',
configureServer(server) {
server.middlewares.use('/__setup/auth/login', async (req, res) => {
try {
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const username = String(payload.username || '').trim()
const password = String(payload.password || '')
if (!username || !password) {
sendJson(res, 400, { detail: '请输入管理员账号和密码。' })
return
}
const passed = verifyAdminCredentials(username, password)
if (!passed) {
sendJson(res, 401, { detail: '管理员账号或密码错误。' })
return
}
sendJson(res, 200, {
ok: true,
detail: '登录成功。',
user: {
username
}
})
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '管理员登录校验失败。'
})
}
})
server.middlewares.use('/__setup/bootstrap/runtime', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateRuntimePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload, readEnvState())
sendJson(res, 200, { ok: true, detail: '端口占用检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '端口占用检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '运行端口检测服务异常。'
})
}
})
server.middlewares.use('/__setup/bootstrap/database', async (req, res) => {
try {
if (req.method !== 'PUT') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateDatabasePayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testDatabaseConnection(payload)
sendJson(res, 200, { ok: true, detail: '数据库连接检测通过。' })
} catch (error) {
sendJson(res, 400, {
ok: false,
detail: error instanceof Error ? error.message : '数据库连接检测失败。'
})
}
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '数据库检测服务异常。'
})
}
})
server.middlewares.use('/__setup/bootstrap', async (req, res) => {
try {
if (req.method === 'GET') {
sendJson(res, 200, normalizeState(readEnvState()))
return
}
if (req.method !== 'POST') {
sendJson(res, 405, { detail: 'Method not allowed' })
return
}
const payload = await readJsonBody(req)
const validationError = validateSetupPayload(payload)
if (validationError) {
sendJson(res, 400, { detail: validationError })
return
}
try {
await testRuntimePorts(payload, readEnvState())
await testDatabaseConnection(payload)
} catch (error) {
sendJson(res, 400, {
detail: error instanceof Error ? error.message : '初始化校验失败。'
})
return
}
persistAdminCredentials(payload)
const currentEnv = readEnvState()
const apiBaseUrl = buildApiBaseUrl(payload, currentEnv)
updateEnvFile({
SETUP_COMPLETED: 'true',
COMPANY_NAME: String(payload.company_name || '').trim(),
COMPANY_CODE: String(payload.company_code || '').trim(),
ADMIN_EMAIL: String(payload.admin_email || '').trim(),
WEB_HOST: String(payload.web_host || '').trim(),
WEB_PORT: String(payload.web_port || '').trim(),
SERVER_HOST: String(payload.server_host || '').trim(),
SERVER_PORT: String(payload.server_port || '').trim(),
POSTGRES_HOST: String(payload.postgres_host || '').trim(),
POSTGRES_PORT: String(payload.postgres_port || '').trim(),
POSTGRES_DB: String(payload.postgres_db || '').trim(),
POSTGRES_USER: String(payload.postgres_user || '').trim(),
POSTGRES_PASSWORD: String(payload.postgres_password || ''),
DATABASE_URL: buildDatabaseUrl(payload),
REDIS_URL: String(payload.redis_url || '').trim(),
CORS_ORIGINS: buildCorsOrigins(payload),
VITE_API_BASE_URL: apiBaseUrl,
...buildClientEnvUpdates(payload, apiBaseUrl)
})
sendJson(res, 201, normalizeState(readEnvState()))
} catch (error) {
sendJson(res, 500, {
detail: error instanceof Error ? error.message : '初始化写入失败。'
})
}
})
}
}
}
export default defineConfig({
plugins: [vue()]
envDir: '..',
plugins: [vue(), localSetupPlugin()]
})