Compare commits
6 Commits
83d7da3d62
...
c00db75c13
| Author | SHA1 | Date | |
|---|---|---|---|
| c00db75c13 | |||
| a5db09f41e | |||
| 62f7810bd0 | |||
| f1dcfcfebf | |||
| 04e4b7148c | |||
| ae63766c91 |
46
.env.example
Normal 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
@@ -11,3 +11,7 @@ web/.vite/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
__pycache__/
|
||||
*.pyc
|
||||
server/.venv/
|
||||
server/.secrets/
|
||||
|
||||
25
README.md
@@ -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
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
# AI 报销预审中台 MVP — 总览
|
||||
|
||||
> **版本:** v1.0
|
||||
> **周期:** 8 周(W1 ~ W8)
|
||||
> **团队:** 3-5 人
|
||||
> **目标:** 跑通「上传材料 → OCR 识别 → 草稿生成 → 规则预审 → 补件交互 → 用户确认 → 模拟同步」完整闭环,优先支持差旅报销场景。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 前端 | Vue 3 + TypeScript + Ant Design Vue + Vite + Pinia |
|
||||
| 后端 | Python 3.11+ / FastAPI + SQLAlchemy + Alembic + Pydantic v2 |
|
||||
| 数据库 | PostgreSQL 15 + Redis 7 |
|
||||
| 文件存储 | MinIO(S3 兼容) |
|
||||
| OCR | 百度云 OCR API + Mock Provider |
|
||||
| 规则引擎 | 自研 JSON Rule Engine |
|
||||
| Agent | 自研 Orchestrator 状态机 + 大模型 API |
|
||||
| 部署 | Docker Compose |
|
||||
|
||||
---
|
||||
|
||||
## 团队分工建议
|
||||
|
||||
| 角色 | 人数 | 职责 |
|
||||
|---|---|---|
|
||||
| 后端工程师 A | 1 | 核心后端:任务管理、影子账本、Agent 编排、规则引擎 |
|
||||
| 后端工程师 B | 1 | OCR 集成、文件服务、适配器层、审计日志 |
|
||||
| 前端工程师 | 1-2 | 所有页面与组件(可拆分为两人并行) |
|
||||
| 全栈/Agent 工程师 | 1 | Agent Prompt 设计、大模型集成、规则配置 |
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览
|
||||
|
||||
| 阶段 | 周数 | 任务数 | 文档 | 可并行度 |
|
||||
|---|---|---|---|---|
|
||||
| Phase 1: 项目基建 | W1 | 4 | [phase-1-project-infra/README.md](phase-1-project-infra/README.md) | 高(前端+后端+Docker并行) |
|
||||
| Phase 2: 后端核心服务 | W2-W3 | 6 | [phase-2-backend-core/README.md](phase-2-backend-core/README.md) | 高(任务API+文件上传+OCR并行) |
|
||||
| Phase 3: Agent 编排 | W3-W4 | 4 | [phase-3-agent-orchestration/README.md](phase-3-agent-orchestration/README.md) | 中(Orchestrator先行,Agents并行) |
|
||||
| Phase 4: 前端核心页面 | W4-W5 | 4 | [phase-4-frontend-pages/README.md](phase-4-frontend-pages/README.md) | 高(页面间独立并行) |
|
||||
| Phase 5: 联调与集成 | W5-W6 | 2 | [phase-5-integration/README.md](phase-5-integration/README.md) | 中 |
|
||||
| Phase 6: 测试与打磨 | W7-W8 | 4 | [phase-6-testing-polish/README.md](phase-6-testing-polish/README.md) | 中 |
|
||||
| **总计** | **8 周** | **24 个任务** | | |
|
||||
|
||||
---
|
||||
|
||||
## 里程碑时间线
|
||||
|
||||
```
|
||||
W1 W2 W3 W4 W5 W6 W7 W8
|
||||
| | | | | | | |
|
||||
├─Phase 1──┤ | | | | | |
|
||||
| 基建 | | | | | | |
|
||||
| ├────────Phase 2──────┤ | | | |
|
||||
| | 后端核心 API | | | | |
|
||||
| | ├────────Phase 3──────┤ | | |
|
||||
| | | Agent 编排 | | | |
|
||||
| | | ├────────Phase 4──────┤ | |
|
||||
| | | | 前端页面 | | |
|
||||
| | | | ├────Phase 5────┤ | |
|
||||
| | | | | 联调集成 | | |
|
||||
| | | | | | ├─────Phase 6─────┤
|
||||
| | | | | | | 测试打磨 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 阶段依赖关系
|
||||
|
||||
```
|
||||
Phase 1 (基建)
|
||||
↓
|
||||
Phase 2 (后端核心) ←── 可与 Phase 3 部分重叠
|
||||
↓
|
||||
Phase 3 (Agent 编排)
|
||||
↓
|
||||
Phase 4 (前端页面) ←── 可与 Phase 3 后半段并行
|
||||
↓
|
||||
Phase 5 (联调集成)
|
||||
↓
|
||||
Phase 6 (测试打磨)
|
||||
```
|
||||
|
||||
**关键路径:** Phase 1 → Phase 2 → Phase 3 → Phase 5 → Phase 6
|
||||
**可并行路径:** Phase 4 可在 Phase 3 后半段提前开始
|
||||
|
||||
---
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| OCR 识别准确率不够 | 草稿数据错误 | 允许用户手动修改,低置信度高亮提示 |
|
||||
| LLM 响应慢或幻觉 | 用户体验差 | Prompt 严格约束输出格式,超时 fallback |
|
||||
| 规则引擎复杂度超预期 | 延期 | MVP 先做 6 条硬编码规则,JSON 配置化后续迭代 |
|
||||
| 前后端联调问题多 | 延期 | W5 提前开始联调,边开发边对齐 API |
|
||||
| 3-5 人不够 | 交付延期 | 优先砍规则管理页和审计页(W8 补) |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
MVP 完成的标志:
|
||||
|
||||
- [ ] 用户能通过 Web 界面创建差旅报销任务
|
||||
- [ ] 上传 3 种以上票据类型(增值税发票、火车票、酒店流水)
|
||||
- [ ] OCR 自动识别票据信息并生成报销草稿
|
||||
- [ ] 规则引擎执行 6 条核心预审规则
|
||||
- [ ] 预审结果以可视化方式展示(风险等级、命中规则、修改建议)
|
||||
- [ ] 用户能补件并重新预审
|
||||
- [ ] 用户确认后模拟同步成功
|
||||
- [ ] 影子报销账本完整记录业务数据
|
||||
- [ ] 审计日志记录所有关键操作
|
||||
- [ ] 完整流程端到端测试通过
|
||||
@@ -1,802 +0,0 @@
|
||||
# Phase 1: 项目基建(W1)
|
||||
|
||||
> **目标:** 搭建前后端项目骨架、定义数据库模型、配置开发环境,确保团队可以立即开始业务开发。
|
||||
> **周期:** 第 1 周
|
||||
> **任务数:** 4 个
|
||||
> **可并行:** 后端骨架 / 前端骨架 / Docker Compose 可完全并行
|
||||
> **前置依赖:** 无
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| 后端项目骨架 | FastAPI + SQLAlchemy + Alembic,可运行的健康检查 |
|
||||
| 数据库 Schema | 全部 12 张表的 ORM 模型 + Alembic 迁移 |
|
||||
| 前端项目骨架 | Vue3 + TS + Ant Design Vue,路由和 API 层配置 |
|
||||
| 开发环境 | Docker Compose(PostgreSQL + Redis + MinIO) |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 1.1: 后端项目骨架搭建
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 1 天
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/__init__.py`
|
||||
- Create: `backend/app/main.py`
|
||||
- Create: `backend/app/core/config.py`
|
||||
- Create: `backend/app/core/database.py`
|
||||
- Create: `backend/app/core/dependencies.py`
|
||||
- Create: `backend/app/api/__init__.py`
|
||||
- Create: `backend/app/api/v1/__init__.py`
|
||||
- Create: `backend/app/api/v1/router.py`
|
||||
- Create: `backend/app/models/__init__.py`
|
||||
- Create: `backend/app/schemas/__init__.py`
|
||||
- Create: `backend/app/services/__init__.py`
|
||||
- Create: `backend/requirements.txt`
|
||||
- Create: `backend/pyproject.toml`
|
||||
- Create: `backend/Dockerfile`
|
||||
- Create: `backend/alembic.ini`
|
||||
- Create: `backend/alembic/env.py`
|
||||
- Test: `backend/tests/__init__.py`
|
||||
- Test: `backend/tests/conftest.py`
|
||||
- Test: `backend/tests/test_health.py`
|
||||
|
||||
- [ ] **Step 1: 初始化后端项目结构**
|
||||
|
||||
创建 FastAPI 项目骨架,使用以下目录结构:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 应用入口
|
||||
│ ├── core/
|
||||
│ │ ├── config.py # Settings(Pydantic BaseSettings)
|
||||
│ │ ├── database.py # SQLAlchemy async engine + session
|
||||
│ │ └── dependencies.py # 通用依赖注入(db session, 当前用户等)
|
||||
│ ├── api/
|
||||
│ │ └── v1/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── router.py # v1 路由聚合
|
||||
│ │ ├── tasks.py # 报销任务 API
|
||||
│ │ ├── documents.py # 票据附件 API
|
||||
│ │ ├── precheck.py # 预审结果 API
|
||||
│ │ └── supplements.py # 补件 API
|
||||
│ ├── models/ # SQLAlchemy ORM models
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── task.py
|
||||
│ │ ├── reimbursement.py
|
||||
│ │ ├── document.py
|
||||
│ │ ├── rule.py
|
||||
│ │ └── audit.py
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── task.py
|
||||
│ │ ├── reimbursement.py
|
||||
│ │ ├── document.py
|
||||
│ │ └── rule.py
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── task_service.py
|
||||
│ │ ├── document_service.py
|
||||
│ │ ├── ocr_service.py
|
||||
│ │ ├── rule_engine.py
|
||||
│ │ └── sync_service.py
|
||||
│ └── agents/ # Agent 编排层
|
||||
│ ├── __init__.py
|
||||
│ ├── orchestrator.py
|
||||
│ ├── intake_agent.py
|
||||
│ ├── parse_agent.py
|
||||
│ ├── rule_check_agent.py
|
||||
│ ├── explain_agent.py
|
||||
│ └── sync_agent.py
|
||||
├── alembic/
|
||||
│ ├── env.py
|
||||
│ └── versions/
|
||||
├── alembic.ini
|
||||
├── requirements.txt
|
||||
├── pyproject.toml
|
||||
├── Dockerfile
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── conftest.py
|
||||
└── test_health.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写核心配置文件**
|
||||
|
||||
`backend/app/core/config.py` 使用 Pydantic BaseSettings:
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "AI Reimbursement Agent"
|
||||
APP_VERSION: str = "0.1.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# MinIO / S3
|
||||
MINIO_ENDPOINT: str = "localhost:9000"
|
||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||
MINIO_SECRET_KEY: str = "minioadmin"
|
||||
MINIO_BUCKET: str = "reimbursement"
|
||||
|
||||
# OCR
|
||||
OCR_PROVIDER: str = "baidu" # baidu | tencent | mock
|
||||
BAIDU_OCR_API_KEY: str = ""
|
||||
BAIDU_OCR_SECRET_KEY: str = ""
|
||||
|
||||
# LLM
|
||||
LLM_PROVIDER: str = "openai"
|
||||
LLM_API_KEY: str = ""
|
||||
LLM_MODEL: str = "gpt-4o-mini"
|
||||
LLM_BASE_URL: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写数据库连接和 FastAPI 入口**
|
||||
|
||||
`backend/app/core/database.py`:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
```
|
||||
|
||||
`backend/app/main.py`:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.core.config import settings
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, version=settings.APP_VERSION)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": settings.APP_VERSION}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写 requirements.txt**
|
||||
|
||||
```
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy[asyncio]==2.0.35
|
||||
asyncpg==0.30.0
|
||||
alembic==1.13.0
|
||||
pydantic==2.9.0
|
||||
pydantic-settings==2.5.0
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
redis==5.1.0
|
||||
minio==7.2.9
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
pytest==8.3.0
|
||||
pytest-asyncio==0.24.0
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编写健康检查测试**
|
||||
|
||||
`backend/tests/conftest.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from app.main import app
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
```
|
||||
|
||||
`backend/tests/test_health.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 运行测试确认骨架可用**
|
||||
|
||||
Run: `cd backend && pip install -r requirements.txt && pytest tests/test_health.py -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 初始化后端项目骨架(FastAPI + SQLAlchemy + Alembic)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: 数据库 Schema + 迁移
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Task 1.1
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/base.py`
|
||||
- Create: `backend/app/models/task.py`
|
||||
- Create: `backend/app/models/reimbursement.py`
|
||||
- Create: `backend/app/models/document.py`
|
||||
- Create: `backend/app/models/rule.py`
|
||||
- Create: `backend/app/models/audit.py`
|
||||
- Create: `backend/app/models/enums.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
- Test: `backend/tests/test_models.py`
|
||||
|
||||
- [ ] **Step 1: 定义枚举类型**
|
||||
|
||||
`backend/app/models/enums.py`:
|
||||
|
||||
```python
|
||||
import enum
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
CREATED = "created"
|
||||
MATERIAL_COLLECTING = "material_collecting"
|
||||
PARSING = "parsing"
|
||||
DRAFT_GENERATED = "draft_generated"
|
||||
PRECHECKING = "prechecking"
|
||||
NEED_SUPPLEMENT = "need_supplement"
|
||||
PENDING_USER_CONFIRM = "pending_user_confirm"
|
||||
SUBMITTING = "submitting"
|
||||
SYNCED = "synced"
|
||||
SYNC_FAILED = "sync_failed"
|
||||
CLOSED = "closed"
|
||||
|
||||
class ExpenseType(str, enum.Enum):
|
||||
TRAVEL_TRANSPORT = "travel_transport"
|
||||
TRAVEL_HOTEL = "travel_hotel"
|
||||
TRAVEL_MEAL = "travel_meal"
|
||||
LOCAL_TRANSPORT = "local_transport"
|
||||
BUSINESS_MEAL = "business_meal"
|
||||
OFFICE_SUPPLY = "office_supply"
|
||||
COMMUNICATION = "communication"
|
||||
OTHER = "other"
|
||||
|
||||
class DocumentType(str, enum.Enum):
|
||||
VAT_INVOICE = "vat_invoice"
|
||||
TRAIN_TICKET = "train_ticket"
|
||||
FLIGHT_ITINERARY = "flight_itinerary"
|
||||
TAXI_RECEIPT = "taxi_receipt"
|
||||
HOTEL_BILL = "hotel_bill"
|
||||
PAYMENT_SCREENSHOT = "payment_screenshot"
|
||||
TRAVEL_ORDER = "travel_order"
|
||||
OTHER_ATTACHMENT = "other_attachment"
|
||||
|
||||
class RiskLevel(str, enum.Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
BLOCKED = "blocked"
|
||||
|
||||
class RuleAction(str, enum.Enum):
|
||||
PASS = "pass"
|
||||
WARN = "warn"
|
||||
REQUIRE_EXPLANATION = "require_explanation"
|
||||
REQUIRE_ATTACHMENT = "require_attachment"
|
||||
REQUIRE_APPROVAL = "require_approval"
|
||||
BLOCK = "block"
|
||||
|
||||
class SyncStatus(str, enum.Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
RETRYING = "retrying"
|
||||
PENDING = "pending"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 定义 Base Mixin**
|
||||
|
||||
`backend/app/models/base.py`:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
def generate_id():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class IDMixin:
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 定义 ReimbursementTask 模型**
|
||||
|
||||
`backend/app/models/task.py`:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.models.base import IDMixin, TimestampMixin, Base
|
||||
from app.models.enums import TaskStatus
|
||||
|
||||
class ReimbursementTask(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "reimbursement_task"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
company_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
task_type: Mapped[str] = mapped_column(String(50), default="travel_expense")
|
||||
status: Mapped[TaskStatus] = mapped_column(default=TaskStatus.CREATED)
|
||||
user_intent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
current_agent: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
documents = relationship("ExpenseDocument", back_populates="task", lazy="selectin")
|
||||
reimbursement = relationship("ShadowReimbursement", back_populates="task", uselist=False, lazy="selectin")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 定义 ShadowReimbursement + ReimbursementItem + SupplementRequest + SyncRecord**
|
||||
|
||||
`backend/app/models/reimbursement.py` — 字段按开发文档 5.2.2 ~ 5.2.4, 5.2.7, 5.2.8 节:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text, Numeric, Date, ForeignKey, Enum as SAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from app.models.base import IDMixin, TimestampMixin, Base
|
||||
from app.models.enums import RiskLevel, SyncStatus
|
||||
|
||||
class ShadowReimbursement(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "shadow_reimbursement"
|
||||
|
||||
task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"), unique=True)
|
||||
applicant_id: Mapped[str] = mapped_column(String(36))
|
||||
department_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
cost_center_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
reimbursement_type: Mapped[str] = mapped_column(String(50))
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=Decimal("0"))
|
||||
currency: Mapped[str] = mapped_column(String(10), default="CNY")
|
||||
risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
precheck_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
backend_system: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
sync_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
task = relationship("ReimbursementTask", back_populates="reimbursement")
|
||||
items = relationship("ReimbursementItem", back_populates="reimbursement", lazy="selectin")
|
||||
|
||||
class ReimbursementItem(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "reimbursement_item"
|
||||
|
||||
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
|
||||
expense_type: Mapped[str] = mapped_column(String(50))
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
||||
tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
occurred_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
vendor_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
invoice_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
policy_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
risk_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
reimbursement = relationship("ShadowReimbursement", back_populates="items")
|
||||
|
||||
class SupplementRequest(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "supplement_request"
|
||||
|
||||
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
|
||||
request_type: Mapped[str] = mapped_column(String(30)) # attachment / explanation / field_modify
|
||||
target_item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
message: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending") # pending / resolved / closed
|
||||
user_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
resolved_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
class SyncRecord(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "sync_record"
|
||||
|
||||
reimbursement_id: Mapped[str] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"))
|
||||
target_system: Mapped[str] = mapped_column(String(50))
|
||||
request_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
|
||||
response_payload: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
|
||||
sync_status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
backend_bill_id: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 定义 ExpenseDocument 模型**
|
||||
|
||||
`backend/app/models/document.py` — 字段按开发文档 5.2.4 节:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text, Numeric, Date, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from app.models.base import IDMixin, TimestampMixin, Base
|
||||
|
||||
class ExpenseDocument(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "expense_document"
|
||||
|
||||
reimbursement_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("shadow_reimbursement.id"), nullable=True)
|
||||
item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
task_id: Mapped[str] = mapped_column(String(36), ForeignKey("reimbursement_task.id"))
|
||||
document_type: Mapped[str] = mapped_column(String(30))
|
||||
file_url: Mapped[str] = mapped_column(String(500))
|
||||
ocr_status: Mapped[str] = mapped_column(String(20), default="pending") # pending / processing / done / failed
|
||||
extracted_json: Mapped[dict | None] = mapped_column(Text, nullable=True)
|
||||
invoice_code: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
invoice_number: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
tax_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
|
||||
seller_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
buyer_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
verify_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
duplicate_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
task = relationship("ReimbursementTask", back_populates="documents")
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 定义 ExpenseRule + RuleHit 模型**
|
||||
|
||||
`backend/app/models/rule.py` — 字段按开发文档 5.2.5, 5.2.6 节:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.models.base import IDMixin, TimestampMixin, Base
|
||||
|
||||
class ExpenseRule(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "expense_rule"
|
||||
|
||||
rule_code: Mapped[str] = mapped_column(String(50), unique=True)
|
||||
rule_name: Mapped[str] = mapped_column(String(100))
|
||||
expense_type: Mapped[str] = mapped_column(String(50))
|
||||
condition_json: Mapped[dict] = mapped_column(Text) # JSON string
|
||||
action: Mapped[str] = mapped_column(String(30))
|
||||
severity: Mapped[str] = mapped_column(String(20))
|
||||
message_template: Mapped[str] = mapped_column(Text)
|
||||
policy_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
version: Mapped[str] = mapped_column(String(10), default="1.0")
|
||||
|
||||
class RuleHit(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "rule_hit"
|
||||
|
||||
reimbursement_id: Mapped[str] = mapped_column(String(36))
|
||||
item_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
rule_id: Mapped[str] = mapped_column(String(36))
|
||||
severity: Mapped[str] = mapped_column(String(20))
|
||||
hit_result: Mapped[dict | None] = mapped_column(Text, nullable=True) # JSON string
|
||||
explanation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
suggestion: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 定义 AuditLog 模型**
|
||||
|
||||
`backend/app/models/audit.py`:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.models.base import IDMixin, TimestampMixin, Base
|
||||
|
||||
class AuditLog(IDMixin, TimestampMixin, Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
action: Mapped[str] = mapped_column(String(50), index=True) # upload / ocr / agent / rule_hit / supplement / confirm / sync
|
||||
actor: Mapped[str] = mapped_column(String(36), index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(50)) # task / document / reimbursement / rule
|
||||
target_id: Mapped[str] = mapped_column(String(36))
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string
|
||||
```
|
||||
|
||||
- [ ] **Step 8: 更新 `models/__init__.py` 导出所有模型**
|
||||
|
||||
```python
|
||||
from app.models.task import ReimbursementTask
|
||||
from app.models.reimbursement import ShadowReimbursement, ReimbursementItem, SupplementRequest, SyncRecord
|
||||
from app.models.document import ExpenseDocument
|
||||
from app.models.rule import ExpenseRule, RuleHit
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.base import Base
|
||||
|
||||
__all__ = [
|
||||
"ReimbursementTask", "ShadowReimbursement", "ReimbursementItem",
|
||||
"ExpenseDocument", "ExpenseRule", "RuleHit",
|
||||
"SupplementRequest", "SyncRecord", "AuditLog", "Base",
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 9: 生成 Alembic 迁移**
|
||||
|
||||
Run: `cd backend && alembic revision --autogenerate -m "init schema"`
|
||||
Run: `cd backend && alembic upgrade head`
|
||||
|
||||
- [ ] **Step 10: 编写模型测试**
|
||||
|
||||
`backend/tests/test_models.py` — 验证所有表能正确创建和插入数据。
|
||||
|
||||
- [ ] **Step 11: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 完成所有数据模型定义和数据库迁移"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: 前端项目骨架搭建
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 1 天
|
||||
**前置依赖:** 无(可与 Task 1.1 并行)
|
||||
|
||||
**Files:**
|
||||
- Create: Vue3 + TypeScript 项目(Vite 初始化)
|
||||
- Create: `frontend/src/router/index.ts`
|
||||
- Create: `frontend/src/stores/`
|
||||
- Create: `frontend/src/api/`
|
||||
- Create: `frontend/src/views/`
|
||||
- Create: `frontend/src/components/`
|
||||
- Create: `frontend/src/layouts/`
|
||||
|
||||
- [ ] **Step 1: 初始化 Vue3 项目**
|
||||
|
||||
```bash
|
||||
npm create vite@latest frontend -- --template vue-ts
|
||||
cd frontend
|
||||
npm install ant-design-vue @ant-design/icons-vue vue-router pinia axios dayjs
|
||||
```
|
||||
|
||||
目录结构:
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API 调用
|
||||
│ │ ├── index.ts # axios 实例
|
||||
│ │ ├── task.ts # 报销任务 API
|
||||
│ │ ├── document.ts # 票据 API
|
||||
│ │ └── precheck.ts # 预审 API
|
||||
│ ├── components/ # 通用组件
|
||||
│ │ ├── FileUpload.vue
|
||||
│ │ ├── ExpenseTable.vue
|
||||
│ │ └── RuleHitCard.vue
|
||||
│ ├── layouts/
|
||||
│ │ └── MainLayout.vue
|
||||
│ ├── router/
|
||||
│ │ └── index.ts
|
||||
│ ├── stores/
|
||||
│ │ ├── task.ts
|
||||
│ │ └── user.ts
|
||||
│ ├── views/
|
||||
│ │ ├── HomeView.vue # 报销入口
|
||||
│ │ ├── UploadView.vue # 票据上传
|
||||
│ │ ├── DraftView.vue # 报销草稿
|
||||
│ │ ├── PrecheckView.vue # 预审结果
|
||||
│ │ ├── SupplementView.vue # 补件交互
|
||||
│ │ ├── ConfirmView.vue # 提交确认
|
||||
│ │ └── AuditView.vue # 审计日志
|
||||
│ ├── App.vue
|
||||
│ └── main.ts
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 配置路由和布局**
|
||||
|
||||
`frontend/src/router/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue') },
|
||||
{ path: '/task/:taskId/upload', name: 'upload', component: () => import('@/views/UploadView.vue') },
|
||||
{ path: '/task/:taskId/draft', name: 'draft', component: () => import('@/views/DraftView.vue') },
|
||||
{ path: '/task/:taskId/precheck', name: 'precheck', component: () => import('@/views/PrecheckView.vue') },
|
||||
{ path: '/task/:taskId/supplement', name: 'supplement', component: () => import('@/views/SupplementView.vue') },
|
||||
{ path: '/task/:taskId/confirm', name: 'confirm', component: () => import('@/views/ConfirmView.vue') },
|
||||
{ path: '/audit', name: 'audit', component: () => import('@/views/AuditView.vue') },
|
||||
]
|
||||
|
||||
export default createRouter({ history: createWebHistory(), routes })
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 配置 API 封装**
|
||||
|
||||
`frontend/src/api/index.ts`:
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
export default api
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 确认前端能正常启动**
|
||||
|
||||
Run: `cd frontend && npm run dev`
|
||||
Expected: 浏览器访问 http://localhost:5173 能看到页面
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat: 初始化前端项目骨架(Vue3 + TypeScript + Ant Design Vue)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.4: Docker Compose 开发环境
|
||||
|
||||
**负责人:** 后端工程师 B
|
||||
**预计工时:** 0.5 天
|
||||
**前置依赖:** 无(可与 Task 1.1、1.3 并行)
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`
|
||||
- Create: `backend/Dockerfile`
|
||||
- Create: `frontend/Dockerfile`
|
||||
- Create: `.env.example`
|
||||
|
||||
- [ ] **Step 1: 编写 docker-compose.yml**
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: x_financial
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 .env.example**
|
||||
|
||||
```env
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=reimbursement
|
||||
|
||||
# OCR(必填:正式环境;开发环境可用 mock)
|
||||
OCR_PROVIDER=mock
|
||||
BAIDU_OCR_API_KEY=
|
||||
BAIDU_OCR_SECRET_KEY=
|
||||
|
||||
# LLM(必填:正式环境)
|
||||
LLM_PROVIDER=openai
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
LLM_BASE_URL=
|
||||
|
||||
# 前端
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写后端 Dockerfile**
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证环境启动**
|
||||
|
||||
Run: `docker-compose up -d`
|
||||
Run: `docker-compose ps`
|
||||
Expected: postgres, redis, minio 均为 running
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml .env.example backend/Dockerfile frontend/Dockerfile
|
||||
git commit -m "feat: 添加 Docker Compose 开发环境(PostgreSQL + Redis + MinIO)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] `cd backend && pytest tests/test_health.py -v` 通过
|
||||
- [ ] `cd backend && alembic upgrade head` 无报错,所有表已创建
|
||||
- [ ] `cd frontend && npm run dev` 能正常启动
|
||||
- [ ] `docker-compose up -d` 三个服务均 running
|
||||
- [ ] `.env.example` 已创建,配置项说明完整
|
||||
@@ -1,834 +0,0 @@
|
||||
# Phase 2: 后端核心服务(W2-W3)
|
||||
|
||||
> **目标:** 实现所有后端业务 API,包括任务管理、文件上传、OCR 集成、规则引擎、影子账本、补件与提交。
|
||||
> **周期:** 第 2 ~ 3 周
|
||||
> **任务数:** 6 个
|
||||
> **可并行:** Task 2.1 / 2.2 / 2.3 可并行;Task 2.4 依赖 2.1;Task 2.5 依赖 2.2 + 2.4
|
||||
> **前置依赖:** Phase 1 完成
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| 报销任务 API | 创建/查询/列表 |
|
||||
| 文件上传 API | MinIO 存储 + 票据管理 |
|
||||
| OCR 服务 | 百度云 + Mock Provider |
|
||||
| 规则引擎 | 6 条核心规则 + 管理 API |
|
||||
| 影子账本 API | 草稿/预审结果查询 |
|
||||
| 补件 + 提交 API | 补件交互 + 模拟同步 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 2.1: 报销任务管理 API
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Phase 1
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/task.py`
|
||||
- Create: `backend/app/services/task_service.py`
|
||||
- Create: `backend/app/api/v1/tasks.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_task_api.py`
|
||||
|
||||
- [ ] **Step 1: 定义 Pydantic schemas**
|
||||
|
||||
`backend/app/schemas/task.py`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
user_id: str
|
||||
company_id: str
|
||||
user_intent: str
|
||||
entry_channel: str = "web"
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TaskDetailResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
company_id: str
|
||||
task_type: str
|
||||
status: str
|
||||
user_intent: str | None
|
||||
current_agent: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
total: int
|
||||
items: list[TaskDetailResponse]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 TaskService 业务逻辑**
|
||||
|
||||
`backend/app/services/task_service.py`:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.task import ReimbursementTask
|
||||
from app.models.enums import TaskStatus
|
||||
from app.schemas.task import TaskCreateRequest
|
||||
|
||||
class TaskService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_task(self, req: TaskCreateRequest) -> ReimbursementTask:
|
||||
task = ReimbursementTask(
|
||||
user_id=req.user_id,
|
||||
company_id=req.company_id,
|
||||
user_intent=req.user_intent,
|
||||
status=TaskStatus.MATERIAL_COLLECTING,
|
||||
)
|
||||
self.db.add(task)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(task)
|
||||
return task
|
||||
|
||||
async def get_task(self, task_id: str) -> ReimbursementTask | None:
|
||||
result = await self.db.execute(
|
||||
select(ReimbursementTask).where(ReimbursementTask.id == task_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_tasks(self, user_id: str | None = None, status: str | None = None,
|
||||
page: int = 1, size: int = 20) -> tuple[list[ReimbursementTask], int]:
|
||||
query = select(ReimbursementTask)
|
||||
count_query = select(func.count()).select_from(ReimbursementTask)
|
||||
|
||||
if user_id:
|
||||
query = query.where(ReimbursementTask.user_id == user_id)
|
||||
count_query = count_query.where(ReimbursementTask.user_id == user_id)
|
||||
if status:
|
||||
query = query.where(ReimbursementTask.status == status)
|
||||
count_query = count_query.where(ReimbursementTask.status == status)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
query = query.offset((page - 1) * size).limit(size).order_by(ReimbursementTask.created_at.desc())
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def update_status(self, task_id: str, status: TaskStatus, current_agent: str | None = None) -> ReimbursementTask | None:
|
||||
task = await self.get_task(task_id)
|
||||
if not task:
|
||||
return None
|
||||
task.status = status
|
||||
task.current_agent = current_agent
|
||||
await self.db.commit()
|
||||
await self.db.refresh(task)
|
||||
return task
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 API 路由**
|
||||
|
||||
`backend/app/api/v1/tasks.py`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.schemas.task import TaskCreateRequest, TaskResponse, TaskDetailResponse, TaskListResponse
|
||||
from app.services.task_service import TaskService
|
||||
|
||||
router = APIRouter(prefix="/reimbursement/tasks", tags=["tasks"])
|
||||
|
||||
@router.post("", response_model=TaskResponse, status_code=201)
|
||||
async def create_task(req: TaskCreateRequest, db: AsyncSession = Depends(get_db)):
|
||||
svc = TaskService(db)
|
||||
task = await svc.create_task(req)
|
||||
return TaskResponse(task_id=task.id, status=task.status.value)
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskDetailResponse)
|
||||
async def get_task(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
svc = TaskService(db)
|
||||
task = await svc.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
async def list_tasks(user_id: str | None = None, status: str | None = None,
|
||||
page: int = 1, size: int = 20, db: AsyncSession = Depends(get_db)):
|
||||
svc = TaskService(db)
|
||||
items, total = await svc.list_tasks(user_id, status, page, size)
|
||||
return TaskListResponse(total=total, items=items)
|
||||
```
|
||||
|
||||
更新 `backend/app/api/v1/router.py`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(tasks_router)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写测试**
|
||||
|
||||
```python
|
||||
# backend/tests/test_task_api.py
|
||||
import pytest
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task(client):
|
||||
response = await client.post("/api/v1/reimbursement/tasks", json={
|
||||
"user_id": "U001",
|
||||
"company_id": "C001",
|
||||
"user_intent": "我要报这次北京出差的费用",
|
||||
"entry_channel": "web"
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert "task_id" in data
|
||||
assert data["status"] == "material_collecting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task(client):
|
||||
# 先创建
|
||||
create_resp = await client.post("/api/v1/reimbursement/tasks", json={
|
||||
"user_id": "U001", "company_id": "C001", "user_intent": "test"
|
||||
})
|
||||
task_id = create_resp.json()["task_id"]
|
||||
# 再查询
|
||||
get_resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["user_id"] == "U001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks(client):
|
||||
response = await client.get("/api/v1/reimbursement/tasks")
|
||||
assert response.status_code == 200
|
||||
assert "total" in response.json()
|
||||
assert "items" in response.json()
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试**
|
||||
|
||||
Run: `cd backend && pytest tests/test_task_api.py -v`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现报销任务管理 API(创建/查询/列表)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: 文件上传与票据管理 API
|
||||
|
||||
**负责人:** 后端工程师 B
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Phase 1
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/document.py`
|
||||
- Create: `backend/app/services/document_service.py`
|
||||
- Create: `backend/app/services/storage_service.py`
|
||||
- Create: `backend/app/api/v1/documents.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_document_api.py`
|
||||
|
||||
- [ ] **Step 1: 实现 MinIO 存储服务**
|
||||
|
||||
`backend/app/services/storage_service.py` — 封装 MinIO 操作:
|
||||
- `upload_file(bucket, file_name, file_data, content_type)` → 上传文件到 MinIO
|
||||
- `get_file_url(bucket, file_name)` → 获取文件访问 URL
|
||||
- `delete_file(bucket, file_name)` → 删除文件
|
||||
- `ensure_bucket(bucket)` → 确保 bucket 存在
|
||||
|
||||
开发阶段可使用 mock 实现(本地文件系统存储)。
|
||||
|
||||
- [ ] **Step 2: 实现文档服务**
|
||||
|
||||
`backend/app/services/document_service.py`:
|
||||
- `upload_document(task_id, file, document_type)` → 保存文件到 MinIO,创建 DB 记录
|
||||
- `get_documents(task_id)` → 查询任务下所有票据
|
||||
- `get_document(document_id)` → 查询单个票据
|
||||
- `update_ocr_result(document_id, ocr_result)` → 更新 OCR 识别结果
|
||||
|
||||
- [ ] **Step 3: 定义 Pydantic schemas**
|
||||
|
||||
`backend/app/schemas/document.py`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
document_id: str
|
||||
ocr_status: str
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
document_type: str
|
||||
file_url: str
|
||||
ocr_status: str
|
||||
invoice_code: str | None
|
||||
invoice_number: str | None
|
||||
invoice_date: date | None
|
||||
amount: Decimal | None
|
||||
seller_name: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 API 路由**
|
||||
|
||||
`backend/app/api/v1/documents.py`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.schemas.document import DocumentUploadResponse, DocumentResponse
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
router = APIRouter(prefix="/reimbursement/tasks/{task_id}/documents", tags=["documents"])
|
||||
|
||||
@router.post("", response_model=DocumentUploadResponse, status_code=201)
|
||||
async def upload_document(
|
||||
task_id: str,
|
||||
file: UploadFile = File(...),
|
||||
document_type: str = Form(...),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
svc = DocumentService(db)
|
||||
doc = await svc.upload_document(task_id, file, document_type)
|
||||
return DocumentUploadResponse(document_id=doc.id, ocr_status=doc.ocr_status)
|
||||
|
||||
@router.get("", response_model=list[DocumentResponse])
|
||||
async def list_documents(task_id: str, db: AsyncSession = Depends(get_db)):
|
||||
svc = DocumentService(db)
|
||||
docs = await svc.get_documents(task_id)
|
||||
return docs
|
||||
```
|
||||
|
||||
在 `router.py` 中注册 documents_router。
|
||||
|
||||
- [ ] **Step 5: 编写测试(使用 mock MinIO)**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现文件上传与票据管理 API(MinIO 存储)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: OCR 服务集成
|
||||
|
||||
**负责人:** 后端工程师 B
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Task 2.2(需要 document_service)
|
||||
**可并行于:** Task 2.1、Task 2.4
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/ocr_service.py`
|
||||
- Create: `backend/app/services/ocr_providers/__init__.py`
|
||||
- Create: `backend/app/services/ocr_providers/base.py`
|
||||
- Create: `backend/app/services/ocr_providers/baidu.py`
|
||||
- Create: `backend/app/services/ocr_providers/mock.py`
|
||||
- Test: `backend/tests/test_ocr_service.py`
|
||||
|
||||
- [ ] **Step 1: 定义 OCR Provider 抽象接口**
|
||||
|
||||
`backend/app/services/ocr_providers/base.py`:
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class OCRResult:
|
||||
document_type: str # 识别出的票据类型
|
||||
raw_text: str # 原始文字
|
||||
fields: dict = field(default_factory=dict) # 结构化字段
|
||||
confidence: float = 0.0 # 整体置信度 0-1
|
||||
provider: str = "" # 提供商名称
|
||||
|
||||
class OCRProvider(ABC):
|
||||
@abstractmethod
|
||||
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_vat_invoice(self, file_url: str) -> OCRResult:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_train_ticket(self, file_url: str) -> OCRResult:
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 Mock OCR Provider**
|
||||
|
||||
`backend/app/services/ocr_providers/mock.py` — 根据文件名/类型返回预定义的结构化数据:
|
||||
|
||||
```python
|
||||
from app.services.ocr_providers.base import OCRProvider, OCRResult
|
||||
|
||||
class MockOCRProvider(OCRProvider):
|
||||
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
|
||||
if document_type == "vat_invoice" or "invoice" in file_url:
|
||||
return await self.recognize_vat_invoice(file_url)
|
||||
elif document_type == "train_ticket" or "train" in file_url:
|
||||
return await self.recognize_train_ticket(file_url)
|
||||
return OCRResult(document_type="unknown", raw_text="", confidence=0.0, provider="mock")
|
||||
|
||||
async def recognize_vat_invoice(self, file_url: str) -> OCRResult:
|
||||
return OCRResult(
|
||||
document_type="vat_invoice",
|
||||
raw_text="增值税电子普通发票",
|
||||
fields={
|
||||
"invoice_code": "050002100311",
|
||||
"invoice_number": "23912077",
|
||||
"invoice_date": "2026-04-20",
|
||||
"amount": "1061.95",
|
||||
"tax_amount": "61.95",
|
||||
"total_amount": "1123.90",
|
||||
"seller_name": "北京XX酒店管理有限公司",
|
||||
"buyer_name": "XX科技有限公司",
|
||||
"check_code": "1234567890",
|
||||
},
|
||||
confidence=0.95,
|
||||
provider="mock"
|
||||
)
|
||||
|
||||
async def recognize_train_ticket(self, file_url: str) -> OCRResult:
|
||||
return OCRResult(
|
||||
document_type="train_ticket",
|
||||
raw_text="火车票",
|
||||
fields={
|
||||
"train_number": "G101",
|
||||
"departure_station": "北京南",
|
||||
"arrival_station": "上海虹桥",
|
||||
"departure_date": "2026-04-18",
|
||||
"departure_time": "07:00",
|
||||
"seat_type": "二等座",
|
||||
"amount": "553.00",
|
||||
"passenger_name": "张三",
|
||||
"id_number": "****1234",
|
||||
},
|
||||
confidence=0.90,
|
||||
provider="mock"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现百度 OCR Provider**
|
||||
|
||||
`backend/app/services/ocr_providers/baidu.py` — 调用百度云 OCR API:
|
||||
- `recognize_vat_invoice()` → 调用增值税发票识别接口
|
||||
- `recognize_train_ticket()` → 调用火车票识别接口
|
||||
- `recognize()` → 自动判断票据类型,调用对应接口
|
||||
- 将百度返回结果标准化为 `OCRResult`
|
||||
- 包含 access_token 获取和缓存逻辑
|
||||
|
||||
- [ ] **Step 4: 实现 OCR Service 门面**
|
||||
|
||||
`backend/app/services/ocr_service.py`:
|
||||
|
||||
```python
|
||||
from app.core.config import settings
|
||||
from app.services.ocr_providers.base import OCRResult
|
||||
from app.services.ocr_providers.mock import MockOCRProvider
|
||||
from app.services.ocr_providers.baidu import BaiduOCRProvider
|
||||
|
||||
class OCRService:
|
||||
def __init__(self):
|
||||
self._provider = self._create_provider()
|
||||
|
||||
def _create_provider(self):
|
||||
if settings.OCR_PROVIDER == "mock":
|
||||
return MockOCRProvider()
|
||||
elif settings.OCR_PROVIDER == "baidu":
|
||||
return BaiduOCRProvider(
|
||||
api_key=settings.BAIDU_OCR_API_KEY,
|
||||
secret_key=settings.BAIDU_OCR_SECRET_KEY
|
||||
)
|
||||
raise ValueError(f"Unknown OCR provider: {settings.OCR_PROVIDER}")
|
||||
|
||||
async def recognize(self, file_url: str, document_type: str | None = None) -> OCRResult:
|
||||
return await self._provider.recognize(file_url, document_type)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编写测试**
|
||||
|
||||
使用 Mock Provider 测试完整 OCR 流程。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现 OCR 服务(百度云 + Mock Provider)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2.4: 规则引擎
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 3 天
|
||||
**前置依赖:** Task 2.1(需要 task 模型)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/rule_engine.py`
|
||||
- Create: `backend/app/services/rule_checkers/__init__.py`
|
||||
- Create: `backend/app/services/rule_checkers/base.py`
|
||||
- Create: `backend/app/services/rule_checkers/required_fields.py`
|
||||
- Create: `backend/app/services/rule_checkers/attachment_check.py`
|
||||
- Create: `backend/app/services/rule_checkers/duplicate_invoice.py`
|
||||
- Create: `backend/app/services/rule_checkers/amount_limit.py`
|
||||
- Create: `backend/app/services/rule_checkers/date_validity.py`
|
||||
- Create: `backend/app/services/rule_checkers/expense_type_match.py`
|
||||
- Create: `backend/app/schemas/rule.py`
|
||||
- Create: `backend/app/api/v1/rules.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_rule_engine.py`
|
||||
|
||||
- [ ] **Step 1: 定义规则检查器基类**
|
||||
|
||||
`backend/app/services/rule_checkers/base.py`:
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class RuleCheckResult:
|
||||
rule_code: str
|
||||
severity: str # low / medium / high / blocked
|
||||
action: str # pass / warn / require_explanation / require_attachment / require_approval / block
|
||||
message: str
|
||||
suggestion: str
|
||||
policy_ref: str
|
||||
hit_detail: dict
|
||||
|
||||
class RuleChecker(ABC):
|
||||
@abstractmethod
|
||||
async def check(self, context: dict) -> RuleCheckResult | None:
|
||||
"""检查规则,命中返回结果,未命中返回 None"""
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 RuleEngine 核心引擎**
|
||||
|
||||
`backend/app/services/rule_engine.py`:
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.services.rule_checkers.base import RuleChecker, RuleCheckResult
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class PrecheckResult:
|
||||
precheck_status: str # pass / need_supplement / blocked
|
||||
risk_level: str # low / medium / high / blocked
|
||||
rule_hits: list[RuleCheckResult] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
|
||||
class RuleEngine:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.checkers: list[RuleChecker] = []
|
||||
|
||||
def register_checker(self, checker: RuleChecker):
|
||||
self.checkers.append(checker)
|
||||
|
||||
async def run_precheck(self, context: dict) -> PrecheckResult:
|
||||
"""执行完整预审,遍历所有注册的 checker"""
|
||||
hits: list[RuleCheckResult] = []
|
||||
for checker in self.checkers:
|
||||
result = await checker.check(context)
|
||||
if result:
|
||||
hits.append(result)
|
||||
|
||||
risk_level = self._calculate_overall_risk(hits)
|
||||
status = self._determine_status(hits)
|
||||
summary = self._generate_summary(hits)
|
||||
|
||||
return PrecheckResult(
|
||||
precheck_status=status,
|
||||
risk_level=risk_level,
|
||||
rule_hits=hits,
|
||||
summary=summary
|
||||
)
|
||||
|
||||
def _calculate_overall_risk(self, hits: list[RuleCheckResult]) -> str:
|
||||
if not hits:
|
||||
return "low"
|
||||
severity_order = {"blocked": 4, "high": 3, "medium": 2, "low": 1}
|
||||
max_severity = max(hits, key=lambda h: severity_order.get(h.severity, 0))
|
||||
return max_severity.severity
|
||||
|
||||
def _determine_status(self, hits: list[RuleCheckResult]) -> str:
|
||||
if not hits:
|
||||
return "pass"
|
||||
if any(h.action == "block" for h in hits):
|
||||
return "blocked"
|
||||
return "need_supplement"
|
||||
|
||||
def _generate_summary(self, hits: list[RuleCheckResult]) -> str:
|
||||
if not hits:
|
||||
return "预审通过,未发现风险。"
|
||||
blocked = sum(1 for h in hits if h.action == "block")
|
||||
warnings = sum(1 for h in hits if h.action in ("warn", "require_explanation"))
|
||||
supplements = sum(1 for h in hits if h.action == "require_attachment")
|
||||
parts = []
|
||||
if blocked:
|
||||
parts.append(f"{blocked} 个阻断项")
|
||||
if supplements:
|
||||
parts.append(f"{supplements} 个缺件")
|
||||
if warnings:
|
||||
parts.append(f"{warnings} 个风险提示")
|
||||
return f"当前报销单存在{'、'.join(parts)}。"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 6 条核心规则检查器**
|
||||
|
||||
1. **`required_fields.py`** — `RequiredFieldsChecker` — 必填字段校验
|
||||
- 检查报销人、部门、事由、费用明细是否有空值
|
||||
- 命中返回 `require_explanation`
|
||||
|
||||
2. **`attachment_check.py`** — `AttachmentCheckChecker` — 附件完整性校验
|
||||
- 住宿费必须上传酒店流水
|
||||
- 交通费必须上传对应票据
|
||||
- 命中返回 `require_attachment`
|
||||
|
||||
3. **`duplicate_invoice.py`** — `DuplicateInvoiceChecker` — 重复发票检查
|
||||
- 检查 invoice_code + invoice_number + amount 是否重复
|
||||
- 命中返回 `block`
|
||||
|
||||
4. **`amount_limit.py`** — `AmountLimitChecker` — 金额超标校验
|
||||
- 按城市等级和费用类型检查标准
|
||||
- 住宿费按每晚金额检查
|
||||
- 命中返回 `require_explanation`
|
||||
|
||||
5. **`date_validity.py`** — `DateValidityChecker` — 日期合理性校验
|
||||
- 费用日期不能晚于今天
|
||||
- 费用日期应在出差期间内
|
||||
- 命中返回 `warn`
|
||||
|
||||
6. **`expense_type_match.py`** — `ExpenseTypeMatchChecker` — 费用类型匹配校验
|
||||
- 住宿费应关联 hotel_bill 类型票据
|
||||
- 交通费应关联 train_ticket / flight_itinerary / taxi_receipt
|
||||
- 命中返回 `warn`
|
||||
|
||||
- [ ] **Step 4: 实现规则管理 API**
|
||||
|
||||
- `GET /api/v1/rules` — 列出所有规则
|
||||
- `POST /api/v1/rules` — 创建规则
|
||||
- `PUT /api/v1/rules/{rule_id}` — 更新规则
|
||||
- `PATCH /api/v1/rules/{rule_id}/toggle` — 启用/禁用规则
|
||||
|
||||
- [ ] **Step 5: 编写测试**
|
||||
|
||||
对每条规则编写单元测试:
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_invoice_checker():
|
||||
checker = DuplicateInvoiceChecker()
|
||||
# 模拟重复发票场景
|
||||
context = {"items": [...], "existing_invoices": [...]}
|
||||
result = await checker.check(context)
|
||||
assert result is not None
|
||||
assert result.action == "block"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_duplicate():
|
||||
checker = DuplicateInvoiceChecker()
|
||||
context = {"items": [...], "existing_invoices": []} # 无重复
|
||||
result = await checker.check(context)
|
||||
assert result is None
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现规则引擎(6 条核心规则 + 管理 API)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2.5: 影子报销账本 CRUD
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Task 2.1 + Task 2.4
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/reimbursement.py`
|
||||
- Create: `backend/app/services/ledger_service.py`
|
||||
- Create: `backend/app/api/v1/ledger.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_ledger_api.py`
|
||||
|
||||
- [ ] **Step 1: 定义 Pydantic schemas**
|
||||
|
||||
`backend/app/schemas/reimbursement.py`:
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
class ReimbursementItemResponse(BaseModel):
|
||||
id: str
|
||||
expense_type: str
|
||||
amount: Decimal
|
||||
tax_amount: Decimal | None
|
||||
occurred_at: date | None
|
||||
city: str | None
|
||||
vendor_name: str | None
|
||||
risk_level: str | None
|
||||
remark: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ReimbursementDraftResponse(BaseModel):
|
||||
reimbursement_id: str
|
||||
reason: str | None
|
||||
total_amount: Decimal
|
||||
precheck_status: str | None
|
||||
risk_level: str | None
|
||||
items: list[ReimbursementItemResponse]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class PrecheckResultResponse(BaseModel):
|
||||
precheck_status: str
|
||||
risk_level: str
|
||||
summary: str
|
||||
rule_hits: list[dict]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 LedgerService**
|
||||
|
||||
`backend/app/services/ledger_service.py` — 核心方法:
|
||||
- `create_shadow_reimbursement(task_id, data)` → 创建影子报销记录
|
||||
- `get_draft(reimbursement_id)` → 获取报销草稿
|
||||
- `get_draft_by_task(task_id)` → 通过任务 ID 获取草稿
|
||||
- `update_precheck_result(reimbursement_id, result)` → 更新预审结果
|
||||
- `add_item(reimbursement_id, item_data)` → 添加报销明细
|
||||
|
||||
- [ ] **Step 3: 实现 API 路由**
|
||||
|
||||
`GET /api/v1/reimbursement/tasks/{task_id}/draft` — 获取报销草稿(对应文档 8.4)
|
||||
`GET /api/v1/reimbursement/tasks/{task_id}/precheck-result` — 获取预审结果(对应文档 8.5)
|
||||
|
||||
- [ ] **Step 4: 编写测试**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现影子报销账本 CRUD API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2.6: 补件与提交 API
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Task 2.5
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/v1/supplements.py`
|
||||
- Create: `backend/app/services/supplement_service.py`
|
||||
- Create: `backend/app/services/sync_service.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_supplement_api.py`
|
||||
|
||||
- [ ] **Step 1: 实现补件服务**
|
||||
|
||||
`backend/app/services/supplement_service.py`:
|
||||
- `create_supplement_request(reimbursement_id, items)` → 创建补件请求
|
||||
- `respond_supplement(request_id, response_text, document_ids)` → 用户补件响应
|
||||
- `get_supplement_requests(task_id)` → 查询补件请求列表
|
||||
|
||||
- [ ] **Step 2: 实现同步服务(MVP 阶段为模拟)**
|
||||
|
||||
`backend/app/services/sync_service.py`:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
class SyncService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def mock_sync_to_backend(self, reimbursement_id: str) -> dict:
|
||||
"""模拟后端同步,生成假的 backend_bill_id"""
|
||||
backend_bill_id = f"BX{datetime.now().strftime('%Y%m%d')}{str(uuid.uuid4())[:6]}"
|
||||
return {
|
||||
"sync_status": "success",
|
||||
"target_system": "expense_system",
|
||||
"backend_bill_id": backend_bill_id,
|
||||
}
|
||||
|
||||
async def get_sync_status(self, task_id: str) -> dict | None:
|
||||
"""查询同步状态"""
|
||||
# 从 sync_record 表查询
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 API 路由**
|
||||
|
||||
`POST /api/v1/reimbursement/tasks/{task_id}/supplements` — 用户补件(对应文档 8.6)
|
||||
`POST /api/v1/reimbursement/tasks/{task_id}/submit` — 用户确认提交(对应文档 8.7)
|
||||
`GET /api/v1/reimbursement/tasks/{task_id}/sync-status` — 查询同步状态(对应文档 8.8)
|
||||
|
||||
- [ ] **Step 4: 编写测试**
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现补件与提交确认 API(含模拟同步)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] `POST /api/v1/reimbursement/tasks` 创建任务返回 201
|
||||
- [ ] `POST /api/v1/reimbursement/tasks/{id}/documents` 上传文件返回 201
|
||||
- [ ] OCR Service 对 Mock Provider 正常返回结构化数据
|
||||
- [ ] 规则引擎对 6 条规则命中/未命中的测试全部通过
|
||||
- [ ] `GET /api/v1/reimbursement/tasks/{id}/draft` 返回草稿数据
|
||||
- [ ] `POST /api/v1/reimbursement/tasks/{id}/supplements` 补件返回 received
|
||||
- [ ] `POST /api/v1/reimbursement/tasks/{id}/submit` 提交返回 submitting
|
||||
- [ ] 所有测试 `pytest tests/ -v` 全部通过
|
||||
@@ -1,568 +0,0 @@
|
||||
# Phase 3: Agent 编排(W3-W4)
|
||||
|
||||
> **目标:** 实现 Agent Orchestrator 状态机、5 个业务 Agent、LLM 集成层和审计日志,完成核心智能处理能力。
|
||||
> **周期:** 第 3 ~ 4 周
|
||||
> **任务数:** 4 个
|
||||
> **可并行:** Task 3.3 / 3.4 可与 Task 3.2 并行
|
||||
> **前置依赖:** Phase 2 完成
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| Orchestrator 状态机 | 任务状态流转 + Agent 调度 |
|
||||
| 5 个 Agent | 受理 / 解析 / 规则校验 / 解释补件 / 同步 |
|
||||
| LLM 集成层 | 多 Provider 支持 + Prompt 模板 |
|
||||
| 审计日志 | 所有关键操作留痕 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 3.1: Agent Orchestrator 状态机
|
||||
|
||||
**负责人:** 全栈/Agent 工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Phase 2(所有 service 就绪)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/agents/__init__.py`
|
||||
- Create: `backend/app/agents/state.py`
|
||||
- Create: `backend/app/agents/orchestrator.py`
|
||||
- Create: `backend/app/api/v1/agent.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_orchestrator.py`
|
||||
|
||||
- [ ] **Step 1: 定义 Agent 状态和上下文**
|
||||
|
||||
`backend/app/agents/state.py`:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass, field
|
||||
from app.models.enums import TaskStatus
|
||||
|
||||
@dataclass
|
||||
class AgentContext:
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
user_intent: str | None = None
|
||||
current_agent: str | None = None
|
||||
ocr_results: list[dict] = field(default_factory=list)
|
||||
reimbursement_data: dict | None = None
|
||||
precheck_result: dict | None = None
|
||||
supplement_requests: list[dict] = field(default_factory=list)
|
||||
error_message: str | None = None
|
||||
retry_count: int = 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 定义 Agent 基类和结果**
|
||||
|
||||
`backend/app/agents/base_agent.py`:
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.agents.state import AgentContext
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
success: bool
|
||||
data: dict = field(default_factory=dict)
|
||||
next_action: str = "continue" # continue / wait_user / need_supplement / retry
|
||||
error: str | None = None
|
||||
|
||||
class BaseAgent(ABC):
|
||||
name: str = ""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, context: AgentContext, db: AsyncSession) -> AgentResult:
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现 Orchestrator 状态机**
|
||||
|
||||
`backend/app/agents/orchestrator.py` — 核心编排逻辑:
|
||||
|
||||
状态转换图(对应开发文档 4.2 节):
|
||||
```
|
||||
CREATED → MATERIAL_COLLECTING → PARSING → DRAFT_GENERATED → PRECHECKING
|
||||
↑ ↓
|
||||
└─── MATERIAL_COLLECTING ←── NEED_SUPPLEMENT ←────────────────┘
|
||||
↓
|
||||
PENDING_USER_CONFIRM → SUBMITTING → SYNCED
|
||||
↓
|
||||
SYNC_FAILED → SUBMITTING(重试)
|
||||
```
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.agents.state import AgentContext
|
||||
from app.agents.base_agent import BaseAgent, AgentResult
|
||||
from app.models.enums import TaskStatus
|
||||
from app.services.task_service import TaskService
|
||||
|
||||
class Orchestrator:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.agents: dict[str, BaseAgent] = {}
|
||||
|
||||
def register_agent(self, agent: BaseAgent):
|
||||
self.agents[agent.name] = agent
|
||||
|
||||
async def run(self, task_id: str, start_from: str = "intake") -> AgentContext:
|
||||
"""启动编排流程"""
|
||||
task_svc = TaskService(self.db)
|
||||
task = await task_svc.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task {task_id} not found")
|
||||
|
||||
context = AgentContext(
|
||||
task_id=task_id,
|
||||
status=task.status,
|
||||
user_intent=task.user_intent,
|
||||
)
|
||||
|
||||
# 根据 start_from 决定从哪个状态开始
|
||||
agent_sequence = self._get_agent_sequence(start_from)
|
||||
|
||||
for agent_name in agent_sequence:
|
||||
context.current_agent = agent_name
|
||||
await task_svc.update_status(task_id, self._agent_to_status(agent_name), agent_name)
|
||||
|
||||
agent = self.agents.get(agent_name)
|
||||
if not agent:
|
||||
continue
|
||||
|
||||
result = await agent.execute(context, self.db)
|
||||
|
||||
if not result.success:
|
||||
context.error_message = result.error
|
||||
break
|
||||
|
||||
context = self._merge_result(context, result)
|
||||
|
||||
if result.next_action == "wait_user":
|
||||
await task_svc.update_status(task_id, TaskStatus.PENDING_USER_CONFIRM, agent_name)
|
||||
break
|
||||
|
||||
if result.next_action == "need_supplement":
|
||||
await task_svc.update_status(task_id, TaskStatus.NEED_SUPPLEMENT, agent_name)
|
||||
break
|
||||
|
||||
return context
|
||||
|
||||
def _get_agent_sequence(self, start_from: str) -> list[str]:
|
||||
sequences = {
|
||||
"intake": ["intake_agent", "parse_agent", "rule_check_agent", "explain_agent"],
|
||||
"precheck": ["rule_check_agent", "explain_agent"],
|
||||
"submit": ["sync_agent"],
|
||||
}
|
||||
return sequences.get(start_from, sequences["intake"])
|
||||
|
||||
def _agent_to_status(self, agent_name: str) -> TaskStatus:
|
||||
mapping = {
|
||||
"intake_agent": TaskStatus.MATERIAL_COLLECTING,
|
||||
"parse_agent": TaskStatus.PARSING,
|
||||
"rule_check_agent": TaskStatus.PRECHECKING,
|
||||
"explain_agent": TaskStatus.PRECHECKING,
|
||||
"sync_agent": TaskStatus.SUBMITTING,
|
||||
}
|
||||
return mapping.get(agent_name, TaskStatus.PRECHECKING)
|
||||
|
||||
def _merge_result(self, context: AgentContext, result: AgentResult) -> AgentContext:
|
||||
"""将 Agent 结果合并到上下文"""
|
||||
data = result.data
|
||||
if "ocr_results" in data:
|
||||
context.ocr_results = data["ocr_results"]
|
||||
if "reimbursement_data" in data:
|
||||
context.reimbursement_data = data["reimbursement_data"]
|
||||
if "precheck_result" in data:
|
||||
context.precheck_result = data["precheck_result"]
|
||||
if "supplement_requests" in data:
|
||||
context.supplement_requests = data["supplement_requests"]
|
||||
return context
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 Agent 启动 API**
|
||||
|
||||
`backend/app/api/v1/agent.py`:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/reimbursement/tasks/{task_id}/agent", tags=["agent"])
|
||||
|
||||
class AgentRunRequest(BaseModel):
|
||||
start_from: str = "intake" # intake / precheck / submit
|
||||
mode: str = "precheck"
|
||||
|
||||
class AgentRunResponse(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
current_agent: str | None
|
||||
|
||||
@router.post("/run", response_model=AgentRunResponse)
|
||||
async def run_agent(task_id: str, req: AgentRunRequest, db: AsyncSession = Depends(get_db)):
|
||||
orchestrator = create_orchestrator(db)
|
||||
context = await orchestrator.run(task_id, start_from=req.start_from)
|
||||
return AgentRunResponse(
|
||||
task_id=context.task_id,
|
||||
status=context.status.value,
|
||||
current_agent=context.current_agent,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 编写状态机转换测试**
|
||||
|
||||
覆盖路径:
|
||||
- 正常路径:创建 → 解析 → 草稿 → 预审 → 通过 → 提交 → 同步
|
||||
- 补件路径:预审 → 需补件 → 等待用户
|
||||
- 重试路径:提交 → 同步失败 → 重试
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现 Agent Orchestrator 状态机编排"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: 5 个 Agent 实现
|
||||
|
||||
**负责人:** 全栈/Agent 工程师
|
||||
**预计工时:** 3 天
|
||||
**前置依赖:** Task 3.1(Orchestrator 就绪)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/agents/intake_agent.py`
|
||||
- Create: `backend/app/agents/parse_agent.py`
|
||||
- Create: `backend/app/agents/rule_check_agent.py`
|
||||
- Create: `backend/app/agents/explain_agent.py`
|
||||
- Create: `backend/app/agents/sync_agent.py`
|
||||
- Test: `backend/tests/test_agents.py`
|
||||
|
||||
- [ ] **Step 1: 实现 IntakeAgent(受理 Agent)**
|
||||
|
||||
`backend/app/agents/intake_agent.py`:
|
||||
- 分析 user_intent 文本,提取报销类型、出差信息
|
||||
- 调用 LLM 做 intent classification
|
||||
- 返回结构化任务信息(报销类型、出差城市、日期范围等)
|
||||
- 输出:`AgentResult(data={"task_info": {...}})`
|
||||
|
||||
- [ ] **Step 2: 实现 ParseAgent(单据解析 Agent)**
|
||||
|
||||
`backend/app/agents/parse_agent.py`:
|
||||
- 遍历任务下所有 document,调用 `ocr_service.recognize()`
|
||||
- 将 OCR 结果汇总为报销明细
|
||||
- 调用 `ledger_service.create_shadow_reimbursement()` 创建影子记录
|
||||
- 调用 `ledger_service.add_item()` 添加每条明细
|
||||
- 自动识别费用类型(可调用 LLM 辅助)
|
||||
- 输出:`AgentResult(data={"ocr_results": [...], "reimbursement_data": {...}})`
|
||||
|
||||
- [ ] **Step 3: 实现 RuleCheckAgent(规则校验 Agent)**
|
||||
|
||||
`backend/app/agents/rule_check_agent.py`:
|
||||
- 构建 context dict(报销数据 + 票据数据 + 已有发票列表)
|
||||
- 注册 6 个 RuleChecker 到 RuleEngine
|
||||
- 调用 `rule_engine.run_precheck(context)`
|
||||
- 保存 RuleHit 记录到 DB
|
||||
- 更新 shadow_reimbursement 的预审状态
|
||||
- 输出:`AgentResult(data={"precheck_result": {...}})`
|
||||
|
||||
- [ ] **Step 4: 实现 ExplainAgent(解释与补件 Agent)**
|
||||
|
||||
`backend/app/agents/explain_agent.py`:
|
||||
- 遍历 rule_hits,使用 LLM 生成自然语言解释
|
||||
- 对 `require_attachment` 类型的命中自动创建 supplement_request
|
||||
- 生成修改建议
|
||||
- 根据预审结果决定 next_action:
|
||||
- 全部通过 → `continue`
|
||||
- 有需补件的 → `need_supplement`
|
||||
- 有阻断的 → `need_supplement`
|
||||
- 输出:`AgentResult(data={"supplement_requests": [...]}, next_action="need_supplement")`
|
||||
|
||||
- [ ] **Step 5: 实现 SyncAgent(同步执行 Agent)**
|
||||
|
||||
`backend/app/agents/sync_agent.py`:
|
||||
- 将 ShadowReimbursement 数据映射为标准报销单格式
|
||||
- 调用 `sync_service.mock_sync_to_backend()`
|
||||
- 记录 SyncRecord
|
||||
- 更新 shadow_reimbursement 的 sync_status 和 backend_bill_id
|
||||
- 处理同步失败重试(retry_count < 3 时标记 retrying)
|
||||
- 输出:`AgentResult(data={"sync_result": {...}})`
|
||||
|
||||
- [ ] **Step 6: 编写每个 Agent 的单元测试**
|
||||
|
||||
使用 mock DB、mock OCR、mock LLM 测试每个 Agent 的输入输出。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现 5 个 Agent(受理/解析/规则校验/解释补件/同步)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: LLM 集成层
|
||||
|
||||
**负责人:** 全栈/Agent 工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Phase 2
|
||||
**可并行于:** Task 3.2
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/llm_service.py`
|
||||
- Create: `backend/app/services/llm_prompts/__init__.py`
|
||||
- Create: `backend/app/services/llm_prompts/intent_classification.py`
|
||||
- Create: `backend/app/services/llm_prompts/risk_explanation.py`
|
||||
- Create: `backend/app/services/llm_prompts/expense_type_mapping.py`
|
||||
- Test: `backend/tests/test_llm_service.py`
|
||||
|
||||
- [ ] **Step 1: 实现 LLM Service 封装**
|
||||
|
||||
`backend/app/services/llm_service.py`:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import json
|
||||
from app.core.config import settings
|
||||
|
||||
class LLMService:
|
||||
def __init__(self):
|
||||
self.api_key = settings.LLM_API_KEY
|
||||
self.model = settings.LLM_MODEL
|
||||
self.base_url = settings.LLM_BASE_URL or "https://api.openai.com/v1"
|
||||
|
||||
async def chat(self, system_prompt: str, user_message: str, json_mode: bool = False) -> str:
|
||||
"""调用 LLM API"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
async def chat_json(self, system_prompt: str, user_message: str) -> dict:
|
||||
"""调用 LLM 并解析 JSON 响应"""
|
||||
raw = await self.chat(system_prompt, user_message, json_mode=True)
|
||||
return json.loads(raw)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Prompt 模板**
|
||||
|
||||
**`intent_classification.py`** — 分析用户意图,识别报销类型:
|
||||
|
||||
```python
|
||||
INTENT_CLASSIFICATION_PROMPT = """你是一个报销意图识别助手。根据用户的描述,识别报销类型和关键信息。
|
||||
|
||||
请严格按以下 JSON 格式输出:
|
||||
{
|
||||
"reimbursement_type": "travel_expense" | "office_expense" | "business_meal" | "other",
|
||||
"travel_info": {
|
||||
"destination": "城市名",
|
||||
"start_date": "YYYY-MM-DD" 或 null,
|
||||
"end_date": "YYYY-MM-DD" 或 null,
|
||||
"purpose": "出差事由"
|
||||
},
|
||||
"confidence": 0.0-1.0
|
||||
}
|
||||
|
||||
用户描述:{user_intent}
|
||||
"""
|
||||
```
|
||||
|
||||
**`risk_explanation.py`** — 将规则命中结果转为自然语言解释:
|
||||
|
||||
```python
|
||||
RISK_EXPLANATION_PROMPT = """你是一个报销制度解释助手。请根据规则命中结果,用简洁易懂的语言向用户解释问题。
|
||||
|
||||
规则命中信息:
|
||||
- 规则名称:{rule_name}
|
||||
- 问题类型:{issue_type}
|
||||
- 制度依据:{policy_ref}
|
||||
- 具体数据:{hit_detail}
|
||||
|
||||
请用 2-3 句话解释:
|
||||
1. 存在什么问题
|
||||
2. 制度标准是什么
|
||||
3. 建议如何处理
|
||||
"""
|
||||
```
|
||||
|
||||
**`expense_type_mapping.py`** — 根据 OCR 结果匹配费用类型:
|
||||
|
||||
```python
|
||||
EXPENSE_TYPE_MAPPING_PROMPT = """根据票据 OCR 识别结果,判断费用类型。
|
||||
|
||||
可选费用类型:
|
||||
- travel_transport: 差旅交通费(火车票、机票、打车)
|
||||
- travel_hotel: 差旅住宿费(酒店发票)
|
||||
- travel_meal: 差旅餐补
|
||||
- local_transport: 市内交通费
|
||||
- business_meal: 业务招待费
|
||||
- office_supply: 办公用品费
|
||||
- communication: 通讯费
|
||||
- other: 其他
|
||||
|
||||
OCR 识别结果:{ocr_result}
|
||||
|
||||
请输出 JSON:{"expense_type": "类型编码", "confidence": 0.0-1.0}
|
||||
"""
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写测试(使用 mock LLM 响应)**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_intent_classification():
|
||||
with patch("app.services.llm_service.LLMService.chat_json", new_callable=AsyncMock) as mock_chat:
|
||||
mock_chat.return_value = {
|
||||
"reimbursement_type": "travel_expense",
|
||||
"travel_info": {"destination": "北京", "purpose": "商务出差"},
|
||||
"confidence": 0.9
|
||||
}
|
||||
llm = LLMService()
|
||||
result = await llm.chat_json("system prompt", "我要报北京出差费用")
|
||||
assert result["reimbursement_type"] == "travel_expense"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现 LLM 集成层(多 Provider + Prompt 模板)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3.4: 审计日志
|
||||
|
||||
**负责人:** 后端工程师 B
|
||||
**预计工时:** 1 天
|
||||
**前置依赖:** Phase 2
|
||||
**可并行于:** Task 3.1、3.2、3.3
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/audit_service.py`
|
||||
- Create: `backend/app/api/v1/audit.py`
|
||||
- Modify: `backend/app/api/v1/router.py`
|
||||
- Test: `backend/tests/test_audit.py`
|
||||
|
||||
- [ ] **Step 1: 实现 AuditService**
|
||||
|
||||
`backend/app/services/audit_service.py`:
|
||||
|
||||
```python
|
||||
import json
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Mapped
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def log(self, action: str, actor: str, target_type: str, target_id: str, detail: dict | None = None):
|
||||
"""记录审计日志"""
|
||||
log_entry = AuditLog(
|
||||
action=action,
|
||||
actor=actor,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
detail=json.dumps(detail, ensure_ascii=False) if detail else None,
|
||||
)
|
||||
self.db.add(log_entry)
|
||||
await self.db.flush() # 不 commit,让调用方统一 commit
|
||||
|
||||
async def query_logs(self, target_type: str | None = None, target_id: str | None = None,
|
||||
actor: str | None = None, page: int = 1, size: int = 50):
|
||||
"""查询审计日志"""
|
||||
query = select(AuditLog)
|
||||
if target_type:
|
||||
query = query.where(AuditLog.target_type == target_type)
|
||||
if target_id:
|
||||
query = query.where(AuditLog.target_id == target_id)
|
||||
if actor:
|
||||
query = query.where(AuditLog.actor == actor)
|
||||
query = query.order_by(AuditLog.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
result = await self.db.execute(query)
|
||||
return result.scalars().all()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 定义审计动作枚举**
|
||||
|
||||
```python
|
||||
class AuditAction:
|
||||
FILE_UPLOAD = "file_upload"
|
||||
OCR_RECOGNIZE = "ocr_recognize"
|
||||
AGENT_CALL = "agent_call"
|
||||
RULE_HIT = "rule_hit"
|
||||
SUPPLEMENT_REQUEST = "supplement_request"
|
||||
SUPPLEMENT_RESPOND = "supplement_respond"
|
||||
USER_CONFIRM = "user_confirm"
|
||||
BACKEND_SYNC = "backend_sync"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在关键路径埋点**
|
||||
|
||||
在以下位置调用 `audit_service.log()`:
|
||||
- `document_service.upload_document()` → `FILE_UPLOAD`
|
||||
- `ocr_service.recognize()` → `OCR_RECOGNIZE`
|
||||
- `orchestrator._run_agent()` → `AGENT_CALL`
|
||||
- `rule_engine.run_precheck()` → `RULE_HIT`(每条命中记录一条)
|
||||
- `supplement_service.create_supplement_request()` → `SUPPLEMENT_REQUEST`
|
||||
- `supplement_service.respond_supplement()` → `SUPPLEMENT_RESPOND`
|
||||
- `sync_service.mock_sync_to_backend()` → `BACKEND_SYNC`
|
||||
|
||||
- [ ] **Step 4: 实现审计日志查询 API**
|
||||
|
||||
`GET /api/v1/audit/logs` — 支持按 target_type、target_id、actor、date_range 过滤
|
||||
|
||||
- [ ] **Step 5: 编写测试**
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 实现审计日志服务(记录 + 查询 API)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] Orchestrator 状态机所有转换路径测试通过
|
||||
- [ ] 5 个 Agent 能独立执行并返回正确结果
|
||||
- [ ] LLM Service 能调用大模型并解析 JSON 响应
|
||||
- [ ] 审计日志在所有关键路径都有记录
|
||||
- [ ] `POST /api/v1/reimbursement/tasks/{id}/agent/run` 能启动完整 Agent 流程
|
||||
- [ ] 所有测试 `pytest tests/ -v` 全部通过
|
||||
@@ -1,500 +0,0 @@
|
||||
# Phase 4: 前端核心页面(W4-W5)
|
||||
|
||||
> **目标:** 实现所有核心前端页面和组件,完成用户交互界面。
|
||||
> **周期:** 第 4 ~ 5 周
|
||||
> **任务数:** 4 个
|
||||
> **可并行:** 4 个任务可由 1-2 名前端工程师并行开发
|
||||
> **前置依赖:** Phase 1(前端骨架)
|
||||
> **备注:** 可与 Phase 3 后半段并行开始
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| 报销入口页 | 对话式报销入口 + 快捷操作 |
|
||||
| 票据上传页 | 文件上传组件 + 票据类型选择 |
|
||||
| 报销草稿页 | 费用明细表格 + 可编辑字段 |
|
||||
| 预审结果页 | 风险展示 + 规则命中详情 |
|
||||
| 补件交互页 | 补件清单 + 上传/回复 |
|
||||
| 提交确认页 | 最终确认 + 同步状态 |
|
||||
| 审计日志页 | 操作时间线 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 4.1: 报销入口页 + 上传组件
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Phase 1(前端骨架)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/HomeView.vue`
|
||||
- Create: `frontend/src/views/UploadView.vue`
|
||||
- Create: `frontend/src/components/FileUpload.vue`
|
||||
- Create: `frontend/src/stores/task.ts`
|
||||
- Create: `frontend/src/api/task.ts`
|
||||
- Create: `frontend/src/api/document.ts`
|
||||
|
||||
- [ ] **Step 1: 实现 API 调用层**
|
||||
|
||||
`frontend/src/api/task.ts`:
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export const createTask = (data: {
|
||||
userId: string
|
||||
companyId: string
|
||||
userIntent: string
|
||||
entryChannel?: string
|
||||
}) => api.post('/reimbursement/tasks', data)
|
||||
|
||||
export const getTask = (taskId: string) =>
|
||||
api.get(`/reimbursement/tasks/${taskId}`)
|
||||
|
||||
export const listTasks = (params?: { userId?: string; status?: string; page?: number; size?: number }) =>
|
||||
api.get('/reimbursement/tasks', { params })
|
||||
|
||||
export const runAgent = (taskId: string, startFrom = 'intake', mode = 'precheck') =>
|
||||
api.post(`/reimbursement/tasks/${taskId}/agent/run`, { start_from: startFrom, mode })
|
||||
```
|
||||
|
||||
`frontend/src/api/document.ts`:
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export const uploadDocument = (taskId: string, file: File, documentType: string) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('document_type', documentType)
|
||||
return api.post(`/reimbursement/tasks/${taskId}/documents`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export const listDocuments = (taskId: string) =>
|
||||
api.get(`/reimbursement/tasks/${taskId}/documents`)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 Pinia Store**
|
||||
|
||||
`frontend/src/stores/task.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { createTask, getTask, listTasks, runAgent } from '@/api/task'
|
||||
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const currentTask = ref<any>(null)
|
||||
const taskList = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function create(userIntent: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await createTask({
|
||||
userId: 'U001', // TODO: 从登录态获取
|
||||
companyId: 'C001',
|
||||
userIntent,
|
||||
})
|
||||
currentTask.value = data
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTask(taskId: string) {
|
||||
const { data } = await getTask(taskId)
|
||||
currentTask.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function startAgent(taskId: string, startFrom = 'intake') {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await runAgent(taskId, startFrom)
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { currentTask, taskList, loading, create, fetchTask, startAgent }
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现报销入口页 HomeView**
|
||||
|
||||
按开发文档 9.2 节:
|
||||
|
||||
- **对话输入框**:用户输入报销意图(如"我要报这次北京出差的费用")
|
||||
- **上传按钮**:直接跳转到上传页
|
||||
- **最近任务列表**:显示用户最近的报销任务和状态
|
||||
- **常用报销类型快捷按钮**:
|
||||
- "报差旅"
|
||||
- "看发票能不能报"
|
||||
- "帮我生成报销单"
|
||||
- "这张发票为什么不合规?"
|
||||
- **智能引导提示**:根据用户输入实时提示
|
||||
|
||||
交互流程:用户输入意图 → 调用 `taskStore.create()` → 跳转到 `/task/{taskId}/upload`
|
||||
|
||||
- [ ] **Step 4: 实现文件上传组件 FileUpload**
|
||||
|
||||
`frontend/src/components/FileUpload.vue`:
|
||||
|
||||
Ant Design Vue 的 `a-upload-dragger` 封装:
|
||||
- 支持拖拽上传
|
||||
- 支持多文件选择
|
||||
- 文件类型校验(PDF、JPG、PNG、OFD)
|
||||
- 单文件大小限制(默认 10MB)
|
||||
- 上传进度条
|
||||
- 预览缩略图
|
||||
- 已上传文件列表
|
||||
- 删除已上传文件
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface Props {
|
||||
taskId: string
|
||||
accept?: string // 默认 '.pdf,.jpg,.jpeg,.png,.ofd'
|
||||
maxFileSize?: number // MB,默认 10
|
||||
maxCount?: number // 默认 10
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现票据上传页 UploadView**
|
||||
|
||||
- 引用 FileUpload 组件
|
||||
- 票据类型下拉选择(Ant Design Select):
|
||||
- 增值税发票 (vat_invoice)
|
||||
- 火车票 (train_ticket)
|
||||
- 机票行程单 (flight_itinerary)
|
||||
- 打车票据 (taxi_receipt)
|
||||
- 酒店流水 (hotel_bill)
|
||||
- 支付截图 (payment_screenshot)
|
||||
- 其他附件 (other_attachment)
|
||||
- 已上传文件列表展示
|
||||
- "开始识别" 按钮 → 调用 `taskStore.startAgent()` → 跳转到草稿页
|
||||
- 返回按钮 → 回到首页
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat: 实现报销入口页和票据上传页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: 报销草稿页
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Task 4.1
|
||||
**可并行于:** Task 4.3(如果两人并行)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/DraftView.vue`
|
||||
- Create: `frontend/src/components/ExpenseTable.vue`
|
||||
- Create: `frontend/src/api/precheck.ts`
|
||||
|
||||
- [ ] **Step 1: 添加预审 API**
|
||||
|
||||
`frontend/src/api/precheck.ts`:
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export const getDraft = (taskId: string) =>
|
||||
api.get(`/reimbursement/tasks/${taskId}/draft`)
|
||||
|
||||
export const getPrecheckResult = (taskId: string) =>
|
||||
api.get(`/reimbursement/tasks/${taskId}/precheck-result`)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 ExpenseTable 组件**
|
||||
|
||||
`frontend/src/components/ExpenseTable.vue`:
|
||||
|
||||
Ant Design Table 展示费用明细:
|
||||
|
||||
| 列名 | 字段 | 可编辑 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 费用类型 | expense_type | ✅ | 下拉选择 |
|
||||
| 金额 | amount | ✅ | 数字输入 |
|
||||
| 税额 | tax_amount | ✅ | 数字输入 |
|
||||
| 发生日期 | occurred_at | ✅ | 日期选择 |
|
||||
| 城市 | city | ✅ | 文本输入 |
|
||||
| 商户 | vendor_name | ✅ | 文本输入 |
|
||||
| 风险等级 | risk_level | ❌ | 彩色标签 |
|
||||
|
||||
风险等级标签颜色映射:
|
||||
- `low` → 绿色 `green`
|
||||
- `medium` → 橙色 `orange`
|
||||
- `high` → 红色 `red`
|
||||
- `blocked` → 深红 `#cf1322`
|
||||
|
||||
支持行内编辑(点击单元格进入编辑模式)。
|
||||
|
||||
- [ ] **Step 3: 实现报销草稿页 DraftView**
|
||||
|
||||
按开发文档 9.3 节布局:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 报销草稿 [预审按钮] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 基本信息 │
|
||||
│ ┌──────────┬──────────┬──────────┐ │
|
||||
│ │ 报销人 │ 部门 │ 成本中心 │ │
|
||||
│ └──────────┴──────────┴──────────┘ │
|
||||
│ ┌──────────┬──────────┐ │
|
||||
│ │ 项目 │ 报销事由 │ │
|
||||
│ └──────────┴──────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 费用明细 │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ ExpenseTable 组件 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ 总金额:¥ 2,380.00 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 票据附件 │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ │
|
||||
│ │ 📄 │ │ 📄 │ │ 📄 │ (缩略图 + 文件名) │
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 预审状态:⏳ 待预审 │
|
||||
│ [执行预审] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
交互:
|
||||
- 页面加载时调用 `getDraft(taskId)` 获取草稿数据
|
||||
- 编辑字段后暂存到本地 state
|
||||
- 点击"执行预审" → 调用 `taskStore.startAgent(taskId, 'precheck')` → 跳转到预审结果页
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat: 实现报销草稿页和费用明细表格组件"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: 预审结果页 + 补件交互页
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 2.5 天
|
||||
**前置依赖:** Task 4.1
|
||||
**可并行于:** Task 4.2(如果两人并行)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/PrecheckView.vue`
|
||||
- Create: `frontend/src/views/SupplementView.vue`
|
||||
- Create: `frontend/src/components/RuleHitCard.vue`
|
||||
- Create: `frontend/src/api/supplement.ts`
|
||||
|
||||
- [ ] **Step 1: 添加补件 API**
|
||||
|
||||
`frontend/src/api/supplement.ts`:
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export const respondSupplement = (taskId: string, supplementRequestId: string, data: {
|
||||
responseText: string
|
||||
documentIds?: string[]
|
||||
}) => api.post(`/reimbursement/tasks/${taskId}/supplements`, {
|
||||
supplement_request_id: supplementRequestId,
|
||||
...data,
|
||||
})
|
||||
|
||||
export const submitTask = (taskId: string, submitTo: string = 'expense_system') =>
|
||||
api.post(`/reimbursement/tasks/${taskId}/submit`, { confirmed: true, submit_to: submitTo })
|
||||
|
||||
export const getSyncStatus = (taskId: string) =>
|
||||
api.get(`/reimbursement/tasks/${taskId}/sync-status`)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 RuleHitCard 组件**
|
||||
|
||||
`frontend/src/components/RuleHitCard.vue`:
|
||||
|
||||
Ant Design Card 展示单条规则命中:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔴 住宿费超标 TRAVEL_HOTEL_LIMIT │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 问题:住宿费超出当前城市和职级标准 │
|
||||
│ 制度依据:差旅报销制度-住宿标准 │
|
||||
│ 建议:请补充超标说明或发起特殊审批 │
|
||||
│ [展开详情] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface Props {
|
||||
ruleCode: string
|
||||
ruleName: string
|
||||
severity: string // low / medium / high / blocked
|
||||
action: string
|
||||
message: string
|
||||
suggestion: string
|
||||
policyRef: string
|
||||
hitDetail?: object
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现预审结果页 PrecheckView**
|
||||
|
||||
按开发文档 9.4 节:
|
||||
|
||||
- **总体结论卡片**:
|
||||
- ✅ 通过 → 绿色
|
||||
- ⚠️ 需补件 → 橙色
|
||||
- 🚫 有阻断 → 红色
|
||||
- **风险等级指示**:彩色 Badge
|
||||
- **通过项列表**:绿色勾选图标 + 规则名称
|
||||
- **风险项列表**:使用 RuleHitCard 组件
|
||||
- **缺件项列表**:橙色提示 + 补件按钮
|
||||
- **操作按钮**:
|
||||
- "一键补件" → 跳转到补件页(仅在有缺件时显示)
|
||||
- "确认提交" → 跳转到确认页(仅预审通过时可用)
|
||||
|
||||
交互:
|
||||
- 页面加载时调用 `getPrecheckResult(taskId)` 获取预审结果
|
||||
- 根据结果渲染不同状态
|
||||
|
||||
- [ ] **Step 4: 实现补件交互页 SupplementView**
|
||||
|
||||
- **待补件清单**:从预审结果的 rule_hits 中过滤出 `require_attachment` / `require_explanation` 类型
|
||||
- **每个补件项**:
|
||||
- 类型标签(补充附件 / 补充说明 / 修改字段)
|
||||
- 提示文案
|
||||
- 操作区域:
|
||||
- 补充附件:调用 FileUpload 组件
|
||||
- 补充说明:文本输入框
|
||||
- **提交补件按钮** → 调用 `respondSupplement()` → 跳转回预审页重新预审
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat: 实现预审结果页和补件交互页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4.4: 提交确认页 + 审计日志页
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 1.5 天
|
||||
**前置依赖:** Task 4.3
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/ConfirmView.vue`
|
||||
- Create: `frontend/src/views/AuditView.vue`
|
||||
- Create: `frontend/src/api/audit.ts`
|
||||
|
||||
- [ ] **Step 1: 添加审计 API**
|
||||
|
||||
`frontend/src/api/audit.ts`:
|
||||
|
||||
```typescript
|
||||
import api from './index'
|
||||
|
||||
export const getAuditLogs = (params?: {
|
||||
target_type?: string
|
||||
target_id?: string
|
||||
actor?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}) => api.get('/audit/logs', { params })
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现提交确认页 ConfirmView**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 提交确认 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 报销单摘要(不可编辑) │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 报销人:张三 部门:技术部 │ │
|
||||
│ │ 事由:北京出差 总金额:¥2,380.00 │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 费用明细汇总: │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ 差旅住宿费 ¥1,200.00 │ │
|
||||
│ │ 差旅交通费 ¥ 553.00 │ │
|
||||
│ │ 差旅餐补 ¥ 627.00 │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 附件清单:3 个文件 │
|
||||
│ 同步目标:费控系统 │
|
||||
│ │
|
||||
│ [返回修改] [确认提交] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 同步状态:⏳ 提交中... │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
交互:
|
||||
- "确认提交" → 调用 `submitTask(taskId)`
|
||||
- 提交后轮询 `getSyncStatus(taskId)`,展示同步进度
|
||||
- 同步成功 → 显示后端单据号
|
||||
- 同步失败 → 显示错误信息 + 重试按钮
|
||||
|
||||
- [ ] **Step 3: 实现审计日志页 AuditView**
|
||||
|
||||
按开发文档 9.5 节(简化版):
|
||||
|
||||
- **Ant Design Timeline** 展示操作记录
|
||||
- **筛选栏**:按任务ID、操作类型、时间范围筛选
|
||||
- **每条日志**:
|
||||
- 时间戳
|
||||
- 操作人
|
||||
- 操作类型(彩色标签)
|
||||
- 详情(可展开)
|
||||
|
||||
操作类型颜色映射:
|
||||
- `file_upload` → 蓝色
|
||||
- `ocr_recognize` → 青色
|
||||
- `agent_call` → 紫色
|
||||
- `rule_hit` → 橙色
|
||||
- `supplement_request` / `supplement_respond` → 绿色
|
||||
- `user_confirm` → 金色
|
||||
- `backend_sync` → 灰色
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat: 实现提交确认页和审计日志页"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] 首页能创建任务并跳转到上传页
|
||||
- [ ] 上传页能上传文件并开始识别
|
||||
- [ ] 草稿页能展示费用明细并支持编辑
|
||||
- [ ] 预审结果页能展示风险项和缺件项
|
||||
- [ ] 补件页能上传附件和填写说明
|
||||
- [ ] 确认页能展示摘要和同步状态
|
||||
- [ ] 审计日志页能展示操作时间线
|
||||
- [ ] 所有页面响应式布局正常
|
||||
- [ ] 前端 `npm run build` 无报错
|
||||
@@ -1,259 +0,0 @@
|
||||
# Phase 5: 联调与集成(W5-W6)
|
||||
|
||||
> **目标:** 前后端联调跑通完整流程,配置规则种子数据,确保全链路畅通。
|
||||
> **周期:** 第 5 ~ 6 周
|
||||
> **任务数:** 2 个
|
||||
> **可并行:** 联调和种子数据可由不同人并行
|
||||
> **前置依赖:** Phase 3 + Phase 4
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| 完整流程跑通 | 从创建任务到同步成功的端到端流程 |
|
||||
| 规则种子数据 | 差旅报销制度 + 6 条核心规则 + 城市等级标准 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 5.1: 前后端联调
|
||||
|
||||
**负责人:** 全员参与
|
||||
**预计工时:** 3-4 天
|
||||
**前置依赖:** Phase 3 + Phase 4
|
||||
|
||||
**Files:**
|
||||
- Modify: 多个前后端文件(修复联调问题)
|
||||
|
||||
- [ ] **Step 1: 启动前后端全栈环境**
|
||||
|
||||
```bash
|
||||
# 启动基础设施
|
||||
docker-compose up -d
|
||||
|
||||
# 启动后端
|
||||
cd backend
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# 启动前端
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
确认:
|
||||
- PostgreSQL 可连接:`psql -h localhost -U postgres -d x_financial`
|
||||
- Redis 可连接:`redis-cli ping`
|
||||
- MinIO 可访问:http://localhost:9001
|
||||
- 后端健康检查:http://localhost:8000/health
|
||||
- 前端可访问:http://localhost:5173
|
||||
|
||||
- [ ] **Step 2: 跑通完整报销流程(10 步)**
|
||||
|
||||
按开发文档 3.1 节的完整流程:
|
||||
|
||||
**步骤 1 - 创建任务**
|
||||
1. 访问首页 http://localhost:5173
|
||||
2. 在输入框输入"我要报这次北京出差的费用"
|
||||
3. 点击提交
|
||||
4. 验证:任务创建成功,跳转到上传页
|
||||
|
||||
**步骤 2 - 上传票据**
|
||||
1. 选择票据类型:增值税发票
|
||||
2. 上传模拟发票文件(可用任意 PDF/PNG)
|
||||
3. 选择票据类型:火车票
|
||||
4. 上传模拟火车票文件
|
||||
5. 验证:文件列表显示 2 个文件
|
||||
|
||||
**步骤 3 - 启动 Agent 识别**
|
||||
1. 点击"开始识别"
|
||||
2. 等待 Agent 处理(观察后端日志)
|
||||
3. 验证:跳转到草稿页,显示识别结果
|
||||
|
||||
**步骤 4 - 查看报销草稿**
|
||||
1. 确认费用明细已自动填充
|
||||
2. 检查金额、商户、日期等字段
|
||||
3. 验证:可编辑字段能修改
|
||||
|
||||
**步骤 5 - 执行预审**
|
||||
1. 点击"执行预审"
|
||||
2. 等待规则引擎执行
|
||||
3. 验证:跳转到预审结果页
|
||||
|
||||
**步骤 6 - 查看预审结果**
|
||||
1. 检查总体结论
|
||||
2. 查看风险项(如有)
|
||||
3. 查看缺件项(如有)
|
||||
4. 验证:规则命中详情展示正确
|
||||
|
||||
**步骤 7 - 补件(如需要)**
|
||||
1. 点击"一键补件"
|
||||
2. 在补件页上传缺失附件
|
||||
3. 提交补件
|
||||
4. 验证:跳转回预审页,重新预审
|
||||
|
||||
**步骤 8 - 确认提交**
|
||||
1. 确认报销单摘要
|
||||
2. 点击"确认提交"
|
||||
3. 等待同步状态更新
|
||||
4. 验证:同步状态变为 success
|
||||
|
||||
**步骤 9 - 查看审计日志**
|
||||
1. 访问审计日志页
|
||||
2. 按任务ID筛选
|
||||
3. 验证:所有操作步骤都有记录
|
||||
|
||||
**步骤 10 - 查看后端 Swagger**
|
||||
1. 访问 http://localhost:8000/docs
|
||||
2. 验证所有 API 文档正确
|
||||
|
||||
- [ ] **Step 3: 修复联调过程中发现的问题**
|
||||
|
||||
常见问题检查清单:
|
||||
- [ ] API 响应格式前后端一致(字段名、嵌套结构)
|
||||
- [ ] 日期格式统一(ISO 8601)
|
||||
- [ ] 金额精度(Decimal vs Number)
|
||||
- [ ] 错误处理(前端能正确显示后端错误信息)
|
||||
- [ ] 文件上传大小限制(前端 + 后端 + MinIO)
|
||||
- [ ] 跨域配置正确
|
||||
- [ ] 路由跳转正常
|
||||
- [ ] Loading 状态显示
|
||||
- [ ] 空状态展示
|
||||
|
||||
- [ ] **Step 4: 压力测试关键接口**
|
||||
|
||||
- `POST /tasks` 创建 100 个任务
|
||||
- `GET /tasks` 列表查询响应时间 < 500ms
|
||||
- `POST /tasks/{id}/documents` 上传 10 个文件
|
||||
- `POST /tasks/{id}/agent/run` Agent 执行时间 < 30s
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: 前后端联调修复(完整流程跑通)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5.2: 规则配置与种子数据
|
||||
|
||||
**负责人:** 全栈/Agent 工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Phase 2(规则引擎)
|
||||
**可并行于:** Task 5.1
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/alembic/seed/expense_policies.sql`
|
||||
- Create: `backend/alembic/seed/expense_rules.sql`
|
||||
- Create: `backend/alembic/seed/city_levels.sql`
|
||||
- Create: `backend/alembic/seed/hotel_limits.sql`
|
||||
- Create: `backend/scripts/seed_data.py`
|
||||
|
||||
- [ ] **Step 1: 编写城市等级数据**
|
||||
|
||||
`backend/alembic/seed/city_levels.sql`:
|
||||
|
||||
按典型企业标准配置:
|
||||
- **一线城市**:北京、上海、广州、深圳
|
||||
- **二线城市**:杭州、南京、成都、武汉、重庆、天津、苏州、西安
|
||||
- **三线城市**:其他城市
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS city_level (
|
||||
city_name VARCHAR(50) PRIMARY KEY,
|
||||
level VARCHAR(10) NOT NULL -- tier1 / tier2 / tier3
|
||||
);
|
||||
|
||||
INSERT INTO city_level (city_name, level) VALUES
|
||||
('北京', 'tier1'), ('上海', 'tier1'), ('广州', 'tier1'), ('深圳', 'tier1'),
|
||||
('杭州', 'tier2'), ('南京', 'tier2'), ('成都', 'tier2'), ('武汉', 'tier2'),
|
||||
('重庆', 'tier2'), ('天津', 'tier2'), ('苏州', 'tier2'), ('西安', 'tier2');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写住宿标准数据**
|
||||
|
||||
`backend/alembic/seed/hotel_limits.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS hotel_limit (
|
||||
city_level VARCHAR(10) NOT NULL,
|
||||
job_level VARCHAR(20) NOT NULL, -- manager / senior / staff
|
||||
limit_per_night DECIMAL(10,2) NOT NULL,
|
||||
PRIMARY KEY (city_level, job_level)
|
||||
);
|
||||
|
||||
INSERT INTO hotel_limit (city_level, job_level, limit_per_night) VALUES
|
||||
('tier1', 'manager', 800.00), ('tier1', 'senior', 600.00), ('tier1', 'staff', 500.00),
|
||||
('tier2', 'manager', 600.00), ('tier2', 'senior', 450.00), ('tier2', 'staff', 350.00),
|
||||
('tier3', 'manager', 500.00), ('tier3', 'senior', 350.00), ('tier3', 'staff', 300.00);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写规则种子数据**
|
||||
|
||||
`backend/alembic/seed/expense_rules.sql` — 预置开发文档 7.2 节的 3 条示例规则 + MVP 需要的 6 条核心规则:
|
||||
|
||||
1. `TRAVEL_HOTEL_LIMIT` — 住宿费标准校验(severity: medium, action: require_explanation)
|
||||
2. `HOTEL_BILL_REQUIRED` — 住宿费必须上传酒店流水(severity: medium, action: require_attachment)
|
||||
3. `DUPLICATE_INVOICE_CHECK` — 重复发票检查(severity: blocked, action: block)
|
||||
4. `REQUIRED_FIELDS_CHECK` — 必填字段校验(severity: medium, action: warn)
|
||||
5. `AMOUNT_ABNORMAL_CHECK` — 金额异常检查(severity: high, action: require_explanation)
|
||||
6. `DATE_VALIDITY_CHECK` — 日期合理性校验(severity: low, action: warn)
|
||||
7. `EXPENSE_TYPE_MATCH_CHECK` — 费用类型匹配校验(severity: low, action: warn)
|
||||
8. `INVOICE_TITLE_CHECK` — 发票抬头校验(severity: high, action: require_explanation)
|
||||
9. `TRIP_PERIOD_MATCH_CHECK` — 出差期间匹配校验(severity: medium, action: warn)
|
||||
|
||||
每条规则包含完整的 condition_json、action、severity、message_template、policy_ref。
|
||||
|
||||
- [ ] **Step 4: 编写数据初始化脚本**
|
||||
|
||||
`backend/scripts/seed_data.py` — 一键初始化所有种子数据:
|
||||
|
||||
```python
|
||||
"""初始化种子数据"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from app.core.config import settings
|
||||
# ... 读取 SQL 文件并执行
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(settings.DATABASE_URL)
|
||||
async with engine.begin() as conn:
|
||||
# 按顺序执行 seed SQL
|
||||
for sql_file in ['city_levels.sql', 'hotel_limits.sql', 'expense_rules.sql']:
|
||||
with open(f'alembic/seed/{sql_file}') as f:
|
||||
await conn.execute(f.read())
|
||||
print("Seed data loaded successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证种子数据**
|
||||
|
||||
Run: `cd backend && python scripts/seed_data.py`
|
||||
验证:
|
||||
- 城市等级表有数据
|
||||
- 住宿标准表有数据
|
||||
- 规则表有 9 条规则且全部 enabled
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "feat: 添加差旅报销制度和规则种子数据"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] 完整报销流程(创建→上传→识别→草稿→预审→补件→提交→同步)端到端跑通
|
||||
- [ ] 前后端 API 格式一致,无字段不匹配
|
||||
- [ ] 错误场景有正确提示(上传失败、OCR 失败、同步失败)
|
||||
- [ ] 种子数据加载成功,规则引擎使用种子数据执行预审
|
||||
- [ ] Swagger 文档 http://localhost:8000/docs 可访问
|
||||
- [ ] 审计日志记录了完整操作链路
|
||||
@@ -1,553 +0,0 @@
|
||||
# Phase 6: 测试与打磨(W7-W8)
|
||||
|
||||
> **目标:** 完善集成测试、E2E 测试、修复 Bug、UI 打磨、编写部署文档,准备 Demo 演示。
|
||||
> **周期:** 第 7 ~ 8 周
|
||||
> **任务数:** 4 个
|
||||
> **可并行:** Task 6.1 / 6.2 / 6.3 可并行
|
||||
> **前置依赖:** Phase 5
|
||||
|
||||
---
|
||||
|
||||
## 本阶段交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|---|---|
|
||||
| 后端集成测试 | 完整报销流程的自动化测试 |
|
||||
| 前端 E2E 测试 | Playwright 自动化测试(可选) |
|
||||
| Bug 修复 + UI 打磨 | 视觉和交互优化 |
|
||||
| 部署文档 | README + 部署指南 + API 文档 |
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
### Task 6.1: 后端集成测试
|
||||
|
||||
**负责人:** 后端工程师 A
|
||||
**预计工时:** 3 天
|
||||
**前置依赖:** Phase 5
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_integration_flow.py`
|
||||
- Create: `backend/tests/helpers.py`(测试辅助函数)
|
||||
- Modify: `backend/tests/conftest.py`(添加测试数据库 fixture)
|
||||
|
||||
- [ ] **Step 1: 更新 conftest.py 添加测试数据库 fixture**
|
||||
|
||||
`backend/tests/conftest.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from app.models.base import Base
|
||||
from app.main import app
|
||||
from app.core.database import get_db
|
||||
|
||||
# 测试数据库 URL
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/x_financial_test"
|
||||
|
||||
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||
test_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_database():
|
||||
"""每个测试前创建表,测试后清理"""
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
async with test_session() as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db):
|
||||
async def override_get_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.clear()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写测试辅助函数**
|
||||
|
||||
`backend/tests/helpers.py`:
|
||||
|
||||
```python
|
||||
from httpx import AsyncClient
|
||||
|
||||
async def create_task(client: AsyncClient, user_id="U001", company_id="C001", intent="报北京出差费用") -> dict:
|
||||
resp = await client.post("/api/v1/reimbursement/tasks", json={
|
||||
"user_id": user_id, "company_id": company_id, "user_intent": intent
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
return resp.json()
|
||||
|
||||
async def upload_document(client: AsyncClient, task_id: str, document_type: str, filename: str = "test.pdf") -> dict:
|
||||
files = {"file": (filename, b"fake file content", "application/pdf")}
|
||||
resp = await client.post(
|
||||
f"/api/v1/reimbursement/tasks/{task_id}/documents",
|
||||
files=files,
|
||||
data={"document_type": document_type}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()
|
||||
|
||||
async def run_agent(client: AsyncClient, task_id: str, start_from="intake") -> dict:
|
||||
resp = await client.post(
|
||||
f"/api/v1/reimbursement/tasks/{task_id}/agent/run",
|
||||
json={"start_from": start_from, "mode": "precheck"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
async def get_draft(client: AsyncClient, task_id: str) -> dict:
|
||||
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/draft")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
async def get_precheck_result(client: AsyncClient, task_id: str) -> dict:
|
||||
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/precheck-result")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
async def respond_supplement(client: AsyncClient, task_id: str, supplement_id: str, text: str) -> dict:
|
||||
resp = await client.post(
|
||||
f"/api/v1/reimbursement/tasks/{task_id}/supplements",
|
||||
json={"supplement_request_id": supplement_id, "response_text": text}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
async def submit_task(client: AsyncClient, task_id: str) -> dict:
|
||||
resp = await client.post(
|
||||
f"/api/v1/reimbursement/tasks/{task_id}/submit",
|
||||
json={"confirmed": True, "submit_to": "expense_system"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
async def get_sync_status(client: AsyncClient, task_id: str) -> dict:
|
||||
resp = await client.get(f"/api/v1/reimbursement/tasks/{task_id}/sync-status")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写完整流程集成测试**
|
||||
|
||||
`backend/tests/test_integration_flow.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from tests.helpers import *
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_reimbursement_flow(client):
|
||||
"""完整报销流程:创建→上传→识别→草稿→预审→补件→提交→同步"""
|
||||
|
||||
# 1. 创建任务
|
||||
task = await create_task(client, intent="我要报这次北京出差的费用")
|
||||
task_id = task["task_id"]
|
||||
assert task["status"] == "material_collecting"
|
||||
|
||||
# 2. 上传票据
|
||||
doc1 = await upload_document(client, task_id, "vat_invoice", "invoice.pdf")
|
||||
assert doc1["ocr_status"] == "pending"
|
||||
|
||||
doc2 = await upload_document(client, task_id, "train_ticket", "train.pdf")
|
||||
assert doc2["ocr_status"] == "pending"
|
||||
|
||||
doc3 = await upload_document(client, task_id, "hotel_bill", "hotel.pdf")
|
||||
|
||||
# 3. 启动 Agent(使用 mock OCR)
|
||||
result = await run_agent(client, task_id, start_from="intake")
|
||||
assert result["status"] in ["draft_generated", "prechecking", "need_supplement", "pending_user_confirm"]
|
||||
|
||||
# 4. 获取草稿
|
||||
draft = await get_draft(client, task_id)
|
||||
assert draft["reimbursement_id"] is not None
|
||||
assert len(draft["items"]) > 0
|
||||
assert draft["total_amount"] > 0
|
||||
|
||||
# 5. 获取预审结果
|
||||
precheck = await get_precheck_result(client, task_id)
|
||||
assert "risk_level" in precheck
|
||||
assert "precheck_status" in precheck
|
||||
assert "rule_hits" in precheck
|
||||
|
||||
# 6. 如果需要补件
|
||||
if precheck["precheck_status"] == "need_supplement":
|
||||
# 找到需要补件的规则
|
||||
for hit in precheck["rule_hits"]:
|
||||
if hit["action"] == "require_attachment":
|
||||
# 补充附件
|
||||
await upload_document(client, task_id, "hotel_bill", "hotel_supplement.pdf")
|
||||
await respond_supplement(client, task_id, hit.get("id", "S001"), "已补充酒店流水")
|
||||
|
||||
# 重新预审
|
||||
await run_agent(client, task_id, start_from="precheck")
|
||||
precheck2 = await get_precheck_result(client, task_id)
|
||||
|
||||
# 7. 确认提交
|
||||
submit = await submit_task(client, task_id)
|
||||
assert submit["status"] == "submitting"
|
||||
|
||||
# 8. 检查同步状态
|
||||
sync = await get_sync_status(client, task_id)
|
||||
assert sync["sync_status"] in ["success", "pending"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_without_intent(client):
|
||||
"""测试不提供意图时创建任务"""
|
||||
resp = await client.post("/api/v1/reimbursement/tasks", json={
|
||||
"user_id": "U001", "company_id": "C001"
|
||||
})
|
||||
assert resp.status_code == 422 # Validation error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_task(client):
|
||||
"""测试查询不存在的任务"""
|
||||
resp = await client.get("/api/v1/reimbursement/tasks/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tasks_pagination(client):
|
||||
"""测试任务列表分页"""
|
||||
# 创建多个任务
|
||||
for i in range(5):
|
||||
await create_task(client, intent=f"test task {i}")
|
||||
|
||||
# 测试分页
|
||||
resp = await client.get("/api/v1/reimbursement/tasks?page=1&size=3")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 5
|
||||
assert len(data["items"]) <= 3
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 编写规则引擎集成测试**
|
||||
|
||||
测试每条规则对真实报销数据的命中情况:
|
||||
- 住宿费超标 → 命中 `TRAVEL_HOTEL_LIMIT`
|
||||
- 缺少酒店流水 → 命中 `HOTEL_BILL_REQUIRED`
|
||||
- 重复发票 → 命中 `DUPLICATE_INVOICE_CHECK`
|
||||
- 合规报销 → 无命中
|
||||
|
||||
- [ ] **Step 5: 确保所有测试通过**
|
||||
|
||||
Run: `cd backend && pytest tests/ -v --tb=short`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/
|
||||
git commit -m "test: 添加完整报销流程集成测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6.2: 前端 E2E 测试(可选)
|
||||
|
||||
**负责人:** 前端工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Phase 5
|
||||
**可并行于:** Task 6.1、6.3
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/e2e/reimbursement.spec.ts`
|
||||
- Create: `frontend/playwright.config.ts`
|
||||
|
||||
- [ ] **Step 1: 安装 Playwright**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install -D @playwright/test
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 配置 Playwright**
|
||||
|
||||
`frontend/playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
baseURL: 'http://localhost:5173',
|
||||
use: {
|
||||
headless: true,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写核心流程 E2E 测试**
|
||||
|
||||
`frontend/e2e/reimbursement.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('完整报销流程', async ({ page }) => {
|
||||
// 1. 访问首页
|
||||
await page.goto('/')
|
||||
await expect(page.locator('h1')).toContainText('报销')
|
||||
|
||||
// 2. 输入报销意图
|
||||
await page.fill('input[placeholder*="报销"]', '我要报这次北京出差的费用')
|
||||
await page.click('button:has-text("提交")')
|
||||
|
||||
// 3. 跳转到上传页
|
||||
await expect(page).toHaveURL(/\/task\/.*\/upload/)
|
||||
|
||||
// 4. 上传文件
|
||||
await page.setInputFiles('input[type="file"]', 'tests/fixtures/invoice.pdf')
|
||||
|
||||
// 5. 选择票据类型
|
||||
await page.selectOption('select', 'vat_invoice')
|
||||
|
||||
// 6. 开始识别
|
||||
await page.click('button:has-text("开始识别")')
|
||||
|
||||
// 7. 跳转到草稿页
|
||||
await expect(page).toHaveURL(/\/draft/, { timeout: 30000 })
|
||||
|
||||
// 8. 执行预审
|
||||
await page.click('button:has-text("执行预审")')
|
||||
|
||||
// 9. 跳转到预审结果页
|
||||
await expect(page).toHaveURL(/\/precheck/, { timeout: 30000 })
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行 E2E 测试**
|
||||
|
||||
Run: `cd frontend && npx playwright test`
|
||||
Expected: All PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "test: 添加前端 E2E 测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6.3: Bug 修复与 UI 打磨
|
||||
|
||||
**负责人:** 全员参与
|
||||
**预计工时:** 3 天
|
||||
**前置依赖:** Phase 5
|
||||
**可并行于:** Task 6.1、6.2
|
||||
|
||||
- [ ] **Step 1: UI 走查清单**
|
||||
|
||||
逐页面检查:
|
||||
|
||||
| 页面 | 检查项 |
|
||||
|---|---|
|
||||
| 首页 | 布局、输入框交互、快捷按钮、最近任务列表 |
|
||||
| 上传页 | 拖拽上传、文件预览、票据类型选择、进度条 |
|
||||
| 草稿页 | 表格编辑、金额汇总、附件预览、预审按钮 |
|
||||
| 预审结果页 | 结论卡片、风险项展示、规则命中详情 |
|
||||
| 补件页 | 补件清单、上传/回复交互、提交反馈 |
|
||||
| 确认页 | 摘要展示、同步状态轮询、成功/失败状态 |
|
||||
| 审计日志页 | 时间线展示、筛选功能 |
|
||||
|
||||
- [ ] **Step 2: 修复共性问题**
|
||||
|
||||
- [ ] 响应式布局适配(1280px / 1024px / 768px 断点)
|
||||
- [ ] Loading 状态:所有异步操作加 loading 指示器
|
||||
- [ ] 错误提示:API 错误统一使用 Ant Design Message 提示
|
||||
- [ ] 空状态:无数据时展示空状态插画和文案
|
||||
- [ ] 表单校验:必填项红框提示 + 校验文案
|
||||
- [ ] 金额格式化:千分位 + 两位小数 + ¥ 前缀
|
||||
- [ ] 日期格式化:YYYY-MM-DD
|
||||
- [ ] 确认弹窗:删除、提交等危险操作二次确认
|
||||
|
||||
- [ ] **Step 3: 添加 Demo 展示数据**
|
||||
|
||||
在首页添加"体验 Demo"按钮,一键生成演示数据:
|
||||
- 创建一个已完成全流程的报销任务
|
||||
- 包含 3 条费用明细
|
||||
- 有规则命中记录
|
||||
- 有审计日志
|
||||
|
||||
- [ ] **Step 4: 性能优化**
|
||||
|
||||
- [ ] 路由懒加载(已配置)
|
||||
- [ ] 表格虚拟滚动(如果明细很多)
|
||||
- [ ] 图片懒加载
|
||||
- [ ] API 请求去重/缓存
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "fix: UI 打磨和 Bug 修复"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6.4: 部署与文档
|
||||
|
||||
**负责人:** 后端工程师 B + 前端工程师
|
||||
**预计工时:** 2 天
|
||||
**前置依赖:** Task 6.3
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.prod.yml`
|
||||
- Create: `nginx.conf`
|
||||
- Modify: `README.md`
|
||||
- Create: `docs/deployment.md`
|
||||
|
||||
- [ ] **Step 1: 编写生产 Docker Compose**
|
||||
|
||||
`docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./frontend/dist:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:${DB_PASSWORD}@postgres:5432/x_financial
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- MINIO_ENDPOINT=minio:9000
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- minio
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: x_financial
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 Nginx 配置**
|
||||
|
||||
`nginx.conf`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写部署文档**
|
||||
|
||||
`docs/deployment.md` — 包含:
|
||||
- 环境要求(Docker、Docker Compose)
|
||||
- 配置说明(.env 文件)
|
||||
- 启动步骤
|
||||
- 停止和重启
|
||||
- 数据库迁移
|
||||
- 种子数据初始化
|
||||
- 日志查看
|
||||
- 常见问题排查
|
||||
|
||||
- [ ] **Step 4: 更新 README**
|
||||
|
||||
项目 README 包含:
|
||||
- 项目简介和架构图
|
||||
- 快速启动(开发环境)
|
||||
- 技术栈说明
|
||||
- 目录结构
|
||||
- 开发指南
|
||||
- API 文档链接
|
||||
|
||||
- [ ] **Step 5: 确认 Swagger 文档完整**
|
||||
|
||||
访问 http://localhost:8000/docs,确认:
|
||||
- 所有 API 端点都有描述
|
||||
- 请求/响应示例完整
|
||||
- 错误码说明完整
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "docs: 添加部署文档、Nginx 配置、生产 Docker Compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本阶段完成检查
|
||||
|
||||
- [ ] `cd backend && pytest tests/ -v` 全部通过
|
||||
- [ ] `cd frontend && npx playwright test` 全部通过(如配置)
|
||||
- [ ] `cd frontend && npm run build` 无报错
|
||||
- [ ] 完整报销流程在浏览器中手动测试无问题
|
||||
- [ ] 所有页面响应式布局正常
|
||||
- [ ] `docker-compose -f docker-compose.prod.yml up -d` 能启动
|
||||
- [ ] README 和部署文档完整
|
||||
- [ ] Swagger API 文档完整
|
||||
- [ ] Demo 数据展示正常
|
||||
51
document/work-log/2026-05-06.md
Normal 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 连接
|
||||
67
document/work-log/2026-05-07.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Work Log - 2026-05-07
|
||||
|
||||
## 今日工作
|
||||
|
||||
### 早上 09:00 - 10:00
|
||||
- **修复了 Windows 启动脚本报错**
|
||||
- 添加虚拟环境检测函数 venv_valid()
|
||||
- 无效时自动重建虚拟环境
|
||||
|
||||
### 早上 10:00 - 11:00
|
||||
- **开始员工管理后端开发**
|
||||
- 设计员工模型(工号、部门、职位、状态)
|
||||
- 添加工号字段(唯一)
|
||||
|
||||
### 中午 11:00 - 12:00
|
||||
- **完成了员工 CRUD 服务**
|
||||
- create_employee() 创建员工
|
||||
- update_employee() 更新员工
|
||||
- get_employees() 分页查询
|
||||
|
||||
### 中午 12:00 - 13:00
|
||||
- **添加了员工变更日志**
|
||||
- 记录员工信息修改历史
|
||||
- 字段:employee_id, field_name, old_value, new_value
|
||||
|
||||
### 下午 13:00 - 14:00
|
||||
- **添加了组织和角色模型**
|
||||
- Organization 组织架构
|
||||
- Role 角色权限
|
||||
|
||||
### 下午 14:00 - 15:00
|
||||
- **完成了员工 API 端点**
|
||||
- GET /api/v1/employees 列表
|
||||
- POST /api/v1/employees 创建
|
||||
- GET /api/v1/employees/{id} 获取单个
|
||||
|
||||
### 下午 15:00 - 16:00
|
||||
- **开始前端员工页面开发**
|
||||
- 表格展示员工列表
|
||||
- 搜索功能
|
||||
|
||||
### 下午 16:00 - 17:00
|
||||
- **完成了前端员工页面**
|
||||
- 搜索和分页
|
||||
- 新增/编辑弹窗
|
||||
|
||||
### 下午 17:00 - 18:00
|
||||
- **添加了后端健康检查**
|
||||
- BackendUnavailableRouteView 页面
|
||||
- 后端不可用时提示并重试
|
||||
|
||||
### 下午 18:00 - 19:00
|
||||
- **重构了前端路由**
|
||||
- 使用 vue-router 路由化导航
|
||||
- 添加 /employees 路由
|
||||
|
||||
### 下午 19:00 - 20:00
|
||||
- **整理了 UI 资源**
|
||||
- 图片移至 web/UI/ 目录
|
||||
- 删除旧文档
|
||||
|
||||
---
|
||||
|
||||
# 待处理
|
||||
|
||||
- [ ] 安装 PostgreSQL
|
||||
- [ ] 创建 x_financial 数据库
|
||||
@@ -2,25 +2,37 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.employee import EmployeeCreate, EmployeeRead
|
||||
from app.schemas.employee import EmployeeCreate, EmployeeMetaRead, EmployeeRead
|
||||
from app.services.employee import EmployeeService
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get("/meta", response_model=EmployeeMetaRead)
|
||||
def get_employee_meta(db: DbSession) -> EmployeeMetaRead:
|
||||
return EmployeeService(db).get_employee_meta()
|
||||
|
||||
|
||||
@router.get("", response_model=list[EmployeeRead])
|
||||
def list_employees(db: DbSession) -> list[EmployeeRead]:
|
||||
return EmployeeService(db).list_employees()
|
||||
def list_employees(
|
||||
db: DbSession,
|
||||
status_filter: Annotated[str | None, Query(alias="status")] = None,
|
||||
keyword: str | None = None,
|
||||
) -> list[EmployeeRead]:
|
||||
return EmployeeService(db).list_employees(status=status_filter, keyword=keyword)
|
||||
|
||||
|
||||
@router.post("", response_model=EmployeeRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_employee(payload: EmployeeCreate, db: DbSession) -> EmployeeRead:
|
||||
return EmployeeService(db).create_employee(payload)
|
||||
try:
|
||||
return EmployeeService(db).create_employee(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{employee_id}", response_model=EmployeeRead)
|
||||
|
||||
@@ -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:
|
||||
|
||||
72
server/src/app/core/logging.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
0
server/src/app/middleware/__init__.py
Normal file
42
server/src/app/middleware/logging.py
Normal 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
|
||||
@@ -1,5 +1,15 @@
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee import Employee
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.reimbursement import ReimbursementRequest
|
||||
from app.models.role import Role
|
||||
|
||||
__all__ = ["ApprovalRecord", "Employee", "ReimbursementRequest"]
|
||||
__all__ = [
|
||||
"ApprovalRecord",
|
||||
"Employee",
|
||||
"EmployeeChangeLog",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
"Role",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, String, Table, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
employee_role_links = Table(
|
||||
"employee_role_links",
|
||||
Base.metadata,
|
||||
Column("employee_id", String(36), ForeignKey("employees.id"), primary_key=True),
|
||||
Column("role_id", String(36), ForeignKey("roles.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
__tablename__ = "employees"
|
||||
@@ -15,11 +22,37 @@ class Employee(Base):
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
employee_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
department: Mapped[str] = mapped_column(String(100), index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
birth_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
join_date: Mapped[date | None] = mapped_column(Date(), nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
position: Mapped[str] = mapped_column(String(100), default="员工")
|
||||
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
|
||||
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
|
||||
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
|
||||
spotlight: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
organization_unit_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("organization_units.id"), nullable=True, index=True
|
||||
)
|
||||
manager_id: Mapped[str | None] = mapped_column(ForeignKey("employees.id"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
organization_unit = relationship("OrganizationUnit", back_populates="employees")
|
||||
manager = relationship("Employee", remote_side=[id], back_populates="reports")
|
||||
reports = relationship("Employee", back_populates="manager")
|
||||
roles = relationship("Role", secondary=employee_role_links, back_populates="employees")
|
||||
change_logs = relationship(
|
||||
"EmployeeChangeLog",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="desc(EmployeeChangeLog.occurred_at)",
|
||||
)
|
||||
reimbursement_requests = relationship("ReimbursementRequest", back_populates="employee")
|
||||
|
||||
21
server/src/app/models/employee_change_log.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class EmployeeChangeLog(Base):
|
||||
__tablename__ = "employee_change_logs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
employee_id: Mapped[str] = mapped_column(ForeignKey("employees.id"), index=True)
|
||||
action: Mapped[str] = mapped_column(String(255))
|
||||
owner: Mapped[str] = mapped_column(String(100))
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
employee = relationship("Employee", back_populates="change_logs")
|
||||
32
server/src/app/models/organization.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class OrganizationUnit(Base):
|
||||
__tablename__ = "organization_units"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
unit_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
unit_type: Mapped[str] = mapped_column(String(30), default="department", index=True)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("organization_units.id"), nullable=True, index=True
|
||||
)
|
||||
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
parent = relationship("OrganizationUnit", remote_side=[id], back_populates="children")
|
||||
children = relationship("OrganizationUnit", back_populates="parent")
|
||||
employees = relationship("Employee", back_populates="organization_unit")
|
||||
24
server/src/app/models/role.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
role_code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||
description: Mapped[str] = mapped_column(String(500), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
employees = relationship("Employee", secondary="employee_role_links", back_populates="roles")
|
||||
@@ -1,17 +1,93 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
|
||||
|
||||
class EmployeeRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def list(self) -> list[Employee]:
|
||||
return self.db.query(Employee).order_by(Employee.created_at.desc()).all()
|
||||
def list(self, status: str | None = None, keyword: str | None = None) -> list[Employee]:
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
selectinload(Employee.change_logs),
|
||||
)
|
||||
.order_by(Employee.updated_at.desc(), Employee.name.asc())
|
||||
)
|
||||
|
||||
if status and status != "全部员工":
|
||||
stmt = stmt.where(Employee.employment_status == status)
|
||||
|
||||
if keyword:
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Employee.name.ilike(pattern),
|
||||
Employee.employee_no.ilike(pattern),
|
||||
Employee.email.ilike(pattern),
|
||||
Employee.position.ilike(pattern),
|
||||
)
|
||||
)
|
||||
|
||||
return list(self.db.execute(stmt).scalars().unique().all())
|
||||
|
||||
def get(self, employee_id: str) -> Employee | None:
|
||||
return self.db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
selectinload(Employee.change_logs),
|
||||
)
|
||||
.where(Employee.id == employee_id)
|
||||
)
|
||||
return self.db.execute(stmt).scalars().unique().first()
|
||||
|
||||
def get_by_employee_no(self, employee_no: str) -> Employee | None:
|
||||
stmt = select(Employee).where(Employee.employee_no == employee_no)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def get_by_email(self, email: str) -> Employee | None:
|
||||
stmt = select(Employee).where(Employee.email == email)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def list_roles(self) -> list[Role]:
|
||||
stmt = select(Role)
|
||||
return list(self.db.execute(stmt).scalars().all())
|
||||
|
||||
def get_role_by_code(self, role_code: str) -> Role | None:
|
||||
stmt = select(Role).where(Role.role_code == role_code)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def list_organization_units(self) -> list[OrganizationUnit]:
|
||||
stmt = select(OrganizationUnit)
|
||||
return list(self.db.execute(stmt).scalars().all())
|
||||
|
||||
def get_organization_by_code(self, unit_code: str) -> OrganizationUnit | None:
|
||||
stmt = select(OrganizationUnit).where(OrganizationUnit.unit_code == unit_code)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def count_employees(self) -> int:
|
||||
stmt = select(func.count()).select_from(Employee)
|
||||
return int(self.db.execute(stmt).scalar_one())
|
||||
|
||||
def count_roles(self) -> int:
|
||||
stmt = select(func.count()).select_from(Role)
|
||||
return int(self.db.execute(stmt).scalar_one())
|
||||
|
||||
def count_organization_units(self) -> int:
|
||||
stmt = select(func.count()).select_from(OrganizationUnit)
|
||||
return int(self.db.execute(stmt).scalar_one())
|
||||
|
||||
def create(self, employee: Employee) -> Employee:
|
||||
self.db.add(employee)
|
||||
|
||||
@@ -1,24 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class EmployeeCreate(BaseModel):
|
||||
employee_no: str
|
||||
class EmployeeHistoryRead(BaseModel):
|
||||
action: str
|
||||
owner: str
|
||||
time: str
|
||||
occurredAt: str
|
||||
|
||||
|
||||
class EmployeeOrganizationRead(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
department: str
|
||||
email: EmailStr
|
||||
unitType: str
|
||||
costCenter: str | None = None
|
||||
location: str | None = None
|
||||
managerName: str | None = None
|
||||
|
||||
|
||||
class EmployeeRoleOptionRead(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
label: str
|
||||
desc: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeStatusSummaryRead(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class EmployeeMetaRead(BaseModel):
|
||||
totalEmployees: int
|
||||
statusSummary: list[EmployeeStatusSummaryRead]
|
||||
roleOptions: list[EmployeeRoleOptionRead]
|
||||
|
||||
|
||||
class EmployeeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
model_config = ConfigDict(from_attributes=False)
|
||||
|
||||
id: str
|
||||
employee_no: str
|
||||
avatar: str
|
||||
name: str
|
||||
employeeNo: str
|
||||
department: str
|
||||
position: str
|
||||
grade: str
|
||||
manager: str
|
||||
financeOwner: str
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
roleCodes: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
statusTone: str
|
||||
gender: str | None = None
|
||||
age: int | None = None
|
||||
birthDate: str | None = None
|
||||
email: EmailStr
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
phone: str | None = None
|
||||
joinDate: str | None = None
|
||||
location: str | None = None
|
||||
costCenter: str | None = None
|
||||
updatedAt: str | None = None
|
||||
lastSync: str | None = None
|
||||
syncState: str
|
||||
spotlight: bool = False
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
history: list[EmployeeHistoryRead] = Field(default_factory=list)
|
||||
organization: EmployeeOrganizationRead | None = None
|
||||
|
||||
|
||||
class EmployeeCreate(BaseModel):
|
||||
employee_no: str = Field(min_length=1, max_length=50)
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
email: EmailStr
|
||||
gender: str | None = Field(default=None, max_length=20)
|
||||
birth_date: str | None = None
|
||||
phone: str | None = Field(default=None, max_length=30)
|
||||
join_date: str | None = None
|
||||
location: str | None = Field(default=None, max_length=100)
|
||||
position: str = Field(default="员工", max_length=100)
|
||||
grade: str = Field(default="P3", max_length=20)
|
||||
cost_center: str | None = Field(default=None, max_length=50)
|
||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||
employment_status: str = Field(default="在职", max_length=30)
|
||||
sync_state: str = Field(default="已同步", max_length=30)
|
||||
spotlight: bool = False
|
||||
organization_unit_code: str | None = Field(default=None, max_length=50)
|
||||
manager_employee_no: str | None = Field(default=None, max_length=50)
|
||||
role_codes: list[str] = Field(default_factory=lambda: ["user"])
|
||||
|
||||
def parsed_birth_date(self) -> date | None:
|
||||
return datetime.strptime(self.birth_date, "%Y-%m-%d").date() if self.birth_date else None
|
||||
|
||||
def parsed_join_date(self) -> date | None:
|
||||
return datetime.strptime(self.join_date, "%Y-%m-%d").date() if self.join_date else None
|
||||
|
||||
@@ -1,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
|
||||
|
||||
986
server/src/app/services/employee_seed.py
Normal file
@@ -0,0 +1,986 @@
|
||||
from __future__ import annotations
|
||||
|
||||
ROLE_DISPLAY_ORDER = {
|
||||
"manager": 1,
|
||||
"finance": 2,
|
||||
"approver": 3,
|
||||
"executive": 4,
|
||||
"auditor": 5,
|
||||
"user": 6,
|
||||
}
|
||||
|
||||
ROLE_DEFINITIONS = [
|
||||
{
|
||||
"role_code": "user",
|
||||
"name": "使用者",
|
||||
"description": "可以发起报销、查看个人单据和使用 AI 助手。",
|
||||
},
|
||||
{
|
||||
"role_code": "finance",
|
||||
"name": "财务人员",
|
||||
"description": "可以处理复核、查看财务知识与风险校验结果。",
|
||||
},
|
||||
{
|
||||
"role_code": "manager",
|
||||
"name": "管理员",
|
||||
"description": "可以维护员工档案、组织结构和角色权限。",
|
||||
},
|
||||
{
|
||||
"role_code": "executive",
|
||||
"name": "高级管理人员",
|
||||
"description": "可以查看跨部门数据看板与关键审批结果。",
|
||||
},
|
||||
{
|
||||
"role_code": "approver",
|
||||
"name": "审批负责人",
|
||||
"description": "可以处理审批中心中的待审单据。",
|
||||
},
|
||||
{
|
||||
"role_code": "auditor",
|
||||
"name": "审计观察员",
|
||||
"description": "可以查看变更记录和权限调整历史。",
|
||||
},
|
||||
]
|
||||
|
||||
ROLE_PERMISSION_MAP = {
|
||||
"user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"],
|
||||
"finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"],
|
||||
"manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"],
|
||||
"executive": ["可查看跨部门经营看板", "可处理高金额报销最终审批"],
|
||||
"approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"],
|
||||
"auditor": ["可查看权限变更与审计留痕", "可导出员工权限观察记录"],
|
||||
}
|
||||
|
||||
ORGANIZATION_DEFINITIONS = [
|
||||
{
|
||||
"unit_code": "ORG-ROOT",
|
||||
"name": "星海科技",
|
||||
"unit_type": "company",
|
||||
"parent_code": None,
|
||||
"cost_center": "CC-0000",
|
||||
"location": "上海",
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
{
|
||||
"unit_code": "EXEC-OFFICE",
|
||||
"name": "总经办",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-1001",
|
||||
"location": "上海",
|
||||
"manager_name": "李文静",
|
||||
},
|
||||
{
|
||||
"unit_code": "FIN-SSC",
|
||||
"name": "财务共享中心",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-2108",
|
||||
"location": "上海",
|
||||
"manager_name": "张晓晴",
|
||||
},
|
||||
{
|
||||
"unit_code": "HR-OD",
|
||||
"name": "人力与组织",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-3206",
|
||||
"location": "杭州",
|
||||
"manager_name": "陈硕",
|
||||
},
|
||||
{
|
||||
"unit_code": "SALES-SOUTH",
|
||||
"name": "华南销售部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-4102",
|
||||
"location": "深圳",
|
||||
"manager_name": "陈嘉",
|
||||
},
|
||||
{
|
||||
"unit_code": "SALES-EAST",
|
||||
"name": "华东销售部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-4108",
|
||||
"location": "上海",
|
||||
"manager_name": "秦墨然",
|
||||
},
|
||||
{
|
||||
"unit_code": "MKT-BRAND",
|
||||
"name": "市场品牌部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-5203",
|
||||
"location": "北京",
|
||||
"manager_name": "刘思雨",
|
||||
},
|
||||
{
|
||||
"unit_code": "RND-CENTER",
|
||||
"name": "产品研发中心",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-6105",
|
||||
"location": "北京",
|
||||
"manager_name": "吴磊",
|
||||
},
|
||||
{
|
||||
"unit_code": "OPS-ADMIN",
|
||||
"name": "行政采购部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-7204",
|
||||
"location": "南京",
|
||||
"manager_name": "梁雨辰",
|
||||
},
|
||||
{
|
||||
"unit_code": "AUDIT-RISK",
|
||||
"name": "风控与审计部",
|
||||
"unit_type": "department",
|
||||
"parent_code": "ORG-ROOT",
|
||||
"cost_center": "CC-8102",
|
||||
"location": "上海",
|
||||
"manager_name": "顾承宇",
|
||||
},
|
||||
]
|
||||
|
||||
EMPLOYEE_DEFINITIONS = [
|
||||
{
|
||||
"employee_no": "E10018",
|
||||
"name": "李文静",
|
||||
"gender": "女",
|
||||
"birth_date": "1987-03-26",
|
||||
"phone": "13900187688",
|
||||
"email": "wenjing.li@xfinance.com",
|
||||
"join_date": "2018-06-21",
|
||||
"location": "上海",
|
||||
"position": "高级财务总监",
|
||||
"grade": "D2",
|
||||
"organization_unit_code": "EXEC-OFFICE",
|
||||
"manager_employee_no": None,
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-1001",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 16:20",
|
||||
"last_sync_at": "2026-05-05 16:20",
|
||||
"role_codes": ["executive", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E10234",
|
||||
"name": "张晓晴",
|
||||
"gender": "女",
|
||||
"birth_date": "1994-08-12",
|
||||
"phone": "13810234567",
|
||||
"email": "xiaoqing.zhang@xfinance.com",
|
||||
"join_date": "2021-03-15",
|
||||
"location": "上海",
|
||||
"position": "费用运营经理",
|
||||
"grade": "M3",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2108",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "待生效",
|
||||
"spotlight": True,
|
||||
"updated_at": "2026-05-06 10:24",
|
||||
"last_sync_at": "2026-05-06 10:24",
|
||||
"role_codes": ["manager", "finance", "approver"],
|
||||
"history": [
|
||||
{
|
||||
"action": "新增“审批负责人”角色",
|
||||
"owner": "系统管理员 · 王敏",
|
||||
"occurred_at": "2026-05-06 10:24",
|
||||
},
|
||||
{
|
||||
"action": "调整财务归口为华东财务组",
|
||||
"owner": "组织管理员 · 陈硕",
|
||||
"occurred_at": "2026-05-05 18:10",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"employee_no": "E10258",
|
||||
"name": "孙楠",
|
||||
"gender": "男",
|
||||
"birth_date": "1992-09-17",
|
||||
"phone": "13722580312",
|
||||
"email": "nan.sun@xfinance.com",
|
||||
"join_date": "2020-11-09",
|
||||
"location": "上海",
|
||||
"position": "财务分析师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2111",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-04 15:18",
|
||||
"last_sync_at": "2026-05-04 15:18",
|
||||
"role_codes": ["finance"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E10271",
|
||||
"name": "周悦宁",
|
||||
"gender": "女",
|
||||
"birth_date": "1993-04-21",
|
||||
"phone": "13622711986",
|
||||
"email": "yuening.zhou@xfinance.com",
|
||||
"join_date": "2021-07-05",
|
||||
"location": "上海",
|
||||
"position": "财务系统专员",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2112",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 09:35",
|
||||
"last_sync_at": "2026-05-07 09:10",
|
||||
"role_codes": ["finance", "auditor"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E10289",
|
||||
"name": "高嘉禾",
|
||||
"gender": "女",
|
||||
"birth_date": "1996-02-14",
|
||||
"phone": "13522895642",
|
||||
"email": "jiahe.gao@xfinance.com",
|
||||
"join_date": "2023-01-10",
|
||||
"location": "上海",
|
||||
"position": "差旅合规专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2115",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-03 11:42",
|
||||
"last_sync_at": "2026-05-03 11:42",
|
||||
"role_codes": ["finance"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E10867",
|
||||
"name": "王敏",
|
||||
"gender": "女",
|
||||
"birth_date": "1996-11-05",
|
||||
"phone": "13688671200",
|
||||
"email": "min.wang@xfinance.com",
|
||||
"join_date": "2022-08-08",
|
||||
"location": "杭州",
|
||||
"position": "组织发展主管",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"manager_employee_no": "E11618",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3206",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 09:18",
|
||||
"last_sync_at": "2026-05-05 09:18",
|
||||
"role_codes": ["manager", "auditor"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11618",
|
||||
"name": "陈硕",
|
||||
"gender": "男",
|
||||
"birth_date": "1990-05-09",
|
||||
"phone": "13816186540",
|
||||
"email": "shuo.chen@xfinance.com",
|
||||
"join_date": "2019-09-16",
|
||||
"location": "杭州",
|
||||
"position": "人力资源经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3201",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-04 17:08",
|
||||
"last_sync_at": "2026-05-04 17:08",
|
||||
"role_codes": ["manager", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12311",
|
||||
"name": "何思成",
|
||||
"gender": "男",
|
||||
"birth_date": "1998-07-19",
|
||||
"phone": "13723117654",
|
||||
"email": "sicheng.he@xfinance.com",
|
||||
"join_date": "2026-02-17",
|
||||
"location": "杭州",
|
||||
"position": "HRBP",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "HR-OD",
|
||||
"manager_employee_no": "E11618",
|
||||
"finance_owner_name": "总部财务BP",
|
||||
"cost_center": "CC-3208",
|
||||
"employment_status": "试用中",
|
||||
"sync_state": "待生效",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 08:42",
|
||||
"last_sync_at": "2026-05-07 08:42",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11026",
|
||||
"name": "刘思雨",
|
||||
"gender": "女",
|
||||
"birth_date": "1991-12-03",
|
||||
"phone": "13921036540",
|
||||
"email": "siyu.liu@xfinance.com",
|
||||
"join_date": "2020-04-13",
|
||||
"location": "北京",
|
||||
"position": "品牌市场经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5203",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 14:36",
|
||||
"last_sync_at": "2026-05-06 14:36",
|
||||
"role_codes": ["user", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12408",
|
||||
"name": "冯可欣",
|
||||
"gender": "女",
|
||||
"birth_date": "1997-10-28",
|
||||
"phone": "13624085542",
|
||||
"email": "kexin.feng@xfinance.com",
|
||||
"join_date": "2024-03-11",
|
||||
"location": "北京",
|
||||
"position": "品牌策划",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5207",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 10:02",
|
||||
"last_sync_at": "2026-05-07 09:48",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12419",
|
||||
"name": "许泽航",
|
||||
"gender": "男",
|
||||
"birth_date": "1995-05-15",
|
||||
"phone": "13524199508",
|
||||
"email": "zehang.xu@xfinance.com",
|
||||
"join_date": "2023-06-19",
|
||||
"location": "北京",
|
||||
"position": "数字营销专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5209",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-03 16:52",
|
||||
"last_sync_at": "2026-05-03 16:52",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11602",
|
||||
"name": "陈嘉",
|
||||
"gender": "男",
|
||||
"birth_date": "1997-02-18",
|
||||
"phone": "13716029901",
|
||||
"email": "jia.chen@xfinance.com",
|
||||
"join_date": "2026-03-01",
|
||||
"location": "深圳",
|
||||
"position": "区域销售经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4102",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-04 14:12",
|
||||
"last_sync_at": "2026-05-04 14:12",
|
||||
"role_codes": ["user", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12476",
|
||||
"name": "马骁然",
|
||||
"gender": "男",
|
||||
"birth_date": "1994-01-08",
|
||||
"phone": "13824760139",
|
||||
"email": "xiaoran.ma@xfinance.com",
|
||||
"join_date": "2022-09-05",
|
||||
"location": "深圳",
|
||||
"position": "销售运营专家",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4106",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 18:15",
|
||||
"last_sync_at": "2026-05-06 18:15",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12508",
|
||||
"name": "唐子墨",
|
||||
"gender": "男",
|
||||
"birth_date": "1996-06-11",
|
||||
"phone": "13925088761",
|
||||
"email": "zimo.tang@xfinance.com",
|
||||
"join_date": "2024-02-26",
|
||||
"location": "深圳",
|
||||
"position": "大客户代表",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4109",
|
||||
"employment_status": "停用",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-01 11:06",
|
||||
"last_sync_at": "2026-05-01 11:06",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12514",
|
||||
"name": "罗欣怡",
|
||||
"gender": "女",
|
||||
"birth_date": "2000-03-02",
|
||||
"phone": "13625141227",
|
||||
"email": "xinyi.luo@xfinance.com",
|
||||
"join_date": "2026-02-24",
|
||||
"location": "深圳",
|
||||
"position": "销售协调专员",
|
||||
"grade": "P3",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4112",
|
||||
"employment_status": "试用中",
|
||||
"sync_state": "待生效",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 15:42",
|
||||
"last_sync_at": "2026-05-05 15:42",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11745",
|
||||
"name": "吴磊",
|
||||
"gender": "男",
|
||||
"birth_date": "1989-09-27",
|
||||
"phone": "13817459812",
|
||||
"email": "lei.wu@xfinance.com",
|
||||
"join_date": "2019-12-09",
|
||||
"location": "北京",
|
||||
"position": "研发平台主管",
|
||||
"grade": "M3",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6105",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 13:08",
|
||||
"last_sync_at": "2026-05-06 13:08",
|
||||
"role_codes": ["user", "approver", "auditor"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11991",
|
||||
"name": "赵明",
|
||||
"gender": "男",
|
||||
"birth_date": "1994-06-09",
|
||||
"phone": "13519913300",
|
||||
"email": "ming.zhao@xfinance.com",
|
||||
"join_date": "2023-11-18",
|
||||
"location": "北京",
|
||||
"position": "产品经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6112",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-02 11:32",
|
||||
"last_sync_at": "2026-05-02 11:32",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12611",
|
||||
"name": "彭一凡",
|
||||
"gender": "男",
|
||||
"birth_date": "1995-02-03",
|
||||
"phone": "13726114588",
|
||||
"email": "yifan.peng@xfinance.com",
|
||||
"join_date": "2022-04-18",
|
||||
"location": "北京",
|
||||
"position": "后端工程师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6114",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 09:44",
|
||||
"last_sync_at": "2026-05-06 09:44",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12618",
|
||||
"name": "苏清禾",
|
||||
"gender": "女",
|
||||
"birth_date": "1994-12-25",
|
||||
"phone": "13626188763",
|
||||
"email": "qinghe.su@xfinance.com",
|
||||
"join_date": "2022-05-16",
|
||||
"location": "北京",
|
||||
"position": "数据工程师",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6116",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 10:26",
|
||||
"last_sync_at": "2026-05-07 10:18",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12624",
|
||||
"name": "沈知远",
|
||||
"gender": "男",
|
||||
"birth_date": "1992-11-06",
|
||||
"phone": "13926241855",
|
||||
"email": "zhiyuan.shen@xfinance.com",
|
||||
"join_date": "2021-11-22",
|
||||
"location": "北京",
|
||||
"position": "测试负责人",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6119",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 13:12",
|
||||
"last_sync_at": "2026-05-05 13:12",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E11852",
|
||||
"name": "周晓彤",
|
||||
"gender": "女",
|
||||
"birth_date": "1997-05-27",
|
||||
"phone": "13818529954",
|
||||
"email": "xiaotong.zhou@xfinance.com",
|
||||
"join_date": "2022-06-30",
|
||||
"location": "南京",
|
||||
"position": "行政采购专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7204",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 11:22",
|
||||
"last_sync_at": "2026-05-05 11:22",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12653",
|
||||
"name": "梁雨辰",
|
||||
"gender": "男",
|
||||
"birth_date": "1991-08-30",
|
||||
"phone": "13726539876",
|
||||
"email": "yuchen.liang@xfinance.com",
|
||||
"join_date": "2021-01-04",
|
||||
"location": "南京",
|
||||
"position": "行政运营经理",
|
||||
"grade": "M1",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7201",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 17:44",
|
||||
"last_sync_at": "2026-05-06 17:44",
|
||||
"role_codes": ["user", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12661",
|
||||
"name": "顾承宇",
|
||||
"gender": "男",
|
||||
"birth_date": "1988-04-16",
|
||||
"phone": "13926614528",
|
||||
"email": "chengyu.gu@xfinance.com",
|
||||
"join_date": "2020-02-03",
|
||||
"location": "上海",
|
||||
"position": "风控审计经理",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8102",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "待生效",
|
||||
"spotlight": True,
|
||||
"updated_at": "2026-05-07 09:52",
|
||||
"last_sync_at": "2026-05-07 09:52",
|
||||
"role_codes": ["auditor", "finance"],
|
||||
"history": [
|
||||
{
|
||||
"action": "更新审计观察范围",
|
||||
"owner": "系统管理员 · 张晓晴",
|
||||
"occurred_at": "2026-05-07 09:52",
|
||||
},
|
||||
{
|
||||
"action": "补充高风险费用抽样规则",
|
||||
"owner": "审计管理员 · 王敏",
|
||||
"occurred_at": "2026-05-06 18:30",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12679",
|
||||
"name": "郑若彤",
|
||||
"gender": "女",
|
||||
"birth_date": "1997-09-13",
|
||||
"phone": "13626794520",
|
||||
"email": "ruotong.zheng@xfinance.com",
|
||||
"join_date": "2024-01-08",
|
||||
"location": "上海",
|
||||
"position": "审计专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"manager_employee_no": "E12661",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8105",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 08:58",
|
||||
"last_sync_at": "2026-05-07 08:40",
|
||||
"role_codes": ["auditor"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12688",
|
||||
"name": "方逸晨",
|
||||
"gender": "男",
|
||||
"birth_date": "1995-01-20",
|
||||
"phone": "13526881142",
|
||||
"email": "yichen.fang@xfinance.com",
|
||||
"join_date": "2023-08-14",
|
||||
"location": "南京",
|
||||
"position": "采购合规分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7208",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-03 14:16",
|
||||
"last_sync_at": "2026-05-03 14:16",
|
||||
"role_codes": ["user", "finance"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12067",
|
||||
"name": "秦墨然",
|
||||
"gender": "男",
|
||||
"birth_date": "1990-10-10",
|
||||
"phone": "13820674519",
|
||||
"email": "moran.qin@xfinance.com",
|
||||
"join_date": "2020-07-20",
|
||||
"location": "上海",
|
||||
"position": "华东销售总监",
|
||||
"grade": "M2",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"manager_employee_no": "E10018",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4108",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 12:40",
|
||||
"last_sync_at": "2026-05-06 12:40",
|
||||
"role_codes": ["user", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12703",
|
||||
"name": "宋知夏",
|
||||
"gender": "女",
|
||||
"birth_date": "1994-07-07",
|
||||
"phone": "13727031129",
|
||||
"email": "zhixia.song@xfinance.com",
|
||||
"join_date": "2022-12-12",
|
||||
"location": "上海",
|
||||
"position": "重点客户经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"manager_employee_no": "E12067",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4111",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-04 10:58",
|
||||
"last_sync_at": "2026-05-04 10:58",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12716",
|
||||
"name": "杜嘉宁",
|
||||
"gender": "男",
|
||||
"birth_date": "1999-11-16",
|
||||
"phone": "13627161248",
|
||||
"email": "jianing.du@xfinance.com",
|
||||
"join_date": "2026-01-19",
|
||||
"location": "上海",
|
||||
"position": "销售代表",
|
||||
"grade": "P3",
|
||||
"organization_unit_code": "SALES-EAST",
|
||||
"manager_employee_no": "E12067",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-4114",
|
||||
"employment_status": "试用中",
|
||||
"sync_state": "待生效",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-05 12:26",
|
||||
"last_sync_at": "2026-05-05 12:26",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12722",
|
||||
"name": "邵宁远",
|
||||
"gender": "男",
|
||||
"birth_date": "1998-12-01",
|
||||
"phone": "13527221506",
|
||||
"email": "ningyuan.shao@xfinance.com",
|
||||
"join_date": "2026-02-08",
|
||||
"location": "北京",
|
||||
"position": "数据分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6122",
|
||||
"employment_status": "试用中",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 09:06",
|
||||
"last_sync_at": "2026-05-07 08:55",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12739",
|
||||
"name": "林可昕",
|
||||
"gender": "女",
|
||||
"birth_date": "1996-10-23",
|
||||
"phone": "13827394510",
|
||||
"email": "kexin.lin@xfinance.com",
|
||||
"join_date": "2023-04-17",
|
||||
"location": "上海",
|
||||
"position": "费用核算专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "华东财务组",
|
||||
"cost_center": "CC-2118",
|
||||
"employment_status": "停用",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-04-30 18:05",
|
||||
"last_sync_at": "2026-04-30 18:05",
|
||||
"role_codes": ["finance"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12744",
|
||||
"name": "赵予安",
|
||||
"gender": "男",
|
||||
"birth_date": "1993-01-30",
|
||||
"phone": "13727442139",
|
||||
"email": "yuan.zhao@xfinance.com",
|
||||
"join_date": "2021-10-11",
|
||||
"location": "上海",
|
||||
"position": "预算控制经理",
|
||||
"grade": "M1",
|
||||
"organization_unit_code": "FIN-SSC",
|
||||
"manager_employee_no": "E10234",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-2120",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 15:34",
|
||||
"last_sync_at": "2026-05-06 15:34",
|
||||
"role_codes": ["finance", "approver"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12750",
|
||||
"name": "谢知行",
|
||||
"gender": "男",
|
||||
"birth_date": "1995-09-14",
|
||||
"phone": "13627501386",
|
||||
"email": "zhixing.xie@xfinance.com",
|
||||
"join_date": "2022-07-25",
|
||||
"location": "深圳",
|
||||
"position": "渠道销售经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "SALES-SOUTH",
|
||||
"manager_employee_no": "E11602",
|
||||
"finance_owner_name": "华南财务组",
|
||||
"cost_center": "CC-4116",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-04 09:48",
|
||||
"last_sync_at": "2026-05-04 09:48",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12758",
|
||||
"name": "顾南枝",
|
||||
"gender": "女",
|
||||
"birth_date": "1994-04-12",
|
||||
"phone": "13827584522",
|
||||
"email": "nanzhi.gu@xfinance.com",
|
||||
"join_date": "2022-05-09",
|
||||
"location": "北京",
|
||||
"position": "内容运营经理",
|
||||
"grade": "P5",
|
||||
"organization_unit_code": "MKT-BRAND",
|
||||
"manager_employee_no": "E11026",
|
||||
"finance_owner_name": "市场财务BP",
|
||||
"cost_center": "CC-5211",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "同步中",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-07 11:08",
|
||||
"last_sync_at": "2026-05-07 10:50",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12763",
|
||||
"name": "孟书言",
|
||||
"gender": "男",
|
||||
"birth_date": "1992-02-09",
|
||||
"phone": "13527633148",
|
||||
"email": "shuyan.meng@xfinance.com",
|
||||
"join_date": "2021-06-28",
|
||||
"location": "北京",
|
||||
"position": "架构工程师",
|
||||
"grade": "P6",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6125",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 19:05",
|
||||
"last_sync_at": "2026-05-06 19:05",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12771",
|
||||
"name": "孔令谦",
|
||||
"gender": "男",
|
||||
"birth_date": "1993-07-18",
|
||||
"phone": "13627711572",
|
||||
"email": "lingqian.kong@xfinance.com",
|
||||
"join_date": "2021-09-13",
|
||||
"location": "南京",
|
||||
"position": "供应商管理专员",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "OPS-ADMIN",
|
||||
"manager_employee_no": "E12653",
|
||||
"finance_owner_name": "行政财务BP",
|
||||
"cost_center": "CC-7210",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-02 17:22",
|
||||
"last_sync_at": "2026-05-02 17:22",
|
||||
"role_codes": ["user"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12782",
|
||||
"name": "乔语岚",
|
||||
"gender": "女",
|
||||
"birth_date": "1996-05-06",
|
||||
"phone": "13727823045",
|
||||
"email": "yulan.qiao@xfinance.com",
|
||||
"join_date": "2023-03-06",
|
||||
"location": "上海",
|
||||
"position": "风控策略分析师",
|
||||
"grade": "P4",
|
||||
"organization_unit_code": "AUDIT-RISK",
|
||||
"manager_employee_no": "E12661",
|
||||
"finance_owner_name": "集团财务",
|
||||
"cost_center": "CC-8108",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-03 13:18",
|
||||
"last_sync_at": "2026-05-03 13:18",
|
||||
"role_codes": ["auditor"],
|
||||
},
|
||||
{
|
||||
"employee_no": "E12790",
|
||||
"name": "邹闻韬",
|
||||
"gender": "男",
|
||||
"birth_date": "1991-03-11",
|
||||
"phone": "13827903167",
|
||||
"email": "wentao.zou@xfinance.com",
|
||||
"join_date": "2020-10-26",
|
||||
"location": "上海",
|
||||
"position": "合规产品负责人",
|
||||
"grade": "P7",
|
||||
"organization_unit_code": "RND-CENTER",
|
||||
"manager_employee_no": "E11745",
|
||||
"finance_owner_name": "研发财务BP",
|
||||
"cost_center": "CC-6128",
|
||||
"employment_status": "在职",
|
||||
"sync_state": "已同步",
|
||||
"spotlight": False,
|
||||
"updated_at": "2026-05-06 08:56",
|
||||
"last_sync_at": "2026-05-06 08:56",
|
||||
"role_codes": ["user", "auditor"],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -15,10 +15,13 @@ src/app/api/v1/endpoints/reimbursements.py
|
||||
src/app/core/__init__.py
|
||||
src/app/core/bootstrap.py
|
||||
src/app/core/config.py
|
||||
src/app/core/logging.py
|
||||
src/app/db/__init__.py
|
||||
src/app/db/base.py
|
||||
src/app/db/base_class.py
|
||||
src/app/db/session.py
|
||||
src/app/middleware/__init__.py
|
||||
src/app/middleware/logging.py
|
||||
src/app/models/__init__.py
|
||||
src/app/models/approval.py
|
||||
src/app/models/employee.py
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export MSYS_NO_PATHCONV=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -33,26 +35,31 @@ set +a
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:-127.0.0.1}"
|
||||
SERVER_PORT="${SERVER_PORT:-8000}"
|
||||
SERVER_RELOAD="${SERVER_RELOAD:-false}"
|
||||
|
||||
is_wsl() {
|
||||
grep -qi microsoft /proc/version 2>/dev/null
|
||||
}
|
||||
|
||||
is_windows_mount() {
|
||||
case "$SCRIPT_DIR" in
|
||||
/mnt/*) return 0 ;;
|
||||
is_msys() {
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
needs_windows_python() {
|
||||
is_msys || is_wsl
|
||||
}
|
||||
|
||||
find_unix_python() {
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
echo "python"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
echo "python"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -74,22 +81,6 @@ find_windows_python() {
|
||||
}
|
||||
|
||||
venv_python_path() {
|
||||
if [ "${VENV_LAYOUT:-auto}" = "windows" ]; then
|
||||
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
|
||||
echo "$VENV_DIR/Scripts/python.exe"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "${VENV_LAYOUT:-auto}" = "unix" ]; then
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
echo "$VENV_DIR/bin/python"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -x "$VENV_DIR/Scripts/python.exe" ]; then
|
||||
echo "$VENV_DIR/Scripts/python.exe"
|
||||
return 0
|
||||
@@ -152,30 +143,25 @@ ensure_pip() {
|
||||
}
|
||||
|
||||
ensure_python_bootstrap() {
|
||||
if is_wsl && is_windows_mount; then
|
||||
if needs_windows_python; then
|
||||
if find_windows_python >/dev/null 2>&1; then
|
||||
PYTHON_BOOTSTRAP="$(find_windows_python)"
|
||||
VENV_LAYOUT="windows"
|
||||
info "Detected WSL on a Windows-mounted project"
|
||||
info "Using Windows Python directly from bash"
|
||||
info "Detected Windows bash environment — using Windows Python"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if find_unix_python >/dev/null 2>&1; then
|
||||
PYTHON_BOOTSTRAP="$(find_unix_python)"
|
||||
VENV_LAYOUT="unix"
|
||||
warn "Windows Python not found, falling back to WSL Python"
|
||||
warn "Windows Python not found, falling back to system Python"
|
||||
return 0
|
||||
fi
|
||||
|
||||
error "Neither Windows Python nor WSL Python is available in PATH."
|
||||
error "Python is not available in PATH."
|
||||
fi
|
||||
|
||||
if ! PYTHON_BOOTSTRAP="$(find_unix_python)"; then
|
||||
error "Python is not installed or not available in PATH. Install Python 3.11+ first so the script can create server/.venv automatically."
|
||||
fi
|
||||
|
||||
VENV_LAYOUT="unix"
|
||||
}
|
||||
|
||||
ensure_dependencies() {
|
||||
@@ -210,7 +196,11 @@ start_server() {
|
||||
info "Access: http://$SERVER_HOST:$SERVER_PORT"
|
||||
echo ""
|
||||
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
if [ "$SERVER_RELOAD" = "true" ]; then
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --reload --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
fi
|
||||
|
||||
exec "$PYTHON_BIN" -m uvicorn app.main:app --app-dir src --host "$SERVER_HOST" --port "$SERVER_PORT"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
|
||||
62
server/tests/test_employee_service.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.employee import EmployeeService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_employee_directory_seeds_rich_employee_data() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
|
||||
employees = service.list_employees()
|
||||
meta = service.get_employee_meta()
|
||||
|
||||
assert len(employees) == 30
|
||||
assert meta.totalEmployees == 30
|
||||
assert any(item.status == "试用中" for item in employees)
|
||||
assert any(item.status == "停用" for item in employees)
|
||||
assert any("审批负责人" in item.roles for item in employees)
|
||||
assert any(item.permissions for item in employees)
|
||||
assert any(item.history for item in employees)
|
||||
|
||||
role_count = db.scalar(select(func.count()).select_from(Role))
|
||||
org_count = db.scalar(select(func.count()).select_from(OrganizationUnit))
|
||||
employee_count = db.scalar(select(func.count()).select_from(Employee))
|
||||
history_count = db.scalar(select(func.count()).select_from(EmployeeChangeLog))
|
||||
|
||||
assert role_count == 6
|
||||
assert org_count == 10
|
||||
assert employee_count == 30
|
||||
assert history_count and history_count >= 30
|
||||
|
||||
|
||||
def test_employee_detail_contains_department_and_roles() -> None:
|
||||
with build_session() as db:
|
||||
service = EmployeeService(db)
|
||||
employee = service.list_employees()[0]
|
||||
detail = service.get_employee(employee.id)
|
||||
|
||||
assert detail is not None
|
||||
assert detail.department
|
||||
assert detail.manager
|
||||
assert detail.organization is not None
|
||||
assert detail.roles
|
||||
133
start.sh
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export MSYS_NO_PATHCONV=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SCRIPT_DIR/.env"
|
||||
ENV_EXAMPLE_FILE="$SCRIPT_DIR/.env.example"
|
||||
@@ -31,6 +33,69 @@ set +a
|
||||
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
||||
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||
|
||||
server_probe_url() {
|
||||
echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/health"
|
||||
}
|
||||
|
||||
server_smoke_url() {
|
||||
echo "http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/employees/meta"
|
||||
}
|
||||
|
||||
server_probe_python() {
|
||||
if [ -x "$SCRIPT_DIR/server/.venv/Scripts/python.exe" ]; then
|
||||
echo "$SCRIPT_DIR/server/.venv/Scripts/python.exe"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -x "$SCRIPT_DIR/server/.venv/bin/python" ]; then
|
||||
echo "$SCRIPT_DIR/server/.venv/bin/python"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
probe_server_health() {
|
||||
local probe_url="${1:-$(server_probe_url)}"
|
||||
local probe_python=""
|
||||
|
||||
if probe_python="$(server_probe_python)"; then
|
||||
"$probe_python" -c "import json, sys, urllib.request; data = json.load(urllib.request.urlopen(sys.argv[1], timeout=2)); raise SystemExit(0 if data.get('status') == 'ok' else 1)" "$probe_url" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl --silent --fail --max-time 2 "$probe_url" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
probe_server_smoke() {
|
||||
local probe_url="${1:-$(server_smoke_url)}"
|
||||
local probe_python=""
|
||||
|
||||
if probe_python="$(server_probe_python)"; then
|
||||
"$probe_python" -c "import sys, urllib.request; response = urllib.request.urlopen(sys.argv[1], timeout=3); raise SystemExit(0 if response.status == 200 else 1)" "$probe_url" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl --silent --fail --max-time 3 "$probe_url" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
probe_server_ready() {
|
||||
local health_url="${1:-$(server_probe_url)}"
|
||||
local smoke_url="${2:-$(server_smoke_url)}"
|
||||
|
||||
probe_server_health "$health_url" && probe_server_smoke "$smoke_url"
|
||||
}
|
||||
|
||||
prepare_web() {
|
||||
info "Preparing web dependencies..."
|
||||
(
|
||||
@@ -69,12 +134,14 @@ start_setup_web() {
|
||||
|
||||
start_all() {
|
||||
local server_pid=""
|
||||
local started_server=false
|
||||
local probe_url=""
|
||||
local smoke_url=""
|
||||
|
||||
prepare_web
|
||||
prepare_server
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
|
||||
if [ "$started_server" = true ] && [ -n "$server_pid" ] && kill -0 "$server_pid" 2>/dev/null; then
|
||||
warn "Stopping FastAPI server..."
|
||||
kill "$server_pid" 2>/dev/null || true
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
@@ -83,49 +150,61 @@ start_all() {
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
info "Starting FastAPI server..."
|
||||
(
|
||||
cd "$SCRIPT_DIR/server"
|
||||
./start.sh start
|
||||
) &
|
||||
server_pid=$!
|
||||
probe_url="$(server_probe_url)"
|
||||
smoke_url="$(server_smoke_url)"
|
||||
|
||||
wait_for_server() {
|
||||
local base_url="http://${SERVER_HOST:-127.0.0.1}:${SERVER_PORT:-8000}${API_V1_PREFIX:-/api/v1}/bootstrap"
|
||||
if probe_server_ready "$probe_url" "$smoke_url"; then
|
||||
warn "FastAPI is already ready at $probe_url. Reusing the existing backend process."
|
||||
elif probe_server_health "$probe_url"; then
|
||||
error "An existing backend process is responding at $probe_url, but the smoke check failed at $smoke_url. Stop the old FastAPI process and rerun ./start.sh."
|
||||
else
|
||||
info "Starting FastAPI server..."
|
||||
(
|
||||
cd "$SCRIPT_DIR/server"
|
||||
./start.sh start
|
||||
) &
|
||||
server_pid=$!
|
||||
started_server=true
|
||||
fi
|
||||
|
||||
wait_for_server_ready() {
|
||||
local attempt=1
|
||||
local max_attempts="$SERVER_STARTUP_TIMEOUT"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
warn "curl not found, skipping backend readiness check."
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Waiting for FastAPI bootstrap endpoint..."
|
||||
info "Waiting for FastAPI readiness before starting the web frontend..."
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error."
|
||||
fi
|
||||
|
||||
if curl --silent --fail "$base_url" >/dev/null 2>&1; then
|
||||
info "FastAPI bootstrap endpoint is ready."
|
||||
if probe_server_ready "$probe_url" "$smoke_url"; then
|
||||
info "FastAPI is ready. Starting web frontend next."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ $((attempt % 15)) -eq 0 ]; then
|
||||
warn "FastAPI is still starting. First run may take longer while .venv and dependencies are prepared."
|
||||
if [ "$started_server" = true ] && ! kill -0 "$server_pid" 2>/dev/null; then
|
||||
if probe_server_ready "$probe_url" "$smoke_url"; then
|
||||
warn "FastAPI is already available at $probe_url. Continuing with the existing process."
|
||||
started_server=false
|
||||
server_pid=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
wait "$server_pid" 2>/dev/null || true
|
||||
error "FastAPI process exited before becoming ready. Run ./server/start.sh start directly to inspect the backend error."
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s: $base_url"
|
||||
if probe_server_health "$probe_url"; then
|
||||
error "FastAPI answered health checks at $probe_url, but the smoke check failed at $smoke_url. The running backend is stale or incompatible."
|
||||
fi
|
||||
|
||||
error "FastAPI did not become ready within ${SERVER_STARTUP_TIMEOUT}s. Inspect server/logs/app.log."
|
||||
}
|
||||
|
||||
wait_for_server
|
||||
wait_for_server_ready
|
||||
|
||||
prepare_web
|
||||
info "Starting web frontend..."
|
||||
cd "$SCRIPT_DIR/web"
|
||||
./start.sh start
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 971 KiB After Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 163 KiB |
187
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
196
web/src/App.vue
@@ -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>
|
||||
|
||||
BIN
web/src/assets/robot-helper.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
@@ -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);
|
||||
|
||||
71
web/src/assets/styles/views/backend-unavailable-view.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.backend-unavailable {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(16, 185, 129, 0.16), transparent 32%),
|
||||
linear-gradient(180deg, #08130f 0%, #0f1f18 100%);
|
||||
}
|
||||
|
||||
.backend-card {
|
||||
width: min(520px, 100%);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 32px 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(7, 18, 13, 0.9);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.35);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.backend-badge {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.28));
|
||||
color: #4ade80;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.backend-card h1 {
|
||||
color: #f8fafc;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.backend-card p {
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.backend-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 10px;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
box-shadow: 0 16px 30px rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: wait;
|
||||
}
|
||||
@@ -21,9 +21,11 @@
|
||||
}
|
||||
|
||||
.employee-list {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.employee-detail {
|
||||
@@ -34,22 +36,26 @@
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
gap: 28px;
|
||||
margin-top: 14px;
|
||||
border-bottom: 1px solid #dbe4ee;
|
||||
}
|
||||
|
||||
.status-tabs button {
|
||||
position: relative;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.status-tabs button.active {
|
||||
color: #0f172a;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-tabs button.active::after {
|
||||
@@ -57,30 +63,51 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -13px;
|
||||
bottom: -1px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
border-radius: 999px 999px 0 0;
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-tabs button small {
|
||||
min-width: 24px;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-tabs button.active small {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.filter-set {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
position: relative;
|
||||
width: 240px;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.list-search .mdi {
|
||||
@@ -103,23 +130,156 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
.list-search input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
.list-search input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.picker-trigger,
|
||||
.ghost-filter-btn,
|
||||
.create-btn,
|
||||
.row-action {
|
||||
min-height: 38px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.picker-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.picker-trigger {
|
||||
min-width: 132px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
justify-content: space-between;
|
||||
gap: 9px;
|
||||
padding: 0 34px 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-trigger .mdi {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-trigger:hover,
|
||||
.picker-filter.open .picker-trigger {
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
background: #f6fffb;
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picker-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 224px;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.picker-popover header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picker-popover header strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.picker-popover header button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.picker-popover header button:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.picker-option-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.picker-option {
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.picker-option:hover {
|
||||
border-color: rgba(16, 185, 129, 0.28);
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.picker-option.active {
|
||||
border-color: #10b981;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(5, 150, 105, 0.16);
|
||||
background: #f8fffb;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
@@ -137,22 +297,237 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 12px;
|
||||
margin-top: 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.active-filter-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.active-filter-chip {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.page-summary {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.pager button:hover:not(.active) {
|
||||
background: #fff;
|
||||
color: #059669;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.pager button.active {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 16px rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.page-size {
|
||||
min-height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-width: 112px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.page-size:hover {
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.page-size-wrap {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.page-size-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
border: 1px solid #d7e0ea;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-size-dropdown button {
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
padding: 0 20px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.page-size-dropdown button:hover {
|
||||
background: #f0fdf4;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.page-size-dropdown button.active {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-state {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 20px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-state i {
|
||||
font-size: 26px;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.table-state.error i {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.table-state.empty i {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table-state p {
|
||||
max-width: 420px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.state-action {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(5, 150, 105, 0.22);
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
table {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 1320px;
|
||||
min-width: 1180px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
colgroup col.col-employee {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
colgroup col.col-employee-no {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
colgroup col.col-department {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
colgroup col.col-position {
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
colgroup col.col-grade {
|
||||
width: 9%;
|
||||
}
|
||||
|
||||
colgroup col.col-role {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
colgroup col.col-status {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
colgroup col.col-updated {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -161,15 +536,25 @@ td {
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
color: #24324a;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f7fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
@@ -185,6 +570,10 @@ tbody tr.spotlight {
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.05), rgba(59, 130, 246, 0.03));
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
@@ -646,10 +1035,29 @@ tbody tr.spotlight {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-filter,
|
||||
.picker-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-popover {
|
||||
width: min(280px, calc(100vw - 64px));
|
||||
}
|
||||
|
||||
.page-size,
|
||||
.pager {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.form-grid,
|
||||
.role-grid {
|
||||
|
||||
@@ -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;
|
||||
|
||||
607
web/src/assets/styles/views/setup-view.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<section class="workbench">
|
||||
<PanelHead
|
||||
v-if="showHeader"
|
||||
@@ -9,9 +9,8 @@
|
||||
|
||||
<article class="panel assistant-hero">
|
||||
<div class="assistant-visual" aria-hidden="true">
|
||||
<div class="assistant-core">
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
<span class="assistant-glow"></span>
|
||||
<img class="assistant-image" :src="robotAssistant" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="assistant-copy">
|
||||
@@ -26,7 +25,6 @@
|
||||
placeholder="例如:我昨天请客户吃饭花了 860 元,还打车去了客户公司"
|
||||
@keydown.ctrl.enter.prevent="openAssistantWithDraft"
|
||||
/>
|
||||
<button type="button" class="hero-action" @click="openAssistantWithDraft">开始识别</button>
|
||||
</div>
|
||||
|
||||
<div class="assistant-tools">
|
||||
@@ -34,10 +32,10 @@
|
||||
<i class="mdi mdi-upload-outline"></i>
|
||||
<span>上传票据</span>
|
||||
</button>
|
||||
|
||||
<div class="assistant-skills">
|
||||
<span v-for="item in assistantSkills" :key="item">{{ item }}</span>
|
||||
</div>
|
||||
<button type="button" class="hero-action" @click="openAssistantWithDraft">
|
||||
<i class="mdi mdi-magnify-scan"></i>
|
||||
<span>开始识别</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -124,7 +122,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import robotAssistant from '../../assets/robot-assistant.png'
|
||||
import robotAssistant from '../../assets/robot-helper.png'
|
||||
|
||||
defineProps({
|
||||
showHeader: { type: Boolean, default: true }
|
||||
@@ -140,8 +138,6 @@ function openAssistantWithDraft() {
|
||||
})
|
||||
}
|
||||
|
||||
const assistantSkills = ['识别报销类别', '检查缺少材料', '生成报销草稿']
|
||||
|
||||
const todoItems = [
|
||||
{
|
||||
title: '业务招待报销建议补参与人员',
|
||||
@@ -240,9 +236,9 @@ const policyItems = [
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 164px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 24px 26px;
|
||||
grid-template-columns: 228px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 20px 24px 20px 18px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.12);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.12), transparent 34%),
|
||||
@@ -275,62 +271,65 @@ const policyItems = [
|
||||
|
||||
.assistant-visual {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 196px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 10px 8px;
|
||||
}
|
||||
|
||||
.assistant-core {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 36px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%);
|
||||
box-shadow:
|
||||
0 20px 44px rgba(15, 23, 42, 0.08),
|
||||
inset 0 -10px 18px rgba(16, 185, 129, 0.10);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.assistant-core::before,
|
||||
.assistant-core::after {
|
||||
.assistant-visual::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #d1fae5;
|
||||
inset: auto auto -78px -58px;
|
||||
width: 264px;
|
||||
height: 228px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.92) 0%, rgba(220, 252, 231, 0.84) 58%, rgba(220, 252, 231, 0) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-core::before {
|
||||
top: -12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.assistant-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
bottom: 18px;
|
||||
width: 132px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.10);
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-core::after {
|
||||
top: -4px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.assistant-core .mdi {
|
||||
font-size: 68px;
|
||||
.assistant-glow {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
bottom: 22px;
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.98) 0%, rgba(236, 253, 245, 0.9) 58%, rgba(236, 253, 245, 0) 100%);
|
||||
box-shadow: 0 24px 48px rgba(16, 185, 129, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 184px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 12px 20px rgba(15, 23, 42, 0.12));
|
||||
object-position: left bottom;
|
||||
filter: drop-shadow(0 22px 28px rgba(15, 23, 42, 0.16));
|
||||
}
|
||||
|
||||
.assistant-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@@ -340,15 +339,16 @@ const policyItems = [
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: #0f9f78;
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(59, 130, 246, 0.12));
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.assistant-copy h3 {
|
||||
color: #0f172a;
|
||||
font-size: 28px;
|
||||
font-size: 26px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -356,16 +356,15 @@ const policyItems = [
|
||||
.assistant-copy p {
|
||||
max-width: 760px;
|
||||
color: #5b6b83;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.assistant-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 52px;
|
||||
padding: 6px 8px 6px 14px;
|
||||
min-height: 48px;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -375,9 +374,9 @@ const policyItems = [
|
||||
.assistant-input textarea {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
padding: 1px 0;
|
||||
@@ -406,8 +405,12 @@ const policyItems = [
|
||||
}
|
||||
|
||||
.hero-action {
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
@@ -417,10 +420,26 @@ const policyItems = [
|
||||
box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
|
||||
.hero-action .mdi,
|
||||
.ghost-action .mdi {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-action span,
|
||||
.ghost-action span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.assistant-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -428,39 +447,22 @@ const policyItems = [
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.34);
|
||||
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #0f9f78;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 250, 247, 0.88));
|
||||
color: #0f766e;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 6px 14px rgba(15, 118, 110, 0.06);
|
||||
}
|
||||
|
||||
.assistant-skills {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
color: #22a06b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.assistant-skills span {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.assistant-skills span + span::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: rgba(16, 185, 129, 0.22);
|
||||
transform: translateY(-50%);
|
||||
.ghost-action .mdi {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
@@ -723,10 +725,28 @@ const policyItems = [
|
||||
@media (max-width: 1080px) {
|
||||
.assistant-hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-visual {
|
||||
justify-content: flex-start;
|
||||
min-height: 188px;
|
||||
justify-content: center;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.assistant-visual::before,
|
||||
.assistant-visual::after,
|
||||
.assistant-glow {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.assistant-visual::before {
|
||||
inset: auto auto -82px 50%;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
@@ -747,6 +767,19 @@ const policyItems = [
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.assistant-visual {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.assistant-glow {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
}
|
||||
|
||||
.assistant-image {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.assistant-input textarea {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
@@ -800,3 +833,5 @@ const policyItems = [
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -28,14 +28,23 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button class="rail-user" type="button" aria-label="打开用户菜单">
|
||||
<span class="user-avatar">张</span>
|
||||
<span class="user-copy">
|
||||
<strong>张晓明</strong>
|
||||
<span>财务管理员</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div class="rail-user">
|
||||
<div class="user-menu" role="menu" aria-label="用户菜单">
|
||||
<button class="user-menu-item" type="button" @click="emit('logout')">
|
||||
<i class="mdi mdi-logout-variant"></i>
|
||||
<span>退出系统</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-summary" tabindex="0" aria-label="用户信息">
|
||||
<span class="user-avatar">{{ displayUser.avatar }}</span>
|
||||
<span class="user-copy">
|
||||
<strong>{{ displayUser.name }}</strong>
|
||||
<span>{{ displayUser.role }}</span>
|
||||
</span>
|
||||
<i class="mdi mdi-chevron-up"></i>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -44,17 +53,25 @@ import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
navItems: { type: Array, required: true },
|
||||
activeView: { type: String, required: true }
|
||||
activeView: { type: String, required: true },
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
name: '系统管理员',
|
||||
role: '财务管理员',
|
||||
avatar: '管'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'openChat'])
|
||||
const emit = defineEmits(['navigate', 'openChat', 'logout'])
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '总览' },
|
||||
workbench: { label: '个人工作台' },
|
||||
requests: { label: '差旅申请/报销' },
|
||||
approval: { label: '审批中心', badge: '12' },
|
||||
chat: { label: 'AI助手' },
|
||||
chat: { label: 'AI 助手' },
|
||||
policies: { label: '知识管理' },
|
||||
audit: { label: '技能中心' },
|
||||
employees: { label: '员工管理' }
|
||||
@@ -67,6 +84,12 @@ const decoratedNavItems = computed(() =>
|
||||
badge: sidebarMeta[item.id]?.badge
|
||||
}))
|
||||
)
|
||||
|
||||
const displayUser = computed(() => ({
|
||||
name: props.currentUser?.name || '系统管理员',
|
||||
role: props.currentUser?.role || '财务管理员',
|
||||
avatar: props.currentUser?.avatar || '管'
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -77,10 +100,10 @@ const decoratedNavItems = computed(() =>
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,.98), rgba(248,251,250,.96)),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 251, 250, 0.96)),
|
||||
#fff;
|
||||
border-right: 1px solid #dbe4ee;
|
||||
box-shadow: 1px 0 0 rgba(15,23,42,.02);
|
||||
box-shadow: 1px 0 0 rgba(15, 23, 42, 0.02);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@@ -164,13 +187,13 @@ const decoratedNavItems = computed(() =>
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(16,185,129,.07);
|
||||
background: rgba(16, 185, 129, 0.07);
|
||||
color: #0f9f78;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(90deg, rgba(16,185,129,.16), rgba(16,185,129,.08));
|
||||
border-color: rgba(16,185,129,.10);
|
||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
|
||||
border-color: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
box-shadow: inset 3px 0 0 #10b981;
|
||||
}
|
||||
@@ -221,25 +244,31 @@ const decoratedNavItems = computed(() =>
|
||||
}
|
||||
|
||||
.rail-user {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 74px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 78px;
|
||||
margin: 0;
|
||||
padding: 16px 20px 18px;
|
||||
border: 0;
|
||||
border-top: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
transition: background 180ms var(--ease), border-color 180ms var(--ease);
|
||||
border-top: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.rail-user:hover {
|
||||
border-top-color: #e2e8f0;
|
||||
background: rgba(255,255,255,.72);
|
||||
.user-summary {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0 0;
|
||||
color: #64748b;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: background 180ms var(--ease);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-summary,
|
||||
.rail-user:focus-within .user-summary {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -250,7 +279,7 @@ const decoratedNavItems = computed(() =>
|
||||
border: 2px solid #fff;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #0f9f78, #65d6b4);
|
||||
box-shadow: 0 6px 14px rgba(15,159,120,.18);
|
||||
box-shadow: 0 6px 14px rgba(15, 159, 120, 0.18);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
@@ -281,10 +310,88 @@ const decoratedNavItems = computed(() =>
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail-user .mdi {
|
||||
.user-summary .mdi {
|
||||
justify-self: end;
|
||||
color: #718096;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
transition: transform 180ms var(--ease), color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-summary .mdi,
|
||||
.rail-user:focus-within .user-summary .mdi {
|
||||
color: #0f9f78;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: calc(100% - 6px);
|
||||
min-width: 132px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow:
|
||||
0 16px 32px rgba(15, 23, 42, 0.10),
|
||||
0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 180ms var(--ease),
|
||||
transform 180ms var(--ease),
|
||||
box-shadow 180ms var(--ease);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.user-menu::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 1px solid rgba(226, 232, 240, 0.96);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.96);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.rail-user:hover .user-menu,
|
||||
.rail-user:focus-within .user-menu {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.user-menu-item {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
transition: background 180ms var(--ease), color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.user-menu-item:hover {
|
||||
background: #fff5f5;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.user-menu-item .mdi {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isEmployees">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -130,6 +140,10 @@ const props = defineProps({
|
||||
activeView: { type: String, default: '' },
|
||||
ranges: { type: Array, default: () => [] },
|
||||
activeRange: { type: String, default: '' },
|
||||
employeeSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
customRange: {
|
||||
type: Object,
|
||||
default: () => ({ start: '2024-07-06', end: '2024-07-12' })
|
||||
@@ -150,6 +164,7 @@ const isOverview = computed(() => props.activeView === 'overview')
|
||||
const isRequests = computed(() => props.activeView === 'requests')
|
||||
const isApproval = computed(() => props.activeView === 'approval')
|
||||
const isPolicies = computed(() => props.activeView === 'policies')
|
||||
const isEmployees = computed(() => props.activeView === 'employees')
|
||||
|
||||
const requestKpis = [
|
||||
{ label: '全部单据', value: 30, delta: '+8', trend: 'up', arrow: 'mdi mdi-arrow-up', color: '#10b981' },
|
||||
@@ -178,6 +193,51 @@ const knowledgeKpis = [
|
||||
{ label: '问答总量', value: '8,562', meta: '较上周 +321', trend: 'up', icon: 'mdi mdi-comment-text-multiple-outline', color: '#8b5cf6' },
|
||||
{ label: '知识命中率', value: '87.3%', meta: '较上周 +1.2%', trend: 'up', icon: 'mdi mdi-bullseye-arrow', color: '#f59e0b' }
|
||||
]
|
||||
|
||||
const employeeKpis = computed(() => {
|
||||
const summary = props.employeeSummary ?? {}
|
||||
const total = Number(summary.total ?? 0)
|
||||
const active = Number(summary.active ?? 0)
|
||||
const onboarding = Number(summary.onboarding ?? 0)
|
||||
const disabled = Number(summary.disabled ?? 0)
|
||||
const followUp = Number(summary.followUp ?? 0)
|
||||
const departments = Number(summary.departments ?? 0)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '员工总数',
|
||||
value: total,
|
||||
unit: '人',
|
||||
meta: `覆盖 ${departments} 个部门`,
|
||||
trend: 'up',
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
label: '在职账号',
|
||||
value: active,
|
||||
unit: '人',
|
||||
meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
|
||||
trend: 'up',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '待处理状态',
|
||||
value: onboarding + disabled,
|
||||
unit: '人',
|
||||
meta: `试用 ${onboarding} / 停用 ${disabled}`,
|
||||
trend: onboarding + disabled > 0 ? 'down' : 'up',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '同步待处理',
|
||||
value: followUp,
|
||||
unit: '人',
|
||||
meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
|
||||
trend: followUp > 0 ? 'down' : 'up',
|
||||
color: '#8b5cf6'
|
||||
}
|
||||
]
|
||||
})
|
||||
const calendarOpen = ref(false)
|
||||
const draftStart = ref(props.customRange.start)
|
||||
const draftEnd = ref(props.customRange.end)
|
||||
|
||||
@@ -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,
|
||||
|
||||
47
web/src/composables/useBackendHealth.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { fetchBackendHealth } from '../services/system.js'
|
||||
|
||||
const backendHealthy = ref(true)
|
||||
const backendChecking = ref(false)
|
||||
const backendError = ref('')
|
||||
let lastCheckedAt = 0
|
||||
|
||||
export async function checkBackendHealth(options = {}) {
|
||||
const force = Boolean(options.force)
|
||||
const now = Date.now()
|
||||
|
||||
if (!force && now - lastCheckedAt < 5000) {
|
||||
return backendHealthy.value
|
||||
}
|
||||
|
||||
backendChecking.value = true
|
||||
|
||||
try {
|
||||
const payload = await fetchBackendHealth()
|
||||
const ok = payload?.status === 'ok'
|
||||
|
||||
backendHealthy.value = ok
|
||||
backendError.value = ok
|
||||
? ''
|
||||
: payload?.database?.error || '后端服务尚未准备完成。'
|
||||
lastCheckedAt = now
|
||||
return ok
|
||||
} catch (error) {
|
||||
backendHealthy.value = false
|
||||
backendError.value = error?.message || '无法连接后端服务。'
|
||||
lastCheckedAt = now
|
||||
return false
|
||||
} finally {
|
||||
backendChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function useBackendHealth() {
|
||||
return {
|
||||
backendHealthy,
|
||||
backendChecking,
|
||||
backendError,
|
||||
checkBackendHealth
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
383
web/src/composables/useSetupView.js
Normal 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
|
||||
}
|
||||
}
|
||||
476
web/src/composables/useSystemState.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const toastText = ref('')
|
||||
|
||||
function toast(text) {
|
||||
toastText.value = text
|
||||
clearTimeout(toast.timer)
|
||||
toast.timer = setTimeout(() => {
|
||||
toastText.value = ''
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const toastText = ref('')
|
||||
|
||||
function toast(text) {
|
||||
toastText.value = text
|
||||
clearTimeout(toast.timer)
|
||||
toast.timer = setTimeout(() => { toastText.value = '' }, 3200)
|
||||
}
|
||||
|
||||
return { toastText, toast }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
24
web/src/services/employees.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchEmployees(params = {}) {
|
||||
const search = new URLSearchParams()
|
||||
|
||||
if (params.status && params.status !== '全部员工') {
|
||||
search.set('status', params.status)
|
||||
}
|
||||
|
||||
if (params.keyword) {
|
||||
search.set('keyword', params.keyword)
|
||||
}
|
||||
|
||||
const query = search.toString()
|
||||
return apiRequest(`/employees${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function fetchEmployeeMeta() {
|
||||
return apiRequest('/employees/meta')
|
||||
}
|
||||
|
||||
export function fetchEmployeeDetail(employeeId) {
|
||||
return apiRequest(`/employees/${employeeId}`)
|
||||
}
|
||||
5
web/src/services/system.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function fetchBackendHealth() {
|
||||
return apiRequest('/health')
|
||||
}
|
||||
87
web/src/utils/requestViewModel.js
Normal 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
|
||||
}
|
||||
}
|
||||
190
web/src/views/AppShellRouteView.vue
Normal 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>
|
||||
27
web/src/views/BackendUnavailableRouteView.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<section class="backend-unavailable">
|
||||
<div class="backend-card">
|
||||
<div class="backend-badge">
|
||||
<i class="mdi mdi-server-network-off"></i>
|
||||
</div>
|
||||
<h1>后端服务不可用</h1>
|
||||
<p>{{ statusMessage }}</p>
|
||||
|
||||
<div class="backend-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="retry-btn"
|
||||
:disabled="retrying || backendChecking"
|
||||
@click="retry"
|
||||
>
|
||||
<i class="mdi" :class="retrying || backendChecking ? 'mdi-loading mdi-spin' : 'mdi-refresh'"></i>
|
||||
<span>{{ retrying || backendChecking ? '重新检测中...' : '重新检测后端' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script src="./scripts/BackendUnavailableRouteView.js"></script>
|
||||
|
||||
<style scoped src="../assets/styles/views/backend-unavailable-view.css"></style>
|
||||
@@ -9,7 +9,10 @@
|
||||
<div class="hero-copy">
|
||||
<div class="hero-tag">{{ selectedEmployee.employeeNo }}</div>
|
||||
<h2>{{ selectedEmployee.name }}</h2>
|
||||
<p>{{ selectedEmployee.department }} / {{ selectedEmployee.position }} / {{ selectedEmployee.grade }}</p>
|
||||
<p>
|
||||
{{ selectedEmployee.department }} / {{ selectedEmployee.position }} /
|
||||
{{ selectedEmployee.grade }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,12 +221,13 @@
|
||||
<nav class="status-tabs" aria-label="员工状态筛选">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab"
|
||||
:key="tab.label"
|
||||
type="button"
|
||||
:class="{ active: activeTab === tab }"
|
||||
@click="activeTab = tab"
|
||||
:class="{ active: activeTab === tab.label }"
|
||||
@click="activeTab = tab.label"
|
||||
>
|
||||
{{ tab }}
|
||||
<span>{{ tab.label }}</span>
|
||||
<small>{{ tab.count }}</small>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -231,25 +235,204 @@
|
||||
<div class="filter-set">
|
||||
<div class="list-search">
|
||||
<i class="mdi mdi-magnify"></i>
|
||||
<input type="search" placeholder="搜索员工姓名、工号、部门或岗位..." />
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="search"
|
||||
placeholder="搜索姓名、工号、部门、岗位"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="filter-btn">
|
||||
<span>{{ filter }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'department' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'department'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('department')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedDepartment || '组织部门' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'department'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择组织部门"
|
||||
>
|
||||
<header>
|
||||
<strong>选择组织部门</strong>
|
||||
<button type="button" aria-label="关闭组织部门选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedDepartment }"
|
||||
@click="selectFilter('department', '')"
|
||||
>
|
||||
全部部门
|
||||
</button>
|
||||
<button
|
||||
v-for="department in departmentOptions"
|
||||
:key="department"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedDepartment === department }"
|
||||
@click="selectFilter('department', department)"
|
||||
>
|
||||
{{ department }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'grade' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'grade'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('grade')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedGrade || '职级' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'grade'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择职级"
|
||||
>
|
||||
<header>
|
||||
<strong>选择职级</strong>
|
||||
<button type="button" aria-label="关闭职级选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedGrade }"
|
||||
@click="selectFilter('grade', '')"
|
||||
>
|
||||
全部职级
|
||||
</button>
|
||||
<button
|
||||
v-for="grade in gradeOptions"
|
||||
:key="grade"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedGrade === grade }"
|
||||
@click="selectFilter('grade', grade)"
|
||||
>
|
||||
{{ grade }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picker-filter" :class="{ open: activeFilterPopover === 'role' }">
|
||||
<button
|
||||
class="picker-trigger"
|
||||
type="button"
|
||||
:aria-expanded="activeFilterPopover === 'role'"
|
||||
aria-haspopup="dialog"
|
||||
@click="toggleFilterPopover('role')"
|
||||
>
|
||||
<span class="picker-label">{{ selectedRole || '系统角色' }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div
|
||||
v-if="activeFilterPopover === 'role'"
|
||||
class="picker-popover"
|
||||
role="dialog"
|
||||
aria-label="选择系统角色"
|
||||
>
|
||||
<header>
|
||||
<strong>选择系统角色</strong>
|
||||
<button type="button" aria-label="关闭系统角色选择" @click="closeFilterPopover">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div class="picker-option-list">
|
||||
<button
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: !selectedRole }"
|
||||
@click="selectFilter('role', '')"
|
||||
>
|
||||
全部角色
|
||||
</button>
|
||||
<button
|
||||
v-for="role in roleFilterOptions"
|
||||
:key="role"
|
||||
type="button"
|
||||
class="picker-option"
|
||||
:class="{ active: selectedRole === role }"
|
||||
@click="selectFilter('role', role)"
|
||||
>
|
||||
{{ role }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
</button>
|
||||
<div class="toolbar-actions">
|
||||
<button v-if="hasActiveFilters" class="ghost-filter-btn" type="button" @click="resetFilters">
|
||||
<i class="mdi mdi-filter-remove-outline"></i>
|
||||
<span>清空筛选</span>
|
||||
</button>
|
||||
|
||||
<button class="create-btn" type="button">
|
||||
<i class="mdi mdi-plus"></i>
|
||||
<span>新增员工</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint"><i class="mdi mdi-information-outline"></i> 点击任意员工行可进入基础信息与角色权限编辑界面</p>
|
||||
<p class="hint">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
点击任意员工行可进入基础信息与角色权限编辑界面
|
||||
</p>
|
||||
|
||||
<div v-if="activeFilterTokens.length" class="active-filter-strip">
|
||||
<span v-for="token in activeFilterTokens" :key="token" class="active-filter-chip">
|
||||
{{ token }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<div v-if="loading" class="table-state">
|
||||
<i class="mdi mdi-loading mdi-spin"></i>
|
||||
<p>正在加载员工数据...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="errorMessage" class="table-state error">
|
||||
<i class="mdi mdi-alert-circle-outline"></i>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<button type="button" class="state-action" @click="loadEmployees">重新加载</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!visibleEmployees.length" class="table-state empty">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<p>没有匹配的员工数据</p>
|
||||
</div>
|
||||
|
||||
<table v-else>
|
||||
<colgroup>
|
||||
<col class="col-employee">
|
||||
<col class="col-employee-no">
|
||||
<col class="col-department">
|
||||
<col class="col-position">
|
||||
<col class="col-grade">
|
||||
<col class="col-role">
|
||||
<col class="col-status">
|
||||
<col class="col-updated">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
@@ -257,8 +440,6 @@
|
||||
<th>部门</th>
|
||||
<th>岗位</th>
|
||||
<th>职级</th>
|
||||
<th>直属上级</th>
|
||||
<th>财务归口</th>
|
||||
<th>系统角色</th>
|
||||
<th>状态</th>
|
||||
<th>最近更新</th>
|
||||
@@ -284,20 +465,81 @@
|
||||
<td>{{ employee.department }}</td>
|
||||
<td>{{ employee.position }}</td>
|
||||
<td><span class="level-pill">{{ employee.grade }}</span></td>
|
||||
<td>{{ employee.manager }}</td>
|
||||
<td>{{ employee.financeOwner }}</td>
|
||||
<td>
|
||||
<div class="role-stack">
|
||||
<span v-for="role in employee.roles.slice(0, 2)" :key="role" class="role-pill">{{ role }}</span>
|
||||
<span v-if="employee.roles.length > 2" class="more-pill">+{{ employee.roles.length - 2 }}</span>
|
||||
<span
|
||||
v-for="role in employee.roles.slice(0, 2)"
|
||||
:key="role"
|
||||
class="role-pill"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
<span v-if="employee.roles.length > 2" class="more-pill">
|
||||
+{{ employee.roles.length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span></td>
|
||||
<td>
|
||||
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
|
||||
</td>
|
||||
<td>{{ employee.updatedAt }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer v-if="!loading && !errorMessage && totalCount" class="list-foot">
|
||||
<span class="page-summary">共 {{ totalCount }} 条,目前第 {{ currentPage }} 页</span>
|
||||
<div class="pager" aria-label="分页">
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === 1"
|
||||
aria-label="上一页"
|
||||
@click="currentPage--"
|
||||
>
|
||||
<i class="mdi mdi-chevron-left"></i>
|
||||
</button>
|
||||
<button
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
class="page-number"
|
||||
:class="{ active: currentPage === page }"
|
||||
type="button"
|
||||
:aria-current="currentPage === page ? 'page' : undefined"
|
||||
@click="currentPage = page"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
<button
|
||||
class="page-nav"
|
||||
type="button"
|
||||
:disabled="currentPage === totalPages"
|
||||
aria-label="下一页"
|
||||
@click="currentPage++"
|
||||
>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="page-size-wrap">
|
||||
<button class="page-size" type="button" @click="togglePageSizeOpen">
|
||||
{{ pageSize }} 条/页 <i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="pageSizeOpen" class="page-size-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="size in pageSizes"
|
||||
:key="size"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="pageSize === size"
|
||||
:class="{ active: pageSize === size }"
|
||||
@click="changePageSize(size)"
|
||||
>
|
||||
{{ size }} 条/页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
46
web/src/views/LoginRouteView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
51
web/src/views/SetupRouteView.vue
Normal 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
@@ -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>
|
||||
39
web/src/views/scripts/BackendUnavailableRouteView.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useBackendHealth } from '../../composables/useBackendHealth.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
|
||||
export default {
|
||||
name: 'BackendUnavailableRouteView',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const { backendChecking, backendError, checkBackendHealth } = useBackendHealth()
|
||||
const { loggedIn, resolveEntryRoute } = useSystemState()
|
||||
const retrying = ref(false)
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。'
|
||||
})
|
||||
|
||||
async function retry() {
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
const ok = await checkBackendHealth({ force: true })
|
||||
if (ok) {
|
||||
await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' })
|
||||
}
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
backendChecking,
|
||||
retrying,
|
||||
statusMessage,
|
||||
retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,202 +1,373 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { fetchEmployeeMeta, fetchEmployees } from '../../services/employees.js'
|
||||
|
||||
const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
|
||||
const FALLBACK_ROLE_OPTIONS = [
|
||||
{
|
||||
id: 'manager',
|
||||
code: 'manager',
|
||||
label: '管理员',
|
||||
desc: '可以维护员工档案、组织结构和角色权限。'
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
code: 'finance',
|
||||
label: '财务人员',
|
||||
desc: '可以处理复核、查看财务知识与风险校验结果。'
|
||||
},
|
||||
{
|
||||
id: 'approver',
|
||||
code: 'approver',
|
||||
label: '审批负责人',
|
||||
desc: '可以处理审批中心中的待审单据。'
|
||||
},
|
||||
{
|
||||
id: 'executive',
|
||||
code: 'executive',
|
||||
label: '高级管理人员',
|
||||
desc: '可以查看跨部门数据看板与关键审批结果。'
|
||||
},
|
||||
{
|
||||
id: 'auditor',
|
||||
code: 'auditor',
|
||||
label: '审计观察员',
|
||||
desc: '可以查看变更记录和权限调整历史。'
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
code: 'user',
|
||||
label: '使用者',
|
||||
desc: '可以发起报销、查看个人单据和使用 AI 助手。'
|
||||
}
|
||||
]
|
||||
|
||||
function matchKeyword(employee, keyword) {
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
employee.name,
|
||||
employee.employeeNo,
|
||||
employee.department,
|
||||
employee.position,
|
||||
employee.email,
|
||||
employee.manager,
|
||||
employee.financeOwner,
|
||||
employee.syncState,
|
||||
...(employee.roles || [])
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
||||
return haystack.includes(keyword)
|
||||
}
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values.filter(Boolean))].sort((left, right) => {
|
||||
return String(left).localeCompare(String(right), 'zh-CN')
|
||||
})
|
||||
}
|
||||
|
||||
function resolveRoleOptions(metaRoles, employees) {
|
||||
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
|
||||
const existingLabels = new Set(options.map((item) => item.label))
|
||||
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
|
||||
(label) => !existingLabels.has(label)
|
||||
)
|
||||
|
||||
return [
|
||||
...options,
|
||||
...unknownRoles.map((label) => ({
|
||||
id: label,
|
||||
code: label,
|
||||
label,
|
||||
desc: '该角色来自当前员工数据。'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
function buildStatusTabs(employees) {
|
||||
return DEFAULT_STATUS_TABS.map((label) => ({
|
||||
label,
|
||||
count:
|
||||
label === '全部员工'
|
||||
? employees.length
|
||||
: employees.filter((item) => item.status === label).length
|
||||
}))
|
||||
}
|
||||
|
||||
function buildEmployeeSummary(employees) {
|
||||
return {
|
||||
total: employees.length,
|
||||
active: employees.filter((item) => item.status === '在职').length,
|
||||
onboarding: employees.filter((item) => item.status === '试用中').length,
|
||||
disabled: employees.filter((item) => item.status === '停用').length,
|
||||
followUp: employees.filter((item) => item.syncState !== '已同步').length,
|
||||
departments: uniqueSorted(employees.map((item) => item.department)).length
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EmployeeManagementView' ,
|
||||
setup(props, { emit }) {
|
||||
const tabs = ['全部员工', '在职', '试用中', '停用']
|
||||
const filters = ['按部门筛选', '按职级筛选', '按系统角色筛选']
|
||||
const activeTab = ref(tabs[0])
|
||||
name: 'EmployeeManagementView',
|
||||
emits: ['overview-change'],
|
||||
setup(_, { emit }) {
|
||||
const activeTab = ref(DEFAULT_STATUS_TABS[0])
|
||||
const selectedEmployee = ref(null)
|
||||
const roleOptions = ref([...FALLBACK_ROLE_OPTIONS])
|
||||
const employees = ref([])
|
||||
const searchKeyword = ref('')
|
||||
const selectedDepartment = ref('')
|
||||
const selectedGrade = ref('')
|
||||
const selectedRole = ref('')
|
||||
const activeFilterPopover = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizes = [10, 20, 50]
|
||||
const pageSizeOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
{ id: 'user', label: '使用者', desc: '可以发起报销、查看个人单据和使用 AI 助手。' },
|
||||
{ id: 'finance', label: '财务人员', desc: '可以处理复核、查看财务知识与风险校验结果。' },
|
||||
{ id: 'manager', label: '管理员', desc: '可以维护员工档案、组织结构和角色权限。' },
|
||||
{ id: 'executive', label: '高级管理人员', desc: '可以查看跨部门数据看板与关键审批结果。' },
|
||||
{ id: 'approver', label: '审批负责人', desc: '可以处理审批中心中的待审单据。' },
|
||||
{ id: 'auditor', label: '审计观察员', desc: '可以查看变更记录和权限调整历史。' }
|
||||
]
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
|
||||
const employees = [
|
||||
{
|
||||
id: 'EMP-001',
|
||||
avatar: '张',
|
||||
name: '张晓晴',
|
||||
employeeNo: 'E10234',
|
||||
department: '财务共享中心',
|
||||
position: '费用运营经理',
|
||||
grade: 'M3',
|
||||
manager: '李文静',
|
||||
financeOwner: '华东财务组',
|
||||
roles: ['管理员', '财务人员', '审批负责人'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '32',
|
||||
birthDate: '1994-08-12',
|
||||
email: 'xiaoqing.zhang@xfinance.com',
|
||||
phone: '138 1023 4567',
|
||||
joinDate: '2021-03-15',
|
||||
location: '上海',
|
||||
costCenter: 'CC-2108',
|
||||
updatedAt: '2026-05-06 10:24',
|
||||
lastSync: '2026-05-06 10:24',
|
||||
syncState: '待生效',
|
||||
spotlight: true,
|
||||
permissions: [
|
||||
'可查看审批中心全部待审单据',
|
||||
'可配置员工角色与部门归属',
|
||||
'可查看知识管理与技能中心配置'
|
||||
],
|
||||
history: [
|
||||
{ action: '新增“审批负责人”角色', owner: '系统管理员 · 王敏', time: '今天 10:24' },
|
||||
{ action: '调整财务归口为华东财务组', owner: '组织管理员 · 陈硕', time: '昨天 18:10' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-002',
|
||||
avatar: '李',
|
||||
name: '李文静',
|
||||
employeeNo: 'E10018',
|
||||
department: '总经办',
|
||||
position: '高级财务总监',
|
||||
grade: 'D2',
|
||||
manager: 'CEO',
|
||||
financeOwner: '集团财务',
|
||||
roles: ['高级管理人员', '审批负责人'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '39',
|
||||
birthDate: '1987-03-26',
|
||||
email: 'wenjing.li@xfinance.com',
|
||||
phone: '139 0018 7688',
|
||||
joinDate: '2018-06-21',
|
||||
location: '上海',
|
||||
costCenter: 'CC-1001',
|
||||
updatedAt: '2026-05-05 16:20',
|
||||
lastSync: '2026-05-05 16:20',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可查看集团层面的审批看板',
|
||||
'可处理高金额报销的最终审批',
|
||||
'可查看部门预算执行情况'
|
||||
],
|
||||
history: [
|
||||
{ action: '更新高级管理人员可见范围', owner: '系统管理员 · 王敏', time: '05-05 16:20' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-003',
|
||||
avatar: '王',
|
||||
name: '王敏',
|
||||
employeeNo: 'E10867',
|
||||
department: '人力与组织',
|
||||
position: '组织发展主管',
|
||||
grade: 'P6',
|
||||
manager: '陈嘉',
|
||||
financeOwner: '总部财务',
|
||||
roles: ['管理员', '审计观察员'],
|
||||
status: '在职',
|
||||
statusTone: 'success',
|
||||
gender: '女',
|
||||
age: '30',
|
||||
birthDate: '1996-11-05',
|
||||
email: 'min.wang@xfinance.com',
|
||||
phone: '136 8867 1200',
|
||||
joinDate: '2022-08-08',
|
||||
location: '杭州',
|
||||
costCenter: 'CC-3206',
|
||||
updatedAt: '2026-05-05 09:18',
|
||||
lastSync: '2026-05-05 09:18',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可维护组织结构与岗位映射',
|
||||
'可查看员工角色分配历史'
|
||||
],
|
||||
history: [
|
||||
{ action: '新增“审计观察员”角色', owner: '系统管理员 · 张晓晴', time: '05-05 09:18' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-004',
|
||||
avatar: '陈',
|
||||
name: '陈嘉',
|
||||
employeeNo: 'E11602',
|
||||
department: '销售运营',
|
||||
position: '区域销售经理',
|
||||
grade: 'M2',
|
||||
manager: '李文静',
|
||||
financeOwner: '华南财务组',
|
||||
roles: ['使用者', '审批负责人'],
|
||||
status: '试用中',
|
||||
statusTone: 'warning',
|
||||
gender: '男',
|
||||
age: '29',
|
||||
birthDate: '1997-02-18',
|
||||
email: 'jia.chen@xfinance.com',
|
||||
phone: '137 1602 9901',
|
||||
joinDate: '2026-03-01',
|
||||
location: '深圳',
|
||||
costCenter: 'CC-4102',
|
||||
updatedAt: '2026-05-04 14:12',
|
||||
lastSync: '2026-05-04 14:12',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'可发起个人报销与出差申请',
|
||||
'可处理本部门基础审批'
|
||||
],
|
||||
history: [
|
||||
{ action: '完成试用期角色初始化', owner: '组织管理员 · 王敏', time: '05-04 14:12' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'EMP-005',
|
||||
avatar: '赵',
|
||||
name: '赵雨辰',
|
||||
employeeNo: 'E11991',
|
||||
department: '研发中心',
|
||||
position: '产品经理',
|
||||
grade: 'P5',
|
||||
manager: '陈嘉',
|
||||
financeOwner: '总部财务',
|
||||
roles: ['使用者'],
|
||||
status: '停用',
|
||||
statusTone: 'neutral',
|
||||
gender: '男',
|
||||
age: '27',
|
||||
birthDate: '1999-06-09',
|
||||
email: 'yuchen.zhao@xfinance.com',
|
||||
phone: '135 1991 3300',
|
||||
joinDate: '2023-11-18',
|
||||
location: '北京',
|
||||
costCenter: 'CC-5209',
|
||||
updatedAt: '2026-05-01 11:06',
|
||||
lastSync: '2026-05-01 11:06',
|
||||
syncState: '已同步',
|
||||
permissions: [
|
||||
'当前账号停用,仅保留历史单据查看记录'
|
||||
],
|
||||
history: [
|
||||
{ action: '账号状态变更为停用', owner: '系统管理员 · 王敏', time: '05-01 11:06' }
|
||||
]
|
||||
}
|
||||
]
|
||||
const departmentOptions = computed(() =>
|
||||
uniqueSorted(employees.value.map((item) => item.department))
|
||||
)
|
||||
|
||||
const gradeOptions = computed(() => uniqueSorted(employees.value.map((item) => item.grade)))
|
||||
|
||||
const roleFilterOptions = computed(() =>
|
||||
uniqueSorted(
|
||||
roleOptions.value.map((item) => item.label).concat(
|
||||
employees.value.flatMap((item) => item.roles || [])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
|
||||
return employees.value.filter((item) => {
|
||||
const matchesStatus =
|
||||
activeTab.value === '全部员工' ? true : item.status === activeTab.value
|
||||
const matchesDepartment = selectedDepartment.value
|
||||
? item.department === selectedDepartment.value
|
||||
: true
|
||||
const matchesGrade = selectedGrade.value ? item.grade === selectedGrade.value : true
|
||||
const matchesRole = selectedRole.value
|
||||
? (item.roles || []).includes(selectedRole.value)
|
||||
: true
|
||||
|
||||
return (
|
||||
matchesStatus &&
|
||||
matchesDepartment &&
|
||||
matchesGrade &&
|
||||
matchesRole &&
|
||||
matchKeyword(item, keyword)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredEmployees.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
|
||||
const visibleEmployees = computed(() => {
|
||||
if (activeTab.value === '全部员工') return employees
|
||||
return employees.filter((item) => item.status === activeTab.value)
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredEmployees.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const activeFilterTokens = computed(() => {
|
||||
const tokens = []
|
||||
|
||||
if (selectedDepartment.value) {
|
||||
tokens.push(`部门:${selectedDepartment.value}`)
|
||||
}
|
||||
|
||||
if (selectedGrade.value) {
|
||||
tokens.push(`职级:${selectedGrade.value}`)
|
||||
}
|
||||
|
||||
if (selectedRole.value) {
|
||||
tokens.push(`角色:${selectedRole.value}`)
|
||||
}
|
||||
|
||||
if (searchKeyword.value.trim()) {
|
||||
tokens.push(`搜索:${searchKeyword.value.trim()}`)
|
||||
}
|
||||
|
||||
return tokens
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
|
||||
|
||||
watch(
|
||||
employeeSummary,
|
||||
(summary) => {
|
||||
emit('overview-change', summary)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(filteredEmployees, () => {
|
||||
currentPage.value = 1
|
||||
pageSizeOpen.value = false
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
searchKeyword.value = ''
|
||||
selectedDepartment.value = ''
|
||||
selectedGrade.value = ''
|
||||
selectedRole.value = ''
|
||||
activeTab.value = DEFAULT_STATUS_TABS[0]
|
||||
activeFilterPopover.value = ''
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
function changePageSize(size) {
|
||||
pageSize.value = size
|
||||
pageSizeOpen.value = false
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function togglePageSizeOpen() {
|
||||
pageSizeOpen.value = !pageSizeOpen.value
|
||||
}
|
||||
|
||||
function toggleFilterPopover(name) {
|
||||
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
|
||||
}
|
||||
|
||||
function closeFilterPopover() {
|
||||
activeFilterPopover.value = ''
|
||||
}
|
||||
|
||||
function selectFilter(name, value) {
|
||||
if (name === 'department') {
|
||||
selectedDepartment.value = value
|
||||
}
|
||||
|
||||
if (name === 'grade') {
|
||||
selectedGrade.value = value
|
||||
}
|
||||
|
||||
if (name === 'role') {
|
||||
selectedRole.value = value
|
||||
}
|
||||
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
const target = event.target
|
||||
|
||||
if (!(target instanceof Element)) {
|
||||
closeFilterPopover()
|
||||
pageSizeOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!target.closest('.picker-filter')) {
|
||||
closeFilterPopover()
|
||||
}
|
||||
|
||||
if (!target.closest('.page-size-wrap')) {
|
||||
pageSizeOpen.value = false
|
||||
}
|
||||
|
||||
if (target.closest('.picker-filter') || target.closest('.page-size-wrap')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const [employeesResult, metaResult] = await Promise.allSettled([
|
||||
fetchEmployees(),
|
||||
fetchEmployeeMeta()
|
||||
])
|
||||
|
||||
if (employeesResult.status !== 'fulfilled') {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value =
|
||||
employeesResult.reason?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
employees.value = Array.isArray(employeesResult.value) ? employeesResult.value : []
|
||||
|
||||
if (metaResult.status === 'fulfilled') {
|
||||
roleOptions.value = resolveRoleOptions(metaResult.value?.roleOptions, employees.value)
|
||||
} else {
|
||||
roleOptions.value = resolveRoleOptions([], employees.value)
|
||||
}
|
||||
|
||||
if (!DEFAULT_STATUS_TABS.includes(activeTab.value)) {
|
||||
activeTab.value = DEFAULT_STATUS_TABS[0]
|
||||
}
|
||||
|
||||
if (selectedEmployee.value) {
|
||||
selectedEmployee.value =
|
||||
employees.value.find((item) => item.id === selectedEmployee.value.id) || null
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
loadEmployees().catch((error) => {
|
||||
employees.value = []
|
||||
roleOptions.value = [...FALLBACK_ROLE_OPTIONS]
|
||||
selectedEmployee.value = null
|
||||
errorMessage.value = error?.message || '员工数据加载失败,请稍后重试。'
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
})
|
||||
|
||||
return {
|
||||
tabs,
|
||||
filters,
|
||||
activeTab,
|
||||
selectedEmployee,
|
||||
roleOptions,
|
||||
employees,
|
||||
visibleEmployees
|
||||
visibleEmployees,
|
||||
searchKeyword,
|
||||
selectedDepartment,
|
||||
selectedGrade,
|
||||
selectedRole,
|
||||
activeFilterPopover,
|
||||
currentPage,
|
||||
pageSize,
|
||||
pageSizes,
|
||||
pageSizeOpen,
|
||||
departmentOptions,
|
||||
gradeOptions,
|
||||
roleFilterOptions,
|
||||
activeFilterTokens,
|
||||
hasActiveFilters,
|
||||
totalCount,
|
||||
totalPages,
|
||||
resetFilters,
|
||||
changePageSize,
|
||||
togglePageSizeOpen,
|
||||
toggleFilterPopover,
|
||||
closeFilterPopover,
|
||||
selectFilter,
|
||||
loading,
|
||||
errorMessage,
|
||||
loadEmployees
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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'] ,
|
||||
filteredRequests: { type: Array, required: true }
|
||||
},
|
||||
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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
179
web/start.sh
@@ -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..."
|
||||
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
|
||||
|
||||
@@ -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 驱动 pg(web/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()]
|
||||
})
|
||||
|
||||