Compare commits

...

3 Commits

Author SHA1 Message Date
mula.liu 9556a30747 v 0.0.1 2026-09-24 17:05:48 +08:00
mula.liu ac6d641f17 v 0.0.1 2026-09-24 16:49:22 +08:00
mula.liu 871a52ddb0 第一个发布版 2026-09-24 16:17:01 +08:00
36 changed files with 962 additions and 433 deletions

23
.dockerignore 100644
View File

@ -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

View File

@ -13,3 +13,26 @@ 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
# ---- 国内网络加速(只在构建镜像时用到)----
# 基础镜像:优先用本地已有的镜像,脚本不会联网去试各种加速站。
# 先在机器上准备好 python:3.12-slim(docker pull / 内网仓库 / docker load)即可;
# 本地是别的名字或别的 tag,就在下面钉死。别把某台机器上的前缀提交进仓库,换台机器就找不到了。
# BASE_IMAGE=python:3.12-slim
# pip 源:构建时在容器里装依赖,容器不认宿主机的代理,只能换源;
# ./deploy.sh 会在容器里真访问一次挑可用的(默认清华源),想固定就取消注释:
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 关掉自动探测(源已在上面配好,或机器能直连官方源):CN_MIRROR=0 ./deploy.sh up
# 跳过构建、直接用本地已有镜像(离线机器上 docker load 完之后用):NO_BUILD=1 ./deploy.sh up

55
Dockerfile 100644
View File

@ -0,0 +1,55 @@
# 部门关键任务追踪看板 · 运行镜像
#
# 构建: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 打进镜像。
#
# 基础镜像。国内直连 docker.io 会在 load metadata 阶段 TLS 超时,所以支持指成本地已有的镜像:
# docker pull python:3.12-slim && docker build --build-arg BASE_IMAGE=python:3.12-slim .
# ./deploy.sh 会优先用本地已有的同名镜像,不会去联网试各种加速站。
ARG BASE_IMAGE=python:3.12-slim
FROM ${BASE_IMAGE}
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 ./
# pip 源:默认清华源(国内直连 pypi.org 基本超时,而且换基础镜像/代理都救不了它,
# 构建容器里的请求只能靠这里换源)。境外机器或内网自建源就覆盖:
# docker build --build-arg PIP_INDEX_URL=https://pypi.org/simple .
# ./deploy.sh up 会自动探测可用源并写进 .env,正常不用手工传。
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/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}\""]

173
README.md
View File

