develop-chezhiqi
mula.liu 2026-09-23 10:49:52 +08:00
parent 07e1b0ae7b
commit bcb3feb3bb
42 changed files with 1967 additions and 495 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -70,15 +70,16 @@ cd /Users/jiliu/WorkSpace/unis_manager
| 报 `permission denied` | `chmod +x run.sh` 后重试(**不要用 sudo**) |
| 不想改权限 | `bash run.sh` |
| 换端口 | `PORT=8771 ./run.sh` |
| 开发模式热加载 | `DEV=1 ./run.sh` |
| 关闭热加载 | `DEV=0 ./run.sh`(默认是开着的) |
| 不自动开浏览器 | `NO_OPEN=1 ./run.sh` |
| 提示"端口已被占用" | 原窗口 `Ctrl+C`,或 `lsof -ti:8770 \| xargs kill` 后重跑 |
> ⚠️ 不要用 `sudo` 启动:数据库文件会变成 root 属主,后续写入会失败。
> **DEV=1 热加载**:uvicorn 除 `*.py` 外还会监听 `web/**` 的 js/css/html 与 `.env`,改动即重启;
> **热更新(默认开启)**:uvicorn 除 `*.py` 外还会监听 `web/**` 的 js/css/html 与 `.env`,改动即重启;
> 页面里的 `web/livereload.js` 每秒读一次 `/health` 的启动代际,发现服务重启后自动刷新浏览器。
> 非 DEV 启动时该脚本首轮即自行停止,`data/*.db` 的写入也不会触发重启。
> 也就是说改完代码**不用手动重启、也不用手动刷新**。`DEV=0 ./run.sh` 可关掉(关闭后该脚本首轮即自行停止)。
> `data/*.db` 的写入不会触发重启。
### 手动分步执行(等价于 run.sh 干的事)
@ -103,13 +104,13 @@ python -m uvicorn app.main:app --port 8770
|---|---|
| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定开合同额(含项目数与本期计收)/ 风险阻塞 |
| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定开项目**——**只给一个入口**「本周定开项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 |
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定开项目入口 → 本周动态总览**(顶部四枚标签:新入库 / 有交付动态 / 所列合同额 / 本期计收,下面分「本周新增」「本周有变化」两段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定开项目入口 → 本周动态总览**(顶部标签:本周新增 / 本周验收 / 有交付动态 / 合同额合计 / 本期计收,下面分「本周新增」「本周验收」「本周有变化」三段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定开动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定开总览的头部 |
| 统计概览 | 页面最底部的折叠区(原「执行面板」,已不占主导航位):周期活跃度柱状图、分类环形图、状态分布、进度 Top10、低进度预警、风险清单,数据来自 `GET /api/stats`;加载失败也不影响看板主体 |
数据分两路:`GET /api/weekly?period_id=|year&month&owner=&all_custom=` 一次返回重点项目清单 + 定开项目清单 + 顶部 KPI(含 `custom_new` 本期新入库数、`custom_updated` 本期有交付记录数);**「本周工作重点」不在 `/api/weekly` 里**,前端并发取 `GET /api/focus` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。
定开项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**或**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)的定开项目;右栏总览默认只列「本周新增」与「本周有变化」两段,其余只在组头计数里出现,并在底部提示「另有 N 个在途定开项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部那枚「合同额合计」只加总**当前列出来的行**,勾与不勾数字会跟着清单变。
定开项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**、**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)、**本周期新下单**(`sign_date` 落在本周期)或**本周期已验收**(`update_date` 落在本周期且项目状态为「已验收」)的定开项目;右栏总览默认列「本周新增」「本周验收」「本周有变化」三段,其中**前两段与「定开项目 → 本周进展」的「新增项目 / 验收情况」口径完全一致**(前者按「下单时间」、后者按「更新时间 + 状态=已验收」),其余只在组头计数里出现,并在底部提示「另有 N 个在途定开项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部几枚标签与「合同额合计」只加总**当前列出来的行**(同一项目同时落在两段时只算一次),勾与不勾数字会跟着清单变。
### 2. 项目管理

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -40,6 +40,12 @@ AI_TIMEOUT = float(os.getenv("AI_TIMEOUT", "90"))
PORT = int(os.getenv("PORT", "8770"))
# 首次启动自动创建的管理员账号(务必上线后改掉密码)
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin").strip() or "admin"
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123").strip() or "admin123"
# 登录态有效天数
SESSION_DAYS = int(os.getenv("SESSION_DAYS", "30"))
# DEV=1 启动时开启开发模式(代码热加载 + 浏览器自动刷新)
DEV = os.getenv("DEV", "").strip().lower() in ("1", "true", "yes", "on")

View File

@ -355,6 +355,9 @@ CUSTOM_PIPELINE = [
("revenue", "计收", "revenue_date", "revenue", "#16a34a"),
]
# 年度计收目标(万元)默认值,来源《年度任务.xlsx》;页面「年度任务」里可改,改后落库覆盖
CUSTOM_ANNUAL_TARGETS_DEFAULT = {1: 35.0, 2: 225.0, 3: 160.0, 4: 260.0}
# 「在途」阶段:还没走完 入库→签单→交付→验收 这条主线(已计收 / 暂停 / 已关闭不算在途)
CUSTOM_INFLIGHT_STAGES = {"lead", "signed", "developing", "delivered", "accepted"}

View File

@ -2,14 +2,14 @@ from __future__ import annotations
import uuid
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import config
from .db import Base, engine
from .routers import ai, board, custom, export, focus, projects, staff, weekly
from . import config, security
from .db import Base, SessionLocal, engine
from .routers import ai, auth, board, custom, export, focus, projects, rbac, staff, weekly
from .schema_patch import ensure_columns
Base.metadata.create_all(bind=engine)
@ -17,6 +17,11 @@ _patch = ensure_columns(engine, Base.metadata)
if _patch:
print(f"[ok] 已补齐数据库字段:{', '.join(_patch)}")
# 内置角色 + 管理员账号(只在缺失时补,不会覆盖已改过的密码/权限)
with SessionLocal() as _db:
for _note in security.ensure_seed(_db):
print(f"[ok] {_note}")
# 每次进程启动换一个值;热加载重启后前端据此自动刷新
BOOT_ID = uuid.uuid4().hex
@ -29,7 +34,8 @@ app.add_middleware(
allow_headers=["*"],
)
api = FastAPI(title="部门关键任务追踪看板 API")
# 所有 /api 接口先过 RBAC 总闸(登录 + 菜单权限),登录接口自身除外
api = FastAPI(title="部门关键任务追踪看板 API", dependencies=[Depends(security.guard)])
api.include_router(board.router, prefix="", tags=["board"])
api.include_router(projects.router, prefix="", tags=["projects"])
api.include_router(ai.router, prefix="", tags=["ai"])
@ -38,6 +44,8 @@ api.include_router(export.router, prefix="", tags=["export"])
api.include_router(staff.router, prefix="", tags=["staff"])
api.include_router(weekly.router, prefix="", tags=["weekly"])
api.include_router(focus.router, prefix="", tags=["focus"])
api.include_router(auth.router, prefix="", tags=["auth"])
api.include_router(rbac.router, prefix="", tags=["rbac"])
app.mount("/api", api)
WEB_DIR = config.BASE_DIR / "web"

