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( + ` + + ` + ); + + 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}
重点项目 ${k.key_total} 个
-
${esc(scopeLabel())} · 执行记录
${k.update_count}
覆盖项目 ${k.covered_projects} / ${k.project_total}
-
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
按项目最新进度估算
-
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
本周期需关注条目
-
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
本周期无执行记录
+
执行记录
${k.update_count}
覆盖项目 ${k.covered_projects} / ${k.project_total}
+
${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)}
-
- ${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)} - ${esc(row.owner || "未指派")} - 最近更新 ${esc(row.last_update || "尚无记录")} - ${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""} +
+
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
+
+ ${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)} + ${esc(row.owner || "未指派")} + 最近更新 ${esc(row.last_update || "尚无记录")} + ${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""} +
-
- +
+ ${canW(menu) ? `` : ""}
`; @@ -684,12 +971,12 @@
本期执行记录(${row.updates.length})
${row.updates.length - ? row.updates.map(updateItem).join("") - : `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「录一笔执行记录」补上。
`} + ? row.updates.map((x) => updateItem(x, "project")).join("") + : `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「添加执行记录」补上。
`}
-
进度与状态${esc(row.progress_source)} · 共 ${p.update_count} 条历史记录 / ${d.tasks.length} 个子任务
+
进度与状态
整体进度${row.progress}%
${d.tasks.length ? `
${d.tasks.map((t) => `${esc(t.name)}`).join("")}
` : ""} @@ -715,8 +1002,8 @@ return ` ${weeklyHead(row)}
-
交付环节补录日期会自动推进商务阶段
- ${pipelineBar(p)} +
交付环节
+ ${pipelineBar(p, !canW("custom"))} ${p.remark ? `
${nl2br(p.remark)}
` : ""}
@@ -738,7 +1025,7 @@
本期交付记录(${row.updates.length})
${row.updates.length - ? row.updates.map(updateItem).join("") + ? row.updates.map((x) => updateItem(x, "custom")).join("") : `
本周期没有交付记录。
`}
@@ -754,18 +1041,21 @@ } function bindWeeklyDetail(box, row) { - const inBtn = $("#wkIntake"); - if (inBtn) inBtn.addEventListener("click", () => { - state.intakePeriodId = state.weekId; - if (row.kind === "project") { state.intakeType = "general"; intakeProjectId = row.id; } - else { state.intakeType = "custom"; customIntakeId = row.id; } - setView("intake"); - }); + // 写入后 afterWrite() 会重绘看板并保留当前选中项,这里不用再自己刷新 + const add = $("#wkAddUpd"); + if (add) add.addEventListener("click", () => + openUpdateDialog({ + kind: row.kind, periodId: state.weekId, projectId: row.id, customProjectId: row.id, + status: row.status, stage: row.stage, + }) + ); + bindUpdateOps(box, row.updates); const dw = $("#wkDrawer"); if (dw) dw.addEventListener("click", () => row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id) ); - box.querySelectorAll("[data-cf]").forEach((inp) => + box.querySelectorAll("[data-cf]").forEach((inp) => { + if (!canW("custom")) { inp.disabled = true; return; } inp.addEventListener("change", async () => { const f = inp.dataset.cf; let v = inp.value; @@ -775,8 +1065,8 @@ toast("已保存"); await afterWrite(); } catch (e) { toast("保存失败:" + e.message); } - }) - ); + }); + }); } /** 定开项目本周动态里的一条:整行可点,点进去看该项目详情 */ @@ -792,7 +1082,7 @@ ${esc(r.owner || "未指派")} ${fen(r.amount)} 万 ${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""} - ${r.risks.length ? `风险 ${r.risks.length}` : ""} + ${(r.risks || []).length ? `风险 ${(r.risks || []).length}` : ""}
${r.summary ? `
${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}
` : ""}
`; @@ -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}
本期已更新 ${k.key_updated}
-
${Ico.badge("alert-circle")}
重点项目未更新
${k.key_idle}
每周都要更新一次
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条
${Ico.badge("activity")}
执行记录
${k.update_count}
在办项目 ${k.project_listed} 个
-
${Ico.badge("wallet")}
定开合同额
${wan(k.custom_amount)}
${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万
-
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
本期上报条目
+
${Ico.badge("wallet")}
本季度验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个
+
${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} - 本周动态 ${cNew.length + cUpd.length} 个
+ 本周动态 ${cDyn} 个
${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) => `
${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 @@ - + `).join("")}
${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}"`, + })}
`; 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" ? "新增" : "编辑"}
+
基本信息
${esc(entryWeek)}
@@ -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 @@ - + `).join("")}
${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}"`, + })}
`; 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( ` -
「已下单项目 / 合同额」与「本季度·本年下单」只统计 华智下单 / 存量项目 / 汇智直签(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。
`; + `; - 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) => `
${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)}
比例 ${p.revenue_ratio}%
+
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${p.manual_ratio == null ? "按阶段" : "手动"} ${p.revenue_ratio}%
${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 ? `
阶段流转附表 · 只读
${timeline}
` : ""} + ${d.timeline.length ? `
阶段流转
${timeline}
` : ""} ` : ""}`; 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 @@ - + `).join("")}
${esc(w.full_label)} (原标签:${esc(w.label)}) ${w.update_count || 0} ${w.custom_update_count || 0}${rw ? `` : ""}
`; - $("#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) => `
${val}
`; // 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起 const baseSec = mode === "edit" ? `
-
基础信息编辑
-
只改基础信息,不会动下面的周期记录
+
项目信息
${state.meta.statuses.map((s) => ``).join("")}
- ${peopleEditorHtml("dwOwners", "负责人", p.owners, { - add: "负责人", - // 界面上负责人不分主次,但存储里 owner = owners[0],统计口径照实说明 - hint: "可多位。看板、筛选、导出与人力负载取名单里的第一位做归属统计。", - })} + ${peopleEditorHtml("dwOwners", "负责人", p.owners, { add: "负责人" })} ${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
%
` : `
-
基础信息只读
+
项目信息
${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 = `
执行记录
${bar}
${body}
`; + 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("")}
姓名归属小组人员归属说明
- ${esc(m.name)} + ${canW("staff") + ? `${esc(m.name)}` + : `${esc(m.name)}`} ${m.archived ? `已归档` : ""}
${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"} ${m.custom_pending ? `${fen(m.custom_pending)} 万` : "—"} ${esc(m.last_update || "—")} - - - - ${rowOps({ + edit: `data-sedit="${m.id}"`, + archive: `data-sarch="${m.id}"`, + archived: m.archived, + remove: `data-sdel="${m.id}"`, + })}
`; 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) => ` + + ${menus.map((m) => levelCell(r, m)).join("")} + + ${rw ? `` : ""} + `).join("")} +
角色 / 说明${esc(m.label)}用户操作
${esc(r.name)}${r.is_builtin ? ` 内置` : ""}${r.description ? `${esc(r.description)}` : ""}${r.user_count} + + ${r.is_builtin ? "" : ``} +
`; + + const roleOpts = (sel) => roles.map((r) => + ``).join(""); + const userTable = ` + + + ${rw ? `` : ""} + + ${users.map((u) => { + const self = u.id === state.me.id; + return ` + + + + + + ${rw ? `` : ""} + `; + }).join("")} +
姓名登录账号角色可用菜单最近登录操作
${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) : "从未登录")} + + ${self ? "" : ` + + `} +
`; + + 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( + ` + + ` + ); + $("#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( + ` + + ` + ); + $("#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( + ` + + ` + ); + $("#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; }