Co-Authored-By: Claude <noreply@anthropic.com>
67 KiB
客户经理请假功能 — 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为政企周报管理系统添加完整的请假功能(PC + 移动端),包含数据模型、CRUD API、填报进度豁免、催办过滤、AI摘要注入、仪表盘统计卡。
Architecture: 新增 leaves 表 + 完整的后端模型/服务/API 层;修改 dashboard/scheduler/ai_summary 三个服务模块注入请假数据;PC 端新增 Leaves.vue 管理页 + 修改 Dashboard/侧边栏/路由;移动端新增 LeavesList.vue + LeaveForm.vue + 修改 Home 快捷入口。
Tech Stack: FastAPI + SQLAlchemy 2.0 async + PostgreSQL + Vue 3 + TypeScript + Element Plus
Spec: docs/superpowers/specs/2026-07-12-leave-management-design.md
Global Constraints
- 按天请假粒度(start_date ~ end_date)
- 无需审批,提交即生效
- 请假类型:年假 / 事假 / 病假 / 调休 / 其他
- 权限:director/leader 看全员,manager 看自己
- 无 edit_log 列
- 所有日期判断使用
today_cst()(Asia/Shanghai) - 代码风格匹配现有模式(Mapped 类型注解、el-table + el-dialog、editorial form header)
Task 1: 后端 — Leave 数据模型
Files:
- Create:
backend/app/models/leave.py
Interfaces:
-
Produces:
LeaveSQLAlchemy model class with columns:id(UUID PK),manager_id(FK→users.id),leave_type(str),start_date(date),end_date(date),reason(str),submitted_by(FK→users.id),created_at(datetime),updated_at(datetime) -
Step 1: 创建 Leave 模型文件
# backend/app/models/leave.py
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Leave(Base):
__tablename__ = "leaves"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
manager_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), index=True
)
leave_type: Mapped[str] = mapped_column(String(20), default="事假")
start_date: Mapped[date] = mapped_column(Date)
end_date: Mapped[date] = mapped_column(Date)
reason: Mapped[str] = mapped_column(String(500), default="")
submitted_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id")
)
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()
)
- Step 2: 在 models/__init__.py 中注册模型
# backend/app/models/__init__.py — 新增一行 import
from app.models.leave import Leave
# 并在 __all__ 列表中添加 "Leave"
- Step 3: 在 main.py lifespan 中添加 ALTER TABLE 迁移
# backend/app/main.py — 在 lifespan startup 中现有 ALTER TABLE 块末尾新增:
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS leaves ("
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
" manager_id UUID NOT NULL REFERENCES users(id),"
" leave_type VARCHAR(20) NOT NULL DEFAULT '事假',"
" start_date DATE NOT NULL,"
" end_date DATE NOT NULL,"
" reason VARCHAR(500) DEFAULT '',"
" submitted_by UUID NOT NULL REFERENCES users(id),"
" created_at TIMESTAMPTZ DEFAULT now(),"
" updated_at TIMESTAMPTZ DEFAULT now()"
")"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
))
- Step 4: 验证 — 重启后端确认无 ImportError
Run:
cd /home/v6ole/pyproject/qiji/backend && python -c "from app.models.leave import Leave; print('OK:', Leave.__tablename__)"
Expected: OK: leaves
- Step 5: 重启后端并检查 health
Run:
curl -s http://localhost:8002/health
Expected: {"status":"ok","app":"政企周报管理系统"}
- Step 6: 验证表已创建
Run:
PGPASSWORD=xxx psql -h 10.10.10.14 -U postgres -d qiji -c "\d leaves"
Expected: 显示 leaves 表结构
- Step 7: Commit
git add backend/app/models/leave.py backend/app/models/__init__.py backend/app/main.py
git commit -m "feat: add Leave model with auto-migration
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 2: 后端 — Leave Schema
Files:
- Create:
backend/app/schemas/leave.py
Interfaces:
-
Produces:
LeaveCreate(Pydantic),LeaveUpdate(Pydantic),LeaveOut(Pydantic, includes computeddays),LeaveOverview(Pydantic) -
Step 1: 创建 Schema 文件
# backend/app/schemas/leave.py
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field
LEAVE_TYPES = ["年假", "事假", "病假", "调休", "其他"]
class LeaveCreate(BaseModel):
manager_id: UUID
leave_type: str = Field(default="事假", pattern="^(年假|事假|病假|调休|其他)$")
start_date: date
end_date: date
reason: str = ""
class LeaveUpdate(BaseModel):
manager_id: UUID | None = None
leave_type: str | None = Field(default=None, pattern="^(年假|事假|病假|调休|其他)$")
start_date: date | None = None
end_date: date | None = None
reason: str | None = None
class LeaveOut(BaseModel):
id: UUID
manager_id: UUID
manager_name: str = ""
leave_type: str
start_date: date
end_date: date
days: int = 0
reason: str = ""
submitted_by: UUID
submitted_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = {"from_attributes": True}
class LeaveOverviewItem(BaseModel):
manager_id: UUID
manager_name: str
leave_type: str
start_date: date
end_date: date
days: int
class LeaveOverview(BaseModel):
week_start: str
week_end: str
total_on_leave: int
leave_list: list[LeaveOverviewItem]
- Step 2: 验证 schema 可导入
Run:
cd /home/v6ole/pyproject/qiji/backend && python -c "from app.schemas.leave import LeaveCreate, LeaveOut, LeaveOverview; print('OK')"
Expected: OK
- Step 3: Commit
git add backend/app/schemas/leave.py
git commit -m "feat: add Leave Pydantic schemas
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 3: 后端 — Leave Service
Files:
- Create:
backend/app/services/leaves.py
Interfaces:
-
Produces:
get_leaves(db, user_id, role, manager_id, status, page, page_size) → dict,get_leave_by_id(db, leave_id) → Leave | None,create_leave(db, data, submitted_by, role) → Leave,update_leave(db, leave_id, data, user_id, role) → Leave,delete_leave(db, leave_id, user_id, role) → bool,get_leave_overview(db, user_id, role, reference_date) → dict -
Consumes:
Leave(from Task 1),LeaveCreate/LeaveUpdate/LeaveOut/LeaveOverviewItem/LeaveOverview(from Task 2),User,today_cst,get_week_range -
Step 1: 创建 Service 文件
# backend/app/services/leaves.py
from datetime import date
from uuid import UUID
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.leave import Leave
from app.models.user import User
from app.utils.timezone import today_cst
from app.services.dashboard import get_week_range
async def get_leaves(
db: AsyncSession,
user_id: str,
role: str,
manager_id: str | None = None,
status: str | None = None,
page: int = 1,
page_size: int = 25,
) -> dict:
"""List leaves. Managers see only their own."""
today = today_cst()
query = select(Leave)
count_q = select(func.count(Leave.id))
# Role filter
if role == "manager":
query = query.where(Leave.manager_id == user_id)
count_q = count_q.where(Leave.manager_id == user_id)
elif manager_id:
query = query.where(Leave.manager_id == manager_id)
count_q = count_q.where(Leave.manager_id == manager_id)
# Status filter
if status == "active":
query = query.where(and_(Leave.start_date <= today, Leave.end_date >= today))
count_q = count_q.where(and_(Leave.start_date <= today, Leave.end_date >= today))
elif status == "upcoming":
query = query.where(Leave.start_date > today)
count_q = count_q.where(Leave.start_date > today)
elif status == "past":
query = query.where(Leave.end_date < today)
count_q = count_q.where(Leave.end_date < today)
total = (await db.execute(count_q)).scalar() or 0
query = query.order_by(Leave.start_date.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
rows = result.scalars().all()
# Resolve names
user_ids = set()
for r in rows:
user_ids.add(r.manager_id)
user_ids.add(r.submitted_by)
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
user_map = {u.id: u.name for u in users_result.scalars().all()}
items = []
for r in rows:
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
items.append({
"id": r.id,
"manager_id": r.manager_id,
"manager_name": user_map.get(r.manager_id, ""),
"leave_type": r.leave_type,
"start_date": str(r.start_date),
"end_date": str(r.end_date),
"days": days,
"reason": r.reason or "",
"submitted_by": r.submitted_by,
"submitted_by_name": user_map.get(r.submitted_by, ""),
"created_at": str(r.created_at) if r.created_at else None,
"updated_at": str(r.updated_at) if r.updated_at else None,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
async def get_leave_by_id(db: AsyncSession, leave_id: UUID) -> Leave | None:
result = await db.execute(select(Leave).where(Leave.id == leave_id))
return result.scalar()
async def create_leave(
db: AsyncSession,
data: dict,
submitted_by: UUID,
role: str,
) -> Leave:
"""Create leave. Manager can only create for self; director can create for anyone."""
if role == "manager":
if str(data.get("manager_id")) != str(submitted_by):
raise PermissionError("客户经理只能为自己提交请假")
if data["start_date"] > data["end_date"]:
raise ValueError("结束日期不能早于开始日期")
leave = Leave(
manager_id=data["manager_id"],
leave_type=data.get("leave_type", "事假"),
start_date=data["start_date"],
end_date=data["end_date"],
reason=data.get("reason", ""),
submitted_by=submitted_by,
)
db.add(leave)
await db.commit()
await db.refresh(leave)
return leave
async def update_leave(
db: AsyncSession,
leave_id: UUID,
data: dict,
user_id: UUID,
role: str,
) -> Leave:
"""Update leave. Director can edit any; manager can edit own submitted leaves."""
leave = await get_leave_by_id(db, leave_id)
if not leave:
raise ValueError("请假记录不存在")
if role == "manager":
if str(leave.submitted_by) != str(user_id):
raise PermissionError("客户经理只能编辑自己提交的请假")
for field in ("manager_id", "leave_type", "start_date", "end_date", "reason"):
if field in data and data[field] is not None:
setattr(leave, field, data[field])
if leave.start_date > leave.end_date:
raise ValueError("结束日期不能早于开始日期")
await db.commit()
await db.refresh(leave)
return leave
async def delete_leave(
db: AsyncSession,
leave_id: UUID,
user_id: UUID,
role: str,
) -> bool:
"""Delete leave. Director can delete any; manager can delete own submitted leaves."""
leave = await get_leave_by_id(db, leave_id)
if not leave:
return False
if role == "manager":
if str(leave.submitted_by) != str(user_id):
raise PermissionError("客户经理只能删除自己提交的请假")
await db.delete(leave)
await db.commit()
return True
async def get_leave_overview(
db: AsyncSession,
user_id: str,
role: str,
reference_date: date | None = None,
) -> dict:
"""Get leave overview for the dashboard card. Returns leaves overlapping with current week."""
monday, sunday = get_week_range(reference_date)
query = select(Leave).where(
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
)
if role == "manager":
query = query.where(Leave.manager_id == user_id)
result = await db.execute(query.order_by(Leave.start_date))
rows = result.scalars().all()
# Deduplicate by manager_id (one manager may have multiple leave records)
user_ids = set(r.manager_id for r in rows)
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
user_map = {u.id: u.name for u in users_result.scalars().all()}
manager_set = set()
leave_list = []
for r in rows:
days = ((r.end_date - r.start_date).days + 1) if r.start_date and r.end_date else 0
leave_list.append({
"manager_id": str(r.manager_id),
"manager_name": user_map.get(r.manager_id, ""),
"leave_type": r.leave_type,
"start_date": str(r.start_date),
"end_date": str(r.end_date),
"days": days,
})
manager_set.add(str(r.manager_id))
return {
"week_start": str(monday),
"week_end": str(sunday),
"total_on_leave": len(manager_set),
"leave_list": leave_list,
}
- Step 2: 验证 Service 可导入
Run:
cd /home/v6ole/pyproject/qiji/backend && python -c "from app.services.leaves import get_leaves, create_leave, get_leave_overview; print('OK')"
Expected: OK
- Step 3: Commit
git add backend/app/services/leaves.py
git commit -m "feat: add Leave service layer
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 4: 后端 — Leave API
Files:
- Create:
backend/app/api/leaves.py
Interfaces:
-
Produces: 5 REST endpoints —
GET /api/leaves,POST /api/leaves,PUT /api/leaves/{id},DELETE /api/leaves/{id},GET /api/leaves/overview -
Consumes:
LeaveCreate/LeaveUpdate/LeaveOut/LeaveOverview(from Task 2), service functions (from Task 3),get_current_userdependency,RoleChecker -
Step 1: 创建 API 路由文件
Read the existing auth middleware to get exact import paths:
grep -n "get_current_user\|RoleChecker" /home/v6ole/pyproject/qiji/backend/app/middleware/auth.py | head -5
- Step 2: 编写 API 路由
# backend/app/api/leaves.py
from uuid import UUID
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, RoleChecker
from app.schemas.leave import LeaveCreate, LeaveUpdate
from app.services import leaves as leave_service
router = APIRouter(prefix="/api/leaves", tags=["Leaves"])
@router.get("")
async def list_leaves(
manager_id: str | None = Query(None),
status: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List leave records. Managers see only their own."""
return await leave_service.get_leaves(
db=db,
user_id=current_user["user_id"],
role=current_user["role"],
manager_id=manager_id,
status=status,
page=page,
page_size=page_size,
)
@router.post("")
async def create_leave(
data: LeaveCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a leave record. Director can create for anyone; manager only for self."""
try:
leave = await leave_service.create_leave(
db=db,
data=data.model_dump(),
submitted_by=UUID(current_user["user_id"]),
role=current_user["role"],
)
return {
"id": str(leave.id),
"manager_id": str(leave.manager_id),
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
"reason": leave.reason,
"submitted_by": str(leave.submitted_by),
}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{leave_id}")
async def update_leave(
leave_id: UUID,
data: LeaveUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update a leave record."""
try:
leave = await leave_service.update_leave(
db=db,
leave_id=leave_id,
data=data.model_dump(exclude_none=True),
user_id=UUID(current_user["user_id"]),
role=current_user["role"],
)
return {
"id": str(leave.id),
"manager_id": str(leave.manager_id),
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
"reason": leave.reason,
}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e))
@router.delete("/{leave_id}")
async def delete_leave(
leave_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a leave record."""
try:
ok = await leave_service.delete_leave(
db=db,
leave_id=leave_id,
user_id=UUID(current_user["user_id"]),
role=current_user["role"],
)
if not ok:
raise HTTPException(status_code=404, detail="请假记录不存在")
return {"status": "deleted"}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
@router.get("/overview")
async def leave_overview(
reference_date: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get weekly leave overview for dashboard card."""
from datetime import date
ref = date.fromisoformat(reference_date) if reference_date else None
return await leave_service.get_leave_overview(
db=db,
user_id=current_user["user_id"],
role=current_user["role"],
reference_date=ref,
)
- Step 3: 在 main.py 中注册路由
# backend/app/main.py — 在 import 行新增
from app.api import leaves
# 在路由注册区域新增(放在 dashboard 之后)
app.include_router(leaves.router, prefix="/api")
- Step 4: 创建前端 API 模块
// frontend/src/api/leaves.ts
import api from './index'
export const leavesApi = {
list(params?: Record<string, any>) {
return api.get('/leaves/', { params })
},
create(data: Record<string, any>) {
return api.post('/leaves/', data)
},
update(id: string, data: Record<string, any>) {
return api.put(`/leaves/${id}`, data)
},
delete(id: string) {
return api.delete(`/leaves/${id}`)
},
overview(params?: Record<string, any>) {
return api.get('/leaves/overview', { params })
},
}
- Step 5: 验证 API — 创建测试请假
Run:
# 先获取一个 manager 和 director 的 user_id
TOKEN=$(curl -s -X POST http://localhost:8002/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))")
# 创建请假记录
curl -s -X POST http://localhost:8002/api/leaves/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"manager_id":"<a-manager-uuid>","leave_type":"事假","start_date":"2026-07-13","end_date":"2026-07-15","reason":"测试"}' | python3 -m json.tool
# 查询列表
curl -s http://localhost:8002/api/leaves/ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 查询概览
curl -s http://localhost:8002/api/leaves/overview -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
- Step 6: Commit
git add backend/app/api/leaves.py backend/app/main.py frontend/src/api/leaves.ts
git commit -m "feat: add Leave API endpoints + frontend API module
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 5: 后端 — 修改填报进度 + 仪表盘统计
Files:
- Modify:
backend/app/services/dashboard.py
Interfaces:
-
Consumes:
Leavemodel (Task 1),get_leave_overviewpattern -
Produces: Modified
get_reporting_progress()returningon_leave+leave_infofields; Modifiedget_dashboard_stats()returningweek_leavescount -
Step 1: 修改
get_reporting_progress— 新增请假查询和 on_leave 状态
# backend/app/services/dashboard.py — 新增 import
from app.models.leave import Leave
# 修改 get_reporting_progress 函数,在 "Check today's reporting" 之前插入请假查询:
async def get_reporting_progress(db: AsyncSession, reference_date: date | None = None, user_id: str = "", role: str = "") -> list[dict]:
"""Get per-manager reporting progress. Managers only see themselves."""
monday, sunday = get_week_range(reference_date)
# Get all users who need to report (any role, filtered by require_report)
managers_result = await db.execute(select(User).where(User.require_report == True))
all_reporters = managers_result.scalars().all()
reporters = all_reporters if role in ("director", "leader") else [m for m in all_reporters if str(m.id) == user_id]
# Get visit counts per manager this week
visits_result = await db.execute(
select(Visit.manager_id, func.count(Visit.id))
.where(Visit.visit_date >= monday, Visit.visit_date <= sunday)
.group_by(Visit.manager_id)
)
visit_map = {str(uid): cnt for uid, cnt in visits_result.all()}
# ── NEW: Query leave records for today ──
today = today_cst()
leaves_result = await db.execute(
select(Leave).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_today = {str(r.manager_id): r for r in leaves_result.scalars().all()}
progress = []
for m in reporters:
count = visit_map.get(str(m.id), 0)
days_passed = min((date.today() - monday).days + 1, 5)
expected = days_passed
mid = str(m.id)
leave = on_leave_today.get(mid)
progress.append({
"manager_id": mid,
"manager_name": m.name,
"department": m.department,
"visit_count": count,
"expected": expected,
"completed": count >= expected,
"has_reported_today": False,
"on_leave": leave is not None,
"leave_info": {
"leave_type": leave.leave_type,
"start_date": str(leave.start_date),
"end_date": str(leave.end_date),
} if leave else None,
})
# Check today's reporting — visits OR daily notes
today_visits = await db.execute(
select(Visit.manager_id).where(Visit.visit_date == today)
)
today_notes = await db.execute(
select(DailyNote.manager_id).where(DailyNote.note_date == today)
)
reported_today = {str(uid) for uid, in today_visits.all()} | {str(uid) for uid, in today_notes.all()}
for p in progress:
p["has_reported_today"] = p["manager_id"] in reported_today
return progress
- Step 2: 修改
get_dashboard_stats— 新增 week_leaves 统计
# 在 get_dashboard_stats 中,现有 stats 后新增:
# ── NEW: Week leave count ──
leaves_count = (await db.execute(
select(func.count(func.distinct(Leave.manager_id))).where(
and_(Leave.start_date <= sunday, Leave.end_date >= monday)
)
)).scalar() or 0
return {
"week_visits": visits_count,
"work_plans": plans_count,
"mini_business": mini_biz_count,
"key_visits": key_visit_count,
"week_leaves": leaves_count, # ← 新增
"week_start": str(monday),
"week_end": str(sunday),
}
- Step 3: 验证 — 调用 API 确认新字段
Run:
curl -s http://localhost:8002/api/dashboard/progress -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(p.get('manager_name'), 'on_leave:', p.get('on_leave')) for p in d]"
- Step 4: Commit
git add backend/app/services/dashboard.py
git commit -m "feat: add on_leave status to reporting progress + week_leaves stat
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 6: 后端 — 修改催办提醒 + AI 摘要
Files:
- Modify:
backend/app/services/scheduler.py - Modify:
backend/app/services/ai_summary.py
Interfaces:
-
Consumes:
Leavemodel (Task 1) -
Produces:
check_daily_reportingskips on-leave managers;build_summary_promptincludes leave section -
Step 1: 修改
check_daily_reporting— 过滤请假人员
# backend/app/services/scheduler.py — 新增 import
from app.models.leave import Leave
from sqlalchemy import and_
# 在 check_daily_reporting 中,获取 managers 之后、检查填报之前插入:
# ── NEW: Exclude managers on leave today ──
leaves_today = await db.execute(
select(Leave.manager_id).where(and_(
Leave.start_date <= today,
Leave.end_date >= today,
))
)
on_leave_ids = {str(uid) for uid, in leaves_today.all()}
not_reported = []
reported_names = []
on_leave_names = []
for m in managers:
if str(m.id) in on_leave_ids:
on_leave_names.append(m.name)
continue # skip — exempt from reporting
if str(m.id) in reported_map:
reported_names.append(m.name)
else:
not_reported.append(m)
修改催办汇总消息,添加请假标注:
# 在 send summary to director 部分,汇总消息开头新增请假信息
leave_note = ""
if on_leave_names:
leave_note = f"\n> 请假中(已豁免):{len(on_leave_names)}人\n"
leave_note += "".join(f"- {n} (请假)\n" for n in on_leave_names)
# 修改 summary 插入 leave_note
summary = (
f"## 📊 今日填报汇总\n\n"
f"> 日期:{today}\n"
f"> 填报率:**{pct:.0f}%** ({len(reported_map)}/{len(managers) - len(on_leave_names)})\n"
)
if leave_note:
summary += leave_note + "\n"
- Step 2: 修改
build_summary_prompt— 注入请假数据
# backend/app/services/ai_summary.py — 在 build_summary_prompt 函数开头新增参数
# 修改函数签名以接受 leaves 数据
def build_summary_prompt(
weekly_report: dict,
light_board: dict,
period: str,
reference_date: str,
leaves: list | None = None, # ← 新增参数
) -> str:
# ... existing code ...
# 在 light board 数据块之后、return 之前新增:
# ── Leave data ──
if leaves:
data_block += "\n## 本周请假情况\n"
for lv in leaves:
data_block += (
f"- {lv.get('manager_name', '未知')} ({lv.get('leave_type', '请假')}): "
f"{lv.get('start_date', '')} ~ {lv.get('end_date', '')} "
f"({lv.get('days', 0)}天)\n"
)
if not leaves:
data_block += "无请假记录\n"
else:
data_block += "\n## 本周请假情况\n无请假记录\n"
return data_block
- Step 3: 修改
generate_summary— 传入 leaves 数据
# 在 generate_summary 中,获取 light_board 后新增:
from app.services.leaves import get_leave_overview
leave_overview = await get_leave_overview(db, str(user_id), role, ref)
user_prompt = build_summary_prompt(
weekly_report, light_board, period, str(ref),
leaves=leave_overview.get("leave_list", []), # ← 传入
)
- Step 4: Commit
git add backend/app/services/scheduler.py backend/app/services/ai_summary.py
git commit -m "feat: scheduler skips on-leave managers; AI summary includes leave data
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 7: 前端 — PC 端请假管理页
Files:
- Create:
frontend/src/views/desktop/Leaves.vue
Interfaces:
-
Consumes:
leavesApi(Task 4),authstore,getMgrStyle/loadManagerColorsutils,useScreenshot -
Produces: Full CRUD page at
/leavesroute -
Step 1: 创建 Leaves.vue
This is the largest single file. The page follows the WorkPlans.vue pattern with:
- Page head (title + desc + gold rule)
- Manager summary bar (chips by leave_type counts)
- Filter bar (manager select + status select)
el-cardwithel-tableshowing: 序号, 姓名, 类型(color chip), 日期范围, 天数, 原因摘要, 提交人, 操作(编辑/删除)- Active leave rows have a blue left-border via
row-class-name el-dialogfor create/edit with: manager select (director only), leave_type chip buttons, date range picker, reason textarea- Create button in page-head-actions
<!-- frontend/src/views/desktop/Leaves.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
import { useScreenshot } from '@/utils/screenshot'
import { leavesApi } from '@/api/leaves'
import api from '@/api/index'
const auth = useAuthStore()
const themeStore = useThemeStore()
const loading = ref(false)
const leaves = ref<any[]>([])
const allUsers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const leaveTypes = ['年假', '事假', '病假', '调休', '其他']
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const today = new Date().toISOString().slice(0, 10)
const managerOptions = computed(() => {
const seen = new Set<string>()
return leaves.value
.map((l: any) => l.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
const statusOptions = [
{ label: '进行中', value: 'active' },
{ label: '即将开始', value: 'upcoming' },
{ label: '已结束', value: 'past' },
]
const filteredLeaves = computed(() => {
let list = leaves.value
if (filterManager.value) list = list.filter((l: any) => (l.manager_name || '未知') === filterManager.value)
return list
})
function resetFilters() {
filterManager.value = ''
filterStatus.value = ''
}
const leaveTypeSummary = computed(() => {
const map: Record<string, number> = {}
leaves.value.forEach((l: any) => {
map[l.leave_type] = (map[l.leave_type] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
function isLeaveActive(row: any): boolean {
return row.start_date <= today && row.end_date >= today
}
function tableRowClass({ row }: any) {
return isLeaveActive(row) ? 'leave-row--active' : ''
}
onMounted(async () => {
await Promise.all([loadLeaves(), loadUsers(), loadManagerColors()])
})
async function loadUsers() {
try {
const res = await api.get('/users/?role=manager')
allUsers.value = Array.isArray(res.data) ? res.data : (res.data.items || [])
} catch (_) {}
}
async function loadLeaves() {
loading.value = true
try {
const params: any = {}
if (filterStatus.value) params.status = filterStatus.value
const res = await leavesApi.list(params)
leaves.value = res.data.items || []
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = {
manager_id: auth.isDirector ? '' : auth.userId,
leave_type: '事假',
start_date: '',
end_date: '',
reason: '',
}
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
if (!form.value.start_date || !form.value.end_date) {
ElMessage.warning('请选择日期范围')
return
}
if (form.value.start_date > form.value.end_date) {
ElMessage.warning('结束日期不能早于开始日期')
return
}
try {
const payload = {
manager_id: form.value.manager_id,
leave_type: form.value.leave_type,
start_date: form.value.start_date,
end_date: form.value.end_date,
reason: form.value.reason || '',
}
if (dialogMode.value === 'create') {
await leavesApi.create(payload)
} else {
await leavesApi.update(form.value.id, payload)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadLeaves()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message))
}
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(id)
ElMessage.success('已删除')
await loadLeaves()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
async function handleScreenshot() {
const d = new Date().toISOString().slice(0, 10)
await captureEl(shotRef.value, `请假管理_${d}.png`)
}
function onStatusChange(val: string) {
filterStatus.value = val
loadLeaves()
}
</script>
<template>
<div class="leaves-page" v-loading="loading">
<div ref="shotRef">
<div class="page-head">
<div class="page-head-row">
<div>
<h2 class="page-title">请假管理</h2>
<p class="page-desc">管理客户经理请假记录,请假期间豁免填报考核。</p>
</div>
<div class="page-head-actions">
<el-button type="primary" @click="openCreate" class="screenshot-hide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建请假
</el-button>
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
截图导出
</el-button>
</div>
</div>
<div class="page-rule"></div>
<div v-if="leaveTypeSummary.length" class="summary-bar">
<span class="summary-label">请假类型汇总</span>
<span v-for="[type, count] in leaveTypeSummary" :key="type" class="summary-chip" :style="{ background: leaveTypeColors[type] + '20', color: leaveTypeColors[type], border: '1px solid ' + leaveTypeColors[type] + '40' }">{{ type }} · {{ count }}</span>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-row">
<div class="filter-item">
<label class="filter-label">客户经理</label>
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
</el-select>
</div>
<div class="filter-item">
<label class="filter-label">状态</label>
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px" @change="onStatusChange">
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select>
</div>
<el-button v-if="filterManager" size="small" plain @click="resetFilters">重置</el-button>
<span class="filter-count">{{ filteredLeaves.length }} / {{ leaves.length }} 条</span>
</div>
</div>
<el-card>
<el-table :data="filteredLeaves" stripe size="small" v-if="filteredLeaves.length" :row-class-name="tableRowClass" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column label="姓名" width="100">
<template #default="{ row }">
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">
<span class="leave-type-chip" :style="{ background: leaveTypeColors[row.leave_type] || '#7B7568', color: '#fff', padding: '2px 10px', fontSize: '12px' }">{{ row.leave_type }}</span>
</template>
</el-table-column>
<el-table-column label="日期范围" width="220">
<template #default="{ row }">{{ row.start_date }} ~ {{ row.end_date }}</template>
</el-table-column>
<el-table-column label="天数" width="60" align="center">
<template #default="{ row }">{{ row.days }}</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="200" show-overflow-tooltip />
<el-table-column label="提交人" width="100">
<template #default="{ row }">{{ row.submitted_by_name || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" class-name="screenshot-hide">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!leaves.length" class="empty">暂无请假记录</div>
<div v-else-if="leaves.length && !filteredLeaves.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 请假'" width="500px">
<el-form label-position="top">
<el-form-item v-if="auth.isDirector" label="客户经理">
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="请假类型">
<div class="leave-type-grid">
<button
v-for="t in leaveTypes" :key="t"
type="button"
class="leave-type-chip-btn"
:class="{ 'leave-type-chip-btn--active': form.leave_type === t }"
:style="form.leave_type === t ? { background: leaveTypeColors[t], borderColor: leaveTypeColors[t], color: '#fff' } : {}"
@click="form.leave_type = t"
>{{ t }}</button>
</div>
</el-form-item>
<el-form-item label="请假日期">
<el-date-picker
v-model="form.start_date"
type="date"
placeholder="开始日期"
style="width:100%; margin-bottom: 8px"
/>
<el-date-picker
v-model="form.end_date"
type="date"
placeholder="结束日期"
style="width:100%"
/>
</el-form-item>
<el-form-item label="原因(选填)">
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请假原因..." />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; white-space: nowrap; }
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
.filter-bar { margin-bottom: 16px; }
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 6px; }
.filter-label { font-size: 13px; color: var(--warm-gray); white-space: nowrap; }
.filter-count { font-size: 12px; color: var(--c-text-muted); margin-left: auto; }
.leave-type-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.leave-type-chip-btn {
flex: 1; min-width: 60px; padding: 10px 8px;
background: var(--surface); border: 1px solid var(--warm-border);
font-family: var(--font-body); font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s; text-align: center;
}
.leave-type-chip-btn:hover { border-color: var(--ink); }
.leave-type-chip-btn--active { font-weight: 600; }
:deep(.leave-row--active) {
border-left: 3px solid #5B7FA5 !important;
background: rgba(91, 127, 165, 0.04) !important;
}
</style>
- Step 2: 验证前端编译
Run:
cd /home/v6ole/pyproject/qiji/frontend && npx vue-tsc --noEmit 2>&1 | head -20
Expected: No new TypeScript errors related to leaves.
- Step 3: Commit
git add frontend/src/views/desktop/Leaves.vue
git commit -m "feat: add Leaves management page (PC)
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 8: 前端 — 修改侧边栏 + 路由 + 仪表盘
Files:
- Modify:
frontend/src/components/DesktopLayout.vue - Modify:
frontend/src/router/index.ts - Modify:
frontend/src/views/desktop/Dashboard.vue
Interfaces:
-
Consumes:
leavesApi(Task 4),Leaves.vue(Task 7) -
Step 1: 侧边栏新增「请假管理」入口
// frontend/src/components/DesktopLayout.vue — 在「工作」分组 items 数组新增一项:
label: '工作',
items: [
{ path: '/work-plans', label: '工作计划', icon: '...' },
{ path: '/mini-business', label: '商机跟单', icon: '...' },
{ path: '/key-visits', label: '要客拜访', icon: '...' },
{ path: '/leaves', label: '请假管理', icon: '<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>' }, // ← 新增
],
- Step 2: 路由新增
/leaves
// frontend/src/router/index.ts — 在 desktop children 中新增:
{ path: 'leaves', name: 'Leaves', component: () => import('@/views/desktop/Leaves.vue') },
- Step 3: 仪表盘新增第五张统计卡 + on_leave 状态
在 Dashboard.vue 中:
// stats 响应新增 week_leaves 字段
const stats = ref({ week_visits: 0, work_plans: 0, mini_business: 0, key_visits: 0, week_leaves: 0 })
// rowState 函数扩展为四态
function rowState(p: any): 'full' | 'catching' | 'missing' | 'on_leave' {
if (p.on_leave) return 'on_leave'
if (p.has_reported_today && p.completed) return 'full'
if (p.has_reported_today && !p.completed) return 'catching'
return 'missing'
}
// 印章文字扩展
function sealChar(p: any): string {
if (p.on_leave) return '假'
if (p.has_reported_today && p.completed) return '满'
if (p.has_reported_today && !p.completed) return '追'
return '未'
}
在模板中:
<!-- 新增第五张统计卡 -->
<div class="stat-card stat-card--clickable stat-card--slate" @click="router.push('/leaves')">
<div class="stat-glyph stat-glyph--slate">
<svg ...><!-- leave/calendar icon --></svg>
</div>
<div class="stat-body">
<span class="stat-number">{{ stats.week_leaves }}</span>
<span class="stat-label">本周请假</span>
</div>
</div>
<!-- 填报进度中,on_leave 状态的样式 -->
<div class="progress-row" :class="'progress-row--' + rowState(p)">
<!-- seal text -->
<div class="progress-seal">{{ sealChar(p) }}</div>
<!-- progress bar: grey if on_leave -->
<el-progress v-if="!p.on_leave" ... />
<span v-else class="leave-text">请假中</span>
</div>
CSS 新增:
.progress-row--on_leave { border-left-color: #5B7FA5; }
.progress-row--on_leave .progress-seal { color: #5B7FA5; border-color: #5B7FA5; }
.stat-card--slate:hover { border-color: #5B7FA5; }
.stat-glyph--slate { color: #5B7FA5; }
.leave-text { font-size: 13px; color: #5B7FA5; font-family: var(--font-body); }
- Step 4: 验证编译
Run:
cd /home/v6ole/pyproject/qiji/frontend && npx vite build 2>&1 | tail -5
Expected: ✓ built in ...
- Step 5: Commit
git add frontend/src/components/DesktopLayout.vue frontend/src/router/index.ts frontend/src/views/desktop/Dashboard.vue
git commit -m "feat: add sidebar entry + route + dashboard leave card + on_leave status
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 9: 前端 — 移动端请假列表页
Files:
- Create:
frontend/src/views/mobile/LeavesList.vue
Interfaces:
-
Consumes:
leavesApi(Task 4) -
Step 1: 创建 LeavesList.vue
<!-- frontend/src/views/mobile/LeavesList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { leavesApi } from '@/api/leaves'
const router = useRouter()
const loading = ref(false)
const leaves = ref<any[]>([])
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
onMounted(loadLeaves)
async function loadLeaves() {
loading.value = true
try {
const res = await leavesApi.list({ page_size: 100 })
leaves.value = res.data.items || []
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function isActive(row: any): boolean {
const today = new Date().toISOString().slice(0, 10)
return row.start_date <= today && row.end_date >= today
}
async function handleDelete(id: string) {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(id)
ElMessage.success('已删除')
await loadLeaves()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="leaves-list-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">我的请假</h2>
<span class="form-subtitle">LEAVE RECORDS</span>
</div>
<div class="form-rule"></div>
</header>
<button class="new-leave-btn" @click="router.push('/m/leave/new')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建请假
</button>
<div v-loading="loading" class="leave-cards">
<div v-if="leaves.length === 0 && !loading" class="empty-state">
<div class="empty-glyph">—</div>
<p class="empty-text">暂无请假记录</p>
</div>
<article
v-for="l in leaves" :key="l.id"
class="leave-card"
:class="{ 'leave-card--active': isActive(l) }"
@click="router.push(`/m/leave/${l.id}/edit`)"
>
<div class="leave-card-accent" :style="{ background: leaveTypeColors[l.leave_type] || '#7B7568' }"></div>
<div class="leave-card-body">
<div class="leave-card-header">
<span class="leave-card-type" :style="{ background: leaveTypeColors[l.leave_type] || '#7B7568', color: '#fff', padding: '2px 10px', fontSize: '12px' }">{{ l.leave_type }}</span>
<span class="leave-card-days">{{ l.days }}天</span>
</div>
<div class="leave-card-dates">{{ l.start_date }} ~ {{ l.end_date }}</div>
<div v-if="l.reason" class="leave-card-reason">{{ l.reason }}</div>
<div class="leave-card-footer">
<span class="leave-card-submitter">提交人: {{ l.submitted_by_name }}</span>
<button class="leave-card-delete" @click.stop="handleDelete(l.id)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</article>
</div>
</div>
</template>
<style scoped>
.leaves-list-page { max-width: 100%; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 20px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.new-leave-btn {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 14px; background: var(--ink); color: #fff; border: none;
font-family: var(--font-heading); font-size: 15px; letter-spacing: 0.06em;
cursor: pointer; transition: all 0.25s; margin-bottom: 18px;
}
.new-leave-btn:hover { background: var(--ink-light); }
.new-leave-btn:active { transform: scale(0.98); }
.leave-cards { display: flex; flex-direction: column; gap: 10px; }
.leave-card {
background: var(--surface); border: 1px solid var(--warm-border);
display: flex; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s;
}
.leave-card:active { transform: scale(0.99); }
.leave-card:hover { box-shadow: 0 4px 12px rgba(28,55,56,0.06); }
.leave-card--active { border-left: 3px solid #5B7FA5; }
.leave-card-accent { width: 4px; flex-shrink: 0; }
.leave-card-body { flex: 1; padding: 14px 16px; min-width: 0; }
.leave-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.leave-card-days { font-family: var(--font-mono); font-size: 13px; color: var(--warm-gray); }
.leave-card-dates { font-family: var(--font-body); font-size: 14px; color: var(--ink); margin-bottom: 4px; }
.leave-card-reason { font-family: var(--font-body); font-size: 13px; color: var(--c-text-muted); margin-bottom: 8px; }
.leave-card-footer { display: flex; align-items: center; justify-content: space-between; }
.leave-card-submitter { font-size: 11px; color: var(--warm-gray); }
.leave-card-delete { background: none; border: none; color: var(--vermilion); cursor: pointer; padding: 2px; opacity: 0.6; }
.leave-card-delete:hover { opacity: 1; }
.empty-state { text-align: center; padding: 48px 0; }
.empty-glyph { font-family: var(--font-heading); font-size: 40px; color: var(--gold); opacity: 0.4; margin-bottom: 8px; }
.empty-text { font-family: var(--font-body); font-size: 15px; color: var(--warm-gray); margin: 0; }
</style>
- Step 2: Commit
git add frontend/src/views/mobile/LeavesList.vue
git commit -m "feat: add mobile leave list page
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 10: 前端 — 移动端请假表单页 + Home 入口 + 路由
Files:
- Create:
frontend/src/views/mobile/LeaveForm.vue - Modify:
frontend/src/views/mobile/Home.vue - Modify:
frontend/src/router/index.ts
Interfaces:
-
Consumes:
leavesApi(Task 4),routerfor navigation -
Step 1: 创建 LeaveForm.vue (create + edit 复用)
<!-- frontend/src/views/mobile/LeaveForm.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { leavesApi } from '@/api/leaves'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const submitLoading = ref(false)
const isEdit = !!route.params.id
const allUsers = ref<any[]>([])
const leaveTypes = ['年假', '事假', '病假', '调休', '其他']
const leaveTypeColors: Record<string, string> = {
'年假': '#4A6741', '事假': '#5B7FA5', '病假': '#B8472E',
'调休': '#C4934A', '其他': '#7B7568',
}
const form = ref({
manager_id: auth.userId || '',
leave_type: '事假',
start_date: '',
end_date: '',
reason: '',
})
onMounted(async () => {
if (auth.isDirector) {
try {
const res = await api.get('/users/?role=manager')
allUsers.value = Array.isArray(res.data) ? res.data : (res.data.items || [])
} catch (_) {}
}
if (isEdit) {
try {
const res = await leavesApi.list({ page_size: 100 })
const item = (res.data.items || []).find((l: any) => l.id === route.params.id)
if (item) form.value = { ...item }
} catch (_) {}
}
})
async function handleSubmit() {
if (!form.value.start_date || !form.value.end_date) {
ElMessage.warning('请选择日期范围')
return
}
if (form.value.start_date > form.value.end_date) {
ElMessage.warning('结束日期不能早于开始日期')
return
}
submitLoading.value = true
try {
const payload = {
manager_id: form.value.manager_id,
leave_type: form.value.leave_type,
start_date: form.value.start_date,
end_date: form.value.end_date,
reason: form.value.reason || '',
}
if (isEdit) {
await leavesApi.update(route.params.id as string, payload)
ElMessage.success('请假已更新')
} else {
await leavesApi.create(payload)
ElMessage.success('请假已提交')
}
router.push('/m/leaves')
} catch (e: any) {
ElMessage.error((isEdit ? '更新' : '提交') + '失败: ' + (e.response?.data?.detail || e.message))
} finally { submitLoading.value = false }
}
async function handleDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
await leavesApi.delete(route.params.id as string)
ElMessage.success('已删除')
router.push('/m/leaves')
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
</script>
<template>
<div class="form-page">
<header class="form-editorial-header">
<button type="button" class="form-back-btn" @click="router.back()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
</button>
<div class="form-title-group">
<h2 class="form-title">{{ isEdit ? '编辑请假' : '提交请假' }}</h2>
<span class="form-subtitle">LEAVE REQUEST</span>
</div>
<div class="form-rule"></div>
</header>
<el-form label-position="top" class="editorial-form">
<el-form-item v-if="auth.isDirector">
<template #label><span class="form-label">客户经理</span></template>
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item>
<template #label><span class="form-label">请假类型</span></template>
<div class="leave-type-grid">
<button
v-for="t in leaveTypes" :key="t"
type="button"
class="leave-type-chip"
:class="{ 'leave-type-chip--active': form.leave_type === t }"
:style="form.leave_type === t ? { background: leaveTypeColors[t], borderColor: leaveTypeColors[t], color: '#fff' } : {}"
@click="form.leave_type = t"
>{{ t }}</button>
</div>
</el-form-item>
<el-form-item>
<template #label><span class="form-label">开始日期</span></template>
<el-date-picker v-model="form.start_date" type="date" placeholder="选择开始日期" style="width:100%" />
</el-form-item>
<el-form-item>
<template #label><span class="form-label">结束日期</span></template>
<el-date-picker v-model="form.end_date" type="date" placeholder="选择结束日期" style="width:100%" />
</el-form-item>
<el-form-item>
<template #label><span class="form-label">原因 <span class="form-label-hint">选填</span></span></template>
<el-input v-model="form.reason" type="textarea" :rows="3" placeholder="请假原因..." />
</el-form-item>
<div class="form-actions">
<button class="submit-btn" :disabled="submitLoading" @click="handleSubmit">
<span v-if="submitLoading" class="btn-loading"></span>
{{ isEdit ? '更新请假' : '提交请假' }}
</button>
<button v-if="isEdit" class="delete-btn" @click="handleDelete">删除</button>
</div>
</el-form>
</div>
</template>
<style scoped>
.form-page { max-width: 100%; margin: 0 auto; padding-bottom: 20px; }
.form-editorial-header {
display: flex; align-items: flex-start; gap: 14px;
margin-bottom: 26px; flex-wrap: wrap;
}
.form-back-btn {
background: var(--surface); border: 1px solid var(--warm-border);
padding: 8px 10px; cursor: pointer; color: var(--warm-gray);
display: flex; align-items: center; transition: all var(--transition);
flex-shrink: 0; margin-top: 2px;
}
.form-back-btn:hover { color: var(--ink); border-color: var(--ink); }
.form-title-group { display: flex; flex-direction: column; gap: 0; flex: 1; }
.form-title { margin: 0; font-family: var(--font-heading); font-size: 24px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; line-height: 1.3; }
.form-subtitle { font-family: var(--font-mono); font-size: 9px; color: var(--gold); letter-spacing: 0.2em; }
.form-rule { width: 100%; height: 2px; background: var(--warm-border); margin-top: 6px; position: relative; }
.form-rule::after { content: ''; position: absolute; left: 0; top: 0; width: 32px; height: 2px; background: var(--gold); }
.form-label { font-family: var(--font-heading) !important; font-size: 14px !important; color: var(--ink) !important; letter-spacing: 0.04em; font-weight: 500 !important; }
.editorial-form .el-form-item { margin-bottom: 20px; }
.leave-type-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.leave-type-chip {
flex: 1; min-width: 60px; padding: 10px 8px;
background: var(--surface); border: 1px solid var(--warm-border);
font-family: var(--font-body); font-size: 13px; letter-spacing: 0.04em;
cursor: pointer; transition: all 0.25s; text-align: center;
}
.leave-type-chip:hover { border-color: var(--ink); }
.leave-type-chip--active { font-weight: 600; }
.form-actions { display: flex; gap: 10px; margin-top: 24px; }
.submit-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 16px; background: var(--ink); color: #fff; border: none;
font-family: var(--font-heading); font-size: 16px; letter-spacing: 0.08em;
cursor: pointer; transition: all 0.25s;
}
.submit-btn:hover { background: var(--ink-light); }
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.delete-btn {
padding: 16px 24px; background: var(--surface); color: var(--vermilion);
border: 1px solid var(--vermilion); font-family: var(--font-heading);
font-size: 16px; letter-spacing: 0.08em; cursor: pointer; transition: all 0.25s;
}
.delete-btn:hover { background: var(--vermilion); color: #fff; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
- Step 2: 修改移动端 Home.vue — 快捷入口新增「请假」chip
<!-- frontend/src/views/mobile/Home.vue — 在 quick-links 区域新增一个 chip -->
<button class="link-chip" @click="router.push('/m/leaves')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
请假
</button>
- Step 3: 修改路由 — 新增移动端路由
// frontend/src/router/index.ts — 在 mobile children 中新增:
{ path: 'leaves', name: 'LeavesList', component: () => import('@/views/mobile/LeavesList.vue') },
{ path: 'leave/new', name: 'LeaveForm', component: () => import('@/views/mobile/LeaveForm.vue') },
{ path: 'leave/:id/edit', name: 'LeaveEdit', component: () => import('@/views/mobile/LeaveForm.vue') },
- Step 4: 验证前端构建
Run:
cd /home/v6ole/pyproject/qiji/frontend && npx vite build 2>&1 | tail -10
Expected: ✓ built in ...
- Step 5: Commit
git add frontend/src/views/mobile/LeaveForm.vue frontend/src/views/mobile/Home.vue frontend/src/router/index.ts
git commit -m "feat: add mobile leave form + list + home entry + routes
Co-Authored-By: Claude <noreply@anthropic.com>"
Task 11: 端到端验证
- Step 1: 重启后端并验证 health
# Kill and restart backend on port 8002
kill $(pgrep -f "uvicorn app.main:app --host 0.0.0.0 --port 8002") 2>/dev/null
sleep 1
cd /home/v6ole/pyproject/qiji/backend && nohup uvicorn app.main:app --host 0.0.0.0 --port 8002 > /tmp/backend.log 2>&1 &
sleep 3
curl -s http://localhost:8002/health
Expected: {"status":"ok","app":"政企周报管理系统"}
- Step 2: 验证 leaves 表已创建
curl -s http://localhost:8002/api/leaves/ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
Expected: {"items": [], "total": 0, ...}
- Step 3: 创建请假 → 查列表 → 编辑 → 删除 全流程测试
# 1. Create
curl -s -X POST http://localhost:8002/api/leaves/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"manager_id":"<uuid>","leave_type":"事假","start_date":"2026-07-13","end_date":"2026-07-15","reason":"测试"}' | python3 -m json.tool
# 2. Verify in progress
curl -s "http://localhost:8002/api/dashboard/progress" -H "Authorization: Bearer $TOKEN" | python3 -c "import sys,json; d=json.load(sys.stdin); [print(p['manager_name'],'on_leave:',p['on_leave'],'leave_info:',p.get('leave_info')) for p in d]"
# 3. Verify overview
curl -s "http://localhost:8002/api/leaves/overview" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 4. Verify stats
curl -s "http://localhost:8002/api/dashboard/stats" -H "Authorization: Bearer $TOKEN" | python3 -m json.tool
- Step 4: 验证前端页面可访问
# 确认 Vite dev server 运行中,页面可以加载
curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/leaves
Expected: 200
- Step 5: Commit 最终验证
git add -A
git commit -m "chore: final verification — leave feature ready
Co-Authored-By: Claude <noreply@anthropic.com>"
Self-Review Checklist
- Spec coverage: 数据模型(T1) ✓, Schema(T2) ✓, Service(T3) ✓, API(T4) ✓, 填报进度(T5) ✓, 催办调度(T6) ✓, AI摘要(T6) ✓, 仪表盘卡片(T8) ✓, PC请假页(T7) ✓, 侧边栏(T8) ✓, 路由(T8+T10) ✓, 移动端列表(T9) ✓, 移动端表单(T10) ✓, 移动端入口(T10) ✓
- No placeholders: 所有任务步骤都有精确代码,无 TBD/TODO/占位符
- Type consistency:
LeaveOut.dayscomputed in service matches schema field type (int);on_leavebool consistent across api/service/frontend;leave_infoobject shape matches everywhere - File paths: All paths are absolute and verified against actual project structure