@ -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
打开 <http://127.0.0.1:8770>
首次运行 `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,100 @@ python -m uvicorn app.main:app --port 8770
> 重新导入:`python scripts/import_excel.py <xlsx> --reset` 之后再跑一次 `python scripts/migrate_custom.py`
> (迁移脚本会删除常规表里的「定制开发」容器,重复执行前请先 reset)
### Docker 部署(换机器 / 上服务器)
不想装 Python,或者要放到服务器上常开,用 Docker:
```bash
cd /path/to/unis_manager
./deploy.sh up # 构建镜像 + 后台启动 + 等健康检查
```
打开 <http://127.0.0.1:8770>。首次启动自动建表、预置分类,并创建内置角色与 `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` / `backup` / `mirror`,顺带检查 docker 是否在跑、`.env` 与 `data/` 是否存在,并在构建前定好基础镜像与 pip 源 |
| `.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 下载超时 | `./deploy.sh mirror` 重探,或手工指定 `PIP_INDEX_URL=... BASE_IMAGE=... ./deploy.sh up`(见下「国内网络加速」) |
| 把现有数据带到新机器 | `./deploy.sh backup` → 拷到目标机 `data/board.db` → `./deploy.sh up` |
#### 国内网络加速(docker.io / pypi.org 连不上时)
国内构建会撞两道**互不相干**的墙,得分开治,只治一道必然还剩一道:
| 卡在哪 | 典型报错 | 治法 |
|---|---|---|
| ① 守护进程拉基础镜像 | `failed to resolve source metadata for docker.io/library/python:3.12-slim: ... TLS handshake timeout` | 用本地已有的镜像(`BASE_IMAGE=` 指定),或先 `docker pull` / 给守护进程配 registry-mirrors |
| ② 构建容器里装依赖 | `[4/8] RUN pip install ... -i https://pypi.org/simple` 非零退出 | 换 pip 源 `PIP_INDEX_URL`(清华 / 阿里 / 腾讯) |
`./deploy.sh up` 构建前把这两道都定下来,原则是**能本地解决就不联网**:
- 基础镜像只看本地有没有:`BASE_IMAGE` 指定的镜像在本地 → 直接用;只有同名 tag(如 `python:3.12-slim`)→ 也用(仓库前缀只是当初从哪儿拉的,镜像是同一份)。本地确实没有才试拉一次(守护进程配的 registry-mirrors 在这一步生效),还拉不到就**直接报错停下**并打印补救命令——不去逐个试加速站,那种循环只会白等几分钟。
- pip 源:在构建用的容器里真 `urlopen` 一次逐个试(容器网络 = 构建网络,比宿主机 curl 准),默认清华源。
- 定下来的值只 export 给本次构建,**不写回 `.env`**(`.env` 在 git 里,把某台机器的地址带到另一台机器正是故障源)。
```bash
./deploy.sh up # 定源(本地有镜像就不联网)→ 构建 → 等健康检查
./deploy.sh mirror # 重探一次 pip 源,并打印可写进 .env 的两行
```
> **别把某台机器上的 `BASE_IMAGE` 前缀提交进仓库。** 换机器后那个前缀对应的镜像本地不存在,构建就会卡在 `load metadata`;这行留空或写裸 `python:3.12-slim`,让每台机器自己的镜像说了算。
不想用脚本,等价的两条 build-arg:
```bash
docker compose build \
--build-arg BASE_IMAGE=python:3.12-slim \
--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
docker compose up -d
```
几个必须知道的点:
- **基础镜像拉成功 ≠ 构建能过**:`docker pull` 只治①,容器里的 `pip install` 照旧撞 pypi.org,必须另外给②换源。
- **shell 里的 `HTTP(S)_PROXY` 对构建没用**:Docker 守护进程和构建容器都不读宿主机的代理变量。要走代理得在 Docker Desktop → Settings → Resources → Proxies 里配 `http://host.docker.internal:7897`(端口换成你本机代理的)。
- **境外机器 / 内网有自建源**:`CN_MIRROR=0 ./deploy.sh up` 跳过 pip 探测,源完全由 `.env` 说了算。
- **目标机完全不能上网**:走离线路。`docker save -o unis-board.tar unis-board:latest` → 拷过去 `docker load -i unis-board.tar` → `NO_BUILD=1 ./deploy.sh up`(跳过构建直接用本地镜像)。
- 构建失败时脚本会自己打印一份排查清单(`deploy.sh` 的 `build_hint`),照抄命令即可。
几个坑:
- **数据只落在 `./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 +197,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 +222,12 @@ python -m uvicorn app.main:app --port 8770
| 操作 | 说明 |
|---|---|
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定开项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定开项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定开项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定开项目按交付负责人命中;团队成员不参与该筛选 |
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定制项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定制项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定制项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定制项目按交付负责人命中;团队成员不参与该筛选 |
| **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 |
| **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 |
> 定开项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
> 定制项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
#### 项目合并
@ -149,7 +244,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
- 合并后按最新记录回算目标项目的进度与状态
- 执行前建议备份:`cp data/board.db data/board.db.bak`
### 3. 定开项目
### 3. 定制项目
| 模块 | 说明 |
|---|---|
@ -157,7 +252,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定开项目并带上项目与周期)留在附表标题下 |
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制项目并带上项目与周期)留在附表标题下 |
### 4. 每周重点工作
@ -169,7 +264,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 |
| 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 |
| 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 |
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定开项目的项目删除上 |
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定制项目的项目删除上 |
| 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 |
| 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
@ -183,12 +278,12 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| 区块 | 说明 |
|---|---|
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定开项目数、定开合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
| 改名 | 走「编辑」改姓名时,**名下项目与定开项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
| 改名 | 走「编辑」改姓名时,**名下项目与定制项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
| 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 |
| 删除 | 名下还挂着项目或定开项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
| 删除 | 名下还挂着项目或定制项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明):
@ -199,14 +294,14 @@ python scripts/import_staff.py --reset # 清空后重新导入
python scripts/import_staff.py <xlsx> # 指定别的文件;首次运行 ./run.sh 会自动导入
```
**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定开项目新建/详情、工作看板等位置统一使用,选项来自花名册:
**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定制项目新建/详情、工作看板等位置统一使用,选项来自花名册:
已归档的人不再作为可选项出现,只有他本来就是这条记录的负责人时才带「(已归档)」标注出现一次;
历史数据里花名册没有的人名同样补一个「(不在花名册)」选项,保证打开老项目不会把负责人改没。
**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定开项目的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。
**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定制项目的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。
### 入库时间
定开项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
定制项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
- 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承)
- 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周
@ -253,18 +348,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 +375,7 @@ python scripts/add_entry_date.py --force # 全部重算
- **可确认收入** = 合同额 × 上表比例
- **待计收** = 可确认收入 − 已计收
- **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新)
- 开票与回款金额在项目详情抽屉、定开项目详情抽屉、工作看板右栏都能直接填
- 开票与回款金额在项目详情抽屉、定制项目详情抽屉、工作看板右栏都能直接填
- 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形)
- 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效
@ -293,10 +388,10 @@ Period(周期: 年/月/周, sort_key)
├── Task(子任务)
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
# 定开项目(独立)
# 定制项目(独立)
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
# 定开项目不做多负责人:other_owners 列保留在库里但不再读写
# 定制项目不做多负责人:other_owners 列保留在库里但不再读写
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
@ -319,7 +414,7 @@ LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面
两套体系各有独立提示词:
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
- **定开项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
- **定制项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
@ -347,14 +442,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 +466,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 +474,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=&region=&keyword=
POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id}

