Compare commits
2 Commits
07e1b0ae7b
...
cd37ea4661
| Author | SHA1 | Date |
|---|---|---|
|
|
cd37ea4661 | |
|
|
bcb3feb3bb |
|
|
@ -93,4 +93,10 @@
|
|||
3. **表头排序**:`下单时间`(sign_date) / `订单金额`(amount, 数值) / `更新时间`(update_date) 三个表头可点击排序,`state.customSort = {key, dir}`;点不同列默认「降序」,再点同列切换升/降;表头显示 ▲/▼,卡片副标题显示当前排序。
|
||||
4. **新增「推动验收计划」筛选**:工具栏加 `#cAcceptPlan`(`state.customAcceptPlan`),选项取数据中实际出现过的值(Q3/Q4/27年/-- 等);导出也带 `accept_plan` 参数(后端 `export_custom` 新增 `accept_plan`)。
|
||||
- `state` 新增 `customAcceptPlan` / `customSort`,均加入 `UI_FIELDS` 持久化。
|
||||
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。**本轮未 push**。
|
||||
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。
|
||||
|
||||
## 第二次推送 develop-chezhiqi
|
||||
- 提交 **07e1b0a**「定开项目:改名(华智下单/进行中)+ 表格排序与筛选 + 列序/按钮调整」(6 files changed, 106 insertions, 78 deletions):`web/app.js`、`app/models.py`、`app/routers/export.py`、`scripts/import_custom_ledger.py`、`data/board.db`、`.workbuddy/memory/2026-09-20.md`。
|
||||
- 已 push 到 `origin/develop-chezhiqi`(`a77cd7b..07e1b0a`),本地与远端一致。
|
||||
- **注意**:首次 `git push` 那次返回空输出、实际未推成功(`status -sb` 显示 ahead 1),重试后成功;以后 push 后要用 `git status -sb` / `git log origin/<branch>` 复核。
|
||||
- 仍未提交:`data/board.db.bak-*` 本地备份(7 个,保持 untracked)。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
# 2026-09-23
|
||||
|
||||
## 合并 main(V 0.1.1)到 develop-chezhiqi
|
||||
|
||||
- `git fetch`:**origin/main 从 e9854eb 前进到 `bcb3feb`「V 0.1.1」**(其祖先已包含 a77cd7b / 07e1b0a,即 develop-chezhiqi 的全部提交)。
|
||||
- `develop-chezhiqi` 是 `origin/main` 的**祖先** → 直接 **fast-forward 合并**:`git merge origin/main`。分支现指向 `bcb3feb`,**领先 origin/develop-chezhiqi 1 个提交(未 push)**。
|
||||
- 合并前已备份数据库:`data/board.db.bak-before-merge-main-<ts>`。
|
||||
- **main 带来的新内容**:登录与权限 —— `app/security.py`、`app/routers/auth.py`、`app/routers/rbac.py`;`users` / `roles` / `user_sessions` 表;`app/config.py`(新增 `ADMIN_PASSWORD`);`app/main.py`(启动时 `security.ensure_seed`);models/schemas 扩展(+83 行等);`web/**`(登录页、按角色显示菜单);`run.sh`、`README.md`、`data/board.db`。
|
||||
- **我之前的改动全部保留**:`web/app.js` 里 项目总览 / 推动验收计划 / 进行中 / 华智下单 / 年度任务 / 新增项目 / data-sort 均在;DB 迁移也在 —— origin:华智下单 84 / 存量项目 27 / 汇智直签 10 / 提前交付 2;status:进行中 10(原交付中);`update_date` 空 111 / 有值 12;共 **123** 条。
|
||||
- **无需新依赖**:`security.py` 仅用 hashlib / hmac / json / secrets 等标准库。
|
||||
- 已启动服务:uvicorn(PID 30036),`/health` ok。
|
||||
- **现在需要登录**(重要):未登录访问 `/api/custom/*` 返回 **401**。默认管理员 **admin / admin123**(`app/config.py` 里 `ADMIN_PASSWORD` 默认值,可用 `.env` 覆盖),实测登录成功(角色「管理员」,menus 全 rw);另有 `user`(部门成员,部分菜单只读)。
|
||||
- 未提交:`.workbuddy/memory/2026-09-20.md`(本地日志改动)。未跟踪:`data/board.db.bak-*`(本地备份)。
|
||||
- 注意:**本地 `main` 分支仍是旧的 e9854eb**(我未改动它);本次合并用的是 `origin/main`。
|
||||
|
||||
## 修复定开 3 个问题
|
||||
|
||||
1. **统计不随项目状态变更**(排查结论):
|
||||
- 不是刷新/缓存问题 —— 实测 PATCH 改状态后 GET 立刻反映;前端 `afterWrite() → refresh() → renderCustom()` 会重新取数,`/api/custom/projects` 也没有缓存头。
|
||||
- 真因是**统计口径**:验收只认「验收时间」`accept_date`,改「项目状态」不会移动任何数字。
|
||||
- **改口径**:验收**先按「项目状态 = 已验收」过滤**(状态一改统计立即同步),**季度归属再按「验收时间」**`accept_date`,**验收时间为空时算「本季度」**。工作看板里同口径的「本季度/本年验收」(`web/app.js` 约 1186 行)也同步改了。
|
||||
- 影响数字:本年验收 65 → **68**、本季度验收 22 → **25**、本季金额 → 101.54 万(多出的 3 条是「状态=已验收但没有验收时间」的项目)。实测:把某项目改成已验收 → 本季 25→26、本年 68→69。
|
||||
2. **「本周」= 9月第4周、默认展示本周**:`periods` 表原来只到「9月第3周」,`/api/periods` 的 `today_id` 为 `null`,前端只能退回最后一个周期(第3周)。
|
||||
3. **周次按时间自动创建**:`app/routers/board.py` 的 `list_periods` 里加了一句 —— `_week_period(db, *constants.week_of_date(date.today()))`(复用 `focus._week_period`),每次取周期列表时自动补上「今天所在的这一周」。实测首次请求即生成 **9月第4周(id 53,2026-09-21 ~ 09-27)**,`today_id = 53`,无需手动新建。
|
||||
|
||||
- 重启 uvicorn(PID 26184);`node --check` / `ast.parse` / `/health` 均通过。本轮改动**未提交**。
|
||||
|
|
@ -45,6 +45,11 @@
|
|||
|
||||
导入「市场责任人」等销售侧人名**不走花名册校验**,不要往 `staff` 里塞。
|
||||
|
||||
## 登录与权限(2026-09-23 合并 main V 0.1.1 起)
|
||||
|
||||
- 全站需**登录**:未登录访问 `/api/*` 返回 **401**。默认管理员 **admin / admin123**(`app/config.py` 的 `ADMIN_PASSWORD` 默认值,可用 `.env` 覆盖);另有 `user`(部门成员)。
|
||||
- 权限模型:`roles.menus` 是 JSON(board/custom/focus/intake/projects/rbac/settings/staff,值 `rw` / `ro`);内置角色 管理员 / 部门成员 / 只读访客。代码:`app/security.py`(hashlib+hmac 手写,无新依赖)、`app/routers/auth.py`、`app/routers/rbac.py`;表 `users` / `roles` / `user_sessions`。
|
||||
|
||||
## 术语(2026-09-16 起)
|
||||
|
||||
模块③ 的正式称呼是 **「定开项目」**(旧称「定制交付」),界面统一用「定开」:定开项目 / 定开项目明细 / 定开合同额 / 定开记录 / 定开动态 / 定开待计收。`categories` 表里也有一行分类叫「定开项目」。
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
18
app/main.py
18
app/main.py
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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}
|
||||
|
|
@ -63,6 +63,12 @@ def resolve_period_ids(
|
|||
@router.get("/periods")
|
||||
def list_periods(db: Session = Depends(get_db)):
|
||||
from ..models import CustomUpdate
|
||||
from .focus import _week_period
|
||||
|
||||
# 周期按时间自动创建:确保「今天所在的这一周」一定存在,不再需要手动新建第几周。
|
||||
# (_week_period 已用于「每周重点工作」,没有则补一条,口径与 week_of_date 一致)
|
||||
_week_period(db, *constants.week_of_date(date.today()))
|
||||
db.commit()
|
||||
|
||||
weeks = db.query(Period).order_by(Period.sort_key).all()
|
||||
uc = dict(
|
||||
|
|
@ -80,6 +86,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 +105,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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 # 管理员重置密码,不需要旧密码
|
||||
|
|
|
|||
|
|
@ -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
|
||||
BIN
data/board.db
BIN
data/board.db
Binary file not shown.
13
run.sh
13
run.sh
|
|
@ -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")
|
||||
|
|
|
|||
1420
web/app.js
1420
web/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
164
web/styles.css
164
web/styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue