diff --git a/.DS_Store b/.DS_Store
index 49c529d..6096b82 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/README.md b/README.md
index 484f336..f6b9aaf 100644
--- a/README.md
+++ b/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. 项目管理
diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc
index 0ba0a82..178c3a9 100644
Binary files a/app/__pycache__/__init__.cpython-313.pyc and b/app/__pycache__/__init__.cpython-313.pyc differ
diff --git a/app/__pycache__/ai_parser.cpython-313.pyc b/app/__pycache__/ai_parser.cpython-313.pyc
index 61fecfa..e8d4e14 100644
Binary files a/app/__pycache__/ai_parser.cpython-313.pyc and b/app/__pycache__/ai_parser.cpython-313.pyc differ
diff --git a/app/__pycache__/config.cpython-313.pyc b/app/__pycache__/config.cpython-313.pyc
index 981ea7a..0a0cf21 100644
Binary files a/app/__pycache__/config.cpython-313.pyc and b/app/__pycache__/config.cpython-313.pyc differ
diff --git a/app/__pycache__/constants.cpython-313.pyc b/app/__pycache__/constants.cpython-313.pyc
index f08f17c..01bba38 100644
Binary files a/app/__pycache__/constants.cpython-313.pyc and b/app/__pycache__/constants.cpython-313.pyc differ
diff --git a/app/__pycache__/db.cpython-313.pyc b/app/__pycache__/db.cpython-313.pyc
index a2dfeee..b5623ad 100644
Binary files a/app/__pycache__/db.cpython-313.pyc and b/app/__pycache__/db.cpython-313.pyc differ
diff --git a/app/__pycache__/export.cpython-313.pyc b/app/__pycache__/export.cpython-313.pyc
index 1ba7041..3070798 100644
Binary files a/app/__pycache__/export.cpython-313.pyc and b/app/__pycache__/export.cpython-313.pyc differ
diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc
index 38915b3..7602ede 100644
Binary files a/app/__pycache__/main.cpython-313.pyc and b/app/__pycache__/main.cpython-313.pyc differ
diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc
index d5c268f..8a190d3 100644
Binary files a/app/__pycache__/models.cpython-313.pyc and b/app/__pycache__/models.cpython-313.pyc differ
diff --git a/app/__pycache__/schema_patch.cpython-313.pyc b/app/__pycache__/schema_patch.cpython-313.pyc
index baa350f..044a280 100644
Binary files a/app/__pycache__/schema_patch.cpython-313.pyc and b/app/__pycache__/schema_patch.cpython-313.pyc differ
diff --git a/app/__pycache__/schemas.cpython-313.pyc b/app/__pycache__/schemas.cpython-313.pyc
index 18a8654..8e0694c 100644
Binary files a/app/__pycache__/schemas.cpython-313.pyc and b/app/__pycache__/schemas.cpython-313.pyc differ
diff --git a/app/__pycache__/security.cpython-313.pyc b/app/__pycache__/security.cpython-313.pyc
new file mode 100644
index 0000000..07d3c57
Binary files /dev/null and b/app/__pycache__/security.cpython-313.pyc differ
diff --git a/app/config.py b/app/config.py
index c40614f..715b87c 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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")
diff --git a/app/constants.py b/app/constants.py
index 91393f2..2fd3947 100644
--- a/app/constants.py
+++ b/app/constants.py
@@ -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"}
diff --git a/app/main.py b/app/main.py
index 15d3254..bc0a69a 100644
--- a/app/main.py
+++ b/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"
diff --git a/app/models.py b/app/models.py
index ac02c35..2ccb74b 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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)
diff --git a/app/routers/__pycache__/__init__.cpython-313.pyc b/app/routers/__pycache__/__init__.cpython-313.pyc
index 36b8487..8561a91 100644
Binary files a/app/routers/__pycache__/__init__.cpython-313.pyc and b/app/routers/__pycache__/__init__.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/ai.cpython-313.pyc b/app/routers/__pycache__/ai.cpython-313.pyc
index 72e27cf..f0a31ae 100644
Binary files a/app/routers/__pycache__/ai.cpython-313.pyc and b/app/routers/__pycache__/ai.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/auth.cpython-313.pyc b/app/routers/__pycache__/auth.cpython-313.pyc
new file mode 100644
index 0000000..0e3f8da
Binary files /dev/null and b/app/routers/__pycache__/auth.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/board.cpython-313.pyc b/app/routers/__pycache__/board.cpython-313.pyc
index f17b668..c42d560 100644
Binary files a/app/routers/__pycache__/board.cpython-313.pyc and b/app/routers/__pycache__/board.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/custom.cpython-313.pyc b/app/routers/__pycache__/custom.cpython-313.pyc
index 1464716..6abc1f6 100644
Binary files a/app/routers/__pycache__/custom.cpython-313.pyc and b/app/routers/__pycache__/custom.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/export.cpython-313.pyc b/app/routers/__pycache__/export.cpython-313.pyc
index 9e0fc67..6e1fb64 100644
Binary files a/app/routers/__pycache__/export.cpython-313.pyc and b/app/routers/__pycache__/export.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/focus.cpython-313.pyc b/app/routers/__pycache__/focus.cpython-313.pyc
index 38f623d..552229c 100644
Binary files a/app/routers/__pycache__/focus.cpython-313.pyc and b/app/routers/__pycache__/focus.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/projects.cpython-313.pyc b/app/routers/__pycache__/projects.cpython-313.pyc
index a973f3e..ccb5928 100644
Binary files a/app/routers/__pycache__/projects.cpython-313.pyc and b/app/routers/__pycache__/projects.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/rbac.cpython-313.pyc b/app/routers/__pycache__/rbac.cpython-313.pyc
new file mode 100644
index 0000000..197afcb
Binary files /dev/null and b/app/routers/__pycache__/rbac.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/staff.cpython-313.pyc b/app/routers/__pycache__/staff.cpython-313.pyc
index 21a6670..214587a 100644
Binary files a/app/routers/__pycache__/staff.cpython-313.pyc and b/app/routers/__pycache__/staff.cpython-313.pyc differ
diff --git a/app/routers/__pycache__/weekly.cpython-313.pyc b/app/routers/__pycache__/weekly.cpython-313.pyc
index 65c4f41..dd7adc6 100644
Binary files a/app/routers/__pycache__/weekly.cpython-313.pyc and b/app/routers/__pycache__/weekly.cpython-313.pyc differ
diff --git a/app/routers/auth.py b/app/routers/auth.py
new file mode 100644
index 0000000..4b6bae0
--- /dev/null
+++ b/app/routers/auth.py
@@ -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}
diff --git a/app/routers/board.py b/app/routers/board.py
index 18fe243..9501d67 100644
--- a/app/routers/board.py
+++ b/app/routers/board.py
@@ -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 {
diff --git a/app/routers/custom.py b/app/routers/custom.py
index b5ed2c9..619c15e 100644
--- a/app/routers/custom.py
+++ b/app/routers/custom.py
@@ -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}
diff --git a/app/routers/projects.py b/app/routers/projects.py
index cabf1b8..5cebb72 100644
--- a/app/routers/projects.py
+++ b/app/routers/projects.py
@@ -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()
diff --git a/app/routers/rbac.py b/app/routers/rbac.py
new file mode 100644
index 0000000..eaba248
--- /dev/null
+++ b/app/routers/rbac.py
@@ -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}
diff --git a/app/routers/weekly.py b/app/routers/weekly.py
index 2d6bdfb..ae3c8d3 100644
--- a/app/routers/weekly.py
+++ b/app/routers/weekly.py
@@ -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()
diff --git a/app/schemas.py b/app/schemas.py
index 762e57d..14487eb 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -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 # 管理员重置密码,不需要旧密码
diff --git a/app/security.py b/app/security.py
new file mode 100644
index 0000000..7327af6
--- /dev/null
+++ b/app/security.py
@@ -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
diff --git a/data/board.db b/data/board.db
index c99e0ec..7224015 100644
Binary files a/data/board.db and b/data/board.db differ
diff --git a/run.sh b/run.sh
index d61704f..66ad3b7 100755
--- a/run.sh
+++ b/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")
diff --git a/web/app.js b/web/app.js
index 61483d5..58fb765 100644
--- a/web/app.js
+++ b/web/app.js
@@ -10,7 +10,6 @@
weekId: null,
monthIdx: null,
categoryId: null,
- keyOnly: false,
keyword: "",
idle: "key",
showArchived: false,
@@ -22,16 +21,16 @@
projects: [],
drawerProject: null,
// 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移
- dw: { group: "week", range: 4, back: 0 },
+ dw: { group: "week", range: 2, back: 0 },
// 定开项目
customMeta: null,
// 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线)
customTab: "list",
+ customAnnualEdit: false, // 年度任务:默认查看模式,点「编辑」才可改
customRegion: "",
customStage: "",
customStatus: "",
customOrigin: "",
- customAcceptPlan: "",
customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc }
customArchived: "false",
customRows: [],
@@ -43,8 +42,6 @@
// 周看板(左栏总览 + 右栏详情)
weekly: null,
weeklySel: null,
- weeklyOwner: "",
- weeklyAllCustom: false,
// 项目管理
projOwner: "",
projIdleOnly: false,
@@ -57,6 +54,8 @@
staffDict: { groups: [], affiliations: [] },
staffArchived: "false",
staffGroup: "",
+ // 登录用户(含角色与菜单权限);未登录时为 null
+ me: null,
};
/* ---------------------------------------------------------------- utils */
@@ -88,7 +87,11 @@
if (!res.ok) {
let msg = res.statusText;
try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ }
- throw new Error(msg);
+ // 会话过期 / 被管理员下线:任何请求都直接把界面切回登录页
+ if (res.status === 401 && path !== "/auth/login") showAuthGate(msg);
+ const err = new Error(msg);
+ err.status = res.status;
+ throw err;
}
return res.json();
}
@@ -106,20 +109,298 @@
toastTimer = setTimeout(() => t.classList.remove("show"), 2400);
}
+ /* ---------------------------------------------- 登录与菜单权限 */
+ /** 菜单权限:can = 能看(ro/rw),canW = 能改(只有 rw)。未登录一律 false。 */
+ function can(menu) {
+ return !!(state.me && state.me.menus && state.me.menus[menu]);
+ }
+ const canR = can;
+ function canW(menu) {
+ return !!(state.me && state.me.menus && state.me.menus[menu] === "rw");
+ }
+
+ function showAuthGate(msg) {
+ $("#authGate").classList.remove("hidden");
+ const box = $("#authErr");
+ box.textContent = msg || "";
+ box.style.display = msg ? "" : "none";
+ const f = $("#authPass").value ? $("#authOk") : ($("#authUser").value ? $("#authPass") : $("#authUser"));
+ setTimeout(() => f.focus(), 60);
+ }
+
+ function bindAuthForm() {
+ $("#authForm").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const btn = $("#authOk");
+ btn.disabled = true;
+ try {
+ await post("/auth/login", { username: $("#authUser").value.trim(), password: $("#authPass").value });
+ location.reload(); // 登录成功后整页重载,按新权限重新渲染
+ } catch (err) {
+ const box = $("#authErr");
+ box.textContent = err.message;
+ box.style.display = "";
+ btn.disabled = false;
+ }
+ });
+ }
+
+ /** 只保留有权限的菜单;顶栏「更新录入」按钮同样跟着 intake 权限走 */
+ function applyNavPermissions() {
+ $$("#nav .nav-item").forEach((b) => b.classList.toggle("hidden", !can(b.dataset.view)));
+ $$("#nav .nav-label").forEach((l) => {
+ let n = l.nextElementSibling, any = false;
+ while (n && !n.classList.contains("nav-label")) {
+ if (n.classList.contains("nav-item") && !n.classList.contains("hidden")) any = true;
+ n = n.nextElementSibling;
+ }
+ l.classList.toggle("hidden", !any);
+ });
+ $("#btnIntake").classList.toggle("hidden", !can("intake"));
+ }
+
+ /** 有权限的第一个菜单,用于回落 */
+ function firstAllowedView() {
+ const b = $$("#nav .nav-item").filter((x) => can(x.dataset.view))[0];
+ return b ? b.dataset.view : null;
+ }
+
+ /* ---------------------------------------- 侧栏底部的用户胶囊 */
+ function renderUserChip() {
+ const me = state.me;
+ $("#sideUser").innerHTML = `
+
+
`;
+ const menu = $("#userMenu");
+ $("#userChip").addEventListener("click", (e) => { e.stopPropagation(); menu.classList.toggle("hidden"); });
+ document.addEventListener("click", () => menu.classList.add("hidden"));
+ $("#miProfile").addEventListener("click", openProfileDialog);
+ $("#miLogout").addEventListener("click", doLogout);
+ }
+
+ /** 用户设置:改昵称 + 改自己的密码;角色由管理员在权限管理里分配 */
+ function openProfileDialog() {
+ const me = state.me;
+ openModal(`
+ 用户设置
+
+
+
+
+
修改密码
+
+
+
+
+ `);
+ $("#pfOk").addEventListener("click", async () => {
+ const btn = $("#pfOk");
+ btn.disabled = true;
+ modalError("");
+ try {
+ const name = $("#pfName").value.trim();
+ if (name && name !== me.name) state.me = await put("/auth/me", { name: name });
+ const nw = $("#pfNew").value.trim();
+ if (nw) await post("/auth/password", { old_password: $("#pfOld").value, new_password: nw });
+ renderUserChip();
+ closeModal(true);
+ toast(nw ? "已保存,其他设备需重新登录" : "已保存");
+ } catch (e) {
+ modalError(e.message);
+ btn.disabled = false;
+ }
+ });
+ }
+
+ async function doLogout() {
+ try { await post("/auth/logout", {}); } catch (e) { /* 会话可能已经失效,忽略 */ }
+ location.reload();
+ }
+
/* ------------------------------------------------ 列表页统一骨架 */
- /** 列表页统一样式(照「定开项目 · 项目明细」):工具栏在卡片上方,
- 卡片头部 = 标题 + 汇总说明 + 卡内筛选,下面是可滚动表格或空态。 */
+ /** 卡头 = 列表页的子 header:标题+计数(左)、筛选 chip(紧跟标题)、操作按钮(最右)。
+ 工具栏不再单独占一行,所有页面统一走这里。 */
+ function cardHead(o) {
+ const tools = [(o.fields || ""), (o.tools || "")].filter(Boolean).join("");
+ const cnt = o.count ? `${o.count}` : "";
+ // chipsRow:切 tab 的 chip 独占一行 header(标题与当前 tab 同名,省掉,计数跟在 chip 后),
+ // 筛选器和主功能按钮放同一行。
+ if (o.chipsRow) {
+ return `
+ ${o.chips ? `
${o.chips}${cnt}
` : ""}
+ ${tools ? `
${tools}
` : ""}
+
`;
+ }
+ return `
+
${esc(o.title)}${cnt}
+ ${o.chips ? `
${o.chips}
` : ""}
+ ${tools ? `
${tools}
` : ""}
+
`;
+ }
+
function listCard(o) {
return `
-
-
${esc(o.title)}${o.sub ? `${esc(o.sub)}` : ""}
- ${o.summary ? `
${o.summary}` : ""}
- ${o.fields ? `
${o.fields}
` : ""}
-
+ ${cardHead(o)}
${o.rows.length ? `
${o.table}
` : `
${o.empty}
`}
`;
}
+ /** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。
+ 事件属性名由调用方给出,沿用各表原有的绑定。 */
+ function rowOps(o) {
+ // 只读角色不给行内「修改 / 归档 / 删除」
+ if (!canW(state.view)) return "";
+ const b = (attr, icon, title, cls) =>
+ ``;
+ if (!o.edit && !o.archive && !o.remove) return "";
+ return `` +
+ (o.edit ? b(o.edit, "pencil", "修改") : "") +
+ (o.archive ? b(o.archive + ` data-archived="${o.archived ? "1" : "0"}"`, "archive", o.archived ? "恢复" : "归档") : "") +
+ (o.remove ? b(o.remove, "trash", "删除", "danger") : "") +
+ ``;
+ }
+
+ /* -------------------------------------- 周期记录(执行 / 交付)的新增·修改·删除 */
+ /** 记录条目右上角的操作按钮,鼠标移到条目上才显现 */
+ function updOps(kind, u) {
+ if (!canW(kind === "custom" ? "custom" : "projects")) return "";
+ return `
+
+
+ `;
+ }
+
+ /**
+ * 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定开抽屉共用)。
+ * kind = "project" 自研产品的执行记录,"custom" 定开项目的交付记录;
+ * 传 update 就是修改,否则在 periodId 下新建;写完调 onDone 刷新所在区域。
+ */
+ function openUpdateDialog(o) {
+ const isP = o.kind !== "custom";
+ const u = o.update || null;
+ const weeks = state.periods.weeks || [];
+ const periodId = u ? u.period_id : o.periodId;
+ const stages = (state.customMeta && state.customMeta.stages) || [];
+ const nv = (v) => (v === null || v === undefined ? "" : String(v));
+ // 新建时沿用项目当前的状态 / 阶段:后端会按记录回写档案,别让默认值改坏台账
+ const defStatus = u ? u.status : o.status || "in_progress";
+ const defStage = u ? u.stage : o.stage || "";
+ // 计收 / 下单增量在库里存的是元,页面上统一按万元填写
+ const toWan = (v) => (v === null || v === undefined ? "" : (Number(v) / 10000).toFixed(2));
+ const fields = isP
+ ? `
+
+ `
+ : `
+
+
+ `;
+
+ openModal(
+ `${u ? "修改" : "添加"}${isP ? "执行记录" : "交付记录"}
+
+ `
+ );
+
+ const numVal = (id) => { const el = $(id); return el.value === "" ? null : Number(el.value); };
+ const wanVal = (id) => { const el = $(id); return el.value === "" ? null : Math.round(Number(el.value) * 10000); };
+ $("#mOk").addEventListener("click", async () => {
+ const content = $("#udContent").value.trim();
+ if (!content) return modalError("请填写内容");
+ const btn = $("#mOk");
+ btn.disabled = true;
+ const body = {
+ period_id: Number($("#udPeriod").value),
+ content: content,
+ progress: numVal("#udProgress"),
+ risk: $("#udRisk").value.trim() || null,
+ next_step: $("#udNext").value.trim() || null,
+ };
+ try {
+ if (isP) {
+ body.status = $("#udStatus").value;
+ body.task_name = $("#udTask").value.trim();
+ if (u) await patch("/updates/" + u.id, body);
+ else await post("/updates", Object.assign({ project_id: o.projectId, source: "manual" }, body));
+ } else {
+ body.stage = $("#udStage").value;
+ body.revenue_delta = wanVal("#udRevenue");
+ body.amount_delta = wanVal("#udAmount");
+ if (u) await patch("/custom/updates/" + u.id, body);
+ else await post("/custom/updates", Object.assign({ custom_project_id: o.customProjectId, source: "manual" }, body));
+ }
+ } catch (e) {
+ btn.disabled = false;
+ return modalError(e.message);
+ }
+ closeModal();
+ toast(u ? "已修改" : "已添加");
+ await afterWrite();
+ if (o.onDone) await o.onDone();
+ });
+ }
+
+ /** 给一块区域里的周期记录绑上「修改 / 删除」。list 用来按 id 找回记录,reload 刷新这块区域。 */
+ function bindUpdateOps(root, list, reload) {
+ root.querySelectorAll("[data-uop]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ const id = Number(b.dataset.uid);
+ const kind = b.dataset.ukind;
+ const u = (list || []).find((x) => x.id === id);
+ if (!u) return;
+ if (b.dataset.uop === "edit") {
+ openUpdateDialog({
+ kind: kind,
+ update: u,
+ projectId: u.project_id,
+ customProjectId: u.custom_project_id,
+ onDone: reload,
+ });
+ return;
+ }
+ const ok = await confirmDialog({
+ title: "删除这条记录?",
+ body: `删除后无法恢复。
+ ${esc([u.period_full, u.task_name || u.project_name].filter(Boolean).join(" · "))}
`,
+ okText: "永久删除",
+ danger: true,
+ });
+ if (!ok) return;
+ try {
+ await del(kind === "custom" ? "/custom/updates/" + id : "/updates/" + id);
+ } catch (e) { toast("删除失败:" + e.message); return; }
+ toast("已删除");
+ await afterWrite();
+ if (reload) await reload();
+ })
+ );
+ }
+
/** 页内搜索框:与右上角全局搜索共用 state.keyword */
function keywordInputHtml(id, placeholder, width) {
return ``;
@@ -347,6 +628,7 @@
/** 拉一次花名册,供各处「负责人」下拉使用 */
async function loadStaff() {
+ if (!canR("staff")) return null; // 没有花名册权限时不请求,负责人下拉留空
const d = await api("/staff?archived=all");
state.staff = d.items;
state.staffDict = d.dict;
@@ -375,13 +657,14 @@
const m = currentMonth();
return m ? m.period_ids : [];
}
- function scopeLabel() {
+ /** 顶栏当前选中周期(周 = 那一周,月 = 该月各周的并集)的实际起止日期,口径同后端 /periods */
+ function scopeRange() {
if (state.gran === "week") {
const w = (state.periods.weeks || []).find((x) => x.id === state.weekId);
- return w ? w.full_label : "—";
+ return w && w.first_day ? { start: w.first_day, end: w.last_day, label: w.full_label } : null;
}
const m = currentMonth();
- return m ? m.label : "—";
+ return m && m.first_day ? { start: m.first_day, end: m.last_day, label: m.label } : null;
}
/* ---------------------------------------------------------------- charts */
@@ -496,7 +779,7 @@
.map((m, i) => ``)
.join("");
}
- $("#periodHint").textContent = state.gran === "week" ? "周视图" : "月视图(自动汇总该月所有周)";
+ $("#periodHint").textContent = state.gran === "week" ? "" : "月视图 · 自动汇总该月所有周";
}
function shiftPeriod(delta) {
@@ -528,15 +811,15 @@
return `
${Ico.badge("layers")}
在管项目
${k.project_total}
-
${esc(scopeLabel())} · 执行记录
${k.update_count}
-
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
-
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
-
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
+
+
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
+
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
+
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
-
周期更新活跃度近 ${trend.length} 个周期的执行记录条数
+
周期更新活跃度
${barChart(trend, { unit: " 条" })}
@@ -554,7 +837,7 @@
-
执行状态分布${esc(scopeLabel())}
+
执行状态分布
${stackBar(s.status_dist)}
${s.status_dist.map((x) => `${esc(x.name)} ${x.count}`).join("")}
@@ -563,7 +846,7 @@
-
风险与阻塞${s.risks.length} 条
+
风险与阻塞
${s.risks.length ? s.risks.map((r) => `
@@ -579,7 +862,7 @@
-
低进度预警进度 < 30% 且未完成
+
低进度预警
${s.low_progress.length ? barList(s.low_progress) : `
暂无
`}
@@ -638,15 +921,16 @@
`;
}
- /** 一条执行 / 交付记录 */
- function updateItem(u) {
+ /** 一条执行 / 交付记录;传 kind 才给行内「修改 / 删除」 */
+ function updateItem(u, kind) {
return `
${esc(u.task_name || u.period_full || "记录")}
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
${u.progress !== null && u.progress !== undefined ? `${u.progress}%` : ""}
- ${u.period_full && u.period_full !== u.task_name ? `${esc(u.period_label || "")}` : ""}
+ ${u.task_name && u.period_full ? `${esc(u.period_label || "")}` : ""}
+ ${kind ? updOps(kind, u) : ""}
${nl2br(u.summary || u.content || "(无正文)")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
@@ -657,17 +941,20 @@
function weeklyHead(row) {
const isP = row.kind === "project";
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
+ const menu = isP ? "projects" : "custom";
return `
-
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
-
`;
@@ -800,46 +1090,46 @@
/** 右栏:定开项目本周动态。左栏的「定开项目」只给入口,清单都收在这里 */
function weeklyCustomOverview(data) {
- const rows = data.custom;
- const added = rows.filter((r) => r.is_new);
- const moved = rows.filter((r) => r.has_update && !r.is_new);
- const idle = rows.filter((r) => !r.is_new && !r.has_update);
- // 合计只算列出来的行,免得数字对不上清单
- const shown = added.concat(moved).concat(state.weeklyAllCustom ? idle : []);
- const sum = shown.reduce((a, r) => a + Number(r.amount || 0), 0);
+ // 「本周新增 / 本周验收」与定开项目的「本周进展」tab 共用同一套口径
+ const { added, accepted, listed } = customWeeklySections(data.custom, scopeRange());
+ // 高风险取全部在档项目:/weekly 的 custom 只有本期有动态的,取全量才不会漏
+ const risky = (state.customAll || data.custom).filter((r) => isHighRisk(r))
+ .sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0));
+ const RISK_SHOW = 10; // 高风险项目可能几十条,卡片里按合同额只显示前若干个
+ // 合计只算列出来的行(同一项目可能同时出现在新增与验收两段,这里按 id 去重),免得数字对不上清单
+ const sum = listed.reduce((a, r) => a + Number(r.amount || 0), 0);
const sec = (title, sub, list, tag) => `
-
${title}${sub} · ${list.length} 个
+
${title}
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("")
: `
本周期没有
`}
`;
return `
-
${Ico.badge("package")}定开项目 · 本周动态
-
-
新入库 ${added.length} 个
-
有交付动态 ${moved.length} 个
-
合同额合计 ${fen(sum)} 万
-
本期计收 ${fen(data.kpi.revenue_delta)} 万
+
+
${Ico.badge("package")}定开项目 · 本周动态
+
+ 本周新增 ${added.length} 个
+ 本周验收 ${accepted.length} 个
+ 高风险 ${risky.length} 个
+ 合同额合计 ${fen(sum)} 万
+ 本期计收 ${fen(data.kpi.revenue_delta)} 万
+
-
- ${sec("本周新增", "入库时间落在本周期", added,
- (r) => `
${esc(r.entry_date || "新入库")}`)}
- ${sec("本周有变化", "本周期有交付记录", moved,
- (r) => `
${r.update_count} 条记录`)}
- ${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle,
- (r) => (r.is_key ? `
重点` : "")) : ""}
- ${!state.weeklyAllCustom && idle.length
- ? `
另有 ${idle.length} 个在途定开项目本周没有动态,勾选「列入全部在途」一并列出。
` : ""}`;
+ ${sec("本周新增", "下单时间落在本周期(已下单 / 提前交付)", added,
+ (r) => `
${esc(String(r.sign_date || "").slice(0, 10) || "本期下单")}`)}
+ ${sec("本周验收", "更新时间落在本周期且状态为已验收", accepted,
+ (r) => `
${esc(String(r.update_date || "").slice(0, 10) || "本期验收")}`)}
+ ${sec("高风险项目", "风险值为「高风险」的在档定开项目", risky.slice(0, RISK_SHOW),
+ (r) => (r.project_status ? `
${esc(r.project_status)}` : ""))}
+ ${risky.length > RISK_SHOW ? `
共 ${risky.length} 个高风险项目,按合同额显示前 ${RISK_SHOW} 个
` : ""}`;
}
function bindWeeklyCustomOverview(box) {
- const ac = box.querySelector("#wkAllCustom");
- if (ac) ac.addEventListener("change", () => { state.weeklyAllCustom = ac.checked; renderBoard(); });
const gc = box.querySelector("#wkGoCustom");
if (gc) gc.addEventListener("click", () => setView("custom"));
box.querySelectorAll("[data-cpick]").forEach((el) =>
@@ -885,13 +1175,20 @@
const host = $("#view-board");
host.innerHTML = `
加载中…
`;
if (!state.customMeta) state.customMeta = await api("/custom/meta");
- const extra = ["all_custom=" + (state.weeklyAllCustom ? "true" : "false")];
- if (state.weeklyOwner) extra.push("owner=" + encodeURIComponent(state.weeklyOwner));
- const [data, focusRows] = await Promise.all([
- api("/weekly" + scopeQuery(extra)),
+ const [data, focusRows, customAll] = await Promise.all([
+ api("/weekly" + scopeQuery()),
api("/focus" + focusQuery(["archived=false"])),
+ // 「本季度验收」与「定制项目 · 项目总览」同一口径:按「验收时间」(Q1~Q4 季度文本)
+ api("/custom/projects?archived=false"),
]);
+ const now = new Date();
+ const qYear = now.getFullYear(), qNo = Math.floor(now.getMonth() / 3) + 1;
+ const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
+ const accQuarter = customAll.filter((p) => qOf(p.accept_date) === "Q" + qNo);
+ const accQuarterAmount = accQuarter.reduce((a, p) => a + Number(p.amount || 0), 0);
+ const accYearCount = customAll.filter((p) => qOf(p.accept_date)).length;
state.weekly = data;
+ state.customAll = customAll; // 右栏「高风险项目」用全量台账
state.focusRows = focusRows;
const k = data.kpi;
const focus = focusBuckets(focusRows);
@@ -906,27 +1203,17 @@
const pick = keep || all.find((r) => r.has_update) || all[0] || null;
state.weeklySel = pick ? { kind: pick.kind, id: pick.id } : null;
- const owners = {};
- all.forEach((r) => { if (r.owner) owners[r.owner] = true; });
- const toolbar = `
-
-
-
-
- ${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定开动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个
-
`;
+ const cSec = customWeeklySections(data.custom, scopeRange());
+ const cNew = cSec.added, cAcc = cSec.accepted, cRisk = customAll.filter((p) => isHighRisk(p)).length;
+ const cDyn = cSec.listed.length;
const kpi = `
-
+
${Ico.badge("star")}
重点项目
${k.key_total}
-
${Ico.badge("alert-circle")}
重点项目未更新
${k.key_idle}
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
${Ico.badge("activity")}
执行记录
${k.update_count}
-
${Ico.badge("wallet")}
定开合同额
${wan(k.custom_amount)}
-
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
+
${Ico.badge("wallet")}
本季度验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
+
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
`;
const group = (title, rows, tip) => `
@@ -938,11 +1225,9 @@
// 左栏顺序:本周工作重点 → 重点项目 → 定开项目(只给入口,点开在右栏看清单)
const focusList = focus.overdue.concat(focus.week);
- const cNew = data.custom.filter((r) => r.is_new);
- const cUpd = data.custom.filter((r) => r.has_update && !r.is_new);
const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
- host.innerHTML = kpi + toolbar + `
+ host.innerHTML = kpi + `
@@ -953,7 +1238,7 @@
${group("重点项目", data.projects, "个已更新")}
定开项目${data.custom.length}
-
+
${Ico.svg("package")}
@@ -962,8 +1247,8 @@
新增 ${cNew.length}
- 有变化 ${cUpd.length}
- ${state.weeklyAllCustom ? `含全部在途` : ""}
+ 验收 ${cAcc.length}
+ 高风险 ${cRisk}
@@ -971,14 +1256,10 @@
`;
- const wo = $("#wkOwner");
- if (wo) wo.addEventListener("change", () => { state.weeklyOwner = wo.value; renderBoard(); });
- const wex = $("#wkExportUpdates");
- if (wex) wex.addEventListener("click", () => download("/api/export/updates" + scopeQuery([])));
// 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体
dashboardHtml().then((html) => {
host.insertAdjacentHTML("beforeend",
- `
统计概览更新活跃度 / 分类分布 / 风险与阻塞
${html}
`);
+ `
统计概览
${html}
`);
}).catch((e) => {
host.insertAdjacentHTML("beforeend", `
统计概览加载失败:${esc(e.message)}
`);
});
@@ -1069,21 +1350,21 @@
const ro = (label, val) => `
`;
return `
-
${Ico.statusBadge(f.status)}${esc(f.content)}
-
- ${focusPill(f)}
- 预计完成 ${esc(focusDueText(f))}
-
-
-
+
+
${Ico.statusBadge(f.status)}${esc(f.content)}
+
+ ${focusPill(f)}
+ 预计完成 ${esc(focusDueText(f))}
+
+ ${canW("focus") ? `
` : ""}
-
说明只读
+
说明
${f.detail ? nl2br(f.detail) : `
未填写说明
`}
-
结果反馈只读
+
结果反馈
${f.result ? nl2br(f.result) : `
未填写结果反馈
`}
`;
}
@@ -1095,19 +1376,10 @@
const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]));
state.focusRows = rows;
const b = focusBuckets(rows);
- const week = focusWeek();
const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
- const toolbar = `
-
- ${filters.map((x) => ``).join("")}
-
- ${keywordInputHtml("fKeyword", "搜索工作内容…", 200)}
-
-
- 本周/延期按录入周统计 · 基准周 ${esc(week ? week.full_label : "—")}
-
`;
+ const chips = filters.map((x) => `
`).join("");
const table = `
@@ -1126,37 +1398,41 @@
| ${focusPill(f)} |
${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))} |
${esc((f.updated_at || "").slice(0, 10) || "—")} |
-
-
-
-
-
- |
+ ${rowOps({
+ edit: `data-fedit="${f.id}"`,
+ archive: `data-farch="${f.id}"`,
+ archived: f.archived,
+ remove: `data-fdel="${f.id}"`,
+ })} |
`).join("")}
`;
const body = listCard({
title: "重点工作清单",
- sub: "按录入时间倒序 · 共 " + rows.length + " 条",
- summary: `当前 ${shown.length} 条 · 已完成 ${b.done.length} · 延期 ${b.overdue.length}`,
+ count: rows.length,
+ chips: chips,
fields: `
`,
+ tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)}
+ ${canR("focus") ? `
` : ""}
+ ${canW("focus") ? `
` : ""}`,
rows: shown,
table: table,
empty: state.keyword
? "没有匹配「" + esc(state.keyword) + "」的重点工作"
: state.focusFilter === "week"
- ? "本周(" + esc(week ? week.full_label : "—") + ")还没有录入重点工作,点右上角「新增重点工作」添加"
+ ? "本周还没有录入重点工作,点右上角「新增重点工作」添加"
: "这一堆还没有条目,点右上角「新增重点工作」添加",
});
- host.innerHTML = toolbar + body;
+ host.innerHTML = body;
bindKeyword("fKeyword");
host.querySelectorAll("[data-ffilter]").forEach((c) =>
c.addEventListener("click", () => { state.focusFilter = c.dataset.ffilter; renderFocus(); })
);
$("#fcArchived").addEventListener("change", (e) => { state.focusArchived = e.target.checked; renderFocus(); });
- $("#btnExportFocus").addEventListener("click", () =>
+ const bex = $("#btnExportFocus");
+ if (bex) bex.addEventListener("click", () =>
download("/api/export/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]))
);
host.querySelectorAll("[data-fopen]").forEach((el) =>
@@ -1181,7 +1457,8 @@
if (row) deleteFocus(row);
})
);
- $("#btnAddFocus").addEventListener("click", () => openFocusDrawer(null, "create"));
+ const bad = $("#btnAddFocus");
+ if (bad) bad.addEventListener("click", () => openFocusDrawer(null, "create"));
}
/** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */
@@ -1205,6 +1482,7 @@
/** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */
async function openFocusDrawer(fid, mode) {
mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view";
+ if (!canW("focus")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `
加载中…
`;
@@ -1246,7 +1524,7 @@
const form = mode === "view" ? `
-
基本信息只读
+
基本信息
${ro("工作内容", esc(f.content) + (f.archived ? `
已归档` : ""))}
${ro("说明", f.detail ? nl2br(f.detail) : "—")}
${ro("预计完成时间", esc(focusDueText(f)))}
@@ -1257,7 +1535,7 @@
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
` : `
-
基本信息${mode === "create" ? "新增" : "编辑"}
+
基本信息
@@ -1267,7 +1545,6 @@
-
条目归在「录入周」这一周,不随预计完成时间变;延期人工开关,更早周次没做完的标上延期就结转进当周视图
`;
$("#drawerBody").innerHTML =
(mode === "view" ? "" : `
`) + form;
@@ -1320,13 +1597,34 @@
}
/* ---------------------------------------------------------------- 项目管理 */
+ /** 删除项目是高危动作:必须逐字输入项目名才能点「永久删除」。列表与抽屉共用一套。 */
+ async function deleteProject(p) {
+ const ok = await confirmDialog({
+ title: "删除项目",
+ danger: true,
+ okText: "永久删除",
+ requireText: p.name,
+ body:
+ `
将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
+
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
+ });
+ if (!ok) return;
+ try {
+ const r = await del("/projects/" + p.id);
+ toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
+ closeDrawer();
+ await afterWrite();
+ } catch (e) {
+ toast("删除失败:" + e.message);
+ }
+ }
+
async function renderProjects() {
saveUI();
const host = $("#view-projects");
host.innerHTML = `
加载中…
`;
const extra = ["archived=" + (state.showArchived ? "all" : "false")];
if (state.categoryId) extra.push("category_id=" + state.categoryId);
- if (state.keyOnly) extra.push("is_key=true");
if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner));
const rows = await api("/projects" + scopeQuery(extra));
const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows;
@@ -1337,17 +1635,12 @@
${state.staff.map((s) => `
`).join("")}
`;
- const keyCount = rows.filter((r) => r.is_key).length;
- const toolbar = `
-
-
-
- ${keywordInputHtml("pKeyword", "搜索项目名称…", 200)}
- ${ownerFilter}
-
-
- ${esc(scopeLabel())} · ${rows.length} 个项目
-
`;
+ const chips = `
`;
+ // 原来挂在左侧菜单栏的分类筛选器,收进卡头,能力不变
+ const catFilter = `
`;
const table = `
@@ -1373,19 +1666,25 @@
| ${p.update_count} |
${p.task_count} |
${esc((p.last_update && p.last_update.period_full) || "—")} |
-
-
-
-
- |
+ ${rowOps({
+ edit: `data-pedit="${p.id}"`,
+ archive: `data-parch="${p.id}"`,
+ archived: p.archived,
+ remove: `data-pdel="${p.id}"`,
+ })} |
`).join("")}
`;
const body = listCard({
title: "重点项目明细",
- sub: "重点项目排在前面 · 列表只读,改数据请点「修改」",
- summary: `共 ${rows.length} 个 · 重点 ${keyCount} 个 · 本${state.gran === "week" ? "周" : "月"}未更新 ${idle} 个`,
+ count: rows.length,
+ chips: chips,
fields: `
`,
+ tools: `${keywordInputHtml("pKeyword", "搜索项目名称…", 180)}
+ ${catFilter}
+ ${ownerFilter}
+ ${canR("projects") ? `
` : ""}
+ ${canW("projects") ? `
` : ""}`,
rows: shown,
table: table,
empty: state.keyword
@@ -1393,9 +1692,14 @@
: "还没有项目",
});
- host.innerHTML = toolbar + body;
+ host.innerHTML = body;
bindKeyword("pKeyword", { enter: true });
+ const pc = $("#pCat");
+ if (pc) pc.addEventListener("change", () => {
+ state.categoryId = pc.value ? Number(pc.value) : null;
+ renderProjects();
+ });
host.querySelectorAll("[data-pidle]").forEach((b) =>
b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); })
);
@@ -1415,14 +1719,22 @@
await afterWrite();
})
);
+ host.querySelectorAll("[data-pdel]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const p = shown.find((x) => x.id === Number(b.dataset.pdel));
+ if (p) deleteProject(p);
+ })
+ );
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
- $("#btnExportProjects").addEventListener("click", () => {
+ const bex = $("#btnExportProjects");
+ if (bex) bex.addEventListener("click", () => {
const q = ["archived=" + (state.showArchived ? "all" : "false")];
if (state.categoryId) q.push("category_id=" + state.categoryId);
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/projects?" + q.join("&"));
});
- $("#btnNewProject").addEventListener("click", () => {
+ const bn = $("#btnNewProject");
+ if (bn) bn.addEventListener("click", () => {
openModal(
`
新建项目
@@ -1479,13 +1791,16 @@
退单: "#7a8394",
};
- /** 项目状态:已验收/交付完成/进行中/待开发/退单 */
- function statusPill(code) {
+ /** 台账里的项目状态:已验收/交付完成/进行中/待开发/退单,和项目状态机不是一套 */
+ function customStatusPill(code) {
if (!code) return `
—`;
const color = CUSTOM_STATUS_COLOR[code] || "#7a8394";
return `
${esc(code)}`;
}
+ /** 风险值是否「高风险」(台账取值为 1.高风险 / 2.低风险) */
+ const isHighRisk = (p) => String(p.risk_level || "").includes("高");
+
/** 风险值:1.高风险 红 / 2.低风险 绿 */
function riskPill(code) {
if (!code) return `
—`;
@@ -1501,9 +1816,6 @@
const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"];
const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"];
const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0;
- /** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */
- const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 };
-
async function renderCustom() {
saveUI();
const host = $("#view-custom");
@@ -1539,39 +1851,35 @@
STATUS_OPTIONS.map((s) => `
`).join("");
const originFilterHtml = `
` +
CUSTOM_ORIGINS.map((o) => `
`).join("");
- // 推动验收计划:取数据里实际出现过的值
- const planVals = Array.from(new Set(allRows.map((p) => String(p.accept_plan || "").trim()).filter(Boolean))).sort();
- const planFilterHtml = `
` +
- planVals.map((v) => `
`).join("");
const isList = state.customTab !== "weekly" && state.customTab !== "annual";
- const toolbar = `
-
-
-
-
-
- ${isList ? `
- ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)}
-
-
-
-
-
-
- 全部 ${allRows.length} 个项目`
- : state.customTab === "annual"
- ? `计收按「验收时间」、签单按「下单时间」归属季度`
- : `${esc(scopeLabel())} · 共 ${allRows.length} 个项目`}
-
`;
+ // 三个 tab 用顶部「周/月」那套分段控件,筛选器和主按钮(导出 / 新建)放下一行
+ const head = {
+ chips: `
+
+
+
+
+
`,
+ tools: isList ? `
+ ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)}
+
+
+
+ ${canR("custom") ? `
` : ""}
+ ${canW("custom") ? `
` : ""}` : "",
+ };
- host.innerHTML = toolbar + (
- state.customTab === "weekly" ? renderCustomWeekly(allRows)
- : state.customTab === "annual" ? renderCustomAnnual(allRows)
- : renderCustomList(allRows));
+ host.innerHTML = (
+ state.customTab === "weekly" ? renderCustomWeekly(allRows, head)
+ : state.customTab === "annual" ? renderCustomAnnual(allRows, head)
+ : renderCustomList(allRows, head));
host.querySelectorAll("[data-ctab]").forEach((b) =>
- b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); })
+ b.addEventListener("click", () => {
+ if (b.dataset.ctab !== state.customTab) state.customAnnualEdit = false; // 切 tab 退出年度任务的编辑态
+ state.customTab = b.dataset.ctab; renderCustom();
+ })
);
const os = $("#cOrigin");
if (os) os.addEventListener("change", () => { state.customOrigin = os.value; renderCustom(); });
@@ -1579,34 +1887,31 @@
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
const st = $("#cStatus");
if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); });
- const ap = $("#cAcceptPlan");
- if (ap) ap.addEventListener("change", () => { state.customAcceptPlan = ap.value; renderCustom(); });
if (isList) bindKeyword("cKeyword", { enter: true });
const bn = $("#btnNewCustom");
if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite()));
const bex = $("#btnExportCustom");
if (bex) bex.addEventListener("click", () => {
- // 导出与「项目明细」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字
+ // 导出与「项目总览」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字
const q = ["archived=" + state.customArchived];
if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin));
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus));
- if (state.customAcceptPlan) q.push("accept_plan=" + encodeURIComponent(state.customAcceptPlan));
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/custom?" + q.join("&"));
});
bindCustomList(host);
}
- function renderCustomList(allRows) {
- // 列表:按 关键字 + 归属 + 区域 + 状态 + 推动验收计划 在本地筛选;再按表头排序
+ function renderCustomList(allRows, hdr) {
+ hdr = hdr || {};
+ // 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;再按表头排序
const kw = (state.keyword || "").trim();
let list = allRows;
if (kw) list = list.filter((p) => String(p.name || "").includes(kw));
if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin);
if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion);
if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus);
- if (state.customAcceptPlan) list = list.filter((p) => String(p.accept_plan || "").trim() === state.customAcceptPlan);
const s = state.customSort || { key: "update_date", dir: "desc" };
const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || ""));
const view = list.slice().sort((a, b) => {
@@ -1649,18 +1954,10 @@
${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")}
${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")}
-
「已下单项目 / 合同额」与「本季度·本年下单」只统计 华智下单 / 存量项目 / 汇智直签(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。
`;
+ `;
- const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0);
- const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length;
- const accepted = view.filter((p) => p.project_status === "已验收").length;
-
- const sortLabel = { sign_date: "下单时间", amount: "订单金额", update_date: "更新时间" }[s.key] || "更新时间";
return `
-
-
定开项目总览按「${sortLabel}」${s.dir === "asc" ? "升序" : "降序"} · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 点表头(下单时间 / 订单金额 / 更新时间)可排序;列表只读,改数据请点「修改」
- 人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个
-
+ ${cardHead({ title: "定开项目总览", count: view.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })}
${statBar}
${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`}
`;
@@ -1711,38 +2008,61 @@
${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()} |
${money(p.amount)} |
${esc(p.market_owner || "—")} |
-
${statusPill(p.project_status)} |
+
${customStatusPill(p.project_status)} |
${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"} |
${p.accept_date ? esc(p.accept_date) : "—"} |
${esc(p.accept_plan || "—")} |
${riskPill(p.risk_level)} |
${esc(p.update_date || "—")} |
${esc((p.remark || "").slice(0, 40)) || "—"} |
- ${ops ? `
| ` : ""}
+ ${ops ? `
${rowOps({
+ edit: `data-cedit="${p.id}"`,
+ archive: `data-carch="${p.id}"`,
+ archived: p.archived,
+ remove: `data-cdel="${p.id}"`,
+ })} | ` : ""}
`).join("")}
`;
}
- /** 本周进展:更新时间落在本周(周一~周日)的项目;并按 验收/下单/提前交付/进行中 分类统计 */
- function renderCustomWeekly(allRows) {
- const d = new Date();
- const dow = (d.getDay() + 6) % 7; // 周一 = 0
- const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow);
- const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6);
- const iso = (x) => x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0");
- const start = iso(mon), end = iso(sun);
+ /** 定开项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径:
+ · 本周新增:下单时间落在本期 ∧(已下单 ∨ 提前交付),和「更新时间」无关
+ · 本周验收:更新时间落在本期 ∧ 项目状态为「已验收」
+ rows 传 /custom/projects 全量或 /weekly 的行都行,两者都带 sign_date / update_date / origin / project_status。 */
+ function customWeeklySections(rows, sc) {
+ const inScope = (v) => {
+ const s = String(v || "").slice(0, 10);
+ return !!(sc && s && s >= sc.start && s <= sc.end);
+ };
+ const added = rows.filter((r) => inScope(r.sign_date) && (isPlaced(r) || r.origin === "提前交付"));
+ const accepted = rows.filter((r) => inScope(r.update_date) && r.project_status === "已验收");
+ const inAdded = {}, inAccepted = {};
+ added.forEach((r) => { inAdded[r.id] = true; });
+ accepted.forEach((r) => { inAccepted[r.id] = true; });
+ const seen = {}, listed = [];
+ [added, accepted].forEach((list) => list.forEach((r) => {
+ if (!seen[r.id]) { seen[r.id] = true; listed.push(r); }
+ }));
+ return { added, accepted, listed };
+ }
+
+ /** 本周进展:更新时间落在「顶栏选中周期」内的项目;并按 验收/下单/提前交付/进行中 分类统计 */
+ function renderCustomWeekly(allRows, hdr) {
+ hdr = hdr || {};
+ const sc = scopeRange();
+ if (!sc) return `
没有取到当前周期的起止日期(刚更新过后端的话,请重启服务后刷新)
`;
+ const start = sc.start, end = sc.end;
const dayOf = (v) => String(v || "").slice(0, 10);
- const inWeek = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; };
- const week = allRows.filter((p) => inWeek(p.update_date)); // 更新时间在本周
+ const inScope = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; };
+ const rows = allRows.filter((p) => inScope(p.update_date)); // 更新时间在本期
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
- const acc = week.filter((p) => p.project_status === "已验收");
- // 新增项目:按「下单时间」在本周的新下单 / 提前进行中项目(不是「本周更新」)
- const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付"));
- // 进行中项目:本周更新的「进行中 + 交付完成」
- const doing = week.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成");
+ // 新增项目 / 验收情况:与工作看板右栏共用 customWeeklySections,两处口径必须一致
+ const { added: newDeal, accepted: acc } = customWeeklySections(allRows, sc);
+ // 进行中项目:本期更新的「进行中 + 交付完成」
+ const doing = rows.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成");
const stat = (label, rows, color) =>
`
${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`;
@@ -1752,23 +2072,23 @@
${stat("新增项目", newDeal, "var(--violet)")}
${stat("进行中项目", doing, "var(--primary)")}
-
本周 = ${start} ~ ${end};新增项目 = 按「下单时间」在本周的新下单 / 提前进行中项目(已下单 + 提前交付);验收情况 / 进行中项目 为「更新时间」在本周的项目(进行中项目 = 进行中 + 交付完成)。
`;
+ `;
return `
-
-
本周进展更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称
- 验收 ${acc.length} · 新增 ${newDeal.length} · 进行中 ${doing.length}
-
+ ${cardHead({ title: "本周进展", count: rows.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })}
${statBar}
- ${week.length
- ? customTableHtml(week.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false)
- : `
本周(${start} ~ ${end})没有更新的定开项目
`}
+ ${rows.length
+ ? customTableHtml(rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false)
+ : `
本期没有更新的定开项目
`}
`;
}
- /** 年度任务:季度计收目标(《年度任务.xlsx》)+ 按「验收时间 / 下单时间」回填的完成数据 */
- function renderCustomAnnual(allRows) {
- const Y = new Date().getFullYear();
+ /** 年度任务:季度计收目标(可在页面上改,存后端)+ 按「验收时间 / 下单时间」回填的完成数据 */
+ function renderCustomAnnual(allRows, hdr) {
+ hdr = hdr || {};
+ const meta = annualTargetMeta();
+ const Y = meta.year;
+ const saved = meta.targets;
const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; };
const signQ = (d) => {
const s = String(d || "");
@@ -1777,36 +2097,40 @@
};
const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元
const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
+ const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
+ const targetOf = (q) => Number(saved[String(q)] || 0);
const rows = [1, 2, 3, 4].map((q) => {
const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目
const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单)
- const target = CUSTOM_ANNUAL_TARGETS[q] || 0;
const accAmt = sumW(acc);
- return { q, target, accN: acc.length, accAmt, rate: target ? (accAmt / target) * 100 : null, signN: sign.length, signAmt: sumW(sign) };
+ return { q, target: targetOf(q), accN: acc.length, accAmt, signN: sign.length, signAmt: sumW(sign) };
});
const tot = rows.reduce((a, r) => ({
target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt,
signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt,
}), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 });
- tot.rate = tot.target ? (tot.accAmt / tot.target) * 100 : null;
- const tr = (label, r, strong) => `
-
- | ${label} |
- ${fmt(r.target)} |
+ const editing = !!state.customAnnualEdit;
+ const tr = (r) => `
+
+ | Q${r.q} |
+ ${editing
+ ? ``
+ : fmt(r.target)} |
${r.accN} |
${fmt(r.accAmt)} |
- ${r.rate == null ? "—" : r.rate.toFixed(1) + "%"} |
+ ${rate(r.target, r.accAmt)} |
${r.signN} |
${fmt(r.signAmt)} |
`;
return `
-
-
年度任务(${Y}年)计收目标取自《年度任务.xlsx》;完成数据按时间自动回填
- 已验收 ${tot.accN} 个 · 新增签单 ${tot.signN} 个
-
+ ${cardHead({
+ title: "年度任务(" + Y + "年)", count: Y + "年",
+ chips: hdr.chips, chipsRow: true,
+ tools: canW("custom") ? `
` : "",
+ })}
@@ -1819,16 +2143,74 @@
| 新增签单项目数量金额(万元) |
- ${rows.map((r) => tr("Q" + r.q, r, false)).join("")}
- ${tr("合计", tot, true)}
+ ${rows.map(tr).join("")}
+
+ | 合计 |
+ ${fmt(tot.target)} |
+ ${tot.accN} |
+ ${fmt(tot.accAmt)} |
+ ${rate(tot.target, tot.accAmt)} |
+ ${tot.signN} |
+ ${fmt(tot.signAmt)} |
+
-
计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(华智下单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。
+
`;
}
- /** 项目总览表:列表只读,行内只有「修改」;名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */
+ /** 年度计收目标(万元):后端存一年一套,没配过时返回《年度任务.xlsx》的默认值 */
+ function annualTargetMeta() {
+ const a = (state.customMeta && state.customMeta.annual_targets) || {};
+ return { year: a.year || new Date().getFullYear(), targets: a.targets || {} };
+ }
+
+ /** 年度目标:默认只读,点「编辑」进入编辑模式(按钮变「保存」),改数字实时重算完成率与合计 */
+ function bindAnnualTargets(host) {
+ const btn = host.querySelector("#atSave");
+ if (!btn) return;
+ if (!state.customAnnualEdit) {
+ btn.addEventListener("click", () => { state.customAnnualEdit = true; renderCustom(); });
+ return;
+ }
+ const meta = annualTargetMeta();
+ const save = btn;
+ const inputs = Array.prototype.slice.call(host.querySelectorAll("[data-at]"));
+ const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 });
+ const valOf = (q) => {
+ const el = inputs.filter((i) => i.dataset.at === String(q))[0];
+ return el ? Number(el.value) || 0 : 0;
+ };
+ const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%");
+ const recalc = () => {
+ const total = [1, 2, 3, 4].reduce((a, q) => a + valOf(q), 0);
+ host.querySelectorAll("[data-ate]").forEach((cell) => {
+ const q = Number(cell.dataset.ate);
+ const target = q === 0 ? total : valOf(q);
+ cell.textContent = rate(target, Number(cell.dataset.acc) || 0);
+ });
+ host.querySelector("[data-att]").textContent = fmt(total);
+ };
+ inputs.forEach((i) => i.addEventListener("input", recalc));
+ save.addEventListener("click", async () => {
+ const targets = {};
+ inputs.forEach((i) => { targets[i.dataset.at] = Number(i.value) || 0; });
+ save.disabled = true;
+ try {
+ state.customMeta.annual_targets = await put("/custom/annual-targets", { year: meta.year, targets: targets });
+ } catch (e) {
+ save.disabled = false;
+ toast("保存失败:" + e.message);
+ return;
+ }
+ state.customAnnualEdit = false;
+ toast("已保存年度目标");
+ renderCustom();
+ });
+ }
+
+ /** 项目总览表:行内「修改 / 归档 / 删除」,名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */
function bindCustomList(host) {
host.querySelectorAll("[data-copen]").forEach((a) =>
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
@@ -1836,6 +2218,20 @@
host.querySelectorAll("[data-cedit]").forEach((b) =>
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
);
+ const findRow = (id) => (state.customRows || []).find((x) => x.id === id);
+ host.querySelectorAll("[data-carch]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const p = findRow(Number(b.dataset.carch));
+ if (p) archiveCustomProject(p, afterWrite);
+ })
+ );
+ host.querySelectorAll("[data-cdel]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const p = findRow(Number(b.dataset.cdel));
+ if (p) deleteCustomProject(p, afterWrite);
+ })
+ );
+ bindAnnualTargets(host);
host.querySelectorAll("[data-sort]").forEach((th) =>
th.addEventListener("click", () => {
const k = th.dataset.sort;
@@ -1986,6 +2382,7 @@
/** cid 定开项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
async function openCustomDrawer(cid, mode) {
mode = mode === "edit" ? "edit" : "view";
+ if (!canW("custom")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `
加载中…
`;
@@ -1993,8 +2390,10 @@
const d = await api("/custom/projects/" + cid);
state.drawerCustom = d;
const p = d.project;
- $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
- $("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`;
+ $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}${p.archived ? `
已归档` : ""}`;
+ const progPct = (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : null;
+ $("#drawerSub").innerHTML = `${stagePill(p.stage)} ${customStatusPill(p.project_status)}` +
+ (progPct ? `
进度 ${progPct}` : "");
const timeline = d.timeline.slice().reverse().map((t) => `
@@ -2004,13 +2403,7 @@
${nl2br(t.content || "")}
`).join("");
- // 「录一笔交付记录」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
- const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
- const recLabel = recWeek ? recWeek.full_label : "本周";
-
- const ratioLabel = p.manual_ratio !== null && p.manual_ratio !== undefined
- ? `手动 ${(p.manual_ratio * 100).toFixed(0)}%`
- : `按阶段 ${p.revenue_ratio}%`;
+ // 「添加动态」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
const ro = (label, val) => `
`;
const optList = (list, sel) => list.map((v) => `
`).join("");
@@ -2023,7 +2416,7 @@
const numOrDash = (v) => (v === null || v === undefined ? "—" : String(Math.round(v)));
const ledgerSec = `
-
定开项目信息${mode === "edit" ? "编辑" : "只读"}
+
项目信息
${mode === "edit" ? `
@@ -2045,37 +2438,25 @@
` : `
${ro("项目归属", esc(p.origin || "—"))}
${ro("项目ID", esc(p.project_code || "—"))}
- ${ro("办事处", esc(p.office || "—"))}
+ ${ro("办事处", esc(p.office || p.region || "—"))}
${ro("行业", esc(p.industry || "—"))}
${ro("下单时间", esc(p.sign_date || "—"))}
${ro("市场下单人天", numOrDash(p.man_days))}
${ro("市场责任人", esc(p.market_owner || "—"))}
- ${ro("项目状态", statusPill(p.project_status))}
- ${ro("进度", (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")}
+ ${ro("交付负责人", esc(p.owner || "—"))}
+ ${ro("团队成员", nameChips(p.members))}
+ ${ro("客户", esc(p.customer || "—"))}
+ ${ro("项目状态", customStatusPill(p.project_status))}
+ ${ro("进度", progPct || "—")}
${ro("验收时间", esc(p.accept_date || "—"))}
${ro("推动验收计划", esc(p.accept_plan || "—"))}
${ro("风险值", riskPill(p.risk_level))}
+ ${ro("交付环节", `
${pipelineBar(p, true)}
`)}
+ ${ro("备注", p.remark ? nl2br(p.remark) : "—")}
${ro("更新时间", esc(p.update_date || "—"))}
`}
`;
- // 基础信息:只读展示(编辑请用上方「定开项目信息」表单)
- const baseSec = `
-
-
基础信息只读
- ${ro("项目名称", esc(p.name) + (p.archived ? `
已归档` : ""))}
- ${ro("阶段", stagePill(p.stage))}
- ${ro("合同额", money(p.amount))}
- ${ro("开发进度", Math.round(p.progress) + "%")}
- ${ro("确认比例", `${esc(ratioLabel)} · 可确认 ${money(p.confirmed_amount)}`)}
- ${ro("交付环节", `
${pipelineBar(p, true)}
`)}
- ${ro("区域", esc(p.region || "—"))}
- ${ro("交付负责人", esc(p.owner || "—"))}
- ${ro("团队成员", nameChips(p.members))}
- ${ro("客户", esc(p.customer || "—"))}
- ${ro("备注", p.remark ? nl2br(p.remark) : "—")}
-
`;
-
drawerActions(mode === "edit" ? `
` : `
@@ -2088,12 +2469,11 @@
${mode === "edit" ? `
` : ""}
${ledgerSec}
${mode === "view" ? `
- ${baseSec}
-
收入确认计算值 · 只读
+
收入确认
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
-
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
+
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
@@ -2104,34 +2484,30 @@
-
周期执行记录(${d.updates.length})附表 · 只读
+
交付记录(${d.updates.length})
- 附表 · 更新记录
-
- 到「更新录入 · 定开项目」写入
+
+
- ${d.updates.length ? d.updates.map((u) => `
-
-
${esc(u.period_full || "")}${stagePill(u.stage)}${u.progress !== null ? `${u.progress}%` : ""}
-
${nl2br(u.content || u.summary || "")}
- ${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
- ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
-
`).join("") : `
暂无记录
`}
+ ${d.updates.length ? d.updates.map((u) => updateItem(u, "custom")).join("") : `
暂无交付记录
`}
- ${d.timeline.length ? `
` : ""}
+ ${d.timeline.length ? `
` : ""}
` : ""}`;
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
- on("#cdAddUpd", () => {
+ on("#cdAddUpd", () =>
+ openUpdateDialog({ kind: "custom", periodId: state.weekId, customProjectId: cid, stage: p.stage, onDone: () => openCustomDrawer(cid, "view") }));
+ on("#cdIntake", () => {
state.intakeType = "custom";
state.intakePeriodId = state.weekId;
customIntakeId = cid;
closeDrawer();
setView("intake");
});
+ bindUpdateOps($("#drawerBody"), d.updates, () => openCustomDrawer(cid, "view"));
on("#cdCancel", () => openCustomDrawer(cid, "view"));
on("#cdSave", async () => {
const btn = $("#cdSave");
@@ -2182,14 +2558,11 @@
const mode = intakeMode();
return `
-
录入方式
-
${mode === "ai"
- ? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终只写 1 条"
- : "填进去什么就存什么,不拆解、不改写、不动项目进度"}
+ ${mode === "ai" ? `
AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持原文,最终只写 1 条` : ""}
`;
}
@@ -2209,7 +2582,7 @@
);
}
- /* ------------------------------------------------ 定开项目录入 */
+ /* ------------------------------------------------ 定制项目录入 */
async function renderCustomIntake(host) {
const projects = await api("/custom/projects");
const weeks = state.periods.weeks.slice().reverse();
@@ -2222,18 +2595,16 @@
host.innerHTML = `
-
-
+
+
${intakeModeSegHtml()}
-
定开项目更新录入${intakeMode() === "ai"
- ? "给已有定开项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入"
- : "给已有定开项目记一期:内容原样存成 1 条,不拆解、不改写"}
-
+
定制项目 · 交付记录录入
+
-
1选择定开项目与周期
+
1选择定制项目与周期
-
项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧「+ 新建定开项目」先建档,建好会自动选中回到这里。
2粘贴该项目的原始内容
@@ -2296,12 +2666,12 @@
on("#btnCDirect", commitCustomDirect);
}
- /** 定开项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
+ /** 定制项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
async function commitCustomDirect() {
const raw = $("#cInRaw").value.trim();
if (!raw) return toast("请先填写内容");
const id = Number($("#cInProject").value);
- if (!id) return toast("请选择定开项目");
+ if (!id) return toast("请选择定制项目");
const pid = Number($("#cInPeriod").value);
if (!pid) return toast("请选择周期");
const btn = $("#btnCDirect");
@@ -2333,7 +2703,7 @@
if (!raw) return toast("请先粘贴内容");
const sel = $("#cInProject");
const id = Number(sel.value);
- if (!id) return toast("请选择定开项目");
+ if (!id) return toast("请选择定制项目");
const btn = useAI ? $("#btnCParse") : $("#btnCRule");
const old = btn.innerHTML;
@@ -2342,7 +2712,7 @@
try {
const r = await post("/custom/ai/parse", {
custom_project_id: id,
- project_name: name,
+ project_name: null,
period_id: Number($("#cInPeriod").value),
raw_text: raw,
use_ai: useAI,
@@ -2383,7 +2753,7 @@
- 正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定开项目」
+ 正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制项目」
`;
$("#btnCCommit").addEventListener("click", commitCustomIntake);
@@ -2435,6 +2805,11 @@
async function renderIntake() {
const host = $("#view-intake");
+ // 录入页整个页面都是写操作,只读角色没有可用的部分,直接给说明
+ if (!canW("intake")) {
+ host.innerHTML = `
「更新录入」是写操作,当前角色是只读权限,无法录入内容。
`;
+ return;
+ }
if (!state.customMeta) state.customMeta = await api("/custom/meta");
if (state.intakeType === "custom") return renderCustomIntake(host);
@@ -2458,12 +2833,12 @@
host.innerHTML = `
-
-
+
+
${intakeModeSegHtml()}
-
更新录入${mode === "ai" ? "选择项目 → 粘贴原始内容 → AI 整理 → 确认写入" : "选择项目 → 填写内容 → 原样写入 1 条"}
+
自研产品 · 执行记录录入
1选择项目与周期
@@ -2494,6 +2869,7 @@
const sel = $("#intakeProject");
sel.addEventListener("change", () => {
+ if (sel.value !== "__new") intakeProjectId = Number(sel.value);
$("#intakeNewProject").style.display = sel.value === "__new" ? "" : "none";
});
$("#intakePeriod").addEventListener("change", async (e) => {
@@ -2520,7 +2896,7 @@
}
/**
- * 重点项目 · 直接写入:填入的内容一字不改存成 1 条执行记录。
+ * 自研产品 · 直接写入:填入的内容一字不改存成 1 条执行记录。
* 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。
*/
async function commitDirectWrite() {
@@ -2605,7 +2981,7 @@
- 正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「周期看板」中立即查看
+ 正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「工作看板」中立即查看
`;
$("#btnCommit").addEventListener("click", () => commitIntake(r, raw));
@@ -2640,7 +3016,7 @@
model: r.parsed.model || null,
});
toast(`已写入 ${res.created.length} 条执行记录`);
- $("#intakePreview").innerHTML = `
写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「周期看板」查看。
`;
+ $("#intakePreview").innerHTML = `
写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「工作看板」查看。
`;
await afterWrite();
} catch (e) {
toast("写入失败:" + e.message);
@@ -2658,10 +3034,11 @@
api("/periods"),
]);
const weeks = periods.weeks.slice().reverse();
+ const rw = canW("settings");
host.innerHTML = `
-
AI 整理配置OpenAI 兼容协议
+
AI 整理配置
${state.meta.ai.enabled
? `
已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})
`
@@ -2670,7 +3047,7 @@
-
+
${rw ? `` : `只读权限`}
兼容:DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
例:Ollama → Base URL
http://localhost:11434/v1,模型
qwen2.5:14b,Key 任意填。
@@ -2697,8 +3074,8 @@
-
周期管理共 ${periods.weeks.length} 个周期 · 录入新周期数据前可先在这里新增
-
+ 周期管理
+ ${rw ? `` : ""}
@@ -2709,14 +3086,16 @@
| ${esc(w.full_label)} (原标签:${esc(w.label)}) |
${w.update_count || 0} |
${w.custom_update_count || 0} |
- |
+ ${rw ? `` : ""} |
`).join("")}
`;
- $("#btnAddPeriod").addEventListener("click", async () => {
+ if (!rw) host.querySelectorAll("#cfgBase,#cfgKey,#cfgModel,#cfgTemp").forEach((i) => { i.disabled = true; });
+ const bap = $("#btnAddPeriod");
+ if (bap) bap.addEventListener("click", async () => {
const id = await createNewPeriod();
if (id) renderSettings();
});
@@ -2736,7 +3115,8 @@
}
})
);
- $("#btnSaveCfg").addEventListener("click", async () => {
+ const bsc = $("#btnSaveCfg");
+ if (bsc) bsc.addEventListener("click", async () => {
await put("/settings", {
values: {
ai_api_base: $("#cfgBase").value.trim(),
@@ -2755,23 +3135,29 @@
/** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
async function openDrawer(pid, mode) {
mode = mode === "edit" ? "edit" : "view";
+ if (!canW("projects")) mode = "view";
$("#drawerMask").classList.add("open");
$("#drawer").classList.add("open");
$("#drawerBody").innerHTML = `
加载中…
`;
drawerActions("");
- const d = await api("/projects/" + pid);
+ let d = await api("/projects/" + pid);
state.drawerProject = d;
+ // 弹窗里改完记录,只重画下面的周期反馈,抽屉不关闭、滚动位置不丢
+ const reloadFeed = async () => {
+ d = await api("/projects/" + pid);
+ state.drawerProject = d;
+ renderFeed();
+ };
const p = d.project;
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
- $("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} · 进度 ${Math.round(p.progress)}% · ${p.update_count} 条执行记录 / ${p.task_count} 个子任务`;
+ $("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)}
进度 ${Math.round(p.progress)}%`;
const ro = (label, val) => `
`;
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
const baseSec = mode === "edit" ? `
-
基础信息编辑
-
只改基础信息,不会动下面的周期记录
+
项目信息
` : `
-
基础信息只读
+
项目信息
${ro("项目名称", esc(p.name) + (p.archived ? `
已归档` : ""))}
${ro("分类", catPill(p.category))}
${ro("状态", statusPill(p.status))}
@@ -2812,7 +3194,7 @@
const updItem = (u) => `
-
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}
+
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}${updOps("project", u)}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""}
${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
@@ -2840,15 +3222,14 @@
}
const FEED_RANGES = {
- week: [[1, "本周"], [4, "近 4 周"], [12, "近 12 周"], [0, "全部"]],
- month: [[1, "本月"], [3, "近 3 月"], [6, "近 6 月"], [0, "全部"]],
+ week: [[2, "近 2 周"], [0, "全部"]],
+ month: [[1, "近 1 月"], [0, "全部"]],
};
- // 附表的写入走「更新录入」,和基础信息的保存彻底分成两条路
- const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
- const recLabel = recWeek ? recWeek.full_label : "本周";
+ // 「添加执行记录」直接去「更新录入」页,不再弹录入窗
const gotoIntake = () => {
state.intakeType = "general";
+ state.intakeMode = "direct";
state.intakePeriodId = state.weekId;
intakeProjectId = pid;
closeDrawer();
@@ -2874,7 +3255,7 @@
const bar = `
-
查看范围(只读筛选)
+
@@ -2885,7 +3266,9 @@
-
显示 ${win.length} / ${units.length} ${unitName} · ${nUpd} 条${first > 0 ? ` · 更早还有 ${first} ${unitName}` : ""}
+
+
+
`;
// win 是按升序取的窗口,展示时倒过来:新的在前
@@ -2896,18 +3279,14 @@
${t.updates.map(updItem).join("")}`).join("")}`).join("")
: `
还没有执行记录
`;
- host.innerHTML = `
周期反馈附表 · 只读
-
- 附表 · 更新记录
-
- 到「更新录入」页粘贴原文写入
-
${bar}
${body}
`;
- host.querySelector("#dwAddUpd").addEventListener("click", gotoIntake);
+ host.innerHTML = `
`;
+ host.querySelector("#dwAddUpd").addEventListener("click", () => gotoIntake());
+ bindUpdateOps(host, d.timeline.flatMap((t) => t.updates), reloadFeed);
host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => {
const g = b.dataset.g;
if (state.dw.group === g) return;
- state.dw = { group: g, range: g === "week" ? 4 : 3, back: 0 };
+ state.dw = { group: g, range: g === "week" ? 2 : 1, back: 0 };
renderFeed();
}));
host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => {
@@ -2927,11 +3306,11 @@
`).join("");
// 周期反馈单独渲染,切换范围/周期不必重新请求
- state.dw = { group: "week", range: 4, back: 0 };
+ state.dw = { group: "week", range: 2, back: 0 };
$("#drawerBody").innerHTML =
(mode === "edit" ? `
` : "") +
baseSec + `
` +
- `
历史进度走势附表 · 只读
${timeline || '
暂无
'}
`;
+ `
历史进度走势
${timeline || '
暂无
'}
`;
renderFeed();
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
@@ -2974,27 +3353,7 @@
closeDrawer();
await afterWrite();
});
- // 删除是高危动作:必须逐字输入项目名才能点「永久删除」
- on("#dwDelete", async () => {
- const ok = await confirmDialog({
- title: "删除项目",
- danger: true,
- okText: "永久删除",
- requireText: p.name,
- body:
- `
将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
-
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`,
- });
- if (!ok) return;
- try {
- const r = await del("/projects/" + pid);
- toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
- closeDrawer();
- await afterWrite();
- } catch (e) {
- toast("删除失败:" + e.message);
- }
- });
+ on("#dwDelete", () => deleteProject(p));
}
// 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右
@@ -3101,7 +3460,7 @@
-
人员归属汇智 / 云数 / 外服
+
人员归属
${sum.affiliations.map((a) => `
@@ -3112,7 +3471,7 @@
-
花名册外的负责人历史数据里对不上花名册的人名
+
花名册外的负责人
${sum.outside.length ? sum.outside.map((o) => `
@@ -3126,15 +3485,6 @@
`;
- const toolbar = `
-
-
- ${keywordInputHtml("stKeyword", "搜索姓名…", 200)}
-
-
- 在册 ${sum.total} 人
-
`;
-
const table = `
| 姓名 | 归属小组 | 人员归属 | 说明 |
@@ -3144,7 +3494,9 @@
${d.items.map((m) => `
|
|
`).join("")}
`;
const body = listCard({
title: "人员花名册",
- sub: "小组 / 归属 / 说明可直接在表内改",
- summary: `当前 ${d.items.length} 人 · 带项目 ${sum.with_project} 人 · 花名册外 ${sum.outside.length} 人`,
+ count: d.items.length,
fields: `
-
`,
+
+ ${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`,
+ tools: `${canR("staff") ? `
` : ""}
+ ${canW("staff") ? `
` : ""}`,
rows: d.items,
table: table,
empty: state.keyword
@@ -3182,13 +3537,15 @@
: (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"),
});
- host.innerHTML = toolbar + kpi + body;
+ host.innerHTML = kpi + body;
bindKeyword("stKeyword");
$("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); });
$("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); });
- $("#btnNewStaff").addEventListener("click", () => openStaffDialog(null));
- $("#btnExportStaff").addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived));
+ const bn = $("#btnNewStaff");
+ if (bn) bn.addEventListener("click", () => openStaffDialog(null));
+ const bex = $("#btnExportStaff");
+ if (bex) bex.addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived));
host.querySelectorAll("[data-sedit]").forEach((b) =>
b.addEventListener("click", () => {
const m = d.items.find((x) => x.id === Number(b.dataset.sedit));
@@ -3211,9 +3568,11 @@
host.querySelectorAll("[data-outside]").forEach((b) =>
b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside }))
);
+ const roStaff = !canW("staff");
host.querySelectorAll("tr[data-sid]").forEach((tr) => {
const id = Number(tr.dataset.sid);
- tr.querySelectorAll("[data-sf]").forEach((inp) =>
+ tr.querySelectorAll("[data-sf]").forEach((inp) => {
+ if (roStaff) { inp.disabled = true; return; }
inp.addEventListener("change", async () => {
const f = inp.dataset.sf;
try {
@@ -3221,33 +3580,238 @@
toast("已保存");
await afterWrite();
} catch (e) { toast("保存失败:" + e.message); }
- })
- );
+ });
+ });
+ });
+ }
+
+ /* ------------------------------------------------------ 权限管理(RBAC) */
+ /** 角色 = 一张「菜单 × 能改/只读」矩阵;用户挂一个角色。下拉改完即存,后端立刻生效。 */
+ async function renderRbac() {
+ const host = $("#view-rbac");
+ host.innerHTML = `
加载中…
`;
+ const [meta, roles, users] = await Promise.all([
+ api("/rbac/meta"), api("/rbac/roles"), api("/rbac/users"),
+ ]);
+ const rw = canW("rbac");
+ const menus = meta.menus;
+ const save = async (fn) => {
+ try { await fn(); } catch (e) { toast("保存失败:" + e.message); }
+ await renderRbac();
+ };
+
+ const levelCell = (role, m) => {
+ const cur = (role.menus || {})[m.key] || "";
+ return `
| `;
+ };
+ const roleTable = `
+
+ | 角色 / 说明 |
+ ${menus.map((m) => ``).join("")}
+ 用户 |
+ ${rw ? `操作 | ` : ""}
+
+ ${roles.map((r) => `
+ | ${esc(r.name)}${r.is_builtin ? ` 内置` : ""}${r.description ? `${esc(r.description)}` : ""} |
+ ${menus.map((m) => levelCell(r, m)).join("")}
+ ${r.user_count} |
+ ${rw ? `
+
+ ${r.is_builtin ? "" : ``}
+ | ` : ""}
+
`).join("")}
+
`;
+
+ const roleOpts = (sel) => roles.map((r) =>
+ `
`).join("");
+ const userTable = `
+
+ | 姓名 | 登录账号 | 角色 | 可用菜单 | 最近登录 |
+ ${rw ? `操作 | ` : ""}
+
+ ${users.map((u) => {
+ const self = u.id === state.me.id;
+ return `
+ | ${esc(u.name)}${self ? ` 我自己` : ""}${u.active ? "" : ` 已停用`} |
+ ${esc(u.username)} |
+ |
+ ${u.menus ? Object.keys(u.menus).length : 0} |
+ ${esc(u.last_login_at ? u.last_login_at.replace("T", " ").slice(0, 16) : "从未登录")} |
+ ${rw ? `
+
+ ${self ? "" : `
+
+ `}
+ | ` : ""}
+
`;
+ }).join("")}
+
`;
+
+ host.innerHTML =
+ listCard({
+ title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色",
+ tools: rw ? `
` : "",
+ }) +
+ `
` +
+ listCard({
+ title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户",
+ tools: rw ? `
` : "",
+ });
+
+ // ---- 权限矩阵:一个角色的整行一起提交 ----
+ host.querySelectorAll("[data-rmenu]").forEach((sel) =>
+ sel.addEventListener("change", () => {
+ const rid = Number(sel.dataset.rmenu);
+ const mine = state.me.role_id === rid;
+ const next = {};
+ sel.closest("tr").querySelectorAll("[data-rmenu]").forEach((x) => { if (x.value) next[x.dataset.menu] = x.value; });
+ save(() => patch("/rbac/roles/" + rid, { menus: next })).then(() => {
+ if (mine) toast("已更新你所在角色的权限,刷新页面后生效");
+ });
+ })
+ );
+ const bnr = $("#btnNewRole");
+ if (bnr) bnr.addEventListener("click", () => openRoleDialog(null));
+ host.querySelectorAll("[data-redit]").forEach((b) =>
+ b.addEventListener("click", () => openRoleDialog(roles.find((x) => x.id === Number(b.dataset.redit))))
+ );
+ host.querySelectorAll("[data-rdel]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ const r = roles.find((x) => x.id === Number(b.dataset.rdel));
+ const ok = await confirmDialog({ title: "删除角色", danger: true, okText: "确认删除",
+ body: `
确认删除角色 ${esc(r.name)}?该角色下不能还有用户。
` });
+ if (ok) save(() => del("/rbac/roles/" + r.id));
+ })
+ );
+
+ // ---- 用户 ----
+ const bnu = $("#btnNewUser");
+ if (bnu) bnu.addEventListener("click", () => openUserDialog(null, roles));
+ host.querySelectorAll("[data-urole]").forEach((sel) =>
+ sel.addEventListener("change", () =>
+ save(() => patch("/rbac/users/" + Number(sel.dataset.urole), { role_id: Number(sel.value) }))
+ )
+ );
+ host.querySelectorAll("[data-upw]").forEach((b) =>
+ b.addEventListener("click", () =>
+ openPasswordReset(users.find((x) => x.id === Number(b.dataset.upw)))
+ )
+ );
+ host.querySelectorAll("[data-uactive]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const on = b.dataset.on === "1";
+ save(() => patch("/rbac/users/" + Number(b.dataset.uactive), { active: !on }));
+ })
+ );
+ host.querySelectorAll("[data-udel]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ const u = users.find((x) => x.id === Number(b.dataset.udel));
+ const ok = await confirmDialog({ title: "删除用户", danger: true, okText: "确认删除",
+ body: `
确认删除用户 ${esc(u.name)}(${esc(u.username)})?该账号会立即下线。
` });
+ if (ok) save(() => del("/rbac/users/" + u.id));
+ })
+ );
+ }
+
+ /** 角色只影响「能看哪些菜单」,名称说明在这里改,菜单授权在矩阵里勾 */
+ function openRoleDialog(role) {
+ const isNew = !role;
+ openModal(
+ `
${isNew ? "新建角色" : "修改角色"}
+
+
+
+ ${isNew ? `
新建后默认不带任何菜单权限,保存后在矩阵里逐项勾选。
` : ""}
+
+
+ `
+ );
+ $("#mOk").addEventListener("click", async () => {
+ const name = $("#rlName").value.trim();
+ if (!name) return modalError("请填写角色名");
+ const body = { name: name, description: $("#rlDesc").value.trim() };
+ try {
+ if (isNew) await post("/rbac/roles", { name: body.name, description: body.description, menus: {} });
+ else await patch("/rbac/roles/" + role.id, body);
+ closeModal();
+ toast(isNew ? "已新建角色,在矩阵里勾选菜单即可授权" : "已保存");
+ await renderRbac();
+ } catch (e) { modalError(e.message); }
+ });
+ }
+
+ function openUserDialog(user, roles) {
+ const isNew = !user;
+ const u = user || {};
+ openModal(
+ `
${isNew ? "新建用户" : "编辑用户"}
+
+
+
换角色会强制该账号重新登录,权限立即按新角色生效。
+
+
+ `
+ );
+ $("#mOk").addEventListener("click", async () => {
+ const name = $("#usName").value.trim();
+ if (!name) return modalError("请填写姓名");
+ const btn = $("#mOk");
+ btn.disabled = true;
+ try {
+ if (isNew) {
+ const username = $("#usUser").value.trim();
+ const password = $("#usPass").value.trim();
+ if (!username) return void (btn.disabled = false, modalError("请填写登录账号"));
+ await post("/rbac/users", { name: name, username: username, password: password, role_id: Number($("#usRole").value), active: true });
+ } else {
+ await patch("/rbac/users/" + u.id, { name: name });
+ }
+ closeModal();
+ toast(isNew ? `已新建用户「${name}」` : "已保存");
+ await renderRbac();
+ } catch (e) { btn.disabled = false; modalError(e.message); }
+ });
+ }
+
+ function openPasswordReset(user) {
+ openModal(
+ `
重置密码
+
+
给 ${esc(user.name)}(${esc(user.username)})设置新密码。
+
+
重置后该用户的其他登录全部失效,需要用新密码重新登录。
+
+
+ `
+ );
+ $("#mOk").addEventListener("click", async () => {
+ try {
+ await patch("/rbac/users/" + user.id, { password: $("#rsPass").value.trim() });
+ closeModal();
+ toast(`已重置「${user.name}」的密码`);
+ } catch (e) { modalError(e.message); }
});
}
/* ---------------------------------------------------------------- shell */
async function loadCategories() {
+ // 侧栏的分类筛选器已下线,这里只给「自研产品」子 header 上的分类下拉供数据
+ if (!canR("projects")) { state.categories = []; return; }
state.categories = await api("/categories");
- const box = $("#sideCategories");
- box.innerHTML =
- `
` +
- state.categories.map((c) => `
-
`).join("");
- box.querySelectorAll("[data-cat]").forEach((b) =>
- b.addEventListener("click", () => {
- state.categoryId = b.dataset.cat === "" ? null : Number(b.dataset.cat);
- loadCategories();
- if (state.view === "board") renderBoard(); else if (state.view === "projects") renderProjects();
- })
- );
}
function setView(view) {
+ if (!can(view)) view = firstAllowedView();
+ if (!view) { hostNoPermission(); return; }
state.view = view;
saveUI();
$$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view));
@@ -3266,10 +3830,10 @@
else if (v === "staff") await renderStaff();
else if (v === "intake") await renderIntake();
else if (v === "settings") await renderSettings();
+ else if (v === "rbac") await renderRbac();
} catch (e) {
$("#view-" + v).innerHTML = `
加载失败:${esc(e.message)}
`;
}
- $("#sideFoot").innerHTML = `当前周期:${esc(scopeLabel())}
共 ${state.periods.weeks.length} 个周期 / ${state.periods.months.length} 个月`;
}
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
@@ -3303,7 +3867,6 @@
renderPeriodSelect();
refresh();
});
- $("#sideKeyOnly").addEventListener("change", (e) => { state.keyOnly = e.target.checked; refresh(); });
$("#btnIntake").addEventListener("click", () => setView("intake"));
$("#drawerClose").addEventListener("click", closeDrawer);
bindDrawerPeople();
@@ -3326,9 +3889,9 @@
/* ------------------------------------------- 界面状态(刷新后保持) */
const UI_KEY = "unis.ui.v1";
- const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword",
+ const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyword",
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
- "customTab", "customArchived", "customRegion", "customOrigin", "customAcceptPlan", "customSort", "staffArchived", "staffGroup"];
+ "customTab", "customArchived", "customRegion", "customOrigin", "customSort", "staffArchived", "staffGroup"];
function saveUI() {
const o = {};
@@ -3341,21 +3904,39 @@
try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; }
if (!o) return;
UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; });
- if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].indexOf(state.view) < 0) state.view = "board";
+ if (["board", "projects", "custom", "focus", "staff", "intake", "settings", "rbac"].indexOf(state.view) < 0) state.view = "board";
if (state.gran !== "month") state.gran = "week";
if (state.focusFilter === "later") state.focusFilter = "week";
}
+ function hostNoPermission() {
+ document.querySelector(".views").innerHTML =
+ `
当前角色没有分配任何功能菜单,请联系管理员在「权限管理」里授权。
`;
+ }
+
async function main() {
restoreUI();
+ bindAuthForm();
+ try {
+ state.me = await api("/auth/me");
+ } catch (e) {
+ if (e.status === 401) return showAuthGate(); // 首次访问:切到登录页但不显示报错
+ throw e;
+ }
+ if (!state.me.menus || !Object.keys(state.me.menus).length) {
+ bindShell();
+ renderUserChip();
+ return hostNoPermission();
+ }
state.meta = await api("/meta");
- await loadStaff();
await loadPeriods();
+ await loadStaff();
await loadCategories();
bindShell();
+ renderUserChip();
+ applyNavPermissions();
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
- $("#sideKeyOnly").checked = !!state.keyOnly;
$("#globalSearch").value = state.keyword || "";
setView(state.view);
}
diff --git a/web/icons.js b/web/icons.js
index c6c5e5f..6d595c3 100644
--- a/web/icons.js
+++ b/web/icons.js
@@ -23,6 +23,8 @@
"chevron-left": '
',
"chevron-right": '
',
"chevron-down": '
',
+ "chevron-up": '
',
+ "log-out": '
',
"arrow-right": '
',
search: '
',
download: '
',
diff --git a/web/index.html b/web/index.html
index ed80203..22157a4 100644
--- a/web/index.html
+++ b/web/index.html
@@ -19,28 +19,17 @@
-
-
-
-
-
-
-
+
@@ -69,6 +58,7 @@
+
@@ -90,6 +80,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/styles.css b/web/styles.css
index 8032e11..dbb1623 100644
--- a/web/styles.css
+++ b/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; }