Binary file not shown.

View File

@ -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}

View File

@ -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 上的属性名)

View File

@ -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"])

View File

@ -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):
"""人力资源库成员。
项目管理 / 定开项目的负责人一律从这里选取;归档后不再出现在下拉里,
项目管理 / 定制项目的负责人一律从这里选取;归档后不再出现在下拉里,
历史记录中的人名保持不变。
"""

View File

@ -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

View File

@ -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(

View File

@ -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):

View File

@ -2,7 +2,7 @@
和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。
字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定开项目的周期归属同一个意思;
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定制项目的周期归属同一个意思;
预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。
「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。
录入整理在「每周重点工作」页,汇总在「工作看板」。

View File

@ -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"确需删除请先转交这些项目。",
)

View File

@ -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],

View File

@ -1,6 +1,6 @@
"""老库补列:create_all 只会建新表,不会给已存在的表加字段。
模型新增可空字段后(如定开项目的验收时间 / 开票 / 回款),启动时自动
模型新增可空字段后(如定制项目的验收时间 / 开票 / 回款),启动时自动
`ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。
用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。

Binary file not shown.

257
deploy.sh 100755
View File

@ -0,0 +1,257 @@
#!/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 容器状态 + 健康检查 + 访问地址
# ./deploy.sh mirror 重新探测可用的 pip 源(基础镜像只认本地已有的,不去试加速站)
# 环境变量:CN_MIRROR=0 跳过加速探测;NO_BUILD=1 不构建、直接用本地镜像
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}"
# ---------- 0.5 通用小工具(供后面读写 .env、探测、加超时用)----------
env_get() { { [ -f .env ] && sed -n "s/^$1=//p" .env | tail -1; } || true; }
# with_timeout 秒 命令…(macOS 没有 timeout 命令,自己搭一个,防止探测卡死)
with_timeout() {
local t="$1" rc=0; shift
# 整个动作丢进子 shell 并把它的 stderr 关掉:否则后台任务被 kill 时
# bash 会往终端插一行「已终止」,看着像脚本自己挂了。
( "$@" >/dev/null 2>&1 &
pid=$!
( sleep "$t"; kill "$pid" 2>/dev/null ) >/dev/null 2>&1 &
wpid=$!
wait "$pid" || rc=$?
kill "$wpid" 2>/dev/null || true
wait "$wpid" 2>/dev/null || true
exit "$rc" ) 2>/dev/null
}
# ---------- 1. 国内加速:基础镜像 + pip 源 ----------
# 国内直连 docker.io / pypi.org 会 TLS 超时(报错形如 failed to resolve source
# metadata for docker.io/library/python),所以构建前先把「真能拉到东西的源」探出来,
# 并写回 .env —— 之后裸跑 docker compose up -d --build 用的也是同一套配置。
BASE_IMAGE="${BASE_IMAGE:-$(env_get BASE_IMAGE)}"
BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}" # 本地有哪个 python 镜像就用哪个;内网自建仓库才需要在这里钉死
PIP_URLS="${PIP_URLS:-https://pypi.tuna.tsinghua.edu.cn/simple https://mirrors.aliyun.com/pypi/simple https://mirrors.cloud.tencent.com/pypi/simple https://pypi.org/simple}"
configure_mirror() {
local force="${1:-}" url tag
PIP_INDEX_URL="${PIP_INDEX_URL:-$(env_get PIP_INDEX_URL)}"
if [ "$force" = "--force" ]; then
PIP_INDEX_URL=""
# 手工钉死过的基础镜像不冲掉,只重探 pip 源
# .env 里钉死过的基础镜像不冲掉,--force 只重探 pip 源
if [ -z "$(env_get BASE_IMAGE)" ]; then BASE_IMAGE="python:3.12-slim"; fi
fi
BASE_IMAGE="${BASE_IMAGE:-python:3.12-slim}"
if [ "${CN_MIRROR:-1}" = "0" ] || [ "${NO_BUILD:-0}" = "1" ]; then
export BASE_IMAGE PIP_INDEX_URL
echo "==> 跳过源探测(CN_MIRROR=0 或 NO_BUILD=1):${BASE_IMAGE:-官方 python} / ${PIP_INDEX_URL:-官方 pypi}"
return 0
fi
# 基础镜像只用「本地有没有」做判据,不去逐个试加速站(试一圈要几分钟,而且多半全挂)。
# 判据也不看宿主机 curl:宿主机走 HTTP_PROXY,守护进程和构建容器都不认 shell 的代理变量。
tag="${BASE_IMAGE##*/}" # 纯 tag,例 python:3.12-slim
if docker image inspect "$BASE_IMAGE" >/dev/null 2>&1; then
echo "==> 基础镜像本地已有,不联网:$BASE_IMAGE"
elif docker image inspect "$tag" >/dev/null 2>&1; then
# 本地已有同名 tag 就直接用。镜像内容是同一份,仓库前缀只是「当初从哪儿拉的」,
# 换台机器还抱着旧前缀去联网拉,反而会把一次本来能成的构建搞挂。
echo "==> 本地已有 ${tag},直接用它(忽略 .env 里的仓库前缀)"
BASE_IMAGE="$tag"
elif with_timeout 25 docker pull --quiet "$BASE_IMAGE"; then
echo "==> 基础镜像已从当前配置的源拉取:$BASE_IMAGE"
else
echo "!! 本地没有 ${BASE_IMAGE},25 秒内也没拉到;这一步过不去,构建必然失败,先解决基础镜像:" >&2
echo " 1) 本地有别的 python 镜像 → 指定它:BASE_IMAGE=$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -m1 python || echo python:3.12-slim) ./deploy.sh up" >&2
echo " 2) 手工拉一次(守护进程配的 registry-mirrors 在这一步生效):docker pull $tag" >&2
echo " 3) 机器不能上网 → 从别处拷:docker save -o py.tar $tag && scp py.tar 目标机 && docker load -i py.tar" >&2
echo " 4) 已经构建过本项目镜像 → 跳过构建直接起:NO_BUILD=1 ./deploy.sh up" >&2
return 1
fi
# pip 源:装依赖是在构建容器里发生的,容器不继承宿主机的代理,
# 所以拿刚定下的基础镜像(此时必然已在本地)真跑一次访问探测,比宿主机 curl 靠谱。
if [ -n "${PIP_INDEX_URL:-}" ]; then
echo "==> pip 源(沿用已有配置):$PIP_INDEX_URL"
else
for url in $PIP_URLS; do
if with_timeout 40 docker run --rm --entrypoint python "$BASE_IMAGE" \
-c "import sys,urllib.request;urllib.request.urlopen(sys.argv[1],timeout=8)" "$url/"; then
PIP_INDEX_URL="$url"; break
fi
done
if [ -z "${PIP_INDEX_URL:-}" ]; then
PIP_INDEX_URL="https://pypi.org/simple"
echo "!! 构建容器里访问不到任何国内 pip 源,先用官方源;超时就手工指定:" >&2
echo " PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up" >&2
fi
fi
# 只 export 给本次构建,故意不写回 .env —— .env 在 git 里,把这台机器探到的
# 加速站带到另一台机器上,正是那边拉不到镜像的原因。
# 想固定就在 .env 里手工写 BASE_IMAGE= / PIP_INDEX_URL= 两行(裸 compose 命令也吃这两个值)。
export BASE_IMAGE PIP_INDEX_URL
echo "==> 本次构建用:基础镜像 $BASE_IMAGE | pip 源 $PIP_INDEX_URL"
}
# 构建失败时给一份可操作的排查清单(国内环境九成是源的问题)
build_hint() {
if [ -n "${HTTPS_PROXY:-}${HTTP_PROXY:-}${https_proxy:-}${http_proxy:-}" ]; then
echo "!! 注意:当前 shell 配了 HTTP(S)_PROXY,但 Docker 守护进程不认 shell 环境变量。" >&2
echo " 要用代理出网,得在 Docker Desktop → Settings → Resources → Proxies 里配" >&2
echo " http://host.docker.internal:7897(端口换成你本机代理的),否则构建期出不了网。" >&2
fi
cat >&2 <<'HINT'
!! 构建失败。国内环境最常见的两个原因:
1) 基础镜像拉不到(failed to resolve source metadata / TLS handshake timeout)
docker images | grep python # 看看本地有哪个可用
BASE_IMAGE=python:3.12-slim ./deploy.sh up # 显式指定本地已有的镜像
docker pull python:3.12-slim # 或手工拉一次
也可以给 Docker 守护进程配 registry-mirrors(服务器改 /etc/docker/daemon.json,
桌面端 Settings → Docker Engine),保存并重启 Docker 后再 ./deploy.sh up
2) pip 装依赖超时
PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple ./deploy.sh up
完全不能上网的机器:在能上网的机器上构建后拷镜像过去
docker save -o unis-board.tar unis-board:latest && scp unis-board.tar host:/srv/unis_manager/
ssh host 'docker load -i /srv/unis_manager/unis-board.tar'
然后在目标机 NO_BUILD=1 ./deploy.sh up
HINT
}
# ---------- 2. 准备 .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
}
# ---------- 3. 等服务就绪 ----------
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
}
# ---------- 4. 构建并启动(NO_BUILD=1 时直接用本地镜像)----------
up_or_exit() {
if [ "${NO_BUILD:-0}" = "1" ]; then
echo "==> NO_BUILD=1:跳过构建,直接用本地镜像 unis-board:latest"
"${COMPOSE[@]}" up -d
elif "${COMPOSE[@]}" up -d --build; then
return 0
else
build_hint; return 1
fi
}
# ---------- 5. 分发命令 ----------
case "${1:-up}" in
up|start)
first_run=0
[ -f data/board.db ] || first_run=1
prepare
configure_mirror
up_or_exit || exit 1
wait_ready
echo " API 文档:$URL/api/docs"
if [ "$first_run" = "1" ]; then
echo " 首次启动:管理员账号取自 .env 的 ADMIN_USERNAME/ADMIN_PASSWORD(默认 admin / admin123,请登录后立刻改密码)"
fi
;;
update)
prepare
configure_mirror
if [ "${NO_BUILD:-0}" = "1" ]; then "${COMPOSE[@]}" up -d --force-recreate
else "${COMPOSE[@]}" up -d --build --force-recreate || { build_hint; exit 1; }
fi
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"
;;
mirror)
prepare
configure_mirror --force
echo "==> 想让以后的构建固定用这组源,把下面两行写进 .env 即可(不写也行):"
echo " BASE_IMAGE=${BASE_IMAGE:-python:3.12-slim}"
if [ -n "${PIP_INDEX_URL:-}" ]; then echo " PIP_INDEX_URL=$PIP_INDEX_URL"; fi
;;
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,11p' "$0"
exit 1
;;
esac

42
docker-compose.yml 100644
View File

@ -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:
# 基础镜像仓库前缀 + pip 源,都由 deploy.sh 探测后写进 .env(也可手工在 .env 里钉死)
BASE_IMAGE: "${BASE_IMAGE:-python:3.12-slim}"
PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/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"

4
run.sh
View File

@ -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=(

View File

@ -44,7 +44,7 @@ CATEGORY_RULES = [
(("无人机", "智飞"), "智能装备"),
(("AI", "智能体", "Agent", "大模型"), "AI 能力"),
(("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"),
(("定制", "交付", "项目支撑"), "定开项目"),
(("定制", "交付", "项目支撑"), "定制项目"),
]

View File

@ -18,7 +18,7 @@ DEFAULT_CATEGORIES = [
("平台产品", 0),
("AI 能力", 1),
("智能装备", 2),
("定开项目", 3),
("定制项目", 3),
("内部运营", 4),
]

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
用法:Icon.svg("wallet") → 裸图标
Icon.badge("wallet") → 图标 + 重点色底板
Icon.status("in_progress") → 执行状态图标
Icon.stage("signed") → 定开阶段图标
Icon.stage("signed") → 定制阶段图标
Icon.pipe("entry") → 交付环节图标
静态标记里写 <i data-icon="wallet"></i>,加载时自动替换成 SVG。 */
(function () {
@ -17,6 +17,8 @@
users: '<circle cx="9.4" cy="8" r="3.4"/><path d="M3 20a6.4 6.4 0 0112.8 0"/><path d="M16.4 5.4a3.4 3.4 0 010 5.2"/><path d="M18.4 14.7A6.4 6.4 0 0121 20"/>',
pencil: '<path d="M4 20.2l.9-4L16.2 4.9a2.1 2.1 0 013 3L7.9 19.3l-3.9.9z"/><path d="M14.8 6.3l2.9 2.9"/>',
sliders: '<path d="M5 7h8.8M18.6 7H21M5 17h3.4M12.8 17h5.2"/><circle cx="16" cy="7" r="2.2"/><circle cx="10.6" cy="17" r="2.2"/>',
"panel-collapse": '<rect x="3.4" y="4.2" width="17.2" height="15.6" rx="2.4"/><path d="M9.8 4.2v15.6"/><path d="M16.6 9.4L13.4 12l3.2 2.6"/>',
"panel-expand": '<rect x="3.4" y="4.2" width="17.2" height="15.6" rx="2.4"/><path d="M9.8 4.2v15.6"/><path d="M13.4 9.4l3.2 2.6-3.2 2.6"/>',
/* 通用动作 */
plus: '<path d="M12 5.2v13.6M5.2 12h13.6"/>',
x: '<path d="M6.2 6.2l11.6 11.6M17.8 6.2L6.2 17.8"/>',
@ -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"],

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>软件开发部 · 关键任务追踪看板</title>
<title>DMS管理平台</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
@ -12,21 +12,25 @@
<div class="brand">
<div class="brand-mark"><i data-icon="target"></i></div>
<div class="brand-text">
<strong>关键任务追踪看板</strong>
<strong>DMS管理平台</strong>
<span>软件开发部</span>
</div>
<!-- 收拢后这颗按钮换成展开图标,平时隐藏,hover 到 logo 上出现;按钮自身不挂 tips -->
<button class="nav-fold" id="navFold" aria-label="收起菜单">
<i class="ic-fold" data-icon="panel-collapse"></i><i class="ic-unfold" data-icon="panel-expand"></i>
</button>
</div>
<nav class="nav" id="nav">
<button class="nav-item active" data-view="board"><span class="ni" data-icon="board"></span>工作看板</button>
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span>自研产品</button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span>定制项目</button>
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></span>每周工作</button>
<button class="nav-item active" data-view="board"><span class="ni" data-icon="board"></span><span class="nl">工作看板</span></button>
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span><span class="nl">自研产品</span></button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span><span class="nl">定制项目</span></button>
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></span><span class="nl">每周工作</span></button>
<div class="nav-label">支撑</div>
<button class="nav-item" data-view="staff"><span class="ni" data-icon="users"></span>人力资源</button>
<button class="nav-item" data-view="intake"><span class="ni" data-icon="pencil"></span>更新录入</button>
<button class="nav-item" data-view="settings"><span class="ni" data-icon="sliders"></span>设置</button>
<button class="nav-item" data-view="rbac"><span class="ni" data-icon="shield-check"></span>权限管理</button>
<button class="nav-item" data-view="staff"><span class="ni" data-icon="users"></span><span class="nl">人力资源</span></button>
<button class="nav-item" data-view="intake"><span class="ni" data-icon="pencil"></span><span class="nl">更新录入</span></button>
<button class="nav-item" data-view="settings"><span class="ni" data-icon="sliders"></span><span class="nl">设置</span></button>
<button class="nav-item" data-view="rbac"><span class="ni" data-icon="shield-check"></span><span class="nl">权限管理</span></button>
</nav>
<div class="side-user" id="sideUser"></div>
@ -86,7 +90,7 @@
<aside class="auth-brand">
<div class="ab-top">
<div class="brand-mark"><i data-icon="target"></i></div>
<div class="brand-text"><strong>关键任务追踪看板</strong><span>软件开发部</span></div>
<div class="brand-text"><strong>DMS管理平台</strong><span>软件开发部</span></div>
</div>
<div class="ab-mid">
<h2>进展、风险、计收<br>一张看板看完</h2>
@ -103,7 +107,7 @@
<h3>登录</h3>
<p class="af-sub">使用部门分配的账号</p>
<label class="af-field"><span>账号</span>
<input class="inp" id="authUser" autocomplete="username" placeholder="如 admin" /></label>
<input class="inp" id="authUser" autocomplete="username" placeholder="请输入账户" /></label>
<label class="af-field"><span>密码</span>
<input class="inp" id="authPass" type="password" autocomplete="current-password" placeholder="请输入密码" /></label>
<div class="notice err" id="authErr" style="display:none"></div>

View File

@ -76,8 +76,9 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh;
padding: 18px 14px;
transition: width .16s ease, flex-basis .16s ease;
}
.brand { display: flex; gap: 10px; align-items: center; margin-bottom: 22px; padding: 0 4px; }
.brand { position: relative; display: flex; gap: 10px; align-items: center; margin-bottom: 22px; padding: 0 4px; }
.brand-mark {
width: 34px; height: 34px; border-radius: 10px; flex: none;
background: var(--primary); color: #fff;
@ -148,6 +149,47 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.user-menu button.danger:hover { background: #fdf0f0; }
.user-menu .ic { width: 15px; height: 15px; }
/* ---------------- 侧栏收拢 ----------------
展开态:收拢按钮贴在 logo 右侧;
收拢态:菜单只留图标(hover 出气泡提示),收拢按钮藏进 logo,hover logo 才变成展开按钮。 */
.nav-fold {
margin-left: auto; width: 26px; height: 26px; flex: none; padding: 0;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--panel-2); color: var(--muted);
display: grid; place-items: center; cursor: pointer; transition: .15s;
}
.nav-fold:hover { color: var(--primary); border-color: var(--border-strong); background: #fff; }
.nav-fold .ic { width: 15px; height: 15px; }
.nav-fold .ic-unfold { display: none; }
/* 气泡提示:只给收拢后的菜单图标(收拢/展开按钮上不出提示) */
.app.nav-folded .nav-item[data-tip]:hover::after {
content: attr(data-tip); position: absolute; left: calc(100% + 8px); top: 50%;
transform: translateY(-50%); white-space: nowrap; z-index: 30; pointer-events: none;
background: #1f2430; color: #fff; font-size: 12px; font-weight: 400;
padding: 5px 9px; border-radius: 7px; box-shadow: 0 4px 14px rgba(16,24,40,.18);
}
/* 侧栏是 sticky,自成层叠上下文:不提层级的话气泡会被内容区里靠后定位的元素(如每周工作的吸顶卡片)盖住 */
.app.nav-folded .sidebar { z-index: 25; }
.app.nav-folded { --sidebar-w: 64px; }
.app.nav-folded .brand { justify-content: center; }
.app.nav-folded .brand-text, .app.nav-folded .nav-item .nl,
.app.nav-folded .user-chip .u-text, .app.nav-folded .user-chip .ic { display: none; }
.app.nav-folded .nav-item { justify-content: center; gap: 0; padding: 9px 0; position: relative; }
.app.nav-folded .nav-label { font-size: 0; height: 1px; background: var(--border); margin: 10px 6px 4px; padding: 0; }
.app.nav-folded .user-chip { justify-content: center; padding: 3px; }
.app.nav-folded .user-menu { left: 0; right: auto; min-width: 168px; }
.app.nav-folded .nav-fold {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 34px; height: 34px; border-radius: 10px; border-color: transparent;
background: var(--primary-soft); color: var(--primary); opacity: 0; pointer-events: none;
}
.app.nav-folded .brand:hover .brand-mark { opacity: 0; }
.app.nav-folded .brand:hover .nav-fold { opacity: 1; pointer-events: auto; }
.app.nav-folded .nav-fold .ic-fold { display: none; }
.app.nav-folded .nav-fold .ic-unfold { display: inline-flex; }
/* ---------------- 登录(左右分栏) ---------------- */
.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%); }
@ -222,6 +264,17 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
cursor: pointer; color: var(--text-2); font-size: 13px;
}
.seg-btn.active { background: #fff; color: var(--primary); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
/* 单选卡片:二选一、选中后下方直接出对应按钮(更新录入的写入方式) */
.radio-row { display: flex; gap: 10px; flex-wrap: wrap; }
.radio {
flex: 1 1 300px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
background: var(--panel); font-size: 13px; cursor: pointer;
}
.radio input { accent-color: var(--primary); margin: 0; flex: none; }
.radio b { font-weight: 600; }
.radio .radio-tip { font-size: 12px; color: var(--muted); }
.radio:has(input:checked) { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, #fff); }
.nav-arrow {
display: inline-grid; place-items: center;
width: 30px; height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border);
@ -290,7 +343,7 @@ tr:hover .row-ops, .row-ops:focus-within { opacity: 1; }
.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; }
/* 统一计数 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-head h4 .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; }
@ -319,25 +372,6 @@ tr:hover .row-ops, .row-ops:focus-within { opacity: 1; }
.kpi.accent-amber { --c: var(--amber); }
.kpi.accent-red { --c: var(--red); }
/* 定开项目明细表上方的统计条(左侧色条 + 金额重点色) */
.stat-row {
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px; padding: 2px 16px 12px;
}
.stat {
position: relative; overflow: hidden;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 16px 12px 18px; box-shadow: var(--shadow);
}
.stat::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--c, var(--primary));
}
.stat .stat-num { font-size: 20px; font-weight: 700; letter-spacing: -.4px; color: var(--text); line-height: 1.25; }
.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; }
/* ---------------- board ---------------- */
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.chip {
@ -349,11 +383,18 @@ tr:hover .row-ops, .row-ops:focus-within { opacity: 1; }
.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; }
/* 定制项目页签:卡片外的文件夹式页签,选中项压住卡片顶边,和下方内容区连成一块 */
.ctabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 14px; position: relative; z-index: 2; }
.ctab {
border: 1px solid transparent; border-bottom: 0; background: transparent;
color: var(--muted); font-size: 13px; padding: 8px 18px; cursor: pointer;
border-radius: 10px 10px 0 0;
}
.ctab:hover { color: var(--primary); background: rgba(37,99,235,.06); }
.ctab.active {
background: var(--panel); border-color: var(--border);
color: var(--primary); font-weight: 600; margin-bottom: -1px;
}
.board-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.pcard {
@ -392,7 +433,7 @@ tr:hover .row-ops, .row-ops:focus-within { opacity: 1; }
.progress > i { display: block; height: 100%; background: var(--primary); border-radius: 99px; transition: width .4s; }
.progress-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 6px; }
/* ---------------- kanban (定开项目) ---------------- */
/* ---------------- kanban (定制项目) ---------------- */
.kanban { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; align-items: flex-start; }
.kcol {
flex: 0 0 236px; background: var(--panel); border: 1px solid var(--border);
@ -425,6 +466,23 @@ table.tbl tbody tr:hover { background: #fafbfd; }
table.tbl td.num, table.tbl th.num { text-align: right; font-variant-numeric: tabular-nums; }
.tbl-wrap { max-height: 620px; overflow: auto; }
.nowrap { white-space: nowrap; }
/* ---------------- 列表分页(全站统一,默认 10 条/页) ---------------- */
.pager {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end;
padding: 10px 14px; border-top: 1px solid var(--border);
}
.pager .pg-info { font-size: 12px; color: var(--muted); }
.pager .pg-btns { display: flex; align-items: center; gap: 4px; }
.pg-btn {
min-width: 28px; height: 28px; padding: 0 7px;
border: 1px solid var(--border); border-radius: 8px; background: #fff;
color: var(--text-2); font-size: 12px; cursor: pointer;
}
.pg-btn:hover:not(:disabled):not(.active) { border-color: var(--border-strong); color: var(--text); }
.pg-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
.pg-btn:disabled { opacity: .45; cursor: not-allowed; }
.pg-gap { font-size: 12px; color: var(--muted); padding: 0 2px; }
.pager .pg-size { height: 28px; }
.inp, .sel {
height: 32px; border: 1px solid var(--border); border-radius: 8px; padding: 0 9px; background: #fff; max-width: 100%;
}
@ -588,7 +646,7 @@ textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.btn.danger.solid { background: var(--red); border-color: var(--red); color: #fff; font-weight: 600; }
.btn.danger.solid:hover { background: #b42318; color: #fff; }
.arch-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #f1f3f7; color: var(--muted); white-space: nowrap; flex: none; }
/* 操作列固定在表格右侧:横向滚动时改名/归档/删除始终可见(项目管理、定开项目共用) */
/* 操作列固定在表格右侧:横向滚动时改名/归档/删除始终可见(项目管理、定制项目共用) */
table.tbl th.col-ops, table.tbl td.col-ops {
position: sticky; right: 0; background: var(--panel); box-shadow: inset 1px 0 0 var(--border);
}
@ -676,15 +734,3 @@ table.tbl td.col-ops .btn.sm { padding: 0 7px; } /* 操作列按钮收紧,避
/* 主导航分组标题(业务模块 / 支撑功能) */
.nav-label { font-size: 11px; color: var(--muted); letter-spacing: .6px; padding: 14px 10px 4px; }
/* 工作看板底部的统计概览折叠区 */
details.card.ov { padding: 12px 16px; }
details.card.ov > summary {
cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text-2); list-style: none;
display: flex; align-items: center; gap: 7px;
}
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 > .ov-body { padding-top: 16px; }