From bcb3feb3bbcd29384d6bc4c371f5219eb6ebfcfe Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Wed, 23 Sep 2026 10:49:52 +0800 Subject: [PATCH] V 0.1.1 --- .DS_Store | Bin 6148 -> 6148 bytes README.md | 11 +- app/__pycache__/__init__.cpython-313.pyc | Bin 158 -> 148 bytes app/__pycache__/ai_parser.cpython-313.pyc | Bin 20266 -> 20256 bytes app/__pycache__/config.cpython-313.pyc | Bin 2852 -> 3227 bytes app/__pycache__/constants.cpython-313.pyc | Bin 21650 -> 21760 bytes app/__pycache__/db.cpython-313.pyc | Bin 1266 -> 1256 bytes app/__pycache__/export.cpython-313.pyc | Bin 5344 -> 5334 bytes app/__pycache__/main.cpython-313.pyc | Bin 3722 -> 4297 bytes app/__pycache__/models.cpython-313.pyc | Bin 32146 -> 36791 bytes app/__pycache__/schema_patch.cpython-313.pyc | Bin 2443 -> 2433 bytes app/__pycache__/schemas.cpython-313.pyc | Bin 5854 -> 8447 bytes app/__pycache__/security.cpython-313.pyc | Bin 0 -> 10837 bytes app/config.py | 6 + app/constants.py | 3 + app/main.py | 18 +- app/models.py | 83 + .../__pycache__/__init__.cpython-313.pyc | Bin 166 -> 156 bytes app/routers/__pycache__/ai.cpython-313.pyc | Bin 7721 -> 7711 bytes app/routers/__pycache__/auth.cpython-313.pyc | Bin 0 -> 5614 bytes app/routers/__pycache__/board.cpython-313.pyc | Bin 26488 -> 27025 bytes .../__pycache__/custom.cpython-313.pyc | Bin 36753 -> 46166 bytes .../__pycache__/export.cpython-313.pyc | Bin 22319 -> 20076 bytes app/routers/__pycache__/focus.cpython-313.pyc | Bin 8692 -> 8682 bytes .../__pycache__/projects.cpython-313.pyc | Bin 23635 -> 24853 bytes app/routers/__pycache__/rbac.cpython-313.pyc | Bin 0 -> 11385 bytes app/routers/__pycache__/staff.cpython-313.pyc | Bin 17198 -> 17188 bytes .../__pycache__/weekly.cpython-313.pyc | Bin 16344 -> 17073 bytes app/routers/auth.py | 72 + app/routers/board.py | 8 + app/routers/custom.py | 105 +- app/routers/projects.py | 29 +- app/routers/rbac.py | 161 ++ app/routers/weekly.py | 14 +- app/schemas.py | 57 + app/security.py | 210 +++ data/board.db | Bin 385024 -> 409600 bytes run.sh | 13 +- web/app.js | 1451 ++++++++++++----- web/icons.js | 2 + web/index.html | 55 +- web/styles.css | 164 +- 42 files changed, 1967 insertions(+), 495 deletions(-) create mode 100644 app/__pycache__/security.cpython-313.pyc create mode 100644 app/routers/__pycache__/auth.cpython-313.pyc create mode 100644 app/routers/__pycache__/rbac.cpython-313.pyc create mode 100644 app/routers/auth.py create mode 100644 app/routers/rbac.py create mode 100644 app/security.py diff --git a/.DS_Store b/.DS_Store index 49c529d32889d26e2265bcc1a2b1f66ede67340e..6096b826db28fcee49a538ee57ccde9660f66f10 100644 GIT binary patch delta 221 zcmZoMXfc=|#>B!ku~2NHo+2an#(>?7iv?Ji7`Z3&Flh=iGbAz;Fcc(}7Z)VuvO zFzlFI#8k*7QC)3hW~!rLW;yvClMG|mWNv18@ou0rR6|*CQC?1dUOG^Oak3GUJtJ;y zjNOy9nWgnP8G;yG85|j07<_@W9z!leif2xKGSpT91_q|9K*RL@g8>7>W@qO0jGNgx e_&I=%-^|GJoq009h$9ChP#sVi!{!K)HOv4gem4>T delta 105 zcmZoMXfc=|#>B)qu~2NHo+2a1#(>?7j2x4BSTrX~vf422ob16;$Sql2ZDeVtqhM%a zF!>^@3}g4?2Q2c8J(Ky^q&G*itzq2QAj-U%or9kPXwYUsj_=Hq`9&N#7=VD0fq`Xn Igvc6Z0Ieb!zyJUM 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 0ba0a82c907886b13220fe2c694fec82bfa9b47d..178c3a9267b18f9459cb4299ccce24da7078030d 100644 GIT binary patch delta 64 zcmbQoIE9h>GcPX}0}yOWUow%~SkqWPv^ce>SU)Q>C$m&PJijPAxF9h(RlhVZvp7CC SF)uMawMaj)pg@0Op)LS`@)d*t delta 74 zcmbQjIFFJ0GcPX}0}xC!Sv8T{*x1(DDkiizwWwI%F(o%MFSEF$D6u5JNIyKkDBGzt bC8bio6et*~->SWpl%F?A delta 82 zcmZ25k8#yJM!wIyyj%=G5K^!zi_vBy-*gURJ7=qy(BjmhVtvPy+|0bp;*z4olKdk5 j@cg1|r_z*^O8ruxV0><3USfJ`QA}b%LCofJ96@dXUiuvw diff --git a/app/__pycache__/config.cpython-313.pyc b/app/__pycache__/config.cpython-313.pyc index 981ea7a19a517a33fa787eb1edb57947c52440a5..0a0cf219462def7ac87624bab81f6701c6f1e018 100644 GIT binary patch delta 470 zcmZ1?Hd~VKGcPX}0}!lK-jv15F_BM#X%6#5jf;%R8-H~&a!Z>sfg~9if{Z67NQu}o zYCyOQDL`=rhG6L;smTJ2lDsm(vM4IWF;vPHNymsQP3C8m5|s; zSP&)yvQV1Q8fdFlu%b0kRgqk^Agiji}Vu<3iLPMVXI_xd~2kbjFeZQjo*#uy|d zGF@_#yENFXO(Bpu31K$lnVVHE(WkHMUf_4`L?Jf&CTo?2^ i;MKtQkY8wm(R9m6mNVj3gj^Ojy3B9fz%%(0R}cWF$ZAf**7Z4FdZ zBpobMBoiYTBNQVXBdf#^BWlVB(#*gREThL#B$v*tDYy9q^KNEiYiFyN(BjmhVtvPy z+|0bp;*z4olKdk5@cg1|r_z*^O8ruxV0><3USfJ`QA}b%LCoeg?3Ij@GdSZIEB(AiIiVH$UVOW1Q^6;|~DRR5!r@ diff --git a/app/__pycache__/constants.cpython-313.pyc b/app/__pycache__/constants.cpython-313.pyc index f08f17c60aa709899397460aadfdc621ef4f312b..01bba388a1f594beec78cf7b3f81b233fcdfc63f 100644 GIT binary patch delta 481 zcmbQVlCfbGBj0CUUM>b8=s3S2>z(^VJ_$y%jp{v&jFUHSVN_;hoHF?alQxjU!aSde zaq{FPthPY%5v!3{a7%D&a9ePDaEBgCk**;_aA!7Sk=|q(Hg8V-;I1Nr;O-d1$pviU z(LGWOSu90H`V7Infs94QK)Me|n*{d zBK^dI0{zW=yeBNp(?Nb_XmE6ZFcfm242Bda!=b=IlcmT7sHjK}M8tpy{mqx%-ZL^5 zZ$9j?i;=N&bDft8Bjbe0i@p6BEjEAlE@qUIbq);<@%N2)^z#dK^ob8~403l3366Jh zb#n~$37MSgdz6cZk%0k-7>X@7oA|l13V#q{P}N)!x}o%d=tTwh3kI?c>a2?_n@MB;*q%Gpd#&Oa> O+>e*z43DUv2nPV(LW3Rv delta 373 zcmZoz#W-mtBj0CUUM>b8c-*==Yn#VJJ_$yRjp{v&jD4H8Fe)=L_D{aSqz&Y-FwbXV z?3=uV)fPxTVm0D0WC(7_W-QX3ti|TdsTbT@q#xWCV=%dnO+30?iXn@o$WWglxFe9U z$OuSx0%_ymuHbG1#v+s8${4<2CS7(0hF~rj%Y-Gk2dv8!hiorc)@J|iV{omi}b_ui?W?cQ&KAROM!y%xrup+>8V9Ai3J5Q zn}v8!SZ)?^f5XUFu$j?w7b9ca<~?32jEudLAA0*Unr}ArDQ28}*!Kt*HzNZB5HS>M zY@X=n%F6UfX!6V8EljoCljnzMSSB$uh=wp0>j4!^67ZGfSi|qD!gi2bz*mdopuV85 dA=e=w+mh{&wurAi$4LWmKQ@jttfGFr8~}GZYLEZ` diff --git a/app/__pycache__/db.cpython-313.pyc b/app/__pycache__/db.cpython-313.pyc index a2dfeee23c3f1f0b7d1e446e65d0cb38085a47e9..b5623ad20e257dfc3a7d3a70f0ffe3e9b728fae7 100644 GIT binary patch delta 69 zcmeyw`GS-8GcPX}0}vccUy?O>BX2U3rmlWyacWVqepY5qW~qL7eo=OCL1J>MeraB2 XaeQuKUSfJ`k$z%9f&S*jO#Vy&F?kpc delta 79 zcmaFC`H7SFGcPX}0}xC!S(UYMBX2U3vAMHVOlWaxQL(;bN^WLeW^qYTVo83Het3RS gwo_?JN~L}&P%u6>F)uMawJ0XBpde=ROeTLO05eYrzceqi XI6gNqFEKr}NI$WlK!3A3%T56R9UvFC delta 79 zcmcbn`9PERGcPX}0}xC!S(Vkbk@q>Xv9+^ROlWaxQL(;bN^WLeW^qYTVo83Het3RS gwo_?JN~L}&P%u6>F)uMawJ0XBpde@~ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 38915b3a9a6b64be600e3bbe67f6781d821e40c6..7602ede8302f2ca1ab4d0871810240c6f8649af3 100644 GIT binary patch delta 2233 zcmah~&u<$=6rSDn?mGTe+i~L9&JWj#+c<9Rs!5@yq$wq+WnHRhS5={G<8JMVvu$Eq zZ*5Vy_y9r$36W?IJ#dLQa4z&O08T|55)e&UDJl|36}MI#S|JY1Y-86=9M$$dznS^I z_r7`icJ}k3yZe3rc)cz}KGx`W*M4)Y`Ucpq`PK16Q{sV-BLyjryda4n+CHCmNx< zDFP8G3Q;KrF=-G6r6Cw1_%ov7&JRl?FcL?qUp_@8zW9;fd~r-iYEbdy7{VAy5TEi^ z|BHnaYGQmFt3?TDG=UVa;xpHnzGmVW^K8k7CfSxx0t6E1*fxhnkW_v0x$R-cA4!sI zaeC2NA3|`@j=oPl_+q?w{0O-b*%wQ{$Jl2<;>e-0@|-!(3^nDBxZ5sFs63JCR{}~f z$3j|7%kS+*siM-GW!hIL=OFSauTOOlfj;@tZca-y;q{Zq88ySo1=G*=HLvUjD`91T z03$h$1kT8X-3T=*cjrZ4!VB%>g=lb+FxE*lAMGh@uoKrs*uc&N+wq59!W-PlYY7|f zB$`K^JXI|mLx~&(3v3*XkOwu6&M@bnPIDt@Y=KE-|E3C3 zjOErUm1(z>JKE+z%EUw{7$uPfloSJ4Wp;)af=w-`k;9>*x`iCjba-7x-Y+vv(T&2gR?*5BvRbW{HIF!B{1jp4?#wxlGK|aoxF-{w zBomgfh_$8qBAgFW$mQ$4Qd8wFZ(i!0;0U0=IQJ1gjXeYsj{Ea8)&5isdtWPC$jFpoHf zVYX1>Pvm}x#-G0_0aLV;`-dX zn{)FUbMwEREv%opvLR^Zuy@Nm=@k!6-8!*)VhwKh-*&E};0DUwN8BcLf-Ae0%%|Qj cqED&}$?)m?zXrWuKcte<4ENKFEAJEk2MGa`3;+NC delta 1727 zcmah}&1)M+6ra_{>T9+7v@FY3>_}BB*|k(Bagz;BnmR5eD{9CN#T2%PMZ1=^u~y7Ze!n;KW;DND zepD3ykH@2k{!Xv`{MAgc6`1Wk)sf$eh7z~ zFQW;N=`#s&Ok=aLRMaaB{>HWmYZS@#yirYHLE0U`BL3E?;vm5)58>#4Vs?YVQqEER)Y_2%jcah!*B{e`56F7-ek`KyiS-IzBT~Fn4y1~uAj1-`%jF-hr z5(gEvqI~9Jy-`gohhBuxWES8YUZ!Gs$xlVjC+6NH&Uq^r@KOtS1&23TTrjhMxOzds z>;m4}c|20}$0u)eWWKTDd3djPYJNeTOBe8#-sU-HKu=(OPAn_;y+W>sH`L0mZx+#0 z5){SFcq6{N9YY8<>6JN8p`=FXMc$&{rh64}S(XKKYOTG0H)XD%uF*;X+>q3QSLZK663aMjl zIMN1Y9gN|iZw-whILaA!o@Pg8``A38nGh?m#!Q5n9cI`=4fGBnI|Tjlh<0SwyEvO? z4J1=xC20#oy5XMI8fc?-7qVnM-lzn}V^hCo`@3|N*O`K!w#gPXU2pYFT|Y6t*1#Yn z-k98CVtWh{^)gP^Y=6fXIURB~`gRY(d%eCsJ~3{?5oNQNby%W*iRXTflw;EMQcyd;a}+RPjom%)xS~Thxqs8K-}{^=gFCPAc!a={*k@$gqOba SlP8G^krKaHJHv{2sOd diff --git a/app/__pycache__/models.cpython-313.pyc b/app/__pycache__/models.cpython-313.pyc index d5c268f12c1d874c80b0d4d64ef4d651b1192e6b..8a190d314f5bdffc3d753603c073c54086337579 100644 GIT binary patch delta 11419 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 diff --git a/app/__pycache__/schema_patch.cpython-313.pyc b/app/__pycache__/schema_patch.cpython-313.pyc index baa350f69cd66fa13b7215027ded16da66e5ed90..044a280bb9479a8defd19d163653410fa29341e8 100644 GIT binary patch delta 69 zcmeAcZWQMI%*)Hg00g@_He}t~$oq*+(_BBaIJKx)KPxjQvs6DkzbHGnATc>rzceqi XI6gNqFEKr}NI$WlK!39ldn6M8GnyCx delta 79 zcmZn^?iS|#%*)Hg00bcgtFr!YASzJ<-Sdw3)AD&;7 g?NpkQQmJ1G6pYVJ%u7s9Es9AjD2Umt!5+y3054%1`v3p{ diff --git a/app/__pycache__/schemas.cpython-313.pyc b/app/__pycache__/schemas.cpython-313.pyc index 18a86543009cd04bbd99d05e6b292d16ea0d31fd..8e0694ca03f07c49ac5494230a919bc58a74918e 100644 GIT binary patch delta 3659 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 E1DuHs%K!iX literal 0 HcmV?d00001 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 36b8487f3fceb1cc8df17988b3ad53d1b5dd3c3f..8561a91a35cccdcd2443cd71c01d2c626d7410cc 100644 GIT binary patch delta 72 zcmZ3+IERt@GcPX}0}#y0STd2@*w9)(v^ce>SU)Q>C$m&PJijPAxF9h(RlhVZvp7CC YF)uMawMaj)pg_MUzqACTaAKt{06v-+)Bpeg delta 82 zcmbQkxQvneGcPX}0}xC!Sv8T{*xJ?EDkiizwWwI%F(o%MFSEF$D6u5JNIyKkDBGzt iC8bio6et*~->SWpmClwVo`)DSZ~->SWpmClwVo`)DW||fMvZT0Qol`Hvj+t diff --git a/app/routers/__pycache__/auth.cpython-313.pyc b/app/routers/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e3f8da683ecd03966b48c07f40e3785c98a83c1 GIT binary patch literal 5614 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 literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/board.cpython-313.pyc b/app/routers/__pycache__/board.cpython-313.pyc index f17b668f3bde8b8596d132239d6d583a6837e3b7..c42d560d6df1eda733621fd40aacd6b66bfd27c8 100644 GIT binary patch delta 2728 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 diff --git a/app/routers/__pycache__/custom.cpython-313.pyc b/app/routers/__pycache__/custom.cpython-313.pyc index 1464716ce123247db78dbfcfb2ae455c1c9f0637..6abc1f6e4ea1fa7f9423dc6f21146d03697c4a11 100644 GIT binary patch delta 16167 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 Or(kfe@35f3-TxO{!j4=3 delta 7185 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 IcsUyMzpJ93IsgCw diff --git a/app/routers/__pycache__/focus.cpython-313.pyc b/app/routers/__pycache__/focus.cpython-313.pyc index 38f623dc9b9d175eaab27eb09398d3eadd084f5d..552229c73e7732184380031f82804841f1872a94 100644 GIT binary patch delta 77 zcmez3{K}d4GcPX}0}$-$*pL;xk@qUQp@n{EacWVqepY5qW~qL7eo=OCL1J>MeraB2 daeQuKUSfJ`k$z%9fqqebX$eT-W?7Ewk^n!k99RGV delta 87 zcmaFm{Kc8~GcPX}0}zB1tjc<^k@qUQwUe_|OlWaxQL(;bN^WLeW^qYTVo83Het3RS nwo_?JN~L}&P%u6>F)uMawJ0XBpdh9wzqACXA!f5M$8|{nf-WHR diff --git a/app/routers/__pycache__/projects.cpython-313.pyc b/app/routers/__pycache__/projects.cpython-313.pyc index a973f3ee16f04f173ca30de09dedc05d89b7f104..ccb59281bfb56bad9b742a798acb5ce28b3e945d 100644 GIT binary patch delta 5551 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 delta 4676 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 literal 0 HcmV?d00001 diff --git a/app/routers/__pycache__/staff.cpython-313.pyc b/app/routers/__pycache__/staff.cpython-313.pyc index 21a667034f7cc6a077ab0346166106a7bb0af5ae..214587a3881ec2ecd7a0fcccd3e4896d540d4f23 100644 GIT binary patch delta 80 zcmZ42#<--7k?%7vFBbz4?CRK%HCufn-$52b3;odI)S_bjtjwIuQvLA!qU_*;#N<@{ g(!9*#_}s+2#Prl6{ltO-{i6KR5|F~pT&yZ~09)W33IG5A delta 89 zcmZ3|#<;GHk?%7vFBbz4gcPjGI-s$U?;wk{le1M!XmM&$vA$zUZf0I)aY<2PNq&)j pcz#i~Q)x;{rG6<;Fg`aiFEKr}C?>I>Af_n4v;?RjW)rK59ROn@AT|I1 diff --git a/app/routers/__pycache__/weekly.cpython-313.pyc b/app/routers/__pycache__/weekly.cpython-313.pyc index 65c4f412f0a9294c44cec2e4725ecb5369ccec34..dd7adc6bba4b16c2df5d78f1fd87cbe06da975d1 100644 GIT binary patch delta 1994 zcmaJ>duUTv7{8}^*fg)3_rruHP15Gk_BL&!eOT*Ir?b>a?lKu2t+9zNwRw1x=s>JG z6uY@0<(soAY;_FRZGTu7u?|I@lfjtK)`}@nHm7qSjM1S`6vlQ=YT7WyF62AE@Ap0K z?|$c;%s1sLGY9lAq;UgJ`3!A^Q&KxblqLyF zu{J&xvjoikP=PFcL0aLklF?Y&kdtV^Pf$Zq7AjC0u)vSXnza>Ougvol=8Ce~@>!xH zrZLZ0ix|&~PFCE>-c%Hj=L2T{wnFR0m9@95rzjtqXJWR185BA-TrCrIF2fQu1(_iR zd*urr27!tN&x$?Uf@j1Yzu*OF?3w*E( z9Bl-T-l|a-R2KF3;fSZG&obOchbMGbZ-EnfF-8i5wfz5Q)lA-PA)}f9nExDT3rSKg zCIzb4)>=u-39AjOkrNIZcB6Lq+i=X=%mk8QF3I#o`Xgy(bDZ13CW5_TCLN0;d!oTu zaC?|zf{6sf#nUO0n3*t2M5@Q+YNkclJR1M{@t5!a_TdG%Yx@*^1OxUlbQ~DR6?78* za2!KnI9SP`Q*gf0fwpIIl?M^}EYsuEA#nm%b7W_@op8WakIuq**H>s9wzvUtP-iwk zyH@~8&wlY-$vOi}RJVY)+AG|%kbo}1JJn~z2MJNcUWU$^Ds%`J zZGfva!s3D}@Tg|5qK_0Pu-RB#b`3tLJzAXnEv#V5(R=VFGpN`_O2|%Zzo=<=e( zalO#g5UCO+Bimy=phoop)pjF5VaJP4y&G!Bjd4BMnQDCv&+$i#%j9IXF-OgTqIQr*O3a|B^OQW|i+< z!M<4`rBEH03`w$d#Yjn3?wXRjrsbZ|)uaANdEFp?My@zI@a{lXZW~c%W$r1NdsqyrWUH3#R)Rhg2 zXJtgM=(yuF7Q|w$MoQDx&1Cqo=Q-%=6YXLTN~} zInfq4(PrKk74UC3WzjO>O_?G}3vb$JY$xiqQH}DJP!l!EZk5x-F4siMWz#fGtkW2^ z%O0pTQJ4Bb4UO$Xo+etWeQ2Vw{S`+WOuyR=(I&o2!Y#+s<;aoX>lSoNa&?GBXh1RG7;s8=zd)+|_4^Bit~AI$ajd>HR}@aU+lCy0;+ z@hI9JJw&c4PupU~K7sb5u|*qOFppwZB9lJMO&r?OoP1c@!y)h+%rIK@F7^Vpbd=Q< zS$(i&F&A~(3>%!u=poh9^|4|{{{hDUk!^6#qQ=88Lzt2?7NrT%G}@v%VXA;roQAm9 zaJ(t{-^1wmf71wOGwcV&>Z2`QZQ2ehYd4vDkM$*d7{0e&Y3uH8?T=-XV%FD{7!%S$ zHYdh&8PWG_MjUxOpGdso%M<0Pv3NQ@oD}=xVh zgg>3%;Y;wX>k3Xl)a}DFFy(gOA?dn%8sn>y_mCNDGGtU@n!-3d*%-hd!&Ku0XCl!J2v_n30@D>;J0WK+L43Cuc16hjh^6HCP`Dh2-ky+%2`4iWa3r07xZY) z5TPJX2ruLs!oY^!!)cfgJyG%Mu6i(&87;`u$(M8DAe4p$t0J2tf2zRb#}n~fvQU!E zj}_$EjF@Y1NmAH^<@C<5{iu^XB5TAAMZtG0KiArNPJr1-GiNm{rZHuEpkt z)+%d0sQ9$%!>ak(6|oxHN*wpTXPh(#nxM#?Qm?PWR+7jJUR)!Xw9b?lY+R zv7P(RVPp(sPm{E_OIV>!e%q}RT+AJvLGUnl>h%Qg224VrVwquuh;F%7Pcgy@?Yb4c zo}!yeVcnXaO)S=P7roZ zO?I0<&-Z51NpX>yJ_CE#l6`j<=>+yxz)x8%fr7GqER)EOCXa~A@K@LAz9AWAR?97$ xC6xT8Hf|~?*<_H`v{|}Km6WP>sTvvBP0cRVB9(r-jM4`sNM_!WQ7Y0pe*>zbcM1Ri 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 c99e0ec4aab6c5fb2ab62880eb78ce09f3400fa0..7224015a5cb75f3ae4ec9dd6e511dcbcdd26ce50 100644 GIT binary patch delta 9083 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≀#|fRGNxQ zDoWrQWP6E5U(T8}+9ifXyS+$UV*FoTqd0@dw-EcoKio{r;=!6SVr_U?DX|_8A1@=8 z;*TSDo0-Sy$g?D8{88T7JkGesh~@qWO!aQAU&=N(aht8Q+V0x^Vxz-XMeW$-cJJ_c zJp0*lS3{GfvAn|BK-KQD9BOQqiVxX$Ji?`LG^@4TX{wM(^BXL*NL-l)CpZkJqy8li zTOm~++{@NEy$tQ&@7&s4SLS=2-d`@1iMuNcD_->NwO6>SN|H`!&au)QT)?t)B0?cF zX%N)%b7iP#+|n**OD*H7XT2Ls_iSWda;^8SXDu7;o68C}?cQe-oB3MFwzaBolfQmG z8r8GIJ+FnMES%;A6KIkU)+z|B3xo#TY^3*R9rm~&V z{vErvJBs&LtS{f^t}64g#g&rBThh4WMQ`agXA+Sn8}RFlnG*Q9k})MU4dNFde6lXR zie~p#sh-+b=dLW{CFW4WhQi9at!0G=>};*uTia6Fve8yueyEHVe9qnT(SU4tR5ptg zFDBY#DXAqOoP}duIu%P*X;nqz-pWmTsw7XbpOXq(wiOodt!=Dy2~~TG_j!1+YHvmT z_Qr#b^=UQ$t{q~sVCGoS0$1oL&rPD4gIGk?;=)xlP5WyPiPZLT zR5Wb$9pLttH@@yJ^%iez*;%`#&h?#!@^o&{<$!1wIjCK=TdowE1cb8)Run-pjbj+O zh#WSK8Gk`Gk~Z>#@r9;ZqyHJb6MxZd@f@OC%F1^zJAGc4XK$0c(ekp5FKqgbaNzY_ zwk@@_+v%;IJ(WAlv(Ws{9gRnyj7&8DOKjZ#86&UHKnuQtwPvIFU%}{922$4J|D)!k ZM+njk;mhqnXVyEj|92V%h3Xp0{|8vmkg@;( delta 1552 zcmaJ>YfMx}6rOYM%)YpLhs6Re4|kCui3;Am`(hVOSjFO|HHD^BTO(?dKGGU$)zDZI z0jXLaAfnr;5d$drXe_AIofZ@fNlj>f)TA}~qd!_?ciA*)rB>Sp8#_o$G-*4NbI&(( z=bZWGoHNtVU~jmZ*_xGx#t=d^LaK#GLW&M8)HB3vab%Q;A$LXeg` z-4EwE+xS>2G^u8#=}kfF)uxTS)&bWVF!@tW3P#Tm{(KMEL0JNhe4ELHOr}tXt?Pjo zZEmN_9dvtw9$&Gpd%Pt&|Naz&Nt(;!bm>9O71Z6uB>~OvcJoE&;KnleiIY391z2sD zl%bgW%x9F%$~(#mCB#llf~K0IPj-H55-b*YO;D_1sSg12ysC9I-Pnx~3Gs}3+3WRP_D zgKzJX_Eth>gvhQ)ne2>I%9cy(<(J5OFd6-Q!=^j2{kSqR5 z5r@ja?TGf$)e!~H!?+y>L^6Mo>+3QtXo20n90Zm4rTr6q5m8ec#ALv3M*%y`^3>+mU!J#d=vP-FupGFv$=+)z;@&s z7Vw|v7}9~SEXQxihmK$4!`X&mVBu^-U9=LLozTWDn{YK{oogW-d%bQiYg_{vSpxYs zLK*^a_ywcsW=AL#n(JWit%3k?dAvUD(Na|)mt}fEDnj%#ZfU^xD9nwv;)fI*(N2sv zsW@c_scWpY%4o`vG9>7pODWuG!d6t;xDE0})bpxMGr@f!l;|H!40cTPeK>yk{OyLF zY%H5}Pv0pmAmSYK_=`2SS8T~X$sxsjG>4SRDyB(9r|3QUD^1ed;>Ea0d2$iSOr5Uo z=>|g9AjH)&QfloVJW>Gc-5*FM&%REAz&citeC+jj^qD#m$P=w32F;X?XqsxXrVGMG ztD$k*_Cge9lRL;9R<=>bY;iLwW%Vt@#`ZRo*=$cUq2Om1n#n6h&8dqgK~j3Cn#j0lfW$ZqjAZJC8Yf<{sg`GTYEXsA$BJY3OP_^|6O`Zywff- jX9-{x)Udf7sfzMJ%5&y+!Tx}mx1S}i8#r7g4;}vi-x 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; }