View File

@ -2,9 +2,11 @@
层级:Period(周期) -> Project(主要项目/工作项) -> Task(子任务) -> Update(执行情况)
辅助:Category(分类) / Tag(标记) / LedgerItem(客户项目台账) / Setting(配置)
权限:Role(角色,一份菜单权限) <- User(用户) ; UserSession(登录会话)
"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import (
@ -615,3 +617,84 @@ class ImportLog(Base):
"raw_input": (self.raw_input or "")[:400],
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
}
# ---------------------------------------------------------------------- 权限
class Role(Base, TimestampMixin):
"""角色 = 一组功能菜单权限。
menus 存 JSON 文本:{"board":"rw","projects":"ro"},
键是菜单标识(见 security.MENUS),值 rw=可编辑 / ro=只读;没有这个键就是看不到该菜单。
"""
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
menus: Mapped[str | None] = mapped_column(Text, nullable=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
def menu_map(self) -> dict:
if not self.menus:
return {}
try:
data = json.loads(self.menus)
except ValueError:
return {}
return data if isinstance(data, dict) else {}
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"description": self.description,
"menus": self.menu_map(),
"is_builtin": self.is_builtin,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class User(Base, TimestampMixin):
"""登录用户。密码只存 pbkdf2 派生值 + 盐,不存明文。"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(128), nullable=False)
password_salt: Mapped[str] = mapped_column(String(64), nullable=False)
role_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("roles.id", ondelete="SET NULL"), nullable=True)
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
role: Mapped[Role | None] = relationship("Role", lazy="joined")
@property
def menus(self) -> dict:
return self.role.menu_map() if self.role else {}
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"username": self.username,
"role_id": self.role_id,
"role_name": self.role.name if self.role else None,
"menus": self.menus,
"active": self.active,
"last_login_at": self.last_login_at.isoformat(timespec="seconds") if self.last_login_at else None,
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
class UserSession(Base):
"""登录会话。退出登录 / 禁用用户时删掉对应 token 即可立即失效。"""
__tablename__ = "user_sessions"
token: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,72 @@
"""登录 / 退出 / 当前用户 / 用户自己的设置(昵称、密码)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from .. import config, security
from ..db import get_db
from ..models import User
from ..schemas import LoginIn, PasswordIn, ProfileIn
router = APIRouter()
@router.post("/auth/login")
def login(body: LoginIn, response: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == (body.username or "").strip()).one_or_none()
if user is None or not security.verify_password(body.password or "", user.password_hash, user.password_salt):
raise HTTPException(status_code=401, detail="用户名或密码不正确")
if not user.active:
raise HTTPException(status_code=403, detail="该账号已被停用,请联系管理员")
token = security.start_session(db, user)
response.set_cookie(
security.SESSION_COOKIE, token,
max_age=config.SESSION_DAYS * 86400,
httponly=True, samesite="lax", path="/",
)
return user.to_dict()
@router.post("/auth/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)):
token = request.cookies.get(security.SESSION_COOKIE)
if token:
db.query(security.UserSession).filter(security.UserSession.token == token).delete()
db.commit()
response.delete_cookie(security.SESSION_COOKIE, path="/")
return {"ok": True}
@router.get("/auth/me")
def me(user: User = Depends(security.current_user)):
return user.to_dict()
@router.put("/auth/me")
def update_me(body: ProfileIn, user: User = Depends(security.current_user), db: Session = Depends(get_db)):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="昵称不能为空")
user.name = name[:64]
db.commit()
return user.to_dict()
@router.post("/auth/password")
def change_password(body: PasswordIn, request: Request, response: Response,
user: User = Depends(security.current_user), db: Session = Depends(get_db)):
if not security.verify_password(body.old_password or "", user.password_hash, user.password_salt):
raise HTTPException(status_code=400, detail="原密码不正确")
new = (body.new_password or "").strip()
if len(new) < 6:
raise HTTPException(status_code=400, detail="新密码至少 6 位")
user.password_hash, user.password_salt = security.hash_password(new)
# 改密后其余设备全部下线,只保留当前这次登录
keep = request.cookies.get(security.SESSION_COOKIE)
query = db.query(security.UserSession).filter(security.UserSession.user_id == user.id)
if keep:
query = query.filter(security.UserSession.token != keep)
query.delete(synchronize_session=False)
db.commit()
return {"ok": True}

View File

@ -80,6 +80,9 @@ def list_periods(db: Session = Depends(get_db)):
d = p.to_dict()
d["update_count"] = uc.get(p.id, 0)
d["custom_update_count"] = cc.get(p.id, 0)
# 该周的实际起止日期(口径见 constants.month_week1),供前端按「顶栏选中的周期」取数
d["first_day"] = constants.period_first_day(p.year, p.month, p.week)
d["last_day"] = constants.period_last_day(p.year, p.month, p.week)
week_list.append(d)
key = (p.year, p.month)
@ -96,6 +99,11 @@ def list_periods(db: Session = Depends(get_db)):
)
item["period_ids"].append(p.id)
item["week_count"] += 1
# 月视图的日期范围 = 该月各周的并集,与 /weekly 的月口径一致
if "first_day" not in item or d["first_day"] < item["first_day"]:
item["first_day"] = d["first_day"]
if "last_day" not in item or d["last_day"] > item["last_day"]:
item["last_day"] = d["last_day"]
y, m, w = constants.week_of_date(date.today())
today_key = y * 10000 + m * 100 + w
return {

View File

@ -15,6 +15,7 @@ from sqlalchemy.orm import Session
from .. import ai_parser
from ..constants import (
CUSTOM_ANNUAL_TARGETS_DEFAULT,
CUSTOM_PIPELINE,
CUSTOM_STAGES,
CUSTOM_STAGE_MAP,
@ -24,7 +25,7 @@ from ..constants import (
normalize_region,
)
from ..db import get_db
from ..models import CustomProject, CustomUpdate, ImportLog, Period, set_people
from ..models import CustomProject, CustomUpdate, ImportLog, Period, Setting, set_people
from .board import resolve_period_ids
router = APIRouter()
@ -138,6 +139,68 @@ def _unique_name(db: Session, name: str, exclude_id: int | None = None):
# ---------------------------------------------------------------- 元信息
# ---------------------------------------------------------------- 年度计收目标
# 目标值按「年 + 季度」存在 settings 里(一页一套),没配过就用《年度任务.xlsx》的默认值。
ANNUAL_TARGETS_PREFIX = "custom_annual_targets"
def _annual_targets_key(year: int) -> str:
return f"{ANNUAL_TARGETS_PREFIX}:{year}"
def read_annual_targets(db: Session, year: int) -> dict[str, float]:
row = db.get(Setting, _annual_targets_key(year))
targets = dict(CUSTOM_ANNUAL_TARGETS_DEFAULT)
if row and row.value:
try:
saved = json.loads(row.value)
except (TypeError, ValueError):
saved = {}
for q in (1, 2, 3, 4):
v = saved.get(str(q))
if v is not None and v != "":
try:
targets[q] = float(v)
except (TypeError, ValueError):
pass
return {str(q): targets[q] for q in (1, 2, 3, 4)}
class AnnualTargetsIn(BaseModel):
year: int
targets: dict[str, float | None]
@router.get("/custom/annual-targets")
def get_annual_targets(year: int | None = None, db: Session = Depends(get_db)):
y = year or date.today().year
return {"year": y, "targets": read_annual_targets(db, y)}
@router.put("/custom/annual-targets")
def put_annual_targets(payload: AnnualTargetsIn, db: Session = Depends(get_db)):
y = payload.year
targets = dict(CUSTOM_ANNUAL_TARGETS_DEFAULT)
for q in (1, 2, 3, 4):
v = payload.targets.get(str(q))
if v is None or v == "":
targets[q] = 0.0
continue
try:
targets[q] = float(v)
except (TypeError, ValueError):
raise HTTPException(400, f"Q{q} 目标值不是数字")
value = json.dumps({str(q): targets[q] for q in (1, 2, 3, 4)}, ensure_ascii=False)
key = _annual_targets_key(y)
row = db.get(Setting, key)
if row:
row.value = value
else:
db.add(Setting(key=key, value=value))
db.commit()
return {"year": y, "targets": {str(q): targets[q] for q in (1, 2, 3, 4)}}
@router.get("/custom/meta")
def custom_meta(db: Session = Depends(get_db)):
from ..constants import ALL_REGIONS, REGION_GROUPS
@ -166,6 +229,8 @@ def custom_meta(db: Session = Depends(get_db)):
for k, n, f, st, col in CUSTOM_PIPELINE
],
"aging_days": CUSTOM_STEP_AGING_DAYS,
# 年度计收目标(万元):默认取《年度任务.xlsx》,页面改过则以库里的值为准
"annual_targets": {"year": date.today().year, "targets": read_annual_targets(db, date.today().year)},
}
@ -573,11 +638,49 @@ def create_custom_update(payload: dict, db: Session = Depends(get_db)):
return u.to_dict()
@router.patch("/custom/updates/{uid}")
def patch_custom_update(uid: int, payload: dict, db: Session = Depends(get_db)):
"""修改单条定开周期记录。
金额/计收是「本期新增量」,改了要按差额回补项目累计值;进度与阶段直接覆盖项目档案。
"""
u = db.get(CustomUpdate, uid)
if not u:
raise HTTPException(404, "记录不存在")
cp = db.get(CustomProject, u.custom_project_id)
prev_revenue = u.revenue_delta or 0.0
for k in ("content", "summary", "risk", "next_step", "owner", "stage"):
if k in payload:
setattr(u, k, payload[k] or None)
for k in ("progress", "amount_delta", "revenue_delta"):
if k in payload:
v = payload[k]
setattr(u, k, None if v in (None, "") else float(v))
if "period_id" in payload:
if not payload["period_id"]:
raise HTTPException(400, "周期不能为空")
u.period_id = int(payload["period_id"])
if payload.get("progress") is not None and cp:
cp.progress = float(payload["progress"])
if payload.get("stage") and cp:
cp.stage = payload["stage"]
# 「本期新增计收」是增量:改了就按差额回补项目累计计收,保持台账对得上
if cp and "revenue_delta" in payload:
cp.revenue_amount = max(0.0, (cp.revenue_amount or 0.0) - prev_revenue + (u.revenue_delta or 0.0))
db.commit()
db.refresh(u)
return u.to_dict()
@router.delete("/custom/updates/{uid}")
def delete_custom_update(uid: int, db: Session = Depends(get_db)):
u = db.get(CustomUpdate, uid)
if not u:
raise HTTPException(404, "记录不存在")
# 「本期新增计收」是加进项目累计值的增量,删记录要一并扣回,否则台账对不上
cp = db.get(CustomProject, u.custom_project_id)
if cp and u.revenue_delta:
cp.revenue_amount = max(0.0, (cp.revenue_amount or 0.0) - float(u.revenue_delta))
db.delete(u)
db.commit()
return {"ok": True}

