diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d3c6c6f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# 构建上下文只要 app/ web/ scripts/ requirements.txt,其余都排除: +# 既避免把本机数据库和密钥烤进镜像,也让每次 build 的上下文保持很小 +.git +.workbuddy +.venv +__pycache__ +*.pyc +.DS_Store + +# 数据库与用户文件:运行时用 volume 挂进来 +data + +# 本机配置:容器内一律走 compose 的 env_file,不把 .env 打进镜像 +.env +.env.* +!.env.example + +# 本机启动/部署脚本 +run.sh +deploy.sh +docker-compose.yml +Dockerfile +README.md diff --git a/.env.example b/.env.example index 9f3b62c..b64918d 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,15 @@ AI_TEMPERATURE=0.2 # ---- 服务 ---- PORT=8770 + +# ---- 登录账号(首次启动时用来创建管理员,之后以数据库里的为准)---- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 +# 登录态有效天数 +SESSION_DAYS=30 + +# ---- Docker 部署(deploy.sh / docker-compose.yml 读取)---- +# 对外端口。容器内部固定监听 8770,换端口只改这里(上面的 PORT 是本机 run.sh 用的) +HOST_PORT=8770 +# 周期/周次/入库时间等日期按此时区计算,别留 UTC,否则整体差 8 小时 +TZ=Asia/Shanghai diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e358a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# 部门关键任务追踪看板 · 运行镜像 +# +# 构建:docker build -t unis-board . (日常部署建议用 ./deploy.sh up) +# 跑: docker run -d -p 8770:8770 -v $PWD/data:/app/data unis-board +# +# 镜像里只有依赖和代码,数据库/附件都在 data/,靠 bind mount 挂进来。 +# data/ 已被 .dockerignore 排除,不会把本机 board.db 打进镜像。 +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# 业务代码用 datetime.now() 取本地时间(周期/周次/入库时间/更新时间), +# 容器默认 UTC 会让这些日期整体差 8 小时,所以必须显式设时区(compose 里的 TZ 会覆盖这里)。 +ENV TZ=Asia/Shanghai + +WORKDIR /app + +# 依赖单独一层:改业务代码不触发重装依赖 +COPY requirements.txt ./ +# 国内网络直连 PyPI 容易超时,构建时换源即可: +# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up +ARG PIP_INDEX_URL=https://pypi.org/simple +RUN pip install --retries 5 --timeout 60 -i "$PIP_INDEX_URL" -r requirements.txt +# 用 MySQL 部署时放开下面这行(与 requirements.txt 的注释一致) +# RUN pip install "pymysql>=1.1" "cryptography>=42.0" + +COPY app ./app +COPY web ./web +COPY scripts ./scripts + +# 上传件 / 导出件目录;SQLite 库同样落在这里,实际内容由 volume 提供 +RUN mkdir -p data/uploads data/exports + +# 容器内固定监听 8770,对外端口由 compose 的 HOST_PORT 控制。 +# 镜像默认以 root 运行:macOS 上 bind mount 只有 root 写 data/ 最省心。 +# Linux 上想换成非特权账号,在 compose 里加 user: "1000:1000" +#(前提是 ./data 的属主也是这个 uid,否则 SQLite 写不进去)。 +ENV PORT=8770 +EXPOSE 8770 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('PORT','8770'),timeout=3).read()" + +# 首次启动建表 + 预置分类(幂等,不覆盖已有数据);内置角色与管理员账号由 app/main.py 启动时补齐 +CMD ["sh", "-c", "python scripts/init_db.py && exec python -m uvicorn app.main:app --host 0.0.0.0 --port \"${PORT:-8770}\""] diff --git a/README.md b/README.md index f6b9aaf..12d21d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 软件开发部 · 关键任务追踪看板 +# DMS管理平台 -基于《软件开发部管理工作执行表.xlsx》构建的部门关键任务追踪系统。 +基于《软件开发部管理工作执行表.xlsx》构建的 DMS 管理平台。 侧栏是**四个业务模块 + 三个支撑页**: @@ -8,12 +8,12 @@ |---|---|---|---| | **① 工作看板** | 每天进来的第一屏 | 三类工作本周期谁更新了、谁没动,点一条从一览看到详情 | 只读汇总(`/api/weekly` + `/api/focus`) | | **② 项目管理** | NEX云桌面、AI Agent、智飞无人机平台、内部运营… | 长期重点项目的迭代执行、子任务进展、风险阻塞 | `projects` / `tasks` / `updates` | -| **③ 定开项目** | 各省市客户定开子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` | +| **③ 定制项目** | 各省市客户定制子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` | | **④ 每周重点工作** | 不值得单独建项目档案的短期活儿 | 一条就是一件事,面板里录,归在**录入周**这一周,跟到完成 | `weekly_focus` | 支撑页三个:**人力资源**(`staff`,全系统「负责人」的唯一来源)、**更新录入**(执行 / 交付记录的唯一写入口:粘贴原文 → AI 整理 → 确认写入)、**设置**(周期管理、AI 配置)。 -②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定开项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。 +②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定制项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。 ## 周期管理 @@ -24,7 +24,7 @@ | 入口 | 操作 | |---|---| | 顶部周期下拉 | 选「+ 新建周期…」 | -| 更新录入页(常规 / 定开都有) | 周期下拉选「+ 新建周期…」 | +| 更新录入页(常规 / 定制都有) | 周期下拉选「+ 新建周期…」 | | 设置页 → 周期管理 | 「+ 新建周期」按钮 | 输入格式 `9月第1周`(也可写 `2027年1月第1周` 显式指定年份),年份按以下规则自动推断: @@ -60,7 +60,7 @@ cd /Users/jiliu/WorkSpace/unis_manager 打开 -首次运行 `run.sh` 会自动完成:选择带依赖的 Python 解释器 → 缺依赖则安装 → 建库 → 导入历史 Excel → 拆分定开项目,然后启动并打开浏览器。 +首次运行 `run.sh` 会自动完成:选择带依赖的 Python 解释器 → 缺依赖则安装 → 建库 → 导入历史 Excel → 拆分定制项目,然后启动并打开浏览器。 ### 启动方式与排错 @@ -73,6 +73,7 @@ cd /Users/jiliu/WorkSpace/unis_manager | 关闭热加载 | `DEV=0 ./run.sh`(默认是开着的) | | 不自动开浏览器 | `NO_OPEN=1 ./run.sh` | | 提示"端口已被占用" | 原窗口 `Ctrl+C`,或 `lsof -ti:8770 \| xargs kill` 后重跑 | +| 用 Docker 部署(不装 Python) | `./deploy.sh up`,见下面「Docker 部署」 | > ⚠️ 不要用 `sudo` 启动:数据库文件会变成 root 属主,后续写入会失败。 @@ -94,6 +95,61 @@ python -m uvicorn app.main:app --port 8770 > 重新导入:`python scripts/import_excel.py --reset` 之后再跑一次 `python scripts/migrate_custom.py` > (迁移脚本会删除常规表里的「定制开发」容器,重复执行前请先 reset) +### Docker 部署(换机器 / 上服务器) + +不想装 Python,或者要放到服务器上常开,用 Docker: + +```bash +cd /path/to/unis_manager +./deploy.sh up # 构建镜像 + 后台启动 + 等健康检查 +``` + +打开 。首次启动自动建表、预置分类,并创建内置角色与 `admin` 账号(密码见 `.env`)。 + +| 文件 | 作用 | +|---|---| +| `Dockerfile` | 运行镜像:装依赖 + 拷 `app/ web/ scripts/`;启动时先跑 `init_db.py`(幂等)再起 uvicorn | +| `docker-compose.yml` | 编排:`./data` 挂进容器存数据库、`HOST_PORT` 控对外端口、`restart: unless-stopped` 随 Docker 自启 | +| `deploy.sh` | 命令封装:`up` / `update` / `restart` / `down` / `logs` / `status`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在 | +| `.dockerignore` | 保证 `data/`、`.env`、`.git` 不会被烤进镜像 | + +| 场景 | 命令 | +|---|---| +| 改完代码上线 | `./deploy.sh update`(重建镜像并替换容器) | +| 改了 `.env`(AI Key / 端口) | `./deploy.sh restart` | +| 看日志排错 | `./deploy.sh logs` | +| 看状态与健康检查 | `./deploy.sh status` | +| 停止(数据保留) | `./deploy.sh down` | +| 换对外端口 | `.env` 里 `HOST_PORT=8080`,再 `./deploy.sh up` | +| 裸 compose 命令 | `docker compose up -d --build` / `docker compose down` | +| 构建时 pip 下载超时 | `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ./deploy.sh up`(也可写进 `.env`) | +| 把现有数据带到新机器 | `./deploy.sh backup` → 拷到目标机 `data/board.db` → `./deploy.sh up` | + +几个坑: + +- **数据只落在 `./data`**:重建镜像、`down` 再 `up` 都不动数据;备份依旧是拷走 `data/board.db`。 +- **时区必须设**:容器默认 UTC,而周期/周次/入库时间都按本地日期算,所以 compose 固定传 `TZ=Asia/Shanghai`,别删。 +- **容器内端口固定 8770**:`.env` 的 `PORT` 是本机 `run.sh` 用的,compose 里覆盖成 8770;要换端口只改 `HOST_PORT`。 +- **别和 `run.sh` 同时跑**:一是 SQLite 是文件库,同一份 `board.db` 不要同时被本机进程和容器读写;二是本机进程绑的是 `127.0.0.1:8770`,比 Docker 发布的 `*:8770` 更精确,浏览器和 `curl 127.0.0.1:8770` 会全打到本机服务上(症状:`/health` 里 `dev` 是 `true`,而容器是 `false`)。两者要共存就给容器换端口:`HOST_PORT=8899 ./deploy.sh up`。 +- **换 MySQL**:放开 `Dockerfile` 里 `pymysql` 那行重新 build,再把 `.env` 的 `DATABASE_URL` 指过去(见「迁移到 MySQL」)。 + +### 带着现有数据部署 + +镜像里**故意不含数据**(`.dockerignore` 排除了 `data/`),容器读的是宿主机挂载进来的 `./data/board.db`,所以: + +- **就在本机这个目录部署** → 什么都不用做,`./deploy.sh up` 起来读的就是当前这份库;连 `user_sessions` 里的登录态都延续,别人不用重新登录。 +- **换一台机器部署** → 先把数据取出来,落到目标机的 `./data/board.db`: + +```bash +./deploy.sh backup # 一致性快照 → data/backups/board-YYYYmmdd_HHMMSS.db +scp data/backups/board-*.db user@host:/srv/unis_manager/data/board.db +ssh user@host 'cd /srv/unis_manager && ./deploy.sh up' +``` + +> - `data/board.db` 本身在 git 里,`git clone` 也会带一份,但那是**最后一次提交**的状态;要「当前最新」请用 `./deploy.sh backup` 的快照(走 SQLite 备份 API,本机服务正在写也能拿到一致的库)。 +> - 启动时的 `init_db.py` 和 `ensure_seed` 只会**补**缺失的表 / 分类 / 内置角色,`users` 非空就不再创建 `admin`,不会覆盖或清空任何数据。实测:拿当前生产库快照起容器,启动前后逐表行数完全一致,123 个定制项目、22 名人员、老 token 全部照常可用。 +> - 目标机上的 `./data` 属主要和运行容器的人对得上(compose 以 root 跑则无此顾虑);SQLite 是文件库,同一份库别让两个实例同时写。 + ## 功能 ### 1. 工作看板 @@ -102,15 +158,15 @@ python -m uvicorn app.main:app --port 8770 | 区域 | 内容 | |---|---| -| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定开合同额(含项目数与本期计收)/ 风险阻塞 | -| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定开项目**——**只给一个入口**「本周定开项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 | -| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定开项目入口 → 本周动态总览**(顶部标签:本周新增 / 本周验收 / 有交付动态 / 合同额合计 / 本期计收,下面分「本周新增」「本周验收」「本周有变化」三段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 | -| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定开动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定开总览的头部 | +| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定制合同额(含项目数与本期计收)/ 风险阻塞 | +| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定制项目**——**只给一个入口**「本周定制项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 | +| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 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` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。 +数据分两路:`GET /api/weekly?period_id=|year&month&owner=&all_custom=` 一次返回重点项目清单 + 定制项目清单 + 顶部 KPI(含 `custom_new` 本期新入库数、`custom_updated` 本期有交付记录数);**「本周工作重点」不在 `/api/weekly` 里**,前端并发取 `GET /api/focus` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。 -定开项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**、**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)、**本周期新下单**(`sign_date` 落在本周期)或**本周期已验收**(`update_date` 落在本周期且项目状态为「已验收」)的定开项目;右栏总览默认列「本周新增」「本周验收」「本周有变化」三段,其中**前两段与「定开项目 → 本周进展」的「新增项目 / 验收情况」口径完全一致**(前者按「下单时间」、后者按「更新时间 + 状态=已验收」),其余只在组头计数里出现,并在底部提示「另有 N 个在途定开项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部几枚标签与「合同额合计」只加总**当前列出来的行**(同一项目同时落在两段时只算一次),勾与不勾数字会跟着清单变。 +定制项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**、**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)、**本周期新下单**(`sign_date` 落在本周期)或**本周期已验收**(`update_date` 落在本周期且项目状态为「已验收」)的定制项目;右栏总览默认列「本周新增」「本周验收」「本周有变化」三段,其中**前两段与「定制项目 → 本周进展」的「新增项目 / 验收情况」口径完全一致**(前者按「下单时间」、后者按「更新时间 + 状态=已验收」),其余只在组头计数里出现,并在底部提示「另有 N 个在途定制项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部几枚标签与「合同额合计」只加总**当前列出来的行**(同一项目同时落在两段时只算一次),勾与不勾数字会跟着清单变。 ### 2. 项目管理 @@ -127,12 +183,12 @@ python -m uvicorn app.main:app --port 8770 | 操作 | 说明 | |---|---| | **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 | -| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定开项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 | -| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定开项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定开项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定开项目按交付负责人命中;团队成员不参与该筛选 | +| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定制项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 | +| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定制项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定制项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定制项目按交付负责人命中;团队成员不参与该筛选 | | **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 | | **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 | -> 定开项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。 +> 定制项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。 #### 项目合并 @@ -149,7 +205,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无 - 合并后按最新记录回算目标项目的进度与状态 - 执行前建议备份:`cp data/board.db data/board.db.bak` -### 3. 定开项目 +### 3. 定制项目 | 模块 | 说明 | |---|---| @@ -157,7 +213,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无 | **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 | | **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 | | **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 | -| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定开项目并带上项目与周期)留在附表标题下 | +| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制项目并带上项目与周期)留在附表标题下 | ### 4. 每周重点工作 @@ -169,7 +225,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无 | 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 | | 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 | | 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 | -| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定开项目的项目删除上 | +| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定制项目的项目删除上 | | 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 | | 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 | @@ -183,12 +239,12 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无 | 区块 | 说明 | |---|---| | 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) | -| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定开项目数、定开合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 | +| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 | | 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 | | 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` | -| 改名 | 走「编辑」改姓名时,**名下项目与定开项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 | +| 改名 | 走「编辑」改姓名时,**名下项目与定制项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 | | 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 | -| 删除 | 名下还挂着项目或定开项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 | +| 删除 | 名下还挂着项目或定制项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 | 导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明): @@ -199,14 +255,14 @@ python scripts/import_staff.py --reset # 清空后重新导入 python scripts/import_staff.py # 指定别的文件;首次运行 ./run.sh 会自动导入 ``` -**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定开项目新建/详情、工作看板等位置统一使用,选项来自花名册: +**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定制项目新建/详情、工作看板等位置统一使用,选项来自花名册: 已归档的人不再作为可选项出现,只有他本来就是这条记录的负责人时才带「(已归档)」标注出现一次; 历史数据里花名册没有的人名同样补一个「(不在花名册)」选项,保证打开老项目不会把负责人改没。 -**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定开项目的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。 +**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定制项目的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。 ### 入库时间 -定开项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**: +定制项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**: - 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承) - 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周 @@ -253,18 +309,18 @@ python scripts/add_entry_date.py --force # 全部重算 | 位置 | 导出内容 | 接口 | |---|---|---| -| 定开项目 | 定开项目明细(含区域、金额、进度、收入确认、入库时间、备注 + 合计行) | `GET /api/export/custom` | -| 定开项目 | 定开项目周期记录 | `GET /api/export/custom-updates` | +| 定制项目 | 定制项目明细(含区域、金额、进度、收入确认、入库时间、备注 + 合计行) | `GET /api/export/custom` | +| 定制项目 | 定制项目周期记录 | `GET /api/export/custom-updates` | | 项目管理 | 项目管理清单(分类、负责人、状态、标签、子任务数等) | `GET /api/export/projects` | | 工作看板 | 本周期(或本月)的执行记录明细 | `GET /api/export/updates` | | 每周重点工作 | 重点工作清单(工作内容 / 说明 / 预计完成时间 / 录入周 / 执行状态 / 是否延期 / 结果反馈 / 更新) | `GET /api/export/focus` | -| 人力资源库 | 花名册(含在管 / 定开项目数、合同额、待计收) | `GET /api/export/staff` | +| 人力资源库 | 花名册(含在管 / 定制项目数、合同额、待计收) | `GET /api/export/staff` | 导出的 xlsx 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。 ## 收入确认口径 -定开项目按**完工百分比法**确认收入: +定制项目按**完工百分比法**确认收入: | 阶段 | 确认比例 | |---|---| @@ -280,7 +336,7 @@ python scripts/add_entry_date.py --force # 全部重算 - **可确认收入** = 合同额 × 上表比例 - **待计收** = 可确认收入 − 已计收 - **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新) -- 开票与回款金额在项目详情抽屉、定开项目详情抽屉、工作看板右栏都能直接填 +- 开票与回款金额在项目详情抽屉、定制项目详情抽屉、工作看板右栏都能直接填 - 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形) - 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效 @@ -293,10 +349,10 @@ Period(周期: 年/月/周, sort_key) ├── Task(子任务) └── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文) -# 定开项目(独立) +# 定制项目(独立) CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例, 已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档) - # 定开项目不做多负责人:other_owners 列保留在库里但不再读写 + # 定制项目不做多负责人:other_owners 列保留在库里但不再读写 └── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步) # 每周重点工作(独立:条目不是项目,不挂子任务与执行记录) @@ -319,7 +375,7 @@ LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面 两套体系各有独立提示词: - **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步 -- **定开项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细 +- **定制项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细 - **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态) - 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。 @@ -347,14 +403,18 @@ app/ main.py FastAPI 入口(API 挂载在 /api,静态页面在 /) config.py 配置与 .env 加载(DATABASE_URL、AI 参数) db.py engine / Session(按 URL 自动适配方言) - models.py ORM 模型(项目 + 定开项目 + 每周重点工作 + 花名册) + models.py ORM 模型(项目 + 定制项目 + 每周重点工作 + 花名册) constants.py 状态/阶段常量、收入确认比例、区域与阶段识别 - ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定开两套) + ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定制两套) routers/ board(周期/元数据/统计) · weekly(工作看板数据) · projects(项目管理) · - custom(定开项目) · focus(每周重点工作) · staff(人力) · ai · export + custom(定制项目) · focus(每周重点工作) · staff(人力) · ai · export web/ index.html · styles.css · app.js(零依赖,无构建步骤) scripts/ init_db.py · import_excel.py · migrate_custom.py · import_staff.py · add_entry_date.py · merge_project.py · shift_year.py data/board.db SQLite 数据库 +Dockerfile 运行镜像(依赖 + 代码,启动时自动补表/预置分类) +docker-compose.yml 部署编排(./data 持久化、HOST_PORT、健康检查) +deploy.sh 部署命令封装:up / update / restart / down / logs / status +.dockerignore 排除 data/ 与 .env,不把数据和密钥打进镜像 ``` ## API 速览 @@ -367,7 +427,7 @@ POST /api/periods 新增周期(传 label 如「9月第1周」 DELETE /api/periods/{id} 删除空周期(有记录则拒绝) GET /api/board?period_id=|year&month 周期汇总(页面主链路不走它,统计走 /api/stats) GET /api/stats?period_id=|year&month -GET /api/weekly?period_id=|year&month&owner=&all_custom= 工作看板:重点项目 + 定开项目本周动态 + KPI +GET /api/weekly?period_id=|year&month&owner=&all_custom= 工作看板:重点项目 + 定制项目本周动态 + KPI (custom_new 本期新入库 / custom_updated 本期有交付记录;行上带 is_new) (左栏第一组「本周工作重点」前端另取 /api/focus 合并) GET /api/projects (?archived=false|true|all) @@ -375,7 +435,7 @@ POST /api/projects PATCH /api/projects/{id} DELETE /api/projects/{id} (owners/members 传人名数组,整体替换:不传=不改,传 []=清空;owners[0] 回写 owner 列供归属统计,界面不再区分主次) GET /api/categories POST /api/categories PATCH /api/categories/{id} DELETE /api/categories/{id} -# 定开项目 +# 定制项目 GET /api/custom/meta 阶段字典 + 区域列表 + 交付环节(pipeline) + 卡点阈值(aging_days) GET /api/custom/projects?stage=®ion=&keyword= POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id} diff --git a/app/ai_parser.py b/app/ai_parser.py index eddb111..879ccc9 100644 --- a/app/ai_parser.py +++ b/app/ai_parser.py @@ -223,10 +223,10 @@ def llm_parse(raw: str, project_name: str, period_label: str, db) -> dict[str, A # ========================================================================== -# 定开项目:阶段 / 金额 / 计收 +# 定制项目:阶段 / 金额 / 计收 # ========================================================================== -CUSTOM_SYSTEM_PROMPT = """你是定开项目的管理助理,负责把项目周报原文整理成结构化记录。 -定开项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。 +CUSTOM_SYSTEM_PROMPT = """你是定制项目的管理助理,负责把项目周报原文整理成结构化记录。 +定制项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。 请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下: { "stage": "developing", @@ -247,7 +247,7 @@ CUSTOM_SYSTEM_PROMPT = """你是定开项目的管理助理,负责把项目周 5. 不要编造原文没有的金额和数字。 """ -CUSTOM_USER_TEMPLATE = """定开项目:{project} +CUSTOM_USER_TEMPLATE = """定制项目:{project} 周期:{period} 当前阶段:{stage} diff --git a/app/constants.py b/app/constants.py index 2fd3947..201e856 100644 --- a/app/constants.py +++ b/app/constants.py @@ -37,7 +37,7 @@ LEDGER_CATEGORIES = [ ] # -------------------------------------------------------------------------- -# 定开项目(交付制,与平台产品类项目分开管理) +# 定制项目(交付制,与平台产品类项目分开管理) # (code, 名称, 颜色, 收入确认基准比例) # 开发中阶段:base + 0.4 * progress/100 # 其余阶段:直接使用 base @@ -74,7 +74,7 @@ def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None) # -------------------------------------------------------------------------- -# 定开项目:文本 → 阶段 / 金额 识别 +# 定制项目:文本 → 阶段 / 金额 识别 # -------------------------------------------------------------------------- CUSTOM_STAGE_RULES = [ (("暂停", "终止", "未达成一致", "搁置"), "paused"), @@ -341,7 +341,7 @@ DEFAULT_CATEGORY_COLORS = [ # -------------------------------------------------------------------------- -# 定开项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收) +# 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收) # key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry) # name 环节名 # date_field 该环节的日期字段(CustomProject 上的属性名) diff --git a/app/main.py b/app/main.py index bc0a69a..75c4fc8 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ with SessionLocal() as _db: # 每次进程启动换一个值;热加载重启后前端据此自动刷新 BOOT_ID = uuid.uuid4().hex -app = FastAPI(title="部门关键任务追踪看板", version="1.0.0") +app = FastAPI(title="DMS管理平台", version="1.0.0") app.add_middleware( CORSMiddleware, @@ -35,7 +35,7 @@ app.add_middleware( ) # 所有 /api 接口先过 RBAC 总闸(登录 + 菜单权限),登录接口自身除外 -api = FastAPI(title="部门关键任务追踪看板 API", dependencies=[Depends(security.guard)]) +api = FastAPI(title="DMS管理平台 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"]) diff --git a/app/models.py b/app/models.py index 2ccb74b..74e95b1 100644 --- a/app/models.py +++ b/app/models.py @@ -99,7 +99,7 @@ class TimestampMixin: class Category(Base, TimestampMixin): - """项目分类(如 平台产品 / AI / 定开项目 / 内部运营)。""" + """项目分类(如 平台产品 / AI / 定制项目 / 内部运营)。""" __tablename__ = "categories" @@ -376,7 +376,7 @@ class LedgerItem(Base, TimestampMixin): class CustomProject(Base, TimestampMixin): - """定开项目(与通用研发项目完全分离)。 + """定制项目(与通用研发项目完全分离)。 关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。 """ @@ -396,7 +396,7 @@ class CustomProject(Base, TimestampMixin): revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额 owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 交付负责人(单人) - other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定开项目不做多负责人 + other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定制项目不做多负责人 members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员 entry_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 入库时间(YYYY-MM-DD) sign_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 签单/下单时间 @@ -503,7 +503,7 @@ class CustomProject(Base, TimestampMixin): class CustomUpdate(Base, TimestampMixin): - """定开项目的阶段流转 / 周期执行记录(独立表)。""" + """定制项目的阶段流转 / 周期执行记录(独立表)。""" __tablename__ = "custom_updates" @@ -557,7 +557,7 @@ class CustomUpdate(Base, TimestampMixin): class Staff(Base, TimestampMixin): """人力资源库成员。 - 项目管理 / 定开项目的负责人一律从这里选取;归档后不再出现在下拉里, + 项目管理 / 定制项目的负责人一律从这里选取;归档后不再出现在下拉里, 历史记录中的人名保持不变。 """ diff --git a/app/routers/board.py b/app/routers/board.py index 2b9b683..95ed04d 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -170,7 +170,7 @@ def create_period(payload: PeriodCreate, db: Session = Depends(get_db)): @router.delete("/periods/{pid}") def delete_period(pid: int, db: Session = Depends(get_db)): - """删除周期。仅允许删除没有任何执行记录(通用 / 定开)的空周期。""" + """删除周期。仅允许删除没有任何执行记录(通用 / 定制)的空周期。""" from ..models import CustomUpdate p = db.get(Period, pid) @@ -476,6 +476,9 @@ def stats( "update_count": len(updates), "covered_projects": len(updated_ids), "risk_count": len(risks), + # 风险 / 阻塞分开计数:at_risk=风险、blocked=阻塞 + "at_risk_count": sum(1 for u in updates if u.status == "at_risk"), + "blocked_count": sum(1 for u in updates if u.status == "blocked"), # 平均进度只统计本周期有更新的项目,避免历史一次性事项拉低数值 "avg_progress": round(sum(covered_prog) / len(covered_prog), 1) if covered_prog diff --git a/app/routers/custom.py b/app/routers/custom.py index 619c15e..f0c39e4 100644 --- a/app/routers/custom.py +++ b/app/routers/custom.py @@ -1,4 +1,4 @@ -"""定开项目 API(独立于通用项目体系)。 +"""定制项目 API(独立于通用项目体系)。 关注:商务阶段流转 与 按进度确认收入。 """ @@ -40,7 +40,7 @@ class CustomProjectIn(BaseModel): progress: float = 0 manual_ratio: float | None = None revenue_amount: float | None = None - owner: str | None = None # 交付负责人(定开项目为单人,多负责人只用于重点项目) + owner: str | None = None # 交付负责人(定制项目为单人,多负责人只用于重点项目) members: list[str] | None = None # 团队成员 entry_date: str | None = None sign_date: str | None = None @@ -135,7 +135,7 @@ def _unique_name(db: Session, name: str, exclude_id: int | None = None): if exclude_id is not None: q = q.filter(CustomProject.id != exclude_id) if q.first(): - raise HTTPException(400, f"已存在同名定开项目「{name}」,请换个名称") + raise HTTPException(400, f"已存在同名定制项目「{name}」,请换个名称") # ---------------------------------------------------------------- 元信息 @@ -291,7 +291,7 @@ def create_custom_project(payload: CustomProjectIn, db: Session = Depends(get_db def get_custom_project(cid: int, db: Session = Depends(get_db)): cp = db.get(CustomProject, cid) if not cp: - raise HTTPException(404, "定开项目不存在") + raise HTTPException(404, "定制项目不存在") ups = ( db.query(CustomUpdate, Period) .join(Period, Period.id == CustomUpdate.period_id) @@ -320,7 +320,7 @@ def get_custom_project(cid: int, db: Session = Depends(get_db)): def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)): cp = db.get(CustomProject, cid) if not cp: - raise HTTPException(404, "定开项目不存在") + raise HTTPException(404, "定制项目不存在") allowed = ( "name", "customer", "region", "amount", "stage", "progress", "manual_ratio", "revenue_amount", "invoice_amount", "received_amount", "owner", @@ -361,7 +361,7 @@ def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)) def delete_custom_project(cid: int, db: Session = Depends(get_db)): cp = db.get(CustomProject, cid) if not cp: - raise HTTPException(404, "定开项目不存在") + raise HTTPException(404, "定制项目不存在") name = cp.name n_upd = len(cp.updates) db.delete(cp) @@ -371,7 +371,7 @@ def delete_custom_project(cid: int, db: Session = Depends(get_db)): # ---------------------------------------------------------------- 按周删除 def _week_filtered(db: Session): - """本周(周一~周日)有「更新时间」的定开项目。""" + """本周(周一~周日)有「更新时间」的定制项目。""" start, end = _week_range() rows = ( db.query(CustomProject) @@ -400,7 +400,7 @@ def weekly_preview(db: Session = Depends(get_db)): @router.post("/custom/purge-week") def purge_week(db: Session = Depends(get_db)): - """删除本周(周一~周日)更新的定开项目,连带其记录。破坏性操作,前端须二次确认。""" + """删除本周(周一~周日)更新的定制项目,连带其记录。破坏性操作,前端须二次确认。""" start, end, rows = _week_filtered(db) names = [r.name for r in rows] for r in rows: @@ -608,7 +608,7 @@ def custom_stats( def create_custom_update(payload: dict, db: Session = Depends(get_db)): cp = db.get(CustomProject, payload.get("custom_project_id")) if not cp: - raise HTTPException(404, "定开项目不存在") + raise HTTPException(404, "定制项目不存在") u = CustomUpdate( custom_project_id=payload["custom_project_id"], period_id=payload["period_id"], @@ -640,7 +640,7 @@ def create_custom_update(payload: dict, db: Session = Depends(get_db)): @router.patch("/custom/updates/{uid}") def patch_custom_update(uid: int, payload: dict, db: Session = Depends(get_db)): - """修改单条定开周期记录。 + """修改单条定制周期记录。 金额/计收是「本期新增量」,改了要按差额回补项目累计值;进度与阶段直接覆盖项目档案。 """ @@ -691,7 +691,7 @@ def delete_custom_update(uid: int, db: Session = Depends(get_db)): def custom_ai_parse(payload: CustomParseIn, db: Session = Depends(get_db)): cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None if payload.custom_project_id and not cp: - raise HTTPException(404, "定开项目不存在") + raise HTTPException(404, "定制项目不存在") period = db.get(Period, payload.period_id) if payload.period_id else None parsed, mode, error = ai_parser.parse_custom( diff --git a/app/routers/export.py b/app/routers/export.py index 19c7c62..088a7f4 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -1,8 +1,8 @@ """数据导出(Excel / xlsx)。 提供四类导出,均可带上页面当前的筛选条件: -- /export/custom 定开项目明细 -- /export/custom-updates 定开项目周期记录 +- /export/custom 定制项目明细 +- /export/custom-updates 定制项目周期记录 - /export/projects 常规项目管理 - /export/updates 常规项目周期执行记录 - /export/staff 人力资源库(含项目负载) @@ -42,12 +42,13 @@ def _resp(content: bytes, filename: str) -> Response: return Response(content=body, headers=headers) -# ---------------------------------------------------------------- 定开项目 +# ---------------------------------------------------------------- 定制项目 @router.get("/export/custom") def export_custom( region: str | None = None, origin: str | None = None, status: str | None = None, + risk: str | None = None, accept_plan: str | None = None, keyword: str | None = None, archived: str = Query("false", pattern="^(true|false|all)$"), @@ -64,13 +65,15 @@ def export_custom( q = q.filter(CustomProject.origin == origin) if status: q = q.filter(CustomProject.project_status == status) + if risk: + q = q.filter(CustomProject.risk_level == risk) if accept_plan: q = q.filter(CustomProject.accept_plan == accept_plan) if keyword: q = q.filter(CustomProject.name.contains(keyword)) projects = _sort_by_update(q.all()) - # 列与「定开项目明细」表格一致 + # 列与「定制项目明细」表格一致 headers = [ "项目归属", "项目名称", "项目ID", "下单时间", "办事处", "行业", "市场下单人天", "订单金额(元)", "市场责任人", "项目状态", @@ -110,14 +113,14 @@ def export_custom( ) content = build_xlsx( - "定开项目明细", + "定制项目明细", headers, rows, widths=[10, 46, 18, 12, 10, 8, 13, 14, 14, 11, 11, 14, 10, 40, 18], - title="定开项目明细", + title="定制项目明细", money_cols=(8,), # 订单金额(元) ) - return _resp(content, "定开项目明细") + return _resp(content, "定制项目明细") @router.get("/export/custom-updates") @@ -158,14 +161,14 @@ def export_custom_updates( ) content = build_xlsx( - "定开项目记录", + "定制项目记录", headers, rows, widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30], - title="定开项目周期记录", + title="定制项目周期记录", money_cols=(7, 8), ) - return _resp(content, "定开项目周期记录") + return _resp(content, "定制项目周期记录") # ---------------------------------------------------------------- 常规项目 @@ -296,7 +299,7 @@ def export_staff( headers = [ "序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目", - "定开项目", "定开合同额(元)", "定开待计收(元)", "最近更新", "说明", "归档", + "定制项目", "定制合同额(元)", "定制待计收(元)", "最近更新", "说明", "归档", ] rows = [] for i, m in enumerate(members, 1): diff --git a/app/routers/focus.py b/app/routers/focus.py index 26b3885..4d78a12 100644 --- a/app/routers/focus.py +++ b/app/routers/focus.py @@ -2,7 +2,7 @@ 和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。 字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。 -period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定开项目的周期归属同一个意思; +period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定制项目的周期归属同一个意思; 预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。 「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。 录入整理在「每周重点工作」页,汇总在「工作看板」。 diff --git a/app/routers/staff.py b/app/routers/staff.py index 2e8ef54..849d1c2 100644 --- a/app/routers/staff.py +++ b/app/routers/staff.py @@ -1,6 +1,6 @@ """人力资源库:人员维护 + 人员负载。 -项目 / 定开项目的「负责人」统一取这里的姓名,因此: +项目 / 定制项目的「负责人」统一取这里的姓名,因此: · 改名时同步刷新已有项目的负责人字段; · 被项目引用的人不允许直接删除,只能归档(归档后不再出现在下拉里)。 """ @@ -51,7 +51,7 @@ ALLOWED_PATCH = ("name", "group", "affiliation", "note", "sort_order", "archived def _workload(db: Session) -> dict[str, dict]: - """按姓名汇总在管项目 / 定开项目负载。""" + """按姓名汇总在管项目 / 定制项目负载。""" load: dict[str, dict] = {} def slot(name: str) -> dict: @@ -283,7 +283,7 @@ def delete_staff(sid: int, force: bool = False, db: Session = Depends(get_db)): if (n1 or n2) and not force: raise HTTPException( 400, - f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定开项目。" + f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定制项目。" f"离职人员建议直接「归档」(归档后不再出现在负责人下拉里,历史记录保留);" f"确需删除请先转交这些项目。", ) diff --git a/app/routers/weekly.py b/app/routers/weekly.py index ae3c8d3..78e1766 100644 --- a/app/routers/weekly.py +++ b/app/routers/weekly.py @@ -1,8 +1,8 @@ -"""周看板:重点项目 + 定开项目的单周总览。 +"""周看板:重点项目 + 定制项目的单周总览。 左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐: · 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目 - · 定开项目 = 打了 ★ 的定开项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」) + · 定制项目 = 打了 ★ 的定制项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」) 前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。 右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。 工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。 @@ -108,7 +108,7 @@ def weekly( } ) - # ---------------------------------------------------------------- 定开项目 + # ---------------------------------------------------------------- 定制项目 cups_by_project: dict[int, list[CustomUpdate]] = defaultdict(list) for u in ( db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).order_by(CustomUpdate.id).all() @@ -125,7 +125,7 @@ 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) - # 「本周新增 / 本周验收」与「定开项目 · 本周进展」同一口径(见 web/app.js 的 + # 「本周新增 / 本周验收」与「定制项目 · 本周进展」同一口径(见 web/app.js 的 # customWeeklySections):新增按「下单时间」,验收按「更新时间 + 状态=已验收」。 sign = (cp.sign_date or "").strip()[:10] upd = (cp.update_date or "").strip()[:10] @@ -182,6 +182,9 @@ def weekly( key_all = sum(1 for p in db.query(Project).filter(Project.archived.is_(False), Project.is_key.is_(True)).all()) risks = sum(len(r["risks"]) for r in project_rows) + sum(len(r["risks"]) for r in custom_rows) + # 风险 / 阻塞分开计数:取执行记录状态(at_risk=风险、blocked=阻塞);定制执行记录没有状态字段,不计入 + at_risk = sum(1 for r in project_rows for u in r["updates"] if u["status"] == "at_risk") + blocked = sum(1 for r in project_rows for u in r["updates"] if u["status"] == "blocked") kpi = { "key_total": key_all, "key_updated": sum(1 for r in project_rows if r["is_key"] and r["has_update"]), @@ -195,6 +198,8 @@ def weekly( "amount_delta": round(sum(r["period_amount_delta"] for r in custom_rows), 2), "revenue_delta": round(sum(r["period_revenue_delta"] for r in custom_rows), 2), "risk_count": risks, + "at_risk_count": at_risk, + "blocked_count": blocked, } return { "periods": [p.to_dict() for p in periods], diff --git a/app/schema_patch.py b/app/schema_patch.py index 98a429a..6027957 100644 --- a/app/schema_patch.py +++ b/app/schema_patch.py @@ -1,6 +1,6 @@ """老库补列:create_all 只会建新表,不会给已存在的表加字段。 -模型新增可空字段后(如定开项目的验收时间 / 开票 / 回款),启动时自动 +模型新增可空字段后(如定制项目的验收时间 / 开票 / 回款),启动时自动 `ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。 用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。 diff --git a/data/board.db b/data/board.db index cd8c41f..7675868 100644 Binary files a/data/board.db and b/data/board.db differ diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..e2cca0a --- /dev/null +++ b/deploy.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# 部门关键任务追踪看板 · Docker 部署脚本 +# 用法: ./deploy.sh up 构建镜像并后台启动(默认命令) +# ./deploy.sh update 改完代码后重建镜像并替换容器 +# ./deploy.sh restart 重启容器(不换镜像) +# ./deploy.sh down 停止并删除容器,数据留在 ./data +# ./deploy.sh logs 跟踪日志(Ctrl+C 退出) +# ./deploy.sh backup 导出数据库快照(部署到新机器时带走数据) +# ./deploy.sh status 容器状态 + 健康检查 + 访问地址 +set -euo pipefail +cd "$(dirname "$0")" + +CONTAINER=unis-board + +# ---------- 0. 前置检查 ---------- +command -v docker >/dev/null 2>&1 || { echo "!! 没有 docker 命令,请先安装 Docker / Docker Desktop" >&2; exit 1; } +docker info >/dev/null 2>&1 || { echo "!! docker 守护进程没起来(Docker Desktop 是否没运行?)" >&2; exit 1; } + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + echo "!! 没找到 docker compose,也没找到 docker-compose" >&2 + exit 1 +fi + +# 对外端口:命令行环境变量 > .env 里的 HOST_PORT > 8770 +if [ -z "${HOST_PORT:-}" ] && [ -f .env ]; then + HOST_PORT="$(sed -n 's/^HOST_PORT=//p' .env | tail -1)" +fi +HOST_PORT="${HOST_PORT:-8770}" +URL="http://127.0.0.1:${HOST_PORT}" + +# ---------- 1. 准备 .env 与 data 目录 ---------- +prepare() { + if [ ! -f .env ]; then + cp .env.example .env + echo "==> 已从 .env.example 生成 .env(AI Key、管理员账号密码请按需修改,改完 restart 生效)" + fi + # 先建好目录,免得 Docker 用 root 建出来盖掉宿主机权限 + mkdir -p data/uploads data/exports +} + +# ---------- 2. 等服务就绪 ---------- +wait_ready() { + echo -n "==> 等待服务就绪 " + for _ in $(seq 1 30); do + state="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER" 2>/dev/null || echo missing)" + case "$state" in + healthy) echo; echo "==> 已就绪:$URL"; return 0 ;; + unhealthy) echo; echo "!! 容器不健康,执行 ./deploy.sh logs 看原因" >&2; return 1 ;; + esac + echo -n "." + sleep 2 + done + echo; echo "!! 等待超时(当前状态:$state),执行 ./deploy.sh logs 看原因" >&2 + return 1 +} + +# ---------- 3. 分发命令 ---------- +case "${1:-up}" in + up|start) + first_run=0 + [ -f data/board.db ] || first_run=1 + prepare + "${COMPOSE[@]}" up -d --build + wait_ready + echo " API 文档:$URL/api/docs" + if [ "$first_run" = "1" ]; then + echo " 首次启动:管理员账号取自 .env 的 ADMIN_USERNAME/ADMIN_PASSWORD(默认 admin / admin123,请登录后立刻改密码)" + fi + ;; + update) + prepare + "${COMPOSE[@]}" up -d --build --force-recreate + wait_ready + ;; + restart) + "${COMPOSE[@]}" restart + wait_ready + ;; + down|stop) + "${COMPOSE[@]}" down + echo "==> 已停止(数据仍在 ./data,要备份请复制 data/board.db)" + ;; + logs) + exec "${COMPOSE[@]}" logs -f --tail=100 + ;; + backup) + # 用 SQLite 备份 API 取一致性快照:服务正在写也不会拷到半截页 + [ -f data/board.db ] || { echo "!! 没找到 data/board.db(若已换 MySQL,请改用 mysqldump)" >&2; exit 1; } + PYBIN="$(command -v python3 || command -v python || true)" + [ -n "$PYBIN" ] || { echo "!! 备份需要 python3(只拷库,不用装项目依赖)" >&2; exit 1; } + mkdir -p data/backups + OUT="data/backups/board-$(date +%Y%m%d_%H%M%S).db" + "$PYBIN" - "$OUT" <<'PYEOF' +import os, sqlite3, sys +src = sqlite3.connect("data/board.db") +dst = sqlite3.connect(sys.argv[1]) +with dst: + src.backup(dst) +print(f"[ok] 已导出 {sys.argv[1]}({os.path.getsize(sys.argv[1]) // 1024} KB)") +PYEOF + echo "==> 部署到新机器:把这个文件放到目标机的 data/board.db,再执行 ./deploy.sh up" + ;; + status) + "${COMPOSE[@]}" ps + echo -n "==> /health:" + curl -s --max-time 3 "$URL/health" 2>/dev/null || echo " 无响应(容器未启动?./deploy.sh up)" + echo + echo "==> 访问地址:$URL" + ;; + *) + sed -n '3,9p' "$0" + exit 1 + ;; +esac diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b5f3967 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +# 部门关键任务追踪看板 · 部署编排 +# +# 常用命令(推荐用封装脚本,会顺带检查 docker/.env/data 目录并等健康检查): +# ./deploy.sh up 构建 + 后台启动 +# ./deploy.sh update 改完代码重建并替换容器 +# ./deploy.sh logs # 看日志 +# ./deploy.sh down # 停止(数据留在 ./data) +# +# 等价的裸 compose 命令:docker compose up -d --build + +services: + board: + build: + context: . + args: + # 传给 Dockerfile 的 pip 源,默认官方;国内网络可在 .env 里设 + # PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple + PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.org/simple}" + image: unis-board:latest + container_name: unis-board + restart: unless-stopped + + # AI 整理参数、管理员账号等都来自 .env(compose 会自动读同目录的 .env 做变量替换) + env_file: .env + + environment: + # 容器内端口固定 8770,跟 EXPOSE/健康检查对齐;要换对外端口请改 HOST_PORT + PORT: "8770" + # 容器里不开热加载(uvicorn --reload 会白耗 CPU,且代码在镜像内不可改) + DEV: "0" + # 覆盖宿主机的 TZ:周期/周次/入库时间都按这个时区计算 + TZ: "${TZ:-Asia/Shanghai}" + + ports: + - "${HOST_PORT:-8770}:8770" + + volumes: + # SQLite 库、上传件、导出件都落在宿主机的 ./data,升级镜像不动数据 + - ./data:/app/data + + # Linux 上想让容器以普通用户跑(配合 ./data 的属主): + # user: "1000:1000" diff --git a/run.sh b/run.sh index 66ad3b7..18c5d8c 100755 --- a/run.sh +++ b/run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 部门关键任务追踪看板 · 启动脚本 +# DMS管理平台 · 启动脚本 # 用法: ./run.sh 正常启动(默认开热更新) # PORT=8771 ./run.sh 换端口启动 # DEV=0 ./run.sh 关闭热更新(改代码不自动重启、页面不自动刷新) @@ -16,7 +16,7 @@ PORT="${PORT:-8770}" XLSX="${XLSX:-/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx}" STAFF_XLSX="${STAFF_XLSX:-/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx}" -echo "==> 部门关键任务追踪看板" +echo "==> DMS管理平台" # ---------- 1. 选择带依赖的 Python 解释器 ---------- CANDIDATES=( diff --git a/scripts/import_excel.py b/scripts/import_excel.py index 59ddf63..7bfe427 100644 --- a/scripts/import_excel.py +++ b/scripts/import_excel.py @@ -44,7 +44,7 @@ CATEGORY_RULES = [ (("无人机", "智飞"), "智能装备"), (("AI", "智能体", "Agent", "大模型"), "AI 能力"), (("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"), - (("定制", "交付", "项目支撑"), "定开项目"), + (("定制", "交付", "项目支撑"), "定制项目"), ] diff --git a/scripts/init_db.py b/scripts/init_db.py index e8ce4df..c09d492 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -18,7 +18,7 @@ DEFAULT_CATEGORIES = [ ("平台产品", 0), ("AI 能力", 1), ("智能装备", 2), - ("定开项目", 3), + ("定制项目", 3), ("内部运营", 4), ] diff --git a/web/app.js b/web/app.js index a217028..78d2bd4 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,4 @@ -/* 软件开发部 · 关键任务追踪看板 */ +/* DMS管理平台 */ (function () { "use strict"; @@ -6,6 +6,7 @@ const state = { view: "board", + navFolded: false, // 侧栏收拢:只留菜单图标 gran: "week", weekId: null, monthIdx: null, @@ -22,18 +23,23 @@ drawerProject: null, // 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移 dw: { group: "week", range: 2, back: 0 }, - // 定开项目 + // 定制项目 customMeta: null, - // 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线) + // 定制:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线) customTab: "list", customAnnualEdit: false, // 年度任务:默认查看模式,点「编辑」才可改 customRegion: "", customStage: "", customStatus: "", + customRisk: "", customOrigin: "", customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc } customArchived: "false", customRows: [], + // 列表分页(全站统一):每页条数 / 各列表当前页 / 各列表上一次的筛选指纹 + pageSize: 10, + pager: {}, + pagerSig: {}, intakeType: "general", // 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条) intakeMode: "direct", @@ -233,16 +239,9 @@ 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}
` : ""} -
`; - } + const cap = o.title || o.count ? `

${esc(o.title)}${cnt}

` : ""; return `
-

${esc(o.title)}${cnt}

+ ${cap} ${o.chips ? `
${o.chips}
` : ""} ${tools ? `
${tools}
` : ""}
`; @@ -252,9 +251,68 @@ return `
${cardHead(o)} ${o.rows.length ? `
${o.table}
` : `
${o.empty}
`} + ${o.pager || ""}
`; } + /* -------------------------------------- 列表分页:全站统一,默认每页 10 条 */ + const PAGE_SIZES = [10, 20, 50, 100]; + + /** 取当前页要渲染的行。 + * key 每个列表一个固定标识,页码记在 state.pager[key]; + * sig 该列表当前的筛选条件,条件一变回到第 1 页,翻页 / 排序保持当前页。 */ + function pagedRows(rows, key, sig) { + sig = [].concat(sig || []).join("\u0001"); + if (state.pagerSig[key] !== sig) { state.pagerSig[key] = sig; state.pager[key] = 1; } + const size = state.pageSize; + const pages = Math.max(1, Math.ceil(rows.length / size)); + const page = Math.min(Math.max(state.pager[key] || 1, 1), pages); + state.pager[key] = page; + return rows.slice((page - 1) * size, page * size); + } + + /** 页码条:一页装得下就不显示 */ + function pagerBar(key, total) { + const size = state.pageSize; + const pages = Math.ceil(total / size); + if (pages < 2) return ""; + const page = state.pager[key] || 1; + const btn = (label, p, active, disabled) => + ``; + // 页码只列 首页 / 末页 / 当前页前后各 2 页,中间用省略号 + let nums = "", last = 0; + for (let i = 1; i <= pages; i++) { + if (i !== 1 && i !== pages && Math.abs(i - page) > 2) continue; + if (i - last > 1) nums += `…`; + nums += btn(i, i, i === page, false); + last = i; + } + const sizes = PAGE_SIZES.map((n) => + ``).join(""); + return `
+ 共 ${total} 条 +
${btn("‹", page - 1, false, page <= 1)}${nums}${btn("›", page + 1, false, page >= pages)}
+ 第 ${page} / ${pages} 页 + +
`; + } + + /** 页码条的交互:点页码 / 换每页条数后由调用方重绘列表 */ + function bindPager(root, rerender) { + root.querySelectorAll("[data-pager]").forEach((bar) => { + const key = bar.dataset.pager; + bar.querySelectorAll("[data-pg]").forEach((b) => + b.addEventListener("click", () => { state.pager[key] = Number(b.dataset.pg); rerender(); }) + ); + const sel = bar.querySelector(".pg-size"); + if (sel) sel.addEventListener("change", () => { + state.pageSize = Number(sel.value) || PAGE_SIZES[0]; + Object.keys(state.pager).forEach((k) => { state.pager[k] = 1; }); + rerender(); + }); + }); + } + /** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。 事件属性名由调用方给出,沿用各表原有的绑定。 */ function rowOps(o) { @@ -281,8 +339,8 @@ } /** - * 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定开抽屉共用)。 - * kind = "project" 自研产品的执行记录,"custom" 定开项目的交付记录; + * 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定制抽屉共用)。 + * kind = "project" 自研产品的执行记录,"custom" 定制项目的交付记录; * 传 update 就是修改,否则在 periodId 下新建;写完调 onDone 刷新所在区域。 */ function openUpdateDialog(o) { @@ -652,6 +710,16 @@ if (!state.periods || state.monthIdx === null) return null; return state.periods.months[state.monthIdx] || null; } + /** 顶栏统计周期落在哪个季度(周 / 月都换算成所属年份的 1~4),没选到周期时退回今天 */ + function scopeQuarter() { + const per = state.gran === "week" + ? (state.periods && (state.periods.weeks || []).find((x) => x.id === state.weekId)) + : currentMonth(); + const m = Number(per && per.month); + if (m >= 1 && m <= 12) return Math.floor((m - 1) / 3) + 1; + const now = new Date(); + return Math.floor(now.getMonth() / 3) + 1; + } function scopePeriodIds() { if (state.gran === "week") return state.weekId ? [state.weekId] : []; const m = currentMonth(); @@ -668,50 +736,6 @@ } /* ---------------------------------------------------------------- charts */ - function barChart(items, opts) { - opts = opts || {}; - const W = 660, H = opts.height || 190, padL = 34, padB = 30, padT = 14; - const innerW = W - padL - 12, innerH = H - padB - padT; - const max = Math.max(1, ...items.map((d) => d.value)); - const n = items.length || 1; - const slot = innerW / n; - const bw = Math.min(38, slot * 0.6); - let bars = ""; - items.forEach((d, i) => { - const h = Math.max(2, (d.value / max) * innerH); - const x = padL + slot * i + (slot - bw) / 2; - const y = padT + innerH - h; - bars += `${esc(d.label)}: ${d.value}${esc(opts.unit || "")}`; - if (d.value > 0) { - bars += `${d.value}`; - } - bars += `${esc(d.label)}`; - }); - let grid = ""; - for (let g = 0; g <= 3; g++) { - const y = padT + (innerH / 3) * g; - grid += ``; - grid += `${Math.round(max - (max / 3) * g)}`; - } - return `${grid}${bars}`; - } - - function donutChart(items) { - const total = items.reduce((a, b) => a + b.value, 0) || 1; - const r = 58, cx = 80, cy = 80, C = 2 * Math.PI * r; - let acc = 0, segs = ""; - items.forEach((d) => { - const len = (d.value / total) * C; - segs += `${esc(d.name)}: ${d.value}`; - acc += len; - }); - return ` - ${segs} - ${total} - 项目总数 - `; - } - function stackBar(items) { const total = items.reduce((a, b) => a + b.count, 0) || 1; let x = 0, segs = ""; @@ -797,89 +821,8 @@ refresh(); } - /* ---------------------------------------------------------------- dashboard */ - /** 统计概览(原「执行面板」):折进工作看板底部,不再占一个主导航位 */ - async function dashboardHtml() { - const s = await api("/stats" + scopeQuery()); - state.stats = s; - const k = s.kpi; - const trend = s.trend.map((t) => { - const cur = scopePeriodIds().indexOf(t.period_id) >= 0; - return { label: `${t.month}/${t.week || ""}`, value: t.update_count, color: cur ? "#2563eb" : "#93b4f5", dim: !cur }; - }); - - return ` -
-
${Ico.badge("layers")}
在管项目
${k.project_total}
重点项目 ${k.key_total} 个
-
执行记录
${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}
-
- -
-
-

周期更新活跃度

-
${barChart(trend, { unit: " 条" })}
-
-
-

项目分类分布

-
-
-
${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}
-
- ${s.category_dist.map((c) => `
${esc(c.name)} ${c.count}
`).join("")} -
-
-
-
-
- -
-
-

执行状态分布

-
- ${stackBar(s.status_dist)} -
${s.status_dist.map((x) => `${esc(x.name)} ${x.count}`).join("")}
-
进度领先 Top 10
- ${barList(s.top_progress)} -
-
-
-

风险与阻塞

-
- ${s.risks.length ? s.risks.map((r) => ` -
-
- ${esc(r.project_name)}${statusPill(r.status)} - ${esc(r.period_label || "")} -
-
${nl2br(r.risk || r.content || "")}
-
`).join("") : `
本周期暂无风险上报
`} -
-
-
- -
-
-

低进度预警

-
${s.low_progress.length ? barList(s.low_progress) : `
暂无
`}
-
-
-

最近更新

-
- ${s.recent_updates.length ? s.recent_updates.map((u) => ` -
-
${esc(u.project_name)}${statusPill(u.status)}${esc(u.task_name || "")}${esc(u.period_label || "")}
-
${nl2br((u.summary || u.content || "").slice(0, 160))}
-
`).join("") : `
暂无
`} -
-
-
`; - } - /* ---------------------------------------------------------------- 周看板 */ - /** 左栏一行:重点项目 / 定开项目共用 */ + /** 左栏一行:重点项目 / 定制项目共用 */ function weeklyItem(r) { const sel = state.weeklySel; const active = sel && sel.kind === r.kind && sel.id === r.id; @@ -994,7 +937,7 @@ ` : ""}`; } - /* 右栏:定开项目详情(交付环节 + 财务) */ + /* 右栏:定制项目详情(交付环节 + 财务) */ async function weeklyCustomDetail(row) { const d = await api("/custom/projects/" + row.id); const p = d.project; @@ -1069,8 +1012,10 @@ }); } - /** 定开项目本周动态里的一条:整行可点,点进去看该项目详情 */ - function weeklyCustomRow(r, tag) { + /** 定制项目本周动态里的一条:整行可点,点进去看该项目详情。 + * o.province = 带上所属省份;o.owner === false = 不显示负责人 */ + function weeklyCustomRow(r, tag, o) { + o = o || {}; return `
@@ -1079,7 +1024,8 @@
${stagePill(r.stage)} - ${esc(r.owner || "未指派")} + ${o.province ? `${esc(r.region || "省份未填")}` : ""} + ${o.owner === false ? "" : `${esc(r.owner || "未指派")}`} ${fen(r.amount)} 万 ${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""} ${(r.risks || []).length ? `风险 ${(r.risks || []).length}` : ""} @@ -1088,45 +1034,47 @@
`; } - /** 右栏:定开项目本周动态。左栏的「定开项目」只给入口,清单都收在这里 */ + /** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */ function weeklyCustomOverview(data) { - // 「本周新增 / 本周验收」与定开项目的「本周进展」tab 共用同一套口径 + // 「本周新增 / 本周验收」与定制项目的「本周进展」tab 共用同一套口径 const { added, accepted, listed } = customWeeklySections(data.custom, scopeRange()); // 高风险取全部在档项目:/weekly 的 custom 只有本期有动态的,取全量才不会漏 - const risky = (state.customAll || data.custom).filter((r) => isHighRisk(r)) + // 只列「统计周期所在季度」要推动验收的高风险项目(按推动验收计划 Q1~Q4) + const riskQ = "Q" + scopeQuarter(); + const risky = (state.customAll || data.custom) + .filter((r) => isHighRisk(r) && String(r.accept_plan || "").trim().toUpperCase() === riskQ) .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) => ` + const sec = (title, sub, list, tag, o) => `
${title}
- ${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("") - : `
本周期没有
`} + ${list.length ? list.map((r) => weeklyCustomRow(r, tag(r), o)).join("") + : `
${(o && o.empty) || "本周期没有"}
`}
`; return `
-
${Ico.badge("package")}定开项目 · 本周动态
+
${Ico.badge("package")}定制项目 · 本周动态
本周新增 ${added.length} 个 本周验收 ${accepted.length} 个 - 高风险 ${risky.length} 个 + 高风险 ${riskQ} ${risky.length} 个 合同额合计 ${fen(sum)} 万 本期计收 ${fen(data.kpi.revenue_delta)} 万
- +
${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} 个
` : ""}`; + ${sec("高风险项目(推动验收计划 " + riskQ + ")", "风险值为高风险且推动验收计划落在统计周期所在季度", risky, + (r) => (r.project_status ? `${esc(r.project_status)}` : ""), + { province: true, owner: false, empty: riskQ + " 没有推动验收的高风险项目" })}`; } function bindWeeklyCustomOverview(box) { @@ -1213,18 +1161,18 @@
${Ico.badge("star")}
重点项目
${k.key_total}
本期已更新 ${k.key_updated}
${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")}
本季度验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个
-
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
+
${Ico.badge("wallet")}
截止验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个
+
${Ico.badge("alert")}
工作风险 / 阻塞
${k.at_risk_count} / ${k.blocked_count}
`; - const group = (title, rows, tip) => ` + const group = (title, rows, tip, icon) => `
-
${title}${rows.length} +
${icon ? Ico.svg(icon) : ""}${title}${rows.length} ${rows.filter((r) => r.has_update).length} ${tip}
${rows.length ? rows.map(weeklyItem).join("") : `
本周期没有记录
`}
`; - // 左栏顺序:本周工作重点 → 重点项目 → 定开项目(只给入口,点开在右栏看清单) + // 左栏顺序:每周工作 → 自研产品 → 定制项目(标题与图标一律跟左侧主导航一致) const focusList = focus.overdue.concat(focus.week); const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom"); @@ -1232,18 +1180,18 @@
-
本周工作重点${focusList.length} +
${Ico.svg("target")}每周工作${focusList.length} ${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}
${focusList.length ? focusList.map(focusItem).join("") : `
本周没有安排重点工作
`}
- ${group("重点项目", data.projects, "个已更新")} + ${group("自研产品", data.projects, "个已更新", "list-checks")}
-
定开项目${data.custom.length} +
${Ico.svg("package")}定制项目${data.custom.length} 本周动态 ${cDyn} 个
${Ico.svg("package")} - 本周定开项目动态 + 本周定制项目动态 ${Ico.svg("arrow-right")}
@@ -1257,14 +1205,6 @@
加载中…
`; - // 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体 - dashboardHtml().then((html) => { - host.insertAdjacentHTML("beforeend", - `
统计概览
${html}
`); - }).catch((e) => { - host.insertAdjacentHTML("beforeend", `
统计概览加载失败:${esc(e.message)}
`); - }); - host.querySelectorAll("[data-wk]").forEach((el) => el.addEventListener("click", () => { const parts = el.dataset.wk.split(":"); @@ -1378,6 +1318,7 @@ state.focusRows = rows; const b = focusBuckets(rows); const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || []; + const pageRows = pagedRows(shown, "focus", [state.keyword, state.focusFilter, state.focusArchived]); const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]]; const chips = filters.map((x) => ``).join(""); @@ -1388,7 +1329,7 @@ 预计完成时间状态结果反馈更新操作 - ${shown.map((f) => ` + ${pageRows.map((f) => `
${esc(f.content)} @@ -1415,14 +1356,15 @@ fields: ``, tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)} ${canR("focus") ? `` : ""} - ${canW("focus") ? `` : ""}`, + ${canW("focus") ? `` : ""}`, rows: shown, table: table, + pager: pagerBar("focus", shown.length), empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的重点工作" : state.focusFilter === "week" - ? "本周还没有录入重点工作,点右上角「新增重点工作」添加" - : "这一堆还没有条目,点右上角「新增重点工作」添加", + ? "本周还没有录入重点工作,点右上角「新增」添加" + : "这一堆还没有条目,点右上角「新增」添加", }); host.innerHTML = body; @@ -1458,6 +1400,7 @@ if (row) deleteFocus(row); }) ); + bindPager(host, renderFocus); const bad = $("#btnAddFocus"); if (bad) bad.addEventListener("click", () => openFocusDrawer(null, "create")); } @@ -1630,6 +1573,8 @@ const rows = await api("/projects" + scopeQuery(extra)); const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows; const idle = rows.filter((r) => !r.updated_this_period).length; + const pageRows = pagedRows(shown, "projects", + [state.keyword, state.showArchived, state.categoryId, state.projOwner, state.projIdleOnly, state.gran]); const ownerFilter = `` + + `` + + list.map((q) => ``).join("") + + ``; + } + /** 风险值:1.高风险 红 / 2.低风险 绿 */ function riskPill(code) { if (!code) return `—`; @@ -1813,7 +1772,7 @@ let keywordFocus = false; - /** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ + /** 定制「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"]; const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"]; const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0; @@ -1823,7 +1782,7 @@ host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); - // 一次性取「全部定开项目」(不带搜索/筛选条件): + // 一次性取「全部定制项目」(不带搜索/筛选条件): // 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。 const allRows = await api("/custom/projects?archived=" + state.customArchived); state.customRows = allRows; @@ -1852,26 +1811,27 @@ STATUS_OPTIONS.map((s) => ``).join(""); const originFilterHtml = `` + CUSTOM_ORIGINS.map((o) => ``).join(""); + const RISK_OPTIONS = ["1.高风险", "2.低风险"]; + const riskFilterHtml = `` + + RISK_OPTIONS.map((r) => ``).join(""); const isList = state.customTab !== "weekly" && state.customTab !== "annual"; - // 三个 tab 用顶部「周/月」那套分段控件,筛选器和主按钮(导出 / 新建)放下一行 + // 三个 tab 是卡片外的页签,选中项和下方卡片连成一体;header 面板只放筛选器和功能按钮 + const tabs = `
` + [["list", "项目总览"], ["weekly", "本周进展"], ["annual", "年度任务"]] + .map(([k, label]) => ``) + .join("") + `
`; const head = { - chips: ` -
- - - -
`, tools: isList ? ` ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)} + ${canR("custom") ? `` : ""} - ${canW("custom") ? `` : ""}` : "", + ${canW("custom") ? `` : ""}` : "", }; - host.innerHTML = ( + host.innerHTML = tabs + ( state.customTab === "weekly" ? renderCustomWeekly(allRows, head) : state.customTab === "annual" ? renderCustomAnnual(allRows, head) : renderCustomList(allRows, head)); @@ -1888,31 +1848,36 @@ if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); const st = $("#cStatus"); if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); }); + const rk = $("#cRisk"); + if (rk) rk.addEventListener("change", () => { state.customRisk = rk.value; renderCustom(); }); if (isList) bindKeyword("cKeyword", { enter: true }); const bn = $("#btnNewCustom"); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); const bex = $("#btnExportCustom"); if (bex) bex.addEventListener("click", () => { - // 导出与「项目总览」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字 + // 导出与「项目总览」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 风险值 + 关键字 const q = ["archived=" + state.customArchived]; if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin)); if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus)); + if (state.customRisk) q.push("risk=" + encodeURIComponent(state.customRisk)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/custom?" + q.join("&")); }); bindCustomList(host); + bindPager(host, renderCustom); } function renderCustomList(allRows, hdr) { hdr = hdr || {}; - // 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;再按表头排序 + // 列表:按 关键字 + 归属 + 区域 + 状态 + 风险值 在本地筛选;再按表头排序 const kw = (state.keyword || "").trim(); let list = allRows; if (kw) list = list.filter((p) => String(p.name || "").includes(kw)); if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin); if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion); if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus); + if (state.customRisk) list = list.filter((p) => (p.risk_level || "") === state.customRisk); const s = state.customSort || { key: "update_date", dir: "desc" }; const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || "")); const view = list.slice().sort((a, b) => { @@ -1920,6 +1885,8 @@ const c = va < vb ? -1 : va > vb ? 1 : 0; return s.dir === "asc" ? c : -c; }); + const pageRows = pagedRows(view, "custom", + [state.customTab, state.keyword, state.customOrigin, state.customRegion, state.customStatus, state.customRisk, state.customArchived]); // ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ---- const placed = allRows.filter(isPlaced); // 已下单 = 华智下单 / 存量项目 / 汇智直签 @@ -1946,26 +1913,27 @@ const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ); const accYear = accRows; - const stat = (label, count, amount, color) => - `
${count}个 · ${fen(amount)}万
${label}
`; + const stat = (label, count, amount, accent, icon) => + `
${Ico.badge(icon)}
${label}
${fen(amount)} 万
${count} 个项目
`; - const statBar = `
- ${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "var(--primary)")} - ${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "var(--violet)")} - ${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "var(--violet)")} - ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")} - ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")} + const statBar = `
+ ${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "accent-blue", "wallet")} + ${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "accent-violet", "trending-up")} + ${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "accent-violet", "trending-up")} + ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "accent-amber", "receipt")} + ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "accent-amber", "receipt")}
`; return `
- ${cardHead({ title: "定开项目总览", count: view.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })} + ${cardHead({ tools: hdr.tools })} ${statBar} - ${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`} + ${view.length ? customTableHtml(pageRows) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}
`} + ${pagerBar("custom", view.length)}
`; } - /** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */ + /** 定制项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */ function customTableHtml(view, withOps) { const ops = withOps !== false; const s = state.customSort || { key: "update_date", dir: "desc" }; @@ -2029,7 +1997,7 @@
`; } - /** 定开项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径: + /** 定制项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径: · 本周新增:下单时间落在本期 ∧(已下单 ∨ 提前交付),和「更新时间」无关 · 本周验收:更新时间落在本期 ∧ 项目状态为「已验收」 rows 传 /custom/projects 全量或 /weekly 的行都行,两者都带 sign_date / update_date / origin / project_status。 */ @@ -2066,22 +2034,24 @@ // 进行中项目:本期更新的「进行中 + 交付完成」 const doing = rows.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成"); - const stat = (label, rows, color) => - `
${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`; + const stat = (label, rows, accent, icon) => + `
${Ico.badge(icon)}
${label}
${fen(sum(rows, "amount"))} 万
${rows.length} 个项目
`; - const statBar = `
- ${stat("验收情况", acc, "var(--amber)")} - ${stat("新增项目", newDeal, "var(--violet)")} - ${stat("进行中项目", doing, "var(--primary)")} + const statBar = `
+ ${stat("验收情况", acc, "accent-amber", "receipt")} + ${stat("新增项目", newDeal, "accent-violet", "trending-up")} + ${stat("进行中项目", doing, "accent-blue", "activity")}
`; return `
- ${cardHead({ title: "本周进展", count: rows.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })} ${statBar} ${rows.length - ? customTableHtml(rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false) - : `
本期没有更新的定开项目
`} + ? customTableHtml(pagedRows( + rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), + "customWeekly", [state.customTab, sc.start, sc.end]), false) + : `
本期没有更新的定制项目
`} + ${pagerBar("customWeekly", rows.length)}
`; } @@ -2128,15 +2098,11 @@ `; return `
- ${cardHead({ - title: "年度任务(" + Y + "年)", count: Y + "年", - chips: hdr.chips, chipsRow: true, - tools: canW("custom") ? `` : "", - })} + ${cardHead({ tools: canW("custom") ? `` : "" })}
- + @@ -2245,8 +2211,8 @@ } /** - * 新建定开项目 —— 全站唯一的新建入口。 - * 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 + * 新建定制项目 —— 全站唯一的新建入口。 + * 「定制项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 */ function openNewCustomDialog(onDone) { const today = new Date(); @@ -2265,7 +2231,7 @@ const txtVal = (sel) => $(sel).value.trim() || null; openModal( - ` + `
季度季度(${Y}年) 计收目标(万元) 计收项目数量 计收项目金额(万元)
- + - ${weeks.map((w) => ` + ${pagedRows(weeks, "periods").map((w) => ` @@ -3093,9 +3035,11 @@
周期常规记录定开记录
周期常规记录定制记录
${esc(w.full_label)} (原标签:${esc(w.label)}) ${w.update_count || 0}
+ ${pagerBar("periods", weeks.length)}
`; if (!rw) host.querySelectorAll("#cfgBase,#cfgKey,#cfgModel,#cfgTemp").forEach((i) => { i.disabled = true; }); + bindPager(host, renderSettings); const bap = $("#btnAddPeriod"); if (bap) bap.addEventListener("click", async () => { const id = await createNewPeriod(); @@ -3479,7 +3423,7 @@
${esc(o.name)} 项目 ${o.project_count} - 定开 ${o.custom_count} + 定制 ${o.custom_count}
`).join("") : `
所有项目负责人都能对上花名册
`} @@ -3487,13 +3431,14 @@
`; + const pageRows = pagedRows(d.items, "staff", [state.keyword, state.staffArchived, state.staffGroup]); const table = ` - + - ${d.items.map((m) => ` + ${pageRows.map((m) => ` `; }; + const rolePage = pagedRows(roles, "rbacRole"); const roleTable = `
姓名归属小组人员归属说明在管项目定开项目定开合同额在管项目定制项目定制合同额 名下待计收最近更新操作
${canW("staff") @@ -3531,9 +3476,10 @@ ${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`, tools: `${canR("staff") ? `` : ""} - ${canW("staff") ? `` : ""}`, + ${canW("staff") ? `` : ""}`, rows: d.items, table: table, + pager: pagerBar("staff", d.items.length), empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的人员" : (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"), @@ -3542,6 +3488,7 @@ host.innerHTML = kpi + body; bindKeyword("stKeyword"); + bindPager(host, renderStaff); $("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); }); $("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); }); const bn = $("#btnNewStaff"); @@ -3608,6 +3555,7 @@ meta.levels.map((l) => ``).join("") + `
@@ -3615,7 +3563,7 @@ ${rw ? `` : ""} - ${roles.map((r) => ` + ${rolePage.map((r) => ` ${menus.map((m) => levelCell(r, m)).join("")} @@ -3628,12 +3576,13 @@ const roleOpts = (sel) => roles.map((r) => ``).join(""); + const userPage = pagedRows(users, "rbacUser"); const userTable = `
角色 / 说明用户操作
${esc(r.name)}${r.is_builtin ? ` 内置` : ""}${r.description ? `${esc(r.description)}` : ""}${r.user_count}
${rw ? `` : ""} - ${users.map((u) => { + ${userPage.map((u) => { const self = u.id === state.me.id; return ` @@ -3654,14 +3603,17 @@ host.innerHTML = listCard({ title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色", - tools: rw ? `` : "", + pager: pagerBar("rbacRole", roles.length), + tools: rw ? `` : "", }) + `
` + listCard({ title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户", - tools: rw ? `` : "", + pager: pagerBar("rbacUser", users.length), + tools: rw ? `` : "", }); + bindPager(host, renderRbac); // ---- 权限矩阵:一个角色的整行一起提交 ---- host.querySelectorAll("[data-rmenu]").forEach((sel) => sel.addEventListener("change", () => { @@ -3846,8 +3798,23 @@ await refresh(); } + /** 侧栏收拢/展开:收拢后菜单只剩图标(气泡提示取菜单名),hover logo 出现展开按钮。 */ + function applyNavFold() { + document.querySelector(".app").classList.toggle("nav-folded", !!state.navFolded); + const b = $("#navFold"); + if (b) b.setAttribute("aria-label", state.navFolded ? "展开菜单" : "收起菜单"); + // 收拢后菜单只剩图标,气泡提示取菜单名 + $$("#nav .nav-item").forEach((x) => { x.dataset.tip = x.textContent.trim(); }); + } + function bindShell() { $$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view))); + $("#navFold").addEventListener("click", () => { + state.navFolded = !state.navFolded; + applyNavFold(); + saveUI(); + }); + applyNavFold(); $$("#granSeg .seg-btn").forEach((b) => b.addEventListener("click", () => { state.gran = b.dataset.gran; @@ -3891,7 +3858,7 @@ /* ------------------------------------------- 界面状态(刷新后保持) */ const UI_KEY = "unis.ui.v1"; - const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyword", + const UI_FIELDS = ["view", "navFolded", "gran", "weekId", "monthIdx", "categoryId", "keyword", "projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived", "customTab", "customArchived", "customRegion", "customOrigin", "customSort", "staffArchived", "staffGroup"]; diff --git a/web/icons.js b/web/icons.js index 6d595c3..1a2f245 100644 --- a/web/icons.js +++ b/web/icons.js @@ -2,7 +2,7 @@ 用法:Icon.svg("wallet") → 裸图标 Icon.badge("wallet") → 图标 + 重点色底板 Icon.status("in_progress") → 执行状态图标 - Icon.stage("signed") → 定开阶段图标 + Icon.stage("signed") → 定制阶段图标 Icon.pipe("entry") → 交付环节图标 静态标记里写 ,加载时自动替换成 SVG。 */ (function () { @@ -17,6 +17,8 @@ users: '', pencil: '', sliders: '', + "panel-collapse": '', + "panel-expand": '', /* 通用动作 */ plus: '', x: '', @@ -133,7 +135,7 @@ ["负责人", "users"], ["人员", "users"], ["人力", "users"], ["花名册", "users"], ["在册", "users"], ["区域", "layers"], ["归属", "layers"], ["分类", "pie"], ["明细", "list-checks"], ["待办", "list-checks"], ["本周新增", "inbox"], ["有变化", "refresh"], - ["在管项目", "layers"], ["定开项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"], + ["在管项目", "layers"], ["定制项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"], ["交付", "package"], ["验收", "shield-check"], ["入库", "inbox"], ["签单", "pencil"], ["阶段流转", "refresh"], ["阶段", "layers"], ["线索", "target"], ["开发", "code"], ["重点项目", "star"], ["重点", "star"], ["周期反馈", "calendar-check"], ["周期管理", "calendar"], diff --git a/web/index.html b/web/index.html index 22157a4..c9aadc3 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ -软件开发部 · 关键任务追踪看板 +DMS管理平台 @@ -12,21 +12,25 @@
- 关键任务追踪看板 + DMS管理平台 软件开发部
+ +
@@ -86,7 +90,7 @@
姓名登录账号角色可用菜单最近登录操作
${esc(u.name)}${self ? ` 我自己` : ""}${u.active ? "" : ` 已停用`}