View File

@ -24,6 +24,7 @@ from ..schemas import (
ProjectPatch,
TagIn,
UpdateIn,
UpdatePatch,
)
from .board import resolve_period_ids, worst_status
@ -354,14 +355,34 @@ def create_update(payload: UpdateIn, db: Session = Depends(get_db)):
@router.patch("/updates/{uid}")
def patch_update(uid: int, payload: dict, db: Session = Depends(get_db)):
def patch_update(uid: int, payload: UpdatePatch, db: Session = Depends(get_db)):
"""修改单条执行记录;改了进度就同步项目档案,与新建时的口径一致。"""
u = db.get(Update, uid)
if not u:
raise HTTPException(404, "记录不存在")
for k in ("content", "summary", "progress", "status", "risk", "next_step", "owner", "task_id"):
if k in payload:
setattr(u, k, payload[k])
data = payload.model_dump(exclude_unset=True)
task_name = (data.pop("task_name", None) or "").strip()
if task_name:
t = (
db.query(Task)
.filter(Task.project_id == u.project_id, Task.name == task_name)
.one_or_none()
)
if not t:
t = Task(project_id=u.project_id, name=task_name)
db.add(t)
db.flush()
data["task_id"] = t.id
if "period_id" in data and not data["period_id"]:
raise HTTPException(400, "周期不能为空")
for k, v in data.items():
setattr(u, k, v)
if data.get("progress") is not None:
p = db.get(Project, u.project_id)
if p:
p.progress = float(data["progress"])
db.commit()
db.refresh(u)
return u.to_dict()

161
app/routers/rbac.py 100644
View File

@ -0,0 +1,161 @@
"""权限管理:角色(菜单权限矩阵)与用户(分配角色 / 启停 / 重置密码)。
只有拿到「权限管理」菜单授权的用户能进(写操作还要 rw),由 security.guard 拦。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import security
from ..db import get_db
from ..models import Role, User
from ..schemas import RoleIn, RolePatch, UserIn, UserPatch
router = APIRouter()
@router.get("/rbac/meta")
def meta():
return {
"menus": [{"key": k, "label": v} for k, v in security.MENUS],
"levels": [{"value": "rw", "label": "可编辑"}, {"value": "ro", "label": "只读"},
{"value": "", "label": "无权限"}],
}
# ---------------------------------------------------------------------- 角色
@router.get("/rbac/roles")
def list_roles(db: Session = Depends(get_db)):
roles = db.query(Role).order_by(Role.id).all()
out = []
for r in roles:
d = r.to_dict()
d["user_count"] = db.query(User).filter(User.role_id == r.id).count()
out.append(d)
return out
@router.post("/rbac/roles")
def create_role(body: RoleIn, db: Session = Depends(get_db)):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="角色名不能为空")
if db.query(Role).filter(Role.name == name).count():
raise HTTPException(status_code=400, detail=f"角色「{name}」已存在")
role = Role(name=name[:64], description=(body.description or "").strip() or None,
menus=security.menus_json(body.menus))
db.add(role)
db.commit()
return role.to_dict()
@router.patch("/rbac/roles/{rid}")
def update_role(rid: int, body: RolePatch, db: Session = Depends(get_db)):
role = db.get(Role, rid)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="角色名不能为空")
dup = db.query(Role).filter(Role.name == name, Role.id != rid).count()
if dup:
raise HTTPException(status_code=400, detail=f"角色「{name}」已存在")
role.name = name[:64]
if body.description is not None:
role.description = body.description.strip() or None
if body.menus is not None:
role.menus = security.menus_json(body.menus)
db.commit()
return role.to_dict()
@router.delete("/rbac/roles/{rid}")
def delete_role(rid: int, me: User = Depends(security.current_user), db: Session = Depends(get_db)):
role = db.get(Role, rid)
if role is None:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_builtin:
raise HTTPException(status_code=400, detail="内置角色不能删除,可以改它的菜单权限")
if me.role_id == rid:
raise HTTPException(status_code=400, detail="不能删除自己正在使用的角色")
if db.query(User).filter(User.role_id == rid).count():
raise HTTPException(status_code=400, detail="该角色下还有用户,请先给他们换角色")
db.delete(role)
db.commit()
return {"ok": True}
# ---------------------------------------------------------------------- 用户
@router.get("/rbac/users")
def list_users(db: Session = Depends(get_db)):
return [u.to_dict() for u in db.query(User).order_by(User.id).all()]
@router.post("/rbac/users")
def create_user(body: UserIn, db: Session = Depends(get_db)):
name = (body.name or "").strip()
username = (body.username or "").strip()
password = (body.password or "").strip()
if not name or not username:
raise HTTPException(status_code=400, detail="姓名和登录账号都要填")
if len(password) < 6:
raise HTTPException(status_code=400, detail="初始密码至少 6 位")
if db.query(User).filter(User.username == username).count():
raise HTTPException(status_code=400, detail=f"登录账号「{username}」已存在")
pw_hash, salt = security.hash_password(password)
user = User(name=name[:64], username=username[:64], password_hash=pw_hash, password_salt=salt,
role_id=body.role_id, active=bool(body.active))
db.add(user)
db.commit()
return user.to_dict()
@router.patch("/rbac/users/{uid}")
def update_user(uid: int, body: UserPatch, me: User = Depends(security.current_user),
db: Session = Depends(get_db)):
user = db.get(User, uid)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="姓名不能为空")
user.name = name[:64]
if body.role_id is not None and body.role_id != user.role_id:
if db.get(Role, body.role_id) is None:
raise HTTPException(status_code=400, detail="角色不存在")
if user.id == me.id:
raise HTTPException(status_code=400, detail="不能修改自己的角色")
user.role_id = body.role_id
security.revoke_all(db, user.id) # 权限变了,强制重新登录
if body.password is not None and body.password.strip():
if len(body.password.strip()) < 6:
raise HTTPException(status_code=400, detail="新密码至少 6 位")
user.password_hash, user.password_salt = security.hash_password(body.password.strip())
security.revoke_all(db, user.id)
if body.active is not None:
if user.id == me.id and not body.active:
raise HTTPException(status_code=400, detail="不能停用自己的账号")
user.active = bool(body.active)
if not user.active:
security.revoke_all(db, user.id)
db.commit()
return user.to_dict()
@router.delete("/rbac/users/{uid}")
def delete_user(uid: int, me: User = Depends(security.current_user), db: Session = Depends(get_db)):
user = db.get(User, uid)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == me.id:
raise HTTPException(status_code=400, detail="不能删除自己,可以换一个角色或让别人删")
remaining = db.query(User).filter(User.active.is_(True), User.id != uid).count()
if not remaining:
raise HTTPException(status_code=400, detail="至少要保留一个可用账号")
security.revoke_all(db, user.id)
db.delete(user)
db.commit()
return {"ok": True}

View File

@ -125,7 +125,19 @@ def weekly(
continue
entry = (cp.entry_date or "").strip()
is_new = bool(scope_start and scope_end and entry and scope_start <= entry <= scope_end)
if not (cp.is_key or items or is_new):
# 「本周新增 / 本周验收」与「定开项目 · 本周进展」同一口径(见 web/app.js 的
# customWeeklySections):新增按「下单时间」,验收按「更新时间 + 状态=已验收」。
sign = (cp.sign_date or "").strip()[:10]
upd = (cp.update_date or "").strip()[:10]
is_signed_new = bool(scope_start and scope_end and sign and scope_start <= sign <= scope_end)
is_accepted = bool(
scope_start
and scope_end
and upd
and scope_start <= upd <= scope_end
and cp.project_status == "已验收"
)
if not (cp.is_key or items or is_new or is_signed_new or is_accepted):
if not all_custom or cp.stage not in inflight_stages:
continue
d = cp.to_dict()

View File

@ -78,6 +78,21 @@ class UpdateIn(BaseModel):
task_name: str | None = None
class UpdatePatch(BaseModel):
"""修改单条执行记录:只提交需要改的字段(未提交的字段保持原值)。"""
task_id: int | None = None
task_name: str | None = None
period_id: int | None = None
content: str | None = None
summary: str | None = None
progress: float | None = None
status: str | None = None
risk: str | None = None
next_step: str | None = None
owner: str | None = None
class AIParseIn(BaseModel):
project_id: int | None = None
project_name: str | None = None
@ -125,3 +140,45 @@ class FocusPatch(BaseModel):
period_id: int | None = None
status: str | None = None
archived: bool | None = None
# ---------------------------------------------------------------------- 权限
class LoginIn(BaseModel):
username: str
password: str
class ProfileIn(BaseModel):
name: str
class PasswordIn(BaseModel):
old_password: str
new_password: str
class RoleIn(BaseModel):
name: str
description: str | None = None
menus: dict[str, str] = Field(default_factory=dict)
class RolePatch(BaseModel):
name: str | None = None
description: str | None = None
menus: dict[str, str] | None = None
class UserIn(BaseModel):
name: str
username: str
password: str
role_id: int | None = None
active: bool = True
class UserPatch(BaseModel):
name: str | None = None
role_id: int | None = None
active: bool | None = None
password: str | None = None # 管理员重置密码,不需要旧密码

210
app/security.py 100644
View File

@ -0,0 +1,210 @@
"""登录与权限(RBAC)。
角色 = 一组功能菜单权限(rw 可编辑 / ro 只读 / 没配就是看不到),用户挂一个角色。
菜单权限同时管两端:
· 前端按 /auth/me 返回的 menus 过滤侧边栏菜单、隐藏写操作按钮;
· 后端用 guard 依赖按「请求路径 -> 菜单」做二次校验,直接调接口也绕不过去。
会话是服务端 token(HttpOnly Cookie),退出登录 / 禁用用户时删 token 立即失效。
密码只存 pbkdf2 派生值,用标准库,不引额外依赖。
"""
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from . import config
from .db import get_db
from .models import Role, User, UserSession, now
SESSION_COOKIE = "unis_session"
PBKDF2_ROUNDS = 120_000
# 功能菜单:键是前后端共用的权限标识,顺序即权限管理页里的展示顺序
MENUS = [
("board", "工作看板"),
("projects", "自研产品"),
("custom", "定制项目"),
("focus", "每周工作"),
("staff", "人力资源"),
("intake", "更新录入"),
("settings", "设置"),
("rbac", "权限管理"),
]
MENU_KEYS = [k for k, _ in MENUS]
MENU_LABELS = dict(MENUS)
LEVELS = ("ro", "rw")
# 接口路径首段 -> 菜单。没列出来的(/meta 等)只要求登录,不看菜单。
SEGMENT_MENU = {
"focus": "focus",
"weekly": "board",
"board": "board",
"stats": "board",
"projects": "projects",
"categories": "projects",
"tags": "projects",
"updates": "projects",
"tasks": "projects",
"custom": "custom",
"staff": "staff",
"ai": "intake",
"settings": "settings",
"rbac": "rbac",
"periods": "settings",
}
# /export/xxx 跟着对应页面走
EXPORT_MENU = {
"custom": "custom",
"custom-updates": "custom",
"projects": "projects",
"updates": "projects",
"staff": "staff",
"focus": "focus",
}
def normalize_menus(raw) -> dict:
"""清洗前端传来的权限表:只留已知菜单 + 合法级别。"""
out: dict[str, str] = {}
for key, value in (raw or {}).items():
if key in MENU_KEYS and value in LEVELS:
out[key] = value
return out
def menus_json(menus: dict) -> str:
return json.dumps(normalize_menus(menus), ensure_ascii=False, sort_keys=True)
# ------------------------------------------------------------------ 密码
def hash_password(password: str) -> tuple[str, str]:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ROUNDS)
return digest.hex(), salt
def verify_password(password: str, password_hash: str, salt: str) -> bool:
try:
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ROUNDS)
except ValueError:
return False
return hmac.compare_digest(digest.hex(), password_hash)
# ------------------------------------------------------------------ 会话
def start_session(db: Session, user: User) -> str:
token = secrets.token_urlsafe(32)
db.add(UserSession(token=token, user_id=user.id,
expires_at=now() + timedelta(days=config.SESSION_DAYS)))
user.last_login_at = now()
db.commit()
return token
def revoke_all(db: Session, user_id: int) -> None:
db.query(UserSession).filter(UserSession.user_id == user_id).delete(synchronize_session=False)
db.commit()
def user_from_token(db: Session, token: str | None) -> User | None:
if not token:
return None
sess = db.get(UserSession, token)
if sess is None or sess.expires_at < now():
return None
user = db.get(User, sess.user_id)
if user is None or not user.active:
return None
return user
# ------------------------------------------------------------------ 依赖
READ_METHODS = ("GET", "HEAD", "OPTIONS")
def resolve_rule(path: str, method: str):
"""请求 -> (菜单, 是否写操作);菜单为 None 表示只要登录就能访问。"""
parts = [x for x in path.split("/") if x] # /api/custom/projects -> [...]
if len(parts) >= 2 and parts[0] == "api" and parts[1] == "export":
menu = EXPORT_MENU.get(parts[2] if len(parts) > 2 else "")
elif len(parts) >= 2 and parts[0] == "api":
menu = SEGMENT_MENU.get(parts[1])
else:
menu = None
if menu is None:
return None
# 周期表:所有页面顶栏都要读,只有新建/删除算设置页的写操作
if len(parts) >= 2 and parts[1] == "periods" and method in READ_METHODS:
return None
return menu, method not in READ_METHODS
def ensure_seed(db: Session) -> list[str]:
"""首次启动(或升级后)补内置角色与管理员账号,返回新建的提示项。"""
notes: list[str] = []
presets = [
("管理员", "全部菜单可编辑,含设置与权限管理",
{k: "rw" for k in MENU_KEYS}, True),
("部门成员", "业务菜单可编辑,不能改设置与权限",
{k: "rw" for k in MENU_KEYS if k not in ("settings", "rbac")}, True),
("只读访客", "只能查看,不能修改任何数据",
{k: "ro" for k in MENU_KEYS if k != "rbac"}, True),
]
role_map: dict[str, Role] = {r.name: r for r in db.query(Role).all()}
for name, desc, menus, builtin in presets:
role = role_map.get(name)
if role is None:
role = Role(name=name, description=desc, menus=menus_json(menus), is_builtin=builtin)
db.add(role)
db.flush()
role_map[name] = role
notes.append(f"已创建内置角色「{name}」")
if not db.query(User).count():
pw_hash, salt = hash_password(config.ADMIN_PASSWORD)
db.add(User(name=config.ADMIN_USERNAME, username=config.ADMIN_USERNAME,
password_hash=pw_hash, password_salt=salt,
role_id=role_map["管理员"].id, active=True))
db.commit()
notes.append(f"已创建管理员账号 {config.ADMIN_USERNAME}(请尽快在用户设置里改密码)")
db.commit()
return notes
def guard(request: Request, db: Session = Depends(get_db)) -> User | None:
"""挂在 /api 子应用上的总闸:先验登录,再按菜单权限放行。"""
path = request.url.path
if path.rstrip("/") == "/api/auth/login":
return None
user = user_from_token(db, request.cookies.get(SESSION_COOKIE))
if user is None:
raise HTTPException(status_code=401, detail="登录已失效,请重新登录")
if path.startswith("/api/rbac/"):
# 权限管理自身必须显式授权,普通用户即使能读别的菜单也不能碰
if "rbac" not in user.menus:
raise HTTPException(status_code=403, detail="没有「权限管理」的访问权限")
if request.method not in READ_METHODS and user.menus.get("rbac") != "rw":
raise HTTPException(status_code=403, detail="「权限管理」是只读权限,不能修改")
return user
rule = resolve_rule(path, request.method)
if rule is None:
return user
menu, need_write = rule
level = user.menus.get(menu)
if not level:
raise HTTPException(status_code=403, detail=f"没有「{MENU_LABELS.get(menu, menu)}」的访问权限")
if need_write and level != "rw":
raise HTTPException(status_code=403, detail=f"「{MENU_LABELS.get(menu, menu)}」是只读权限,不能修改")
return user
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user = user_from_token(db, request.cookies.get(SESSION_COOKIE))
if user is None:
raise HTTPException(status_code=401, detail="请先登录")
return user

Binary file not shown.

13
run.sh
View File

@ -1,12 +1,17 @@
#!/usr/bin/env bash
# 部门关键任务追踪看板 · 启动脚本
# 用法: ./run.sh 正常启动
# 用法: ./run.sh 正常启动(默认开热更新)
# PORT=8771 ./run.sh 换端口启动
# DEV=1 ./run.sh 开发模式(改 Python/前端/.env 自动重启,浏览器自动刷新)
# DEV=0 ./run.sh 关闭热更新(改代码不自动重启、页面不自动刷新)
# NO_OPEN=1 ./run.sh 启动后不自动打开浏览器
set -euo pipefail
cd "$(dirname "$0")"
# 热更新默认开启:改 Python / 前端 / .env 自动重启服务,页面发现重启后自动刷新。
# 需要 DEV 也传给 app.config(/health 的 dev 标记决定页面是否轮询),所以这里 export 出去。
DEV="${DEV:-1}"
export DEV
PORT="${PORT:-8770}"
XLSX="${XLSX:-/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx}"
STAFF_XLSX="${STAFF_XLSX:-/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx}"
@ -74,9 +79,9 @@ if [ ! -f data/board.db ]; then
fi
# ---------- 4. 启动 ----------
# 开发模式:uvicorn 默认只监听 *.py,这里补上前端文件与 .env
# uvicorn 默认只监听 *.py,这里补上前端文件与 .env
RELOAD_ARGS=()
if [ "${DEV:-0}" = "1" ]; then
if [ "$DEV" = "1" ]; then
RELOAD_ARGS=(--reload
--reload-include "*.js" --reload-include "*.css" --reload-include "*.html"
--reload-include ".env")

1451
web/app.js

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,8 @@
"chevron-left": '<path d="M14.8 5.4L8 12l6.8 6.6"/>',
"chevron-right": '<path d="M9.2 5.4L16 12l-6.8 6.6"/>',
"chevron-down": '<path d="M6 9.2l6 6 6-6"/>',
"chevron-up": '<path d="M6 14.8l6-6 6 6"/>',
"log-out": '<path d="M14.6 7.6V5.4a1.8 1.8 0 00-1.8-1.8H5.6a1.8 1.8 0 00-1.8 1.8v13.2a1.8 1.8 0 001.8 1.8h7.2a1.8 1.8 0 001.8-1.8v-2.2"/><path d="M9.4 12h10.2M16.6 8.8l3.2 3.2-3.2 3.2"/>',
"arrow-right": '<path d="M4.2 12h14.4M13 6.4l5.6 5.6L13 17.6"/>',
search: '<circle cx="11" cy="11" r="6.4"/><path d="M15.8 15.8l4.4 4.4"/>',
download: '<path d="M12 4v10.4M8 10.8l4 4 4-4"/><path d="M4.4 19.4h15.2"/>',

View File

@ -19,28 +19,17 @@
<nav class="nav" id="nav">
<button class="nav-item active" data-view="board"><span class="ni" data-icon="board"></span>工作看板</button>
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span>重点项目</button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span>定开项目</button>
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></span>每周重点工作</button>
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span>自研产品</button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span>定制项目</button>
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></span>每周工作</button>
<div class="nav-label">支撑</div>
<button class="nav-item" data-view="staff"><span class="ni" data-icon="users"></span>人力资源</button>
<button class="nav-item" data-view="intake"><span class="ni" data-icon="pencil"></span>更新录入</button>
<button class="nav-item" data-view="settings"><span class="ni" data-icon="sliders"></span>设置</button>
<button class="nav-item" data-view="rbac"><span class="ni" data-icon="shield-check"></span>权限管理</button>
</nav>
<div class="side-block">
<div class="side-title">分类筛选</div>
<div class="side-list" id="sideCategories"></div>
</div>
<div class="side-block">
<label class="switch">
<input type="checkbox" id="sideKeyOnly" />
<span>只看重点项目</span>
</label>
</div>
<div class="side-foot" id="sideFoot">—</div>
<div class="side-user" id="sideUser"></div>
</aside>
<main class="main">
@ -69,6 +58,7 @@
<section class="view hidden" id="view-staff"></section>
<section class="view hidden" id="view-intake"></section>
<section class="view hidden" id="view-settings"></section>
<section class="view hidden" id="view-rbac"></section>
</div>
</main>
</div>
@ -90,6 +80,39 @@
<div class="modal" id="modal"></div>
</div>
<div class="auth-mask hidden" id="authGate">
<div class="auth-shell">
<!-- 左:品牌区 -->
<aside class="auth-brand">
<div class="ab-top">
<div class="brand-mark"><i data-icon="target"></i></div>
<div class="brand-text"><strong>关键任务追踪看板</strong><span>软件开发部</span></div>
</div>
<div class="ab-mid">
<h2>进展、风险、计收<br>一张看板看完</h2>
<ul>
<li><i data-icon="list-checks"></i><span>自研产品:每周执行记录与重点清单</span></li>
<li><i data-icon="wallet"></i><span>定制项目:下单、交付到验收计收</span></li>
<li><i data-icon="shield-check"></i><span>权限管理:谁能看、谁能改,一处配好</span></li>
</ul>
</div>
<div class="ab-foot">数据留在本部门服务器,仅限内部使用</div>
</aside>
<!-- 右:登录表单 -->
<form class="auth-form" id="authForm" autocomplete="on">
<h3>登录</h3>
<p class="af-sub">使用部门分配的账号</p>
<label class="af-field"><span>账号</span>
<input class="inp" id="authUser" autocomplete="username" placeholder="如 admin" /></label>
<label class="af-field"><span>密码</span>
<input class="inp" id="authPass" type="password" autocomplete="current-password" placeholder="请输入密码" /></label>
<div class="notice err" id="authErr" style="display:none"></div>
<button class="btn primary block" type="submit" id="authOk">登录<i data-icon="arrow-right"></i></button>
<div class="af-foot">忘记密码或需要开通账号,请联系管理员</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="/static/icons.js"></script>

View File

@ -19,8 +19,8 @@
--amber: #d97706;
--red: #dc2626;
--violet: #7c3aed;
--shadow: 0 1px 2px rgba(16,24,40,.04), 0 4px 14px rgba(16,24,40,.05);
--radius: 10px;
--shadow: 0 1px 2px rgba(16,24,40,.04), 0 6px 20px rgba(16,24,40,.05);
--radius: 12px;
--radius-sm: 8px;
--sidebar-w: 232px;
}
@ -115,7 +115,94 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.side-item .cnt { margin-left: auto; font-size: 11px; color: var(--muted); }
.switch { display: flex; align-items: center; gap: 8px; padding: 0 4px; cursor: pointer; color: var(--text-2); }
.side-foot { margin-top: auto; font-size: 11px; color: var(--muted); padding: 10px 4px; border-top: 1px solid var(--border); line-height: 1.6; }
/* 用户信息胶囊:钉在侧栏最下方,点开是向上弹出的二级菜单 */
.side-user { position: relative; margin-top: auto; }
.user-chip {
display: flex; align-items: center; gap: 9px; width: 100%;
padding: 7px 8px; border-radius: 10px; cursor: pointer;
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
}
.user-chip:hover { border-color: var(--border-strong); background: #fff; }
.avatar {
width: 28px; height: 28px; border-radius: 9px; flex: none;
display: grid; place-items: center; font-size: 12px; font-weight: 600;
background: var(--primary); color: #fff;
}
.user-chip .u-text { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; flex: 1; }
.user-chip .u-text b { font-size: 12.5px; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-chip .u-text i { font-style: normal; font-size: 11px; color: var(--muted); }
.user-chip .ic { width: 15px; height: 15px; color: var(--muted); flex: none; }
.user-menu {
position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); z-index: 30;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 10px 28px rgba(16,24,40,.14); padding: 5px; display: flex; flex-direction: column; gap: 2px;
}
.user-menu button {
display: flex; align-items: center; gap: 8px; padding: 8px 9px;
border: 0; background: transparent; border-radius: 7px; cursor: pointer;
font-size: 12.5px; color: var(--text-2); text-align: left;
}
.user-menu button:hover { background: var(--panel-2); color: var(--text); }
.user-menu button.danger { color: var(--red); }
.user-menu button.danger:hover { background: #fdf0f0; }
.user-menu .ic { width: 15px; height: 15px; }
/* ---------------- 登录(左右分栏) ---------------- */
.auth-mask { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 28px;
background: var(--bg) radial-gradient(900px 520px at 12% -10%, #e2ecfd, transparent 70%); }
.auth-shell { width: min(880px, 100%); display: grid; grid-template-columns: 1.05fr .95fr;
background: var(--panel); border: 1px solid var(--border); border-radius: 18px; overflow: hidden;
box-shadow: 0 30px 70px rgba(16,24,40,.14), 0 2px 6px rgba(16,24,40,.05); }
/* 左:品牌区 */
.auth-brand { position: relative; overflow: hidden; padding: 34px 34px 30px;
display: flex; flex-direction: column; gap: 28px; color: #e8f0ff;
background: linear-gradient(155deg, #16255c 0%, #1f3f9e 52%, #3b5fd0 100%); }
.auth-brand::before, .auth-brand::after { content: ""; position: absolute; border-radius: 50%; pointer-events: none; }
.auth-brand::before { width: 360px; height: 360px; right: -170px; top: -130px;
background: radial-gradient(closest-side, rgba(255,255,255,.17), transparent); }
.auth-brand::after { width: 260px; height: 260px; left: -130px; bottom: -140px;
background: radial-gradient(closest-side, rgba(125,180,255,.28), transparent); }
.auth-brand .brand-mark { background: rgba(255,255,255,.16); color: #fff; }
.ab-top { position: relative; display: flex; align-items: center; gap: 11px; }
.ab-top .brand-text strong { color: #fff; }
.ab-top .brand-text span { color: rgba(232,240,255,.7); }
.ab-mid { position: relative; margin: auto 0; }
.ab-mid h2 { margin: 0 0 20px; font-size: 23px; line-height: 1.42; font-weight: 600; letter-spacing: .2px; }
.ab-mid ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 11px; }
.ab-mid li { display: flex; align-items: center; gap: 10px; font-size: 12.5px; color: rgba(232,240,255,.85); }
.ab-mid li .ic { width: 15px; height: 15px; flex: none; opacity: .85; }
.ab-foot { position: relative; font-size: 11.5px; color: rgba(232,240,255,.6); }
/* 右:表单 */
.auth-form { padding: 40px 40px 32px; display: flex; flex-direction: column; }
.auth-form h3 { margin: 0; font-size: 21px; letter-spacing: .2px; }
.af-sub { margin: 6px 0 26px; font-size: 12.5px; color: var(--muted); }
.af-field { display: block; margin-bottom: 15px; }
.af-field > span { display: block; margin-bottom: 6px; font-size: 12px; color: var(--text-2); }
.af-field .inp { width: 100%; height: 40px; }
.af-field .inp::placeholder { color: #aab1bf; }
.auth-form .notice.err { margin-bottom: 14px; }
.btn.block { width: 100%; height: 42px; margin-top: 8px; justify-content: center; gap: 7px; }
.btn.block .ic { width: 16px; height: 16px; }
.af-foot { margin-top: auto; padding-top: 24px; font-size: 11.5px; color: var(--muted); }
@media (max-width: 780px) {
.auth-mask { padding: 16px; }
.auth-shell { grid-template-columns: 1fr; }
.auth-brand { padding: 18px 20px; gap: 0; }
.auth-brand .ab-mid, .auth-brand .ab-foot { display: none; }
.auth-form { padding: 26px 22px 22px; }
.af-foot { padding-top: 18px; }
}
/* ---------------- 权限管理 ---------------- */
.rbac-menu-th { font-size: 11px !important; white-space: nowrap; }
.rbac-role { min-width: 168px; }
.rbac-role b { display: block; font-size: 13px; }
.rbac-role span { font-size: 11px; color: var(--muted); }
.tag-mini.builtin { background: #eef2ff; color: #4f46e5; }
/* ---------------- main ---------------- */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@ -164,16 +251,28 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
.btn.primary:hover { background: #1d4ed8; }
.btn.ghost { background: transparent; }
.btn.sm { height: 26px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
.btn.sm { height: 28px; padding: 0 11px; font-size: 12px; border-radius: 8px; }
.btn.sm .ic { width: 12px; height: 12px; }
.btn.danger { color: var(--red); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
.btn.danger:hover { border-color: #f3c6c6; background: #fdf0f0; }
/* 危险确认弹窗的主按钮:实心红,别和「取消」长得太像 */
.btn.danger.solid { background: var(--red); border-color: var(--red); color: #fff; font-weight: 600; }
.btn.danger.solid:hover { background: #b42318; border-color: #b42318; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.icon-btn {
display: inline-grid; place-items: center; border: 0; background: transparent;
cursor: pointer; color: var(--muted); padding: 5px; border-radius: 7px;
}
.icon-btn .ic { width: 16px; height: 16px; }
.icon-btn:hover { background: var(--panel-2); color: var(--text); }
.icon-btn.sm { width: 26px; height: 26px; padding: 0; border-radius: 7px; }
.icon-btn.sm .ic { width: 15px; height: 15px; }
.icon-btn.xs { width: 22px; height: 22px; padding: 0; border-radius: 6px; }
.icon-btn.xs .ic { width: 13px; height: 13px; }
.icon-btn.danger:hover { background: #fdf0f0; color: var(--red); }
/* 表格行内操作:平时压灰,指针落到这一行才提亮 */
.row-ops { display: inline-flex; align-items: center; gap: 2px; opacity: .45; }
tr:hover .row-ops, .row-ops:focus-within { opacity: 1; }
.views { padding: 20px 22px 60px; }
.hidden { display: none !important; }
@ -190,7 +289,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
}
.card-head h4 { margin: 0; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.card-head h4 .t, .card-head .period-hint, .card-head .switch { white-space: nowrap; }
.card-head .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; }
/* 统一计数 chip:卡头、分组标题共用一颗灰底小圆牌 */
.card-head h4 .cnt, .card-head .head-chips .cnt { font-size: 11px; font-weight: 600; color: var(--text-2); background: #f1f3f7; border-radius: 999px; padding: 1px 7px; }
.card-body { padding: 16px; }
.grid { display: grid; gap: 16px; }
@ -222,7 +322,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
/* 定开项目明细表上方的统计条(左侧色条 + 金额重点色) */
.stat-row {
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px; padding: 2px 16px 16px;
gap: 12px; padding: 2px 16px 12px;
}
.stat {
position: relative; overflow: hidden;
@ -237,10 +337,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.stat .stat-money { color: var(--c, var(--primary)); }
.stat .stat-unit { font-size: 11px; font-weight: 500; color: var(--muted); margin: 0 2px; }
.stat .stat-label { font-size: 12px; color: var(--muted); margin-top: 5px; }
.stat-note { padding: 0 16px 14px; font-size: 12px; color: var(--muted); }
/* ---------------- board ---------------- */
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 11px; border-radius: 999px; font-size: 12px;
@ -250,6 +349,12 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.chip.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.chip.active .ic { color: var(--primary); }
/* 卡头加 chips-row:三个 tab 独占一行 header,筛选器 + 主功能按钮放同一行 */
.card-head.chips-row .head-chips { flex-basis: 100%; }
.card-head.chips-row .head-chips .cnt { margin-left: 4px; }
.card-head.chips-row .head-tools > * { flex: 0 1 auto; min-width: 0; }
.card-head.chips-row .head-tools .btn { flex: 0 0 auto; }
.board-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.pcard {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
@ -321,10 +426,12 @@ table.tbl td.num, table.tbl th.num { text-align: right; font-variant-numeric: ta
.tbl-wrap { max-height: 620px; overflow: auto; }
.nowrap { white-space: nowrap; }
.inp, .sel {
height: 28px; border: 1px solid var(--border); border-radius: 7px; padding: 0 8px; background: #fff; max-width: 100%;
height: 32px; border: 1px solid var(--border); border-radius: 8px; padding: 0 9px; background: #fff; max-width: 100%;
}
textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.inp:focus, .sel:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
/* 表格里的数字输入(年度目标等):右对齐、等宽数字,别把行高撑开 */
.num-inp { width: 100px; height: 28px; text-align: right; font-variant-numeric: tabular-nums; }
/* ---------------- drawer ---------------- */
.drawer-mask { position: fixed; inset: 0; background: rgba(18,24,38,.32); opacity: 0; pointer-events: none; transition: .2s; z-index: 40; }
@ -356,7 +463,6 @@ textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.sec { margin-bottom: 22px; }
.sec-title { font-size: 12px; color: var(--muted); letter-spacing: .5px; margin-bottom: 10px; display: flex; align-items: center; gap: 7px; }
.sec-title::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.sec-title .sub { letter-spacing: 0; }
.form-row { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: center; margin-bottom: 10px; }
.form-row > label { font-size: 12px; color: var(--muted); }
@ -365,23 +471,25 @@ textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.form-row > div { min-width: 0; }
.inline-fields { display: flex; gap: 8px; flex-wrap: wrap; }
/* 详情(只读)/ 修改(编辑)分离:只读值、区块操作条、区块属性徽标 */
/* 卡头(子 header)内的两块:筛选 chip 紧跟标题,操作按钮统一靠最右 */
.head-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.head-tools {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
justify-content: flex-end; margin-left: auto;
}
.head-tools .btn.sm, .head-tools .inp, .head-tools .sel, .head-tools .search { height: 28px; }
.head-tools .inp, .head-tools .sel, .head-tools .search { font-size: 12.5px; }
.head-tools .switch { font-size: 12px; padding: 0; }
.sel.xs { height: 26px; padding: 0 6px; font-size: 12px; }
/* 详情(只读)/ 修改(编辑)分离:只读值、区块操作条 */
.form-row > .ro { font-size: 13px; color: var(--text); line-height: 1.7; }
.sec-actions {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-top: 14px; padding: 9px 11px;
background: var(--panel-2); border-radius: 9px;
}
.sec-actions-label { font-size: 11.5px; color: var(--muted); }
/* 附表的更新记录写入条:底色淡染,不再用虚线彩框 */
.sec-actions.upd { background: color-mix(in srgb, var(--green) 6%, #fff); }
.sec-actions.upd .sec-actions-label { color: var(--green); font-weight: 600; }
.sec-hint { font-size: 11.5px; color: var(--muted); margin-left: auto; }
.sec-kind {
font-size: 10.5px; font-weight: 600; letter-spacing: 0; padding: 1px 6px;
border-radius: 5px; background: var(--primary-soft); color: var(--primary);
}
.sec-kind.ro { background: #f1f3f7; color: var(--muted); font-weight: 500; }
.wk-i-name .star, .wk-head-title .star, .b-name .star, .drawer-head h3 .star { margin-right: 4px; vertical-align: -2px; }
.star.readonly { cursor: default; }
.star.readonly:not(.on) { opacity: .35; }
@ -394,16 +502,19 @@ textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.upd-item .u-risk .ic { margin-top: 2px; color: var(--amber); }
.upd-item .u-next { display: flex; align-items: flex-start; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--primary); background: var(--primary-soft); border-radius: 7px; padding: 6px 9px; }
.upd-item .u-next .ic { margin-top: 2px; }
/* 条目右上角的「修改 / 删除」:平时不占视觉,指针进条目才显现 */
.u-ops { display: inline-flex; gap: 2px; margin-left: auto; opacity: .45; transition: .12s; }
.upd-item:hover .u-ops, .u-ops:focus-within { opacity: 1; }
.feed-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.feed-bar .seg-btn { padding: 4px 10px; font-size: 12px; }
.feed-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.feed-bar .chip { padding: 3px 9px; }
.feed-pager { display: flex; gap: 4px; }
.feed-ops { display: flex; gap: 8px; margin-left: auto; }
.nav-arrow.sm { width: 24px; height: 24px; border-radius: 7px; }
.nav-arrow.sm .ic { width: 13px; height: 13px; }
.nav-arrow.sm:disabled { opacity: .45; cursor: not-allowed; }
.feed-meta { font-size: 11.5px; color: var(--muted); }
.feed-hd { display: flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--text-2); margin: 14px 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
.feed-hd b { font-size: 13px; color: var(--text); }
.feed-hd .feed-cnt { margin-left: auto; font-size: 11.5px; color: var(--muted); }
@ -520,7 +631,13 @@ table.tbl td.col-ops .btn.sm { padding: 0 7px; } /* 操作列按钮收紧,避
.wk-i-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 7px; padding-left: 22px; }
.wk-i-sub { margin-top: 6px; font-size: 11px; color: var(--muted); line-height: 1.5; padding-left: 22px; }
.wk-head { margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
/* 详情面板头 = 子 header:左侧标题与元信息,右侧放这个面板自己的操作按钮 */
.wk-head {
display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap;
margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border);
}
.wk-head-main { flex: 1 1 320px; min-width: 0; }
.wk-head-ops { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
.wk-head-title { display: flex; align-items: center; gap: 9px; font-size: 16px; font-weight: 600; line-height: 1.4; }
.wk-head-title .ic-badge { width: 28px; height: 28px; border-radius: 8px; }
.wk-head-title .ic-badge > .ic { width: 16px; height: 16px; }
@ -570,5 +687,4 @@ details.card.ov > summary::-webkit-details-marker { display: none; }
details.card.ov > summary .ov-chev { display: inline-flex; color: var(--muted); transition: transform .15s; }
details.card.ov > summary .ov-chev .ic { width: 14px; height: 14px; }
details.card.ov[open] > summary .ov-chev { transform: rotate(90deg); }
details.card.ov > summary .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 4px; }
details.card.ov > .ov-body { padding-top: 16px; }